@363045841yyt/klinechart 0.8.4 → 0.8.5

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 (45) hide show
  1. package/dist/components/BatchStockDialog.vue.d.ts +13 -0
  2. package/dist/components/BatchStockDialog.vue.d.ts.map +1 -0
  3. package/dist/components/CompareSymbolSelector.vue.d.ts.map +1 -1
  4. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  5. package/dist/components/ExportProgressDialog.vue.d.ts +15 -0
  6. package/dist/components/ExportProgressDialog.vue.d.ts.map +1 -0
  7. package/dist/components/KLineChart.vue.d.ts +5 -9
  8. package/dist/components/KLineChart.vue.d.ts.map +1 -1
  9. package/dist/components/LeftToolbar.vue.d.ts.map +1 -1
  10. package/dist/components/SymbolSelector.vue.d.ts.map +1 -1
  11. package/dist/components/TopToolbar.vue.d.ts.map +1 -1
  12. package/dist/composables/chart/useChartTheme.d.ts +329 -0
  13. package/dist/composables/chart/useChartTheme.d.ts.map +1 -0
  14. package/dist/composables/chart/useDrawingManager.d.ts +86 -0
  15. package/dist/composables/chart/useDrawingManager.d.ts.map +1 -0
  16. package/dist/composables/chart/useIndicatorManager.d.ts +38 -0
  17. package/dist/composables/chart/useIndicatorManager.d.ts.map +1 -0
  18. package/dist/composables/chart/useRangeSelection.d.ts +65 -0
  19. package/dist/composables/chart/useRangeSelection.d.ts.map +1 -0
  20. package/dist/composables/useTeleportedPopup.d.ts +8 -0
  21. package/dist/composables/useTeleportedPopup.d.ts.map +1 -0
  22. package/dist/index.cjs +9 -2
  23. package/dist/index.css +1 -1
  24. package/dist/index.js +1722 -1060
  25. package/dist/tools/calcRangeOverlayPixel.d.ts +15 -0
  26. package/dist/tools/calcRangeOverlayPixel.d.ts.map +1 -0
  27. package/dist/tools/getKLineIndexByTimestamp.d.ts +4 -0
  28. package/dist/tools/getKLineIndexByTimestamp.d.ts.map +1 -0
  29. package/dist/web-component.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/components/BatchStockDialog.vue +293 -0
  32. package/src/components/CompareSymbolSelector.vue +35 -8
  33. package/src/components/Dropdown.vue +42 -19
  34. package/src/components/ExportProgressDialog.vue +226 -0
  35. package/src/components/KLineChart.vue +325 -396
  36. package/src/components/LeftToolbar.vue +2 -1
  37. package/src/components/SymbolSelector.vue +35 -8
  38. package/src/components/TopToolbar.vue +55 -2
  39. package/src/composables/chart/useChartTheme.ts +86 -0
  40. package/src/composables/chart/useDrawingManager.ts +67 -0
  41. package/src/composables/chart/useIndicatorManager.ts +307 -0
  42. package/src/composables/chart/useRangeSelection.ts +417 -0
  43. package/src/composables/useTeleportedPopup.ts +33 -0
  44. package/src/tools/calcRangeOverlayPixel.ts +28 -0
  45. package/src/tools/getKLineIndexByTimestamp.ts +40 -0
@@ -66,6 +66,75 @@
66
66
  @update-style="onUpdateDrawingStyle"
67
67
  @delete="onDeleteDrawing"
68
68
  />
