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

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 (234) 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/data/timeShareBuffer.d.ts +3 -0
  14. package/dist/data/timeShareBuffer.d.ts.map +1 -1
  15. package/dist/data/timeShareBuffer.js +13 -0
  16. package/dist/data/timeShareBuffer.js.map +1 -1
  17. package/dist/engine/chart.d.ts +9 -9
  18. package/dist/engine/chart.d.ts.map +1 -1
  19. package/dist/engine/chart.js +87 -101
  20. package/dist/engine/chart.js.map +1 -1
  21. package/dist/engine/chartTypes.d.ts +5 -0
  22. package/dist/engine/chartTypes.d.ts.map +1 -1
  23. package/dist/engine/controller/interaction.d.ts +4 -2
  24. package/dist/engine/controller/interaction.d.ts.map +1 -1
  25. package/dist/engine/controller/interaction.js +18 -10
  26. package/dist/engine/controller/interaction.js.map +1 -1
  27. package/dist/engine/data/chartDataManager.d.ts +14 -18
  28. package/dist/engine/data/chartDataManager.d.ts.map +1 -1
  29. package/dist/engine/data/chartDataManager.js +55 -37
  30. package/dist/engine/data/chartDataManager.js.map +1 -1
  31. package/dist/engine/data/incrementalLoadHint.d.ts +2 -4
  32. package/dist/engine/data/incrementalLoadHint.d.ts.map +1 -1
  33. package/dist/engine/data/incrementalLoadHint.js +5 -2
  34. package/dist/engine/data/incrementalLoadHint.js.map +1 -1
  35. package/dist/engine/data/scrollCompensator.d.ts +3 -13
  36. package/dist/engine/data/scrollCompensator.d.ts.map +1 -1
  37. package/dist/engine/data/scrollCompensator.js +13 -14
  38. package/dist/engine/data/scrollCompensator.js.map +1 -1
  39. package/dist/engine/indicators/chartIndicatorManager.d.ts +16 -17
  40. package/dist/engine/indicators/chartIndicatorManager.d.ts.map +1 -1
  41. package/dist/engine/indicators/chartIndicatorManager.js +37 -37
  42. package/dist/engine/indicators/chartIndicatorManager.js.map +1 -1
  43. package/dist/engine/layout/chartPaneLayout.d.ts +11 -7
  44. package/dist/engine/layout/chartPaneLayout.d.ts.map +1 -1
  45. package/dist/engine/layout/chartPaneLayout.js +58 -16
  46. package/dist/engine/layout/chartPaneLayout.js.map +1 -1
  47. package/dist/engine/modes/timeShareMath.d.ts +56 -0
  48. package/dist/engine/modes/timeShareMath.d.ts.map +1 -0
  49. package/dist/engine/modes/timeShareMath.js +90 -0
  50. package/dist/engine/modes/timeShareMath.js.map +1 -0
  51. package/dist/engine/modes/timeShareMode.d.ts +5 -0
  52. package/dist/engine/modes/timeShareMode.d.ts.map +1 -1
  53. package/dist/engine/modes/timeShareMode.js +27 -27
  54. package/dist/engine/modes/timeShareMode.js.map +1 -1
  55. package/dist/engine/paneRenderer.d.ts +2 -0
  56. package/dist/engine/paneRenderer.d.ts.map +1 -1
  57. package/dist/engine/paneRenderer.js +15 -5
  58. package/dist/engine/paneRenderer.js.map +1 -1
  59. package/dist/engine/render/chartRenderer.d.ts +28 -22
  60. package/dist/engine/render/chartRenderer.d.ts.map +1 -1
  61. package/dist/engine/render/chartRenderer.js +143 -85
  62. package/dist/engine/render/chartRenderer.js.map +1 -1
  63. package/dist/engine/render/layers/leftYAxisLayer.d.ts +9 -2
  64. package/dist/engine/render/layers/leftYAxisLayer.d.ts.map +1 -1
  65. package/dist/engine/render/layers/leftYAxisLayer.js +11 -2
  66. package/dist/engine/render/layers/leftYAxisLayer.js.map +1 -1
  67. package/dist/engine/render/layers/mainIndicatorLegendLayer.d.ts +6 -4
  68. package/dist/engine/render/layers/mainIndicatorLegendLayer.d.ts.map +1 -1
  69. package/dist/engine/render/layers/mainIndicatorLegendLayer.js +6 -9
  70. package/dist/engine/render/layers/mainIndicatorLegendLayer.js.map +1 -1
  71. package/dist/engine/render/layers/mainIndicatorLegendLayer.test.d.ts +2 -0
  72. package/dist/engine/render/layers/mainIndicatorLegendLayer.test.d.ts.map +1 -0
  73. package/dist/engine/render/layers/mainIndicatorLegendLayer.test.js +19 -0
  74. package/dist/engine/render/layers/mainIndicatorLegendLayer.test.js.map +1 -0
  75. package/dist/engine/render/layers/yAxisLayer.d.ts +9 -2
  76. package/dist/engine/render/layers/yAxisLayer.d.ts.map +1 -1
  77. package/dist/engine/render/layers/yAxisLayer.js +11 -2
  78. package/dist/engine/render/layers/yAxisLayer.js.map +1 -1
  79. package/dist/engine/renderers/Indicator/mainIndicatorLegend.d.ts +7 -3
  80. package/dist/engine/renderers/Indicator/mainIndicatorLegend.d.ts.map +1 -1
  81. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js +234 -330
  82. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js.map +1 -1
  83. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.d.ts +73 -0
  84. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.d.ts.map +1 -0
  85. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.js +182 -0
  86. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.js.map +1 -0
  87. package/dist/engine/renderers/leftYAxis.d.ts +15 -2
  88. package/dist/engine/renderers/leftYAxis.d.ts.map +1 -1
  89. package/dist/engine/renderers/leftYAxis.js +56 -16
  90. package/dist/engine/renderers/leftYAxis.js.map +1 -1
  91. package/dist/engine/renderers/timeShare.d.ts.map +1 -1
  92. package/dist/engine/renderers/timeShare.js +20 -9
  93. package/dist/engine/renderers/timeShare.js.map +1 -1
  94. package/dist/engine/renderers/yAxis.d.ts +16 -6
  95. package/dist/engine/renderers/yAxis.d.ts.map +1 -1
  96. package/dist/engine/renderers/yAxis.js +49 -16
  97. package/dist/engine/renderers/yAxis.js.map +1 -1
  98. package/dist/engine/state/viewportState.d.ts +4 -1
  99. package/dist/engine/state/viewportState.d.ts.map +1 -1
  100. package/dist/engine/state/viewportState.js +40 -8
  101. package/dist/engine/state/viewportState.js.map +1 -1
  102. package/dist/engine/subPaneManager.d.ts +5 -3
  103. package/dist/engine/subPaneManager.d.ts.map +1 -1
  104. package/dist/engine/subPaneManager.js +3 -2
  105. package/dist/engine/subPaneManager.js.map +1 -1
  106. package/dist/engine/utils/chartZoomController.d.ts +9 -7
  107. package/dist/engine/utils/chartZoomController.d.ts.map +1 -1
  108. package/dist/engine/utils/chartZoomController.js +13 -12
  109. package/dist/engine/utils/chartZoomController.js.map +1 -1
  110. package/dist/engine/viewport/chartViewportManager.d.ts +5 -16
  111. package/dist/engine/viewport/chartViewportManager.d.ts.map +1 -1
  112. package/dist/engine/viewport/chartViewportManager.js +7 -48
  113. package/dist/engine/viewport/chartViewportManager.js.map +1 -1
  114. package/dist/engine/viewport/viewport.d.ts +14 -0
  115. package/dist/engine/viewport/viewport.d.ts.map +1 -1
  116. package/dist/engine/viewport/viewport.js +10 -0
  117. package/dist/engine/viewport/viewport.js.map +1 -1
  118. package/dist/foundation/config/chartSettings.d.ts +0 -5
  119. package/dist/foundation/config/chartSettings.d.ts.map +1 -1
  120. package/dist/foundation/config/chartSettings.js +14 -0
  121. package/dist/foundation/config/chartSettings.js.map +1 -1
  122. package/dist/foundation/plugin/rendererPluginManager.test.js +15 -0
  123. package/dist/foundation/plugin/rendererPluginManager.test.js.map +1 -1
  124. package/dist/foundation/plugin/types.d.ts +3 -0
  125. package/dist/foundation/plugin/types.d.ts.map +1 -1
  126. package/dist/foundation/plugin/types.js.map +1 -1
  127. package/dist/foundation/reactivity/frameTransaction.d.ts +11 -0
  128. package/dist/foundation/reactivity/frameTransaction.d.ts.map +1 -1
  129. package/dist/foundation/reactivity/frameTransaction.js +17 -2
  130. package/dist/foundation/reactivity/frameTransaction.js.map +1 -1
  131. package/dist/foundation/utils/kLineDraw/axis.d.ts +3 -0
  132. package/dist/foundation/utils/kLineDraw/axis.d.ts.map +1 -1
  133. package/dist/foundation/utils/kLineDraw/axis.js +40 -9
  134. package/dist/foundation/utils/kLineDraw/axis.js.map +1 -1
  135. package/dist/foundation/utils/sessionTimeLabels.d.ts +59 -0
  136. package/dist/foundation/utils/sessionTimeLabels.d.ts.map +1 -0
  137. package/dist/foundation/utils/sessionTimeLabels.js +170 -0
  138. package/dist/foundation/utils/sessionTimeLabels.js.map +1 -0
  139. package/dist/foundation/utils/timeShareAxisLabels.d.ts +27 -0
  140. package/dist/foundation/utils/timeShareAxisLabels.d.ts.map +1 -0
  141. package/dist/foundation/utils/timeShareAxisLabels.js +45 -0
  142. package/dist/foundation/utils/timeShareAxisLabels.js.map +1 -0
  143. package/dist/rendering/render/Renderer.d.ts +58 -74
  144. package/dist/rendering/render/Renderer.d.ts.map +1 -1
  145. package/dist/rendering/render/Renderer.js +8 -18
  146. package/dist/rendering/render/Renderer.js.map +1 -1
  147. package/dist/rendering/render/createWebGPURenderer.d.ts.map +1 -1
  148. package/dist/rendering/render/createWebGPURenderer.js +30 -17
  149. package/dist/rendering/render/createWebGPURenderer.js.map +1 -1
  150. package/dist/rendering/render/createWebGPUSurfaceBackend.js +3 -3
  151. package/dist/rendering/render/createWebGPUSurfaceBackend.js.map +1 -1
  152. package/dist/rendering/render/physicalLine.d.ts +4 -0
  153. package/dist/rendering/render/physicalLine.d.ts.map +1 -0
  154. package/dist/rendering/render/physicalLine.js +37 -0
  155. package/dist/rendering/render/physicalLine.js.map +1 -0
  156. package/dist/rendering/render/physicalRegion.d.ts +14 -0
  157. package/dist/rendering/render/physicalRegion.d.ts.map +1 -0
  158. package/dist/rendering/render/physicalRegion.js +15 -0
  159. package/dist/rendering/render/physicalRegion.js.map +1 -0
  160. package/dist/rendering/scene/createLayerFromPlugin.d.ts.map +1 -1
  161. package/dist/rendering/scene/createLayerFromPlugin.js +8 -3
  162. package/dist/rendering/scene/createLayerFromPlugin.js.map +1 -1
  163. package/dist/rendering/scene/createScene.d.ts.map +1 -1
  164. package/dist/rendering/scene/createScene.js +4 -3
  165. package/dist/rendering/scene/createScene.js.map +1 -1
  166. package/dist/version.d.ts +1 -1
  167. package/dist/version.js +1 -1
  168. package/package.json +1 -1
  169. package/src/__tests__/stateKernel.test.ts +1 -0
  170. package/src/controllers/createChartController.ts +9 -8
  171. package/src/controllers/index.ts +11 -0
  172. package/src/controllers/types.ts +7 -0
  173. package/src/data/__tests__/timeShareBuffer.test.ts +45 -0
  174. package/src/data/binance.ts +1 -1
  175. package/src/data/timeShareBuffer.ts +15 -0
  176. package/src/engine/__tests__/chart.dpr.test.ts +31 -2
  177. package/src/engine/__tests__/paneRenderer.resize.test.ts +52 -54
  178. package/src/engine/__tests__/subPaneManager.test.ts +5 -3
  179. package/src/engine/chart.ts +105 -115
  180. package/src/engine/chartTypes.ts +5 -0
  181. package/src/engine/controller/__tests__/interaction.dpr.test.ts +39 -2
  182. package/src/engine/controller/interaction.ts +18 -10
  183. package/src/engine/data/__tests__/chartDataManager.incrementalLoad.test.ts +37 -17
  184. package/src/engine/data/__tests__/scrollCompensator.geometry.test.ts +87 -75
  185. package/src/engine/data/chartDataManager.ts +64 -59
  186. package/src/engine/data/incrementalLoadHint.ts +8 -7
  187. package/src/engine/data/scrollCompensator.ts +16 -24
  188. package/src/engine/indicators/__tests__/chartIndicatorManager.test.ts +18 -26
  189. package/src/engine/indicators/chartIndicatorManager.ts +62 -62
  190. package/src/engine/layout/chartPaneLayout.ts +73 -21
  191. package/src/engine/modes/__tests__/timeShareMath.test.ts +185 -0
  192. package/src/engine/modes/__tests__/timeShareMode.test.ts +81 -0
  193. package/src/engine/modes/timeShareMath.ts +150 -0
  194. package/src/engine/modes/timeShareMode.ts +35 -28
  195. package/src/engine/paneRenderer.ts +17 -10
  196. package/src/engine/render/chartRenderer.ts +214 -145
  197. package/src/engine/render/layers/leftYAxisLayer.ts +29 -7
  198. package/src/engine/render/layers/mainIndicatorLegendLayer.test.ts +26 -0
  199. package/src/engine/render/layers/mainIndicatorLegendLayer.ts +13 -10
  200. package/src/engine/render/layers/yAxisLayer.ts +29 -7
  201. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +270 -403
  202. package/src/engine/renderers/Indicator/mainIndicatorLegendContext.ts +290 -0
  203. package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +73 -2
  204. package/src/engine/renderers/__tests__/yAxis.renderer.test.ts +10 -6
  205. package/src/engine/renderers/leftYAxis.ts +60 -18
  206. package/src/engine/renderers/timeShare.ts +25 -9
  207. package/src/engine/renderers/yAxis.ts +57 -22
  208. package/src/engine/state/__tests__/settingsState.test.ts +9 -0
  209. package/src/engine/state/viewportState.ts +44 -8
  210. package/src/engine/subPaneManager.ts +8 -5
  211. package/src/engine/utils/chartZoomController.ts +22 -19
  212. package/src/engine/viewport/__tests__/visibleRange.clamp.test.ts +50 -0
  213. package/src/engine/viewport/chartViewportManager.ts +9 -60
  214. package/src/engine/viewport/viewport.ts +14 -0
  215. package/src/foundation/config/chartSettings.ts +13 -0
  216. package/src/foundation/plugin/rendererPluginManager.test.ts +18 -0
  217. package/src/foundation/plugin/types.ts +3 -0
  218. package/src/foundation/reactivity/frameTransaction.ts +20 -6
  219. package/src/foundation/utils/__tests__/sessionTimeLabels.test.ts +91 -0
  220. package/src/foundation/utils/kLineDraw/axis.ts +50 -9
  221. package/src/foundation/utils/sessionTimeLabels.ts +237 -0
  222. package/src/foundation/utils/timeShareAxisLabels.ts +94 -0
  223. package/src/rendering/render/Renderer.ts +69 -85
  224. package/src/rendering/render/__tests__/physicalLine.test.ts +51 -0
  225. package/src/rendering/render/__tests__/physicalRegion.test.ts +23 -0
  226. package/src/rendering/render/__tests__/webgpuRenderer.test.ts +31 -1
  227. package/src/rendering/render/__tests__/webgpuSurfaceBackend.test.ts +11 -0
  228. package/src/rendering/render/createWebGPURenderer.ts +30 -17
  229. package/src/rendering/render/createWebGPUSurfaceBackend.ts +3 -3
  230. package/src/rendering/render/physicalLine.ts +45 -0
  231. package/src/rendering/render/physicalRegion.ts +32 -0
  232. package/src/rendering/scene/createLayerFromPlugin.ts +8 -3
  233. package/src/rendering/scene/createScene.ts +4 -3
  234. package/src/version.ts +1 -1
