1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

オプチャグラフ開発記⑧ Chart.jsで「使いやすい統計グラフ」を作るための設計と工夫【React・Chart.js】

Last updated at Posted at 2025-12-29

はじめに

オプチャグラフは、LINE OpenChatの統計情報を収集・分析・可視化するWebサービスです。

統計グラフ機能

オプチャグラフのオープンチャット詳細ページでは、メンバー数推移とランキング順位を可視化する統計グラフを提供しています。

主な機能:

  • 4つの表示期間(最新24時間・1週間・1ヶ月・全期間)
  • メンバー数(折れ線)+ ランキング順位(棒)の複合グラフ
  • ピンチ・ホイールによるズーム/パン操作
  • 曜日に応じたラベル色分け
  • 多言語対応(日本語・繁体中文・タイ語)

スクリーンショット 2025-12-29 153033.png


全体像:グラフ操作時に何が起きるか

まず、ユーザーがグラフを操作するときの動作を整理します。

よくある状況

  1. ユーザーが「全期間」で600日分のデータを見ている
  2. ピンチズームで1週間分に拡大する
  3. パン操作で過去のデータを見に行く

期待する動作

  • ズームに応じてY軸スケールが最適化される
  • 表示範囲に応じてラベルフォーマットが変わる(日付のみ → 曜日付き → 2行表示)
  • ツールチップと縦線が連動して表示される(表示数が1週間分の場合は縦線を非表示)
  • 全体が滑らかに動く

Screen_Recording_20251229_155231_Kiwi Browser_1(1).gif

素直に実装すると起きる問題

問題 原因
ズーム時にY軸が見づらくなる データ全体のmin/maxでスケールが固定されている
X軸ラベルが読めない 表示範囲に関係なく同じフォーマット
ツールチップの位置がズレる デフォルトのポジショナーが複合グラフに最適化されていない
ランキング順位が逆に表示される 1位が最高だがY軸は下が小さい

これらを解決するために、以下の仕組みを組み合わせています。


設計方針

Chart.jsのカスタマイズは、コールバック関数やプラグインで行う。これらの関数がチャートの状態(表示期間、ズーム中か、デバイス種別など)を参照できるよう、OpenChatChartクラスで状態を一元管理している。

📁 OpenChatChart.ts

export default class OpenChatChart {
  chart: ChartJS = null!
  limit: ChartLimit = 0       // 表示期間
  isZooming = false           // ズーム中か
  isPC = true                 // PC表示か
  graph2Max = 0               // ランキングの最大値
  // ...
}

コンストラクタでthisを各コールバックやプラグインに渡すことで、どこからでも状態を参照できる。


1. ズーム機能:Y軸スケールとラベルの動的調整

全期間表示でズーム/パンを有効にしていますが、素直に実装すると使いづらい。

問題1:Y軸が見づらい

全期間(600日)でY軸を設定すると、1週間にズームしたとき無駄な余白だらけになる。

解決策

ズーム完了時に、表示範囲のデータだけでY軸のmin/max/stepSizeを再計算する。

Screenshot_20251229_161707_Kiwi Browser.jpg

📁 zoomOptions.ts

1. ユーザーがズーム操作
2. onZoomComplete発火
3. chart.scales.x.min/max から表示範囲を取得
4. その範囲のデータだけでY軸を再計算
5. chart.options.scales.rainChart.min/max を更新

データ範囲に応じてステップサイズも自動調整:

データ差 stepSize
〜99 2
100〜999 10
1000〜 100

問題2:ラベルが読めない

全期間で「6/27」と表示していたラベルを、8日間にズームしても同じでは読みにくい。

解決策

表示範囲のデータ数に応じて、ラベルフォーマットを3段階で切り替える。

Screenshot_20251229_161707_Kiwi Browser.paint3.jpg

表示データ数 フォーマット
32以上 日付のみ 06/27
9〜31 曜日付き1行 6/27(金)
8以下 曜日2行 6/27 + (金)

2. 棒グラフ:ランキング順位の反転処理

ランキング順位を表示する棒グラフの工夫です。

問題

Chart.jsのY軸は下が小さく上が大きい。でもランキングは「1位が一番良い」ので、1位を上に表示したい。

スクリーンショット 2025-12-29 161438.png

なぜ reverse: true を使わないか

Chart.jsにはY軸を反転する reverse: true オプションがある。でも今回はこれを使っていない。理由は2つ。

1. 圏外(0)の扱いが壊れる

ランキングに入っていない状態を「圏外」として0で表現している。reverse: trueを使うとY軸全体が反転するため、0がグラフの一番上に来てしまう。

reverse: true を使った場合:
  Y軸: 0(圏外) ─ 1位 ─ 5位 ─ 10位
       ↑一番上     ↓一番下
  → 圏外が1位より上になってしまう

2. 複数箇所での変換が必要になる

reverse: trueだとデータはそのまま(1位=1, 10位=10)だが、以下の場所で「見た目の順位」と「実際のデータ」を変換し続ける必要がある:

  • Y軸ラベル表示
  • データラベル(棒グラフ上の数字)
  • ツールチップ
  • ズーム時のスケール再計算

解決策:データ側で反転する

データを反転させて描画し、ラベル表示時に元に戻す方式を採用。

スクリーンショット 2025-12-29 161257.png

📁 OpenChatChart.ts

