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

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