スケール・エレメント・レンダリングパイプライン:データがピクセルになるまで
前提知識
- ›第1〜4回の記事
- ›座標系と軸スケーリングの基本的な理解
- ›HTML5 Canvas描画API(fillRect、arc、lineTo)の基礎知識
スケール・エレメント・レンダリングパイプライン:データがピクセルになるまで
あらゆるチャートライブラリは、[12, 19, 3, 5, 2, 3] のような数値の配列を Canvas 上の特定のピクセル座標にある幾何学的な形状へと変換するという根本的な課題に直面します。Chart.jsはこの問題を、3つのコンポーネントからなるパイプラインで解決しています。Scales がデータ値をピクセル座標へマッピングし、DatasetControllers がパースと要素生成を統括し、Elements がジオメトリとCanvas描画をカプセル化します。この記事では、生のデータが入力されてからピクセルとして描画されるまでの流れを追っていきましょう。
Elementベースクラスとアニメーション対応プロパティ
Chart.jsのすべての視覚的プリミティブは、Element ベースクラスを継承しています。
src/core/core.element.ts#L6-L45
classDiagram
class Element~T, O~ {
+x: number
+y: number
+active: boolean
+options: O
+$animations: Record
+getProps(props, final?): Partial~T~
+tooltipPosition(useFinalPosition): Point
+hasValue(): boolean
}
class ArcElement {
+startAngle: number
+endAngle: number
+innerRadius: number
+outerRadius: number
+draw(ctx)
+inRange(x, y)
}
class BarElement {
+base: number
+width: number
+height: number
+draw(ctx)
+inRange(x, y)
}
class PointElement {
+radius: number
+pointStyle: string
+draw(ctx)
+inRange(x, y)
}
class LineElement {
+points: Point[]
+segments: Segment[]
+draw(ctx)
}
Element <|-- ArcElement
Element <|-- BarElement
Element <|-- PointElement
Element <|-- LineElement
最も重要なメソッドは getProps() です。final = false(またはundefined)で呼び出された場合は this をそのまま返し、現在のプロパティ値を参照します。一方、final = true で呼び出された場合は、要求されたプロパティごとに $animations を照合します。そのプロパティに対してアニメーションが実行中であれば、現在の補間値ではなく、アニメーションの目標値(_to)を返します。
この二重の振る舞いは、第2回の記事で見たホバーリプレイの仕組みにとって欠かせないものです。update() が useFinalPosition = true で最後のマウスイベントをリプレイする際、各エレメントはアニメーション完了後の最終的な位置を報告します。遷移の途中の現在地ではありません。これにより、ツールチップとホバースタイルが正しい最終位置に紐付けられます。
具体的なElement:Arc・Bar・Line・Point
各エレメント型は、それぞれ独自のジオメトリ、ヒットテスト、Canvas描画を実装しています。
ArcElement はジオメトリの面で最も複雑です。その draw() メソッドは、ドーナツセグメントの角丸、内外の半径による弧、クリッピングを使ったボーダーレンダリングを処理します。7行目の clipSelf() 関数は、evenoddクリッピングルールを使い、弧のパスをクリッピングしてから二重幅のボーダーを描画することで内側のボーダーを実現しています。
src/elements/element.arc.ts#L1-L38
BarElement は、オプションの角丸に対応した矩形バーを扱います。inRange() メソッドはシンプルな境界チェックを行いますが、draw() メソッドでは角丸矩形の描画やスタックバーのオフセット処理といった複雑な処理が必要になります。
PointElement は複数のシェイプスタイル(circle、cross、crossRot、dash、line、rect、rectRounded、rectRot、star、triangle)に対応しており、それぞれが独立したCanvasパス構築として実装されています。
LineElement は他のエレメントとは性質が異なります。データポイントごとに生成されるエレメントではなく、データセット単位のエレメントです。ポイントとセグメントの配列を保持し、線の端点スタイルや結合スタイル、さらにベジェ曲線による滑らかな描画のための tension プロパティを処理します。
Scaleのアーキテクチャとライフサイクル
Scale クラスは Element(位置と寸法を持つ)を継承しつつ、LayoutItem インターフェース(レイアウトシステムにボックスとして参加する)も実装しています。この二重の役割は巧みな設計です。スケールは座標変換器であると同時に、自身の描画処理を持つ視覚的なコンポーネントでもあります。
src/core/core.scale.js#L168-L179
flowchart TD
INIT["scale.init(options)"] --> DDL["determineDataLimits()<br/>Find min/max from datasets"]
DDL --> BT["buildTicks()<br/>Generate tick values"]
BT --> CONFIG["configure()<br/>Set pixel range, padding"]
CONFIG --> GTL["generateTickLabels()<br/>Format tick values to strings"]
GTL --> CLR["calculateLabelRotation()<br/>Auto-rotate if needed"]
CLR --> FIT["fit()<br/>Calculate width/height<br/>needed for labels"]
FIT --> DRAW["draw()<br/>Render grid, ticks, title"]
スケールのライフサイクルは update() 中に実行されます。まず buildOrUpdateScales() が各スケールの init() を呼び出し、続いてレイアウトエンジンが update() を呼んで fit() がトリガーされます。第2回の記事で見たように、fit() はレイアウトエンジンによってサイズが安定するまで繰り返し呼び出されます。
データパイプラインにおいて最重要なメソッドは getPixelForValue(value) と getValueForPixel(pixel) です。これらはデータ空間とピクセル空間の間の全単射マッピングを形成しています。
Scaleの派生クラス:Linear・Log・Time・Category・Radial
各スケール型は determineDataLimits()、buildTicks()、ピクセルマッピングメソッドをオーバーライドします。
src/scales/scale.linear.js#L1-L51
classDiagram
class Scale {
+determineDataLimits()*
+buildTicks()*
+getPixelForValue(value)*
+getValueForPixel(pixel)*
+getPixelForDecimal(decimal)
+getDecimalForPixel(pixel)
}
class LinearScaleBase {
+handleTickRangeOptions()
+getTickLimit()
}
class LinearScale {
+id = "linear"
+getPixelForValue(v): decimal mapping
+computeTickLimit()
}
class LogarithmicScale {
+id = "logarithmic"
+getPixelForValue(v): log10 mapping
}
class TimeScale {
+id = "time"
+_adapter: DateAdapter
+getPixelForValue(v): timestamp mapping
}
class CategoryScale {
+id = "category"
+getPixelForValue(v): index mapping
}
class RadialLinearScale {
+id = "radialLinear"
+getPointPositionForValue(i, v): polar mapping
}
Scale <|-- LinearScaleBase
LinearScaleBase <|-- LinearScale
Scale <|-- LogarithmicScale
Scale <|-- TimeScale
Scale <|-- CategoryScale
Scale <|-- RadialLinearScale
LinearScale は派生の仕組みを最もわかりやすく示した例です。44行目の getPixelForValue() は一行の式で完結しています。this.getPixelForDecimal((value - this._startValue) / this._valueRange) です。ピクセルの補間処理そのものはベースクラスの getPixelForDecimal() が担うため、派生スケールはデータ空間から [0, 1] への正規化だけを提供すれば済みます。
TimeScale はアダプターパターンを導入しており、日付のパースとフォーマットをプラグイン可能な DateAdapter に委譲します。これにより、利用者はChart.jsがいずれのライブラリにも依存することなく、好みの日付ライブラリ(Luxon、Moment、date-fns)を選択できます。
ヒント: ベース
ScaleクラスのgetPixelForDecimal(decimal)/getDecimalForPixel(pixel)ペアは、ピクセル範囲・パディング・軸の反転をすべて処理してくれます。カスタムスケールを作成する際は、getPixelForValue()をオーバーライドして[0, 1]に正規化した値でthis.getPixelForDecimal()を呼び出しましょう。ピクセルを直接計算しようとしてはいけません。
DatasetController:データとElementをつなぐ橋
DatasetController ベースクラスは、生データ・スケール・エレメントを結びつけるのりしろです。データのパース、エレメントの生成、スタッキング、そしてパフォーマンスに大きく貢献する共有オプション最適化を担当します。
src/core/core.datasetController.js#L1-L100
共有オプションパターンは、Chart.jsが持つ最もインパクトの大きいパフォーマンス最適化のひとつです。エレメントのオプションを解決する際、DatasetController は第3回で紹介したconfigエンジンの resolveNamedOptions() を呼び出します。結果に $shared: true(スクリプタブルオプションもインデックス可能オプションも検出されなかったことを意味する)が含まれていれば、データセット内のすべてのエレメントが単一のオプションオブジェクト参照を共有します。10,000ポイントの折れ線グラフでは、10,000個ではなく1個のオプションオブジェクトで済むということです。
BarController や LineController といったチャートタイプのコントローラーは、DatasetController を継承してタイプ固有のエレメント配置を実装します。
src/controllers/controller.bar.js#L1-L53
BarController は、データポイント間の最小間隔をもとにバーの幅を計算してオーバーラップを防ぎ、グループ化・スタック化されたバーのオフセットを処理し、インデックススケールで位置を、値スケールで高さを決定して BarElement インスタンスを配置します。
データからピクセルへの完全なパイプライン
単一のデータポイントがパイプライン全体をどのように流れるか、追ってみましょう。
sequenceDiagram
participant Raw as Raw Data [12, 19, 3]
participant DC as DatasetController
participant Parse as Data Parsing
participant IScale as Index Scale (CategoryScale)
participant VScale as Value Scale (LinearScale)
participant Elem as BarElement
participant Canvas as Canvas Context
Raw->>DC: update() → _update(mode)
DC->>Parse: parsePrimitiveData()
Parse-->>DC: [{x: 0, y: 12}, {x: 1, y: 19}, {x: 2, y: 3}]
DC->>IScale: getPixelForValue(0)
IScale-->>DC: 85px
DC->>VScale: getPixelForValue(12)
VScale-->>DC: 180px
DC->>VScale: getPixelForValue(0)
VScale-->>DC: 300px (base)
DC->>Elem: Update properties
Note over Elem: x=85, y=180, base=300, width=40
Note over DC: During draw():
Elem->>Canvas: ctx.fillRect(65, 180, 40, 120)
updateElements() では、コントローラーが各スケールにピクセル位置を問い合わせ、レイアウトからバーの幅を算出してエレメントのプロパティを更新します。draw() では、各エレメントが自身のプロパティ(アニメーション中の場合は補間値)を読み取り、Canvasコマンドを発行します。
関心の分離は明確に保たれています。コントローラーはデータ構造とスケールマッピングを知っており、エレメントはジオメトリとCanvas描画を知っており、スケールは値からピクセルへの変換を知っています。それぞれが互いの内部実装を知る必要はありません。
次回の記事へ
ここまでで、データから静的なピクセルへの流れを追うことができました。しかしチャートは静止しているわけではありません。アニメーションし、ホバーに反応し、インタラクションに応じて更新されます。最終回では、3層のアニメーションアーキテクチャ、DOMイベントからホバー状態へのイベントハンドリングパイプライン、そして大量のデータを扱っても Chart.js をレスポンシブに保つパフォーマンスパターンを探っていきます。