Skip to content

实现双副图同时显示

Context

用户希望在 K 线图中同时显示两个副图(如 MACD + RSI),而非当前的互斥切换模式。现有架构已支持多 pane 设计,但 UI 层实现了互斥逻辑,需要改造为允许多副图同时显示。

关键改动点

1. 扩展 Pane 配置(从 2 个到 3 个)

文件: KLineChart.vue

修改 panes 配置,新增 sub2 pane:

typescript
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 参数

需要修改的渲染器文件:

每个渲染器工厂函数添加可选 paneId 参数:

typescript
export function createMACDRendererPlugin(paneId: string = 'sub'): RendererPlugin {
    return {
        name: `macd_${paneId}`,
        paneId: paneId,
        // ...
    }
}

3. 重构指标注册逻辑

文件: KLineChart.vue

当前所有副图指标在 createChart 时注册到 sub pane。需要:

  1. 为每个副图指标注册两个实例(一个到 sub,一个到 sub2
  2. 或者改为动态注册/注销机制

4. 修改 UI 交互逻辑(自动分配模式)

文件: KLineChart.vue

移除副图指标的互斥逻辑(第 350-375 行),改为自动分配:

  • 第一个选择的副图指标 → 分配到 sub
  • 第二个选择的副图指标 → 分配到 sub2
  • 已有两个副图指标时,替换最后一个选中的(或让用户选择替换哪个)

5. 更新 paneTitle 渲染

文件: paneTitle.ts

当前副图标题是固定绑定到 sub pane。需要为 sub2 也创建独立的标题渲染器。

实现步骤

Phase 1: 基础架构改动

文件: chart.ts

  1. 扩展 PaneSpec 类型,添加 visible 字段
  2. 修改 layoutPanes() 方法:
    • 过滤 visible: false 的 pane
    • 修改硬编码的 'sub' 判断为 pane.id !== 'main'
  3. 添加 updatePaneLayout(panes: PaneSpec[]) 方法
  4. 暴露 unregisterRenderer(name: string) 方法

文件: pane.ts

  • 无需修改(现有 Pane 类已满足需求)

Phase 2: 指标数据管理器

新建文件: indicatorDataManager.ts

  1. 实现 IndicatorDataManager
  2. 提供缓存 key 生成规则:{indicator}_{param1}_{param2}_...
  3. 集成到 Chart 类,通过 RenderContext 传递

Phase 3: 渲染器改造

修改文件列表

改造内容

typescript
// 改造前
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

  1. 新增状态:
typescript
const subIndicatorSlots = ref<{
    sub: SubIndicatorInstance | null
    sub2: SubIndicatorInstance | null
}>({ sub: null, sub2: null })
  1. 新增计算属性:
typescript
const layoutPanes = computed<PaneSpec[]>(() => {
    // 根据槽位状态动态生成布局配置
})
  1. 修改 handleIndicatorToggle 逻辑:

    • 区分主图指标和副图指标
    • 副图指标使用槽位分配逻辑
  2. 修改 handleUpdateParams 逻辑:

    • 支持指定 paneId 更新参数

Phase 5: 渲染器注册改造

文件: KLineChart.vue

  1. 预注册两套副图指标渲染器:
typescript
// 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)
})
  1. 创建两个副图标题渲染器:
typescript
chart.useRenderer(createPaneTitleRendererPlugin({
    paneId: 'sub',
    title: 'VOL',
    getTitleInfo: () => getSubPaneTitleInfo('sub'),
}))
chart.useRenderer(createPaneTitleRendererPlugin({
    paneId: 'sub2',
    title: '',
    getTitleInfo: () => getSubPaneTitleInfo('sub2'),
}))

Phase 6: 测试验证

  1. 单元测试:IndicatorDataManager 缓存逻辑
  2. 集成测试:副图指标分配与移除
  3. 视觉测试:
    • 单副图布局
    • 双副图布局
    • 十字线同步
    • 参数面板响应

风险分析与解决方案

🔴 高风险:Pane 动态增删支持

现状分析

  • Chart 类在初始化时调用 initPanes() 创建所有 pane
  • layoutPanes() 中硬编码了 pane.id === 'sub' 的判断
  • 没有运行时动态增删 pane 的 API

解决方案: 采用静态预注册 + visible 标记方案:

  1. 扩展 PaneSpec 类型,添加 visible 字段:
typescript
// src/core/chart.ts
export type PaneSpec = {
    id: string
    ratio: number
    visible?: boolean  // 新增,默认 true
}
  1. 修改 layoutPanes () 布局逻辑,过滤掉不可见的 pane:
typescript
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
}
  1. 添加 Chart.updatePaneLayout () API
typescript
/** 更新 pane 布局(可见性和比例) */
updatePaneLayout(panes: PaneSpec[]): void {
    this.opt.panes = panes
    this.layoutPanes()
    this.scheduleDraw()
}
  1. 修改硬编码判断
typescript
// 修改前(硬编码)
if (pane.id === 'sub') {
    pane.setPadding(0, 0)
}

// 修改后(通用判断)
if (pane.id !== 'main') {
    pane.setPadding(0, 0)
}

