@363045841yyt/klinechart 0.7.4 → 0.7.5-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1570 +1,1570 @@
1
- <template>
2
- <div class="chart-wrapper" :data-theme="chartTheme">
3
- <div
4
- class="chart-stage"
5
- :class="{
6
- 'is-dragging': isDragging,
7
- 'is-resizing-pane': isResizingPane,
8
- 'is-hovering-pane-separator': isHoveringPaneSeparator,
9
- 'is-hovering-right-axis': isHoveringRightAxis,
10
- 'is-hovering-kline': hoveredIdx !== null,
11
- }"
12
- >
13
- <LeftToolbar
14
- ref="toolbarRef"
15
- :is-fullscreen="isFullscreen"
16
- @select-tool="handleSelectTool"
17
- @toggle-fullscreen="$emit('toggleFullscreen')"
18
- @zoom-in="applyZoomToLevel(zoomLevel + 1)"
19
- @zoom-out="applyZoomToLevel(zoomLevel - 1)"
20
- @settings-change="handleSettingsChange"
21
- />
22
- <div class="chart-main" ref="chartMainRef">
23
- <div class="pane-separator-layer" aria-hidden="true">
24
- <div
25
- v-for="line in paneSeparatorLines"
26
- :key="line.id"
27
- class="pane-separator-line"
28
- :class="{ 'is-active': hoveredPaneBoundaryId === line.id }"
29
- :style="{ top: `${line.top}px` }"
30
- ></div>
31
- </div>
32
- <div ref="tooltipLayerRef" class="tooltip-layer"></div>
33
- <div
34
- class="chart-container"
35
- :style="{ cursor: containerCursor }"
36
- ref="containerRef"
37
- @scroll.passive="onScroll"
38
- @pointerdown="onPointerDown"
39
- @pointermove="onPointerMove"
40
- @pointerup="onPointerUp"
41
- @pointerleave="onPointerLeave"
42
- >
43
- <div class="scroll-content" :style="{ width: totalWidth + 'px' }">
44
- <div class="canvas-layer" ref="canvasLayerRef">
45
- <canvas class="x-axis-canvas" ref="xAxisCanvasRef"></canvas>
46
-
47
- <DrawingStyleToolbar
48
- v-if="selectedDrawing"
49
- :drawing="selectedDrawing"
50
- @update-style="onUpdateDrawingStyle"
51
- @delete="onDeleteDrawing"
52
- />
53
- </div>
54
- </div>
55
- </div>
56
- <Teleport v-if="tooltipLayerRef" :to="tooltipLayerRef">
57
- <div
58
- v-if="hovered"
59
- class="tooltip-anchor kline-tooltip-anchor"
60
- :class="{ 'use-anchor': useAnchorPositioning }"
61
- :style="klineTooltipAnchorStyle"
62
- ></div>
63
- <div
64
- v-if="hoveredMarker || hoveredCustomMarker"
65
- class="tooltip-anchor marker-tooltip-anchor"
66
- :class="{ 'use-anchor': useAnchorPositioning }"
67
- :style="markerTooltipAnchorStyle"
68
- ></div>
69
- <KLineTooltip
70
- v-if="hovered"
71
- :k="hovered"
72
- :index="hoveredIndex"
73
- :data="chartData"
74
- :pos="teleportedTooltipPos"
75
- :set-el="setTooltipEl"
76
- :use-anchor="useAnchorPositioning"
77
- :anchor-placement="tooltipAnchorPlacement"
78
- />
79
- <MarkerTooltip
80
- v-if="hoveredMarker || hoveredCustomMarker"
81
- :marker="hoveredMarker || hoveredCustomMarker"
82
- :pos="teleportedMarkerTooltipPos"
83
- :use-anchor="useAnchorPositioning"
84
- :anchor-placement="markerTooltipAnchorPlacement"
85
- :set-el="setMarkerTooltipEl"
86
- />
87
- </Teleport>
88
- <div
89
- class="right-axis-host"
90
- ref="rightAxisLayerRef"
91
- :style="{ width: axisHostWidth + 'px' }"
92
- @pointerdown="onRightAxisPointerDown"
93
- @pointermove="onRightAxisPointerMove"
94
- @pointerup="onRightAxisPointerUp"
95
- @pointerleave="onRightAxisPointerLeave"
96
- ></div>
97
- </div>
98
- </div>
99
- <IndicatorSelector
100
- :active-indicators="activeIndicators"
101
- :indicator-params="indicatorParams"
102
- @toggle="handleIndicatorToggle"
103
- @update-params="handleUpdateParams"
104
- @reorder-sub-indicators="handleReorderSubIndicators"
105
- />
106
- </div>
107
- </template>
108
-
109
- <script setup lang="ts">
110
- import { ref, computed, onMounted, onUnmounted, watch, nextTick, shallowRef } from 'vue'
111
- import {
112
- SemanticChartController,
113
- __setDataFetcher,
114
- type SemanticChartConfig,
115
- type DataFetcher,
116
- } from '@363045841yyt/klinechart-core/semantic'
117
- import KLineTooltip from './KLineTooltip.vue'
118
- import MarkerTooltip from './MarkerTooltip.vue'
119
- import IndicatorSelector from './IndicatorSelector.vue'
120
- import DrawingStyleToolbar from './DrawingStyleToolbar.vue'
121
- import { Chart, type PaneSpec } from '@363045841yyt/klinechart-core/engine/chart'
122
- import type { KLineData } from '@363045841yyt/klinechart-core/types/price'
123
- import {
124
- createChartStore,
125
- TRAILING_DRAWING_SLOTS,
126
- type ChartStore,
127
- } from '@363045841yyt/klinechart-core/engine/chart-store'
128
- import { zoomLevelToKWidth, kGapFromKWidth } from '@363045841yyt/klinechart-core/engine/utils/zoom'
129
- import { getPhysicalKLineConfig } from '@363045841yyt/klinechart-core/engine/utils/klineConfig'
130
- import { type SubIndicatorType } from '@363045841yyt/klinechart-core/engine/renderers/Indicator'
131
- import {
132
- SUB_PANE_INDICATOR_CONFIGS,
133
- SUB_PANE_INDICATORS,
134
- } from '@363045841yyt/klinechart-core/engine/renderers/Indicator/subPaneConfig'
135
- import {
136
- createPaneTitleRendererPlugin,
137
- type TitleInfo,
138
- } from '@363045841yyt/klinechart-core/engine/renderers/paneTitle'
139
- import type { InteractionSnapshot } from '@363045841yyt/klinechart-core/engine/controller/interaction'
140
- import type { DrawingStyle } from '@363045841yyt/klinechart-core/plugin'
141
- import LeftToolbar from './LeftToolbar.vue'
142
- import {
143
- DrawingInteractionController,
144
- type DrawingToolId,
145
- } from '@363045841yyt/klinechart-core/engine/drawing'
146
-
147
- const props = withDefaults(
148
- defineProps<{
149
- /** 语义化配置(必需,唯一控制源) */
150
- semanticConfig: SemanticChartConfig
151
-
152
- /** 数据获取函数(必需)。框架不绑定数据源,由使用者注入。 */
153
- dataFetcher: DataFetcher
154
-
155
- yPaddingPx?: number
156
- minKWidth?: number
157
- maxKWidth?: number
158
- /** 右侧价格轴宽度 */
159
- rightAxisWidth?: number
160
- /** 底部时间轴高度 */
161
- bottomAxisHeight?: number
162
- /** 价格标签额外宽度(用于显示涨跌幅,默认 60px) */
163
- priceLabelWidth?: number
164
-
165
- /** 缩放级别数量(默认 10) */
166
- zoomLevels?: number
167
- /** 初始缩放级别(1 ~ zoomLevels,默认居中) */
168
- initialZoomLevel?: number
169
- /** 是否全屏 */
170
- isFullscreen?: boolean
171
- }>(),
172
- {
173
- yPaddingPx: 20,
174
- minKWidth: 1,
175
- maxKWidth: 50,
176
- rightAxisWidth: 0,
177
- bottomAxisHeight: 24,
178
- priceLabelWidth: 60,
179
- zoomLevels: 20,
180
- initialZoomLevel: 3,
181
- isFullscreen: false,
182
- },
183
- )
184
-
185
- const emit = defineEmits<{
186
- (e: 'zoomLevelChange', level: number, kWidth: number): void
187
- (e: 'toggleFullscreen'): void
188
- }>()
189
-
190
- const xAxisCanvasRef = ref<HTMLCanvasElement | null>(null)
191
- const canvasLayerRef = ref<HTMLDivElement | null>(null)
192
- const rightAxisLayerRef = ref<HTMLDivElement | null>(null)
193
- const containerRef = ref<HTMLDivElement | null>(null)
194
- const chartMainRef = ref<HTMLDivElement | null>(null)
195
- const tooltipLayerRef = ref<HTMLDivElement | null>(null)
196
- const toolbarRef = ref<InstanceType<typeof LeftToolbar> | null>(null)
197
-
198
- /* ========== 十字线(鼠标悬停位置) ========== */
199
- const chartRef = shallowRef<Chart | null>(null)
200
-
201
- /* ========== 语义化控制器 ========== */
202
- const semanticController = shallowRef<SemanticChartController | null>(null)
203
-
204
- /* ========== ChartStore(响应式状态中心) ========== */
205
- const store = createChartStore({
206
- initialZoomLevel: props.initialZoomLevel ?? 1,
207
- minKWidth: props.minKWidth,
208
- maxKWidth: props.maxKWidth,
209
- zoomLevels: props.zoomLevels,
210
- rightAxisWidth: props.rightAxisWidth,
211
- priceLabelWidth: props.priceLabelWidth,
212
- })
213
-
214
- /* ========== 主题状态 ========== */
215
- const chartTheme = ref<'light' | 'dark'>('light')
216
-
217
- // 初始化 kWidth / kGap
218
- store.actions.setZoomState(
219
- store.state.zoomLevel,
220
- zoomLevelToKWidth(store.state.zoomLevel, {
221
- minKWidth: props.minKWidth,
222
- maxKWidth: props.maxKWidth,
223
- zoomLevelCount: props.zoomLevels,
224
- dpr: store.state.viewportDpr,
225
- }),
226
- kGapFromKWidth(
227
- zoomLevelToKWidth(store.state.zoomLevel, {
228
- minKWidth: props.minKWidth,
229
- maxKWidth: props.maxKWidth,
230
- zoomLevelCount: props.zoomLevels,
231
- dpr: store.state.viewportDpr,
232
- }),
233
- store.state.viewportDpr,
234
- ),
235
- )
236
-
237
- // 为逐步迁移保留的局部别名
238
- const dataLength = computed(() => store.state.dataLength)
239
- const viewportDpr = computed(() => store.state.viewportDpr)
240
- const zoomLevel = computed(() => store.state.zoomLevel)
241
- const kWidth = computed(() => store.state.kWidth)
242
- const kGap = computed(() => store.state.kGap)
243
- const paneRatios = computed(() => store.state.paneRatios)
244
- const selectedDrawingId = computed(() => store.state.selectedDrawingId)
245
- const dataVersion = computed(() => store.state.dataVersion)
246
-
247
- function scheduleRender() {
248
- chartRef.value?.scheduleDraw()
249
- }
250
-
251
- function handleSettingsChange(settings: Record<string, boolean | string>) {
252
- chartRef.value?.updateSettings(settings)
253
-
254
- // 万条K线性能测试
255
- if (settings.performanceTest10kKlines) {
256
- const testData = generate10kKLineData()
257
- console.time('updateData-10k')
258
- chartRef.value?.updateData(testData)
259
- console.timeEnd('updateData-10k')
260
- store.actions.setDataLength(testData.length)
261
- store.actions.bumpDataVersion()
262
- } else {
263
- // 如果关闭性能测试,恢复原始数据
264
- // 通过重新应用语义化配置来恢复
265
- if (semanticController.value && chartRef.value?.getData()?.length === 10000) {
266
- semanticController.value.applyConfig(props.semanticConfig)
267
- }
268
- }
269
- }
270
-
271
- // 生成1万条K线测试数据
272
- function generate10kKLineData() {
273
- const data: KLineData[] = []
274
- const startTime = new Date('2020-01-01').getTime()
275
- const dayMs = 24 * 60 * 60 * 1000
276
-
277
- let lastClose = 3000 // 起始价格
278
-
279
- for (let i = 0; i < 10000; i++) {
280
- const timestamp = startTime + i * dayMs
281
-
282
- // 生成随机波动
283
- const volatility = 0.02 // 2%日波动率
284
- const trend = 0.0001 // 轻微上涨趋势
285
- const change = (Math.random() - 0.5) * 2 * volatility + trend
286
-
287
- const open = lastClose
288
- const close = open * (1 + change)
289
- const high = Math.max(open, close) * (1 + Math.random() * 0.01)
290
- const low = Math.min(open, close) * (1 - Math.random() * 0.01)
291
- const volume = Math.floor(1000000 + Math.random() * 5000000)
292
-
293
- data.push({
294
- timestamp,
295
- open: parseFloat(open.toFixed(2)),
296
- high: parseFloat(high.toFixed(2)),
297
- low: parseFloat(low.toFixed(2)),
298
- close: parseFloat(close.toFixed(2)),
299
- volume,
300
- })
301
-
302
- lastClose = close
303
- }
304
-
305
- return data
306
- }
307
-
308
- function measureTooltipSize(el: HTMLDivElement, minWidth: number, minHeight: number) {
309
- const r = el.getBoundingClientRect()
310
- return {
311
- width: Math.max(minWidth, Math.round(r.width)),
312
- height: Math.max(minHeight, Math.round(r.height)),
313
- }
314
- }
315
-
316
- function setTooltipEl(el: HTMLDivElement | null) {
317
- if (!el) return
318
- nextTick(() => {
319
- if (!el.isConnected) return
320
- const size = measureTooltipSize(el, 180, 80)
321
- chartRef.value?.interaction.setTooltipSize(size)
322
- })
323
- }
324
-
325
- function setMarkerTooltipEl(el: HTMLDivElement | null) {
326
- if (!el) return
327
- nextTick(() => {
328
- if (!el.isConnected) return
329
- markerTooltipSize.value = measureTooltipSize(el, 120, 60)
330
- })
331
- }
332
-
333
- // ===== Marker tooltip 状态 =====
334
- const mousePos = ref({ x: 0, y: 0 })
335
- const useAnchorPositioning = ref(false)
336
-
337
- // 容器 rect 缓存,避免 pointermove 中反复 getBoundingClientRect 强制同步布局
338
- let _cachedContainerRect: DOMRect | null = null
339
- function invalidateContainerRectCache(): void {
340
- _cachedContainerRect = null
341
- }
342
- function getContainerRect(container: HTMLDivElement): DOMRect {
343
- if (!_cachedContainerRect) {
344
- _cachedContainerRect = container.getBoundingClientRect()
345
- }
346
- return _cachedContainerRect
347
- }
348
-
349
- // ===== 交互状态(单一来源:InteractionController snapshot) =====
350
- const interactionState = shallowRef<InteractionSnapshot>({
351
- crosshairPos: null,
352
- crosshairIndex: null,
353
- crosshairPrice: null,
354
- hoveredIndex: null,
355
- activePaneId: null,
356
- tooltipPos: { x: 0, y: 0 },
357
- tooltipAnchorPlacement: 'right-bottom',
358
- hoveredMarkerData: null,
359
- hoveredCustomMarker: null,
360
- isDragging: false,
361
- isResizingPaneBoundary: false,
362
- isHoveringPaneBoundary: false,
363
- hoveredPaneBoundaryId: null,
364
- isHoveringRightAxis: false,
365
- })
366
-
367
- const drawingController = shallowRef<DrawingInteractionController | null>(null)
368
- const selectedDrawing = computed(() => {
369
- const id = selectedDrawingId.value
370
- if (!id) return null
371
- return store.state.drawings.find((d) => d.id === id) ?? null
372
- })
373
- const paneSeparatorLines = ref<Array<{ id: string; top: number }>>([])
374
- const markerTooltipSize = ref({ width: 220, height: 120 })
375
- const tooltipLayerOffset = computed(() => {
376
- const container = containerRef.value
377
- const chartMain = chartMainRef.value
378
- if (!container || !chartMain) return { x: 0, y: 0 }
379
- return {
380
- x: container.offsetLeft,
381
- y: container.offsetTop,
382
- }
383
- })
384
-
385
- const hoveredMarker = computed(() => interactionState.value.hoveredMarkerData)
386
- const hoveredCustomMarker = computed(() => interactionState.value.hoveredCustomMarker)
387
- const isDragging = computed(() => interactionState.value.isDragging)
388
- const isResizingPane = computed(() => interactionState.value.isResizingPaneBoundary)
389
- const isHoveringPaneSeparator = computed(() => interactionState.value.isHoveringPaneBoundary)
390
- const hoveredPaneBoundaryId = computed(() => interactionState.value.hoveredPaneBoundaryId)
391
- const isHoveringRightAxis = computed(() => interactionState.value.isHoveringRightAxis)
392
- const hoveredIdx = computed(() => interactionState.value.hoveredIndex)
393
- const crosshairIdx = computed(() => interactionState.value.crosshairIndex)
394
-
395
- // 统一光标样式:用内联 style 替代 CSS 类后代选择器,切断级联失效链
396
- const containerCursor = computed(() => {
397
- if (isDragging.value) return 'grabbing'
398
- if (isResizingPane.value || isHoveringPaneSeparator.value) return 'ns-resize'
399
- if (hoveredIdx.value !== null) return 'pointer'
400
- return 'crosshair'
401
- })
402
-
403
- const hovered = computed(() => {
404
- const idx = interactionState.value.hoveredIndex
405
- if (typeof idx !== 'number') return null
406
- void dataVersion.value // 建立响应式依赖
407
- const data = chartRef.value?.getData()
408
- if (data && idx >= 0 && idx < data.length) {
409
- return data[idx]
410
- }
411
- return null
412
- })
413
- const hoveredIndex = computed(() => interactionState.value.hoveredIndex)
414
- const tooltipPos = computed(() => interactionState.value.tooltipPos)
415
- const teleportedTooltipPos = computed(() => ({
416
- x: tooltipPos.value.x + tooltipLayerOffset.value.x,
417
- y: tooltipPos.value.y + tooltipLayerOffset.value.y,
418
- }))
419
- const klineTooltipAnchorStyle = computed(() => ({
420
- left: `${teleportedTooltipPos.value.x}px`,
421
- top: `${teleportedTooltipPos.value.y}px`,
422
- }))
423
- const teleportedMarkerTooltipPos = computed(() => ({
424
- x: mousePos.value.x + tooltipLayerOffset.value.x,
425
- y: mousePos.value.y + tooltipLayerOffset.value.y,
426
- }))
427
- const markerTooltipAnchorStyle = computed(() => ({
428
- left: `${teleportedMarkerTooltipPos.value.x}px`,
429
- top: `${teleportedMarkerTooltipPos.value.y}px`,
430
- }))
431
- const tooltipAnchorPlacement = computed(() => interactionState.value.tooltipAnchorPlacement)
432
- const markerTooltipAnchorPlacement = computed<'right-bottom' | 'left-bottom'>(() => {
433
- const chart = chartRef.value
434
- const viewport = chart?.getViewport()
435
- const container = containerRef.value
436
- const plotWidth = viewport?.plotWidth ?? (container ? container.clientWidth : 0)
437
- const padding = 12
438
- const gap = 12
439
- const rightCandidateX = mousePos.value.x + gap
440
- const wouldOverflowRight = rightCandidateX + markerTooltipSize.value.width + padding > plotWidth
441
- return wouldOverflowRight ? 'left-bottom' : 'right-bottom'
442
- })
443
-
444
- // 获取当前图表数据
445
- const chartData = computed(() => {
446
- void dataVersion.value // 建立响应式依赖,确保数据变化时重新求值
447
- return chartRef.value?.getData() ?? []
448
- })
449
-
450
- // 通知数据变化(在数据更新后调用)
451
- function handleSelectTool(toolId: string) {
452
- drawingController.value?.setTool(toolId as DrawingToolId)
453
- }
454
-
455
- function onUpdateDrawingStyle(style: Partial<DrawingStyle>) {
456
- const d = selectedDrawing.value
457
- if (!d || !drawingController.value) return
458
- drawingController.value.updateDrawingStyle(d.id, style)
459
- store.actions.bumpDrawingVersion()
460
- }
461
-
462
- function onDeleteDrawing() {
463
- const d = selectedDrawing.value
464
- if (!d || !drawingController.value) return
465
- drawingController.value.removeDrawing(d.id)
466
- store.actions.setSelectedDrawingId(null)
467
- store.actions.bumpDrawingVersion()
468
- store.actions.setDrawings(drawingController.value.getDrawings())
469
- }
470
-
471
- function onPointerDown(e: PointerEvent) {
472
- chartRef.value?.handlePointerEvent(e, {
473
- onPointerDown: (event, container) => {
474
- if (drawingController.value?.onPointerDown(event, container)) {
475
- store.actions.setDrawings(drawingController.value.getDrawings())
476
- store.actions.bumpDrawingVersion()
477
- return true
478
- }
479
- return false
480
- },
481
- })
482
- }
483
-
484
- function onPointerMove(e: PointerEvent) {
485
- const container = containerRef.value
486
- if (container) {
487
- const rect = getContainerRect(container)
488
- mousePos.value = {
489
- x: e.clientX - rect.left,
490
- y: e.clientY - rect.top,
491
- }
492
- }
493
- chartRef.value?.handlePointerEvent(e, {
494
- onPointerMove: (event, container) => {
495
- if (drawingController.value?.onPointerMove(event, container)) {
496
- store.actions.setDrawings(drawingController.value.getDrawings())
497
- return true
498
- }
499
- return false
500
- },
501
- })
502
- }
503
-
504
- function onPointerUp(e: PointerEvent) {
505
- chartRef.value?.handlePointerEvent(e, {
506
- onPointerUp: (event, container) => {
507
- if (drawingController.value?.onPointerUp(event, container)) {
508
- store.actions.setDrawings(drawingController.value.getDrawings())
509
- return true
510
- }
511
- return false
512
- },
513
- })
514
- }
515
-
516
- function onPointerLeave(e: PointerEvent) {
517
- // pointerleave 不需要绘图控制器路由,直接调用
518
- chartRef.value?.handlePointerEvent(e)
519
- }
520
-
521
- function onRightAxisPointerDown(e: PointerEvent) {
522
- chartRef.value?.handlePointerEvent(e)
523
- }
524
-
525
- function onRightAxisPointerMove(e: PointerEvent) {
526
- chartRef.value?.handlePointerEvent(e)
527
- }
528
-
529
- function onRightAxisPointerUp(e: PointerEvent) {
530
- chartRef.value?.handlePointerEvent(e)
531
- }
532
-
533
- function onRightAxisPointerLeave(e: PointerEvent) {
534
- chartRef.value?.handlePointerEvent(e)
535
- }
536
-
537
- function onScroll() {
538
- chartRef.value?.handleScrollEvent()
539
- }
540
-
541
- // 主图指标显式状态(副图指标从 subPanes 派生)
542
- const mainActiveIndicators = ref<string[]>([])
543
-
544
- // 副图指标列表从 subPanes 自动派生
545
- const subActiveIndicators = computed(() => {
546
- const ids: string[] = []
547
- const seen = new Set<string>()
548
- for (const pane of subPanes.value) {
549
- if (!seen.has(pane.indicatorId)) {
550
- seen.add(pane.indicatorId)
551
- ids.push(pane.indicatorId)
552
- }
553
- }
554
- return ids
555
- })
556
-
557
- // 最终合并列表(主图 + 副图),保持显示顺序
558
- const activeIndicators = computed(() => [
559
- ...mainActiveIndicators.value,
560
- ...subActiveIndicators.value,
561
- ])
562
-
563
- // 指标参数配置(MA 的 periods 是数组,需要更宽松的类型)
564
- const indicatorParams = ref<Record<string, Record<string, unknown>>>({})
565
-
566
- // 副图槽位状态
567
- interface SubPaneSlot {
568
- id: string // pane ID: 'RSI_0', 'MACD_0', ...
569
- indicatorId: SubIndicatorType
570
- params: Record<string, unknown>
571
- }
572
-
573
- // 副图槽位数组(支持多副图)
574
- const subPanes = ref<SubPaneSlot[]>([])
575
-
576
- // 最大副图数量
577
- const maxSubPanes = 4
578
-
579
- function buildPaneLayoutIntent(): PaneSpec[] {
580
- const mainRatio = paneRatios.value['main'] ?? 3
581
- return subPanes.value.length === 0
582
- ? [{ id: 'main', ratio: mainRatio, visible: true, role: 'price' }]
583
- : [
584
- { id: 'main', ratio: mainRatio, visible: true, role: 'price' },
585
- ...subPanes.value.map((pane) => ({
586
- id: pane.id,
587
- ratio: paneRatios.value[pane.id] ?? 1,
588
- visible: true,
589
- role: 'indicator' as const,
590
- })),
591
- ]
592
- }
593
-
594
- // 获取指标默认参数
595
- function getDefaultParams(
596
- indicatorId: SubIndicatorType,
597
- ): Record<string, number | boolean | string> {
598
- return { ...SUB_PANE_INDICATOR_CONFIGS[indicatorId].defaultParams }
599
- }
600
-
601
- // 副图实例计数器:用于生成 'RSI_0', 'MACD_0' 这样的 paneId
602
- const subPaneCounters = new Map<SubIndicatorType, number>()
603
-
604
- function generatePaneId(indicatorId: SubIndicatorType): string {
605
- const count = subPaneCounters.get(indicatorId) ?? 0
606
- subPaneCounters.set(indicatorId, count + 1)
607
- return `${indicatorId}_${count}`
608
- }
609
-
610
- // paneTitle 渲染器名称映射(paneId -> rendererName)
611
- const paneTitleRendererNames = new Map<string, string>()
612
-
613
- function mountSubPaneTitle(paneId: string, indicatorId: SubIndicatorType): void {
614
- const paneTitleRenderer = createPaneTitleRendererPlugin({
615
- paneId,
616
- title: indicatorId,
617
- getTitleInfo: () => getSubPaneTitleInfo(paneId),
618
- })
619
- chartRef.value?.useRenderer(paneTitleRenderer)
620
- paneTitleRendererNames.set(paneId, paneTitleRenderer.name)
621
- }
622
-
623
- function unmountSubPaneTitle(paneId: string): void {
624
- const rendererName = paneTitleRendererNames.get(paneId)
625
- if (rendererName) {
626
- chartRef.value?.removeRenderer(rendererName)
627
- paneTitleRendererNames.delete(paneId)
628
- }
629
- }
630
-
631
- // 添加副图(使用 Chart API)
632
- function addSubPane(
633
- indicatorId: SubIndicatorType = 'VOLUME',
634
- params?: Record<string, number | boolean | string>,
635
- ): boolean {
636
- if (subPanes.value.length >= maxSubPanes) {
637
- return false
638
- }
639
-
640
- const mergedParams = params ?? getDefaultParams(indicatorId)
641
-
642
- // 使用高层 Facade API 创建副图指标
643
- const paneId = chartRef.value?.addIndicator(indicatorId, 'sub', mergedParams)
644
- if (!paneId) return false
645
-
646
- // 创建 paneTitle 渲染器(UI 层职责)
647
- mountSubPaneTitle(paneId, indicatorId)
648
-
649
- // 更新本地状态
650
- subPanes.value.push({
651
- id: paneId,
652
- indicatorId,
653
- params: mergedParams,
654
- })
655
-
656
- scheduleRender()
657
- return true
658
- }
659
-
660
- // 移除副图(使用高层 Facade API)
661
- function removeSubPane(paneId: string): void {
662
- const index = subPanes.value.findIndex((p) => p.id === paneId)
663
- if (index === -1) return
664
-
665
- const pane = subPanes.value[index]
666
- if (!pane) return
667
-
668
- // 移除 paneTitle 渲染器
669
- unmountSubPaneTitle(paneId)
670
-
671
- // 使用高层 Facade API 移除指标
672
- chartRef.value?.removeIndicator(paneId)
673
-
674
- // 更新本地状态
675
- subPanes.value.splice(index, 1)
676
- }
677
-
678
- // 清除所有副图(使用高层 Facade API)
679
- function clearAllSubPanes(): void {
680
- // 使用高层 Facade API 逐个移除
681
- for (const pane of subPanes.value) {
682
- chartRef.value?.removeIndicator(pane.id)
683
- unmountSubPaneTitle(pane.id)
684
- }
685
-
686
- // 清空本地状态
687
- subPanes.value = []
688
- subPaneCounters.clear()
689
- paneTitleRendererNames.clear()
690
- }
691
-
692
- // 从语义化配置初始化指标状态(单向数据流:config → chart)
693
- function initIndicatorsFromConfig(): void {
694
- const config = props.semanticConfig
695
- const chart = chartRef.value
696
- if (!chart) return
697
-
698
- // 初始化主图指标 - 直接调用Chart API
699
- const mainIndicators = config.indicators?.main
700
- if (mainIndicators) {
701
- for (const indicator of mainIndicators) {
702
- if (indicator.enabled) {
703
- // 同步Vue状态(用于UI展示)
704
- if (!mainActiveIndicators.value.includes(indicator.type)) {
705
- mainActiveIndicators.value.push(indicator.type)
706
- }
707
- // 保存参数
708
- if (indicator.params) {
709
- indicatorParams.value[indicator.type] = indicator.params as Record<string, unknown>
710
- }
711
- // 启用指标(Chart内部管理渲染器)
712
- chart.enableMainIndicator(
713
- indicator.type,
714
- indicator.params as Record<string, number | boolean | string>,
715
- )
716
- }
717
- }
718
- }
719
-
720
- // 副图指标参数由 syncSubPanesFromChart 处理
721
- }
722
-
723
- // 监听主图指标参数变化,同步到Chart(状态由Chart管理,Vue只同步参数)
724
- watch(
725
- [activeIndicators, indicatorParams],
726
- ([indicators]) => {
727
- const chart = chartRef.value
728
- if (!chart) return
729
-
730
- // 只更新mainIndicatorLegend的配置(用于图例显示)
731
- // 渲染器的启用/禁用由Chart内部管理
732
- chart.updateRendererConfig('mainIndicatorLegend', {
733
- indicators: {
734
- MA: {
735
- enabled: indicators.includes('MA'),
736
- params: indicatorParams.value['MA'] || {},
737
- },
738
- BOLL: {
739
- enabled: indicators.includes('BOLL'),
740
- params: indicatorParams.value['BOLL'] || {},
741
- },
742
- EXPMA: {
743
- enabled: indicators.includes('EXPMA'),
744
- params: indicatorParams.value['EXPMA'] || {},
745
- },
746
- ENE: {
747
- enabled: indicators.includes('ENE'),
748
- params: indicatorParams.value['ENE'] || {},
749
- },
750
- },
751
- })
752
-
753
- scheduleRender()
754
- },
755
- { deep: true },
756
- )
757
-
758
- // 从 Chart 同步副图状态到本地(语义化配置后调用)
759
- function syncSubPanesFromChart(): void {
760
- const chartSubPaneEntries = chartRef.value?.getSubPaneEntries() ?? []
761
-
762
- // 清空本地状态
763
- subPanes.value = []
764
- paneTitleRendererNames.clear()
765
-
766
- for (const entry of chartSubPaneEntries) {
767
- const { paneId, indicatorId, params } = entry
768
-
769
- // 恢复计数器状态
770
- const match = paneId.match(/^(.+)_(\d+)$/)
771
- if (match) {
772
- const [, indicator, countStr] = match
773
- const count = parseInt(countStr!, 10)
774
- const currentCount = subPaneCounters.get(indicator as SubIndicatorType) ?? 0
775
- if (count >= currentCount) {
776
- subPaneCounters.set(indicator as SubIndicatorType, count + 1)
777
- }
778
- }
779
-
780
- // 创建 paneTitle 渲染器
781
- mountSubPaneTitle(paneId, indicatorId)
782
-
783
- // 更新本地状态
784
- subPanes.value.push({
785
- id: paneId,
786
- indicatorId,
787
- params: { ...params },
788
- })
789
- }
790
-
791
- scheduleRender()
792
- }
793
-
794
- // 切换副图指标(使用 Chart API)
795
- function switchSubIndicator(paneId: string, newIndicatorId: SubIndicatorType): void {
796
- const pane = subPanes.value.find((p) => p.id === paneId)
797
- if (!pane) return
798
-
799
- const nextParams = getDefaultParams(newIndicatorId)
800
-
801
- // 移除旧的 paneTitle 渲染器
802
- unmountSubPaneTitle(paneId)
803
-
804
- // 使用 Chart API 替换副图指标(paneId 不变,只换指标类型)
805
- chartRef.value?.replaceSubPaneIndicator(paneId, newIndicatorId, nextParams)
806
-
807
- // 创建新的 paneTitle 渲染器
808
- mountSubPaneTitle(paneId, newIndicatorId)
809
-
810
- // 更新本地状态(paneId 保持不变)
811
- const index = subPanes.value.findIndex((p) => p.id === paneId)
812
- if (index !== -1) {
813
- subPanes.value[index] = {
814
- id: paneId,
815
- indicatorId: newIndicatorId,
816
- params: nextParams,
817
- }
818
- }
819
- }
820
-
821
- // 获取副图标题信息(带缓存,只在 crosshairIdx 或 data 变化时重算)
822
- const _titleInfoCache = new Map<
823
- string,
824
- { idx: number | null; dataLen: number; result: TitleInfo | null }
825
- >()
826
-
827
- function getSubPaneTitleInfo(paneId: string): TitleInfo | null {
828
- const pane = subPanes.value.find((p) => p.id === paneId)
829
- if (!pane) return null
830
-
831
- const data = chartRef.value?.getData()
832
- if (!data || data.length === 0) return null
833
-
834
- const idx = crosshairIdx.value
835
- const dataLen = data.length
836
-
837
- // 缓存命中:crosshairIdx 和 dataLen 都没变
838
- const cached = _titleInfoCache.get(paneId)
839
- if (cached && cached.idx === idx && cached.dataLen === dataLen) {
840
- return cached.result
841
- }
842
-
843
- const config = SUB_PANE_INDICATOR_CONFIGS[pane.indicatorId]
844
- const params = pane.params as Record<string, number>
845
- const pluginHost = chartRef.value?.plugin
846
- const result = pluginHost ? config.getTitleInfo(data, idx, params, pluginHost, paneId) : null
847
-
848
- _titleInfoCache.set(paneId, { idx, dataLen, result })
849
- return result
850
- }
851
-
852
- // 指标切换处理(使用高层 Facade API)
853
- function handleIndicatorToggle(indicatorId: string, active: boolean) {
854
- const chart = chartRef.value
855
- if (!chart) return
856
-
857
- // 主图指标处理
858
- const mainIndicatorIds = [
859
- 'MA',
860
- 'BOLL',
861
- 'EXPMA',
862
- 'ENE',
863
- 'WMA',
864
- 'DEMA',
865
- 'TEMA',
866
- 'HMA',
867
- 'KAMA',
868
- 'SAR',
869
- 'SUPERTREND',
870
- 'KELTNER',
871
- 'DONCHIAN',
872
- 'ICHIMOKU',
873
- 'PIVOT',
874
- 'FIB',
875
- 'STRUCTURE',
876
- 'ZONES',
877
- ]
878
- if (mainIndicatorIds.includes(indicatorId)) {
879
- const existingIndicator = mainActiveIndicators.value.find((id) => id === indicatorId)
880
-
881
- if (active && !existingIndicator) {
882
- // 添加主图指标
883
- chart.addIndicator(indicatorId, 'main', indicatorParams.value[indicatorId])
884
- mainActiveIndicators.value.push(indicatorId)
885
- } else if (!active && existingIndicator) {
886
- // 移除主图指标
887
- const instanceId = indicatorId.toUpperCase()
888
- chart.removeIndicator(instanceId)
889
- mainActiveIndicators.value = mainActiveIndicators.value.filter((id) => id !== indicatorId)
890
- }
891
- return
892
- }
893
-
894
- // 副图指标处理
895
- if (SUB_PANE_INDICATORS.includes(indicatorId as SubIndicatorType)) {
896
- if (active) {
897
- // 如果已存在同类型指标 pane,跳过
898
- const existingPane = subPanes.value.find((p) => p.indicatorId === indicatorId)
899
- if (existingPane) return
900
-
901
- // 副图数量上限检查
902
- if (subPanes.value.length >= maxSubPanes) return
903
-
904
- // 使用高层 API 添加副图指标
905
- const paneId = chart.addIndicator(indicatorId, 'sub', indicatorParams.value[indicatorId])
906
- if (paneId) {
907
- // 创建 paneTitle 渲染器
908
- mountSubPaneTitle(paneId, indicatorId as SubIndicatorType)
909
- // 同步本地状态
910
- subPanes.value.push({
911
- id: paneId,
912
- indicatorId: indicatorId as SubIndicatorType,
913
- params: { ...indicatorParams.value[indicatorId] },
914
- })
915
- } else if (subPanes.value.length > 0) {
916
- // 添加失败(可能达到上限),替换最后一个
917
- const lastPane = subPanes.value[subPanes.value.length - 1]
918
- switchSubIndicator(lastPane.id, indicatorId as SubIndicatorType)
919
- }
920
- } else {
921
- // 找到并移除该指标的所有 pane
922
- const panesToRemove = subPanes.value.filter((p) => p.indicatorId === indicatorId)
923
- panesToRemove.forEach((pane) => {
924
- chart.removeIndicator(pane.id)
925
- unmountSubPaneTitle(pane.id)
926
- })
927
- subPanes.value = subPanes.value.filter((p) => p.indicatorId !== indicatorId)
928
- }
929
- scheduleRender()
930
- }
931
- }
932
-
933
- // 更新主图指标图例配置
934
- function updateMainIndicatorLegendConfig() {
935
- chartRef.value?.updateRendererConfig('mainIndicatorLegend', {
936
- indicators: {
937
- MA: {
938
- enabled: activeIndicators.value.includes('MA'),
939
- params: indicatorParams.value['MA'] || {},
940
- },
941
- BOLL: {
942
- enabled: activeIndicators.value.includes('BOLL'),
943
- params: indicatorParams.value['BOLL'] || {},
944
- },
945
- EXPMA: {
946
- enabled: activeIndicators.value.includes('EXPMA'),
947
- params: indicatorParams.value['EXPMA'] || {},
948
- },
949
- ENE: {
950
- enabled: activeIndicators.value.includes('ENE'),
951
- params: indicatorParams.value['ENE'] || {},
952
- },
953
- },
954
- })
955
- }
956
-
957
- // 指标参数更新处理
958
- function handleUpdateParams(indicatorId: string, params: Record<string, unknown>) {
959
- // 保存参数配置
960
- indicatorParams.value[indicatorId] = params
961
-
962
- // 主图指标参数更新 - 使用Chart API
963
- if (
964
- indicatorId === 'MA' ||
965
- indicatorId === 'BOLL' ||
966
- indicatorId === 'EXPMA' ||
967
- indicatorId === 'ENE'
968
- ) {
969
- chartRef.value?.updateMainIndicatorParams(
970
- indicatorId,
971
- params as Record<string, number | boolean | string>,
972
- )
973
- scheduleRender()
974
- return
975
- }
976
-
977
- if (SUB_PANE_INDICATORS.includes(indicatorId as SubIndicatorType)) {
978
- subPanes.value
979
- .filter((p) => p.indicatorId === indicatorId)
980
- .forEach((pane) => {
981
- chartRef.value?.updateSubPaneParams(pane.id, params)
982
- pane.params = { ...params }
983
- })
984
- scheduleRender()
985
- return
986
- }
987
-
988
- scheduleRender()
989
- }
990
-
991
- function handleReorderSubIndicators(orderedIndicatorIds: string[]) {
992
- if (!orderedIndicatorIds.length || subPanes.value.length <= 1) return
993
-
994
- const validOrder = orderedIndicatorIds.filter((id): id is SubIndicatorType =>
995
- SUB_PANE_INDICATORS.includes(id as SubIndicatorType),
996
- )
997
- if (!validOrder.length) return
998
-
999
- const paneByIndicator = new Map(subPanes.value.map((pane) => [pane.indicatorId, pane] as const))
1000
- const nextSubPanes: SubPaneSlot[] = []
1001
-
1002
- for (const indicatorId of validOrder) {
1003
- const pane = paneByIndicator.get(indicatorId)
1004
- if (pane) {
1005
- nextSubPanes.push(pane)
1006
- paneByIndicator.delete(indicatorId)
1007
- }
1008
- }
1009
-
1010
- if (nextSubPanes.length === 0) return
1011
-
1012
- for (const pane of subPanes.value) {
1013
- if (paneByIndicator.has(pane.indicatorId)) {
1014
- nextSubPanes.push(pane)
1015
- paneByIndicator.delete(pane.indicatorId)
1016
- }
1017
- }
1018
-
1019
- const currentSubIds = subPanes.value.map((p) => p.id)
1020
- const nextSubIds = nextSubPanes.map((p) => p.id)
1021
- if (currentSubIds.join('|') === nextSubIds.join('|')) return
1022
-
1023
- subPanes.value = nextSubPanes
1024
-
1025
- // activeIndicators 由 computed 自动派生,无需手动同步
1026
-
1027
- const chart = chartRef.value
1028
- if (!chart) return
1029
- chart.updatePaneLayout(buildPaneLayoutIntent())
1030
- }
1031
-
1032
- /* 计算总宽度:从 Vue 响应式状态读取,zoom 变化时自动重算 */
1033
- const axisHostWidth = computed(() => props.rightAxisWidth + props.priceLabelWidth)
1034
-
1035
- const TRAILING_DRAWING_SLOTS_VAL = TRAILING_DRAWING_SLOTS
1036
-
1037
- const totalWidth = store.computed.totalWidth
1038
-
1039
- // 缩放由 Chart 回调驱动 scrollLeft 与渲染时序。
1040
-
1041
- function scrollToRight() {
1042
- const container = containerRef.value
1043
- const chart = chartRef.value
1044
- if (!container || !chart) return
1045
-
1046
- const dataLength = chart.getData()?.length ?? 0
1047
- if (dataLength === 0) return
1048
-
1049
- const dpr = chart.getCurrentDpr()
1050
- const { unitPx, startXPx } = getPhysicalKLineConfig(kWidth.value, kGap.value, dpr)
1051
-
1052
- // 计算最后一根K线的结束位置(不含 TRAILING_DRAWING_SLOTS)
1053
- const lastKLineEndPx = (startXPx + dataLength * unitPx) / dpr
1054
-
1055
- // 计算最大可滚动距离
1056
- const maxScrollLeft = Math.max(0, container.scrollWidth - container.clientWidth)
1057
-
1058
- // 计算需要的滚动位置,使最后一根K线紧贴最右侧
1059
- const targetScrollLeft = Math.min(
1060
- maxScrollLeft,
1061
- Math.max(0, lastKLineEndPx - container.clientWidth),
1062
- )
1063
-
1064
- container.scrollLeft = Math.round(targetScrollLeft * dpr) / dpr
1065
- scheduleRender()
1066
- }
1067
-
1068
- /* 缩放到指定级别(通过 Chart facade API) */
1069
- function applyZoomToLevel(targetLevel: number, anchorX?: number) {
1070
- const chart = chartRef.value
1071
- if (!chart) return
1072
- chart.zoomToLevel(targetLevel, anchorX)
1073
- }
1074
-
1075
- defineExpose({
1076
- scheduleRender,
1077
- scrollToRight,
1078
- addSubPane,
1079
- removeSubPane,
1080
- switchSubIndicator,
1081
- clearAllSubPanes,
1082
- get plugin() {
1083
- return chartRef.value?.plugin
1084
- },
1085
-
1086
- // Zoom Level API(Vue SSOT)
1087
- zoomToLevel: applyZoomToLevel,
1088
- zoomIn: (anchorX?: number) => applyZoomToLevel(zoomLevel.value + 1, anchorX),
1089
- zoomOut: (anchorX?: number) => applyZoomToLevel(zoomLevel.value - 1, anchorX),
1090
- getZoomLevel: () => zoomLevel.value,
1091
- getZoomLevelCount: () => chartRef.value?.getZoomLevelCount() ?? 10,
1092
- })
1093
-
1094
- // ==================== onMounted 拆分函数 ====================
1095
-
1096
- function setupWheelHandler(container: HTMLDivElement): (e: WheelEvent) => void {
1097
- const onWheelHandler = (e: WheelEvent) => {
1098
- e.preventDefault()
1099
- const chart = chartRef.value
1100
- if (!chart) return
1101
-
1102
- // 使用 Chart facade API 处理滚轮事件
1103
- chart.handleWheelEvent(e)
1104
- }
1105
- container.addEventListener('wheel', onWheelHandler, { passive: false })
1106
- return onWheelHandler
1107
- }
1108
-
1109
- function initChart(
1110
- container: HTMLDivElement,
1111
- canvasLayer: HTMLDivElement,
1112
- rightAxisLayer: HTMLDivElement,
1113
- xAxisCanvas: HTMLCanvasElement,
1114
- ): Chart {
1115
- const chart = new Chart(
1116
- { container, canvasLayer, rightAxisLayer, xAxisCanvas },
1117
- {
1118
- yPaddingPx: props.yPaddingPx,
1119
- rightAxisWidth: props.rightAxisWidth,
1120
- bottomAxisHeight: props.bottomAxisHeight,
1121
- priceLabelWidth: props.priceLabelWidth,
1122
- minKWidth: props.minKWidth,
1123
- maxKWidth: props.maxKWidth,
1124
- panes: [{ id: 'main', ratio: 1 }],
1125
- paneGap: 0,
1126
- zoomLevels: props.zoomLevels,
1127
- initialZoomLevel: props.initialZoomLevel,
1128
- },
1129
- )
1130
- return chart
1131
- }
1132
-
1133
- function setupChartCallbacks(chart: Chart): void {
1134
- // 注意:setOnViewportChange 已合并到 viewport signal 订阅者中
1135
-
1136
- chart.setOnPaneLayoutChange(() => {
1137
- // 分隔线位置计算(需要实际像素位置,保留在回调中)
1138
- invalidateContainerRectCache()
1139
- const renderers = chart.getPaneRenderers()
1140
- const borderTop = containerRef.value
1141
- ? parseInt(getComputedStyle(containerRef.value).borderTopWidth) || 0
1142
- : 0
1143
- paneSeparatorLines.value = renderers.slice(0, -1).map((renderer) => {
1144
- const pane = renderer.getPane()
1145
- return {
1146
- id: pane.id,
1147
- top: pane.top + pane.height + borderTop,
1148
- }
1149
- })
1150
- })
1151
-
1152
- // 订阅 paneRatios signal,同步到 Vue store
1153
- const unsubscribePaneRatios = chart.paneRatios.subscribe(() => {
1154
- const ratios = chart.paneRatios.peek()
1155
- store.actions.setPaneRatios({ ...ratios })
1156
- })
1157
-
1158
- // 订阅 viewport signal,处理缩放、DPR、width 变化和 scrollLeft 更新
1159
- const unsubscribeViewport = chart.viewport.subscribe(() => {
1160
- const vp = chart.viewport.peek()
1161
-
1162
- // DPR 变化时同步到 store
1163
- if (store.state.viewportDpr !== vp.dpr) {
1164
- store.actions.setViewportDpr(vp.dpr)
1165
- }
1166
-
1167
- // ViewWidth 变化时同步到 store
1168
- if (store.state.viewWidth !== vp.plotWidth) {
1169
- store.actions.setViewWidth(vp.plotWidth)
1170
- }
1171
-
1172
- // 完整同步 zoom state 到 Vue store(Chart 是 SSOT)
1173
- if (
1174
- store.state.zoomLevel !== vp.zoomLevel ||
1175
- store.state.kWidth !== vp.kWidth ||
1176
- store.state.kGap !== vp.kGap
1177
- ) {
1178
- store.actions.setZoomState(vp.zoomLevel, vp.kWidth, vp.kGap)
1179
- }
1180
-
1181
- // 在 nextTick 中应用 desiredScrollLeft
1182
- const desiredLeft = vp.desiredScrollLeft
1183
- if (desiredLeft !== undefined && desiredLeft !== containerRef.value?.scrollLeft) {
1184
- invalidateContainerRectCache()
1185
- nextTick(() => {
1186
- const c = containerRef.value
1187
- if (!c) return
1188
- const maxScrollLeft = Math.max(0, c.scrollWidth - c.clientWidth)
1189
- const clampedScrollLeft = Math.min(Math.max(0, desiredLeft), maxScrollLeft)
1190
- const dpr = chart.getCurrentDpr()
1191
- c.scrollLeft = Math.round(clampedScrollLeft * dpr) / dpr
1192
- })
1193
- }
1194
- })
1195
-
1196
- // 订阅 data signal,替换 onDataChange 回调
1197
- const unsubscribeData = chart.data.subscribe(() => {
1198
- const data = chart.data.peek()
1199
- store.actions.setDataLength(data.length)
1200
- store.actions.bumpDataVersion()
1201
- })
1202
-
1203
- // 订阅 theme signal,同步到 CSS data-theme
1204
- const unsubscribeTheme = chart.theme.subscribe(() => {
1205
- const theme = chart.theme.peek()
1206
- chartTheme.value = theme
1207
- })
1208
-
1209
- // 保存 unsubscribe 函数以便清理
1210
- onUnmounted(() => {
1211
- unsubscribeViewport()
1212
- unsubscribeData()
1213
- unsubscribePaneRatios()
1214
- unsubscribeTheme()
1215
- })
1216
- }
1217
-
1218
- function applyInitialSettings(chart: Chart): void {
1219
- const initialSettings = toolbarRef.value?.getSettings() ?? { showVolumePriceMarkers: true }
1220
- chart.updateSettings(initialSettings)
1221
-
1222
- if (initialSettings.performanceTest10kKlines) {
1223
- const testData = generate10kKLineData()
1224
- console.time('updateData-10k')
1225
- chart.updateData(testData)
1226
- console.timeEnd('updateData-10k')
1227
- }
1228
- }
1229
-
1230
- function setupDrawingController(chart: Chart): void {
1231
- drawingController.value = new DrawingInteractionController(chart)
1232
- drawingController.value.setCallbacks({
1233
- onDrawingCreated: (drawing) => {
1234
- store.actions.setDrawings([...store.state.drawings, drawing])
1235
- store.actions.setSelectedDrawingId(drawing.id)
1236
- },
1237
- onToolChange: () => {},
1238
- onDrawingSelected: (drawing) => {
1239
- store.actions.setSelectedDrawingId(drawing?.id ?? null)
1240
- },
1241
- })
1242
- }
1243
-
1244
- function setupInteractionCallbacks(chart: Chart): void {
1245
- chart.interaction.setTooltipAnchorPositioning(useAnchorPositioning.value)
1246
- chart.interaction.setOnInteractionChange((snapshot) => {
1247
- interactionState.value = snapshot
1248
- })
1249
-
1250
- chart.interaction.setOnPinchZoom((delta, centerClientX) => {
1251
- if (!chart) return
1252
- const container = containerRef.value
1253
- if (!container) return
1254
- // centerClientX 是 clientX,需要转换为视口局部坐标
1255
- const rect = container.getBoundingClientRect()
1256
- const centerX = centerClientX - rect.left
1257
- chart.handlePinchZoom(delta, centerX)
1258
- })
1259
-
1260
- interactionState.value = chart.interaction.getInteractionSnapshot()
1261
- store.actions.setViewportDpr(chart.getCurrentDpr())
1262
- chart.resize()
1263
- }
1264
-
1265
- /** 语义化控制器:外部配置 → Chart API 的桥梁 */
1266
- function setupSemanticController(chart: Chart): void {
1267
- __setDataFetcher(props.dataFetcher)
1268
- semanticController.value = new SemanticChartController(chart)
1269
-
1270
- semanticController.value.on('config:error', (error) => {
1271
- console.error('Semantic config error:', error)
1272
- })
1273
-
1274
- // config:ready → Chart 侧已完成创建,Vue 回读状态
1275
- semanticController.value.on('config:ready', () => {
1276
- initIndicatorsFromConfig()
1277
- syncSubPanesFromChart()
1278
- nextTick(() => scrollToRight())
1279
- })
1280
- // 应用副图、主图配置
1281
- semanticController.value.applyConfig(props.semanticConfig).then((result) => {
1282
- if (result && !result.success) {
1283
- console.error('Semantic config apply failed:', result.errors)
1284
- }
1285
- })
1286
- }
1287
-
1288
- onMounted(() => {
1289
- useAnchorPositioning.value = false
1290
-
1291
- const container = containerRef.value
1292
- const canvasLayer = canvasLayerRef.value
1293
- const rightAxisLayer = rightAxisLayerRef.value
1294
- const xAxisCanvas = xAxisCanvasRef.value
1295
- if (!container || !canvasLayer || !rightAxisLayer || !xAxisCanvas) return
1296
-
1297
- // 1) 滚轮缩放:passive:false 以阻止页面滚动
1298
- const onWheelHandler = setupWheelHandler(container)
1299
-
1300
- // 2) 创建 Chart 实例并注册全部渲染器
1301
- const chart = initChart(container, canvasLayer, rightAxisLayer, xAxisCanvas)
1302
- chartRef.value = chart
1303
-
1304
- // 3) 视口 / 面板布局 / 数据变更回调
1305
- setupChartCallbacks(chart)
1306
-
1307
- // 4) 同步 zoom 状态(Vue SSOT → Chart)
1308
- chart.applyRenderState(store.state.kWidth, store.state.kGap, store.state.zoomLevel)
1309
-
1310
- // 5) 工具栏初始设置(含性能测试数据)
1311
- applyInitialSettings(chart)
1312
-
1313
- // 6) 绘图交互控制器(线段/箭头等)
1314
- setupDrawingController(chart)
1315
-
1316
- // 7) 十字线、捏合缩放、初始交互快照
1317
- setupInteractionCallbacks(chart)
1318
-
1319
- // 8) 语义化配置控制器(最终驱动数据加载)
1320
- setupSemanticController(chart)
1321
-
1322
- // 供 onUnmounted 移除 wheel 监听
1323
- ;(chart as any).__onWheel = onWheelHandler
1324
- })
1325
-
1326
- onUnmounted(() => {
1327
- const chart = chartRef.value
1328
- if (chart) {
1329
- const onWheel = (chart as any).__onWheel as
1330
- | ((this: HTMLElement, ev: WheelEvent) => any)
1331
- | undefined
1332
- const container = containerRef.value
1333
- if (onWheel && container) container.removeEventListener('wheel', onWheel)
1334
- chart.destroy()
1335
- }
1336
- chartRef.value = null
1337
- drawingController.value = null
1338
- })
1339
-
1340
- // kWidth/kGap 由 zoomLevel 派生,不再通过 props 直接修改
1341
- // 如需程序化控制缩放,请使用 expose 的 zoomToLevel/zoomIn/zoomOut 方法
1342
-
1343
- // 监听 yPaddingPx 变化
1344
- watch(
1345
- () => props.yPaddingPx,
1346
- (newVal) => {
1347
- chartRef.value?.updateOptions({ yPaddingPx: newVal })
1348
- },
1349
- )
1350
-
1351
- // 监听 semanticConfig 变化(唯一数据源)
1352
- watch(
1353
- () => props.semanticConfig,
1354
- async (newConfig, oldConfig) => {
1355
- if (newConfig && newConfig !== oldConfig) {
1356
- const result = await semanticController.value?.applyConfig(newConfig)
1357
- if (result && !result.success) {
1358
- console.error('Semantic config apply failed:', result.errors)
1359
- }
1360
- }
1361
- },
1362
- { deep: true },
1363
- )
1364
- </script>
1365
-
1366
- <style scoped>
1367
- .chart-wrapper {
1368
- --kmap-height: var(--kmap-chart-height, 100%);
1369
- --kmap-width: var(--kmap-chart-width, 100%);
1370
-
1371
- --chart-bg: #ffffff;
1372
- --chart-bg-secondary: #f8f9fa;
1373
- --chart-border: #e5e7eb;
1374
- --chart-border-active: #3b82f6;
1375
- --chart-text: #374151;
1376
- --chart-text-secondary: #6b7280;
1377
-
1378
- display: flex;
1379
- align-items: center;
1380
- justify-content: center;
1381
- width: var(--kmap-width);
1382
- height: var(--kmap-height);
1383
- min-height: 300px;
1384
- flex-direction: column;
1385
- }
1386
-
1387
- .chart-wrapper[data-theme='dark'] {
1388
- --chart-bg: #1a1a2e;
1389
- --chart-bg-secondary: #16162a;
1390
- --chart-border: #2d2d44;
1391
- --chart-border-active: #60a5fa;
1392
- --chart-text: #e5e7eb;
1393
- --chart-text-secondary: #9ca3af;
1394
- }
1395
-
1396
- .chart-stage {
1397
- width: 95%;
1398
- height: 85%;
1399
- min-height: 255px;
1400
- display: flex;
1401
- align-items: stretch;
1402
- gap: 8px;
1403
- }
1404
-
1405
- .chart-main {
1406
- flex: 1 1 auto;
1407
- min-width: 0;
1408
- height: 100%;
1409
- display: flex;
1410
- align-items: stretch;
1411
- gap: 0;
1412
- position: relative;
1413
- }
1414
-
1415
- .pane-separator-layer {
1416
- position: absolute;
1417
- inset: 0;
1418
- pointer-events: none;
1419
- z-index: 20;
1420
- }
1421
-
1422
- .pane-separator-line {
1423
- position: absolute;
1424
- left: 0;
1425
- right: 0;
1426
- height: 0;
1427
- border-top: 1px solid var(--chart-border);
1428
- opacity: 1;
1429
- box-sizing: border-box;
1430
- transition:
1431
- border-top-color 120ms ease,
1432
- border-top-width 120ms ease,
1433
- margin-top 120ms ease,
1434
- opacity 120ms ease;
1435
- }
1436
-
1437
- .pane-separator-line.is-active {
1438
- border-top-color: var(--chart-border-active);
1439
- border-top-width: 2px;
1440
- margin-top: -1px;
1441
- }
1442
-
1443
- .chart-stage.is-resizing-pane,
1444
- .chart-stage.is-hovering-pane-separator {
1445
- cursor: ns-resize;
1446
- }
1447
-
1448
- .chart-stage.is-hovering-kline {
1449
- cursor: pointer;
1450
- }
1451
-
1452
- .chart-stage.is-hovering-right-axis {
1453
- cursor: ns-resize;
1454
- }
1455
-
1456
- .chart-stage.is-dragging {
1457
- cursor: grabbing;
1458
- }
1459
-
1460
- .chart-container {
1461
- position: relative;
1462
- flex: 1 1 auto;
1463
- overflow-x: auto;
1464
- overflow-y: hidden;
1465
- height: 100%;
1466
- min-height: inherit;
1467
- scrollbar-width: none;
1468
- -ms-overflow-style: none;
1469
- border: 1px solid var(--chart-border);
1470
- border-right: 0;
1471
- border-radius: 6px 0 0 6px;
1472
- box-sizing: border-box;
1473
- background: var(--chart-bg);
1474
-
1475
- -webkit-touch-callout: none;
1476
- -webkit-user-select: none;
1477
- user-select: none;
1478
- touch-action: none;
1479
- }
1480
-
1481
- .chart-container::-webkit-scrollbar {
1482
- display: none;
1483
- }
1484
-
1485
- .right-axis-host {
1486
- position: relative;
1487
- flex: 0 0 auto;
1488
- height: 100%;
1489
- min-height: inherit;
1490
- box-sizing: border-box;
1491
- background: var(--chart-bg);
1492
- overflow: visible;
1493
- border: 1px solid var(--chart-border);
1494
- border-top-right-radius: 6px;
1495
- border-bottom-right-radius: 6px;
1496
-
1497
- -webkit-touch-callout: none;
1498
- -webkit-user-select: none;
1499
- user-select: none;
1500
- touch-action: none;
1501
- }
1502
-
1503
- .scroll-content {
1504
- height: 100%;
1505
- min-height: inherit;
1506
- position: relative;
1507
- }
1508
-
1509
- .canvas-layer {
1510
- position: sticky;
1511
- left: 0;
1512
- top: 0;
1513
- pointer-events: none;
1514
- }
1515
-
1516
- .tooltip-layer {
1517
- position: absolute;
1518
- inset: 0;
1519
- pointer-events: none;
1520
- z-index: 30;
1521
- }
1522
-
1523
- .tooltip-anchor {
1524
- position: absolute;
1525
- width: 1px;
1526
- height: 1px;
1527
- pointer-events: none;
1528
- }
1529
-
1530
- .tooltip-anchor.kline-tooltip-anchor.use-anchor {
1531
- anchor-name: --kline-tooltip-anchor;
1532
- }
1533
-
1534
- .tooltip-anchor.marker-tooltip-anchor.use-anchor {
1535
- anchor-name: --marker-tooltip-anchor;
1536
- }
1537
-
1538
- @media (max-width: 768px), (max-height: 640px) {
1539
- .chart-stage {
1540
- gap: 6px;
1541
- }
1542
- }
1543
- </style>
1544
-
1545
- <style>
1546
- .plot-canvas {
1547
- position: absolute;
1548
- left: 0;
1549
- top: 0;
1550
- display: block;
1551
- }
1552
-
1553
- .right-axis {
1554
- position: absolute;
1555
- display: block;
1556
- left: 0;
1557
- }
1558
-
1559
- .x-axis-canvas {
1560
- position: absolute;
1561
- left: 0;
1562
- bottom: 0;
1563
- display: block;
1564
- z-index: 10;
1565
- }
1566
-
1567
- .right-axis {
1568
- z-index: 15;
1569
- }
1570
- </style>
1
+ <template>
2
+ <div class="chart-wrapper" :data-theme="chartTheme">
3
+ <div
4
+ class="chart-stage"
5
+ :class="{
6
+ 'is-dragging': isDragging,
7
+ 'is-resizing-pane': isResizingPane,
8
+ 'is-hovering-pane-separator': isHoveringPaneSeparator,
9
+ 'is-hovering-right-axis': isHoveringRightAxis,
10
+ 'is-hovering-kline': hoveredIdx !== null,
11
+ }"
12
+ >
13
+ <LeftToolbar
14
+ ref="toolbarRef"
15
+ :is-fullscreen="isFullscreen"
16
+ @select-tool="handleSelectTool"
17
+ @toggle-fullscreen="$emit('toggleFullscreen')"
18
+ @zoom-in="applyZoomToLevel(zoomLevel + 1)"
19
+ @zoom-out="applyZoomToLevel(zoomLevel - 1)"
20
+ @settings-change="handleSettingsChange"
21
+ />
22
+ <div class="chart-main" ref="chartMainRef">
23
+ <div class="pane-separator-layer" aria-hidden="true">
24
+ <div
25
+ v-for="line in paneSeparatorLines"
26
+ :key="line.id"
27
+ class="pane-separator-line"
28
+ :class="{ 'is-active': hoveredPaneBoundaryId === line.id }"
29
+ :style="{ top: `${line.top}px` }"
30
+ ></div>
31
+ </div>
32
+ <div ref="tooltipLayerRef" class="tooltip-layer"></div>
33
+ <div
34
+ class="chart-container"
35
+ :style="{ cursor: containerCursor }"
36
+ ref="containerRef"
37
+ @scroll.passive="onScroll"
38
+ @pointerdown="onPointerDown"
39
+ @pointermove="onPointerMove"
40
+ @pointerup="onPointerUp"
41
+ @pointerleave="onPointerLeave"
42
+ >
43
+ <div class="scroll-content" :style="{ width: totalWidth + 'px' }">
44
+ <div class="canvas-layer" ref="canvasLayerRef">
45
+ <canvas class="x-axis-canvas" ref="xAxisCanvasRef"></canvas>
46
+
47
+ <DrawingStyleToolbar
48
+ v-if="selectedDrawing"
49
+ :drawing="selectedDrawing"
50
+ @update-style="onUpdateDrawingStyle"
51
+ @delete="onDeleteDrawing"
52
+ />
53
+ </div>
54
+ </div>
55
+ </div>
56
+ <Teleport v-if="tooltipLayerRef" :to="tooltipLayerRef">
57
+ <div
58
+ v-if="hovered"
59
+ class="tooltip-anchor kline-tooltip-anchor"
60
+ :class="{ 'use-anchor': useAnchorPositioning }"
61
+ :style="klineTooltipAnchorStyle"
62
+ ></div>
63
+ <div
64
+ v-if="hoveredMarker || hoveredCustomMarker"
65
+ class="tooltip-anchor marker-tooltip-anchor"
66
+ :class="{ 'use-anchor': useAnchorPositioning }"
67
+ :style="markerTooltipAnchorStyle"
68
+ ></div>
69
+ <KLineTooltip
70
+ v-if="hovered"
71
+ :k="hovered"
72
+ :index="hoveredIndex"
73
+ :data="chartData"
74
+ :pos="teleportedTooltipPos"
75
+ :set-el="setTooltipEl"
76
+ :use-anchor="useAnchorPositioning"
77
+ :anchor-placement="tooltipAnchorPlacement"
78
+ />
79
+ <MarkerTooltip
80
+ v-if="hoveredMarker || hoveredCustomMarker"
81
+ :marker="hoveredMarker || hoveredCustomMarker"
82
+ :pos="teleportedMarkerTooltipPos"
83
+ :use-anchor="useAnchorPositioning"
84
+ :anchor-placement="markerTooltipAnchorPlacement"
85
+ :set-el="setMarkerTooltipEl"
86
+ />
87
+ </Teleport>
88
+ <div
89
+ class="right-axis-host"
90
+ ref="rightAxisLayerRef"
91
+ :style="{ width: axisHostWidth + 'px' }"
92
+ @pointerdown="onRightAxisPointerDown"
93
+ @pointermove="onRightAxisPointerMove"
94
+ @pointerup="onRightAxisPointerUp"
95
+ @pointerleave="onRightAxisPointerLeave"
96
+ ></div>
97
+ </div>
98
+ </div>
99
+ <IndicatorSelector
100
+ :active-indicators="activeIndicators"
101
+ :indicator-params="indicatorParams"
102
+ @toggle="handleIndicatorToggle"
103
+ @update-params="handleUpdateParams"
104
+ @reorder-sub-indicators="handleReorderSubIndicators"
105
+ />
106
+ </div>
107
+ </template>
108
+
109
+ <script setup lang="ts">
110
+ import { ref, computed, onMounted, onUnmounted, watch, nextTick, shallowRef } from 'vue'
111
+ import {
112
+ SemanticChartController,
113
+ __setDataFetcher,
114
+ type SemanticChartConfig,
115
+ type DataFetcher,
116
+ } from '@363045841yyt/klinechart-core/semantic'
117
+ import KLineTooltip from './KLineTooltip.vue'
118
+ import MarkerTooltip from './MarkerTooltip.vue'
119
+ import IndicatorSelector from './IndicatorSelector.vue'
120
+ import DrawingStyleToolbar from './DrawingStyleToolbar.vue'
121
+ import { Chart, type PaneSpec } from '@363045841yyt/klinechart-core/engine/chart'
122
+ import type { KLineData } from '@363045841yyt/klinechart-core/types/price'
123
+ import {
124
+ createChartStore,
125
+ TRAILING_DRAWING_SLOTS,
126
+ type ChartStore,
127
+ } from '@363045841yyt/klinechart-core/engine/chart-store'
128
+ import { zoomLevelToKWidth, kGapFromKWidth } from '@363045841yyt/klinechart-core/engine/utils/zoom'
129
+ import { getPhysicalKLineConfig } from '@363045841yyt/klinechart-core/engine/utils/klineConfig'
130
+ import { type SubIndicatorType } from '@363045841yyt/klinechart-core/engine/renderers/Indicator'
131
+ import {
132
+ SUB_PANE_INDICATOR_CONFIGS,
133
+ SUB_PANE_INDICATORS,
134
+ } from '@363045841yyt/klinechart-core/engine/renderers/Indicator/subPaneConfig'
135
+ import {
136
+ createPaneTitleRendererPlugin,
137
+ type TitleInfo,
138
+ } from '@363045841yyt/klinechart-core/engine/renderers/paneTitle'
139
+ import type { InteractionSnapshot } from '@363045841yyt/klinechart-core/engine/controller/interaction'
140
+ import type { DrawingStyle } from '@363045841yyt/klinechart-core/plugin'
141
+ import LeftToolbar from './LeftToolbar.vue'
142
+ import {
143
+ DrawingInteractionController,
144
+ type DrawingToolId,
145
+ } from '@363045841yyt/klinechart-core/engine/drawing'
146
+
147
+ const props = withDefaults(
148
+ defineProps<{
149
+ /** 语义化配置(必需,唯一控制源) */
150
+ semanticConfig: SemanticChartConfig
151
+
152
+ /** 数据获取函数(必需)。框架不绑定数据源,由使用者注入。 */
153
+ dataFetcher: DataFetcher
154
+
155
+ yPaddingPx?: number
156
+ minKWidth?: number
157
+ maxKWidth?: number
158
+ /** 右侧价格轴宽度 */
159
+ rightAxisWidth?: number
160
+ /** 底部时间轴高度 */
161
+ bottomAxisHeight?: number
162
+ /** 价格标签额外宽度(用于显示涨跌幅,默认 60px) */
163
+ priceLabelWidth?: number
164
+
165
+ /** 缩放级别数量(默认 10) */
166
+ zoomLevels?: number
167
+ /** 初始缩放级别(1 ~ zoomLevels,默认居中) */
168
+ initialZoomLevel?: number
169
+ /** 是否全屏 */
170
+ isFullscreen?: boolean
171
+ }>(),
172
+ {
173
+ yPaddingPx: 20,
174
+ minKWidth: 1,
175
+ maxKWidth: 50,
176
+ rightAxisWidth: 0,
177
+ bottomAxisHeight: 24,
178
+ priceLabelWidth: 60,
179
+ zoomLevels: 20,
180
+ initialZoomLevel: 3,
181
+ isFullscreen: false,
182
+ },
183
+ )
184
+
185
+ const emit = defineEmits<{
186
+ (e: 'zoomLevelChange', level: number, kWidth: number): void
187
+ (e: 'toggleFullscreen'): void
188
+ }>()
189
+
190
+ const xAxisCanvasRef = ref<HTMLCanvasElement | null>(null)
191
+ const canvasLayerRef = ref<HTMLDivElement | null>(null)
192
+ const rightAxisLayerRef = ref<HTMLDivElement | null>(null)
193
+ const containerRef = ref<HTMLDivElement | null>(null)
194
+ const chartMainRef = ref<HTMLDivElement | null>(null)
195
+ const tooltipLayerRef = ref<HTMLDivElement | null>(null)
196
+ const toolbarRef = ref<InstanceType<typeof LeftToolbar> | null>(null)
197
+
198
+ /* ========== 十字线(鼠标悬停位置) ========== */
199
+ const chartRef = shallowRef<Chart | null>(null)
200
+
201
+ /* ========== 语义化控制器 ========== */
202
+ const semanticController = shallowRef<SemanticChartController | null>(null)
203
+
204
+ /* ========== ChartStore(响应式状态中心) ========== */
205
+ const store = createChartStore({
206
+ initialZoomLevel: props.initialZoomLevel ?? 1,
207
+ minKWidth: props.minKWidth,
208
+ maxKWidth: props.maxKWidth,
209
+ zoomLevels: props.zoomLevels,
210
+ rightAxisWidth: props.rightAxisWidth,
211
+ priceLabelWidth: props.priceLabelWidth,
212
+ })
213
+
214
+ /* ========== 主题状态 ========== */
215
+ const chartTheme = ref<'light' | 'dark'>('light')
216
+
217
+ // 初始化 kWidth / kGap
218
+ store.actions.setZoomState(
219
+ store.state.zoomLevel,
220
+ zoomLevelToKWidth(store.state.zoomLevel, {
221
+ minKWidth: props.minKWidth,
222
+ maxKWidth: props.maxKWidth,
223
+ zoomLevelCount: props.zoomLevels,
224
+ dpr: store.state.viewportDpr,
225
+ }),
226
+ kGapFromKWidth(
227
+ zoomLevelToKWidth(store.state.zoomLevel, {
228
+ minKWidth: props.minKWidth,
229
+ maxKWidth: props.maxKWidth,
230
+ zoomLevelCount: props.zoomLevels,
231
+ dpr: store.state.viewportDpr,
232
+ }),
233
+ store.state.viewportDpr,
234
+ ),
235
+ )
236
+
237
+ // 为逐步迁移保留的局部别名
238
+ const dataLength = computed(() => store.state.dataLength)
239
+ const viewportDpr = computed(() => store.state.viewportDpr)
240
+ const zoomLevel = computed(() => store.state.zoomLevel)
241
+ const kWidth = computed(() => store.state.kWidth)
242
+ const kGap = computed(() => store.state.kGap)
243
+ const paneRatios = computed(() => store.state.paneRatios)
244
+ const selectedDrawingId = computed(() => store.state.selectedDrawingId)
245
+ const dataVersion = computed(() => store.state.dataVersion)
246
+
247
+ function scheduleRender() {
248
+ chartRef.value?.scheduleDraw()
249
+ }
250
+
251
+ function handleSettingsChange(settings: Record<string, boolean | string>) {
252
+ chartRef.value?.updateSettings(settings)
253
+
254
+ // 万条K线性能测试
255
+ if (settings.performanceTest10kKlines) {
256
+ const testData = generate10kKLineData()
257
+ console.time('updateData-10k')
258
+ chartRef.value?.updateData(testData)
259
+ console.timeEnd('updateData-10k')
260
+ store.actions.setDataLength(testData.length)
261
+ store.actions.bumpDataVersion()
262
+ } else {
263
+ // 如果关闭性能测试,恢复原始数据
264
+ // 通过重新应用语义化配置来恢复
265
+ if (semanticController.value && chartRef.value?.getData()?.length === 10000) {
266
+ semanticController.value.applyConfig(props.semanticConfig)
267
+ }
268
+ }
269
+ }
270
+
271
+ // 生成1万条K线测试数据
272
+ function generate10kKLineData() {
273
+ const data: KLineData[] = []
274
+ const startTime = new Date('2020-01-01').getTime()
275
+ const dayMs = 24 * 60 * 60 * 1000
276
+
277
+ let lastClose = 3000 // 起始价格
278
+
279
+ for (let i = 0; i < 10000; i++) {
280
+ const timestamp = startTime + i * dayMs
281
+
282
+ // 生成随机波动
283
+ const volatility = 0.02 // 2%日波动率
284
+ const trend = 0.0001 // 轻微上涨趋势
285
+ const change = (Math.random() - 0.5) * 2 * volatility + trend
286
+
287
+ const open = lastClose
288
+ const close = open * (1 + change)
289
+ const high = Math.max(open, close) * (1 + Math.random() * 0.01)
290
+ const low = Math.min(open, close) * (1 - Math.random() * 0.01)
291
+ const volume = Math.floor(1000000 + Math.random() * 5000000)
292
+
293
+ data.push({
294
+ timestamp,
295
+ open: parseFloat(open.toFixed(2)),
296
+ high: parseFloat(high.toFixed(2)),
297
+ low: parseFloat(low.toFixed(2)),
298
+ close: parseFloat(close.toFixed(2)),
299
+ volume,
300
+ })
301
+
302
+ lastClose = close
303
+ }
304
+
305
+ return data
306
+ }
307
+
308
+ function measureTooltipSize(el: HTMLDivElement, minWidth: number, minHeight: number) {
309
+ const r = el.getBoundingClientRect()
310
+ return {
311
+ width: Math.max(minWidth, Math.round(r.width)),
312
+ height: Math.max(minHeight, Math.round(r.height)),
313
+ }
314
+ }
315
+
316
+ function setTooltipEl(el: HTMLDivElement | null) {
317
+ if (!el) return
318
+ nextTick(() => {
319
+ if (!el.isConnected) return
320
+ const size = measureTooltipSize(el, 180, 80)
321
+ chartRef.value?.interaction.setTooltipSize(size)
322
+ })
323
+ }
324
+
325
+ function setMarkerTooltipEl(el: HTMLDivElement | null) {
326
+ if (!el) return
327
+ nextTick(() => {
328
+ if (!el.isConnected) return
329
+ markerTooltipSize.value = measureTooltipSize(el, 120, 60)
330
+ })
331
+ }
332
+
333
+ // ===== Marker tooltip 状态 =====
334
+ const mousePos = ref({ x: 0, y: 0 })
335
+ const useAnchorPositioning = ref(false)
336
+
337
+ // 容器 rect 缓存,避免 pointermove 中反复 getBoundingClientRect 强制同步布局
338
+ let _cachedContainerRect: DOMRect | null = null
339
+ function invalidateContainerRectCache(): void {
340
+ _cachedContainerRect = null
341
+ }
342
+ function getContainerRect(container: HTMLDivElement): DOMRect {
343
+ if (!_cachedContainerRect) {
344
+ _cachedContainerRect = container.getBoundingClientRect()
345
+ }
346
+ return _cachedContainerRect
347
+ }
348
+
349
+ // ===== 交互状态(单一来源:InteractionController snapshot) =====
350
+ const interactionState = shallowRef<InteractionSnapshot>({
351
+ crosshairPos: null,
352
+ crosshairIndex: null,
353
+ crosshairPrice: null,
354
+ hoveredIndex: null,
355
+ activePaneId: null,
356
+ tooltipPos: { x: 0, y: 0 },
357
+ tooltipAnchorPlacement: 'right-bottom',
358
+ hoveredMarkerData: null,
359
+ hoveredCustomMarker: null,
360
+ isDragging: false,
361
+ isResizingPaneBoundary: false,
362
+ isHoveringPaneBoundary: false,
363
+ hoveredPaneBoundaryId: null,
364
+ isHoveringRightAxis: false,
365
+ })
366
+
367
+ const drawingController = shallowRef<DrawingInteractionController | null>(null)
368
+ const selectedDrawing = computed(() => {
369
+ const id = selectedDrawingId.value
370
+ if (!id) return null
371
+ return store.state.drawings.find((d) => d.id === id) ?? null
372
+ })
373
+ const paneSeparatorLines = ref<Array<{ id: string; top: number }>>([])
374
+ const markerTooltipSize = ref({ width: 220, height: 120 })
375
+ const tooltipLayerOffset = computed(() => {
376
+ const container = containerRef.value
377
+ const chartMain = chartMainRef.value
378
+ if (!container || !chartMain) return { x: 0, y: 0 }
379
+ return {
380
+ x: container.offsetLeft,
381
+ y: container.offsetTop,
382
+ }
383
+ })
384
+
385
+ const hoveredMarker = computed(() => interactionState.value.hoveredMarkerData)
386
+ const hoveredCustomMarker = computed(() => interactionState.value.hoveredCustomMarker)
387
+ const isDragging = computed(() => interactionState.value.isDragging)
388
+ const isResizingPane = computed(() => interactionState.value.isResizingPaneBoundary)
389
+ const isHoveringPaneSeparator = computed(() => interactionState.value.isHoveringPaneBoundary)
390
+ const hoveredPaneBoundaryId = computed(() => interactionState.value.hoveredPaneBoundaryId)
391
+ const isHoveringRightAxis = computed(() => interactionState.value.isHoveringRightAxis)
392
+ const hoveredIdx = computed(() => interactionState.value.hoveredIndex)
393
+ const crosshairIdx = computed(() => interactionState.value.crosshairIndex)
394
+
395
+ // 统一光标样式:用内联 style 替代 CSS 类后代选择器,切断级联失效链
396
+ const containerCursor = computed(() => {
397
+ if (isDragging.value) return 'grabbing'
398
+ if (isResizingPane.value || isHoveringPaneSeparator.value) return 'ns-resize'
399
+ if (hoveredIdx.value !== null) return 'pointer'
400
+ return 'crosshair'
401
+ })
402
+
403
+ const hovered = computed(() => {
404
+ const idx = interactionState.value.hoveredIndex
405
+ if (typeof idx !== 'number') return null
406
+ void dataVersion.value // 建立响应式依赖
407
+ const data = chartRef.value?.getData()
408
+ if (data && idx >= 0 && idx < data.length) {
409
+ return data[idx]
410
+ }
411
+ return null
412
+ })
413
+ const hoveredIndex = computed(() => interactionState.value.hoveredIndex)
414
+ const tooltipPos = computed(() => interactionState.value.tooltipPos)
415
+ const teleportedTooltipPos = computed(() => ({
416
+ x: tooltipPos.value.x + tooltipLayerOffset.value.x,
417
+ y: tooltipPos.value.y + tooltipLayerOffset.value.y,
418
+ }))
419
+ const klineTooltipAnchorStyle = computed(() => ({
420
+ left: `${teleportedTooltipPos.value.x}px`,
421
+ top: `${teleportedTooltipPos.value.y}px`,
422
+ }))
423
+ const teleportedMarkerTooltipPos = computed(() => ({
424
+ x: mousePos.value.x + tooltipLayerOffset.value.x,
425
+ y: mousePos.value.y + tooltipLayerOffset.value.y,
426
+ }))
427
+ const markerTooltipAnchorStyle = computed(() => ({
428
+ left: `${teleportedMarkerTooltipPos.value.x}px`,
429
+ top: `${teleportedMarkerTooltipPos.value.y}px`,
430
+ }))
431
+ const tooltipAnchorPlacement = computed(() => interactionState.value.tooltipAnchorPlacement)
432
+ const markerTooltipAnchorPlacement = computed<'right-bottom' | 'left-bottom'>(() => {
433
+ const chart = chartRef.value
434
+ const viewport = chart?.getViewport()
435
+ const container = containerRef.value
436
+ const plotWidth = viewport?.plotWidth ?? (container ? container.clientWidth : 0)
437
+ const padding = 12
438
+ const gap = 12
439
+ const rightCandidateX = mousePos.value.x + gap
440
+ const wouldOverflowRight = rightCandidateX + markerTooltipSize.value.width + padding > plotWidth
441
+ return wouldOverflowRight ? 'left-bottom' : 'right-bottom'
442
+ })
443
+
444
+ // 获取当前图表数据
445
+ const chartData = computed(() => {
446
+ void dataVersion.value // 建立响应式依赖,确保数据变化时重新求值
447
+ return chartRef.value?.getData() ?? []
448
+ })
449
+
450
+ // 通知数据变化(在数据更新后调用)
451
+ function handleSelectTool(toolId: string) {
452
+ drawingController.value?.setTool(toolId as DrawingToolId)
453
+ }
454
+
455
+ function onUpdateDrawingStyle(style: Partial<DrawingStyle>) {
456
+ const d = selectedDrawing.value
457
+ if (!d || !drawingController.value) return
458
+ drawingController.value.updateDrawingStyle(d.id, style)
459
+ store.actions.bumpDrawingVersion()
460
+ }
461
+
462
+ function onDeleteDrawing() {
463
+ const d = selectedDrawing.value
464
+ if (!d || !drawingController.value) return
465
+ drawingController.value.removeDrawing(d.id)
466
+ store.actions.setSelectedDrawingId(null)
467
+ store.actions.bumpDrawingVersion()
468
+ store.actions.setDrawings(drawingController.value.getDrawings())
469
+ }
470
+
471
+ function onPointerDown(e: PointerEvent) {
472
+ chartRef.value?.handlePointerEvent(e, {
473
+ onPointerDown: (event, container) => {
474
+ if (drawingController.value?.onPointerDown(event, container)) {
475
+ store.actions.setDrawings(drawingController.value.getDrawings())
476
+ store.actions.bumpDrawingVersion()
477
+ return true
478
+ }
479
+ return false
480
+ },
481
+ })
482
+ }
483
+
484
+ function onPointerMove(e: PointerEvent) {
485
+ const container = containerRef.value
486
+ if (container) {
487
+ const rect = getContainerRect(container)
488
+ mousePos.value = {
489
+ x: e.clientX - rect.left,
490
+ y: e.clientY - rect.top,
491
+ }
492
+ }
493
+ chartRef.value?.handlePointerEvent(e, {
494
+ onPointerMove: (event, container) => {
495
+ if (drawingController.value?.onPointerMove(event, container)) {
496
+ store.actions.setDrawings(drawingController.value.getDrawings())
497
+ return true
498
+ }
499
+ return false
500
+ },
501
+ })
502
+ }
503
+
504
+ function onPointerUp(e: PointerEvent) {
505
+ chartRef.value?.handlePointerEvent(e, {
506
+ onPointerUp: (event, container) => {
507
+ if (drawingController.value?.onPointerUp(event, container)) {
508
+ store.actions.setDrawings(drawingController.value.getDrawings())
509
+ return true
510
+ }
511
+ return false
512
+ },
513
+ })
514
+ }
515
+
516
+ function onPointerLeave(e: PointerEvent) {
517
+ // pointerleave 不需要绘图控制器路由,直接调用
518
+ chartRef.value?.handlePointerEvent(e)
519
+ }
520
+
521
+ function onRightAxisPointerDown(e: PointerEvent) {
522
+ chartRef.value?.handlePointerEvent(e)
523
+ }
524
+
525
+ function onRightAxisPointerMove(e: PointerEvent) {
526
+ chartRef.value?.handlePointerEvent(e)
527
+ }
528
+
529
+ function onRightAxisPointerUp(e: PointerEvent) {
530
+ chartRef.value?.handlePointerEvent(e)
531
+ }
532
+
533
+ function onRightAxisPointerLeave(e: PointerEvent) {
534
+ chartRef.value?.handlePointerEvent(e)
535
+ }
536
+
537
+ function onScroll() {
538
+ chartRef.value?.handleScrollEvent()
539
+ }
540
+
541
+ // 主图指标显式状态(副图指标从 subPanes 派生)
542
+ const mainActiveIndicators = ref<string[]>([])
543
+
544
+ // 副图指标列表从 subPanes 自动派生
545
+ const subActiveIndicators = computed(() => {
546
+ const ids: string[] = []
547
+ const seen = new Set<string>()
548
+ for (const pane of subPanes.value) {
549
+ if (!seen.has(pane.indicatorId)) {
550
+ seen.add(pane.indicatorId)
551
+ ids.push(pane.indicatorId)
552
+ }
553
+ }
554
+ return ids
555
+ })
556
+
557
+ // 最终合并列表(主图 + 副图),保持显示顺序
558
+ const activeIndicators = computed(() => [
559
+ ...mainActiveIndicators.value,
560
+ ...subActiveIndicators.value,
561
+ ])
562
+
563
+ // 指标参数配置(MA 的 periods 是数组,需要更宽松的类型)
564
+ const indicatorParams = ref<Record<string, Record<string, unknown>>>({})
565
+
566
+ // 副图槽位状态
567
+ interface SubPaneSlot {
568
+ id: string // pane ID: 'RSI_0', 'MACD_0', ...
569
+ indicatorId: SubIndicatorType
570
+ params: Record<string, unknown>
571
+ }
572
+
573
+ // 副图槽位数组(支持多副图)
574
+ const subPanes = ref<SubPaneSlot[]>([])
575
+
576
+ // 最大副图数量
577
+ const maxSubPanes = 4
578
+
579
+ function buildPaneLayoutIntent(): PaneSpec[] {
580
+ const mainRatio = paneRatios.value['main'] ?? 3
581
+ return subPanes.value.length === 0
582
+ ? [{ id: 'main', ratio: mainRatio, visible: true, role: 'price' }]
583
+ : [
584
+ { id: 'main', ratio: mainRatio, visible: true, role: 'price' },
585
+ ...subPanes.value.map((pane) => ({
586
+ id: pane.id,
587
+ ratio: paneRatios.value[pane.id] ?? 1,
588
+ visible: true,
589
+ role: 'indicator' as const,
590
+ })),
591
+ ]
592
+ }
593
+
594
+ // 获取指标默认参数
595
+ function getDefaultParams(
596
+ indicatorId: SubIndicatorType,
597
+ ): Record<string, number | boolean | string> {
598
+ return { ...SUB_PANE_INDICATOR_CONFIGS[indicatorId].defaultParams }
599
+ }
600
+
601
+ // 副图实例计数器:用于生成 'RSI_0', 'MACD_0' 这样的 paneId
602
+ const subPaneCounters = new Map<SubIndicatorType, number>()
603
+
604
+ function generatePaneId(indicatorId: SubIndicatorType): string {
605
+ const count = subPaneCounters.get(indicatorId) ?? 0
606
+ subPaneCounters.set(indicatorId, count + 1)
607
+ return `${indicatorId}_${count}`
608
+ }
609
+
610
+ // paneTitle 渲染器名称映射(paneId -> rendererName)
611
+ const paneTitleRendererNames = new Map<string, string>()
612
+
613
+ function mountSubPaneTitle(paneId: string, indicatorId: SubIndicatorType): void {
614
+ const paneTitleRenderer = createPaneTitleRendererPlugin({
615
+ paneId,
616
+ title: indicatorId,
617
+ getTitleInfo: () => getSubPaneTitleInfo(paneId),
618
+ })
619
+ chartRef.value?.useRenderer(paneTitleRenderer)
620
+ paneTitleRendererNames.set(paneId, paneTitleRenderer.name)
621
+ }
622
+
623
+ function unmountSubPaneTitle(paneId: string): void {
624
+ const rendererName = paneTitleRendererNames.get(paneId)
625
+ if (rendererName) {
626
+ chartRef.value?.removeRenderer(rendererName)
627
+ paneTitleRendererNames.delete(paneId)
628
+ }
629
+ }
630
+
631
+ // 添加副图(使用 Chart API)
632
+ function addSubPane(
633
+ indicatorId: SubIndicatorType = 'VOLUME',
634
+ params?: Record<string, number | boolean | string>,
635
+ ): boolean {
636
+ if (subPanes.value.length >= maxSubPanes) {
637
+ return false
638
+ }
639
+
640
+ const mergedParams = params ?? getDefaultParams(indicatorId)
641
+
642
+ // 使用高层 Facade API 创建副图指标
643
+ const paneId = chartRef.value?.addIndicator(indicatorId, 'sub', mergedParams)
644
+ if (!paneId) return false
645
+
646
+ // 创建 paneTitle 渲染器(UI 层职责)
647
+ mountSubPaneTitle(paneId, indicatorId)
648
+
649
+ // 更新本地状态
650
+ subPanes.value.push({
651
+ id: paneId,
652
+ indicatorId,
653
+ params: mergedParams,
654
+ })
655
+
656
+ scheduleRender()
657
+ return true
658
+ }
659
+
660
+ // 移除副图(使用高层 Facade API)
661
+ function removeSubPane(paneId: string): void {
662
+ const index = subPanes.value.findIndex((p) => p.id === paneId)
663
+ if (index === -1) return
664
+
665
+ const pane = subPanes.value[index]
666
+ if (!pane) return
667
+
668
+ // 移除 paneTitle 渲染器
669
+ unmountSubPaneTitle(paneId)
670
+
671
+ // 使用高层 Facade API 移除指标
672
+ chartRef.value?.removeIndicator(paneId)
673
+
674
+ // 更新本地状态
675
+ subPanes.value.splice(index, 1)
676
+ }
677
+
678
+ // 清除所有副图(使用高层 Facade API)
679
+ function clearAllSubPanes(): void {
680
+ // 使用高层 Facade API 逐个移除
681
+ for (const pane of subPanes.value) {
682
+ chartRef.value?.removeIndicator(pane.id)
683
+ unmountSubPaneTitle(pane.id)
684
+ }
685
+
686
+ // 清空本地状态
687
+ subPanes.value = []
688
+ subPaneCounters.clear()
689
+ paneTitleRendererNames.clear()
690
+ }
691
+
692
+ // 从语义化配置初始化指标状态(单向数据流:config → chart)
693
+ function initIndicatorsFromConfig(): void {
694
+ const config = props.semanticConfig
695
+ const chart = chartRef.value
696
+ if (!chart) return
697
+
698
+ // 初始化主图指标 - 直接调用Chart API
699
+ const mainIndicators = config.indicators?.main
700
+ if (mainIndicators) {
701
+ for (const indicator of mainIndicators) {
702
+ if (indicator.enabled) {
703
+ // 同步Vue状态(用于UI展示)
704
+ if (!mainActiveIndicators.value.includes(indicator.type)) {
705
+ mainActiveIndicators.value.push(indicator.type)
706
+ }
707
+ // 保存参数
708
+ if (indicator.params) {
709
+ indicatorParams.value[indicator.type] = indicator.params as Record<string, unknown>
710
+ }
711
+ // 启用指标(Chart内部管理渲染器)
712
+ chart.enableMainIndicator(
713
+ indicator.type,
714
+ indicator.params as Record<string, number | boolean | string>,
715
+ )
716
+ }
717
+ }
718
+ }
719
+
720
+ // 副图指标参数由 syncSubPanesFromChart 处理
721
+ }
722
+
723
+ // 监听主图指标参数变化,同步到Chart(状态由Chart管理,Vue只同步参数)
724
+ watch(
725
+ [activeIndicators, indicatorParams],
726
+ ([indicators]) => {
727
+ const chart = chartRef.value
728
+ if (!chart) return
729
+
730
+ // 只更新mainIndicatorLegend的配置(用于图例显示)
731
+ // 渲染器的启用/禁用由Chart内部管理
732
+ chart.updateRendererConfig('mainIndicatorLegend', {
733
+ indicators: {
734
+ MA: {
735
+ enabled: indicators.includes('MA'),
736
+ params: indicatorParams.value['MA'] || {},
737
+ },
738
+ BOLL: {
739
+ enabled: indicators.includes('BOLL'),
740
+ params: indicatorParams.value['BOLL'] || {},
741
+ },
742
+ EXPMA: {
743
+ enabled: indicators.includes('EXPMA'),
744
+ params: indicatorParams.value['EXPMA'] || {},
745
+ },
746
+ ENE: {
747
+ enabled: indicators.includes('ENE'),
748
+ params: indicatorParams.value['ENE'] || {},
749
+ },
750
+ },
751
+ })
752
+
753
+ scheduleRender()
754
+ },
755
+ { deep: true },
756
+ )
757
+
758
+ // 从 Chart 同步副图状态到本地(语义化配置后调用)
759
+ function syncSubPanesFromChart(): void {
760
+ const chartSubPaneEntries = chartRef.value?.getSubPaneEntries() ?? []
761
+
762
+ // 清空本地状态
763
+ subPanes.value = []
764
+ paneTitleRendererNames.clear()
765
+
766
+ for (const entry of chartSubPaneEntries) {
767
+ const { paneId, indicatorId, params } = entry
768
+
769
+ // 恢复计数器状态
770
+ const match = paneId.match(/^(.+)_(\d+)$/)
771
+ if (match) {
772
+ const [, indicator, countStr] = match
773
+ const count = parseInt(countStr!, 10)
774
+ const currentCount = subPaneCounters.get(indicator as SubIndicatorType) ?? 0
775
+ if (count >= currentCount) {
776
+ subPaneCounters.set(indicator as SubIndicatorType, count + 1)
777
+ }
778
+ }
779
+
780
+ // 创建 paneTitle 渲染器
781
+ mountSubPaneTitle(paneId, indicatorId)
782
+
783
+ // 更新本地状态
784
+ subPanes.value.push({
785
+ id: paneId,
786
+ indicatorId,
787
+ params: { ...params },
788
+ })
789
+ }
790
+
791
+ scheduleRender()
792
+ }
793
+
794
+ // 切换副图指标(使用 Chart API)
795
+ function switchSubIndicator(paneId: string, newIndicatorId: SubIndicatorType): void {
796
+ const pane = subPanes.value.find((p) => p.id === paneId)
797
+ if (!pane) return
798
+
799
+ const nextParams = getDefaultParams(newIndicatorId)
800
+
801
+ // 移除旧的 paneTitle 渲染器
802
+ unmountSubPaneTitle(paneId)
803
+
804
+ // 使用 Chart API 替换副图指标(paneId 不变,只换指标类型)
805
+ chartRef.value?.replaceSubPaneIndicator(paneId, newIndicatorId, nextParams)
806
+
807
+ // 创建新的 paneTitle 渲染器
808
+ mountSubPaneTitle(paneId, newIndicatorId)
809
+
810
+ // 更新本地状态(paneId 保持不变)
811
+ const index = subPanes.value.findIndex((p) => p.id === paneId)
812
+ if (index !== -1) {
813
+ subPanes.value[index] = {
814
+ id: paneId,
815
+ indicatorId: newIndicatorId,
816
+ params: nextParams,
817
+ }
818
+ }
819
+ }
820
+
821
+ // 获取副图标题信息(带缓存,只在 crosshairIdx 或 data 变化时重算)
822
+ const _titleInfoCache = new Map<
823
+ string,
824
+ { idx: number | null; dataLen: number; result: TitleInfo | null }
825
+ >()
826
+
827
+ function getSubPaneTitleInfo(paneId: string): TitleInfo | null {
828
+ const pane = subPanes.value.find((p) => p.id === paneId)
829
+ if (!pane) return null
830
+
831
+ const data = chartRef.value?.getData()
832
+ if (!data || data.length === 0) return null
833
+
834
+ const idx = crosshairIdx.value
835
+ const dataLen = data.length
836
+
837
+ // 缓存命中:crosshairIdx 和 dataLen 都没变
838
+ const cached = _titleInfoCache.get(paneId)
839
+ if (cached && cached.idx === idx && cached.dataLen === dataLen) {
840
+ return cached.result
841
+ }
842
+
843
+ const config = SUB_PANE_INDICATOR_CONFIGS[pane.indicatorId]
844
+ const params = pane.params as Record<string, number>
845
+ const pluginHost = chartRef.value?.plugin
846
+ const result = pluginHost ? config.getTitleInfo(data, idx, params, pluginHost, paneId) : null
847
+
848
+ _titleInfoCache.set(paneId, { idx, dataLen, result })
849
+ return result
850
+ }
851
+
852
+ // 指标切换处理(使用高层 Facade API)
853
+ function handleIndicatorToggle(indicatorId: string, active: boolean) {
854
+ const chart = chartRef.value
855
+ if (!chart) return
856
+
857
+ // 主图指标处理
858
+ const mainIndicatorIds = [
859
+ 'MA',
860
+ 'BOLL',
861
+ 'EXPMA',
862
+ 'ENE',
863
+ 'WMA',
864
+ 'DEMA',
865
+ 'TEMA',
866
+ 'HMA',
867
+ 'KAMA',
868
+ 'SAR',
869
+ 'SUPERTREND',
870
+ 'KELTNER',
871
+ 'DONCHIAN',
872
+ 'ICHIMOKU',
873
+ 'PIVOT',
874
+ 'FIB',
875
+ 'STRUCTURE',
876
+ 'ZONES',
877
+ ]
878
+ if (mainIndicatorIds.includes(indicatorId)) {
879
+ const existingIndicator = mainActiveIndicators.value.find((id) => id === indicatorId)
880
+
881
+ if (active && !existingIndicator) {
882
+ // 添加主图指标
883
+ chart.addIndicator(indicatorId, 'main', indicatorParams.value[indicatorId])
884
+ mainActiveIndicators.value.push(indicatorId)
885
+ } else if (!active && existingIndicator) {
886
+ // 移除主图指标
887
+ const instanceId = indicatorId.toUpperCase()
888
+ chart.removeIndicator(instanceId)
889
+ mainActiveIndicators.value = mainActiveIndicators.value.filter((id) => id !== indicatorId)
890
+ }
891
+ return
892
+ }
893
+
894
+ // 副图指标处理
895
+ if (SUB_PANE_INDICATORS.includes(indicatorId as SubIndicatorType)) {
896
+ if (active) {
897
+ // 如果已存在同类型指标 pane,跳过
898
+ const existingPane = subPanes.value.find((p) => p.indicatorId === indicatorId)
899
+ if (existingPane) return
900
+
901
+ // 副图数量上限检查
902
+ if (subPanes.value.length >= maxSubPanes) return
903
+
904
+ // 使用高层 API 添加副图指标
905
+ const paneId = chart.addIndicator(indicatorId, 'sub', indicatorParams.value[indicatorId])
906
+ if (paneId) {
907
+ // 创建 paneTitle 渲染器
908
+ mountSubPaneTitle(paneId, indicatorId as SubIndicatorType)
909
+ // 同步本地状态
910
+ subPanes.value.push({
911
+ id: paneId,
912
+ indicatorId: indicatorId as SubIndicatorType,
913
+ params: { ...indicatorParams.value[indicatorId] },
914
+ })
915
+ } else if (subPanes.value.length > 0) {
916
+ // 添加失败(可能达到上限),替换最后一个
917
+ const lastPane = subPanes.value[subPanes.value.length - 1]
918
+ switchSubIndicator(lastPane.id, indicatorId as SubIndicatorType)
919
+ }
920
+ } else {
921
+ // 找到并移除该指标的所有 pane
922
+ const panesToRemove = subPanes.value.filter((p) => p.indicatorId === indicatorId)
923
+ panesToRemove.forEach((pane) => {
924
+ chart.removeIndicator(pane.id)
925
+ unmountSubPaneTitle(pane.id)
926
+ })
927
+ subPanes.value = subPanes.value.filter((p) => p.indicatorId !== indicatorId)
928
+ }
929
+ scheduleRender()
930
+ }
931
+ }
932
+
933
+ // 更新主图指标图例配置
934
+ function updateMainIndicatorLegendConfig() {
935
+ chartRef.value?.updateRendererConfig('mainIndicatorLegend', {
936
+ indicators: {
937
+ MA: {
938
+ enabled: activeIndicators.value.includes('MA'),
939
+ params: indicatorParams.value['MA'] || {},
940
+ },
941
+ BOLL: {
942
+ enabled: activeIndicators.value.includes('BOLL'),
943
+ params: indicatorParams.value['BOLL'] || {},
944
+ },
945
+ EXPMA: {
946
+ enabled: activeIndicators.value.includes('EXPMA'),
947
+ params: indicatorParams.value['EXPMA'] || {},
948
+ },
949
+ ENE: {
950
+ enabled: activeIndicators.value.includes('ENE'),
951
+ params: indicatorParams.value['ENE'] || {},
952
+ },
953
+ },
954
+ })
955
+ }
956
+
957
+ // 指标参数更新处理
958
+ function handleUpdateParams(indicatorId: string, params: Record<string, unknown>) {
959
+ // 保存参数配置
960
+ indicatorParams.value[indicatorId] = params
961
+
962
+ // 主图指标参数更新 - 使用Chart API
963
+ if (
964
+ indicatorId === 'MA' ||
965
+ indicatorId === 'BOLL' ||
966
+ indicatorId === 'EXPMA' ||
967
+ indicatorId === 'ENE'
968
+ ) {
969
+ chartRef.value?.updateMainIndicatorParams(
970
+ indicatorId,
971
+ params as Record<string, number | boolean | string>,
972
+ )
973
+ scheduleRender()
974
+ return
975
+ }
976
+
977
+ if (SUB_PANE_INDICATORS.includes(indicatorId as SubIndicatorType)) {
978
+ subPanes.value
979
+ .filter((p) => p.indicatorId === indicatorId)
980
+ .forEach((pane) => {
981
+ chartRef.value?.updateSubPaneParams(pane.id, params)
982
+ pane.params = { ...params }
983
+ })
984
+ scheduleRender()
985
+ return
986
+ }
987
+
988
+ scheduleRender()
989
+ }
990
+
991
+ function handleReorderSubIndicators(orderedIndicatorIds: string[]) {
992
+ if (!orderedIndicatorIds.length || subPanes.value.length <= 1) return
993
+
994
+ const validOrder = orderedIndicatorIds.filter((id): id is SubIndicatorType =>
995
+ SUB_PANE_INDICATORS.includes(id as SubIndicatorType),
996
+ )
997
+ if (!validOrder.length) return
998
+
999
+ const paneByIndicator = new Map(subPanes.value.map((pane) => [pane.indicatorId, pane] as const))
1000
+ const nextSubPanes: SubPaneSlot[] = []
1001
+
1002
+ for (const indicatorId of validOrder) {
1003
+ const pane = paneByIndicator.get(indicatorId)
1004
+ if (pane) {
1005
+ nextSubPanes.push(pane)
1006
+ paneByIndicator.delete(indicatorId)
1007
+ }
1008
+ }
1009
+
1010
+ if (nextSubPanes.length === 0) return
1011
+
1012
+ for (const pane of subPanes.value) {
1013
+ if (paneByIndicator.has(pane.indicatorId)) {
1014
+ nextSubPanes.push(pane)
1015
+ paneByIndicator.delete(pane.indicatorId)
1016
+ }
1017
+ }
1018
+
1019
+ const currentSubIds = subPanes.value.map((p) => p.id)
1020
+ const nextSubIds = nextSubPanes.map((p) => p.id)
1021
+ if (currentSubIds.join('|') === nextSubIds.join('|')) return
1022
+
1023
+ subPanes.value = nextSubPanes
1024
+
1025
+ // activeIndicators 由 computed 自动派生,无需手动同步
1026
+
1027
+ const chart = chartRef.value
1028
+ if (!chart) return
1029
+ chart.updatePaneLayout(buildPaneLayoutIntent())
1030
+ }
1031
+
1032
+ /* 计算总宽度:从 Vue 响应式状态读取,zoom 变化时自动重算 */
1033
+ const axisHostWidth = computed(() => props.rightAxisWidth + props.priceLabelWidth)
1034
+
1035
+ const TRAILING_DRAWING_SLOTS_VAL = TRAILING_DRAWING_SLOTS
1036
+
1037
+ const totalWidth = store.computed.totalWidth
1038
+
1039
+ // 缩放由 Chart 回调驱动 scrollLeft 与渲染时序。
1040
+
1041
+ function scrollToRight() {
1042
+ const container = containerRef.value
1043
+ const chart = chartRef.value
1044
+ if (!container || !chart) return
1045
+
1046
+ const dataLength = chart.getData()?.length ?? 0
1047
+ if (dataLength === 0) return
1048
+
1049
+ const dpr = chart.getCurrentDpr()
1050
+ const { unitPx, startXPx } = getPhysicalKLineConfig(kWidth.value, kGap.value, dpr)
1051
+
1052
+ // 计算最后一根K线的结束位置(不含 TRAILING_DRAWING_SLOTS)
1053
+ const lastKLineEndPx = (startXPx + dataLength * unitPx) / dpr
1054
+
1055
+ // 计算最大可滚动距离
1056
+ const maxScrollLeft = Math.max(0, container.scrollWidth - container.clientWidth)
1057
+
1058
+ // 计算需要的滚动位置,使最后一根K线紧贴最右侧
1059
+ const targetScrollLeft = Math.min(
1060
+ maxScrollLeft,
1061
+ Math.max(0, lastKLineEndPx - container.clientWidth),
1062
+ )
1063
+
1064
+ container.scrollLeft = Math.round(targetScrollLeft * dpr) / dpr
1065
+ scheduleRender()
1066
+ }
1067
+
1068
+ /* 缩放到指定级别(通过 Chart facade API) */
1069
+ function applyZoomToLevel(targetLevel: number, anchorX?: number) {
1070
+ const chart = chartRef.value
1071
+ if (!chart) return
1072
+ chart.zoomToLevel(targetLevel, anchorX)
1073
+ }
1074
+
1075
+ defineExpose({
1076
+ scheduleRender,
1077
+ scrollToRight,
1078
+ addSubPane,
1079
+ removeSubPane,
1080
+ switchSubIndicator,
1081
+ clearAllSubPanes,
1082
+ get plugin() {
1083
+ return chartRef.value?.plugin
1084
+ },
1085
+
1086
+ // Zoom Level API(Vue SSOT)
1087
+ zoomToLevel: applyZoomToLevel,
1088
+ zoomIn: (anchorX?: number) => applyZoomToLevel(zoomLevel.value + 1, anchorX),
1089
+ zoomOut: (anchorX?: number) => applyZoomToLevel(zoomLevel.value - 1, anchorX),
1090
+ getZoomLevel: () => zoomLevel.value,
1091
+ getZoomLevelCount: () => chartRef.value?.getZoomLevelCount() ?? 10,
1092
+ })
1093
+
1094
+ // ==================== onMounted 拆分函数 ====================
1095
+
1096
+ function setupWheelHandler(container: HTMLDivElement): (e: WheelEvent) => void {
1097
+ const onWheelHandler = (e: WheelEvent) => {
1098
+ e.preventDefault()
1099
+ const chart = chartRef.value
1100
+ if (!chart) return
1101
+
1102
+ // 使用 Chart facade API 处理滚轮事件
1103
+ chart.handleWheelEvent(e)
1104
+ }
1105
+ container.addEventListener('wheel', onWheelHandler, { passive: false })
1106
+ return onWheelHandler
1107
+ }
1108
+
1109
+ function initChart(
1110
+ container: HTMLDivElement,
1111
+ canvasLayer: HTMLDivElement,
1112
+ rightAxisLayer: HTMLDivElement,
1113
+ xAxisCanvas: HTMLCanvasElement,
1114
+ ): Chart {
1115
+ const chart = new Chart(
1116
+ { container, canvasLayer, rightAxisLayer, xAxisCanvas },
1117
+ {
1118
+ yPaddingPx: props.yPaddingPx,
1119
+ rightAxisWidth: props.rightAxisWidth,
1120
+ bottomAxisHeight: props.bottomAxisHeight,
1121
+ priceLabelWidth: props.priceLabelWidth,
1122
+ minKWidth: props.minKWidth,
1123
+ maxKWidth: props.maxKWidth,
1124
+ panes: [{ id: 'main', ratio: 1 }],
1125
+ paneGap: 0,
1126
+ zoomLevels: props.zoomLevels,
1127
+ initialZoomLevel: props.initialZoomLevel,
1128
+ },
1129
+ )
1130
+ return chart
1131
+ }
1132
+
1133
+ function setupChartCallbacks(chart: Chart): void {
1134
+ // 注意:setOnViewportChange 已合并到 viewport signal 订阅者中
1135
+
1136
+ chart.setOnPaneLayoutChange(() => {
1137
+ // 分隔线位置计算(需要实际像素位置,保留在回调中)
1138
+ invalidateContainerRectCache()
1139
+ const renderers = chart.getPaneRenderers()
1140
+ const borderTop = containerRef.value
1141
+ ? parseInt(getComputedStyle(containerRef.value).borderTopWidth) || 0
1142
+ : 0
1143
+ paneSeparatorLines.value = renderers.slice(0, -1).map((renderer) => {
1144
+ const pane = renderer.getPane()
1145
+ return {
1146
+ id: pane.id,
1147
+ top: pane.top + pane.height + borderTop,
1148
+ }
1149
+ })
1150
+ })
1151
+
1152
+ // 订阅 paneRatios signal,同步到 Vue store
1153
+ const unsubscribePaneRatios = chart.paneRatios.subscribe(() => {
1154
+ const ratios = chart.paneRatios.peek()
1155
+ store.actions.setPaneRatios({ ...ratios })
1156
+ })
1157
+
1158
+ // 订阅 viewport signal,处理缩放、DPR、width 变化和 scrollLeft 更新
1159
+ const unsubscribeViewport = chart.viewport.subscribe(() => {
1160
+ const vp = chart.viewport.peek()
1161
+
1162
+ // DPR 变化时同步到 store
1163
+ if (store.state.viewportDpr !== vp.dpr) {
1164
+ store.actions.setViewportDpr(vp.dpr)
1165
+ }
1166
+
1167
+ // ViewWidth 变化时同步到 store
1168
+ if (store.state.viewWidth !== vp.plotWidth) {
1169
+ store.actions.setViewWidth(vp.plotWidth)
1170
+ }
1171
+
1172
+ // 完整同步 zoom state 到 Vue store(Chart 是 SSOT)
1173
+ if (
1174
+ store.state.zoomLevel !== vp.zoomLevel ||
1175
+ store.state.kWidth !== vp.kWidth ||
1176
+ store.state.kGap !== vp.kGap
1177
+ ) {
1178
+ store.actions.setZoomState(vp.zoomLevel, vp.kWidth, vp.kGap)
1179
+ }
1180
+
1181
+ // 在 nextTick 中应用 desiredScrollLeft
1182
+ const desiredLeft = vp.desiredScrollLeft
1183
+ if (desiredLeft !== undefined && desiredLeft !== containerRef.value?.scrollLeft) {
1184
+ invalidateContainerRectCache()
1185
+ nextTick(() => {
1186
+ const c = containerRef.value
1187
+ if (!c) return
1188
+ const maxScrollLeft = Math.max(0, c.scrollWidth - c.clientWidth)
1189
+ const clampedScrollLeft = Math.min(Math.max(0, desiredLeft), maxScrollLeft)
1190
+ const dpr = chart.getCurrentDpr()
1191
+ c.scrollLeft = Math.round(clampedScrollLeft * dpr) / dpr
1192
+ })
1193
+ }
1194
+ })
1195
+
1196
+ // 订阅 data signal,替换 onDataChange 回调
1197
+ const unsubscribeData = chart.data.subscribe(() => {
1198
+ const data = chart.data.peek()
1199
+ store.actions.setDataLength(data.length)
1200
+ store.actions.bumpDataVersion()
1201
+ })
1202
+
1203
+ // 订阅 theme signal,同步到 CSS data-theme
1204
+ const unsubscribeTheme = chart.theme.subscribe(() => {
1205
+ const theme = chart.theme.peek()
1206
+ chartTheme.value = theme
1207
+ })
1208
+
1209
+ // 保存 unsubscribe 函数以便清理
1210
+ onUnmounted(() => {
1211
+ unsubscribeViewport()
1212
+ unsubscribeData()
1213
+ unsubscribePaneRatios()
1214
+ unsubscribeTheme()
1215
+ })
1216
+ }
1217
+
1218
+ function applyInitialSettings(chart: Chart): void {
1219
+ const initialSettings = toolbarRef.value?.getSettings() ?? { showVolumePriceMarkers: true }
1220
+ chart.updateSettings(initialSettings)
1221
+
1222
+ if (initialSettings.performanceTest10kKlines) {
1223
+ const testData = generate10kKLineData()
1224
+ console.time('updateData-10k')
1225
+ chart.updateData(testData)
1226
+ console.timeEnd('updateData-10k')
1227
+ }
1228
+ }
1229
+
1230
+ function setupDrawingController(chart: Chart): void {
1231
+ drawingController.value = new DrawingInteractionController(chart)
1232
+ drawingController.value.setCallbacks({
1233
+ onDrawingCreated: (drawing) => {
1234
+ store.actions.setDrawings([...store.state.drawings, drawing])
1235
+ store.actions.setSelectedDrawingId(drawing.id)
1236
+ },
1237
+ onToolChange: () => {},
1238
+ onDrawingSelected: (drawing) => {
1239
+ store.actions.setSelectedDrawingId(drawing?.id ?? null)
1240
+ },
1241
+ })
1242
+ }
1243
+
1244
+ function setupInteractionCallbacks(chart: Chart): void {
1245
+ chart.interaction.setTooltipAnchorPositioning(useAnchorPositioning.value)
1246
+ chart.interaction.setOnInteractionChange((snapshot) => {
1247
+ interactionState.value = snapshot
1248
+ })
1249
+
1250
+ chart.interaction.setOnPinchZoom((delta, centerClientX) => {
1251
+ if (!chart) return
1252
+ const container = containerRef.value
1253
+ if (!container) return
1254
+ // centerClientX 是 clientX,需要转换为视口局部坐标
1255
+ const rect = container.getBoundingClientRect()
1256
+ const centerX = centerClientX - rect.left
1257
+ chart.handlePinchZoom(delta, centerX)
1258
+ })
1259
+
1260
+ interactionState.value = chart.interaction.getInteractionSnapshot()
1261
+ store.actions.setViewportDpr(chart.getCurrentDpr())
1262
+ chart.resize()
1263
+ }
1264
+
1265
+ /** 语义化控制器:外部配置 → Chart API 的桥梁 */
1266
+ function setupSemanticController(chart: Chart): void {
1267
+ __setDataFetcher(props.dataFetcher)
1268
+ semanticController.value = new SemanticChartController(chart)
1269
+
1270
+ semanticController.value.on('config:error', (error) => {
1271
+ console.error('Semantic config error:', error)
1272
+ })
1273
+
1274
+ // config:ready → Chart 侧已完成创建,Vue 回读状态
1275
+ semanticController.value.on('config:ready', () => {
1276
+ initIndicatorsFromConfig()
1277
+ syncSubPanesFromChart()
1278
+ nextTick(() => scrollToRight())
1279
+ })
1280
+ // 应用副图、主图配置
1281
+ semanticController.value.applyConfig(props.semanticConfig).then((result) => {
1282
+ if (result && !result.success) {
1283
+ console.error('Semantic config apply failed:', result.errors)
1284
+ }
1285
+ })
1286
+ }
1287
+
1288
+ onMounted(() => {
1289
+ useAnchorPositioning.value = false
1290
+
1291
+ const container = containerRef.value
1292
+ const canvasLayer = canvasLayerRef.value
1293
+ const rightAxisLayer = rightAxisLayerRef.value
1294
+ const xAxisCanvas = xAxisCanvasRef.value
1295
+ if (!container || !canvasLayer || !rightAxisLayer || !xAxisCanvas) return
1296
+
1297
+ // 1) 滚轮缩放:passive:false 以阻止页面滚动
1298
+ const onWheelHandler = setupWheelHandler(container)
1299
+
1300
+ // 2) 创建 Chart 实例并注册全部渲染器
1301
+ const chart = initChart(container, canvasLayer, rightAxisLayer, xAxisCanvas)
1302
+ chartRef.value = chart
1303
+
1304
+ // 3) 视口 / 面板布局 / 数据变更回调
1305
+ setupChartCallbacks(chart)
1306
+
1307
+ // 4) 同步 zoom 状态(Vue SSOT → Chart)
1308
+ chart.applyRenderState(store.state.kWidth, store.state.kGap, store.state.zoomLevel)
1309
+
1310
+ // 5) 工具栏初始设置(含性能测试数据)
1311
+ applyInitialSettings(chart)
1312
+
1313
+ // 6) 绘图交互控制器(线段/箭头等)
1314
+ setupDrawingController(chart)
1315
+
1316
+ // 7) 十字线、捏合缩放、初始交互快照
1317
+ setupInteractionCallbacks(chart)
1318
+
1319
+ // 8) 语义化配置控制器(最终驱动数据加载)
1320
+ setupSemanticController(chart)
1321
+
1322
+ // 供 onUnmounted 移除 wheel 监听
1323
+ ;(chart as any).__onWheel = onWheelHandler
1324
+ })
1325
+
1326
+ onUnmounted(() => {
1327
+ const chart = chartRef.value
1328
+ if (chart) {
1329
+ const onWheel = (chart as any).__onWheel as
1330
+ | ((this: HTMLElement, ev: WheelEvent) => any)
1331
+ | undefined
1332
+ const container = containerRef.value
1333
+ if (onWheel && container) container.removeEventListener('wheel', onWheel)
1334
+ chart.destroy()
1335
+ }
1336
+ chartRef.value = null
1337
+ drawingController.value = null
1338
+ })
1339
+
1340
+ // kWidth/kGap 由 zoomLevel 派生,不再通过 props 直接修改
1341
+ // 如需程序化控制缩放,请使用 expose 的 zoomToLevel/zoomIn/zoomOut 方法
1342
+
1343
+ // 监听 yPaddingPx 变化
1344
+ watch(
1345
+ () => props.yPaddingPx,
1346
+ (newVal) => {
1347
+ chartRef.value?.updateOptions({ yPaddingPx: newVal })
1348
+ },
1349
+ )
1350
+
1351
+ // 监听 semanticConfig 变化(唯一数据源)
1352
+ watch(
1353
+ () => props.semanticConfig,
1354
+ async (newConfig, oldConfig) => {
1355
+ if (newConfig && newConfig !== oldConfig) {
1356
+ const result = await semanticController.value?.applyConfig(newConfig)
1357
+ if (result && !result.success) {
1358
+ console.error('Semantic config apply failed:', result.errors)
1359
+ }
1360
+ }
1361
+ },
1362
+ { deep: true },
1363
+ )
1364
+ </script>
1365
+
1366
+ <style scoped>
1367
+ .chart-wrapper {
1368
+ --kmap-height: var(--kmap-chart-height, 100%);
1369
+ --kmap-width: var(--kmap-chart-width, 100%);
1370
+
1371
+ --chart-bg: #ffffff;
1372
+ --chart-bg-secondary: #f8f9fa;
1373
+ --chart-border: #e5e7eb;
1374
+ --chart-border-active: #3b82f6;
1375
+ --chart-text: #374151;
1376
+ --chart-text-secondary: #6b7280;
1377
+
1378
+ display: flex;
1379
+ align-items: center;
1380
+ justify-content: center;
1381
+ width: var(--kmap-width);
1382
+ height: var(--kmap-height);
1383
+ min-height: 300px;
1384
+ flex-direction: column;
1385
+ }
1386
+
1387
+ .chart-wrapper[data-theme='dark'] {
1388
+ --chart-bg: #1a1a2e;
1389
+ --chart-bg-secondary: #16162a;
1390
+ --chart-border: #2d2d44;
1391
+ --chart-border-active: #60a5fa;
1392
+ --chart-text: #e5e7eb;
1393
+ --chart-text-secondary: #9ca3af;
1394
+ }
1395
+
1396
+ .chart-stage {
1397
+ width: 95%;
1398
+ height: 85%;
1399
+ min-height: 255px;
1400
+ display: flex;
1401
+ align-items: stretch;
1402
+ gap: 8px;
1403
+ }
1404
+
1405
+ .chart-main {
1406
+ flex: 1 1 auto;
1407
+ min-width: 0;
1408
+ height: 100%;
1409
+ display: flex;
1410
+ align-items: stretch;
1411
+ gap: 0;
1412
+ position: relative;
1413
+ }
1414
+
1415
+ .pane-separator-layer {
1416
+ position: absolute;
1417
+ inset: 0;
1418
+ pointer-events: none;
1419
+ z-index: 20;
1420
+ }
1421
+
1422
+ .pane-separator-line {
1423
+ position: absolute;
1424
+ left: 0;
1425
+ right: 0;
1426
+ height: 0;
1427
+ border-top: 1px solid var(--chart-border);
1428
+ opacity: 1;
1429
+ box-sizing: border-box;
1430
+ transition:
1431
+ border-top-color 120ms ease,
1432
+ border-top-width 120ms ease,
1433
+ margin-top 120ms ease,
1434
+ opacity 120ms ease;
1435
+ }
1436
+
1437
+ .pane-separator-line.is-active {
1438
+ border-top-color: var(--chart-border-active);
1439
+ border-top-width: 2px;
1440
+ margin-top: -1px;
1441
+ }
1442
+
1443
+ .chart-stage.is-resizing-pane,
1444
+ .chart-stage.is-hovering-pane-separator {
1445
+ cursor: ns-resize;
1446
+ }
1447
+
1448
+ .chart-stage.is-hovering-kline {
1449
+ cursor: pointer;
1450
+ }
1451
+
1452
+ .chart-stage.is-hovering-right-axis {
1453
+ cursor: ns-resize;
1454
+ }
1455
+
1456
+ .chart-stage.is-dragging {
1457
+ cursor: grabbing;
1458
+ }
1459
+
1460
+ .chart-container {
1461
+ position: relative;
1462
+ flex: 1 1 auto;
1463
+ overflow-x: auto;
1464
+ overflow-y: hidden;
1465
+ height: 100%;
1466
+ min-height: inherit;
1467
+ scrollbar-width: none;
1468
+ -ms-overflow-style: none;
1469
+ border: 1px solid var(--chart-border);
1470
+ border-right: 0;
1471
+ border-radius: 6px 0 0 6px;
1472
+ box-sizing: border-box;
1473
+ background: var(--chart-bg);
1474
+
1475
+ -webkit-touch-callout: none;
1476
+ -webkit-user-select: none;
1477
+ user-select: none;
1478
+ touch-action: none;
1479
+ }
1480
+
1481
+ .chart-container::-webkit-scrollbar {
1482
+ display: none;
1483
+ }
1484
+
1485
+ .right-axis-host {
1486
+ position: relative;
1487
+ flex: 0 0 auto;
1488
+ height: 100%;
1489
+ min-height: inherit;
1490
+ box-sizing: border-box;
1491
+ background: var(--chart-bg);
1492
+ overflow: visible;
1493
+ border: 1px solid var(--chart-border);
1494
+ border-top-right-radius: 6px;
1495
+ border-bottom-right-radius: 6px;
1496
+
1497
+ -webkit-touch-callout: none;
1498
+ -webkit-user-select: none;
1499
+ user-select: none;
1500
+ touch-action: none;
1501
+ }
1502
+
1503
+ .scroll-content {
1504
+ height: 100%;
1505
+ min-height: inherit;
1506
+ position: relative;
1507
+ }
1508
+
1509
+ .canvas-layer {
1510
+ position: sticky;
1511
+ left: 0;
1512
+ top: 0;
1513
+ pointer-events: none;
1514
+ }
1515
+
1516
+ .tooltip-layer {
1517
+ position: absolute;
1518
+ inset: 0;
1519
+ pointer-events: none;
1520
+ z-index: 30;
1521
+ }
1522
+
1523
+ .tooltip-anchor {
1524
+ position: absolute;
1525
+ width: 1px;
1526
+ height: 1px;
1527
+ pointer-events: none;
1528
+ }
1529
+
1530
+ .tooltip-anchor.kline-tooltip-anchor.use-anchor {
1531
+ anchor-name: --kline-tooltip-anchor;
1532
+ }
1533
+
1534
+ .tooltip-anchor.marker-tooltip-anchor.use-anchor {
1535
+ anchor-name: --marker-tooltip-anchor;
1536
+ }
1537
+
1538
+ @media (max-width: 768px), (max-height: 640px) {
1539
+ .chart-stage {
1540
+ gap: 6px;
1541
+ }
1542
+ }
1543
+ </style>
1544
+
1545
+ <style>
1546
+ .plot-canvas {
1547
+ position: absolute;
1548
+ left: 0;
1549
+ top: 0;
1550
+ display: block;
1551
+ }
1552
+
1553
+ .right-axis {
1554
+ position: absolute;
1555
+ display: block;
1556
+ left: 0;
1557
+ }
1558
+
1559
+ .x-axis-canvas {
1560
+ position: absolute;
1561
+ left: 0;
1562
+ bottom: 0;
1563
+ display: block;
1564
+ z-index: 10;
1565
+ }
1566
+
1567
+ .right-axis {
1568
+ z-index: 15;
1569
+ }
1570
+ </style>