getReverseGraph2(graph2: (number | null)[]) {
  return graph2.map(v => {
    if (v === null) return v
    return v ? this.graph2Max + 1 - v : 0  // 圏外(v=0)はそのまま0
  })
}

この方式の利点:

利点 説明
圏外が常に下 0は0のまま、反転されない
変換式が統一 すべて graph2Max + 1 - v で計算可能
境界計算が簡単 Y軸の min/max/stepSize が直感的に設定できる
実データ: [1位, 5位, 10位, 圏外]
      ↓ 反転(graph2Max=10の場合)
描画データ: [10, 6, 1, 0]
      ↓ 表示時に逆算
ラベル: [1位, 5位, 10位, 圏外]

3. ツールチップ:位置と縦線を同期させる

問題

Chart.jsのデフォルトツールチップは、折れ線と棒グラフが混在する複合グラフでは位置が安定しない。
また、縦線(カスタムで表示させている Vertical Line)を一緒に表示したいが、別々に実装すると位置がズレる。

Screen_Recording_20251229_160845_Kiwi Browser(1).gif

解決策

Tooltip.positionersを拡張して、ツールチップ位置の計算と縦線描画を同じ関数で行う。

📁 getTooltipAndLineCallback.ts

1. マウス/タッチイベント発生
2. カスタムポジショナーが呼ばれる
3. 平均位置を計算(Tooltip.positioners.average)
4. その位置で縦線を描画(ctx.beginPath → lineTo → stroke)
5. ツールチップ位置を返す

さらに、以下の条件ではツールチップを非表示にしている:

  • パディングによる空データの場合
  • ズーム/パン操作中(ocChart.onPaning/ocChart.onZoomingで判定)
  • 1週間表示で棒グラフがない場合

ツールチップをカスタマイズする参考記事


4. X軸ラベルの色分け:ゼロ幅文字でマーキング

問題

土曜日は青、日曜日は赤で表示したい。でもChart.jsのticks.colorコールバックでは、ラベル文字列しか受け取れない。

24時間表示では「昨日のデータ」と「今日のデータ」も色分けしたいが、時刻文字列だけでは区別できない。

解決策

ゼロ幅文字をマーカーとして埋め込む。視覚的には見えないが、色分け関数で検出できる。

📁 getHourTicksFormatterCallback.ts

// 視覚的には見えないマーカー
const isYestString = '\u200B'   // ZERO WIDTH SPACE
const isRecentString = '\u200C' // ZERO WIDTH NON-JOINER

// 時刻フォーマット時にマーカーを付与
if (index === ticks.length - 1) return isRecentString + hour  // 最新
if (today !== day) return isYestString + hour                  // 昨日
return hour

📁 getHorizontalLabelFontColor.ts

if (label.includes(saturday)) return '#44617B'      // 土曜:青
if (label.includes(sunday)) return '#9C3848'        // 日曜:赤
if (label.includes(isYestString)) return '#b7b7b7'  // 昨日:灰色
if (label.includes(isRecentString)) return '#111'   // 最新:黒

graph-month-chart.png
1ヶ月表示:土曜日(青)と日曜日(赤)の色分け

graph-hour-chart.png
24時間表示:昨日(薄グレー)、今日(濃グレー)、最新(黒)の色分け


5. 折れ線グラフ:ポイント表示の制御

メンバー数を表示する折れ線グラフの工夫です。

ポイント表示:表示範囲に応じて出し分け

全期間(600ポイント)で全部のドットを表示すると見づらい。でも1週間表示では全ポイント見せたい。

Screenshot_20251229_161707_Kiwi Browser.paint2.jpg

📁 getPointRadiusCallback.ts

条件 表示するポイント
8データ以下(ズーム時含む) 全ポイント
9データ以上 両端 + データの最初/最後のみ

pointRadiusにコールバックを渡し、context.chart.scales.x.min/maxで現在の表示範囲を取得して判定する。

参考記事


6. モバイル対応:visibilitychangeでメモリを管理

問題

モバイルブラウザではバックグラウンドタブのメモリが解放されることがある。タブを戻したときにChart.jsのCanvasが壊れていることがある。

解決策

visibilitychangeイベントでチャートのライフサイクルを管理する。

📁 OpenChatChart.ts

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    // タブがアクティブになったら再描画
    this.canvas?.getContext('2d')?.clearRect(...)
    this.createChart(false)  // アニメーションなしで即座に
  }
  
  if (document.visibilityState === 'hidden') {
    // タブが非アクティブになったら破棄
    this.chart.destroy()
  }
})

まとめ

課題 解決策
ズーム時にY軸が見づらい 表示範囲のデータでスケール再計算
ズーム時にラベルが読めない 表示データ数で3段階にフォーマット変更
ランキング1位を上に+圏外を下に reverseではなくデータを手動で反転
ツールチップと縦線がズレる カスタムポジショナーで同時処理
曜日・時間帯で色を変えたい ゼロ幅文字でマーキング
グラデーション再生成が重い サイズ変更時のみ再生成するキャッシュ
全ポイント表示すると見づらい 表示範囲に応じて動的に出し分け
モバイルでCanvasが壊れる visibilitychangeで再描画

どれも「Chart.jsのデフォルト動作では足りない部分」を、コールバックやプラグインで補完する考え方です。


関連リンク

前回までの記事:

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?