@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
@@ -1,22 +1,24 @@
1
1
  import type { KLineData } from '../types/price'
2
2
  import type { ChartSettings } from '../config/chartSettings'
3
- import { createSignal, computed, type Signal, type Computed } from '../reactivity/signal'
4
- import type { SymbolSpec, DataFetcher } from '../controllers/types'
5
- import { DataBuffer } from '../data-fetchers/dataBuffer'
6
- import { getVisibleRange } from './viewport/viewport'
7
- import { Pane, type VisibleRange, UpdateLevel } from './layout/pane'
3
+ import { createSignal, type Signal, type Computed } from '../reactivity/signal'
4
+ import type { SymbolSpec } from '../controllers/types'
5
+ import { ChartDataManager } from './data/chartDataManager'
6
+ import { ChartPaneLayout } from './layout/chartPaneLayout'
7
+ import { UpdateLevel } from './layout/pane'
8
8
  import type { ScaleType } from './utils/tickPosition'
9
9
  import { InteractionController, type InteractionSnapshot } from './controller/interaction'
10
10
  export type { InteractionSnapshot }
11
+ import type { ChartDom, PaneSpec, ChartOptions, Viewport, ViewportState, IndicatorInstance, SubPaneInfo, DrawingToolType } from './chartTypes'
11
12
  import { PaneRenderer } from './paneRenderer'
12
13
  import { SharedWebGLSurface } from './renderers/webgl/sharedWebGLSurface'
13
- import { MarkerManager, type CustomMarkerEntity } from './marker/registry'
14
- import { getPhysicalKLineConfig, calcKWidthPx } from './utils/klineConfig'
15
- import { computeZoom, computeZoomToLevel, type ZoomConfig } from './utils/zoom'
16
- import { IndicatorScheduler } from './indicators/scheduler'
17
- import { getBuiltinIndicatorDefinitions } from './indicators/registerBuiltins'
18
- import { getRegisteredIndicatorDefinitions } from './indicators/indicatorDefinitionRegistry'
19
- import { SubPaneManager, type SubPaneEntry } from './subPaneManager'
14
+ import type { MarkerManager, CustomMarkerEntity } from './marker/registry'
15
+ import { getPhysicalKLineConfig } from './utils/klineConfig'
16
+ import { ChartZoomController } from './utils/chartZoomController'
17
+ import { ChartViewportManager } from './viewport/chartViewportManager'
18
+ import { ChartIndicatorManager } from './indicators/chartIndicatorManager'
19
+ import type { IndicatorScheduler } from './indicators/scheduler'
20
+ import type { SubPaneEntry } from './subPaneManager'
21
+ import { ChartRenderer } from './render/chartRenderer'
20
22
 
