@363045841yyt/klinechart-core 0.9.0-alpha.3 → 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 (74) hide show
  1. package/dist/controllers/index.d.ts +1 -1
  2. package/dist/controllers/index.d.ts.map +1 -1
  3. package/dist/data/timeShareBuffer.d.ts +3 -0
  4. package/dist/data/timeShareBuffer.d.ts.map +1 -1
  5. package/dist/data/timeShareBuffer.js +13 -0
  6. package/dist/data/timeShareBuffer.js.map +1 -1
  7. package/dist/engine/chart.d.ts.map +1 -1
  8. package/dist/engine/chart.js +18 -5
  9. package/dist/engine/chart.js.map +1 -1
  10. package/dist/engine/data/chartDataManager.d.ts +2 -0
  11. package/dist/engine/data/chartDataManager.d.ts.map +1 -1
  12. package/dist/engine/data/chartDataManager.js +9 -0
  13. package/dist/engine/data/chartDataManager.js.map +1 -1
  14. package/dist/engine/modes/timeShareMath.d.ts +56 -0
  15. package/dist/engine/modes/timeShareMath.d.ts.map +1 -0
  16. package/dist/engine/modes/timeShareMath.js +90 -0
  17. package/dist/engine/modes/timeShareMath.js.map +1 -0
  18. package/dist/engine/modes/timeShareMode.d.ts +5 -0
  19. package/dist/engine/modes/timeShareMode.d.ts.map +1 -1
  20. package/dist/engine/modes/timeShareMode.js +27 -27
  21. package/dist/engine/modes/timeShareMode.js.map +1 -1
  22. package/dist/engine/render/chartRenderer.d.ts.map +1 -1
  23. package/dist/engine/render/chartRenderer.js +27 -17
  24. package/dist/engine/render/chartRenderer.js.map +1 -1
  25. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js +2 -2
  26. package/dist/engine/renderers/Indicator/mainIndicatorLegend.js.map +1 -1
  27. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.d.ts +4 -3
  28. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.d.ts.map +1 -1
  29. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.js +13 -8
  30. package/dist/engine/renderers/Indicator/mainIndicatorLegendContext.js.map +1 -1
  31. package/dist/engine/renderers/timeShare.d.ts.map +1 -1
  32. package/dist/engine/renderers/timeShare.js +20 -9
  33. package/dist/engine/renderers/timeShare.js.map +1 -1
  34. package/dist/foundation/config/chartSettings.d.ts +0 -5
  35. package/dist/foundation/config/chartSettings.d.ts.map +1 -1
  36. package/dist/foundation/config/chartSettings.js +14 -0
  37. package/dist/foundation/config/chartSettings.js.map +1 -1
  38. package/dist/foundation/utils/kLineDraw/axis.d.ts +3 -0
  39. package/dist/foundation/utils/kLineDraw/axis.d.ts.map +1 -1
  40. package/dist/foundation/utils/kLineDraw/axis.js +40 -9
  41. package/dist/foundation/utils/kLineDraw/axis.js.map +1 -1
  42. package/dist/foundation/utils/sessionTimeLabels.d.ts +59 -0
  43. package/dist/foundation/utils/sessionTimeLabels.d.ts.map +1 -0
  44. package/dist/foundation/utils/sessionTimeLabels.js +170 -0
  45. package/dist/foundation/utils/sessionTimeLabels.js.map +1 -0
  46. package/dist/foundation/utils/timeShareAxisLabels.d.ts +27 -0
  47. package/dist/foundation/utils/timeShareAxisLabels.d.ts.map +1 -0
  48. package/dist/foundation/utils/timeShareAxisLabels.js +45 -0
  49. package/dist/foundation/utils/timeShareAxisLabels.js.map +1 -0
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/package.json +1 -1
  53. package/src/controllers/index.ts +1 -1
  54. package/src/data/__tests__/timeShareBuffer.test.ts +45 -0
  55. package/src/data/timeShareBuffer.ts +15 -0
  56. package/src/engine/__tests__/chart.dpr.test.ts +28 -0
  57. package/src/engine/chart.ts +29 -6
  58. package/src/engine/data/chartDataManager.ts +10 -0
  59. package/src/engine/modes/__tests__/timeShareMath.test.ts +185 -0
  60. package/src/engine/modes/__tests__/timeShareMode.test.ts +81 -0
  61. package/src/engine/modes/timeShareMath.ts +150 -0
  62. package/src/engine/modes/timeShareMode.ts +35 -28
  63. package/src/engine/render/chartRenderer.ts +29 -18
  64. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +2 -2
  65. package/src/engine/renderers/Indicator/mainIndicatorLegendContext.ts +19 -11
  66. package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +4 -4
  67. package/src/engine/renderers/timeShare.ts +25 -9
  68. package/src/engine/state/__tests__/settingsState.test.ts +9 -0
  69. package/src/foundation/config/chartSettings.ts +13 -0
  70. package/src/foundation/utils/__tests__/sessionTimeLabels.test.ts +91 -0
  71. package/src/foundation/utils/kLineDraw/axis.ts +50 -9
  72. package/src/foundation/utils/sessionTimeLabels.ts +237 -0
  73. package/src/foundation/utils/timeShareAxisLabels.ts +94 -0
  74. package/src/version.ts +1 -1
