@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
@@ -139,6 +139,7 @@ import IconTablerShape from '~icons/tabler/shape'
139
139
  import IconTablerChartDots3 from '~icons/tabler/chart-dots-3'
140
140
  import IconTablerCaretUpDown from '~icons/tabler/caret-up-down'
141
141
  import IconTablerBrackets from '~icons/tabler/brackets'
142
+ import IconTablerArrowsHorizontal from '~icons/tabler/arrows-horizontal'
142
143
  import {
143
144
  DEFAULT_SETTINGS,
144
145
  SETTINGS_STORAGE_KEY,
@@ -181,6 +182,7 @@ const primaryTools: ToolDef[] = [
181
182
  { id: 'disjoint-channel', title: '不相交通道', icon: IconTablerBrackets },
182
183
  ],
183
184
  },
185
+ { id: 'range-select', title: '导出区间数据', icon: IconTablerArrowsHorizontal },
184
186
  ]
185
187
 
186
188
  defineProps<{
@@ -482,5 +484,4 @@ onUnmounted(() => {
482
484
  height: 36px;
483
485
  }
484
486
  }
485
-
486
487
  </style>
@@ -13,8 +13,16 @@
13
13
  <span v-if="loading" class="symbol-chip__spinner" aria-hidden="true" />
14
14
  <IconTablerAlertTriangle v-else-if="error" class="symbol-chip__warn" aria-hidden="true" />
15
15
  </button>
16
- <Transition name="symbol-popover">
17
- <div v-if="showPopup" class="symbol-popover" role="dialog" aria-label="切换合约">
16
+ <Teleport :to="teleportTarget">
17
+ <Transition name="symbol-popover">
18
+ <div
19
+ v-if="showPopup"
20
+ ref="popupRef"
21
+ class="symbol-popover"
22
+ :style="popupStyle"
23
+ role="dialog"
24
+ aria-label="切换合约"
25
+ >
18
26
  <div class="symbol-search">
19
27
  <span class="symbol-search__icon" aria-hidden="true">
20
28
  <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
@@ -95,14 +103,17 @@
95
103
  <span class="symbol-list__exchange">{{ item.exchange }}</span>
96
104
  </button>
97
105
  </div>
98
- </div>
99
- </Transition>
106
+ </div>
107
+ </Transition>
108
+ </Teleport>
100
109
  </div>
101
110
  </template>
102
111
 
103
112
  <script setup lang="ts">
104
113
  import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
105
114
  import IconTablerAlertTriangle from '~icons/tabler/alert-triangle'
115
+ import { useTeleportedPopup } from '../composables/useTeleportedPopup'
116
+ import { useFullscreenTeleportTarget } from '../composables/useFullscreenTeleportTarget'
106
117
 
107
118
  export interface SymbolItem {
108
119
  code: string
@@ -126,6 +137,15 @@ const showPopup = ref(false)
126
137
  const searchQuery = ref('')
127
138
  const searchInputRef = ref<HTMLInputElement | null>(null)
128
139
  const chipWrapRef = ref<HTMLElement | null>(null)
140
+ const popupRef = ref<HTMLElement | null>(null)
141
+
142
+ const teleportTarget = useFullscreenTeleportTarget()
143
+
144
+ const { popupStyle, startPositionSync, stopPositionSync } = useTeleportedPopup(
145
+ chipWrapRef,
146
+ popupRef,
147
+ 8,
148
+ )
129
149
 
130
150
  const currentSymbol = computed<SymbolItem | undefined>(() =>
131
151
  props.symbols.find((s) => s.code === props.symbol),
@@ -155,6 +175,14 @@ function togglePopup() {
155
175
  }
156
176
  }
157
177
 
178
+ watch(showPopup, (val) => {
179
+ if (val) {
180
+ startPositionSync()
181
+ } else {
182
+ stopPositionSync()
183
+ }
184
+ })
185
+
158
186
  function clearSearch() {
159
187
  searchQuery.value = ''
160
188
  searchInputRef.value?.focus()
@@ -170,7 +198,9 @@ function selectSymbol(item: SymbolItem) {
170
198
  }
171
199
 
172
200
  function onDocumentClick(e: MouseEvent) {
173
- if (chipWrapRef.value && !chipWrapRef.value.contains(e.target as Node)) {
201
+ const chip = chipWrapRef.value
202
+ const popup = popupRef.value
203
+ if (chip && !chip.contains(e.target as Node) && !popup?.contains(e.target as Node)) {
174
204
  showPopup.value = false
175
205
  }
176
206
  }
@@ -238,9 +268,6 @@ watch(() => props.symbol, () => {
238
268
  }
239
269
 
240
270
  .symbol-popover {
241
- position: absolute;
242
- top: calc(100% + 8px);
243
- left: 0;
244
271
  z-index: 20;
245
272
  width: min(320px, calc(100vw - 24px));
246
273
  padding: 14px;
@@ -1,5 +1,12 @@
1
1
  <template>
2
- <div class="top-toolbar">
2
+ <div
3
+ ref="toolbarRef"
4
+ class="top-toolbar"
5
+ @mousedown="onMouseDown"
6
+ @mousemove="onMouseMove"
7
+ @mouseup="onMouseUp"
8
+ @mouseleave="onMouseUp"
9
+ >
3
10
  <SymbolSelector
4
11
  v-if="displaySymbol"
5
12
  :symbol="displaySymbol"
@@ -38,7 +45,7 @@
38
45
  </template>
39
46
 
40
47
  <script setup lang="ts">
41
- import { computed } from 'vue'
48
+ import { computed, ref } from 'vue'
42
49
  import KLineLevelDropdown, { type KLineLevel } from './KLineLevelDropdown.vue'
43
50
  import KLineAdjustmentDropdown, { type KLineAdjustment } from './KLineAdjustmentDropdown.vue'
44
51
  import SymbolSelector from './SymbolSelector.vue'
@@ -47,6 +54,41 @@ import type { SymbolItem } from './SymbolSelector.vue'
47
54
 
48
55
  export type { SymbolItem }
49
56
 
57
+ const toolbarRef = ref<HTMLElement | null>(null)
58
+
59
+ let isDown = false
60
+ let startX = 0
61
+ let scrollLeft = 0
62
+
63
+ function onMouseDown(e: MouseEvent) {
64
+ const el = toolbarRef.value
65
+ if (!el) return
66
+ isDown = true
67
+ startX = e.pageX - el.getBoundingClientRect().left
68
+ scrollLeft = el.scrollLeft
69
+ el.style.cursor = 'grabbing'
70
+ el.style.userSelect = 'none'
71
+ }
72
+
73
+ function onMouseMove(e: MouseEvent) {
74
+ if (!isDown) return
75
+ const el = toolbarRef.value
76
+ if (!el) return
77
+ e.preventDefault()
78
+ const x = e.pageX - el.getBoundingClientRect().left
79
+ const walk = x - startX
80
+ el.scrollLeft = scrollLeft - walk
81
+ }
82
+
83
+ function onMouseUp() {
84
+ if (!isDown) return
85
+ isDown = false
86
+ const el = toolbarRef.value
87
+ if (!el) return
88
+ el.style.cursor = ''
89
+ el.style.userSelect = ''
90
+ }
91
+
50
92
  const props = defineProps<{
51
93
  symbol?: string
52
94
  kLineLevel?: string
@@ -115,6 +157,14 @@ function onSymbolSelectorChange(item: SymbolItem) {
115
157
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
116
158
  box-sizing: border-box;
117
159
  user-select: none;
160
+ overflow-x: auto;
161
+ overflow-y: hidden;
162
+ scrollbar-width: none;
163
+ -ms-overflow-style: none;
164
+ }
165
+
166
+ .top-toolbar::-webkit-scrollbar {
167
+ display: none;
118
168
  }
119
169
 
120
170
  .indicator-button {
@@ -168,5 +218,8 @@ function onSymbolSelectorChange(item: SymbolItem) {
168
218
  .indicator-button__text {
169
219
  display: none;
170
220
  }
221
+ .indicator-button {
222
+ height: 26px;
223
+ }
171
224
  }
172
225
  </style>
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Manages chart theme state (light/dark), computed CSS vars for theming,
3
+ * tooltip up/down colors, and auto theme detection via prefers-color-scheme.
4
+ * Handles settings persistence through ChartController.updateSettingsFacade.
5
+ */
6
+ import { ref, computed, onUnmounted } from 'vue'
7
+ import type { Ref } from 'vue'
8
+ import {
9
+ resolveThemeColors,
10
+ themeToCssVars,
11
+ lightTheme,
12
+ darkTheme,
13
+ type ColorPresetSettings,
14
+ } from '@363045841yyt/klinechart-core'
15
+ import type { ChartSettings } from '@363045841yyt/klinechart-core/config'
16
+ import type { ChartController } from '@363045841yyt/klinechart-core/controllers'
17
+
18
+ export function useChartTheme(ctrl: Ref<ChartController | null>) {
19
+ const chartTheme = ref<'light' | 'dark'>('light')
20
+ const chartSettings = ref<ChartSettings>({})
21
+
22
+ const tooltipColors = computed(() => {
23
+ const isAsiaMarket = chartSettings.value.isAsiaMarket ?? false
24
+ const colors = resolveThemeColors(chartTheme.value, isAsiaMarket as boolean | undefined)
25
+ return {
26
+ upColor: colors.candleUpBody,
27
+ downColor: colors.candleDownBody,
28
+ }
29
+ })
30
+
31
+ const themeCssVars = computed(() => {
32
+ const theme = chartTheme.value === 'dark' ? darkTheme : lightTheme
33
+ const overrides = (chartSettings.value.colorPresetSettings as ColorPresetSettings | undefined)?.[
34
+ chartTheme.value
35
+ ]
36
+ if (overrides && Object.keys(overrides).length > 0) {
37
+ return themeToCssVars({ ...theme, colors: { ...theme.colors, ...overrides } })
38
+ }
39
+ return themeToCssVars(theme)
40
+ })
41
+
42
+ let autoThemeMediaQuery: MediaQueryList | null = null
43
+
44
+ function onSystemThemeChange(e: MediaQueryListEvent) {
45
+ ctrl.value?.setTheme(e.matches ? 'dark' : 'light')
46
+ }
47
+
48
+ function applyThemeFromSettings(themeSetting: string | undefined) {
49
+ const chartCtrl = ctrl.value
50
+ if (!chartCtrl || !themeSetting) return
51
+
52
+ if (themeSetting === 'auto') {
53
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
54
+ chartCtrl.setTheme(mq.matches ? 'dark' : 'light')
55
+ if (autoThemeMediaQuery !== mq) {
56
+ autoThemeMediaQuery?.removeEventListener('change', onSystemThemeChange)
57
+ autoThemeMediaQuery = mq
58
+ mq.addEventListener('change', onSystemThemeChange)
59
+ }
60
+ } else {
61
+ autoThemeMediaQuery?.removeEventListener('change', onSystemThemeChange)
62
+ autoThemeMediaQuery = null
63
+ chartCtrl.setTheme(themeSetting as 'light' | 'dark')
64
+ }
65
+ }
66
+
67
+ function handleSettingsChange(settings: ChartSettings) {
68
+ chartSettings.value = settings
69
+ applyThemeFromSettings(settings.theme as string)
70
+ ctrl.value?.updateSettingsFacade(settings)
71
+ }
72
+
73
+ onUnmounted(() => {
74
+ autoThemeMediaQuery?.removeEventListener('change', onSystemThemeChange)
75
+ autoThemeMediaQuery = null
76
+ })
77
+
78
+ return {
79
+ chartTheme,
80
+ chartSettings,
81
+ tooltipColors,
82
+ themeCssVars,
83
+ handleSettingsChange,
84
+ applyThemeFromSettings,
85
+ }
86
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Manages drawing interaction state (selected drawing, drawings list),
3
+ * tool activation, style updates, and deletion.
4
+ * Provides setupDrawing() to initialize DrawingInteractionController
5
+ * with lifecycle callbacks that sync back to Vue refs.
6
+ */
7
+ import { ref, computed, shallowRef, type Ref } from 'vue'
8
+ import {
9
+ DrawingInteractionController,
10
+ type ChartController,
11
+ type DrawingToolId,
12
+ } from '@363045841yyt/klinechart-core/controllers'
13
+ import type { DrawingObject, DrawingStyle } from '@363045841yyt/klinechart-core/plugin'
14
+
15
+ export function useDrawingManager(ctrl: Ref<ChartController | null>) {
16
+ const drawingController = shallowRef<DrawingInteractionController | null>(null)
17
+ const selectedDrawingId = ref<string | null>(null)
18
+ const drawings = ref<DrawingObject[]>([])
19
+ const selectedDrawing = computed(() => {
20
+ const id = selectedDrawingId.value
21
+ if (!id) return null
22
+ return drawings.value.find((d) => d.id === id) ?? null
23
+ })
24
+
25
+ function handleSelectTool(toolId: string) {
26
+ drawingController.value?.setTool(toolId as DrawingToolId)
27
+ }
28
+
29
+ function onUpdateDrawingStyle(style: Partial<DrawingStyle>) {
30
+ const d = selectedDrawing.value
31
+ if (!d || !drawingController.value) return
32
+ drawingController.value.updateDrawingStyle(d.id, style)
33
+ drawings.value = drawingController.value.getDrawings()
34
+ }
35
+
36
+ function onDeleteDrawing() {
37
+ const d = selectedDrawing.value
38
+ if (!d || !drawingController.value) return
39
+ drawingController.value.removeDrawing(d.id)
40
+ drawings.value = drawingController.value.getDrawings()
41
+ }
42
+
43
+ function setupDrawing(chartCtrl: ChartController): void {
44
+ drawingController.value = new DrawingInteractionController(chartCtrl)
45
+ drawingController.value.setCallbacks({
46
+ onDrawingCreated: (drawing) => {
47
+ drawings.value = [...drawings.value, drawing]
48
+ selectedDrawingId.value = drawing.id
49
+ },
50
+ onToolChange: () => {},
51
+ onDrawingSelected: (drawing) => {
52
+ selectedDrawingId.value = drawing?.id ?? null
53
+ },
54
+ })
55
+ }
56
+
57
+ return {
58
+ drawingController,
59
+ selectedDrawingId,
60
+ selectedDrawing,
61
+ drawings,
62
+ handleSelectTool,
63
+ onUpdateDrawingStyle,
64
+ onDeleteDrawing,
65
+ setupDrawing,
66
+ }
67
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Manages indicator state for both main-pane and sub-pane indicators.
3
+ * Provides pane layout construction, default param resolution,
4
+ * indicator toggle/update/reorder logic, and bridges signal subscriptions
5
+ * (ctrl.indicators, ctrl.subPanes) to Vue reactive refs.
6
+ */
7
+ import { ref, computed, type Ref } from 'vue'
8
+ import type {
9
+ ChartController,
10
+ PaneSpec,
11
+ IndicatorInstance,
12
+ SubIndicatorType,
13
+ } from '@363045841yyt/klinechart-core/controllers'
14
+ import { getRegisteredIndicatorDefinition } from '@363045841yyt/klinechart-core/indicators'
15
+ import type { SemanticChartConfig } from '@363045841yyt/klinechart-core/semantic'
16
+
17
+ interface SubPaneSlot {
18
+ id: string
19
+ indicatorId: SubIndicatorType
20
+ params: Record<string, unknown>
21
+ }
22
+
23
+ export function useIndicatorManager(
24
+ ctrl: Ref<ChartController | null>,
25
+ paneRatiosRef: Ref<Record<string, number>>,
26
+ ) {
27
+ const maxSubPanes = 4
28
+
29
+ const mainActiveIndicators = ref<string[]>([])
30
+
31
+ const subPanes = ref<SubPaneSlot[]>([])
32
+
33
+ const subActiveIndicators = computed(() => {
34
+ const ids: string[] = []
35
+ const seen = new Set<string>()
36
+ for (const pane of subPanes.value) {
37
+ if (!seen.has(pane.indicatorId)) {
38
+ seen.add(pane.indicatorId)
39
+ ids.push(pane.indicatorId)
40
+ }
41
+ }
42
+ return ids
43
+ })
44
+
45
+ const activeIndicators = computed(() => [
46
+ ...mainActiveIndicators.value,
47
+ ...subActiveIndicators.value,
48
+ ])
49
+
50
+ const indicatorParams = ref<Record<string, Record<string, unknown>>>({})
51
+
52
+ function buildPaneLayoutIntent(): PaneSpec[] {
53
+ const mainRatio = paneRatiosRef.value['main'] ?? 3
54
+ return subPanes.value.length === 0
55
+ ? [{ id: 'main', ratio: mainRatio, visible: true, role: 'price' }]
56
+ : [
57
+ { id: 'main', ratio: mainRatio, visible: true, role: 'price' },
58
+ ...subPanes.value.map((pane) => ({
59
+ id: pane.id,
60
+ ratio: paneRatiosRef.value[pane.id] ?? 1,
61
+ visible: true,
62
+ role: 'indicator' as const,
63
+ })),
64
+ ]
65
+ }
66
+
67
+ function getDefaultParams(
68
+ indicatorId: SubIndicatorType,
69
+ ): Record<string, number | boolean | string> {
70
+ if (indicatorId === 'VOLUME') return {}
71
+ const meta = getRegisteredIndicatorDefinition(indicatorId)
72
+ if (meta?.runtime?.defaultConfig) {
73
+ return { ...meta.runtime.defaultConfig } as Record<string, number | boolean | string>
74
+ }
75
+ return {}
76
+ }
77
+
78
+ function isSubPaneIndicator(id: string): boolean {
79
+ if (id === 'VOLUME') return true
80
+ const def = getRegisteredIndicatorDefinition(id)
81
+ return !!def && def.category !== 'main'
82
+ }
83
+
84
+ function addSubPane(
85
+ indicatorId: SubIndicatorType = 'VOLUME',
86
+ params?: Record<string, number | boolean | string>,
87
+ ): boolean {
88
+ if (subPanes.value.length >= maxSubPanes) {
89
+ return false
90
+ }
91
+
92
+ const mergedParams = params ?? getDefaultParams(indicatorId)
93
+
94
+ const paneId = ctrl.value?.addIndicator(indicatorId, 'sub', mergedParams)
95
+ if (!paneId) return false
96
+ return true
97
+ }
98
+
99
+ function removeSubPane(paneId: string): void {
100
+ ctrl.value?.removeIndicator(paneId)
101
+ }
102
+
103
+ function clearAllSubPanes(): void {
104
+ for (const pane of subPanes.value) {
105
+ ctrl.value?.removeIndicator(pane.id)
106
+ }
107
+ }
108
+
109
+ function initIndicatorsFromConfig(semanticConfig?: SemanticChartConfig): void {
110
+ const config = semanticConfig
111
+ const c = ctrl.value
112
+ if (!config || !c) return
113
+
114
+ const mainIndicators = config.indicators?.main
115
+ if (mainIndicators) {
116
+ for (const indicator of mainIndicators) {
117
+ if (indicator.enabled) {
118
+ c.addIndicator(
119
+ indicator.type,
120
+ 'main',
121
+ indicator.params as Record<string, number | boolean | string>,
122
+ )
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ function switchSubIndicator(paneId: string, newIndicatorId: SubIndicatorType): void {
129
+ const nextParams = getDefaultParams(newIndicatorId)
130
+ ctrl.value?.replaceSubPaneIndicator(paneId, newIndicatorId, nextParams)
131
+ }
132
+
133
+ function handleIndicatorToggle(indicatorId: string, active: boolean) {
134
+ const c = ctrl.value
135
+ if (!c) return
136
+
137
+ const def = getRegisteredIndicatorDefinition(indicatorId)
138
+ const isMain = def && (def.category === 'main' || def.allowMainPane)
139
+ if (isMain) {
140
+ const existingIndicator = mainActiveIndicators.value.find((id) => id === indicatorId)
141
+ if (active && !existingIndicator) {
142
+ c.addIndicator(indicatorId, 'main', indicatorParams.value[indicatorId])
143
+ } else if (!active && existingIndicator) {
144
+ c.removeIndicator(indicatorId.toUpperCase())
145
+ }
146
+ return
147
+ }
148
+
149
+ if (isSubPaneIndicator(indicatorId)) {
150
+ if (active) {
151
+ const existingPane = subPanes.value.find((p) => p.indicatorId === indicatorId)
152
+ if (existingPane) return
153
+ if (subPanes.value.length >= maxSubPanes) return
154
+
155
+ const paneId = c.addIndicator(indicatorId, 'sub', indicatorParams.value[indicatorId])
156
+ if (!paneId && subPanes.value.length > 0) {
157
+ const lastPane = subPanes.value[subPanes.value.length - 1]
158
+ switchSubIndicator(lastPane.id, indicatorId as SubIndicatorType)
159
+ }
160
+ } else {
161
+ const panesToRemove = subPanes.value.filter((p) => p.indicatorId === indicatorId)
162
+ panesToRemove.forEach((pane) => {
163
+ c.removeIndicator(pane.id)
164
+ })
165
+ }
166
+ }
167
+ }
168
+
169
+ function handleUpdateParams(indicatorId: string, params: Record<string, unknown>) {
170
+ if (
171
+ indicatorId === 'MA' ||
172
+ indicatorId === 'BOLL' ||
173
+ indicatorId === 'EXPMA' ||
174
+ indicatorId === 'ENE'
175
+ ) {
176
+ ctrl.value?.updateIndicatorParams(indicatorId, params)
177
+ return
178
+ }
179
+ if (isSubPaneIndicator(indicatorId)) {
180
+ subPanes.value
181
+ .filter((p) => p.indicatorId === indicatorId)
182
+ .forEach((pane) => {
183
+ ctrl.value?.updateIndicatorParams(pane.id, params)
184
+ })
185
+ }
186
+ }
187
+
188
+ function handleReorderSubIndicators(orderedIndicatorIds: string[]) {
189
+ if (!orderedIndicatorIds.length || subPanes.value.length <= 1) return
190
+
191
+ const validOrder = orderedIndicatorIds.filter((id): id is SubIndicatorType =>
192
+ isSubPaneIndicator(id),
193
+ )
194
+ if (!validOrder.length) return
195
+
196
+ const paneByIndicator = new Map(subPanes.value.map((pane) => [pane.indicatorId, pane] as const))
197
+ const nextSubPanes: SubPaneSlot[] = []
198
+
199
+ for (const indicatorId of validOrder) {
200
+ const pane = paneByIndicator.get(indicatorId)
201
+ if (pane) {
202
+ nextSubPanes.push(pane)
203
+ paneByIndicator.delete(indicatorId)
204
+ }
205
+ }
206
+
207
+ if (nextSubPanes.length === 0) return
208
+
209
+ for (const pane of subPanes.value) {
210
+ if (paneByIndicator.has(pane.indicatorId)) {
211
+ nextSubPanes.push(pane)
212
+ paneByIndicator.delete(pane.indicatorId)
213
+ }
214
+ }
215
+
216
+ const currentSubIds = subPanes.value.map((p) => p.id)
217
+ const nextSubIds = nextSubPanes.map((p) => p.id)
218
+ if (currentSubIds.join('|') === nextSubIds.join('|')) return
219
+
220
+ subPanes.value = nextSubPanes
221
+
222
+ const c = ctrl.value
223
+ if (!c) return
224
+ c.updatePaneLayout(buildPaneLayoutIntent())
225
+ }
226
+
227
+ function setupIndicatorSubscriptions(chartCtrl: ChartController): () => void {
228
+ const unsubIndicators = chartCtrl.indicators.subscribe(() => {
229
+ const instances = chartCtrl.indicators.peek()
230
+
231
+ const mains = instances
232
+ .filter((i): i is IndicatorInstance & { role: 'main' } => i.role === 'main')
233
+ .map((i) => i.definitionId)
234
+ mainActiveIndicators.value = mains
235
+
236
+ const nextParams = { ...indicatorParams.value }
237
+ for (const inst of instances) {
238
+ if (inst.role === 'main' && inst.params && Object.keys(inst.params).length > 0) {
239
+ nextParams[inst.definitionId] = { ...inst.params }
240
+ }
241
+ }
242
+
243
+ chartCtrl.updateRendererConfig('mainIndicatorLegend', {
244
+ indicators: {
245
+ MA: { enabled: mains.includes('MA'), params: nextParams['MA'] || {} },
246
+ BOLL: { enabled: mains.includes('BOLL'), params: nextParams['BOLL'] || {} },
247
+ EXPMA: { enabled: mains.includes('EXPMA'), params: nextParams['EXPMA'] || {} },
248
+ ENE: { enabled: mains.includes('ENE'), params: nextParams['ENE'] || {} },
249
+ },
250
+ })
251
+
252
+ indicatorParams.value = nextParams
253
+ })
254
+
255
+ const unsubSubPanes = chartCtrl.subPanes.subscribe(() => {
256
+ const subPaneInfos = chartCtrl.subPanes.peek()
257
+ const signalIds = new Set(subPaneInfos.map((sp) => sp.paneId))
258
+
259
+ const merged = subPanes.value.filter((p) => signalIds.has(p.id))
260
+ const existingIds = new Set(merged.map((p) => p.id))
261
+ for (const sp of subPaneInfos) {
262
+ if (!existingIds.has(sp.paneId)) {
263
+ merged.push({
264
+ id: sp.paneId,
265
+ indicatorId: sp.indicatorId as SubIndicatorType,
266
+ params: sp.params,
267
+ })
268
+ }
269
+ }
270
+ subPanes.value = merged
271
+
272
+ const nextParams = { ...indicatorParams.value }
273
+ for (const sp of subPaneInfos) {
274
+ if (sp.params && Object.keys(sp.params).length > 0) {
275
+ nextParams[sp.indicatorId] = { ...sp.params }
276
+ }
277
+ }
278
+ indicatorParams.value = nextParams
279
+ })
280
+
281
+ return () => {
282
+ unsubIndicators()
283
+ unsubSubPanes()
284
+ }
285
+ }
286
+
287
+ return {
288
+ mainActiveIndicators,
289
+ subActiveIndicators,
290
+ activeIndicators,
291
+ indicatorParams,
292
+ subPanes,
293
+ maxSubPanes,
294
+ buildPaneLayoutIntent,
295
+ getDefaultParams,
296
+ isSubPaneIndicator,
297
+ addSubPane,
298
+ removeSubPane,
299
+ clearAllSubPanes,
300
+ initIndicatorsFromConfig,
301
+ switchSubIndicator,
302
+ handleIndicatorToggle,
303
+ handleUpdateParams,
304
+ handleReorderSubIndicators,
305
+ setupIndicatorSubscriptions,
306
+ }
307
+ }