69
+ <div
70
+ v-if="rangeSelectionReady"
71
+ class="range-selection-export"
72
+ @pointerdown.stop
73
+ @pointermove.stop
74
+ @pointerup.stop
75
+ >
76
+ <input
77
+ class="range-selection-export__label"
78
+ v-model="customStartDate"
79
+ :placeholder="rangeSelectionStartLabel"
80
+ />
81
+ <span class="range-selection-export__sep">~</span>
82
+ <input
83
+ class="range-selection-export__label"
84
+ v-model="customEndDate"
85
+ :placeholder="rangeSelectionEndLabel"
86
+ />
87
+ <button
88
+ type="button"
89
+ class="toolbar-btn"
90
+ title="批量设置"
91
+ @click.stop="showBatchStockDialog = true"
92
+ >
93
+ 批量设置
94
+ </button>
95
+ <button
96
+ type="button"
97
+ class="toolbar-btn"
98
+ title="导出"
99
+ @click.stop="exportRangeToCsv"
100
+ >
101
+ 导出
102
+ </button>
103
+ <button
104
+ type="button"
105
+ class="toolbar-btn delete-btn"
106
+ title="删除选区"
107
+ @click.stop="clearRangeSelection"
108
+ >
109
+ <svg class="delete-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
110
+ <path d="M3 6h18" />
111
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
112
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
113
+ </svg>
114
+ </button>
115
+ </div>
116
+ </div>
117
+ <div
118
+ v-if="rangeSelectionOverlayStyle"
119
+ class="range-selection-overlay"
120
+ :class="{ 'is-dragging': rangeSelection.isDragging }"
121
+ :style="rangeSelectionOverlayStyle"
122
+ aria-label="已选择的 K 线区间"
123
+ >
124
+ <div
125
+ v-if="rangeSelectionReady"
126
+ class="range-selection-handle range-selection-handle--left"
127
+ @pointerdown.stop="onEdgePointerDown('left', $event)"
128
+ @pointermove.stop="onEdgePointerMove($event)"
129
+ @pointerup.stop="onEdgePointerUp($event)"
130
+ />
131
+ <div
132
+ v-if="rangeSelectionReady"
133
+ class="range-selection-handle range-selection-handle--right"
134
+ @pointerdown.stop="onEdgePointerDown('right', $event)"
135
+ @pointermove.stop="onEdgePointerMove($event)"
136
+ @pointerup.stop="onEdgePointerUp($event)"
137
+ />
69
138
  </div>
70
139
  </div>
71
140
  </div>
@@ -116,6 +185,12 @@
116
185
  ></div>
117
186
  </div>
118
187
  </div>
188
+ <ExportProgressDialog :progress="exportingProgress" @close="exportingProgress = null" />
189
+ <BatchStockDialog
190
+ :show="showBatchStockDialog"
191
+ @close="showBatchStockDialog = false"
192
+ @apply="onBatchApply"
193
+ />
119
194
  <IndicatorSelector
120
195
  ref="indicatorSelectorRef"
121
196
  :active-indicators="activeIndicators"
@@ -142,34 +217,22 @@ import { provideFullscreenTeleportTarget } from '../composables/useFullscreenTel
142
217
  import {
143
218
  createChartController,
144
219
  type ChartController,
145
- type PaneSpec,
146
- type IndicatorInstance,
147
- type SubIndicatorType,
148
220
  type InteractionSnapshot,
149
- type DrawingToolId,
150
- type KLineData,
151
221
  type SymbolSpec,
152
222
  zoomLevelToKWidth,
153
223
  kGapFromKWidth,
154
- DrawingInteractionController,
155
224
  } 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'
225
+ import { useChartTheme } from '../composables/chart/useChartTheme'
226
+ import { useIndicatorManager } from '../composables/chart/useIndicatorManager'
227
+ import { useDrawingManager } from '../composables/chart/useDrawingManager'
161
228
  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'
229
+ import { useRangeSelection } from '../composables/chart/useRangeSelection'
170
230
  import LeftToolbar from './LeftToolbar.vue'
171
231
  import TopToolbar, { type SymbolItem } from './TopToolbar.vue'
232
+ import BatchStockDialog from './BatchStockDialog.vue'
233
+ import ExportProgressDialog from './ExportProgressDialog.vue'
172
234
 
235
+ // ── Props & Emits ──
173
236
  const props = withDefaults(
174
237
  defineProps<{
175
238
  /** 语义化配置(可选,唯一控制源) */
@@ -231,6 +294,7 @@ const emit = defineEmits<{
231
294
  (e: 'kLineAdjustChange', adjust: 'qfq' | 'hfq' | 'splits' | 'none'): void
232
295
  }>()
233
296
 
297
+ // ── Symbol / Comparison State ──
234
298
  const kLineLevel = ref(props.semanticConfig?.data?.period ?? 'daily')
235
299
  const kLineAdjust = ref(props.semanticConfig?.data?.adjust ?? 'none')
236
300
  const isIntraday = computed(() => kLineLevel.value.includes('min'))
@@ -302,9 +366,12 @@ function forcePercentAxis() {
302
366
  controller.value?.updateSettingsFacade(nextSettings)
303
367
  try {
304
368
  localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(nextSettings))
305
- } catch { /* quota exceeded */ }
369
+ } catch {
370
+ /* quota exceeded */
371
+ }
306
372
  }
