recharts を入れずに、React + 自前 SVG でダッシュボードを作る ― ヒートマップ・トレンド・散布図・継続カレンダー
⚠ NOTICE — この記事の文体について
本記事の文章は 生成 AI(Claude)の協力のもとで執筆しています。実装内容・コード・設計判断はすべて筆者本人のものですが、説明の構成や言い回しに AI 特有のクセが残っている可能性があります。バイブコーディング(AI ペアプロ駆動開発)の実践レポートとして読んでもらえると幸いです。
はじめに
このサイトには図がそこそこ多い。/insights の継続ヒートマップ、心拍効率トレンド、走行距離と翌朝コンディションの散布図、週次のトレーニング負荷。/condition や /stats にもトレンドや分布の図がある。
これを全部、チャートライブラリを使わずに React と自前 SVG で描いた。recharts も visx も Chart.js も入れていない。この記事では、なぜそうしたのかと、集計と描画をどう分けたか、データが無いときに壊さない作りまでを書く。
なぜチャートライブラリを使わないのか
正直に言うと、最初から「ライブラリは入れない」と固く決めていたわけではない。recharts や visx の名前は知っていたし、選択肢として頭にはあった。ただ、自分が描きたい図を1つずつ並べてみたら、どれも数十行の SVG で足りそうだった。ヒートマップにしても散布図にしても、やりたいことは「座標を計算して <rect> や <circle> を置く」だけで、汎用ライブラリが抱えるインタラクションや多軸の機能は要らない。
それなら、多機能なライブラリを1つ抱えるより、必要な図を必要なぶんだけ手で描いたほうが身軽だと思った。決め手になったのは、次の3つだ。
- バンドルが軽くなる。汎用チャートライブラリは多機能なぶん重い。自分が描きたい図は数種類で、それぞれは数十行の SVG で済む。全機能を抱えるライブラリは要らなかった。
- デザインを完全に制御できる。サイト全体が純黒とネオン1色という強いトーンを持っている。汎用ライブラリのデフォルトに寄せると、そこだけ「グラフライブラリの画面」になる。配色も余白も軸の出し方も全部こちらで決めたかった。
- 依存を増やさない。チャートライブラリはメジャーアップデートで API が変わることがある。図の描画くらいは外部の都合に振り回されたくなかった。
集計は純粋関数に切り出す
自前で描くと決めたとき、最初にやったのは集計と描画を分けることだった。
集計ロジックは lib/insights.ts に、副作用のない純粋関数として置いた。走行ログと Apple Health の配列を受け取り、図に必要なデータ構造を返すだけ。fetch も DOM も触らない。表示は components/insights/ 以下の SVG コンポーネントに任せて、集計の結果を受け取って描くだけにする。
この分け方の良いところは、集計を単体テストできることだ。lib/insights.test.ts で、入力の配列に対して期待する集計結果が出るかを直接検証できる。SVG を描かずに数字の正しさだけを確かめられる。
// lib/insights.ts のヘッダコメントより
// /insights ダッシュボードの集計純粋関数群。
// 既存 run-stats.ts と同様、副作用なし・テスト可能。表示は components/insights に委ねる。
//
// 曜日計算は必ず weekdayJst を使う
// (Date.getDay() は host TZ 依存で UTC 実行だと週が 1 週ずれる)。
曜日計算に Date.getDay() を直接使わないのは、サーバーが UTC で動くと週が1週ずれるからだ。JST 固定の weekdayJst に集約して、host のタイムゾーンに依存しないようにしている。
継続ヒートマップを SVG で描く
GitHub の草に近い、日別の継続ヒートマップ。computeStreak が、今日を基準に過去53週ぶんのセルを古い順に組む。同じ日に複数回走ったら距離を合算し、距離に応じて濃淡レベル(0〜4)を振る。
// lib/insights.ts
// 距離→濃淡。0=空 / ~5 / ~10 / ~15 / 15km+。
function distanceLevel(km: number): 0 | 1 | 2 | 3 | 4 {
if (km <= 0) return 0;
if (km < 5) return 1;
if (km < 10) return 2;
if (km < 15) return 3;
return 4;
}
セルは今週の土曜を末尾にそろえて、371日(53週)ぶんを並べる。描画側はこの配列を受け取って、<rect> をグリッド状に並べるだけだ。濃淡は5段階のデザイントークンに対応させていて、生の hex をコンポーネントに散らさない。
連続日数も同じ関数の中で出している。今日から過去へさかのぼって、走った日が途切れない最長をカウントする。
トレンドグラフと期間切替
/condition のトレンドグラフは、90日 / 1年 / 全期間で切り替えられる。長期を日次のまま描くと点が潰れて読めないので、期間に応じて集約の粒度を変えている。90日は日次、1年は週次平均、全期間は月次平均。サーバーで3期間ぶんを用意しておき、クライアントは再取得せずトグルだけで即時に切り替える。
軸も目盛りもライブラリ任せにせず、自分でレンジを取って <path> と <line> を引いている。点が0件のときはコンポーネントが null を返すので、データが来ていない指標は自動で消える(後述)。
散布図 ― 走行距離 × 翌朝コンディション
走った距離と、その翌朝の安静時心拍 / 心拍変動を散布図にしている。横軸が走行距離、縦軸が翌朝の指標。1点が1日だ。
これも <circle> を座標に置いていくだけの素朴な SVG だが、面白いのはデータの結合のほうだ。「ある日の走り」と「その翌日の朝のコンディション」を突き合わせる必要があるので、集計層で日付をキーに翌日の summary を引いて点にする。図そのものより、走りと翌朝を対応づける前処理が本体になっている。
TRIMP / ACWR でトレーニング負荷を出す
週次のトレーニング負荷チャートでは、TRIMP と ACWR を計算している。
TRIMP は、ざっくり言えば「運動時間 × 強度」でその走りの負荷を1つの数字にしたもの。強度は心拍を安静時〜最大の間で正規化して出す。心拍が欠けているランは、距離ベースで所要時間を見積もって中央強度で補完する。
// lib/insights.ts
/** ラン 1 本の TRIMP 近似。durationMin × HR 強度係数。HR 欠落は距離ベース補完。 */
function runTrimp(a: RunActivity): number {
const hr = a.averageHeartrate;
if (hr != null && Number.isFinite(hr)) {
const intensity = Math.max(0, Math.min(1, (hr - HR_REST) / (HR_MAX - HR_REST)));
return a.durationMin * intensity;
}
// HR 欠落: 距離 × 5 (=おおよその所要分) × 中央強度 で近似。
const minutes =
Number.isFinite(a.durationMin) && a.durationMin > 0 ? a.durationMin : a.distanceKm * 5;
return minutes * FALLBACK_INTENSITY;
}
ACWR(Acute:Chronic Workload Ratio)は、当週の TRIMP を直近4週の平均 TRIMP で割った比だ。急に負荷を上げすぎていないかの目安になる。慢性側の平均が0なら比が出せないので null にして、図では描かない。
// 慢性負荷: 直近 4 週 (当週含まず) の平均
const chronicSlice = weeks.slice(Math.max(0, i - CHRONIC_WEEKS), i);
const chronicAvg =
chronicSlice.length > 0
? chronicSlice.reduce((s, w) => s + (trimpByWeek.get(w) ?? 0), 0) / chronicSlice.length
: 0;
const acwr = chronicAvg > 0 ? round1(trimp / chronicAvg) : null;
TRIMP も ACWR も、走り込みと故障のバランスを調べているうちに行き当たった指標だ。実装してみて思うのは、この手の数字は見えているだけで効く、ということ。週ごとの負荷が棒の高さで並んで、急に上げすぎた週がひと目で分かるようになると、それだけで次の週の入り方が少し慎重になる。
劇的に行動が変わった、とまでは言わないけれど、可視化されているとモチベーションになるし、自分の積み上げを目で確認できると走るのが楽しくなる。頭の中で「今週ちょっと踏みすぎたかな」と曖昧に思うのと、グラフで比が跳ねているのを見るのとでは、効き方が違う。
データが無い時に壊さない
自前 SVG で一番気をつけたのが、データ欠損のときに崩れないことだった。
各チャートは、点が0件なら null を返す。Apple Health を連携していない人には回復系の指標が無いので、その図は黙って消える。逆に走行データだけは必ずあるので、継続ヒートマップは走行ログだけから全セルを組み立てる。computeStreak は today の日付だけを起点にセルを作るので、Health が無くても壊れない。
// lib/insights.ts のヘッダコメントより
// 各チャートは points.length === 0 で null を返し、computeStreak は today だけから
// 全セルを構築するため Health 未連携でも走行データだけで壊れず描画
// (回復側のみ非表示)。
ライブラリを使っていると「データが空のときの表示」はライブラリの作法に従うことになるが、自前なら「無いものは描かない」を関数の戻り値1つで決められる。この単純さは自前の利点だった。
手で書いていて一番てこずったのは、意外にも散布図だった。<circle> を置くだけなら一番簡単なはずなのに、苦労したのは描画ではなく前処理のほうだ。「ある日の走り」と「その翌朝のコンディション」を突き合わせるところで、日付のキーが1日ずれると点が全部おかしな位置に飛ぶ。しかもタイムゾーンが絡むと、サーバー実行と手元で結果が変わる。グラフの形がおかしいときに、SVG を疑うのか、結合を疑うのか、切り分けに一番時間を使った図だった。
軸ラベルも地味に手間だった。ライブラリなら勝手に「いい感じの目盛り」を振ってくれるところを、自前だと刻みの数値を自分で決めることになる。きりの良い値で目盛りが並ぶように調整する、という当たり前の体裁を、自分のコードで用意しないといけない。描く前の準備に手数がかかる、というのが自前 SVG の正直なコストだった。
まとめ
チャートライブラリを入れない、という選択は、最初は遠回りに見えた。でも実際にやると、効いていたのは描画より前の設計だった。
- 集計は純粋関数に切り出して単体テストする(描画と切り離す)
- 表示は SVG コンポーネントが集計結果を受け取って描くだけ
- 継続ヒートマップ・トレンド・散布図・トレーニング負荷を自前 SVG で描画
- TRIMP / ACWR で週次の負荷を数値化
points.length === 0で null を返し、データ欠損時は黙って非表示
バンドルは軽いままで、配色も軸も全部こちらの手の中にある。図がサイトのトーンから浮かない。自分の用途では、これがちょうど良かった。
シリーズナビ
この記事を書いた人

気に入ったら、次の記事もメールで。月1〜2通だけ。
[ Next Action / 次の動き ]
走行データは、こちらから。
読了ありがとうございます。気が向いたら、生データもどうぞ。