@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.
- 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/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.js +1722 -1060
- 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/KLineChart.vue +325 -396
- 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/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
|
+
}
|
|
@@ -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
|
+
}
|