Scales, Elements, and the Rendering Pipeline: How Data Becomes Pixels
Prerequisites
- ›Articles 1-4
- ›Basic understanding of coordinate systems and axis scaling
- ›Familiarity with HTML5 Canvas drawing API (fillRect, arc, lineTo)
Scales, Elements, and the Rendering Pipeline: How Data Becomes Pixels
Every charting library faces the same fundamental challenge: transform an array of numbers like [12, 19, 3, 5, 2, 3] into geometric shapes at specific pixel coordinates on a canvas. Chart.js solves this through a three-component pipeline: Scales map data values to pixel coordinates, DatasetControllers orchestrate parsing and element creation, and Elements encapsulate geometry and Canvas drawing. In this article, we trace data from raw input to rendered pixels.
The Element Base Class and Animation-Aware Properties
Every visual primitive in Chart.js extends the Element base class:
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
The most important method is getProps(). When called with final = false (or undefined), it simply returns this—the current property values. When called with final = true, it checks each requested property against $animations: if an animation is active for that property, it returns the animation's target value (_to) rather than the current interpolated value.
This dual behavior is essential for the hover replay mechanism we saw in Article 2. When update() replays the last mouse event with useFinalPosition = true, elements report where they will be after animation completes, not where they currently are mid-transition. This ensures tooltips and hover styles attach to the correct final positions.
Concrete Elements: Arc, Bar, Line, Point
Each element type implements its own geometry, hit testing, and Canvas drawing:
ArcElement is the most complex geometrically. Its draw() method handles border radius corners on doughnut segments, inner/outer radius arcs, and clip-based border rendering. The clipSelf() function at line 7 uses the evenodd clip rule to draw inner borders by clipping the arc path and drawing a double-width border:
src/elements/element.arc.ts#L1-L38
BarElement handles rectangular bars with optional border radius. Its inRange() method performs a simple bounds check, but the draw() method must handle the complexity of rounded rectangles and stacked bar offsets.
PointElement supports multiple shape styles (circle, cross, crossRot, dash, line, rect, rectRounded, rectRot, star, triangle), each implemented as a separate Canvas path construction.
LineElement is unique among elements: it's a dataset-level element rather than a per-data-point element. It stores an array of points and segments, handling line caps, joins, and the tension property for Bézier curve smoothing.
Scale Architecture and Lifecycle
The Scale class extends Element (it has position and dimensions) and also implements the LayoutItem interface (it participates in the layout system as a box). This dual role is clever: a scale is both a coordinate transformer and a visual component with its own draw call.
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"]
The scale lifecycle runs during update(): first buildOrUpdateScales() calls init() on each scale, then the layout engine calls update() which triggers fit(). The fit() method is called iteratively by the layout engine (as we saw in Article 2) until sizes stabilize.
The most important methods for the data pipeline are getPixelForValue(value) and getValueForPixel(pixel). These form a bijective mapping between data space and pixel space.
Scale Specializations: Linear, Log, Time, Category, Radial
Each scale type overrides determineDataLimits(), buildTicks(), and the pixel-mapping methods:
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 is the cleanest example of specialization. Its getPixelForValue() at line 44 is a single expression: this.getPixelForDecimal((value - this._startValue) / this._valueRange). The base class's getPixelForDecimal() handles the actual pixel interpolation, so specialized scales only need to provide the normalization from data space to [0, 1].
TimeScale introduces the adapter pattern: it delegates date parsing and formatting to a pluggable DateAdapter. This allows consumers to choose their date library (Luxon, Moment, date-fns) without Chart.js depending on any of them.
Tip: The
getPixelForDecimal(decimal)/getDecimalForPixel(pixel)pair on the baseScaleclass handles pixel range, padding, and reverse axes. When creating custom scales, overridegetPixelForValue()and callthis.getPixelForDecimal()with a normalized[0, 1]value—don't compute pixels directly.
DatasetController: Bridging Data to Elements
The DatasetController base class is the glue between raw data, scales, and elements. It handles data parsing, element creation, stacking, and the critical shared options optimization:
src/core/core.datasetController.js#L1-L100
The shared options pattern is one of Chart.js's most impactful performance optimizations. When resolving options for elements, DatasetController calls resolveNamedOptions() from the config engine (Article 3). If the result has $shared: true (meaning no scriptable or indexable options were detected), all elements in the dataset share a single options object reference. For a line chart with 10,000 points, this means one options object instead of 10,000.
Chart-type controllers like BarController and LineController extend DatasetController to implement type-specific element positioning:
src/controllers/controller.bar.js#L1-L53
BarController computes bar widths based on the minimum spacing between data points (to prevent overlap), handles grouped and stacked bar offsets, and positions BarElement instances using the index scale for position and the value scale for height.
The Complete Data-to-Pixel Pipeline
Let's trace a single data point through the complete pipeline:
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)
During updateElements(), the controller asks each scale for pixel positions, computes bar width from the layout, and updates element properties. During draw(), each element reads its properties (potentially interpolated if animating) and issues Canvas commands.
The separation of concerns is clean: controllers know about data structure and scale mapping, elements know about geometry and Canvas drawing, and scales know about value-to-pixel transformation. None of them knows about the others' internals.
Bridging to the Next Article
We've traced the path from data to static pixels, but charts aren't static—they animate, respond to hover, and update on interaction. In the final article, we'll explore the three-layer animation architecture, the event handling pipeline from DOM events to hover state, and the performance patterns that keep Chart.js responsive even with large datasets.