307
373
 
374
+ // ── DOM Template Refs ──
308
375
  const containerRef = ref<HTMLDivElement | null>(null)
309
376
  const chartMainRef = ref<HTMLDivElement | null>(null)
310
377
  const chartWrapperRef = ref<HTMLDivElement | null>(null)
@@ -313,26 +380,100 @@ const toolbarRef = ref<InstanceType<typeof LeftToolbar> | null>(null)
313
380
  const indicatorSelectorRef = ref<InstanceType<typeof IndicatorSelector> | null>(null)
314
381
  provideFullscreenTeleportTarget(chartWrapperRef)
315
382
 
316
- /* ========== 图表控制器 ========== */
383
+ // ── Controller & Composable Wiring ──
317
384
  const controller = shallowRef<ChartController | null>(null)
318
385
 
319
- /* ========== 语义化控制器 ========== */
386
+ const {
387
+ chartTheme,
388
+ chartSettings,
389
+ tooltipColors,
390
+ themeCssVars,
391
+ handleSettingsChange,
392
+ applyThemeFromSettings,
393
+ } = useChartTheme(controller)
394
+
320
395
  const semanticController = shallowRef<SemanticChartController | null>(null)
321
396
 
322
- /* ========== 本地响应式状态(信号驱动,取代 ChartStore) ========== */
397
+ /* ========== 本地响应式状态 ========== */
323
398
  const dataLength = ref(0)
324
399
  const dataVersion = ref(0)
400
+ const showBatchStockDialog = ref(false)
401
+ const batchStockCodes = ref<string[]>([])
402
+ const viewportVersion = ref(0)
325
403
  const viewportDpr = ref(1)
326
404
  const zoomLevel = ref(props.initialZoomLevel ?? 1)
327
405
  const kWidth = ref(0)
328
406
  const kGap = ref(1)
329
407
  const viewWidth = ref(0)
330
408
  const paneRatios = ref<Record<string, number>>({})
331
- const selectedDrawingId = ref<string | null>(null)
332
- const drawings = ref<DrawingObject[]>([])
333
409
  const comparisonColorsMap = ref<Map<string, string>>(new Map())
334
410
  const comparisonLoading = ref(false)
411
+ const activeToolId = ref('cursor')
412
+
413
+ const {
414
+ mainActiveIndicators,
415
+ subActiveIndicators,
416
+ activeIndicators,
417
+ indicatorParams,
418
+ subPanes,
419
+ buildPaneLayoutIntent,
420
+ getDefaultParams,
421
+ isSubPaneIndicator,
422
+ addSubPane,
423
+ removeSubPane,
424
+ clearAllSubPanes,
425
+ initIndicatorsFromConfig,
426
+ switchSubIndicator,
427
+ handleIndicatorToggle,
428
+ handleUpdateParams,
429
+ handleReorderSubIndicators,
430
+ setupIndicatorSubscriptions,
431
+ } = useIndicatorManager(controller, paneRatios)
432
+
433
+ const {
434
+ drawingController,
435
+ selectedDrawingId,
436
+ selectedDrawing,
437
+ drawings,
438
+ handleSelectTool: handleDrawingToolSelect,
439
+ onUpdateDrawingStyle,
440
+ onDeleteDrawing,
441
+ setupDrawing,
442
+ } = useDrawingManager(controller)
443
+
444
+ const {
445
+ rangeSelection,
446
+ customStartDate,
447
+ customEndDate,
448
+ containerScrollLeft,
449
+ isRangeSelectActive,
450
+ rangeSelectionReady,
451
+ rangeSelectionBounds,
452
+ rangeSelectionStartLabel,
453
+ rangeSelectionEndLabel,
454
+ rangeSelectionOverlayStyle,
455
+ clearRangeSelection,
456
+ handleRangePointerDown,
457
+ handleRangePointerMove,
458
+ handleRangePointerUp,
459
+ exportRangeToCsv,
460
+ exportingProgress,
461
+ onEdgePointerDown,
462
+ onEdgePointerMove,
463
+ onEdgePointerUp,
464
+ onScroll: onRangeScroll,
465
+ syncScrollLeft: syncRangeScrollLeft,
466
+ } = useRangeSelection({
467
+ controller,
468
+ activeToolId,
469
+ containerRef,
470
+ dataVersion,
471
+ viewportVersion,
472
+ dataFetcher: computed(() => props.dataFetcher),
473
+ batchStockCodes,
474
+ })
335
475
 
