Read OSS

Scales, Elements, and the Rendering Pipeline: How Data Becomes Pixels

Advanced

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 base Scale class handles pixel range, padding, and reverse axes. When creating custom scales, override getPixelForValue() and call this.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.