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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/controllers/createChartController.d.ts.map +1 -1
  2. package/dist/controllers/createChartController.js +30 -4
  3. package/dist/controllers/createChartController.js.map +1 -1
  4. package/dist/controllers/types.d.ts +9 -2
  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 +6 -1
  9. package/dist/data-fetchers/dataBuffer.d.ts.map +1 -1
  10. package/dist/data-fetchers/dataBuffer.js +88 -47
  11. package/dist/data-fetchers/dataBuffer.js.map +1 -1
  12. package/dist/data-fetchers/index.d.ts +1 -0
  13. package/dist/data-fetchers/index.d.ts.map +1 -1
  14. package/dist/data-fetchers/index.js +1 -0
  15. package/dist/data-fetchers/index.js.map +1 -1
  16. package/dist/data-fetchers/router.d.ts.map +1 -1
  17. package/dist/data-fetchers/router.js +3 -0
  18. package/dist/data-fetchers/router.js.map +1 -1
  19. package/dist/data-fetchers/tradingview.d.ts +3 -0
  20. package/dist/data-fetchers/tradingview.d.ts.map +1 -0
  21. package/dist/data-fetchers/tradingview.js +45 -0
  22. package/dist/data-fetchers/tradingview.js.map +1 -0
  23. package/dist/engine/chart.d.ts +34 -351
  24. package/dist/engine/chart.d.ts.map +1 -1
  25. package/dist/engine/chart.js +246 -1716
  26. package/dist/engine/chart.js.map +1 -1
  27. package/dist/engine/chartContext.d.ts +24 -0
  28. package/dist/engine/chartContext.d.ts.map +1 -0
  29. package/dist/engine/chartContext.js +19 -0
  30. package/dist/engine/chartContext.js.map +1 -0
  31. package/dist/engine/chartTypes.d.ts +77 -0
  32. package/dist/engine/chartTypes.d.ts.map +1 -0
  33. package/dist/engine/chartTypes.js +2 -0
  34. package/dist/engine/chartTypes.js.map +1 -0
  35. package/dist/engine/controller/interaction.d.ts +1 -0
  36. package/dist/engine/controller/interaction.d.ts.map +1 -1
  37. package/dist/engine/controller/interaction.js +9 -2
  38. package/dist/engine/controller/interaction.js.map +1 -1
  39. package/dist/engine/data/chartDataManager.d.ts +102 -0
  40. package/dist/engine/data/chartDataManager.d.ts.map +1 -0
  41. package/dist/engine/data/chartDataManager.js +590 -0
  42. package/dist/engine/data/chartDataManager.js.map +1 -0
  43. package/dist/engine/indicators/chartIndicatorManager.d.ts +102 -0
  44. package/dist/engine/indicators/chartIndicatorManager.d.ts.map +1 -0
  45. package/dist/engine/indicators/chartIndicatorManager.js +437 -0
  46. package/dist/engine/indicators/chartIndicatorManager.js.map +1 -0
  47. package/dist/engine/layout/chartPaneLayout.d.ts +53 -0
  48. package/dist/engine/layout/chartPaneLayout.d.ts.map +1 -0
  49. package/dist/engine/layout/chartPaneLayout.js +388 -0
  50. package/dist/engine/layout/chartPaneLayout.js.map +1 -0
  51. package/dist/engine/render/chartRenderer.d.ts +86 -0
  52. package/dist/engine/render/chartRenderer.d.ts.map +1 -0
  53. package/dist/engine/render/chartRenderer.js +438 -0
  54. package/dist/engine/render/chartRenderer.js.map +1 -0
  55. package/dist/engine/renderers/Indicator/mainIndicatorLegend.d.ts.map +1 -1
  56. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js +73 -7
  57. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js.map +1 -1
  58. package/dist/engine/renderers/comparisonLine.d.ts.map +1 -1
  59. package/dist/engine/renderers/comparisonLine.js +25 -11
  60. package/dist/engine/renderers/comparisonLine.js.map +1 -1
  61. package/dist/engine/subPaneManager.d.ts +27 -6
  62. package/dist/engine/subPaneManager.d.ts.map +1 -1
  63. package/dist/engine/subPaneManager.js +54 -56
  64. package/dist/engine/subPaneManager.js.map +1 -1
  65. package/dist/engine/utils/chartZoomController.d.ts +33 -0
  66. package/dist/engine/utils/chartZoomController.d.ts.map +1 -0
  67. package/dist/engine/utils/chartZoomController.js +66 -0
  68. package/dist/engine/utils/chartZoomController.js.map +1 -0
  69. package/dist/engine/viewport/chartViewportManager.d.ts +72 -0
  70. package/dist/engine/viewport/chartViewportManager.d.ts.map +1 -0
  71. package/dist/engine/viewport/chartViewportManager.js +249 -0
  72. package/dist/engine/viewport/chartViewportManager.js.map +1 -0
  73. package/dist/engine/viewport/viewport.js +1 -1
  74. package/dist/engine/viewport/viewport.js.map +1 -1
  75. package/dist/plugin/types.d.ts +1 -0
  76. package/dist/plugin/types.d.ts.map +1 -1
  77. package/dist/plugin/types.js.map +1 -1
  78. package/dist/tokens/theme-china.d.ts.map +1 -1
  79. package/dist/tokens/theme-china.js +0 -4
  80. package/dist/tokens/theme-china.js.map +1 -1
  81. package/dist/tokens/theme-dark.d.ts.map +1 -1
  82. package/dist/tokens/theme-dark.js +0 -4
  83. package/dist/tokens/theme-dark.js.map +1 -1
  84. package/dist/tokens/theme-light.d.ts.map +1 -1
  85. package/dist/tokens/theme-light.js +1 -5
  86. package/dist/tokens/theme-light.js.map +1 -1
  87. package/dist/tokens/types.d.ts +0 -4
  88. package/dist/tokens/types.d.ts.map +1 -1
  89. package/dist/types/price.d.ts +2 -0
  90. package/dist/types/price.d.ts.map +1 -1
  91. package/dist/types/price.js.map +1 -1
  92. package/dist/version.d.ts +1 -1
  93. package/dist/version.d.ts.map +1 -1
  94. package/dist/version.js +1 -1
  95. package/dist/version.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/controllers/createChartController.ts +49 -13
  98. package/src/controllers/types.ts +9 -2
  99. package/src/data-fetchers/__tests__/dataBuffer.test.ts +77 -0
  100. package/src/data-fetchers/baostock.ts +3 -3
  101. package/src/data-fetchers/dataBuffer.ts +70 -22
  102. package/src/data-fetchers/index.ts +1 -0
  103. package/src/data-fetchers/router.ts +3 -0
  104. package/src/data-fetchers/tradingview.ts +48 -0
  105. package/src/engine/__tests__/subPaneManager.test.ts +154 -0
  106. package/src/engine/chart.ts +260 -2103
  107. package/src/engine/chartContext.ts +34 -0
  108. package/src/engine/chartTypes.ts +88 -0
  109. package/src/engine/controller/__tests__/interaction.dpr.test.ts +1 -0
  110. package/src/engine/controller/interaction.ts +10 -2
  111. package/src/engine/data/chartDataManager.ts +691 -0
  112. package/src/engine/indicators/__tests__/chartIndicatorManager.test.ts +103 -0
  113. package/src/engine/indicators/chartIndicatorManager.ts +566 -0
  114. package/src/engine/layout/chartPaneLayout.ts +474 -0
  115. package/src/engine/render/chartRenderer.ts +579 -0
  116. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +99 -13
  117. package/src/engine/renderers/comparisonLine.ts +25 -11
  118. package/src/engine/subPaneManager.ts +75 -59
  119. package/src/engine/utils/chartZoomController.ts +104 -0
  120. package/src/engine/viewport/chartViewportManager.ts +310 -0
  121. package/src/engine/viewport/viewport.ts +1 -1
  122. package/src/plugin/types.ts +1 -0
  123. package/src/tokens/__tests__/__snapshots__/baseline.test.ts.snap +1 -9
  124. package/src/tokens/theme-china.ts +0 -4
  125. package/src/tokens/theme-dark.ts +0 -4
  126. package/src/tokens/theme-light.ts +2 -6
  127. package/src/tokens/types.ts +0 -4
  128. package/src/types/price.ts +2 -0
  129. package/src/version.ts +1 -1
  130. package/src/engine/chart.d.ts +0 -619
