@363045841yyt/klinechart-core 0.9.0-alpha.2 → 0.9.0-alpha.3

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 (190) hide show
  1. package/dist/controllers/createChartController.d.ts.map +1 -1
  2. package/dist/controllers/createChartController.js +9 -8
  3. package/dist/controllers/createChartController.js.map +1 -1
  4. package/dist/controllers/index.d.ts +1 -0
  5. package/dist/controllers/index.d.ts.map +1 -1
  6. package/dist/controllers/index.js.map +1 -1
  7. package/dist/controllers/types.d.ts +5 -0
  8. package/dist/controllers/types.d.ts.map +1 -1
  9. package/dist/data/binance.d.ts +1 -1
  10. package/dist/data/binance.d.ts.map +1 -1
  11. package/dist/data/binance.js +1 -1
  12. package/dist/data/binance.js.map +1 -1
  13. package/dist/engine/chart.d.ts +9 -9
  14. package/dist/engine/chart.d.ts.map +1 -1
  15. package/dist/engine/chart.js +69 -96
  16. package/dist/engine/chart.js.map +1 -1
  17. package/dist/engine/chartTypes.d.ts +5 -0
  18. package/dist/engine/chartTypes.d.ts.map +1 -1
  19. package/dist/engine/controller/interaction.d.ts +4 -2
  20. package/dist/engine/controller/interaction.d.ts.map +1 -1
  21. package/dist/engine/controller/interaction.js +18 -10
  22. package/dist/engine/controller/interaction.js.map +1 -1
  23. package/dist/engine/data/chartDataManager.d.ts +12 -18
  24. package/dist/engine/data/chartDataManager.d.ts.map +1 -1
  25. package/dist/engine/data/chartDataManager.js +46 -37
  26. package/dist/engine/data/chartDataManager.js.map +1 -1
  27. package/dist/engine/data/incrementalLoadHint.d.ts +2 -4
  28. package/dist/engine/data/incrementalLoadHint.d.ts.map +1 -1
  29. package/dist/engine/data/incrementalLoadHint.js +5 -2
  30. package/dist/engine/data/incrementalLoadHint.js.map +1 -1
  31. package/dist/engine/data/scrollCompensator.d.ts +3 -13
  32. package/dist/engine/data/scrollCompensator.d.ts.map +1 -1
  33. package/dist/engine/data/scrollCompensator.js +13 -14
  34. package/dist/engine/data/scrollCompensator.js.map +1 -1
  35. package/dist/engine/indicators/chartIndicatorManager.d.ts +16 -17
  36. package/dist/engine/indicators/chartIndicatorManager.d.ts.map +1 -1
  37. package/dist/engine/indicators/chartIndicatorManager.js +37 -37
  38. package/dist/engine/indicators/chartIndicatorManager.js.map +1 -1
  39. package/dist/engine/layout/chartPaneLayout.d.ts +11 -7
  40. package/dist/engine/layout/chartPaneLayout.d.ts.map +1 -1
  41. package/dist/engine/layout/chartPaneLayout.js +58 -16
  42. package/dist/engine/layout/chartPaneLayout.js.map +1 -1
  43. package/dist/engine/paneRenderer.d.ts +2 -0
  44. package/dist/engine/paneRenderer.d.ts.map +1 -1
  45. package/dist/engine/paneRenderer.js +15 -5
  46. package/dist/engine/paneRenderer.js.map +1 -1
  47. package/dist/engine/render/chartRenderer.d.ts +28 -22
  48. package/dist/engine/render/chartRenderer.d.ts.map +1 -1
  49. package/dist/engine/render/chartRenderer.js +117 -69
  50. package/dist/engine/render/chartRenderer.js.map +1 -1
  51. package/dist/engine/render/layers/leftYAxisLayer.d.ts +9 -2
  52. package/dist/engine/render/layers/leftYAxisLayer.d.ts.map +1 -1
  53. package/dist/engine/render/layers/leftYAxisLayer.js +11 -2
  54. package/dist/engine/render/layers/leftYAxisLayer.js.map +1 -1
  55. package/dist/engine/render/layers/mainIndicatorLegendLayer.d.ts +6 -4
  56. package/dist/engine/render/layers/mainIndicatorLegendLayer.d.ts.map +1 -1
  57. package/dist/engine/render/layers/mainIndicatorLegendLayer.js +6 -9
  58. package/dist/engine/render/layers/mainIndicatorLegendLayer.js.map +1 -1
  59. package/dist/engine/render/layers/mainIndicatorLegendLayer.test.d.ts +2 -0
  60. package/dist/engine/render/layers/mainIndicatorLegendLayer.test.d.ts.map +1 -0
  61. package/dist/engine/render/layers/mainIndicatorLegendLayer.test.js +19 -0
  62. package/dist/engine/render/layers/mainIndicatorLegendLayer.test.js.map +1 -0
  63. package/dist/engine/render/layers/yAxisLayer.d.ts +9 -2
  64. package/dist/engine/render/layers/yAxisLayer.d.ts.map +1 -1
  65. package/dist/engine/render/layers/yAxisLayer.js +11 -2
  66. package/dist/engine/render/layers/yAxisLayer.js.map +1 -1
  67. package/dist/engine/renderers/Indicator/mainIndicatorLegend.d.ts +7 -3
  68. package/dist/engine/renderers/Indicator/mainIndicatorLegend.d.ts.map +1 -1
  69. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js +234 -330
  70. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js.map +1 -1
  71. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.d.ts +72 -0
  72. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.d.ts.map +1 -0
  73. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.js +177 -0
  74. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.js.map +1 -0
  75. package/dist/engine/renderers/leftYAxis.d.ts +15 -2
  76. package/dist/engine/renderers/leftYAxis.d.ts.map +1 -1
  77. package/dist/engine/renderers/leftYAxis.js +56 -16
  78. package/dist/engine/renderers/leftYAxis.js.map +1 -1
  79. package/dist/engine/renderers/yAxis.d.ts +16 -6
  80. package/dist/engine/renderers/yAxis.d.ts.map +1 -1
  81. package/dist/engine/renderers/yAxis.js +49 -16
  82. package/dist/engine/renderers/yAxis.js.map +1 -1
  83. package/dist/engine/state/viewportState.d.ts +4 -1
  84. package/dist/engine/state/viewportState.d.ts.map +1 -1
  85. package/dist/engine/state/viewportState.js +40 -8
  86. package/dist/engine/state/viewportState.js.map +1 -1
  87. package/dist/engine/subPaneManager.d.ts +5 -3
  88. package/dist/engine/subPaneManager.d.ts.map +1 -1
  89. package/dist/engine/subPaneManager.js +3 -2
  90. package/dist/engine/subPaneManager.js.map +1 -1
  91. package/dist/engine/utils/chartZoomController.d.ts +9 -7
  92. package/dist/engine/utils/chartZoomController.d.ts.map +1 -1
  93. package/dist/engine/utils/chartZoomController.js +13 -12
  94. package/dist/engine/utils/chartZoomController.js.map +1 -1
  95. package/dist/engine/viewport/chartViewportManager.d.ts +5 -16
  96. package/dist/engine/viewport/chartViewportManager.d.ts.map +1 -1
  97. package/dist/engine/viewport/chartViewportManager.js +7 -48
  98. package/dist/engine/viewport/chartViewportManager.js.map +1 -1
  99. package/dist/engine/viewport/viewport.d.ts +14 -0
  100. package/dist/engine/viewport/viewport.d.ts.map +1 -1
  101. package/dist/engine/viewport/viewport.js +10 -0
  102. package/dist/engine/viewport/viewport.js.map +1 -1
  103. package/dist/foundation/plugin/rendererPluginManager.test.js +15 -0
  104. package/dist/foundation/plugin/rendererPluginManager.test.js.map +1 -1
  105. package/dist/foundation/plugin/types.d.ts +3 -0
  106. package/dist/foundation/plugin/types.d.ts.map +1 -1
  107. package/dist/foundation/plugin/types.js.map +1 -1
  108. package/dist/foundation/reactivity/frameTransaction.d.ts +11 -0
  109. package/dist/foundation/reactivity/frameTransaction.d.ts.map +1 -1
  110. package/dist/foundation/reactivity/frameTransaction.js +17 -2
  111. package/dist/foundation/reactivity/frameTransaction.js.map +1 -1
  112. package/dist/rendering/render/Renderer.d.ts +58 -74
  113. package/dist/rendering/render/Renderer.d.ts.map +1 -1
  114. package/dist/rendering/render/Renderer.js +8 -18
  115. package/dist/rendering/render/Renderer.js.map +1 -1
  116. package/dist/rendering/render/createWebGPURenderer.d.ts.map +1 -1
  117. package/dist/rendering/render/createWebGPURenderer.js +30 -17
  118. package/dist/rendering/render/createWebGPURenderer.js.map +1 -1
  119. package/dist/rendering/render/createWebGPUSurfaceBackend.js +3 -3
  120. package/dist/rendering/render/createWebGPUSurfaceBackend.js.map +1 -1
  121. package/dist/rendering/render/physicalLine.d.ts +4 -0
  122. package/dist/rendering/render/physicalLine.d.ts.map +1 -0
  123. package/dist/rendering/render/physicalLine.js +37 -0
  124. package/dist/rendering/render/physicalLine.js.map +1 -0
  125. package/dist/rendering/render/physicalRegion.d.ts +14 -0
  126. package/dist/rendering/render/physicalRegion.d.ts.map +1 -0
  127. package/dist/rendering/render/physicalRegion.js +15 -0
  128. package/dist/rendering/render/physicalRegion.js.map +1 -0
  129. package/dist/rendering/scene/createLayerFromPlugin.d.ts.map +1 -1
  130. package/dist/rendering/scene/createLayerFromPlugin.js +8 -3
  131. package/dist/rendering/scene/createLayerFromPlugin.js.map +1 -1
  132. package/dist/rendering/scene/createScene.d.ts.map +1 -1
  133. package/dist/rendering/scene/createScene.js +4 -3
  134. package/dist/rendering/scene/createScene.js.map +1 -1
  135. package/dist/version.d.ts +1 -1
  136. package/dist/version.js +1 -1
  137. package/package.json +1 -1
  138. package/src/__tests__/stateKernel.test.ts +1 -0
  139. package/src/controllers/createChartController.ts +9 -8
  140. package/src/controllers/index.ts +11 -0
  141. package/src/controllers/types.ts +7 -0
  142. package/src/data/binance.ts +1 -1
  143. package/src/engine/__tests__/chart.dpr.test.ts +3 -2
  144. package/src/engine/__tests__/paneRenderer.resize.test.ts +52 -54
  145. package/src/engine/__tests__/subPaneManager.test.ts +5 -3
  146. package/src/engine/chart.ts +76 -109
  147. package/src/engine/chartTypes.ts +5 -0
  148. package/src/engine/controller/__tests__/interaction.dpr.test.ts +39 -2
  149. package/src/engine/controller/interaction.ts +18 -10
  150. package/src/engine/data/__tests__/chartDataManager.incrementalLoad.test.ts +37 -17
  151. package/src/engine/data/__tests__/scrollCompensator.geometry.test.ts +87 -75
  152. package/src/engine/data/chartDataManager.ts +54 -59
  153. package/src/engine/data/incrementalLoadHint.ts +8 -7
  154. package/src/engine/data/scrollCompensator.ts +16 -24
  155. package/src/engine/indicators/__tests__/chartIndicatorManager.test.ts +18 -26
  156. package/src/engine/indicators/chartIndicatorManager.ts +62 -62
  157. package/src/engine/layout/chartPaneLayout.ts +73 -21
  158. package/src/engine/paneRenderer.ts +17 -10
  159. package/src/engine/render/chartRenderer.ts +187 -129
  160. package/src/engine/render/layers/leftYAxisLayer.ts +29 -7
  161. package/src/engine/render/layers/mainIndicatorLegendLayer.test.ts +26 -0
  162. package/src/engine/render/layers/mainIndicatorLegendLayer.ts +13 -10
  163. package/src/engine/render/layers/yAxisLayer.ts +29 -7
  164. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +270 -403
  165. package/src/engine/renderers/Indicator/mainIndicatorLegendContext.ts +282 -0
  166. package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +73 -2
  167. package/src/engine/renderers/__tests__/yAxis.renderer.test.ts +10 -6
  168. package/src/engine/renderers/leftYAxis.ts +60 -18
  169. package/src/engine/renderers/yAxis.ts +57 -22
  170. package/src/engine/state/viewportState.ts +44 -8
  171. package/src/engine/subPaneManager.ts +8 -5
  172. package/src/engine/utils/chartZoomController.ts +22 -19
  173. package/src/engine/viewport/__tests__/visibleRange.clamp.test.ts +50 -0
  174. package/src/engine/viewport/chartViewportManager.ts +9 -60
  175. package/src/engine/viewport/viewport.ts +14 -0
  176. package/src/foundation/plugin/rendererPluginManager.test.ts +18 -0
  177. package/src/foundation/plugin/types.ts +3 -0
  178. package/src/foundation/reactivity/frameTransaction.ts +20 -6
  179. package/src/rendering/render/Renderer.ts +69 -85
  180. package/src/rendering/render/__tests__/physicalLine.test.ts +51 -0
  181. package/src/rendering/render/__tests__/physicalRegion.test.ts +23 -0
  182. package/src/rendering/render/__tests__/webgpuRenderer.test.ts +31 -1
  183. package/src/rendering/render/__tests__/webgpuSurfaceBackend.test.ts +11 -0
  184. package/src/rendering/render/createWebGPURenderer.ts +30 -17
  185. package/src/rendering/render/createWebGPUSurfaceBackend.ts +3 -3
  186. package/src/rendering/render/physicalLine.ts +45 -0
  187. package/src/rendering/render/physicalRegion.ts +32 -0
  188. package/src/rendering/scene/createLayerFromPlugin.ts +8 -3
  189. package/src/rendering/scene/createScene.ts +4 -3
  190. package/src/version.ts +1 -1
