@363045841yyt/klinechart 0.8.4 → 0.8.6

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.
Files changed (69) hide show
  1. package/README.md +6 -1
  2. package/dist/components/BaseModal.vue.d.ts +54 -0
  3. package/dist/components/BaseModal.vue.d.ts.map +1 -0
  4. package/dist/components/BatchStockDialog.vue.d.ts +13 -0
  5. package/dist/components/BatchStockDialog.vue.d.ts.map +1 -0
  6. package/dist/components/ChartSettingsDialog.vue.d.ts.map +1 -1
  7. package/dist/components/ColorPresetPanel.vue.d.ts +4 -1
  8. package/dist/components/ColorPresetPanel.vue.d.ts.map +1 -1
  9. package/dist/components/CompareSymbolSelector.vue.d.ts.map +1 -1
  10. package/dist/components/DrawingStyleToolbar.vue.d.ts.map +1 -1
  11. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  12. package/dist/components/ExportProgressDialog.vue.d.ts +15 -0
  13. package/dist/components/ExportProgressDialog.vue.d.ts.map +1 -0
  14. package/dist/components/IndicatorParams.vue.d.ts.map +1 -1
  15. package/dist/components/IndicatorSelector.vue.d.ts.map +1 -1
  16. package/dist/components/KLineChart.vue.d.ts +5 -9
  17. package/dist/components/KLineChart.vue.d.ts.map +1 -1
  18. package/dist/components/LeftToolbar.vue.d.ts.map +1 -1
  19. package/dist/components/RangeSelectionExport.vue.d.ts +23 -0
  20. package/dist/components/RangeSelectionExport.vue.d.ts.map +1 -0
  21. package/dist/components/SymbolSelector.vue.d.ts.map +1 -1
  22. package/dist/components/TopToolbar.vue.d.ts.map +1 -1
  23. package/dist/components/common/CanvasToolbar.vue.d.ts +14 -0
  24. package/dist/components/common/CanvasToolbar.vue.d.ts.map +1 -0
  25. package/dist/components/common/CanvasToolbarStack.vue.d.ts +14 -0
  26. package/dist/components/common/CanvasToolbarStack.vue.d.ts.map +1 -0
  27. package/dist/composables/chart/useChartTheme.d.ts +329 -0
  28. package/dist/composables/chart/useChartTheme.d.ts.map +1 -0
  29. package/dist/composables/chart/useDrawingManager.d.ts +86 -0
  30. package/dist/composables/chart/useDrawingManager.d.ts.map +1 -0
  31. package/dist/composables/chart/useIndicatorManager.d.ts +38 -0
  32. package/dist/composables/chart/useIndicatorManager.d.ts.map +1 -0
  33. package/dist/composables/chart/useRangeSelection.d.ts +66 -0
  34. package/dist/composables/chart/useRangeSelection.d.ts.map +1 -0
  35. package/dist/composables/useTeleportedPopup.d.ts +8 -0
  36. package/dist/composables/useTeleportedPopup.d.ts.map +1 -0
  37. package/dist/index.cjs +9 -2
  38. package/dist/index.css +1 -1
  39. package/dist/index.js +2149 -1409
  40. package/dist/tools/calcRangeOverlayPixel.d.ts +15 -0
  41. package/dist/tools/calcRangeOverlayPixel.d.ts.map +1 -0
  42. package/dist/tools/getKLineIndexByTimestamp.d.ts +4 -0
  43. package/dist/tools/getKLineIndexByTimestamp.d.ts.map +1 -0
  44. package/dist/web-component.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/components/BaseModal.vue +292 -0
  47. package/src/components/BatchStockDialog.vue +128 -0
  48. package/src/components/ChartSettingsDialog.vue +248 -405
  49. package/src/components/ColorPresetPanel.vue +58 -106
  50. package/src/components/CompareSymbolSelector.vue +37 -10
  51. package/src/components/DrawingStyleToolbar.vue +33 -72
  52. package/src/components/Dropdown.vue +42 -19
  53. package/src/components/ExportProgressDialog.vue +118 -0
  54. package/src/components/IndicatorParams.vue +194 -321
  55. package/src/components/IndicatorSelector.vue +188 -405
  56. package/src/components/KLineChart.vue +228 -403
  57. package/src/components/LeftToolbar.vue +3 -2
  58. package/src/components/RangeSelectionExport.vue +117 -0
  59. package/src/components/SymbolSelector.vue +37 -10
  60. package/src/components/TopToolbar.vue +55 -2
  61. package/src/components/common/CanvasToolbar.vue +70 -0
  62. package/src/components/common/CanvasToolbarStack.vue +32 -0
  63. package/src/composables/chart/useChartTheme.ts +86 -0
  64. package/src/composables/chart/useDrawingManager.ts +67 -0
  65. package/src/composables/chart/useIndicatorManager.ts +307 -0
  66. package/src/composables/chart/useRangeSelection.ts +424 -0
  67. package/src/composables/useTeleportedPopup.ts +46 -0
  68. package/src/tools/calcRangeOverlayPixel.ts +28 -0
  69. package/src/tools/getKLineIndexByTimestamp.ts +40 -0
@@ -60,18 +60,53 @@
60
60
  <div class="canvas-layer" ref="canvasLayerRef">
61
61
  <canvas class="x-axis-canvas" ref="xAxisCanvasRef"></canvas>
62
62
 
