@363045841yyt/klinechart-core 0.8.1-alpha.4 → 0.8.1

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 (110) hide show
  1. package/dist/controllers/createChartController.d.ts.map +1 -1
  2. package/dist/controllers/createChartController.js +21 -1
  3. package/dist/controllers/createChartController.js.map +1 -1
  4. package/dist/controllers/types.d.ts +6 -1
  5. package/dist/controllers/types.d.ts.map +1 -1
  6. package/dist/data-fetchers/baostock.js +3 -3
  7. package/dist/data-fetchers/baostock.js.map +1 -1
  8. package/dist/data-fetchers/dataBuffer.d.ts +5 -1
  9. package/dist/data-fetchers/dataBuffer.d.ts.map +1 -1
  10. package/dist/data-fetchers/dataBuffer.js +82 -48
  11. package/dist/data-fetchers/dataBuffer.js.map +1 -1
  12. package/dist/data-fetchers/tradingview.d.ts.map +1 -1
  13. package/dist/data-fetchers/tradingview.js +4 -5
  14. package/dist/data-fetchers/tradingview.js.map +1 -1
  15. package/dist/engine/chart.d.ts +29 -367
  16. package/dist/engine/chart.d.ts.map +1 -1
  17. package/dist/engine/chart.js +239 -1842
  18. package/dist/engine/chart.js.map +1 -1
  19. package/dist/engine/chartContext.d.ts +24 -0
  20. package/dist/engine/chartContext.d.ts.map +1 -0
  21. package/dist/engine/chartContext.js +19 -0
  22. package/dist/engine/chartContext.js.map +1 -0
  23. package/dist/engine/chartTypes.d.ts +77 -0
  24. package/dist/engine/chartTypes.d.ts.map +1 -0
  25. package/dist/engine/chartTypes.js +2 -0
  26. package/dist/engine/chartTypes.js.map +1 -0
  27. package/dist/engine/data/chartDataManager.d.ts +102 -0
  28. package/dist/engine/data/chartDataManager.d.ts.map +1 -0
  29. package/dist/engine/data/chartDataManager.js +590 -0
  30. package/dist/engine/data/chartDataManager.js.map +1 -0
  31. package/dist/engine/indicators/chartIndicatorManager.d.ts +102 -0
  32. package/dist/engine/indicators/chartIndicatorManager.d.ts.map +1 -0
  33. package/dist/engine/indicators/chartIndicatorManager.js +437 -0
  34. package/dist/engine/indicators/chartIndicatorManager.js.map +1 -0
  35. package/dist/engine/layout/chartPaneLayout.d.ts +53 -0
  36. package/dist/engine/layout/chartPaneLayout.d.ts.map +1 -0
  37. package/dist/engine/layout/chartPaneLayout.js +388 -0
  38. package/dist/engine/layout/chartPaneLayout.js.map +1 -0
  39. package/dist/engine/render/chartRenderer.d.ts +86 -0
  40. package/dist/engine/render/chartRenderer.d.ts.map +1 -0
  41. package/dist/engine/render/chartRenderer.js +438 -0
  42. package/dist/engine/render/chartRenderer.js.map +1 -0
  43. package/dist/engine/renderers/Indicator/mainIndicatorLegend.d.ts.map +1 -1
  44. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js +73 -7
  45. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js.map +1 -1
  46. package/dist/engine/renderers/comparisonLine.d.ts.map +1 -1
  47. package/dist/engine/renderers/comparisonLine.js +25 -11
  48. package/dist/engine/renderers/comparisonLine.js.map +1 -1
  49. package/dist/engine/subPaneManager.d.ts +27 -6
  50. package/dist/engine/subPaneManager.d.ts.map +1 -1
  51. package/dist/engine/subPaneManager.js +54 -56
  52. package/dist/engine/subPaneManager.js.map +1 -1
  53. package/dist/engine/utils/chartZoomController.d.ts +33 -0
  54. package/dist/engine/utils/chartZoomController.d.ts.map +1 -0
  55. package/dist/engine/utils/chartZoomController.js +66 -0
  56. package/dist/engine/utils/chartZoomController.js.map +1 -0
  57. package/dist/engine/viewport/chartViewportManager.d.ts +72 -0
  58. package/dist/engine/viewport/chartViewportManager.d.ts.map +1 -0
  59. package/dist/engine/viewport/chartViewportManager.js +249 -0
  60. package/dist/engine/viewport/chartViewportManager.js.map +1 -0
  61. package/dist/plugin/types.d.ts +1 -0
  62. package/dist/plugin/types.d.ts.map +1 -1
  63. package/dist/plugin/types.js.map +1 -1
  64. package/dist/tokens/theme-china.d.ts.map +1 -1
  65. package/dist/tokens/theme-china.js +0 -4
  66. package/dist/tokens/theme-china.js.map +1 -1
  67. package/dist/tokens/theme-dark.d.ts.map +1 -1
  68. package/dist/tokens/theme-dark.js +0 -4
  69. package/dist/tokens/theme-dark.js.map +1 -1
  70. package/dist/tokens/theme-light.d.ts.map +1 -1
  71. package/dist/tokens/theme-light.js +1 -5
  72. package/dist/tokens/theme-light.js.map +1 -1
  73. package/dist/tokens/types.d.ts +0 -4
  74. package/dist/tokens/types.d.ts.map +1 -1
  75. package/dist/types/price.d.ts +2 -0
  76. package/dist/types/price.d.ts.map +1 -1
  77. package/dist/types/price.js.map +1 -1
  78. package/dist/version.d.ts +1 -1
  79. package/dist/version.d.ts.map +1 -1
  80. package/dist/version.js +1 -1
  81. package/dist/version.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/controllers/createChartController.ts +39 -10
  84. package/src/controllers/types.ts +6 -1
  85. package/src/data-fetchers/baostock.ts +3 -3
  86. package/src/data-fetchers/dataBuffer.ts +64 -23
  87. package/src/data-fetchers/tradingview.ts +4 -5
  88. package/src/engine/__tests__/subPaneManager.test.ts +154 -0
  89. package/src/engine/chart.ts +252 -2250
  90. package/src/engine/chartContext.ts +34 -0
  91. package/src/engine/chartTypes.ts +88 -0
  92. package/src/engine/data/chartDataManager.ts +691 -0
  93. package/src/engine/indicators/__tests__/chartIndicatorManager.test.ts +103 -0
  94. package/src/engine/indicators/chartIndicatorManager.ts +566 -0
  95. package/src/engine/layout/chartPaneLayout.ts +474 -0
  96. package/src/engine/render/chartRenderer.ts +579 -0
  97. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +99 -13
  98. package/src/engine/renderers/comparisonLine.ts +25 -11
  99. package/src/engine/subPaneManager.ts +75 -59
  100. package/src/engine/utils/chartZoomController.ts +104 -0
  101. package/src/engine/viewport/chartViewportManager.ts +310 -0
  102. package/src/plugin/types.ts +1 -0
  103. package/src/tokens/__tests__/__snapshots__/baseline.test.ts.snap +1 -9
  104. package/src/tokens/theme-china.ts +0 -4
  105. package/src/tokens/theme-dark.ts +0 -4
  106. package/src/tokens/theme-light.ts +2 -6
  107. package/src/tokens/types.ts +0 -4
  108. package/src/types/price.ts +2 -0
  109. package/src/version.ts +1 -1
  110. package/src/engine/chart.d.ts +0 -626