@@ -0,0 +1,290 @@
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 LegendCurrentBar = Omit<KLineData, 'volume'> & {
21
+ volume: number | null
22
+ // 成交量+单位格式化文本(eg. 1.23亿)
23
+ volumeText: string | null
24
+ color: string
25
+ }
26
+
27
+ export interface LegendTimeshareRow {
28
+ price: number
29
+ average: number
30
+ changeAmount: number
31
+ changePercent: number
32
+ volume: number
33
+ volumeText: string
34
+ amount: number
35
+ amountText: string
36
+ changeColor: string
37
+ }
38
+
39
+ export interface LegendIndicatorRow {
40
+ name: string
41
+ params?: number[]
42
+ values?: TitleValueItem[]
43
+ }
44
+
45
+ export interface LegendComparisonRow {
46
+ symbol: string
47
+ percent: number
48
+ color: string
49
+ percentColor: string
50
+ }
51
+
52
+ /**
53
+ * 主图左上角图例完整上下文。
54
+ * Canvas 绘制与 Vue legend slot 共用同一份数据。
55
+ */
56
+ export interface LegendTemplateContext {
57
+ period: string
58
+ index: number
59
+ hasCrosshair: boolean
60
+ layout: LegendLayout
61
+ colors: {
62
+ textPrimary: string
63
+ textTertiary: string
64
+ up: string
65
+ down: string
66
+ }
67
+ /** 十字线指向的当前 K 线展示行(含 volumeText / color 与自定义字段) */
68
+ currentBar: LegendCurrentBar | null
69
+ timeshare: LegendTimeshareRow | null
70
+ indicators: ReadonlyArray<LegendIndicatorRow>
71
+ comparisons: ReadonlyArray<LegendComparisonRow>
72
+ /** 当前索引处的原始 K 线(分时模式下可能无 close) */
73
+ bar: KLineData | TimeShareData | null
74
+ }
75
+
76
+ export interface BuildLegendTemplateContextInput {
77
+ context: RenderContext
78
+ host: PluginHost | null
79
+ yPaddingPx: number
80
+ }
81
+
82
+ export function formatVolumeShort(v: number): string {
83
+ if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
84
+ if (v >= 1e4) return (v / 1e4).toFixed(2) + '万'
85
+ return v.toFixed(2)
86
+ }
87
+
88
+ export function formatAmountShort(v: number): string {
89
+ if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
90
+ if (v >= 1e4) return (v / 1e4).toFixed(2) + '万'
91
+ return v.toFixed(2)
92
+ }
93
+
94
+ export function buildLegendTemplateContext(
95
+ input: BuildLegendTemplateContextInput,
96
+ ): LegendTemplateContext | null {
97
+ const { context, host, yPaddingPx } = input
98
+ const klineData = context.data as KLineData[]
99
+ if (!klineData.length) return null
100
+
101
+ const colors = resolveThemeColors(
102
+ context.theme,
103
+ context.isAsiaMarket,
104
+ context.colorPresetSettings,
105
+ )
106
+ const fontSize = 12
107
+ const lineHeight = fontSize + 6
108
+ const legendX = 12
109
+ const gap = 10
110
+ const legendYOffset = 6
111
+ const compact = context.paneWidth < 400
112
+ const range = context.range
113
+ const crosshairIndex = context.crosshairIndex
114
+ const hasCrosshair = typeof crosshairIndex === 'number'
115
+ const targetIndex = hasCrosshair ? crosshairIndex : 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 rawPreClose = context.settings?.preClose as number | undefined
130
+ const preClose =
131
+ typeof rawPreClose === 'number' && Number.isFinite(rawPreClose) && rawPreClose !== 0
132
+ ? rawPreClose
133
+ : (tsData[0]?.price ?? 0)
134
+ const item = tsData[targetIndex]
135
+ if (item) {
136
+ const changeAmount = item.price - preClose
137
+ const changePercent = preClose !== 0 ? (changeAmount / preClose) * 100 : 0
138
+ timeshare = {
139
+ price: item.price,
140
+ average: item.average,
141
+ changeAmount,
142
+ changePercent,
143
+ volume: item.volume,
144
+ volumeText: formatVolumeShort(item.volume),
145
+ amount: item.amount,
146
+ amountText: formatAmountShort(item.amount),
147
+ changeColor: changeAmount >= 0 ? colors.candleUpBody : colors.candleDownBody,
148
+ }
149
+ }
150
+ }
151
+
152
+ let currentBar: LegendCurrentBar | null = null
153
+ if (hasCrosshair) {
154
+ const k = klineData[targetIndex]
155
+ if (k && typeof k.close === 'number') {
156
+ const isUp = k.close >= k.open
157
+ currentBar = {
158
+ ...k,
159
+ volume: typeof k.volume === 'number' ? k.volume : null,
160
+ volumeText: typeof k.volume === 'number' ? formatVolumeShort(k.volume) : null,
161
+ color: isUp ? colors.candleUpBody : colors.candleDownBody,
162
+ }
163
+ }
164
+ }
165
+
166
+ const indicators = collectIndicatorRows(host, klineData, targetIndex)
167
+ const comparisons = collectComparisonRows(context, klineData, targetIndex, range, colors)
168
+
169
+ return {
170
+ period: context.period,
171
+ index: targetIndex,
172
+ hasCrosshair,
173
+ layout,
174
+ colors: {
175
+ textPrimary: colors.text.primary,
176
+ textTertiary: colors.text.tertiary,
177
+ up: colors.candleUpBody,
178
+ down: colors.candleDownBody,
179
+ },
180
+ currentBar,
181
+ timeshare,
182
+ indicators,
183
+ comparisons,
184
+ bar: klineData[targetIndex] ?? null,
185
+ }
186
+ }
187
+
188
+ function collectIndicatorRows(
189
+ host: PluginHost | null,
190
+ klineData: KLineData[],
191
+ targetIndex: number,
192
+ ): LegendIndicatorRow[] {
193
+ if (!host || typeof host.getService !== 'function') return []
194
+ const scheduler = host.getService<IndicatorScheduler>('indicatorScheduler')
195
+ if (!scheduler) return []
196
+
197
+ const rows: LegendIndicatorRow[] = []
198
+ for (const meta of scheduler.getMainIndicators()) {
199
+ if (!meta.getTitleInfo) continue
200
+ if (!scheduler.isMainIndicatorActive(meta.name)) continue
201
+ const params = scheduler.getMainIndicatorParams(meta.name) ?? {}
202
+ const titleInfo: TitleInfo | null = meta.getTitleInfo(
203
+ klineData,
204
+ targetIndex,
205
+ params as Record<string, number | boolean | string>,
206
+ host,
207
+ 'main',
208
+ )
209
+ if (!titleInfo) continue
210
+ rows.push({
211
+ name: titleInfo.name,
212
+ params: titleInfo.params,
213
+ values: titleInfo.values,
214
+ })
215
+ }
216
+ return rows
217
+ }
218
+
219
+ function collectComparisonRows(
220
+ context: RenderContext,
221
+ klineData: KLineData[],
222
+ targetIndex: number,
223
+ range: { start: number; end: number },
224
+ colors: ReturnType<typeof resolveThemeColors>,
225
+ ): LegendComparisonRow[] {
226
+ const comparisonSymbols = context.comparisonSymbols
227
+ const comparisonData = context.comparisonData
228
+ const comparisonColors = context.comparisonColors
229
+ if (!comparisonSymbols?.length || !comparisonData?.size) return []
230
+
231
+ const baseIndex = Math.max(0, range.start)
232
+ const baseItem = klineData[baseIndex]
233
+ if (!baseItem || !Number.isFinite(baseItem.close) || baseItem.close <= 0) return []
234
+
235
+ const baseDate = baseItem.date ?? ''
236
+ const rows: LegendComparisonRow[] = []
237
+
238
+ for (const spec of comparisonSymbols) {
239
+ const data = comparisonData.get(spec.symbol)
240
+ if (!data?.length) continue
241
+
242
+ const baseline = baseDate
243
+ ? findBaselineByDate(data, baseDate)
244
+ : findBaselineByTimestamp(data, baseItem.timestamp)
245
+ if (!baseline || baseline.close <= 0) continue
246
+
247
+ const byDate = new Map<string, KLineData>()
248
+ for (const item of data) {
249
+ byDate.set(item.date ?? String(item.timestamp), item)
250
+ }
251
+
252
+ const mainItem = klineData[targetIndex]
253
+ if (!mainItem) continue
254
+ const key = mainItem.date ?? String(mainItem.timestamp)
255
+ const currentItem = byDate.get(key)
256
+ if (!currentItem || !Number.isFinite(currentItem.close)) continue
257
+
258
+ const percent = ((currentItem.close - baseline.close) / baseline.close) * 100
259
+ const color = comparisonColors?.get(spec.symbol) ?? '#f59e0b'
260
+ rows.push({
261
+ symbol: spec.symbol,
262
+ percent,
263
+ color,
264
+ percentColor:
265
+ percent > 0
266
+ ? colors.candleUpBody
267
+ : percent < 0
268
+ ? colors.candleDownBody
269
+ : colors.text.primary,
270
+ })
271
+ }
272
+ return rows
273
+ }
274
+
275
+ function findBaselineByDate(data: ReadonlyArray<KLineData>, date: string): KLineData | null {
276
+ for (const item of data) {
277
+ if (item.date && item.date >= date) return item
278
+ }
279
+ return null
280
+ }
281
+
282
+ function findBaselineByTimestamp(
283
+ data: ReadonlyArray<KLineData>,
284
+ timestamp: number,
285
+ ): KLineData | null {
286
+ for (const item of data) {
287
+ if (item.timestamp >= timestamp) return item
288
+ }
289
+ return null
290
+ }
@@ -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.currentBar).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 currentBar 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.currentBar.turnoverRate).toBe(3.14)
623
+ expect(legend.currentBar.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
+ }
@@ -8,6 +8,10 @@ import { RENDERER_PRIORITY } from '../../foundation/plugin/index'
8
8
  import { resolveThemeColors } from '../../foundation/tokens/index'