21
23
  import {
22
24
  createPluginHost,
@@ -24,159 +26,30 @@ import {
24
26
  RendererPluginManager,
25
27
  type RendererPlugin,
26
28
  type RendererPluginWithHost,
27
- type RenderContext,
28
29
  wrapPaneInfo,
29
- type PaneRole,
30
- type PaneCapabilities,
31
- type YAxisLabel,
32
- type XAxisLabel,
33
- type YAxisRange,
34
- type XAxisRange,
35
30
  } from '../plugin'
36
- import { createSubIndicatorRenderer, type SubIndicatorType } from './renderers/Indicator'
37
- import { createMainIndicatorLegendRendererPlugin } from './renderers/Indicator/mainIndicatorLegend'
38
- import { DrawingStore } from './drawing'
39
- import { createDrawingRendererPlugin, createDrawingLabelOverlayPlugin } from './drawing/plugin'
40
- import { createGridLinesRendererPlugin } from './renderers/gridLines'
41
- import { createCandleRenderer } from './renderers/candle'
42
- import { createComparisonLineRenderer } from './renderers/comparisonLine'
43
- import { createLastPriceLineRendererPlugin, createLastPriceLabelRegistrarPlugin } from './renderers/lastPrice'
44
- import { createCustomMarkersRenderer } from './renderers/customMarkers'
45
- import { createExtremaMarkersRendererPlugin } from './renderers/extremaMarkers'
46
- import { createYAxisRendererPlugin } from './renderers/yAxis'
47
- import { createCrosshairRendererPlugin } from './renderers/crosshair'
48
- import { createTimeAxisRendererPlugin } from './renderers/timeAxis'
31
+ import type { SubIndicatorType } from './renderers/Indicator'
49
32
 
50
33
 
51
34
  // 重新导出以保持向后兼容
52
- export { getPhysicalKLineConfig, calcKWidthPx }
53
-
54
- /**
55
- * 图表 DOM 元素引用
56
- * @property container 图表容器 div
57
- * @property canvasLayer Canvas 层容器 div(包含所有绘制 canvas)
58
- */
59
- /**
60
- * 图表 DOM 元素引用
61
- * @property container 图表容器 div
62
- * @property canvasLayer Canvas 层容器 div(包含所有绘制 canvas)
63
- * @property xAxisCanvas X 轴时间轴 canvas
64
- */
65
- export type ChartDom = {
66
- container: HTMLDivElement
67
- scrollContent?: HTMLDivElement
68
- canvasLayer: HTMLDivElement
69
- rightAxisLayer: HTMLDivElement
70
- xAxisCanvas: HTMLCanvasElement
71
- }
72
-
73
- /**
74
- * Pane 面板配置
75
- * @property id Pane 标识符
76
- * @property ratio Pane 高度占比
77
- * @property visible 是否可见(默认 true)
78
- */
79
- export type PaneSpec = {
80
- id: string
81
- ratio: number
82
- visible?: boolean
83
- minHeightPx?: number
84
- role?: PaneRole
85
- capabilities?: Partial<PaneCapabilities>
86
- }
87
-
88
- export type PaneRendererDom = {
89
- mainCanvas: HTMLCanvasElement
90
- overlayCanvas: HTMLCanvasElement
91
- yAxisCanvas: HTMLCanvasElement
92
- }
93
-
94
- export type ChartOptions = {
95
- /** K 线宽度(可选,由 zoomLevel 派生) */
96
- kWidth?: number
97
- /** K 线间隙(可选,由 DPR 计算) */
98
- kGap?: number
99
- yPaddingPx: number
100
- rightAxisWidth: number
101
- bottomAxisHeight: number
102
- minKWidth: number
103
- maxKWidth: number
104
- panes: PaneSpec[]
105
-
106
- /** pane 之间的真实分隔空隙(逻辑像素) */
107
- paneGap?: number
108
-
109
- /** 价格标签额外宽度(用于显示涨跌幅,默认 60px) */
110
- priceLabelWidth?: number
111
-
112
- /** pane 最小高度(逻辑像素,默认 60) */
113
- defaultPaneMinHeightPx?: number
114
-
115
- /**
116
- * 缩放级别数量(默认 10)
117
- * - 将 minKWidth ~ maxKWidth 划分为多少个离散级别
118
- * - 例如 10 表示有 10 个缩放级别(1-10)
119
- */
120
- zoomLevels?: number
121
-
122
- /**
123
- * 初始缩放级别(1 ~ zoomLevels,默认 1)
124
- * 未指定时默认为最小级别
125
- */
126
- initialZoomLevel?: number
127
- }
128
-
129
- /** K 线起始 x 坐标数组,positions[i] 表示第 i 根 K 线的起始 x 坐标(逻辑像素) */
130
- export type KLinePositions = number[]
131
-
132
- export type Viewport = {
133
- viewWidth: number
134
- viewHeight: number
135
- plotWidth: number
136
- plotHeight: number
137
- scrollLeft: number
138
- dpr: number
139
- }
35
+ export { getPhysicalKLineConfig }
36
+ export type { ChartDom, PaneSpec, PaneRendererDom, ChartOptions, KLinePositions, Viewport, ViewportState, IndicatorRole, IndicatorInstance, SubPaneInfo, DrawingToolType, DrawingObject } from './chartTypes'
140
37
 
141
38
  type ResolvedChartOptions = Omit<ChartOptions, 'kWidth' | 'kGap'> & {
142
39
  kWidth: number
143
40
  kGap: number
144
41
  }
145
42
 
146
- type FrameData = {
147
- vp: Viewport
148
- range: VisibleRange
149
- kLinePositions: KLinePositions
150
- kLineCenters: number[]
151
- kBarRects: Array<{ x: number; width: number }>
152
- kWidthPx: number
153
- useCachedFrame: boolean
154
- }
155
-
156
- /** 主图指标条目,存在 = 激活 */
157
- interface MainIndicatorEntry {
158
- params: Record<string, number | boolean | string>
159
- }
160
-
161
43
  export class Chart {
162
44
  private dom: ChartDom
163
45
  private opt: ResolvedChartOptions
164
- private _internalData: KLineData[] = []
165
- private _dataFetcher: DataFetcher | null = null
166
- private _dataBuffer: DataBuffer = new DataBuffer()
167
- private _dataBufferUnsub: (() => void) | null = null
168
- private _comparisonSpecs: SymbolSpec[] = []
169
- private _comparisonData: Map<string, KLineData[]> = new Map()
170
- private _comparisonBuffers: Map<string, DataBuffer> = new Map()
171
- private _comparisonBufferUnsubs: Map<string, () => void> = new Map()
172
-
173
- private raf: number | null = null
174
- private pendingUpdateLevel: UpdateLevel = UpdateLevel.All
175
- private _internalViewport: Viewport | null = null
176
-
177
- private paneRenderers: PaneRenderer[] = []
178
- private markerManager: MarkerManager
179
- private drawingStore = new DrawingStore()
46
+ private dataManager: ChartDataManager
47
+
48
+ private viewportManager: ChartViewportManager
49
+ private layoutManager: ChartPaneLayout
50
+ private get paneRenderers(): PaneRenderer[] {
51
+ return this.layoutManager.getPaneRenderers()
52
+ }
180
53
  readonly interaction: InteractionController
181
54
 
182
55
  /** 插件宿主 */
@@ -185,182 +58,17 @@ export class Chart {
185
58
  /** 渲染器插件管理器 */
186
59
  private rendererPluginManager: RendererPluginManager
187
60
 
188
- /** 精确 DPR(来自 ResizeObserver 的 devicePixelContentBoxSize) */
189
- private preciseDpr = 0
190
-
191
- /** 统一监听容器尺寸与 DPR 变化 */
192
- private resizeObserver?: ResizeObserver
193
-
194
- /** scroll 事件处理器引用(用于 cleanup) */
195
- private onScroll?: () => void
196
-
197
- /** 最近一次观测到的容器尺寸 */
198
- private observedSize = { width: 0, height: 0 }
199
-
200
- /** 缓存的 scrollLeft(通过 scroll 事件同步,避免每帧读取 DOM 触发强制回流) */
201
- private cachedScrollLeft = 0
202
-
203
- /** 左侧加载缓冲宽度;DOM scrollLeft 不能为负,用它映射出逻辑负滚动 */
204
- private leftLoadBufferWidth = 0
205
-
206
- /** 待写入 DOM 的 scrollLeft(在 RAF 回调中应用,确保 Vue 已完成 DOM 更新) */
207
- private _pendingScrollLeft: number | null = null
208
-
209
- private incrementalLoadHintEl: HTMLDivElement | null = null
210
- private incrementalLoadHintTimer: number | null = null
211
- private pendingPrependedCount = 0
212
-
213
- /** overlay 上一帧是否有十字线(用于判断何时需要清除) */
214
- private overlayHadCrosshair = false
215
-
216
- /** 用户设置配置(传递给渲染器) */
217
- private settings: ChartSettings = {}
218
-
219
- /** pane ratio 状态(按 paneId 维护,sum=1 仅对可见 pane) */
220
- private _internalPaneRatios: Map<string, number> = new Map()
221
-
222
- /** 共享 X 轴上下文缓存 */
223
- private xAxisCtx: CanvasRenderingContext2D | null = null
224
-
225
61
  /** Chart 级共享 WebGL canvas/context */
226
62
  private sharedWebGLSurface: SharedWebGLSurface
227
63
 
228
- private getScrollContentHost(): HTMLDivElement | null {
229
- return this.dom.scrollContent ?? this.dom.container ?? null
230
- }
231
-
232
- private ensureIncrementalLoadHint(): HTMLDivElement | null {
233
- const host = this.getScrollContentHost()
234
- if (!host) return null
235
-
236
- if (this.incrementalLoadHintEl && this.incrementalLoadHintEl.isConnected) {
237
- return this.incrementalLoadHintEl
238
- }
239
-
240
- const ownerDoc = host.ownerDocument
241
- if (!ownerDoc) return null
242
-
243
- const hint = ownerDoc.createElement('div')
244
- hint.className = 'klc-incremental-load-hint'
245
- hint.style.position = 'absolute'
246
- hint.style.left = '0'
247
- hint.style.top = '0'
248
- hint.style.height = '0px'
249
- hint.style.width = '0px'
250
- hint.style.pointerEvents = 'none'
251
- hint.style.opacity = '0'
252
- hint.style.filter = 'blur(10px)'
253
- hint.style.transition = 'opacity 420ms ease, filter 420ms ease'
254
- hint.style.background = 'rgba(71, 91, 132, 0.5)'
255
- hint.style.zIndex = '3'
256
- hint.style.willChange = 'opacity, filter, width'
257
- host.appendChild(hint)
258
- this.incrementalLoadHintEl = hint
259
- return hint
260
- }
261
-
262
- private clearIncrementalLoadHintTimer(): void {
263
- if (this.incrementalLoadHintTimer !== null) {
264
- window.clearTimeout(this.incrementalLoadHintTimer)
265
- this.incrementalLoadHintTimer = null
266
- }
267
- }
268
-
269
- private hideIncrementalLoadHint(): void {
270
- const hint = this.incrementalLoadHintEl
271
- if (!hint) return
272
- hint.style.opacity = '0'
273
- hint.style.filter = 'blur(10px)'
274
- }
275
-
276
- private showIncrementalLoadHint(count: number): void {
277
- if (count <= 0) return
278
- const hint = this.ensureIncrementalLoadHint()
279
- if (!hint) return
280
-
281
- this.clearIncrementalLoadHintTimer()
282
-
283
- const dpr = this.getEffectiveDpr()
284
- const { unitPx, startXPx } = getPhysicalKLineConfig(this.opt.kWidth, this.opt.kGap, dpr)
285
- const width = this.getLeftLoadBufferWidth() + (startXPx + count * unitPx) / dpr
286
- hint.style.width = `${Math.max(0, width)}px`
287
- hint.style.height = `${Math.max(
288
- 0,
289
- this._internalViewport?.viewHeight ?? this.dom.container?.clientHeight ?? 0,
290
- )}px`
291
-
292
- hint.getBoundingClientRect()
293
- hint.style.opacity = '1'
294
- hint.style.filter = 'blur(0px)'
64
+ /** 缩放控制器 */
65
+ private zoomController: ChartZoomController
295
66
 
296
- this.incrementalLoadHintTimer = window.setTimeout(() => {
297
- this.hideIncrementalLoadHint()
298
- this.incrementalLoadHintTimer = null
299
- }, 900)
300
- }
301
-
302
- /** 当前缩放级别(1 ~ zoomLevelCount) */
303
- private currentZoomLevel: number = 1
67
+ /** 指标管理器 */
68
+ private indicatorManager: ChartIndicatorManager
304
69
 
305
- /** 缩放级别总数 */
306
- private readonly zoomLevelCount: number
307
-
308
- /** 指标调度器(负责计算 MA 等指标并写入 StateStore)
309
- * TODO: 阶段5迁移为插件注册,Scheduler 通过事件监听 data/viewport 变更,Chart 不直接持有
310
- */
311
- private indicatorScheduler: IndicatorScheduler
312
-
313
- /** 数据已更新但 Worker 指标尚未回写,期间避免用旧指标 state 绘制中间帧 */
314
- private pendingIndicatorDataUpdate = false
315
-
316
- /** 上次可见范围(用于检测视口变化) */
317
- private lastVisibleRange: VisibleRange = { start: 0, end: 0 }
318
-
319
- /** 原始可见范围可为负数,仅用于判断左侧空白区加载 */
320
- private lastRawVisibleRange: VisibleRange = { start: 0, end: 0 }
321
-
322
- /** Overlay 帧复用的最近主渲染结果 */
323
- private cachedDrawFrame: {
324
- viewport: Viewport
325
- range: VisibleRange
326
- kLinePositions: KLinePositions
327
- kLineCenters: number[]
328
- kBarRects: Array<{ x: number; width: number }>
329
- kWidthPx: number
330
- } | null = null
331
-
332
- /** 副图管理器 */
333
- private subPaneManager = new SubPaneManager()
334
-
335
- /** 主图指标激活状态与参数(存在即激活,默认参数在 enable 时初始化) */
336
- private _mainIndicatorsSignal: Signal<Map<string, MainIndicatorEntry>> = createSignal<Map<string, MainIndicatorEntry>>(new Map())
337
-
338
- /** 主图指标默认参数(从注册表中懒加载) */
339
- private static _defaultMainParamsCache: Record<string, Record<string, number | boolean | string>> | null = null
340
-
341
- private static get DEFAULT_MAIN_PARAMS(): Record<string, Record<string, number | boolean | string>> {
342
- if (Chart._defaultMainParamsCache === null) {
343
- Chart._defaultMainParamsCache = {}
344
- for (const def of getRegisteredIndicatorDefinitions()) {
345
- if (def.category === 'main') {
346
- Chart._defaultMainParamsCache[def.displayName.toUpperCase()] = (def.runtime?.defaultConfig ?? {}) as Record<string, number | boolean | string>
347
- }
348
- }
349
- }
350
- return Chart._defaultMainParamsCache
351
- }
352
-
353
- /** 可启用的主图指标白名单(从注册表中懒加载) */
354
- private static _enableMainIndicatorsCache: string[] | null = null
355
-
356
- private static get ENABLE_MAIN_INDICATORS(): string[] {
357
- if (Chart._enableMainIndicatorsCache === null) {
358
- Chart._enableMainIndicatorsCache = getRegisteredIndicatorDefinitions()
359
- .filter(d => d.category === 'main')
360
- .map(d => d.displayName.toUpperCase())
361
- }
362
- return Chart._enableMainIndicatorsCache
363
- }
70
+ /** 渲染器 */
71
+ private renderer: ChartRenderer
364
72
 
365
73
  /**
366
74
  * 启用主图指标
@@ -369,216 +77,42 @@ export class Chart {
369
77
  * @returns 是否成功启用
370
78
  */
371
79
  enableMainIndicator(indicatorId: string, params?: Record<string, number | boolean | string>): boolean {
372
- const id = indicatorId.toUpperCase()
373
- if (!Chart.ENABLE_MAIN_INDICATORS.includes(id)) {
374
- console.warn(`[Chart] 未知的主图指标: ${indicatorId}`)
375
- return false
376
- }
377
-
378
- const map = this._mainIndicatorsSignal.peek()
379
- const existing = map.get(id)
380
-
381
- if (existing) {
382
- // 已启用,更新参数
383
- if (params) {
384
- const next = new Map(map)
385
- next.set(id, { params: { ...existing.params, ...params } })
386
- this._mainIndicatorsSignal.set(next)
387
- this.updateIndicatorSchedulerConfig(id)
388
- }
389
- return true
390
- }
391
-
392
- // 合并默认参数和传入参数
393
- const defaults = Chart.DEFAULT_MAIN_PARAMS[id] ?? {}
394
- const merged = params ? { ...defaults, ...params } : defaults
395
- const next = new Map(map)
396
- next.set(id, { params: merged })
397
- this._mainIndicatorsSignal.set(next)
398
-
399
- // 启用对应的渲染器
400
- this.enableMainIndicatorRenderer(id)
401
-
402
- // 更新调度器配置(触发异步重算)
403
- this.updateIndicatorSchedulerConfig(id)
404
-
405
- // 同步重算主图状态:latestResult 已有该指标的 series,只是没注册到 registry
406
- // 补调 updateVisibleRange 使其走 updateVisibleStatesOnly,立即从 latestResult 合成极值
407
- this.indicatorScheduler.updateVisibleRange(this.lastVisibleRange)
408
-
409
- this.scheduleDraw()
410
- return true
80
+ return this.indicatorManager.enableMainIndicator(indicatorId, params)
411
81
  }
412
82
 
413
- /**
414
- * 禁用主图指标
415
- * @param indicatorId 指标ID
416
- * @returns 是否成功禁用
417
- */
418
83
  disableMainIndicator(indicatorId: string): boolean {
419
- const id = indicatorId.toUpperCase()
420
- const map = this._mainIndicatorsSignal.peek()
421
- if (!map.has(id)) return false
422
-
423
- const next = new Map(map)
424
- next.delete(id)
425
- this._mainIndicatorsSignal.set(next)
426
-
427
- // 禁用对应的渲染器
428
- this.disableMainIndicatorRenderer(id)
429
-
430
- // 更新调度器配置
431
- this.updateIndicatorSchedulerConfig(id)
432
-
433
- this.scheduleDraw()
434
- return true
84
+ return this.indicatorManager.disableMainIndicator(indicatorId)
435
85
  }
436
86
 
437
- /**
438
- * 切换主图指标启用状态
439
- * @param indicatorId 指标ID
440
- * @param enabled 是否启用
441
- */
442
87
  toggleMainIndicator(indicatorId: string, enabled: boolean): void {
443
- if (enabled) {
444
- this.enableMainIndicator(indicatorId)
445
- } else {
446
- this.disableMainIndicator(indicatorId)
447
- }
88
+ this.indicatorManager.toggleMainIndicator(indicatorId, enabled)
448
89
  }
449
90
 
450
- /**
451
- * 获取当前激活的主图指标列表
452
- * @returns 激活的指标ID数组
453
- */
454
91
  getActiveMainIndicators(): string[] {
455
- return [...this._mainIndicatorsSignal.peek().keys()]
92
+ return this.indicatorManager.getActiveMainIndicators()
456
93
  }
457
94
 
458
- /**
459
- * 检查主图指标是否激活
460
- * @param indicatorId 指标ID
461
- */
462
95
  isMainIndicatorActive(indicatorId: string): boolean {
463
- return this._mainIndicatorsSignal.peek().has(indicatorId.toUpperCase())
96
+ return this.indicatorManager.isMainIndicatorActive(indicatorId)
464
97
  }
465
98
 
466
- /**
467
- * 更新主图指标参数
468
- * @param indicatorId 指标ID
469
- * @param params 参数对象
470
- */
471
99
  updateMainIndicatorParams(indicatorId: string, params: Record<string, number | boolean | string>): void {
472
- const id = indicatorId.toUpperCase()
473
- const map = this._mainIndicatorsSignal.peek()
474
- const entry = map.get(id)
475
- if (!entry) return
476
-
477
- const merged = { ...entry.params, ...params }
478
- const next = new Map(map)
479
- next.set(id, { params: merged })
480
- this._mainIndicatorsSignal.set(next)
481
-
482
- // 同步更新渲染器配置
483
- const rendererName = id.toLowerCase()
484
- const renderer = this.getRenderer(rendererName)
485
- if (renderer && renderer.setConfig) {
486
- renderer.setConfig(merged)
487
- }
488
-
489
- // 更新调度器
490
- this.updateIndicatorSchedulerConfig(id)
491
- this.scheduleDraw()
100
+ this.indicatorManager.updateMainIndicatorParams(indicatorId, params)
492
101
  }
493
102
 
494
- /**
495
- * 获取主图指标参数
496
- * @param indicatorId 指标ID
497
- */
498
103
  getMainIndicatorParams(indicatorId: string): Record<string, number | boolean | string> | null {
499
- return this._mainIndicatorsSignal.peek().get(indicatorId.toUpperCase())?.params ?? null
104
+ return this.indicatorManager.getMainIndicatorParams(indicatorId)
500
105
  }
501
106
 
502
- /**
503
- * 清除所有主图指标
504
- */
505
107
  clearMainIndicators(): void {
506
- const map = this._mainIndicatorsSignal.peek()
507
- for (const id of map.keys()) {
508
- this.disableMainIndicatorRenderer(id)
509
- }
510
- this._mainIndicatorsSignal.set(new Map())
511
- this.scheduleDraw()
512
- }
513
-
514
- /**
515
- * 启用主图指标渲染器(内部方法)
516
- */
517
- private enableMainIndicatorRenderer(indicatorId: string): void {
518
- const definition = this.indicatorScheduler.getIndicatorMetadata(indicatorId)
519
- const mainPane = definition?.mainPane
520
- if (!definition || !mainPane) return
521
-
522
- if (!this.getRenderer(mainPane.rendererName)) {
523
- this.useRenderer(definition.rendererFactory({ paneId: 'main', indicatorId }))
524
- }
525
-
526
- this.setRendererEnabled(mainPane.rendererName, true)
527
-
528
- if (!this.getRenderer('mainIndicatorLegend')) {
529
- this.useRenderer(createMainIndicatorLegendRendererPlugin({ yPaddingPx: this.opt.yPaddingPx }))
530
- }
531
- }
532
-
533
- /**
534
- * 禁用主图指标渲染器(内部方法)
535
- */
536
- private disableMainIndicatorRenderer(indicatorId: string): void {
537
- const rendererName = this.indicatorScheduler.getIndicatorMetadata(indicatorId)?.mainPane?.rendererName
538
- if (rendererName) {
539
- this.setRendererEnabled(rendererName, false)
540
- }
541
- }
542
-
543
- /**
544
- * 更新调度器配置(内部方法)
545
- */
546
- private updateIndicatorSchedulerConfig(indicatorId: string): void {
547
- const entry = this._mainIndicatorsSignal.peek().get(indicatorId)
548
- const isActive = entry !== undefined
549
- const params = entry?.params ?? {}
550
-
551
- const definition = this.indicatorScheduler.getIndicatorMetadata(indicatorId)
552
- const toActiveConfig = definition?.mainPane?.toActiveConfig
553
- if (!definition?.updateConfig || !toActiveConfig) return
554
-
555
- const config = toActiveConfig(params, isActive)
556
- if (config !== null) {
557
- definition.updateConfig(this.indicatorScheduler, config, 'main')
558
- }
108
+ this.indicatorManager.clearMainIndicators()
559
109
  }
560
110
 
561
111
  /**
562
112
  * @deprecated 使用 enableMainIndicator/disableMainIndicator 替代
563
113
  */
564
114
  setActiveMainIndicators(indicators: string[]): void {
565
- // 计算需要启用和禁用的指标
566
- const newSet = new Set(indicators.map(i => i.toUpperCase()))
567
- const currentSet = new Set(this._mainIndicatorsSignal.peek().keys())
568
-
569
- // 禁用不再激活的
570
- for (const id of currentSet) {
571
- if (!newSet.has(id)) {
572
- this.disableMainIndicator(id)
573
- }
574
- }
575
-
576
- // 启用新激活的
577
- for (const id of newSet) {
578
- if (!currentSet.has(id)) {
579
- this.enableMainIndicator(id)
580
- }
581
- }
115
+ this.indicatorManager.setActiveMainIndicators(indicators)
582
116
  }
583
117
 
584
118
  /**
@@ -595,7 +129,6 @@ export class Chart {
595
129
  this.interaction.setOnInteractionChange((snapshot) => {
596
130
  this._interactionSignal.set(snapshot)
597
131
  })
598
- this.markerManager = new MarkerManager()
599
132
  this.pluginHost = createPluginHost()
600
133
  this.rendererPluginManager = new RendererPluginManager()
601
134
  this.sharedWebGLSurface = new SharedWebGLSurface()
@@ -604,189 +137,140 @@ export class Chart {
604
137
  this.rendererPluginManager.setPluginHost(this.pluginHost)
605
138
  this.rendererPluginManager.setInvalidateCallback(() => this.scheduleDraw())
606
139
 
607
- this.syncPaneRatiosFromSpecs(this.opt.panes)
608
-
609
- // 缩放级别由外部 SSOT 管理,Chart 只接收不计算
610
- this.zoomLevelCount = Math.max(2, Math.round(this.opt.zoomLevels ?? 20))
611
- this.currentZoomLevel = this.opt.initialZoomLevel ?? 1
612
- this.currentZoomLevel = Math.max(1, Math.min(this.zoomLevelCount, this.currentZoomLevel))
613
- // 注意:初始 kWidth/kGap 应由外部通过 applyRenderState() 传入
614
-
615
- // 初始化指标调度器
616
- this.indicatorScheduler = new IndicatorScheduler()
617
- this.indicatorScheduler.setPluginHost(this.pluginHost)
618
- for (const definition of getBuiltinIndicatorDefinitions()) {
619
- this.indicatorScheduler.registerIndicator(definition)
620
- }
621
- this.indicatorScheduler.setInvalidateCallback(() => {
622
- this.pendingIndicatorDataUpdate = false
623
- this.scheduleDraw()
140
+ this.viewportManager = new ChartViewportManager({
141
+ getDom: () => this.dom,
142
+ getBottomAxisHeight: () => this.opt.bottomAxisHeight,
143
+ getLeftLoadBufferWidth: () => this.dataManager.getLeftLoadBufferWidth(),
144
+ getZoomLevel: () => this.zoomController.currentZoomLevel,
145
+ getLastVisibleRange: () => this.dataManager.lastVisibleRange,
146
+ getKWidth: () => this.opt.kWidth,
147
+ getKGap: () => this.opt.kGap,
148
+ scheduleDraw: (level) => this.scheduleDraw(level),
149
+ onResizeCompleted: () => { this.resize() },
150
+ resizeSharedWebGLSurface: (plotWidth, plotHeight, dpr) => this.sharedWebGLSurface.resize(plotWidth, plotHeight, dpr),
624
151
  })
625
152
 
626
- // 注册副图活跃列表提供者,调度器据此只计算启用的副图
627
- this.indicatorScheduler.setActiveSubPaneProvider(
628
- () => this.subPaneManager.getPaneIds(),
629
- )
630
-
631
- this.initPanes()
632
-
633
- // dev: 主副图状态变更日志
634
- if ((import.meta as any).env?.MODE !== 'production') {
635
- this._indicatorsComputed.subscribe(() => {
636
- const instances = this._indicatorsComputed.peek()
637
- console.log('[Chart] indicators signal changed:', instances)
638
- })
639
- this._subPanesComputed.subscribe(() => {
640
- const subPanes = this._subPanesComputed.peek()
641
- console.log('[Chart] subPanes signal changed:', subPanes)
642
- })
643
- }
644
-
645
- // 注册绘图主插件(负责绘制 shape,layer: 'main')
646
- this.useRenderer(createDrawingRendererPlugin({ store: this.drawingStore }))
647
- // 注册绘图标签插件(负责推送选中绘图的轴标签,layer: 'overlay')
648
- // 注意:此插件依赖 overlay 更新级别,若将来添加 Main 级别需调整
649
- this.useRenderer(createDrawingLabelOverlayPlugin({ store: this.drawingStore }))
650
- this.initCoreRenderers()
651
- this.initResizeObserver()
652
- }
653
-
654
-
655
- private initCoreRenderers(): void {
656
- const axisWidth = this.opt.rightAxisWidth + (this.opt.priceLabelWidth ?? 0)
657
-
658
- this.useRenderer(createGridLinesRendererPlugin())
659
- this.useRenderer(createCandleRenderer())
660
- this.useRenderer(createComparisonLineRenderer())
661
- this.useRenderer(createLastPriceLineRendererPlugin())
662
- this.useRenderer(createLastPriceLabelRegistrarPlugin())
663
- this.useRenderer(createCustomMarkersRenderer())
664
- this.useRenderer(createExtremaMarkersRendererPlugin())
665
- this.useRenderer(createMainIndicatorLegendRendererPlugin({
666
- yPaddingPx: this.opt.yPaddingPx,
667
- }))
668
- this.useRenderer(createYAxisRendererPlugin({
669
- axisWidth,
670
- yPaddingPx: this.opt.yPaddingPx,
671
- getCrosshair: () => {
672
- const pos = this.interaction.crosshairPos
673
- const price = this.interaction.crosshairPrice
674
- const activePaneId = this.interaction.activePaneId
675
- if (pos && price !== null) {
676
- return { y: pos.y, price, activePaneId }
677
- }
678
- return null
679
- },
680
- }))
681
- this.useRenderer(createCrosshairRendererPlugin({
682
- getCrosshairState: () => ({
683
- pos: this.interaction.crosshairPos,
684
- activePaneId: this.interaction.activePaneId,
685
- isDragging: this.interaction.isDraggingState(),
686
- price: this.interaction.crosshairPrice,
153
+ this.layoutManager = new ChartPaneLayout(this.opt.panes, {
154
+ getDom: () => this.dom,
155
+ getOption: () => ({
156
+ rightAxisWidth: this.opt.rightAxisWidth,
157
+ yPaddingPx: this.opt.yPaddingPx,
158
+ priceLabelWidth: this.opt.priceLabelWidth,
159
+ paneGap: this.opt.paneGap,
160
+ defaultPaneMinHeightPx: this.opt.defaultPaneMinHeightPx,
687
161
  }),
688
- }))
689
- this.useRenderer(createTimeAxisRendererPlugin({
690
- height: this.opt.bottomAxisHeight,
691
- getCrosshair: () => {
692
- const pos = this.interaction.crosshairPos
693
- const idx = this.interaction.crosshairIndex
694
- if (pos && idx !== null) {
695
- return { x: pos.x, index: idx }
696
- }
697
- return null
162
+ getViewport: () => this.viewportManager.getViewport(),
163
+ getSharedWebGLSurface: () => this.sharedWebGLSurface,
164
+ setKnownPaneIds: (ids) => this.rendererPluginManager.setKnownPaneIds(ids),
165
+ notifyPaneResize: (paneId, pane) => this.rendererPluginManager.notifyResize(paneId, wrapPaneInfo(pane)),
166
+ scheduleDraw: (level) => this.scheduleDraw(level),
167
+ onLayoutChange: (ratios, specs) => {
168
+ this._paneRatiosSignal.set(ratios)
169
+ this._paneLayoutSignal.set(specs)
170
+ this.opt = { ...this.opt, panes: specs }
698
171
  },
699
- }))
700
- }
701
-
702
-
703
- private initResizeObserver() {
704
- if (typeof ResizeObserver === 'undefined') return
705
-
706
- const target = this.dom.container
707
- if (!target) return
708
-
709
- // 初始化 scrollLeft 缓存
710
- this.cachedScrollLeft = target.scrollLeft
711
- this.onScroll = () => { this.cachedScrollLeft = target.scrollLeft }
712
- target.addEventListener('scroll', this.onScroll, { passive: true })
713
-
714
- this.resizeObserver = new ResizeObserver((entries) => {
715
- const entry = entries[0]
716
- if (!entry) return
717
-
718
- const prevWidth = this.observedSize.width
719
- const prevHeight = this.observedSize.height
720
- const prevDpr = this.preciseDpr
721
-
722
- this.updateObservedMetrics(entry)
723
-
724
- const widthChanged = this.observedSize.width !== prevWidth
725
- const heightChanged = this.observedSize.height !== prevHeight
726
- const dprChanged = this.preciseDpr !== prevDpr
727
- if ((import.meta as any).env?.MODE !== 'production') {
728
- console.log(
729
- `[Chart] resize observer: ` +
730
- `size ${prevWidth}x${prevHeight} -> ${this.observedSize.width}x${this.observedSize.height} ` +
731
- `dpr ${prevDpr} -> ${this.preciseDpr} ` +
732
- `changed: ${widthChanged || heightChanged ? 'size' : ''}${widthChanged || heightChanged && dprChanged ? '+' : ''}${dprChanged ? 'dpr' : ''}`
733
- )
734
- }
735
- if (widthChanged || heightChanged || dprChanged) {
736
- this.resize()
737
- }
738
172
  })
739
173
 
740
- try {
741
- this.resizeObserver.observe(target, { box: 'device-pixel-content-box' as ResizeObserverBoxOptions })
742
- } catch {
743
- this.resizeObserver.observe(target)
744
- }
745
- }
746
-
747
-
174
+ this.dataManager = new ChartDataManager({
175
+ getOption: () => this.opt,
176
+ getEffectiveDpr: () => this.viewportManager.getEffectiveDpr(),
177
+ getLogicalScrollLeft: () => this.viewportManager.getLogicalScrollLeft(),
178
+ getCachedScrollLeft: () => this.viewportManager.getCachedScrollLeft(),
179
+ setCachedScrollLeft: (v) => { this.viewportManager.setCachedScrollLeft(v) },
180
+ setPendingScrollLeft: (v) => { this.viewportManager.setPendingScrollLeft(v) },
181
+ getDom: () => this.dom,
182
+ getObservedSize: () => this.viewportManager.getObservedSize(),
183
+ getViewport: () => this.viewportManager.getViewport(),
184
+ scheduleDraw: (level) => this.scheduleDraw(level),
185
+ resetInteraction: () => this.interaction.reset(),
186
+ getIndicatorScheduler: () => this.indicatorManager.indicatorSchedulerAccessor,
187
+ setPendingIndicatorDataUpdate: (v) => { this.dataManager.pendingIndicatorDataUpdate = v },
188
+ isPointerDown: () => this.interaction.isPointerDown(),
189
+ })
748
190
 
749
- private updateObservedMetrics(entry: ResizeObserverEntry) {
750
- const cssWidth = Math.max(1, Math.round(entry.contentRect.width))
751
- const cssHeight = Math.max(1, Math.round(entry.contentRect.height))
752
- this.observedSize.width = cssWidth
753
- this.observedSize.height = cssHeight
191
+ this.zoomController = new ChartZoomController({
192
+ getLogicalScrollLeft: () => this.viewportManager.getLogicalScrollLeft(),
193
+ getCurrentDpr: () => this.viewportManager.getEffectiveDpr(),
194
+ getLeftLoadBufferWidth: () => this.dataManager.getLeftLoadBufferWidth(),
195
+ setScrollLeft: (v) => { this.viewportManager.setScrollLeft(v) },
196
+ onZoomCommitted: (result) => {
197
+ this.opt = { ...this.opt, kWidth: result.kWidth, kGap: result.kGap }
198
+ this.updateViewportSignal()
199
+ this.scheduleDraw()
200
+ },
201
+ getKWidth: () => this.opt.kWidth,
202
+ getKGap: () => this.opt.kGap,
203
+ getMinKWidth: () => this.opt.minKWidth,
204
+ getMaxKWidth: () => this.opt.maxKWidth,
205
+ zoomLevelCount: Math.max(2, Math.round(this.opt.zoomLevels ?? 20)),
206
+ initialZoomLevel: this.opt.initialZoomLevel ?? 1,
207
+ })
208
+ // 注意:初始 kWidth/kGap 应由外部通过 applyRenderState() 传入
754
209
 
755
- const pixelSize = entry.devicePixelContentBoxSize?.[0]
756
- const cssSize = entry.contentBoxSize?.[0]
757
- if (!pixelSize || !cssSize || cssSize.inlineSize <= 0) {
758
- this.preciseDpr = 0
759
- return
760
- }
210
+ // 初始化指标管理器
211
+ this.indicatorManager = new ChartIndicatorManager({
212
+ getOption: () => this.opt,
213
+ getPluginHost: () => this.pluginHost,
214
+ getRenderer: (name) => this.getRenderer(name),
215
+ useRenderer: (plugin, config) => this.useRenderer(plugin, config),
216
+ removeRenderer: (name) => this.removeRenderer(name),
217
+ updateRendererConfig: (name, config) => this.updateRendererConfig(name, config),
218
+ setRendererEnabled: (name, enabled) => this.setRendererEnabled(name, enabled),
219
+ hasPane: (paneId) => this.layoutManager.hasPane(paneId),
220
+ upsertPane: (def) => this.layoutManager.upsertPane(def),
221
+ removePaneDefinition: (paneId) => this.layoutManager.removePaneDefinition(paneId),
222
+ getPaneSpecs: () => this.layoutManager.getPaneSpecs(),
223
+ getPaneRatiosSignal: () => this._paneRatiosSignal,
224
+ getInternalPaneRatios: () => this.layoutManager.getInternalPaneRatios(),
225
+ setInternalPaneRatio: (paneId, ratio) => this.layoutManager.setInternalPaneRatio(paneId, ratio),
226
+ deleteInternalPaneRatio: (paneId) => this.layoutManager.deleteInternalPaneRatio(paneId),
227
+ applyPaneLayoutSpecs: (specs) => this.layoutManager.applyPaneLayoutSpecs(specs),
228
+ getLastVisibleRange: () => this.dataManager.lastVisibleRange,
229
+ getCrosshairPos: () => this.interaction.crosshairPos,
230
+ getCrosshairPrice: () => this.interaction.crosshairPrice,
231
+ getActivePaneId: () => this.interaction.activePaneId,
232
+ scheduleDraw: (level) => this.scheduleDraw(level),
233
+ setPendingIndicatorDataUpdate: (v) => { this.dataManager.pendingIndicatorDataUpdate = v },
234
+ })
761
235
 
762
- const raw = pixelSize.inlineSize / cssSize.inlineSize
763
- this.preciseDpr = Math.round(raw * 64) / 64
236
+ // 初始化渲染器
237
+ this.renderer = new ChartRenderer({
238
+ getDom: () => this.dom,
239
+ getOption: () => this.opt,
240
+ getPaneRenderers: () => this.paneRenderers,
241
+ getInteraction: () => this.interaction,
242
+ getSharedWebGLSurface: () => this.sharedWebGLSurface,
243
+ getPluginHost: () => this.pluginHost,
244
+ getRendererPluginManager: () => this.rendererPluginManager,
245
+ getTheme: () => this._themeSignal.peek(),
246
+ getCurrentZoomLevel: () => this.zoomController.currentZoomLevel,
247
+ getZoomLevelCount: () => this.zoomController.zoomLevelCount,
248
+ getViewportManager: () => this.viewportManager,
249
+ getDataManager: () => this.dataManager,
250
+ getIndicatorManager: () => this.indicatorManager,
251
+ })
252
+ this.renderer.registerDrawingPlugins()
253
+ this.renderer.initCoreRenderers()
254
+ this.viewportManager.init()
764
255
  }
765
256
 
766
- private getEffectiveDpr(): number {
767
- let dpr = this.preciseDpr > 0
768
- ? this.preciseDpr
769
- : Math.round((window.devicePixelRatio || 1) * 64) / 64
770
- if (dpr < 1) dpr = 1
771
- return dpr
772
- }
773
257
 
774
258
  getViewport(): Viewport | null {
775
- return this._internalViewport
259
+ return this.viewportManager.getViewport()
776
260
  }
777
261
 
778
262
  getCurrentDpr(): number {
779
- return this.getEffectiveDpr()
263
+ return this.viewportManager.getEffectiveDpr()
780
264
  }
781
265
 
782
266
  /** 获取缓存的 scrollLeft(避免读取 DOM 触发强制回流) */
783
267
  getCachedScrollLeft(): number {
784
- return this.cachedScrollLeft
268
+ return this.viewportManager.getCachedScrollLeft()
785
269
  }
786
270
 
787
271
  /** 获取逻辑 scrollLeft(减去左侧加载缓冲宽度,可为负值) */
788
272
  getLogicalScrollLeft(): number {
789
- return this.cachedScrollLeft - this.getLeftLoadBufferWidth()
273
+ return this.viewportManager.getLogicalScrollLeft()
790
274
  }
791
275
 
792
276
  /** 获取插件宿主 */
@@ -831,15 +315,20 @@ export class Chart {
831
315
 
832
316
  /** 更新用户设置(触发重绘) */
833
317
  updateSettings(settings: ChartSettings): void {
834
- this.settings = { ...settings }
318
+ this.renderer.updateSettings(settings)
835
319
  this.interaction.updateSettings(settings)
836
320
 
837
321
  // 同步刻度类型设置到所有 pane(百分比仅用于主图)
838
- const axisType = (settings.axisType as ScaleType) ?? 'linear'
839
- for (const renderer of this.paneRenderers) {
840
- const pane = renderer.getPane()
841
- const scaleType = axisType === 'percent' && pane.role !== 'price' ? 'linear' : axisType
842
- pane.yAxis.setScaleType(scaleType)
322
+ if ('axisType' in settings) {
323
+ const axisType = (settings.axisType as ScaleType) ?? 'linear'
324
+ const currentType = this.paneRenderers[0]?.getPane().yAxis.getScaleType()
325
+ if (axisType !== currentType) {
326
+ for (const renderer of this.paneRenderers) {
327
+ const pane = renderer.getPane()
328
+ const scaleType = axisType === 'percent' && pane.role !== 'price' ? 'linear' : axisType
329
+ pane.yAxis.setScaleType(scaleType)
330
+ }
331
+ }
843
332
  }
844
333
 
845
334
  this.scheduleDraw()
@@ -850,321 +339,10 @@ export class Chart {
850
339
  * @param level 更新级别,决定渲染哪些层
851
340
  */
852
341
  draw(level: UpdateLevel = UpdateLevel.All) {
853
- // 1. 重置 Marker 标记
854
- this.markerManager.clear()
855
-
856
- // 2. 准备帧数据(视口 / 可见范围 / K 线坐标,优先走缓存)
857
- const frame = this.prepareFrameData(level)
858
- if (!frame) {
859
- if (this._internalData.length === 0) this.clearAllCanvases()
860
- return
861
- }
862
-
863
- const { vp, range, kLinePositions, kLineCenters, kBarRects, kWidthPx, useCachedFrame } = frame
864
-
865
- // 3. 更新交互控制器坐标映射
866
- this.interaction.setKLinePositions(kLinePositions, range, kWidthPx)
867
-
868
- // 4. 通知调度器当前活跃主图指标 + 获取价格范围
869
- this.indicatorScheduler.setActiveMainIndicators(
870
- [...this._mainIndicatorsSignal.peek().entries()].map(([id, entry]) => ({ id, params: entry.params })),
871
- )
872
- const mainIndicatorRange = useCachedFrame ? null : this.indicatorScheduler.getMainIndicatorPriceRange()
873
- const hasCrosshair = this.interaction.getCrosshairIndex() !== null
874
-
875
- // 5. 遍历所有 Pane 渲染主层 / overlay / Y 轴
876
- const { sharedXAxisLabels, sharedXAxisRanges } = this.renderPanes(
877
- vp, range, kLinePositions, kLineCenters, kBarRects, kWidthPx,
878
- mainIndicatorRange, hasCrosshair, useCachedFrame, level,
879
- )
880
-
881
- // 6. 持久化十字线状态供下帧判断清除
882
- this.overlayHadCrosshair = hasCrosshair
883
-
884
- // 7. 渲染 X 轴时间轴
885
- this.renderXAxis(vp, range, kLinePositions, kLineCenters, kBarRects, kWidthPx, sharedXAxisLabels, sharedXAxisRanges)
886
- }
887
-
888
- private prepareFrameData(level: UpdateLevel): FrameData | null {
889
- const useCachedFrame = level === UpdateLevel.Overlay && this.cachedDrawFrame !== null
890
-
891
- const vp = useCachedFrame ? this.cachedDrawFrame!.viewport : this.computeViewport()
892
- if (!vp) return null
893
-
894
- if (this._internalData.length === 0) return null
895
-
896
- const rawRange = useCachedFrame
897
- ? this.cachedDrawFrame!.range
898
- : (() => {
899
- const { start, end } = getVisibleRange(
900
- vp.scrollLeft,
901
- vp.plotWidth,
902
- this.opt.kWidth,
903
- this.opt.kGap,
904
- this._internalData.length,
905
- vp.dpr
906
- )
907
- return { start, end }
908
- })()
909
- const range = { start: Math.max(0, rawRange.start), end: rawRange.end }
910
-
911
- if (!useCachedFrame && (
912
- range.start !== this.lastVisibleRange.start
913
- || range.end !== this.lastVisibleRange.end
914
- || rawRange.start !== this.lastRawVisibleRange.start
915
- || rawRange.end !== this.lastRawVisibleRange.end
916
- )) {
917
- this.indicatorScheduler.updateVisibleRange(range)
918
- this.lastVisibleRange = range
919
- this.lastRawVisibleRange = rawRange
920
- this.checkVisibleRangeGapWhenIdle()
921
- }
922
-
923
- const kLinePositions = useCachedFrame
924
- ? this.cachedDrawFrame!.kLinePositions
925
- : this.calcKLinePositions(range)
926
-
927
- let kLineCenters: number[]
928
- let kBarRects: Array<{ x: number; width: number }>
929
- let kWidthPx: number
930
-
931
- if (useCachedFrame) {
932
- kLineCenters = this.cachedDrawFrame!.kLineCenters
933
- kBarRects = this.cachedDrawFrame!.kBarRects
934
- kWidthPx = this.cachedDrawFrame!.kWidthPx
935
- } else {
936
- const physConfig = getPhysicalKLineConfig(this.opt.kWidth, this.opt.kGap, vp.dpr)
937
- let barWidthPx = Math.max(1, physConfig.unitPx - 1)
938
- if (barWidthPx % 2 === 0) barWidthPx -= 1
939
-
940
- kLineCenters = new Array(kLinePositions.length)
941
- kBarRects = new Array(kLinePositions.length)
942
-
943
- for (let i = 0; i < kLinePositions.length; i++) {
944
- const x = kLinePositions[i]!
945
- const leftPx = Math.round(x * vp.dpr)
946
- const wickXPx = leftPx + (physConfig.kWidthPx - 1) / 2
947
- kLineCenters[i] = wickXPx / vp.dpr
948
-
949
- const barLeftPx = wickXPx - (barWidthPx - 1) / 2
950
- kBarRects[i] = { x: barLeftPx / vp.dpr, width: barWidthPx / vp.dpr }
951
- }
952
-
953
- kWidthPx = getPhysicalKLineConfig(this.opt.kWidth, this.opt.kGap, vp.dpr).kWidthPx
954
- this.cachedDrawFrame = {
955
- viewport: { ...vp },
956
- range: { ...range },
957
- kLinePositions,
958
- kLineCenters,
959
- kBarRects,
960
- kWidthPx,
961
- }
962
- }
963
-
964
- return { vp, range, kLinePositions, kLineCenters, kBarRects, kWidthPx, useCachedFrame }
342
+ this.renderer.draw(level)
965
343
  }
966
344
 
967
- private clearAllCanvases() {
968
- const vp = this.computeViewport()
969
- if (!vp) return
970
- for (const r of this.paneRenderers) {
971
- const { mainCtx, overlayCtx, yAxisCtx } = r.getContexts()
972
- const pane = r.getPane()
973
- mainCtx?.clearRect(0, 0, vp.plotWidth + 1, pane.height + 2 / vp.dpr)
974
- overlayCtx?.clearRect(0, 0, vp.plotWidth + 1, pane.height + 2 / vp.dpr)
975
- yAxisCtx?.clearRect(0, 0, vp.plotWidth + 1, pane.height + 2 / vp.dpr)
976
- }
977
- const xCtx = this.xAxisCtx
978
- if (xCtx) {
979
- const xW = xCtx.canvas.width
980
- const xH = xCtx.canvas.height
981
- xCtx.clearRect(0, 0, xW, xH)
982
- }
983
- }
984
345
 
985
- private renderPanes(
986
- vp: Viewport,
987
- range: VisibleRange,
988
- kLinePositions: KLinePositions,
989
- kLineCenters: number[],
990
- kBarRects: Array<{ x: number; width: number }>,
991
- kWidthPx: number,
992
- mainIndicatorRange: { min: number; max: number } | null,
993
- hasCrosshair: boolean,
994
- useCachedFrame: boolean,
995
- level: UpdateLevel,
996
- ): { sharedXAxisLabels: XAxisLabel[]; sharedXAxisRanges: XAxisRange[] } {
997
- const sharedYAxisLabels: YAxisLabel[] = []
998
- const sharedXAxisLabels: XAxisLabel[] = []
999
- const sharedYAxisRanges: YAxisRange[] = []
1000
- const sharedXAxisRanges: XAxisRange[] = []
1001
-
1002
- for (const renderer of this.paneRenderers) {
1003
- const pane = renderer.getPane()
1004
- const { mainCtx, overlayCtx, yAxisCtx } = renderer.getContexts()
1005
- const { candleSurface, lineSurface } = renderer.getWebGL()
1006
-
1007
- if (!useCachedFrame) {
1008
- const indicatorRange = pane.role === 'price' ? mainIndicatorRange : null
1009
- const comparisonRange = pane.id === 'main' ? this.getComparisonEquivalentPriceRange(range) : null
1010
- const mergedRange = this.mergeNumericRanges(indicatorRange, comparisonRange)
1011
- pane.updateRange(this._internalData, range, mergedRange)
1012
- if (pane.id === 'main' && this.settings.disableMainPaneVerticalScroll) {
1013
- pane.yAxis.resetTransform()
1014
- }
1015
- }
1016
-
1017
- const shouldUpdateMain = level === UpdateLevel.Main || level === UpdateLevel.All
1018
- const shouldUpdateOverlay = level === UpdateLevel.All || (level === UpdateLevel.Overlay && (hasCrosshair || this.overlayHadCrosshair))
1019
-
1020
- if (shouldUpdateMain && mainCtx) {
1021
- mainCtx.setTransform(1, 0, 0, 1, 0, 0)
1022
- mainCtx.scale(vp.dpr, vp.dpr)
1023
- mainCtx.clearRect(0, 0, vp.plotWidth + 1, pane.height + 2 / vp.dpr)
1024
- candleSurface?.clear()
1025
- lineSurface?.clear()
1026
- }
1027
-
1028
- if (shouldUpdateOverlay && overlayCtx) {
1029
- const overlayWidth = overlayCtx.canvas.width / vp.dpr
1030
- overlayCtx.setTransform(1, 0, 0, 1, 0, 0)
1031
- overlayCtx.scale(vp.dpr, vp.dpr)
1032
- overlayCtx.clearRect(0, 0, overlayWidth + 1, pane.height + 2 / vp.dpr)
1033
- }
1034
-
1035
- if (yAxisCtx && !useCachedFrame) {
1036
- const yAxisWidth = yAxisCtx.canvas.width / vp.dpr
1037
- yAxisCtx.setTransform(1, 0, 0, 1, 0, 0)
1038
- yAxisCtx.scale(vp.dpr, vp.dpr)
1039
- yAxisCtx.clearRect(0, 0, yAxisWidth, pane.height + 2 / vp.dpr)
1040
- }
1041
-
1042
- const context: RenderContext = {
1043
- ctx: mainCtx!,
1044
- overlayCtx: overlayCtx ?? undefined,
1045
- pane: wrapPaneInfo(pane),
1046
- data: this._internalData,
1047
- comparisonData: this._comparisonData,
1048
- comparisonSymbols: this._comparisonSpecs,
1049
- range,
1050
- scrollLeft: vp.scrollLeft,
1051
- kWidth: this.opt.kWidth,
1052
- kGap: this.opt.kGap,
1053
- dpr: vp.dpr,
1054
- paneWidth: vp.plotWidth,
1055
- kLinePositions,
1056
- kLineCenters,
1057
- kBarRects,
1058
- markerManager: this.markerManager,
1059
- crosshairIndex: this.interaction.getCrosshairIndex(),
1060
- yAxisCtx: yAxisCtx ?? undefined,
1061
- candleWebGLSurface: candleSurface ?? undefined,
1062
- lineWebGLSurface: lineSurface ?? undefined,
1063
- zoomLevel: this.currentZoomLevel,
1064
- zoomLevelCount: this.zoomLevelCount,
1065
- viewport: {
1066
- scrollLeft: vp.scrollLeft,
1067
- plotWidth: vp.plotWidth,
1068
- plotHeight: vp.plotHeight,
1069
- },
1070
- settings: this.settings,
1071
- yAxisLabels: sharedYAxisLabels,
1072
- xAxisLabels: sharedXAxisLabels,
1073
- yAxisRanges: sharedYAxisRanges,
1074
- xAxisRanges: sharedXAxisRanges,
1075
- theme: this._themeSignal.peek(),
1076
- isAsiaMarket: this.settings.isAsiaMarket as boolean,
1077
- colorPresetSettings: this.settings.colorPresetSettings,
1078
- }
1079
-
1080
- if (shouldUpdateMain || shouldUpdateOverlay) {
1081
- const errors = this.rendererPluginManager.render(pane.id, context, level)
1082
- if (errors.length > 0) {
1083
- this.pluginHost.events.emit('renderer:error', { paneId: pane.id, errors })
1084
- }
1085
-
1086
- const yAxisErrors = this.rendererPluginManager.renderPlugin('yAxis', context)
1087
- if (yAxisErrors.length > 0) {
1088
- this.pluginHost.events.emit('renderer:error', { paneId: pane.id, errors: yAxisErrors })
1089
- }
1090
- }
1091
- }
1092
-
1093
- return { sharedXAxisLabels, sharedXAxisRanges }
1094
- }
1095
-
1096
- private renderXAxis(
1097
- vp: Viewport,
1098
- range: VisibleRange,
1099
- kLinePositions: KLinePositions,
1100
- kLineCenters: number[],
1101
- kBarRects: Array<{ x: number; width: number }>,
1102
- kWidthPx: number,
1103
- sharedXAxisLabels: XAxisLabel[],
1104
- sharedXAxisRanges: XAxisRange[],
1105
- ): void {
1106
- const xAxisCtx = this.xAxisCtx ?? this.dom.xAxisCanvas.getContext('2d')
1107
- if (!this.xAxisCtx) {
1108
- this.xAxisCtx = xAxisCtx
1109
- }
1110
- if (xAxisCtx) {
1111
- const timeAxisContext: RenderContext = {
1112
- ctx: xAxisCtx,
1113
- pane: {
1114
- id: 'xAxis',
1115
- role: 'auxiliary',
1116
- capabilities: {
1117
- showPriceAxisTicks: false,
1118
- showCrosshairPriceLabel: false,
1119
- candleHitTest: false,
1120
- supportsPriceTranslate: false,
1121
- },
1122
- top: 0,
1123
- height: this.opt.bottomAxisHeight,
1124
- yAxis: {
1125
- priceToY: () => 0,
1126
- yToPrice: () => 0,
1127
- getPaddingTop: () => 0,
1128
- getPaddingBottom: () => 0,
1129
- getPriceOffset: () => 0,
1130
- getDisplayRange: (baseRange) => baseRange ?? { maxPrice: 0, minPrice: 0 },
1131
- getScaleType: () => 'linear' as const,
1132
- getBasePrice: () => null,
1133
- toPercent: () => 0,
1134
- fromPercent: () => 0,
1135
- getDisplayPercentRange: () => ({ minPct: 0, maxPct: 0 }),
1136
- },
1137
- priceRange: { maxPrice: 0, minPrice: 0 },
1138
- },
1139
- data: this._internalData,
1140
- range,
1141
- scrollLeft: vp.scrollLeft,
1142
- kWidth: this.opt.kWidth,
1143
- kGap: this.opt.kGap,
1144
- dpr: vp.dpr,
1145
- paneWidth: vp.plotWidth,
1146
- kLinePositions,
1147
- kLineCenters,
1148
- kBarRects,
1149
- xAxisCtx,
1150
- viewport: {
1151
- scrollLeft: vp.scrollLeft,
1152
- plotWidth: vp.plotWidth,
1153
- plotHeight: vp.plotHeight,
1154
- },
1155
- yAxisLabels: [],
1156
- xAxisLabels: sharedXAxisLabels,
1157
- xAxisRanges: sharedXAxisRanges,
1158
- theme: this._themeSignal.peek(),
1159
- isAsiaMarket: this.settings.isAsiaMarket as boolean,
1160
- colorPresetSettings: this.settings.colorPresetSettings,
1161
- }
1162
- const errors = this.rendererPluginManager.renderPlugin('timeAxis', timeAxisContext)
1163
- if (errors.length > 0) {
1164
- this.pluginHost.events.emit('renderer:error', { paneId: 'timeAxis', errors })
1165
- }
1166
- }
1167
- }
1168
346
 
1169
347
  // ========== Render State API (Vue SSOT) ==========
1170
348
 
@@ -1175,11 +353,11 @@ export class Chart {
1175
353
  */
1176
354
  applyRenderState(kWidth: number, kGap: number, zoomLevel?: number): void {
1177
355
  const nextZoomLevel = zoomLevel !== undefined
1178
- ? Math.max(1, Math.min(this.zoomLevelCount, zoomLevel))
1179
- : this.currentZoomLevel
356
+ ? Math.max(1, Math.min(this.zoomController.zoomLevelCount, zoomLevel))
357
+ : this.zoomController.currentZoomLevel
1180
358
  const renderStateChanged = this.opt.kWidth !== kWidth
1181
359
  || this.opt.kGap !== kGap
1182
- || this.currentZoomLevel !== nextZoomLevel
360
+ || this.zoomController.currentZoomLevel !== nextZoomLevel
1183
361
 
1184
362
  if (!renderStateChanged) {
1185
363
  return
@@ -1187,7 +365,7 @@ export class Chart {
1187
365
 
1188
366
  this.opt = { ...this.opt, kWidth, kGap }
1189
367
  if (zoomLevel !== undefined) {
1190
- this.currentZoomLevel = nextZoomLevel
368
+ this.zoomController.setZoomLevel(nextZoomLevel)
1191
369
  }
1192
370
  this.updateViewportSignal()
1193
371
  this.scheduleDraw()
@@ -1195,7 +373,7 @@ export class Chart {
1195
373
 
1196
374
  /** 获取总缩放级别数 */
1197
375
  getZoomLevelCount(): number {
1198
- return this.zoomLevelCount
376
+ return this.zoomController.zoomLevelCount
1199
377
  }
1200
378
 
1201
379
  /** 获取所有 PaneRenderer */
@@ -1205,18 +383,18 @@ export class Chart {
1205
383
 
1206
384
  /** 获取 MarkerManager(供 InteractionController 使用) */
1207
385
  getMarkerManager(): MarkerManager {
1208
- return this.markerManager
386
+ return this.renderer.getMarkerManager()
1209
387
  }
1210
388
 
1211
389
  /** 更新自定义标记 */
1212
390
  updateCustomMarkers(markers: CustomMarkerEntity[]): void {
1213
- this.markerManager.setCustomMarkers(markers)
391
+ this.renderer.getMarkerManager().setCustomMarkers(markers)
1214
392
  this.scheduleDraw()
1215
393
  }
1216
394
 
1217
395
  /** 清除自定义标记 */
1218
396
  clearCustomMarkers(): void {
1219
- this.markerManager.clearCustomMarkers()
397
+ this.renderer.getMarkerManager().clearCustomMarkers()
1220
398
  this.scheduleDraw()
1221
399
  }
1222
400
 
@@ -1230,36 +408,6 @@ export class Chart {
1230
408
  return this.opt
1231
409
  }
1232
410
 
1233
- /**
1234
- * 计算 K 线起始 x 坐标数组,与 candle.ts 的像素对齐方式保持一致
1235
- * @param range 可见 K 线索引范围
1236
- * @returns x 坐标数组(逻辑像素,经过物理像素对齐)
1237
- */
1238
- calcKLinePositions(range: VisibleRange): KLinePositions {
1239
- const { start, end } = range
1240
- const count = end - start
1241
-
1242
- // 边界检查:防止负数或零长度数组
1243
- if (count <= 0) {
1244
- return []
1245
- }
1246
-
1247
- const dpr = this.getEffectiveDpr()
1248
-
1249
- // 统一使用 getPhysicalKLineConfig,确保与渲染完全一致
1250
- const { unitPx, startXPx } = getPhysicalKLineConfig(this.opt.kWidth, this.opt.kGap, dpr)
1251
-
1252
- const positions: number[] = new Array(count)
1253
-
1254
- for (let i = 0; i < count; i++) {
1255
- const dataIndex = start + i
1256
- const leftPx = startXPx + dataIndex * unitPx
1257
- positions[i] = leftPx / dpr
1258
- }
1259
-
1260
- return positions
1261
- }
1262
-
1263
411
  /**
1264
412
  * 更新配置并触发布局/重绘
1265
413
  * @param partial 部分配置项
@@ -1277,7 +425,7 @@ export class Chart {
1277
425
  if (partial.panes) {
1278
426
  const nextPanes = partial.panes.map((pane) => ({ ...pane }))
1279
427
  this.opt = { ...this.opt, ...partial, panes: nextPanes }
1280
- this.applyPaneLayoutSpecs(nextPanes)
428
+ this.layoutManager.applyPaneLayoutSpecs(nextPanes)
1281
429
  return
1282
430
  }
1283
431
 
@@ -1285,347 +433,93 @@ export class Chart {
1285
433
  this.resize()
1286
434
  }
1287
435
 
1288
- /** 更新 pane 布局配置
1289
- * @param panes 新的 pane 配置数组
1290
- *
1291
- * 显式整盘替换:清空之前 user-resize 留下的 paneRatios 缓存,让 spec 中的 ratio
1292
- * 真正生效。`addPane`/`upsertPane`/`removePaneDefinition` 走 `applyPaneLayoutSpecs`
1293
- * 时仍保留 prev 值以记住用户拖拽过的高度——只有显式的 layout replacement 才重置。
1294
- */
1295
436
  updatePaneLayout(panes: PaneSpec[]): void {
1296
- this._internalPaneRatios.clear()
1297
- this.applyPaneLayoutSpecs(panes)
437
+ this.layoutManager.updatePaneLayout(panes)
1298
438
  }
1299
439
 
1300
440
  setPaneDefinitions(defs: PaneSpec[]): void {
1301
- this.applyPaneLayoutSpecs(defs)
441
+ this.layoutManager.setPaneDefinitions(defs)
1302
442
  }
1303
443
 
1304
444
  upsertPane(def: PaneSpec): void {
1305
- const idx = this.opt.panes.findIndex((pane) => pane.id === def.id)
1306
- if (idx === -1) {
1307
- this.applyPaneLayoutSpecs([...this.opt.panes, { ...def }])
1308
- return
1309
- }
1310
-
1311
- const next = [...this.opt.panes]
1312
- next[idx] = { ...next[idx], ...def }
1313
- this.applyPaneLayoutSpecs(next)
445
+ this.layoutManager.upsertPane(def)
1314
446
  }
1315
447
 
1316
448
  removePaneDefinition(paneId: string): void {
1317
- if (!this.opt.panes.some((pane) => pane.id === paneId)) return
1318
- this._internalPaneRatios.delete(paneId)
1319
- this.applyPaneLayoutSpecs(this.opt.panes.filter((pane) => pane.id !== paneId))
449
+ this.layoutManager.removePaneDefinition(paneId)
1320
450
  }
1321
451
 
1322
452
  bindIndicatorToPane(paneId: string, indicatorId: SubIndicatorType, params?: Record<string, number | boolean | string>): void {
1323
- const paneExists = this.opt.panes.some((pane) => pane.id === paneId)
1324
- if (!paneExists) {
1325
- this.upsertPane({ id: paneId, ratio: 1, visible: true, role: 'indicator' })
1326
- }
1327
-
1328
- const definition = this.indicatorScheduler.getIndicatorMetadata(indicatorId)
1329
- if (!definition) {
1330
- throw new Error(`[Chart] Unknown indicator: ${indicatorId}`)
1331
- }
1332
- const renderer = createSubIndicatorRenderer({ indicatorId, paneId, definition, params })
1333
- const rendererName = renderer.name
1334
- const existing = this.getRenderer(rendererName)
1335
- if (existing) {
1336
- if (params) this.updateRendererConfig(rendererName, params)
1337
- return
1338
- }
1339
-
1340
- this.useRenderer(renderer, params)
453
+ this.indicatorManager.bindIndicatorToPane(paneId, indicatorId, params)
1341
454
  }
1342
455
 
1343
456
  /** 更新绘图对象 */
1344
457
  setDrawings(drawings: import('../plugin').DrawingObject[]): void {
1345
- this.drawingStore.setAll(drawings)
458
+ this.renderer.getDrawingStore().setAll(drawings)
1346
459
  this._drawingsSignal.set(drawings)
1347
460
  this.scheduleDraw()
1348
461
  }
1349
462
 
1350
463
  /** 更新选中的绘图 ID */
1351
464
  setSelectedDrawingId(id: string | null): void {
1352
- if (this.drawingStore.getSelectedId() === id) return
1353
- this.drawingStore.setSelectedId(id)
465
+ const store = this.renderer.getDrawingStore()
466
+ if (store.getSelectedId() === id) return
467
+ store.setSelectedId(id)
1354
468
  this.scheduleDraw()
1355
469
  }
1356
470
 
1357
- /** 获取当前 pane 布局快照(含 ratio) */
1358
471
  getPaneLayoutSpecs(): PaneSpec[] {
1359
- const visible = this.opt.panes.filter(p => p.visible !== false)
1360
- const sum = visible.reduce((s, p) => s + (this._internalPaneRatios.get(p.id) ?? p.ratio ?? 0), 0)
1361
- const safeSum = sum > 0 ? sum : 1
1362
- return this.opt.panes.map((spec) => {
1363
- const base = this._internalPaneRatios.get(spec.id) ?? spec.ratio ?? 0
1364
- const ratio = spec.visible === false ? base : base / safeSum
1365
- const pane = this.paneRenderers.find((r) => r.getPane().id === spec.id)?.getPane()
1366
- return {
1367
- ...spec,
1368
- ratio,
1369
- role: pane?.role ?? spec.role,
1370
- capabilities: pane ? { ...pane.capabilities } : spec.capabilities,
1371
- }
1372
- })
1373
- }
1374
-
1375
- private emitPaneLayoutChange(): void {
1376
- // 同步 pane ratios 到 signal
1377
- const ratios: Record<string, number> = {}
1378
- this._internalPaneRatios.forEach((ratio, id) => {
1379
- ratios[id] = ratio
1380
- })
1381
- this._paneRatiosSignal.set(ratios)
1382
-
1383
- this._paneLayoutSignal.set(this.getPaneLayoutSpecs())
1384
- }
1385
-
1386
- private applyPaneLayoutSpecs(panes: PaneSpec[]): void {
1387
- this.opt.panes = panes.map((spec) => ({ ...spec }))
1388
- this.syncPaneRatiosFromSpecs(this.opt.panes)
1389
- this.initPanes()
1390
- this.layoutPanes()
1391
- this.emitPaneLayoutChange()
1392
- this.scheduleDraw()
472
+ return this.layoutManager.getPaneLayoutSpecs()
1393
473
  }
1394
474
 
1395
- /**
1396
- * 调整相邻 pane 边界(支持连锁挤压)
1397
- * @param upperPaneId 上方 pane ID(边界位于此 pane 与其下方邻居之间)
1398
- * @param deltaY Y 方向位移(逻辑像素,正数表示边界向下,upper 增大;负数表示向上,upper 减小)
1399
- */
1400
475
  resizePaneBoundary(upperPaneId: string, deltaY: number): boolean {
1401
- // === 1. 参数校验 ===
1402
- if (!Number.isFinite(deltaY) || deltaY === 0) return false
1403
- const vp = this._internalViewport
1404
- if (!vp) return false
1405
-
1406
- // === 2. 定位相邻 pane 对(边界两侧) ===
1407
- const visibleSpecs = this.opt.panes.filter(p => p.visible !== false)
1408
- const boundaryIndex = visibleSpecs.findIndex(p => p.id === upperPaneId)
1409
- if (boundaryIndex < 0 || boundaryIndex >= visibleSpecs.length - 1) return false
1410
-
1411
- const upperSpec = visibleSpecs[boundaryIndex]
1412
- const lowerSpec = visibleSpecs[boundaryIndex + 1]
1413
- if (!upperSpec || !lowerSpec) return false
1414
-
1415
- // === 3. 收集所有 pane 当前高度 ===
1416
- const heights = new Map<string, number>()
1417
- for (const spec of visibleSpecs) {
1418
- const renderer = this.paneRenderers.find(r => r.getPane().id === spec.id)
1419
- if (renderer) {
1420
- heights.set(spec.id, renderer.getPane().height)
1421
- }
1422
- }
1423
-
1424
- // === 4. 连锁挤压/扩展 ===
1425
- // deltaY > 0: 边界下移,upper expand,lower shrink
1426
- // deltaY < 0: 边界上移,upper shrink,lower expand
1427
- const expandIdx = deltaY > 0 ? boundaryIndex : boundaryIndex + 1
1428
- const shrinkIdx = deltaY > 0 ? boundaryIndex + 1 : boundaryIndex
1429
- const expandDir = deltaY > 0 ? -1 : 1 // expand 方向(向边界方向找)
1430
- const shrinkDir = deltaY > 0 ? 1 : -1 // shrink 方向(远离边界方向找)
1431
-
1432
- let remaining = Math.abs(deltaY)
1433
-
1434
- // 先尝试 shrink(从 shrinkIdx 开始,沿 shrinkDir 方向连锁)
1435
- let shrinkCursor = shrinkIdx
1436
- while (remaining > 0 && shrinkCursor >= 0 && shrinkCursor < visibleSpecs.length) {
1437
- const spec = visibleSpecs[shrinkCursor]
1438
- if (!spec) break
1439
-
1440
- const currentH = heights.get(spec.id) ?? 0
1441
- const minH = this.getPaneMinHeight(spec, vp.plotHeight)
1442
- const canShrink = Math.max(0, currentH - minH)
1443
-
1444
- if (canShrink > 0) {
1445
- const shrink = Math.min(canShrink, remaining)
1446
- heights.set(spec.id, currentH - shrink)
1447
- remaining -= shrink
1448
- }
1449
-
1450
- // 继续向 shrinkDir 方向找下一个可 shrink 的 pane
1451
- if (remaining > 0) {
1452
- shrinkCursor += shrinkDir
1453
- }
1454
- }
1455
-
1456
- // 如果还有剩余(无法完全 shrink),说明拖拽无效
1457
- if (remaining > 0) return false
1458
-
1459
- // 将节省的高度全部加到 expand 方
1460
- const expandSpec = visibleSpecs[expandIdx]
1461
- if (!expandSpec) return false
1462
- const expandCurrentH = heights.get(expandSpec.id) ?? 0
1463
- heights.set(expandSpec.id, expandCurrentH + Math.abs(deltaY))
1464
-
1465
- // === 5. 将像素高度转换为 ratio ===
1466
- const gap = Math.max(0, this.opt.paneGap ?? 0)
1467
- const totalGaps = gap * Math.max(0, visibleSpecs.length - 1)
1468
- const availableH = Math.max(1, vp.plotHeight - totalGaps)
1469
-
1470
- for (const spec of visibleSpecs) {
1471
- const h = heights.get(spec.id) ?? 0
1472
- this._internalPaneRatios.set(spec.id, h / availableH)
1473
- }
1474
-
1475
- // === 6. 归一化并同步 ===
1476
- this.normalizeVisiblePaneRatios(visibleSpecs)
1477
- this.syncPaneRatiosToSpecs()
1478
-
1479
- // === 7. 应用布局 ===
1480
- this.layoutPanes()
1481
- this.emitPaneLayoutChange()
1482
- this.scheduleDraw()
1483
- return true
476
+ return this.layoutManager.resizePaneBoundary(upperPaneId, deltaY)
1484
477
  }
1485
478
 
1486
- private resolvePaneRole(spec: PaneSpec, index: number): PaneRole {
1487
- if (spec.role) return spec.role
1488
- return index === 0 ? 'price' : 'indicator'
1489
- }
1490
-
1491
-
1492
479
  addPane(paneId: string): void {
1493
- if (this.opt.panes.some((spec) => spec.id === paneId)) {
1494
- console.warn(`Pane "${paneId}" already exists`)
1495
- return
1496
- }
1497
-
1498
- const hasPricePane = this.opt.panes.some((spec, index) => this.resolvePaneRole(spec, index) === 'price')
1499
- const role: PaneRole = hasPricePane ? 'indicator' : 'price'
1500
- this.applyPaneLayoutSpecs([
1501
- ...this.opt.panes,
1502
- { id: paneId, ratio: 1, visible: true, role },
1503
- ])
480
+ this.layoutManager.addPane(paneId)
1504
481
  }
1505
482
 
1506
- /**
1507
- * 动态移除 pane
1508
- * @param paneId pane 标识符
1509
- */
1510
483
  removePane(paneId: string): void {
1511
- if (!this.opt.panes.some((spec) => spec.id === paneId)) return
1512
-
1513
- const next = this.opt.panes.filter((spec) => spec.id !== paneId)
1514
- this._internalPaneRatios.delete(paneId)
1515
- this.applyPaneLayoutSpecs(next)
484
+ this.layoutManager.removePane(paneId)
1516
485
  }
1517
486
 
1518
- /**
1519
- * 检查 pane 是否存在
1520
- * @param paneId pane 标识符
1521
- */
1522
487
  hasPane(paneId: string): boolean {
1523
- return this.opt.panes.some((spec) => spec.id === paneId)
488
+ return this.layoutManager.hasPane(paneId)
1524
489
  }
1525
490
 
1526
491
  // ========== 副图管理 API ==========
1527
492
 
1528
- /**
1529
- * 创建副图面板并注册指标渲染器
1530
- * @param paneId 副图实例标识符(如 'RSI_0', 'MACD_0')
1531
- * @param indicatorId 指标类型
1532
- * @param params 指标参数
1533
- * @returns 是否创建成功
1534
- */
1535
493
  createSubPane(paneId: string, indicatorId: SubIndicatorType, params?: Record<string, number | boolean | string>): boolean {
1536
- // 调整 pane ratios:主图占 3,副图各占 1
1537
- const visibleSpecs = this.opt.panes.filter((pane) => pane.visible !== false)
1538
- const pricePanes = visibleSpecs.filter((pane, index) => this.resolvePaneRole(pane, index) === 'price')
1539
- const indicatorPanes = visibleSpecs.filter((pane, index) => this.resolvePaneRole(pane, index) === 'indicator')
1540
-
1541
- if (pricePanes.length === 1) {
1542
- const pricePane = pricePanes[0]
1543
- if (pricePane) {
1544
- this._internalPaneRatios.set(pricePane.id, 3)
1545
- }
1546
- for (const pane of indicatorPanes) {
1547
- this._internalPaneRatios.set(pane.id, 1)
1548
- }
1549
- this._internalPaneRatios.set(paneId, 1)
1550
- } else {
1551
- this._internalPaneRatios.set(paneId, 1)
1552
- }
1553
-
1554
- this.upsertPane({ id: paneId, ratio: this._internalPaneRatios.get(paneId) ?? 1, visible: true, role: 'indicator' })
1555
-
1556
- const success = this.subPaneManager.create(this, paneId, indicatorId, params ?? this.getDefaultSubPaneParams(indicatorId))
1557
- return success
494
+ return this.indicatorManager.createSubPane(paneId, indicatorId, params)
1558
495
  }
1559
496
 
1560
- /**
1561
- * 移除副图面板及其渲染器
1562
- * @param paneId 副图实例标识符
1563
- */
1564
497
  removeSubPane(paneId: string): void {
1565
- this.subPaneManager.remove(this, paneId)
498
+ this.indicatorManager.removeSubPane(paneId)
1566
499
  }
1567
500
 
1568
- /**
1569
- * 替换副图的指标类型
1570
- * @param paneId 副图实例标识符
1571
- * @param newIndicatorId 新的指标类型
1572
- * @param params 新指标参数
1573
- */
1574
501
  replaceSubPaneIndicator(paneId: string, newIndicatorId: SubIndicatorType, params?: Record<string, number | boolean | string>): void {
1575
- this.subPaneManager.replaceIndicator(this, paneId, newIndicatorId, params ?? this.getDefaultSubPaneParams(newIndicatorId))
502
+ this.indicatorManager.replaceSubPaneIndicator(paneId, newIndicatorId, params)
1576
503
  }
1577
504
 
1578
- /**
1579
- * 更新副图指标参数
1580
- * @param paneId 副图实例标识符
1581
- * @param params 新参数
1582
- */
1583
505
  updateSubPaneParams(paneId: string, params: Record<string, unknown>): void {
1584
- this.subPaneManager.updateParams(this, paneId, params)
506
+ this.indicatorManager.updateSubPaneParams(paneId, params)
1585
507
  }
1586
508
 
1587
- /**
1588
- * 清除所有副图面板
1589
- */
1590
509
  clearSubPanes(): void {
1591
- // 获取所有副图 paneId
1592
- const subPaneIds = this.subPaneManager.getPaneIds()
1593
-
1594
- if (subPaneIds.length === 0) return
1595
-
1596
- // 移除所有副图
1597
- this.subPaneManager.clear(this)
1598
-
1599
- // 清理 pane ratios
1600
- for (const paneId of subPaneIds) {
1601
- this._internalPaneRatios.delete(paneId)
1602
- }
1603
-
1604
- // 更新布局,移除所有副图 pane
1605
- this.applyPaneLayoutSpecs(this.opt.panes.filter((spec) => !subPaneIds.includes(spec.id)))
510
+ this.indicatorManager.clearSubPanes()
1606
511
  }
1607
512
 
1608
- /**
1609
- * 获取当前所有副图指标类型
1610
- * @deprecated 使用 getSubPaneEntries 获取完整信息
1611
- */
1612
513
  getSubPaneIndicators(): SubIndicatorType[] {
1613
- return this.subPaneManager.getAll().map((entry) => entry.indicatorId)
514
+ return this.indicatorManager.getSubPaneIndicators()
1614
515
  }
1615
516
 
1616
- /**
1617
- * 获取所有副图条目
1618
- */
1619
517
  getSubPaneEntries(): SubPaneEntry[] {
1620
- return this.subPaneManager.getAll()
518
+ return this.indicatorManager.getSubPaneEntries()
1621
519
  }
1622
520
 
1623
- /**
1624
- * 根据 paneId 获取副图条目
1625
- * @param paneId 副图实例标识符
1626
- */
1627
521
  getSubPaneEntry(paneId: string): SubPaneEntry | undefined {
1628
- return this.subPaneManager.getByPaneId(paneId)
522
+ return this.indicatorManager.getSubPaneEntry(paneId)
1629
523
  }
1630
524
 
1631
525
  private getDefaultSubPaneParams(indicatorId: SubIndicatorType): Record<string, unknown> {
@@ -1671,8 +565,7 @@ export class Chart {
1671
565
  return { ...(defaults[indicatorId] ?? {}) }
1672
566
  }
1673
567
 
1674
- /** 副图渲染器名称前缀(保留向后兼容) */
1675
- private static readonly SUB_PANE_PREFIX = 'sub_'
568
+
1676
569
 
1677
570
  /**
1678
571
  * 平移价格轴(用于主图区域上下拖动)
@@ -1730,166 +623,58 @@ export class Chart {
1730
623
  * @param data K 线数据数组
1731
624
  */
1732
625
  updateData(data: KLineData[]) {
1733
- this._internalData = data ?? []
1734
- this._dataSignal.set([...this._internalData])
1735
-
1736
- // 重算 DOM scrollLeft 状态, 防止左右滚动超出数据长度范围
1737
- const container = this.dom.container
1738
- if (container) {
1739
- const minScrollLeft = this.getLeftLoadBufferWidth()
1740
- if (this.cachedScrollLeft < minScrollLeft) {
1741
- this.cachedScrollLeft = minScrollLeft
1742
- this._pendingScrollLeft = minScrollLeft
1743
- }
1744
- const contentWidth = this.getContentWidth()
1745
- const maxScrollLeft = Math.max(0, contentWidth - container.clientWidth)
1746
- if (this.cachedScrollLeft > maxScrollLeft) {
1747
- this.cachedScrollLeft = maxScrollLeft
1748
- this._pendingScrollLeft = maxScrollLeft
1749
- }
1750
- }
1751
-
1752
- // 重置交互状态
1753
- this.interaction.reset()
1754
-
1755
- // 如果 visibleRange 还是 {0,0},先从 viewport 算一个初步范围
1756
- // 避免 scheduler 第一次计算时用 {0,0} 产出 Infinity 极值
1757
- if (this.lastVisibleRange.start === 0 && this.lastVisibleRange.end === 0 && this._internalData.length > 0) {
1758
- const plotWidth = this.observedSize.width > 0
1759
- ? this.observedSize.width
1760
- : Math.max(1, Math.round(this.dom.container?.clientWidth ?? 800))
1761
- const dpr = this.getEffectiveDpr()
1762
- const { start, end } = getVisibleRange(
1763
- this.getLogicalScrollLeft(),
1764
- plotWidth,
1765
- this.opt.kWidth,
1766
- this.opt.kGap,
1767
- this._internalData.length,
1768
- dpr,
1769
- )
1770
- this.lastRawVisibleRange = { start, end }
1771
- this.lastVisibleRange = { start: Math.max(0, start), end }
1772
- }
1773
-
1774
- // 触发指标计算(在 scheduleDraw 之前,确保渲染器读到最新状态)
1775
- const indicatorsReady = this.indicatorScheduler.update(this._internalData, this.lastVisibleRange)
1776
- if (indicatorsReady) {
1777
- this.pendingIndicatorDataUpdate = false
1778
- this.scheduleDraw()
1779
- } else {
1780
- this.pendingIndicatorDataUpdate = true
1781
- }
626
+ this.dataManager.updateData(data)
1782
627
  }
1783
628
 
1784
629
  /** 获取当前数据源(供 renderers 和 interaction 使用) */
1785
630
  getData(): KLineData[] {
1786
- return this._internalData
631
+ return this.dataManager.getData()
1787
632
  }
1788
633
 
1789
634
  /** 获取指标调度器(供外部控制器更新指标配置) */
1790
635
  getIndicatorScheduler(): IndicatorScheduler {
1791
- return this.indicatorScheduler
1792
- }
1793
-
1794
- private getTrailingSlotCount(): number {
1795
- return 24
636
+ return this.indicatorManager.indicatorSchedulerAccessor
1796
637
  }
1797
638
 
1798
639
  getLogicalSlotCount(): number {
1799
- return this._internalData.length + this.getTrailingSlotCount()
640
+ return this.dataManager.getLogicalSlotCount()
1800
641
  }
1801
642
 
1802
643
  getTimestampAtLogicalIndex(index: number): number | null {
1803
- if (!Number.isInteger(index) || index < 0 || index >= this._internalData.length) return null
1804
- return this._internalData[index]?.timestamp ?? null
644
+ return this.dataManager.getTimestampAtLogicalIndex(index)
1805
645
  }
1806
646
 
1807
647
  /** 根据视口内 X 坐标反查逻辑索引(允许超出最后一根 K 线) */
1808
648
  getLogicalIndexAtX(mouseX: number): number | null {
1809
- const vp = this._internalViewport
1810
- if (!vp || this._internalData.length === 0) return null
1811
- const dpr = this.getEffectiveDpr()
1812
- const { startXPx, unitPx } = getPhysicalKLineConfig(this.opt.kWidth, this.opt.kGap, dpr)
1813
- const worldX = Math.round((vp.scrollLeft + mouseX) * dpr)
1814
- const index = Math.floor((worldX - startXPx) / unitPx)
1815
- if (index < 0) return null
1816
- return index
649
+ return this.dataManager.getLogicalIndexAtX(mouseX)
1817
650
  }
1818
651
 
1819
652
  /** 根据视口内 X 坐标反查数据索引(用于绘图落点) */
1820
653
  getDataIndexAtX(mouseX: number): number | null {
1821
- const index = this.getLogicalIndexAtX(mouseX)
1822
- if (index === null || index >= this._internalData.length) return null
1823
- return index
654
+ return this.dataManager.getDataIndexAtX(mouseX)
1824
655
  }
1825
656
 
1826
657
 
1827
- private static readonly LEADING_SLOTS = 60
1828
- private static readonly TRAILING_DRAWING_SLOTS = 24
1829
-
1830
658
  /** 获取内容总宽度(用于外部 scroll-content 撑开 scrollWidth) */
1831
659
  getContentWidth(): number {
1832
- const dataLength = this._internalData.length
1833
- if (dataLength === 0) return 0
1834
- const kWidth = this.opt.kWidth
1835
- const kGap = this.opt.kGap
1836
- const viewWidth = this._internalViewport?.plotWidth ?? 0
1837
- const dpr = this.getEffectiveDpr()
1838
- const { startXPx, unitPx } = getPhysicalKLineConfig(kWidth, kGap, dpr)
1839
- const dataPlotWidth = (startXPx + (Chart.LEADING_SLOTS + dataLength + Chart.TRAILING_DRAWING_SLOTS) * unitPx) / dpr
1840
- return this.getLeftLoadBufferWidth() + Math.max(dataPlotWidth, viewWidth)
660
+ return this.dataManager.getContentWidth()
1841
661
  }
1842
662
 
1843
663
  /** 滚动到最右侧(最新数据位置) */
1844
664
  scrollToRight(): void {
1845
- const container = this.dom.container
1846
- if (!container || this._internalData.length === 0) return
1847
- const dpr = this.getEffectiveDpr()
1848
- const { unitPx, startXPx } = getPhysicalKLineConfig(this.opt.kWidth, this.opt.kGap, dpr)
1849
- const lastKLineEndPx = (startXPx + this._internalData.length * unitPx) / dpr
1850
- const target = this.getLeftLoadBufferWidth() + Math.max(0, lastKLineEndPx - container.clientWidth)
1851
- const maxScroll = Math.max(0, container.scrollWidth - container.clientWidth)
1852
- container.scrollLeft = Math.round(Math.min(target, maxScroll) * dpr) / dpr
1853
- this.cachedScrollLeft = container.scrollLeft
1854
- }
1855
-
1856
- private getLeftLoadBufferWidth(): number {
1857
- if (this._internalData.length === 0) {
1858
- this.leftLoadBufferWidth = 0
1859
- return 0
1860
- }
1861
- const plotWidth = this._internalViewport?.plotWidth
1862
- ?? (this.observedSize.width > 0 ? this.observedSize.width : undefined)
1863
- ?? Math.round(this.dom.container?.clientWidth ?? 0)
1864
- this.leftLoadBufferWidth = Math.max(0, plotWidth)
1865
- return this.leftLoadBufferWidth
1866
- }
1867
-
1868
- private computeRawVisibleRange(): VisibleRange | null {
1869
- if (this._internalData.length === 0) return null
1870
- const vp = this._internalViewport ?? this.computeViewport()
1871
- if (!vp) return null
1872
- return getVisibleRange(
1873
- vp.scrollLeft,
1874
- vp.plotWidth,
1875
- this.opt.kWidth,
1876
- this.opt.kGap,
1877
- this._internalData.length,
1878
- vp.dpr,
1879
- )
665
+ this.dataManager.scrollToRight()
1880
666
  }
1881
667
 
1882
668
  /** 容器尺寸变化时调用 */
1883
669
  resize() {
1884
- const vp = this.computeViewport()
670
+ const vp = this.viewportManager.computeViewport()
1885
671
  // 防御性检查:容器尺寸无效时跳过布局
1886
672
  if (!vp || vp.viewWidth < 10 || vp.viewHeight < 10) {
1887
673
  return
1888
674
  }
1889
- this.cachedDrawFrame = null
1890
- this.layoutPanes()
1891
- this.emitPaneLayoutChange()
1892
- this.updateViewportSignal()
675
+ this.renderer.clearCachedFrame()
676
+ this.layoutManager.layoutPanes()
677
+ this.viewportManager.updateViewportSignal()
1893
678
  this.scheduleDraw()
1894
679
  }
1895
680
 
@@ -1898,432 +683,27 @@ export class Chart {
1898
683
  * @param level 更新级别,默认为 All
1899
684
  */
1900
685
  scheduleDraw(level: UpdateLevel = UpdateLevel.All): void {
1901
- // 合并更新级别:如果已有更高级别的调度,保持高级别
1902
- if (this.raf !== null) {
1903
- // 已有 All 级别调度,任何新请求都忽略
1904
- if (this.pendingUpdateLevel === UpdateLevel.All) return
1905
- // 新请求是 All,覆盖之前的 Main/Overlay
1906
- if (level === UpdateLevel.All) {
1907
- this.pendingUpdateLevel = UpdateLevel.All
1908
- return
1909
- }
1910
- // Main + Overlay = All
1911
- if (
1912
- (this.pendingUpdateLevel === UpdateLevel.Main && level === UpdateLevel.Overlay) ||
1913
- (this.pendingUpdateLevel === UpdateLevel.Overlay && level === UpdateLevel.Main)
1914
- ) {
1915
- this.pendingUpdateLevel = UpdateLevel.All
1916
- return
1917
- }
1918
- // 同级别或更低级别,忽略
1919
- return
1920
- }
1921
-
1922
- this.pendingUpdateLevel = level
1923
- this.raf = requestAnimationFrame(() => {
1924
- this.raf = null
1925
- const levelToDraw = this.pendingUpdateLevel
1926
- this.pendingUpdateLevel = UpdateLevel.All // 重置为默认值
1927
- this.draw(levelToDraw)
1928
- if (this._pendingScrollLeft !== null) {
1929
- const c = this.dom.container
1930
- if (c) {
1931
- c.scrollLeft = this._pendingScrollLeft
1932
- this.cachedScrollLeft = c.scrollLeft
1933
- this._pendingScrollLeft = null
1934
- }
1935
- }
1936
- })
686
+ this.renderer.scheduleDraw(level)
1937
687
  }
1938
688
 
1939
689
  /** 销毁图表实例 */
1940
690
  async destroy() {
1941
- if (this.raf !== null) {
1942
- cancelAnimationFrame(this.raf)
1943
- this.raf = null
1944
- }
1945
-
1946
- if (this._dataBufferUnsub) {
1947
- this._dataBufferUnsub()
1948
- this._dataBufferUnsub = null
1949
- }
1950
- this.clearIncrementalLoadHintTimer()
1951
- this.incrementalLoadHintEl?.remove()
1952
- this.incrementalLoadHintEl = null
1953
- this.pendingPrependedCount = 0
1954
- this._dataBuffer.dispose()
1955
- this.clearComparisonBuffers()
1956
-
1957
- // 清理尺寸观察器
1958
- this.resizeObserver?.disconnect()
1959
- this.resizeObserver = undefined
1960
- this.preciseDpr = 0
1961
- this.observedSize = { width: 0, height: 0 }
1962
-
1963
- // 清理 scroll 监听
1964
- if (this.onScroll) {
1965
- this.dom.container?.removeEventListener('scroll', this.onScroll)
1966
- this.onScroll = undefined
1967
- }
1968
-
1969
- this._internalViewport = null
1970
- this.cachedDrawFrame = null
1971
- this.xAxisCtx = null
1972
- this.paneRenderers.forEach((r) => r.destroy())
1973
- this.paneRenderers = []
691
+ this.renderer.destroy()
692
+ this.dataManager.destroy()
693
+ this.viewportManager.destroy()
694
+ this.layoutManager.destroy()
1974
695
  this.sharedWebGLSurface.destroy()
1975
-
1976
- // 清理渲染器插件管理器(会调用所有 onUninstall)
1977
- this.rendererPluginManager.clear()
1978
-
1979
- this.indicatorScheduler.destroy()
696
+ this.indicatorManager.destroy()
1980
697
  await this.pluginHost.destroy()
1981
698
  }
1982
699
 
1983
- /** 初始化所有 pane */
1984
- private initPanes() {
1985
- this.paneRenderers = this.opt.panes.map((spec, index) => {
1986
- const pane = new Pane(spec.id, {
1987
- role: this.resolvePaneRole(spec, index),
1988
- capabilities: spec.capabilities,
1989
- })
1990
-
1991
- const mainCanvas = document.createElement('canvas')
1992
- const overlayCanvas = document.createElement('canvas')
1993
- const yAxisCanvas = document.createElement('canvas')
1994
-
1995
- const isMain = pane.role === 'price'
1996
-
1997
- // Main Canvas - K线、指标、网格
1998
- mainCanvas.id = `${spec.id}-main`
1999
- mainCanvas.className = isMain ? 'main-canvas main' : 'main-canvas sub'
2000
- mainCanvas.style.position = 'absolute'
2001
- mainCanvas.style.left = '0'
2002
- mainCanvas.style.top = '0'
2003
-
2004
- // Overlay Canvas - 十字线、Tooltip(透明,事件穿透)
2005
- overlayCanvas.id = `${spec.id}-overlay`
2006
- overlayCanvas.className = 'overlay-canvas'
2007
- overlayCanvas.style.position = 'absolute'
2008
- overlayCanvas.style.left = '0'
2009
- overlayCanvas.style.top = '0'
2010
- overlayCanvas.style.pointerEvents = 'none' // 事件穿透到 mainCanvas
2011
- overlayCanvas.style.backgroundColor = 'transparent'
2012
-
2013
- yAxisCanvas.id = `${spec.id}-yAxis`
2014
- yAxisCanvas.className = 'right-axis'
2015
- yAxisCanvas.style.position = 'absolute'
2016
- yAxisCanvas.style.left = '0'
2017
-
2018
- const renderer = new PaneRenderer(
2019
- { mainCanvas, overlayCanvas, yAxisCanvas },
2020
- pane,
2021
- {
2022
- rightAxisWidth: this.opt.rightAxisWidth,
2023
- yPaddingPx: this.opt.yPaddingPx,
2024
- priceLabelWidth: this.opt.priceLabelWidth,
2025
- },
2026
- this.sharedWebGLSurface,
2027
- )
2028
-
2029
- return renderer
2030
- })
2031
-
2032
- const canvasLayer = this.dom.canvasLayer
2033
- const rightAxisLayer = this.dom.rightAxisLayer
2034
- if (canvasLayer) {
2035
- const existingCanvases = canvasLayer.querySelectorAll('canvas:not(.x-axis-canvas)')
2036
- existingCanvases.forEach((canvas) => canvas.remove())
2037
- }
2038
- if (rightAxisLayer) {
2039
- const existingAxisCanvases = rightAxisLayer.querySelectorAll('canvas.right-axis')
2040
- existingAxisCanvases.forEach((canvas) => canvas.remove())
2041
- }
2042
-
2043
- this.paneRenderers.forEach((renderer) => {
2044
- const dom = renderer.getDom()
2045
- // 先添加 mainCanvas,再添加 overlayCanvas(overlay 在上层)
2046
- canvasLayer.appendChild(dom.mainCanvas)
2047
- canvasLayer.appendChild(dom.overlayCanvas)
2048
- rightAxisLayer.appendChild(dom.yAxisCanvas)
2049
- })
2050
-
2051
- this.rendererPluginManager.setKnownPaneIds(this.paneRenderers.map((renderer) => renderer.getPane().id))
2052
- }
2053
-
2054
-
2055
- private syncPaneRatiosFromSpecs(specs: PaneSpec[]): void {
2056
- const next = new Map<string, number>()
2057
- for (const spec of specs) {
2058
- const prev = this._internalPaneRatios.get(spec.id)
2059
- const incoming = Number.isFinite(spec.ratio) ? spec.ratio : 0
2060
- const ratio = prev !== undefined ? prev : (incoming > 0 ? incoming : 1)
2061
- next.set(spec.id, ratio)
2062
- }
2063
- this._internalPaneRatios = next
2064
- this.normalizeVisiblePaneRatios(specs)
2065
- this.syncPaneRatiosToSpecs()
2066
- }
2067
-
2068
- private syncPaneRatiosToSpecs(): void {
2069
- const visible = this.opt.panes.filter(p => p.visible !== false)
2070
- const visibleSum = visible.reduce((s, p) => s + (this._internalPaneRatios.get(p.id) ?? p.ratio ?? 0), 0)
2071
- const safeVisibleSum = visibleSum > 0 ? visibleSum : 1
2072
-
2073
- this.opt.panes = this.opt.panes.map((spec) => {
2074
- const ratio = this._internalPaneRatios.get(spec.id) ?? spec.ratio ?? 0
2075
- if (spec.visible === false) {
2076
- return { ...spec, ratio }
2077
- }
2078
- return { ...spec, ratio: ratio / safeVisibleSum }
2079
- })
2080
- }
2081
-
2082
- private normalizeVisiblePaneRatios(specs: PaneSpec[]): void {
2083
- const visible = specs.filter(p => p.visible !== false)
2084
- if (visible.length === 0) return
2085
-
2086
- let sum = 0
2087
- for (const spec of visible) {
2088
- const raw = this._internalPaneRatios.get(spec.id) ?? spec.ratio ?? 0
2089
- const safe = Number.isFinite(raw) && raw > 0 ? raw : 0
2090
- this._internalPaneRatios.set(spec.id, safe)
2091
- sum += safe
2092
- }
2093
-
2094
- if (sum <= 0) {
2095
- const equal = 1 / visible.length
2096
- for (const spec of visible) {
2097
- this._internalPaneRatios.set(spec.id, equal)
2098
- }
2099
- return
2100
- }
2101
-
2102
- for (const spec of visible) {
2103
- const v = this._internalPaneRatios.get(spec.id) ?? 0
2104
- this._internalPaneRatios.set(spec.id, v / sum)
2105
- }
2106
- }
2107
-
2108
- private getPaneMinHeight(spec: PaneSpec, plotHeight: number): number {
2109
- const fallback = this.opt.defaultPaneMinHeightPx ?? 120 // 最小高度
2110
- const raw = spec.minHeightPx ?? fallback
2111
- return Math.max(1, Math.min(Math.round(raw), Math.max(1, plotHeight)))
2112
- }
2113
-
2114
- private computePaneHeightsByRatio(visibleSpecs: PaneSpec[], availableH: number): number[] {
2115
- if (visibleSpecs.length === 0) return []
2116
-
2117
- const ratios = visibleSpecs.map(spec => this._internalPaneRatios.get(spec.id) ?? spec.ratio ?? 0)
2118
- const ratioSum = ratios.reduce((s, r) => s + (r > 0 ? r : 0), 0)
2119
- const safeRatios = ratioSum > 0
2120
- ? ratios.map(r => (r > 0 ? r : 0) / ratioSum)
2121
- : visibleSpecs.map(() => 1 / visibleSpecs.length)
2122
-
2123
- const heights = safeRatios.map(r => Math.max(1, Math.round(availableH * r)))
2124
- const mins = visibleSpecs.map(spec => this.getPaneMinHeight(spec, availableH))
2125
-
2126
- for (let i = 0; i < heights.length; i++) {
2127
- heights[i] = Math.max(heights[i]!, Math.min(mins[i]!, availableH))
2128
- }
2129
-
2130
- let total = heights.reduce((s, h) => s + h, 0)
2131
-
2132
- if (total > availableH) {
2133
- let overflow = total - availableH
2134
- while (overflow > 0) {
2135
- let shrunk = false
2136
- for (let i = heights.length - 1; i >= 0 && overflow > 0; i--) {
2137
- const minH = Math.max(1, Math.min(mins[i]!, availableH))
2138
- const h = heights[i]!
2139
- if (h > minH) {
2140
- heights[i] = h - 1
2141
- overflow--
2142
- shrunk = true
2143
- }
2144
- }
2145
- if (!shrunk) break
2146
- }
2147
- } else if (total < availableH) {
2148
- heights[heights.length - 1] = (heights[heights.length - 1] ?? 1) + (availableH - total)
2149
- }
2150
-
2151
- total = heights.reduce((s, h) => s + h, 0)
2152
- if (total !== availableH && heights.length > 0) {
2153
- heights[heights.length - 1] = Math.max(1, (heights[heights.length - 1] ?? 1) + (availableH - total))
2154
- }
2155
-
2156
- return heights
2157
- }
2158
-
2159
- /** 计算每个 pane 的布局(top 和 height) */
2160
- private layoutPanes() {
2161
- const vp = this._internalViewport
2162
- if (!vp) return
2163
-
2164
- const visibleSpecs = this.opt.panes.filter(p => p.visible !== false)
2165
- if (visibleSpecs.length === 0) return
2166
-
2167
- const gap = Math.max(0, this.opt.paneGap ?? 0)
2168
- let y = 0
2169
-
2170
- const totalGaps = gap * Math.max(0, visibleSpecs.length - 1)
2171
- const availableH = Math.max(1, vp.plotHeight - totalGaps)
2172
-
2173
- this.normalizeVisiblePaneRatios(visibleSpecs)
2174
- const paneHeights = this.computePaneHeightsByRatio(visibleSpecs, availableH)
2175
-
2176
- for (let i = 0; i < visibleSpecs.length; i++) {
2177
- const spec = visibleSpecs[i]
2178
- if (!spec) continue
2179
-
2180
- const renderer = this.paneRenderers.find(r => r.getPane().id === spec.id)
2181
- if (!renderer) continue
2182
-
2183
- const pane = renderer.getPane()
2184
- const h = paneHeights[i] ?? 1
2185
-
2186
- pane.setLayout(y, h)
2187
- pane.setPadding(this.opt.yPaddingPx, this.opt.yPaddingPx)
2188
700
 
2189
- renderer.resize(vp.plotWidth, h, vp.dpr)
2190
- renderer.setWebGLRegion({
2191
- x: 0,
2192
- y,
2193
- width: vp.plotWidth,
2194
- height: h,
2195
- dpr: vp.dpr,
2196
- })
2197
- this.rendererPluginManager.notifyResize(pane.id, wrapPaneInfo(pane))
2198
- const dom = renderer.getDom()
2199
- dom.mainCanvas.style.top = `${y}px`
2200
- dom.overlayCanvas.style.top = `${y}px`
2201
- dom.yAxisCanvas.style.top = `${y}px`
2202
- dom.yAxisCanvas.style.left = '0px'
2203
-
2204
- y += h + gap
2205
- }
2206
-
2207
- // 按实际像素高度回写 ratio,确保后续 resize 视觉比例稳定
2208
- const finalAvailable = Math.max(1, availableH)
2209
- for (const spec of visibleSpecs) {
2210
- const renderer = this.paneRenderers.find(r => r.getPane().id === spec.id)
2211
- if (!renderer) continue
2212
- const h = renderer.getPane().height
2213
- this._internalPaneRatios.set(spec.id, h / finalAvailable)
2214
- }
2215
- this.normalizeVisiblePaneRatios(visibleSpecs)
2216
- this.syncPaneRatiosToSpecs()
2217
- }
2218
701
  private computeViewport(): Viewport | null {
2219
- const container = this.dom.container
2220
- if (!container) return null
2221
-
2222
- const observedWidth = this.observedSize.width
2223
- const observedHeight = this.observedSize.height
2224
- const viewWidth = observedWidth > 0
2225
- ? observedWidth
2226
- : Math.max(1, Math.round(container.clientWidth))
2227
- const viewHeight = observedHeight > 0
2228
- ? observedHeight
2229
- : Math.max(1, Math.round(container.clientHeight))
2230
-
2231
- const plotWidth = Math.round(viewWidth)
2232
- const plotHeight = Math.round(viewHeight - this.opt.bottomAxisHeight)
2233
-
2234
- let dpr = this.getEffectiveDpr()
2235
-
2236
- const MAX_CANVAS_PIXELS = 16 * 1024 * 1024
2237
- const requestedPixels = viewWidth * dpr * (viewHeight * dpr)
2238
- if (requestedPixels > MAX_CANVAS_PIXELS) {
2239
- dpr = Math.sqrt(MAX_CANVAS_PIXELS / (viewWidth * viewHeight))
2240
- }
2241
-
2242
- // 对齐 scrollLeft,消除 translate 亚像素偏移
2243
- const scrollLeft = Math.round(this.getLogicalScrollLeft() * dpr) / dpr
2244
-
2245
- const canvasLayerWidth = `${viewWidth}px`
2246
- if (this.dom.canvasLayer.style.width !== canvasLayerWidth) {
2247
- this.dom.canvasLayer.style.width = canvasLayerWidth
2248
- }
2249
-
2250
- const canvasLayerHeight = `${viewHeight}px`
2251
- if (this.dom.canvasLayer.style.height !== canvasLayerHeight) {
2252
- this.dom.canvasLayer.style.height = canvasLayerHeight
2253
- }
2254
-
2255
- const xAxisWidth = Math.round(plotWidth * dpr)
2256
- if (this.dom.xAxisCanvas.width !== xAxisWidth) {
2257
- this.dom.xAxisCanvas.width = xAxisWidth
2258
- }
2259
-
2260
- const xAxisHeight = Math.round(this.opt.bottomAxisHeight * dpr)
2261
- if (this.dom.xAxisCanvas.height !== xAxisHeight) {
2262
- this.dom.xAxisCanvas.height = xAxisHeight
2263
- }
2264
-
2265
- const xAxisCssWidth = `${xAxisWidth / dpr}px`
2266
- if (this.dom.xAxisCanvas.style.width !== xAxisCssWidth) {
2267
- this.dom.xAxisCanvas.style.width = xAxisCssWidth
2268
- }
2269
-
2270
- const xAxisCssHeight = `${xAxisHeight / dpr}px`
2271
- if (this.dom.xAxisCanvas.style.height !== xAxisCssHeight) {
2272
- this.dom.xAxisCanvas.style.height = xAxisCssHeight
2273
- }
2274
-
2275
- this.sharedWebGLSurface.resize(plotWidth, plotHeight, dpr)
2276
-
2277
- const vp: Viewport = {
2278
- viewWidth,
2279
- viewHeight,
2280
- plotWidth,
2281
- plotHeight,
2282
- scrollLeft,
2283
- dpr,
2284
- }
2285
- const prevViewport = this._internalViewport
2286
- const viewportChanged = !prevViewport
2287
- || prevViewport.viewWidth !== vp.viewWidth
2288
- || prevViewport.viewHeight !== vp.viewHeight
2289
- || prevViewport.plotWidth !== vp.plotWidth
2290
- || prevViewport.plotHeight !== vp.plotHeight
2291
- || prevViewport.scrollLeft !== vp.scrollLeft
2292
- || prevViewport.dpr !== vp.dpr
2293
-
2294
- this._internalViewport = vp
2295
- if (viewportChanged) {
2296
- const current = this._viewportSignal.peek()
2297
- this._viewportSignal.set({
2298
- zoomLevel: current.zoomLevel,
2299
- plotWidth: vp.plotWidth,
2300
- plotHeight: vp.plotHeight,
2301
- dpr: vp.dpr > 0 ? vp.dpr : current.dpr,
2302
- visibleFrom: current.visibleFrom,
2303
- visibleTo: current.visibleTo,
2304
- kWidth: current.kWidth,
2305
- kGap: current.kGap,
2306
- })
2307
- }
2308
- return vp
702
+ return this.viewportManager.computeViewport()
2309
703
  }
2310
704
 
2311
705
  // ==================== Facade API (High-level interface for adapters) ====================
2312
706
 
2313
- // ---------- Signals ----------
2314
- private _viewportSignal = createSignal<ViewportState>({
2315
- zoomLevel: 1,
2316
- plotWidth: 0,
2317
- plotHeight: 0,
2318
- dpr: 1,
2319
- visibleFrom: 0,
2320
- visibleTo: 0,
2321
- kWidth: 0,
2322
- kGap: 1,
2323
- })
2324
-
2325
- private _dataSignal = createSignal<ReadonlyArray<KLineData>>([])
2326
- private _symbolsSignal = createSignal<ReadonlyArray<SymbolSpec>>([])
2327
707
  private _themeSignal = createSignal<'light' | 'dark'>('light')
2328
708
  private _drawingToolSignal = createSignal<DrawingToolType | null>(null)
2329
709
  private _drawingsSignal = createSignal<ReadonlyArray<import('../plugin').DrawingObject>>([])
@@ -2346,51 +726,29 @@ export class Chart {
2346
726
  isHoveringRightAxis: false,
2347
727
  })
2348
728
 
2349
- private _indicatorsComputed = computed<ReadonlyArray<IndicatorInstance>>(() => {
2350
- const mainIndicators: IndicatorInstance[] = [...this._mainIndicatorsSignal().entries()].map(([id, entry]) => ({
2351
- id,
2352
- definitionId: id,
2353
- label: id,
2354
- name: id,
2355
- role: 'main' as const,
2356
- params: { ...entry.params },
2357
- }))
2358
-
2359
- const subIndicators: IndicatorInstance[] = this.subPaneManager.entriesSignal().map(entry => ({
2360
- id: entry.paneId,
2361
- definitionId: entry.indicatorId,
2362
- label: entry.indicatorId,
2363
- name: entry.indicatorId,
2364
- role: 'sub' as const,
2365
- paneId: entry.paneId,
2366
- params: { ...entry.params },
2367
- }))
2368
-
2369
- return [...mainIndicators, ...subIndicators]
2370
- })
2371
- private _subPanesComputed = computed<ReadonlyArray<SubPaneInfo>>(() => {
2372
- const ratios = this._paneRatiosSignal()
2373
- return this.subPaneManager.entriesSignal().map(entry => ({
2374
- paneId: entry.paneId,
2375
- indicatorId: entry.indicatorId,
2376
- params: { ...entry.params },
2377
- ratio: ratios[entry.paneId] ?? 1,
2378
- }))
2379
- })
2380
-
2381
729
  /** 视口状态信号 */
2382
730
  get viewport(): Signal<ViewportState> {
2383
- return this._viewportSignal
731
+ return this.viewportManager.viewportSignal
2384
732
  }
2385
733
 
2386
734
  /** 数据信号 */
2387
735
  get data(): Signal<ReadonlyArray<KLineData>> {
2388
- return this._dataSignal
736
+ return this.dataManager.data
2389
737
  }
2390
738
 
2391
739
  /** 符号信号 */
2392
740
  get symbols(): Signal<ReadonlyArray<SymbolSpec>> {
2393
- return this._symbolsSignal
741
+ return this.dataManager.symbols
742
+ }
743
+
744
+ /** 比较商品颜色信号 */
745
+ get comparisonColors(): Signal<ReadonlyMap<string, string>> {
746
+ return this.dataManager.comparisonColors
747
+ }
748
+
749
+ /** 比较商品加载信号 */
750
+ get comparisonLoading(): Signal<boolean> {
751
+ return this.dataManager.comparisonLoading
2394
752
  }
2395
753
 
2396
754
  /** 主题信号 */
@@ -2400,12 +758,12 @@ export class Chart {
2400
758
 
2401
759
  /** 指标实例列表信号(派生信号,自动随主/副图状态更新) */
2402
760
  get indicators(): Computed<ReadonlyArray<IndicatorInstance>> {
2403
- return this._indicatorsComputed
761
+ return this.indicatorManager.indicatorsComputed
2404
762
  }
2405
763
 
2406
764
  /** 子图信息信号(派生信号,自动随副图条目/比例更新) */
2407
765
  get subPanes(): Computed<ReadonlyArray<SubPaneInfo>> {
2408
- return this._subPanesComputed
766
+ return this.indicatorManager.subPanesComputed
2409
767
  }
2410
768
 
2411
769
  /** 当前绘图工具信号 */
@@ -2434,233 +792,36 @@ export class Chart {
2434
792
 
2435
793
  // ---------- Data ----------
2436
794
 
2437
- /**
2438
- * 设置数据(高层 API)
2439
- * 内部调用 updateData,并更新 data signal
2440
- */
2441
795
  setData(data: KLineData[]): void {
2442
- this.updateData(data)
796
+ this.dataManager.setData(data)
2443
797
  }
2444
798
 
2445
- /**
2446
- * 追加数据(高层 API)
2447
- * 合并现有数据并更新
2448
- */
2449
799
  appendData(newData: KLineData[]): void {
2450
- const merged = [...this._internalData, ...newData]
2451
- this.setData(merged)
800
+ this.dataManager.appendData(newData)
2452
801
  }
2453
802
 
2454
- /**
2455
- * 设置数据获取器适配器
2456
- */
2457
- setDataFetcher(fetcher: DataFetcher | null): void {
2458
- this._dataFetcher = fetcher
2459
- this._dataBuffer.setFetcher(fetcher)
2460
- for (const buffer of this._comparisonBuffers.values()) {
2461
- buffer.setFetcher(fetcher)
2462
- }
803
+ setDataFetcher(fetcher: import('../controllers/types').DataFetcher | null): void {
804
+ this.dataManager.setDataFetcher(fetcher)
2463
805
  }
2464
806
 
2465
- get dataBuffer(): DataBuffer {
2466
- return this._dataBuffer
807
+ get dataBuffer(): import('../data-fetchers/dataBuffer').DataBuffer {
808
+ return this.dataManager.dataBuffer
2467
809
  }
2468
810
 
2469
811
  checkVisibleRangeGap(): void {
2470
- if (this._internalData.length === 0) return
2471
- const window = this._dataBuffer.loadedWindow
2472
- if (!window) return
2473
- const range = this.computeRawVisibleRange() ?? this.lastRawVisibleRange
2474
-
2475
- if (range.start < 0 && this._dataFetcher) {
2476
- const MS_PER_DAY = 86_400_000
2477
- const earlierThanEarliest = window.earliestTs - 90 * MS_PER_DAY
2478
- this._dataBuffer.ensureRange(earlierThanEarliest, window.earliestTs)
2479
- return
2480
- }
2481
-
2482
- if (range.start >= this._internalData.length) return
2483
- const firstVisibleTs = this._internalData[Math.max(0, range.start)]?.timestamp
2484
- if (firstVisibleTs === undefined) return
2485
- if (firstVisibleTs < window.earliestTs) {
2486
- this._dataBuffer.ensureRange(firstVisibleTs, window.earliestTs)
2487
- }
2488
- }
2489
-
2490
- private checkVisibleRangeGapWhenIdle(): void {
2491
- if (this.interaction.isPointerDown()) return
2492
- this.checkVisibleRangeGap()
2493
- }
2494
-
2495
- private getComparisonEquivalentPriceRange(range: VisibleRange): { min: number; max: number } | null {
2496
- if (this._comparisonSpecs.length === 0 || this._comparisonData.size === 0) return null
2497
- const baseIndex = Math.max(0, range.start)
2498
- const mainBase = this._internalData[baseIndex]?.close
2499
- const baseTimestamp = this._internalData[baseIndex]?.timestamp
2500
- if (!Number.isFinite(mainBase) || mainBase <= 0 || baseTimestamp === undefined) return null
2501
-
2502
- let min = Number.POSITIVE_INFINITY
2503
- let max = Number.NEGATIVE_INFINITY
2504
-
2505
- for (const spec of this._comparisonSpecs) {
2506
- const data = this._comparisonData.get(spec.symbol)
2507
- if (!data?.length) continue
2508
-
2509
- const baseline = this.findComparisonBaseline(data, baseTimestamp)
2510
- if (!baseline || !Number.isFinite(baseline.close) || baseline.close <= 0) continue
2511
-
2512
- const byTimestamp = new Map<number, KLineData>()
2513
- for (const item of data) byTimestamp.set(item.timestamp, item)
2514
-
2515
- for (let i = Math.max(0, range.start); i < range.end && i < this._internalData.length; i++) {
2516
- const mainItem = this._internalData[i]
2517
- if (!mainItem) continue
2518
- const item = byTimestamp.get(mainItem.timestamp)
2519
- if (!item || !Number.isFinite(item.close)) continue
2520
-
2521
- const pct = (item.close - baseline.close) / baseline.close
2522
- const equivalentPrice = mainBase * (1 + pct)
2523
- if (!Number.isFinite(equivalentPrice)) continue
2524
- min = Math.min(min, equivalentPrice)
2525
- max = Math.max(max, equivalentPrice)
2526
- }
2527
- }
2528
-
2529
- if (!Number.isFinite(min) || !Number.isFinite(max)) return null
2530
- return { min, max }
812
+ this.dataManager.checkVisibleRangeGap()
2531
813
  }
2532
814
 
2533
- private findComparisonBaseline(data: ReadonlyArray<KLineData>, timestamp: number): KLineData | null {
2534
- for (const item of data) {
2535
- if (item.timestamp >= timestamp) return item
2536
- }
2537
- return null
2538
- }
2539
-
2540
- private mergeNumericRanges(
2541
- left: { min: number; max: number } | null | undefined,
2542
- right: { min: number; max: number } | null | undefined,
2543
- ): { min: number; max: number } | null {
2544
- if (!left) return right ?? null
2545
- if (!right) return left
2546
- return {
2547
- min: Math.min(left.min, right.min),
2548
- max: Math.max(left.max, right.max),
2549
- }
2550
- }
2551
-
2552
- private syncComparisonBuffers(specs: ReadonlyArray<SymbolSpec>): void {
2553
- this._comparisonSpecs = [...specs]
2554
- const nextKeys = new Set(specs.map((spec) => spec.symbol))
2555
-
2556
- for (const [key, buffer] of this._comparisonBuffers) {
2557
- if (nextKeys.has(key)) continue
2558
- this._comparisonBufferUnsubs.get(key)?.()
2559
- this._comparisonBufferUnsubs.delete(key)
2560
- buffer.dispose()
2561
- this._comparisonBuffers.delete(key)
2562
- this._comparisonData.delete(key)
2563
- }
2564
-
2565
- if (!this._dataFetcher) return
2566
-
2567
- for (const spec of specs) {
2568
- const key = spec.symbol
2569
- let buffer = this._comparisonBuffers.get(key)
2570
- if (!buffer) {
2571
- const newBuffer = new DataBuffer()
2572
- newBuffer.setFetcher(this._dataFetcher)
2573
- this._comparisonBuffers.set(key, newBuffer)
2574
- const unsubscribe = newBuffer.data.subscribe(() => {
2575
- this._comparisonData.set(key, [...newBuffer.data.peek()])
2576
- this.scheduleDraw()
2577
- })
2578
- this._comparisonBufferUnsubs.set(key, unsubscribe)
2579
- buffer = newBuffer
2580
- } else {
2581
- buffer.setFetcher(this._dataFetcher)
2582
- }
2583
- buffer.setSymbol(spec)
2584
- }
815
+ setSymbols(specs: ReadonlyArray<SymbolSpec>): void {
816
+ this.dataManager.setSymbols(specs)
2585
817
  }
2586
818
 
2587
- private clearComparisonBuffers(): void {
2588
- for (const unsubscribe of this._comparisonBufferUnsubs.values()) unsubscribe()
2589
- this._comparisonBufferUnsubs.clear()
2590
- for (const buffer of this._comparisonBuffers.values()) buffer.dispose()
2591
- this._comparisonBuffers.clear()
2592
- this._comparisonData.clear()
2593
- this._comparisonSpecs = []
819
+ addComparisonSymbol(spec: SymbolSpec): void {
820
+ this.dataManager.addComparisonSymbol(spec)
2594
821
  }
2595
822
 
2596
- /**
2597
- * 设置当前符号并触发数据加载
2598
- */
2599
- setSymbols(specs: ReadonlyArray<SymbolSpec>): void {
2600
- this._symbolsSignal.set(specs)
2601
- if (specs.length === 0) {
2602
- this.clearComparisonBuffers()
2603
- return
2604
- }
2605
- const spec = specs[0]!
2606
- this.syncComparisonBuffers(specs.slice(1))
2607
- if (!this._dataFetcher) return
2608
-
2609
- this._dataBuffer.setFetcher(this._dataFetcher)
2610
-
2611
- this._dataBuffer.onPrepend = (count: number) => {
2612
- this.pendingPrependedCount = count
2613
- const dpr = this.getEffectiveDpr()
2614
- const { unitPx } = getPhysicalKLineConfig(this.opt.kWidth, this.opt.kGap, dpr)
2615
- const compensation = (count * unitPx) / dpr
2616
- const container = this.dom.container
2617
- if (container) {
2618
- container.scrollLeft += compensation
2619
- this.cachedScrollLeft = container.scrollLeft
2620
- }
2621
- }
2622
-
2623
- if (!this._dataBufferUnsub) {
2624
- this._dataBufferUnsub = this._dataBuffer.data.subscribe(() => {
2625
- const bufferData = this._dataBuffer.data.peek()
2626
- this._internalData = [...bufferData]
2627
- this._dataSignal.set([...this._internalData])
2628
- const prependedCount = this.pendingPrependedCount
2629
- this.pendingPrependedCount = 0
2630
- if (this.cachedScrollLeft < this.getLeftLoadBufferWidth()) {
2631
- const desiredScrollLeft = this.getLeftLoadBufferWidth()
2632
- this.cachedScrollLeft = desiredScrollLeft
2633
- this._pendingScrollLeft = desiredScrollLeft
2634
- }
2635
- this.interaction.reset()
2636
- if (this.lastVisibleRange.start === 0 && this.lastVisibleRange.end === 0 && this._internalData.length > 0) {
2637
- const plotWidth = this.observedSize.width > 0
2638
- ? this.observedSize.width
2639
- : Math.max(1, Math.round(this.dom.container?.clientWidth ?? 800))
2640
- const dpr = this.getEffectiveDpr()
2641
- const { start, end } = getVisibleRange(
2642
- this.getLogicalScrollLeft(),
2643
- plotWidth,
2644
- this.opt.kWidth,
2645
- this.opt.kGap,
2646
- this._internalData.length,
2647
- dpr,
2648
- )
2649
- this.lastRawVisibleRange = { start, end }
2650
- this.lastVisibleRange = { start: Math.max(0, start), end }
2651
- }
2652
- const indicatorsReady = this.indicatorScheduler.update(this._internalData, this.lastVisibleRange)
2653
- if (indicatorsReady) {
2654
- this.pendingIndicatorDataUpdate = false
2655
- this.scheduleDraw()
2656
- } else {
2657
- this.pendingIndicatorDataUpdate = true
2658
- }
2659
- this.showIncrementalLoadHint(prependedCount)
2660
- })
2661
- }
2662
-
2663
- this._dataBuffer.setSymbol(spec)
823
+ removeComparisonSymbol(symbol: string): void {
824
+ this.dataManager.removeComparisonSymbol(symbol)
2664
825
  }
2665
826
 
2666
827
  // ---------- Theme ----------
@@ -2680,57 +841,21 @@ export class Chart {
2680
841
  * 计算并应用新的 render state,更新 viewport signal
2681
842
  */
2682
843
  zoomToLevel(level: number, anchorX?: number): void {
2683
- const clamped = Math.max(1, Math.min(this.zoomLevelCount, Math.round(level)))
2684
- this.applyZoom(clamped, anchorX)
844
+ this.zoomController.zoomToLevel(level, anchorX)
2685
845
  }
2686
846
 
2687
847
  /**
2688
848
  * 放大(高层 API)
2689
849
  */
2690
850
  zoomIn(anchorX?: number): void {
2691
- this.zoomToLevel(this.currentZoomLevel + 1, anchorX)
851
+ this.zoomController.zoomIn(anchorX)
2692
852
  }
2693
853
 
2694
854
  /**
2695
855
  * 缩小(高层 API)
2696
856
  */
2697
857
  zoomOut(anchorX?: number): void {
2698
- this.zoomToLevel(this.currentZoomLevel - 1, anchorX)
2699
- }
2700
-
2701
- /**
2702
- * 内部缩放实现
2703
- * 使用 computeZoom 纯函数计算精确的 scrollLeft
2704
- */
2705
- private applyZoom(targetLevel: number, anchorViewportX?: number): void {
2706
- if (targetLevel === this.currentZoomLevel) return
2707
-
2708
- const delta = targetLevel - this.currentZoomLevel
2709
- const logicalScrollLeft = this.getLogicalScrollLeft()
2710
- const dpr = this.getCurrentDpr()
2711
-
2712
- const result = computeZoom(
2713
- delta,
2714
- anchorViewportX ?? 0,
2715
- logicalScrollLeft,
2716
- this.currentZoomLevel,
2717
- this.opt.kWidth,
2718
- this.opt.kGap,
2719
- {
2720
- minKWidth: this.opt.minKWidth,
2721
- maxKWidth: this.opt.maxKWidth,
2722
- zoomLevelCount: this.zoomLevelCount,
2723
- dpr,
2724
- },
2725
- )
2726
-
2727
- if (!result) return
2728
-
2729
- const domScrollLeft = result.newScrollLeft + this.getLeftLoadBufferWidth()
2730
- this.currentZoomLevel = result.targetLevel
2731
- this.cachedScrollLeft = domScrollLeft
2732
- this._pendingScrollLeft = domScrollLeft
2733
- this.applyRenderState(result.newKWidth, result.newKGap, result.targetLevel)
858
+ this.zoomController.zoomOut(anchorX)
2734
859
  }
2735
860
 
2736
861
  // ---------- Interaction (Zero-config unified entry) ----------
@@ -2806,16 +931,8 @@ export class Chart {
2806
931
  * 使用 computeZoom 计算精确的 scrollLeft,更新 viewport signal
2807
932
  */
2808
933
  handleWheelEvent(e: WheelEvent): void {
2809
- const delta = e.deltaY > 0 ? -1 : 1
2810
- const targetLevel = Math.max(1, Math.min(this.zoomLevelCount, this.currentZoomLevel + delta))
2811
-
2812
- if (targetLevel === this.currentZoomLevel) return
2813
-
2814
- // 获取鼠标在视口中的位置作为缩放锚点(视口局部坐标)
2815
934
  const rect = this.dom.container.getBoundingClientRect()
2816
- const mouseX = e.clientX - rect.left
2817
-
2818
- this.applyZoom(targetLevel, mouseX)
935
+ this.zoomController.handleWheel(e.deltaY, e.clientX - rect.left)
2819
936
  }
2820
937
 
2821
938
  /**
@@ -2823,7 +940,7 @@ export class Chart {
2823
940
  * 更新缓存的 scrollLeft 并触发交互 controller
2824
941
  */
2825
942
  handleScrollEvent(): void {
2826
- this.interaction.onScroll({ scheduleDraw: !this.pendingIndicatorDataUpdate })
943
+ this.interaction.onScroll({ scheduleDraw: !this.dataManager.pendingIndicatorDataUpdate })
2827
944
  // 更新 viewport signal 中的 visible range
2828
945
  this.updateViewportSignal()
2829
946
  }
@@ -2834,30 +951,14 @@ export class Chart {
2834
951
  * @param centerClientX 捏合中心在视口中的 X 坐标
2835
952
  */
2836
953
  handlePinchZoom(delta: number, centerClientX: number): void {
2837
- const targetLevel = Math.max(1, Math.min(this.zoomLevelCount, this.currentZoomLevel + delta))
2838
- if (targetLevel === this.currentZoomLevel) return
2839
-
2840
- // centerClientX 已经是视口局部坐标,直接使用
2841
- this.applyZoom(targetLevel, centerClientX)
954
+ this.zoomController.handlePinch(delta, centerClientX)
2842
955
  }
2843
956
 
2844
957
  /**
2845
958
  * 更新 viewport signal(用于滚动事件)
2846
959
  */
2847
960
  private updateViewportSignal(): void {
2848
- const vp = this._internalViewport
2849
- if (!vp) return
2850
-
2851
- this._viewportSignal.set({
2852
- zoomLevel: this.currentZoomLevel,
2853
- plotWidth: vp.plotWidth,
2854
- plotHeight: vp.plotHeight,
2855
- dpr: vp.dpr,
2856
- visibleFrom: this.lastVisibleRange.start,
2857
- visibleTo: this.lastVisibleRange.end,
2858
- kWidth: this.opt.kWidth,
2859
- kGap: this.opt.kGap,
2860
- })
961
+ this.viewportManager.updateViewportSignal()
2861
962
  }
2862
963
 
2863
964
  // ---------- Indicators (Explicit role) ----------
@@ -2874,81 +975,19 @@ export class Chart {
2874
975
  role: 'main' | 'sub',
2875
976
  params?: Record<string, unknown>,
2876
977
  ): string | null {
2877
- if (role === 'main') {
2878
- const success = this.enableMainIndicator(definitionId, params as Record<string, number | boolean | string>)
2879
- if (!success) return null
2880
- return definitionId.toUpperCase()
2881
- } else {
2882
- // 副图指标
2883
- const paneId = `${definitionId.toUpperCase()}_${Date.now()}`
2884
- const success = this.createSubPane(
2885
- paneId,
2886
- definitionId as SubIndicatorType,
2887
- params as Record<string, number | boolean | string>,
2888
- )
2889
- if (!success) return null
2890
- return paneId
2891
- }
978
+ return this.indicatorManager.addIndicator(definitionId, role, params)
2892
979
  }
2893
980
 
2894
- /**
2895
- * 移除指标(高层 API)
2896
- * @param instanceId 指标实例 ID
2897
- * @returns 是否成功移除
2898
- */
2899
981
  removeIndicator(instanceId: string): boolean {
2900
- const id = instanceId.toUpperCase()
2901
-
2902
- // 先尝试作为主图指标移除
2903
- if (this._mainIndicatorsSignal.peek().has(id)) {
2904
- return this.disableMainIndicator(instanceId)
2905
- }
2906
-
2907
- // 再尝试作为副图指标移除
2908
- const subPaneEntry = this.getSubPaneEntry(instanceId)
2909
- if (subPaneEntry) {
2910
- this.removeSubPane(instanceId)
2911
- return true
2912
- }
2913
-
2914
- return false
982
+ return this.indicatorManager.removeIndicator(instanceId)
2915
983
  }
2916
984
 
2917
- /**
2918
- * 更新指标参数(高层 API)
2919
- * @param instanceId 指标实例 ID
2920
- * @param params 新参数
2921
- * @returns 是否成功更新
2922
- */
2923
985
  updateIndicatorParams(instanceId: string, params: Record<string, unknown>): boolean {
2924
- const id = instanceId.toUpperCase()
2925
-
2926
- // 先尝试作为主图指标更新
2927
- if (this._mainIndicatorsSignal.peek().has(id)) {
2928
- this.updateMainIndicatorParams(instanceId, params as Record<string, number | boolean | string>)
2929
- return true
2930
- }
2931
-
2932
- // 再尝试作为副图指标更新
2933
- const subPaneEntry = this.getSubPaneEntry(instanceId)
2934
- if (subPaneEntry) {
2935
- this.updateSubPaneParams(instanceId, params)
2936
- return true
2937
- }
2938
-
2939
- return false
986
+ return this.indicatorManager.updateIndicatorParams(instanceId, params)
2940
987
  }
2941
988
 
2942
- /**
2943
- * 重新排序指标(高层 API)
2944
- * @param orderedInstanceIds 排序后的指标实例 ID 数组
2945
- * @returns 是否成功
2946
- */
2947
989
  reorderIndicators(orderedInstanceIds: string[]): boolean {
2948
- // TODO: 实现副图指标的重新排序
2949
- // 需要调用 updatePaneLayout 来调整 pane 顺序
2950
- console.warn('[Chart] reorderIndicators not fully implemented yet')
2951
- return false
990
+ return this.indicatorManager.reorderIndicators(orderedInstanceIds)
2952
991
  }
2953
992
 
2954
993
 
@@ -3017,43 +1056,6 @@ export class Chart {
3017
1056
  */
3018
1057
  }
3019
1058
 
3020
- // ==================== Type definitions for Facade ====================
3021
-
3022
- export type ViewportState = {
3023
- zoomLevel: number
3024
- plotWidth: number
3025
- plotHeight: number
3026
- dpr: number
3027
- visibleFrom: number
3028
- visibleTo: number
3029
- kWidth: number
3030
- kGap: number
3031
- }
3032
1059
 
3033
- export type IndicatorRole = 'main' | 'sub'
3034
-
3035
- export interface IndicatorInstance {
3036
- id: string
3037
- definitionId: string
3038
- label: string
3039
- name: string
3040
- role: IndicatorRole
3041
- paneId?: string
3042
- params: Record<string, unknown>
3043
- }
3044
-
3045
- export interface SubPaneInfo {
3046
- paneId: string
3047
- indicatorId: string
3048
- params: Record<string, unknown>
3049
- ratio: number
3050
- }
3051
-
3052
- export type DrawingToolType = 'trendline' | 'horizontal' | 'fib' | 'rectangle' | 'arrow'
3053
-
3054
- export interface DrawingObject {
3055
- id: string
3056
- type: DrawingToolType
3057
- }
3058
1060
 
3059
1061