Skip to content

K 线图表系统架构详解:Chart → Controller → Pane → Renderer

一、架构概述

本 K 线图表系统采用分层架构设计,将渲染逻辑、交互逻辑、布局逻辑、图形绘制逻辑完全解耦。整个系统由四个核心层次组成:

  • Chart:全局控制器,负责渲染调度、坐标计算、视口管理
  • InteractionController:交互控制器,负责用户事件处理、命中检测、状态管理
  • PaneRenderer:面板渲染器,负责单个面板的 Canvas 管理、Y 轴绘制、渲染器链执行
  • Pane:窗口区域,负责布局管理、价格映射、渲染器管理(目前有 main 和 sub 两个 Pane 区域)
  • Renderer:注册在 Pane 下
ts
// 注册 Pane 渲染器
  chart.setPaneRenderers('main', [
    GridLinesRenderer,
    ExtremaMarkersRenderer,
    createMARenderer(props.showMA),
    CandleRenderer,
    LastPriceLineRenderer,
  ])
  chart.setPaneRenderers('sub', [GridLinesRenderer, subVolumeRenderer])

在此基础上,具体图形绘制由实现 PaneRenderer 接口的 Renderer 组件完成。


二、Chart 层详解

2.1 职责范围

Chart 类位于架构的最顶层,承担以下核心职责:

  1. 渲染调度:统一触发所有 PaneRenderer 的 draw () 方法
  2. 全局坐标计算:计算所有 K 线的 X 坐标数组,作为全局坐标源
  3. 物理像素配置:计算 DPR、奇数化 K 线宽度,确保高分屏绘制清晰
  4. 视口计算:根据容器尺寸和滚动位置计算 plotWidth、plotHeight
  5. 全局元素绘制:绘制时间轴、十字线、MA 图例、边框等跨面板元素
  6. Pane 生命周期管理:创建、初始化、销毁所有 PaneRenderer

2.2 核心方法

draw () - 主渲染循环

draw() {
    1. 计算视口
    2. 获取可见范围
    3. 计算 K 线位置数组
    4. 同步位置给交互控制器
    5. 遍历执行所有 PaneRenderer.draw()
    6. 绘制全局层(时间轴、十字线、图例、边框)
}

calcKLinePositions () - 全局坐标计算(物理像素对齐)

接收 VisibleRange,返回所有可见 K 线的起始 X 坐标数组(逻辑像素)。此数组是整个系统的统一坐标源,所有渲染器和交互控制器都使用此数组,确保视觉和交互的一致性。

核心原理:在物理像素空间计算整数坐标,再转回逻辑像素,消除亚像素渲染导致的模糊问题。

typescript
// 1. 物理像素间距(整数,统一)
const unitPx = Math.round((kWidth + kGap) * dpr)
const startXPx = Math.round(kGap * dpr)

for (let i = 0; i < count; i++) {
    const dataIndex = start + i
    // 2. 物理像素坐标(等间距整数)
    const leftPx = startXPx + dataIndex * unitPx
    // 3. 转回逻辑坐标
    positions[i] = leftPx / dpr
}

为什么这样做

  • unitPx 是整数,K 线间距完全一致,无累积误差
  • leftPx 是整数,绘制时 positions[i] × dpr 回到整数物理像素
  • Canvas 渲染在整数物理像素位置,避免亚像素模糊

getKLinePhysicalConfig () - 物理像素配置

返回物理像素配置对象,包含:

  • kWidthPx:奇数化后的 K 线物理像素宽度
  • kGapPx:K 线间隙物理像素宽度
  • unitPx:单根 K 线占用的物理像素总宽度
  • 以及对应的逻辑像素值

computeViewport () - 视口计算

根据 DOM 容器的 getBoundingClientRect () 和 scrollLeft 计算:

  • DPR(设备像素比,超过 16 MP 自动降级)
  • plotWidth(绘图区域宽度)
  • plotHeight(绘图区域高度)
  • 设置所有 Canvas 的 width/height 属性和 CSS 尺寸

2.3 组件管理

Chart 内部维护:

  • paneRenderers: PaneRenderer[] - 所有面板渲染器实例
  • interaction: InteractionController - 交互控制器实例
  • markerManager: MarkerManager - 标记管理器实例

这些组件在构造函数中创建,通过依赖注入方式关联。


三、InteractionController 层详解

3.1 职责范围

