@forgecharts/sdk 1.1.23
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/package.json +50 -0
- package/src/__tests__/backwardCompatibility.test.ts +191 -0
- package/src/__tests__/candleInvariant.test.ts +500 -0
- package/src/__tests__/public-api-surface.ts +76 -0
- package/src/__tests__/timeframeBoundary.test.ts +583 -0
- package/src/api/DrawingManager.ts +188 -0
- package/src/api/EventBus.ts +53 -0
- package/src/api/IndicatorDAG.ts +389 -0
- package/src/api/IndicatorRegistry.ts +47 -0
- package/src/api/LayoutManager.ts +72 -0
- package/src/api/PaneManager.ts +129 -0
- package/src/api/ReferenceAPI.ts +195 -0
- package/src/api/TChart.ts +881 -0
- package/src/api/createChart.ts +43 -0
- package/src/api/drawing tools/fib gann menu/fibRetracement.ts +27 -0
- package/src/api/drawing tools/lines menu/crossLine.ts +21 -0
- package/src/api/drawing tools/lines menu/disjointChannel.ts +74 -0
- package/src/api/drawing tools/lines menu/extendedLine.ts +22 -0
- package/src/api/drawing tools/lines menu/flatTopBottom.ts +45 -0
- package/src/api/drawing tools/lines menu/horizontal.ts +24 -0
- package/src/api/drawing tools/lines menu/horizontalRay.ts +25 -0
- package/src/api/drawing tools/lines menu/infoLine.ts +127 -0
- package/src/api/drawing tools/lines menu/insidePitchfork.ts +21 -0
- package/src/api/drawing tools/lines menu/modifiedSchiffPitchfork.ts +18 -0
- package/src/api/drawing tools/lines menu/parallelChannel.ts +47 -0
- package/src/api/drawing tools/lines menu/pitchfork.ts +15 -0
- package/src/api/drawing tools/lines menu/ray.ts +28 -0
- package/src/api/drawing tools/lines menu/regressionTrend.ts +157 -0
- package/src/api/drawing tools/lines menu/schiffPitchfork.ts +18 -0
- package/src/api/drawing tools/lines menu/trendAngle.ts +64 -0
- package/src/api/drawing tools/lines menu/trendline.ts +16 -0
- package/src/api/drawing tools/lines menu/vertical.ts +16 -0
- package/src/api/drawing tools/pointers menu/crosshair.ts +17 -0
- package/src/api/drawing tools/pointers menu/cursor.ts +16 -0
- package/src/api/drawing tools/pointers menu/demonstration.ts +35 -0
- package/src/api/drawing tools/pointers menu/dot.ts +26 -0
- package/src/api/drawing tools/shapes menu/rectangle.ts +24 -0
- package/src/api/drawing tools/shapes menu/text.ts +30 -0
- package/src/api/drawingUtils.ts +82 -0
- package/src/core/CanvasLayer.ts +77 -0
- package/src/core/Chart.ts +917 -0
- package/src/core/CoordTransform.ts +282 -0
- package/src/core/Crosshair.ts +207 -0
- package/src/core/IndicatorEngine.ts +216 -0
- package/src/core/InteractionManager.ts +899 -0
- package/src/core/PriceScale.ts +133 -0
- package/src/core/Series.ts +132 -0
- package/src/core/TimeScale.ts +175 -0
- package/src/datafeed/DatafeedConnector.ts +300 -0
- package/src/engine/CandleEngine.ts +458 -0
- package/src/engine/__tests__/CandleEngine.test.ts +402 -0
- package/src/engine/candleInvariants.ts +172 -0
- package/src/engine/mergeUtils.ts +93 -0
- package/src/engine/timeframeUtils.ts +118 -0
- package/src/index.ts +190 -0
- package/src/internal.ts +41 -0
- package/src/licensing/ChartRuntimeResolver.ts +380 -0
- package/src/licensing/LicenseManager.ts +131 -0
- package/src/licensing/__tests__/ChartRuntimeResolver.test.ts +207 -0
- package/src/licensing/__tests__/LicenseManager.test.ts +180 -0
- package/src/licensing/licenseTypes.ts +19 -0
- package/src/pine/PineCompiler.ts +68 -0
- package/src/pine/diagnostics.ts +30 -0
- package/src/pine/index.ts +7 -0
- package/src/pine/pine-ast.ts +163 -0
- package/src/pine/pine-lexer.ts +265 -0
- package/src/pine/pine-parser.ts +439 -0
- package/src/pine/pine-transpiler.ts +301 -0
- package/src/pixi/LayerName.ts +35 -0
- package/src/pixi/PixiCandlestickRenderer.ts +125 -0
- package/src/pixi/PixiChart.ts +425 -0
- package/src/pixi/PixiCrosshairRenderer.ts +134 -0
- package/src/pixi/PixiDrawingRenderer.ts +121 -0
- package/src/pixi/PixiGridRenderer.ts +136 -0
- package/src/pixi/PixiLayerManager.ts +102 -0
- package/src/renderers/CandlestickRenderer.ts +130 -0
- package/src/renderers/HistogramRenderer.ts +63 -0
- package/src/renderers/LineRenderer.ts +77 -0
- package/src/theme/colors.ts +21 -0
- package/src/tools/barDivergenceCheck.ts +305 -0
- package/src/trading/TradingOverlayStore.ts +161 -0
- package/src/trading/UnmanagedIngestion.ts +156 -0
- package/src/trading/__tests__/ManagedTradingController.test.ts +338 -0
- package/src/trading/__tests__/TradingOverlayStore.test.ts +323 -0
- package/src/trading/__tests__/UnmanagedIngestion.test.ts +205 -0
- package/src/trading/managed/ManagedTradingController.ts +292 -0
- package/src/trading/managed/managedCapabilities.ts +98 -0
- package/src/trading/managed/managedTypes.ts +151 -0
- package/src/trading/tradingTypes.ts +135 -0
- package/src/tscript/TScriptIndicator.ts +54 -0
- package/src/tscript/ast.ts +105 -0
- package/src/tscript/lexer.ts +190 -0
- package/src/tscript/parser.ts +334 -0
- package/src/tscript/runtime.ts +525 -0
- package/src/tscript/series.ts +84 -0
- package/src/types/IChart.ts +56 -0
- package/src/types/IRenderer.ts +16 -0
- package/src/types/ISeries.ts +30 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +15 -0
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
import type { Drawing, DrawingType, OHLCV, HitTestResult, ChartKeyAction, KeyModifiers } from '@forgecharts/types';
|
|
2
|
+
import type { CoordTransform } from './CoordTransform';
|
|
3
|
+
|
|
4
|
+
// ─── Public handler bag ───────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Callbacks injected by the owning chart engine.
|
|
8
|
+
* Every handler is optional; omit the ones your engine doesn't need.
|
|
9
|
+
*/
|
|
10
|
+
export type InteractionHandlers = {
|
|
11
|
+
/** User dragged the chart. Apply `transform.pan(dx, dy)` and mark dirty. */
|
|
12
|
+
onPan?: (dx: number, dy: number) => void;
|
|
13
|
+
/**
|
|
14
|
+
* User scrolled the wheel or pressed +/-.
|
|
15
|
+
* `factor > 1` = zoom out (more bars visible); `factor < 1` = zoom in.
|
|
16
|
+
* Apply `transform.zoomTime(factor, originX)` and mark dirty.
|
|
17
|
+
*/
|
|
18
|
+
onZoom?: (factor: number, originX: number) => void;
|
|
19
|
+
/** Mouse moved while not dragging — update the crosshair. */
|
|
20
|
+
onCrosshairMove?: (x: number, y: number, hit: HitTestResult) => void;
|
|
21
|
+
/** Mouse left the chart area — hide the crosshair. */
|
|
22
|
+
onCrosshairHide?: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Left-click without drag (click threshold not exceeded).
|
|
25
|
+
* Selection state is updated internally before this fires.
|
|
26
|
+
*/
|
|
27
|
+
onSelect?: (x: number, y: number, hit: HitTestResult) => void;
|
|
28
|
+
/** User clicked empty space — selection cleared. */
|
|
29
|
+
onDeselect?: () => void;
|
|
30
|
+
/** Right-click / contextmenu. `e.preventDefault()` is already called. */
|
|
31
|
+
onContextMenu?: (x: number, y: number, hit: HitTestResult) => void;
|
|
32
|
+
/** Recognised keyboard shortcut. */
|
|
33
|
+
onKeyAction?: (action: ChartKeyAction, modifiers: KeyModifiers) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Double-click on the price axis — reset price scale to fit visible bars
|
|
36
|
+
* and scroll to the latest bar.
|
|
37
|
+
*/
|
|
38
|
+
onFitContent?: () => void;
|
|
39
|
+
/** User scrolled the wheel over the price axis — zoom the price scale. */
|
|
40
|
+
onPriceScaleZoom?: (factor: number, originY: number) => void;
|
|
41
|
+
/** User dragged up/down on the price axis strip — drag down expands, up compresses. */
|
|
42
|
+
onPriceAxisDrag?: (dy: number) => void;
|
|
43
|
+
// ── Drawing tool handlers ─────────────────────────────────────────────────
|
|
44
|
+
/** Called when the user clicks while a drawing tool is active. */
|
|
45
|
+
onDrawClick?: (x: number, y: number) => void;
|
|
46
|
+
/** Called on every mousemove while a drawing tool is active (preview). */
|
|
47
|
+
onDrawPointerMove?: (x: number, y: number) => void;
|
|
48
|
+
/** Handle drag start — anchor `handleIndex` of drawing `id`. */
|
|
49
|
+
onHandleDragStart?: (id: string, handleIndex: number) => void;
|
|
50
|
+
/** Handle dragged to new pixel position. */
|
|
51
|
+
onHandleDragMove?: (id: string, handleIndex: number, x: number, y: number) => void;
|
|
52
|
+
/** Handle drag released. */
|
|
53
|
+
onHandleDragEnd?: (id: string, handleIndex: number) => void;
|
|
54
|
+
/** Body of drawing `id` grabbed for move. */
|
|
55
|
+
onBodyMoveStart?: (id: string) => void;
|
|
56
|
+
/** Body dragged by (dx, dy) pixel delta. */
|
|
57
|
+
onBodyMoveMove?: (id: string, dx: number, dy: number) => void;
|
|
58
|
+
/** Body move released. */
|
|
59
|
+
onBodyMoveEnd?: (id: string) => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ─── Construction options ─────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export type InteractionOptions = {
|
|
65
|
+
/** Allow click-drag panning. Defaults to `true`. */
|
|
66
|
+
readonly enablePan?: boolean;
|
|
67
|
+
/** Allow mouse-wheel zooming. Defaults to `true`. */
|
|
68
|
+
readonly enableZoom?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Pointer travel distance (px) below which mousedown + mouseup is treated
|
|
71
|
+
* as a click rather than a pan gesture. Defaults to `4`.
|
|
72
|
+
*/
|
|
73
|
+
readonly clickThreshold?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Zoom multiplier per wheel step.
|
|
76
|
+
* `factor > 1` = zoom out on scroll-down. Defaults to `1.1`.
|
|
77
|
+
*/
|
|
78
|
+
readonly zoomStep?: number;
|
|
79
|
+
/** Pixels to scroll per arrow-key press. Defaults to `40`. */
|
|
80
|
+
readonly keyPanStep?: number;
|
|
81
|
+
/** Zoom multiplier per keyboard +/- press. Defaults to `1.1`. */
|
|
82
|
+
readonly keyZoomStep?: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/** Tolerated pixel distance for drawing hit detection. */
|
|
88
|
+
const DRAWING_HIT_PX = 6;
|
|
89
|
+
/** Tolerated pixel distance for handle hit detection. */
|
|
90
|
+
const HANDLE_HIT_PX = 8;
|
|
91
|
+
|
|
92
|
+
// ─── InteractionManager ───────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* InteractionManager — the single owner of all DOM input events for a chart.
|
|
96
|
+
*
|
|
97
|
+
* Responsibilities:
|
|
98
|
+
* - Mouse: pan (left-drag), crosshair tracking, click detection
|
|
99
|
+
* - Wheel: zoom
|
|
100
|
+
* - Right-click: contextmenu routing
|
|
101
|
+
* - Keyboard: arrow-key pan, +/- zoom, Esc / Delete shortcuts
|
|
102
|
+
* - Hit-testing: drawings → bars → axis zones
|
|
103
|
+
* - Selection state: tracks the currently selected drawing id
|
|
104
|
+
*
|
|
105
|
+
* Usage:
|
|
106
|
+
* ```ts
|
|
107
|
+
* const interaction = new InteractionManager(
|
|
108
|
+
* canvasElement,
|
|
109
|
+
* chart.transform(),
|
|
110
|
+
* {
|
|
111
|
+
* onPan: (dx, dy) => { transform.pan(dx, dy); markDirty(); },
|
|
112
|
+
* onZoom: (f, ox) => { transform.zoomTime(f, ox); markDirty(); },
|
|
113
|
+
* onCrosshairMove: (x, y) => { crosshair.update(x, y); markDirty(); },
|
|
114
|
+
* onCrosshairHide: () => { crosshair.hide(); markDirty(); },
|
|
115
|
+
* onContextMenu: (x, y, hit) => showMenu(x, y, hit),
|
|
116
|
+
* onKeyAction: (action) => handleKey(action),
|
|
117
|
+
* },
|
|
118
|
+
* );
|
|
119
|
+
*
|
|
120
|
+
* // Call on chart resize:
|
|
121
|
+
* interaction.setTransform(chart.transform());
|
|
122
|
+
*
|
|
123
|
+
* // Feed visible bars for bar-snap hit-testing:
|
|
124
|
+
* interaction.setBars(series.data());
|
|
125
|
+
*
|
|
126
|
+
* // Feed drawings for drawing hit-testing:
|
|
127
|
+
* interaction.setDrawings(drawingManager.all());
|
|
128
|
+
*
|
|
129
|
+
* // Teardown:
|
|
130
|
+
* interaction.destroy();
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export class InteractionManager {
|
|
134
|
+
private readonly _el: HTMLElement;
|
|
135
|
+
private _transform: CoordTransform;
|
|
136
|
+
private readonly _handlers: InteractionHandlers;
|
|
137
|
+
private readonly _opts: Required<InteractionOptions>;
|
|
138
|
+
|
|
139
|
+
// ── Hit-test data feeds ───────────────────────────────────────────────────
|
|
140
|
+
private _drawings: readonly Drawing[] = [];
|
|
141
|
+
private _bars: readonly OHLCV[] = [];
|
|
142
|
+
|
|
143
|
+
// ── Mouse state ───────────────────────────────────────────────────────────
|
|
144
|
+
private _isDragging = false;
|
|
145
|
+
private _dragStartX = 0;
|
|
146
|
+
private _dragStartY = 0;
|
|
147
|
+
private _lastDragX = 0;
|
|
148
|
+
private _lastDragY = 0;
|
|
149
|
+
/** True once the pointer has moved past `clickThreshold` during a drag. */
|
|
150
|
+
private _hasMoved = false;
|
|
151
|
+
/** True while the user is dragging on the price axis strip. */
|
|
152
|
+
private _priceAxisDragging = false;
|
|
153
|
+
|
|
154
|
+
// ── Selection state ───────────────────────────────────────────────────────
|
|
155
|
+
private _selectedDrawingId: string | null = null;
|
|
156
|
+
|
|
157
|
+
// ── Drawing tool state ────────────────────────────────────────────────────
|
|
158
|
+
private _drawingMode = false;
|
|
159
|
+
/** True when the native cursor should be hidden (crosshair / dot / demonstration). */
|
|
160
|
+
private _hideCursor = false;
|
|
161
|
+
|
|
162
|
+
/** Sets cursor style, respecting _hideCursor — 'none' always wins when hiding. */
|
|
163
|
+
private _setCursor(style: string): void {
|
|
164
|
+
this._el.style.cursor = this._hideCursor ? 'none' : style;
|
|
165
|
+
}
|
|
166
|
+
/** Set when a drawing is committed mid-mousedown; prevents the paired mouseup from auto-selecting it. */
|
|
167
|
+
private _suppressNextClick = false;
|
|
168
|
+
private _handleDragId: string | null = null;
|
|
169
|
+
private _handleDragIdx = -1;
|
|
170
|
+
private _bodyMoveId: string | null = null;
|
|
171
|
+
private _bodyMovePrevX = 0;
|
|
172
|
+
private _bodyMovePrevY = 0;
|
|
173
|
+
|
|
174
|
+
constructor(
|
|
175
|
+
element: HTMLElement,
|
|
176
|
+
transform: CoordTransform,
|
|
177
|
+
handlers: InteractionHandlers,
|
|
178
|
+
options: InteractionOptions = {},
|
|
179
|
+
) {
|
|
180
|
+
this._el = element;
|
|
181
|
+
this._transform = transform;
|
|
182
|
+
this._handlers = handlers;
|
|
183
|
+
|
|
184
|
+
this._opts = {
|
|
185
|
+
enablePan: options.enablePan ?? true,
|
|
186
|
+
enableZoom: options.enableZoom ?? true,
|
|
187
|
+
clickThreshold: options.clickThreshold ?? 4,
|
|
188
|
+
zoomStep: options.zoomStep ?? 1.1,
|
|
189
|
+
keyPanStep: options.keyPanStep ?? 40,
|
|
190
|
+
keyZoomStep: options.keyZoomStep ?? 1.1,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Make container keyboard-focusable so `keydown` fires on it
|
|
194
|
+
if (element.tabIndex < 0) element.tabIndex = 0;
|
|
195
|
+
element.style.outline = 'none'; // suppress browser focus ring
|
|
196
|
+
|
|
197
|
+
this._attach();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Data feeds (call after every data / drawing change) ──────────────────
|
|
201
|
+
|
|
202
|
+
/** Swap the transform reference after a resize. */
|
|
203
|
+
setTransform(transform: CoordTransform): void {
|
|
204
|
+
this._transform = transform;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Provide the current drawing list for hit-testing. */
|
|
208
|
+
setDrawings(drawings: readonly Drawing[]): void {
|
|
209
|
+
this._drawings = drawings;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Provide the visible bars for bar-snap hit-testing on the primary series.
|
|
214
|
+
* Passing `series.data()` is fine; the manager filters by visible range.
|
|
215
|
+
*/
|
|
216
|
+
setBars(bars: readonly OHLCV[]): void {
|
|
217
|
+
this._bars = bars;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Selection ────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/** Id of the currently selected drawing, or `null`. */
|
|
223
|
+
get selectedDrawingId(): string | null {
|
|
224
|
+
return this._selectedDrawingId;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Programmatically deselect (e.g. on symbol / timeframe change). */
|
|
228
|
+
clearSelection(): void {
|
|
229
|
+
this._selectedDrawingId = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Switches between drawing-tool mode (crosshair cursor, click fires onDrawClick)
|
|
234
|
+
* and normal cursor mode. Also resets any in-progress drag state.
|
|
235
|
+
*/
|
|
236
|
+
/** Hides (`true`) or shows (`false`) the native OS cursor. Used by crosshair, dot, and demonstration modes. */
|
|
237
|
+
setHideCursor(hidden: boolean): void {
|
|
238
|
+
this._hideCursor = hidden;
|
|
239
|
+
if (!this._drawingMode) {
|
|
240
|
+
this._el.style.cursor = hidden ? 'none' : 'default';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
setDrawingMode(active: boolean): void {
|
|
245
|
+
if (this._drawingMode && !active) {
|
|
246
|
+
// Drawing was committed during a mousedown — suppress the paired mouseup click
|
|
247
|
+
// so it doesn't immediately auto-select the newly placed drawing.
|
|
248
|
+
this._suppressNextClick = true;
|
|
249
|
+
}
|
|
250
|
+
this._drawingMode = active;
|
|
251
|
+
this._el.style.cursor = active ? 'crosshair' : (this._hideCursor ? 'none' : 'default');
|
|
252
|
+
this._isDragging = false;
|
|
253
|
+
this._hasMoved = false;
|
|
254
|
+
this._handleDragId = null;
|
|
255
|
+
this._handleDragIdx = -1;
|
|
256
|
+
this._bodyMoveId = null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/** Removes all DOM event listeners. Must be called in the chart's `destroy()`. */
|
|
262
|
+
destroy(): void {
|
|
263
|
+
this._detach();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── DOM listener registry ────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
private _attach(): void {
|
|
269
|
+
const el = this._el;
|
|
270
|
+
|
|
271
|
+
// Mouse — always registered so crosshair works even with pan disabled
|
|
272
|
+
el.addEventListener('mousedown', this._onMouseDown);
|
|
273
|
+
el.addEventListener('mousemove', this._onMouseMove);
|
|
274
|
+
el.addEventListener('mouseup', this._onMouseUp);
|
|
275
|
+
el.addEventListener('mouseleave', this._onMouseLeave);
|
|
276
|
+
|
|
277
|
+
if (this._opts.enableZoom) {
|
|
278
|
+
el.addEventListener('wheel', this._onWheel, { passive: false });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
el.addEventListener('contextmenu', this._onContextMenu);
|
|
282
|
+
el.addEventListener('keydown', this._onKeyDown);
|
|
283
|
+
el.addEventListener('dblclick', this._onDblClick);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private _detach(): void {
|
|
287
|
+
const el = this._el;
|
|
288
|
+
el.removeEventListener('mousedown', this._onMouseDown);
|
|
289
|
+
el.removeEventListener('mousemove', this._onMouseMove);
|
|
290
|
+
el.removeEventListener('mouseup', this._onMouseUp);
|
|
291
|
+
el.removeEventListener('mouseleave', this._onMouseLeave);
|
|
292
|
+
el.removeEventListener('wheel', this._onWheel);
|
|
293
|
+
el.removeEventListener('contextmenu', this._onContextMenu);
|
|
294
|
+
el.removeEventListener('keydown', this._onKeyDown);
|
|
295
|
+
el.removeEventListener('dblclick', this._onDblClick);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Mouse handlers ───────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
private _onMouseDown = (e: MouseEvent): void => {
|
|
301
|
+
if (e.button !== 0) return; // left button only
|
|
302
|
+
this._el.focus({ preventScroll: true });
|
|
303
|
+
|
|
304
|
+
const x = e.offsetX;
|
|
305
|
+
const y = e.offsetY;
|
|
306
|
+
|
|
307
|
+
// Drawing tool mode: route click to drawing placement
|
|
308
|
+
if (this._drawingMode) {
|
|
309
|
+
this._handlers.onDrawClick?.(x, y);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Cursor mode: check handles of the selected drawing first (highest priority)
|
|
314
|
+
if (this._selectedDrawingId !== null) {
|
|
315
|
+
for (const drawing of this._drawings) {
|
|
316
|
+
if (drawing.id !== this._selectedDrawingId || drawing.visible === false) continue;
|
|
317
|
+
const hi = this._hitTestHandles(x, y, drawing);
|
|
318
|
+
if (hi >= 0) {
|
|
319
|
+
this._handleDragId = drawing.id;
|
|
320
|
+
this._handleDragIdx = hi;
|
|
321
|
+
this._setCursor('grabbing');
|
|
322
|
+
this._handlers.onHandleDragStart?.(drawing.id, hi);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
break; // handles checked — move on
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Auto-select + immediately start body drag on ANY hovered drawing.
|
|
330
|
+
// This means one mousedown is all that's needed — no separate
|
|
331
|
+
// "click to select, then click+drag" two-step required.
|
|
332
|
+
const drawingBodyHit = this._hitTestDrawings(x, y);
|
|
333
|
+
if (drawingBodyHit !== null) {
|
|
334
|
+
this._selectedDrawingId = drawingBodyHit.id;
|
|
335
|
+
this._bodyMoveId = drawingBodyHit.id;
|
|
336
|
+
this._bodyMovePrevX = x;
|
|
337
|
+
this._bodyMovePrevY = y;
|
|
338
|
+
this._setCursor('grabbing');
|
|
339
|
+
this._handlers.onBodyMoveStart?.(drawingBodyHit.id);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Price axis — start vertical drag to stretch/compress price scale
|
|
344
|
+
if (x >= this._transform.plotWidth && y < this._transform.plotHeight) {
|
|
345
|
+
this._priceAxisDragging = true;
|
|
346
|
+
this._lastDragY = y;
|
|
347
|
+
this._setCursor('ns-resize');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Normal pan
|
|
352
|
+
this._isDragging = true;
|
|
353
|
+
this._hasMoved = false;
|
|
354
|
+
this._dragStartX = x;
|
|
355
|
+
this._dragStartY = y;
|
|
356
|
+
this._lastDragX = x;
|
|
357
|
+
this._lastDragY = y;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
private _onMouseMove = (e: MouseEvent): void => {
|
|
361
|
+
const x = e.offsetX;
|
|
362
|
+
const y = e.offsetY;
|
|
363
|
+
|
|
364
|
+
// Drawing-tool preview
|
|
365
|
+
if (this._drawingMode) {
|
|
366
|
+
this._handlers.onDrawPointerMove?.(x, y);
|
|
367
|
+
this._handlers.onCrosshairMove?.(x, y, this._hitTest(x, y));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Handle drag
|
|
372
|
+
if (this._handleDragId !== null) {
|
|
373
|
+
this._setCursor('grabbing');
|
|
374
|
+
this._handlers.onHandleDragMove?.(this._handleDragId, this._handleDragIdx, x, y);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Body move
|
|
379
|
+
if (this._bodyMoveId !== null) {
|
|
380
|
+
const dx = x - this._bodyMovePrevX;
|
|
381
|
+
const dy = y - this._bodyMovePrevY;
|
|
382
|
+
this._bodyMovePrevX = x;
|
|
383
|
+
this._bodyMovePrevY = y;
|
|
384
|
+
this._setCursor('grabbing');
|
|
385
|
+
this._handlers.onBodyMoveMove?.(this._bodyMoveId, dx, dy);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Price axis drag — zoom price scale vertically
|
|
390
|
+
if (this._priceAxisDragging) {
|
|
391
|
+
const dy = y - this._lastDragY;
|
|
392
|
+
this._lastDragY = y;
|
|
393
|
+
if (dy !== 0) this._handlers.onPriceAxisDrag?.(dy);
|
|
394
|
+
this._setCursor('ns-resize');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (this._isDragging) {
|
|
399
|
+
const dx = x - this._lastDragX;
|
|
400
|
+
const dy = y - this._lastDragY;
|
|
401
|
+
|
|
402
|
+
const totalDist = Math.hypot(x - this._dragStartX, y - this._dragStartY);
|
|
403
|
+
if (totalDist > this._opts.clickThreshold) {
|
|
404
|
+
this._hasMoved = true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (this._hasMoved && this._opts.enablePan) {
|
|
408
|
+
this._handlers.onPan?.(dx, dy);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
this._lastDragX = x;
|
|
412
|
+
this._lastDragY = y;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Crosshair + cursor feedback
|
|
416
|
+
const hit = this._hitTest(x, y);
|
|
417
|
+
this._handlers.onCrosshairMove?.(x, y, hit);
|
|
418
|
+
|
|
419
|
+
if (!this._isDragging) {
|
|
420
|
+
if (x >= this._transform.plotWidth && y < this._transform.plotHeight) {
|
|
421
|
+
this._setCursor('ns-resize');
|
|
422
|
+
} else if (hit.kind === 'drawingHandle') {
|
|
423
|
+
this._setCursor('grab');
|
|
424
|
+
} else if (hit.kind === 'drawing') {
|
|
425
|
+
this._setCursor('grab');
|
|
426
|
+
} else {
|
|
427
|
+
this._setCursor('default');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
private _onMouseUp = (e: MouseEvent): void => {
|
|
433
|
+
if (e.button !== 0) return;
|
|
434
|
+
|
|
435
|
+
// Finish price axis drag
|
|
436
|
+
if (this._priceAxisDragging) {
|
|
437
|
+
this._priceAxisDragging = false;
|
|
438
|
+
this._setCursor('default');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Finish handle drag
|
|
443
|
+
if (this._handleDragId !== null) {
|
|
444
|
+
const id = this._handleDragId;
|
|
445
|
+
const idx = this._handleDragIdx;
|
|
446
|
+
this._handleDragId = null;
|
|
447
|
+
this._handleDragIdx = -1;
|
|
448
|
+
this._setCursor('grab');
|
|
449
|
+
this._handlers.onHandleDragEnd?.(id, idx);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Finish body move
|
|
454
|
+
if (this._bodyMoveId !== null) {
|
|
455
|
+
const id = this._bodyMoveId;
|
|
456
|
+
this._bodyMoveId = null;
|
|
457
|
+
this._setCursor('grab');
|
|
458
|
+
this._handlers.onBodyMoveEnd?.(id);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const wasClick = !this._hasMoved;
|
|
463
|
+
this._isDragging = false;
|
|
464
|
+
this._hasMoved = false;
|
|
465
|
+
|
|
466
|
+
if (wasClick) {
|
|
467
|
+
if (this._suppressNextClick) {
|
|
468
|
+
this._suppressNextClick = false;
|
|
469
|
+
} else {
|
|
470
|
+
const x = e.offsetX;
|
|
471
|
+
const y = e.offsetY;
|
|
472
|
+
const hit = this._hitTest(x, y);
|
|
473
|
+
this._handleClick(x, y, hit);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
private _onMouseLeave = (): void => {
|
|
479
|
+
this._isDragging = false;
|
|
480
|
+
this._hasMoved = false;
|
|
481
|
+
this._priceAxisDragging = false;
|
|
482
|
+
this._handleDragId = null;
|
|
483
|
+
this._handleDragIdx = -1;
|
|
484
|
+
this._bodyMoveId = null;
|
|
485
|
+
this._handlers.onCrosshairHide?.();
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// ─── Wheel / zoom ─────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
private _onWheel = (e: WheelEvent): void => {
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
const x = e.offsetX;
|
|
493
|
+
const y = e.offsetY;
|
|
494
|
+
const plotW = this._transform.plotWidth;
|
|
495
|
+
const plotH = this._transform.plotHeight;
|
|
496
|
+
|
|
497
|
+
// Proportional zoom: normalise deltaY against one standard notch (120 px).
|
|
498
|
+
// High-resolution trackpads send many small deltas; mice send 120 at a time.
|
|
499
|
+
// Range: 0.8% per tiny trackpad nudge → 4% per full mouse-wheel notch.
|
|
500
|
+
// This keeps one notch feeling gentle while fast spinning still zooms quickly.
|
|
501
|
+
const rawAbs = Math.abs(e.deltaY);
|
|
502
|
+
const speed = Math.min(rawAbs / 120, 1); // 0–1; 1 = one standard notch
|
|
503
|
+
const magnitude = 0.008 + speed * 0.032; // 0.8% minimum → 4% per notch
|
|
504
|
+
const baseZoom = 1 + magnitude;
|
|
505
|
+
const factor = e.deltaY > 0 ? baseZoom : 1 / baseZoom;
|
|
506
|
+
|
|
507
|
+
// Price axis strip (right side) — zoom price scale
|
|
508
|
+
if (x >= plotW && y < plotH) {
|
|
509
|
+
this._handlers.onPriceScaleZoom?.(factor, y);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Time axis strip (bottom) — zoom time scale (expand/contract left-right)
|
|
514
|
+
if (y >= plotH) {
|
|
515
|
+
// Zoom around the horizontal centre of the plot so the chart expands
|
|
516
|
+
// and contracts symmetrically rather than anchored to the cursor.
|
|
517
|
+
this._handlers.onZoom?.(factor, plotW / 2);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Chart plot area — zoom time axis
|
|
522
|
+
this._handlers.onZoom?.(factor, x);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// ─── Double-click — price axis fit ──────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
private _onDblClick = (e: MouseEvent): void => {
|
|
528
|
+
// Fire onFitContent when the double-click lands on the price axis (right)
|
|
529
|
+
// or the time axis (bottom) — both reset to the default viewport.
|
|
530
|
+
const onPriceAxis = e.offsetX >= this._transform.plotWidth;
|
|
531
|
+
const onTimeAxis = e.offsetY >= this._transform.plotHeight;
|
|
532
|
+
if (onPriceAxis || onTimeAxis) {
|
|
533
|
+
e.preventDefault();
|
|
534
|
+
this._handlers.onFitContent?.();
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// ─── Context menu ─────────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
private _onContextMenu = (e: MouseEvent): void => {
|
|
541
|
+
e.preventDefault();
|
|
542
|
+
const x = e.offsetX;
|
|
543
|
+
const y = e.offsetY;
|
|
544
|
+
const hit = this._hitTest(x, y);
|
|
545
|
+
this._handlers.onContextMenu?.(x, y, hit);
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// ─── Keyboard ─────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
private _onKeyDown = (e: KeyboardEvent): void => {
|
|
551
|
+
const action = this._keyToAction(e.key);
|
|
552
|
+
if (action === null) return;
|
|
553
|
+
|
|
554
|
+
// Suppress browser scroll / zoom for chart keys
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
|
|
557
|
+
const modifiers: KeyModifiers = {
|
|
558
|
+
shift: e.shiftKey,
|
|
559
|
+
ctrl: e.ctrlKey,
|
|
560
|
+
alt: e.altKey,
|
|
561
|
+
meta: e.metaKey,
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
this._handlers.onKeyAction?.(action, modifiers);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
private _keyToAction(key: string): ChartKeyAction | null {
|
|
568
|
+
switch (key) {
|
|
569
|
+
case 'ArrowLeft': return 'panLeft';
|
|
570
|
+
case 'ArrowRight': return 'panRight';
|
|
571
|
+
case 'ArrowUp': return 'panUp';
|
|
572
|
+
case 'ArrowDown': return 'panDown';
|
|
573
|
+
case '+':
|
|
574
|
+
case '=': return 'zoomIn';
|
|
575
|
+
case '-':
|
|
576
|
+
case '_': return 'zoomOut';
|
|
577
|
+
case 'Home': return 'scrollToStart';
|
|
578
|
+
case 'End': return 'scrollToEnd';
|
|
579
|
+
case 'Escape': return 'deselect';
|
|
580
|
+
case 'Delete':
|
|
581
|
+
case 'Backspace': return 'deleteSelection';
|
|
582
|
+
default: return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ─── Click → selection ────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
private _handleClick(x: number, y: number, hit: HitTestResult): void {
|
|
589
|
+
if (hit.kind === 'drawing') {
|
|
590
|
+
this._selectedDrawingId = hit.id;
|
|
591
|
+
this._handlers.onSelect?.(x, y, hit);
|
|
592
|
+
} else if (hit.kind === 'bar') {
|
|
593
|
+
this._selectedDrawingId = null;
|
|
594
|
+
this._handlers.onSelect?.(x, y, hit);
|
|
595
|
+
} else {
|
|
596
|
+
this._selectedDrawingId = null;
|
|
597
|
+
this._handlers.onDeselect?.();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ─── Hit-testing ─────────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
private _hitTest(x: number, y: number): HitTestResult {
|
|
604
|
+
const t = this._transform;
|
|
605
|
+
|
|
606
|
+
// Price axis zone (right strip)
|
|
607
|
+
if (x >= t.plotWidth) {
|
|
608
|
+
return { kind: 'priceAxis' };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Time axis zone (bottom strip)
|
|
612
|
+
if (y >= t.plotHeight) {
|
|
613
|
+
return { kind: 'timeAxis' };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Handles of selected drawing — highest priority
|
|
617
|
+
if (this._selectedDrawingId !== null) {
|
|
618
|
+
for (const drawing of this._drawings) {
|
|
619
|
+
if (drawing.id !== this._selectedDrawingId || drawing.visible === false) continue;
|
|
620
|
+
const hi = this._hitTestHandles(x, y, drawing);
|
|
621
|
+
if (hi >= 0) {
|
|
622
|
+
return { kind: 'drawingHandle', id: drawing.id, type: drawing.type, handleIndex: hi };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Drawings — higher priority than bars
|
|
628
|
+
const drawingHit = this._hitTestDrawings(x, y);
|
|
629
|
+
if (drawingHit !== null) {
|
|
630
|
+
return { kind: 'drawing', id: drawingHit.id, type: drawingHit.type };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Nearest visible bar
|
|
634
|
+
const barHit = this._hitTestBar(x);
|
|
635
|
+
if (barHit !== null) {
|
|
636
|
+
return { kind: 'bar', index: barHit.index, bar: barHit.bar };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return { kind: 'none' };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private _hitTestHandles(x: number, y: number, drawing: Drawing): number {
|
|
643
|
+
const t = this._transform;
|
|
644
|
+
for (let i = 0; i < drawing.points.length; i++) {
|
|
645
|
+
const p = drawing.points[i]!;
|
|
646
|
+
if (Math.hypot(x - t.timeToX(p.time), y - t.priceToY(p.price)) < HANDLE_HIT_PX) {
|
|
647
|
+
return i;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return -1;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private _hitTestDrawings(x: number, y: number): { id: string; type: DrawingType } | null {
|
|
654
|
+
let bestId: string | null = null;
|
|
655
|
+
let bestType: DrawingType | null = null;
|
|
656
|
+
let bestDist = DRAWING_HIT_PX + 1;
|
|
657
|
+
|
|
658
|
+
for (const drawing of this._drawings) {
|
|
659
|
+
if (drawing.visible === false) continue;
|
|
660
|
+
|
|
661
|
+
const rawDist = this._distToDrawing(x, y, drawing);
|
|
662
|
+
// Small bias for the selected drawing so it "sticks" under the cursor
|
|
663
|
+
const dist = rawDist + (drawing.id === this._selectedDrawingId ? -2 : 0);
|
|
664
|
+
|
|
665
|
+
if (dist < bestDist) {
|
|
666
|
+
bestDist = dist;
|
|
667
|
+
bestId = drawing.id;
|
|
668
|
+
bestType = drawing.type;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return bestId !== null && bestType !== null ? { id: bestId, type: bestType } : null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private _distToDrawing(mx: number, my: number, drawing: Drawing): number {
|
|
676
|
+
const t = this._transform;
|
|
677
|
+
|
|
678
|
+
switch (drawing.type) {
|
|
679
|
+
case 'horizontal': {
|
|
680
|
+
const p0 = drawing.points[0];
|
|
681
|
+
if (!p0) return Infinity;
|
|
682
|
+
return Math.abs(my - t.priceToY(p0.price));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
case 'vertical': {
|
|
686
|
+
const p0 = drawing.points[0];
|
|
687
|
+
if (!p0) return Infinity;
|
|
688
|
+
return Math.abs(mx - t.timeToX(p0.time));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
case 'trendline':
|
|
692
|
+
case 'fibRetracement': {
|
|
693
|
+
const p0 = drawing.points[0];
|
|
694
|
+
const p1 = drawing.points[1];
|
|
695
|
+
if (!p0 || !p1) return Infinity;
|
|
696
|
+
return this._distPointToSegment(
|
|
697
|
+
mx, my,
|
|
698
|
+
t.timeToX(p0.time), t.priceToY(p0.price),
|
|
699
|
+
t.timeToX(p1.time), t.priceToY(p1.price),
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
case 'ray': {
|
|
704
|
+
const p0 = drawing.points[0];
|
|
705
|
+
const p1 = drawing.points[1];
|
|
706
|
+
if (!p0 || !p1) return Infinity;
|
|
707
|
+
const x0 = t.timeToX(p0.time);
|
|
708
|
+
const y0 = t.priceToY(p0.price);
|
|
709
|
+
const dx = t.timeToX(p1.time) - x0;
|
|
710
|
+
const dy = t.priceToY(p1.price) - y0;
|
|
711
|
+
// Compute exit using the same formula as the renderer
|
|
712
|
+
const pw = t.plotWidth;
|
|
713
|
+
const ph = t.plotHeight;
|
|
714
|
+
let tMin = Infinity;
|
|
715
|
+
if (dx > 0) tMin = Math.min(tMin, (pw - x0) / dx);
|
|
716
|
+
else if (dx < 0) tMin = Math.min(tMin, -x0 / dx);
|
|
717
|
+
if (dy > 0) tMin = Math.min(tMin, (ph - y0) / dy);
|
|
718
|
+
else if (dy < 0) tMin = Math.min(tMin, -y0 / dy);
|
|
719
|
+
const ex = tMin === Infinity ? x0 : x0 + dx * tMin;
|
|
720
|
+
const ey = tMin === Infinity ? y0 : y0 + dy * tMin;
|
|
721
|
+
return this._distPointToSegment(mx, my, x0, y0, ex, ey);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
case 'rectangle': {
|
|
725
|
+
const p0 = drawing.points[0];
|
|
726
|
+
const p1 = drawing.points[1];
|
|
727
|
+
if (!p0 || !p1) return Infinity;
|
|
728
|
+
const rx = Math.min(t.timeToX(p0.time), t.timeToX(p1.time));
|
|
729
|
+
const ry = Math.min(t.priceToY(p0.price), t.priceToY(p1.price));
|
|
730
|
+
const rw = Math.abs(t.timeToX(p1.time) - t.timeToX(p0.time));
|
|
731
|
+
const rh = Math.abs(t.priceToY(p1.price) - t.priceToY(p0.price));
|
|
732
|
+
return this._distToRect(mx, my, rx, ry, rw, rh);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
case 'text': {
|
|
736
|
+
const p0 = drawing.points[0];
|
|
737
|
+
if (!p0) return Infinity;
|
|
738
|
+
const px = t.timeToX(p0.time);
|
|
739
|
+
const py = t.priceToY(p0.price);
|
|
740
|
+
// Approximate bounding box
|
|
741
|
+
const fs = drawing.fontSize ?? 13;
|
|
742
|
+
return this._distToRect(mx, my, px, py - fs - 8, 80, fs + 8);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ── new line types (two-anchor, infinite or extended) ──────────────
|
|
746
|
+
|
|
747
|
+
case 'infoLine':
|
|
748
|
+
case 'extendedLine':
|
|
749
|
+
case 'trendAngle': {
|
|
750
|
+
const p0 = drawing.points[0];
|
|
751
|
+
const p1 = drawing.points[1];
|
|
752
|
+
if (!p0 || !p1) return Infinity;
|
|
753
|
+
// Approximate by segment; close enough for hit-testing
|
|
754
|
+
return this._distPointToSegment(
|
|
755
|
+
mx, my,
|
|
756
|
+
t.timeToX(p0.time), t.priceToY(p0.price),
|
|
757
|
+
t.timeToX(p1.time), t.priceToY(p1.price),
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
case 'horizontalRay': {
|
|
762
|
+
const p0 = drawing.points[0];
|
|
763
|
+
if (!p0) return Infinity;
|
|
764
|
+
const x0 = t.timeToX(p0.time);
|
|
765
|
+
const y = t.priceToY(p0.price);
|
|
766
|
+
if (mx < x0) return Infinity;
|
|
767
|
+
return Math.abs(my - y);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
case 'crossLine': {
|
|
771
|
+
const p0 = drawing.points[0];
|
|
772
|
+
if (!p0) return Infinity;
|
|
773
|
+
const x = t.timeToX(p0.time);
|
|
774
|
+
const y = t.priceToY(p0.price);
|
|
775
|
+
return Math.min(Math.abs(my - y), Math.abs(mx - x));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ── channels ──────────────────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
case 'parallelChannel':
|
|
781
|
+
case 'flatTopBottom':
|
|
782
|
+
case 'regressionTrend':
|
|
783
|
+
case 'disjointChannel': {
|
|
784
|
+
const p0 = drawing.points[0];
|
|
785
|
+
const p1 = drawing.points[1];
|
|
786
|
+
if (!p0 || !p1) return Infinity;
|
|
787
|
+
const d0 = this._distPointToSegment(
|
|
788
|
+
mx, my,
|
|
789
|
+
t.timeToX(p0.time), t.priceToY(p0.price),
|
|
790
|
+
t.timeToX(p1.time), t.priceToY(p1.price),
|
|
791
|
+
);
|
|
792
|
+
const p2 = drawing.points[2];
|
|
793
|
+
if (!p2) return d0;
|
|
794
|
+
const d1 = this._distPointToSegment(
|
|
795
|
+
mx, my,
|
|
796
|
+
t.timeToX(p0.time), t.priceToY(p2.price),
|
|
797
|
+
t.timeToX(p1.time), t.priceToY(p2.price),
|
|
798
|
+
);
|
|
799
|
+
return Math.min(d0, d1);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ── pitchforks ────────────────────────────────────────────────────
|
|
803
|
+
|
|
804
|
+
case 'pitchfork':
|
|
805
|
+
case 'schiffPitchfork':
|
|
806
|
+
case 'modifiedSchiffPitchfork':
|
|
807
|
+
case 'insidePitchfork': {
|
|
808
|
+
const p0 = drawing.points[0];
|
|
809
|
+
const p1 = drawing.points[1];
|
|
810
|
+
const p2 = drawing.points[2];
|
|
811
|
+
if (!p0 || !p1) return Infinity;
|
|
812
|
+
let best = this._distPointToSegment(
|
|
813
|
+
mx, my,
|
|
814
|
+
t.timeToX(p0.time), t.priceToY(p0.price),
|
|
815
|
+
t.timeToX(p1.time), t.priceToY(p1.price),
|
|
816
|
+
);
|
|
817
|
+
if (p2) {
|
|
818
|
+
best = Math.min(best, this._distPointToSegment(
|
|
819
|
+
mx, my,
|
|
820
|
+
t.timeToX(p0.time), t.priceToY(p0.price),
|
|
821
|
+
t.timeToX(p2.time), t.priceToY(p2.price),
|
|
822
|
+
));
|
|
823
|
+
}
|
|
824
|
+
return best;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
default:
|
|
828
|
+
// Fib/Gann and future drawing types are not hit-testable yet.
|
|
829
|
+
return Infinity;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Returns the nearest visible bar to x-coordinate `mx`, or `null` if no bar
|
|
835
|
+
* falls within half a bar-width.
|
|
836
|
+
*/
|
|
837
|
+
private _hitTestBar(mx: number): { index: number; bar: OHLCV } | null {
|
|
838
|
+
const t = this._transform;
|
|
839
|
+
const { from, to } = t.timeRange;
|
|
840
|
+
const visibleSeconds = Math.max(1, to - from);
|
|
841
|
+
const barHalfPx = (t.plotWidth / visibleSeconds) / 2;
|
|
842
|
+
|
|
843
|
+
let bestIndex = -1;
|
|
844
|
+
let bestDist = Infinity;
|
|
845
|
+
|
|
846
|
+
for (let i = 0; i < this._bars.length; i++) {
|
|
847
|
+
const bar = this._bars[i];
|
|
848
|
+
if (!bar || bar.time < from || bar.time > to) continue;
|
|
849
|
+
const dist = Math.abs(mx - t.timeToX(bar.time));
|
|
850
|
+
if (dist < bestDist) {
|
|
851
|
+
bestDist = dist;
|
|
852
|
+
bestIndex = i;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (bestIndex < 0 || bestDist > barHalfPx) return null;
|
|
857
|
+
|
|
858
|
+
const bar = this._bars[bestIndex];
|
|
859
|
+
if (!bar) return null;
|
|
860
|
+
return { index: bestIndex, bar };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ─── Geometry helpers ────────────────────────────────────────────────────
|
|
864
|
+
|
|
865
|
+
/** Distance from point (px, py) to line segment (x1,y1)→(x2,y2). */
|
|
866
|
+
private _distPointToSegment(
|
|
867
|
+
px: number, py: number,
|
|
868
|
+
x1: number, y1: number,
|
|
869
|
+
x2: number, y2: number,
|
|
870
|
+
): number {
|
|
871
|
+
const dx = x2 - x1;
|
|
872
|
+
const dy = y2 - y1;
|
|
873
|
+
const lenSq = dx * dx + dy * dy;
|
|
874
|
+
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
|
|
875
|
+
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
|
|
876
|
+
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Minimum distance from point (px, py) to the perimeter of an
|
|
881
|
+
* axis-aligned rectangle. Returns 0 if the point is inside.
|
|
882
|
+
*/
|
|
883
|
+
private _distToRect(
|
|
884
|
+
px: number, py: number,
|
|
885
|
+
rx: number, ry: number,
|
|
886
|
+
rw: number, rh: number,
|
|
887
|
+
): number {
|
|
888
|
+
const inside =
|
|
889
|
+
px >= rx && px <= rx + rw &&
|
|
890
|
+
py >= ry && py <= ry + rh;
|
|
891
|
+
if (inside) return 0;
|
|
892
|
+
return Math.min(
|
|
893
|
+
this._distPointToSegment(px, py, rx, ry, rx + rw, ry),
|
|
894
|
+
this._distPointToSegment(px, py, rx + rw, ry, rx + rw, ry + rh),
|
|
895
|
+
this._distPointToSegment(px, py, rx + rw, ry + rh, rx, ry + rh),
|
|
896
|
+
this._distPointToSegment(px, py, rx, ry + rh, rx, ry),
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
}
|