@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,27 +1,19 @@
1
- import type { DrawingObject, DrawingKind, DrawingAnchor, DrawingStyle } from '../../plugin'
1
+ import type { DrawingObject, DrawingKind, DrawingStyle } from '../../plugin'
2
2
  import type { DrawingChartAdapter } from '../../controllers/types'
3
- import { getPhysicalKLineConfig } from '../utils/klineConfig'
4
- import { computeLinearRegression } from './linearRegression'
5
-
6
- export type DrawingToolId =
7
- | 'cursor'
8
- | 'trend-line'
9
- | 'ray'
10
- | 'h-line'
11
- | 'h-ray'
12
- | 'v-line'
13
- | 'crosshair-line'
14
- | 'info-line'
15
- | 'parallel-channel'
16
- | 'regression-channel'
17
- | 'flat-line'
18
- | 'disjoint-channel'
19
-
20
- export interface DrawingAnchorInput {
21
- index: number
22
- time?: number
23
- price: number
24
- }
3
+
4
+ import { DrawingState, PREVIEW_ID } from './DrawingState'
5
+ import { AnchorCollector } from './AnchorCollector'
6
+ import { PreviewRenderer } from './PreviewRenderer'
7
+ import { HitTester } from './HitTester'
8
+ import { DragHandler } from './DragHandler'
9
+ import { resolveAnchorFromPointer } from './coordinateUtils'
10
+ import type { DrawingAnchorInput } from './coordinateUtils'
11
+ import type { DrawingToolId } from './toolConfig'
12
+ import { getAnchorCountForTool, getDrawingKind, CHANNEL_KINDS } from './toolConfig'
13
+
14
+ // Re-export types so index.ts re-exports work unchanged
15
+ export type { DrawingToolId } from './toolConfig'
16
+ export type { DrawingAnchorInput } from './coordinateUtils'
25
17
 
