@363045841yyt/klinechart 0.8.3 → 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.
- package/dist/components/BatchStockDialog.vue.d.ts +13 -0
- package/dist/components/BatchStockDialog.vue.d.ts.map +1 -0
- package/dist/components/CompareSymbolSelector.vue.d.ts.map +1 -1
- package/dist/components/Dropdown.vue.d.ts.map +1 -1
- package/dist/components/ExportProgressDialog.vue.d.ts +15 -0
- package/dist/components/ExportProgressDialog.vue.d.ts.map +1 -0
- package/dist/components/IndicatorSelector.vue.d.ts.map +1 -1
- package/dist/components/KLineChart.vue.d.ts +5 -9
- package/dist/components/KLineChart.vue.d.ts.map +1 -1
- package/dist/components/LeftToolbar.vue.d.ts.map +1 -1
- package/dist/components/SymbolSelector.vue.d.ts.map +1 -1
- package/dist/components/TopToolbar.vue.d.ts.map +1 -1
- package/dist/composables/chart/useChartTheme.d.ts +329 -0
- package/dist/composables/chart/useChartTheme.d.ts.map +1 -0
- package/dist/composables/chart/useDrawingManager.d.ts +86 -0
- package/dist/composables/chart/useDrawingManager.d.ts.map +1 -0
- package/dist/composables/chart/useIndicatorManager.d.ts +38 -0
- package/dist/composables/chart/useIndicatorManager.d.ts.map +1 -0
- package/dist/composables/chart/useRangeSelection.d.ts +65 -0
- package/dist/composables/chart/useRangeSelection.d.ts.map +1 -0
- package/dist/composables/useTeleportedPopup.d.ts +8 -0
- package/dist/composables/useTeleportedPopup.d.ts.map +1 -0
- package/dist/index.cjs +9 -2
- package/dist/index.css +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1769 -1090
- package/dist/tools/calcRangeOverlayPixel.d.ts +15 -0
- package/dist/tools/calcRangeOverlayPixel.d.ts.map +1 -0
- package/dist/tools/getKLineIndexByTimestamp.d.ts +4 -0
- package/dist/tools/getKLineIndexByTimestamp.d.ts.map +1 -0
- package/dist/web-component.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/BatchStockDialog.vue +293 -0
- package/src/components/CompareSymbolSelector.vue +35 -8
- package/src/components/Dropdown.vue +42 -19
- package/src/components/ExportProgressDialog.vue +226 -0
- package/src/components/IndicatorSelector.vue +13 -5
- package/src/components/KLineChart.vue +329 -399
- package/src/components/LeftToolbar.vue +2 -1
- package/src/components/SymbolSelector.vue +35 -8
- package/src/components/TopToolbar.vue +55 -2
- package/src/composables/chart/useChartTheme.ts +86 -0
- package/src/composables/chart/useDrawingManager.ts +67 -0
- package/src/composables/chart/useIndicatorManager.ts +307 -0
- package/src/composables/chart/useRangeSelection.ts +417 -0
- package/src/composables/useTeleportedPopup.ts +33 -0
- package/src/index.ts +41 -14
- package/src/tools/calcRangeOverlayPixel.ts +28 -0
- 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
|
-
<
|
|
17
|
-
<
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|