@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
@@ -10,6 +10,17 @@
10
10
  * 一帧固定走 capture → derive → seal → render → publish → complete。
11
11
  * render 或 publish 期间的 writeInput 一律进入下一代 pending。
12
12
  * 非 idle 时调用 flush 不会嵌套发布,仅保留 dirty 供外层完成后调度。
13
+ *
14
+ * @remarks 适用范围
15
+ * 这是一个半通用的 rAF 合帧器(semi-general frame coalescer),
16
+ * 专为 ChartRenderer 的绘制管线设计,并非全场景通用的高频处理方案。
17
+ *
18
+ * @Todo 如需在其他场景(WebSocket 消息合并、键盘事件批处理)复用,需补充:
19
+ * - 深层合并 / 自定义 merge 策略(目前 mergeInput 仅浅合并)
20
+ * - 快照深层不可变保证(目前 sealSnapshotRoot 只冻根对象)
21
+ * - 超时/失帧策略(目前纯乐观逐帧)
22
+ * - 背压与优先级集成(FrameBudget 在 scheduler 层但未在此集成)
23
+ * - 非 Record 输入类型支持(目前 TInput 约束为 Record)
13
24
  */
14
25
 
15
26
  import { createSignal, type ReadonlySignal } from './signal'
@@ -88,10 +99,9 @@ export interface FrameTransaction<TInput extends Record<string, unknown>, TSnaps
88
99
  * @typeParam TInput - 可合并的输入形状(浅层 Partial 合并)
89
100
  * @typeParam TSnapshot - derive 产出的不可变快照类型
90
101
  */