InteractionController 负责所有用户交互逻辑:

  1. 事件处理:处理鼠标/触控的 down、move、up、leave、wheel、scroll 事件
  2. 命中检测:检测鼠标位置是否命中 K 线或 Marker
  3. 状态管理:维护十字线位置、hover 索引、activePaneId、tooltip 位置
  4. 拖拽逻辑:处理拖拽滚动、防止触控模拟鼠标事件冲突
  5. Marker 交互:处理 Marker 的 hover、click 回调

3.2 核心状态

  • crosshairPos: {x, y} | null - 十字线位置
  • crosshairIndex: number | null - 十字线指向的 K 线索引
  • hoveredIndex: number | null - 鼠标悬停的 K 线索引
  • activePaneId: string | null - 当前活跃的 Pane ID
  • kLinePositions: number[] | null - 当前帧的 K 线位置数组
  • visibleRange: {start, end} | null - 当前帧的可见范围
  • isDragging: boolean - 是否正在拖拽
  • isTouchSession: boolean - 是否处于触控会话

3.3 核心方法

updateHoverFromPoint () - 鼠标位置更新

根据屏幕坐标(clientX, clientY)更新交互状态:

  1. 计算鼠标在 container 内的局部坐标(mouseX, mouseY)
  2. 检查是否在绘图区域内(plotWidth × plotHeight)
  3. 优先检测 Marker 命中(使用 worldX = scrollLeft + mouseX)
  4. 在物理像素空间计算 K 线索引(与 calcKLinePositions 一致):
    // 获取物理像素配置
    const { unitPx, startXPx } = getKLinePhysicalConfig()
    
    // 在物理像素空间计算索引
    worldXPx = worldX * dpr
    offsetPx = worldXPx - startXPx
    idx = Math.floor(offsetPx / unitPx)
    这样保证了交互定位与渲染位置的完全对齐。
  5. 使用预计算的 kLinePositions 计算十字线 X 坐标,确保与渲染完全对齐:
    kLineStartX = kLinePositions[idx - range.start]
    snappedX = kLineStartX + (kWidthPx - 1) / 2 / dpr - scrollLeft
  6. 判定鼠标落在哪个 Pane(根据 mouseY 和 pane. top/height)
  7. 执行 K 线命中检测(body 或 wick),更新 hoveredIndex
  8. 计算 tooltip 位置,防止溢出容器边界

setKLinePositions () - 同步渲染坐标

在每帧绘制开始时,Chart 调用此方法将最新的 kLinePositions 和 visibleRange 传递给交互控制器,确保命中检测使用与渲染完全一致的坐标。

onPointerDown/Move/Up/Leave - 触控事件处理

  • 只处理主指针(e.isPrimary === true)
  • 标记触控会话,避免触控模拟的鼠标事件干扰
  • 优先检测 Marker 点击,命中则触发回调并返回
  • 未命中 Marker 则开始拖拽,记录 dragStartX 和 scrollStartX

onMouseDown/Move/Up/Leave - 鼠标事件处理

  • 触控会话中忽略模拟的鼠标事件
  • 优先检测 Marker 点击
  • 拖拽时更新 container. scrollLeft
  • 非拖拽时调用 updateHover ()

onScroll () - 滚动事件处理

  • 清空 kLinePositions 和 visibleRange,避免使用过期数据
  • 清除 hover 状态
  • 触发 Chart 重绘

四、PaneRenderer 层详解

4.1 职责范围

PaneRenderer 是单个面板的渲染管理器,负责:

  1. Canvas 管理:管理 plotCanvas(绘图层)和 yAxisCanvas(Y 轴层)两个 Canvas
  2. 渲染调度:执行 Pane 内部的渲染器链
  3. Y 轴绘制:绘制右侧价格轴刻度
  4. 十字线标签:绘制十字线在当前 Pane 的价格标签
  5. 标题绘制:绘制 Pane 标题(如 "VOL - 成交量")

4.2 Canvas 分层

  • plotCanvas:绘制 K 线、MA、网格等图形内容
  • yAxisCanvas:绘制右侧价格轴、十字线价格标签

两个 Canvas 叠叠显示,plotCanvas 在左,yAxisCanvas 在右。

4.3 渲染流程

