K 线图表系统架构详解:Chart → Controller → Pane → Renderer
一、架构概述
本 K 线图表系统采用分层架构设计,将渲染逻辑、交互逻辑、布局逻辑、图形绘制逻辑完全解耦。整个系统由四个核心层次组成:
- Chart:全局控制器,负责渲染调度、坐标计算、视口管理
- InteractionController:交互控制器,负责用户事件处理、命中检测、状态管理
- PaneRenderer:面板渲染器,负责单个面板的 Canvas 管理、Y 轴绘制、渲染器链执行
- Pane:窗口区域,负责布局管理、价格映射、渲染器管理(目前有 main 和 sub 两个 Pane 区域)
- Renderer:注册在 Pane 下
// 注册 Pane 渲染器
chart.setPaneRenderers('main', [
GridLinesRenderer,
ExtremaMarkersRenderer,
createMARenderer(props.showMA),
CandleRenderer,
LastPriceLineRenderer,
])
chart.setPaneRenderers('sub', [GridLinesRenderer, subVolumeRenderer])在此基础上,具体图形绘制由实现 PaneRenderer 接口的 Renderer 组件完成。
二、Chart 层详解
2.1 职责范围
Chart 类位于架构的最顶层,承担以下核心职责:
- 渲染调度:统一触发所有 PaneRenderer 的 draw () 方法
- 全局坐标计算:计算所有 K 线的 X 坐标数组,作为全局坐标源
- 物理像素配置:计算 DPR、奇数化 K 线宽度,确保高分屏绘制清晰
- 视口计算:根据容器尺寸和滚动位置计算 plotWidth、plotHeight
- 全局元素绘制:绘制时间轴、十字线、MA 图例、边框等跨面板元素
- Pane 生命周期管理:创建、初始化、销毁所有 PaneRenderer
2.2 核心方法
draw () - 主渲染循环
draw() {
1. 计算视口
2. 获取可见范围
3. 计算 K 线位置数组
4. 同步位置给交互控制器
5. 遍历执行所有 PaneRenderer.draw()
6. 绘制全局层(时间轴、十字线、图例、边框)
}calcKLinePositions () - 全局坐标计算(物理像素对齐)
接收 VisibleRange,返回所有可见 K 线的起始 X 坐标数组(逻辑像素)。此数组是整个系统的统一坐标源,所有渲染器和交互控制器都使用此数组,确保视觉和交互的一致性。
核心原理:在物理像素空间计算整数坐标,再转回逻辑像素,消除亚像素渲染导致的模糊问题。
// 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 负责所有用户交互逻辑:
- 事件处理:处理鼠标/触控的 down、move、up、leave、wheel、scroll 事件
- 命中检测:检测鼠标位置是否命中 K 线或 Marker
- 状态管理:维护十字线位置、hover 索引、activePaneId、tooltip 位置
- 拖拽逻辑:处理拖拽滚动、防止触控模拟鼠标事件冲突
- Marker 交互:处理 Marker 的 hover、click 回调
3.2 核心状态
crosshairPos: {x, y} | null- 十字线位置crosshairIndex: number | null- 十字线指向的 K 线索引hoveredIndex: number | null- 鼠标悬停的 K 线索引activePaneId: string | null- 当前活跃的 Pane IDkLinePositions: number[] | null- 当前帧的 K 线位置数组visibleRange: {start, end} | null- 当前帧的可见范围isDragging: boolean- 是否正在拖拽isTouchSession: boolean- 是否处于触控会话
3.3 核心方法
updateHoverFromPoint () - 鼠标位置更新
根据屏幕坐标(clientX, clientY)更新交互状态:
- 计算鼠标在 container 内的局部坐标(mouseX, mouseY)
- 检查是否在绘图区域内(plotWidth × plotHeight)
- 优先检测 Marker 命中(使用 worldX = scrollLeft + mouseX)
- 在物理像素空间计算 K 线索引(与 calcKLinePositions 一致):这样保证了交互定位与渲染位置的完全对齐。
// 获取物理像素配置 const { unitPx, startXPx } = getKLinePhysicalConfig() // 在物理像素空间计算索引 worldXPx = worldX * dpr offsetPx = worldXPx - startXPx idx = Math.floor(offsetPx / unitPx) - 使用预计算的 kLinePositions 计算十字线 X 坐标,确保与渲染完全对齐:
kLineStartX = kLinePositions[idx - range.start] snappedX = kLineStartX + (kWidthPx - 1) / 2 / dpr - scrollLeft - 判定鼠标落在哪个 Pane(根据 mouseY 和 pane. top/height)
- 执行 K 线命中检测(body 或 wick),更新 hoveredIndex
- 计算 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 是单个面板的渲染管理器,负责:
- Canvas 管理:管理 plotCanvas(绘图层)和 yAxisCanvas(Y 轴层)两个 Canvas
- 渲染调度:执行 Pane 内部的渲染器链
- Y 轴绘制:绘制右侧价格轴刻度
- 十字线标签:绘制十字线在当前 Pane 的价格标签
- 标题绘制:绘制 Pane 标题(如 "VOL - 成交量")
4.2 Canvas 分层
- plotCanvas:绘制 K 线、MA、网格等图形内容
- yAxisCanvas:绘制右侧价格轴、十字线价格标签
两个 Canvas 叠叠显示,plotCanvas 在左,yAxisCanvas 在右。
4.3 渲染流程
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.top、pane.height- 布局信息pane.renderers- 渲染器链
五、Pane 层详解
5.1 职责范围
Pane 代表一个窗口区域(主图或副图),负责:
- 布局管理:存储 top 和 height 位置信息
- 价格映射:通过 yAxis 实现 priceToY 坐标转换
- 渲染器链:管理该 Pane 的所有子渲染器
- 价格范围更新:根据可见数据范围计算价格区间
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:
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 接口
所有具体渲染器都必须实现此接口:
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 () 方法执行流程:
- 获取物理像素配置:
const {kWidthPx} = getPhysicalKLineConfig(kWidth, kGap, dpr) - 设置变换:
ctx.translate(-scrollLeft, 0)进入世界坐标系 - 遍历可见 K 线:
- 使用
kLinePositions[i - range.start]获取 X 坐标 - 使用
pane.yAxis.priceToY()获取 open、close、high、low 的 Y 坐标 - 调用
createAlignedKLineFromPx()进行物理像素对齐 - 绘制实体矩形和影线
- 使用
- 绘制量价关系标记(如果需要)
- 恢复变换:
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 完整交互流程
- 用户移动鼠标
- InteractionController.onMouseMove ()
- updateHoverFromPoint ()
- 计算局部坐标
- Marker 命中检测
- 计算 K 线索引
- 计算 snappedX
- 判定 activePane
- K 线命中检测
- 更新状态(crosshairPos、crosshairIndex、hoveredIndex 等)
- Chart.scheduleDraw ()
- 下一帧 RAF → Chart.draw ()
- 所有组件使用最新状态渲染
8.2 拖拽流程
- 用户按下鼠标
- InteractionController.onPointerDown ()
- Marker 命中检测,未命中则开始拖拽
- 设置 isDragging = true,记录 dragStartX 和 scrollStartX
- 用户拖动鼠标
- InteractionController.onPointerMove ()
- 更新 container. scrollLeft = scrollStartX + (dragStartX - clientX)
- 自动触发 onScroll 事件
- Chart.scheduleDraw ()
- 用户释放鼠标
- InteractionController.onPointerUp ()
- 设置 isDragging = false
九、扩展性设计
9.1 添加新 Renderer
// 1. 实现 PaneRenderer 接口
export const MyRenderer: PaneRenderer = {
draw (args) {
// 绘制逻辑
}
}
// 2. 注册到 Pane
pane.addRenderer (MyRenderer)9.2 添加新 Pane
// 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
// 在 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 线。
// 物理像素空间计算索引
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、配置,降低耦合。
十一、架构优势
- 解耦性高:Chart、PaneRenderer、Pane、Renderer 各司其职,修改某一层不影响其他层
- 可扩展性强:通过实现 PaneRenderer 接口即可添加新渲染器
- 性能优秀:RAF 合并、可见区域渲染、物理像素对齐
- 交互精确:统一坐标源确保命中检测和渲染完全一致
- 维护性好:单一职责原则,每个模块职责清晰
- 可测试性高:各模块独立,易于单元测试
十二、架构层次关系图
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 线图表系统。