@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
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
import { formatTimestamp } from '@363045841yyt/klinechart-core'
|
|
3
|
+
import type { KLineData, ChartController, DataFetcher } from '@363045841yyt/klinechart-core/controllers'
|
|
4
|
+
import { calcRangeOverlayPixel } from '../../tools/calcRangeOverlayPixel'
|
|
5
|
+
import type { Bounds } from '../../tools/calcRangeOverlayPixel'
|
|
6
|
+
import {
|
|
7
|
+
getKLineIndexByTimestamp,
|
|
8
|
+
findNearestKLineIndex,
|
|
9
|
+
} from '../../tools/getKLineIndexByTimestamp'
|
|
10
|
+
|
|
11
|
+
interface RangeSelectionState {
|
|
12
|
+
startTimestamp: number | null
|
|
13
|
+
endTimestamp: number | null
|
|
14
|
+
isDragging: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fmtDate(item: KLineData | undefined): string {
|
|
18
|
+
if (!item) return '?'
|
|
19
|
+
if (item.date) return item.date
|
|
20
|
+
return new Date(item.timestamp).toISOString().slice(0, 10)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toYYYYMMDD(ts: number): string {
|
|
24
|
+
const d = new Date(ts)
|
|
25
|
+
const y = d.getFullYear()
|
|
26
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
27
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
28
|
+
return `${y}${m}${day}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeDateInput(input: string): string | null {
|
|
32
|
+
const parts = input.trim().split(/[-/]/)
|
|
33
|
+
if (parts.length !== 3) return null
|
|
34
|
+
const y = parts[0]!.padStart(4, '0')
|
|
35
|
+
const m = parts[1]!.padStart(2, '0')
|
|
36
|
+
const d = parts[2]!.padStart(2, '0')
|
|
37
|
+
return `${y}-${m}-${d}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toCsvCell(value: unknown): string {
|
|
41
|
+
if (value === null || value === undefined) return ''
|
|
42
|
+
const text = String(value)
|
|
43
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseDateToTimestamp(input: string): number | null {
|
|
47
|
+
const normalized = normalizeDateInput(input)
|
|
48
|
+
if (!normalized) return null
|
|
49
|
+
const d = new Date(normalized)
|
|
50
|
+
return isNaN(d.getTime()) ? null : d.getTime()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useRangeSelection(options: {
|
|
54
|
+
controller: Ref<ChartController | null>
|
|
55
|
+
activeToolId: Ref<string>
|
|
56
|
+
containerRef: Ref<HTMLElement | null>
|
|
57
|
+
dataVersion: Ref<number>
|
|
58
|
+
viewportVersion: Ref<number>
|
|
59
|
+
dataFetcher: Ref<DataFetcher | null>
|
|
60
|
+
batchStockCodes: Ref<string[]>
|
|
61
|
+
}) {
|
|
62
|
+
const { controller, activeToolId, containerRef, dataVersion, viewportVersion, dataFetcher, batchStockCodes } = options
|
|
63
|
+
|
|
64
|
+
const containerScrollLeft = ref(0)
|
|
65
|
+
const customStartDate = ref('')
|
|
66
|
+
const customEndDate = ref('')
|
|
67
|
+
const resizeSide = ref<'left' | 'right' | null>(null)
|
|
68
|
+
const exportingProgress = ref<{ current: number; total: number; label: string } | null>(null)
|
|
69
|
+
|
|
70
|
+
const rangeSelection = ref<RangeSelectionState>({
|
|
71
|
+
startTimestamp: null,
|
|
72
|
+
endTimestamp: null,
|
|
73
|
+
isDragging: false,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const isRangeSelectActive = computed(() => activeToolId.value === 'range-select')
|
|
77
|
+
|
|
78
|
+
const rangeSelectionReady = computed(
|
|
79
|
+
() =>
|
|
80
|
+
rangeSelection.value.startTimestamp !== null &&
|
|
81
|
+
rangeSelection.value.endTimestamp !== null,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const rangeSelectionBounds: ComputedRef<Bounds | null> = computed(() => {
|
|
85
|
+
void dataVersion.value
|
|
86
|
+
const data = controller.value?.getData() ?? []
|
|
87
|
+
const { startTimestamp, endTimestamp } = rangeSelection.value
|
|
88
|
+
if (startTimestamp === null || endTimestamp === null || data.length === 0) return null
|
|
89
|
+
|
|
90
|
+
const rawStart = findNearestKLineIndex(data, startTimestamp, 'left')
|
|
91
|
+
const rawEnd = findNearestKLineIndex(data, endTimestamp, 'right')
|
|
92
|
+
if (rawStart === null || rawEnd === null) return null
|
|
93
|
+
|
|
94
|
+
return { start: Math.min(rawStart, rawEnd), end: Math.max(rawStart, rawEnd) }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const rangeSelectionStartLabel: ComputedRef<string> = computed(() => {
|
|
98
|
+
const bounds = rangeSelectionBounds.value
|
|
99
|
+
const data = controller.value?.getData() ?? []
|
|
100
|
+
if (!bounds || data.length === 0) return ''
|
|
101
|
+
return fmtDate(data[bounds.start])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const rangeSelectionEndLabel: ComputedRef<string> = computed(() => {
|
|
105
|
+
const bounds = rangeSelectionBounds.value
|
|
106
|
+
const data = controller.value?.getData() ?? []
|
|
107
|
+
if (!bounds || data.length === 0) return ''
|
|
108
|
+
return fmtDate(data[bounds.end])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const rangeSelectionOverlayStyle = computed(() => {
|
|
112
|
+
const bounds = rangeSelectionBounds.value
|
|
113
|
+
if (!bounds) return null
|
|
114
|
+
|
|
115
|
+
void containerScrollLeft.value
|
|
116
|
+
void viewportVersion.value
|
|
117
|
+
|
|
118
|
+
const ctrl = controller.value
|
|
119
|
+
const viewport = ctrl?.getViewport()
|
|
120
|
+
const container = containerRef.value
|
|
121
|
+
if (!ctrl || !viewport || !container) return null
|
|
122
|
+
|
|
123
|
+
const px = calcRangeOverlayPixel(bounds, ctrl, container, viewport)
|
|
124
|
+
return {
|
|
125
|
+
left: `${px.left}px`,
|
|
126
|
+
width: `${px.width}px`,
|
|
127
|
+
height: `${px.height}px`,
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function clearRangeSelection() {
|
|
132
|
+
rangeSelection.value = { startTimestamp: null, endTimestamp: null, isDragging: false }
|
|
133
|
+
customStartDate.value = ''
|
|
134
|
+
customEndDate.value = ''
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
watch(customStartDate, (val) => {
|
|
138
|
+
const data = controller.value?.getData() ?? []
|
|
139
|
+
const targetTs = parseDateToTimestamp(val)
|
|
140
|
+
if (targetTs === null || data.length === 0) return
|
|
141
|
+
rangeSelection.value = { ...rangeSelection.value, startTimestamp: targetTs, isDragging: false }
|
|
142
|
+
if (targetTs < data[0]!.timestamp) {
|
|
143
|
+
controller.value?.ensureDataRange(targetTs)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
watch(customEndDate, (val) => {
|
|
148
|
+
const data = controller.value?.getData() ?? []
|
|
149
|
+
const targetTs = parseDateToTimestamp(val)
|
|
150
|
+
if (targetTs === null || data.length === 0) return
|
|
151
|
+
rangeSelection.value = { ...rangeSelection.value, endTimestamp: targetTs, isDragging: false }
|
|
152
|
+
if (targetTs < data[0]!.timestamp) {
|
|
153
|
+
controller.value?.ensureDataRange(targetTs)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
function getRangeSelectionIndex(e: PointerEvent, container: HTMLElement): number | null {
|
|
158
|
+
const data = controller.value?.getData() ?? []
|
|
159
|
+
if (data.length === 0) return null
|
|
160
|
+
|
|
161
|
+
const rect = container.getBoundingClientRect()
|
|
162
|
+
const rawIndex = controller.value?.getLogicalIndexAtX(e.clientX - rect.left)
|
|
163
|
+
if (rawIndex === null || rawIndex === undefined) return null
|
|
164
|
+
return Math.max(0, Math.min(rawIndex, data.length - 1))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function handleRangePointerDown(e: PointerEvent, container: HTMLElement): boolean {
|
|
168
|
+
if (!isRangeSelectActive.value) return false
|
|
169
|
+
if (
|
|
170
|
+
rangeSelection.value.startTimestamp !== null &&
|
|
171
|
+
rangeSelection.value.endTimestamp !== null &&
|
|
172
|
+
!rangeSelection.value.isDragging
|
|
173
|
+
) {
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
const index = getRangeSelectionIndex(e, container)
|
|
177
|
+
if (index === null) return true
|
|
178
|
+
|
|
179
|
+
const data = controller.value?.getData() ?? []
|
|
180
|
+
const ts = data[index]?.timestamp
|
|
181
|
+
if (ts === undefined) return true
|
|
182
|
+
|
|
183
|
+
rangeSelection.value = { startTimestamp: ts, endTimestamp: ts, isDragging: true }
|
|
184
|
+
customStartDate.value = ''
|
|
185
|
+
customEndDate.value = ''
|
|
186
|
+
container.setPointerCapture?.(e.pointerId)
|
|
187
|
+
e.preventDefault()
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function handleRangePointerMove(e: PointerEvent, container: HTMLElement): boolean {
|
|
192
|
+
if (!isRangeSelectActive.value || !rangeSelection.value.isDragging) return false
|
|
193
|
+
const index = getRangeSelectionIndex(e, container)
|
|
194
|
+
if (index !== null) {
|
|
195
|
+
const data = controller.value?.getData() ?? []
|
|
196
|
+
const ts = data[index]?.timestamp
|
|
197
|
+
if (ts !== undefined) {
|
|
198
|
+
rangeSelection.value = { ...rangeSelection.value, endTimestamp: ts }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
e.preventDefault()
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function handleRangePointerUp(e: PointerEvent, container: HTMLElement): boolean {
|
|
206
|
+
if (!isRangeSelectActive.value || !rangeSelection.value.isDragging) return false
|
|
207
|
+
const index = getRangeSelectionIndex(e, container)
|
|
208
|
+
if (index !== null) {
|
|
209
|
+
const data = controller.value?.getData() ?? []
|
|
210
|
+
const ts = data[index]?.timestamp
|
|
211
|
+
if (ts !== undefined) {
|
|
212
|
+
rangeSelection.value = {
|
|
213
|
+
...rangeSelection.value,
|
|
214
|
+
endTimestamp: ts,
|
|
215
|
+
isDragging: false,
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
rangeSelection.value = { ...rangeSelection.value, isDragging: false }
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
rangeSelection.value = { ...rangeSelection.value, isDragging: false }
|
|
222
|
+
}
|
|
223
|
+
container.releasePointerCapture?.(e.pointerId)
|
|
224
|
+
e.preventDefault()
|
|
225
|
+
return true
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function onEdgePointerDown(side: 'left' | 'right', e: PointerEvent) {
|
|
229
|
+
if (!isRangeSelectActive.value) return
|
|
230
|
+
resizeSide.value = side
|
|
231
|
+
const el = e.currentTarget as HTMLElement
|
|
232
|
+
el.setPointerCapture?.(e.pointerId)
|
|
233
|
+
e.preventDefault()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function onEdgePointerMove(e: PointerEvent) {
|
|
237
|
+
if (
|
|
238
|
+
!resizeSide.value ||
|
|
239
|
+
rangeSelection.value.startTimestamp === null ||
|
|
240
|
+
rangeSelection.value.endTimestamp === null
|
|
241
|
+
)
|
|
242
|
+
return
|
|
243
|
+
const rect = containerRef.value?.getBoundingClientRect()
|
|
244
|
+
if (!rect) return
|
|
245
|
+
const data = controller.value?.getData() ?? []
|
|
246
|
+
if (!data.length) return
|
|
247
|
+
const rawIndex = controller.value?.getLogicalIndexAtX(e.clientX - rect.left)
|
|
248
|
+
if (rawIndex === null || rawIndex === undefined) return
|
|
249
|
+
const index = Math.max(0, Math.min(rawIndex, data.length - 1))
|
|
250
|
+
const ts = data[index]?.timestamp
|
|
251
|
+
if (ts === undefined) return
|
|
252
|
+
|
|
253
|
+
if (resizeSide.value === 'left') {
|
|
254
|
+
if (ts > rangeSelection.value.endTimestamp) {
|
|
255
|
+
rangeSelection.value = {
|
|
256
|
+
startTimestamp: rangeSelection.value.endTimestamp,
|
|
257
|
+
endTimestamp: ts,
|
|
258
|
+
isDragging: false,
|
|
259
|
+
}
|
|
260
|
+
resizeSide.value = 'right'
|
|
261
|
+
} else {
|
|
262
|
+
rangeSelection.value = { ...rangeSelection.value, startTimestamp: ts }
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
if (ts < rangeSelection.value.startTimestamp) {
|
|
266
|
+
rangeSelection.value = {
|
|
267
|
+
startTimestamp: ts,
|
|
268
|
+
endTimestamp: rangeSelection.value.startTimestamp,
|
|
269
|
+
isDragging: false,
|
|
270
|
+
}
|
|
271
|
+
resizeSide.value = 'left'
|
|
272
|
+
} else {
|
|
273
|
+
rangeSelection.value = { ...rangeSelection.value, endTimestamp: ts }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function onEdgePointerUp(e: PointerEvent) {
|
|
279
|
+
if (!resizeSide.value) return
|
|
280
|
+
const el = e.currentTarget as HTMLElement
|
|
281
|
+
el.releasePointerCapture?.(e.pointerId)
|
|
282
|
+
resizeSide.value = null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const CSV_FIELDS: Array<keyof KLineData> = [
|
|
286
|
+
'timestamp',
|
|
287
|
+
'open',
|
|
288
|
+
'high',
|
|
289
|
+
'low',
|
|
290
|
+
'close',
|
|
291
|
+
'volume',
|
|
292
|
+
'turnover',
|
|
293
|
+
'turnoverRate',
|
|
294
|
+
'amplitude',
|
|
295
|
+
'changePercent',
|
|
296
|
+
'changeAmount',
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
function downloadCsv(
|
|
300
|
+
items: ReadonlyArray<KLineData>,
|
|
301
|
+
prefix: string,
|
|
302
|
+
startTs: number,
|
|
303
|
+
endTs: number,
|
|
304
|
+
) {
|
|
305
|
+
const header = `stockCode,time,${CSV_FIELDS.join(',')}`
|
|
306
|
+
const rows = [
|
|
307
|
+
header,
|
|
308
|
+
...items.map((item) => {
|
|
309
|
+
const timeStr = toCsvCell(formatTimestamp(item.timestamp, { showTime: true }))
|
|
310
|
+
const code = toCsvCell(item.stockCode ?? prefix)
|
|
311
|
+
return `${code},${timeStr},${CSV_FIELDS.map((field) => toCsvCell(item[field])).join(',')}`
|
|
312
|
+
}),
|
|
313
|
+
]
|
|
314
|
+
const blob = new Blob([`\uFEFF${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' })
|
|
315
|
+
const url = URL.createObjectURL(blob)
|
|
316
|
+
const a = document.createElement('a')
|
|
317
|
+
a.href = url
|
|
318
|
+
a.download = `${prefix}-${toYYYYMMDD(startTs)}-${toYYYYMMDD(endTs)}.csv`
|
|
319
|
+
document.body.appendChild(a)
|
|
320
|
+
a.click()
|
|
321
|
+
document.body.removeChild(a)
|
|
322
|
+
URL.revokeObjectURL(url)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function exportRangeToCsv() {
|
|
326
|
+
const bounds = rangeSelectionBounds.value
|
|
327
|
+
const data = controller.value?.getData() ?? []
|
|
328
|
+
if (!bounds || data.length === 0) return
|
|
329
|
+
|
|
330
|
+
const startTs = data[bounds.start]!.timestamp
|
|
331
|
+
const endTs = data[bounds.end]!.timestamp
|
|
332
|
+
const mainStockCode = controller.value?.symbols.peek()?.[0]?.symbol ?? 'unknown'
|
|
333
|
+
const batchCodes = batchStockCodes.value
|
|
334
|
+
const total = 1 + batchCodes.length
|
|
335
|
+
const prefix = batchCodes.length > 0 ? `batch${total}` : mainStockCode
|
|
336
|
+
|
|
337
|
+
const allItems: KLineData[] = []
|
|
338
|
+
|
|
339
|
+
exportingProgress.value = { current: 0, total, label: '正在准备主品种数据...' }
|
|
340
|
+
|
|
341
|
+
// Main stock
|
|
342
|
+
for (const item of data.slice(bounds.start, bounds.end + 1)) {
|
|
343
|
+
allItems.push(item)
|
|
344
|
+
}
|
|
345
|
+
exportingProgress.value = { current: 1, total, label: '主品种数据已就绪' }
|
|
346
|
+
|
|
347
|
+
// Batch stocks (sequential)
|
|
348
|
+
const fetchFn = dataFetcher.value
|
|
349
|
+
if (fetchFn && batchCodes.length > 0) {
|
|
350
|
+
const spec = controller.value?.symbols.peek()?.[0]
|
|
351
|
+
const startDate = formatTimestamp(startTs)
|
|
352
|
+
const endDate = formatTimestamp(endTs)
|
|
353
|
+
const period = spec?.period ?? 'daily'
|
|
354
|
+
const adjust = spec?.adjust ?? 'none'
|
|
355
|
+
const exchange = spec?.exchange
|
|
356
|
+
const source = spec?.source ?? 'gotdx'
|
|
357
|
+
|
|
358
|
+
for (let i = 0; i < batchCodes.length; i++) {
|
|
359
|
+
const code = batchCodes[i]!
|
|
360
|
+
exportingProgress.value = { current: 1 + i, total, label: `正在获取 ${code}...` }
|
|
361
|
+
try {
|
|
362
|
+
const items = await fetchFn(source, {
|
|
363
|
+
symbol: code,
|
|
364
|
+
startDate,
|
|
365
|
+
endDate,
|
|
366
|
+
period,
|
|
367
|
+
adjust,
|
|
368
|
+
exchange,
|
|
369
|
+
})
|
|
370
|
+
for (const item of items) {
|
|
371
|
+
allItems.push(item)
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
exportingProgress.value = { current: total, total, label: '正在生成文件...' }
|
|
380
|
+
downloadCsv(allItems, prefix, startTs, endTs)
|
|
381
|
+
exportingProgress.value = { current: total, total, label: '导出完成' }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function onScroll() {
|
|
385
|
+
const cont = containerRef.value
|
|
386
|
+
if (cont) containerScrollLeft.value = cont.scrollLeft
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function syncScrollLeft() {
|
|
390
|
+
const cont = containerRef.value
|
|
391
|
+
if (cont) containerScrollLeft.value = cont.scrollLeft
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
rangeSelection,
|
|
396
|
+
customStartDate,
|
|
397
|
+
customEndDate,
|
|
398
|
+
containerScrollLeft,
|
|
399
|
+
isRangeSelectActive,
|
|
400
|
+
rangeSelectionReady,
|
|
401
|
+
rangeSelectionBounds,
|
|
402
|
+
rangeSelectionStartLabel,
|
|
403
|
+
rangeSelectionEndLabel,
|
|
404
|
+
rangeSelectionOverlayStyle,
|
|
405
|
+
clearRangeSelection,
|
|
406
|
+
handleRangePointerDown,
|
|
407
|
+
handleRangePointerMove,
|
|
408
|
+
handleRangePointerUp,
|
|
409
|
+
exportRangeToCsv,
|
|
410
|
+
exportingProgress,
|
|
411
|
+
onEdgePointerDown,
|
|
412
|
+
onEdgePointerMove,
|
|
413
|
+
onEdgePointerUp,
|
|
414
|
+
onScroll,
|
|
415
|
+
syncScrollLeft,
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ref, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export function useTeleportedPopup(
|
|
4
|
+
triggerRef: Ref<HTMLElement | null>,
|
|
5
|
+
popupRef: Ref<HTMLElement | null>,
|
|
6
|
+
gap = 4,
|
|
7
|
+
) {
|
|
8
|
+
const popupStyle = ref<Record<string, string>>({})
|
|
9
|
+
|
|
10
|
+
function updatePosition() {
|
|
11
|
+
const trigger = triggerRef.value
|
|
12
|
+
if (!trigger) return
|
|
13
|
+
const rect = trigger.getBoundingClientRect()
|
|
14
|
+
popupStyle.value = {
|
|
15
|
+
position: 'fixed',
|
|
16
|
+
top: `${rect.bottom + gap}px`,
|
|
17
|
+
left: `${rect.left}px`,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function startPositionSync() {
|
|
22
|
+
updatePosition()
|
|
23
|
+
document.addEventListener('scroll', updatePosition, { capture: true, passive: true })
|
|
24
|
+
window.addEventListener('resize', updatePosition, { passive: true })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stopPositionSync() {
|
|
28
|
+
document.removeEventListener('scroll', updatePosition, { capture: true })
|
|
29
|
+
window.removeEventListener('resize', updatePosition)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { popupStyle, updatePosition, startPositionSync, stopPositionSync }
|
|
33
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -88,7 +88,7 @@ export function __setControllerFactory(
|
|
|
88
88
|
*
|
|
89
89
|
* Throws if container is null/undefined (SSR-safe guard).
|
|
90
90
|
*/
|
|
91
|
-
export function createChart(opts: ChartMountOptions): ChartController {
|
|
91
|
+
export function createChart(opts: ChartMountOptions): ChartController | Promise<ChartController> {
|
|
92
92
|
if (opts.container == null) {
|
|
93
93
|
throw new Error(
|
|
94
94
|
'[@363045841yyt/klinechart] createChart: `container` is required and must be a non-null HTMLElement',
|
|
@@ -147,10 +147,24 @@ export function useChart(
|
|
|
147
147
|
opts: Omit<ChartMountOptions, 'container'>,
|
|
148
148
|
): { chart: Ref<ChartController | null> } {
|
|
149
149
|
const chart = shallowRef<ChartController | null>(null)
|
|
150
|
+
let disposed = false
|
|
150
151
|
|
|
151
152
|
const mountIfReady = (el: HTMLElement | null): void => {
|
|
152
153
|
if (el == null || chart.value != null) return
|
|
153
|
-
|
|
154
|
+
const created = createChart({ ...opts, container: el })
|
|
155
|
+
const applyController = (ctrl: ChartController): void => {
|
|
156
|
+
if (disposed) {
|
|
157
|
+
ctrl.dispose()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
chart.value = ctrl
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (typeof (created as Promise<ChartController>).then === 'function') {
|
|
164
|
+
;(created as Promise<ChartController>).then(applyController)
|
|
165
|
+
} else {
|
|
166
|
+
applyController(created as ChartController)
|
|
167
|
+
}
|
|
154
168
|
}
|
|
155
169
|
|
|
156
170
|
// Mount synchronously if the ref is already populated (e.g. SFC where the
|
|
@@ -167,6 +181,7 @@ export function useChart(
|
|
|
167
181
|
)
|
|
168
182
|
|
|
169
183
|
const dispose = (): void => {
|
|
184
|
+
disposed = true
|
|
170
185
|
stopWatch()
|
|
171
186
|
const ctrl = chart.value
|
|
172
187
|
if (ctrl != null) {
|
|
@@ -359,29 +374,40 @@ export const KLineChart = defineComponent({
|
|
|
359
374
|
const scope = effectScope()
|
|
360
375
|
|
|
361
376
|
const chart = shallowRef<ChartController | null>(null)
|
|
377
|
+
let mounted = true
|
|
378
|
+
|
|
379
|
+
const applyController = (ctrl: ChartController): void => {
|
|
380
|
+
if (!mounted) {
|
|
381
|
+
ctrl.dispose()
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
chart.value = ctrl
|
|
385
|
+
emit('ready', ctrl)
|
|
386
|
+
// Bridge viewport changes back out as zoomLevelChange.
|
|
387
|
+
const emitViewport = (): void => {
|
|
388
|
+
const vp = ctrl.viewport.peek()
|
|
389
|
+
emit('zoomLevelChange', vp.zoomLevel, vp.kWidth)
|
|
390
|
+
}
|
|
391
|
+
emitViewport()
|
|
392
|
+
const unsub = ctrl.viewport.subscribe(emitViewport)
|
|
393
|
+
onScopeDispose(unsub)
|
|
394
|
+
}
|
|
362
395
|
|
|
363
396
|
onMounted(() => {
|
|
364
397
|
const el = containerRef.value
|
|
365
398
|
if (el == null) return
|
|
366
399
|
scope.run(() => {
|
|
367
|
-
|
|
400
|
+
const created = createChart({
|
|
368
401
|
container: el,
|
|
369
402
|
data: props.data,
|
|
370
403
|
initialZoomLevel: props.initialZoomLevel,
|
|
371
404
|
zoomLevels: props.zoomLevels,
|
|
372
405
|
theme: props.theme,
|
|
373
406
|
})
|
|
374
|
-
if (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const emitViewport = (): void => {
|
|
379
|
-
const vp = ctrl.viewport.peek()
|
|
380
|
-
emit('zoomLevelChange', vp.zoomLevel, vp.kWidth)
|
|
381
|
-
}
|
|
382
|
-
emitViewport()
|
|
383
|
-
const unsub = ctrl.viewport.subscribe(emitViewport)
|
|
384
|
-
onScopeDispose(unsub)
|
|
407
|
+
if (typeof (created as Promise<ChartController>).then === 'function') {
|
|
408
|
+
;(created as Promise<ChartController>).then(applyController)
|
|
409
|
+
} else {
|
|
410
|
+
applyController(created as ChartController)
|
|
385
411
|
}
|
|
386
412
|
})
|
|
387
413
|
|
|
@@ -401,6 +427,7 @@ export const KLineChart = defineComponent({
|
|
|
401
427
|
})
|
|
402
428
|
|
|
403
429
|
onUnmounted(() => {
|
|
430
|
+
mounted = false
|
|
404
431
|
chart.value?.dispose()
|
|
405
432
|
chart.value = null
|
|
406
433
|
scope.stop()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ChartController } from '@363045841yyt/klinechart-core/controllers'
|
|
2
|
+
|
|
3
|
+
export interface Bounds {
|
|
4
|
+
start: number
|
|
5
|
+
end: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function calcRangeOverlayPixel(
|
|
9
|
+
bounds: Bounds,
|
|
10
|
+
controller: ChartController,
|
|
11
|
+
container: HTMLElement,
|
|
12
|
+
viewport: { scrollLeft: number; plotWidth: number; plotHeight: number },
|
|
13
|
+
): { left: number; width: number; height: number } {
|
|
14
|
+
const { kWidth: currentKWidth, kGap: currentKGap } = controller.getKWidthKGap()
|
|
15
|
+
const dpr = controller.getCurrentDpr()
|
|
16
|
+
const kWidthPx = Math.max(
|
|
17
|
+
1,
|
|
18
|
+
Math.round(currentKWidth * dpr) + (Math.round(currentKWidth * dpr) % 2 === 0 ? 1 : 0),
|
|
19
|
+
)
|
|
20
|
+
const kGapPx = Math.round(currentKGap * dpr)
|
|
21
|
+
const unitPx = kWidthPx + kGapPx
|
|
22
|
+
const startXPx = kGapPx
|
|
23
|
+
|
|
24
|
+
const leftBuffer = container.scrollLeft - viewport.scrollLeft
|
|
25
|
+
const left = leftBuffer + (startXPx + bounds.start * unitPx) / dpr
|
|
26
|
+
const right = leftBuffer + (startXPx + bounds.end * unitPx + kWidthPx) / dpr
|
|
27
|
+
return { left, width: right - left, height: viewport.plotHeight }
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { KLineData } from '@363045841yyt/klinechart-core/controllers'
|
|
2
|
+
|
|
3
|
+
function binarySearch(
|
|
4
|
+
data: ReadonlyArray<KLineData>,
|
|
5
|
+
timestamp: number,
|
|
6
|
+
): { low: number; high: number } {
|
|
7
|
+
let low = 0
|
|
8
|
+
let high = data.length - 1
|
|
9
|
+
while (low <= high) {
|
|
10
|
+
const mid = (low + high) >>> 1
|
|
11
|
+
const ts = data[mid]!.timestamp
|
|
12
|
+
if (ts === timestamp) return { low: mid, high: mid }
|
|
13
|
+
if (ts < timestamp) low = mid + 1
|
|
14
|
+
else high = mid - 1
|
|
15
|
+
}
|
|
16
|
+
return { low, high }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getKLineIndexByTimestamp(
|
|
20
|
+
data: ReadonlyArray<KLineData>,
|
|
21
|
+
timestamp: number,
|
|
22
|
+
): number | null {
|
|
23
|
+
if (data.length === 0) return null
|
|
24
|
+
const { low, high } = binarySearch(data, timestamp)
|
|
25
|
+
return low === high ? low : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findNearestKLineIndex(
|
|
29
|
+
data: ReadonlyArray<KLineData>,
|
|
30
|
+
timestamp: number,
|
|
31
|
+
direction: 'left' | 'right',
|
|
32
|
+
): number | null {
|
|
33
|
+
if (data.length === 0) return null
|
|
34
|
+
const { low, high } = binarySearch(data, timestamp)
|
|
35
|
+
if (low === high) return low
|
|
36
|
+
if (direction === 'left') {
|
|
37
|
+
return high >= 0 ? high : (low < data.length ? low : null)
|
|
38
|
+
}
|
|
39
|
+
return low < data.length ? low : high
|
|
40
|
+
}
|