typescript
draw(args) {
    1. 更新 Pane 价格范围(pane.updateRange)
    2. 清空两个 Canvas
    3. 设置 Canvas 缩放(scale(dpr, dpr))
    4. 执行渲染器链(pane.renderers)
    5. 绘制 Y 轴刻度
    6. 绘制十字线价格标签(如果有十字线)
    7. 绘制 Pane 标题(如果有)
}

4.4 resize () - Canvas 尺寸调整

根据传入的 width、height、dpr 设置:

  • Canvas 元素的 width/height 属性(物理像素)
  • Canvas 元素的 style. width/style. height(逻辑像素)
  • yAxisCanvas 的宽度为 rightAxisWidth + priceLabelWidth

4.5 与 Pane 的关系

PaneRenderer 内部持有一个 Pane 实例,通过 this.pane 访问:

  • pane.yAxis.priceToY(price) - 价格转 Y 坐标
  • pane.toppane.height - 布局信息
  • pane.renderers - 渲染器链

五、Pane 层详解

5.1 职责范围

Pane 代表一个窗口区域(主图或副图),负责:

  1. 布局管理:存储 top 和 height 位置信息
  2. 价格映射:通过 yAxis 实现 priceToY 坐标转换
  3. 渲染器链:管理该 Pane 的所有子渲染器
  4. 价格范围更新:根据可见数据范围计算价格区间

5.2 核心属性

  • id: string - Pane 标识符('main' 或 'sub')
  • top: number - 相对 plotCanvas 顶部的偏移
  • height: number - Pane 高度
  • priceRange: PriceRange - 当前可见价格范围({maxPrice, minPrice})
  • yAxis: PriceScale - 价格轴实例
  • renderers: PaneRenderer[] - 渲染器链

5.3 核心方法

setLayout (top, height)

设置 Pane 的垂直布局,同时更新 yAxis 的高度。

setPadding (top, bottom)

设置 Y 轴上下内边距,影响价格映射的顶部/底部留白。

addRenderer (renderer)

注册一个渲染器到渲染器链。

updateRange (data, range)

根据当前可见的索引范围计算价格区间,并同步到 yAxis:

typescript
this.priceRange = getVisiblePriceRange(data, range.start, range.end)
this.yAxis.setRange(this.priceRange)

5.4 PriceScale(Y 轴)

Pane 内部的 yAxis 是 PriceScale 实例,提供:

  • setHeight(height) - 设置高度
  • setPadding(top, bottom) - 设置内边距
  • setRange(priceRange) - 设置价格范围
  • priceToY(price) - 价格转 Y 坐标(核心映射函数)

六、Renderer 层详解

6.1 PaneRenderer 接口

所有具体渲染器都必须实现此接口:

typescript
interface PaneRenderer {
    draw(args: {
        ctx: CanvasRenderingContext2D,
        pane: Pane,
        data: KLineData[],
        range: VisibleRange,
        scrollLeft: number,
        kWidth: number,
        kGap: number,
        dpr: number,
        paneWidth: number,
        kLinePositions: number[],
        markerManager: MarkerManager
    }): void
}

6.2 常见 Renderer

Renderer职责调用时机
CandleRenderer绘制 K 线蜡烛图、影线、量价标记渲染器链
GridRenderer绘制网格线渲染器链第一位
MARenderer绘制均线(MA 5、MA 10...)渲染器链
YAxisRenderer绘制右侧价格轴PaneRenderer.draw ()
TimeAxis绘制底部时间轴Chart.draw ()
Crosshair绘制十字线Chart.draw ()

6.3 CandleRenderer 示例

CandleRenderer 的 draw () 方法执行流程:

  1. 获取物理像素配置:const {kWidthPx} = getPhysicalKLineConfig(kWidth, kGap, dpr)
  2. 设置变换:ctx.translate(-scrollLeft, 0) 进入世界坐标系
  3. 遍历可见 K 线:
    • 使用 kLinePositions[i - range.start] 获取 X 坐标
    • 使用 pane.yAxis.priceToY() 获取 open、close、high、low 的 Y 坐标
    • 调用 createAlignedKLineFromPx() 进行物理像素对齐
    • 绘制实体矩形和影线
  4. 绘制量价关系标记(如果需要)
  5. 恢复变换:ctx.restore()

6.4 渲染器链顺序

Pane 的渲染器链按顺序执行,后绘制的覆盖先绘制的:

GridRenderer → CandleRenderer → MARenderer
    ↓              ↓                ↓
  网格(底层)    K线              均线(上层)

