@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.
@@ -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
+ }