@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.
- package/dist/controllers/createChartController.d.ts.map +1 -1
- package/dist/controllers/createChartController.js +21 -1
- package/dist/controllers/createChartController.js.map +1 -1
- package/dist/controllers/types.d.ts +6 -1
- package/dist/controllers/types.d.ts.map +1 -1
- package/dist/data-fetchers/baostock.js +3 -3
- package/dist/data-fetchers/baostock.js.map +1 -1
- package/dist/data-fetchers/dataBuffer.d.ts +5 -1
- package/dist/data-fetchers/dataBuffer.d.ts.map +1 -1
- package/dist/data-fetchers/dataBuffer.js +82 -48
- package/dist/data-fetchers/dataBuffer.js.map +1 -1
- package/dist/data-fetchers/tradingview.d.ts.map +1 -1
- package/dist/data-fetchers/tradingview.js +4 -5
- package/dist/data-fetchers/tradingview.js.map +1 -1
- package/dist/engine/chart.d.ts +29 -367
- package/dist/engine/chart.d.ts.map +1 -1
- package/dist/engine/chart.js +239 -1842
- package/dist/engine/chart.js.map +1 -1
- package/dist/engine/chartContext.d.ts +24 -0
- package/dist/engine/chartContext.d.ts.map +1 -0
- package/dist/engine/chartContext.js +19 -0
- package/dist/engine/chartContext.js.map +1 -0
- package/dist/engine/chartTypes.d.ts +77 -0
- package/dist/engine/chartTypes.d.ts.map +1 -0
- package/dist/engine/chartTypes.js +2 -0
- package/dist/engine/chartTypes.js.map +1 -0
- package/dist/engine/data/chartDataManager.d.ts +102 -0
- package/dist/engine/data/chartDataManager.d.ts.map +1 -0
- package/dist/engine/data/chartDataManager.js +590 -0
- package/dist/engine/data/chartDataManager.js.map +1 -0
- package/dist/engine/indicators/chartIndicatorManager.d.ts +102 -0
- package/dist/engine/indicators/chartIndicatorManager.d.ts.map +1 -0
- package/dist/engine/indicators/chartIndicatorManager.js +437 -0
- package/dist/engine/indicators/chartIndicatorManager.js.map +1 -0
- package/dist/engine/layout/chartPaneLayout.d.ts +53 -0
- package/dist/engine/layout/chartPaneLayout.d.ts.map +1 -0
- package/dist/engine/layout/chartPaneLayout.js +388 -0
- package/dist/engine/layout/chartPaneLayout.js.map +1 -0
- package/dist/engine/render/chartRenderer.d.ts +86 -0
- package/dist/engine/render/chartRenderer.d.ts.map +1 -0
- package/dist/engine/render/chartRenderer.js +438 -0
- package/dist/engine/render/chartRenderer.js.map +1 -0
- package/dist/engine/renderers/Indicator/mainIndicatorLegend.d.ts.map +1 -1
- package/dist/engine/renderers/Indicator/mainIndicatorLegend.js +73 -7
- package/dist/engine/renderers/Indicator/mainIndicatorLegend.js.map +1 -1
- package/dist/engine/renderers/comparisonLine.d.ts.map +1 -1
- package/dist/engine/renderers/comparisonLine.js +25 -11
- package/dist/engine/renderers/comparisonLine.js.map +1 -1
- package/dist/engine/subPaneManager.d.ts +27 -6
- package/dist/engine/subPaneManager.d.ts.map +1 -1
- package/dist/engine/subPaneManager.js +54 -56
- package/dist/engine/subPaneManager.js.map +1 -1
- package/dist/engine/utils/chartZoomController.d.ts +33 -0
- package/dist/engine/utils/chartZoomController.d.ts.map +1 -0
- package/dist/engine/utils/chartZoomController.js +66 -0
- package/dist/engine/utils/chartZoomController.js.map +1 -0
- package/dist/engine/viewport/chartViewportManager.d.ts +72 -0
- package/dist/engine/viewport/chartViewportManager.d.ts.map +1 -0
- package/dist/engine/viewport/chartViewportManager.js +249 -0
- package/dist/engine/viewport/chartViewportManager.js.map +1 -0
- package/dist/plugin/types.d.ts +1 -0
- package/dist/plugin/types.d.ts.map +1 -1
- package/dist/plugin/types.js.map +1 -1
- package/dist/tokens/theme-china.d.ts.map +1 -1
- package/dist/tokens/theme-china.js +0 -4
- package/dist/tokens/theme-china.js.map +1 -1
- package/dist/tokens/theme-dark.d.ts.map +1 -1
- package/dist/tokens/theme-dark.js +0 -4
- package/dist/tokens/theme-dark.js.map +1 -1
- package/dist/tokens/theme-light.d.ts.map +1 -1
- package/dist/tokens/theme-light.js +1 -5
- package/dist/tokens/theme-light.js.map +1 -1
- package/dist/tokens/types.d.ts +0 -4
- package/dist/tokens/types.d.ts.map +1 -1
- package/dist/types/price.d.ts +2 -0
- package/dist/types/price.d.ts.map +1 -1
- package/dist/types/price.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/src/controllers/createChartController.ts +39 -10
- package/src/controllers/types.ts +6 -1
- package/src/data-fetchers/baostock.ts +3 -3
- package/src/data-fetchers/dataBuffer.ts +64 -23
- package/src/data-fetchers/tradingview.ts +4 -5
- package/src/engine/__tests__/subPaneManager.test.ts +154 -0
- package/src/engine/chart.ts +252 -2250
- package/src/engine/chartContext.ts +34 -0
- package/src/engine/chartTypes.ts +88 -0
- package/src/engine/data/chartDataManager.ts +691 -0
- package/src/engine/indicators/__tests__/chartIndicatorManager.test.ts +103 -0
- package/src/engine/indicators/chartIndicatorManager.ts +566 -0
- package/src/engine/layout/chartPaneLayout.ts +474 -0
- package/src/engine/render/chartRenderer.ts +579 -0
- package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +99 -13
- package/src/engine/renderers/comparisonLine.ts +25 -11
- package/src/engine/subPaneManager.ts +75 -59
- package/src/engine/utils/chartZoomController.ts +104 -0
- package/src/engine/viewport/chartViewportManager.ts +310 -0
- package/src/plugin/types.ts +1 -0
- package/src/tokens/__tests__/__snapshots__/baseline.test.ts.snap +1 -9
- package/src/tokens/theme-china.ts +0 -4
- package/src/tokens/theme-dark.ts +0 -4
- package/src/tokens/theme-light.ts +2 -6
- package/src/tokens/types.ts +0 -4
- package/src/types/price.ts +2 -0
- package/src/version.ts +1 -1
- 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
|
+
}
|