七、数据流转

7.1 渲染数据流

外部数据 (K线数组)

Chart.updateData(data)

Chart.draw()

    ├─→ computeViewport() → Viewport
    ├─→ getVisibleRange() → VisibleRange
    ├─→ calcKLinePositions() → kLinePositions[]

    └─→ 遍历 PaneRenderer

            PaneRenderer.draw()

                ├─→ pane.updateRange() → priceRange
                ├─→ 遍历 pane.renderers
                │       ↓
                │       Renderer.draw({kLinePositions, pane, data, ...})

                └─→ YAxisRenderer.draw()

7.2 交互数据流

用户事件

InteractionController.onMouseMove()

updateHoverFromPoint(clientX, clientY)

    ├─→ 计算 mouseX, mouseY, worldX
    ├─→ Marker 命中检测(markerManager.hitTest)
    ├─→ 计算 K 线索引(使用与 Chart 相同的算法)
    ├─→ 使用 kLinePositions 计算 snappedX
    ├─→ 判定 activePane
    ├─→ K 线命中检测
    └─→ 更新状态 → Chart.scheduleDraw()

7.3 坐标系转换

屏幕坐标

容器相对坐标

世界坐标

逻辑像素坐标

物理像素坐标

八、交互流程

8.1 完整交互流程

  1. 用户移动鼠标
  2. InteractionController.onMouseMove ()
  3. updateHoverFromPoint ()
    • 计算局部坐标
    • Marker 命中检测
    • 计算 K 线索引
    • 计算 snappedX
    • 判定 activePane
    • K 线命中检测
  4. 更新状态(crosshairPos、crosshairIndex、hoveredIndex 等)
  5. Chart.scheduleDraw ()
  6. 下一帧 RAF → Chart.draw ()
  7. 所有组件使用最新状态渲染

8.2 拖拽流程

  1. 用户按下鼠标
  2. InteractionController.onPointerDown ()
  3. Marker 命中检测,未命中则开始拖拽
  4. 设置 isDragging = true,记录 dragStartX 和 scrollStartX
  5. 用户拖动鼠标
  6. InteractionController.onPointerMove ()
  7. 更新 container. scrollLeft = scrollStartX + (dragStartX - clientX)
  8. 自动触发 onScroll 事件
  9. Chart.scheduleDraw ()
  10. 用户释放鼠标
  11. InteractionController.onPointerUp ()
  12. 设置 isDragging = false

九、扩展性设计

9.1 添加新 Renderer

typescript
// 1. 实现 PaneRenderer 接口
export const MyRenderer: PaneRenderer = {
    draw (args) {
        // 绘制逻辑
    }
}

// 2. 注册到 Pane
pane.addRenderer (MyRenderer)

9.2 添加新 Pane

typescript
// 1. 配置新 Pane
const opt: ChartOptions = {
    panes: [
        { id: 'main', ratio: 0.7 },
        { id: 'sub', ratio: 0.3 },
        { id: 'indicator', ratio: 0.2 }
    ]
}

// 2. Chart.initPanes () 自动创建 PaneRenderer
// 3. 添加渲染器
const renderer = chart.getPaneRenderers (). find (r => r.getPane (). id === 'indicator')
const pane = renderer.getPane ()
pane.addRenderer (MyIndicatorRenderer)

9.3 添加新 Marker

typescript
// 在 Renderer 中注册
markerManager.register ({
    id: 'mk_1',
    type: 'triangle',
    x, y, width, height,
    dataIndex: i,
    metadata: { ... }
})

// 自动支持 hover/click

十、关键技术点总结

10.1 统一坐标源

Chart.calcKLinePositions () 计算的 kLinePositions 数组是整个系统的统一坐标源,所有 Renderer 和 InteractionController 都使用此数组,确保视觉和交互完全对齐。

10.2 物理像素对齐(消除亚像素渲染)

问题背景:当 逻辑坐标 × dpr 不是整数时,Canvas 会在两个物理像素之间进行插值,导致渲染模糊。

解决方案:所有坐标先在物理像素空间计算为整数,再转回逻辑像素。

┌────────────────────────────────────────────────────────────┐
│  核心原则:物理像素空间整数运算 → 转回逻辑像素给 Canvas API  │
└────────────────────────────────────────────────────────────┘

