@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.
- package/README.md +149 -153
- package/dist/index.cjs +2 -2
- package/dist/index.js +15 -9
- package/dist/klinechart.css +1 -1
- package/package.json +82 -76
- package/src/__tests__/_mockController.ts +192 -192
- package/src/__tests__/contract.test.ts +132 -132
- package/src/components/DrawingStyleToolbar.vue +199 -199
- package/src/components/IndicatorParams.vue +570 -570
- package/src/components/IndicatorSelector.vue +1169 -1169
- package/src/components/KLineChart.vue +1570 -1570
- package/src/components/KLineTooltip.vue +200 -200
- package/src/components/LeftToolbar.vue +844 -844
- package/src/components/MarkerTooltip.vue +155 -155
- package/src/components/index.ts +7 -7
- package/src/composables/useFullscreenTeleportTarget.ts +18 -18
- package/src/debug/canvasProfiler.ts +296 -296
- package/src/index.ts +402 -402
- package/src/version.ts +3 -3
|
@@ -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>
|