26
18
  export interface DrawingInteractionCallbacks {
27
19
  onDrawingCreated?: (drawing: DrawingObject) => void
@@ -29,191 +21,204 @@ export interface DrawingInteractionCallbacks {
29
21
  onDrawingSelected?: (drawing: DrawingObject | null) => void
30
22
  }
31
23
 
32
- type HitResult =
33
- | { drawing: DrawingObject; anchorIndex: number }
34
- | { drawing: DrawingObject }
35
-
36
- type LineSegment = { a: { x: number; y: number }; b: { x: number; y: number } }
37
-
38
- type RegressionChannelGeometry = {
39
- segments: LineSegment[]
40
- endpoints: Array<{ point: { x: number; y: number }; anchorIndex: 0 | 1 }>
41
- }
42
-
43
- interface DragState {
44
- drawingId: string
45
- anchorIndex?: number
46
- snapshot: DrawingAnchor[]
47
- startMouse: { x: number; y: number }
48
- }
49
-
50
- const ANCHOR_HIT_RADIUS = 8
51
- const LINE_HIT_RADIUS = 6
52
-
53
24
  /**
54
- * 绘图交互控制器
55
- * 封装绘图工具的交互逻辑,与 Vue 组件解耦
25
+ * 绘图交互控制器 v2 — 精简事件路由,组合子模块。
26
+ *
27
+ * ┌─────────────────────────────────────┐
28
+ * │ DrawingInteractionController │
29
+ * │ ├─ DrawingState (图元 CRUD) │
30
+ * │ ├─ AnchorCollector (锚点累积) │
31
+ * │ ├─ PreviewRenderer (预览构建) │
32
+ * │ ├─ HitTester (命中检测) │
33
+ * │ └─ DragHandler (拖拽管理) │
34
+ * └─────────────────────────────────────┘
56
35
  */
57
36
  export class DrawingInteractionController {
58
- private adapter: DrawingChartAdapter
59
37
  private activeTool: DrawingToolId = 'cursor'
60
- private pendingAnchors: DrawingAnchorInput[] = []
61
- private drawings: DrawingObject[] = []
38
+ private adapter: DrawingChartAdapter
62
39
  private callbacks: DrawingInteractionCallbacks = {}
63
- private previewDrawingId = '__preview__'
64
- private dragState: DragState | null = null
65
- private selectedDrawingId: string | null = null
66
-
67
- // 单锚点工具列表
68
- private static readonly SINGLE_ANCHOR_TOOLS: DrawingToolId[] = [
69
- 'h-line',
70
- 'h-ray',
71
- 'v-line',
72
- 'crosshair-line',
73
- ]
74
-
75
- // 双锚点工具列表
76
- private static readonly DOUBLE_ANCHOR_TOOLS: DrawingToolId[] = [
77
- 'trend-line',
78
- 'ray',
79
- 'info-line',
80
- 'regression-channel',
81
- ]
82
-
83
- // 三锚点工具列表
84
- private static readonly TRIPLE_ANCHOR_TOOLS: DrawingToolId[] = [
85
- 'parallel-channel',
86
- 'flat-line',
87
- 'disjoint-channel',
88
- ]
40
+
41
+ private drawingState: DrawingState
42
+ private anchorCollector: AnchorCollector
43
+ private previewRenderer: PreviewRenderer
44
+ private hitTester: HitTester
45
+ private dragHandler: DragHandler
89
46
 
90
47
  constructor(adapter: DrawingChartAdapter) {
91
48
  this.adapter = adapter
49
+ this.drawingState = new DrawingState(adapter)
50
+ this.anchorCollector = new AnchorCollector()
51
+ this.previewRenderer = new PreviewRenderer()
52
+ this.hitTester = new HitTester()
53
+ this.dragHandler = new DragHandler()
92
54
  }
93
55
 
56
+ // ============ 配置 ============
57
+
58
+ /** 注册回调(创建/选中/工具切换事件通知) */
94
59
  setCallbacks(callbacks: DrawingInteractionCallbacks) {
95
60
  this.callbacks = callbacks
96
61
  }
97
62
 
63
+ // ============ 工具状态 ============
64
+
65
+ /** 返回当前激活的绘图工具 ID */
98
66
  getActiveTool(): DrawingToolId {
99
67
  return this.activeTool
100
68
  }
101
69
 
70
+ /**
71
+ * 切换绘图工具。
72
+ * 切换时自动清空锚点累积、移除预览、结束拖拽、清除选中,并通知外部。
73
+ */
102
74
  setTool(toolId: DrawingToolId) {
103
75
  this.activeTool = toolId
104
- this.pendingAnchors = []
105
- this.removePreview()
106
- this.dragState = null
76
+ this.anchorCollector.reset()
77
+ this.drawingState.removePreview()
78
+ this.dragHandler.endDrag()
107
79
  this.setSelected(null)
108
80
  this.callbacks.onToolChange?.(toolId)
109
81
  }
110
82
 
83
+ // ============ 图元 CRUD(委托 DrawingState) ============
84
+
85
+ /** 返回所有图元(含预览) */
111
86
  getDrawings(): DrawingObject[] {
112
- return this.drawings
87
+ return this.drawingState.getAll()
113
88
  }
114
89
 
90
+ /** 整体替换图元列表 */
115
91
  setDrawings(drawings: DrawingObject[]) {
116
- this.drawings = drawings
117
- this.adapter.setDrawings(drawings)
92
+ this.drawingState.setDrawings(drawings)
118
93
  }
119
94
 
95
+ /** 清空锚点累积、预览、拖拽状态及所有图元 */
120
96
  clear() {
121
- this.pendingAnchors = []
122
- this.removePreview()
123
- this.dragState = null
124
- this.setSelected(null)
125
- }
126
-
127
- getSelectedDrawing(): DrawingObject | null {
128
- if (!this.selectedDrawingId) return null
129
- return this.drawings.find((d) => d.id === this.selectedDrawingId) ?? null
97
+ this.anchorCollector.reset()
98
+ this.drawingState.removePreview()
99
+ this.dragHandler.endDrag()
100
+ this.drawingState.clear()
130
101
  }
131
102
 
103
+ /** 更新指定图元的样式(合并) */
132
104
  updateDrawingStyle(drawingId: string, style: Partial<DrawingStyle>): void {
133
- this.drawings = this.drawings.map((d) =>
134
- d.id === drawingId ? { ...d, style: { ...d.style, ...style } } : d
135
- )
136
- this.adapter.setDrawings(this.drawings)
105
+ this.drawingState.updateDrawingStyle(drawingId, style)
137
106
  }
138
107
 
108
+ /** 删除指定图元 */
139
109
  removeDrawing(drawingId: string): void {
140
- this.drawings = this.drawings.filter((d) => d.id !== drawingId)
141
- if (this.selectedDrawingId === drawingId) {
142
- this.setSelected(null)
143
- }
144
- this.adapter.setDrawings(this.drawings)
110
+ this.drawingState.removeDrawing(drawingId)
111
+ }
112
+
113
+ // ============ 选中状态 ============
114
+
115
+ /** 返回当前选中的图元 */
116
+ getSelectedDrawing(): DrawingObject | null {
117
+ return this.drawingState.getSelected()
145
118
  }
146
119
 
120
+ // ============ 事件处理 ============
121
+
147
122
  /**
148
- * 处理指针移动事件
149
- * @returns 是否处理了事件(阻止冒泡)
123
+ * 指针移动事件入口。
124
+ * 拖拽中 → 委托 DragHandler 更新锚点;绘图模式 → 构建预览图元。
125
+ * @returns true 表示事件已消费,需要重绘
150
126
  */
151
127
  onPointerMove(e: PointerEvent, container: HTMLElement): boolean {
152
- // 拖拽已有图元
153
- if (this.dragState) {
154
- return this.handleDragMove(e, container)
128
+ // 1) 正在拖拽
129
+ if (this.dragHandler.isDragging()) {
130
+ const drawing = this.drawingState.getById(
131
+ this.dragHandler.getDraggingDrawingId() ?? '',
132
+ )
133
+ if (!drawing) {
134
+ this.dragHandler.endDrag()
135
+ return false
136
+ }
137
+ const updated = this.dragHandler.handleDragMove(drawing, e, container, this.adapter)
138
+ if (!updated) return false
139
+ this.drawingState.addOrUpdate(updated)
140
+ return true
155
141
  }
156
142
 
157
- // 创建预览
143
+ // 2) 绘图工具预览
158
144
  if (this.activeTool !== 'cursor') {
159
- return this.handlePreviewMove(e, container)
145
+ const anchor = resolveAnchorFromPointer(e, container, this.adapter)
146
+ if (!anchor) {
147
+ this.drawingState.removePreview()
148
+ return false
149
+ }
150
+
151
+ const preview = this.previewRenderer.buildPreview(
152
+ this.activeTool,
153
+ this.anchorCollector.pendingAnchors,
154
+ anchor,
155
+ )
156
+ if (!preview) {
157
+ this.drawingState.removePreview()
158
+ return false
159
+ }
160
+
161
+ this.drawingState.setPreview(preview)
162
+ return true
160
163
  }
161
164
 
162
165
  return false
163
166
  }
164
167
 
165
168
  /**
166
- * 处理指针按下事件
167
- * @returns 是否处理了事件(阻止冒泡)
169
+ * 指针按下事件入口。
170
+ * 光标模式 → 命中检测 + 选中 + 拖拽开始;绘图模式 → 创建或累积锚点。
171
+ * @returns true 表示事件已消费
168
172
  */
169
173
  onPointerDown(e: PointerEvent, container: HTMLElement): boolean {
170
- // 光标模式:命中检测已有图元
174
+ // 光标模式:命中检测 → 选中 → 开始拖拽
171
175
  if (this.activeTool === 'cursor') {
172
176
  return this.handleCursorDown(e, container)
173
177
  }
174
178
 
175
- const anchor = this.resolveAnchorFromPointer(e, container)
179
+ // 绘图模式
180
+ const anchor = resolveAnchorFromPointer(e, container, this.adapter)
176
181
  if (!anchor) return false
177
182
 
178
- // 单锚点工具:点击一次立即创建
179
- if (DrawingInteractionController.SINGLE_ANCHOR_TOOLS.includes(this.activeTool)) {
183
+ const anchorCount = getAnchorCountForTool(this.activeTool)
184
+
185
+ // 单锚点工具:点击即创建
186
+ if (anchorCount === 1) {
180
187
  this.createSingleAnchorDrawing(anchor)
181
188
  return true
182
189
  }
183
190
 
184
- // 双/三锚点工具:累积锚点
185
- const isDouble = DrawingInteractionController.DOUBLE_ANCHOR_TOOLS.includes(this.activeTool)
186
- const isTriple = DrawingInteractionController.TRIPLE_ANCHOR_TOOLS.includes(this.activeTool)
187
- if (!isDouble && !isTriple) return false
188
-
189
- this.pendingAnchors.push(anchor)
190
- const requiredAnchors = isDouble ? 2 : 3
191
-
192
- if (this.pendingAnchors.length >= requiredAnchors) {
193
- this.createMultiAnchorDrawing(this.pendingAnchors)
194
- this.pendingAnchors = []
191
+ // 多锚点工具:累积
192
+ if (anchorCount === 2 || anchorCount === 3) {
193
+ const result = this.anchorCollector.addAnchor(anchor, this.activeTool)
194
+ if (result) {
195
+ this.createMultiAnchorDrawing(result)
196
+ }
197
+ return true
195
198
  }
196
- return true
199
+
200
+ return false
197
201
  }
198
202
 
199
203
  /**
200
- * 处理指针释放事件
201
- * @returns 是否处理了事件(阻止冒泡)
204
+ * 指针抬起事件入口。结束拖拽。
205
+ * @returns true 表示事件已消费
202
206
  */
203
207
  onPointerUp(_e: PointerEvent, _container: HTMLElement): boolean {
204
- if (!this.dragState) return false
205
- this.dragState = null
208
+ if (!this.dragHandler.isDragging()) return false
209
+ this.dragHandler.endDrag()
206
210
  return true
207
211
  }
208
212
 
209
- // ============ 光标模式:命中检测与拖拽 ============
213
+ // ============ 私有方法 ============
210
214
 
215
+ /** 光标模式下指针按下:命中检测 → 选中 → 开始拖拽 */
211
216
  private handleCursorDown(e: PointerEvent, container: HTMLElement): boolean {
212
217
  const rect = container.getBoundingClientRect()
213
218
  const mouseX = e.clientX - rect.left
214
219
  const mouseY = e.clientY - rect.top
215
220
 
216
- const hit = this.hitTest(mouseX, mouseY)
221
+ const hit = this.hitTester.hitTest(mouseX, mouseY, this.drawingState.getNonPreview(), this.adapter)
217
222
  if (!hit) {
218
223
  this.setSelected(null)
219
224
  return false
@@ -221,521 +226,38 @@ export class DrawingInteractionController {
221
226
 
222
227
  this.setSelected(hit.drawing)
223
228
 
224
- this.dragState = {
225
- drawingId: hit.drawing.id,
226
- anchorIndex: 'anchorIndex' in hit ? hit.anchorIndex : undefined,
227
- snapshot: hit.drawing.anchors.map((a) => ({ ...a })),
228
- startMouse: { x: mouseX, y: mouseY },
229
- }
230
- return true
231
- }
232
-
233
- private handleDragMove(e: PointerEvent, container: HTMLElement): boolean {
234
- if (!this.dragState) return false
235
-
236
- const drawing = this.drawings.find((d) => d.id === this.dragState!.drawingId)
237
- if (!drawing) {
238
- this.dragState = null
239
- return false
240
- }
241
-
242
- const newAnchor = this.resolveAnchorFromPointer(e, container)
243
-
244
- if (this.dragState.anchorIndex !== undefined) {
245
- // 拖拽单个锚点
246
- if (newAnchor) {
247
- const idx = this.dragState.anchorIndex
248
- drawing.anchors[idx] = {
249
- ...drawing.anchors[idx]!,
250
- index: newAnchor.index,
251
- time: newAnchor.time,
252
- price: newAnchor.price,
253
- }
254
- // flat-line:第三个锚点的 index/time 始终跟随第二个锚点
255
- if (drawing.kind === 'flat-line' && idx === 1 && drawing.anchors.length >= 3) {
256
- drawing.anchors[2] = {
257
- ...drawing.anchors[2]!,
258
- index: newAnchor.index,
259
- time: newAnchor.time,
260
- }
261
- }
262
- }
263
- } else {
264
- // 拖拽整条线:基于鼠标偏移量移动所有锚点
265
- const rect = container.getBoundingClientRect()
266
- const mouseX = e.clientX - rect.left
267
- const mouseY = e.clientY - rect.top
268
- const dx = mouseX - this.dragState.startMouse.x
269
- const dy = mouseY - this.dragState.startMouse.y
270
-
271
- for (let i = 0; i < drawing.anchors.length; i++) {
272
- const snap = this.dragState.snapshot[i]!
273
- const snapScreen = this.anchorToScreen(snap)
274
- if (!snapScreen) continue
275
-
276
- const targetX = snapScreen.x + dx
277
- const targetY = snapScreen.y + dy
278
- const newFromScreen = this.screenToAnchor(targetX, targetY)
279
- if (newFromScreen) {
280
- drawing.anchors[i] = {
281
- ...drawing.anchors[i]!,
282
- index: newFromScreen.index,
283
- time: newFromScreen.time,
284
- price: newFromScreen.price,
285
- }
286
- }
287
- }
288
- }
289
-
290
- this.adapter.setDrawings([...this.drawings])
291
- return true
292
- }
293
-
294
- // ============ 预览模式 ============
295
-
296
- private handlePreviewMove(e: PointerEvent, container: HTMLElement): boolean {
297
- const anchor = this.resolveAnchorFromPointer(e, container)
298
- if (!anchor) {
299
- this.removePreview()
300
- return false
301
- }
302
-
303
- const isSingle = DrawingInteractionController.SINGLE_ANCHOR_TOOLS.includes(this.activeTool)
304
- const isDouble = DrawingInteractionController.DOUBLE_ANCHOR_TOOLS.includes(this.activeTool)
305
- const isTriple = DrawingInteractionController.TRIPLE_ANCHOR_TOOLS.includes(this.activeTool)
306
- if (!isSingle && !isDouble && !isTriple) return false
307
-
308
- let preview: DrawingObject
309
-
310
- if (isSingle) {
311
- preview = {
312
- id: this.previewDrawingId,
313
- kind: this.getDrawingKind(this.activeTool),
314
- paneId: 'main',
315
- visible: true,
316
- anchors: [{ id: `${this.previewDrawingId}-a`, index: anchor.index, time: anchor.time, price: anchor.price }],
317
- params: {},
318
- style: {
319
- stroke: '#2962ff',
320
- strokeWidth: 1,
321
- strokeStyle: 'dashed',
322
- },
323
- }
324
- } else if (isDouble && this.pendingAnchors.length >= 1) {
325
- preview = {
326
- id: this.previewDrawingId,
327
- kind: this.getDrawingKind(this.activeTool),
328
- paneId: 'main',
329
- visible: true,
330
- anchors: [
331
- { id: `${this.previewDrawingId}-a`, index: this.pendingAnchors[0]!.index, time: this.pendingAnchors[0]!.time, price: this.pendingAnchors[0]!.price },
332
- { id: `${this.previewDrawingId}-b`, index: anchor.index, time: anchor.time, price: anchor.price },
333
- ],
334
- params: this.activeTool === 'regression-channel' ? { sigma: 2 } : {},
335
- style: {
336
- stroke: '#2962ff',
337
- strokeWidth: 1,
338
- strokeStyle: 'dashed',
339
- ...(this.activeTool === 'regression-channel' ? { fillOpacity: 0.1 } : {}),
340
- },
341
- }
342
- } else if (isTriple) {
343
- if (this.pendingAnchors.length === 0) return false
344
-
345
- if (this.pendingAnchors.length === 1) {
346
- // 修复:用 trend-line 渲染线段预览(2 个锚点),三锚点工具的 definition 需要 3 个锚点才能渲染
347
- preview = {
348
- id: this.previewDrawingId,
349
- kind: 'trend-line',
350
- paneId: 'main',
351
- visible: true,
352
- anchors: [
353
- { id: `${this.previewDrawingId}-a`, index: this.pendingAnchors[0]!.index, time: this.pendingAnchors[0]!.time, price: this.pendingAnchors[0]!.price },
354
- { id: `${this.previewDrawingId}-b`, index: anchor.index, time: anchor.time, price: anchor.price },
355
- ],
356
- params: {},
357
- style: {
358
- stroke: '#2962ff',
359
- strokeWidth: 1,
360
- strokeStyle: 'dashed',
361
- },
362
- }
363
- } else {
364
- const thirdAnchor = this.activeTool === 'flat-line'
365
- ? {
366
- id: `${this.previewDrawingId}-c`,
367
- index: this.pendingAnchors[1]!.index,
368
- time: this.pendingAnchors[1]!.time,
369
- price: anchor.price,
370
- }
371
- : {
372
- id: `${this.previewDrawingId}-c`,
373
- index: anchor.index,
374
- time: anchor.time,
375
- price: anchor.price,
376
- }
377
-
378
- preview = {
379
- id: this.previewDrawingId,
380
- kind: this.getDrawingKind(this.activeTool),
381
- paneId: 'main',
382
- visible: true,
383
- anchors: [
384
- { id: `${this.previewDrawingId}-a`, index: this.pendingAnchors[0]!.index, time: this.pendingAnchors[0]!.time, price: this.pendingAnchors[0]!.price },
385
- { id: `${this.previewDrawingId}-b`, index: this.pendingAnchors[1]!.index, time: this.pendingAnchors[1]!.time, price: this.pendingAnchors[1]!.price },
386
- thirdAnchor,
387
- ],
388
- params: {},
389
- style: {
390
- stroke: '#2962ff',
391
- strokeWidth: 1,
392
- strokeStyle: 'dashed',
393
- fillOpacity: 0.1,
394
- },
395
- }
396
- }
397
- } else {
398
- return false
399
- }
400
-
401
- this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
402
- this.drawings = [...this.drawings, preview]
403
- this.adapter.setDrawings(this.drawings)
229
+ this.dragHandler.startDrag(
230
+ hit.drawing,
231
+ 'anchorIndex' in hit ? hit.anchorIndex : undefined,
232
+ mouseX,
233
+ mouseY,
234
+ )
404
235
  return true
405
236
  }
406
237
 
407
- // ============ 命中检测 ============
408
-
409
- private hitTest(mouseX: number, mouseY: number): HitResult | null {
410
- const drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId && d.visible)
411
- const regressionGeometryCache = new Map<string, RegressionChannelGeometry | null>()
412
-
413
- // 锚点优先
414
- for (const drawing of drawings) {
415
- // regression-channel:回归线端点也是可拖拽区域
416
- if (drawing.kind === 'regression-channel' && drawing.anchors.length >= 2) {
417
- const hit = this.hitTestRegressionEndpoints(drawing, mouseX, mouseY, regressionGeometryCache)
418
- if (hit) return hit
419
- }
420
-
421
- for (let i = 0; i < drawing.anchors.length; i++) {
422
- const screen = this.anchorToScreen(drawing.anchors[i]!)
423
- if (!screen) continue
424
- const dist = Math.hypot(mouseX - screen.x, mouseY - screen.y)
425
- if (dist <= ANCHOR_HIT_RADIUS) {
426
- return { drawing, anchorIndex: i }
427
- }
428
- }
429
- }
430
-
431
- // 线条其次
432
- for (const drawing of drawings) {
433
- const segments = this.getDrawingLineSegments(drawing, regressionGeometryCache)
434
- for (const seg of segments) {
435
- const dist = pointToSegmentDist(mouseX, mouseY, seg.a, seg.b)
436
- if (dist <= LINE_HIT_RADIUS) {
437
- return { drawing }
438
- }
439
- }
440
- }
441
-
442
- return null
443
- }
444
-
445
- private getDrawingLineSegments(
446
- drawing: DrawingObject,
447
- regressionGeometryCache?: Map<string, RegressionChannelGeometry | null>,
448
- ): LineSegment[] {
449
- const viewport = this.adapter.getViewport()
450
- if (!viewport) return []
451
-
452
- if (drawing.kind === 'regression-channel') {
453
- return this.getRegressionChannelGeometry(drawing, regressionGeometryCache)?.segments ?? []
454
- }
455
-
456
- // 单锚点图元:根据 kind 构造屏幕线段
457
- if (drawing.anchors.length === 1) {
458
- const screen = this.anchorToScreen(drawing.anchors[0]!)
459
- if (!screen) return []
460
-
461
- const paneInfo = this.adapter.getPaneInfo('main')
462
- if (!paneInfo) return []
463
-
464
- const right = viewport.plotWidth
465
- const bottom = paneInfo.height
466
-
467
- switch (drawing.kind) {
468
- case 'horizontal-line':
469
- return [{ a: { x: 0, y: screen.y }, b: { x: right, y: screen.y } }]
470
- case 'horizontal-ray':
471
- return [{ a: screen, b: { x: right, y: screen.y } }]
472
- case 'vertical-line':
473
- return [{ a: { x: screen.x, y: 0 }, b: { x: screen.x, y: bottom } }]
474
- case 'cross-line':
475
- return [
476
- { a: { x: 0, y: screen.y }, b: { x: right, y: screen.y } },
477
- { a: { x: screen.x, y: 0 }, b: { x: screen.x, y: bottom } },
478
- ]
479
- default:
480
- return []
481
- }
482
- }
483
-
484
- // 多锚点图元:按 kind 特殊处理
485
- const points = drawing.anchors.map((a) => this.anchorToScreen(a)).filter(Boolean) as { x: number; y: number }[]
486
- if (points.length < 2) return []
487
-
488
- const segments: LineSegment[] = []
489
-
490
- if (points.length === 2) {
491
- const a = points[0]!
492
- const b = points[1]!
493
-
494
- // 其他双锚点工具:标准线段
495
- const dx = b.x - a.x
496
- const dy = b.y - a.y
497
-
498
- let start = a
499
- let end = b
500
-
501
- const extend = this.getExtendMode(drawing)
502
- const maxLen = Math.max(viewport.plotWidth, viewport.plotHeight) * 4
503
-
504
- if (extend === 'right' || extend === 'both') {
505
- end = { x: b.x + dx * maxLen, y: b.y + dy * maxLen }
506
- }
507
- if (extend === 'left' || extend === 'both') {
508
- start = { x: a.x - dx * maxLen, y: a.y - dy * maxLen }
509
- }
510
-
511
- segments.push({ a: start, b: end })
512
- } else if (points.length >= 3) {
513
- switch (drawing.kind) {
514
- case 'parallel-channel': {
515
- const [p1, p2, p3] = points as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]
516
- const dx = p2.x - p1.x
517
- const dy = p2.y - p1.y
518
- const p4 = { x: p3.x + dx, y: p3.y + dy }
519
- segments.push(
520
- { a: p1, b: p2 },
521
- { a: p3, b: p4 },
522
- )
523
- break
524
- }
525
- case 'flat-line': {
526
- const [p1, p2, p3] = points as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]
527
- const h1 = { x: p1.x, y: p3.y }
528
- const h2 = { x: p2.x, y: p3.y }
529
- segments.push({ a: p1, b: p2 })
530
- segments.push({ a: h1, b: h2 })
531
- break
532
- }
533
- case 'disjoint-channel': {
534
- const [p1, p2, p3] = points as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]
535
- const dx = p2.x - p1.x
536
- const dy = p2.y - p1.y
537
- const p4 = { x: p3.x + dx, y: p3.y - dy }
538
- segments.push({ a: p1, b: p2 })
539
- segments.push({ a: p3, b: p4 })
540
- break
541
- }
542
- default:
543
- for (let i = 0; i < points.length - 1; i++) {
544
- segments.push({ a: points[i]!, b: points[i + 1]! })
545
- }
546
- }
547
- }
548
-
549
- return segments
550
- }
551
-
552
-
553
- /**
554
- * regression-channel 专用:回归线端点也是可拖拽的锚点区域
555
- * 回归线端点可能远离存储的锚点,需要额外检测
556
- */
557
- private hitTestRegressionEndpoints(
558
- drawing: DrawingObject,
559
- mouseX: number,
560
- mouseY: number,
561
- regressionGeometryCache?: Map<string, RegressionChannelGeometry | null>,
562
- ): { drawing: DrawingObject; anchorIndex: number } | null {
563
- const geometry = this.getRegressionChannelGeometry(drawing, regressionGeometryCache)
564
- if (!geometry) return null
565
-
566
- for (const endpoint of geometry.endpoints) {
567
- const dist = Math.hypot(mouseX - endpoint.point.x, mouseY - endpoint.point.y)
568
- if (dist <= ANCHOR_HIT_RADIUS) {
569
- return { drawing, anchorIndex: endpoint.anchorIndex }
570
- }
571
- }
572
-
573
- return null
574
- }
575
-
576
-
577
- private getRegressionChannelGeometry(
578
- drawing: DrawingObject,
579
- regressionGeometryCache?: Map<string, RegressionChannelGeometry | null>,
580
- ): RegressionChannelGeometry | null {
581
- const cached = regressionGeometryCache?.get(drawing.id)
582
- if (cached !== undefined) return cached
583
-
584
- const data = this.adapter.getData()
585
- if (data.length === 0 || drawing.anchors.length < 2) {
586
- regressionGeometryCache?.set(drawing.id, null)
587
- return null
588
- }
589
-
590
- const firstIndex = Math.round(drawing.anchors[0]!.index)
591
- const secondIndex = Math.round(drawing.anchors[1]!.index)
592
- const clampedFirst = Math.min(Math.max(firstIndex, 0), data.length - 1)
593
- const clampedSecond = Math.min(Math.max(secondIndex, 0), data.length - 1)
594
- const startIndex = Math.min(clampedFirst, clampedSecond)
595
- const endIndex = Math.max(clampedFirst, clampedSecond)
596
- const slice = data.slice(startIndex, endIndex + 1)
597
- const regression = computeLinearRegression(slice.map((item: { close: number }) => item.close))
598
- if (!regression) {
599
- regressionGeometryCache?.set(drawing.id, null)
600
- return null
601
- }
602
-
603
- const sigma = (drawing.params as { sigma?: number } | undefined)?.sigma ?? 2
604
- const offset = regression.stdDev * sigma
605
- const firstValue = regression.intercept
606
- const lastValue = regression.intercept + regression.slope * (slice.length - 1)
607
-
608
- const middleStart = this.anchorToScreen({ id: '', index: firstIndex, price: firstValue })
609
- const middleEnd = this.anchorToScreen({ id: '', index: secondIndex, price: lastValue })
610
- const upperStart = this.anchorToScreen({ id: '', index: firstIndex, price: firstValue + offset })
611
- const upperEnd = this.anchorToScreen({ id: '', index: secondIndex, price: lastValue + offset })
612
- const lowerStart = this.anchorToScreen({ id: '', index: firstIndex, price: firstValue - offset })
613
- const lowerEnd = this.anchorToScreen({ id: '', index: secondIndex, price: lastValue - offset })
614
-
615
- const segments: LineSegment[] = []
616
- if (middleStart && middleEnd) segments.push({ a: middleStart, b: middleEnd })
617
- if (upperStart && upperEnd) segments.push({ a: upperStart, b: upperEnd })
618
- if (lowerStart && lowerEnd) segments.push({ a: lowerStart, b: lowerEnd })
619
-
620
- const endpoints: RegressionChannelGeometry['endpoints'] = []
621
- if (middleStart) endpoints.push({ point: middleStart, anchorIndex: 0 })
622
- if (middleEnd) endpoints.push({ point: middleEnd, anchorIndex: 1 })
623
- if (upperStart) endpoints.push({ point: upperStart, anchorIndex: 0 })
624
- if (upperEnd) endpoints.push({ point: upperEnd, anchorIndex: 1 })
625
- if (lowerStart) endpoints.push({ point: lowerStart, anchorIndex: 0 })
626
- if (lowerEnd) endpoints.push({ point: lowerEnd, anchorIndex: 1 })
627
-
628
- const geometry = { segments, endpoints }
629
- regressionGeometryCache?.set(drawing.id, geometry)
630
- return geometry
631
- }
632
-
633
- private getExtendMode(drawing: DrawingObject): 'none' | 'left' | 'right' | 'both' {
634
- switch (drawing.kind) {
635
- case 'ray':
636
- return 'right'
637
- case 'extended-line':
638
- return 'both'
639
- default:
640
- return 'none'
641
- }
642
- }
643
-
644
- // ============ 坐标转换 ============
645
-
646
- private anchorToScreen(anchor: DrawingAnchor): { x: number; y: number } | null {
647
- const viewport = this.adapter.getViewport()
648
- if (!viewport) return null
649
-
650
- const { kWidth, kGap } = this.adapter.getKWidthKGap()
651
- const dpr = this.adapter.getCurrentDpr()
652
- const { startXPx, unitPx } = getPhysicalKLineConfig(kWidth, kGap, dpr)
653
- if (!Number.isFinite(anchor.index)) return null
654
-
655
- const x = (startXPx + anchor.index * unitPx + (unitPx - 1) / 2) / dpr - viewport.scrollLeft
656
-
657
- const y = this.adapter.priceToY('main', anchor.price)
658
- return { x, y }
659
- }
660
-
661
- private screenToAnchor(
662
- screenX: number,
663
- screenY: number
664
- ): DrawingAnchorInput | null {
665
- const data = this.adapter.getData()
666
- const viewport = this.adapter.getViewport()
667
- if (!viewport || data.length === 0) return null
668
-
669
- const logicalIndex = this.adapter.getLogicalIndexAtX(screenX)
670
- if (logicalIndex === null) return null
671
-
672
- const paneInfo = this.adapter.getPaneInfo('main')
673
- if (!paneInfo) return null
674
-
675
- const timestamp = this.adapter.getTimestampAtLogicalIndex(logicalIndex) ?? undefined
676
-
677
- return {
678
- index: logicalIndex,
679
- time: timestamp ?? undefined,
680
- price: this.adapter.yToPrice('main', screenY - paneInfo.top),
681
- }
682
- }
683
-
684
- // ============ 工具方法 ============
685
-
238
+ /** 设置选中图元并通知回调 */
686
239
  private setSelected(drawing: DrawingObject | null) {
687
- const newId = drawing?.id ?? null
688
- if (this.selectedDrawingId === newId) return
689
- this.selectedDrawingId = newId
690
- this.adapter.setSelectedDrawingId(newId)
240
+ this.drawingState.setSelected(drawing)
691
241
  this.callbacks.onDrawingSelected?.(drawing)
692
242
  }
693
243
 
694
- private removePreview() {
695
- if (!this.drawings.some((d) => d.id === this.previewDrawingId)) return
696
- this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
697
- this.adapter.setDrawings(this.drawings)
698
- }
699
-
700
- private resolveAnchorFromPointer(
701
- e: PointerEvent,
702
- container: HTMLElement
703
- ): DrawingAnchorInput | null {
704
- const data = this.adapter.getData()
705
- const viewport = this.adapter.getViewport()
706
- if (!viewport || data.length === 0) return null
707
-
708
- const rect = container.getBoundingClientRect()
709
- const mouseX = e.clientX - rect.left
710
- const mouseY = e.clientY - rect.top
711
- if (mouseX < 0 || mouseY < 0 || mouseX > viewport.plotWidth || mouseY > viewport.plotHeight) {
712
- return null
713
- }
714
-
715
- const paneInfo = this.adapter.getPaneInfo('main')
716
- if (!paneInfo) return null
717
- if (mouseY < paneInfo.top || mouseY > paneInfo.top + paneInfo.height) return null
718
-
719
- const logicalIndex = this.adapter.getLogicalIndexAtX(mouseX)
720
- if (logicalIndex === null) return null
721
- const timestamp = this.adapter.getTimestampAtLogicalIndex(logicalIndex) ?? undefined
722
-
723
- return {
724
- index: logicalIndex,
725
- time: timestamp ?? undefined,
726
- price: this.adapter.yToPrice('main', mouseY - paneInfo.top),
727
- }
728
- }
729
-
244
+ /** 单锚点工具:点击即创建图元,完成后切回光标模式 */
730
245
  private createSingleAnchorDrawing(anchor: DrawingAnchorInput) {
731
- this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
246
+ this.drawingState.removePreview()
732
247
 
733
248
  const drawing: DrawingObject = {
734
249
  id: `drawing-${Date.now()}`,
735
- kind: this.getDrawingKind(this.activeTool),
250
+ kind: getDrawingKind(this.activeTool),
736
251
  paneId: 'main',
737
252
  visible: true,
738
- anchors: [{ id: `${Date.now()}-a`, index: anchor.index, time: anchor.time, price: anchor.price }],
253
+ anchors: [
254
+ {
255
+ id: `${Date.now()}-a`,
256
+ index: anchor.index,
257
+ time: anchor.time,
258
+ price: anchor.price,
259
+ },
260
+ ],
739
261
  params: {},
740
262
  style: {
741
263
  stroke: '#2962ff',
@@ -744,32 +266,34 @@ export class DrawingInteractionController {
744
266
  },
745
267
  }
746
268
 
747
- this.drawings = [...this.drawings, drawing]
748
- this.adapter.setDrawings(this.drawings)
269
+ this.drawingState.addOrUpdate(drawing)
749
270
  this.callbacks.onDrawingCreated?.(drawing)
750
271
  this.activeTool = 'cursor'
751
272
  this.callbacks.onToolChange?.('cursor')
752
273
  }
753
274
 
275
+ /** 多锚点工具(2-3 锚点):锚点累积满后创建图元,完成后切回光标模式 */
754
276
  private createMultiAnchorDrawing(anchors: DrawingAnchorInput[]) {
755
- this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
756
-
757
- const kind = this.getDrawingKind(this.activeTool)
758
- const params: Record<string, unknown> = kind === 'regression-channel' ? { sigma: 2 } : {}
759
-
760
- const normalizedAnchors = kind === 'flat-line' && anchors.length >= 3
761
- ? [
762
- anchors[0]!,
763
- anchors[1]!,
764
- {
765
- index: anchors[1]!.index,
766
- time: anchors[1]!.time,
767
- price: anchors[2]!.price,
768
- },
769
- ]
770
- : anchors
277
+ this.drawingState.removePreview()
278
+
279
+ const kind = getDrawingKind(this.activeTool)
280
+ const params: Record<string, unknown> =
281
+ kind === 'regression-channel' ? { sigma: 2 } : {}
282
+
283
+ const normalizedAnchors =
284
+ kind === 'flat-line' && anchors.length >= 3
285
+ ? [
286
+ anchors[0]!,
287
+ anchors[1]!,
288
+ {
289
+ index: anchors[1]!.index,
290
+ time: anchors[1]!.time,
291
+ price: anchors[2]!.price,
292
+ },
293
+ ]
294
+ : anchors
771
295
 
772
- const isChannel = ['parallel-channel', 'regression-channel', 'flat-line', 'disjoint-channel'].includes(kind)
296
+ const isChannel = CHANNEL_KINDS.includes(kind)
773
297
 
774
298
  const drawing: DrawingObject = {
775
299
  id: `drawing-${Date.now()}`,
@@ -791,43 +315,9 @@ export class DrawingInteractionController {
791
315
  },
792
316
  }
793
317
 
794
- this.drawings = [...this.drawings, drawing]
795
- this.adapter.setDrawings(this.drawings)
318
+ this.drawingState.addOrUpdate(drawing)
796
319
  this.callbacks.onDrawingCreated?.(drawing)
797
320
  this.activeTool = 'cursor'
798
321
  this.callbacks.onToolChange?.('cursor')
799
322
  }
800
-
801
- private getDrawingKind(toolId: DrawingToolId): DrawingKind {
802
- switch (toolId) {
803
- case 'cursor':
804
- throw new Error('cursor is not a drawing kind')
805
- case 'h-line':
806
- return 'horizontal-line'
807
- case 'h-ray':
808
- return 'horizontal-ray'
809
- case 'v-line':
810
- return 'vertical-line'
811
- case 'crosshair-line':
812
- return 'cross-line'
813
- default:
814
- return toolId
815
- }
816
- }
817
- }
818
-
819
- function pointToSegmentDist(
820
- px: number,
821
- py: number,
822
- a: { x: number; y: number },
823
- b: { x: number; y: number }
824
- ): number {
825
- const dx = b.x - a.x
826
- const dy = b.y - a.y
827
- const lenSq = dx * dx + dy * dy
828
- if (lenSq === 0) return Math.hypot(px - a.x, py - a.y)
829
-
830
- let t = ((px - a.x) * dx + (py - a.y) * dy) / lenSq
831
- t = Math.max(0, Math.min(1, t))
832
- return Math.hypot(px - (a.x + t * dx), py - (a.y + t * dy))
833
- }
323
+ }