Step 1: 物理像素参数(保证整数)
─────────────────────────────
  unitPx = Math.round((kWidth + kGap) * dpr)   ← 整数间距
  startXPx = Math.round(kGap * dpr)            ← 整数起始

Step 2: K 线坐标计算
───────────────────
  for (i = 0; i < count; i++) {
    leftPx = startXPx + i * unitPx    ← 整数 + 整数×整数 = 整数
    positions[i] = leftPx / dpr       ← 转回逻辑像素
  }

Step 3: 渲染时
─────────────
  Canvas 设置 ctx.scale(dpr, dpr)
  ctx.fillRect(positions[i], y, w, h)

  实际绘制: positions[i] × dpr = leftPx (整数)
  → 物理像素坐标是整数,无亚像素模糊

K 线定位:交互控制器也在物理像素空间计算索引,确保十字线精确指向鼠标所在的 K 线。

typescript
// 物理像素空间计算索引
worldXPx = worldX * dpr
offsetPx = worldXPx - startXPx
idx = Math.floor(offsetPx / unitPx)  // 整数除法,结果精确

10.3 RAF 性能优化

使用 requestAnimationFrame 合并多次渲染请求,避免频繁重绘。

10.4 可见区域渲染

只渲染 range. start 到 range. end 的 K 线,大幅提升性能。

10.5 Canvas 分层

plotCanvas、yAxisCanvas、xAxisCanvas、borderCanvas 分层,减少重绘面积。

10.6 事件代理

InteractionController 集中处理所有用户事件,避免各组件各自监听导致的状态不一致。

10.7 渲染器链

通过 renderers 数组按顺序执行,后绘制的覆盖先绘制的,支持灵活组合。

10.8 依赖注入

Chart 通过构造函数传入 DOM、配置,PaneRenderer 通过构造函数传入 DOM、Pane、配置,降低耦合。


十一、架构优势

  1. 解耦性高:Chart、PaneRenderer、Pane、Renderer 各司其职,修改某一层不影响其他层
  2. 可扩展性强:通过实现 PaneRenderer 接口即可添加新渲染器
  3. 性能优秀:RAF 合并、可见区域渲染、物理像素对齐
  4. 交互精确:统一坐标源确保命中检测和渲染完全一致
  5. 维护性好:单一职责原则,每个模块职责清晰
  6. 可测试性高:各模块独立,易于单元测试

十二、架构层次关系图

Chart (全局控制器)
    ├─→ InteractionController (交互控制器)
    │       └─→ 使用 kLinePositions 命中检测

    ├─→ MarkerManager (标记管理)
    │       └─→ 注册、命中检测 Marker

    └─→ PaneRenderer[] (面板渲染器列表)

            └─→ Pane (窗口区域)
                    ├─→ yAxis (价格轴)
                    │       └─→ priceToY ()

                    └─→ renderers[] (渲染器链)
                            ├─→ GridRenderer
                            ├─→ CandleRenderer
                            └─→ MARenderer

十三、完整调用链

Vue 组件

KLineChart. vue

Chart.updateData (data)

Chart.draw ()

    ├─→ computeViewport () → Viewport

    ├─→ getVisibleRange () → VisibleRange

    ├─→ calcKLinePositions () → kLinePositions[]

    ├─→ interaction.setKLinePositions (kLinePositions, range)

    ├─→ 遍历 paneRenderers
    │       ↓
    │       PaneRenderer.draw ()
    │           ↓
    │           ├─→ pane.updateRange (data, range)
    │           ├─→ plotCtx.scale (dpr, dpr)
    │           ├─→ yAxisCtx.scale (dpr, dpr)
    │           ├─→ 遍历 pane. renderers
    │           │       ↓
    │           │       Renderer.draw ({
    │           │           ctx: plotCtx,
    │           │           pane: pane,
    │           │           data: data,
    │           │           range: range,
    │           │           kLinePositions: kLinePositions,
    │           │           ...
    │           │       })
    │           │
    │           ├─→ YAxisRenderer.draw ()
    │           └─→ drawCrosshairPriceLabelForPane ()

    ├─→ drawTimeAxisLayer ({kLinePositions, ...})
    ├─→ drawCrosshair ({kLinePositions, ...})
    ├─→ drawMALegend ()
    └─→ drawAllPanesBorders ()

此架构通过清晰的层次划分和职责分离,实现了一个高性能、可扩展、易维护的 K 线图表系统。