Read OSS

比例尺、元素与渲染管线:数据如何变成像素

高级

前置知识

  • 第 1–4 篇文章
  • 对坐标系和坐标轴缩放有基本了解
  • 熟悉 HTML5 Canvas 绘图 API(fillRect、arc、lineTo)

比例尺、元素与渲染管线:数据如何变成像素

所有图表库都面临同一个核心挑战:把 [12, 19, 3, 5, 2, 3] 这样一组数字,转化为 Canvas 上特定像素坐标处的几何图形。Chart.js 通过三个组件的协作来解决这个问题:Scale 负责将数据值映射到像素坐标,DatasetController 负责编排数据解析和元素创建,Element 负责封装几何形状和 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(或不传)时,它直接返回 this,即属性的当前值。当传入 final = true 时,它会逐个检查请求的属性是否存在于 $animations 中:若该属性正在执行动画,则返回动画的目标值(_to),而不是当前的插值状态。

这种双重行为对第 2 篇文章中介绍的悬停重放机制至关重要。当 update()useFinalPosition = true 重放上一次鼠标事件时,元素汇报的是动画完成后的最终位置,而非过渡途中的当前位置。这确保了 tooltip 和悬停样式能够正确地附着到动画结束后的目标位置。

具体元素类型:Arc、Bar、Line、Point

每种元素类型都实现了各自的几何逻辑、命中检测和 Canvas 绘制:

ArcElement 的几何实现最为复杂。其 draw() 方法需要处理环形图扇区的圆角、内外半径弧线,以及基于裁剪的边框渲染。第 7 行的 clipSelf() 函数使用 evenodd 裁剪规则,通过裁剪弧线路径并绘制双倍宽度的边框来实现内边框效果:

src/elements/element.arc.ts#L1-L38

BarElement 处理支持可选圆角的矩形柱子。其 inRange() 方法执行简单的边界检测,而 draw() 方法则需要应对圆角矩形和堆叠柱状偏移等复杂情况。

PointElement 支持多种形状样式(circlecrosscrossRotdashlinerectrectRoundedrectRotstartriangle),每种样式都有独立的 Canvas 路径构建实现。

LineElement 在所有元素中独树一帜:它是数据集级别的元素,而不是每个数据点对应一个元素。它存储一组点和线段,并处理线端样式、连接方式以及用于贝塞尔曲线平滑的 tension 属性。

Scale 的架构与生命周期

Scale 类继承自 Element(它有位置和尺寸信息),同时也实现了 LayoutItem 接口(它作为一个盒子参与布局系统)。这种双重角色设计十分巧妙:Scale 既是坐标变换器,也是拥有独立绘制调用的视觉组件。

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

Scale 的生命周期在 update() 期间运行:首先由 buildOrUpdateScales() 对每个 Scale 调用 init(),然后布局引擎调用 update(),进而触发 fit()。正如第 2 篇文章所介绍的,布局引擎会迭代调用 fit() 方法,直到尺寸稳定为止。

数据管线中最重要的两个方法是 getPixelForValue(value)getValueForPixel(pixel),它们共同构成了数据空间与像素空间之间的双射映射。

Scale 的各种实现:Linear、Log、Time、Category、Radial

每种 Scale 类型都会重写 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() 处理,因此各专用 Scale 只需负责将数据空间归一化到 [0, 1] 区间即可。

TimeScale 引入了适配器模式:它将日期解析和格式化委托给可插拔的 DateAdapter,使用者可以自由选择日期库(Luxon、Moment、date-fns),而 Chart.js 本身不依赖其中任何一个。

提示: 基类 Scale 上的 getPixelForDecimal(decimal) / getDecimalForPixel(pixel) 方法已处理好像素范围、内边距和反转坐标轴等逻辑。开发自定义 Scale 时,只需重写 getPixelForValue(),并将归一化后的 [0, 1] 值传给 this.getPixelForDecimal() 即可——不要直接计算像素坐标。

DatasetController:连接数据与元素的桥梁

DatasetController 基类是原始数据、Scale 和 Element 之间的粘合层,负责数据解析、元素创建、堆叠处理,以及关键的共享选项优化:

src/core/core.datasetController.js#L1-L100

共享选项模式是 Chart.js 最具影响力的性能优化之一。在为元素解析选项时,DatasetController 会调用配置引擎(第 3 篇文章)中的 resolveNamedOptions()。如果结果包含 $shared: true(表示未检测到可脚本化或可索引的选项),数据集中所有元素将共享同一个选项对象引用。对于拥有 10,000 个数据点的折线图,这意味着只需要一个选项对象,而不是 10,000 个。

BarControllerLineController 等图表类型控制器通过继承 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() 阶段,控制器向各 Scale 查询像素位置,根据布局计算柱子宽度,并更新元素属性。在 draw() 阶段,每个元素读取自身属性(若正在执行动画则读取插值结果),并发出 Canvas 绘制指令。

这里的职责划分非常清晰:控制器只关心数据结构和 Scale 映射,元素只关心几何形状和 Canvas 绘制,Scale 只关心数值到像素的变换。三者各司其职,互不干涉内部实现。

通往下一篇

至此,我们已经完整追踪了数据到静态像素的路径。但图表并不是静止的——它们会播放动画、响应悬停,并随交互更新。在最后一篇文章中,我们将深入探讨三层动画架构、从 DOM 事件到悬停状态的事件处理管线,以及让 Chart.js 在大数据集下依然保持流畅响应的性能设计模式。