@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.
Files changed (51) hide show
  1. package/dist/components/BatchStockDialog.vue.d.ts +13 -0
  2. package/dist/components/BatchStockDialog.vue.d.ts.map +1 -0
  3. package/dist/components/CompareSymbolSelector.vue.d.ts.map +1 -1
  4. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  5. package/dist/components/ExportProgressDialog.vue.d.ts +15 -0
  6. package/dist/components/ExportProgressDialog.vue.d.ts.map +1 -0
  7. package/dist/components/IndicatorSelector.vue.d.ts.map +1 -1
  8. package/dist/components/KLineChart.vue.d.ts +5 -9
  9. package/dist/components/KLineChart.vue.d.ts.map +1 -1
  10. package/dist/components/LeftToolbar.vue.d.ts.map +1 -1
  11. package/dist/components/SymbolSelector.vue.d.ts.map +1 -1
  12. package/dist/components/TopToolbar.vue.d.ts.map +1 -1
  13. package/dist/composables/chart/useChartTheme.d.ts +329 -0
  14. package/dist/composables/chart/useChartTheme.d.ts.map +1 -0
  15. package/dist/composables/chart/useDrawingManager.d.ts +86 -0
  16. package/dist/composables/chart/useDrawingManager.d.ts.map +1 -0
  17. package/dist/composables/chart/useIndicatorManager.d.ts +38 -0
  18. package/dist/composables/chart/useIndicatorManager.d.ts.map +1 -0
  19. package/dist/composables/chart/useRangeSelection.d.ts +65 -0
  20. package/dist/composables/chart/useRangeSelection.d.ts.map +1 -0
  21. package/dist/composables/useTeleportedPopup.d.ts +8 -0
  22. package/dist/composables/useTeleportedPopup.d.ts.map +1 -0
  23. package/dist/index.cjs +9 -2
  24. package/dist/index.css +1 -1
  25. package/dist/index.d.cts +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1769 -1090
  29. package/dist/tools/calcRangeOverlayPixel.d.ts +15 -0
  30. package/dist/tools/calcRangeOverlayPixel.d.ts.map +1 -0
  31. package/dist/tools/getKLineIndexByTimestamp.d.ts +4 -0
  32. package/dist/tools/getKLineIndexByTimestamp.d.ts.map +1 -0
  33. package/dist/web-component.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/components/BatchStockDialog.vue +293 -0
  36. package/src/components/CompareSymbolSelector.vue +35 -8
  37. package/src/components/Dropdown.vue +42 -19
  38. package/src/components/ExportProgressDialog.vue +226 -0
  39. package/src/components/IndicatorSelector.vue +13 -5
  40. package/src/components/KLineChart.vue +329 -399
  41. package/src/components/LeftToolbar.vue +2 -1
  42. package/src/components/SymbolSelector.vue +35 -8
  43. package/src/components/TopToolbar.vue +55 -2
  44. package/src/composables/chart/useChartTheme.ts +86 -0
  45. package/src/composables/chart/useDrawingManager.ts +67 -0
  46. package/src/composables/chart/useIndicatorManager.ts +307 -0
  47. package/src/composables/chart/useRangeSelection.ts +417 -0
  48. package/src/composables/useTeleportedPopup.ts +33 -0
  49. package/src/index.ts +41 -14
  50. package/src/tools/calcRangeOverlayPixel.ts +28 -0
  51. 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
- chart.value = createChart({ ...opts, container: el })
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
- chart.value = createChart({
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 (chart.value != null) {
375
- emit('ready', chart.value)
376
- // Bridge viewport changes back out as zoomLevelChange.
377
- const ctrl = chart.value
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
+ }