記録の推移を見せる画面を追加して、手元では問題なく動いていました。ところが公開してすぐ、「グラフのところが真っ白です」という連絡が届きます。
自分の端末では再現しません。私のデータは何週間分も溜まっていたからです。相手はインストールしたその日の方でした。個人開発では、いちばん検証に使う端末がいちばん実態から遠い、ということがよく起こります。
データが1件しかない状態を作って開いた瞬間、線が消えました。原因はグラフライブラリではなく、その手前で私が書いていた目盛りの計算にありました。
値が動かないとき、目盛りの幅は 0 になります
折れ線グラフを描くとき、たいていは最小値と最大値から縦軸の幅を決めます。私も素直にそう書いていました。
const H = 200; // 描画領域の高さ(px)
const naive = (values) => {
const min = Math.min(...values);
const max = Math.max(...values);
return { min, max, span: max - min, pxPerUnit: H / (max - min) };
};この関数に、実際に起こりうる入力を通してみます。手元の Node で実行した結果がこちらです。
1点 span= 0 pxPerUnit= Infinity isFinite= false
同値3日 span= 0 pxPerUnit= Infinity isFinite= false
全0 span= 0 pxPerUnit= Infinity isFinite= false
通常7日分 span= 6 pxPerUnit= 33.333333333333336
値が7日分ばらついている通常のケースでは、1単位あたり 33.3px という妥当な倍率が出ます。
一方で、データが1点しかない場合、同じ値が続く場合、そして全部が 0 の場合は、いずれも幅が 0 になります。JavaScript では 0 での除算は例外を投げず、Infinity を返します。
つまりエラーは出ません。計算は静かに続きます。
0 で割った結果が、そのまま座標に流れます
Infinity を含んだ倍率は、次の掛け算でさらに厄介な値に変わります。同じく実行した結果です。
4 * Infinity = Infinity
0 * Infinity = NaN
値が 0 のとき、y = (value - min) * pxPerUnit は NaN になります。
NaN が入った座標は、SVG でも Canvas でも「描かない」という扱いになります。線は引かれず、警告も出ません。コンポーネントは正常に返り、画面には枠だけが残ります。
私が最初に疑ったのはライブラリのバージョンでした。実際には自分で書いた4行の関数が原因で、切り分けに半日ほど使っています。数値計算が黙って通ってしまう箇所は、ログを仕込まないと見つかりません。
| 入力の状態 | span | 倍率 | 画面に起きること |
|---|---|---|---|
| 7日分の記録あり | 6 | 33.3px/単位 | 期待どおりに描画される |
| 記録が1件だけ | 0 | Infinity | 点も線も出ない |
| 同じ値が3日続く | 0 | Infinity | 横一直線すら出ない |
| すべて 0 で補完 | 0 | Infinity | 座標が NaN になり空白 |
欠測を 0 で埋めると、グラフが別のことを語り始めます
幅が 0 になる原因のひとつは、私自身が作っていました。記録のない日を 0 で埋めていたのです。
配列の長さを7日に揃えたいという実装上の都合でしたが、これは表示の意味を変えてしまいます。「その日は記録しなかった」と「その日は 0 だった」は、読む人にとって別の事実です。
体重や睡眠時間のように 0 があり得ない指標では、谷が突き刺さったグラフになります。回数を数える指標なら 0 は正しいこともあります。
私はこの判断を、指標ごとに1行のメモとして残すようにしました。あとから自分が迷わないためです。
| 指標の種類 | 記録のない日の扱い | 理由 |
|---|---|---|
| 体重・体温・睡眠時間 | 線を途切れさせる | 0 は実在しない値のため |
| 歩数・回数・保存件数 | 0 として扱う | 0 回という事実が成立するため |
| 気分・評価などの主観値 | 線を途切れさせる | 未入力を中間値と誤読させないため |
描く前に整える小さな層をひとつ置きます
対処はグラフライブラリの設定ではなく、その手前に置いた関数で行っています。ライブラリを乗り換えても残る層にしておきたかったからです。
やっていることは2つだけです。幅に下限を与えることと、描くに足りない入力を弾くことです。
type Point = { x: number; y: number | null };
type Axis = { lo: number; hi: number; span: number; pxPerUnit: number };
export const buildAxis = (
values: number[],
height = 200,
minSpan = 1,
): Axis | null => {
const finite = values.filter((v) => Number.isFinite(v));
if (finite.length === 0) return null;
let lo = Math.min(...finite);
let hi = Math.max(...finite);
if (hi - lo < minSpan) {
const center = (hi + lo) / 2;
lo = center - minSpan / 2;
hi = center + minSpan / 2;
}
return { lo, hi, span: hi - lo, pxPerUnit: height / (hi - lo) };
};minSpan を通したあとの実測値です。幅 0 だった3ケースが、すべて有限の倍率に変わりました。
padded 1点 {"lo":3.5,"hi":4.5,"span":1,"pxPerUnit":200}
padded 同値3日 {"lo":4.5,"hi":5.5,"span":1,"pxPerUnit":200}
padded 全0 {"lo":-0.5,"hi":0.5,"span":1,"pxPerUnit":200}
padded 通常 {"lo":3,"hi":9,"span":6,"pxPerUnit":33.333333333333336}
値が1つでも、中心に置かれた線が描かれます。全部が 0 でも、下に少し余白を取った状態で 0 の線が引かれます。
minSpan の値は指標の単位に合わせます。歩数なら 100、睡眠時間なら 1 といった具合です。私は手元の複数のアプリでこの関数を使い回していますが、単位だけはどうしても固定できませんでした。ここを固定値にすると、単位の違う画面で目盛りが不自然になります。
null を返しているのは、有限の値が1つもないときです。この場合はグラフを描かず、呼び出し側で別の表示に切り替えます。
この関数は描画を一切 import しない素の module に置いています。コンポーネントを組み立てなくても検証できるからです。1点・同値・空配列の3つを確かめる assertion なら1分で書けますし、あとから下限の規則を触ったときに気づけます。
もうひとつ効いたのは、リリースビルドで buildAxis が null を返したときに警告を残すことでした。静かに壊れる数値計算はクラッシュレポートに出てきません。自分で信号を出しておかないと、空白のグラフはどなたかがわざわざ連絡をくださるまで気づけないままになります。
数が足りないときは、グラフを出さない判断もします
技術的に描けることと、見せる価値があることは別でした。
記録が1件だけの折れ線は、線がなく点が1つあるだけです。これを見せられた方は、自分の記録がどうなっているのか何も分かりません。むしろ不具合に見えます。
私は下限を3件に置いています。それ未満のときは、グラフの代わりに「あと2日分でグラフが出ます」という案内を出しています。次に開く理由になるからです。
const MIN_POINTS = 3;
export const TrendSection = ({ points }: { points: Point[] }) => {
const values = points.map((p) => p.y).filter((v): v is number => v !== null);
const axis = buildAxis(values, 200, 1);
if (values.length < MIN_POINTS || axis === null) {
return <TrendPlaceholder remaining={MIN_POINTS - values.length} />;
}
return <TrendChart points={points} axis={axis} />;
};この分岐は、空の状態をどう見せるかという設計と地続きです。読み込み中・記録なし・件数不足は見た目が似ていて、まとめて扱うと読者が混乱します。それぞれの出し分けはRork アプリの空状態を設計しきるで扱っています。棒グラフ側の実装で複数系列を並べたい場合は、react-native-chart-kit の BarChart で複数データセットを横並び表示する実装パターンが参考になります。
次に手を動かすなら、テストデータを1件・同値3件・全0の3パターンだけ用意して、自分のグラフ画面を開いてみてください。半日を溶かす前に、その3回で分かります。
記録そのものをどう型で守るかまで踏み込みたい場合は、複数アプリで共有するイベント層の設計をRork製6アプリで分析イベントを型で守るにまとめてあります。
最後までお読みいただきありがとうございました。同じ空白のグラフで立ち止まっている方の手がかりになれば嬉しいです。