91
- export function createFrameTransaction<
92
- TInput extends Record<string, unknown>,
93
- TSnapshot,
94
- >(options: FrameTransactionOptions<TInput, TSnapshot>): FrameTransaction<TInput, TSnapshot> {
102
+ export function createFrameTransaction<TInput extends Record<string, unknown>, TSnapshot>(
103
+ options: FrameTransactionOptions<TInput, TSnapshot>,
104
+ ): FrameTransaction<TInput, TSnapshot> {
95
105
  const { derive, render } = options
96
106
  const schedule =
97
107
  options.schedule ??
@@ -145,24 +155,27 @@ export function createFrameTransaction<
145
155
  }
146
156
 
147
157
  let sealedInput: TInput | undefined
158
+ // 封存输入,后续写入只能进下一代
148
159
  phase = 'capturing'
149
160
  try {
150
161
  sealedInput = pending
151
162
  dirty = false
152
- // 本帧 capture 之后的新写入进入 nextPending,不得污染 sealedInput
153
163
  pending = { ...sealedInput }
154
164
 
165
+ // 从封存输入纯推导不可变快照
155
166
  phase = 'deriving'
156
167
  const nextGeneration = generation + 1
157
168
  const snapshot = derive(sealedInput, nextGeneration)
158
169
 
170
+ // 冻结快照根对象
159
171
  phase = 'sealing'
160
172
  sealSnapshotRoot(snapshot)
161
173
 
174
+ // 用本帧快照绘制
162
175
  phase = 'rendering'
163
176
  render?.(snapshot)
164
177
 
165
- // 先推进代际再发布,保证订阅者读 generation 与 snapshot.generation 一致
178
+ // 先推进代际再发布,保证订阅者读到一致代际
166
179
  phase = 'publishing'
167
180
  generation = nextGeneration
168
181
  published.set(snapshot)
@@ -191,6 +204,7 @@ export function createFrameTransaction<
191
204
  }
192
205
 
193
206
  function scheduleFlush(): void {
207
+ // 已有 raf 帧,
194
208
  if (scheduleQueued) return
195
209
  if (!dirty && nextPending === null) return
196
210
  scheduleQueued = true
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ ASHARE_MARKET_SESSION,
5
+ HK_MARKET_SESSION,
6
+ US_MARKET_SESSION,
7
+ computeSessionTimeLabels,
8
+ countSessionSlots,
9
+ minuteOfDayToTimestamp,
10
+ resolveMarketSessionSlots,
11
+ type MarketSessionConfig,
12
+ type OpenTimeRange,
13
+ } from '../sessionTimeLabels'
14
+
15
+ function hm(h: number, m: number): number {
16
+ return h * 60 + m
17
+ }
18
+
19
+ describe('countSessionSlots / resolveMarketSessionSlots', () => {
20
+ it('A-share is 240 one-minute slots', () => {
21
+ expect(countSessionSlots(ASHARE_MARKET_SESSION.sessions)).toBe(240)
22
+ expect(resolveMarketSessionSlots(ASHARE_MARKET_SESSION)).toBe(240)
23
+ })
24
+
25
+ it('HK is 330, US is 390 (not maxed to 240)', () => {
26
+ expect(resolveMarketSessionSlots(HK_MARKET_SESSION)).toBe(330)
27
+ expect(resolveMarketSessionSlots(US_MARKET_SESSION)).toBe(390)
28
+ })
29
+
30
+ it('respects slotMinutes (e.g. 5-min bars)', () => {
31
+ const cfg: MarketSessionConfig = {
32
+ ...ASHARE_MARKET_SESSION,
33
+ slotMinutes: 5,
34
+ }
35
+ // 240 分钟 / 5 = 48
36
+ expect(resolveMarketSessionSlots(cfg)).toBe(48)
37
+ })
38
+
39
+ it('does not expand slots by arrived data length (sessions are SSOT)', () => {
40
+ expect(resolveMarketSessionSlots(ASHARE_MARKET_SESSION, 300)).toBe(240)
41
+ expect(resolveMarketSessionSlots(HK_MARKET_SESSION, 100)).toBe(330)
42
+ })
43
+ })
44
+
45
+ describe('computeSessionTimeLabels multi-market', () => {
46
+ it('A-share closed-side endpoints only', () => {
47
+ const labels = computeSessionTimeLabels(ASHARE_MARKET_SESSION.sessions, { axisWidth: 800 })
48
+ expect(labels.map((l) => l.minuteOfDay)).toEqual([hm(9, 30), hm(13, 0), hm(15, 0)])
49
+ })
50
+
51
+ it('HK endpoints: 09:30 / 13:00 / 16:00', () => {
52
+ const labels = computeSessionTimeLabels(HK_MARKET_SESSION.sessions, { axisWidth: 800 })
53
+ expect(labels.map((l) => l.minuteOfDay)).toEqual([hm(9, 30), hm(13, 0), hm(16, 0)])
54
+ expect(labels.map((l) => l.slotIndex)).toEqual([0, 150, 329])
55
+ })
56
+
57
+ it('US single continuous session endpoints: 09:30 / 16:00', () => {
58
+ const labels = computeSessionTimeLabels(US_MARKET_SESSION.sessions, { axisWidth: 800 })
59
+ expect(labels.map((l) => l.minuteOfDay)).toEqual([hm(9, 30), hm(16, 0)])
60
+ expect(labels.map((l) => l.slotIndex)).toEqual([0, 389])
61
+ })
62
+
63
+ it('accepts arbitrary sessions via config.sessions', () => {
64
+ const night: OpenTimeRange[] = [
65
+ { open: hm(21, 0), close: hm(23, 0) },
66
+ { open: hm(0, 0), close: hm(2, 30) },
67
+ ]
68
+ // 两段同日分钟:21:00-23:00 (120) + 0:00-2:30 (150) = 270
69
+ // 闭侧端点:21:00, 0:00, 2:30
70
+ const labels = computeSessionTimeLabels(night, { axisWidth: 800 })
71
+ expect(labels.map((l) => l.minuteOfDay)).toEqual([hm(21, 0), hm(0, 0), hm(2, 30)])
72
+ expect(countSessionSlots(night)).toBe(270)
73
+ })
74
+ })
75
+
76
+ describe('minuteOfDayToTimestamp timeZone', () => {
77
+ it('maps wall clock in Asia/Shanghai independently of host local TZ', () => {
78
+ // 任意 UTC 锚点落在 2026-07-21 上海日历日
79
+ const base = Date.UTC(2026, 6, 21, 4, 0, 0) // 12:00 CST
80
+ const ts = minuteOfDayToTimestamp(base, hm(9, 30), 'Asia/Shanghai')
81
+ // 2026-07-21 09:30 Asia/Shanghai = 01:30 UTC
82
+ expect(ts).toBe(Date.UTC(2026, 6, 21, 1, 30, 0))
83
+ })
84
+
85
+ it('maps wall clock in America/New_York for US session open', () => {
86
+ // 2026-07-21 是美东夏令时 EDT (UTC-4)
87
+ const base = Date.UTC(2026, 6, 21, 16, 0, 0) // 12:00 EDT
88
+ const ts = minuteOfDayToTimestamp(base, hm(9, 30), 'America/New_York')
89
+ expect(ts).toBe(Date.UTC(2026, 6, 21, 13, 30, 0)) // 09:30 EDT
90
+ })
91
+ })
@@ -12,6 +12,14 @@ import {
12
12
  findDayBoundaries,
13
13
  } from '../dateFormat'
14
14
  import { priceToY, yToPrice } from '../priceToY'
15
+ import {
16
+ ASHARE_MARKET_SESSION,
17
+ computeTimeShareTimeLabels,
18
+ minuteOfDayToTimestamp,
19
+ resolveMarketSessionSlots,
20
+ timeShareSlotCenterX,
21
+ type MarketSessionConfig,
22
+ } from '../timeShareAxisLabels'
15
23
 
16
24
  const textWidthCache = new Map<string, number>()
17
25
  const TEXT_WIDTH_CACHE_LIMIT = 512
@@ -63,6 +71,8 @@ export interface TimeAxisOptions {
63
71
  monthKeys?: Int32Array
64
72
  /** 预计算的日期键值数组(year*366+dayOfYear),与 data 长度一致 */
65
73
  dayKeys?: Int32Array
74
+ /** 分时市场 session;默认 A 股 */
75
+ marketSession?: MarketSessionConfig
66
76
  }
67
77
 
68
78
  export interface LastPriceLineOptions {
@@ -362,16 +372,47 @@ export function drawTimeAxis(
362
372
  let labelFn: (ts: number) => { text: string; isYear: boolean }
363
373
 
364
374
  if (isTimeShare) {
365
- const visibleCount = endIndex - startIndex
366
- const maxLabels = Math.max(2, Math.min(8, Math.floor(visibleCount / 2) || 1))
367
- const step = Math.max(1, Math.floor(visibleCount / maxLabels))
368
- boundaries = []
369
- for (let i = 0; i <= maxLabels; i++) {
370
- const idx = startIndex + Math.min(i * step, Math.max(0, visibleCount - 1))
371
- boundaries.push(idx)
375
+ // 只画各区间闭侧端点;全天首/末槽贴边 left/right 对齐
376
+ const market = opts.marketSession ?? ASHARE_MARKET_SESSION
377
+ const sessionSlots = resolveMarketSessionSlots(market)
378
+ const labels = computeTimeShareTimeLabels({
379
+ axisWidth: width,
380
+ marketSession: market,
381
+ minLabelSpacingPx: 56,
382
+ })
383
+ const baseTs = data[0]?.timestamp ?? Date.now()
384
+ setCanvasFont(ctx, regularFont)
385
+ ctx.textBaseline = 'middle'
386
+ const edgePad = 2
387
+ const lastSlot = Math.max(0, sessionSlots - 1)
388
+ for (const label of labels) {
389
+ const ts = minuteOfDayToTimestamp(baseTs, label.minuteOfDay, market.timeZone)
390
+ const text = formatTimeLabel(ts)
391
+ const isDayStart = label.slotIndex === 0
392
+ const isDayEnd = label.slotIndex === lastSlot
393
+
394
+ let align: CanvasTextAlign = 'center'
395
+ let drawX: number
396
+ if (isDayStart) {
397
+ align = 'left'
398
+ drawX = x + edgePad
399
+ } else if (isDayEnd) {
400
+ align = 'right'
401
+ drawX = x + width - edgePad
402
+ } else {
403
+ const centerX = timeShareSlotCenterX(label.slotIndex, width, sessionSlots, dpr)
404
+ drawX = centerX - scrollLeft
405
+ if (drawX < edgePad || drawX > width - edgePad) continue
406
+ }
407
+
408
+ ctx.textAlign = align
409
+ ctx.fillText(text, roundToPhysicalPixel(drawX, dpr), alignToPhysicalPixelCenter(textY, dpr))
372
410
  }
373
- labelFn = (ts) => ({ text: formatTimeLabel(ts), isYear: false })
374
- } else if (isMinuteData) {
411
+ ctx.textAlign = 'center'
412
+ return
413
+ }
414
+
415
+ if (isMinuteData) {
375
416
  boundaries = findDayBoundaries(data, opts.dayKeys)
376
417
  labelFn = formatDay
377
418
  } else {
@@ -0,0 +1,237 @@
1
+ /**
2
+ * 开盘时段:minuteOfDay 为自 0 点起的分钟数(交易所本地墙钟)。
3
+ * sessions[0..n-2] 左闭右开 [open, close);sessions[n-1] 左闭右闭 [open, close]。
4
+ * 只打印闭侧端点:半开段不展示 close。
5
+ */
6
+ export type OpenTimeRange = {
7
+ open: number
8
+ close: number
9
+ }
10
+
11
+ /** 市场分时 session 配置(几何 / 标签共用) */
12
+ export type MarketSessionConfig = {
13
+ /** IANA 时区,如 Asia/Shanghai */
14
+ timeZone: string
15
+ sessions: ReadonlyArray<OpenTimeRange>
16
+ /** 每个 bar 槽对应的分钟数,默认 1 */
17
+ slotMinutes?: number
18
+ }
19
+
20
+ export type SessionTimeLabel = {
21
+ /** 自 0 点起的分钟数(交易所墙钟) */
22
+ minuteOfDay: number
23
+ /** 在展开后的槽序列中的索引(0-based) */
24
+ slotIndex: number
25
+ /** 是否为闭侧端点 */
26
+ isEndpoint: boolean
27
+ }
28
+
29
+ export type SessionTimeLabelOptions = {
30
+ axisWidth: number
31
+ /** 保留字段,端点-only 模式下不参与密度计算 */
32
+ minLabelSpacingPx?: number
33
+ }
34
+
35
+ function hm(h: number, m: number): number {
36
+ return h * 60 + m
37
+ }
38
+
39
+ /** A 股默认 */
40
+ export const ASHARE_OPEN_SESSIONS: readonly OpenTimeRange[] = [
41
+ { open: hm(9, 30), close: hm(11, 30) },
42
+ { open: hm(13, 0), close: hm(15, 0) },
43
+ ]
44
+
45
+ export const ASHARE_MARKET_SESSION: MarketSessionConfig = {
46
+ timeZone: 'Asia/Shanghai',
47
+ sessions: ASHARE_OPEN_SESSIONS,
48
+ slotMinutes: 1,
49
+ }
50
+
51
+ /** 港股 */
52
+ export const HK_MARKET_SESSION: MarketSessionConfig = {
53
+ timeZone: 'Asia/Hong_Kong',
54
+ sessions: [
55
+ { open: hm(9, 30), close: hm(12, 0) },
56
+ { open: hm(13, 0), close: hm(16, 0) },
57
+ ],
58
+ slotMinutes: 1,
59
+ }
60
+
61
+ /** 美股(常规盘,无午休) */
62
+ export const US_MARKET_SESSION: MarketSessionConfig = {
63
+ timeZone: 'America/New_York',
64
+ sessions: [{ open: hm(9, 30), close: hm(16, 0) }],
65
+ slotMinutes: 1,
66
+ }
67
+
68
+ /**
69
+ * 只输出各区间闭侧端点。
70
+ * - 非末段 [open, close):仅 open
71
+ * - 末段 [open, close]:open 与 close
72
+ * close 标签映射到 close-1 槽(bar 序列为各段 [open, close))。
73
+ */
74
+ export function computeSessionTimeLabels(
75
+ sessions: ReadonlyArray<OpenTimeRange>,
76
+ options: SessionTimeLabelOptions,
77
+ ): SessionTimeLabel[] {
78
+ if (!(options.axisWidth > 0) || sessions.length === 0) return []
79
+
80
+ const labels: SessionTimeLabel[] = []
81
+ let slotOffset = 0
82
+
83
+ for (let s = 0; s < sessions.length; s++) {
84
+ const range = sessions[s]!
85
+ if (!(range.close > range.open)) continue
86
+ const slotCount = range.close - range.open
87
+ const isLast = s === sessions.length - 1
88
+
89
+ labels.push({
90
+ minuteOfDay: range.open,
91
+ slotIndex: slotOffset,
92
+ isEndpoint: true,
93
+ })
94
+
95
+ if (isLast) {
96
+ labels.push({
97
+ minuteOfDay: range.close,
98
+ slotIndex: slotOffset + slotCount - 1,
99
+ isEndpoint: true,
100
+ })
101
+ }
102
+
103
+ slotOffset += slotCount
104
+ }
105
+
106
+ const bySlot = new Map<number, SessionTimeLabel>()
107
+ for (const label of labels) {
108
+ bySlot.set(label.slotIndex, label)
109
+ }
110
+ return [...bySlot.values()].sort((a, b) => a.slotIndex - b.slotIndex)
111
+ }
112
+
113
+ /** 槽总数(各段 [open, close) 分钟数之和;未除 slotMinutes) */
114
+ export function countSessionSlots(sessions: ReadonlyArray<OpenTimeRange>): number {
115
+ let n = 0
116
+ for (const r of sessions) {
117
+ if (r.close > r.open) n += r.close - r.open
118
+ }
119
+ return n
120
+ }
121
+
122
+ /**
123
+ * 全天 bar 槽数 = 分钟总和 / slotMinutes。
124
+ * sessions 为 SSOT,不因 arrivedCount 放大。
125
+ */
126
+ export function resolveMarketSessionSlots(
127
+ config: MarketSessionConfig,
128
+ _arrivedCount?: number,
129
+ ): number {
130
+ const minutes = countSessionSlots(config.sessions)
131
+ const step = config.slotMinutes && config.slotMinutes > 0 ? config.slotMinutes : 1
132
+ return Math.max(0, Math.floor(minutes / step))
133
+ }
134
+
135
+ /**
136
+ * 将交易所墙钟 minuteOfDay 转为 UTC 毫秒。
137
+ * baseTimestamp 用于确定「哪一天」(按 timeZone 日历日)。
138
+ */
139
+ export function minuteOfDayToTimestamp(
140
+ baseTimestamp: number,
141
+ minuteOfDay: number,
142
+ timeZone: string = 'Asia/Shanghai',
143
+ ): number {
144
+ const { year, month, day } = getCalendarPartsInTimeZone(baseTimestamp, timeZone)
145
+ return zonedWallTimeToUtc(year, month, day, minuteOfDay, timeZone)
146
+ }
147
+
148
+ /** 槽中心 X */
149
+ export function sessionSlotCenterX(
150
+ slotIndex: number,
151
+ axisWidth: number,
152
+ sessionSlots: number,
153
+ dpr: number,
154
+ ): number {
155
+ if (!(axisWidth > 0) || !(sessionSlots > 0)) return 0
156
+ const step = axisWidth / sessionSlots
157
+ return Math.round((slotIndex + 0.5) * step * dpr) / dpr
158
+ }
159
+
160
+ // ── 时区工具(无第三方依赖,Intl) ──
161
+
162
+ function getCalendarPartsInTimeZone(
163
+ timestamp: number,
164
+ timeZone: string,
165
+ ): { year: number; month: number; day: number } {
166
+ const parts = new Intl.DateTimeFormat('en-US', {
167
+ timeZone,
168
+ year: 'numeric',
169
+ month: '2-digit',
170
+ day: '2-digit',
171
+ }).formatToParts(new Date(timestamp))
172
+ let year = 0
173
+ let month = 1
174
+ let day = 1
175
+ for (const p of parts) {
176
+ if (p.type === 'year') year = Number(p.value)
177
+ else if (p.type === 'month') month = Number(p.value)
178
+ else if (p.type === 'day') day = Number(p.value)
179
+ }
180
+ return { year, month, day }
181
+ }
182
+
183
+ /**
184
+ * 将某时区墙钟 (y-m-d + minuteOfDay) 转为 UTC epoch。
185
+ * 用迭代修正 UTC 猜测,覆盖 DST。
186
+ */
187
+ function zonedWallTimeToUtc(
188
+ year: number,
189
+ month: number,
190
+ day: number,
191
+ minuteOfDay: number,
192
+ timeZone: string,
193
+ ): number {
194
+ const hour = Math.floor(minuteOfDay / 60)
195
+ const minute = minuteOfDay % 60
196
+ // 初值:当作 UTC 墙钟
197
+ let utc = Date.UTC(year, month - 1, day, hour, minute, 0, 0)
198
+ for (let i = 0; i < 4; i++) {
199
+ const wall = getWallClockInTimeZone(utc, timeZone)
200
+ const desiredMin = hour * 60 + minute
201
+ const actualMin = wall.hour * 60 + wall.minute
202
+ const dayDelta =
203
+ Date.UTC(year, month - 1, day) - Date.UTC(wall.year, wall.month - 1, wall.day)
204
+ const minDelta = desiredMin - actualMin + dayDelta / 60_000
205
+ if (minDelta === 0) break
206
+ utc += minDelta * 60_000
207
+ }
208
+ return utc
209
+ }
210
+
211
+ function getWallClockInTimeZone(
212
+ timestamp: number,
213
+ timeZone: string,
214
+ ): { year: number; month: number; day: number; hour: number; minute: number } {
215
+ const parts = new Intl.DateTimeFormat('en-US', {
216
+ timeZone,
217
+ year: 'numeric',
218
+ month: '2-digit',
219
+ day: '2-digit',
220
+ hour: '2-digit',
221
+ minute: '2-digit',
222
+ hourCycle: 'h23',
223
+ }).formatToParts(new Date(timestamp))
224
+ let year = 0
225
+ let month = 1
226
+ let day = 1
227
+ let hour = 0
228
+ let minute = 0
229
+ for (const p of parts) {
230
+ if (p.type === 'year') year = Number(p.value)
231
+ else if (p.type === 'month') month = Number(p.value)
232
+ else if (p.type === 'day') day = Number(p.value)
233
+ else if (p.type === 'hour') hour = Number(p.value)
234
+ else if (p.type === 'minute') minute = Number(p.value)
235
+ }
236
+ return { year, month, day, hour, minute }
237
+ }
@@ -0,0 +1,94 @@
1
+ import {
2
+ ASHARE_MARKET_SESSION,
3
+ ASHARE_OPEN_SESSIONS,
4
+ computeSessionTimeLabels,
5
+ countSessionSlots,
6
+ minuteOfDayToTimestamp,
7
+ resolveMarketSessionSlots,
8
+ sessionSlotCenterX,
9
+ type MarketSessionConfig,
10
+ type SessionTimeLabel,
11
+ } from './sessionTimeLabels'
12
+
13
+ /** A 股默认全天 1 分钟槽位数(兼容旧导出) */
14
+ export const ASHARE_TIMESHARE_SESSION_SLOTS = resolveMarketSessionSlots(ASHARE_MARKET_SESSION)
15
+
16
+ /** 分时时间标签最小水平间距(逻辑像素) */
17
+ export const TIMESHARE_MIN_LABEL_SPACING_PX = 56
18
+
19
+ export type TimeShareTimeLabelInput = {
20
+ axisWidth: number
21
+ /** 市场 session;默认 A 股 */
22
+ marketSession?: MarketSessionConfig
23
+ minLabelSpacingPx?: number
24
+ }
25
+
26
+ /**
27
+ * 分时时间标签索引(委托 computeSessionTimeLabels)。
28
+ * 默认 A 股;传入 marketSession 可换港股/美股等。
29
+ */
30
+ export function computeTimeShareTimeLabelIndices(input: TimeShareTimeLabelInput): number[] {
31
+ return computeTimeShareTimeLabels(input).map((l) => l.slotIndex)
32
+ }
33
+
34
+ /** 完整标签(含 minuteOfDay / isEndpoint) */
35
+ export function computeTimeShareTimeLabels(input: TimeShareTimeLabelInput): SessionTimeLabel[] {
36
+ const market = input.marketSession ?? ASHARE_MARKET_SESSION
37
+ return computeSessionTimeLabels(market.sessions, {
38
+ axisWidth: input.axisWidth,
39
+ minLabelSpacingPx: input.minLabelSpacingPx ?? TIMESHARE_MIN_LABEL_SPACING_PX,
40
+ })
41
+ }
42
+
43
+ export function timeShareSlotCenterX(
44
+ slotIndex: number,
45
+ axisWidth: number,
46
+ sessionSlots: number,
47
+ dpr: number,
48
+ ): number {
49
+ return sessionSlotCenterX(slotIndex, axisWidth, sessionSlots, dpr)
50
+ }
51
+
52
+ /**
53
+ * 由开盘日基准时间戳 + slot 索引推算标签时间(按 market session)。
54
+ */
55
+ export function resolveTimeShareSlotTimestamp(
56
+ baseTimestamp: number,
57
+ slotIndex: number,
58
+ marketSession: MarketSessionConfig = ASHARE_MARKET_SESSION,
59
+ ): number {
60
+ const step = marketSession.slotMinutes && marketSession.slotMinutes > 0 ? marketSession.slotMinutes : 1
61
+ let remaining = slotIndex * step
62
+ for (const range of marketSession.sessions) {
63
+ const len = range.close - range.open
64
+ if (remaining < len) {
65
+ return minuteOfDayToTimestamp(
66
+ baseTimestamp,
67
+ range.open + remaining,
68
+ marketSession.timeZone,
69
+ )
70
+ }
71
+ remaining -= len
72
+ }
73
+ const last = marketSession.sessions[marketSession.sessions.length - 1]
74
+ if (last) {
75
+ return minuteOfDayToTimestamp(baseTimestamp, last.close, marketSession.timeZone)
76
+ }
77
+ return baseTimestamp
78
+ }
79
+
80
+ export {
81
+ ASHARE_MARKET_SESSION,
82
+ ASHARE_OPEN_SESSIONS,
83
+ computeSessionTimeLabels,
84
+ countSessionSlots,
85
+ minuteOfDayToTimestamp,
86
+ resolveMarketSessionSlots,
87
+ sessionSlotCenterX,
88
+ }
89
+ export type {
90
+ MarketSessionConfig,
91
+ OpenTimeRange,
92
+ SessionTimeLabel,
93
+ } from './sessionTimeLabels'
94
+ export { HK_MARKET_SESSION, US_MARKET_SESSION } from './sessionTimeLabels'