@363045841yyt/klinechart-core 0.7.3 → 0.7.5

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 (247) hide show
  1. package/README.md +201 -201
  2. package/README.zh-CN.md +201 -201
  3. package/dist/controllers/index.d.ts +1 -0
  4. package/dist/controllers/index.d.ts.map +1 -1
  5. package/dist/controllers/index.js +1 -0
  6. package/dist/controllers/index.js.map +1 -1
  7. package/dist/engine/chart.d.ts +11 -19
  8. package/dist/engine/chart.d.ts.map +1 -1
  9. package/dist/engine/chart.js +92 -109
  10. package/dist/engine/chart.js.map +1 -1
  11. package/dist/engine/renderers/Indicator/indicatorData.d.ts +1 -0
  12. package/dist/engine/renderers/Indicator/indicatorData.d.ts.map +1 -1
  13. package/dist/engine/renderers/Indicator/indicatorData.js +1 -1
  14. package/dist/engine/renderers/Indicator/indicatorData.js.map +1 -1
  15. package/dist/engine/renderers/webgl/candleSurface.js +47 -47
  16. package/dist/engine/subPaneManager.d.ts +4 -0
  17. package/dist/engine/subPaneManager.d.ts.map +1 -1
  18. package/dist/engine/subPaneManager.js +13 -0
  19. package/dist/engine/subPaneManager.js.map +1 -1
  20. package/dist/version.d.ts +1 -1
  21. package/dist/version.d.ts.map +1 -1
  22. package/dist/version.js +1 -2
  23. package/dist/version.js.map +1 -1
  24. package/package.json +129 -122
  25. package/src/__tests__/signal.test.ts +124 -124
  26. package/src/config/chartSettings.ts +66 -66
  27. package/src/controllers/__tests__/drawing.test.ts +214 -214
  28. package/src/controllers/__tests__/indicatorSelector.test.ts +481 -481
  29. package/src/controllers/__tests__/toolbar.test.ts +225 -225
  30. package/src/controllers/createChartController.ts +665 -665
  31. package/src/controllers/createDrawingController.ts +96 -96
  32. package/src/controllers/createIndicatorSelectorController.ts +307 -307
  33. package/src/controllers/createToolbarController.ts +146 -146
  34. package/src/controllers/index.ts +20 -19
  35. package/src/controllers/types.ts +284 -284
  36. package/src/engine/__tests__/chart.dpr.test.ts +401 -401
  37. package/src/engine/__tests__/paneRenderer.resize.test.ts +92 -92
  38. package/src/engine/chart-store.ts +121 -121
  39. package/src/engine/chart.d.ts +617 -617
  40. package/src/engine/chart.ts +2803 -2815
  41. package/src/engine/controller/__tests__/interaction.dpr.test.ts +259 -259
  42. package/src/engine/controller/interaction.ts +722 -722
  43. package/src/engine/controller/markerInteraction.ts +130 -130
  44. package/src/engine/controller/pinchTracker.ts +82 -82
  45. package/src/engine/controller/tooltipPosition.ts +48 -48
  46. package/src/engine/draw/__tests__/pixelAlign.spec.ts +176 -176
  47. package/src/engine/draw/pixelAlign.ts +259 -259
  48. package/src/engine/drawing/index.ts +655 -655
  49. package/src/engine/drawing/interaction.ts +842 -842
  50. package/src/engine/drawing/plugin.ts +343 -343
  51. package/src/engine/indicators/__tests__/__fixtures__/golden/atr.json +38 -38
  52. package/src/engine/indicators/__tests__/__fixtures__/golden/dema.json +14 -14
  53. package/src/engine/indicators/__tests__/__fixtures__/golden/hma.json +14 -14
  54. package/src/engine/indicators/__tests__/__fixtures__/golden/index.ts +55 -55
  55. package/src/engine/indicators/__tests__/__fixtures__/golden/kama.json +14 -14
  56. package/src/engine/indicators/__tests__/__fixtures__/golden/tema.json +14 -14
  57. package/src/engine/indicators/__tests__/__fixtures__/golden/wma.json +40 -40
  58. package/src/engine/indicators/__tests__/__fixtures__/synthetic.ts +65 -65
  59. package/src/engine/indicators/__tests__/_propertyAssertions.ts +76 -76
  60. package/src/engine/indicators/__tests__/atr.test.ts +153 -153
  61. package/src/engine/indicators/__tests__/calculators.test.ts +614 -614
  62. package/src/engine/indicators/__tests__/cmf-mfi.test.ts +100 -100
  63. package/src/engine/indicators/__tests__/dema.test.ts +73 -73
  64. package/src/engine/indicators/__tests__/donchian.test.ts +70 -70
  65. package/src/engine/indicators/__tests__/hma.test.ts +73 -73
  66. package/src/engine/indicators/__tests__/ichimoku.test.ts +105 -105
  67. package/src/engine/indicators/__tests__/kama.test.ts +80 -80
  68. package/src/engine/indicators/__tests__/keltner.test.ts +65 -65
  69. package/src/engine/indicators/__tests__/pivot-fib.test.ts +110 -110
  70. package/src/engine/indicators/__tests__/roc.test.ts +68 -68
  71. package/src/engine/indicators/__tests__/sar.test.ts +86 -86
  72. package/src/engine/indicators/__tests__/scheduler.test.ts +831 -831
  73. package/src/engine/indicators/__tests__/soa.test.ts +533 -533
  74. package/src/engine/indicators/__tests__/structure.test.ts +110 -110
  75. package/src/engine/indicators/__tests__/supertrend.test.ts +65 -65
  76. package/src/engine/indicators/__tests__/tema.test.ts +68 -68
  77. package/src/engine/indicators/__tests__/trix.test.ts +70 -70
  78. package/src/engine/indicators/__tests__/volatility.test.ts +117 -117
  79. package/src/engine/indicators/__tests__/volume.test.ts +115 -115
  80. package/src/engine/indicators/__tests__/volumeProfile.test.ts +74 -74
  81. package/src/engine/indicators/__tests__/vwap.test.ts +69 -69
  82. package/src/engine/indicators/__tests__/wma.test.ts +112 -112
  83. package/src/engine/indicators/__tests__/zones.test.ts +95 -95
  84. package/src/engine/indicators/atrState.ts +27 -27
  85. package/src/engine/indicators/bollState.ts +51 -51
  86. package/src/engine/indicators/calculators.ts +2593 -2593
  87. package/src/engine/indicators/cciState.ts +25 -25
  88. package/src/engine/indicators/chaikinVolState.ts +32 -32
  89. package/src/engine/indicators/cmfState.ts +27 -27
  90. package/src/engine/indicators/demaState.ts +27 -27
  91. package/src/engine/indicators/donchianState.ts +43 -43
  92. package/src/engine/indicators/eneState.ts +43 -43
  93. package/src/engine/indicators/expmaState.ts +43 -43
  94. package/src/engine/indicators/fastkState.ts +25 -25
  95. package/src/engine/indicators/fibState.ts +41 -41
  96. package/src/engine/indicators/hmaState.ts +27 -27
  97. package/src/engine/indicators/hvState.ts +28 -28
  98. package/src/engine/indicators/ichimokuState.ts +70 -70
  99. package/src/engine/indicators/indicator.worker.ts +169 -169
  100. package/src/engine/indicators/indicatorDefinitionRegistry.ts +62 -62
  101. package/src/engine/indicators/indicatorMetadata.ts +110 -110
  102. package/src/engine/indicators/indicatorRegistry.ts +106 -106
  103. package/src/engine/indicators/indicatorRuntime.ts +1548 -1548
  104. package/src/engine/indicators/kamaState.ts +34 -34
  105. package/src/engine/indicators/keltnerState.ts +49 -49
  106. package/src/engine/indicators/kstState.ts +42 -42
  107. package/src/engine/indicators/maState.ts +36 -36
  108. package/src/engine/indicators/macdState.ts +76 -76
  109. package/src/engine/indicators/mfiState.ts +27 -27
  110. package/src/engine/indicators/momState.ts +25 -25
  111. package/src/engine/indicators/obvState.ts +25 -25
  112. package/src/engine/indicators/parkinsonState.ts +28 -28
  113. package/src/engine/indicators/pivotState.ts +51 -51
  114. package/src/engine/indicators/pvtState.ts +25 -25
  115. package/src/engine/indicators/rocState.ts +27 -27
  116. package/src/engine/indicators/rsiState.ts +65 -65
  117. package/src/engine/indicators/sarState.ts +41 -41
  118. package/src/engine/indicators/scheduler.ts +1205 -1205
  119. package/src/engine/indicators/soa.ts +352 -352
  120. package/src/engine/indicators/stateComposer.ts +1262 -1262
  121. package/src/engine/indicators/stochState.ts +26 -26
  122. package/src/engine/indicators/structureState.ts +69 -69
  123. package/src/engine/indicators/supertrendState.ts +37 -37
  124. package/src/engine/indicators/temaState.ts +27 -27
  125. package/src/engine/indicators/trixState.ts +35 -35
  126. package/src/engine/indicators/vmaState.ts +27 -27
  127. package/src/engine/indicators/volumeProfileState.ts +63 -63
  128. package/src/engine/indicators/vwapState.ts +29 -29
  129. package/src/engine/indicators/wmaState.ts +27 -27
  130. package/src/engine/indicators/wmsrState.ts +25 -25
  131. package/src/engine/indicators/workerProtocol.ts +613 -613
  132. package/src/engine/indicators/zonesState.ts +47 -47
  133. package/src/engine/layout/pane.ts +161 -161
  134. package/src/engine/marker/registry.ts +265 -265
  135. package/src/engine/paneRenderer.ts +169 -169
  136. package/src/engine/renderers/Indicator/atr.ts +237 -237
  137. package/src/engine/renderers/Indicator/boll.ts +317 -317
  138. package/src/engine/renderers/Indicator/cci.ts +275 -275
  139. package/src/engine/renderers/Indicator/chaikinVol.ts +138 -138
  140. package/src/engine/renderers/Indicator/cmf.ts +137 -137
  141. package/src/engine/renderers/Indicator/dema.ts +136 -136
  142. package/src/engine/renderers/Indicator/donchian.ts +137 -137
  143. package/src/engine/renderers/Indicator/ene.ts +271 -271
  144. package/src/engine/renderers/Indicator/expma.ts +197 -197
  145. package/src/engine/renderers/Indicator/fastk.ts +316 -316
  146. package/src/engine/renderers/Indicator/fib.ts +141 -141
  147. package/src/engine/renderers/Indicator/hma.ts +136 -136
  148. package/src/engine/renderers/Indicator/hv.ts +124 -124
  149. package/src/engine/renderers/Indicator/ichimoku.ts +181 -181
  150. package/src/engine/renderers/Indicator/index.ts +241 -241
  151. package/src/engine/renderers/Indicator/indicatorData.ts +650 -650
  152. package/src/engine/renderers/Indicator/kama.ts +136 -136
  153. package/src/engine/renderers/Indicator/keltner.ts +137 -137
  154. package/src/engine/renderers/Indicator/kst.ts +302 -302
  155. package/src/engine/renderers/Indicator/ma.ts +200 -200
  156. package/src/engine/renderers/Indicator/macd.ts +477 -477
  157. package/src/engine/renderers/Indicator/macdLegend.ts +141 -141
  158. package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +272 -272
  159. package/src/engine/renderers/Indicator/mfi.ts +142 -142
  160. package/src/engine/renderers/Indicator/mom.ts +311 -311
  161. package/src/engine/renderers/Indicator/obv.ts +123 -123
  162. package/src/engine/renderers/Indicator/parkinson.ts +124 -124
  163. package/src/engine/renderers/Indicator/pivot.ts +131 -131
  164. package/src/engine/renderers/Indicator/pvt.ts +123 -123
  165. package/src/engine/renderers/Indicator/roc.ts +143 -143
  166. package/src/engine/renderers/Indicator/rsi.ts +390 -390
  167. package/src/engine/renderers/Indicator/sar.ts +113 -113
  168. package/src/engine/renderers/Indicator/scale/atr_scale.ts +19 -19
  169. package/src/engine/renderers/Indicator/scale/cci_scale.ts +19 -19
  170. package/src/engine/renderers/Indicator/scale/fastk_scale.ts +19 -19
  171. package/src/engine/renderers/Indicator/scale/indicator_scale.ts +204 -204
  172. package/src/engine/renderers/Indicator/scale/kst_scale.ts +19 -19
  173. package/src/engine/renderers/Indicator/scale/macd_scale.ts +22 -22
  174. package/src/engine/renderers/Indicator/scale/mom_scale.ts +19 -19
  175. package/src/engine/renderers/Indicator/scale/rsi_scale.ts +19 -19
  176. package/src/engine/renderers/Indicator/scale/stoch_scale.ts +19 -19
  177. package/src/engine/renderers/Indicator/scale/volume_scale.ts +26 -26
  178. package/src/engine/renderers/Indicator/scale/wmsr_scale.ts +19 -19
  179. package/src/engine/renderers/Indicator/stoch.ts +359 -359
  180. package/src/engine/renderers/Indicator/structure.ts +126 -126
  181. package/src/engine/renderers/Indicator/subPaneConfig.ts +265 -265
  182. package/src/engine/renderers/Indicator/supertrend.ts +115 -115
  183. package/src/engine/renderers/Indicator/tema.ts +136 -136
  184. package/src/engine/renderers/Indicator/trix.ts +158 -158
  185. package/src/engine/renderers/Indicator/vma.ts +124 -124
  186. package/src/engine/renderers/Indicator/volumeProfile.ts +125 -125
  187. package/src/engine/renderers/Indicator/vwap.ts +123 -123
  188. package/src/engine/renderers/Indicator/wma.ts +136 -136
  189. package/src/engine/renderers/Indicator/wmsr.ts +328 -328
  190. package/src/engine/renderers/Indicator/zones.ts +104 -104
  191. package/src/engine/renderers/__tests__/boll.renderer.test.ts +314 -314
  192. package/src/engine/renderers/__tests__/ene.renderer.test.ts +305 -305
  193. package/src/engine/renderers/__tests__/expma.renderer.test.ts +279 -279
  194. package/src/engine/renderers/__tests__/ma.renderer.test.ts +426 -426
  195. package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +502 -502
  196. package/src/engine/renderers/__tests__/yAxis.renderer.test.ts +173 -173
  197. package/src/engine/renderers/candle.ts +459 -459
  198. package/src/engine/renderers/crosshair.ts +69 -69
  199. package/src/engine/renderers/customMarkers.ts +162 -162
  200. package/src/engine/renderers/extremaMarkers.ts +246 -246
  201. package/src/engine/renderers/gridLines.ts +90 -90
  202. package/src/engine/renderers/lastPrice.ts +97 -97
  203. package/src/engine/renderers/paneTitle.ts +136 -136
  204. package/src/engine/renderers/subVolume.ts +236 -236
  205. package/src/engine/renderers/timeAxis.ts +121 -121
  206. package/src/engine/renderers/webgl/candleSurface.ts +955 -955
  207. package/src/engine/renderers/webgl/sharedWebGLSurface.ts +146 -146
  208. package/src/engine/renderers/yAxis.ts +105 -105
  209. package/src/engine/scale/__tests__/logFormula.spec.ts +148 -148
  210. package/src/engine/scale/logFormula.ts +130 -130
  211. package/src/engine/scale/price.ts +39 -39
  212. package/src/engine/scale/priceScale.ts +264 -264
  213. package/src/engine/subPaneManager.ts +442 -427
  214. package/src/engine/theme/colors.ts +642 -642
  215. package/src/engine/theme/fonts.ts +20 -20
  216. package/src/engine/utils/klineConfig.ts +49 -49
  217. package/src/engine/utils/tickCount.ts +11 -11
  218. package/src/engine/utils/tickPosition.ts +214 -214
  219. package/src/engine/utils/zoom.ts +83 -83
  220. package/src/engine/viewport/viewport.ts +67 -67
  221. package/src/index.ts +3 -3
  222. package/src/plugin/ConfigManager.ts +93 -93
  223. package/src/plugin/EventBus.ts +77 -77
  224. package/src/plugin/HookSystem.ts +106 -106
  225. package/src/plugin/PluginHost.ts +243 -243
  226. package/src/plugin/PluginRegistry.ts +92 -92
  227. package/src/plugin/StateStore.ts +73 -73
  228. package/src/plugin/index.ts +19 -19
  229. package/src/plugin/rendererPluginManager.ts +368 -368
  230. package/src/plugin/stateKeys.ts +8 -8
  231. package/src/plugin/types.ts +526 -526
  232. package/src/reactivity/index.ts +2 -2
  233. package/src/reactivity/signal.ts +119 -119
  234. package/src/semantic/controller.ts +251 -251
  235. package/src/semantic/drawShape.ts +260 -260
  236. package/src/semantic/index.ts +28 -28
  237. package/src/semantic/schema.json +256 -256
  238. package/src/semantic/types.ts +251 -251
  239. package/src/semantic/validator.ts +349 -349
  240. package/src/types/kLine.ts +13 -13
  241. package/src/types/price.ts +56 -56
  242. package/src/types/volumePrice.ts +33 -33
  243. package/src/utils/dateFormat.ts +208 -208
  244. package/src/utils/kLineDraw/axis.ts +562 -562
  245. package/src/utils/priceToY.ts +34 -34
  246. package/src/utils/volumePrice.ts +202 -202
  247. package/src/version.ts +1 -1
