@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { DrawingObject, DrawingStyle } from '../../plugin'
|
|
2
|
+
import type { DrawingChartAdapter } from '../../controllers/types'
|
|
3
|
+
|
|
4
|
+
const PREVIEW_ID = '__preview__'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drawing 数据管理层 —— 图元 CRUD、选中状态、预览管理。
|
|
8
|
+
*
|
|
9
|
+
* 所有变更操作会自动同步到 DrawingChartAdapter(触发渲染)。
|
|
10
|
+
* 不处理事件、不处理命中检测、不处理拖拽逻辑,只维护数据一致性。
|
|
11
|
+
*/
|
|
12
|
+
export class DrawingState {
|
|
13
|
+
private drawings: DrawingObject[] = []
|
|
14
|
+
private selectedDrawingId: string | null = null
|
|
15
|
+
|
|
16
|
+
constructor(private adapter: DrawingChartAdapter) {}
|
|
17
|
+
|
|
18
|
+
// ---- Read ----
|
|
19
|
+
|
|
20
|
+
/** 返回全部图元(含预览) */
|
|
21
|
+
getAll(): DrawingObject[] {
|
|
22
|
+
return this.drawings
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 返回非预览图元(用于命中检测) */
|
|
26
|
+
getNonPreview(): DrawingObject[] {
|
|
27
|
+
return this.drawings.filter((d) => d.id !== PREVIEW_ID)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 按 ID 查找图元 */
|
|
31
|
+
getById(id: string): DrawingObject | undefined {
|
|
32
|
+
return this.drawings.find((d) => d.id === id)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 是否有预览图元 */
|
|
36
|
+
hasPreview(): boolean {
|
|
37
|
+
return this.drawings.some((d) => d.id === PREVIEW_ID)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 返回当前选中图元 */
|
|
41
|
+
getSelected(): DrawingObject | null {
|
|
42
|
+
if (!this.selectedDrawingId) return null
|
|
43
|
+
return this.drawings.find((d) => d.id === this.selectedDrawingId) ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 返回当前选中图元的 ID */
|
|
47
|
+
getSelectedId(): string | null {
|
|
48
|
+
return this.selectedDrawingId
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Write ----
|
|
52
|
+
// 所有 write 方法都会调用 adapter.setDrawings() 触发渲染
|
|
53
|
+
|
|
54
|
+
/** 整体替换图元列表(会清理选中状态) */
|
|
55
|
+
setDrawings(drawings: DrawingObject[]): void {
|
|
56
|
+
this.drawings = drawings
|
|
57
|
+
this.adapter.setDrawings(drawings)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 替换图元列表,若选中项被移除则自动清除选中 */
|
|
61
|
+
replaceDrawings(drawings: DrawingObject[]): void {
|
|
62
|
+
this.drawings = drawings
|
|
63
|
+
if (this.selectedDrawingId && !this.drawings.some((d) => d.id === this.selectedDrawingId)) {
|
|
64
|
+
this.selectedDrawingId = null
|
|
65
|
+
}
|
|
66
|
+
this.adapter.setDrawings(this.drawings)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 添加或更新单个图元(id 相同则替换) */
|
|
70
|
+
addOrUpdate(drawing: DrawingObject): void {
|
|
71
|
+
const idx = this.drawings.findIndex((d) => d.id === drawing.id)
|
|
72
|
+
if (idx >= 0) {
|
|
73
|
+
this.drawings[idx] = drawing
|
|
74
|
+
} else {
|
|
75
|
+
this.drawings.push(drawing)
|
|
76
|
+
}
|
|
77
|
+
this.adapter.setDrawings(this.drawings)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 删除图元,若为选中项则清除选中 */
|
|
81
|
+
removeDrawing(drawingId: string): void {
|
|
82
|
+
this.drawings = this.drawings.filter((d) => d.id !== drawingId)
|
|
83
|
+
if (this.selectedDrawingId === drawingId) {
|
|
84
|
+
this.selectedDrawingId = null
|
|
85
|
+
}
|
|
86
|
+
this.adapter.setDrawings(this.drawings)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 更新图元样式(合并到已有样式) */
|
|
90
|
+
updateDrawingStyle(drawingId: string, style: Partial<DrawingStyle>): void {
|
|
91
|
+
this.drawings = this.drawings.map((d) =>
|
|
92
|
+
d.id === drawingId ? { ...d, style: { ...d.style, ...style } } : d
|
|
93
|
+
)
|
|
94
|
+
this.adapter.setDrawings(this.drawings)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 设置选中图元。
|
|
99
|
+
* 仅在选中 ID 确实变化时才触发 adapter 同步。
|
|
100
|
+
* 不触发 onDrawingSelected 回调 —— 由调用方(controller)管理。
|
|
101
|
+
*/
|
|
102
|
+
setSelected(drawing: DrawingObject | null): void {
|
|
103
|
+
const newId = drawing?.id ?? null
|
|
104
|
+
if (this.selectedDrawingId === newId) return
|
|
105
|
+
this.selectedDrawingId = newId
|
|
106
|
+
this.adapter.setSelectedDrawingId(newId)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 删除预览图元(__preview__) */
|
|
110
|
+
removePreview(): void {
|
|
111
|
+
if (!this.hasPreview()) return
|
|
112
|
+
this.drawings = this.drawings.filter((d) => d.id !== PREVIEW_ID)
|
|
113
|
+
this.adapter.setDrawings(this.drawings)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 设置预览图元(替换已有的 __preview__) */
|
|
117
|
+
setPreview(preview: DrawingObject): void {
|
|
118
|
+
this.drawings = this.drawings.filter((d) => d.id !== PREVIEW_ID)
|
|
119
|
+
this.drawings.push(preview)
|
|
120
|
+
this.adapter.setDrawings(this.drawings)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 清空所有图元并清除选中 */
|
|
124
|
+
clear(): void {
|
|
125
|
+
this.drawings = []
|
|
126
|
+
this.selectedDrawingId = null
|
|
127
|
+
this.adapter.setDrawings([])
|
|
128
|
+
this.adapter.setSelectedDrawingId(null)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { PREVIEW_ID }
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { DrawingObject } from '../../plugin'
|
|
2
|
+
import type { DrawingChartAdapter } from '../../controllers/types'
|
|
3
|
+
import { anchorToScreen, pointToSegmentDist } from './coordinateUtils'
|
|
4
|
+
import { getExtendMode } from './toolConfig'
|
|
5
|
+
import { computeLinearRegression } from './linearRegression'
|
|
6
|
+
|
|
7
|
+
// ---- Types ----
|
|
8
|
+
|
|
9
|
+
/** 命中检测结果:anchorIndex 存在表示点到锚点,否则点到线段 */
|
|
10
|
+
export type HitResult =
|
|
11
|
+
| { drawing: DrawingObject; anchorIndex: number }
|
|
12
|
+
| { drawing: DrawingObject }
|
|
13
|
+
|
|
14
|
+
/** 二维线段,两端点为屏幕坐标(px) */
|
|
15
|
+
export interface LineSegment {
|
|
16
|
+
a: { x: number; y: number }
|
|
17
|
+
b: { x: number; y: number }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 回归通道几何信息(屏幕坐标)。
|
|
22
|
+
* segments 为三条平行线(中/上/下),endpoints 标出可拖拽的端点。
|
|
23
|
+
*/
|
|
24
|
+
export interface RegressionChannelGeometry {
|
|
25
|
+
segments: LineSegment[]
|
|
26
|
+
endpoints: Array<{ point: { x: number; y: number }; anchorIndex: 0 | 1 }>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 锚点点击命中半径(px) */
|
|
30
|
+
const ANCHOR_HIT_RADIUS = 8
|
|
31
|
+
/** 线段点击命中半径(px) */
|
|
32
|
+
const LINE_HIT_RADIUS = 6
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Hit detection — test mouse position against drawing anchors and line segments.
|
|
36
|
+
* Pure computation, no side effects.
|
|
37
|
+
*/
|
|
38
|
+
export class HitTester {
|
|
39
|
+
/**
|
|
40
|
+
* Find the drawing (and optionally which anchor) under the given mouse position.
|
|
41
|
+
* Anchors are checked first, then line segments.
|
|
42
|
+
*/
|
|
43
|
+
hitTest(
|
|
44
|
+
mouseX: number,
|
|
45
|
+
mouseY: number,
|
|
46
|
+
drawings: DrawingObject[],
|
|
47
|
+
adapter: DrawingChartAdapter,
|
|
48
|
+
): HitResult | null {
|
|
49
|
+
const visibleDrawings = drawings.filter((d) => d.visible)
|
|
50
|
+
const regressionGeometryCache = new Map<string, RegressionChannelGeometry | null>()
|
|
51
|
+
|
|
52
|
+
// Check anchor hits first
|
|
53
|
+
for (const drawing of visibleDrawings) {
|
|
54
|
+
// regression-channel: computed endpoints are also draggable
|
|
55
|
+
if (drawing.kind === 'regression-channel' && drawing.anchors.length >= 2) {
|
|
56
|
+
const hit = this.hitTestRegressionEndpoints(drawing, mouseX, mouseY, adapter, regressionGeometryCache)
|
|
57
|
+
if (hit) return hit
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < drawing.anchors.length; i++) {
|
|
61
|
+
const screen = anchorToScreen(drawing.anchors[i]!, adapter)
|
|
62
|
+
if (!screen) continue
|
|
63
|
+
const dist = Math.hypot(mouseX - screen.x, mouseY - screen.y)
|
|
64
|
+
if (dist <= ANCHOR_HIT_RADIUS) {
|
|
65
|
+
return { drawing, anchorIndex: i }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check line segment hits
|
|
71
|
+
for (const drawing of visibleDrawings) {
|
|
72
|
+
const segments = this.getDrawingLineSegments(drawing, adapter, regressionGeometryCache)
|
|
73
|
+
for (const seg of segments) {
|
|
74
|
+
const dist = pointToSegmentDist(mouseX, mouseY, seg.a, seg.b)
|
|
75
|
+
if (dist <= LINE_HIT_RADIUS) {
|
|
76
|
+
return { drawing }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get the screen-space line segments for a drawing, used for hit-testing.
|
|
86
|
+
*/
|
|
87
|
+
getDrawingLineSegments(
|
|
88
|
+
drawing: DrawingObject,
|
|
89
|
+
adapter: DrawingChartAdapter,
|
|
90
|
+
regressionGeometryCache?: Map<string, RegressionChannelGeometry | null>,
|
|
91
|
+
): LineSegment[] {
|
|
92
|
+
const viewport = adapter.getViewport()
|
|
93
|
+
if (!viewport) return []
|
|
94
|
+
|
|
95
|
+
// regression-channel: compute from linear regression geometry
|
|
96
|
+
if (drawing.kind === 'regression-channel') {
|
|
97
|
+
return this.getRegressionChannelGeometry(drawing, adapter, regressionGeometryCache)?.segments ?? []
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Single-anchor drawings (horizontal-line, horizontal-ray, vertical-line, cross-line)
|
|
101
|
+
if (drawing.anchors.length === 1) {
|
|
102
|
+
const screen = anchorToScreen(drawing.anchors[0]!, adapter)
|
|
103
|
+
if (!screen) return []
|
|
104
|
+
|
|
105
|
+
const paneInfo = adapter.getPaneInfo('main')
|
|
106
|
+
if (!paneInfo) return []
|
|
107
|
+
|
|
108
|
+
const right = viewport.plotWidth
|
|
109
|
+
const bottom = paneInfo.height
|
|
110
|
+
|
|
111
|
+
switch (drawing.kind) {
|
|
112
|
+
case 'horizontal-line':
|
|
113
|
+
return [{ a: { x: 0, y: screen.y }, b: { x: right, y: screen.y } }]
|
|
114
|
+
case 'horizontal-ray':
|
|
115
|
+
return [{ a: screen, b: { x: right, y: screen.y } }]
|
|
116
|
+
case 'vertical-line':
|
|
117
|
+
return [{ a: { x: screen.x, y: 0 }, b: { x: screen.x, y: bottom } }]
|
|
118
|
+
case 'cross-line':
|
|
119
|
+
return [
|
|
120
|
+
{ a: { x: 0, y: screen.y }, b: { x: right, y: screen.y } },
|
|
121
|
+
{ a: { x: screen.x, y: 0 }, b: { x: screen.x, y: bottom } },
|
|
122
|
+
]
|
|
123
|
+
default:
|
|
124
|
+
return []
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Multi-anchor drawings (2+)
|
|
129
|
+
const points = drawing.anchors
|
|
130
|
+
.map((a) => anchorToScreen(a, adapter))
|
|
131
|
+
.filter(Boolean) as { x: number; y: number }[]
|
|
132
|
+
if (points.length < 2) return []
|
|
133
|
+
|
|
134
|
+
const segments: LineSegment[] = []
|
|
135
|
+
|
|
136
|
+
if (points.length === 2) {
|
|
137
|
+
const a = points[0]!
|
|
138
|
+
const b = points[1]!
|
|
139
|
+
const dx = b.x - a.x
|
|
140
|
+
const dy = b.y - a.y
|
|
141
|
+
|
|
142
|
+
let start = a
|
|
143
|
+
let end = b
|
|
144
|
+
|
|
145
|
+
const extend = getExtendMode(drawing.kind)
|
|
146
|
+
const maxLen = Math.max(viewport.plotWidth, viewport.plotHeight) * 4
|
|
147
|
+
|
|
148
|
+
if (extend === 'right' || extend === 'both') {
|
|
149
|
+
end = { x: b.x + dx * maxLen, y: b.y + dy * maxLen }
|
|
150
|
+
}
|
|
151
|
+
if (extend === 'left' || extend === 'both') {
|
|
152
|
+
start = { x: a.x - dx * maxLen, y: a.y - dy * maxLen }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
segments.push({ a: start, b: end })
|
|
156
|
+
} else if (points.length >= 3) {
|
|
157
|
+
switch (drawing.kind) {
|
|
158
|
+
case 'parallel-channel': {
|
|
159
|
+
const [p1, p2, p3] = points as [
|
|
160
|
+
{ x: number; y: number },
|
|
161
|
+
{ x: number; y: number },
|
|
162
|
+
{ x: number; y: number },
|
|
163
|
+
]
|
|
164
|
+
const dx = p2.x - p1.x
|
|
165
|
+
const dy = p2.y - p1.y
|
|
166
|
+
const p4 = { x: p3.x + dx, y: p3.y + dy }
|
|
167
|
+
segments.push({ a: p1, b: p2 }, { a: p3, b: p4 })
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
case 'flat-line': {
|
|
171
|
+
const [p1, p2, p3] = points as [
|
|
172
|
+
{ x: number; y: number },
|
|
173
|
+
{ x: number; y: number },
|
|
174
|
+
{ x: number; y: number },
|
|
175
|
+
]
|
|
176
|
+
const h1 = { x: p1.x, y: p3.y }
|
|
177
|
+
const h2 = { x: p2.x, y: p3.y }
|
|
178
|
+
segments.push({ a: p1, b: p2 }, { a: h1, b: h2 })
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
case 'disjoint-channel': {
|
|
182
|
+
const [p1, p2, p3] = points as [
|
|
183
|
+
{ x: number; y: number },
|
|
184
|
+
{ x: number; y: number },
|
|
185
|
+
{ x: number; y: number },
|
|
186
|
+
]
|
|
187
|
+
const dx = p2.x - p1.x
|
|
188
|
+
const dy = p2.y - p1.y
|
|
189
|
+
const p4 = { x: p3.x + dx, y: p3.y - dy }
|
|
190
|
+
segments.push({ a: p1, b: p2 }, { a: p3, b: p4 })
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
default:
|
|
194
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
195
|
+
segments.push({ a: points[i]!, b: points[i + 1]! })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return segments
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Compute the screen-space geometry of a regression channel.
|
|
205
|
+
*/
|
|
206
|
+
getRegressionChannelGeometry(
|
|
207
|
+
drawing: DrawingObject,
|
|
208
|
+
adapter: DrawingChartAdapter,
|
|
209
|
+
cache?: Map<string, RegressionChannelGeometry | null>,
|
|
210
|
+
): RegressionChannelGeometry | null {
|
|
211
|
+
const cached = cache?.get(drawing.id)
|
|
212
|
+
if (cached !== undefined) return cached
|
|
213
|
+
|
|
214
|
+
const data = adapter.getData()
|
|
215
|
+
if (data.length === 0 || drawing.anchors.length < 2) {
|
|
216
|
+
cache?.set(drawing.id, null)
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const firstIndex = Math.round(drawing.anchors[0]!.index)
|
|
221
|
+
const secondIndex = Math.round(drawing.anchors[1]!.index)
|
|
222
|
+
const clampedFirst = Math.min(Math.max(firstIndex, 0), data.length - 1)
|
|
223
|
+
const clampedSecond = Math.min(Math.max(secondIndex, 0), data.length - 1)
|
|
224
|
+
const startIndex = Math.min(clampedFirst, clampedSecond)
|
|
225
|
+
const endIndex = Math.max(clampedFirst, clampedSecond)
|
|
226
|
+
const slice = data.slice(startIndex, endIndex + 1)
|
|
227
|
+
const regression = computeLinearRegression(
|
|
228
|
+
slice.map((item: { close: number }) => item.close),
|
|
229
|
+
)
|
|
230
|
+
if (!regression) {
|
|
231
|
+
cache?.set(drawing.id, null)
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const sigma = ((drawing.params as { sigma?: number } | undefined)?.sigma) ?? 2
|
|
236
|
+
const offset = regression.stdDev * sigma
|
|
237
|
+
const firstValue = regression.intercept
|
|
238
|
+
const lastValue = regression.intercept + regression.slope * (slice.length - 1)
|
|
239
|
+
|
|
240
|
+
const middleStart = anchorToScreen({ id: '', index: firstIndex, price: firstValue }, adapter)
|
|
241
|
+
const middleEnd = anchorToScreen({ id: '', index: secondIndex, price: lastValue }, adapter)
|
|
242
|
+
const upperStart = anchorToScreen({ id: '', index: firstIndex, price: firstValue + offset }, adapter)
|
|
243
|
+
const upperEnd = anchorToScreen({ id: '', index: secondIndex, price: lastValue + offset }, adapter)
|
|
244
|
+
const lowerStart = anchorToScreen({ id: '', index: firstIndex, price: firstValue - offset }, adapter)
|
|
245
|
+
const lowerEnd = anchorToScreen({ id: '', index: secondIndex, price: lastValue - offset }, adapter)
|
|
246
|
+
|
|
247
|
+
const segments: LineSegment[] = []
|
|
248
|
+
if (middleStart && middleEnd) segments.push({ a: middleStart, b: middleEnd })
|
|
249
|
+
if (upperStart && upperEnd) segments.push({ a: upperStart, b: upperEnd })
|
|
250
|
+
if (lowerStart && lowerEnd) segments.push({ a: lowerStart, b: lowerEnd })
|
|
251
|
+
|
|
252
|
+
const endpoints: RegressionChannelGeometry['endpoints'] = []
|
|
253
|
+
if (middleStart) endpoints.push({ point: middleStart, anchorIndex: 0 })
|
|
254
|
+
if (middleEnd) endpoints.push({ point: middleEnd, anchorIndex: 1 })
|
|
255
|
+
if (upperStart) endpoints.push({ point: upperStart, anchorIndex: 0 })
|
|
256
|
+
if (upperEnd) endpoints.push({ point: upperEnd, anchorIndex: 1 })
|
|
257
|
+
if (lowerStart) endpoints.push({ point: lowerStart, anchorIndex: 0 })
|
|
258
|
+
if (lowerEnd) endpoints.push({ point: lowerEnd, anchorIndex: 1 })
|
|
259
|
+
|
|
260
|
+
const geometry: RegressionChannelGeometry = { segments, endpoints }
|
|
261
|
+
cache?.set(drawing.id, geometry)
|
|
262
|
+
return geometry
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* regression-channel only: check hit against computed regression endpoints
|
|
267
|
+
* (which may be far from stored anchor positions).
|
|
268
|
+
*/
|
|
269
|
+
private hitTestRegressionEndpoints(
|
|
270
|
+
drawing: DrawingObject,
|
|
271
|
+
mouseX: number,
|
|
272
|
+
mouseY: number,
|
|
273
|
+
adapter: DrawingChartAdapter,
|
|
274
|
+
cache?: Map<string, RegressionChannelGeometry | null>,
|
|
275
|
+
): { drawing: DrawingObject; anchorIndex: number } | null {
|
|
276
|
+
const geometry = this.getRegressionChannelGeometry(drawing, adapter, cache)
|
|
277
|
+
if (!geometry) return null
|
|
278
|
+
|
|
279
|
+
for (const endpoint of geometry.endpoints) {
|
|
280
|
+
const dist = Math.hypot(mouseX - endpoint.point.x, mouseY - endpoint.point.y)
|
|
281
|
+
if (dist <= ANCHOR_HIT_RADIUS) {
|
|
282
|
+
return { drawing, anchorIndex: endpoint.anchorIndex }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { DrawingObject } from '../../plugin'
|
|
2
|
+
import { PREVIEW_ID } from './DrawingState'
|
|
3
|
+
import type { DrawingToolId } from './toolConfig'
|
|
4
|
+
import { SINGLE_ANCHOR_TOOLS, DOUBLE_ANCHOR_TOOLS, TRIPLE_ANCHOR_TOOLS, getDrawingKind, CHANNEL_KINDS } from './toolConfig'
|
|
5
|
+
import type { DrawingAnchorInput } from './coordinateUtils'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Constructs preview DrawingObject instances for various tool types.
|
|
9
|
+
* Pure construction — no side effects, no adapter calls.
|
|
10
|
+
*/
|
|
11
|
+
export class PreviewRenderer {
|
|
12
|
+
/**
|
|
13
|
+
* Build a preview drawing from the current tool state and pointer anchor.
|
|
14
|
+
* @returns a preview DrawingObject, or null if the state is insufficient for a preview.
|
|
15
|
+
*/
|
|
16
|
+
buildPreview(
|
|
17
|
+
activeTool: DrawingToolId,
|
|
18
|
+
pendingAnchors: DrawingAnchorInput[],
|
|
19
|
+
currentAnchor: DrawingAnchorInput,
|
|
20
|
+
): DrawingObject | null {
|
|
21
|
+
const isSingle = SINGLE_ANCHOR_TOOLS.includes(activeTool as any)
|
|
22
|
+
const isDouble = DOUBLE_ANCHOR_TOOLS.includes(activeTool as any)
|
|
23
|
+
const isTriple = TRIPLE_ANCHOR_TOOLS.includes(activeTool as any)
|
|
24
|
+
|
|
25
|
+
if (!isSingle && !isDouble && !isTriple) return null
|
|
26
|
+
|
|
27
|
+
if (isSingle) {
|
|
28
|
+
return this.buildSingleAnchorPreview(activeTool, currentAnchor)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (isDouble) {
|
|
32
|
+
if (pendingAnchors.length < 1) return null
|
|
33
|
+
return this.buildDoubleAnchorPreview(activeTool, pendingAnchors[0]!, currentAnchor)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Triple anchor tools
|
|
37
|
+
return this.buildTripleAnchorPreview(activeTool, pendingAnchors, currentAnchor)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 单锚点工具预览:虚线样式 */
|
|
41
|
+
private buildSingleAnchorPreview(
|
|
42
|
+
activeTool: DrawingToolId,
|
|
43
|
+
anchor: DrawingAnchorInput,
|
|
44
|
+
): DrawingObject {
|
|
45
|
+
return {
|
|
46
|
+
id: PREVIEW_ID,
|
|
47
|
+
kind: getDrawingKind(activeTool),
|
|
48
|
+
paneId: 'main',
|
|
49
|
+
visible: true,
|
|
50
|
+
anchors: [
|
|
51
|
+
{ id: `${PREVIEW_ID}-a`, index: anchor.index, time: anchor.time, price: anchor.price },
|
|
52
|
+
],
|
|
53
|
+
params: {},
|
|
54
|
+
style: {
|
|
55
|
+
stroke: '#2962ff',
|
|
56
|
+
strokeWidth: 1,
|
|
57
|
+
strokeStyle: 'dashed',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 双锚点工具预览:两个锚点之间的虚线,回归通道附带填充区域 */
|
|
63
|
+
private buildDoubleAnchorPreview(
|
|
64
|
+
activeTool: DrawingToolId,
|
|
65
|
+
first: DrawingAnchorInput,
|
|
66
|
+
second: DrawingAnchorInput,
|
|
67
|
+
): DrawingObject {
|
|
68
|
+
return {
|
|
69
|
+
id: PREVIEW_ID,
|
|
70
|
+
kind: getDrawingKind(activeTool),
|
|
71
|
+
paneId: 'main',
|
|
72
|
+
visible: true,
|
|
73
|
+
anchors: [
|
|
74
|
+
{ id: `${PREVIEW_ID}-a`, index: first.index, time: first.time, price: first.price },
|
|
75
|
+
{ id: `${PREVIEW_ID}-b`, index: second.index, time: second.time, price: second.price },
|
|
76
|
+
],
|
|
77
|
+
params: activeTool === 'regression-channel' ? { sigma: 2 } : {},
|
|
78
|
+
style: {
|
|
79
|
+
stroke: '#2962ff',
|
|
80
|
+
strokeWidth: 1,
|
|
81
|
+
strokeStyle: 'dashed',
|
|
82
|
+
...(activeTool === 'regression-channel' ? { fillOpacity: 0.1 } : {}),
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 三锚点工具预览:
|
|
89
|
+
* - pending 数 = 0 → 无法预览,返回 null
|
|
90
|
+
* - pending 数 = 1 → 暂以趋势线(双锚点)形式显示前两个点
|
|
91
|
+
* - pending 数 ≥ 2 → 完整三锚点预览(含 flat-line 的特殊 price 处理)
|
|
92
|
+
*/
|
|
93
|
+
private buildTripleAnchorPreview(
|
|
94
|
+
activeTool: DrawingToolId,
|
|
95
|
+
pendingAnchors: DrawingAnchorInput[],
|
|
96
|
+
currentAnchor: DrawingAnchorInput,
|
|
97
|
+
): DrawingObject | null {
|
|
98
|
+
if (pendingAnchors.length === 0) return null
|
|
99
|
+
|
|
100
|
+
if (pendingAnchors.length === 1) {
|
|
101
|
+
// Need 3 anchors but only have 1 pending — render as a trend-line segment (2 anchors)
|
|
102
|
+
// so the user can see what they're drawing before placing the 3rd point
|
|
103
|
+
return {
|
|
104
|
+
id: PREVIEW_ID,
|
|
105
|
+
kind: 'trend-line',
|
|
106
|
+
paneId: 'main',
|
|
107
|
+
visible: true,
|
|
108
|
+
anchors: [
|
|
109
|
+
{ id: `${PREVIEW_ID}-a`, index: pendingAnchors[0]!.index, time: pendingAnchors[0]!.time, price: pendingAnchors[0]!.price },
|
|
110
|
+
{ id: `${PREVIEW_ID}-b`, index: currentAnchor.index, time: currentAnchor.time, price: currentAnchor.price },
|
|
111
|
+
],
|
|
112
|
+
params: {},
|
|
113
|
+
style: {
|
|
114
|
+
stroke: '#2962ff',
|
|
115
|
+
strokeWidth: 1,
|
|
116
|
+
strokeStyle: 'dashed',
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// pendingAnchors.length >= 2 — full 3-anchor preview
|
|
122
|
+
const thirdAnchor = activeTool === 'flat-line'
|
|
123
|
+
? {
|
|
124
|
+
id: `${PREVIEW_ID}-c`,
|
|
125
|
+
index: pendingAnchors[1]!.index,
|
|
126
|
+
time: pendingAnchors[1]!.time,
|
|
127
|
+
price: currentAnchor.price,
|
|
128
|
+
}
|
|
129
|
+
: {
|
|
130
|
+
id: `${PREVIEW_ID}-c`,
|
|
131
|
+
index: currentAnchor.index,
|
|
132
|
+
time: currentAnchor.time,
|
|
133
|
+
price: currentAnchor.price,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const isChannel = CHANNEL_KINDS.includes(getDrawingKind(activeTool) as any)
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
id: PREVIEW_ID,
|
|
140
|
+
kind: getDrawingKind(activeTool),
|
|
141
|
+
paneId: 'main',
|
|
142
|
+
visible: true,
|
|
143
|
+
anchors: [
|
|
144
|
+
{ id: `${PREVIEW_ID}-a`, index: pendingAnchors[0]!.index, time: pendingAnchors[0]!.time, price: pendingAnchors[0]!.price },
|
|
145
|
+
{ id: `${PREVIEW_ID}-b`, index: pendingAnchors[1]!.index, time: pendingAnchors[1]!.time, price: pendingAnchors[1]!.price },
|
|
146
|
+
thirdAnchor,
|
|
147
|
+
],
|
|
148
|
+
params: {},
|
|
149
|
+
style: {
|
|
150
|
+
stroke: '#2962ff',
|
|
151
|
+
strokeWidth: 1,
|
|
152
|
+
strokeStyle: 'dashed',
|
|
153
|
+
...(isChannel ? { fillOpacity: 0.1 } : {}),
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|