@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.
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.d.ts.map +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/data-fetchers/dataBuffer.d.ts +0 -1
- package/dist/data-fetchers/dataBuffer.d.ts.map +1 -1
- package/dist/data-fetchers/dataBuffer.effects.d.ts +21 -0
- package/dist/data-fetchers/dataBuffer.effects.d.ts.map +1 -0
- package/dist/data-fetchers/dataBuffer.effects.js +55 -0
- package/dist/data-fetchers/dataBuffer.effects.js.map +1 -0
- package/dist/data-fetchers/dataBuffer.js +58 -93
- package/dist/data-fetchers/dataBuffer.js.map +1 -1
- package/dist/data-fetchers/index.d.ts +1 -0
- package/dist/data-fetchers/index.d.ts.map +1 -1
- package/dist/data-fetchers/index.js +1 -0
- package/dist/data-fetchers/index.js.map +1 -1
- package/dist/data-fetchers/timeShareBuffer.d.ts +2 -1
- package/dist/data-fetchers/timeShareBuffer.d.ts.map +1 -1
- package/dist/data-fetchers/timeShareBuffer.js +36 -14
- package/dist/data-fetchers/timeShareBuffer.js.map +1 -1
- package/dist/engine/data/chartDataManager.d.ts.map +1 -1
- package/dist/engine/data/chartDataManager.js +2 -1
- package/dist/engine/data/chartDataManager.js.map +1 -1
- package/dist/engine/drawing/AnchorCollector.d.ts +26 -0
- package/dist/engine/drawing/AnchorCollector.d.ts.map +1 -0
- package/dist/engine/drawing/AnchorCollector.js +47 -0
- package/dist/engine/drawing/AnchorCollector.js.map +1 -0
- package/dist/engine/drawing/DragHandler.d.ts +38 -0
- package/dist/engine/drawing/DragHandler.d.ts.map +1 -0
- package/dist/engine/drawing/DragHandler.js +92 -0
- package/dist/engine/drawing/DragHandler.js.map +1 -0
- package/dist/engine/drawing/DrawingState.d.ts +51 -0
- package/dist/engine/drawing/DrawingState.d.ts.map +1 -0
- package/dist/engine/drawing/DrawingState.js +115 -0
- package/dist/engine/drawing/DrawingState.js.map +1 -0
- package/dist/engine/drawing/HitTester.d.ts +59 -0
- package/dist/engine/drawing/HitTester.d.ts.map +1 -0
- package/dist/engine/drawing/HitTester.js +219 -0
- package/dist/engine/drawing/HitTester.js.map +1 -0
- package/dist/engine/drawing/PreviewRenderer.d.ts +26 -0
- package/dist/engine/drawing/PreviewRenderer.d.ts.map +1 -0
- package/dist/engine/drawing/PreviewRenderer.js +131 -0
- package/dist/engine/drawing/PreviewRenderer.js.map +1 -0
- package/dist/engine/drawing/coordinateUtils.d.ts +57 -0
- package/dist/engine/drawing/coordinateUtils.d.ts.map +1 -0
- package/dist/engine/drawing/coordinateUtils.js +103 -0
- package/dist/engine/drawing/coordinateUtils.js.map +1 -0
- package/dist/engine/drawing/index.d.ts.map +1 -1
- package/dist/engine/drawing/index.js +11 -3
- package/dist/engine/drawing/index.js.map +1 -1
- package/dist/engine/drawing/interaction.d.ts +44 -40
- package/dist/engine/drawing/interaction.d.ts.map +1 -1
- package/dist/engine/drawing/interaction.js +132 -571
- package/dist/engine/drawing/interaction.js.map +1 -1
- package/dist/engine/drawing/toolConfig.d.ts +24 -0
- package/dist/engine/drawing/toolConfig.d.ts.map +1 -0
- package/dist/engine/drawing/toolConfig.js +76 -0
- package/dist/engine/drawing/toolConfig.js.map +1 -0
- package/dist/plugin/types.d.ts +1 -0
- package/dist/plugin/types.d.ts.map +1 -1
- package/dist/plugin/types.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +4 -1
- package/src/controllers/index.ts +1 -0
- package/src/data-fetchers/__tests__/dataBuffer.test.ts +1 -3
- package/src/data-fetchers/dataBuffer.effects.ts +118 -0
- package/src/data-fetchers/dataBuffer.ts +45 -86
- package/src/data-fetchers/index.ts +7 -0
- package/src/data-fetchers/timeShareBuffer.ts +58 -19
- package/src/engine/__tests__/paneRenderer.resize.test.ts +3 -0
- package/src/engine/__tests__/subPaneManager.test.ts +13 -3
- package/src/engine/data/chartDataManager.ts +2 -1
- package/src/engine/drawing/AnchorCollector.ts +57 -0
- package/src/engine/drawing/DragHandler.ts +121 -0
- package/src/engine/drawing/DrawingState.ts +132 -0
- package/src/engine/drawing/HitTester.ts +288 -0
- package/src/engine/drawing/PreviewRenderer.ts +157 -0
- package/src/engine/drawing/coordinateUtils.ts +139 -0
- package/src/engine/drawing/index.ts +10 -3
- package/src/engine/drawing/interaction.ts +177 -687
- package/src/engine/drawing/toolConfig.ts +103 -0
- package/src/engine/indicators/__tests__/chartIndicatorManager.test.ts +1 -0
- package/src/engine/indicators/__tests__/stateComposer.test.ts +5 -4
- package/src/engine/renderers/Indicator/__tests__/createSubIndicatorRenderer.test.ts +1 -0
- package/src/plugin/types.ts +1 -0
- package/src/tokens/__tests__/tokens.test.ts +2 -1
- package/src/version.ts +1 -1
|
@@ -1,27 +1,19 @@
|
|
|
1
|
-
import type { DrawingObject, DrawingKind,
|
|
1
|
+
import type { DrawingObject, DrawingKind, DrawingStyle } from '../../plugin'
|
|
2
2
|
import type { DrawingChartAdapter } from '../../controllers/types'
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
*
|
|
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
|
|
61
|
-
private drawings: DrawingObject[] = []
|
|
38
|
+
private adapter: DrawingChartAdapter
|
|
62
39
|
private callbacks: DrawingInteractionCallbacks = {}
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
private
|
|
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.
|
|
105
|
-
this.removePreview()
|
|
106
|
-
this.
|
|
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.
|
|
87
|
+
return this.drawingState.getAll()
|
|
113
88
|
}
|
|
114
89
|
|
|
90
|
+
/** 整体替换图元列表 */
|
|
115
91
|
setDrawings(drawings: DrawingObject[]) {
|
|
116
|
-
this.drawings
|
|
117
|
-
this.adapter.setDrawings(drawings)
|
|
92
|
+
this.drawingState.setDrawings(drawings)
|
|
118
93
|
}
|
|
119
94
|
|
|
95
|
+
/** 清空锚点累积、预览、拖拽状态及所有图元 */
|
|
120
96
|
clear() {
|
|
121
|
-
this.
|
|
122
|
-
this.removePreview()
|
|
123
|
-
this.
|
|
124
|
-
this.
|
|
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.
|
|
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.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
*
|
|
123
|
+
* 指针移动事件入口。
|
|
124
|
+
* 拖拽中 → 委托 DragHandler 更新锚点;绘图模式 → 构建预览图元。
|
|
125
|
+
* @returns true 表示事件已消费,需要重绘
|
|
150
126
|
*/
|
|
151
127
|
onPointerMove(e: PointerEvent, container: HTMLElement): boolean {
|
|
152
|
-
//
|
|
153
|
-
if (this.
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
179
|
+
// 绘图模式
|
|
180
|
+
const anchor = resolveAnchorFromPointer(e, container, this.adapter)
|
|
176
181
|
if (!anchor) return false
|
|
177
182
|
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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.
|
|
205
|
-
this.
|
|
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.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
246
|
+
this.drawingState.removePreview()
|
|
732
247
|
|
|
733
248
|
const drawing: DrawingObject = {
|
|
734
249
|
id: `drawing-${Date.now()}`,
|
|
735
|
-
kind:
|
|
250
|
+
kind: getDrawingKind(this.activeTool),
|
|
736
251
|
paneId: 'main',
|
|
737
252
|
visible: true,
|
|
738
|
-
anchors: [
|
|
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.
|
|
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.
|
|
756
|
-
|
|
757
|
-
const kind =
|
|
758
|
-
const params: Record<string, unknown> =
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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 =
|
|
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.
|
|
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
|
+
}
|