63
- <DrawingStyleToolbar
64
- v-if="selectedDrawing"
65
- :drawing="selectedDrawing"
66
- @update-style="onUpdateDrawingStyle"
67
- @delete="onDeleteDrawing"
63
+ <CanvasToolbarStack>
64
+ <RangeSelectionExport
65
+ v-if="rangeSelectionReady"
66
+ v-model:start-date="customStartDate"
67
+ v-model:end-date="customEndDate"
68
+ :start-label="rangeSelectionStartLabel"
69
+ :end-label="rangeSelectionEndLabel"
70
+ :count="rangeSelectionCount"
71
+ @export="exportRangeToCsv"
72
+ @clear="clearRangeSelection"
73
+ @batch-setting="showBatchStockDialog = true"
74
+ />
75
+ <DrawingStyleToolbar
76
+ v-if="selectedDrawing"
77
+ :drawing="selectedDrawing"
78
+ @update-style="onUpdateDrawingStyle"
79
+ @delete="onDeleteDrawing"
80
+ />
81
+ </CanvasToolbarStack>
82
+ </div>
83
+ <div
84
+ v-if="rangeSelectionOverlayStyle"
85
+ class="range-selection-overlay"
86
+ :class="{ 'is-dragging': rangeSelection.isDragging }"
87
+ :style="rangeSelectionOverlayStyle"
88
+ aria-label="已选择的 K 线区间"
89
+ >
90
+ <div
91
+ v-if="rangeSelectionReady"
92
+ class="range-selection-handle range-selection-handle--left"
93
+ @pointerdown.stop="onEdgePointerDown('left', $event)"
94
+ @pointermove.stop="onEdgePointerMove($event)"
95
+ @pointerup.stop="onEdgePointerUp($event)"
96
+ />
97
+ <div
98
+ v-if="rangeSelectionReady"
99
+ class="range-selection-handle range-selection-handle--right"
100
+ @pointerdown.stop="onEdgePointerDown('right', $event)"
101
+ @pointermove.stop="onEdgePointerMove($event)"
102
+ @pointerup.stop="onEdgePointerUp($event)"
68
103
  />
69
104
  </div>
70
105
  </div>
71
106
  </div>
72
107
  <Teleport v-if="tooltipLayerRef" :to="tooltipLayerRef">
73
108
  <div
74
- v-if="hovered"
109
+ v-if="hovered && !isMobile"
75
110
  class="tooltip-anchor kline-tooltip-anchor"
76
111
  :class="{ 'use-anchor': useAnchorPositioning }"
77
112
  :style="klineTooltipAnchorStyle"
@@ -83,7 +118,7 @@
83
118
  :style="markerTooltipAnchorStyle"
84
119
  ></div>
85
120
  <KLineTooltip
86
- v-if="hovered"
121
+ v-if="hovered && !isMobile"
87
122
  :k="hovered"
88
123
  :index="hoveredIndex"
89
124
  :data="chartData"
@@ -116,6 +151,12 @@
116
151
  ></div>
117
152
  </div>
118
153
  </div>
154
+ <ExportProgressDialog :progress="exportingProgress" @close="exportingProgress = null" />
155
+ <BatchStockDialog
156
+ :show="showBatchStockDialog"
157
+ @close="showBatchStockDialog = false"
158
+ @apply="onBatchApply"
159
+ />
119
160
  <IndicatorSelector
120
161
  ref="indicatorSelectorRef"
121
162
  :active-indicators="activeIndicators"
@@ -138,38 +179,28 @@ import KLineTooltip from './KLineTooltip.vue'
138
179
  import MarkerTooltip from './MarkerTooltip.vue'
139
180
  import IndicatorSelector from './IndicatorSelector.vue'
140
181
  import DrawingStyleToolbar from './DrawingStyleToolbar.vue'
182
+ import RangeSelectionExport from './RangeSelectionExport.vue'
183
+ import CanvasToolbarStack from './common/CanvasToolbarStack.vue'
141
184
  import { provideFullscreenTeleportTarget } from '../composables/useFullscreenTeleportTarget'
142
185
  import {
143
186
  createChartController,
144
187
  type ChartController,
145
- type PaneSpec,
146
- type IndicatorInstance,
147
- type SubIndicatorType,
148
188
  type InteractionSnapshot,
149
- type DrawingToolId,
150
- type KLineData,
151
189
  type SymbolSpec,
152
190
  zoomLevelToKWidth,
153
191
  kGapFromKWidth,
154
- DrawingInteractionController,
155
192
  } from '@363045841yyt/klinechart-core/controllers'
156
- import {
157
- getRegisteredIndicatorDefinition,
158
- getRegisteredIndicatorDefinitions,
159
- } from '@363045841yyt/klinechart-core/indicators'
160
- import type { DrawingObject, DrawingStyle } from '@363045841yyt/klinechart-core/plugin'
193
+ import { useChartTheme } from '../composables/chart/useChartTheme'
194
+ import { useIndicatorManager } from '../composables/chart/useIndicatorManager'
195
+ import { useDrawingManager } from '../composables/chart/useDrawingManager'
161
196
  import { SETTINGS_STORAGE_KEY } from '@363045841yyt/klinechart-core/config'
162
- import type { ChartSettings } from '@363045841yyt/klinechart-core/config'
163
- import {
164
- resolveThemeColors,
165
- themeToCssVars,
166
- lightTheme,
167
- darkTheme,
168
- type ColorPresetSettings,
169
- } from '@363045841yyt/klinechart-core'
197
+ import { useRangeSelection } from '../composables/chart/useRangeSelection'
170
198
  import LeftToolbar from './LeftToolbar.vue'
171
199
  import TopToolbar, { type SymbolItem } from './TopToolbar.vue'
200
+ import BatchStockDialog from './BatchStockDialog.vue'
201
+ import ExportProgressDialog from './ExportProgressDialog.vue'
172
202
 