476
+ // ── Viewport Initial Values ──
336
477
  // 初始化 kWidth / kGap(与 Chart 引擎 zoom→物理值 转换一致)
337
478
  const initZoom = zoomLevel.value
338
479
  kWidth.value = zoomLevelToKWidth(initZoom, {
@@ -343,66 +484,12 @@ kWidth.value = zoomLevelToKWidth(initZoom, {
343
484
  })
344
485
  kGap.value = kGapFromKWidth(kWidth.value, viewportDpr.value)
345
486
 
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
-
487
+ // ── No-op Render Trigger (exposed) ──
396
488
  function scheduleRender() {
397
489
  /* Controller auto-renders on state changes */
398
490
  }
399
491
 
400
- function handleSettingsChange(settings: ChartSettings) {
401
- chartSettings.value = settings
402
- applyThemeFromSettings(controller.value, settings.theme as string)
403
- controller.value?.updateSettingsFacade(settings)
404
- }
405
-
492
+ // ── Tooltip Measurement ──
406
493
  function measureTooltipSize(el: HTMLDivElement, minWidth: number, minHeight: number) {
407
494
  const r = el.getBoundingClientRect()
408
495
  return {
@@ -428,11 +515,10 @@ function setMarkerTooltipEl(el: HTMLDivElement | null) {
428
515
  })
429
516
  }
430
517
 
431
- // ===== Marker tooltip 状态 =====
518
+ // ── Marker Tooltip & Container Rect Cache ──
432
519
  const mousePos = ref({ x: 0, y: 0 })
433
520
  const useAnchorPositioning = ref(false)
434
521
 
435
- // 容器 rect 缓存,避免 pointermove 中反复 getBoundingClientRect 强制同步布局
436
522
  let _cachedContainerRect: DOMRect | null = null
437
523
  function invalidateContainerRectCache(): void {
438
524
  _cachedContainerRect = null
@@ -444,7 +530,7 @@ function getContainerRect(container: HTMLDivElement): DOMRect {
444
530
  return _cachedContainerRect
445
531
  }
446
532
 
447
- // ===== 交互状态(单一来源:InteractionController snapshot) =====
533
+ // ── Interaction State Bridge ──
448
534
  const interactionState = shallowRef<InteractionSnapshot>({
449
535
  crosshairPos: null,
450
536
  crosshairIndex: null,
@@ -462,12 +548,6 @@ const interactionState = shallowRef<InteractionSnapshot>({
462
548
  isHoveringRightAxis: false,
463
549
  })
464
550
 
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
551
  const paneSeparatorLines = ref<Array<{ id: string; top: number }>>([])
472
552
  const markerTooltipSize = ref({ width: 220, height: 120 })
473
553
  const tooltipLayerOffset = computed(() => {
@@ -490,7 +570,7 @@ const isHoveringRightAxis = computed(() => interactionState.value.isHoveringRigh
490
570
  const hoveredIdx = computed(() => interactionState.value.hoveredIndex)
491
571
  const crosshairIdx = computed(() => interactionState.value.crosshairIndex)
492
572
 
493
- // 统一光标样式:用内联 style 替代 CSS 类后代选择器,切断级联失效链
573
+ // ── Derived Computed (Cursor, Hovered, Tooltip) ──
494
574
  const containerCursor = computed(() => {
495
575
  if (isDragging.value) return 'grabbing'
496
576
  if (isResizingPane.value || isHoveringPaneSeparator.value) return 'ns-resize'
@@ -544,32 +624,33 @@ const chartData = computed(() => {
544
624
  return controller.value?.getData() ?? []
545
625
  })
546
626
 
547
- // 通知数据变化(在数据更新后调用)
548
- function handleSelectTool(toolId: string) {
549
- drawingController.value?.setTool(toolId as DrawingToolId)
550
- }
551
-
627
+ // ── Pointer Event Handlers ──
552
628
  function onToggleIndicator() {
553
629
  indicatorSelectorRef.value?.toggleMenu()
554
630
  }
555
631
 
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()
632
+ function onBatchApply(codes: string[]) {
633
+ batchStockCodes.value = codes
561
634
  }
562
635
 
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()
636
+ function handleSelectTool(toolId: string) {
637
+ activeToolId.value = toolId
638
+ if (toolId === 'range-select') {
639
+ drawingController.value?.setTool('cursor')
640
+ selectedDrawingId.value = null
641
+ return
642
+ }
643
+
644
+ clearRangeSelection()
645
+ handleDrawingToolSelect(toolId)
568
646
  }
569
647
 
570
648
  function onPointerDown(e: PointerEvent) {
571
649
  controller.value?.handlePointerEvent(e, {
572
650
  onPointerDown: (event, container) => {
651
+ if (handleRangePointerDown(event, container)) {
652
+ return true
653
+ }
573
654
  if (drawingController.value?.onPointerDown(event, container)) {
574
655
  return true
575
656
  }
@@ -589,6 +670,9 @@ function onPointerMove(e: PointerEvent) {
589
670
  }
590
671
  controller.value?.handlePointerEvent(e, {
591
672
  onPointerMove: (event, container) => {
673
+ if (handleRangePointerMove(event, container)) {
674
+ return true
675
+ }
592
676
  if (drawingController.value?.onPointerMove(event, container)) {
593
677
  drawings.value = drawingController.value.getDrawings()
594
678
  return true
@@ -601,6 +685,9 @@ function onPointerMove(e: PointerEvent) {
601
685
  function onPointerUp(e: PointerEvent) {
602
686
  controller.value?.handlePointerEvent(e, {
603
687
  onPointerUp: (event, container) => {
688
+ if (handleRangePointerUp(event, container)) {
689
+ return true
690
+ }
604
691
  if (drawingController.value?.onPointerUp(event, container)) {
605
692
  return true
606
693
  }
@@ -630,226 +717,11 @@ function onRightAxisPointerLeave(e: PointerEvent) {
630
717
  }
631
718
 
632
719
  function onScroll() {
720
+ onRangeScroll()
633
721
  controller.value?.handleScrollEvent()
634
722
  }
635
723
 
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 变化时自动重算 */
724
+ // ── Width / Zoom / Expose ──
853
725
  const axisHostWidth = computed(() => props.rightAxisWidth + props.priceLabelWidth)
854
726
 
855
727
  const totalWidth = computed(() => {
@@ -879,7 +751,7 @@ defineExpose({
879
751
  getController: () => controller.value,
880
752
  })
881
753
 
882
- // ==================== onMounted 拆分函数 ====================
754
+ // ── Lifecycle Setup ──
883
755
 
884
756
  function setupWheelHandler(): (e: WheelEvent) => void {
885
757
  const onWheelHandler = (e: WheelEvent) => {
@@ -938,6 +810,8 @@ function setupChartCallbacks(ctrl: ChartController): void {
938
810
  const unsubscribeViewport = ctrl.viewport.subscribe(() => {
939
811
  const vp = ctrl.viewport.peek()
940
812
 
813
+ viewportVersion.value++
814
+
941
815
  if (viewportDpr.value !== vp.dpr) {
942
816
  viewportDpr.value = vp.dpr
943
817
  }
@@ -949,6 +823,12 @@ function setupChartCallbacks(ctrl: ChartController): void {
949
823
  kWidth.value = vp.kWidth
950
824
  kGap.value = vp.kGap
951
825
  }
826
+
827
+ nextTick(() => {
828
+ requestAnimationFrame(() => {
829
+ syncRangeScrollLeft()
830
+ })
831
+ })
952
832
  })
953
833
 
954
834
  const unsubscribeData = ctrl.data.subscribe(() => {
@@ -968,58 +848,7 @@ function setupChartCallbacks(ctrl: ChartController): void {
968
848
  emit('themeChange', newTheme)
969
849
  })
970
850
 
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
- })
851
+ const unsubscribeIndicators = setupIndicatorSubscriptions(ctrl)
1023
852
 
1024
853
  const unsubscribeComparisonColors = ctrl.comparisonColors.subscribe(() => {
1025
854
  comparisonColorsMap.value = new Map(ctrl.comparisonColors.peek())
@@ -1037,34 +866,18 @@ function setupChartCallbacks(ctrl: ChartController): void {
1037
866
  unsubscribePaneLayout()
1038
867
  unsubscribeTheme()
1039
868
  unsubscribeIndicators()
1040
- unsubscribeSubPanes()
1041
869
  unsubscribeComparisonColors()
1042
870
  unsubscribeComparisonLoading()
1043
- autoThemeMediaQuery?.removeEventListener('change', onSystemThemeChange)
1044
871
  })
1045
872
  }
1046
873
 
1047
874
  function applyInitialSettings(ctrl: ChartController): void {
1048
875
  const initialSettings = toolbarRef.value?.getSettings() ?? { showVolumePriceMarkers: true }
1049
876
  chartSettings.value = initialSettings
1050
- applyThemeFromSettings(ctrl, initialSettings.theme as string)
877
+ applyThemeFromSettings(initialSettings.theme as string)
1051
878
  ctrl.updateSettingsFacade(initialSettings)
1052
879
  }
1053
880
 
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
881
  function setupInteractionCallbacks(ctrl: ChartController): void {
1069
882
  ctrl.setTooltipAnchorPositioning(useAnchorPositioning.value)
1070
883
  ctrl.interactionState.subscribe(() => {
@@ -1085,7 +898,7 @@ function setupSemanticController(ctrl: ChartController): void {
1085
898
 
1086
899
  // config:ready → Chart 侧已完成创建,Vue 回读状态
1087
900
  semanticController.value.on('config:ready', () => {
1088
- initIndicatorsFromConfig()
901
+ initIndicatorsFromConfig(props.semanticConfig)
1089
902
  nextTick(() => controller.value?.scrollToRight())
1090
903
  })
1091
904
  // 暂时断开语义化配置加载,由搜索结果驱动
@@ -1096,6 +909,7 @@ function setupSemanticController(ctrl: ChartController): void {
1096
909
  // })
1097
910
  }
1098
911
 
912
+ // ── onMounted ──
1099
913
  onMounted(async () => {
1100
914
  useAnchorPositioning.value = false
1101
915
 
@@ -1121,13 +935,13 @@ onMounted(async () => {
1121
935
  // 3.5) 在任何 draw 之前注册主图指标(BOLL/MA 等)
1122
936
  // initIndicatorsFromConfig 是同步的,读 props.semanticConfig 即可注册,
1123
937
  // 确保 scheduler 首次 applyResults 时 BOLL 已在 registry 里
1124
- initIndicatorsFromConfig()
938
+ initIndicatorsFromConfig(props.semanticConfig)
1125
939
 
1126
940
  // 4) 工具栏初始设置
1127
941
  applyInitialSettings(ctrl)
1128
942
 
1129
943
  // 5) 绘图交互控制器
1130
- setupDrawingController(ctrl)
944
+ setupDrawing(ctrl)
1131
945
 
1132
946
  // 6) 交互信号桥接
1133
947
  setupInteractionCallbacks(ctrl)
@@ -1136,6 +950,7 @@ onMounted(async () => {
1136
950
  setupSemanticController(ctrl)
1137
951
  })
1138
952
 
953
+ // ── onUnmounted & Watchers ──
1139
954
  onUnmounted(() => {
1140
955
  const ctrl = controller.value
1141
956
  if (ctrl) {
@@ -1307,10 +1122,124 @@ watch(
1307
1122
  position: relative;
1308
1123
  }
1309
1124
 
1125
+ .range-selection-overlay {
1126
+ position: absolute;
1127
+ top: 0;
1128
+ z-index: 25;
1129
+ box-sizing: border-box;
1130
+ border: 1px solid rgba(24, 144, 255, 0.75);
1131
+ background: rgba(24, 144, 255, 0.14);
1132
+ pointer-events: none;
1133
+ }
1134
+
1135
+ .range-selection-overlay.is-dragging {
1136
+ background: rgba(24, 144, 255, 0.2);
1137
+ }
1138
+
1139
+ .range-selection-handle {
1140
+ position: absolute;
1141
+ top: 0;
1142
+ bottom: 0;
1143
+ width: 8px;
1144
+ cursor: ew-resize;
1145
+ pointer-events: auto;
1146
+ z-index: 101;
1147
+ }
1148
+
1149
+ .range-selection-handle--left { left: -4px; }
1150
+
1151
+ .range-selection-handle--right { right: -4px; }
1152
+
1153
+ .range-selection-export {
1154
+ position: absolute;
1155
+ left: 50%;
1156
+ top: 8px;
1157
+ transform: translateX(-50%);
1158
+ display: flex;
1159
+ align-items: center;
1160
+ gap: 6px;
1161
+ padding: 4px 8px;
1162
+ height: 32px;
1163
+ background: color-mix(in srgb, var(--klc-color-tag-bg-white) 88%, transparent);
1164
+ backdrop-filter: blur(8px);
1165
+ -webkit-backdrop-filter: blur(8px);
1166
+ border: 1px solid var(--klc-color-border-button);
1167
+ border-radius: 6px;
1168
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
1169
+ z-index: 100;
1170
+ user-select: none;
1171
+ pointer-events: auto;
1172
+ }
1173
+
1174
+ .range-selection-export .toolbar-btn {
1175
+ display: inline-flex;
1176
+ align-items: center;
1177
+ justify-content: center;
1178
+ height: 24px;
1179
+ padding: 0 8px;
1180
+ border: 1px solid var(--klc-color-border-button);
1181
+ border-radius: 4px;
1182
+ background: transparent;
1183
+ color: var(--klc-color-axis-text);
1184
+ font-size: 12px;
1185
+ cursor: pointer;
1186
+ transition:
1187
+ border-color 0.15s ease,
1188
+ background 0.15s ease,
1189
+ color 0.15s ease;
1190
+ white-space: nowrap;
1191
+ }
1192
+
1193
+ .range-selection-export .toolbar-btn:hover {
1194
+ border-color: var(--klc-color-axis-line);
1195
+ background: var(--klc-color-grid-minor);
1196
+ color: var(--klc-color-foreground);
1197
+ }
1198
+
1199
+ .range-selection-export .toolbar-btn.delete-btn {
1200
+ padding: 0;
1201
+ width: 24px;
1202
+ border-color: transparent;
1203
+ }
1204
+
1205
+ .range-selection-export .toolbar-btn.delete-btn:hover {
1206
+ color: #dc2626;
1207
+ border-color: #fca5a5;
1208
+ background: #fef2f2;
1209
+ }
1210
+
1211
+ .range-selection-export .delete-icon {
1212
+ width: 14px;
1213
+ height: 14px;
1214
+ }
1215
+
1216
+ .range-selection-export__label {
1217
+ color: var(--klc-color-axis-text);
1218
+ font-size: 11px;
1219
+ white-space: nowrap;
1220
+ border: 1px solid var(--klc-color-border-button);
1221
+ background: none;
1222
+ outline: none;
1223
+ padding: 1px 4px;
1224
+ width: 80px;
1225
+ height: 24px;
1226
+ box-sizing: border-box;
1227
+ font-family: inherit;
1228
+ border-radius: 3px;
1229
+ text-align: center;
1230
+ }
1231
+
1232
+ .range-selection-export__sep {
1233
+ color: var(--klc-color-axis-text);
1234
+ font-size: 11px;
1235
+ user-select: none;
1236
+ }
1237
+
1310
1238
  .canvas-layer {
1311
1239
  position: sticky;
1312
1240
  left: 0;
1313
1241
  top: 0;
1242
+ z-index: 26;
1314
1243
  pointer-events: none;
1315
1244
  }
1316
1245