@363045841yyt/klinechart-core 0.7.3 → 0.7.5-alpha.2
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/README.md +201 -201
- package/README.zh-CN.md +201 -201
- package/dist/engine/renderers/webgl/candleSurface.js +47 -47
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -2
- package/dist/version.js.map +1 -1
- package/package.json +129 -122
- package/src/__tests__/signal.test.ts +124 -124
- package/src/config/chartSettings.ts +66 -66
- package/src/controllers/__tests__/drawing.test.ts +214 -214
- package/src/controllers/__tests__/indicatorSelector.test.ts +481 -481
- package/src/controllers/__tests__/toolbar.test.ts +225 -225
- package/src/controllers/createChartController.ts +665 -665
- package/src/controllers/createDrawingController.ts +96 -96
- package/src/controllers/createIndicatorSelectorController.ts +307 -307
- package/src/controllers/createToolbarController.ts +146 -146
- package/src/controllers/index.ts +19 -19
- package/src/controllers/types.ts +284 -284
- package/src/engine/__tests__/chart.dpr.test.ts +401 -401
- package/src/engine/__tests__/paneRenderer.resize.test.ts +92 -92
- package/src/engine/chart-store.ts +121 -121
- package/src/engine/chart.d.ts +617 -617
- package/src/engine/chart.ts +2815 -2815
- package/src/engine/controller/__tests__/interaction.dpr.test.ts +259 -259
- package/src/engine/controller/interaction.ts +722 -722
- package/src/engine/controller/markerInteraction.ts +130 -130
- package/src/engine/controller/pinchTracker.ts +82 -82
- package/src/engine/controller/tooltipPosition.ts +48 -48
- package/src/engine/draw/__tests__/pixelAlign.spec.ts +176 -176
- package/src/engine/draw/pixelAlign.ts +259 -259
- package/src/engine/drawing/index.ts +655 -655
- package/src/engine/drawing/interaction.ts +842 -842
- package/src/engine/drawing/plugin.ts +343 -343
- package/src/engine/indicators/__tests__/__fixtures__/golden/atr.json +38 -38
- package/src/engine/indicators/__tests__/__fixtures__/golden/dema.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/hma.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/index.ts +55 -55
- package/src/engine/indicators/__tests__/__fixtures__/golden/kama.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/tema.json +14 -14
- package/src/engine/indicators/__tests__/__fixtures__/golden/wma.json +40 -40
- package/src/engine/indicators/__tests__/__fixtures__/synthetic.ts +65 -65
- package/src/engine/indicators/__tests__/_propertyAssertions.ts +76 -76
- package/src/engine/indicators/__tests__/atr.test.ts +153 -153
- package/src/engine/indicators/__tests__/calculators.test.ts +614 -614
- package/src/engine/indicators/__tests__/cmf-mfi.test.ts +100 -100
- package/src/engine/indicators/__tests__/dema.test.ts +73 -73
- package/src/engine/indicators/__tests__/donchian.test.ts +70 -70
- package/src/engine/indicators/__tests__/hma.test.ts +73 -73
- package/src/engine/indicators/__tests__/ichimoku.test.ts +105 -105
- package/src/engine/indicators/__tests__/kama.test.ts +80 -80
- package/src/engine/indicators/__tests__/keltner.test.ts +65 -65
- package/src/engine/indicators/__tests__/pivot-fib.test.ts +110 -110
- package/src/engine/indicators/__tests__/roc.test.ts +68 -68
- package/src/engine/indicators/__tests__/sar.test.ts +86 -86
- package/src/engine/indicators/__tests__/scheduler.test.ts +831 -831
- package/src/engine/indicators/__tests__/soa.test.ts +533 -533
- package/src/engine/indicators/__tests__/structure.test.ts +110 -110
- package/src/engine/indicators/__tests__/supertrend.test.ts +65 -65
- package/src/engine/indicators/__tests__/tema.test.ts +68 -68
- package/src/engine/indicators/__tests__/trix.test.ts +70 -70
- package/src/engine/indicators/__tests__/volatility.test.ts +117 -117
- package/src/engine/indicators/__tests__/volume.test.ts +115 -115
- package/src/engine/indicators/__tests__/volumeProfile.test.ts +74 -74
- package/src/engine/indicators/__tests__/vwap.test.ts +69 -69
- package/src/engine/indicators/__tests__/wma.test.ts +112 -112
- package/src/engine/indicators/__tests__/zones.test.ts +95 -95
- package/src/engine/indicators/atrState.ts +27 -27
- package/src/engine/indicators/bollState.ts +51 -51
- package/src/engine/indicators/calculators.ts +2593 -2593
- package/src/engine/indicators/cciState.ts +25 -25
- package/src/engine/indicators/chaikinVolState.ts +32 -32
- package/src/engine/indicators/cmfState.ts +27 -27
- package/src/engine/indicators/demaState.ts +27 -27
- package/src/engine/indicators/donchianState.ts +43 -43
- package/src/engine/indicators/eneState.ts +43 -43
- package/src/engine/indicators/expmaState.ts +43 -43
- package/src/engine/indicators/fastkState.ts +25 -25
- package/src/engine/indicators/fibState.ts +41 -41
- package/src/engine/indicators/hmaState.ts +27 -27
- package/src/engine/indicators/hvState.ts +28 -28
- package/src/engine/indicators/ichimokuState.ts +70 -70
- package/src/engine/indicators/indicator.worker.ts +169 -169
- package/src/engine/indicators/indicatorDefinitionRegistry.ts +62 -62
- package/src/engine/indicators/indicatorMetadata.ts +110 -110
- package/src/engine/indicators/indicatorRegistry.ts +106 -106
- package/src/engine/indicators/indicatorRuntime.ts +1548 -1548
- package/src/engine/indicators/kamaState.ts +34 -34
- package/src/engine/indicators/keltnerState.ts +49 -49
- package/src/engine/indicators/kstState.ts +42 -42
- package/src/engine/indicators/maState.ts +36 -36
- package/src/engine/indicators/macdState.ts +76 -76
- package/src/engine/indicators/mfiState.ts +27 -27
- package/src/engine/indicators/momState.ts +25 -25
- package/src/engine/indicators/obvState.ts +25 -25
- package/src/engine/indicators/parkinsonState.ts +28 -28
- package/src/engine/indicators/pivotState.ts +51 -51
- package/src/engine/indicators/pvtState.ts +25 -25
- package/src/engine/indicators/rocState.ts +27 -27
- package/src/engine/indicators/rsiState.ts +65 -65
- package/src/engine/indicators/sarState.ts +41 -41
- package/src/engine/indicators/scheduler.ts +1205 -1205
- package/src/engine/indicators/soa.ts +352 -352
- package/src/engine/indicators/stateComposer.ts +1262 -1262
- package/src/engine/indicators/stochState.ts +26 -26
- package/src/engine/indicators/structureState.ts +69 -69
- package/src/engine/indicators/supertrendState.ts +37 -37
- package/src/engine/indicators/temaState.ts +27 -27
- package/src/engine/indicators/trixState.ts +35 -35
- package/src/engine/indicators/vmaState.ts +27 -27
- package/src/engine/indicators/volumeProfileState.ts +63 -63
- package/src/engine/indicators/vwapState.ts +29 -29
- package/src/engine/indicators/wmaState.ts +27 -27
- package/src/engine/indicators/wmsrState.ts +25 -25
- package/src/engine/indicators/workerProtocol.ts +613 -613
- package/src/engine/indicators/zonesState.ts +47 -47
- package/src/engine/layout/pane.ts +161 -161
- package/src/engine/marker/registry.ts +265 -265
- package/src/engine/paneRenderer.ts +169 -169
- package/src/engine/renderers/Indicator/atr.ts +237 -237
- package/src/engine/renderers/Indicator/boll.ts +317 -317
- package/src/engine/renderers/Indicator/cci.ts +275 -275
- package/src/engine/renderers/Indicator/chaikinVol.ts +138 -138
- package/src/engine/renderers/Indicator/cmf.ts +137 -137
- package/src/engine/renderers/Indicator/dema.ts +136 -136
- package/src/engine/renderers/Indicator/donchian.ts +137 -137
- package/src/engine/renderers/Indicator/ene.ts +271 -271
- package/src/engine/renderers/Indicator/expma.ts +197 -197
- package/src/engine/renderers/Indicator/fastk.ts +316 -316
- package/src/engine/renderers/Indicator/fib.ts +141 -141
- package/src/engine/renderers/Indicator/hma.ts +136 -136
- package/src/engine/renderers/Indicator/hv.ts +124 -124
- package/src/engine/renderers/Indicator/ichimoku.ts +181 -181
- package/src/engine/renderers/Indicator/index.ts +241 -241
- package/src/engine/renderers/Indicator/indicatorData.ts +650 -650
- package/src/engine/renderers/Indicator/kama.ts +136 -136
- package/src/engine/renderers/Indicator/keltner.ts +137 -137
- package/src/engine/renderers/Indicator/kst.ts +302 -302
- package/src/engine/renderers/Indicator/ma.ts +200 -200
- package/src/engine/renderers/Indicator/macd.ts +477 -477
- package/src/engine/renderers/Indicator/macdLegend.ts +141 -141
- package/src/engine/renderers/Indicator/mainIndicatorLegend.ts +272 -272
- package/src/engine/renderers/Indicator/mfi.ts +142 -142
- package/src/engine/renderers/Indicator/mom.ts +311 -311
- package/src/engine/renderers/Indicator/obv.ts +123 -123
- package/src/engine/renderers/Indicator/parkinson.ts +124 -124
- package/src/engine/renderers/Indicator/pivot.ts +131 -131
- package/src/engine/renderers/Indicator/pvt.ts +123 -123
- package/src/engine/renderers/Indicator/roc.ts +143 -143
- package/src/engine/renderers/Indicator/rsi.ts +390 -390
- package/src/engine/renderers/Indicator/sar.ts +113 -113
- package/src/engine/renderers/Indicator/scale/atr_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/cci_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/fastk_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/indicator_scale.ts +204 -204
- package/src/engine/renderers/Indicator/scale/kst_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/macd_scale.ts +22 -22
- package/src/engine/renderers/Indicator/scale/mom_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/rsi_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/stoch_scale.ts +19 -19
- package/src/engine/renderers/Indicator/scale/volume_scale.ts +26 -26
- package/src/engine/renderers/Indicator/scale/wmsr_scale.ts +19 -19
- package/src/engine/renderers/Indicator/stoch.ts +359 -359
- package/src/engine/renderers/Indicator/structure.ts +126 -126
- package/src/engine/renderers/Indicator/subPaneConfig.ts +265 -265
- package/src/engine/renderers/Indicator/supertrend.ts +115 -115
- package/src/engine/renderers/Indicator/tema.ts +136 -136
- package/src/engine/renderers/Indicator/trix.ts +158 -158
- package/src/engine/renderers/Indicator/vma.ts +124 -124
- package/src/engine/renderers/Indicator/volumeProfile.ts +125 -125
- package/src/engine/renderers/Indicator/vwap.ts +123 -123
- package/src/engine/renderers/Indicator/wma.ts +136 -136
- package/src/engine/renderers/Indicator/wmsr.ts +328 -328
- package/src/engine/renderers/Indicator/zones.ts +104 -104
- package/src/engine/renderers/__tests__/boll.renderer.test.ts +314 -314
- package/src/engine/renderers/__tests__/ene.renderer.test.ts +305 -305
- package/src/engine/renderers/__tests__/expma.renderer.test.ts +279 -279
- package/src/engine/renderers/__tests__/ma.renderer.test.ts +426 -426
- package/src/engine/renderers/__tests__/mainIndicatorLegend.renderer.test.ts +502 -502
- package/src/engine/renderers/__tests__/yAxis.renderer.test.ts +173 -173
- package/src/engine/renderers/candle.ts +459 -459
- package/src/engine/renderers/crosshair.ts +69 -69
- package/src/engine/renderers/customMarkers.ts +162 -162
- package/src/engine/renderers/extremaMarkers.ts +246 -246
- package/src/engine/renderers/gridLines.ts +90 -90
- package/src/engine/renderers/lastPrice.ts +97 -97
- package/src/engine/renderers/paneTitle.ts +136 -136
- package/src/engine/renderers/subVolume.ts +236 -236
- package/src/engine/renderers/timeAxis.ts +121 -121
- package/src/engine/renderers/webgl/candleSurface.ts +955 -955
- package/src/engine/renderers/webgl/sharedWebGLSurface.ts +146 -146
- package/src/engine/renderers/yAxis.ts +105 -105
- package/src/engine/scale/__tests__/logFormula.spec.ts +148 -148
- package/src/engine/scale/logFormula.ts +130 -130
- package/src/engine/scale/price.ts +39 -39
- package/src/engine/scale/priceScale.ts +264 -264
- package/src/engine/subPaneManager.ts +427 -427
- package/src/engine/theme/colors.ts +642 -642
- package/src/engine/theme/fonts.ts +20 -20
- package/src/engine/utils/klineConfig.ts +49 -49
- package/src/engine/utils/tickCount.ts +11 -11
- package/src/engine/utils/tickPosition.ts +214 -214
- package/src/engine/utils/zoom.ts +83 -83
- package/src/engine/viewport/viewport.ts +67 -67
- package/src/index.ts +3 -3
- package/src/plugin/ConfigManager.ts +93 -93
- package/src/plugin/EventBus.ts +77 -77
- package/src/plugin/HookSystem.ts +106 -106
- package/src/plugin/PluginHost.ts +243 -243
- package/src/plugin/PluginRegistry.ts +92 -92
- package/src/plugin/StateStore.ts +73 -73
- package/src/plugin/index.ts +19 -19
- package/src/plugin/rendererPluginManager.ts +368 -368
- package/src/plugin/stateKeys.ts +8 -8
- package/src/plugin/types.ts +526 -526
- package/src/reactivity/index.ts +2 -2
- package/src/reactivity/signal.ts +119 -119
- package/src/semantic/controller.ts +251 -251
- package/src/semantic/drawShape.ts +260 -260
- package/src/semantic/index.ts +28 -28
- package/src/semantic/schema.json +256 -256
- package/src/semantic/types.ts +251 -251
- package/src/semantic/validator.ts +349 -349
- package/src/types/kLine.ts +13 -13
- package/src/types/price.ts +56 -56
- package/src/types/volumePrice.ts +33 -33
- package/src/utils/dateFormat.ts +208 -208
- package/src/utils/kLineDraw/axis.ts +562 -562
- package/src/utils/priceToY.ts +34 -34
- package/src/utils/volumePrice.ts +202 -202
- 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
|
+
}
|