203
+ // ── Props & Emits ──
173
204
  const props = withDefaults(
174
205
  defineProps<{
175
206
  /** 语义化配置(可选,唯一控制源) */
@@ -231,6 +262,7 @@ const emit = defineEmits<{
231
262
  (e: 'kLineAdjustChange', adjust: 'qfq' | 'hfq' | 'splits' | 'none'): void
232
263
  }>()
233
264
 
265
+ // ── Symbol / Comparison State ──
234
266
  const kLineLevel = ref(props.semanticConfig?.data?.period ?? 'daily')
235
267
  const kLineAdjust = ref(props.semanticConfig?.data?.adjust ?? 'none')
236
268
  const isIntraday = computed(() => kLineLevel.value.includes('min'))
@@ -302,9 +334,12 @@ function forcePercentAxis() {
302
334
  controller.value?.updateSettingsFacade(nextSettings)
303
335
  try {
304
336
  localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(nextSettings))
305
- } catch { /* quota exceeded */ }
337
+ } catch {
338
+ /* quota exceeded */
339
+ }
306
340
  }
307
341
 
342
+ // ── DOM Template Refs ──
308
343
  const containerRef = ref<HTMLDivElement | null>(null)
309
344
  const chartMainRef = ref<HTMLDivElement | null>(null)
310
345
  const chartWrapperRef = ref<HTMLDivElement | null>(null)
@@ -313,26 +348,101 @@ const toolbarRef = ref<InstanceType<typeof LeftToolbar> | null>(null)
313
348
  const indicatorSelectorRef = ref<InstanceType<typeof IndicatorSelector> | null>(null)
314
349
  provideFullscreenTeleportTarget(chartWrapperRef)
315
350
 
316
- /* ========== 图表控制器 ========== */
351
+ // ── Controller & Composable Wiring ──
317
352
  const controller = shallowRef<ChartController | null>(null)
318
353
 
319
- /* ========== 语义化控制器 ========== */
354
+ const {
355
+ chartTheme,
356
+ chartSettings,
357
+ tooltipColors,
358
+ themeCssVars,
359
+ handleSettingsChange,
360
+ applyThemeFromSettings,
361
+ } = useChartTheme(controller)
362
+
320
363
  const semanticController = shallowRef<SemanticChartController | null>(null)
321
364
 
322
- /* ========== 本地响应式状态(信号驱动,取代 ChartStore) ========== */
365
+ /* ========== 本地响应式状态 ========== */
323
366
  const dataLength = ref(0)
324
367
  const dataVersion = ref(0)
368
+ const showBatchStockDialog = ref(false)
369
+ const batchStockCodes = ref<string[]>([])
370
+ const viewportVersion = ref(0)
325
371
  const viewportDpr = ref(1)
326
372
  const zoomLevel = ref(props.initialZoomLevel ?? 1)
327
373
  const kWidth = ref(0)
328
374
  const kGap = ref(1)
329
375
  const viewWidth = ref(0)
330
376
  const paneRatios = ref<Record<string, number>>({})
331
- const selectedDrawingId = ref<string | null>(null)
332
- const drawings = ref<DrawingObject[]>([])
333
377
  const comparisonColorsMap = ref<Map<string, string>>(new Map())
334
378
  const comparisonLoading = ref(false)
379
+ const activeToolId = ref('cursor')
380
+
381
+ const {
382
+ mainActiveIndicators,
383
+ subActiveIndicators,
384
+ activeIndicators,
385
+ indicatorParams,
386
+ subPanes,
387
+ buildPaneLayoutIntent,
388
+ getDefaultParams,
389
+ isSubPaneIndicator,
390
+ addSubPane,
391
+ removeSubPane,
392
+ clearAllSubPanes,
393
+ initIndicatorsFromConfig,
394
+ switchSubIndicator,
395
+ handleIndicatorToggle,
396
+ handleUpdateParams,
397
+ handleReorderSubIndicators,
398
+ setupIndicatorSubscriptions,
399
+ } = useIndicatorManager(controller, paneRatios)
400
+
401
+ const {
402
+ drawingController,
403
+ selectedDrawingId,
404
+ selectedDrawing,
405
+ drawings,
406
+ handleSelectTool: handleDrawingToolSelect,
407
+ onUpdateDrawingStyle,
408
+ onDeleteDrawing,
409
+ setupDrawing,
410
+ } = useDrawingManager(controller)
411
+
412
+ const {
413
+ rangeSelection,
414
+ customStartDate,
415
+ customEndDate,
416
+ containerScrollLeft,
417
+ isRangeSelectActive,
418
+ rangeSelectionReady,
419
+ rangeSelectionBounds,
420
+ rangeSelectionCount,
421
+ rangeSelectionStartLabel,
422
+ rangeSelectionEndLabel,
423
+ rangeSelectionOverlayStyle,
424
+ clearRangeSelection,
425
+ handleRangePointerDown,
426
+ handleRangePointerMove,
427
+ handleRangePointerUp,
428
+ exportRangeToCsv,
429
+ exportingProgress,
430
+ onEdgePointerDown,
431
+ onEdgePointerMove,
432
+ onEdgePointerUp,
433
+ onScroll: onRangeScroll,
434
+ syncScrollLeft: syncRangeScrollLeft,
435
+ } = useRangeSelection({
436
+ controller,
437
+ activeToolId,
438
+ containerRef,
439
+ dataVersion,
440
+ viewportVersion,
441
+ dataFetcher: computed(() => props.dataFetcher),
442
+ batchStockCodes,
443
+ })
335
444
 
445
+ // ── Viewport Initial Values ──
336
446
  // 初始化 kWidth / kGap(与 Chart 引擎 zoom→物理值 转换一致)