9
9
  import type { TimeShareData } from '../../foundation/types/price'
10
10
  import { Indicator } from '../indicators/indicatorDefinitionRegistry'
11
+ import {
12
+ computeTimeSharePaneLayout,
13
+ resolveTimeShareBaseline,
14
+ } from '../modes/timeShareMath'
11
15
 
12
16
  /** 成交量区域占 pane 高度的比例(底部) */
13
17
  const VOLUME_RATIO = 0.25
@@ -34,15 +38,17 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost {
34
38
  context.isAsiaMarket,
35
39
  context.colorPresetSettings,
36
40
  )
37
- const preClose = (settings?.preClose as number) ?? tsData[0]?.price ?? 0
38
- if (preClose === 0) return
41
+ const preClose = resolveTimeShareBaseline({
42
+ preClose: settings?.preClose as number | undefined,
43
+ firstPrice: tsData[0]?.price,
44
+ })
45
+ if (preClose === null) return
39
46
 
40
47
  const paneHeight = pane.height
41
- const volumeAreaHeight = Math.round(paneHeight * VOLUME_RATIO * dpr) / dpr
42
- const priceAreaHeight = paneHeight - volumeAreaHeight
48
+ const layout = computeTimeSharePaneLayout(paneHeight, VOLUME_RATIO)
49
+ const { volumeAreaHeight, priceAreaHeight } = layout
43
50
 