@@ -0,0 +1,282 @@
1
+ import type { PluginHost, RenderContext } from '../../../foundation/plugin/index'
2
+ import { resolveThemeColors } from '../../../foundation/tokens/index'
3
+ import type { KLineData, TimeShareData } from '../../../foundation/types/price'
4
+ import type { TitleInfo, TitleValueItem } from '../../indicators/indicatorMetadata'
5
+ import type { IndicatorScheduler } from '../../indicators/scheduler'
6
+
7
+ /** 图例渲染模式:canvas 默认绘制;external 仅发布上下文,不画 Canvas 文字 */
8
+ export type LegendRenderMode = 'canvas' | 'external'
9
+
10
+ export interface LegendLayout {
11
+ x: number
12
+ y: number
13
+ lineHeight: number
14
+ gap: number
15
+ paneWidth: number
16
+ compact: boolean
17
+ }
18
+
19
+ /** 当前 K 线及图例派生的展示字段,保留 KLineData 自定义属性。 */
20
+ export type LegendOhlcRow = Omit<KLineData, 'volume'> & {
21
+ volume: number | null
22
+ volumeText: string | null
23
+ color: string
24
+ }
25
+
26
+ export interface LegendTimeshareRow {
27
+ price: number
28
+ average: number
29
+ changeAmount: number
30
+ changePercent: number
31
+ volume: number
32
+ volumeText: string
33
+ amount: number
34
+ amountText: string
35
+ changeColor: string
36
+ }
37
+
38
+ export interface LegendIndicatorRow {
39
+ name: string
40
+ params?: number[]
41
+ values?: TitleValueItem[]
42
+ }
43
+
44
+ export interface LegendComparisonRow {
45
+ symbol: string
46
+ percent: number
47
+ color: string
48
+ percentColor: string
49
+ }
50
+
51
+ /**
52
+ * 主图左上角图例完整上下文。
53
+ * Canvas 绘制与 Vue legend slot 共用同一份数据。
54
+ */
55
+ export interface LegendTemplateContext {
56
+ period: string
57
+ index: number
58
+ hasCrosshair: boolean
59
+ layout: LegendLayout
60
+ colors: {
61
+ textPrimary: string
62
+ textTertiary: string
63
+ up: string
64
+ down: string
65
+ }
66
+ ohlc: LegendOhlcRow | null
67
+ timeshare: LegendTimeshareRow | null
68
+ indicators: ReadonlyArray<LegendIndicatorRow>
69
+ comparisons: ReadonlyArray<LegendComparisonRow>
70
+ /** 当前索引处的 K 线(分时模式下可能无 close) */
71
+ bar: KLineData | TimeShareData | null
72
+ }
73
+
74
+ export interface BuildLegendTemplateContextInput {
75
+ context: RenderContext
76
+ host: PluginHost | null
77
+ yPaddingPx: number
78
+ }
79
+
80
+ export function formatVolumeShort(v: number): string {
81
+ if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
82
+ if (v >= 1e4) return (v / 1e4).toFixed(2) + '万'
83
+ return v.toFixed(2)
84
+ }
85
+
86
+ export function formatAmountShort(v: number): string {
87
+ if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
88
+ if (v >= 1e4) return (v / 1e4).toFixed(2) + '万'
89
+ return v.toFixed(2)
90
+ }
91
+
92
+ export function buildLegendTemplateContext(
93
+ input: BuildLegendTemplateContextInput,
94
+ ): LegendTemplateContext | null {
95
+ const { context, host, yPaddingPx } = input
96
+ const klineData = context.data as KLineData[]
97
+ if (!klineData.length) return null
98
+
99
+ const colors = resolveThemeColors(
100
+ context.theme,
101
+ context.isAsiaMarket,
102
+ context.colorPresetSettings,
103
+ )
104
+ const fontSize = 12
105
+ const lineHeight = fontSize + 6
106
+ const legendX = 12
107
+ const gap = 10
108
+ const legendYOffset = 6
109
+ const compact = context.paneWidth < 400
110
+ const range = context.range
111
+ const crosshairIndex = context.crosshairIndex
112
+ const hasCrosshair = typeof crosshairIndex === 'number'
113
+ const targetIndex = hasCrosshair
114
+ ? crosshairIndex
115
+ : Math.min(range.end - 1, klineData.length - 1)
116
+
117
+ const layout: LegendLayout = {
118
+ x: legendX,
119
+ y: yPaddingPx / 2 + legendYOffset,
120
+ lineHeight,
121
+ gap,
122
+ paneWidth: context.paneWidth,
123
+ compact,
124
+ }
125
+
126
+ let timeshare: LegendTimeshareRow | null = null
127
+ if (context.period === 'timeshare') {
128
+ const tsData = context.data as TimeShareData[]
129
+ const preClose = (context.settings?.preClose as number) ?? tsData[0]?.price ?? 0
130
+ const item = tsData[targetIndex]
131
+ if (item) {
132
+ const changeAmount = item.price - preClose
133
+ const changePercent = preClose !== 0 ? (changeAmount / preClose) * 100 : 0
134
+ timeshare = {
135
+ price: item.price,
136
+ average: item.average,
137
+ changeAmount,
138
+ changePercent,
139
+ volume: item.volume,
140
+ volumeText: formatVolumeShort(item.volume),
141
+ amount: item.amount,
142
+ amountText: formatAmountShort(item.amount),
143
+ changeColor: changeAmount >= 0 ? colors.candleUpBody : colors.candleDownBody,
144
+ }
145
+ }
146
+ }
147
+
148
+ let ohlc: LegendOhlcRow | null = null
149
+ if (hasCrosshair) {
150
+ const k = klineData[targetIndex]
151
+ if (k && typeof k.close === 'number') {
152
+ const isUp = k.close >= k.open
153
+ ohlc = {
154
+ ...k,
155
+ volume: typeof k.volume === 'number' ? k.volume : null,
156
+ volumeText: typeof k.volume === 'number' ? formatVolumeShort(k.volume) : null,
157
+ color: isUp ? colors.candleUpBody : colors.candleDownBody,
158
+ }
159
+ }
160
+ }
161
+
162
+ const indicators = collectIndicatorRows(host, klineData, targetIndex)
163
+ const comparisons = collectComparisonRows(context, klineData, targetIndex, range, colors)
164
+
165
+ return {
166
+ period: context.period,
167
+ index: targetIndex,
168
+ hasCrosshair,
169
+ layout,
170
+ colors: {
171
+ textPrimary: colors.text.primary,
172
+ textTertiary: colors.text.tertiary,
173
+ up: colors.candleUpBody,
174
+ down: colors.candleDownBody,
175
+ },
176
+ ohlc,
177
+ timeshare,
178
+ indicators,
179
+ comparisons,
180
+ bar: klineData[targetIndex] ?? null,
181
+ }
182
+ }
183
+
184
+ function collectIndicatorRows(
185
+ host: PluginHost | null,
186
+ klineData: KLineData[],
187
+ targetIndex: number,
188
+ ): LegendIndicatorRow[] {
189
+ if (!host || typeof host.getService !== 'function') return []
190
+ const scheduler = host.getService<IndicatorScheduler>('indicatorScheduler')
191
+ if (!scheduler) return []
192
+
193
+ const rows: LegendIndicatorRow[] = []
194
+ for (const meta of scheduler.getMainIndicators()) {
195
+ if (!meta.getTitleInfo) continue
196
+ if (!scheduler.isMainIndicatorActive(meta.name)) continue
197
+ const params = scheduler.getMainIndicatorParams(meta.name) ?? {}
198
+ const titleInfo: TitleInfo | null = meta.getTitleInfo(
199
+ klineData,
200
+ targetIndex,
201
+ params as Record<string, number | boolean | string>,
202
+ host,
203
+ 'main',
204
+ )
205
+ if (!titleInfo) continue
206
+ rows.push({
207
+ name: titleInfo.name,
208
+ params: titleInfo.params,
209
+ values: titleInfo.values,
210
+ })
211
+ }
212
+ return rows
213
+ }
214
+
215
+ function collectComparisonRows(
216
+ context: RenderContext,
217
+ klineData: KLineData[],
218
+ targetIndex: number,
219
+ range: { start: number; end: number },
220
+ colors: ReturnType<typeof resolveThemeColors>,
221
+ ): LegendComparisonRow[] {
222
+ const comparisonSymbols = context.comparisonSymbols
223
+ const comparisonData = context.comparisonData
224
+ const comparisonColors = context.comparisonColors
225
+ if (!comparisonSymbols?.length || !comparisonData?.size) return []
226
+
227
+ const baseIndex = Math.max(0, range.start)
228
+ const baseItem = klineData[baseIndex]
229
+ if (!baseItem || !Number.isFinite(baseItem.close) || baseItem.close <= 0) return []
230
+
231
+ const baseDate = baseItem.date ?? ''
232
+ const rows: LegendComparisonRow[] = []
233
+
234
+ for (const spec of comparisonSymbols) {
235
+ const data = comparisonData.get(spec.symbol)
236
+ if (!data?.length) continue
237
+
238
+ const baseline = baseDate
239
+ ? findBaselineByDate(data, baseDate)
240
+ : findBaselineByTimestamp(data, baseItem.timestamp)
241
+ if (!baseline || baseline.close <= 0) continue
242
+
243
+ const byDate = new Map<string, KLineData>()
244
+ for (const item of data) {
245
+ byDate.set(item.date ?? String(item.timestamp), item)
246
+ }
247
+
248
+ const mainItem = klineData[targetIndex]
249
+ if (!mainItem) continue
250
+ const key = mainItem.date ?? String(mainItem.timestamp)
251
+ const currentItem = byDate.get(key)
252
+ if (!currentItem || !Number.isFinite(currentItem.close)) continue
253
+
254
+ const percent = ((currentItem.close - baseline.close) / baseline.close) * 100
255
+ const color = comparisonColors?.get(spec.symbol) ?? '#f59e0b'
256
+ rows.push({
257
+ symbol: spec.symbol,
258
+ percent,
259
+ color,
260
+ percentColor:
261
+ percent > 0 ? colors.candleUpBody : percent < 0 ? colors.candleDownBody : colors.text.primary,
262
+ })
263
+ }
264
+ return rows
265
+ }
266
+
267
+ function findBaselineByDate(data: ReadonlyArray<KLineData>, date: string): KLineData | null {
268
+ for (const item of data) {
269
+ if (item.date && item.date >= date) return item
270
+ }
271
+ return null
272
+ }
273
+
274
+ function findBaselineByTimestamp(
275
+ data: ReadonlyArray<KLineData>,
276
+ timestamp: number,
277
+ ): KLineData | null {
278
+ for (const item of data) {
279
+ if (item.timestamp >= timestamp) return item
280
+ }
281
+ return null
282
+ }
@@ -20,7 +20,7 @@ import type { KLineData } from '@/types/price'
20
20
  // Type helper for tests - we know these methods exist on the implementation
