Read OSS

Bringing Charts to Life: Animation System, Event Handling, and Interaction

Advanced

Prerequisites

  • Articles 1-5
  • Understanding of requestAnimationFrame and browser event model
  • Familiarity with easing functions and interpolation concepts

Bringing Charts to Life: Animation System, Event Handling, and Interaction

A static chart is a picture. An interactive, animated chart is an application. This final article covers the three systems that bring Chart.js charts to life: a three-layer animation architecture that smoothly transitions between states, an event handling pipeline that turns DOM events into hover states, and performance optimizations that keep everything responsive. These systems are interconnected—animation completion triggers hover re-evaluation, and interaction decisions depend on whether elements have reached their final positions.

Three-Layer Animation Architecture

Chart.js separates animation concerns into three distinct classes, each with a clearly defined responsibility:

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 handles a single property transition. Animations manages all pending animations for a chart. Animator is the global singleton that runs the requestAnimationFrame loop.

This separation means that adding a new animatable property requires no changes to the frame loop or orchestration logic—you just configure it in the Animations layer.

Per-Property Animation and Interpolation

The Animation class at its core is a property interpolator with easing:

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

The constructor at line 28 sets up the animation by resolving from and to values, selecting an interpolation function based on the value type, and configuring easing:

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

The tick() method at line 76 is called once per frame by the Animator. It computes elapsed time, applies easing, and writes the interpolated value directly to the target object: this._target[prop] = this._fn(from, to, factor). The element's properties are mutated in place—there's no virtual DOM or diffing.

Color interpolation uses the @kurkle/color library (Chart.js's only runtime dependency) to mix colors in RGBA space. The interpolator converts both colors to Color objects, calls mix(), and returns a hex string. This produces perceptually smooth color transitions.

The wait() method at line 105 returns a Promise that resolves when the animation completes. This is used by the shared options optimization in Animations._animateOptions()—when transitioning to shared options, the system waits for all option animations to complete before swapping to the shared reference.

The Animator Singleton and Frame Loop

The Animator manages the requestAnimationFrame loop for all charts simultaneously:

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

A critical design choice: the _refresh() method at line 38 checks if (this._request) return before scheduling a new frame. This means no matter how many charts call start(), only one requestAnimationFrame is ever pending. All charts are batched into a single frame callback, which is more efficient than separate rAF loops.

The _update() loop at line 57 iterates the _charts Map and ticks each chart's animations. When an animation item's _active flag becomes false, it's removed using the swap-and-pop technique (line 83-84) instead of splice(), maintaining O(1) removal cost.

When a chart's animation items array empties, the complete callback is fired. As we saw in Article 2, this callback is onAnimationsComplete, which calls chart.notifyPlugins('afterRender') and the user's animation.onComplete callback.

Event Handling Pipeline: DOM to Chart

Events flow through a multi-stage pipeline from the browser DOM to Chart.js's interaction resolution:

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

The bindUserEvents() method at line 976 iterates this.options.events (by default: mousemove, mouseout, click, touchstart, touchmove) and registers a listener for each via the platform. The platform normalizes touch and pointer events to mouse equivalents using the EVENT_TYPES mapping:

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

Tip: You can customize which events Chart.js listens for by setting options.events. If you don't need touch support, removing touchstart and touchmove reduces event handler overhead. If you only need click interaction, set events: ['click'].

Interaction Modes and Hit Testing

The Interaction module provides six built-in modes for determining which elements are "active" given a mouse position:

src/core/core.interaction.js#L22-L64

Mode Behavior
point Elements that contain the mouse point
nearest Single nearest element to mouse position
index All elements at the same index (vertical slice)
dataset All elements in the nearest dataset
x All elements at the same x position
y All elements at the same y position
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"]

The binarySearch() function at line 22 is the key performance optimization. For datasets where data is sorted along the index axis (which is the common case), it uses _lookupByKey to perform a binary search, reducing hit testing from O(n) to O(log n). For a chart with 10,000 points, this means ~14 comparisons instead of 10,000.

The binary search has an additional optimization for shared options: when controller._sharedOptions is set, it knows all elements have equal proportions. It can use the first element's getRange() to determine the hit-test boundary and perform a range-based binary search, finding all potentially intersecting elements in a single pass.

The Replay Mechanism and Animation-Aware Hover

The _handleEvent() method contains one of Chart.js's subtlest but most important UX details:

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

When update() completes its pipeline, it replays the last mouse event with replay = true at line 531. This replay calls _handleEvent(e, true), which sets useFinalPosition = true when querying active elements. As we explored in Article 5, Element.getProps() with final = true returns animation target values.

The comment block at lines 1199-1211 in the source explains the reasoning: evaluating active elements on every animation frame would be expensive, and during animation, elements haven't reached their final positions yet. By evaluating once with final positions after update(), Chart.js gets the correct hover state without per-frame recalculation.

The determineLastEvent() helper at line 93 handles edge cases: mouseout events clear the last event (no hover when mouse leaves), and click events preserve the previous last event (clicks shouldn't change the stored hover position).

Performance Patterns: Shared Options and Decimation

Two performance patterns deserve special attention because they span multiple systems:

Shared Options

When DatasetController resolves element options and finds that $shared: true (from the needContext() optimization in the config engine—see Article 3), it stores the options object as this._sharedOptions. All elements in the dataset reference this single object.

This has cascading benefits:

  1. Memory: One object instead of N objects (significant for large datasets)
  2. Animation: Animations._animateOptions() detects shared options and defers the swap until animations complete, using Promise.all on pending animations
  3. Interaction: The binary search optimization in core.interaction.js relies on _sharedOptions to know that all elements have equal proportions

LTTB Decimation

The Decimation plugin's LTTB algorithm at src/plugins/plugin.decimation.js#L3-L79 reduces data points while preserving visual shape:

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"]

The algorithm works by dividing data into buckets, computing the average point in the next bucket, and selecting the point from the current bucket that forms the largest triangle with the previous selected point and the next bucket's average. This preserves peaks, valleys, and trends while dramatically reducing point count.

The initialization of maxArea = area = -1 at line 57 (instead of the original algorithm's 1) is a bug fix specific to Chart.js: for flat traces where all triangles have zero area, the original code would never set nextA, causing a crash on the next iteration.

Series Conclusion

Over these six articles, we've traced Chart.js from its module organization through the complete data-to-pixel pipeline. We've seen how three carefully designed entry points serve different consumption patterns (Article 1), how the Chart constructor orchestrates a multi-stage update pipeline (Article 2), how a Proxy-based option resolver supports cascading defaults, scriptable options, and live fallback routes (Article 3), how the Registry and Plugin system enable extensibility without special-casing built-in components (Article 4), how Scales, Elements, and Controllers collaborate to transform data into Canvas geometry (Article 5), and how the animation and interaction systems bring it all to life (Article 6).

The overarching design philosophy is uniformity and composition: built-in chart types are registered components, not privileged code; options resolution is a generic scope-walking algorithm, not chart-type-specific logic; and the animation system is property-type-agnostic, working with any value that can be interpolated. This uniformity is what makes Chart.js genuinely extensible—not through escape hatches and overrides, but through the same mechanisms the library uses internally.