@@ -90,27 +90,24 @@ export function createMainIndicatorLegendRendererPlugin(options: {
90
90
  if (!meta.getTitleInfo) continue
91
91
  if (!scheduler?.isMainIndicatorActive(meta.name)) continue
92
92
  const params = scheduler?.getMainIndicatorParams(meta.name) ?? {}
93
- const getTitleInfo = meta.getTitleInfo
93
+
94
+ const titleInfo = meta.getTitleInfo(
95
+ klineData,
96
+ targetIndex,
97
+ params as Record<string, number | boolean | string>,
98
+ pluginHost!,
99
+ 'main',
100
+ )
101
+ if (!titleInfo) continue
94
102
 
95
103
  rows.push({
96
104
  draw: (rowIndex: number) => {
97
- const titleInfo = getTitleInfo(
98
- klineData,
99
- targetIndex,
100
- params as Record<string, number | boolean | string>,
101
- pluginHost!,
102
- 'main',
103
- )
104
- if (!titleInfo) return
105
-
106
105
  let x = legendX
107
106
  let y = config.yPaddingPx / 2 + legendYOffset + rowIndex * lineHeight
108
- // 指标名称
109
107
  overlayCtx.fillStyle = colors.text.primary
110
108
  overlayCtx.fillText(titleInfo.name, x, y)
111
109
  x += measureTextWidth(overlayCtx, titleInfo.name)
112
110
 
113
- // 指标参数
114
111
  if (titleInfo.params && titleInfo.params.length > 0) {
115
112
  const paramText = `(${titleInfo.params.join(',')})`
116
113
  overlayCtx.fillStyle = colors.text.tertiary
@@ -120,7 +117,6 @@ export function createMainIndicatorLegendRendererPlugin(options: {
120
117
  x += gap
121
118
  }
122
119
 
123
- // 指标数值
124
120
  if (titleInfo.values) {
125
121
  y += 1
126
122
  for (const item of titleInfo.values) {
@@ -134,6 +130,8 @@ export function createMainIndicatorLegendRendererPlugin(options: {
134
130
  })
135
131
  }
136
132
 
133
+ pushComparisonLegendRows(context, klineData, targetIndex, range, rows, config.yPaddingPx, overlayCtx, legendX, legendYOffset, lineHeight, gap, colors)
134
+
137
135
  rows.forEach((row, index) => row.draw(index))
138
136
  overlayCtx.restore()
139
137
  },
@@ -151,3 +149,91 @@ export function createMainIndicatorLegendRendererPlugin(options: {
151
149
  },
152
150
  }
153
151
  }
152
+
153
+ function pushComparisonLegendRows(
154
+ context: RenderContext,
155
+ klineData: KLineData[],
156
+ targetIndex: number,
157
+ range: { start: number; end: number },
158
+ rows: Array<{ draw: (rowIndex: number) => void }>,
159
+ yPaddingPx: number,
160
+ overlayCtx: CanvasRenderingContext2D,
161
+ legendX: number,
162
+ legendYOffset: number,
163
+ lineHeight: number,
164
+ gap: number,
165
+ colors: ReturnType<typeof resolveThemeColors>,
166
+ ): void {
167
+ const comparisonSymbols = context.comparisonSymbols
168
+ const comparisonData = context.comparisonData
169
+ const comparisonColors = context.comparisonColors
170
+ if (!comparisonSymbols?.length || !comparisonData?.size) return
171
+
172
+ const baseIndex = Math.max(0, range.start)
173
+ const baseItem = klineData[baseIndex]
174
+ if (!baseItem || !Number.isFinite(baseItem.close) || baseItem.close <= 0) return
175
+
176
+ const baseDate = baseItem.date ?? ''
177
+
178
+ for (const spec of comparisonSymbols) {
179
+ const data = comparisonData.get(spec.symbol)
180
+ if (!data?.length) continue
181
+
182
+ const baseline = baseDate
183
+ ? findBaselineByDate(data, baseDate)
184
+ : findBaselineByTimestamp(data, baseItem.timestamp)
185
+ if (!baseline || baseline.close <= 0) continue
186
+
187
+ const byDate = new Map<string, KLineData>()
188
+ for (const item of data) {
189
+ byDate.set(item.date ?? String(item.timestamp), item)
190
+ }
191
+
192
+ const mainItem = klineData[targetIndex]
193
+ if (!mainItem) continue
194
+ const key = mainItem.date ?? String(mainItem.timestamp)
195
+ const currentItem = byDate.get(key)
196
+ if (!currentItem || !Number.isFinite(currentItem.close)) continue
197
+
198
+ const pct = ((currentItem.close - baseline.close) / baseline.close) * 100
199
+ const color = comparisonColors?.get(spec.symbol) ?? '#f59e0b'
200
+
201
+ rows.push({
202
+ draw: (rowIndex: number) => {
203
+ let x = legendX
204
+ const y = yPaddingPx / 2 + legendYOffset + rowIndex * lineHeight
205
+
206
+ const dotRadius = 4
207
+ overlayCtx.fillStyle = color
208
+ overlayCtx.beginPath()
209
+ const fontSize = lineHeight - 6
210
+ overlayCtx.arc(x + dotRadius, y + fontSize / 2 - 1, dotRadius, 0, Math.PI * 2)
211
+ overlayCtx.fill()
212
+ x += dotRadius * 2 + 4
213
+
214
+ overlayCtx.fillStyle = colors.text.primary
215
+ overlayCtx.fillText(spec.symbol, x, y)
216
+ x += measureTextWidth(overlayCtx, spec.symbol) + gap
217
+
218
+ const sign = pct > 0 ? '+' : ''
219
+ const pctText = `${sign}${pct.toFixed(2)}%`
220
+ overlayCtx.fillStyle = pct > 0 ? colors.candleUpBody : pct < 0 ? colors.candleDownBody : colors.text.primary
221
+ overlayCtx.fillText(pctText, x, y + 1)
222
+ },
223
+ })
224
+ }
225
+ }
226
+
227
+ function findBaselineByDate(data: ReadonlyArray<KLineData>, date: string): KLineData | null {
228
+ for (const item of data) {
229
+ if (item.date && item.date >= date) return item
230
+ }
231
+ return null
232
+ }
233
+
234
+ function findBaselineByTimestamp(data: ReadonlyArray<KLineData>, timestamp: number): KLineData | null {
235
+ for (const item of data) {
236
+ if (item.timestamp >= timestamp) return item
237
+ }
238
+ return null
239
+ }
@@ -2,7 +2,7 @@ import type { RendererPlugin, RenderContext } from '../../plugin'
2
2
  import { RENDERER_PRIORITY } from '../../plugin'
3
3
  import type { KLineData } from '../../types/price'
4
4
 
5
- const COMPARISON_COLORS = ['#f59e0b', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f97316']
5
+ const DEFAULT_COMPARISON_COLOR = '#f59e0b'
6
6
 
7
7
  export function createComparisonLineRenderer(): RendererPlugin {
8
8
  return {
@@ -21,9 +21,10 @@ export function createComparisonLineRenderer(): RendererPlugin {
21
21
  if (context.pane.id !== 'main') return
22
22
 
23
23
  const baseIndex = Math.max(0, context.range.start)
24
- const mainBase = mainData[baseIndex]?.close
25
- const baseTimestamp = mainData[baseIndex]?.timestamp
26
- if (!Number.isFinite(mainBase) || mainBase <= 0 || baseTimestamp === undefined) return
24
+ const baseItem = mainData[baseIndex]
25
+ if (!baseItem || !Number.isFinite(baseItem.close) || baseItem.close <= 0) return
26
+ const mainBase = baseItem.close
27
+ const baseDate = baseItem.date ?? ''
27
28
 
28
29
  const ctx = context.ctx
29
30
  ctx.save()
@@ -31,18 +32,23 @@ export function createComparisonLineRenderer(): RendererPlugin {
31
32
  ctx.lineWidth = Math.max(1, 1.5 / context.dpr)
32
33
 
33
34
  for (let symbolIndex = 0; symbolIndex < comparisonSymbols.length; symbolIndex++) {
34
- const spec = comparisonSymbols[symbolIndex]
35
+ const spec = comparisonSymbols[symbolIndex]!
35
36
  const data = comparisonData.get(spec.symbol)
36
37
  if (!data?.length) continue
37
38
 
38
- const baseline = findBaseline(data, baseTimestamp)
39
+ const baseline = baseDate ? findBaselineByDate(data, baseDate) : findBaselineByTimestamp(data, baseItem.timestamp)
39
40
  if (!baseline || baseline.close <= 0) continue
40
41
 
41
- const byTimestamp = new Map<number, KLineData>()
42
- for (const item of data) byTimestamp.set(item.timestamp, item)
42
+ const byDate = new Map<string, KLineData>()
43
+ for (const item of data) {
44
+ if (item.date) byDate.set(item.date, item)
45
+ else byDate.set(String(item.timestamp), item)
46
+ }
47
+
48
+ const colors = context.comparisonColors
43
49
 
44
50
  ctx.beginPath()
45
- ctx.strokeStyle = COMPARISON_COLORS[symbolIndex % COMPARISON_COLORS.length]!
51
+ ctx.strokeStyle = colors?.get(spec.symbol) ?? DEFAULT_COMPARISON_COLOR
46
52
  let hasPath = false
47
53
  let previousHadPoint = false
48
54
 
@@ -52,7 +58,8 @@ export function createComparisonLineRenderer(): RendererPlugin {
52
58
  previousHadPoint = false
53
59
  continue
54
60
  }
55
- const item = byTimestamp.get(mainItem.timestamp)
61
+ const key = mainItem.date ?? String(mainItem.timestamp)
62
+ const item = byDate.get(key)
56
63
  const x = context.kLineCenters[i - context.range.start]
57
64
  if (!item || x === undefined || !Number.isFinite(item.close)) {
58
65
  previousHadPoint = false
@@ -81,7 +88,14 @@ export function createComparisonLineRenderer(): RendererPlugin {
81
88
  }
82
89
  }
83
90
 
84
- function findBaseline(data: ReadonlyArray<KLineData>, timestamp: number): KLineData | null {
91
+ function findBaselineByDate(data: ReadonlyArray<KLineData>, date: string): KLineData | null {
92
+ for (const item of data) {
93
+ if (item.date && item.date >= date) return item
94
+ }
95
+ return null
96
+ }
97
+
98
+ function findBaselineByTimestamp(data: ReadonlyArray<KLineData>, timestamp: number): KLineData | null {
85
99
  for (const item of data) {
86
100
  if (item.timestamp >= timestamp) return item
87
101
  }
@@ -1,10 +1,12 @@
1
- import type { Chart } from './chart'
2
1
  import type { SubIndicatorType } from './renderers/Indicator'
3
2
  import { createSignal, type Signal } from '../reactivity/signal'
4
3
  import { createSubIndicatorRenderer } from './renderers/Indicator'
5
4
  import { createPaneTitleRendererPlugin } from './renderers/paneTitle'
6
5
  import { createIndicatorScaleRendererPlugin } from './renderers/Indicator/scale/indicator_scale'
7
6
  import { findIndicator } from './renderers/Indicator/indicatorCatalog'
7
+ import type { IndicatorScheduler } from './indicators/scheduler'
8
+ import type { RendererPlugin, RendererPluginWithHost } from '../plugin'
9
+ import type { PaneSpec } from './chartTypes'
8
10
 
9
11
  export interface SubPaneEntry {
10
12
  paneId: string
@@ -15,6 +17,23 @@ export interface SubPaneEntry {
15
17
  paneTitleRendererName: string
16
18
  }
17
19
 
20
+ export interface SubPaneContext {
21
+ getIndicatorScheduler: () => IndicatorScheduler
22
+ hasPane: (paneId: string) => boolean
23
+ upsertPane: (def: PaneSpec) => void
24
+ getRenderer: <T extends RendererPlugin = RendererPlugin>(name: string) => T | undefined
25
+ useRenderer: (plugin: RendererPlugin | RendererPluginWithHost, config?: Record<string, unknown>) => void
26
+ removeRenderer: (name: string) => void
27
+ removePaneDefinition: (paneId: string) => void
28
+ updateRendererConfig: (name: string, config: Record<string, unknown>) => void
29
+ getRightAxisWidth: () => number
30
+ getPriceLabelWidth: () => number
31
+ getYPaddingPx: () => number
32
+ getCrosshairPos: () => { x: number; y: number } | null
33
+ getCrosshairPrice: () => number | null
34
+ getActivePaneId: () => string | null
35
+ }
36
+
18
37
  export class SubPaneManager {
19
38
  private entries = new Map<string, SubPaneEntry>()
20
39
  private _entriesSignal = createSignal<ReadonlyArray<SubPaneEntry>>([])
@@ -27,82 +46,78 @@ export class SubPaneManager {
27
46
  this._entriesSignal.set(this.getAll())
28
47
  }
29
48
 
30
- create(chart: Chart, paneId: string, indicatorId: SubIndicatorType, params: Record<string, unknown>): boolean {
49
+ create(ctx: SubPaneContext, paneId: string, indicatorId: SubIndicatorType, params: Record<string, unknown>): boolean {
31
50
  if (this.entries.has(paneId)) {
32
51
  return true
33
52
  }
34
53
 
35
54
  const scaleRendererName = `${indicatorId.toLowerCase()}_scale_${paneId}`
36
55
  const paneTitleRendererName = `paneTitle_${paneId}`
37
- const renderer = this.createIndicatorRenderer(chart, paneId, indicatorId, params)
56
+ const renderer = this.createIndicatorRenderer(ctx, paneId, indicatorId, params)
38
57
  if (!renderer) return false
39
58
  const rendererName = renderer.name
40
59
 
41
- const paneExists = chart.hasPane(paneId)
60
+ const paneExists = ctx.hasPane(paneId)
42
61
  if (!paneExists) {
43
- chart.upsertPane({ id: paneId, ratio: 1, visible: true, role: 'indicator' })
62
+ ctx.upsertPane({ id: paneId, ratio: 1, visible: true, role: 'indicator' })
44
63
  }
45
64
 
46
- const existingRenderer = chart.getRenderer(rendererName)
65
+ const existingRenderer = ctx.getRenderer(rendererName)
47
66
  if (!existingRenderer) {
48
- chart.useRenderer(renderer, params as Record<string, number | boolean | string>)
67
+ ctx.useRenderer(renderer, params as Record<string, number | boolean | string>)
49
68
  }
50
69
 
51
- this.mountScaleRenderer(chart, paneId, indicatorId, scaleRendererName)
52
- this.mountPaneTitleRenderer(chart, paneId, indicatorId, params)
70
+ this.mountScaleRenderer(ctx, paneId, indicatorId, scaleRendererName)
71
+ this.mountPaneTitleRenderer(ctx, paneId, indicatorId, params)
53
72
 
54
- // 必须在 syncSchedulerConfig 之前注册 entry,
55
- // 否则 scheduler 的 buildActiveConfig 读不到新 paneId,会将新指标的 show* 标志置为 false
56
73
  this.entries.set(paneId, { paneId, indicatorId, params, rendererName, scaleRendererName, paneTitleRendererName })
57
74
 
58
- this.syncSchedulerConfig(chart, paneId, indicatorId, params)
75
+ this.syncSchedulerConfig(ctx, paneId, indicatorId, params)
59
76
 
60
- chart.getIndicatorScheduler().onSubPaneChanged()
77
+ ctx.getIndicatorScheduler().onSubPaneChanged()
61
78
 
62
79
  this.syncEntriesSignal()
63
80
  return true
64
81
  }
65
82
 
66
- remove(chart: Chart, paneId: string): void {
83
+ remove(ctx: SubPaneContext, paneId: string): void {
67
84
  const entry = this.entries.get(paneId)
68
85
  if (!entry) return
69
86
 
70
- chart.removeRenderer(entry.rendererName)
71
- chart.removeRenderer(entry.scaleRendererName)
72
- chart.removeRenderer(entry.paneTitleRendererName)
87
+ ctx.removeRenderer(entry.rendererName)
88
+ ctx.removeRenderer(entry.scaleRendererName)
89
+ ctx.removeRenderer(entry.paneTitleRendererName)
73
90
 
74
91
  this.entries.delete(paneId)
75
92
 
76
- if (chart.hasPane(paneId)) {
77
- chart.removePaneDefinition(paneId)
93
+ if (ctx.hasPane(paneId)) {
94
+ ctx.removePaneDefinition(paneId)
78
95
  }
79
96
 
80
- chart.getIndicatorScheduler().onSubPaneChanged()
97
+ ctx.getIndicatorScheduler().onSubPaneChanged()
81
98
  this.syncEntriesSignal()
82
99
  }
83
100
 
84
- replaceIndicator(chart: Chart, paneId: string, newIndicatorId: SubIndicatorType, newParams: Record<string, unknown>): void {
101
+ replaceIndicator(ctx: SubPaneContext, paneId: string, newIndicatorId: SubIndicatorType, newParams: Record<string, unknown>): void {
85
102
  const entry = this.entries.get(paneId)
86
103
  if (!entry) return
87
104
 
88
- const oldIndicatorId = entry.indicatorId
89
-
90
- chart.removeRenderer(entry.rendererName)
91
- chart.removeRenderer(entry.scaleRendererName)
92
- chart.removeRenderer(entry.paneTitleRendererName)
105
+ ctx.removeRenderer(entry.rendererName)
106
+ ctx.removeRenderer(entry.scaleRendererName)
107
+ ctx.removeRenderer(entry.paneTitleRendererName)
93
108
 
94
109
  const newScaleRendererName = `${newIndicatorId.toLowerCase()}_scale_${paneId}`
95
110
  const newPaneTitleRendererName = `paneTitle_${paneId}`
96
- const renderer = this.createIndicatorRenderer(chart, paneId, newIndicatorId, newParams)
111
+ const renderer = this.createIndicatorRenderer(ctx, paneId, newIndicatorId, newParams)
97
112
  if (!renderer) return
98
113
  const newRendererName = renderer.name
99
114
 
100
- chart.useRenderer(renderer, newParams as Record<string, number | boolean | string>)
115
+ ctx.useRenderer(renderer, newParams as Record<string, number | boolean | string>)
101
116
 
102
- this.mountScaleRenderer(chart, paneId, newIndicatorId, newScaleRendererName)
103
- this.mountPaneTitleRenderer(chart, paneId, newIndicatorId, newParams)
117
+ this.mountScaleRenderer(ctx, paneId, newIndicatorId, newScaleRendererName)
118
+ this.mountPaneTitleRenderer(ctx, paneId, newIndicatorId, newParams)
104
119
 
105
- this.syncSchedulerConfig(chart, paneId, newIndicatorId, newParams)
120
+ this.syncSchedulerConfig(ctx, paneId, newIndicatorId, newParams)
106
121
 
107
122
  this.entries.set(paneId, {
108
123
  paneId,
@@ -113,19 +128,20 @@ export class SubPaneManager {
113
128
  paneTitleRendererName: newPaneTitleRendererName,
114
129
  })
115
130
 
116
- chart.getIndicatorScheduler().onSubPaneChanged()
131
+ ctx.getIndicatorScheduler().onSubPaneChanged()
117
132
  this.syncEntriesSignal()
118
133
  }
119
134
 
120
- updateParams(chart: Chart, paneId: string, params: Record<string, unknown>): void {
135
+ updateParams(ctx: SubPaneContext, paneId: string, params: Record<string, unknown>): void {
121
136
  const entry = this.entries.get(paneId)
122
137
  if (!entry) return
123
138
 
124
139
  entry.params = { ...params }
125
140
 
126
- chart.updateRendererConfig(entry.rendererName, params)
141
+ ctx.updateRendererConfig(entry.rendererName, params)
142
+ ctx.updateRendererConfig(entry.paneTitleRendererName, { params: entry.params, indicatorId: entry.indicatorId })
127
143
 
128
- this.syncSchedulerConfig(chart, paneId, entry.indicatorId, entry.params)
144
+ this.syncSchedulerConfig(ctx, paneId, entry.indicatorId, entry.params)
129
145
  this.syncEntriesSignal()
130
146
  }
131
147
 
@@ -134,12 +150,12 @@ export class SubPaneManager {
134
150
  }
135
151
 
136
152
  private createIndicatorRenderer(
137
- chart: Chart,
153
+ ctx: SubPaneContext,
138
154
  paneId: string,
139
155
  indicatorId: SubIndicatorType,
140
156
  params: Record<string, unknown>,
141
- ): import('../plugin').RendererPlugin {
142
- const definition = chart.getIndicatorScheduler().getIndicatorMetadata(indicatorId)
157
+ ): RendererPlugin {
158
+ const definition = ctx.getIndicatorScheduler().getIndicatorMetadata(indicatorId)
143
159
  if (!definition) {
144
160
  throw new Error(`[SubPaneManager] Unknown indicator: ${indicatorId}`)
145
161
  }
@@ -154,38 +170,38 @@ export class SubPaneManager {
154
170
  return Array.from(this.entries.keys())
155
171
  }
156
172
 
157
- clear(chart: Chart): void {
173
+ clear(ctx: SubPaneContext): void {
158
174
  for (const entry of this.entries.values()) {
159
- chart.removeRenderer(entry.rendererName)
160
- chart.removeRenderer(entry.scaleRendererName)
161
- chart.removeRenderer(entry.paneTitleRendererName)
175
+ ctx.removeRenderer(entry.rendererName)
176
+ ctx.removeRenderer(entry.scaleRendererName)
177
+ ctx.removeRenderer(entry.paneTitleRendererName)
162
178
  }
163
179
  this.entries.clear()
164
- chart.getIndicatorScheduler().onSubPaneChanged()
180
+ ctx.getIndicatorScheduler().onSubPaneChanged()
165
181
  this.syncEntriesSignal()
166
182
  }
167
183
 
168
184
  private syncSchedulerConfig(
169
- chart: Chart,
185
+ ctx: SubPaneContext,
170
186
  paneId: string,
171
187
  indicatorId: SubIndicatorType,
172
188
  params: Record<string, unknown>,
173
189
  ): void {
174
- const scheduler = chart.getIndicatorScheduler()
190
+ const scheduler = ctx.getIndicatorScheduler()
175
191
  const definition = scheduler.getIndicatorMetadata(indicatorId)
176
192
  definition?.updateConfig?.(scheduler, params, paneId)
177
193
  }
178
194
 
179
- private mountScaleRenderer(chart: Chart, paneId: string, indicatorId: SubIndicatorType, scaleRendererName: string): void {
180
- const existing = chart.getRenderer(scaleRendererName)
195
+ private mountScaleRenderer(ctx: SubPaneContext, paneId: string, indicatorId: SubIndicatorType, scaleRendererName: string): void {
196
+ const existing = ctx.getRenderer(scaleRendererName)
181
197
  if (existing) return
182
198
 
183
- const axisWidth = chart.getOption().rightAxisWidth + (chart.getOption().priceLabelWidth ?? 60)
184
- const yPaddingPx = chart.getOption().yPaddingPx
199
+ const axisWidth = ctx.getRightAxisWidth() + (ctx.getPriceLabelWidth() ?? 60)
200
+ const yPaddingPx = ctx.getYPaddingPx()
185
201
  const getCrosshair = () => {
186
- const pos = chart.interaction.crosshairPos
187
- const price = chart.interaction.crosshairPrice
188
- const activePaneId = chart.interaction.activePaneId
202
+ const pos = ctx.getCrosshairPos()
203
+ const price = ctx.getCrosshairPrice()
204
+ const activePaneId = ctx.getActivePaneId()
189
205
  if (pos && price !== null) {
190
206
  return { y: pos.y, price, activePaneId }
191
207
  }
@@ -194,14 +210,14 @@ export class SubPaneManager {
194
210
 
195
211
  const opts = { axisWidth, paneId, yPaddingPx, getCrosshair }
196
212
 
197
- const definition = chart.getIndicatorScheduler().getIndicatorMetadata(indicatorId)
213
+ const definition = ctx.getIndicatorScheduler().getIndicatorMetadata(indicatorId)
198
214
  if (definition?.scaleRendererFactory) {
199
- chart.useRenderer(definition.scaleRendererFactory({ ...opts, indicatorId }))
215
+ ctx.useRenderer(definition.scaleRendererFactory({ ...opts, indicatorId }))
200
216
  return
201
217
  }
202
218
 
203
219
  if (definition?.scale) {
204
- chart.useRenderer(createIndicatorScaleRendererPlugin({
220
+ ctx.useRenderer(createIndicatorScaleRendererPlugin({
205
221
  ...opts,
206
222
  indicatorKey: definition.scale.indicatorKey ?? definition.name,
207
223
  label: definition.scale.label ?? definition.displayName,
@@ -211,11 +227,11 @@ export class SubPaneManager {
211
227
  }
212
228
  }
213
229
 
214
- private mountPaneTitleRenderer(chart: Chart, paneId: string, indicatorId: SubIndicatorType, params: Record<string, unknown>): void {
230
+ private mountPaneTitleRenderer(ctx: SubPaneContext, paneId: string, indicatorId: SubIndicatorType, params: Record<string, unknown>): void {
215
231
  const rendererName = `paneTitle_${paneId}`
216
- const existing = chart.getRenderer(rendererName)
232
+ const existing = ctx.getRenderer(rendererName)
217
233
  if (existing) {
218
- chart.updateRendererConfig(rendererName, { params, indicatorId })
234
+ ctx.updateRendererConfig(rendererName, { params, indicatorId })
219
235
  return
220
236
  }
221
237
 
@@ -225,6 +241,6 @@ export class SubPaneManager {
225
241
  indicatorId,
226
242
  params,
227
243
  })
228
- chart.useRenderer(renderer)
244
+ ctx.useRenderer(renderer)
229
245
  }
230
246
  }
@@ -0,0 +1,104 @@
1
+ import { computeZoom } from './zoom'
2
+
3
+ export interface ZoomCommittedResult {
4
+ kWidth: number
5
+ kGap: number
6
+ zoomLevel: number
7
+ }
8
+
9
+ export interface ZoomDependencies {
10
+ getLogicalScrollLeft: () => number
11
+ getCurrentDpr: () => number
12
+ getLeftLoadBufferWidth: () => number
13
+ setScrollLeft: (v: number) => void
14
+ onZoomCommitted: (result: ZoomCommittedResult) => void
15
+ getKWidth: () => number
16
+ getKGap: () => number
17
+ getMinKWidth: () => number
18
+ getMaxKWidth: () => number
19
+ zoomLevelCount: number
20
+ initialZoomLevel: number
21
+ }
22
+
23
+ export class ChartZoomController {
24
+ private _currentZoomLevel: number
25
+ private readonly deps: ZoomDependencies
26
+
27
+ constructor(deps: ZoomDependencies) {
28
+ this.deps = deps
29
+ const clamped = Math.max(1, Math.min(deps.zoomLevelCount, deps.initialZoomLevel ?? 1))
30
+ this._currentZoomLevel = clamped
31
+ }
32
+
33
+ get currentZoomLevel(): number {
34
+ return this._currentZoomLevel
35
+ }
36
+
37
+ get zoomLevelCount(): number {
38
+ return this.deps.zoomLevelCount
39
+ }
40
+
41
+ setZoomLevel(level: number): void {
42
+ this._currentZoomLevel = Math.max(1, Math.min(this.deps.zoomLevelCount, level))
43
+ }
44
+
45
+ zoomToLevel(level: number, anchorX?: number): void {
46
+ const clamped = Math.max(1, Math.min(this.deps.zoomLevelCount, Math.round(level)))
47
+ this.applyZoom(clamped, anchorX)
48
+ }
49
+
50
+ zoomIn(anchorX?: number): void {
51
+ this.zoomToLevel(this._currentZoomLevel + 1, anchorX)
52
+ }
53
+
54
+ zoomOut(anchorX?: number): void {
55
+ this.zoomToLevel(this._currentZoomLevel - 1, anchorX)
56
+ }
57
+
58
+ handleWheel(deltaY: number, viewportX: number): void {
59
+ const delta = deltaY > 0 ? -1 : 1
60
+ const targetLevel = Math.max(1, Math.min(this.deps.zoomLevelCount, this._currentZoomLevel + delta))
61
+ if (targetLevel === this._currentZoomLevel) return
62
+ this.applyZoom(targetLevel, viewportX)
63
+ }
64
+
65
+ handlePinch(delta: number, centerClientX: number): void {
66
+ const targetLevel = Math.max(1, Math.min(this.deps.zoomLevelCount, this._currentZoomLevel + delta))
67
+ if (targetLevel === this._currentZoomLevel) return
68
+ this.applyZoom(targetLevel, centerClientX)
69
+ }
70
+
71
+ private applyZoom(targetLevel: number, anchorViewportX?: number): void {
72
+ if (targetLevel === this._currentZoomLevel) return
73
+
74
+ const delta = targetLevel - this._currentZoomLevel
75
+ const logicalScrollLeft = this.deps.getLogicalScrollLeft()
76
+ const dpr = this.deps.getCurrentDpr()
77
+
78
+ const result = computeZoom(
79
+ delta,
80
+ anchorViewportX ?? 0,
81
+ logicalScrollLeft,
82
+ this._currentZoomLevel,
83
+ this.deps.getKWidth(),
84
+ this.deps.getKGap(),
85
+ {
86
+ minKWidth: this.deps.getMinKWidth(),
87
+ maxKWidth: this.deps.getMaxKWidth(),
88
+ zoomLevelCount: this.deps.zoomLevelCount,
89
+ dpr,
90
+ },
91
+ )
92
+
93
+ if (!result) return
94
+
95
+ const domScrollLeft = result.newScrollLeft + this.deps.getLeftLoadBufferWidth()
96
+ this._currentZoomLevel = result.targetLevel
97
+ this.deps.setScrollLeft(domScrollLeft)
98
+ this.deps.onZoomCommitted({
99
+ kWidth: result.newKWidth,
100
+ kGap: result.newKGap,
101
+ zoomLevel: result.targetLevel,
102
+ })
103
+ }
104
+ }