@@ -0,0 +1,691 @@
1
+ import type { KLineData } from '../../types/price'
2
+ import type { SymbolSpec, DataFetcher } from '../../controllers/types'
3
+ import { createSignal, type Signal } from '../../reactivity/signal'
4
+ import { DataBuffer } from '../../data-fetchers/dataBuffer'
5
+ import type { ChartDom, Viewport } from '../chartTypes'
6
+ import type { VisibleRange, UpdateLevel } from '../layout/pane'
7
+ import { getVisibleRange } from '../viewport/viewport'
8
+ import { getPhysicalKLineConfig } from '../utils/klineConfig'
9
+
10
+ const COMPARISON_PALETTE = ['#f59e0b', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f97316']
11
+ const DEFAULT_COMPARISON_COLOR = '#f59e0b'
12
+
13
+ export interface DataDependencies {
14
+ getOption: () => { kWidth: number; kGap: number }
15
+ getEffectiveDpr: () => number
16
+ getLogicalScrollLeft: () => number
17
+ getCachedScrollLeft: () => number
18
+ setCachedScrollLeft: (v: number) => void
19
+ setPendingScrollLeft: (v: number) => void
20
+ getDom: () => ChartDom
21
+ getObservedSize: () => { width: number; height: number }
22
+ getViewport: () => Viewport | null
23
+ scheduleDraw: (level?: UpdateLevel) => void
24
+ resetInteraction: () => void
25
+ getIndicatorScheduler: () => {
26
+ update: (data: KLineData[], range: VisibleRange) => boolean
27
+ }
28
+ setPendingIndicatorDataUpdate: (v: boolean) => void
29
+ isPointerDown: () => boolean
30
+ }
31
+
32
+ export class ChartDataManager {
33
+ private _internalData: KLineData[] = []
34
+ private _dataFetcher: DataFetcher | null = null
35
+ private _dataBuffer: DataBuffer = new DataBuffer()
36
+ private _dataBufferUnsub: (() => void) | null = null
37
+ private _comparisonSpecs: SymbolSpec[] = []
38
+ private _comparisonData: Map<string, KLineData[]> = new Map()
39
+ private _comparisonBuffers: Map<string, DataBuffer> = new Map()
40
+ private _comparisonBufferUnsubs: Map<string, () => void> = new Map()
41
+ private _comparisonColors: Map<string, string> = new Map()
42
+ private _comparisonColorsSignal = createSignal<ReadonlyMap<string, string>>(new Map())
43
+ private _comparisonLoadingUnsubs: Map<string, () => void> = new Map()
44
+ private _comparisonLoadingSignal = createSignal<boolean>(false)
45
+
46
+ private _dataSignal = createSignal<ReadonlyArray<KLineData>>([])
47
+ private _symbolsSignal = createSignal<ReadonlyArray<SymbolSpec>>([])
48
+
49
+ private _pendingFetches: Array<{
50
+ source: string
51
+ spec: SymbolSpec
52
+ startTs: number
53
+ endTs: number
54
+ resolve: (data: ReadonlyArray<KLineData>) => void
55
+ reject: (err: Error) => void
56
+ }> = []
57
+
58
+ private _batchFlushScheduled = false
59
+
60
+ private incrementalLoadHintEl: HTMLDivElement | null = null
61
+ private incrementalLoadHintTimer: number | null = null
62
+ private pendingPrependedCount = 0
63
+
64
+ lastVisibleRange: VisibleRange = { start: 0, end: 0 }
65
+ lastRawVisibleRange: VisibleRange = { start: 0, end: 0 }
66
+ pendingIndicatorDataUpdate = false
67
+
68
+ private deps: DataDependencies
69
+
70
+ constructor(deps: DataDependencies) {
71
+ this.deps = deps
72
+ }
73
+
74
+ private getScrollContentHost(): HTMLDivElement | null {
75
+ return this.deps.getDom().scrollContent ?? this.deps.getDom().container ?? null
76
+ }
77
+
78
+ getLeftLoadBufferWidth(): number {
79
+ if (this._internalData.length === 0) return 0
80
+ const plotWidth = this.deps.getViewport()?.plotWidth
81
+ ?? (this.deps.getObservedSize().width > 0 ? this.deps.getObservedSize().width : undefined)
82
+ ?? Math.round(this.deps.getDom().container?.clientWidth ?? 0)
83
+ return Math.max(0, plotWidth)
84
+ }
85
+
86
+ private computeRawVisibleRange(): VisibleRange | null {
87
+ if (this._internalData.length === 0) return null
88
+ const vp = this.deps.getViewport()
89
+ if (!vp) return null
90
+ const opt = this.deps.getOption()
91
+ return getVisibleRange(
92
+ vp.scrollLeft,
93
+ vp.plotWidth,
94
+ opt.kWidth,
95
+ opt.kGap,
96
+ this._internalData.length,
97
+ vp.dpr,
98
+ )
99
+ }
100
+
101
+ private getTrailingSlotCount(): number {
102
+ return 24
103
+ }
104
+
105
+ private static readonly LEADING_SLOTS = 60
106
+ private static readonly TRAILING_DRAWING_SLOTS = 24
107
+
108
+ private clearIncrementalLoadHintTimer(): void {
109
+ if (this.incrementalLoadHintTimer !== null) {
110
+ window.clearTimeout(this.incrementalLoadHintTimer)
111
+ this.incrementalLoadHintTimer = null
112
+ }
113
+ }
114
+
115
+ private hideIncrementalLoadHint(): void {
116
+ const hint = this.incrementalLoadHintEl
117
+ if (!hint) return
118
+ hint.style.opacity = '0'
119
+ hint.style.filter = 'blur(10px)'
120
+ }
121
+
122
+ private ensureIncrementalLoadHint(): HTMLDivElement | null {
123
+ const host = this.getScrollContentHost()
124
+ if (!host) return null
125
+ if (this.incrementalLoadHintEl && this.incrementalLoadHintEl.isConnected) {
126
+ return this.incrementalLoadHintEl
127
+ }
128
+ const ownerDoc = host.ownerDocument
129
+ if (!ownerDoc) return null
130
+ const hint = ownerDoc.createElement('div')
131
+ hint.className = 'klc-incremental-load-hint'
132
+ hint.style.position = 'absolute'
133
+ hint.style.left = '0'
134
+ hint.style.top = '0'
135
+ hint.style.height = '0px'
136
+ hint.style.width = '0px'
137
+ hint.style.pointerEvents = 'none'
138
+ hint.style.opacity = '0'
139
+ hint.style.filter = 'blur(10px)'
140
+ hint.style.transition = 'opacity 420ms ease, filter 420ms ease'
141
+ hint.style.background = 'rgba(71, 91, 132, 0.5)'
142
+ hint.style.zIndex = '3'
143
+ hint.style.willChange = 'opacity, filter, width'
144
+ host.appendChild(hint)
145
+ this.incrementalLoadHintEl = hint
146
+ return hint
147
+ }
148
+
149
+ private showIncrementalLoadHint(count: number): void {
150
+ if (count <= 0) return
151
+ const hint = this.ensureIncrementalLoadHint()
152
+ if (!hint) return
153
+ this.clearIncrementalLoadHintTimer()
154
+ const dpr = this.deps.getEffectiveDpr()
155
+ const opt = this.deps.getOption()
156
+ const { unitPx, startXPx } = getPhysicalKLineConfig(opt.kWidth, opt.kGap, dpr)
157
+ const width = this.getLeftLoadBufferWidth() + (startXPx + count * unitPx) / dpr
158
+ hint.style.width = `${Math.max(0, width)}px`
159
+ hint.style.height = `${Math.max(
160
+ 0,
161
+ this.deps.getViewport()?.viewHeight ?? this.deps.getDom().container?.clientHeight ?? 0,
162
+ )}px`
163
+ hint.getBoundingClientRect()
164
+ hint.style.opacity = '1'
165
+ hint.style.filter = 'blur(0px)'
166
+ this.incrementalLoadHintTimer = window.setTimeout(() => {
167
+ this.hideIncrementalLoadHint()
168
+ this.incrementalLoadHintTimer = null
169
+ }, 900)
170
+ }
171
+
172
+ get data(): Signal<ReadonlyArray<KLineData>> {
173
+ return this._dataSignal
174
+ }
175
+
176
+ get symbols(): Signal<ReadonlyArray<SymbolSpec>> {
177
+ return this._symbolsSignal
178
+ }
179
+
180
+ getInternalData(): KLineData[] {
181
+ return this._internalData
182
+ }
183
+
184
+ getComparisonData(): Map<string, KLineData[]> {
185
+ return this._comparisonData
186
+ }
187
+
188
+ getComparisonSpecs(): SymbolSpec[] {
189
+ return this._comparisonSpecs
190
+ }
191
+
192
+ get dataBuffer(): DataBuffer {
193
+ return this._dataBuffer
194
+ }
195
+
196
+ get comparisonColors(): Signal<ReadonlyMap<string, string>> {
197
+ return this._comparisonColorsSignal
198
+ }
199
+
200
+ get comparisonLoading(): Signal<boolean> {
201
+ return this._comparisonLoadingSignal
202
+ }
203
+
204
+ getComparisonColors(): Map<string, string> {
205
+ return this._comparisonColors
206
+ }
207
+
208
+ private recomputeComparisonLoading(): void {
209
+ const anyLoading = Array.from(this._comparisonBuffers.values()).some((b) => b.loading.peek())
210
+ this._comparisonLoadingSignal.set(anyLoading)
211
+ }
212
+
213
+ updateData(data: KLineData[]): void {
214
+ this._internalData = data ?? []
215
+ this._dataSignal.set([...this._internalData])
216
+
217
+ const container = this.deps.getDom().container
218
+ if (container) {
219
+ const minScrollLeft = this.getLeftLoadBufferWidth()
220
+ if (this.deps.getCachedScrollLeft() < minScrollLeft) {
221
+ this.deps.setCachedScrollLeft(minScrollLeft)
222
+ this.deps.setPendingScrollLeft(minScrollLeft)
223
+ }
224
+ const contentWidth = this.getContentWidth()
225
+ const maxScrollLeft = Math.max(0, contentWidth - container.clientWidth)
226
+ if (this.deps.getCachedScrollLeft() > maxScrollLeft) {
227
+ this.deps.setCachedScrollLeft(maxScrollLeft)
228
+ this.deps.setPendingScrollLeft(maxScrollLeft)
229
+ }
230
+ }
231
+
232
+ this.deps.resetInteraction()
233
+
234
+ if (this.lastVisibleRange.start === 0 && this.lastVisibleRange.end === 0 && this._internalData.length > 0) {
235
+ const plotWidth = this.deps.getObservedSize().width > 0
236
+ ? this.deps.getObservedSize().width
237
+ : Math.max(1, Math.round(this.deps.getDom().container?.clientWidth ?? 800))
238
+ const dpr = this.deps.getEffectiveDpr()
239
+ const opt = this.deps.getOption()
240
+ const { start, end } = getVisibleRange(
241
+ this.deps.getLogicalScrollLeft(),
242
+ plotWidth,
243
+ opt.kWidth,
244
+ opt.kGap,
245
+ this._internalData.length,
246
+ dpr,
247
+ )
248
+ this.lastRawVisibleRange = { start, end }
249
+ this.lastVisibleRange = { start: Math.max(0, start), end }
250
+ }
251
+
252
+ const scheduler = this.deps.getIndicatorScheduler()
253
+ const indicatorsReady = scheduler.update(this._internalData, this.lastVisibleRange)
254
+ if (indicatorsReady) {
255
+ this.pendingIndicatorDataUpdate = false
256
+ this.deps.scheduleDraw()
257
+ } else {
258
+ this.pendingIndicatorDataUpdate = true
259
+ }
260
+ }
261
+
262
+ setData(data: KLineData[]): void {
263
+ this.updateData(data)
264
+ }
265
+
266
+ appendData(newData: KLineData[]): void {
267
+ const merged = [...this._internalData, ...newData]
268
+ this.setData(merged)
269
+ }
270
+
271
+ getData(): KLineData[] {
272
+ return this._internalData
273
+ }
274
+
275
+ setDataFetcher(fetcher: DataFetcher | null): void {
276
+ this._dataFetcher = fetcher
277
+ if (!fetcher) {
278
+ this._dataBuffer.setRequestFetch(null)
279
+ for (const buffer of this._comparisonBuffers.values()) {
280
+ buffer.setRequestFetch(null)
281
+ }
282
+ return
283
+ }
284
+ const handler = this._createBatchHandler(fetcher)
285
+ this._dataBuffer.setRequestFetch(handler)
286
+ for (const buffer of this._comparisonBuffers.values()) {
287
+ buffer.setRequestFetch(handler)
288
+ }
289
+ }
290
+
291
+ private _createBatchHandler(
292
+ fetcher: DataFetcher,
293
+ ): (spec: SymbolSpec, startTs: number, endTs: number) => Promise<ReadonlyArray<KLineData>> {
294
+ return (spec, startTs, endTs) =>
295
+ new Promise<ReadonlyArray<KLineData>>((resolve, reject) => {
296
+ this._pendingFetches.push({
297
+ source: spec.source ?? 'baostock',
298
+ spec,
299
+ startTs,
300
+ endTs,
301
+ resolve,
302
+ reject,
303
+ })
304
+ this._scheduleBatchFlush()
305
+ })
306
+ }
307
+
308
+ private _scheduleBatchFlush(): void {
309
+ if (this._batchFlushScheduled) return
310
+ this._batchFlushScheduled = true
311
+ Promise.resolve().then(() => this._flushBatch())
312
+ }
313
+
314
+ private async _flushBatch(): Promise<void> {
315
+ this._batchFlushScheduled = false
316
+ const batch = this._pendingFetches.splice(0)
317
+ if (batch.length === 0 || !this._dataFetcher) return
318
+ const fetcher = this._dataFetcher
319
+ const CONCURRENCY = 4
320
+ for (let i = 0; i < batch.length; i += CONCURRENCY) {
321
+ const chunk = batch.slice(i, i + CONCURRENCY)
322
+ await Promise.allSettled(
323
+ chunk.map(({ source, spec, startTs, endTs, resolve, reject }) =>
324
+ fetcher(source, {
325
+ symbol: spec.symbol,
326
+ startDate: batchFormatDate(startTs),
327
+ endDate: batchFormatDate(endTs),
328
+ period: spec.period ?? 'daily',
329
+ adjust: spec.adjust ?? 'none',
330
+ exchange: spec.exchange,
331
+ }).then(resolve, reject),
332
+ ),
333
+ )
334
+ }
335
+ }
336
+
337
+ checkVisibleRangeGap(): void {
338
+ if (this._internalData.length === 0) return
339
+ const window = this._dataBuffer.loadedWindow
340
+ if (!window) return
341
+ const range = this.computeRawVisibleRange() ?? this.lastRawVisibleRange
342
+
343
+ const MS_PER_DAY = 86_400_000
344
+ let firstVisibleTs: number | undefined
345
+
346
+ if (range.start < 0 && this._dataFetcher) {
347
+ const earlierThanEarliest = window.earliestTs - 90 * MS_PER_DAY
348
+ this._dataBuffer.ensureRange(earlierThanEarliest, window.earliestTs)
349
+ firstVisibleTs = this._internalData[0]?.timestamp
350
+ } else if (range.start < this._internalData.length) {
351
+ firstVisibleTs = this._internalData[Math.max(0, range.start)]?.timestamp
352
+ if (firstVisibleTs !== undefined && firstVisibleTs < window.earliestTs) {
353
+ this._dataBuffer.ensureRange(firstVisibleTs, window.earliestTs)
354
+ }
355
+ }
356
+
357
+ if (firstVisibleTs === undefined) return
358
+
359
+ for (const buffer of this._comparisonBuffers.values()) {
360
+ buffer.ensureRange(firstVisibleTs, window.earliestTs)
361
+ }
362
+ }
363
+
364
+ private syncComparisonBuffers(specs: ReadonlyArray<SymbolSpec>): void {
365
+ this._comparisonSpecs = [...specs]
366
+ const nextKeys = new Set(specs.map((spec) => spec.symbol))
367
+
368
+ for (const [key, buffer] of this._comparisonBuffers) {
369
+ if (nextKeys.has(key)) continue
370
+ this._comparisonBufferUnsubs.get(key)?.()
371
+ this._comparisonBufferUnsubs.delete(key)
372
+ buffer.dispose()
373
+ this._comparisonBuffers.delete(key)
374
+ this._comparisonData.delete(key)
375
+ }
376
+
377
+ if (!this._dataFetcher) return
378
+
379
+ for (const spec of specs) {
380
+ const key = spec.symbol
381
+ let buffer = this._comparisonBuffers.get(key)
382
+ if (!buffer) {
383
+ const newBuffer = new DataBuffer()
384
+ newBuffer.setFetcher(this._dataFetcher)
385
+ if (this._dataFetcher) {
386
+ newBuffer.setRequestFetch(this._createBatchHandler(this._dataFetcher))
387
+ }
388
+ this._comparisonBuffers.set(key, newBuffer)
389
+ const unsubscribe = newBuffer.data.subscribe(() => {
390
+ this._comparisonData.set(key, [...newBuffer.data.peek()])
391
+ this.deps.scheduleDraw()
392
+ })
393
+ this._comparisonBufferUnsubs.set(key, unsubscribe)
394
+ const unsubLoading = newBuffer.loading.subscribe(() => this.recomputeComparisonLoading())
395
+ this._comparisonLoadingUnsubs.set(key, unsubLoading)
396
+ buffer = newBuffer
397
+ } else {
398
+ buffer.setFetcher(this._dataFetcher)
399
+ if (this._dataFetcher) {
400
+ buffer.setRequestFetch(this._createBatchHandler(this._dataFetcher))
401
+ }
402
+ }
403
+ const mainEarliest = this._dataBuffer.loadedWindow?.earliestTs
404
+ buffer.setSymbol(spec, mainEarliest)
405
+ }
406
+ }
407
+
408
+ private clearComparisonBuffers(): void {
409
+ for (const unsubscribe of this._comparisonBufferUnsubs.values()) unsubscribe()
410
+ this._comparisonBufferUnsubs.clear()
411
+ for (const unsub of this._comparisonLoadingUnsubs.values()) unsub()
412
+ this._comparisonLoadingUnsubs.clear()
413
+ for (const buffer of this._comparisonBuffers.values()) buffer.dispose()
414
+ this._comparisonBuffers.clear()
415
+ this._comparisonData.clear()
416
+ this._comparisonColors.clear()
417
+ this._comparisonColorsSignal.set(new Map())
418
+ this._comparisonLoadingSignal.set(false)
419
+ this._comparisonSpecs = []
420
+ }
421
+
422
+ addComparisonSymbol(spec: SymbolSpec): void {
423
+ const key = spec.symbol
424
+ if (this._comparisonBuffers.has(key)) return
425
+ this._comparisonSpecs.push(spec)
426
+
427
+ const color = COMPARISON_PALETTE[this._comparisonColors.size % COMPARISON_PALETTE.length] ?? DEFAULT_COMPARISON_COLOR
428
+ this._comparisonColors.set(key, color)
429
+ this._comparisonColorsSignal.set(new Map(this._comparisonColors))
430
+
431
+ if (!this._dataFetcher) return
432
+
433
+ const newBuffer = new DataBuffer()
434
+ newBuffer.setFetcher(this._dataFetcher)
435
+ if (this._dataFetcher) {
436
+ newBuffer.setRequestFetch(this._createBatchHandler(this._dataFetcher))
437
+ }
438
+ this._comparisonBuffers.set(key, newBuffer)
439
+ const unsubscribe = newBuffer.data.subscribe(() => {
440
+ this._comparisonData.set(key, [...newBuffer.data.peek()])
441
+ this.deps.scheduleDraw()
442
+ })
443
+ this._comparisonBufferUnsubs.set(key, unsubscribe)
444
+ const unsubLoading = newBuffer.loading.subscribe(() => this.recomputeComparisonLoading())
445
+ this._comparisonLoadingUnsubs.set(key, unsubLoading)
446
+ const mainEarliest = this._dataBuffer.loadedWindow?.earliestTs
447
+ newBuffer.setSymbol(spec, mainEarliest)
448
+ this._symbolsSignal.set([this._symbolsSignal.peek()[0]!, ...this._comparisonSpecs])
449
+ }
450
+
451
+ removeComparisonSymbol(symbol: string): void {
452
+ const key = symbol
453
+ if (!this._comparisonBuffers.has(key)) return
454
+
455
+ this._comparisonBufferUnsubs.get(key)?.()
456
+ this._comparisonBufferUnsubs.delete(key)
457
+ this._comparisonLoadingUnsubs.get(key)?.()
458
+ this._comparisonLoadingUnsubs.delete(key)
459
+ this._comparisonBuffers.get(key)?.dispose()
460
+ this._comparisonBuffers.delete(key)
461
+ this._comparisonData.delete(key)
462
+ this._comparisonColors.delete(key)
463
+ this._comparisonColorsSignal.set(new Map(this._comparisonColors))
464
+ this._comparisonSpecs = this._comparisonSpecs.filter((s) => s.symbol !== symbol)
465
+ this._symbolsSignal.set([this._symbolsSignal.peek()[0]!, ...this._comparisonSpecs])
466
+ this.recomputeComparisonLoading()
467
+ }
468
+
469
+ setSymbols(specs: ReadonlyArray<SymbolSpec>): void {
470
+ this._symbolsSignal.set(specs)
471
+ if (specs.length === 0) {
472
+ this.clearComparisonBuffers()
473
+ return
474
+ }
475
+ const spec = specs[0]!
476
+ this.syncComparisonBuffers(specs.slice(1))
477
+ if (!this._dataFetcher) return
478
+
479
+ this._dataBuffer.setFetcher(this._dataFetcher)
480
+
481
+ this._dataBuffer.onPrepend = (count: number) => {
482
+ this.pendingPrependedCount = count
483
+ const dpr = this.deps.getEffectiveDpr()
484
+ const opt = this.deps.getOption()
485
+ const { unitPx } = getPhysicalKLineConfig(opt.kWidth, opt.kGap, dpr)
486
+ const compensation = (count * unitPx) / dpr
487
+ const container = this.deps.getDom().container
488
+ if (container) {
489
+ container.scrollLeft += compensation
490
+ this.deps.setCachedScrollLeft(container.scrollLeft)
491
+ }
492
+ }
493
+
494
+ if (!this._dataBufferUnsub) {
495
+ this._dataBufferUnsub = this._dataBuffer.data.subscribe(() => {
496
+ const prevLength = this._internalData.length
497
+ const bufferData = this._dataBuffer.data.peek()
498
+ this._internalData = [...bufferData]
499
+ this._dataSignal.set([...this._internalData])
500
+ const prependedCount = this.pendingPrependedCount
501
+ this.pendingPrependedCount = 0
502
+
503
+ if (this.deps.getCachedScrollLeft() < this.getLeftLoadBufferWidth()) {
504
+ const desiredScrollLeft = this.getLeftLoadBufferWidth()
505
+ this.deps.setCachedScrollLeft(desiredScrollLeft)
506
+ this.deps.setPendingScrollLeft(desiredScrollLeft)
507
+ }
508
+
509
+ if (prevLength === 0 && this._internalData.length > 0) {
510
+ const dpr = this.deps.getEffectiveDpr()
511
+ const opt = this.deps.getOption()
512
+ const { unitPx, startXPx } = getPhysicalKLineConfig(opt.kWidth, opt.kGap, dpr)
513
+ const lastKLineEndPx = (startXPx + this._internalData.length * unitPx) / dpr
514
+ const container = this.deps.getDom().container
515
+ if (container) {
516
+ const target = this.getLeftLoadBufferWidth() + Math.max(0, lastKLineEndPx - container.clientWidth)
517
+ const contentWidth = this.getContentWidth()
518
+ const maxScroll = Math.max(0, contentWidth - container.clientWidth)
519
+ const scrollLeft = Math.round(Math.min(target, maxScroll) * dpr) / dpr
520
+ this.deps.setCachedScrollLeft(scrollLeft)
521
+ this.deps.setPendingScrollLeft(scrollLeft)
522
+ }
523
+ }
524
+
525
+ this.deps.resetInteraction()
526
+
527
+ if (this.lastVisibleRange.start === 0 && this.lastVisibleRange.end === 0 && this._internalData.length > 0) {
528
+ const plotWidth = this.deps.getObservedSize().width > 0
529
+ ? this.deps.getObservedSize().width
530
+ : Math.max(1, Math.round(this.deps.getDom().container?.clientWidth ?? 800))
531
+ const dpr = this.deps.getEffectiveDpr()
532
+ const opt = this.deps.getOption()
533
+ const { start, end } = getVisibleRange(
534
+ this.deps.getLogicalScrollLeft(),
535
+ plotWidth,
536
+ opt.kWidth,
537
+ opt.kGap,
538
+ this._internalData.length,
539
+ dpr,
540
+ )
541
+ this.lastRawVisibleRange = { start, end }
542
+ this.lastVisibleRange = { start: Math.max(0, start), end }
543
+ }
544
+
545
+ const scheduler = this.deps.getIndicatorScheduler()
546
+ const indicatorsReady = scheduler.update(this._internalData, this.lastVisibleRange)
547
+ if (indicatorsReady) {
548
+ this.pendingIndicatorDataUpdate = false
549
+ this.deps.scheduleDraw()
550
+ } else {
551
+ this.pendingIndicatorDataUpdate = true
552
+ }
553
+
554
+ this.showIncrementalLoadHint(prependedCount)
555
+ })
556
+ }
557
+
558
+ this._dataBuffer.setSymbol(spec)
559
+ }
560
+
561
+ getContentWidth(): number {
562
+ const dataLength = this._internalData.length
563
+ if (dataLength === 0) return 0
564
+ const opt = this.deps.getOption()
565
+ const viewWidth = this.deps.getViewport()?.plotWidth ?? 0
566
+ const dpr = this.deps.getEffectiveDpr()
567
+ const { startXPx, unitPx } = getPhysicalKLineConfig(opt.kWidth, opt.kGap, dpr)
568
+ const dataPlotWidth = (startXPx + (ChartDataManager.LEADING_SLOTS + dataLength + ChartDataManager.TRAILING_DRAWING_SLOTS) * unitPx) / dpr
569
+ return this.getLeftLoadBufferWidth() + Math.max(dataPlotWidth, viewWidth)
570
+ }
571
+
572
+ scrollToRight(): void {
573
+ const container = this.deps.getDom().container
574
+ if (!container || this._internalData.length === 0) return
575
+ const dpr = this.deps.getEffectiveDpr()
576
+ const opt = this.deps.getOption()
577
+ const { unitPx, startXPx } = getPhysicalKLineConfig(opt.kWidth, opt.kGap, dpr)
578
+ const lastKLineEndPx = (startXPx + this._internalData.length * unitPx) / dpr
579
+ const target = this.getLeftLoadBufferWidth() + Math.max(0, lastKLineEndPx - container.clientWidth)
580
+ const maxScroll = Math.max(0, container.scrollWidth - container.clientWidth)
581
+ container.scrollLeft = Math.round(Math.min(target, maxScroll) * dpr) / dpr
582
+ this.deps.setCachedScrollLeft(container.scrollLeft)
583
+ }
584
+
585
+ getComparisonEquivalentPriceRange(range: VisibleRange): { min: number; max: number } | null {
586
+ if (this._comparisonSpecs.length === 0 || this._comparisonData.size === 0) return null
587
+ const baseIndex = Math.max(0, range.start)
588
+ const baseItem = this._internalData[baseIndex]
589
+ if (!baseItem || !Number.isFinite(baseItem.close) || baseItem.close <= 0) return null
590
+ const mainBase = baseItem.close
591
+ const baseDate = baseItem.date ?? ''
592
+
593
+ let min = Number.POSITIVE_INFINITY
594
+ let max = Number.NEGATIVE_INFINITY
595
+
596
+ for (const spec of this._comparisonSpecs) {
597
+ const data = this._comparisonData.get(spec.symbol)
598
+ if (!data?.length) continue
599
+
600
+ const baseline = baseDate
601
+ ? findComparisonBaselineByDate(data, baseDate)
602
+ : findComparisonBaselineByTimestamp(data, baseItem.timestamp)
603
+ if (!baseline || !Number.isFinite(baseline.close) || baseline.close <= 0) continue
604
+
605
+ const byDate = new Map<string, KLineData>()
606
+ for (const item of data) {
607
+ if (item.date) byDate.set(item.date, item)
608
+ else byDate.set(String(item.timestamp), item)
609
+ }
610
+
611
+ for (let i = Math.max(0, range.start); i < range.end && i < this._internalData.length; i++) {
612
+ const mainItem = this._internalData[i]
613
+ if (!mainItem) continue
614
+ const key = mainItem.date ?? String(mainItem.timestamp)
615
+ const item = byDate.get(key)
616
+ if (!item || !Number.isFinite(item.close)) continue
617
+
618
+ const pct = (item.close - baseline.close) / baseline.close
619
+ const equivalentPrice = mainBase * (1 + pct)
620
+ if (!Number.isFinite(equivalentPrice)) continue
621
+ min = Math.min(min, equivalentPrice)
622
+ max = Math.max(max, equivalentPrice)
623
+ }
624
+ }
625
+
626
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return null
627
+ return { min, max }
628
+ }
629
+
630
+ getLogicalSlotCount(): number {
631
+ return this._internalData.length + this.getTrailingSlotCount()
632
+ }
633
+
634
+ getTimestampAtLogicalIndex(index: number): number | null {
635
+ if (!Number.isInteger(index) || index < 0 || index >= this._internalData.length) return null
636
+ return this._internalData[index]?.timestamp ?? null
637
+ }
638
+
639
+ getLogicalIndexAtX(mouseX: number): number | null {
640
+ const vp = this.deps.getViewport()
641
+ if (!vp || this._internalData.length === 0) return null
642
+ const dpr = this.deps.getEffectiveDpr()
643
+ const opt = this.deps.getOption()
644
+ const { startXPx, unitPx } = getPhysicalKLineConfig(opt.kWidth, opt.kGap, dpr)
645
+ const worldX = Math.round((vp.scrollLeft + mouseX) * dpr)
646
+ const index = Math.floor((worldX - startXPx) / unitPx)
647
+ if (index < 0) return null
648
+ return index
649
+ }
650
+
651
+ getDataIndexAtX(mouseX: number): number | null {
652
+ const index = this.getLogicalIndexAtX(mouseX)
653
+ if (index === null || index >= this._internalData.length) return null
654
+ return index
655
+ }
656
+
657
+ destroy(): void {
658
+ if (this._dataBufferUnsub) {
659
+ this._dataBufferUnsub()
660
+ this._dataBufferUnsub = null
661
+ }
662
+ this.clearIncrementalLoadHintTimer()
663
+ this.incrementalLoadHintEl?.remove()
664
+ this.incrementalLoadHintEl = null
665
+ this.pendingPrependedCount = 0
666
+ this._dataBuffer.dispose()
667
+ this.clearComparisonBuffers()
668
+ }
669
+ }
670
+
671
+ function findComparisonBaselineByDate(data: ReadonlyArray<KLineData>, date: string): KLineData | null {
672
+ for (const item of data) {
673
+ if (item.date && item.date >= date) return item
674
+ }
675
+ return null
676
+ }
677
+
678
+ function findComparisonBaselineByTimestamp(data: ReadonlyArray<KLineData>, timestamp: number): KLineData | null {
679
+ for (const item of data) {
680
+ if (item.timestamp >= timestamp) return item
681
+ }
682
+ return null
683
+ }
684
+
685
+ function batchFormatDate(ts: number): string {
686
+ const d = new Date(ts)
687
+ const y = d.getFullYear()
688
+ const m = String(d.getMonth() + 1).padStart(2, '0')
689
+ const day = String(d.getDate()).padStart(2, '0')
690
+ return `${y}-${m}-${day}`
691
+ }