44
51
  const { start, end } = range
45
- const visibleCount = Math.min(end - start, tsData.length - start)
46
52
  const itemCount = Math.min(end, tsData.length) - start
47
53
 
48
54
  const xPositions: number[] = []
@@ -51,16 +57,19 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost {
51
57
  const volumes: number[] = []
52
58
  let maxVolume = 0
53
59
 
60
+ // 价格 Y 映射到 price 子区域:pane.yAxis 仍按全高,再线性压到 priceAreaHeight
61
+ const scaleYToPriceArea = (y: number) => (y / Math.max(paneHeight, 1)) * priceAreaHeight
62
+
54
63
  for (let i = start; i < start + itemCount; i++) {
55
64
  const item = tsData[i]
56
65
  if (!item) continue
57
66
  const x = kLineCenters[i - start]
58
67
  if (x === undefined) continue
59
68
  xPositions.push(x)
60
- yPrices.push(pane.yAxis.priceToY(item.price))
61
- yAvgs.push(pane.yAxis.priceToY(item.average))
69
+ yPrices.push(scaleYToPriceArea(pane.yAxis.priceToY(item.price)))
70
+ yAvgs.push(scaleYToPriceArea(pane.yAxis.priceToY(item.average)))
62
71
  volumes.push(item.volume ?? 0)
63
- maxVolume = Math.max(maxVolume, item.volume)
72
+ maxVolume = Math.max(maxVolume, item.volume ?? 0)
64
73
  }
65
74
 
66
75
  if (xPositions.length < 2) return
@@ -68,7 +77,13 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost {
68
77
  ctx.save()
69
78
  ctx.translate(-scrollLeft, 0)
70
79
 
71
- const preCloseY = pane.yAxis.priceToY(preClose)
80
+ const preCloseY = scaleYToPriceArea(pane.yAxis.priceToY(preClose))
81
+
82
+ // 价格区裁剪,避免线/填充画进成交量区(translate 后世界 scrollLeft 映射到 0)
83
+ ctx.save()
84
+ ctx.beginPath()
85
+ ctx.rect(scrollLeft, 0, context.paneWidth, priceAreaHeight)
86
+ ctx.clip()
72
87
 
73
88
  drawPreCloseLine(ctx, xPositions, preCloseY, dpr, colors.timeSharePreClose)
74
89
 
@@ -85,6 +100,7 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost {
85
100
  drawSegmentLine(ctx, xPositions, yPrices, dpr, colors.timeSharePriceLine, 2)
86
101
 
87
102
  drawSegmentLine(ctx, xPositions, yAvgs, dpr, colors.timeShareAvgLine, 1.5)
103
+ ctx.restore()
88
104
 
89
105
  drawVolumeBars(
90
106
  ctx,