チャートに命を吹き込む:アニメーションシステム、イベント処理、インタラクション
前提知識
- ›第1〜5記事
- ›requestAnimationFrame とブラウザのイベントモデルの理解
- ›イージング関数と補間の概念に関する基本知識
チャートに命を吹き込む:アニメーションシステム、イベント処理、インタラクション
静的なチャートは「絵」です。インタラクティブでアニメーションするチャートは「アプリケーション」です。このシリーズ最終回では、Chart.js のチャートを動かす3つのシステムを取り上げます。状態間をなめらかに遷移させる三層アニメーションアーキテクチャ、DOM イベントをホバー状態へと変換するイベント処理パイプライン、そしてすべてをレスポンシブに保つパフォーマンス最適化です。これらのシステムは互いに連携しており、アニメーションの完了がホバーの再評価を引き起こしたり、インタラクションの判断がアニメーション完了後の最終位置に依存したりします。
三層アニメーションアーキテクチャ
Chart.js はアニメーションの関心事を責務の明確な3つのクラスに分離しています。
classDiagram
class Animation {
-_active: boolean
-_fn: InterpolatorFunction
-_easing: EasingFunction
-_start: number
-_duration: number
-_from: any
-_to: any
-_target: object
-_prop: string
+tick(date)
+cancel()
+wait(): Promise
}
class Animations {
-_chart: Chart
-_properties: Map
+configure(config)
+update(target, values): Animation[]
-_createAnimations(target, values)
-_animateOptions(target, values)
}
class Animator {
-_charts: Map~Chart, AnimState~
-_running: boolean
-_request: number
+listen(chart, event, cb)
+add(chart, items)
+start(chart)
+stop(chart)
-_refresh()
-_update(date)
}
Animator "1" -- "*" Animations : coordinates
Animations "1" -- "*" Animation : creates/manages
Animation --> Element : writes to target[prop]
Animation は単一プロパティの遷移を担い、Animations はチャート全体の保留中アニメーションを管理し、Animator は requestAnimationFrame ループを動かすグローバルなシングルトンです。
この分離によって、アニメーション可能な新しいプロパティを追加する際にフレームループやオーケストレーションのロジックを変更する必要はなく、Animations 層で設定するだけで済みます。
プロパティごとのアニメーションと補間
Animation クラスの本質は、イージングを伴うプロパティ補間器です。
src/core/core.animation.js#L1-L119
28行目のコンストラクタでは、from と to の値を解決し、値の型に基づいて補間関数を選択し、イージングを設定することでアニメーションを初期化します。
flowchart TD
INIT["new Animation(cfg, target, prop, to)"] --> TYPE{"Determine type"}
TYPE -->|"typeof from === 'number'"| NUM["interpolators.number<br/>from + (to - from) * factor"]
TYPE -->|"from is color string"| COLOR["interpolators.color<br/>RGBA space mixing via @kurkle/color"]
TYPE -->|"typeof from === 'boolean'"| BOOL["interpolators.boolean<br/>factor > 0.5 ? to : from"]
TYPE -->|"custom cfg.fn"| CUSTOM["User-provided function"]
NUM --> EASING["Apply easing: effects[cfg.easing]"]
COLOR --> EASING
BOOL --> EASING
CUSTOM --> EASING
76行目の tick() メソッドは Animator から毎フレーム呼ばれます。経過時間を計算し、イージングを適用し、補間された値をターゲットオブジェクトへ直接書き込みます(this._target[prop] = this._fn(from, to, factor))。要素のプロパティはその場でミューテートされ、仮想 DOM や差分検出の仕組みはありません。
カラー補間には @kurkle/color ライブラリ(Chart.js 唯一のランタイム依存)を使用し、RGBA 空間で色を混合します。補間器は両方の色を Color オブジェクトに変換し、mix() を呼び出してHEX文字列として返します。これにより、知覚的になめらかなカラートランジションが実現されます。
105行目の wait() メソッドはアニメーション完了時に解決する Promise を返します。これは Animations._animateOptions() の共有オプション最適化で使われており、共有オプションへの遷移時にすべてのオプションアニメーションが完了するまで待ってから共有参照に切り替えます。
Animator シングルトンとフレームループ
Animator はすべてのチャートの requestAnimationFrame ループをまとめて管理します。
src/core/core.animator.js#L38-L107
sequenceDiagram
participant Chart1 as Chart A
participant Chart2 as Chart B
participant Anim as Animator
participant RAF as Browser rAF
Chart1->>Anim: start(chartA)
Anim->>Anim: _refresh()
Anim->>RAF: requestAnimationFrame
Chart2->>Anim: start(chartB)
Note over Anim: No new rAF needed<br/>(already running)
RAF->>Anim: _update(timestamp)
Anim->>Anim: Tick Chart A animations
Anim->>Chart1: chartA.draw()
Anim->>Anim: Tick Chart B animations
Anim->>Chart2: chartB.draw()
alt Animations remaining
Anim->>RAF: _refresh() → next frame
else All complete
Anim->>Anim: _running = false
Anim->>Chart1: notify 'complete'
Anim->>Chart2: notify 'complete'
end
重要な設計上の選択として、38行目の _refresh() メソッドは if (this._request) return でチェックを行い、次のフレームを二重スケジュールしません。つまり、複数のチャートが start() を呼び出しても、requestAnimationFrame のペンディングは常に一つだけです。すべてのチャートが単一のフレームコールバックにまとめられるため、チャートごとに別々の rAF ループを持つよりも効率的です。
57行目の _update() ループは _charts Map を走査し、各チャートのアニメーションを tick します。アニメーションアイテムの _active フラグが false になると、splice() ではなく swap-and-pop 手法(83〜84行目)で削除されます。これにより O(1) の削除コストが維持されます。
チャートのアニメーションアイテム配列が空になると、complete コールバックが呼ばれます。第2回で見たとおり、このコールバックは onAnimationsComplete であり、chart.notifyPlugins('afterRender') とユーザーの animation.onComplete コールバックを呼び出します。
イベント処理パイプライン:DOM からチャートへ
イベントはブラウザの DOM から Chart.js のインタラクション解決まで、複数のステージを経て流れます。
src/core/core.controller.js#L976-L992
sequenceDiagram
participant DOM as Browser DOM
participant Platform as DomPlatform
participant Chart as Chart
participant Plugins as PluginService
participant Interaction as Interaction Module
participant Hover as Hover Styles
DOM->>Platform: mousemove event
Platform->>Platform: Map touch/pointer → mouse type
Platform->>Chart: listener(e, offsetX, offsetY)
Chart->>Chart: _eventHandler(e)
Chart->>Chart: isPointInArea(e)?
Chart->>Plugins: beforeEvent hook (cancelable)
Chart->>Chart: _handleEvent(e, replay, inChartArea)
Chart->>Interaction: getElementsAtEventForMode()
Interaction-->>Chart: active elements[]
Chart->>Chart: onHover callback
Chart->>Chart: onClick callback (if click)
Chart->>Hover: _updateHoverStyles(active, lastActive)
Chart->>Plugins: afterEvent hook
Chart->>Chart: render() if changed
976行目の bindUserEvents() メソッドは this.options.events(デフォルト:mousemove、mouseout、click、touchstart、touchmove)を走査し、それぞれに対してプラットフォーム経由でリスナーを登録します。プラットフォームはタッチイベントやポインターイベントを EVENT_TYPES マッピングを使ってマウスイベントに正規化します。
src/platform/platform.dom.js#L21-L31
ヒント:
options.eventsを設定することで、Chart.js がリッスンするイベントをカスタマイズできます。タッチ操作が不要ならtouchstartとtouchmoveを取り除くことでイベントハンドラのオーバーヘッドを削減できます。クリックだけでよければevents: ['click']と設定しましょう。
インタラクションモードとヒットテスト
Interaction モジュールは、マウス位置に対してどの要素を「アクティブ」とするかを決定するための組み込みモードを六つ提供しています。
src/core/core.interaction.js#L22-L64
| モード | 動作 |
|---|---|
point |
マウス位置を含む要素 |
nearest |
マウス位置に最も近い単一要素 |
index |
同じインデックスのすべての要素(縦のスライス) |
dataset |
最も近いデータセットのすべての要素 |
x |
同じ x 位置のすべての要素 |
y |
同じ y 位置のすべての要素 |
flowchart TD
EVENT["Mouse position (x, y)"] --> MODE{"Interaction mode?"}
MODE -->|nearest| BINARY["binarySearch()<br/>Narrow candidate range"]
MODE -->|index| INDEX["Find closest index<br/>then all elements at that index"]
MODE -->|point| POINT["Test inRange() on<br/>all elements"]
BINARY --> EVAL["evaluateInteractionItems()"]
EVAL --> DIST["Calculate distances"]
DIST --> ACTIVE["Return active elements"]
22行目の binarySearch() 関数がパフォーマンス最適化の要です。データがインデックス軸に沿ってソートされているデータセット(一般的なケース)では、_lookupByKey を使った二分探索を行い、ヒットテストを O(n) から O(log n) に削減します。10,000ポイントのチャートであれば、10,000回ではなく約14回の比較で済みます。
二分探索にはもう一つの最適化があります。controller._sharedOptions が設定されている場合、すべての要素が等しい比率を持つことがわかります。そのため最初の要素の getRange() でヒットテスト境界を決定し、範囲ベースの二分探索で一度に交差候補をすべて見つけることができます。
リプレイ機構とアニメーションを考慮したホバー
_handleEvent() メソッドには、Chart.js の中でも特に細かいながら重要な UX 上の工夫が含まれています。
src/core/core.controller.js#L1196-L1238
sequenceDiagram
participant Update as chart.update()
participant Handler as _eventHandler
participant Elements as Elements
participant Hover as Hover Styles
Note over Update: Data changes, elements will animate
Update->>Update: _lastEvent exists?
Update->>Handler: _eventHandler(lastEvent, replay=true)
Handler->>Elements: getActiveElements(useFinalPosition=true)
Note over Elements: getProps() returns animation<br/>target values, not current
Elements-->>Handler: Active elements at final positions
Handler->>Hover: Apply hover styles to final-position elements
Note over Handler: User sees hover on correct<br/>elements even during animation
update() がパイプラインを完了すると、531行目で replay = true として直前のマウスイベントをリプレイします。このリプレイは _handleEvent(e, true) を呼び出し、アクティブ要素を問い合わせる際に useFinalPosition = true をセットします。第5回で見たように、final = true を指定した Element.getProps() はアニメーションのターゲット値(最終値)を返します。
ソース1199〜1211行目のコメントブロックにその意図が記されています。すべてのアニメーションフレームごとにアクティブ要素を評価するのはコストが高く、かつアニメーション中は要素がまだ最終位置に到達していません。update() の後に最終位置を使って一度だけ評価することで、フレームごとの再計算なしに正しいホバー状態を得られるのです。
93行目の determineLastEvent() ヘルパーはエッジケースを処理します。mouseout イベントは最後のイベントをクリア(マウスがチャート外に出たらホバーなし)し、クリックイベントは直前の最後のイベントを保持します(クリックによってホバー位置が上書きされないようにするため)。
パフォーマンスパターン:共有オプションとデシメーション
複数のシステムにまたがる二つのパフォーマンスパターンについて詳しく見ておきましょう。
共有オプション
DatasetController が要素のオプションを解決する際に $shared: true(第3回で触れた config エンジンの needContext() 最適化によるもの)を検出すると、そのオプションオブジェクトを this._sharedOptions として保存します。データセット内のすべての要素がこの単一オブジェクトを参照します。
これには連鎖する利点があります。
- メモリ:N 個のオブジェクトではなく1つで済む(大規模データセットで効果大)
- アニメーション:
Animations._animateOptions()が共有オプションを検出し、Promise.allで保留中のアニメーションが完了してから切り替えを行う - インタラクション:
core.interaction.jsの二分探索最適化が_sharedOptionsを利用して全要素が等比であることを把握する
LTTB デシメーション
src/plugins/plugin.decimation.js#L3-L79 の Decimation プラグインが実装する LTTB アルゴリズムは、視覚的な形状を保ちながらデータポイント数を削減します。
flowchart TD
DATA["10,000 data points"] --> CHECK{"points > available pixels?"}
CHECK -->|No| PASS["Use all points"]
CHECK -->|Yes| BUCKET["Divide into N buckets<br/>(N ≈ canvas width in pixels)"]
BUCKET --> TRIANGLE["For each bucket:<br/>Find point forming largest<br/>triangle with neighbors"]
TRIANGLE --> RESULT["~800 representative points"]
RESULT --> RENDER["Render reduced dataset"]
アルゴリズムはデータをバケットに分割し、次のバケットの平均点を算出し、現在のバケットから「直前に選ばれた点」と「次のバケットの平均」を結ぶ三角形の面積が最大になる点を選びます。これにより、ピーク・谷・トレンドを保持しながらポイント数を大幅に削減します。
57行目の maxArea = area = -1(元のアルゴリズムの 1 ではなく)という初期化は Chart.js 固有のバグ修正です。すべての三角形の面積がゼロになるようなフラットなトレースでは、元のコードでは nextA が設定されず、次のイテレーションでクラッシュが起きていました。
シリーズのまとめ
この6回の記事を通じて、モジュール構成からデータ→ピクセルへの完全なパイプラインまで、Chart.js の全体像を追ってきました。3つのエントリーポイントと利用パターン(第1回)、Chart コンストラクタと多段階 update パイプライン(第2回)、Proxy ベースのオプションリゾルバによるカスケードデフォルトと scriptable オプション(第3回)を扱いました。さらに Registry・プラグインシステムによる拡張性(第4回)、Scale・Element・Controller の協調によるデータの Canvas ジオメトリ変換(第5回)、アニメーションとインタラクション(第6回)を見てきました。
設計全体を貫く哲学は均一性とコンポジションです。組み込みのチャートタイプは特権的なコードではなく登録されたコンポーネントであり、オプションの解決はチャートタイプ固有のロジックではなくスコープを辿る汎用アルゴリズムであり、アニメーションシステムは補間できる値であれば何にでも対応するプロパティ型非依存の仕組みです。この均一性こそが Chart.js を真に拡張可能にしている理由です。特別な抜け穴やオーバーライドによってではなく、ライブラリ自身が内部で使っているのと同じメカニズムを通じて拡張できるのです。