21
21
  interface TestableLegendRenderer extends RendererPluginWithHost {
22
22
  draw: (context: RenderContext) => void
23
- getConfig: () => { yPaddingPx: number }
23
+ getConfig: () => { yPaddingPx: number; renderMode: 'canvas' | 'external' }
24
24
  setConfig: (config: Record<string, unknown>) => void
25
25
  }
26
26
 
@@ -210,7 +210,7 @@ describe('createMainIndicatorLegendRendererPlugin', () => {
210
210
  const plugin = createMainIndicatorLegendRendererPlugin({ yPaddingPx: 20 })
211
211
 
212
212
  expect(plugin.name).toBe('mainIndicatorLegend')
213
- expect(plugin.version).toBe('2.1.0')
213
+ expect(plugin.version).toBe('2.2.0')
214
214
  expect(plugin.description).toBe('主图指标图例渲染器(MA 数据来自 StateStore)')
215
215
  expect(plugin.debugName).toBe('主图指标图例')
216
216
  expect(plugin.paneId).toBe('main')
@@ -552,3 +552,74 @@ describe('MainIndicatorLegend with other indicators', () => {
552
552
  expect(wmaLabelCalls.length).toBeGreaterThan(0)
553
553
  })
554
554
  })
555
+
556
+ describe('MainIndicatorLegend external mode & context callback', () => {
557
+ it('publishes legend context via onContext while still painting in canvas mode', () => {
558
+ const onContext = vi.fn()
559
+ const scheduler = createMockScheduler({ ma: { getTitleInfo: createMAGetTitleInfo() } }, ['ma'])
560
+ const state = createTestMARenderState()
561
+ const mockHost = createMockPluginHost(state, scheduler)
562
+ const plugin = createMainIndicatorLegendRendererPlugin({
563
+ yPaddingPx: 20,
564
+ onContext,
565
+ }) as TestableLegendRenderer
566
+ plugin.onInstall(mockHost)
567
+
568
+ const ctx = createMockCanvasContext()
569
+ const context = createMockRenderContext(ctx, { crosshairIndex: 50 })
570
+ plugin.draw(context)
571
+
572
+ expect(onContext).toHaveBeenCalledTimes(1)
573
+ const legend = onContext.mock.calls[0]![0]
574
+ expect(legend).not.toBeNull()
575
+ expect(legend.index).toBe(50)
576
+ expect(legend.hasCrosshair).toBe(true)
577
+ expect(legend.ohlc).not.toBeNull()
578
+ expect(legend.indicators.some((row: { name: string }) => row.name === 'MA')).toBe(true)
579
+ expect(vi.mocked(ctx.fillText).mock.calls.length).toBeGreaterThan(0)
580
+ })
581
+
582
+ it('does not paint canvas text when renderMode is external but still publishes context', () => {
583
+ const onContext = vi.fn()
584
+ const scheduler = createMockScheduler({ ma: { getTitleInfo: createMAGetTitleInfo() } }, ['ma'])
585
+ const state = createTestMARenderState()
586
+ const mockHost = createMockPluginHost(state, scheduler)
587
+ const plugin = createMainIndicatorLegendRendererPlugin({
588
+ yPaddingPx: 20,
589
+ onContext,
590
+ }) as TestableLegendRenderer
591
+ plugin.onInstall(mockHost)
592
+ plugin.setConfig({ renderMode: 'external' })
593
+ expect(plugin.getConfig().renderMode).toBe('external')
594
+
595
+ const ctx = createMockCanvasContext()
596
+ const context = createMockRenderContext(ctx, { crosshairIndex: 10 })
597
+ plugin.draw(context)
598
+
599
+ expect(onContext).toHaveBeenCalledTimes(1)
600
+ expect(onContext.mock.calls[0]![0]).not.toBeNull()
601
+ expect(vi.mocked(ctx.fillText)).not.toHaveBeenCalled()
602
+ })
603
+
604
+ it('retains custom KLineData fields in the ohlc slot context', () => {
605
+ const onContext = vi.fn()
606
+ const plugin = createMainIndicatorLegendRendererPlugin({
607
+ yPaddingPx: 20,
608
+ onContext,
609
+ }) as TestableLegendRenderer
610
+ plugin.onInstall(createMockPluginHost())
611
+ plugin.setConfig({ renderMode: 'external' })
612
+
613
+ const ctx = createMockCanvasContext()
614
+ const context = createMockRenderContext(ctx, { crosshairIndex: 10 })
615
+ Object.assign((context.data as KLineData[])[10]!, {
616
+ turnoverRate: 3.14,
617
+ customLabel: 'featured',
618
+ })
619
+ plugin.draw(context)
620
+
621
+ const legend = onContext.mock.calls[0]![0]
622
+ expect(legend.ohlc.turnoverRate).toBe(3.14)
623
+ expect(legend.ohlc.customLabel).toBe('featured')
624
+ })
625
+ })
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
2
 
