@forgecharts/sdk 1.1.23 → 1.1.27
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 +28 -4
- package/src/internal.ts +1 -1
- package/src/react/canvas/ChartCanvas.tsx +984 -0
- package/src/react/canvas/ChartContextMenu.tsx +60 -0
- package/src/react/canvas/ChartSettingsDialog.tsx +133 -0
- package/src/react/canvas/IndicatorLabel.tsx +347 -0
- package/src/react/canvas/IndicatorPane.tsx +503 -0
- package/src/react/canvas/PointerOverlay.tsx +126 -0
- package/src/react/canvas/toolbars/LeftToolbar.tsx +1096 -0
- package/src/react/hooks/useChartCapabilities.ts +76 -0
- package/src/react/index.ts +51 -0
- package/src/react/internal.ts +62 -0
- package/src/react/shell/ManagedAppShell.tsx +699 -0
- package/src/react/trading/TradingBridge.ts +156 -0
- package/src/react/workspace/ChartWorkspace.tsx +228 -0
- package/src/react/workspace/FloatingPanel.tsx +131 -0
- package/src/react/workspace/IndicatorsDialog.tsx +246 -0
- package/src/react/workspace/LayoutMenu.tsx +345 -0
- package/src/react/workspace/SymbolSearchDialog.tsx +377 -0
- package/src/react/workspace/TabBar.tsx +87 -0
- package/src/react/workspace/toolbars/BottomToolbar.tsx +372 -0
- package/src/react/workspace/toolbars/RightToolbar.tsx +46 -0
- package/src/react/workspace/toolbars/TopToolbar.tsx +431 -0
- package/tsconfig.json +2 -1
- package/tsup.config.ts +4 -3
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndicatorPane — Canvas 2D rendering of a single indicator sub-pane.
|
|
3
|
+
*
|
|
4
|
+
* Uses the main chart's CoordTransform for x-axis alignment, then computes
|
|
5
|
+
* its own per-pane y-axis from indicator value ranges.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef } from 'react';
|
|
9
|
+
import type { OHLCV, IndicatorInstance, ChartTheme } from '@forgecharts/types';
|
|
10
|
+
import type { CoordTransform, DAGResult } from '../../';
|
|
11
|
+
import type { IndicatorPoint, MACDPoint } from '../../';
|
|
12
|
+
import { IndicatorLabel } from './IndicatorLabel';
|
|
13
|
+
|
|
14
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/** Width of the right-side price axis — must match the main chart's AXIS_WIDTH. */
|
|
17
|
+
const AXIS_W = 70;
|
|
18
|
+
/** Vertical padding inside the pane (logical pixels). */
|
|
19
|
+
const PAD_T = 8;
|
|
20
|
+
const PAD_B = 8;
|
|
21
|
+
|
|
22
|
+
// ─── Theme palettes ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const PALETTE = {
|
|
25
|
+
dark: {
|
|
26
|
+
bg: '#131722',
|
|
27
|
+
text: '#d1d4dc',
|
|
28
|
+
muted: '#787b86',
|
|
29
|
+
border: '#2a2e39',
|
|
30
|
+
up: '#26a69a',
|
|
31
|
+
down: '#ef5350',
|
|
32
|
+
macdLine: '#2196f3',
|
|
33
|
+
signalLine: '#ff9800',
|
|
34
|
+
},
|
|
35
|
+
light: {
|
|
36
|
+
bg: '#ffffff',
|
|
37
|
+
text: '#131722',
|
|
38
|
+
muted: '#9098a1',
|
|
39
|
+
border: '#e0e3eb',
|
|
40
|
+
up: '#26a69a',
|
|
41
|
+
down: '#ef5350',
|
|
42
|
+
macdLine: '#1565c0',
|
|
43
|
+
signalLine: '#e65100',
|
|
44
|
+
},
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
type Pal = (typeof PALETTE)[keyof typeof PALETTE];
|
|
48
|
+
|
|
49
|
+
// ─── Low-level draw helpers ───────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function _vy(value: number, min: number, max: number, topPad: number, plotH: number): number {
|
|
52
|
+
if (max === min) return topPad + plotH / 2;
|
|
53
|
+
return topPad + plotH * (1 - (value - min) / (max - min));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function _hline(
|
|
57
|
+
ctx: CanvasRenderingContext2D,
|
|
58
|
+
y: number,
|
|
59
|
+
plotW: number,
|
|
60
|
+
color: string,
|
|
61
|
+
dash: number[] = [],
|
|
62
|
+
): void {
|
|
63
|
+
ctx.strokeStyle = color;
|
|
64
|
+
ctx.lineWidth = 0.5;
|
|
65
|
+
ctx.setLineDash(dash);
|
|
66
|
+
ctx.beginPath();
|
|
67
|
+
ctx.moveTo(0, y + 0.5);
|
|
68
|
+
ctx.lineTo(plotW, y + 0.5);
|
|
69
|
+
ctx.stroke();
|
|
70
|
+
ctx.setLineDash([]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _axisLabels(
|
|
74
|
+
ctx: CanvasRenderingContext2D,
|
|
75
|
+
ticks: number[],
|
|
76
|
+
min: number,
|
|
77
|
+
max: number,
|
|
78
|
+
topPad: number,
|
|
79
|
+
plotH: number,
|
|
80
|
+
canvasH: number,
|
|
81
|
+
plotW: number,
|
|
82
|
+
color: string,
|
|
83
|
+
decimals = 0,
|
|
84
|
+
): void {
|
|
85
|
+
ctx.font = '9px -apple-system, BlinkMacSystemFont, sans-serif';
|
|
86
|
+
ctx.fillStyle = color;
|
|
87
|
+
ctx.textAlign = 'left';
|
|
88
|
+
ctx.textBaseline = 'middle';
|
|
89
|
+
for (const tick of ticks) {
|
|
90
|
+
const y = _vy(tick, min, max, topPad, plotH);
|
|
91
|
+
if (y < 2 || y > canvasH - 2) continue;
|
|
92
|
+
ctx.fillText(tick.toFixed(decimals), plotW + 4, y);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Per-indicator renderers ──────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function _renderRSI(
|
|
99
|
+
ctx: CanvasRenderingContext2D,
|
|
100
|
+
points: readonly IndicatorPoint[],
|
|
101
|
+
transform: CoordTransform,
|
|
102
|
+
plotW: number,
|
|
103
|
+
h: number,
|
|
104
|
+
lineColor: string,
|
|
105
|
+
pal: Pal,
|
|
106
|
+
): void {
|
|
107
|
+
const plotH = h - PAD_T - PAD_B;
|
|
108
|
+
const min = 0;
|
|
109
|
+
const max = 100;
|
|
110
|
+
const y70 = _vy(70, min, max, PAD_T, plotH);
|
|
111
|
+
const y50 = _vy(50, min, max, PAD_T, plotH);
|
|
112
|
+
const y30 = _vy(30, min, max, PAD_T, plotH);
|
|
113
|
+
|
|
114
|
+
// Zone fills
|
|
115
|
+
ctx.fillStyle = 'rgba(239,83,80,0.06)';
|
|
116
|
+
ctx.fillRect(0, PAD_T, plotW, y70 - PAD_T);
|
|
117
|
+
ctx.fillStyle = 'rgba(38,166,154,0.06)';
|
|
118
|
+
ctx.fillRect(0, y30, plotW, h - PAD_B - y30);
|
|
119
|
+
|
|
120
|
+
// Reference lines
|
|
121
|
+
_hline(ctx, y70, plotW, pal.muted, [3, 3]);
|
|
122
|
+
_hline(ctx, y50, plotW, pal.border);
|
|
123
|
+
_hline(ctx, y30, plotW, pal.muted, [3, 3]);
|
|
124
|
+
|
|
125
|
+
// Level labels inside plot area
|
|
126
|
+
ctx.font = '9px sans-serif';
|
|
127
|
+
ctx.fillStyle = pal.muted;
|
|
128
|
+
ctx.textAlign = 'left';
|
|
129
|
+
ctx.textBaseline = 'bottom';
|
|
130
|
+
ctx.fillText('70', 2, y70);
|
|
131
|
+
ctx.textBaseline = 'top';
|
|
132
|
+
ctx.fillText('30', 2, y30);
|
|
133
|
+
|
|
134
|
+
// RSI line
|
|
135
|
+
if (points.length > 0) {
|
|
136
|
+
ctx.strokeStyle = lineColor;
|
|
137
|
+
ctx.lineWidth = 1.5;
|
|
138
|
+
ctx.beginPath();
|
|
139
|
+
let started = false;
|
|
140
|
+
for (const p of points) {
|
|
141
|
+
const x = transform.timeToX(p.time);
|
|
142
|
+
if (x < -20 || x > plotW + 20) { started = false; continue; }
|
|
143
|
+
const y = _vy(p.value, min, max, PAD_T, plotH);
|
|
144
|
+
if (!started) { ctx.moveTo(x, y); started = true; } else { ctx.lineTo(x, y); }
|
|
145
|
+
}
|
|
146
|
+
ctx.stroke();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_axisLabels(ctx, [0, 30, 50, 70, 100], min, max, PAD_T, plotH, h, plotW, pal.muted);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _renderMACD(
|
|
153
|
+
ctx: CanvasRenderingContext2D,
|
|
154
|
+
points: readonly MACDPoint[],
|
|
155
|
+
transform: CoordTransform,
|
|
156
|
+
plotW: number,
|
|
157
|
+
h: number,
|
|
158
|
+
pal: Pal,
|
|
159
|
+
): void {
|
|
160
|
+
if (points.length === 0) return;
|
|
161
|
+
const plotH = h - PAD_T - PAD_B;
|
|
162
|
+
|
|
163
|
+
// Auto-scale to visible values
|
|
164
|
+
let min = Infinity;
|
|
165
|
+
let max = -Infinity;
|
|
166
|
+
for (const p of points) {
|
|
167
|
+
const x = transform.timeToX(p.time);
|
|
168
|
+
if (x < -20 || x > plotW + 20) continue;
|
|
169
|
+
min = Math.min(min, p.histogram, p.macd, p.signal);
|
|
170
|
+
max = Math.max(max, p.histogram, p.macd, p.signal);
|
|
171
|
+
}
|
|
172
|
+
if (!isFinite(min)) { min = -0.01; max = 0.01; }
|
|
173
|
+
const rangePad = (max - min) * 0.1 || 0.001;
|
|
174
|
+
min -= rangePad; max += rangePad;
|
|
175
|
+
|
|
176
|
+
// Zero line
|
|
177
|
+
_hline(ctx, _vy(0, min, max, PAD_T, plotH), plotW, pal.border, [2, 2]);
|
|
178
|
+
|
|
179
|
+
// Compute bar pixel width from consecutive timestamps
|
|
180
|
+
let barPx = 5;
|
|
181
|
+
if (points.length >= 2) {
|
|
182
|
+
barPx = Math.max(1, Math.abs(transform.timeToX(points[1]!.time) - transform.timeToX(points[0]!.time)) * 0.7);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Histogram bars
|
|
186
|
+
const y0 = _vy(0, min, max, PAD_T, plotH);
|
|
187
|
+
for (const p of points) {
|
|
188
|
+
const x = transform.timeToX(p.time);
|
|
189
|
+
if (x < -barPx || x > plotW + barPx) continue;
|
|
190
|
+
const yH = _vy(p.histogram, min, max, PAD_T, plotH);
|
|
191
|
+
ctx.fillStyle = p.histogram >= 0 ? `${pal.up}99` : `${pal.down}99`;
|
|
192
|
+
ctx.fillRect(x - barPx / 2, Math.min(y0, yH), barPx, Math.abs(y0 - yH) || 1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// MACD line
|
|
196
|
+
ctx.strokeStyle = pal.macdLine;
|
|
197
|
+
ctx.lineWidth = 1.5;
|
|
198
|
+
ctx.beginPath();
|
|
199
|
+
let started = false;
|
|
200
|
+
for (const p of points) {
|
|
201
|
+
const x = transform.timeToX(p.time);
|
|
202
|
+
if (x < -20 || x > plotW + 20) { started = false; continue; }
|
|
203
|
+
const y = _vy(p.macd, min, max, PAD_T, plotH);
|
|
204
|
+
if (!started) { ctx.moveTo(x, y); started = true; } else { ctx.lineTo(x, y); }
|
|
205
|
+
}
|
|
206
|
+
ctx.stroke();
|
|
207
|
+
|
|
208
|
+
// Signal line
|
|
209
|
+
ctx.strokeStyle = pal.signalLine;
|
|
210
|
+
ctx.lineWidth = 1;
|
|
211
|
+
ctx.beginPath();
|
|
212
|
+
started = false;
|
|
213
|
+
for (const p of points) {
|
|
214
|
+
const x = transform.timeToX(p.time);
|
|
215
|
+
if (x < -20 || x > plotW + 20) { started = false; continue; }
|
|
216
|
+
const y = _vy(p.signal, min, max, PAD_T, plotH);
|
|
217
|
+
if (!started) { ctx.moveTo(x, y); started = true; } else { ctx.lineTo(x, y); }
|
|
218
|
+
}
|
|
219
|
+
ctx.stroke();
|
|
220
|
+
|
|
221
|
+
const range = max - min;
|
|
222
|
+
const dec = range < 0.1 ? 4 : range < 10 ? 2 : 0;
|
|
223
|
+
_axisLabels(
|
|
224
|
+
ctx,
|
|
225
|
+
[min + range * 0.1, min + range * 0.5, min + range * 0.9],
|
|
226
|
+
min, max, PAD_T, plotH, h, plotW, pal.muted, dec,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function _renderVolume(
|
|
231
|
+
ctx: CanvasRenderingContext2D,
|
|
232
|
+
bars: readonly OHLCV[],
|
|
233
|
+
points: readonly IndicatorPoint[],
|
|
234
|
+
transform: CoordTransform,
|
|
235
|
+
plotW: number,
|
|
236
|
+
h: number,
|
|
237
|
+
pal: Pal,
|
|
238
|
+
): void {
|
|
239
|
+
if (points.length === 0) return;
|
|
240
|
+
const plotH = h - PAD_T - PAD_B;
|
|
241
|
+
const min = 0;
|
|
242
|
+
const max = Math.max(...points.map((p) => p.value));
|
|
243
|
+
if (max === 0) return;
|
|
244
|
+
|
|
245
|
+
const barMap = new Map(bars.map((b) => [b.time, b]));
|
|
246
|
+
let barPx = 5;
|
|
247
|
+
if (points.length >= 2) {
|
|
248
|
+
barPx = Math.max(1, Math.abs(transform.timeToX(points[1]!.time) - transform.timeToX(points[0]!.time)) * 0.8);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const yBase = _vy(0, min, max, PAD_T, plotH);
|
|
252
|
+
for (const p of points) {
|
|
253
|
+
const x = transform.timeToX(p.time);
|
|
254
|
+
if (x < -barPx || x > plotW + barPx) continue;
|
|
255
|
+
const bar = barMap.get(p.time);
|
|
256
|
+
const isUp = bar !== undefined ? bar.close >= bar.open : true;
|
|
257
|
+
const yTop = _vy(p.value, min, max, PAD_T, plotH);
|
|
258
|
+
ctx.fillStyle = isUp ? `${pal.up}99` : `${pal.down}99`;
|
|
259
|
+
ctx.fillRect(x - barPx / 2, yTop, barPx, Math.abs(yBase - yTop) || 1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_axisLabels(ctx, [max], min, max, PAD_T, plotH, h, plotW, pal.muted, 0);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _renderLineIndicator(
|
|
266
|
+
ctx: CanvasRenderingContext2D,
|
|
267
|
+
points: readonly IndicatorPoint[],
|
|
268
|
+
transform: CoordTransform,
|
|
269
|
+
plotW: number,
|
|
270
|
+
h: number,
|
|
271
|
+
lineColor: string,
|
|
272
|
+
pal: Pal,
|
|
273
|
+
): void {
|
|
274
|
+
if (points.length === 0) return;
|
|
275
|
+
const plotH = h - PAD_T - PAD_B;
|
|
276
|
+
|
|
277
|
+
// Auto-scale to visible values
|
|
278
|
+
let min = Infinity;
|
|
279
|
+
let max = -Infinity;
|
|
280
|
+
for (const p of points) {
|
|
281
|
+
const x = transform.timeToX(p.time);
|
|
282
|
+
if (x < -20 || x > plotW + 20) continue;
|
|
283
|
+
min = Math.min(min, p.value);
|
|
284
|
+
max = Math.max(max, p.value);
|
|
285
|
+
}
|
|
286
|
+
if (!isFinite(min)) return;
|
|
287
|
+
const rangePad = (max - min) * 0.1 || 1;
|
|
288
|
+
min -= rangePad; max += rangePad;
|
|
289
|
+
|
|
290
|
+
ctx.strokeStyle = lineColor;
|
|
291
|
+
ctx.lineWidth = 1.5;
|
|
292
|
+
ctx.beginPath();
|
|
293
|
+
let started = false;
|
|
294
|
+
for (const p of points) {
|
|
295
|
+
const x = transform.timeToX(p.time);
|
|
296
|
+
if (x < -20 || x > plotW + 20) { started = false; continue; }
|
|
297
|
+
const y = _vy(p.value, min, max, PAD_T, plotH);
|
|
298
|
+
if (!started) { ctx.moveTo(x, y); started = true; } else { ctx.lineTo(x, y); }
|
|
299
|
+
}
|
|
300
|
+
ctx.stroke();
|
|
301
|
+
|
|
302
|
+
const range = max - min;
|
|
303
|
+
const dec = range < 0.1 ? 4 : range < 10 ? 2 : 0;
|
|
304
|
+
_axisLabels(ctx, [min + range * 0.25, min + range * 0.75], min, max, PAD_T, plotH, h, plotW, pal.muted, dec);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Label helper ─────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
function _indicatorLabel(type: string, params: Record<string, unknown> = {}): string {
|
|
310
|
+
switch (type) {
|
|
311
|
+
case 'rsi': return `RSI(${params['period'] ?? 14})`;
|
|
312
|
+
case 'macd': return `MACD(${params['fast'] ?? 12},${params['slow'] ?? 26},${params['signal'] ?? 9})`;
|
|
313
|
+
case 'volume': return 'Volume';
|
|
314
|
+
case 'sma': return `SMA(${params['period'] ?? 20})`;
|
|
315
|
+
case 'ema': return `EMA(${params['period'] ?? 20})`;
|
|
316
|
+
case 'wma': return `WMA(${params['period'] ?? 20})`;
|
|
317
|
+
default: return type.toUpperCase();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
export type IndicatorPaneProps = {
|
|
324
|
+
/** Indicators to render in this pane. */
|
|
325
|
+
indicators: readonly IndicatorInstance[];
|
|
326
|
+
/** Full bar dataset — still needed for volume bar up/down colouring. */
|
|
327
|
+
bars: readonly OHLCV[];
|
|
328
|
+
/** Shared coordinate transform from the main price chart. Null until chart mounts. */
|
|
329
|
+
transform: CoordTransform | null;
|
|
330
|
+
/** CSS height of this pane in pixels. */
|
|
331
|
+
height: number;
|
|
332
|
+
/** Colour theme — must match the main chart. */
|
|
333
|
+
theme: ChartTheme;
|
|
334
|
+
/** Pre-computed DAG results — keyed by indicator id. */
|
|
335
|
+
computedResults: ReadonlyMap<string, DAGResult>;
|
|
336
|
+
/** Called when the user clicks the × button on an indicator label. */
|
|
337
|
+
onRemove?: (id: string) => void;
|
|
338
|
+
/** Called when the user clicks the ⚙ button on an indicator label. */
|
|
339
|
+
onConfigure?: (indicator: IndicatorInstance) => void;
|
|
340
|
+
/** Ref to the current crosshair X pixel (from the price pane). Drawn as a synced vertical line. */
|
|
341
|
+
crosshairXRef?: { current: number | null };
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export function IndicatorPane({
|
|
345
|
+
indicators,
|
|
346
|
+
bars,
|
|
347
|
+
transform,
|
|
348
|
+
height,
|
|
349
|
+
theme,
|
|
350
|
+
computedResults,
|
|
351
|
+
crosshairXRef,
|
|
352
|
+
onRemove,
|
|
353
|
+
onConfigure,
|
|
354
|
+
}: IndicatorPaneProps) {
|
|
355
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
356
|
+
// Stable ref to the draw function — rebuilt whenever data changes,
|
|
357
|
+
// called each RAF frame to stay in sync with the candle layer.
|
|
358
|
+
const drawFnRef = useRef<(() => void) | null>(null);
|
|
359
|
+
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
drawFnRef.current = () => {
|
|
362
|
+
const canvas = canvasRef.current;
|
|
363
|
+
if (!canvas || !transform || indicators.length === 0) return;
|
|
364
|
+
|
|
365
|
+
const dpr = window.devicePixelRatio ?? 1;
|
|
366
|
+
const cssW = canvas.offsetWidth || (canvas.parentElement?.clientWidth ?? 800);
|
|
367
|
+
const cssH = height;
|
|
368
|
+
const physW = Math.round(cssW * dpr);
|
|
369
|
+
const physH = Math.round(cssH * dpr);
|
|
370
|
+
|
|
371
|
+
if (canvas.width !== physW || canvas.height !== physH) {
|
|
372
|
+
canvas.width = physW;
|
|
373
|
+
canvas.height = physH;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const ctx = canvas.getContext('2d');
|
|
377
|
+
if (!ctx) return;
|
|
378
|
+
|
|
379
|
+
const pal = PALETTE[theme];
|
|
380
|
+
|
|
381
|
+
ctx.save();
|
|
382
|
+
ctx.scale(dpr, dpr);
|
|
383
|
+
|
|
384
|
+
const w = cssW;
|
|
385
|
+
const h = cssH;
|
|
386
|
+
const plotW = w - AXIS_W;
|
|
387
|
+
|
|
388
|
+
// Background
|
|
389
|
+
ctx.fillStyle = pal.bg;
|
|
390
|
+
ctx.fillRect(0, 0, w, h);
|
|
391
|
+
|
|
392
|
+
// Top separator
|
|
393
|
+
ctx.strokeStyle = pal.border;
|
|
394
|
+
ctx.lineWidth = 1;
|
|
395
|
+
ctx.beginPath();
|
|
396
|
+
ctx.moveTo(0, 0.5);
|
|
397
|
+
ctx.lineTo(w, 0.5);
|
|
398
|
+
ctx.stroke();
|
|
399
|
+
|
|
400
|
+
// Axis divider
|
|
401
|
+
ctx.beginPath();
|
|
402
|
+
ctx.moveTo(plotW + 0.5, 0);
|
|
403
|
+
ctx.lineTo(plotW + 0.5, h);
|
|
404
|
+
ctx.stroke();
|
|
405
|
+
|
|
406
|
+
// Clip plot area so indicator lines don't bleed into axis
|
|
407
|
+
ctx.save();
|
|
408
|
+
ctx.beginPath();
|
|
409
|
+
ctx.rect(0, 0, plotW, h);
|
|
410
|
+
ctx.clip();
|
|
411
|
+
|
|
412
|
+
for (const indicator of indicators) {
|
|
413
|
+
const result = computedResults.get(indicator.id);
|
|
414
|
+
if (!result) continue;
|
|
415
|
+
const color = indicator.config.color;
|
|
416
|
+
|
|
417
|
+
switch (indicator.config.type) {
|
|
418
|
+
case 'rsi':
|
|
419
|
+
if (result.kind === 'series')
|
|
420
|
+
_renderRSI(ctx, result.points, transform, plotW, h, color ?? '#7c4dff', pal);
|
|
421
|
+
break;
|
|
422
|
+
case 'macd':
|
|
423
|
+
if (result.kind === 'macd')
|
|
424
|
+
_renderMACD(ctx, result.points, transform, plotW, h, pal);
|
|
425
|
+
break;
|
|
426
|
+
case 'volume':
|
|
427
|
+
if (result.kind === 'series')
|
|
428
|
+
_renderVolume(ctx, bars, result.points, transform, plotW, h, pal);
|
|
429
|
+
break;
|
|
430
|
+
default:
|
|
431
|
+
// sma, ema, wma, bbands, and any chained / custom indicators
|
|
432
|
+
if (result.kind === 'series')
|
|
433
|
+
_renderLineIndicator(ctx, result.points, transform, plotW, h, color ?? '#ffb300', pal);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Crosshair vertical line (synced from price pane) ────────────────
|
|
438
|
+
const xhair = crosshairXRef?.current ?? null;
|
|
439
|
+
if (xhair !== null && xhair >= 0 && xhair <= plotW) {
|
|
440
|
+
ctx.strokeStyle = theme === 'dark' ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)';
|
|
441
|
+
ctx.lineWidth = 0.5;
|
|
442
|
+
ctx.setLineDash([3, 3]);
|
|
443
|
+
ctx.beginPath();
|
|
444
|
+
ctx.moveTo(xhair + 0.5, 0);
|
|
445
|
+
ctx.lineTo(xhair + 0.5, h);
|
|
446
|
+
ctx.stroke();
|
|
447
|
+
ctx.setLineDash([]);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
ctx.restore(); // clip
|
|
451
|
+
|
|
452
|
+
ctx.restore(); // dpr scale
|
|
453
|
+
};
|
|
454
|
+
// Draw immediately when data (not viewport) changes.
|
|
455
|
+
drawFnRef.current();
|
|
456
|
+
}, [computedResults, bars, height, indicators, transform, theme]);
|
|
457
|
+
|
|
458
|
+
// RAF loop: redraw every frame so the pane tracks pan/zoom in the same
|
|
459
|
+
// animation frame as the candle layer — no React state update needed.
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
let rafId: number;
|
|
462
|
+
const tick = () => {
|
|
463
|
+
drawFnRef.current?.();
|
|
464
|
+
rafId = requestAnimationFrame(tick);
|
|
465
|
+
};
|
|
466
|
+
rafId = requestAnimationFrame(tick);
|
|
467
|
+
return () => cancelAnimationFrame(rafId);
|
|
468
|
+
}, []);
|
|
469
|
+
|
|
470
|
+
const pal = PALETTE[theme];
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<div style={{ position: 'relative', width: '100%', height: `${height}px` }}>
|
|
474
|
+
<canvas
|
|
475
|
+
ref={canvasRef}
|
|
476
|
+
style={{ width: '100%', height: `${height}px`, display: 'block' }}
|
|
477
|
+
/>
|
|
478
|
+
{/* Indicator name labels — one per indicator in this pane */}
|
|
479
|
+
<div
|
|
480
|
+
style={{
|
|
481
|
+
position: 'absolute',
|
|
482
|
+
top: 4,
|
|
483
|
+
left: 4,
|
|
484
|
+
display: 'flex',
|
|
485
|
+
flexDirection: 'column',
|
|
486
|
+
gap: 2,
|
|
487
|
+
pointerEvents: 'auto',
|
|
488
|
+
zIndex: 2,
|
|
489
|
+
}}
|
|
490
|
+
>
|
|
491
|
+
{indicators.map((ind) => (
|
|
492
|
+
<IndicatorLabel
|
|
493
|
+
key={ind.id}
|
|
494
|
+
indicator={ind}
|
|
495
|
+
color={pal.text}
|
|
496
|
+
onRemove={() => onRemove?.(ind.id)}
|
|
497
|
+
onConfigure={() => onConfigure?.(ind)}
|
|
498
|
+
/>
|
|
499
|
+
))}
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import type { PointerToolType } from '../../';
|
|
3
|
+
import {
|
|
4
|
+
DOT_RADIUS,
|
|
5
|
+
DOT_COLOR,
|
|
6
|
+
DEMONSTRATION_RADIUS,
|
|
7
|
+
DEMONSTRATION_FILL,
|
|
8
|
+
DEMONSTRATION_STROKE,
|
|
9
|
+
DEMONSTRATION_COLOR,
|
|
10
|
+
} from '../../';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PointerOverlay — a transparent `<div>` that covers the entire chart area and
|
|
14
|
+
* renders tool-specific cursor visuals for the `dot` and `demonstration` modes.
|
|
15
|
+
*
|
|
16
|
+
* - **dot**: SVG crosshair lines + small filled circle at the intersection.
|
|
17
|
+
* - **demonstration**: SVG crosshair + semi-transparent filled circle centred
|
|
18
|
+
* on the cursor.
|
|
19
|
+
*
|
|
20
|
+
* The component does NOT intercept pointer events (`pointerEvents: none`), so
|
|
21
|
+
* all mouse interactions pass through to the canvas layers beneath it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Props = {
|
|
25
|
+
mode: PointerToolType;
|
|
26
|
+
/** Width of the container to size the SVG overlay. */
|
|
27
|
+
width: number;
|
|
28
|
+
/** Height of the container to size the SVG overlay. */
|
|
29
|
+
height: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const CROSSHAIR_COLOR = DEMONSTRATION_COLOR;
|
|
33
|
+
|
|
34
|
+
export function PointerOverlay({ mode, width, height }: Props): React.ReactElement | null {
|
|
35
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
36
|
+
const hGroupRef = useRef<SVGGElement>(null); // translateY group → horizontal line
|
|
37
|
+
const vGroupRef = useRef<SVGGElement>(null); // translateX group → vertical line
|
|
38
|
+
const cursorGroup = useRef<SVGGElement>(null); // translate(x,y) group → dot/circle
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const svg = svgRef.current;
|
|
42
|
+
if (!svg) return;
|
|
43
|
+
const container = svg.parentElement;
|
|
44
|
+
if (!container) return;
|
|
45
|
+
|
|
46
|
+
// Cache the bounding rect so we never call getBoundingClientRect() inside the
|
|
47
|
+
// hot move handler (it forces a synchronous reflow on every event).
|
|
48
|
+
let cachedRect = container.getBoundingClientRect();
|
|
49
|
+
const ro = new ResizeObserver(() => { cachedRect = container.getBoundingClientRect(); });
|
|
50
|
+
ro.observe(container);
|
|
51
|
+
|
|
52
|
+
const onMove = (e: MouseEvent) => {
|
|
53
|
+
const x = e.clientX - cachedRect.left;
|
|
54
|
+
const y = e.clientY - cachedRect.top;
|
|
55
|
+
|
|
56
|
+
if (x < 0 || y < 0 || x > cachedRect.width || y > cachedRect.height) {
|
|
57
|
+
svg.style.opacity = '0';
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
svg.style.opacity = '1';
|
|
62
|
+
|
|
63
|
+
// CSS transforms are GPU-composited — no layout reflow, no paint.
|
|
64
|
+
if (hGroupRef.current) hGroupRef.current.style.transform = `translateY(${y}px)`;
|
|
65
|
+
if (vGroupRef.current) vGroupRef.current.style.transform = `translateX(${x}px)`;
|
|
66
|
+
if (cursorGroup.current) cursorGroup.current.style.transform = `translate(${x}px,${y}px)`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const hide = () => { svg.style.opacity = '0'; };
|
|
70
|
+
|
|
71
|
+
// passive:true lets the browser skip asking us about preventDefault → lower latency
|
|
72
|
+
container.addEventListener('mousemove', onMove, { passive: true });
|
|
73
|
+
container.addEventListener('mouseleave', hide, { passive: true });
|
|
74
|
+
return () => {
|
|
75
|
+
container.removeEventListener('mousemove', onMove);
|
|
76
|
+
container.removeEventListener('mouseleave', hide);
|
|
77
|
+
ro.disconnect();
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
if (mode !== 'dot' && mode !== 'demonstration') return null;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<svg
|
|
85
|
+
ref={svgRef}
|
|
86
|
+
width={width}
|
|
87
|
+
height={height}
|
|
88
|
+
style={{
|
|
89
|
+
position: 'absolute',
|
|
90
|
+
top: 0,
|
|
91
|
+
left: 0,
|
|
92
|
+
pointerEvents: 'none',
|
|
93
|
+
zIndex: 5,
|
|
94
|
+
opacity: 0,
|
|
95
|
+
overflow: 'visible',
|
|
96
|
+
willChange: 'opacity',
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
{/* Horizontal crosshair line — spans ±∞ so width never matters */}
|
|
100
|
+
<g ref={hGroupRef}>
|
|
101
|
+
<line x1="-9999" y1="0" x2="9999" y2="0"
|
|
102
|
+
stroke={CROSSHAIR_COLOR} strokeWidth="1" strokeDasharray="4 3" />
|
|
103
|
+
</g>
|
|
104
|
+
|
|
105
|
+
{/* Vertical crosshair line — spans ±∞ so height never matters */}
|
|
106
|
+
<g ref={vGroupRef}>
|
|
107
|
+
<line x1="0" y1="-9999" x2="0" y2="9999"
|
|
108
|
+
stroke={CROSSHAIR_COLOR} strokeWidth="1" strokeDasharray="4 3" />
|
|
109
|
+
</g>
|
|
110
|
+
|
|
111
|
+
{/* Cursor element translated to (x, y) */}
|
|
112
|
+
<g ref={cursorGroup}>
|
|
113
|
+
{mode === 'dot' && (
|
|
114
|
+
<circle r={DOT_RADIUS} fill={DOT_COLOR} />
|
|
115
|
+
)}
|
|
116
|
+
{mode === 'demonstration' && (
|
|
117
|
+
<circle r={DEMONSTRATION_RADIUS}
|
|
118
|
+
fill={DEMONSTRATION_FILL}
|
|
119
|
+
stroke={DEMONSTRATION_STROKE}
|
|
120
|
+
strokeWidth="1.5"
|
|
121
|
+
/>
|
|
122
|
+
)}
|
|
123
|
+
</g>
|
|
124
|
+
</svg>
|
|
125
|
+
);
|
|
126
|
+
}
|