@@ -1,842 +1,842 @@
1
- import type { DrawingObject, DrawingKind, DrawingAnchor, DrawingStyle } from '../../plugin'
2
- import type { Chart } from '../chart'
3
- import { getPhysicalKLineConfig } from '../utils/klineConfig'
4
- import { computeLinearRegression } from './index'
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
- }
25
-
26
- export interface DrawingInteractionCallbacks {
27
- onDrawingCreated?: (drawing: DrawingObject) => void
28
- onToolChange?: (toolId: DrawingToolId) => void
29
- onDrawingSelected?: (drawing: DrawingObject | null) => void
30
- }
31
-
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
- /**
54
- * 绘图交互控制器
55
- * 封装绘图工具的交互逻辑,与 Vue 组件解耦
56
- */
57
- export class DrawingInteractionController {
58
- private chart: Chart
59
- private activeTool: DrawingToolId = 'cursor'
60
- private pendingAnchors: DrawingAnchorInput[] = []
61
- private drawings: DrawingObject[] = []
62
- 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
- ]
89
-
90
- constructor(chart: Chart) {
91
- this.chart = chart
92
- }
93
-
94
- setCallbacks(callbacks: DrawingInteractionCallbacks) {
95
- this.callbacks = callbacks
96
- }
97
-
98
- getActiveTool(): DrawingToolId {
99
- return this.activeTool
100
- }
101
-
102
- setTool(toolId: DrawingToolId) {
103
- this.activeTool = toolId
104
- this.pendingAnchors = []
105
- this.removePreview()
106
- this.dragState = null
107
- this.setSelected(null)
108
- this.callbacks.onToolChange?.(toolId)
109
- }
110
-
111
- getDrawings(): DrawingObject[] {
112
- return this.drawings
113
- }
114
-
115
- setDrawings(drawings: DrawingObject[]) {
116
- this.drawings = drawings
117
- this.chart.setDrawings(drawings)
118
- }
119
-
120
- 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
130
- }
131
-
132
- 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.chart.setDrawings(this.drawings)
137
- }
138
-
139
- 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.chart.setDrawings(this.drawings)
145
- }
146
-
147
- /**
148
- * 处理指针移动事件
149
- * @returns 是否处理了事件(阻止冒泡)
150
- */
151
- onPointerMove(e: PointerEvent, container: HTMLElement): boolean {
152
- // 拖拽已有图元
153
- if (this.dragState) {
154
- return this.handleDragMove(e, container)
155
- }
156
-
157
- // 创建预览
158
- if (this.activeTool !== 'cursor') {
159
- return this.handlePreviewMove(e, container)
160
- }
161
-
162
- return false
163
- }
164
-
165
- /**
166
- * 处理指针按下事件
167
- * @returns 是否处理了事件(阻止冒泡)
168
- */
169
- onPointerDown(e: PointerEvent, container: HTMLElement): boolean {
170
- // 光标模式:命中检测已有图元
171
- if (this.activeTool === 'cursor') {
172
- return this.handleCursorDown(e, container)
173
- }
174
-
175
- const anchor = this.resolveAnchorFromPointer(e, container)
176
- if (!anchor) return false
177
-
178
- // 单锚点工具:点击一次立即创建
179
- if (DrawingInteractionController.SINGLE_ANCHOR_TOOLS.includes(this.activeTool)) {
180
- this.createSingleAnchorDrawing(anchor)
181
- return true
182
- }
183
-
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 = []
195
- }
196
- return true
197
- }
198
-
199
- /**
200
- * 处理指针释放事件
201
- * @returns 是否处理了事件(阻止冒泡)
202
- */
203
- onPointerUp(_e: PointerEvent, _container: HTMLElement): boolean {
204
- if (!this.dragState) return false
205
- this.dragState = null
206
- return true
207
- }
208
-
209
- // ============ 光标模式:命中检测与拖拽 ============
210
-
211
- private handleCursorDown(e: PointerEvent, container: HTMLElement): boolean {
212
- const rect = container.getBoundingClientRect()
213
- const mouseX = e.clientX - rect.left
214
- const mouseY = e.clientY - rect.top
215
-
216
- const hit = this.hitTest(mouseX, mouseY)
217
- if (!hit) {
218
- this.setSelected(null)
219
- return false
220
- }
221
-
222
- this.setSelected(hit.drawing)
223
-
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.chart.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.chart.setDrawings(this.drawings)
404
- return true
405
- }
406
-
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.chart.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 paneRenderer = this.chart.getPaneRenderers().find((item) => item.getPane().id === 'main')
462
- const pane = paneRenderer?.getPane()
463
- if (!pane) return []
464
-
465
- const right = viewport.plotWidth
466
- const bottom = pane.height
467
-
468
- switch (drawing.kind) {
469
- case 'horizontal-line':
470
- return [{ a: { x: 0, y: screen.y }, b: { x: right, y: screen.y } }]
471
- case 'horizontal-ray':
472
- return [{ a: screen, b: { x: right, y: screen.y } }]
473
- case 'vertical-line':
474
- return [{ a: { x: screen.x, y: 0 }, b: { x: screen.x, y: bottom } }]
475
- case 'cross-line':
476
- return [
477
- { a: { x: 0, y: screen.y }, b: { x: right, y: screen.y } },
478
- { a: { x: screen.x, y: 0 }, b: { x: screen.x, y: bottom } },
479
- ]
480
- default:
481
- return []
482
- }
483
- }
484
-
485
- // 多锚点图元:按 kind 特殊处理
486
- const points = drawing.anchors.map((a) => this.anchorToScreen(a)).filter(Boolean) as { x: number; y: number }[]
487
- if (points.length < 2) return []
488
-
489
- const segments: LineSegment[] = []
490
-
491
- if (points.length === 2) {
492
- const a = points[0]!
493
- const b = points[1]!
494
-
495
- // 其他双锚点工具:标准线段
496
- const dx = b.x - a.x
497
- const dy = b.y - a.y
498
-
499
- let start = a
500
- let end = b
501
-
502
- const extend = this.getExtendMode(drawing)
503
- const maxLen = Math.max(viewport.plotWidth, viewport.plotHeight) * 4
504
-
505
- if (extend === 'right' || extend === 'both') {
506
- end = { x: b.x + dx * maxLen, y: b.y + dy * maxLen }
507
- }
508
- if (extend === 'left' || extend === 'both') {
509
- start = { x: a.x - dx * maxLen, y: a.y - dy * maxLen }
510
- }
511
-
512
- segments.push({ a: start, b: end })
513
- } else if (points.length >= 3) {
514
- switch (drawing.kind) {
515
- case 'parallel-channel': {
516
- const [p1, p2, p3] = points as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]
517
- const dx = p2.x - p1.x
518
- const dy = p2.y - p1.y
519
- const p4 = { x: p3.x + dx, y: p3.y + dy }
520
- segments.push(
521
- { a: p1, b: p2 },
522
- { a: p3, b: p4 },
523
- )
524
- break
525
- }
526
- case 'flat-line': {
527
- const [p1, p2, p3] = points as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]
528
- const h1 = { x: p1.x, y: p3.y }
529
- const h2 = { x: p2.x, y: p3.y }
530
- segments.push({ a: p1, b: p2 })
531
- segments.push({ a: h1, b: h2 })
532
- break
533
- }
534
- case 'disjoint-channel': {
535
- const [p1, p2, p3] = points as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]
536
- const dx = p2.x - p1.x
537
- const dy = p2.y - p1.y
538
- const p4 = { x: p3.x + dx, y: p3.y - dy }
539
- segments.push({ a: p1, b: p2 })
540
- segments.push({ a: p3, b: p4 })
541
- break
542
- }
543
- default:
544
- for (let i = 0; i < points.length - 1; i++) {
545
- segments.push({ a: points[i]!, b: points[i + 1]! })
546
- }
547
- }
548
- }
549
-
550
- return segments
551
- }
552
-
553
-
554
- /**
555
- * regression-channel 专用:回归线端点也是可拖拽的锚点区域
556
- * 回归线端点可能远离存储的锚点,需要额外检测
557
- */
558
- private hitTestRegressionEndpoints(
559
- drawing: DrawingObject,
560
- mouseX: number,
561
- mouseY: number,
562
- regressionGeometryCache?: Map<string, RegressionChannelGeometry | null>,
563
- ): { drawing: DrawingObject; anchorIndex: number } | null {
564
- const geometry = this.getRegressionChannelGeometry(drawing, regressionGeometryCache)
565
- if (!geometry) return null
566
-
567
- for (const endpoint of geometry.endpoints) {
568
- const dist = Math.hypot(mouseX - endpoint.point.x, mouseY - endpoint.point.y)
569
- if (dist <= ANCHOR_HIT_RADIUS) {
570
- return { drawing, anchorIndex: endpoint.anchorIndex }
571
- }
572
- }
573
-
574
- return null
575
- }
576
-
577
-
578
- private getRegressionChannelGeometry(
579
- drawing: DrawingObject,
580
- regressionGeometryCache?: Map<string, RegressionChannelGeometry | null>,
581
- ): RegressionChannelGeometry | null {
582
- const cached = regressionGeometryCache?.get(drawing.id)
583
- if (cached !== undefined) return cached
584
-
585
- const data = this.chart.getData()
586
- if (data.length === 0 || drawing.anchors.length < 2) {
587
- regressionGeometryCache?.set(drawing.id, null)
588
- return null
589
- }
590
-
591
- const firstIndex = Math.round(drawing.anchors[0]!.index)
592
- const secondIndex = Math.round(drawing.anchors[1]!.index)
593
- const clampedFirst = Math.min(Math.max(firstIndex, 0), data.length - 1)
594
- const clampedSecond = Math.min(Math.max(secondIndex, 0), data.length - 1)
595
- const startIndex = Math.min(clampedFirst, clampedSecond)
596
- const endIndex = Math.max(clampedFirst, clampedSecond)
597
- const slice = data.slice(startIndex, endIndex + 1)
598
- const regression = computeLinearRegression(slice.map((item: { close: number }) => item.close))
599
- if (!regression) {
600
- regressionGeometryCache?.set(drawing.id, null)
601
- return null
602
- }
603
-
604
- const sigma = (drawing.params as { sigma?: number } | undefined)?.sigma ?? 2
605
- const offset = regression.stdDev * sigma
606
- const firstValue = regression.intercept
607
- const lastValue = regression.intercept + regression.slope * (slice.length - 1)
608
-
609
- const middleStart = this.anchorToScreen({ id: '', index: firstIndex, price: firstValue })
610
- const middleEnd = this.anchorToScreen({ id: '', index: secondIndex, price: lastValue })
611
- const upperStart = this.anchorToScreen({ id: '', index: firstIndex, price: firstValue + offset })
612
- const upperEnd = this.anchorToScreen({ id: '', index: secondIndex, price: lastValue + offset })
613
- const lowerStart = this.anchorToScreen({ id: '', index: firstIndex, price: firstValue - offset })
614
- const lowerEnd = this.anchorToScreen({ id: '', index: secondIndex, price: lastValue - offset })
615
-
616
- const segments: LineSegment[] = []
617
- if (middleStart && middleEnd) segments.push({ a: middleStart, b: middleEnd })
618
- if (upperStart && upperEnd) segments.push({ a: upperStart, b: upperEnd })
619
- if (lowerStart && lowerEnd) segments.push({ a: lowerStart, b: lowerEnd })
620
-
621
- const endpoints: RegressionChannelGeometry['endpoints'] = []
622
- if (middleStart) endpoints.push({ point: middleStart, anchorIndex: 0 })
623
- if (middleEnd) endpoints.push({ point: middleEnd, anchorIndex: 1 })
624
- if (upperStart) endpoints.push({ point: upperStart, anchorIndex: 0 })
625
- if (upperEnd) endpoints.push({ point: upperEnd, anchorIndex: 1 })
626
- if (lowerStart) endpoints.push({ point: lowerStart, anchorIndex: 0 })
627
- if (lowerEnd) endpoints.push({ point: lowerEnd, anchorIndex: 1 })
628
-
629
- const geometry = { segments, endpoints }
630
- regressionGeometryCache?.set(drawing.id, geometry)
631
- return geometry
632
- }
633
-
634
- private getExtendMode(drawing: DrawingObject): 'none' | 'left' | 'right' | 'both' {
635
- switch (drawing.kind) {
636
- case 'ray':
637
- return 'right'
638
- case 'extended-line':
639
- return 'both'
640
- default:
641
- return 'none'
642
- }
643
- }
644
-
645
- // ============ 坐标转换 ============
646
-
647
- private anchorToScreen(anchor: DrawingAnchor): { x: number; y: number } | null {
648
- const viewport = this.chart.getViewport()
649
- if (!viewport) return null
650
-
651
- const opt = this.chart.getOption()
652
- const dpr = this.chart.getCurrentDpr()
653
- const { startXPx, unitPx } = getPhysicalKLineConfig(opt.kWidth, opt.kGap, dpr)
654
- if (!Number.isFinite(anchor.index)) return null
655
-
656
- const x = (startXPx + anchor.index * unitPx + (unitPx - 1) / 2) / dpr - viewport.scrollLeft
657
-
658
- const paneRenderer = this.chart.getPaneRenderers().find((item) => item.getPane().id === 'main')
659
- const pane = paneRenderer?.getPane()
660
- if (!pane) return null
661
-
662
- const y = pane.yAxis.priceToY(anchor.price)
663
- return { x, y }
664
- }
665
-
666
- private screenToAnchor(
667
- screenX: number,
668
- screenY: number
669
- ): DrawingAnchorInput | null {
670
- const data = this.chart.getData()
671
- const viewport = this.chart.getViewport()
672
- if (!viewport || data.length === 0) return null
673
-
674
- const logicalIndex = this.chart.getLogicalIndexAtX(screenX)
675
- if (logicalIndex === null) return null
676
-
677
- const paneRenderer = this.chart.getPaneRenderers().find((item) => item.getPane().id === 'main')
678
- const pane = paneRenderer?.getPane()
679
- if (!pane) return null
680
-
681
- const timestamp = this.chart.getTimestampAtLogicalIndex(logicalIndex) ?? undefined
682
-
683
- return {
684
- index: logicalIndex,
685
- time: timestamp ?? undefined,
686
- price: pane.yAxis.yToPrice(screenY - pane.top),
687
- }
688
- }
689
-
690
- // ============ 工具方法 ============
691
-
692
- private setSelected(drawing: DrawingObject | null) {
693
- const newId = drawing?.id ?? null
694
- if (this.selectedDrawingId === newId) return
695
- this.selectedDrawingId = newId
696
- this.chart.setSelectedDrawingId(newId)
697
- this.callbacks.onDrawingSelected?.(drawing)
698
- }
699
-
700
- private removePreview() {
701
- if (!this.drawings.some((d) => d.id === this.previewDrawingId)) return
702
- this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
703
- this.chart.setDrawings(this.drawings)
704
- }
705
-
706
- private resolveAnchorFromPointer(
707
- e: PointerEvent,
708
- container: HTMLElement
709
- ): DrawingAnchorInput | null {
710
- const data = this.chart.getData()
711
- const viewport = this.chart.getViewport()
712
- if (!viewport || data.length === 0) return null
713
-
714
- const rect = container.getBoundingClientRect()
715
- const mouseX = e.clientX - rect.left
716
- const mouseY = e.clientY - rect.top
717
- if (mouseX < 0 || mouseY < 0 || mouseX > viewport.plotWidth || mouseY > viewport.plotHeight) {
718
- return null
719
- }
720
-
721
- const paneRenderer = this.chart.getPaneRenderers().find((item) => {
722
- const pane = item.getPane()
723
- return pane.id === 'main' && mouseY >= pane.top && mouseY <= pane.top + pane.height
724
- })
725
- const pane = paneRenderer?.getPane()
726
- if (!pane) return null
727
-
728
- const logicalIndex = this.chart.getLogicalIndexAtX(mouseX)
729
- if (logicalIndex === null) return null
730
- const timestamp = this.chart.getTimestampAtLogicalIndex(logicalIndex) ?? undefined
731
-
732
- return {
733
- index: logicalIndex,
734
- time: timestamp ?? undefined,
735
- price: pane.yAxis.yToPrice(mouseY - pane.top),
736
- }
737
- }
738
-
739
- private createSingleAnchorDrawing(anchor: DrawingAnchorInput) {
740
- this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
741
-
742
- const drawing: DrawingObject = {
743
- id: `drawing-${Date.now()}`,
744
- kind: this.getDrawingKind(this.activeTool),
745
- paneId: 'main',
746
- visible: true,
747
- anchors: [{ id: `${Date.now()}-a`, index: anchor.index, time: anchor.time, price: anchor.price }],
748
- params: {},
749
- style: {
750
- stroke: '#2962ff',
751
- strokeWidth: 1,
752
- strokeStyle: 'solid',
753
- },
754
- }
755
-
756
- this.drawings = [...this.drawings, drawing]
757
- this.chart.setDrawings(this.drawings)
758
- this.callbacks.onDrawingCreated?.(drawing)
759
- this.activeTool = 'cursor'
760
- this.callbacks.onToolChange?.('cursor')
761
- }
762
-
763
- private createMultiAnchorDrawing(anchors: DrawingAnchorInput[]) {
764
- this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
765
-
766
- const kind = this.getDrawingKind(this.activeTool)
767
- const params: Record<string, unknown> = kind === 'regression-channel' ? { sigma: 2 } : {}
768
-
769
- const normalizedAnchors = kind === 'flat-line' && anchors.length >= 3
770
- ? [
771
- anchors[0]!,
772
- anchors[1]!,
773
- {
774
- index: anchors[1]!.index,
775
- time: anchors[1]!.time,
776
- price: anchors[2]!.price,
777
- },
778
- ]
779
- : anchors
780
-
781
- const isChannel = ['parallel-channel', 'regression-channel', 'flat-line', 'disjoint-channel'].includes(kind)
782
-
783
- const drawing: DrawingObject = {
784
- id: `drawing-${Date.now()}`,
785
- kind,
786
- paneId: 'main',
787
- visible: true,
788
- anchors: normalizedAnchors.map((a, i) => ({
789
- id: `${Date.now()}-${String.fromCharCode(97 + i)}`,
790
- index: a.index,
791
- time: a.time,
792
- price: a.price,
793
- })),
794
- params,
795
- style: {
796
- stroke: '#2962ff',
797
- strokeWidth: 1,
798
- strokeStyle: 'solid',
799
- ...(isChannel ? { fillOpacity: 0.1 } : {}),
800
- },
801
- }
802
-
803
- this.drawings = [...this.drawings, drawing]
804
- this.chart.setDrawings(this.drawings)
805
- this.callbacks.onDrawingCreated?.(drawing)
806
- this.activeTool = 'cursor'
807
- this.callbacks.onToolChange?.('cursor')
808
- }
809
-
810
- private getDrawingKind(toolId: DrawingToolId): DrawingKind {
811
- switch (toolId) {
812
- case 'cursor':
813
- throw new Error('cursor is not a drawing kind')
814
- case 'h-line':
815
- return 'horizontal-line'
816
- case 'h-ray':
817
- return 'horizontal-ray'
818
- case 'v-line':
819
- return 'vertical-line'
820
- case 'crosshair-line':
821
- return 'cross-line'
822
- default:
823
- return toolId
824
- }
825
- }
826
- }
827
-
828
- function pointToSegmentDist(
829
- px: number,
830
- py: number,
831
- a: { x: number; y: number },
832
- b: { x: number; y: number }
833
- ): number {
834
- const dx = b.x - a.x
835
- const dy = b.y - a.y
836
- const lenSq = dx * dx + dy * dy
837
- if (lenSq === 0) return Math.hypot(px - a.x, py - a.y)
838
-
839
- let t = ((px - a.x) * dx + (py - a.y) * dy) / lenSq
840
- t = Math.max(0, Math.min(1, t))
841
- return Math.hypot(px - (a.x + t * dx), py - (a.y + t * dy))
842
- }
1
+ import type { DrawingObject, DrawingKind, DrawingAnchor, DrawingStyle } from '../../plugin'
2
+ import type { Chart } from '../chart'
3
+ import { getPhysicalKLineConfig } from '../utils/klineConfig'
4
+ import { computeLinearRegression } from './index'
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
+ }
25
+
26
+ export interface DrawingInteractionCallbacks {
27
+ onDrawingCreated?: (drawing: DrawingObject) => void
28
+ onToolChange?: (toolId: DrawingToolId) => void
29
+ onDrawingSelected?: (drawing: DrawingObject | null) => void
30
+ }
31
+
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
+ /**
54
+ * 绘图交互控制器
55
+ * 封装绘图工具的交互逻辑,与 Vue 组件解耦
56
+ */
57
+ export class DrawingInteractionController {
58
+ private chart: Chart
59
+ private activeTool: DrawingToolId = 'cursor'
60
+ private pendingAnchors: DrawingAnchorInput[] = []
61
+ private drawings: DrawingObject[] = []
62
+ 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
+ ]
89
+
90
+ constructor(chart: Chart) {
91
+ this.chart = chart
92
+ }
93
+
94
+ setCallbacks(callbacks: DrawingInteractionCallbacks) {
95
+ this.callbacks = callbacks
96
+ }
97
+
98
+ getActiveTool(): DrawingToolId {
99
+ return this.activeTool
100
+ }
101
+
102
+ setTool(toolId: DrawingToolId) {
103
+ this.activeTool = toolId
104
+ this.pendingAnchors = []
105
+ this.removePreview()
106
+ this.dragState = null
107
+ this.setSelected(null)
108
+ this.callbacks.onToolChange?.(toolId)
109
+ }
110
+
111
+ getDrawings(): DrawingObject[] {
112
+ return this.drawings
113
+ }
114
+
115
+ setDrawings(drawings: DrawingObject[]) {
116
+ this.drawings = drawings
117
+ this.chart.setDrawings(drawings)
118
+ }
119
+
120
+ 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
130
+ }
131
+
132
+ 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.chart.setDrawings(this.drawings)
137
+ }
138
+
139
+ 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.chart.setDrawings(this.drawings)
145
+ }
146
+
147
+ /**
148
+ * 处理指针移动事件
149
+ * @returns 是否处理了事件(阻止冒泡)
150
+ */
151
+ onPointerMove(e: PointerEvent, container: HTMLElement): boolean {
152
+ // 拖拽已有图元
153
+ if (this.dragState) {
154
+ return this.handleDragMove(e, container)
155
+ }
156
+
157
+ // 创建预览
158
+ if (this.activeTool !== 'cursor') {
159
+ return this.handlePreviewMove(e, container)
160
+ }
161
+
162
+ return false
163
+ }
164
+
165
+ /**
166
+ * 处理指针按下事件
167
+ * @returns 是否处理了事件(阻止冒泡)
168
+ */
169
+ onPointerDown(e: PointerEvent, container: HTMLElement): boolean {
170
+ // 光标模式:命中检测已有图元
171
+ if (this.activeTool === 'cursor') {
172
+ return this.handleCursorDown(e, container)
173
+ }
174
+
175
+ const anchor = this.resolveAnchorFromPointer(e, container)
176
+ if (!anchor) return false
177
+
178
+ // 单锚点工具:点击一次立即创建
179
+ if (DrawingInteractionController.SINGLE_ANCHOR_TOOLS.includes(this.activeTool)) {
180
+ this.createSingleAnchorDrawing(anchor)
181
+ return true
182
+ }
183
+
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 = []
195
+ }
196
+ return true
197
+ }
198
+
199
+ /**
200
+ * 处理指针释放事件
201
+ * @returns 是否处理了事件(阻止冒泡)
202
+ */
203
+ onPointerUp(_e: PointerEvent, _container: HTMLElement): boolean {
204
+ if (!this.dragState) return false
205
+ this.dragState = null
206
+ return true
207
+ }
208
+
209
+ // ============ 光标模式:命中检测与拖拽 ============
210
+
211
+ private handleCursorDown(e: PointerEvent, container: HTMLElement): boolean {
212
+ const rect = container.getBoundingClientRect()
213
+ const mouseX = e.clientX - rect.left
214
+ const mouseY = e.clientY - rect.top
215
+
216
+ const hit = this.hitTest(mouseX, mouseY)
217
+ if (!hit) {
218
+ this.setSelected(null)
219
+ return false
220
+ }
221
+
222
+ this.setSelected(hit.drawing)
223
+
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.chart.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.chart.setDrawings(this.drawings)
404
+ return true
405
+ }
406
+
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.chart.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 paneRenderer = this.chart.getPaneRenderers().find((item) => item.getPane().id === 'main')
462
+ const pane = paneRenderer?.getPane()
463
+ if (!pane) return []
464
+
465
+ const right = viewport.plotWidth
466
+ const bottom = pane.height
467
+
468
+ switch (drawing.kind) {
469
+ case 'horizontal-line':
470
+ return [{ a: { x: 0, y: screen.y }, b: { x: right, y: screen.y } }]
471
+ case 'horizontal-ray':
472
+ return [{ a: screen, b: { x: right, y: screen.y } }]
473
+ case 'vertical-line':
474
+ return [{ a: { x: screen.x, y: 0 }, b: { x: screen.x, y: bottom } }]
475
+ case 'cross-line':
476
+ return [
477
+ { a: { x: 0, y: screen.y }, b: { x: right, y: screen.y } },
478
+ { a: { x: screen.x, y: 0 }, b: { x: screen.x, y: bottom } },
479
+ ]
480
+ default:
481
+ return []
482
+ }
483
+ }
484
+
485
+ // 多锚点图元:按 kind 特殊处理
486
+ const points = drawing.anchors.map((a) => this.anchorToScreen(a)).filter(Boolean) as { x: number; y: number }[]
487
+ if (points.length < 2) return []
488
+
489
+ const segments: LineSegment[] = []
490
+
491
+ if (points.length === 2) {
492
+ const a = points[0]!
493
+ const b = points[1]!
494
+
495
+ // 其他双锚点工具:标准线段
496
+ const dx = b.x - a.x
497
+ const dy = b.y - a.y
498
+
499
+ let start = a
500
+ let end = b
501
+
502
+ const extend = this.getExtendMode(drawing)
503
+ const maxLen = Math.max(viewport.plotWidth, viewport.plotHeight) * 4
504
+
505
+ if (extend === 'right' || extend === 'both') {
506
+ end = { x: b.x + dx * maxLen, y: b.y + dy * maxLen }
507
+ }
508
+ if (extend === 'left' || extend === 'both') {
509
+ start = { x: a.x - dx * maxLen, y: a.y - dy * maxLen }
510
+ }
511
+
512
+ segments.push({ a: start, b: end })
513
+ } else if (points.length >= 3) {
514
+ switch (drawing.kind) {
515
+ case 'parallel-channel': {
516
+ const [p1, p2, p3] = points as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]
517
+ const dx = p2.x - p1.x
518
+ const dy = p2.y - p1.y
519
+ const p4 = { x: p3.x + dx, y: p3.y + dy }
520
+ segments.push(
521
+ { a: p1, b: p2 },
522
+ { a: p3, b: p4 },
523
+ )
524
+ break
525
+ }
526
+ case 'flat-line': {
527
+ const [p1, p2, p3] = points as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]
528
+ const h1 = { x: p1.x, y: p3.y }
529
+ const h2 = { x: p2.x, y: p3.y }
530
+ segments.push({ a: p1, b: p2 })
531
+ segments.push({ a: h1, b: h2 })
532
+ break
533
+ }
534
+ case 'disjoint-channel': {
535
+ const [p1, p2, p3] = points as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]
536
+ const dx = p2.x - p1.x
537
+ const dy = p2.y - p1.y
538
+ const p4 = { x: p3.x + dx, y: p3.y - dy }
539
+ segments.push({ a: p1, b: p2 })
540
+ segments.push({ a: p3, b: p4 })
541
+ break
542
+ }
543
+ default:
544
+ for (let i = 0; i < points.length - 1; i++) {
545
+ segments.push({ a: points[i]!, b: points[i + 1]! })
546
+ }
547
+ }
548
+ }
549
+
550
+ return segments
551
+ }
552
+
553
+
554
+ /**
555
+ * regression-channel 专用:回归线端点也是可拖拽的锚点区域
556
+ * 回归线端点可能远离存储的锚点,需要额外检测
557
+ */
558
+ private hitTestRegressionEndpoints(
559
+ drawing: DrawingObject,
560
+ mouseX: number,
561
+ mouseY: number,
562
+ regressionGeometryCache?: Map<string, RegressionChannelGeometry | null>,
563
+ ): { drawing: DrawingObject; anchorIndex: number } | null {
564
+ const geometry = this.getRegressionChannelGeometry(drawing, regressionGeometryCache)
565
+ if (!geometry) return null
566
+
567
+ for (const endpoint of geometry.endpoints) {
568
+ const dist = Math.hypot(mouseX - endpoint.point.x, mouseY - endpoint.point.y)
569
+ if (dist <= ANCHOR_HIT_RADIUS) {
570
+ return { drawing, anchorIndex: endpoint.anchorIndex }
571
+ }
572
+ }
573
+
574
+ return null
575
+ }
576
+
577
+
578
+ private getRegressionChannelGeometry(
579
+ drawing: DrawingObject,
580
+ regressionGeometryCache?: Map<string, RegressionChannelGeometry | null>,
581
+ ): RegressionChannelGeometry | null {
582
+ const cached = regressionGeometryCache?.get(drawing.id)
583
+ if (cached !== undefined) return cached
584
+
585
+ const data = this.chart.getData()
586
+ if (data.length === 0 || drawing.anchors.length < 2) {
587
+ regressionGeometryCache?.set(drawing.id, null)
588
+ return null
589
+ }
590
+
591
+ const firstIndex = Math.round(drawing.anchors[0]!.index)
592
+ const secondIndex = Math.round(drawing.anchors[1]!.index)
593
+ const clampedFirst = Math.min(Math.max(firstIndex, 0), data.length - 1)
594
+ const clampedSecond = Math.min(Math.max(secondIndex, 0), data.length - 1)
595
+ const startIndex = Math.min(clampedFirst, clampedSecond)
596
+ const endIndex = Math.max(clampedFirst, clampedSecond)
597
+ const slice = data.slice(startIndex, endIndex + 1)
598
+ const regression = computeLinearRegression(slice.map((item: { close: number }) => item.close))
599
+ if (!regression) {
600
+ regressionGeometryCache?.set(drawing.id, null)
601
+ return null
602
+ }
603
+
604
+ const sigma = (drawing.params as { sigma?: number } | undefined)?.sigma ?? 2
605
+ const offset = regression.stdDev * sigma
606
+ const firstValue = regression.intercept
607
+ const lastValue = regression.intercept + regression.slope * (slice.length - 1)
608
+
609
+ const middleStart = this.anchorToScreen({ id: '', index: firstIndex, price: firstValue })
610
+ const middleEnd = this.anchorToScreen({ id: '', index: secondIndex, price: lastValue })
611
+ const upperStart = this.anchorToScreen({ id: '', index: firstIndex, price: firstValue + offset })
612
+ const upperEnd = this.anchorToScreen({ id: '', index: secondIndex, price: lastValue + offset })
613
+ const lowerStart = this.anchorToScreen({ id: '', index: firstIndex, price: firstValue - offset })
614
+ const lowerEnd = this.anchorToScreen({ id: '', index: secondIndex, price: lastValue - offset })
615
+
616
+ const segments: LineSegment[] = []
617
+ if (middleStart && middleEnd) segments.push({ a: middleStart, b: middleEnd })
618
+ if (upperStart && upperEnd) segments.push({ a: upperStart, b: upperEnd })
619
+ if (lowerStart && lowerEnd) segments.push({ a: lowerStart, b: lowerEnd })
620
+
621
+ const endpoints: RegressionChannelGeometry['endpoints'] = []
622
+ if (middleStart) endpoints.push({ point: middleStart, anchorIndex: 0 })
623
+ if (middleEnd) endpoints.push({ point: middleEnd, anchorIndex: 1 })
624
+ if (upperStart) endpoints.push({ point: upperStart, anchorIndex: 0 })
625
+ if (upperEnd) endpoints.push({ point: upperEnd, anchorIndex: 1 })
626
+ if (lowerStart) endpoints.push({ point: lowerStart, anchorIndex: 0 })
627
+ if (lowerEnd) endpoints.push({ point: lowerEnd, anchorIndex: 1 })
628
+
629
+ const geometry = { segments, endpoints }
630
+ regressionGeometryCache?.set(drawing.id, geometry)
631
+ return geometry
632
+ }
633
+
634
+ private getExtendMode(drawing: DrawingObject): 'none' | 'left' | 'right' | 'both' {
635
+ switch (drawing.kind) {
636
+ case 'ray':
637
+ return 'right'
638
+ case 'extended-line':
639
+ return 'both'
640
+ default:
641
+ return 'none'
642
+ }
643
+ }
644
+
645
+ // ============ 坐标转换 ============
646
+
647
+ private anchorToScreen(anchor: DrawingAnchor): { x: number; y: number } | null {
648
+ const viewport = this.chart.getViewport()
649
+ if (!viewport) return null
650
+
651
+ const opt = this.chart.getOption()
652
+ const dpr = this.chart.getCurrentDpr()
653
+ const { startXPx, unitPx } = getPhysicalKLineConfig(opt.kWidth, opt.kGap, dpr)
654
+ if (!Number.isFinite(anchor.index)) return null
655
+
656
+ const x = (startXPx + anchor.index * unitPx + (unitPx - 1) / 2) / dpr - viewport.scrollLeft
657
+
658
+ const paneRenderer = this.chart.getPaneRenderers().find((item) => item.getPane().id === 'main')
659
+ const pane = paneRenderer?.getPane()
660
+ if (!pane) return null
661
+
662
+ const y = pane.yAxis.priceToY(anchor.price)
663
+ return { x, y }
664
+ }
665
+
666
+ private screenToAnchor(
667
+ screenX: number,
668
+ screenY: number
669
+ ): DrawingAnchorInput | null {
670
+ const data = this.chart.getData()
671
+ const viewport = this.chart.getViewport()
672
+ if (!viewport || data.length === 0) return null
673
+
674
+ const logicalIndex = this.chart.getLogicalIndexAtX(screenX)
675
+ if (logicalIndex === null) return null
676
+
677
+ const paneRenderer = this.chart.getPaneRenderers().find((item) => item.getPane().id === 'main')
678
+ const pane = paneRenderer?.getPane()
679
+ if (!pane) return null
680
+
681
+ const timestamp = this.chart.getTimestampAtLogicalIndex(logicalIndex) ?? undefined
682
+
683
+ return {
684
+ index: logicalIndex,
685
+ time: timestamp ?? undefined,
686
+ price: pane.yAxis.yToPrice(screenY - pane.top),
687
+ }
688
+ }
689
+
690
+ // ============ 工具方法 ============
691
+
692
+ private setSelected(drawing: DrawingObject | null) {
693
+ const newId = drawing?.id ?? null
694
+ if (this.selectedDrawingId === newId) return
695
+ this.selectedDrawingId = newId
696
+ this.chart.setSelectedDrawingId(newId)
697
+ this.callbacks.onDrawingSelected?.(drawing)
698
+ }
699
+
700
+ private removePreview() {
701
+ if (!this.drawings.some((d) => d.id === this.previewDrawingId)) return
702
+ this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
703
+ this.chart.setDrawings(this.drawings)
704
+ }
705
+
706
+ private resolveAnchorFromPointer(
707
+ e: PointerEvent,
708
+ container: HTMLElement
709
+ ): DrawingAnchorInput | null {
710
+ const data = this.chart.getData()
711
+ const viewport = this.chart.getViewport()
712
+ if (!viewport || data.length === 0) return null
713
+
714
+ const rect = container.getBoundingClientRect()
715
+ const mouseX = e.clientX - rect.left
716
+ const mouseY = e.clientY - rect.top
717
+ if (mouseX < 0 || mouseY < 0 || mouseX > viewport.plotWidth || mouseY > viewport.plotHeight) {
718
+ return null
719
+ }
720
+
721
+ const paneRenderer = this.chart.getPaneRenderers().find((item) => {
722
+ const pane = item.getPane()
723
+ return pane.id === 'main' && mouseY >= pane.top && mouseY <= pane.top + pane.height
724
+ })
725
+ const pane = paneRenderer?.getPane()
726
+ if (!pane) return null
727
+
728
+ const logicalIndex = this.chart.getLogicalIndexAtX(mouseX)
729
+ if (logicalIndex === null) return null
730
+ const timestamp = this.chart.getTimestampAtLogicalIndex(logicalIndex) ?? undefined
731
+
732
+ return {
733
+ index: logicalIndex,
734
+ time: timestamp ?? undefined,
735
+ price: pane.yAxis.yToPrice(mouseY - pane.top),
736
+ }
737
+ }
738
+
739
+ private createSingleAnchorDrawing(anchor: DrawingAnchorInput) {
740
+ this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
741
+
742
+ const drawing: DrawingObject = {
743
+ id: `drawing-${Date.now()}`,
744
+ kind: this.getDrawingKind(this.activeTool),
745
+ paneId: 'main',
746
+ visible: true,
747
+ anchors: [{ id: `${Date.now()}-a`, index: anchor.index, time: anchor.time, price: anchor.price }],
748
+ params: {},
749
+ style: {
750
+ stroke: '#2962ff',
751
+ strokeWidth: 1,
752
+ strokeStyle: 'solid',
753
+ },
754
+ }
755
+
756
+ this.drawings = [...this.drawings, drawing]
757
+ this.chart.setDrawings(this.drawings)
758
+ this.callbacks.onDrawingCreated?.(drawing)
759
+ this.activeTool = 'cursor'
760
+ this.callbacks.onToolChange?.('cursor')
761
+ }
762
+
763
+ private createMultiAnchorDrawing(anchors: DrawingAnchorInput[]) {
764
+ this.drawings = this.drawings.filter((d) => d.id !== this.previewDrawingId)
765
+
766
+ const kind = this.getDrawingKind(this.activeTool)
767
+ const params: Record<string, unknown> = kind === 'regression-channel' ? { sigma: 2 } : {}
768
+
769
+ const normalizedAnchors = kind === 'flat-line' && anchors.length >= 3
770
+ ? [
771
+ anchors[0]!,
772
+ anchors[1]!,
773
+ {
774
+ index: anchors[1]!.index,
775
+ time: anchors[1]!.time,
776
+ price: anchors[2]!.price,
777
+ },
778
+ ]
779
+ : anchors
780
+
781
+ const isChannel = ['parallel-channel', 'regression-channel', 'flat-line', 'disjoint-channel'].includes(kind)
782
+
783
+ const drawing: DrawingObject = {
784
+ id: `drawing-${Date.now()}`,
785
+ kind,
786
+ paneId: 'main',
787
+ visible: true,
788
+ anchors: normalizedAnchors.map((a, i) => ({
789
+ id: `${Date.now()}-${String.fromCharCode(97 + i)}`,
790
+ index: a.index,
791
+ time: a.time,
792
+ price: a.price,
793
+ })),
794
+ params,
795
+ style: {
796
+ stroke: '#2962ff',
797
+ strokeWidth: 1,
798
+ strokeStyle: 'solid',
799
+ ...(isChannel ? { fillOpacity: 0.1 } : {}),
800
+ },
801
+ }
802
+
803
+ this.drawings = [...this.drawings, drawing]
804
+ this.chart.setDrawings(this.drawings)
805
+ this.callbacks.onDrawingCreated?.(drawing)
806
+ this.activeTool = 'cursor'
807
+ this.callbacks.onToolChange?.('cursor')
808
+ }
809
+
810
+ private getDrawingKind(toolId: DrawingToolId): DrawingKind {
811
+ switch (toolId) {
812
+ case 'cursor':
813
+ throw new Error('cursor is not a drawing kind')
814
+ case 'h-line':
815
+ return 'horizontal-line'
816
+ case 'h-ray':
817
+ return 'horizontal-ray'
818
+ case 'v-line':
819
+ return 'vertical-line'
820
+ case 'crosshair-line':
821
+ return 'cross-line'
822
+ default:
823
+ return toolId
824
+ }
825
+ }
826
+ }
827
+
828
+ function pointToSegmentDist(
829
+ px: number,
830
+ py: number,
831
+ a: { x: number; y: number },
832
+ b: { x: number; y: number }
833
+ ): number {
834
+ const dx = b.x - a.x
835
+ const dy = b.y - a.y
836
+ const lenSq = dx * dx + dy * dy
837
+ if (lenSq === 0) return Math.hypot(px - a.x, py - a.y)
838
+
839
+ let t = ((px - a.x) * dx + (py - a.y) * dy) / lenSq
840
+ t = Math.max(0, Math.min(1, t))
841
+ return Math.hypot(px - (a.x + t * dx), py - (a.y + t * dy))
842
+ }