3
- import { createYAxisRendererPlugin } from '@/core/renderers/yAxis'
3
+ import { createYAxisRendererPlugin, createYAxisOverlayRendererPlugin } from '@/core/renderers/yAxis'
4
4
  import type { RenderContext, PaneInfo, YAxisTick } from '@/plugin'
5
5
 
6
6
  vi.mock('@/utils/kLineDraw/axis', () => ({
@@ -135,9 +135,10 @@ describe('yAxis renderer', () => {
135
135
  })
136
136
 
137
137
  it('draws last price label via drawAxisPriceLabel for main pane when yAxisLabels contains lastPrice', () => {
138
- const plugin = createYAxisRendererPlugin({ axisWidth: 80, yPaddingPx: 0 })
138
+ const plugin = createYAxisOverlayRendererPlugin({ axisWidth: 80, yPaddingPx: 0 })
139
139
  const context = createContext({
140
140
  pane: createPane({ id: 'main' }),
141
+ yAxisOverlayCtx: createCtx(),
141
142
  yAxisLabels: [
142
143
  {
143
144
  type: 'lastPrice',
@@ -165,12 +166,15 @@ describe('yAxis renderer', () => {
165
166
  })
166
167
 
167
168
  it('draws crosshair price label exactly once for active pane', () => {
168
- const plugin = createYAxisRendererPlugin({
169
+ const plugin = createYAxisOverlayRendererPlugin({
169
170
  axisWidth: 80,
170
171
  yPaddingPx: 0,
171
172
  getCrosshair: () => ({ y: 55, price: 95, activePaneId: 'main' }),
172
173
  })
173
- const context = createContext({ pane: createPane({ id: 'main' }) })
174
+ const context = createContext({
175
+ pane: createPane({ id: 'main' }),
176
+ yAxisOverlayCtx: createCtx(),
177
+ })
174
178
 
175
179
  plugin.draw(context)
176
180
 
@@ -178,12 +182,12 @@ describe('yAxis renderer', () => {
178
182
  })
179
183
 
180
184
  it('does not draw crosshair price label when getCrosshair returns null', () => {
181
- const plugin = createYAxisRendererPlugin({
185
+ const plugin = createYAxisOverlayRendererPlugin({
182
186
  axisWidth: 80,
183
187
  yPaddingPx: 0,
184
188
  getCrosshair: () => null,
185
189
  })
186
- const context = createContext()
190
+ const context = createContext({ yAxisOverlayCtx: createCtx() })
187
191
 
188
192
  plugin.draw(context)
189
193
 
@@ -6,49 +6,52 @@ import { roundToPhysicalPixel } from '../../foundation/utils/pixelAlign'
6
6
  import { getFont, setCanvasFont } from '../../foundation/tokens/fonts'
7
7
  import { priceAtYForScaleType, type ScaleType } from '../utils/tickPosition'
8
8
 
9
- export function createLeftYAxisRendererPlugin(options: {
9
+ type LeftYAxisOptions = {
10
10
  axisWidth: number
11
11
  yPaddingPx: number
12
12
  getCrosshair?: () => { y: number; price: number; activePaneId: string | null } | null
13
- }): RendererPlugin {
13
+ }
14
+
15
+ function shouldShowLeftAxis(period: string, settings: RenderContext['settings']): boolean {
16
+ if (period === 'timeshare') return true
17
+ const leftType = settings?.leftAxisType as string | undefined
18
+ return Boolean(leftType && leftType !== 'none')
19
+ }
20
+
21
+ /**
22
+ * 左 Y 轴静态层:刻度,画到 leftAxisCtx(main 级刷新)
23
+ */
24
+ export function createLeftYAxisStaticRendererPlugin(options: LeftYAxisOptions): RendererPlugin {
14
25
  return {
15
26
  name: 'leftYAxis',
16
- version: '1.0.0',
17
- description: '左侧Y轴价格刻度渲染器',
18
- debugName: '左侧Y',
27
+ version: '2.0.0',
28
+ description: '左侧Y轴价格刻度渲染器(静态)',
29
+ debugName: '左侧Y轴刻度',
19
30
  paneId: GLOBAL_PANE_ID,
20
31
  priority: RENDERER_PRIORITY.SYSTEM_YAXIS,
21
- layer: 'overlay',
22
32
 
23
33
  draw(context: RenderContext) {
24
34
  const { leftAxisCtx, pane, dpr, period } = context
25
35
  if (!leftAxisCtx) return
36
+ if (!shouldShowLeftAxis(period, context.settings)) return
37
+ if (!pane.capabilities.showPriceAxisTicks) return
38
+ if (!context.yAxisTicks) return
26
39
 
27
40
  const axisWidth = leftAxisCtx.canvas ? leftAxisCtx.canvas.width / dpr : 0
28
41
  if (axisWidth <= 0) return
29
42
 
30
- // 分时模式始终显示左轴(线性),不受设置约束
31
- if (period !== 'timeshare') {
32
- const leftType = context.settings?.leftAxisType as string | undefined
33
- if (!leftType || leftType === 'none') return
34
- }
35
-
36
43
  const tokenColors = resolveThemeColors(
37
44
  context.theme,
38
45
  context.isAsiaMarket,
39
46
  context.colorPresetSettings,
40
47
  )
41
48
 
42
- if (!pane.capabilities.showPriceAxisTicks) return
43
-
44
49
  const scaleType: ScaleType =
45
50
  period === 'timeshare'
46
51
  ? 'linear'
47
52
  : ((context.settings?.leftAxisType as ScaleType) ?? 'linear')
48
53
  const paneScaleType = pane.yAxis.getScaleType()
49
54
 
50
- if (!context.yAxisTicks) return
51
-
52
55
  leftAxisCtx.clearRect(0, 0, axisWidth, pane.height)
53
56
 
54
57
  const font = getFont(12)
@@ -58,7 +61,6 @@ export function createLeftYAxisRendererPlugin(options: {
58
61
  leftAxisCtx.fillStyle = tokenColors.text.secondary
59
62
 
60
63
  const textX = roundToPhysicalPixel(axisWidth / 2, dpr)
61
-
62
64
  const needsOwnValues = scaleType !== paneScaleType
63
65
  const crosshairPriceRange = pane.yAxis.getDisplayRange()
64
66
 
@@ -92,14 +94,47 @@ export function createLeftYAxisRendererPlugin(options: {
92
94
  }
93
95
  leftAxisCtx.fillText(formatTick(displayValue), textX, tick.y)
94
96
  }
97
+ },
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 左 Y 轴动态层:十字线价签,画到 leftAxisOverlayCtx(overlay 级刷新)
103
+ */
104
+ export function createLeftYAxisOverlayRendererPlugin(options: LeftYAxisOptions): RendererPlugin {
105
+ return {
106
+ name: 'leftYAxisOverlay',
107
+ version: '2.0.0',
108
+ description: '左侧Y轴动态标签渲染器',
109
+ debugName: '左侧Y轴标签',
110
+ paneId: GLOBAL_PANE_ID,
111
+ priority: RENDERER_PRIORITY.SYSTEM_YAXIS + 1,
112
+ layer: 'overlay',
113
+
114
+ draw(context: RenderContext) {
115
+ const { leftAxisOverlayCtx, leftAxisCtx, pane, dpr, period } = context
116
+ if (!shouldShowLeftAxis(period, context.settings)) return
117
+
118
+ const targetCtx = leftAxisOverlayCtx ?? leftAxisCtx
119
+ if (!targetCtx) return
120
+
121
+ const axisWidth = targetCtx.canvas ? targetCtx.canvas.width / dpr : 0
122
+ if (axisWidth <= 0) return
123
+
124
+ targetCtx.clearRect(0, 0, axisWidth, pane.height)
95
125
 
96
126
  const crosshair = options.getCrosshair?.()
97
127
  if (!crosshair || crosshair.activePaneId !== pane.id || crosshair.price === null) return
98
128
 
129
+ const scaleType: ScaleType =
130
+ period === 'timeshare'
131
+ ? 'linear'
132
+ : ((context.settings?.leftAxisType as ScaleType) ?? 'linear')
99
133
  const isCrosshairPercent = scaleType === 'percent'
100
134
  const crosshairPrice = isCrosshairPercent
101
135
  ? pane.yAxis.toPercent(crosshair.price)
102
136
  : crosshair.price
137
+ const crosshairPriceRange = pane.yAxis.getDisplayRange()
103
138
  const crosshairLabelRange: { minPrice: number; maxPrice: number } = isCrosshairPercent
104
139
  ? (() => {
105
140
  const p = pane.yAxis.getDisplayPercentRange()
@@ -114,7 +149,7 @@ export function createLeftYAxisRendererPlugin(options: {
114
149
  : undefined
115
150
 
116
151
  drawCrosshairPriceLabel(
117
- leftAxisCtx,
152
+ targetCtx,
118
153
  {
119
154
  x: 0,
120
155
  y: pane.top,
@@ -136,3 +171,10 @@ export function createLeftYAxisRendererPlugin(options: {
136
171
  },
137
172
  }
138
173
  }
174
+
175
+ /**
176
+ * @deprecated 使用 createLeftYAxisStaticRendererPlugin + createLeftYAxisOverlayRendererPlugin
177
+ */
178
+ export function createLeftYAxisRendererPlugin(options: LeftYAxisOptions): RendererPlugin {
179
+ return createLeftYAxisStaticRendererPlugin(options)
180
+ }
@@ -1,37 +1,37 @@
1
1
  import type { RendererPlugin, RenderContext } from '../../foundation/plugin/index'
2
2
  import { RENDERER_PRIORITY, GLOBAL_PANE_ID } from '../../foundation/plugin/index'
3
3
  import { resolveThemeColors } from '../../foundation/tokens/index'
4
- import type { KLineData } from '../../foundation/types/price'
5
4
  import { drawCrosshairPriceLabel, drawAxisPriceLabel } from '../../foundation/utils/kLineDraw/axis'
6
5
  import { roundToPhysicalPixel } from '../../foundation/utils/pixelAlign'
7
6
  import { getFont, setCanvasFont } from '../../foundation/tokens/fonts'
8
7
 
9
- /**
10
- * 创建 Y 轴渲染器插件
11
- * 按 pane capability 决定是否绘制刻度与价格标签
12
- */
13
- export function createYAxisRendererPlugin(options: {
8
+ type YAxisOptions = {
14
9
  axisWidth: number
15
10
  yPaddingPx: number
16
11
  getCrosshair?: () => { y: number; price: number; activePaneId: string | null } | null
17
- }): RendererPlugin {
12
+ }
13
+
14
+ function shouldShowRightAxis(period: string, settings: RenderContext['settings']): boolean {
15
+ if (period === 'timeshare') return true
16
+ const rightType = settings?.rightAxisType as string | undefined
17
+ return rightType !== 'none'
18
+ }
19
+
20
+ /**
21
+ * Y 轴静态层:刻度 + 价格范围带,画到 yAxisCtx(main 级刷新)
22
+ */
23
+ export function createYAxisStaticRendererPlugin(options: YAxisOptions): RendererPlugin {
18
24
  return {
19
25
  name: 'yAxis',
20
- version: '1.0.0',
21
- description: 'Y轴价格刻度渲染器',
22
- debugName: 'Y',
26
+ version: '2.0.0',
27
+ description: 'Y轴价格刻度渲染器(静态)',
28
+ debugName: 'Y轴刻度',
23
29
  paneId: GLOBAL_PANE_ID,
24
30
  priority: RENDERER_PRIORITY.SYSTEM_YAXIS,
25
- layer: 'overlay',
26
31
 
27
32
  draw(context: RenderContext) {
28
- const { ctx, pane, dpr, yAxisCtx, data, period } = context
29
-
30
- // 分时模式始终显示右轴,不受设置约束
31
- if (period !== 'timeshare') {
32
- const rightType = context.settings?.rightAxisType as string | undefined
33
- if (rightType === 'none') return
34
- }
33
+ const { ctx, pane, dpr, yAxisCtx, period } = context
34
+ if (!shouldShowRightAxis(period, context.settings)) return
35
35
 
36
36
  const tokenColors = resolveThemeColors(
37
37
  context.theme,
@@ -39,11 +39,8 @@ export function createYAxisRendererPlugin(options: {
39
39
  context.colorPresetSettings,
40
40
  )
41
41
  const scaleType = pane.yAxis.getScaleType()
42
-
43
42
  const targetCtx = yAxisCtx || ctx
44
43
  const axisWidth = yAxisCtx?.canvas ? yAxisCtx.canvas.width / dpr : options.axisWidth
45
- const displayRange = pane.yAxis.getDisplayRange(pane.priceRange)
46
-
47
44
  const isPercent = scaleType === 'percent' && pane.role === 'price'
48
45
 
49
46
  if (pane.capabilities.showPriceAxisTicks && context.yAxisTicks) {
@@ -70,7 +67,7 @@ export function createYAxisRendererPlugin(options: {
70
67
  }
71
68
  }
72
69
 
73
- // 绘制价格范围带(先于标签,使标签覆盖在范围带之上)
70
+ // 价格范围带(先于标签,使标签覆盖在范围带之上)
74
71
  if (context.yAxisRanges && pane.role === 'price') {
75
72
  for (const range of context.yAxisRanges) {
76
73
  const topY = range.topY + pane.top
@@ -83,6 +80,36 @@ export function createYAxisRendererPlugin(options: {
83
80
  targetCtx.restore()
84
81
  }
85
82
  }
83
+ },
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Y 轴动态层:yAxisLabels + 十字线价签,画到 yAxisOverlayCtx(overlay 级刷新)
89
+ */
90
+ export function createYAxisOverlayRendererPlugin(options: YAxisOptions): RendererPlugin {
91
+ return {
92
+ name: 'yAxisOverlay',
93
+ version: '2.0.0',
94
+ description: 'Y轴动态标签渲染器',
95
+ debugName: 'Y轴标签',
96
+ paneId: GLOBAL_PANE_ID,
97
+ priority: RENDERER_PRIORITY.SYSTEM_YAXIS + 1,
98
+ layer: 'overlay',
99
+
100
+ draw(context: RenderContext) {
101
+ const { pane, dpr, yAxisOverlayCtx, yAxisCtx, period } = context
102
+ if (!shouldShowRightAxis(period, context.settings)) return
103
+
104
+ const targetCtx = yAxisOverlayCtx ?? yAxisCtx
105
+ if (!targetCtx) return
106
+
107
+ const axisWidth = targetCtx.canvas ? targetCtx.canvas.width / dpr : options.axisWidth
108
+ targetCtx.clearRect(0, 0, axisWidth, pane.height)
109
+
110
+ const scaleType = pane.yAxis.getScaleType()
111
+ const displayRange = pane.yAxis.getDisplayRange(pane.priceRange)
112
+ const isPercent = scaleType === 'percent' && pane.role === 'price'
86
113
 
87
114
  // 绘制来自 yAxisLabels 的标签(最新价格、极值点、绘图锚点等)
88
115
  if (context.yAxisLabels && pane.role === 'price') {
@@ -151,3 +178,11 @@ export function createYAxisRendererPlugin(options: {
151
178
  },
152
179
  }
153
180
  }
181
+
182
+ /**
183
+ * @deprecated 使用 createYAxisStaticRendererPlugin + createYAxisOverlayRendererPlugin
184
+ * 保留兼容:静态+动态合画到 yAxisCtx
185
+ */
186
+ export function createYAxisRendererPlugin(options: YAxisOptions): RendererPlugin {
187
+ return createYAxisStaticRendererPlugin(options)
188
+ }