实现双副图同时显示
Context
用户希望在 K 线图中同时显示两个副图(如 MACD + RSI),而非当前的互斥切换模式。现有架构已支持多 pane 设计,但 UI 层实现了互斥逻辑,需要改造为允许多副图同时显示。
关键改动点
1. 扩展 Pane 配置(从 2 个到 3 个)
文件: KLineChart.vue
修改 panes 配置,新增 sub2 pane:
const panes: PaneSpec[] = [
{ id: 'main', ratio: 0.6 }, // 主图
{ id: 'sub', ratio: 0.2 }, // 副图1
{ id: 'sub2', ratio: 0.2 }, // 副图2
]需要同步修改 paneRatios props 类型定义。
2. 支持渲染器动态绑定 paneId
方案: 为副图指标渲染器添加 paneId 参数
需要修改的渲染器文件:
- macd.ts -
createMACDRendererPlugin - rsi.ts -
createRSIRendererPlugin - cci.ts -
createCCIRendererPlugin - stoch.ts -
createStochRendererPlugin - mom.ts -
createMomRendererPlugin - wmsr.ts -
createWmsrRendererPlugin - kst.ts -
createKstRendererPlugin - fastk.ts -
createFastkRendererPlugin
每个渲染器工厂函数添加可选 paneId 参数:
export function createMACDRendererPlugin(paneId: string = 'sub'): RendererPlugin {
return {
name: `macd_${paneId}`,
paneId: paneId,
// ...
}
}3. 重构指标注册逻辑
文件: KLineChart.vue
当前所有副图指标在 createChart 时注册到 sub pane。需要:
- 为每个副图指标注册两个实例(一个到
sub,一个到sub2) - 或者改为动态注册/注销机制
4. 修改 UI 交互逻辑(自动分配模式)
文件: KLineChart.vue
移除副图指标的互斥逻辑(第 350-375 行),改为自动分配:
- 第一个选择的副图指标 → 分配到
sub - 第二个选择的副图指标 → 分配到
sub2 - 已有两个副图指标时,替换最后一个选中的(或让用户选择替换哪个)
5. 更新 paneTitle 渲染
文件: paneTitle.ts
当前副图标题是固定绑定到 sub pane。需要为 sub2 也创建独立的标题渲染器。
实现步骤
Phase 1: 基础架构改动
文件: chart.ts
- 扩展
PaneSpec类型,添加visible字段 - 修改
layoutPanes()方法:- 过滤
visible: false的 pane - 修改硬编码的
'sub'判断为pane.id !== 'main'
- 过滤
- 添加
updatePaneLayout(panes: PaneSpec[])方法 - 暴露
unregisterRenderer(name: string)方法
文件: pane.ts
- 无需修改(现有
Pane类已满足需求)
Phase 2: 指标数据管理器
新建文件: indicatorDataManager.ts
- 实现
IndicatorDataManager类 - 提供缓存 key 生成规则:
{indicator}_{param1}_{param2}_... - 集成到
Chart类,通过RenderContext传递
Phase 3: 渲染器改造
修改文件列表:
改造内容:
// 改造前
export function createMACDRendererPlugin(): RendererPlugin {
return {
name: 'macd',
paneId: 'sub',
// ...
}
}
// 改造后
export interface MACDRendererOptions {
paneId?: 'sub' | 'sub2'
}
export function createMACDRendererPlugin(options: MACDRendererOptions = {}): RendererPlugin {
const paneId = options.paneId ?? 'sub'
return {
name: `macd_${paneId}`,
paneId,
// ...
}
}Phase 4: UI 状态管理
文件: KLineChart.vue
- 新增状态:
const subIndicatorSlots = ref<{
sub: SubIndicatorInstance | null
sub2: SubIndicatorInstance | null
}>({ sub: null, sub2: null })- 新增计算属性:
const layoutPanes = computed<PaneSpec[]>(() => {
// 根据槽位状态动态生成布局配置
})修改
handleIndicatorToggle逻辑:- 区分主图指标和副图指标
- 副图指标使用槽位分配逻辑
修改
handleUpdateParams逻辑:- 支持指定 paneId 更新参数
Phase 5: 渲染器注册改造
文件: KLineChart.vue
- 预注册两套副图指标渲染器:
// sub 副图的渲染器
chart.useRenderer(createMACDRendererPlugin({ paneId: 'sub' }))
chart.useRenderer(createRSIRendererPlugin({ paneId: 'sub' }))
// ... 其他指标
// sub2 副图的渲染器
chart.useRenderer(createMACDRendererPlugin({ paneId: 'sub2' }))
chart.useRenderer(createRSIRendererPlugin({ paneId: 'sub2' }))
// ... 其他指标
// 默认全部禁用
['macd_sub', 'rsi_sub', ..., 'macd_sub2', 'rsi_sub2', ...].forEach(name => {
chart.setRendererEnabled(name, false)
})- 创建两个副图标题渲染器:
chart.useRenderer(createPaneTitleRendererPlugin({
paneId: 'sub',
title: 'VOL',
getTitleInfo: () => getSubPaneTitleInfo('sub'),
}))
chart.useRenderer(createPaneTitleRendererPlugin({
paneId: 'sub2',
title: '',
getTitleInfo: () => getSubPaneTitleInfo('sub2'),
}))Phase 6: 测试验证
- 单元测试:
IndicatorDataManager缓存逻辑 - 集成测试:副图指标分配与移除
- 视觉测试:
- 单副图布局
- 双副图布局
- 十字线同步
- 参数面板响应
风险分析与解决方案
🔴 高风险:Pane 动态增删支持
现状分析:
- Chart 类在初始化时调用
initPanes()创建所有 pane layoutPanes()中硬编码了pane.id === 'sub'的判断- 没有运行时动态增删 pane 的 API
解决方案: 采用静态预注册 + visible 标记方案:
- 扩展 PaneSpec 类型,添加
visible字段:
// src/core/chart.ts
export type PaneSpec = {
id: string
ratio: number
visible?: boolean // 新增,默认 true
}- 修改 layoutPanes () 布局逻辑,过滤掉不可见的 pane:
private layoutPanes() {
// 只计算可见 pane 的比例
const visiblePanes = this.opt.panes.filter(p => p.visible !== false)
const totalRatio = visiblePanes.reduce((s, p) => s + p.ratio, 0) || 1
// ... 后续布局计算只处理可见 pane
}- 添加 Chart.updatePaneLayout () API:
/** 更新 pane 布局(可见性和比例) */
updatePaneLayout(panes: PaneSpec[]): void {
this.opt.panes = panes
this.layoutPanes()
this.scheduleDraw()
}- 修改硬编码判断:
// 修改前(硬编码)
if (pane.id === 'sub') {
pane.setPadding(0, 0)
}
// 修改后(通用判断)
if (pane.id !== 'main') {
pane.setPadding(0, 0)
}优势:避免 ratio: 0 导致的除零或归一化问题
🟡 中风险:渲染器命名唯一性
现状分析:
RendererPluginManager.register()已有重复检测机制- 当前命名如
macd、rsi是单一的
解决方案: 使用 {indicator}_{paneId} 命名规范:
// MACD 渲染到 sub
{ name: 'macd_sub', paneId: 'sub', ... }
// MACD 渲染到 sub2
{ name: 'macd_sub2', paneId: 'sub2', ... }🟡 中风险:数据计算重复
现状分析:
- 如果同一指标渲染到两个 pane,会各自计算一遍数据
- 例如 MACD_sub 和 MACD_sub 2 会分别调用
getMACDPoints() - 渲染器内缓存易导致状态碎片化
解决方案:抽离全局 IndicatorDataManager
// src/core/indicatorDataManager.ts
export class IndicatorDataManager {
private cache: Map<string, { data: unknown[], timestamp: number }> = new Map()
private dataVersion: number = 0
/** 数据更新时清空缓存 */
onDataUpdate(): void {
this.dataVersion++
this.cache.clear()
}
/** 获取或计算指标数据 */
get<T>(
key: string,
compute: () => T,
deps: { version: number }
): T {
const cacheKey = `${key}_${deps.version}`
const cached = this.cache.get(cacheKey)
if (cached) return cached.data as T
const result = compute()
this.cache.set(cacheKey, { data: result as unknown[], timestamp: Date.now() })
return result
}
}
// 使用方式(渲染器中)
const indicatorManager = new IndicatorDataManager()
// MACD 渲染器
function draw(context: RenderContext) {
const macdData = indicatorManager.get(
`macd_${config.fastPeriod}_${config.slowPeriod}_${config.signalPeriod}`,
() => computeMACD(data, config),
{ version: dataVersion }
)
}集成点:
- Chart 类持有
IndicatorDataManager实例 - 数据更新时调用
indicatorManager.onDataUpdate() - 渲染器通过
context.indicatorManager访问
🟡 中风险:渲染器生命周期管理
现状分析:
- 当前渲染器通过
useRenderer()注册,没有unregister暴露给外部 - 需要明确 add/remove 生命周期,避免内存泄漏与事件监听残留
解决方案:
- Chart 类暴露 unregisterRenderer API:
// src/core/chart.ts
/** 移除渲染器插件 */
unregisterRenderer(name: string): void {
this.rendererPluginManager.unregister(name)
}- 渲染器生命周期规范:
// 渲染器接口扩展
export interface RendererPlugin {
name: string
paneId: string | symbol
priority: number
draw(context: RenderContext): void
// 可选生命周期钩子
onInstall?(host: PluginHost): void // 注册时调用
onUninstall?(): void // 移除时调用
setConfig?(config: Record<string, unknown>): void
onDataUpdate? (data: unknown[], range: { start: number; end: number }): void
}- 资源清理检查清单:
- 事件监听器:在
onUninstall中移除 - 定时器:在
onUninstall中清除 - DOM 引用:在
onUninstall中置空 - 缓存数据:随
IndicatorDataManager统一管理
- 事件监听器:在
🟡 中风险:空 pane 占用空间
现状分析:
- 如果没有指标分配到 sub 2,空白区域影响用户体验
解决方案: 使用 visible 标记 动态调整布局:
// 初始状态(只有主图 + 默认成交量)
const initialPanes: PaneSpec[] = [
{ id: 'main', ratio: 0.75, visible: true },
{ id: 'sub', ratio: 0.25, visible: true }, // 成交量
{ id: 'sub 2', ratio: 0.2, visible: false }, // 隐藏
]
// 添加第一个副图指标(MACD -> sub)
// 成交量被替换,sub 2 仍隐藏
const oneSubIndicator: PaneSpec[] = [
{ id: 'main', ratio: 0.75, visible: true },
{ id: 'sub', ratio: 0.25, visible: true }, // MACD
{ id: 'sub 2', ratio: 0.2, visible: false },
]
// 添加第二个副图指标(RSI -> sub 2)
const twoSubIndicators: PaneSpec[] = [
{ id: 'main', ratio: 0.6, visible: true },
{ id: 'sub', ratio: 0.2, visible: true }, // MACD
{ id: 'sub 2', ratio: 0.2, visible: true }, // RSI
]🟢 低风险:十字线/缩放同步
现状分析:
- crosshair 渲染器的 paneId 是
GLOBAL_PANE_ID(Symbol) - 已自动渲染到所有 pane,无需额外处理
Y 轴数值映射逻辑:
- 每个 pane 有独立的
PriceScale实例 - 十字线的 Y 坐标转换由
activePaneId决定使用哪个 pane 的 PriceScale
// crosshair. ts 现有逻辑
const isActive = pane. id === state. activePaneId
// 垂直线:所有 pane 都绘制
// 水平线:只在 activePane 上绘制
// 价格标签:使用 activePane 的 PriceScale 转换多副图场景下需确认:
- 鼠标在 sub 上时,十字线价格标签显示 sub 的 Y 轴数值
- 鼠标在 sub 2 上时,十字线价格标签显示 sub 2 的 Y 轴数值
- 当前实现已支持此逻辑(通过
InteractionController. activePaneId)
⚠️ 需强化:状态管理
问题:指标参数未与 Pane 隔离,同指标双实例时易冲突
解决方案:
- 指标实例状态结构:
// 副图指标实例
interface SubIndicatorInstance {
indicatorId: string // 'MACD' | 'RSI' | ...
paneId: 'sub' | 'sub 2' // 目标 pane
rendererName: string // 'macd_sub' | 'rsi_sub 2' | ...
params: Record<string, number> // 独立参数配置
}
// KLineChart. vue 状态
const subIndicatorSlots = ref<{
sub: SubIndicatorInstance | null
sub 2: SubIndicatorInstance | null
}>({
sub: null,
sub 2: null
})- 参数面板绑定:
// 参数更新时,指定更新哪个 pane 上的实例
function handleUpdateParams (indicatorId: string, paneId: 'sub' | 'sub 2', params: Record<string, number>) {
const instance = subIndicatorSlots. value[paneId]
if (instance && instance. indicatorId === indicatorId) {
instance. params = params
const rendererName = `${indicatorId.toLowerCase ()}_${paneId}`
chartRef.value?.updateRendererConfig (rendererName, params)
}
}- 边缘情况处理:见下文"最终决策确认"章节
决策 1:成交量模型 - VOLUME 可关闭(模型 A)
交互规则:
- 成交量是普通的副图指标,可被用户关闭
- 初始状态:sub = VOLUME,sub 2 = null
- 用户选择其他指标时,替换 sub 的成交量(先注销成交量,再注册新指标)
- 用户关闭所有副图指标后,sub 和 sub 2 都为 null,副图区域隐藏
- 无自动恢复逻辑:成交量不会自动恢复,用户需手动选择
// 槽位状态结构
interface SubIndicatorInstance {
indicatorId: string // 'VOLUME' | 'MACD' | 'RSI' | ...
paneId: 'sub' | 'sub 2'
rendererName: string
params: Record<string, number>
}
const subIndicatorSlots = ref<{
sub: SubIndicatorInstance | null
sub 2: SubIndicatorInstance | null
}>({
sub: { indicatorId: 'VOLUME', paneId: 'sub', rendererName: 'volume_sub', params: {} },
sub 2: null
})决策 2:渲染器管理 - 按需动态注册(无缓存)
删除 rendererFactoryCache,每次都创建新实例,避免悬空引用问题。
function assignIndicator (indicatorId: string, paneId: 'sub' | 'sub 2') {
// 每次创建新实例
const renderer = createIndicatorRenderer (indicatorId, { paneId })
// 注册渲染器(useRenderer 默认启用)
chartRef.value?.useRenderer (renderer)
// 设置默认参数
const defaultParams = getDefaultParams (indicatorId)
renderer.setConfig?. (defaultParams)
// 更新槽位状态
subIndicatorSlots. value[paneId] = {
indicatorId,
paneId,
rendererName: renderer. name,
params: defaultParams
}
}
function removeIndicator (paneId: 'sub' | 'sub 2') {
const instance = subIndicatorSlots. value[paneId]
if (instance) {
chartRef.value?.unregisterRenderer (instance. rendererName)
subIndicatorSlots. value[paneId] = null
}
}决策 3:布局更新 - 只用 watch,无手动调用
// 布局计算
const layoutPanes = computed<PaneSpec[]>(() => {
const hasSub = subIndicatorSlots. value. sub !== null
const hasSub 2 = subIndicatorSlots. value. sub 2 !== null
if (! hasSub && !hasSub 2) {
// 无任何副图指标,隐藏副图区域
return [
{ id: 'main', ratio: 1, visible: true },
{ id: 'sub', ratio: 0.25, visible: false },
{ id: 'sub 2', ratio: 0.25, visible: false },
]
}
if (hasSub && hasSub 2) {
// 两个副图都有指标
return [
{ id: 'main', ratio: 0.6, visible: true },
{ id: 'sub', ratio: 0.2, visible: true },
{ id: 'sub 2', ratio: 0.2, visible: true },
]
}
// 只有一个副图
return [
{ id: 'main', ratio: 0.75, visible: true },
{ id: 'sub', ratio: 0.25, visible: hasSub },
{ id: 'sub 2', ratio: 0.25, visible: hasSub 2 },
]
})
// 布局更新(只用 watch,flush: 'post' 确保 DOM 更新后执行)
watch (
layoutPanes,
(newPanes) => {
chartRef.value?.updatePaneLayout (newPanes)
},
{ flush: 'post' }
)单副图时 sub 2 位置说明
当 hasSub=false, hasSub 2=true 时:
sub的visible: false,不占用空间sub 2的visible: true,会紧贴主图下方- 这是预期行为:用户选择了 sub 2,sub 2 就显示在主图下方
如果希望 sub 2 始终保持固定位置(即使 sub 隐藏),需要修改布局逻辑。但当前设计是紧凑布局,隐藏的 pane 不占用空间。
useRenderer 默认启用
当前 RendererPluginManager.register () 实现中,渲染器注册后:
- 如果
plugin. enabled !== false,默认启用 - 可以通过
setEnabled ()修改
// register 方法
register (plugin: RendererPlugin): void {
this.plugins.set (plugin. name, plugin)
if (plugin. enabled !== undefined) {
this.enabledState.set (plugin. name, plugin. enabled)
}
// ...
}因此 assignIndicator 中不需要调用 setRendererEnabled (name, true)。
IndicatorDataManager 统一签名
export class IndicatorDataManager {
private cache = new Map<string, CacheEntry<unknown>>()
private dataVersion = 0
private readonly MAX_CACHE_SIZE = 50
/** 数据更新时调用(由 Chart 在数据变化时调用) */
onDataUpdate (): void {
this. dataVersion++
// 惰性清理:删除旧版本缓存
for (const [key, entry] of this. cache) {
if (entry. dataVersion < this. dataVersion) {
this.cache.delete (key)
}
}
}
/** 获取或计算指标数据 */
get<T>(indicatorId: string, params: Record<string, number>, compute: () => T): T {
const paramsHash = hashParams (params)
const key = `${indicatorId}_${paramsHash}_${this. dataVersion}`
const cached = this.cache.get (key)
if (cached) return cached. data as T
// LRU 淘汰
if (this. cache. size >= this. MAX_CACHE_SIZE) {
const firstKey = this.cache.keys (). next (). value
if (firstKey) this.cache.delete (firstKey)
}
const result = compute ()
this.cache.set (key, {
data: result,
dataVersion: this. dataVersion,
paramsHash,
})
return result
}
}
// hash 函数:处理浮点精度
function hashParams (params: Record<string, number>): string {
return Object.entries (params)
.sort (([a], [b]) => a.localeCompare (b))
.map (([k, v]) => `${k}:${Number (v.toFixed (8))}`)
.join ('_')
}渲染器调用方式:
// RenderContext 中传递 indicatorManager
const context: RenderContext = {
// ...
indicatorManager: this. indicatorManager,
}
// 渲染器中使用
function draw (context: RenderContext) {
const macdData = context.indicatorManager?.get (
'MACD',
{ fast: config. fastPeriod, slow: config. slowPeriod, signal: config. signalPeriod },
() => computeMACD (data, config)
)
}Chart 类新增方法
class Chart {
private indicatorManager = new IndicatorDataManager ()
/** 更新 pane 布局 */
updatePaneLayout (panes: PaneSpec[]): void {
this. opt. panes = panes
this.layoutPanes ()
this.scheduleDraw ()
}
/** 移除渲染器 */
unregisterRenderer (name: string): void {
this.rendererPluginManager.unregister (name)
}
/** 检查渲染器是否存在 */
hasRenderer (name: string): boolean {
return this.rendererPluginManager.getPlugin (name) !== undefined
}
}getSubPaneTitleInfo 实现
function getSubPaneTitleInfo (paneId: 'sub' | 'sub 2'): TitleInfo | null {
const instance = subIndicatorSlots. value[paneId]
if (! instance) return null
if (instance. indicatorId === 'VOLUME') {
return { title: 'VOL', values: [] }
}
const data = props. data
if (! data || data. length === 0) return null
const lastIndex = data. length - 1
const p = instance. params
// 复用现有的 getTitleInfo 函数(从 KLineChart. vue 现有代码提取)
switch (instance. indicatorId) {
case 'MACD':
return getMACDTitleInfo (data, lastIndex, p.fastPeriod ?? 12, p.slowPeriod ?? 26, p.signalPeriod ?? 9)
case 'RSI':
return getRSITitleInfo (data, lastIndex, p.period 1 ?? 6, p.period 2 ?? 12, p.period 3 ?? 24)
case 'CCI':
return getCCITitleInfo (data, lastIndex, p.period ?? 14)
case 'STOCH':
return getSTOCHTitleInfo (data, lastIndex, p.n ?? 9, p.m ?? 3)
case 'MOM':
return getMOMTitleInfo (data, lastIndex, p.period ?? 10)
case 'WMSR':
return getWMSRTitleInfo (data, lastIndex, p.period ?? 14)
case 'KST':
return getKSTTitleInfo (data, lastIndex, p.roc 1 ?? 10, p.roc 2 ?? 15, p.roc 3 ?? 20, p.roc 4 ?? 30, p.signalPeriod ?? 9)
case 'FASTK':
return getFASTKTitleInfo (data, lastIndex, p.period ?? 9)
default:
return { title: instance. indicatorId, values: [] }
}
}工具栏按钮视觉规范
空间受限时使用角标方案:
[MACD ①] [RSI ②] [CCI] [STOCH]
↑已激活 ↑已激活 ↑未激活function getIndicatorButtonLabel (indicatorId: string): string {
const { sub, sub 2 } = subIndicatorSlots. value
if (sub?. indicatorId === indicatorId) return `${indicatorId} ①`
if (sub 2?. indicatorId === indicatorId) return `${indicatorId} ②`
return indicatorId
}🔴 高影响:成交量在双副图模型中的定位
问题:sub 默认绑定成交量逻辑耦合过深,在 ! hasSub && hasSub 2 场景下布局歧义
解决方案:将 sub/sub 2 改为纯通用槽位,成交量作为默认指标
// 成交量作为特殊的副图指标
interface VolumeIndicator extends SubIndicatorInstance {
indicatorId: 'VOLUME'
paneId: 'sub' | 'sub 2'
rendererName: 'volume_sub' | 'volume_sub 2'
}
// 初始状态:sub 显示成交量
const subIndicatorSlots = ref<{
sub: SubIndicatorInstance | VolumeIndicator
sub 2: SubIndicatorInstance | null
}>({
sub: { indicatorId: 'VOLUME', paneId: 'sub', rendererName: 'volume_sub', params: {} },
sub 2: null
})
// 添加第一个副图指标时,替换 sub 的成交量
// 添加第二个副图指标时,使用 sub 2
// 关闭所有副图指标时,恢复 sub 显示成交量布局规则更新:
const layoutPanes = computed (() => {
const hasSub = subIndicatorSlots. value. sub !== null
const hasSub 2 = subIndicatorSlots. value. sub 2 !== null
// 无副图指标时,sub 显示成交量
if (! hasSub && !hasSub 2) {
return [
{ id: 'main', ratio: 0.75, visible: true },
{ id: 'sub', ratio: 0.25, visible: true }, // 成交量
{ id: 'sub 2', ratio: 0.2, visible: false },
]
}
// sub 有成交量或指标,sub 2 有指标
if (hasSub && hasSub 2) {
return [
{ id: 'main', ratio: 0.6, visible: true },
{ id: 'sub', ratio: 0.2, visible: true },
{ id: 'sub 2', ratio: 0.2, visible: true },
]
}
// 其他情况:单副图
return [
{ id: 'main', ratio: 0.75, visible: true },
{ id: hasSub ? 'sub' : 'sub 2', ratio: 0.25, visible: true },
{ id: hasSub ? 'sub 2' : 'sub', ratio: 0.2, visible: false },
]
})🔴 高影响:RenderContext 接口变更
见"最终决策确认"章节中的 IndicatorDataManager 统一签名部分。
API 补充定义
基础功能
- 添加 MACD 到副图 1,确认正确显示
- 添加 RSI 到副图 2,确认两个副图独立显示,各自有独立的 Y 轴价格范围
- 测试十字线同步(鼠标在不同副图上移动)
- 测试缩放/平移同步
布局测试
- 测试只有一个副图指标时的布局(sub 2 应隐藏)
- 测试关闭所有副图指标后,副图区域隐藏,主图占满
交互测试
- 测试替换副图指标功能(两个槽位都满时的显式替换)
- 测试 Dialog 取消后按钮状态正确
- 测试成交量作为普通指标可被选择和关闭
性能测试
- 快速连点切换指标,确认无闪烁、无布局抖动
- 长周期运行,确认缓存内存稳定(≤ 50 条)