@363045841yyt/klinechart 0.8.4 → 0.8.6

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 (69) hide show
  1. package/README.md +6 -1
  2. package/dist/components/BaseModal.vue.d.ts +54 -0
  3. package/dist/components/BaseModal.vue.d.ts.map +1 -0
  4. package/dist/components/BatchStockDialog.vue.d.ts +13 -0
  5. package/dist/components/BatchStockDialog.vue.d.ts.map +1 -0
  6. package/dist/components/ChartSettingsDialog.vue.d.ts.map +1 -1
  7. package/dist/components/ColorPresetPanel.vue.d.ts +4 -1
  8. package/dist/components/ColorPresetPanel.vue.d.ts.map +1 -1
  9. package/dist/components/CompareSymbolSelector.vue.d.ts.map +1 -1
  10. package/dist/components/DrawingStyleToolbar.vue.d.ts.map +1 -1
  11. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  12. package/dist/components/ExportProgressDialog.vue.d.ts +15 -0
  13. package/dist/components/ExportProgressDialog.vue.d.ts.map +1 -0
  14. package/dist/components/IndicatorParams.vue.d.ts.map +1 -1
  15. package/dist/components/IndicatorSelector.vue.d.ts.map +1 -1
  16. package/dist/components/KLineChart.vue.d.ts +5 -9
  17. package/dist/components/KLineChart.vue.d.ts.map +1 -1
  18. package/dist/components/LeftToolbar.vue.d.ts.map +1 -1
  19. package/dist/components/RangeSelectionExport.vue.d.ts +23 -0
  20. package/dist/components/RangeSelectionExport.vue.d.ts.map +1 -0
  21. package/dist/components/SymbolSelector.vue.d.ts.map +1 -1
  22. package/dist/components/TopToolbar.vue.d.ts.map +1 -1
  23. package/dist/components/common/CanvasToolbar.vue.d.ts +14 -0
  24. package/dist/components/common/CanvasToolbar.vue.d.ts.map +1 -0
  25. package/dist/components/common/CanvasToolbarStack.vue.d.ts +14 -0
  26. package/dist/components/common/CanvasToolbarStack.vue.d.ts.map +1 -0
  27. package/dist/composables/chart/useChartTheme.d.ts +329 -0
  28. package/dist/composables/chart/useChartTheme.d.ts.map +1 -0
  29. package/dist/composables/chart/useDrawingManager.d.ts +86 -0
  30. package/dist/composables/chart/useDrawingManager.d.ts.map +1 -0
  31. package/dist/composables/chart/useIndicatorManager.d.ts +38 -0
  32. package/dist/composables/chart/useIndicatorManager.d.ts.map +1 -0
  33. package/dist/composables/chart/useRangeSelection.d.ts +66 -0
  34. package/dist/composables/chart/useRangeSelection.d.ts.map +1 -0
  35. package/dist/composables/useTeleportedPopup.d.ts +8 -0
  36. package/dist/composables/useTeleportedPopup.d.ts.map +1 -0
  37. package/dist/index.cjs +9 -2
  38. package/dist/index.css +1 -1
  39. package/dist/index.js +2149 -1409
  40. package/dist/tools/calcRangeOverlayPixel.d.ts +15 -0
  41. package/dist/tools/calcRangeOverlayPixel.d.ts.map +1 -0
  42. package/dist/tools/getKLineIndexByTimestamp.d.ts +4 -0
  43. package/dist/tools/getKLineIndexByTimestamp.d.ts.map +1 -0
  44. package/dist/web-component.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/components/BaseModal.vue +292 -0
  47. package/src/components/BatchStockDialog.vue +128 -0
  48. package/src/components/ChartSettingsDialog.vue +248 -405
  49. package/src/components/ColorPresetPanel.vue +58 -106
  50. package/src/components/CompareSymbolSelector.vue +37 -10
  51. package/src/components/DrawingStyleToolbar.vue +33 -72
  52. package/src/components/Dropdown.vue +42 -19
  53. package/src/components/ExportProgressDialog.vue +118 -0
  54. package/src/components/IndicatorParams.vue +194 -321
  55. package/src/components/IndicatorSelector.vue +188 -405
  56. package/src/components/KLineChart.vue +228 -403
  57. package/src/components/LeftToolbar.vue +3 -2
  58. package/src/components/RangeSelectionExport.vue +117 -0
  59. package/src/components/SymbolSelector.vue +37 -10
  60. package/src/components/TopToolbar.vue +55 -2
  61. package/src/components/common/CanvasToolbar.vue +70 -0
  62. package/src/components/common/CanvasToolbarStack.vue +32 -0
  63. package/src/composables/chart/useChartTheme.ts +86 -0
  64. package/src/composables/chart/useDrawingManager.ts +67 -0
  65. package/src/composables/chart/useIndicatorManager.ts +307 -0
  66. package/src/composables/chart/useRangeSelection.ts +424 -0
  67. package/src/composables/useTeleportedPopup.ts +46 -0
  68. package/src/tools/calcRangeOverlayPixel.ts +28 -0
  69. package/src/tools/getKLineIndexByTimestamp.ts +40 -0