@@ -224,8 +224,8 @@ function paintLegendOnCanvas(overlayCtx: CanvasRenderingContext2D, legend: Legen
224
224
  }
225
225
  }
226
226
 
227
- if (legend.ohlc) {
228
- const k = legend.ohlc
227
+ if (legend.currentBar) {
228
+ const k = legend.currentBar
229
229
  if (!compact) {
230
230
  let x = legendX
231
231
  const y = rowY()
@@ -17,8 +17,9 @@ export interface LegendLayout {
17
17
  }
18
18
 
19
19
  /** 当前 K 线及图例派生的展示字段,保留 KLineData 自定义属性。 */
20
- export type LegendOhlcRow = Omit<KLineData, 'volume'> & {
20
+ export type LegendCurrentBar = Omit<KLineData, 'volume'> & {
21
21
  volume: number | null
22
+ // 成交量+单位格式化文本(eg. 1.23亿)
22
23
  volumeText: string | null
23
24
  color: string
24
25
  }
@@ -63,11 +64,12 @@ export interface LegendTemplateContext {
63
64
  up: string
64
65
  down: string
65
66
  }
66
- ohlc: LegendOhlcRow | null
67
+ /** 十字线指向的当前 K 线展示行(含 volumeText / color 与自定义字段) */
68
+ currentBar: LegendCurrentBar | null
67
69
  timeshare: LegendTimeshareRow | null
68
70
  indicators: ReadonlyArray<LegendIndicatorRow>
69
71
  comparisons: ReadonlyArray<LegendComparisonRow>
70
- /** 当前索引处的 K 线(分时模式下可能无 close) */
72
+ /** 当前索引处的原始 K 线(分时模式下可能无 close) */
71
73
  bar: KLineData | TimeShareData | null
72
74
  }
73
75
 
@@ -110,9 +112,7 @@ export function buildLegendTemplateContext(
110
112
  const range = context.range
111
113
  const crosshairIndex = context.crosshairIndex
112
114
  const hasCrosshair = typeof crosshairIndex === 'number'
113
- const targetIndex = hasCrosshair
114
- ? crosshairIndex
115
- : Math.min(range.end - 1, klineData.length - 1)
115
+ const targetIndex = hasCrosshair ? crosshairIndex : Math.min(range.end - 1, klineData.length - 1)
116
116
 
117
117
  const layout: LegendLayout = {
118
118
  x: legendX,
@@ -126,7 +126,11 @@ export function buildLegendTemplateContext(
126
126
  let timeshare: LegendTimeshareRow | null = null
127
127
  if (context.period === 'timeshare') {
128
128
  const tsData = context.data as TimeShareData[]
129
- const preClose = (context.settings?.preClose as number) ?? tsData[0]?.price ?? 0
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)
130
134
  const item = tsData[targetIndex]
131
135
  if (item) {
132
136
  const changeAmount = item.price - preClose
@@ -145,12 +149,12 @@ export function buildLegendTemplateContext(
145
149
  }
146
150
  }
147
151
 
148
- let ohlc: LegendOhlcRow | null = null
152
+ let currentBar: LegendCurrentBar | null = null
149
153
  if (hasCrosshair) {
150
154
  const k = klineData[targetIndex]
151
155
  if (k && typeof k.close === 'number') {
152
156
  const isUp = k.close >= k.open
153
- ohlc = {
157
+ currentBar = {
154
158
  ...k,
155
159
  volume: typeof k.volume === 'number' ? k.volume : null,
156
160
  volumeText: typeof k.volume === 'number' ? formatVolumeShort(k.volume) : null,
@@ -173,7 +177,7 @@ export function buildLegendTemplateContext(
173
177
  up: colors.candleUpBody,
174
178
  down: colors.candleDownBody,
175
179
  },
176
- ohlc,
180
+ currentBar,
177
181
  timeshare,
178
182
  indicators,
179
183
  comparisons,
@@ -258,7 +262,11 @@ function collectComparisonRows(
258
262
  percent,
259
263
  color,
260
264
  percentColor:
261
- percent > 0 ? colors.candleUpBody : percent < 0 ? colors.candleDownBody : colors.text.primary,
265
+ percent > 0
266
+ ? colors.candleUpBody
267
+ : percent < 0
268
+ ? colors.candleDownBody
269
+ : colors.text.primary,
262
270
  })
263
271
  }
264
272
  return rows
@@ -574,7 +574,7 @@ describe('MainIndicatorLegend external mode & context callback', () => {
574
574
  expect(legend).not.toBeNull()
575
575
  expect(legend.index).toBe(50)
576
576
  expect(legend.hasCrosshair).toBe(true)
577
- expect(legend.ohlc).not.toBeNull()
577
+ expect(legend.currentBar).not.toBeNull()
578
578
  expect(legend.indicators.some((row: { name: string }) => row.name === 'MA')).toBe(true)
579
579
  expect(vi.mocked(ctx.fillText).mock.calls.length).toBeGreaterThan(0)
580
580
  })
@@ -601,7 +601,7 @@ describe('MainIndicatorLegend external mode & context callback', () => {
601
601
  expect(vi.mocked(ctx.fillText)).not.toHaveBeenCalled()
602
602
  })
603
603
 
604
- it('retains custom KLineData fields in the ohlc slot context', () => {
604
+ it('retains custom KLineData fields in the currentBar slot context', () => {
605
605
  const onContext = vi.fn()
606
606
  const plugin = createMainIndicatorLegendRendererPlugin({
607
607
  yPaddingPx: 20,
@@ -619,7 +619,7 @@ describe('MainIndicatorLegend external mode & context callback', () => {
619
619
  plugin.draw(context)
620
620
 
621
621
  const legend = onContext.mock.calls[0]![0]
622
- expect(legend.ohlc.turnoverRate).toBe(3.14)
623
- expect(legend.ohlc.customLabel).toBe('featured')
622
+ expect(legend.currentBar.turnoverRate).toBe(3.14)
623
+ expect(legend.currentBar.customLabel).toBe('featured')
624
624
  })
625
625
  })
@@ -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,
@@ -43,4 +43,13 @@ describe('settingsState', () => {
43
43
  ;(snap as { showGridLines?: boolean }).showGridLines = false
44
44
  }).toThrow()
45
45
  })
46
+
47
+ it('preserves extension keys like preClose through resolve/patch', () => {
48
+ expect(resolveSettings({ preClose: 12.34 }).preClose).toBe(12.34)
49
+ const s = createSettingsState({ preClose: 12.34 })
50
+ expect(s.readonly.settings.peek().preClose).toBe(12.34)
51
+ s.actions.patch({ showGridLines: false })
52
+ expect(s.readonly.settings.peek().preClose).toBe(12.34)
53
+ expect(s.readonly.settings.peek().showGridLines).toBe(false)
54
+ })
46
55
  })
@@ -158,6 +158,11 @@ export type ChartSettings = {
158
158
  * @param partial - 偏好的部分设置(通常是组件 prop 传入)
159
159
  * @returns 合并后的 ChartSettings 对象
160
160
  */
161
+ const KNOWN_SETTING_KEYS = new Set<string>([
162
+ ...DEFAULT_SETTINGS.map((item) => item.key),
163
+ 'colorPresetSettings',
164
+ ])
165
+
161
166
  export function resolveSettings(partial?: Partial<ChartSettings>): ChartSettings {
162
167
  // 用 Partial<_SettingByKey> 而非 ChartSettings 避免交叉类型索引赋值报错
163
168
  const result: Partial<_SettingByKey> = {}
@@ -170,6 +175,14 @@ export function resolveSettings(partial?: Partial<ChartSettings>): ChartSettings
170
175
  ;(result as ChartSettings).colorPresetSettings = normalizeColorPresetSettings(
171
176
  partial?.colorPresetSettings,
172
177
  )
178
+ // 保留扩展字段(如 preClose),避免业务元数据被 resolve 清掉
179
+ if (partial) {
180
+ for (const [key, value] of Object.entries(partial)) {
181
+ if (KNOWN_SETTING_KEYS.has(key)) continue
182
+ if (value === undefined) continue
183
+ ;(result as Record<string, unknown>)[key] = value
184
+ }
185
+ }
173
186
  return result as ChartSettings
174
187
  }
175
188
 
@@ -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
+ }