家計簿アプリを作る #98:カテゴリ別の月次推移グラフ

家計簿アプリ作成シリーズの第98回です。年間サマリーページには月ごとの収入・支出を並べた棒グラフがありますが、「支出が増えた月に何の支出が増えたのか」までは分かりません。今回はカテゴリ別の内訳が月ごとに分かる積み上げ棒グラフを追加します。シリーズ最後の機能追加です。

集計の向きを決める

Chart.jsのdatasetsは「系列ごとに、全ラベル分の値が並んだ配列」を要求します。今回はカテゴリが系列、12ヶ月がラベルです。

{
  labels: ['1月', '2月', ...],
  datasets: [
    { label: '食費',   data: [12000, 15000, ...] },
    { label: '交通費', data: [3000,  2800,  ...] },
  ]
}

既存の集計は「月ごとの収入・支出」という形で、月が主・カテゴリが従の向きでした。グラフに必要なのは逆向きなので、「カテゴリごとに、12ヶ月の配列を作る」という順で組み立てます。

const yearExpenses = yearTransactions.filter(t => t.type === 'expense');
const categoryTransactions = Array.from(
  new Set(yearExpenses.map((t) => t.category))
).map(category => {
  const expensesInCategory = yearExpenses.filter(t => t.category === category);
  const monthlyExpenses = Array.from({ length: 12 }, (_, i) => {
    const month = `${selectedYear}-${String(i + 1).padStart(2, '0')}`;
    return expensesInCategory
      .filter(t => t.date.startsWith(month))
      .reduce((sum, t) => sum + t.amount, 0);
  });
  return { category, monthlyExpenses };
});

ポイントは、12ヶ月分の配列を先に確定させてから値を引いていることです。「データがある月だけを集める」作り方にすると、食費が1月と3月にしか無い場合に[12000, 8000]という2要素の配列になり、2月の位置がずれます。Array.from({ length: 12 })で枠を先に作れば、データが無い月は自動的に0になります。

既存のグラフと月の位置を揃える

書いてみて気付いたのですが、既存の月次推移グラフは「データがある月だけ」を並べていました。そのままだと、上のグラフは5本、下のグラフは12本、というように月の位置が対応しません。並べて比較するグラフとしては不便なので、既存の方も12ヶ月固定に変更しました。

結果として両方の集計が「12ヶ月の配列を作り、各月の値を引く」という同じ形になり、コードの見通しもよくなりました。

カテゴリの色をどう決めるか

カテゴリは利用者が自由に追加できるので、いくつになるか分かりません。固定の色配列では足りなくなる可能性があります。

最初はランダムで色を振ってみたのですが、これには3つ問題がありました。

backgroundColor: `hsl(${Math.random() * 360}, 70%, 50%)`,
borderColor: `hsl(${Math.random() * 360}, 70%, 50%)`,
  • $derivedの中なので、依存する値が変わると再計算され、ダークモードを切り替えるたびに色が変わります
  • Math.random()を2回呼んでいるので、塗りと枠線が別の色になります
  • ランダムなので、隣り合うカテゴリがほぼ同じ色相になることがあります

インデックスから色相を決める形にすると、3つとも解決します。

datasets: data.categoryTransactions.map((category, i) => {
  const hue = (i * 360) / data.categoryTransactions.length;
  return {
    label: category.category,
    data: category.monthlyExpenses,
    backgroundColor: `hsl(${hue}, 70%, 50%)`,
    borderColor: `hsl(${hue}, 70%, 50%)`,
    borderWidth: 1,
  };
}),

カテゴリ数で360度を割っているので、何個あっても色相が均等に散ります。同じ入力なら同じ色になるので、再計算しても色が動きません。

hsl(色相・彩度・明度)で指定しているのも理由があります。色相だけを動かせば、彩度と明度は揃ったままにできます。RGBで同じことをやろうとすると、色ごとに明るさがばらついて、読みにくいグラフになります。

アロー関数:=> ({...})と=> { return {...} }

色相の計算を変数に出そうとして、構文エラーを出しました。

.map((category, i) => ({
  const hue = ...;      // ❌ Unexpected keyword 'const'
  label: category.category,
}))

=> ({ ... })は「このオブジェクトを返す」という書き方で、括弧の中はオブジェクトの定義(キーと値の並び)しか書けません。変数宣言のような文を置く場所ではないので、constが現れた時点で構文エラーになります。

文を書きたい場合は、関数の本体を{ }にしてreturnで返します。

.map((category, i) => {
  const hue = ...;
  return { label: category.category, ... };
})

=> ({...})の括弧は、{がブロックの開始と解釈されないようにするためのものです。普段は意識せず使い分けていますが、こうして引っかかると意味がはっきりします。

積み上げの指定とダークモード

Chart.jsでは、軸にstacked: trueを指定すると積み上げになります。既存のbuildChartOptionsはダークモード対応の色を組み立てる関数だったので、引数を1つ足して両方のグラフで使い回せるようにしました。

function buildChartOptions(titleText: string, stacked: boolean = false) {
  const color = darkMode.isDark ? "#e5e7eb" : "#374151";
  const gridColor = darkMode.isDark ? "#374151" : "#e5e7eb";
  return {
    plugins: { legend: { labels: { color } }, title: { display: true, text: titleText, color } },
    scales: {
      x: { ticks: { color }, grid: { color: gridColor }, stacked },
      y: { ticks: { color }, grid: { color: gridColor }, stacked },
    },
  };
}

既定値をfalseにしたので、既存の呼び出しは変更不要です。お題57でダークモード対応を入れておいたおかげで、新しいグラフも追加するだけで対応済みになりました。

まとめ

ポイント 内容
集計の向き グラフが要求する形(系列×ラベル)に合わせて集計の向きを決める
枠を先に作る 12ヶ月の配列を先に確定させ、値を引く。データが無い月は0で埋まる
既存との整合 並べて見るグラフは、ラベルの粒度と範囲を揃える
可変の色 個数が決まらないものは、インデックスから色相を等間隔に振る
hsl 色相だけを動かせば、明るさの揃った色が並ぶ
アロー関数 => ({...})の中には文を書けない。文が要るなら=> { return {...} }
既存の作りが効く ダークモード対応を関数にまとめてあったので、追加のコストがほぼ無かった
← トップページに戻る