@363045841yyt/klinechart-core 0.8.10-alpha.2 → 0.8.10

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 (89) hide show
  1. package/dist/controllers/index.d.ts +1 -1
  2. package/dist/controllers/index.d.ts.map +1 -1
  3. package/dist/controllers/index.js.map +1 -1
  4. package/dist/data-fetchers/dataBuffer.d.ts +0 -1
  5. package/dist/data-fetchers/dataBuffer.d.ts.map +1 -1
  6. package/dist/data-fetchers/dataBuffer.effects.d.ts +21 -0
  7. package/dist/data-fetchers/dataBuffer.effects.d.ts.map +1 -0
  8. package/dist/data-fetchers/dataBuffer.effects.js +55 -0
  9. package/dist/data-fetchers/dataBuffer.effects.js.map +1 -0
  10. package/dist/data-fetchers/dataBuffer.js +58 -93
  11. package/dist/data-fetchers/dataBuffer.js.map +1 -1
  12. package/dist/data-fetchers/index.d.ts +1 -0
  13. package/dist/data-fetchers/index.d.ts.map +1 -1
  14. package/dist/data-fetchers/index.js +1 -0
  15. package/dist/data-fetchers/index.js.map +1 -1
  16. package/dist/data-fetchers/timeShareBuffer.d.ts +2 -1
  17. package/dist/data-fetchers/timeShareBuffer.d.ts.map +1 -1
  18. package/dist/data-fetchers/timeShareBuffer.js +36 -14
  19. package/dist/data-fetchers/timeShareBuffer.js.map +1 -1
  20. package/dist/engine/data/chartDataManager.d.ts.map +1 -1
  21. package/dist/engine/data/chartDataManager.js +2 -1
  22. package/dist/engine/data/chartDataManager.js.map +1 -1
  23. package/dist/engine/drawing/AnchorCollector.d.ts +26 -0
  24. package/dist/engine/drawing/AnchorCollector.d.ts.map +1 -0
  25. package/dist/engine/drawing/AnchorCollector.js +47 -0
  26. package/dist/engine/drawing/AnchorCollector.js.map +1 -0
  27. package/dist/engine/drawing/DragHandler.d.ts +38 -0
  28. package/dist/engine/drawing/DragHandler.d.ts.map +1 -0
  29. package/dist/engine/drawing/DragHandler.js +92 -0
  30. package/dist/engine/drawing/DragHandler.js.map +1 -0
  31. package/dist/engine/drawing/DrawingState.d.ts +51 -0
  32. package/dist/engine/drawing/DrawingState.d.ts.map +1 -0
  33. package/dist/engine/drawing/DrawingState.js +115 -0
  34. package/dist/engine/drawing/DrawingState.js.map +1 -0
  35. package/dist/engine/drawing/HitTester.d.ts +59 -0
  36. package/dist/engine/drawing/HitTester.d.ts.map +1 -0
  37. package/dist/engine/drawing/HitTester.js +219 -0
  38. package/dist/engine/drawing/HitTester.js.map +1 -0
  39. package/dist/engine/drawing/PreviewRenderer.d.ts +26 -0
  40. package/dist/engine/drawing/PreviewRenderer.d.ts.map +1 -0
  41. package/dist/engine/drawing/PreviewRenderer.js +131 -0
  42. package/dist/engine/drawing/PreviewRenderer.js.map +1 -0
  43. package/dist/engine/drawing/coordinateUtils.d.ts +57 -0
  44. package/dist/engine/drawing/coordinateUtils.d.ts.map +1 -0
  45. package/dist/engine/drawing/coordinateUtils.js +103 -0
  46. package/dist/engine/drawing/coordinateUtils.js.map +1 -0
  47. package/dist/engine/drawing/index.d.ts.map +1 -1
  48. package/dist/engine/drawing/index.js +11 -3
  49. package/dist/engine/drawing/index.js.map +1 -1
  50. package/dist/engine/drawing/interaction.d.ts +44 -40
  51. package/dist/engine/drawing/interaction.d.ts.map +1 -1
  52. package/dist/engine/drawing/interaction.js +132 -571
  53. package/dist/engine/drawing/interaction.js.map +1 -1
  54. package/dist/engine/drawing/toolConfig.d.ts +24 -0
  55. package/dist/engine/drawing/toolConfig.d.ts.map +1 -0
  56. package/dist/engine/drawing/toolConfig.js +76 -0
  57. package/dist/engine/drawing/toolConfig.js.map +1 -0
  58. package/dist/plugin/types.d.ts +1 -0
  59. package/dist/plugin/types.d.ts.map +1 -1
  60. package/dist/plugin/types.js.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/dist/version.d.ts.map +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/version.js.map +1 -1
  65. package/package.json +4 -1
  66. package/src/controllers/index.ts +1 -0
  67. package/src/data-fetchers/__tests__/dataBuffer.test.ts +1 -3
  68. package/src/data-fetchers/dataBuffer.effects.ts +118 -0
  69. package/src/data-fetchers/dataBuffer.ts +45 -86
  70. package/src/data-fetchers/index.ts +7 -0
  71. package/src/data-fetchers/timeShareBuffer.ts +58 -19
  72. package/src/engine/__tests__/paneRenderer.resize.test.ts +3 -0
  73. package/src/engine/__tests__/subPaneManager.test.ts +13 -3
  74. package/src/engine/data/chartDataManager.ts +2 -1
  75. package/src/engine/drawing/AnchorCollector.ts +57 -0
  76. package/src/engine/drawing/DragHandler.ts +121 -0
  77. package/src/engine/drawing/DrawingState.ts +132 -0
  78. package/src/engine/drawing/HitTester.ts +288 -0
  79. package/src/engine/drawing/PreviewRenderer.ts +157 -0
  80. package/src/engine/drawing/coordinateUtils.ts +139 -0
  81. package/src/engine/drawing/index.ts +10 -3
  82. package/src/engine/drawing/interaction.ts +177 -687
  83. package/src/engine/drawing/toolConfig.ts +103 -0
  84. package/src/engine/indicators/__tests__/chartIndicatorManager.test.ts +1 -0
  85. package/src/engine/indicators/__tests__/stateComposer.test.ts +5 -4
  86. package/src/engine/renderers/Indicator/__tests__/createSubIndicatorRenderer.test.ts +1 -0
  87. package/src/plugin/types.ts +1 -0
  88. package/src/tokens/__tests__/tokens.test.ts +2 -1
  89. package/src/version.ts +1 -1
