@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
@@ -151,7 +151,13 @@ export class Chart {
151
151
  zoomLevel: number
152
152
  scaleTypes: Map<string, ScaleType>
153
153
  mainIndicators: Array<{ id: string; params: Record<string, number | boolean | string> }>
154
- subPanes: Array<{ id: string; params: Record<string, unknown> }>
154
+ subPanes: Array<{
155
+ paneId: string
156
+ indicatorId: string
157
+ params: Record<string, unknown>
158
+ }>
159
+ paneRatios: Record<string, number>
160
+ candleEnabled: boolean
155
161
  } | null = null
156
162
 
157
163
  /** 上次预警评估的最新 K 线时间戳(用于去重) */
@@ -470,17 +476,21 @@ export class Chart {
470
476
  const prev = this._activeMode
471
477
 
472
478
  if (mode === this._timeShareMode) {
479
+ const candlePlugin = this.rendererPluginManager.getPlugin('candle')
473
480
  this._savedTimeShareState = {
474
481
  zoomLevel: this.kernel.zoom.readonly.zoomLevel.peek(),
475
482
  scaleTypes: new Map(this.kernel.pane.readonly.paneScaleTypes.peek()),
476
483
  mainIndicators: [],
477
484
  subPanes: [],
485
+ paneRatios: { ...this.kernel.pane.readonly.paneRatios.peek() },
486
+ candleEnabled: candlePlugin?.enabled !== false,
478
487
  }
479
488
  for (const [id, entry] of this.kernel.indicator.readonly.mainIndicators.peek()) {
480
489
  this._savedTimeShareState.mainIndicators.push({ id, params: { ...entry.params } })
481
490
  }
482
491
  this._savedTimeShareState.subPanes = this.indicatorManager.getSubPaneEntries().map((e) => ({
483
- id: e.indicatorId,
492
+ paneId: e.paneId,
493
+ indicatorId: e.indicatorId,
484
494
  params: { ...e.params },
485
495
  }))
486
496
  // 分时强制 percent 进 kernel,再投影(不再由 updatePaneRange 每帧旁路写)
@@ -526,7 +536,7 @@ export class Chart {
526
536
 
527
537
  if (mode === this._timeShareMode) {
528
538
  for (const { id } of this._savedTimeShareState!.mainIndicators) {
529
- if (id !== 'TIMESHARE') {
539
+ if (id !== 'TIMESHARE' && id !== 'timeShare') {
530
540
  this.indicatorManager.disableMainIndicator(id)
531
541
  }
532
542
  }
@@ -535,13 +545,26 @@ export class Chart {
535
545
  const saved = this._savedTimeShareState
536
546
  if (saved) {
537
547
  for (const { id, params } of saved.mainIndicators) {
538
- if (id !== 'TIMESHARE') {
548
+ if (id !== 'TIMESHARE' && id !== 'timeShare') {
539
549
  this.indicatorManager.enableMainIndicator(id, params)
540
550
  }
541
551
  }
542
- for (const { id, params } of saved.subPanes) {
543
- this.indicatorManager.addIndicator(id, 'sub', params)
552
+ for (const { paneId, indicatorId, params } of saved.subPanes) {
553
+ this.indicatorManager.createSubPane(
554
+ paneId,
555
+ indicatorId as SubIndicatorType,
556
+ params as Record<string, number | boolean | string>,
557
+ )
544
558
  }
559
+ // 恢复进入分时前的 pane 比例(createSubPane 会重算 3:1 分配)
560
+ if (Object.keys(saved.paneRatios).length > 0) {
561
+ const specs = this.kernel.pane.readonly.paneSpecs.peek().map((pane) => ({
562
+ ...pane,
563
+ ratio: saved.paneRatios[pane.id] ?? pane.ratio,
564
+ }))
565
+ this.kernel.pane.actions.commitLayout(saved.paneRatios, specs)
566
+ }
567
+ this.setRendererEnabled('candle', saved.candleEnabled)
545
568
  }
546
569
  this._savedTimeShareState = null
547
570
  }
@@ -452,6 +452,16 @@ export class ChartDataManager {
452
452
  return buf ? buf.getRawData() : []
453
453
  }
454
454
 
455
+ getTimeSharePreClose(): number | null {
456
+ const buf = this.getActiveTimeShareBuffer()
457
+ return buf?.getPreClose() ?? null
458
+ }
459
+
460
+ setTimeSharePreClose(preClose: number | null): void {
461
+ const buf = this.getActiveTimeShareBuffer()
462
+ if (buf) buf.setPreClose(preClose)
463
+ }
464
+
455
465
  getTimeShareSignal(): ReadonlySignal<ReadonlyArray<TimeShareData>> {
456
466
  const buf = this.getActiveTimeShareBuffer()
457
467
  return (buf?.data ?? createSignal<ReadonlyArray<TimeShareData>>([])) as ReadonlySignal<
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ resolveTimeShareSlotTimestamp,
5
+ timeShareSlotCenterX,
6
+ } from '../../../foundation/utils/timeShareAxisLabels'
7
+ import {
8
+ ASHARE_TIMESHARE_SESSION_SLOTS,
9
+ computeTimeShareBarMetrics,
10
+ computeTimeSharePaneLayout,
11
+ computeTimeSharePriceRange,
12
+ computeTimeShareTimeLabelIndices,
13
+ computeTimeShareXLayout,
14
+ resolveTimeShareSessionSlots,
15
+ resolveTimeShareBaseline,
16
+ } from '../timeShareMath'
17
+
18
+ describe('resolveTimeShareBaseline', () => {
19
+ it('prefers finite non-zero preClose over first trade price', () => {
20
+ expect(resolveTimeShareBaseline({ preClose: 10.5, firstPrice: 11 })).toBe(10.5)
21
+ })
22
+
23
+ it('falls back to first price when preClose is missing or zero', () => {
24
+ expect(resolveTimeShareBaseline({ preClose: 0, firstPrice: 11 })).toBe(11)
25
+ expect(resolveTimeShareBaseline({ firstPrice: 11 })).toBe(11)
26
+ expect(resolveTimeShareBaseline({ preClose: null, firstPrice: 9 })).toBe(9)
27
+ })
28
+
29
+ it('returns null when no valid baseline exists', () => {
30
+ expect(resolveTimeShareBaseline({ preClose: 0, firstPrice: 0 })).toBeNull()
31
+ expect(resolveTimeShareBaseline({})).toBeNull()
32
+ expect(resolveTimeShareBaseline({ preClose: NaN, firstPrice: Infinity })).toBeNull()
33
+ })
34
+ })
35
+
36
+ describe('computeTimeSharePriceRange', () => {
37
+ it('uses preClose baseline and pads around max absolute percent move', () => {
38
+ // open gap up: first trade 11, preClose 10 → +10%; later 10.5 → 5%
39
+ const range = computeTimeSharePriceRange([11, 10.5, 10.2], 10)
40
+ expect(range).not.toBeNull()
41
+ // maxAbsPct=10, padding=max(1, 0.5)=1 → display 11%
42
+ expect(range!.maxPrice).toBeCloseTo(10 * 1.11, 8)
43
+ expect(range!.minPrice).toBeCloseTo(10 * 0.89, 8)
44
+ })
45
+
46
+ it('still applies minimum padding when all prices equal baseline (flat day)', () => {
47
+ const range = computeTimeSharePriceRange([10, 10, 10], 10)
48
+ expect(range).not.toBeNull()
49
+ // maxAbsPct=0, padding=min 0.5% → ±0.5%
50
+ expect(range!.maxPrice).toBeCloseTo(10.05, 8)
51
+ expect(range!.minPrice).toBeCloseTo(9.95, 8)
52
+ })
53
+
54
+ it('returns null for invalid baseline', () => {
55
+ expect(computeTimeSharePriceRange([10], 0)).toBeNull()
56
+ expect(computeTimeSharePriceRange([10], NaN)).toBeNull()
57
+ })
58
+ })
59
+
60
+ describe('resolveTimeShareSessionSlots', () => {
61
+ it('defaults to A-share 240 one-minute slots (sessions SSOT)', () => {
62
+ expect(ASHARE_TIMESHARE_SESSION_SLOTS).toBe(240)
63
+ expect(resolveTimeShareSessionSlots(0)).toBe(240)
64
+ expect(resolveTimeShareSessionSlots(60)).toBe(240)
65
+ })
66
+
67
+ it('does not expand when arrived points exceed session slots', () => {
68
+ expect(resolveTimeShareSessionSlots(300)).toBe(240)
69
+ })
70
+ })
71
+
72
+ describe('computeTimeShareBarMetrics', () => {
73
+ it('divides viewWidth by full session slots, not arrived point count', () => {
74
+ // 盘中仅 60 点:宽度仍按 240 槽划分,不把 60 点拉满屏
75
+ const partial = computeTimeShareBarMetrics(60, 320, 1)
76
+ const full = computeTimeShareBarMetrics(240, 320, 1)
77
+ expect(partial).not.toBeNull()
78
+ expect(full).not.toBeNull()
79
+ expect(partial!.kWidth).toBeCloseTo(full!.kWidth, 8)
80
+ expect(partial!.kGap).toBeCloseTo(full!.kGap, 8)
81
+ expect((partial!.kWidth + partial!.kGap) * 240).toBeCloseTo(320, 6)
82
+ })
83
+
84
+ it('fits full session into viewWidth even when per-bar width is sub-pixel (DPR=1 narrow)', () => {
85
+ const m = computeTimeShareBarMetrics(240, 320, 1)
86
+ expect(m).not.toBeNull()
87
+ const unit = m!.kWidth + m!.kGap
88
+ expect(unit * 240).toBeCloseTo(320, 6)
89
+ expect(m!.kWidth).toBeGreaterThan(0)
90
+ expect(m!.kGap).toBeGreaterThanOrEqual(0)
91
+ })
92
+
93
+ it('keeps full session visible at high DPR', () => {
94
+ const m = computeTimeShareBarMetrics(240, 320, 2)
95
+ expect(m).not.toBeNull()
96
+ expect((m!.kWidth + m!.kGap) * 240).toBeCloseTo(320, 6)
97
+ })
98
+
99
+ it('returns null for empty data or invalid width', () => {
100
+ expect(computeTimeShareBarMetrics(0, 320, 1)).toBeNull()
101
+ expect(computeTimeShareBarMetrics(10, 0, 1)).toBeNull()
102
+ })
103
+ })
104
+
105
+ describe('computeTimeShareXLayout', () => {
106
+ it('places partial-day points on session timeline leaving right-side blank', () => {
107
+ const layout = computeTimeShareXLayout({
108
+ arrivedCount: 60,
109
+ sessionSlots: 240,
110
+ totalWidth: 480,
111
+ dpr: 1,
112
+ })
113
+ expect(layout).not.toBeNull()
114
+ // step = 480/240 = 2;第 0 点中心 1,第 59 点中心 119,未到右缘 480
115
+ expect(layout!.centers[0]).toBeCloseTo(1, 6)
116
+ expect(layout!.centers[59]).toBeCloseTo(119, 6)
117
+ expect(layout!.centers[59]!).toBeLessThan(480 * 0.3)
118
+ expect(layout!.step).toBeCloseTo(2, 6)
119
+ })
120
+
121
+ it('fills full width only when arrivedCount covers full session', () => {
122
+ const layout = computeTimeShareXLayout({
123
+ arrivedCount: 240,
124
+ sessionSlots: 240,
125
+ totalWidth: 480,
126
+ dpr: 1,
127
+ })
128
+ expect(layout).not.toBeNull()
129
+ expect(layout!.centers[0]).toBeCloseTo(1, 6)
130
+ expect(layout!.centers[239]).toBeCloseTo(479, 6)
131
+ })
132
+ })
133
+
134
+ describe('computeTimeSharePaneLayout', () => {
135
+ it('splits price area above volume area without overlap', () => {
136
+ const layout = computeTimeSharePaneLayout(400, 0.25)
137
+ expect(layout.priceAreaHeight).toBe(300)
138
+ expect(layout.volumeAreaHeight).toBe(100)
139
+ expect(layout.priceTop).toBe(0)
140
+ expect(layout.volumeTop).toBe(300)
141
+ expect(layout.priceTop + layout.priceAreaHeight).toBe(layout.volumeTop)
142
+ })
143
+ })
144
+
145
+ describe('computeTimeShareTimeLabelIndices', () => {
146
+ it('only session closed-side endpoints: 9:30 / 13:00 / 15:00', () => {
147
+ const labels = computeTimeShareTimeLabelIndices({
148
+ axisWidth: 800,
149
+ sessionSlots: 240,
150
+ })
151
+ // 9:30 → 0;13:00 → 120;15:00 → 239
152
+ expect(labels).toEqual([0, 120, 239])
153
+ })
154
+
155
+ it('returns empty for invalid axis width', () => {
156
+ expect(
157
+ computeTimeShareTimeLabelIndices({
158
+ axisWidth: 0,
159
+ sessionSlots: 240,
160
+ }),
161
+ ).toEqual([])
162
+ })
163
+ })
164
+
165
+ describe('timeShare slot time/x helpers', () => {
166
+ it('maps A-share slots across lunch break', () => {
167
+ // 2026-07-21 local
168
+ const day = new Date(2026, 6, 21, 10, 0, 0, 0).getTime()
169
+ expect(new Date(resolveTimeShareSlotTimestamp(day, 0)).getHours()).toBe(9)
170
+ expect(new Date(resolveTimeShareSlotTimestamp(day, 0)).getMinutes()).toBe(30)
171
+ // slot 120 = 13:00
172
+ expect(new Date(resolveTimeShareSlotTimestamp(day, 120)).getHours()).toBe(13)
173
+ expect(new Date(resolveTimeShareSlotTimestamp(day, 120)).getMinutes()).toBe(0)
174
+ // slot 239 = 14:59
175
+ expect(new Date(resolveTimeShareSlotTimestamp(day, 239)).getHours()).toBe(14)
176
+ expect(new Date(resolveTimeShareSlotTimestamp(day, 239)).getMinutes()).toBe(59)
177
+ })
178
+
179
+ it('places last session slot near right edge of axis', () => {
180
+ const x0 = timeShareSlotCenterX(0, 480, 240, 1)
181
+ const xLast = timeShareSlotCenterX(239, 480, 240, 1)
182
+ expect(x0).toBeCloseTo(1, 6)
183
+ expect(xLast).toBeCloseTo(479, 6)
184
+ })
185
+ })
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { Pane } from '../../layout/pane'
4
+ import { TimeShareMode } from '../timeShareMode'
5
+ import type { TimeShareData } from '../../../foundation/types/price'
6
+
7
+ function ts(price: number, i = 0): TimeShareData {
8
+ return { timestamp: i, price, average: price, volume: 1, amount: price }
9
+ }
10
+
11
+ function mockDm(points: TimeShareData[], preClose: number | null = null) {
12
+ return {
13
+ getTimeShareData: () => points,
14
+ getTimeSharePreClose: () => preClose,
15
+ } as unknown as import('../../data/chartDataManager').ChartDataManager
16
+ }
17
+
18
+ describe('TimeShareMode', () => {
19
+ it('computeKWidth fits full session into narrow view (no DPR truncation)', () => {
20
+ const mode = new TimeShareMode()
21
+ const m = mode.computeKWidth(240, 320, 1)
22
+ expect(m).not.toBeNull()
23
+ expect((m!.kWidth + m!.kGap) * 240).toBeCloseTo(320, 6)
24
+ })
25
+
26
+ it('computeKWidth for partial day matches full-session bar size', () => {
27
+ const mode = new TimeShareMode()
28
+ const partial = mode.computeKWidth(60, 320, 1)
29
+ const full = mode.computeKWidth(240, 320, 1)
30
+ expect(partial).not.toBeNull()
31
+ expect(full).not.toBeNull()
32
+ expect(partial!.kWidth).toBeCloseTo(full!.kWidth, 8)
33
+ expect(partial!.kGap).toBeCloseTo(full!.kGap, 8)
34
+ })
35
+
36
+ it('setMarketSession switches bar metrics to HK 330 slots', () => {
37
+ const mode = new TimeShareMode()
38
+ const ashare = mode.computeKWidth(100, 330, 1)!
39
+ mode.setMarketSession({
40
+ timeZone: 'Asia/Hong_Kong',
41
+ sessions: [
42
+ { open: 9 * 60 + 30, close: 12 * 60 },
43
+ { open: 13 * 60, close: 16 * 60 },
44
+ ],
45
+ slotMinutes: 1,
46
+ })
47
+ const hk = mode.computeKWidth(100, 330, 1)!
48
+ // A 股 240 槽 unit=330/240;港股 330 槽 unit=1
49
+ expect(ashare.kWidth + ashare.kGap).toBeCloseTo(330 / 240, 6)
50
+ expect(hk.kWidth + hk.kGap).toBeCloseTo(1, 6)
51
+ })
52
+
53
+ it('updatePaneRange uses preClose as basePrice and covers open gap', () => {
54
+ const mode = new TimeShareMode()
55
+ const pane = new Pane('main')
56
+ const setBase = vi.spyOn(pane.yAxis, 'setBasePrice')
57
+ const setRange = vi.spyOn(pane.yAxis, 'setRange')
58
+
59
+ // open gap: first trade 11, preClose 10
60
+ mode.updatePaneRange(pane, { start: 0, end: 3 }, mockDm([ts(11), ts(10.5), ts(10.2)], 10))
61
+
62
+ expect(setBase).toHaveBeenCalledWith(10)
63
+ expect(setRange).toHaveBeenCalled()
64
+ const range = setRange.mock.calls[0]![0] as { minPrice: number; maxPrice: number }
65
+ // maxAbs 10% + 1% padding → ±11%
66
+ expect(range.maxPrice).toBeCloseTo(11.1, 6)
67
+ expect(range.minPrice).toBeCloseTo(8.9, 6)
68
+ })
69
+
70
+ it('updatePaneRange still sets range on flat day', () => {
71
+ const mode = new TimeShareMode()
72
+ const pane = new Pane('main')
73
+ const setRange = vi.spyOn(pane.yAxis, 'setRange')
74
+
75
+ mode.updatePaneRange(pane, { start: 0, end: 2 }, mockDm([ts(10), ts(10)], 10))
76
+ expect(setRange).toHaveBeenCalled()
77
+ const range = setRange.mock.calls[0]![0] as { minPrice: number; maxPrice: number }
78
+ expect(range.maxPrice).toBeCloseTo(10.05, 6)
79
+ expect(range.minPrice).toBeCloseTo(9.95, 6)
80
+ })
81
+ })
@@ -0,0 +1,150 @@
1
+ import {
2
+ ASHARE_MARKET_SESSION,
3
+ resolveMarketSessionSlots,
4
+ type MarketSessionConfig,
5
+ } from '../../foundation/utils/timeShareAxisLabels'
6
+
7
+ export type TimeShareBaselineInput = {
8
+ preClose?: number | null
9
+ firstPrice?: number | null
10
+ }
11
+
12
+ export function resolveTimeShareBaseline(input: TimeShareBaselineInput): number | null {
13
+ const candidates = [input.preClose, input.firstPrice]
14
+ for (const v of candidates) {
15
+ if (typeof v === 'number' && Number.isFinite(v) && v !== 0) return v
16
+ }
17
+ return null
18
+ }
19
+
20
+ export type TimeSharePriceRange = {
21
+ minPrice: number
22
+ maxPrice: number
23
+ }
24
+
25
+ /**
26
+ * 分时 Y 轴价格区间:以 baseline(昨收)为中心,对称覆盖可见最大绝对涨跌幅并加 padding。
27
+ * 全天平盘时仍保留最小 0.5% 边距,避免 range 退化。
28
+ */
29
+ export function computeTimeSharePriceRange(
30
+ prices: ReadonlyArray<number | undefined | null>,
31
+ baseline: number,
32
+ ): TimeSharePriceRange | null {
33
+ if (!Number.isFinite(baseline) || baseline === 0) return null
34
+
35
+ let maxAbsPct = 0
36
+ for (const p of prices) {
37
+ if (typeof p !== 'number' || !Number.isFinite(p)) continue
38
+ const pct = Math.abs((p - baseline) / baseline) * 100
39
+ if (pct > maxAbsPct) maxAbsPct = pct
40
+ }
41
+
42
+ const padding = Math.max(maxAbsPct * 0.1, 0.5)
43
+ const displayPct = maxAbsPct + padding
44
+ return {
45
+ minPrice: baseline * (1 - displayPct / 100),
46
+ maxPrice: baseline * (1 + displayPct / 100),
47
+ }
48
+ }
49
+
50
+ /** A 股默认全天 1 分钟槽位数(兼容旧导出) */
51
+ export const ASHARE_TIMESHARE_SESSION_SLOTS = resolveMarketSessionSlots(ASHARE_MARKET_SESSION)
52
+
53
+ /**
54
+ * 全天交易槽位数:以 marketSession 为 SSOT,不因 arrivedCount 放大。
55
+ * @deprecated 优先用 resolveMarketSessionSlots(config)
56
+ */
57
+ export function resolveTimeShareSessionSlots(
58
+ arrivedCount: number,
59
+ marketSession: MarketSessionConfig = ASHARE_MARKET_SESSION,
60
+ ): number {
61
+ void arrivedCount
62
+ return resolveMarketSessionSlots(marketSession)
63
+ }
64
+
65
+ /**
66
+ * 分时 bar 宽度:按全天 sessionSlots 均分 viewWidth,而非按已到达点数。
67
+ * 盘中部分数据时右侧留白;允许亚像素,避免窄屏截断。
68
+ */
69
+ export function computeTimeShareBarMetrics(
70
+ dataLength: number,
71
+ viewWidth: number,
72
+ dpr: number,
73
+ marketSession: MarketSessionConfig = ASHARE_MARKET_SESSION,
74
+ ): { kWidth: number; kGap: number } | null {
75
+ if (dataLength <= 0 || viewWidth <= 0 || !(dpr > 0)) return null
76
+
77
+ const sessionSlots = resolveMarketSessionSlots(marketSession)
78
+ if (sessionSlots <= 0) return null
79
+ const unit = viewWidth / sessionSlots
80
+ const preferredGap = 1 / dpr
81
+ const kGap = Math.min(preferredGap, unit * 0.2)
82
+ const kWidth = Math.max(unit - kGap, unit * 0.01)
83
+ return { kWidth, kGap: unit - kWidth }
84
+ }
85
+
86
+ export type TimeShareXLayoutInput = {
87
+ arrivedCount: number
88
+ sessionSlots: number
89
+ totalWidth: number
90
+ dpr: number
91
+ }
92
+
93
+ export type TimeShareXLayout = {
94
+ step: number
95
+ centers: number[]
96
+ lefts: number[]
97
+ barWidth: number
98
+ kWidthPx: number
99
+ }
100
+
101
+ /**
102
+ * 分时 X 布局:step = totalWidth / sessionSlots,已到达点按 slot 索引落位,未到时段留白。
103
+ */
104
+ export function computeTimeShareXLayout(input: TimeShareXLayoutInput): TimeShareXLayout | null {
105
+ const { arrivedCount, sessionSlots, totalWidth, dpr } = input
106
+ if (arrivedCount <= 0 || sessionSlots <= 0 || totalWidth <= 0 || !(dpr > 0)) return null
107
+
108
+ const step = totalWidth / sessionSlots
109
+ const centers: number[] = new Array(arrivedCount)
110
+ const lefts: number[] = new Array(arrivedCount)
111
+ for (let i = 0; i < arrivedCount; i++) {
112
+ centers[i] = Math.round((i + 0.5) * step * dpr) / dpr
113
+ lefts[i] = Math.round(i * step * dpr) / dpr
114
+ }
115
+
116
+ const logicalBarWidth = Math.max(step * 0.6, 1 / dpr)
117
+ const barWidthPx = Math.max(1, Math.round(logicalBarWidth * dpr))
118
+ const barWidth = barWidthPx / dpr
119
+ const kWidthPx = Math.max(1, Math.round(step * dpr))
120
+
121
+ return { step, centers, lefts, barWidth, kWidthPx }
122
+ }
123
+
124
+ export type TimeSharePaneLayout = {
125
+ priceTop: number
126
+ priceAreaHeight: number
127
+ volumeTop: number
128
+ volumeAreaHeight: number
129
+ }
130
+
131
+ export function computeTimeSharePaneLayout(
132
+ paneHeight: number,
133
+ volumeRatio: number,
134
+ ): TimeSharePaneLayout {
135
+ const ratio = Math.min(1, Math.max(0, volumeRatio))
136
+ const volumeAreaHeight = paneHeight * ratio
137
+ const priceAreaHeight = paneHeight - volumeAreaHeight
138
+ return {
139
+ priceTop: 0,
140
+ priceAreaHeight,
141
+ volumeTop: priceAreaHeight,
142
+ volumeAreaHeight,
143
+ }
144
+ }
145
+
146
+ export {
147
+ TIMESHARE_MIN_LABEL_SPACING_PX,
148
+ computeTimeShareTimeLabelIndices,
149
+ type TimeShareTimeLabelInput,
150
+ } from '../../foundation/utils/timeShareAxisLabels'
@@ -1,6 +1,14 @@
1
1
  import type { ChartDataManager } from '../data/chartDataManager'
2
2
  import type { Pane, VisibleRange } from '../layout/pane'
3
3
 
4
+ import type { MarketSessionConfig } from '../../foundation/utils/timeShareAxisLabels'
5
+ import { ASHARE_MARKET_SESSION } from '../../foundation/utils/timeShareAxisLabels'
6
+
7
+ import {
8
+ computeTimeShareBarMetrics,
9
+ computeTimeSharePriceRange,
10
+ resolveTimeShareBaseline,
11
+ } from './timeShareMath'
4
12
  import type { ChartModeHandler } from './types'
5
13
 
6
14
  export class TimeShareMode implements ChartModeHandler {
@@ -12,6 +20,17 @@ export class TimeShareMode implements ChartModeHandler {
12
20
  readonly allowRightAxisScale = false
13
21
  readonly useIndicatorScheduler = false
14
22
 
23
+ /** 市场 session;默认 A 股,可 setMarketSession 切换 */
24
+ private _marketSession: MarketSessionConfig = ASHARE_MARKET_SESSION
25
+
26
+ get marketSession(): MarketSessionConfig {
27
+ return this._marketSession
28
+ }
29
+
30
+ setMarketSession(config: MarketSessionConfig): void {
31
+ this._marketSession = config
32
+ }
33
+
15
34
  computeContentWidth(
16
35
  _dataLength: number,
17
36
  leftBufferWidth: number,
@@ -27,17 +46,7 @@ export class TimeShareMode implements ChartModeHandler {
27
46
  viewWidth: number,
28
47
  dpr: number,
29
48
  ): { kWidth: number; kGap: number } | null {
30
- if (dataLength <= 0 || viewWidth <= 0) return null
31
-
32
- const kGapPx = 1
33
- const totalGapPx = (dataLength + 1) * kGapPx
34
- const availablePx = Math.max(1, viewWidth * dpr - totalGapPx)
35
- const kWidthPx = Math.max(1, Math.floor(availablePx / dataLength))
36
-
37
- return {
38
- kWidth: kWidthPx / dpr,
39
- kGap: kGapPx / dpr,
40
- }
49
+ return computeTimeShareBarMetrics(dataLength, viewWidth, dpr, this._marketSession)
41
50
  }
42
51
 
43
52
  updatePaneRange(
@@ -47,30 +56,28 @@ export class TimeShareMode implements ChartModeHandler {
47
56
  _mergedIndicatorRange?: { min: number; max: number } | null,
48
57
  ): void {
49
58
  const tsData = dm.getTimeShareData()
50
- const end = Math.min(range.end, tsData.length)
51
59
  if (tsData.length === 0) return
52
60
 
53
- const baseline = tsData[0]?.price ?? 0
54
- if (baseline === 0) return
61
+ const end = Math.min(range.end, tsData.length)
62
+ const start = Math.max(0, range.start)
63
+ // 优先昨收;缺失时回退首笔价
64
+ const baseline = resolveTimeShareBaseline({
65
+ preClose: dm.getTimeSharePreClose(),
66
+ firstPrice: tsData[0]?.price,
67
+ })
68
+ if (baseline === null) return
55
69
 
56
70
  // scaleType 由 kernel.paneScaleTypes 投影(进入 timeshare 时写 percent);此处只设会话 basePrice
57
71
  pane.yAxis.setBasePrice(baseline)
58
72
 
59
- let maxAbsPct = 0
60
- for (let i = Math.max(0, range.start); i < end; i++) {
73
+ const prices: number[] = []
74
+ for (let i = start; i < end; i++) {
61
75
  const p = tsData[i]?.price
62
- if (p !== undefined && Number.isFinite(p)) {
63
- const pct = Math.abs((p - baseline) / baseline) * 100
64
- if (pct > maxAbsPct) maxAbsPct = pct
65
- }
76
+ if (p !== undefined) prices.push(p)
66
77
  }
67
- if (maxAbsPct <= 0) return
68
-
69
- const padding = Math.max(maxAbsPct * 0.1, 0.5)
70
- const displayPct = maxAbsPct + padding
71
- const minPrice = baseline * (1 - displayPct / 100)
72
- const maxPrice = baseline * (1 + displayPct / 100)
73
- pane.yAxis.setRange({ maxPrice, minPrice })
78
+ const priceRange = computeTimeSharePriceRange(prices, baseline)
79
+ if (!priceRange) return
80
+ pane.yAxis.setRange(priceRange)
74
81
  }
75
82
 
76
83
  onActivate(
@@ -103,6 +110,6 @@ export class TimeShareMode implements ChartModeHandler {
103
110
  _next: ChartModeHandler | null,
104
111
  ): void {
105
112
  chart.disableMainIndicator('timeShare')
106
- chart.setRendererEnabled('candle', true)
113
+ // candle 可见性由 Chart.setActiveMode 按进入前快照恢复,此处不强制 true
107
114
  }
108
115
  }
@@ -31,6 +31,9 @@ import { ChartIndicatorManager } from '../indicators/chartIndicatorManager'
31
31
  import { UpdateLevel } from '../layout/pane'
32
32
  import type { VisibleRange } from '../layout/pane'
33
33
  import { MarkerManager, type CustomMarkerEntity, type MarkerManagerDeps } from '../marker/registry'
34
+ import { ASHARE_MARKET_SESSION } from '../../foundation/utils/timeShareAxisLabels'
35
+ import { resolveMarketSessionSlots } from '../../foundation/utils/timeShareAxisLabels'
36
+ import { computeTimeShareXLayout } from '../modes/timeShareMath'
34
37
  import type { ChartModeHandler } from '../modes/types'
35
38
  import { PaneRenderer } from '../paneRenderer'
36
39
  import { createTimeAxisRendererPlugin } from '../renderers/timeAxis'
@@ -588,29 +591,31 @@ export class ChartRenderer {
588
591
  kBarRects[i] = { x: barLeftPx / vp.dpr, width: barWidthPx / vp.dpr }
589
592
  }
590
593
 
591
- // TimeShare plotWidth 平分 bar,覆盖 calcKLinePositions 的结果
594
+ // TimeShare:按全天 sessionSlots 划分宽度,已到达点落在时间线上,未到时段右侧留白
592
595
  if (mode.debugName === 'TimeShare') {
593
- const totalWidth = vp.plotWidth
594
596
  const count = kLineCenters.length
595
- if (count > 0) {
596
- const dpr = vp.dpr
597
- const step = totalWidth / count
598
- for (let i = 0; i < count; i++) {
599
- kLineCenters[i] = Math.round((i + 0.5) * step * dpr) / dpr
600
- kLinePositions[i] = Math.round(i * step * dpr) / dpr
601
- }
602
- kWidthPx = Math.round((totalWidth * dpr) / count)
603
-
604
- const logicalBarWidth = Math.max(1, step * 0.6)
605
- const barWidthPx = Math.round(logicalBarWidth * dpr)
606
- const halfBarPx = Math.floor(barWidthPx / 2)
597
+ const marketSession =
598
+ 'marketSession' in mode && mode.marketSession
599
+ ? (mode as { marketSession: typeof ASHARE_MARKET_SESSION }).marketSession
600
+ : ASHARE_MARKET_SESSION
601
+ const layout = computeTimeShareXLayout({
602
+ arrivedCount: count,
603
+ sessionSlots: resolveMarketSessionSlots(marketSession),
604
+ totalWidth: vp.plotWidth,
605
+ dpr: vp.dpr,
606
+ })
607
+ if (layout) {
608
+ const halfBarPx = Math.floor((layout.barWidth * vp.dpr) / 2)
607
609
  for (let i = 0; i < count; i++) {
608
- const centerPx = Math.round(kLineCenters[i]! * dpr)
610
+ kLineCenters[i] = layout.centers[i]!
611
+ kLinePositions[i] = layout.lefts[i]!
612
+ const centerPx = Math.round(layout.centers[i]! * vp.dpr)
609
613
  kBarRects[i] = {
610
- x: (centerPx - halfBarPx) / dpr,
611
- width: barWidthPx / dpr,
614
+ x: (centerPx - halfBarPx) / vp.dpr,
615
+ width: layout.barWidth,
612
616
  }
613
617
  }
618
+ kWidthPx = layout.kWidthPx
614
619
  } else {
615
620
  kWidthPx = getPhysicalKLineConfig(opt.kWidth, opt.kGap, vp.dpr).kWidthPx
616
621
  }
@@ -826,7 +831,13 @@ export class ChartRenderer {
826
831
  plotWidth: vp.plotWidth,
827
832
  plotHeight: vp.plotHeight,
828
833
  },
829
- settings: this.settings,
834
+ settings: {
835
+ ...this.settings,
836
+ // 分时昨收优先读 series 元数据,settings 作回退
837
+ preClose:
838
+ dataManager.getTimeSharePreClose() ??
839
+ (this.settings.preClose as number | undefined),
840
+ },
830
841
  yAxisLabels: sharedYAxisLabels,
831
842
  xAxisLabels: sharedXAxisLabels,
832
843
  yAxisRanges: sharedYAxisRanges,