优势:避免 ratio: 0 导致的除零或归一化问题

🟡 中风险:渲染器命名唯一性

现状分析

  • RendererPluginManager.register() 已有重复检测机制
  • 当前命名如 macdrsi 是单一的

解决方案: 使用 {indicator}_{paneId} 命名规范:

typescript
// MACD 渲染到 sub
{ name: 'macd_sub', paneId: 'sub', ... }
// MACD 渲染到 sub2
{ name: 'macd_sub2', paneId: 'sub2', ... }

🟡 中风险:数据计算重复

现状分析

  • 如果同一指标渲染到两个 pane,会各自计算一遍数据
  • 例如 MACD_sub 和 MACD_sub 2 会分别调用 getMACDPoints()
  • 渲染器内缓存易导致状态碎片化

解决方案:抽离全局 IndicatorDataManager

typescript
// 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 生命周期,避免内存泄漏与事件监听残留

解决方案

  1. Chart 类暴露 unregisterRenderer API
typescript
// src/core/chart.ts
/** 移除渲染器插件 */
unregisterRenderer(name: string): void {
    this.rendererPluginManager.unregister(name)
}
  1. 渲染器生命周期规范
typescript
// 渲染器接口扩展
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
}
  1. 资源清理检查清单
    • 事件监听器:在 onUninstall 中移除
    • 定时器:在 onUninstall 中清除
    • DOM 引用:在 onUninstall 中置空
    • 缓存数据:随 IndicatorDataManager 统一管理

🟡 中风险:空 pane 占用空间

现状分析

  • 如果没有指标分配到 sub 2,空白区域影响用户体验

解决方案: 使用 visible 标记 动态调整布局:

typescript
// 初始状态(只有主图 + 默认成交量)
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
typescript
// crosshair. ts 现有逻辑
const isActive = pane. id === state. activePaneId
// 垂直线:所有 pane 都绘制
// 水平线:只在 activePane 上绘制
// 价格标签:使用 activePane 的 PriceScale 转换

多副图场景下需确认

  • 鼠标在 sub 上时,十字线价格标签显示 sub 的 Y 轴数值
  • 鼠标在 sub 2 上时,十字线价格标签显示 sub 2 的 Y 轴数值
  • 当前实现已支持此逻辑(通过 InteractionController. activePaneId

⚠️ 需强化:状态管理

问题:指标参数未与 Pane 隔离,同指标双实例时易冲突

解决方案

  1. 指标实例状态结构
typescript
// 副图指标实例
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
})
  1. 参数面板绑定
typescript
// 参数更新时,指定更新哪个 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. 边缘情况处理:见下文"最终决策确认"章节

决策 1:成交量模型 - VOLUME 可关闭(模型 A)

交互规则

  1. 成交量是普通的副图指标,可被用户关闭
  2. 初始状态:sub = VOLUME,sub 2 = null
  3. 用户选择其他指标时,替换 sub 的成交量(先注销成交量,再注册新指标)
  4. 用户关闭所有副图指标后,sub 和 sub 2 都为 null,副图区域隐藏
  5. 无自动恢复逻辑:成交量不会自动恢复,用户需手动选择
typescript
// 槽位状态结构
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,每次都创建新实例,避免悬空引用问题。

typescript
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,无手动调用

typescript
// 布局计算
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 时:

  • subvisible: false,不占用空间
  • sub 2visible: true,会紧贴主图下方
  • 这是预期行为:用户选择了 sub 2,sub 2 就显示在主图下方

如果希望 sub 2 始终保持固定位置(即使 sub 隐藏),需要修改布局逻辑。但当前设计是紧凑布局,隐藏的 pane 不占用空间。

useRenderer 默认启用

当前 RendererPluginManager.register () 实现中,渲染器注册后:

  • 如果 plugin. enabled !== false,默认启用
  • 可以通过 setEnabled () 修改
typescript
// 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 统一签名

typescript
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 ('_')
}

渲染器调用方式

typescript
// 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 类新增方法

typescript
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 实现

typescript
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]
 ↑已激活    ↑已激活   ↑未激活
typescript
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 改为纯通用槽位,成交量作为默认指标

typescript
// 成交量作为特殊的副图指标
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 显示成交量

布局规则更新

typescript
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 补充定义

基础功能

  1. 添加 MACD 到副图 1,确认正确显示
  2. 添加 RSI 到副图 2,确认两个副图独立显示,各自有独立的 Y 轴价格范围
  3. 测试十字线同步(鼠标在不同副图上移动)
  4. 测试缩放/平移同步

布局测试

  1. 测试只有一个副图指标时的布局(sub 2 应隐藏)
  2. 测试关闭所有副图指标后,副图区域隐藏,主图占满

交互测试

  1. 测试替换副图指标功能(两个槽位都满时的显式替换)
  2. 测试 Dialog 取消后按钮状态正确
  3. 测试成交量作为普通指标可被选择和关闭

性能测试

  1. 快速连点切换指标,确认无闪烁、无布局抖动
  2. 长周期运行,确认缓存内存稳定(≤ 50 条)