@@ -1,41 +1,15 @@
1
1
  import { createSignal, type Signal } from '../reactivity/signal'
2
2
  import type { DataFetcher, KLineData, SymbolSpec } from '../controllers/types'
3
3
  import type { DataBufferLike } from './dataBufferTypes'
4
+ import { Effect, pipe } from 'effect'
5
+ import type { Effect as EffectType } from 'effect/Effect'
6
+ import { fetchKLine, KLineFetchService, getPeriodDays, formatDate, MS_PER_DAY } from './dataBuffer.effects'
4
7
 
5
8
  export interface DataWindow {
6
9
  earliestTs: number
7
10
  latestTs: number
8
11
  }
9
12
 
10
- const MS_PER_DAY = 86_400_000
11
- const FETCH_MAX_RETRIES = 2
12
-
13
- const PERIOD_INITIAL_DAYS: Record<string, number> = {
14
- '1min': 3,
15
- '5min': 30,
16
- '15min': 60,
17
- '30min': 90,
18
- '60min': 180,
19
- daily: 365,
20
- weekly: 365,
21
- monthly: 365,
22
- quarterly: 365,
23
- yearly: 365,
24
- timeshare: 1,
25
- }
26
-
27
- export function getPeriodDays(period?: string): number {
28
- return PERIOD_INITIAL_DAYS[period ?? 'daily'] ?? 365
29
- }
30
-
31
- function formatDate(ts: number): string {
32
- const d = new Date(ts)
33
- const y = d.getFullYear()
34
- const m = String(d.getMonth() + 1).padStart(2, '0')
35
- const day = String(d.getDate()).padStart(2, '0')
36
- return `${y}-${m}-${day}`
37
- }
38
-
39
13
  function mergeSortedData(
40
14
  existing: KLineData[],
41
15
  incoming: KLineData[],
@@ -62,6 +36,7 @@ export class DataBuffer implements DataBufferLike {
62
36
  private _loadedWindow: DataWindow | null = null
63
37
  private _pendingFetch: Promise<void> | null = null
64
38
  private _disposed = false
39
+ // 已尝试请求过的边界时间戳集合,防止同一个时间段重复请求
65
40
  private _attemptedBoundaries: Set<number> = new Set()
66
41
  private _monthKeys: Int32Array | null = null
67
42
  private _dayKeys: Int32Array | null = null
@@ -157,57 +132,61 @@ export class DataBuffer implements DataBufferLike {
157
132
  this.fetchRange(requestStartTs, incrementalEnd)
158
133
  }
159
134
 
160
- private loadInitial(): void {
161
- if ((!this._requestFetch && !this._fetcher) || !this._currentSpec || this._disposed) return
135
+ private loadInitial(): void {
136
+ if ((!this._requestFetch && !this._fetcher) || !this._currentSpec || this._disposed) return
162
137
 
163
- const now = Date.now()
164
- const days = getPeriodDays(this._currentSpec.period)
165
- const startDate = now - days * MS_PER_DAY
166
- const endDate = now
138
+ const now = Date.now()
139
+ const days = getPeriodDays(this._currentSpec.period)
140
+ const startDate = now - days * MS_PER_DAY
141
+ const endDate = now
167
142
 
168
- this.fetchRange(startDate, endDate)
169
- }
143
+ this.fetchRange(startDate, endDate)
144
+ }
170
145
 
171
146
  private loadInitialRange(startTs: number, endTs: number): void {
172
147
  if ((!this._requestFetch && !this._fetcher) || !this._currentSpec || this._disposed) return
173
148
  this.fetchRange(startTs, endTs)
174
149
  }
175
150
 
176
- private fetchRange(startTs: number, endTs: number, retryCount = 0): void {
151
+ private fetchRange(startTs: number, endTs: number): void {
177
152
  if ((!this._requestFetch && !this._fetcher) || !this._currentSpec || this._disposed) return
178
153
 
179
154
  if (this._pendingFetch) {
180
155
  this._pendingFetch = this._pendingFetch.then(() => {
181
156
  if (this._disposed) return
182
- return this.fetchRange(startTs, endTs, retryCount)
157
+ return this.fetchRange(startTs, endTs)
183
158
  })
184
159
  return
185
160
  }
186
161
 
187
162
  const spec = this._currentSpec
188
- const fetcher = this._fetcher
189
-
190
163
  this._loadingSignal.set(true)
191
164
 
192
- const doFetch = (): Promise<void> => {
193
- const fetchPromise = this._requestFetch
194
- ? this._requestFetch(spec, startTs, endTs)
195
- : (fetcher as NonNullable<DataFetcher>)(spec.source ?? 'baostock', {
196
- symbol: spec.symbol,
197
- startDate: formatDate(startTs),
198
- endDate: formatDate(endTs),
199
- period: spec.period ?? 'daily',
200
- adjust: spec.adjust ?? 'none',
201
- exchange: spec.exchange,
202
- })
203
- return fetchPromise.then((incoming) => {
204
- if (this._disposed) return
165
+ const service: { readonly fetch: (spec: SymbolSpec, startTs: number, endTs: number) => EffectType<ReadonlyArray<KLineData>, unknown> } = {
166
+ fetch: (s, start, end) =>
167
+ Effect.tryPromise(() => {
168
+ if (this._requestFetch) {
169
+ return this._requestFetch(s, start, end)
170
+ }
171
+ // 未定义 Fetcher 走 gotdx fallback 获取
172
+ return (this._fetcher as NonNullable<DataFetcher>)(s.source ?? 'gotdx', {
173
+ symbol: s.symbol,
174
+ startDate: formatDate(start),
175
+ endDate: formatDate(end),
176
+ period: s.period ?? 'daily',
177
+ adjust: s.adjust ?? 'none',
178
+ exchange: s.exchange,
179
+ })
180
+ }),
181
+ }
205
182
 
206
- if (incoming.length === 0) {
207
- throw new Error(
208
- `[DataBuffer] empty data for ${spec.symbol} ${formatDate(startTs)}~${formatDate(endTs)}`,
209
- )
210
- }
183
+ this._pendingFetch = pipe(
184
+ fetchKLine(spec, startTs, endTs),
185
+ Effect.provideService(KLineFetchService, service),
186
+ Effect.runPromise, // 链式传递返回值, Effect -> Promise -> run
187
+ )
188
+ .then((incoming) => {
189
+ if (this._disposed) return
211
190
 
212
191
  const oldLength = this._data.length
213
192
  const oldEarliestTs = oldLength > 0 ? this._data[0]!.timestamp : null
@@ -245,35 +224,15 @@ export class DataBuffer implements DataBufferLike {
245
224
  }
246
225
  }
247
226
  })
248
- }
249
-
250
- const attempt = (count: number): Promise<void> => {
251
- return doFetch().catch((err) => {
252
- if (this._disposed) return
253
-
254
- if (count < FETCH_MAX_RETRIES) {
255
- const delay = Math.pow(2, count) * 1000
256
- console.warn(
257
- `[DataBuffer] fetch failed, retry ${count + 1}/${FETCH_MAX_RETRIES} in ${delay}ms:`,
258
- err,
259
- )
260
- return new Promise<void>((resolve) => setTimeout(resolve, delay)).then(() => {
261
- if (this._disposed) return
262
- return attempt(count + 1)
263
- })
264
- }
265
-
266
- console.error(`[DataBuffer] fetch failed after ${FETCH_MAX_RETRIES + 1} attempts:`, err)
227
+ .catch(() => {
267
228
  this._attemptedBoundaries.delete(endTs)
268
229
  })
269
- }
270
-
271
- this._pendingFetch = attempt(retryCount).finally(() => {
272
- this._pendingFetch = null
273
- if (!this._disposed) {
274
- this._loadingSignal.set(false)
275
- }
276
- })
230
+ .finally(() => {
231
+ this._pendingFetch = null
232
+ if (!this._disposed) {
233
+ this._loadingSignal.set(false)
234
+ }
235
+ })
277
236
  }
278
237
 
279
238
  /**
@@ -8,5 +8,12 @@ export { TimeShareBuffer } from './timeShareBuffer'
8
8
  export type { DataBufferLike } from './dataBufferTypes'
9
9
  export { getRegisteredFetcher, getTimeShareFetcher, fetcherSupportsTimeShare } from './fetcherDefinitionRegistry'
10
10
  export type { TimeShareFetcherFn, TimeShareFetchConfig } from './types'
11
+ export {
12
+ getPeriodDays,
13
+ fetchKLine,
14
+ fetchTimeShare,
15
+ KLineFetchService,
16
+ TimeShareFetchService,
17
+ } from './dataBuffer.effects'
11
18
  import './gotdx'
12
19
  import './tradingview'
@@ -5,14 +5,26 @@ import type { TimeShareFetcherFn } from './types'
5
5
  import type { DataBufferLike } from './dataBufferTypes'
6
6
  import type { DataWindow } from './dataBuffer'
7
7
  import { routerTimeShareFetcher } from './router'
8
+ import { Effect, Fiber, pipe } from 'effect'
9
+ import type { Effect as EffectType } from 'effect/Effect'
10
+ import { fetchTimeShare, TimeShareFetchService } from './dataBuffer.effects'
8
11
 
9
12
  export class TimeShareBuffer implements DataBufferLike {
13
+ // 当前持有的分时数据数组(内部可变副本)
10
14
  private _data: TimeShareData[] = []
15
+ // 向外部广播只读数据快照的信号
11
16
  private _dataSignal = createSignal<ReadonlyArray<TimeShareData>>([])
17
+ // 是否正在加载中,外部 UI 绑定用
12
18
  private _loadingSignal = createSignal<boolean>(false)
19
+ // 可选的自定义 fetcher,优先级大于默认 fectcher
13
20
  private _fetcher: TimeShareFetcherFn | null = null
21
+ // 指定查询的历史日期(0 = 当天)
14
22
  private _queryDate = 0
23
+ // 请求序号,每次 load() 递增
15
24
  private _requestSeq = 0
25
+ // 当前运行的 fetch Fiber 句柄,用于随时中断旧请求
26
+ private _fetchFiber: Fiber.RuntimeFiber<readonly TimeShareData[], unknown> | null = null
27
+ // 实例是否已销毁,阻止后续任何操作
16
28
  private _disposed = false
17
29
 
18
30
  get data(): Signal<ReadonlyArray<unknown>> {
@@ -51,30 +63,52 @@ export class TimeShareBuffer implements DataBufferLike {
51
63
  return this._queryDate
52
64
  }
53
65
 
54
- async load(spec: SymbolSpec): Promise<void> {
66
+ load(spec: SymbolSpec): void {
55
67
  if (this._disposed) return
56
- const fetcher = this._fetcher ?? routerTimeShareFetcher
68
+
69
+ if (this._fetchFiber) {
70
+ Fiber.interrupt(this._fetchFiber)
71
+ this._fetchFiber = null
72
+ }
73
+
57
74
  const requestSeq = ++this._requestSeq
58
75
  this._loadingSignal.set(true)
59
76
 
60
- try {
61
- const data = await fetcher(spec.source ?? 'gotdx', {
62
- symbol: spec.symbol,
63
- exchange: spec.exchange,
64
- date: this._queryDate || undefined,
65
- })
66
- this._queryDate = 0
67
-
68
- if (requestSeq !== this._requestSeq) return
69
- if (this._disposed) return
70
-
71
- this._data = [...data]
72
- this._dataSignal.set([...data])
73
- } finally {
74
- if (requestSeq === this._requestSeq) {
75
- this._loadingSignal.set(false)
76
- }
77
+ const timeShareService: {
78
+ readonly fetch: (s: SymbolSpec, date?: number) => EffectType<ReadonlyArray<TimeShareData>, unknown>
79
+ } = {
80
+ fetch: (s, date) =>
81
+ Effect.tryPromise(() => {
82
+ const fetcher = this._fetcher ?? routerTimeShareFetcher
83
+ return fetcher(s.source ?? 'gotdx', {
84
+ symbol: s.symbol,
85
+ exchange: s.exchange,
86
+ date,
87
+ })
88
+ }),
77
89
  }
90
+
91
+ const effect = pipe(
92
+ fetchTimeShare(spec, this._queryDate || undefined),
93
+ Effect.provideService(TimeShareFetchService, timeShareService),
94
+ Effect.tap((data) =>
95
+ Effect.sync(() => {
96
+ if (this._disposed) return
97
+ this._queryDate = 0
98
+ this._data = [...data]
99
+ this._dataSignal.set([...data])
100
+ }),
101
+ ),
102
+ Effect.ensuring(
103
+ Effect.sync(() => {
104
+ if (requestSeq === this._requestSeq) {
105
+ this._loadingSignal.set(false)
106
+ }
107
+ }),
108
+ ),
109
+ )
110
+
111
+ this._fetchFiber = Effect.runFork(effect)
78
112
  }
79
113
 
80
114
  setInlineData(data: unknown[]): void {
@@ -83,9 +117,14 @@ export class TimeShareBuffer implements DataBufferLike {
83
117
  this._dataSignal.set([...(data as TimeShareData[])])
84
118
  }
85
119
 
120
+ // 销毁实例
86
121
  dispose(): void {
87
122
  this._disposed = true
88
123
  this._requestSeq++
124
+ if (this._fetchFiber) {
125
+ Fiber.interrupt(this._fetchFiber)
126
+ this._fetchFiber = null
127
+ }
89
128
  this._data = []
90
129
  this._loadingSignal.set(false)
91
130
  }
@@ -16,6 +16,7 @@ describe('PaneRenderer resize DPR mapping', () => {
16
16
  pane,
17
17
  {
18
18
  rightAxisWidth: 80,
19
+ leftAxisWidth: 60,
19
20
  yPaddingPx: 0,
20
21
  priceLabelWidth: 60,
21
22
  },
@@ -44,6 +45,7 @@ describe('PaneRenderer resize DPR mapping', () => {
44
45
  pane,
45
46
  {
46
47
  rightAxisWidth: 100,
48
+ leftAxisWidth: 60,
47
49
  yPaddingPx: 0,
48
50
  priceLabelWidth: 70,
49
51
  },
@@ -77,6 +79,7 @@ describe('PaneRenderer resize DPR mapping', () => {
77
79
  pane,
78
80
  {
79
81
  rightAxisWidth: 100,
82
+ leftAxisWidth: 60,
80
83
  yPaddingPx: 0,
81
84
  priceLabelWidth: 70,
82
85
  },
@@ -6,7 +6,12 @@ import type { IndicatorScheduler } from '../indicators/scheduler'
6
6
  function createMockScheduler(): Partial<IndicatorScheduler> {
7
7
  return {
8
8
  getIndicatorMetadata: vi.fn((_id: string) => ({
9
- rendererFactory: vi.fn(() => ({ name: 'custom_rsi_rsi_0' })),
9
+ name: _id,
10
+ displayName: 'Test',
11
+ category: 'sub' as const,
12
+ stateKey: _id,
13
+ defaultPaneId: 'sub',
14
+ rendererFactory: vi.fn(() => ({ name: 'custom_rsi_rsi_0', paneId: 'sub', priority: 0, draw: vi.fn() })),
10
15
  updateConfig: vi.fn(),
11
16
  scale: { indicatorKey: 'test', label: 'Test', decimals: 2 },
12
17
  })),
@@ -88,8 +93,13 @@ describe('SubPaneManager', () => {
88
93
  it('should update scheduler config via definition.updateConfig', () => {
89
94
  const updateConfigSpy = vi.fn()
90
95
  const customScheduler: Partial<IndicatorScheduler> = {
91
- getIndicatorMetadata: vi.fn(() => ({
92
- rendererFactory: vi.fn(() => ({ name: 'custom_rsi_rsi_0' })),
96
+ getIndicatorMetadata: vi.fn((_id: string) => ({
97
+ name: _id,
98
+ displayName: 'Test',
99
+ category: 'sub' as const,
100
+ stateKey: _id,
101
+ defaultPaneId: 'sub',
102
+ rendererFactory: vi.fn(() => ({ name: 'custom_rsi_rsi_0', paneId: 'sub', priority: 0, draw: vi.fn() })),
93
103
  updateConfig: updateConfigSpy,
94
104
  scale: { indicatorKey: 'test', label: 'Test', decimals: 2 },
95
105
  })),
@@ -1,7 +1,8 @@
1
1
  import type { KLineData, TimeShareData } from '../../types/price'
2
2
  import type { SymbolSpec, DataFetcher, CustomDataSource } from '../../controllers/types'
3
3
  import { createSignal, type Signal } from '../../reactivity/signal'
4
- import { DataBuffer, getPeriodDays } from '../../data-fetchers/dataBuffer'
4
+ import { DataBuffer } from '../../data-fetchers/dataBuffer'
5
+ import { getPeriodDays } from '../../data-fetchers/dataBuffer.effects'
5
6
  import { TimeShareBuffer } from '../../data-fetchers/timeShareBuffer'
6
7
  import type { DataBufferLike } from '../../data-fetchers/dataBufferTypes'
7
8
  import type { TimeShareFetcherFn } from '../../data-fetchers/types'
@@ -0,0 +1,57 @@
1
+ import type { DrawingToolId } from './toolConfig'
2
+ import { getAnchorCountForTool } from './toolConfig'
3
+ import type { DrawingAnchorInput } from './coordinateUtils'
4
+
5
+ /**
6
+ * Accumulates pointer anchors until the required count is reached for a given tool.
7
+ * Single-anchor tools are not managed here — they create immediately on first click.
8
+ */
9
+ export class AnchorCollector {
10
+ pendingAnchors: DrawingAnchorInput[] = []
11
+
12
+ /** Returns true when the tool uses multiple anchors (2 or 3). */
13
+ isMultiAnchorTool(toolId: DrawingToolId): boolean {
14
+ const count = getAnchorCountForTool(toolId)
15
+ return count === 2 || count === 3
16
+ }
17
+
18
+ /** Returns the required anchor count for the given tool, or null for single-anchor / cursor. */
19
+ getRequiredCount(toolId: DrawingToolId): number | null {
20
+ return getAnchorCountForTool(toolId)
21
+ }
22
+
23
+ /** 当前已累积的锚点数量 */
24
+ getPendingCount(): number {
25
+ return this.pendingAnchors.length
26
+ }
27
+
28
+ /**
29
+ * Add an anchor for the given tool.
30
+ * @returns the full anchor list if the required count is reached (caller should create drawing),
31
+ * or null if still accumulating.
32
+ */
33
+ addAnchor(anchor: DrawingAnchorInput, toolId: DrawingToolId): DrawingAnchorInput[] | null {
34
+ const required = this.getRequiredCount(toolId)
35
+ if (required === null) return null
36
+
37
+ this.pendingAnchors.push(anchor)
38
+
39
+ if (this.pendingAnchors.length >= required) {
40
+ const result = [...this.pendingAnchors]
41
+ this.pendingAnchors = []
42
+ return result
43
+ }
44
+
45
+ return null
46
+ }
47
+
48
+ /** Peek at the first pending anchor without modifying state. */
49
+ getFirst(): DrawingAnchorInput | undefined {
50
+ return this.pendingAnchors[0]
51
+ }
52
+
53
+ /** 清空累积的锚点 */
54
+ reset(): void {
55
+ this.pendingAnchors = []
56
+ }
57
+ }
@@ -0,0 +1,121 @@
1
+ import type { DrawingObject, DrawingAnchor } from '../../plugin'
2
+ import type { DrawingChartAdapter } from '../../controllers/types'
3
+ import { resolveAnchorFromPointer, anchorToScreen, screenToAnchor } from './coordinateUtils'
4
+
5
+ // ---- Types ----
6
+
7
+ export interface DragState {
8
+ drawingId: string
9
+ anchorIndex?: number
10
+ snapshot: DrawingAnchor[]
11
+ startMouse: { x: number; y: number }
12
+ }
13
+
14
+ /**
15
+ * Manages drag state and handles drag-move mutations for drawings.
16
+ * Does NOT own the drawings array — the caller retrieves and writes back.
17
+ */
18
+ export class DragHandler {
19
+ private dragState: DragState | null = null
20
+
21
+ /** 当前是否有未结束的拖拽 */
22
+ isDragging(): boolean {
23
+ return this.dragState !== null
24
+ }
25
+
26
+ /** 拖拽中的图元 ID */
27
+ getDraggingDrawingId(): string | null {
28
+ return this.dragState?.drawingId ?? null
29
+ }
30
+
31
+ /**
32
+ * 开始拖拽。
33
+ * @param drawing 拖拽的图元
34
+ * @param anchorIndex 拖拽单个锚点时传锚点下标;拖拽整线时不传
35
+ * @param mouseX 起始鼠标 X(屏幕 px)
36
+ * @param mouseY 起始鼠标 Y(屏幕 px)
37
+ */
38
+ startDrag(
39
+ drawing: DrawingObject,
40
+ anchorIndex: number | undefined,
41
+ mouseX: number,
42
+ mouseY: number,
43
+ ): void {
44
+ this.dragState = {
45
+ drawingId: drawing.id,
46
+ anchorIndex,
47
+ snapshot: drawing.anchors.map((a) => ({ ...a })),
48
+ startMouse: { x: mouseX, y: mouseY },
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Handle drag-move for the given drawing, mutating its anchors.
54
+ * @returns a new DrawingObject reference with updated anchors, or null if drag state is stale.
55
+ */
56
+ handleDragMove(
57
+ drawing: DrawingObject,
58
+ e: PointerEvent,
59
+ container: HTMLElement,
60
+ adapter: DrawingChartAdapter,
61
+ ): DrawingObject | null {
62
+ if (!this.dragState) return null
63
+
64
+ const newAnchor = resolveAnchorFromPointer(e, container, adapter)
65
+ const updatedAnchors = [...drawing.anchors]
66
+
67
+ if (this.dragState.anchorIndex !== undefined) {
68
+ // Dragging a single anchor point
69
+ if (!newAnchor) return null
70
+ const idx = this.dragState.anchorIndex
71
+
72
+ updatedAnchors[idx] = {
73
+ ...updatedAnchors[idx]!,
74
+ index: newAnchor.index,
75
+ time: newAnchor.time,
76
+ price: newAnchor.price,
77
+ }
78
+
79
+ // flat-line: third anchor's index/time follows the second
80
+ if (drawing.kind === 'flat-line' && idx === 1 && updatedAnchors.length >= 3) {
81
+ updatedAnchors[2] = {
82
+ ...updatedAnchors[2]!,
83
+ index: newAnchor.index,
84
+ time: newAnchor.time,
85
+ }
86
+ }
87
+ } else {
88
+ // Dragging the entire line — offset all anchors by mouse delta
89
+ const rect = container.getBoundingClientRect()
90
+ const mouseX = e.clientX - rect.left
91
+ const mouseY = e.clientY - rect.top
92
+ const dx = mouseX - this.dragState.startMouse.x
93
+ const dy = mouseY - this.dragState.startMouse.y
94
+
95
+ for (let i = 0; i < drawing.anchors.length; i++) {
96
+ const snap = this.dragState.snapshot[i]!
97
+ const snapScreen = anchorToScreen(snap, adapter)
98
+ if (!snapScreen) continue
99
+
100
+ const targetX = snapScreen.x + dx
101
+ const targetY = snapScreen.y + dy
102
+ const newFromScreen = screenToAnchor(targetX, targetY, adapter)
103
+ if (newFromScreen) {
104
+ updatedAnchors[i] = {
105
+ ...updatedAnchors[i]!,
106
+ index: newFromScreen.index,
107
+ time: newFromScreen.time,
108
+ price: newFromScreen.price,
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ return { ...drawing, anchors: updatedAnchors }
115
+ }
116
+
117
+ /** 结束拖拽,清空状态 */
118
+ endDrag(): void {
119
+ this.dragState = null
120
+ }
121
+ }