337
447
  const initZoom = zoomLevel.value
338
448
  kWidth.value = zoomLevelToKWidth(initZoom, {
@@ -343,66 +453,12 @@ kWidth.value = zoomLevelToKWidth(initZoom, {
343
453
  })
344
454
  kGap.value = kGapFromKWidth(kWidth.value, viewportDpr.value)
345
455
 
346
- /* ========== 主题状态 ========== */
347
- const chartTheme = ref<'light' | 'dark'>('light')
348
-
349
- const chartSettings = ref<ChartSettings>({})
350
-
351
- const tooltipColors = computed(() => {
352
- const isAsiaMarket = chartSettings.value.isAsiaMarket ?? false
353
- const colors = resolveThemeColors(chartTheme.value, isAsiaMarket as boolean | undefined)
354
- return {
355
- upColor: colors.candleUpBody,
356
- downColor: colors.candleDownBody,
357
- }
358
- })
359
-
360
- const themeCssVars = computed(() => {
361
- const theme = chartTheme.value === 'dark' ? darkTheme : lightTheme
362
- const overrides = (chartSettings.value.colorPresetSettings as ColorPresetSettings | undefined)?.[
363
- chartTheme.value
364
- ]
365
- if (overrides && Object.keys(overrides).length > 0) {
366
- return themeToCssVars({ ...theme, colors: { ...theme.colors, ...overrides } })
367
- }
368
- return themeToCssVars(theme)
369
- })
370
-
371
- /* ========== 主题切换(支持 light / dark / auto 跟随系统) ========== */
372
- let autoThemeMediaQuery: MediaQueryList | null = null
373
-
374
- function onSystemThemeChange(e: MediaQueryListEvent) {
375
- controller.value?.setTheme(e.matches ? 'dark' : 'light')
376
- }
377
-
378
- function applyThemeFromSettings(ctrl: ChartController | null, themeSetting: string | undefined) {
379
- if (!ctrl || !themeSetting) return
380
-
381
- if (themeSetting === 'auto') {
382
- const mq = window.matchMedia('(prefers-color-scheme: dark)')
383
- ctrl.setTheme(mq.matches ? 'dark' : 'light')
384
- if (autoThemeMediaQuery !== mq) {
385
- autoThemeMediaQuery?.removeEventListener('change', onSystemThemeChange)
386
- autoThemeMediaQuery = mq
387
- mq.addEventListener('change', onSystemThemeChange)
388
- }
389
- } else {
390
- autoThemeMediaQuery?.removeEventListener('change', onSystemThemeChange)
391
- autoThemeMediaQuery = null
392
- ctrl.setTheme(themeSetting as 'light' | 'dark')
393
- }
394
- }
395
-
456
+ // ── No-op Render Trigger (exposed) ──
396
457
  function scheduleRender() {
397
458
  /* Controller auto-renders on state changes */
398
459
  }
399
460
 
400
- function handleSettingsChange(settings: ChartSettings) {
401
- chartSettings.value = settings
402
- applyThemeFromSettings(controller.value, settings.theme as string)
403
- controller.value?.updateSettingsFacade(settings)
404
- }
405
-
461
+ // ── Tooltip Measurement ──
406
462
  function measureTooltipSize(el: HTMLDivElement, minWidth: number, minHeight: number) {
407
463
  const r = el.getBoundingClientRect()
408
464
  return {
@@ -428,11 +484,10 @@ function setMarkerTooltipEl(el: HTMLDivElement | null) {
428
484
  })
429
485
  }
430
486
 
431
- // ===== Marker tooltip 状态 =====
487
+ // ── Marker Tooltip & Container Rect Cache ──
432
488
  const mousePos = ref({ x: 0, y: 0 })
433
489
  const useAnchorPositioning = ref(false)
434
490
 
435
- // 容器 rect 缓存,避免 pointermove 中反复 getBoundingClientRect 强制同步布局
436
491
  let _cachedContainerRect: DOMRect | null = null
437
492
  function invalidateContainerRectCache(): void {
438
493
  _cachedContainerRect = null
@@ -444,7 +499,7 @@ function getContainerRect(container: HTMLDivElement): DOMRect {
444
499
  return _cachedContainerRect
445
500
  }
446
501
 
447
- // ===== 交互状态(单一来源:InteractionController snapshot) =====
502
+ // ── Interaction State Bridge ──
448
503
  const interactionState = shallowRef<InteractionSnapshot>({
449
504
  crosshairPos: null,
450
505
  crosshairIndex: null,
@@ -462,12 +517,6 @@ const interactionState = shallowRef<InteractionSnapshot>({
462
517
  isHoveringRightAxis: false,
463
518
  })
464
519
 
465
- const drawingController = shallowRef<DrawingInteractionController | null>(null)
466
- const selectedDrawing = computed(() => {
467
- const id = selectedDrawingId.value
468
- if (!id) return null
469
- return drawings.value.find((d) => d.id === id) ?? null
470
- })
471
520
  const paneSeparatorLines = ref<Array<{ id: string; top: number }>>([])
472
521
  const markerTooltipSize = ref({ width: 220, height: 120 })
473
522
  const tooltipLayerOffset = computed(() => {
@@ -487,10 +536,11 @@ const isResizingPane = computed(() => interactionState.value.isResizingPaneBound
487
536
  const isHoveringPaneSeparator = computed(() => interactionState.value.isHoveringPaneBoundary)
488
537
  const hoveredPaneBoundaryId = computed(() => interactionState.value.hoveredPaneBoundaryId)
489
538
  const isHoveringRightAxis = computed(() => interactionState.value.isHoveringRightAxis)
539
+ const isMobile = window.matchMedia('(pointer: coarse)').matches
490
540
  const hoveredIdx = computed(() => interactionState.value.hoveredIndex)
491
541
  const crosshairIdx = computed(() => interactionState.value.crosshairIndex)
492
542
 
493
- // 统一光标样式:用内联 style 替代 CSS 类后代选择器,切断级联失效链
543
+ // ── Derived Computed (Cursor, Hovered, Tooltip) ──
494
544
  const containerCursor = computed(() => {
495
545
  if (isDragging.value) return 'grabbing'
496
546
  if (isResizingPane.value || isHoveringPaneSeparator.value) return 'ns-resize'
@@ -544,32 +594,33 @@ const chartData = computed(() => {
544
594
  return controller.value?.getData() ?? []
545
595
  })
546
596
 
547
- // 通知数据变化(在数据更新后调用)
548
- function handleSelectTool(toolId: string) {
549
- drawingController.value?.setTool(toolId as DrawingToolId)
550
- }
551
-
597
+ // ── Pointer Event Handlers ──
552
598
  function onToggleIndicator() {
553
599
  indicatorSelectorRef.value?.toggleMenu()
554
600
  }
555
601
 
556
- function onUpdateDrawingStyle(style: Partial<DrawingStyle>) {
557
- const d = selectedDrawing.value
558
- if (!d || !drawingController.value) return
559
- drawingController.value.updateDrawingStyle(d.id, style)
560
- drawings.value = drawingController.value.getDrawings()
602
+ function onBatchApply(codes: string[]) {
603
+ batchStockCodes.value = codes
561
604
  }
562
605
 
563
- function onDeleteDrawing() {
564
- const d = selectedDrawing.value
565
- if (!d || !drawingController.value) return
566
- drawingController.value.removeDrawing(d.id)
567
- drawings.value = drawingController.value.getDrawings()
606
+ function handleSelectTool(toolId: string) {
607
+ activeToolId.value = toolId
608
+ if (toolId === 'range-select') {
609
+ drawingController.value?.setTool('cursor')
610
+ selectedDrawingId.value = null
611
+ return
612
+ }
613
+
614
+ clearRangeSelection()
615
+ handleDrawingToolSelect(toolId)
568
616
  }
569
617
 
570
618
  function onPointerDown(e: PointerEvent) {
571
619
  controller.value?.handlePointerEvent(e, {
572
620
  onPointerDown: (event, container) => {
621
+ if (handleRangePointerDown(event, container)) {
622
+ return true
623
+ }
573
624
  if (drawingController.value?.onPointerDown(event, container)) {
574
625
  return true
575
626
  }
@@ -589,6 +640,9 @@ function onPointerMove(e: PointerEvent) {
589
640
  }
590
641
  controller.value?.handlePointerEvent(e, {
591
642
  onPointerMove: (event, container) => {
643
+ if (handleRangePointerMove(event, container)) {
644
+ return true
645
+ }
592
646
  if (drawingController.value?.onPointerMove(event, container)) {
593
647
  drawings.value = drawingController.value.getDrawings()
594
648
  return true
@@ -601,6 +655,9 @@ function onPointerMove(e: PointerEvent) {
601
655
  function onPointerUp(e: PointerEvent) {
602
656
  controller.value?.handlePointerEvent(e, {
603
657
  onPointerUp: (event, container) => {
658
+ if (handleRangePointerUp(event, container)) {
659
+ return true
660
+ }
604
661
  if (drawingController.value?.onPointerUp(event, container)) {
605
662
  return true
606
663
  }
@@ -630,226 +687,11 @@ function onRightAxisPointerLeave(e: PointerEvent) {
630
687
  }
631
688
 
632
689
  function onScroll() {
690
+ onRangeScroll()
633
691
  controller.value?.handleScrollEvent()
634
692
  }
635
693
 
636
- // 主图指标显式状态(副图指标从 subPanes 派生)
637
- const mainActiveIndicators = ref<string[]>([])
638
-
639
- // 副图指标列表从 subPanes 自动派生
640
- const subActiveIndicators = computed(() => {
641
- const ids: string[] = []
642
- const seen = new Set<string>()
643
- for (const pane of subPanes.value) {
644
- if (!seen.has(pane.indicatorId)) {
645
- seen.add(pane.indicatorId)
646
- ids.push(pane.indicatorId)
647
- }
648
- }
649
- return ids
650
- })
651
-
652
- // 最终合并列表(主图 + 副图),保持显示顺序
653
- const activeIndicators = computed(() => [
654
- ...mainActiveIndicators.value,
655
- ...subActiveIndicators.value,
656
- ])
657
-
658
- // 指标参数配置(MA 的 periods 是数组,需要更宽松的类型)
659
- const indicatorParams = ref<Record<string, Record<string, unknown>>>({})
660
-
661
- // 副图槽位状态
662
- interface SubPaneSlot {
663
- id: string // pane ID: 'RSI_0', 'MACD_0', ...
664
- indicatorId: SubIndicatorType
665
- params: Record<string, unknown>
666
- }
667
-
668
- // 副图槽位数组(支持多副图)
669
- const subPanes = ref<SubPaneSlot[]>([])
670
-
671
- // 最大副图数量
672
- const maxSubPanes = 4
673
-
674
- function buildPaneLayoutIntent(): PaneSpec[] {
675
- const mainRatio = paneRatios.value['main'] ?? 3
676
- return subPanes.value.length === 0
677
- ? [{ id: 'main', ratio: mainRatio, visible: true, role: 'price' }]
678
- : [
679
- { id: 'main', ratio: mainRatio, visible: true, role: 'price' },
680
- ...subPanes.value.map((pane) => ({
681
- id: pane.id,
682
- ratio: paneRatios.value[pane.id] ?? 1,
683
- visible: true,
684
- role: 'indicator' as const,
685
- })),
686
- ]
687
- }
688
-
689
- // 获取指标默认参数
690
- function getDefaultParams(
691
- indicatorId: SubIndicatorType,
692
- ): Record<string, number | boolean | string> {
693
- if (indicatorId === 'VOLUME') return {}
694
- const meta = getRegisteredIndicatorDefinition(indicatorId)
695
- if (meta?.runtime?.defaultConfig) {
696
- return { ...meta.runtime.defaultConfig } as Record<string, number | boolean | string>
697
- }
698
- return {}
699
- }
700
-
701
- // 副图指标判定(基于 registry category + VOLUME 特例)
702
- function isSubPaneIndicator(id: string): boolean {
703
- if (id === 'VOLUME') return true
704
- const def = getRegisteredIndicatorDefinition(id)
705
- return !!def && def.category !== 'main'
706
- }
707
-
708
- // 添加副图(使用 Chart API)
709
- function addSubPane(
710
- indicatorId: SubIndicatorType = 'VOLUME',
711
- params?: Record<string, number | boolean | string>,
712
- ): boolean {
713
- if (subPanes.value.length >= maxSubPanes) {
714
- return false
715
- }
716
-
717
- const mergedParams = params ?? getDefaultParams(indicatorId)
718
-
719
- const paneId = controller.value?.addIndicator(indicatorId, 'sub', mergedParams)
720
- if (!paneId) return false
721
- return true
722
- }
723
-
724
- function removeSubPane(paneId: string): void {
725
- controller.value?.removeIndicator(paneId)
726
- }
727
-
728
- function clearAllSubPanes(): void {
729
- for (const pane of subPanes.value) {
730
- controller.value?.removeIndicator(pane.id)
731
- }
732
- }
733
-
734
- function initIndicatorsFromConfig(): void {
735
- const config = props.semanticConfig
736
- const c = controller.value
737
- if (!config || !c) return
738
-
739
- const mainIndicators = config.indicators?.main
740
- if (mainIndicators) {
741
- for (const indicator of mainIndicators) {
742
- if (indicator.enabled) {
743
- const added = c.addIndicator(
744
- indicator.type,
745
- 'main',
746
- indicator.params as Record<string, number | boolean | string>,
747
- )
748
- }
749
- }
750
- }
751
- }
752
-
753
- function switchSubIndicator(paneId: string, newIndicatorId: SubIndicatorType): void {
754
- const nextParams = getDefaultParams(newIndicatorId)
755
- controller.value?.replaceSubPaneIndicator(paneId, newIndicatorId, nextParams)
756
- }
757
-
758
- function handleIndicatorToggle(indicatorId: string, active: boolean) {
759
- const c = controller.value
760
- if (!c) return
761
-
762
- const def = getRegisteredIndicatorDefinition(indicatorId)
763
- const isMain = def && (def.category === 'main' || def.allowMainPane)
764
- if (isMain) {
765
- const existingIndicator = mainActiveIndicators.value.find((id) => id === indicatorId)
766
- if (active && !existingIndicator) {
767
- c.addIndicator(indicatorId, 'main', indicatorParams.value[indicatorId])
768
- } else if (!active && existingIndicator) {
769
- c.removeIndicator(indicatorId.toUpperCase())
770
- }
771
- return
772
- }
773
-
774
- if (isSubPaneIndicator(indicatorId)) {
775
- if (active) {
776
- const existingPane = subPanes.value.find((p) => p.indicatorId === indicatorId)
777
- if (existingPane) return
778
- if (subPanes.value.length >= maxSubPanes) return
779
-
780
- const paneId = c.addIndicator(indicatorId, 'sub', indicatorParams.value[indicatorId])
781
- if (!paneId && subPanes.value.length > 0) {
782
- const lastPane = subPanes.value[subPanes.value.length - 1]
783
- switchSubIndicator(lastPane.id, indicatorId as SubIndicatorType)
784
- }
785
- } else {
786
- const panesToRemove = subPanes.value.filter((p) => p.indicatorId === indicatorId)
787
- panesToRemove.forEach((pane) => {
788
- c.removeIndicator(pane.id)
789
- })
790
- }
791
- }
792
- }
793
-
794
- function handleUpdateParams(indicatorId: string, params: Record<string, unknown>) {
795
- if (
796
- indicatorId === 'MA' ||
797
- indicatorId === 'BOLL' ||
798
- indicatorId === 'EXPMA' ||
799
- indicatorId === 'ENE'
800
- ) {
801
- controller.value?.updateIndicatorParams(indicatorId, params)
802
- return
803
- }
804
- if (isSubPaneIndicator(indicatorId)) {
805
- subPanes.value
806
- .filter((p) => p.indicatorId === indicatorId)
807
- .forEach((pane) => {
808
- controller.value?.updateIndicatorParams(pane.id, params)
809
- })
810
- }
811
- }
812
-
813
- function handleReorderSubIndicators(orderedIndicatorIds: string[]) {
814
- if (!orderedIndicatorIds.length || subPanes.value.length <= 1) return
815
-
816
- const validOrder = orderedIndicatorIds.filter((id): id is SubIndicatorType =>
817
- isSubPaneIndicator(id),
818
- )
819
- if (!validOrder.length) return
820
-
821
- const paneByIndicator = new Map(subPanes.value.map((pane) => [pane.indicatorId, pane] as const))
822
- const nextSubPanes: SubPaneSlot[] = []
823
-
824
- for (const indicatorId of validOrder) {
825
- const pane = paneByIndicator.get(indicatorId)
826
- if (pane) {
827
- nextSubPanes.push(pane)
828
- paneByIndicator.delete(indicatorId)
829
- }
830
- }
831
-
832
- if (nextSubPanes.length === 0) return
833
-
834
- for (const pane of subPanes.value) {
835
- if (paneByIndicator.has(pane.indicatorId)) {
836
- nextSubPanes.push(pane)
837
- paneByIndicator.delete(pane.indicatorId)
838
- }
839
- }
840
-
841
- const currentSubIds = subPanes.value.map((p) => p.id)
842
- const nextSubIds = nextSubPanes.map((p) => p.id)
843
- if (currentSubIds.join('|') === nextSubIds.join('|')) return
844
-
845
- subPanes.value = nextSubPanes
846
-
847
- const c = controller.value
848
- if (!c) return
849
- c.updatePaneLayout(buildPaneLayoutIntent())
850
- }
851
-
852
- /* 计算总宽度:从 Vue 响应式状态读取,zoom 变化时自动重算 */
694
+ // ── Width / Zoom / Expose ──
853
695
  const axisHostWidth = computed(() => props.rightAxisWidth + props.priceLabelWidth)
854
696
 
855
697
  const totalWidth = computed(() => {
@@ -879,7 +721,7 @@ defineExpose({
879
721
  getController: () => controller.value,
880
722
  })
881
723
 
882
- // ==================== onMounted 拆分函数 ====================
724
+ // ── Lifecycle Setup ──
883
725
 
884
726
  function setupWheelHandler(): (e: WheelEvent) => void {
885
727
  const onWheelHandler = (e: WheelEvent) => {
@@ -938,6 +780,8 @@ function setupChartCallbacks(ctrl: ChartController): void {
938
780
  const unsubscribeViewport = ctrl.viewport.subscribe(() => {
939
781
  const vp = ctrl.viewport.peek()
940
782
 
783
+ viewportVersion.value++
784
+
941
785
  if (viewportDpr.value !== vp.dpr) {
942
786
  viewportDpr.value = vp.dpr
943
787
  }
@@ -949,6 +793,12 @@ function setupChartCallbacks(ctrl: ChartController): void {
949
793
  kWidth.value = vp.kWidth
950
794
  kGap.value = vp.kGap
951
795
  }
796
+
797
+ nextTick(() => {
798
+ requestAnimationFrame(() => {
799
+ syncRangeScrollLeft()
800
+ })
801
+ })
952
802
  })
953
803
 
954
804
  const unsubscribeData = ctrl.data.subscribe(() => {
@@ -968,58 +818,7 @@ function setupChartCallbacks(ctrl: ChartController): void {
968
818
  emit('themeChange', newTheme)
969
819
  })
970
820
 
971
- const unsubscribeIndicators = ctrl.indicators.subscribe(() => {
972
- const instances = ctrl.indicators.peek()
973
-
974
- const mains = instances
975
- .filter((i): i is IndicatorInstance & { role: 'main' } => i.role === 'main')
976
- .map((i) => i.definitionId)
977
- mainActiveIndicators.value = mains
978
-
979
- const nextParams = { ...indicatorParams.value }
980
- for (const inst of instances) {
981
- if (inst.role === 'main' && inst.params && Object.keys(inst.params).length > 0) {
982
- nextParams[inst.definitionId] = { ...inst.params }
983
- }
984
- }
985
-
986
- ctrl.updateRendererConfig('mainIndicatorLegend', {
987
- indicators: {
988
- MA: { enabled: mains.includes('MA'), params: nextParams['MA'] || {} },
989
- BOLL: { enabled: mains.includes('BOLL'), params: nextParams['BOLL'] || {} },
990
- EXPMA: { enabled: mains.includes('EXPMA'), params: nextParams['EXPMA'] || {} },
991
- ENE: { enabled: mains.includes('ENE'), params: nextParams['ENE'] || {} },
992
- },
993
- })
994
-
995
- indicatorParams.value = nextParams
996
- })
997
-
998
- const unsubscribeSubPanes = ctrl.subPanes.subscribe(() => {
999
- const subPaneInfos = ctrl.subPanes.peek()
1000
- const signalIds = new Set(subPaneInfos.map((sp) => sp.paneId))
1001
-
1002
- const merged = subPanes.value.filter((p) => signalIds.has(p.id))
1003
- const existingIds = new Set(merged.map((p) => p.id))
1004
- for (const sp of subPaneInfos) {
1005
- if (!existingIds.has(sp.paneId)) {
1006
- merged.push({
1007
- id: sp.paneId,
1008
- indicatorId: sp.indicatorId as SubIndicatorType,
1009
- params: sp.params,
1010
- })
1011
- }
1012
- }
1013
- subPanes.value = merged
1014
-
1015
- const nextParams = { ...indicatorParams.value }
1016
- for (const sp of subPaneInfos) {
1017
- if (sp.params && Object.keys(sp.params).length > 0) {
1018
- nextParams[sp.indicatorId] = { ...sp.params }
1019
- }
1020
- }
1021
- indicatorParams.value = nextParams
1022
- })
821
+ const unsubscribeIndicators = setupIndicatorSubscriptions(ctrl)
1023
822
 
1024
823
  const unsubscribeComparisonColors = ctrl.comparisonColors.subscribe(() => {
1025
824
  comparisonColorsMap.value = new Map(ctrl.comparisonColors.peek())
@@ -1037,34 +836,18 @@ function setupChartCallbacks(ctrl: ChartController): void {
1037
836
  unsubscribePaneLayout()
1038
837
  unsubscribeTheme()
1039
838
  unsubscribeIndicators()
1040
- unsubscribeSubPanes()
1041
839
  unsubscribeComparisonColors()
1042
840
  unsubscribeComparisonLoading()
1043
- autoThemeMediaQuery?.removeEventListener('change', onSystemThemeChange)
1044
841
  })
1045
842
  }
1046
843
 
1047
844
  function applyInitialSettings(ctrl: ChartController): void {
1048
845
  const initialSettings = toolbarRef.value?.getSettings() ?? { showVolumePriceMarkers: true }
1049
846
  chartSettings.value = initialSettings
1050
- applyThemeFromSettings(ctrl, initialSettings.theme as string)
847
+ applyThemeFromSettings(initialSettings.theme as string)
1051
848
  ctrl.updateSettingsFacade(initialSettings)
1052
849
  }
1053
850
 
1054
- function setupDrawingController(ctrl: ChartController): void {
1055
- drawingController.value = new DrawingInteractionController(ctrl)
1056
- drawingController.value.setCallbacks({
1057
- onDrawingCreated: (drawing) => {
1058
- drawings.value = [...drawings.value, drawing]
1059
- selectedDrawingId.value = drawing.id
1060
- },
1061
- onToolChange: () => {},
1062
- onDrawingSelected: (drawing) => {
1063
- selectedDrawingId.value = drawing?.id ?? null
1064
- },
1065
- })
1066
- }
1067
-
1068
851
  function setupInteractionCallbacks(ctrl: ChartController): void {
1069
852
  ctrl.setTooltipAnchorPositioning(useAnchorPositioning.value)
1070
853
  ctrl.interactionState.subscribe(() => {
@@ -1085,7 +868,7 @@ function setupSemanticController(ctrl: ChartController): void {
1085
868
 
1086
869
  // config:ready → Chart 侧已完成创建,Vue 回读状态
1087
870
  semanticController.value.on('config:ready', () => {
1088
- initIndicatorsFromConfig()
871
+ initIndicatorsFromConfig(props.semanticConfig)
1089
872
  nextTick(() => controller.value?.scrollToRight())
1090
873
  })
1091
874
  // 暂时断开语义化配置加载,由搜索结果驱动
@@ -1096,6 +879,7 @@ function setupSemanticController(ctrl: ChartController): void {
1096
879
  // })
1097
880
  }
1098
881
 
882
+ // ── onMounted ──
1099
883
  onMounted(async () => {
1100
884
  useAnchorPositioning.value = false
1101
885
 
@@ -1121,13 +905,13 @@ onMounted(async () => {
1121
905
  // 3.5) 在任何 draw 之前注册主图指标(BOLL/MA 等)
1122
906
  // initIndicatorsFromConfig 是同步的,读 props.semanticConfig 即可注册,
1123
907
  // 确保 scheduler 首次 applyResults 时 BOLL 已在 registry 里
1124
- initIndicatorsFromConfig()
908
+ initIndicatorsFromConfig(props.semanticConfig)
1125
909
 
1126
910
  // 4) 工具栏初始设置
1127
911
  applyInitialSettings(ctrl)
1128
912
 
1129
913
  // 5) 绘图交互控制器
1130
- setupDrawingController(ctrl)
914
+ setupDrawing(ctrl)
1131
915
 
1132
916
  // 6) 交互信号桥接
1133
917
  setupInteractionCallbacks(ctrl)
@@ -1136,6 +920,7 @@ onMounted(async () => {
1136
920
  setupSemanticController(ctrl)
1137
921
  })
1138
922
 
923
+ // ── onUnmounted & Watchers ──
1139
924
  onUnmounted(() => {
1140
925
  const ctrl = controller.value
1141
926
  if (ctrl) {
@@ -1307,10 +1092,43 @@ watch(
1307
1092
  position: relative;
1308
1093
  }
1309
1094
 
1095
+ .range-selection-overlay {
1096
+ position: absolute;
1097
+ top: 0;
1098
+ z-index: 25;
1099
+ box-sizing: border-box;
1100
+ border: 1px solid rgba(24, 144, 255, 0.75);
1101
+ background: rgba(24, 144, 255, 0.14);
1102
+ pointer-events: none;
1103
+ }
1104
+
1105
+ .range-selection-overlay.is-dragging {
1106
+ background: rgba(24, 144, 255, 0.2);
1107
+ }
1108
+
1109
+ .range-selection-handle {
1110
+ position: absolute;
1111
+ top: 0;
1112
+ bottom: 0;
1113
+ width: 8px;
1114
+ cursor: ew-resize;
1115
+ pointer-events: auto;
1116
+ z-index: 101;
1117
+ }
1118
+
1119
+ .range-selection-handle--left {
1120
+ left: -4px;
1121
+ }
1122
+
1123
+ .range-selection-handle--right {
1124
+ right: -4px;
1125
+ }
1126
+
1310
1127
  .canvas-layer {
1311
1128
  position: sticky;
1312
1129
  left: 0;
1313
1130
  top: 0;
1131
+ z-index: 26;
1314
1132
  pointer-events: none;
1315
1133
  }
1316
1134
 
@@ -1345,6 +1163,7 @@ watch(
1345
1163
  gap: 4px;
1346
1164
  }
1347
1165
  }
1166
+
1348
1167
  </style>
1349
1168
 
1350
1169
  <style>
@@ -1373,3 +1192,9 @@ watch(
1373
1192
  z-index: 15;
1374
1193
  }
1375
1194
  </style>
1195
+
1196
+ <style>
1197
+ * {
1198
+ -webkit-tap-highlight-color: transparent;
1199
+ }
1200
+ </style>