@@ -0,0 +1,424 @@
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 rangeSelectionCount = computed(() => {
112
+ const bounds = rangeSelectionBounds.value
113
+ if (!bounds) return 0
114
+ return bounds.end - bounds.start + 1
115
+ })
116
+
117
+ const rangeSelectionOverlayStyle = computed(() => {
118
+ const bounds = rangeSelectionBounds.value
119
+ if (!bounds) return null
120
+
121
+ void containerScrollLeft.value
122
+ void viewportVersion.value
123
+
124
+ const ctrl = controller.value
125
+ const viewport = ctrl?.getViewport()
126
+ const container = containerRef.value
127
+ if (!ctrl || !viewport || !container) return null
128
+
129
+ const px = calcRangeOverlayPixel(bounds, ctrl, container, viewport)
130
+ return {
131
+ left: `${px.left}px`,
132
+ width: `${px.width}px`,
133
+ height: `${px.height}px`,
134
+ }
135
+ })
136
+
137
+ function clearRangeSelection() {
138
+ rangeSelection.value = { startTimestamp: null, endTimestamp: null, isDragging: false }
139
+ customStartDate.value = ''
140
+ customEndDate.value = ''
141
+ }
142
+
143
+ watch(customStartDate, (val) => {
144
+ const data = controller.value?.getData() ?? []
145
+ const targetTs = parseDateToTimestamp(val)
146
+ if (targetTs === null || data.length === 0) return
147
+ rangeSelection.value = { ...rangeSelection.value, startTimestamp: targetTs, isDragging: false }
148
+ if (targetTs < data[0]!.timestamp) {
149
+ controller.value?.ensureDataRange(targetTs)
150
+ }
151
+ })
152
+
153
+ watch(customEndDate, (val) => {
154
+ const data = controller.value?.getData() ?? []
155
+ const targetTs = parseDateToTimestamp(val)
156
+ if (targetTs === null || data.length === 0) return
157
+ rangeSelection.value = { ...rangeSelection.value, endTimestamp: targetTs, isDragging: false }
158
+ if (targetTs < data[0]!.timestamp) {
159
+ controller.value?.ensureDataRange(targetTs)
160
+ }
161
+ })
162
+
163
+ function getRangeSelectionIndex(e: PointerEvent, container: HTMLElement): number | null {
164
+ const data = controller.value?.getData() ?? []
165
+ if (data.length === 0) return null
166
+
167
+ const rect = container.getBoundingClientRect()
168
+ const rawIndex = controller.value?.getLogicalIndexAtX(e.clientX - rect.left)
169
+ if (rawIndex === null || rawIndex === undefined) return null
170
+ return Math.max(0, Math.min(rawIndex, data.length - 1))
171
+ }
172
+
173
+ function handleRangePointerDown(e: PointerEvent, container: HTMLElement): boolean {
174
+ if (!isRangeSelectActive.value) return false
175
+ if (
176
+ rangeSelection.value.startTimestamp !== null &&
177
+ rangeSelection.value.endTimestamp !== null &&
178
+ !rangeSelection.value.isDragging
179
+ ) {
180
+ return false
181
+ }
182
+ const index = getRangeSelectionIndex(e, container)
183
+ if (index === null) return true
184
+
185
+ const data = controller.value?.getData() ?? []
186
+ const ts = data[index]?.timestamp
187
+ if (ts === undefined) return true
188
+
189
+ rangeSelection.value = { startTimestamp: ts, endTimestamp: ts, isDragging: true }
190
+ customStartDate.value = ''
191
+ customEndDate.value = ''
192
+ container.setPointerCapture?.(e.pointerId)
193
+ e.preventDefault()
194
+ return true
195
+ }
196
+
197
+ function handleRangePointerMove(e: PointerEvent, container: HTMLElement): boolean {
198
+ if (!isRangeSelectActive.value || !rangeSelection.value.isDragging) return false
199
+ const index = getRangeSelectionIndex(e, container)
200
+ if (index !== null) {
201
+ const data = controller.value?.getData() ?? []
202
+ const ts = data[index]?.timestamp
203
+ if (ts !== undefined) {
204
+ rangeSelection.value = { ...rangeSelection.value, endTimestamp: ts }
205
+ }
206
+ }
207
+ e.preventDefault()
208
+ return true
209
+ }
210
+
211
+ function handleRangePointerUp(e: PointerEvent, container: HTMLElement): boolean {
212
+ if (!isRangeSelectActive.value || !rangeSelection.value.isDragging) return false
213
+ const index = getRangeSelectionIndex(e, container)
214
+ if (index !== null) {
215
+ const data = controller.value?.getData() ?? []
216
+ const ts = data[index]?.timestamp
217
+ if (ts !== undefined) {
218
+ rangeSelection.value = {
219
+ ...rangeSelection.value,
220
+ endTimestamp: ts,
221
+ isDragging: false,
222
+ }
223
+ } else {
224
+ rangeSelection.value = { ...rangeSelection.value, isDragging: false }
225
+ }
226
+ } else {
227
+ rangeSelection.value = { ...rangeSelection.value, isDragging: false }
228
+ }
229
+ container.releasePointerCapture?.(e.pointerId)
230
+ e.preventDefault()
231
+ return true
232
+ }
233
+
234
+ function onEdgePointerDown(side: 'left' | 'right', e: PointerEvent) {
235
+ if (!isRangeSelectActive.value) return
236
+ resizeSide.value = side
237
+ const el = e.currentTarget as HTMLElement
238
+ el.setPointerCapture?.(e.pointerId)
239
+ e.preventDefault()
240
+ }
241
+
242
+ function onEdgePointerMove(e: PointerEvent) {
243
+ if (
244
+ !resizeSide.value ||
245
+ rangeSelection.value.startTimestamp === null ||
246
+ rangeSelection.value.endTimestamp === null
247
+ )
248
+ return
249
+ const rect = containerRef.value?.getBoundingClientRect()
250
+ if (!rect) return
251
+ const data = controller.value?.getData() ?? []
252
+ if (!data.length) return
253
+ const rawIndex = controller.value?.getLogicalIndexAtX(e.clientX - rect.left)
254
+ if (rawIndex === null || rawIndex === undefined) return
255
+ const index = Math.max(0, Math.min(rawIndex, data.length - 1))
256
+ const ts = data[index]?.timestamp
257
+ if (ts === undefined) return
258
+
259
+ if (resizeSide.value === 'left') {
260
+ if (ts > rangeSelection.value.endTimestamp) {
261
+ rangeSelection.value = {
262
+ startTimestamp: rangeSelection.value.endTimestamp,
263
+ endTimestamp: ts,
264
+ isDragging: false,
265
+ }
266
+ resizeSide.value = 'right'
267
+ } else {
268
+ rangeSelection.value = { ...rangeSelection.value, startTimestamp: ts }
269
+ }
270
+ } else {
271
+ if (ts < rangeSelection.value.startTimestamp) {
272
+ rangeSelection.value = {
273
+ startTimestamp: ts,
274
+ endTimestamp: rangeSelection.value.startTimestamp,
275
+ isDragging: false,
276
+ }
277
+ resizeSide.value = 'left'
278
+ } else {
279
+ rangeSelection.value = { ...rangeSelection.value, endTimestamp: ts }
280
+ }
281
+ }
282
+ }
283
+
284
+ function onEdgePointerUp(e: PointerEvent) {
285
+ if (!resizeSide.value) return
286
+ const el = e.currentTarget as HTMLElement
287
+ el.releasePointerCapture?.(e.pointerId)
288
+ resizeSide.value = null
289
+ }
290
+
291
+ const CSV_FIELDS: Array<keyof KLineData> = [
292
+ 'timestamp',
293
+ 'open',
294
+ 'high',
295
+ 'low',
296
+ 'close',
297
+ 'volume',
298
+ 'turnover',
299
+ 'turnoverRate',
300
+ 'amplitude',
301
+ 'changePercent',
302
+ 'changeAmount',
303
+ ]
304
+
305
+ function downloadCsv(
306
+ items: ReadonlyArray<KLineData>,
307
+ prefix: string,
308
+ startTs: number,
309
+ endTs: number,
310
+ ) {
311
+ const header = `stockCode,time,${CSV_FIELDS.join(',')}`
312
+ const rows = [
313
+ header,
314
+ ...items.map((item) => {
315
+ const timeStr = toCsvCell(formatTimestamp(item.timestamp, { showTime: true }))
316
+ const code = toCsvCell(item.stockCode ?? prefix)
317
+ return `${code},${timeStr},${CSV_FIELDS.map((field) => toCsvCell(item[field])).join(',')}`
318
+ }),
319
+ ]
320
+ const blob = new Blob([`\uFEFF${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' })
321
+ const url = URL.createObjectURL(blob)
322
+ const a = document.createElement('a')
323
+ a.href = url
324
+ a.download = `${prefix}-${toYYYYMMDD(startTs)}-${toYYYYMMDD(endTs)}.csv`
325
+ document.body.appendChild(a)
326
+ a.click()
327
+ document.body.removeChild(a)
328
+ URL.revokeObjectURL(url)
329
+ }
330
+
331
+ async function exportRangeToCsv() {
332
+ const bounds = rangeSelectionBounds.value
333
+ const data = controller.value?.getData() ?? []
334
+ if (!bounds || data.length === 0) return
335
+
336
+ const startTs = data[bounds.start]!.timestamp
337
+ const endTs = data[bounds.end]!.timestamp
338
+ const mainStockCode = controller.value?.symbols.peek()?.[0]?.symbol ?? 'unknown'
339
+ const batchCodes = batchStockCodes.value
340
+ const total = 1 + batchCodes.length
341
+ const prefix = batchCodes.length > 0 ? `batch${total}` : mainStockCode
342
+
343
+ const allItems: KLineData[] = []
344
+
345
+ exportingProgress.value = { current: 0, total, label: '正在准备主品种数据...' }
346
+
347
+ // Main stock
348
+ for (const item of data.slice(bounds.start, bounds.end + 1)) {
349
+ allItems.push(item)
350
+ }
351
+ exportingProgress.value = { current: 1, total, label: '主品种数据已就绪' }
352
+
353
+ // Batch stocks (sequential)
354
+ const fetchFn = dataFetcher.value
355
+ if (fetchFn && batchCodes.length > 0) {
356
+ const spec = controller.value?.symbols.peek()?.[0]
357
+ const startDate = formatTimestamp(startTs)
358
+ const endDate = formatTimestamp(endTs)
359
+ const period = spec?.period ?? 'daily'
360
+ const adjust = spec?.adjust ?? 'none'
361
+ const exchange = spec?.exchange
362
+ const source = spec?.source ?? 'gotdx'
363
+
364
+ for (let i = 0; i < batchCodes.length; i++) {
365
+ const code = batchCodes[i]!
366
+ exportingProgress.value = { current: 1 + i, total, label: `正在获取 ${code}...` }
367
+ try {
368
+ const items = await fetchFn(source, {
369
+ symbol: code,
370
+ startDate,
371
+ endDate,
372
+ period,
373
+ adjust,
374
+ exchange,
375
+ })
376
+ for (const item of items) {
377
+ allItems.push(item)
378
+ }
379
+ } catch {
380
+ continue
381
+ }
382
+ }
383
+ }
384
+
385
+ exportingProgress.value = { current: total, total, label: '正在生成文件...' }
386
+ downloadCsv(allItems, prefix, startTs, endTs)
387
+ exportingProgress.value = { current: total, total, label: '导出完成' }
388
+ }
389
+
390
+ function onScroll() {
391
+ const cont = containerRef.value
392
+ if (cont) containerScrollLeft.value = cont.scrollLeft
393
+ }
394
+
395
+ function syncScrollLeft() {
396
+ const cont = containerRef.value
397
+ if (cont) containerScrollLeft.value = cont.scrollLeft
398
+ }
399
+
400
+ return {
401
+ rangeSelection,
402
+ customStartDate,
403
+ customEndDate,
404
+ containerScrollLeft,
405
+ isRangeSelectActive,
406
+ rangeSelectionReady,
407
+ rangeSelectionBounds,
408
+ rangeSelectionCount,
409
+ rangeSelectionStartLabel,
410
+ rangeSelectionEndLabel,
411
+ rangeSelectionOverlayStyle,
412
+ clearRangeSelection,
413
+ handleRangePointerDown,
414
+ handleRangePointerMove,
415
+ handleRangePointerUp,
416
+ exportRangeToCsv,
417
+ exportingProgress,
418
+ onEdgePointerDown,
419
+ onEdgePointerMove,
420
+ onEdgePointerUp,
421
+ onScroll,
422
+ syncScrollLeft,
423
+ }
424
+ }
@@ -0,0 +1,46 @@
1
+ import { ref, nextTick, 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
+ const popup = popupRef.value
15
+
16
+ let left = rect.left
17
+ if (popup) {
18
+ const popupWidth = popup.offsetWidth
19
+ const viewportWidth = window.innerWidth
20
+ const margin = 8
21
+ if (left + popupWidth > viewportWidth - margin) {
22
+ left = Math.max(margin, viewportWidth - popupWidth - margin)
23
+ }
24
+ }
25
+
26
+ popupStyle.value = {
27
+ position: 'fixed',
28
+ top: `${rect.bottom + gap}px`,
29
+ left: `${left}px`,
30
+ }
31
+ }
32
+
33
+ function startPositionSync() {
34
+ updatePosition()
35
+ nextTick(() => updatePosition())
36
+ document.addEventListener('scroll', updatePosition, { capture: true, passive: true })
37
+ window.addEventListener('resize', updatePosition, { passive: true })
38
+ }
39
+
40
+ function stopPositionSync() {
41
+ document.removeEventListener('scroll', updatePosition, { capture: true })
42
+ window.removeEventListener('resize', updatePosition)
43
+ }
44
+
45
+ return { popupStyle, updatePosition, startPositionSync, stopPositionSync }
46
+ }
@@ -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
+ }