Read OSS

チャートに命を吹き込む:アニメーションシステム、イベント処理、インタラクション

上級

前提知識

  • 第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 はチャート全体の保留中アニメーションを管理し、AnimatorrequestAnimationFrame ループを動かすグローバルなシングルトンです。

この分離によって、アニメーション可能な新しいプロパティを追加する際にフレームループやオーケストレーションのロジックを変更する必要はなく、Animations 層で設定するだけで済みます。

プロパティごとのアニメーションと補間

Animation クラスの本質は、イージングを伴うプロパティ補間器です。

src/core/core.animation.js#L1-L119

28行目のコンストラクタでは、fromto の値を解決し、値の型に基づいて補間関数を選択し、イージングを設定することでアニメーションを初期化します。

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(デフォルト:mousemovemouseoutclicktouchstarttouchmove)を走査し、それぞれに対してプラットフォーム経由でリスナーを登録します。プラットフォームはタッチイベントやポインターイベントを EVENT_TYPES マッピングを使ってマウスイベントに正規化します。

src/platform/platform.dom.js#L21-L31

ヒント: options.events を設定することで、Chart.js がリッスンするイベントをカスタマイズできます。タッチ操作が不要なら touchstarttouchmove を取り除くことでイベントハンドラのオーバーヘッドを削減できます。クリックだけでよければ 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 として保存します。データセット内のすべての要素がこの単一オブジェクトを参照します。

これには連鎖する利点があります。

  1. メモリ:N 個のオブジェクトではなく1つで済む(大規模データセットで効果大)
  2. アニメーションAnimations._animateOptions() が共有オプションを検出し、Promise.all で保留中のアニメーションが完了してから切り替えを行う
  3. インタラクション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 を真に拡張可能にしている理由です。特別な抜け穴やオーバーライドによってではなく、ライブラリ自身が内部で使っているのと同じメカニズムを通じて拡張できるのです。