はじめに
オプチャグラフは、LINE OpenChatの統計情報を収集・分析・可視化するWebサービスです。
統計グラフ機能
オプチャグラフのオープンチャット詳細ページでは、メンバー数推移とランキング順位を可視化する統計グラフを提供しています。
主な機能:
- 4つの表示期間(最新24時間・1週間・1ヶ月・全期間)
- メンバー数(折れ線)+ ランキング順位(棒)の複合グラフ
- ピンチ・ホイールによるズーム/パン操作
- 曜日に応じたラベル色分け
- 多言語対応(日本語・繁体中文・タイ語)
全体像:グラフ操作時に何が起きるか
まず、ユーザーがグラフを操作するときの動作を整理します。
よくある状況
- ユーザーが「全期間」で600日分のデータを見ている
- ピンチズームで1週間分に拡大する
- パン操作で過去のデータを見に行く
期待する動作
- ズームに応じてY軸スケールが最適化される
- 表示範囲に応じてラベルフォーマットが変わる(日付のみ → 曜日付き → 2行表示)
- ツールチップと縦線が連動して表示される(表示数が1週間分の場合は縦線を非表示)
- 全体が滑らかに動く
素直に実装すると起きる問題
| 問題 | 原因 |
|---|---|
| ズーム時にY軸が見づらくなる | データ全体のmin/maxでスケールが固定されている |
| X軸ラベルが読めない | 表示範囲に関係なく同じフォーマット |
| ツールチップの位置がズレる | デフォルトのポジショナーが複合グラフに最適化されていない |
| ランキング順位が逆に表示される | 1位が最高だがY軸は下が小さい |
これらを解決するために、以下の仕組みを組み合わせています。
設計方針
Chart.jsのカスタマイズは、コールバック関数やプラグインで行う。これらの関数がチャートの状態(表示期間、ズーム中か、デバイス種別など)を参照できるよう、OpenChatChartクラスで状態を一元管理している。
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を再計算する。
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段階で切り替える。
| 表示データ数 | フォーマット | 例 |
|---|---|---|
| 32以上 | 日付のみ | 06/27 |
| 9〜31 | 曜日付き1行 | 6/27(金) |
| 8以下 | 曜日2行 |
6/27 + (金)
|
2. 棒グラフ:ランキング順位の反転処理
ランキング順位を表示する棒グラフの工夫です。
問題
Chart.jsのY軸は下が小さく上が大きい。でもランキングは「1位が一番良い」ので、1位を上に表示したい。
なぜ 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軸ラベル表示
- データラベル(棒グラフ上の数字)
- ツールチップ
- ズーム時のスケール再計算
解決策:データ側で反転する
データを反転させて描画し、ラベル表示時に元に戻す方式を採用。
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)を一緒に表示したいが、別々に実装すると位置がズレる。
解決策
Tooltip.positionersを拡張して、ツールチップ位置の計算と縦線描画を同じ関数で行う。
1. マウス/タッチイベント発生
2. カスタムポジショナーが呼ばれる
3. 平均位置を計算(Tooltip.positioners.average)
4. その位置で縦線を描画(ctx.beginPath → lineTo → stroke)
5. ツールチップ位置を返す
さらに、以下の条件ではツールチップを非表示にしている:
- パディングによる空データの場合
- ズーム/パン操作中(
ocChart.onPaning/ocChart.onZoomingで判定) - 1週間表示で棒グラフがない場合
ツールチップをカスタマイズする参考記事
4. X軸ラベルの色分け:ゼロ幅文字でマーキング
問題
土曜日は青、日曜日は赤で表示したい。でもChart.jsのticks.colorコールバックでは、ラベル文字列しか受け取れない。
24時間表示では「昨日のデータ」と「今日のデータ」も色分けしたいが、時刻文字列だけでは区別できない。
解決策
ゼロ幅文字をマーカーとして埋め込む。視覚的には見えないが、色分け関数で検出できる。
// 視覚的には見えないマーカー
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
if (label.includes(saturday)) return '#44617B' // 土曜:青
if (label.includes(sunday)) return '#9C3848' // 日曜:赤
if (label.includes(isYestString)) return '#b7b7b7' // 昨日:灰色
if (label.includes(isRecentString)) return '#111' // 最新:黒

24時間表示:昨日(薄グレー)、今日(濃グレー)、最新(黒)の色分け
5. 折れ線グラフ:ポイント表示の制御
メンバー数を表示する折れ線グラフの工夫です。
ポイント表示:表示範囲に応じて出し分け
全期間(600ポイント)で全部のドットを表示すると見づらい。でも1週間表示では全ポイント見せたい。
| 条件 | 表示するポイント |
|---|---|
| 8データ以下(ズーム時含む) | 全ポイント |
| 9データ以上 | 両端 + データの最初/最後のみ |
pointRadiusにコールバックを渡し、context.chart.scales.x.min/maxで現在の表示範囲を取得して判定する。
参考記事
6. モバイル対応:visibilitychangeでメモリを管理
問題
モバイルブラウザではバックグラウンドタブのメモリが解放されることがある。タブを戻したときにChart.jsのCanvasが壊れていることがある。
解決策
visibilitychangeイベントでチャートのライフサイクルを管理する。
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のデフォルト動作では足りない部分」を、コールバックやプラグインで補完する考え方です。
関連リンク
前回までの記事:
- ①データパイプライン - 25万件/毎時のクロール〜静的ファイル生成
- ②DBの差分検出 - 25万件のオプチャ更新を99%削減する仕組み
- ③バッチ設計 - 冪等性・再開可能性・障害耐性
- ④2025年の技術的チャレンジ振り返り - 多言語対応の実装・キーワードスパム対策等
- ⑤タグ機能 - オープンチャットを分類するタグ付けシステム
- ⑥キーワード羅列対策 - 検索用のキーワード羅列対策
- ⑦カテゴリ別ランキングページ - React・Swiper.jsのパフォーマンス調整








