@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,984 @@
1
+ /**
2
+ * ChartCanvas — stacks the main price chart above one or more indicator panes.
3
+ *
4
+ * Features:
5
+ * - Creates a TChart instance and attaches any `initialIndicators`.
6
+ * - Each non-overlay indicator gets its own sub-pane (managed by TChart's PaneManager).
7
+ * - Draggable dividers let the user resize panes; the price-split fraction is
8
+ * reported via onPriceFractionChange (no localStorage in this package).
9
+ * - A requestAnimationFrame loop forwards viewport changes (pan/zoom) to the
10
+ * indicator canvases so they stay in sync with the main chart’s time axis.
11
+ *
12
+ * Previously exported as MultiPaneChart — that name is kept as an alias.
13
+ */
14
+
15
+ import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
16
+ import { TChart, IndicatorDAG } from '../../';
17
+ import type { TradingOverlayStore } from '../../';
18
+ import type { CoordTransform, DrawingToolType, DAGResult, PointerToolType } from '../../';
19
+ import type { OHLCV, PaneDescriptor, IndicatorConfig, IndicatorInstance, DrawingType, ChartLayout } from '@forgecharts/types';
20
+ import type { IDatafeed, Timeframe, ChartTheme } from '@forgecharts/types';
21
+ import type { TradingBridgeCallbacks } from '../trading/TradingBridge';
22
+ import { IndicatorPane } from './IndicatorPane';
23
+ import { LeftToolbar } from './toolbars/LeftToolbar';
24
+ import type { VisibilityAction } from './toolbars/LeftToolbar';
25
+ import { IndicatorLabel, IndicatorConfigDialog } from './IndicatorLabel';
26
+ import { ChartContextMenu } from './ChartContextMenu';
27
+ import { ChartSettingsDialog } from './ChartSettingsDialog';
28
+ import { PointerOverlay } from './PointerOverlay';
29
+ import type { CanvasSettings } from './ChartSettingsDialog';
30
+
31
+ // ─── Defaults ──────────────────────────────────────────────────────────────────
32
+
33
+ const OVERLAY_COLORS = ['#2196f3', '#4caf50', '#ff9800', '#e040fb', '#00bcd4', '#f44336'] as const;
34
+
35
+ /** Candle duration in seconds per timeframe — mirrors Chart.ts _TF_SECONDS. */
36
+ const _TF_SECONDS: Partial<Record<string, number>> = {
37
+ '1s': 1, '5s': 5, '10s': 10, '30s': 30,
38
+ '1m': 60, '3m': 180, '5m': 300, '15m': 900, '30m': 1800,
39
+ '1h': 3600, '2h': 7200, '4h': 14400, '6h': 21600, '12h': 43200,
40
+ '1d': 86400, '3d': 259200, '1w': 604800, '1M': 2592000,
41
+ };
42
+
43
+ /**
44
+ * Returns true if a hex background color is perceptually light (luminance > 0.5).
45
+ * Used to derive a contrasting axis/text color automatically.
46
+ */
47
+ function _isLightBg(hex: string): boolean {
48
+ const c = hex.replace('#', '');
49
+ if (c.length < 6) return false;
50
+ const r = parseInt(c.slice(0, 2), 16) / 255;
51
+ const g = parseInt(c.slice(2, 4), 16) / 255;
52
+ const b = parseInt(c.slice(4, 6), 16) / 255;
53
+ // sRGB luminance (WCAG formula)
54
+ const toLinear = (v: number) => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
55
+ const L = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
56
+ return L > 0.179;
57
+ }
58
+
59
+ // ─── Divider ──────────────────────────────────────────────────────────────────
60
+
61
+ type DividerProps = { onDrag: (dy: number) => void };
62
+
63
+ function Divider({ onDrag }: DividerProps) {
64
+ const lastY = useRef(0);
65
+
66
+ const handleMouseDown = useCallback(
67
+ (e: React.MouseEvent) => {
68
+ e.preventDefault();
69
+ lastY.current = e.clientY;
70
+
71
+ const onMove = (me: MouseEvent) => {
72
+ onDrag(me.clientY - lastY.current);
73
+ lastY.current = me.clientY;
74
+ };
75
+ const onUp = () => {
76
+ window.removeEventListener('mousemove', onMove);
77
+ window.removeEventListener('mouseup', onUp);
78
+ };
79
+ window.addEventListener('mousemove', onMove);
80
+ window.addEventListener('mouseup', onUp);
81
+ },
82
+ [onDrag],
83
+ );
84
+
85
+ return (
86
+ <div
87
+ onMouseDown={handleMouseDown}
88
+ style={{
89
+ height: 4,
90
+ cursor: 'ns-resize',
91
+ background: 'var(--border, #2a2e39)',
92
+ flexShrink: 0,
93
+ userSelect: 'none',
94
+ }}
95
+ />
96
+ );
97
+ }
98
+
99
+ // ─── Price helpers ───────────────────────────────────────────────────────────
100
+
101
+ function _fmtPrice(v: number): string {
102
+ const abs = Math.abs(v);
103
+ const dec = abs >= 100 ? 2 : abs >= 1 ? 2 : abs >= 0.0001 ? 4 : 6;
104
+ return v.toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec });
105
+ }
106
+
107
+ function _fmtVol(v: number): string {
108
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
109
+ if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
110
+ return v.toFixed(0);
111
+ }
112
+
113
+ // ─── PricePaneHeader ──────────────────────────────────────────────────────────
114
+
115
+ function PricePaneHeader({
116
+ symbol,
117
+ timeframe,
118
+ bars,
119
+ theme,
120
+ indicators,
121
+ overlayColors,
122
+ onRemove,
123
+ onConfigure,
124
+ getExchangeLogoUrl,
125
+ }: {
126
+ symbol: string;
127
+ timeframe: string;
128
+ bars: readonly OHLCV[];
129
+ theme: import('@forgecharts/types').ChartTheme;
130
+ indicators: readonly IndicatorInstance[];
131
+ overlayColors: readonly string[];
132
+ onRemove: (id: string) => void;
133
+ onConfigure: (ind: IndicatorInstance) => void;
134
+ getExchangeLogoUrl?: (exchange: string) => string | null | undefined;
135
+ }) {
136
+ const [collapsed, setCollapsed] = useState(false);
137
+ const lastBar = bars[bars.length - 1];
138
+ const prevBar = bars[bars.length - 2];
139
+ const change = lastBar && prevBar ? lastBar.close - prevBar.close : 0;
140
+ const pctChange = prevBar && prevBar.close !== 0 ? (change / prevBar.close) * 100 : 0;
141
+ const isUp = change >= 0;
142
+ const changeColor = isUp ? 'var(--up, #26a641)' : 'var(--down, #f85149)';
143
+ const muted = theme === 'dark' ? 'rgba(255,255,255,0.42)' : 'rgba(0,0,0,0.38)';
144
+ const textColor = theme === 'dark' ? 'rgba(255,255,255,0.82)' : 'rgba(0,0,0,0.82)';
145
+ const overlayInds = indicators.filter((ind) => ind.config.overlay === true);
146
+
147
+ return (
148
+ <div
149
+ style={{
150
+ position: 'absolute',
151
+ top: 6,
152
+ left: 6,
153
+ zIndex: 3,
154
+ pointerEvents: 'auto',
155
+ maxWidth: 'calc(100% - 80px)',
156
+ fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif',
157
+ userSelect: 'none',
158
+ }}
159
+ >
160
+ {/* ── Symbol + OHLCV row ─────────────────────────────────────────── */}
161
+ <div style={{ display: 'flex', alignItems: 'baseline', flexWrap: 'wrap', gap: '0 5px', fontSize: 11.5, lineHeight: 1.7 }}>
162
+ {(() => {
163
+ const exchange = symbol.includes(':') ? symbol.split(':')[0]!.toUpperCase() : '';
164
+ if (!exchange) return null;
165
+ const logoUrl = getExchangeLogoUrl?.(exchange);
166
+ if (!logoUrl) return null;
167
+ return (
168
+ <img
169
+ src={logoUrl}
170
+ alt={exchange}
171
+ width={14}
172
+ height={14}
173
+ style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: 1, borderRadius: 2, flexShrink: 0 }}
174
+ onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
175
+ />
176
+ );
177
+ })()}
178
+ <span style={{ fontWeight: 700, color: textColor }}>{symbol}</span>
179
+ <span style={{ color: muted }}>· {timeframe}</span>
180
+ {lastBar && (
181
+ <>
182
+ <span>
183
+ <span style={{ color: muted }}>O</span>
184
+ <span style={{ color: muted }}> </span>
185
+ <span style={{ color: textColor }}>{_fmtPrice(lastBar.open)}</span>
186
+ </span>
187
+ <span>
188
+ <span style={{ color: '#26a641', fontWeight: 500 }}>H</span>
189
+ <span style={{ color: muted }}> </span>
190
+ <span style={{ color: textColor }}>{_fmtPrice(lastBar.high)}</span>
191
+ </span>
192
+ <span>
193
+ <span style={{ color: '#f85149', fontWeight: 500 }}>L</span>
194
+ <span style={{ color: muted }}> </span>
195
+ <span style={{ color: textColor }}>{_fmtPrice(lastBar.low)}</span>
196
+ </span>
197
+ <span>
198
+ <span style={{ color: muted }}>C</span>
199
+ <span style={{ color: muted }}> </span>
200
+ <span style={{ color: textColor }}>{_fmtPrice(lastBar.close)}</span>
201
+ </span>
202
+ <span style={{ color: changeColor }}>
203
+ {isUp ? '+' : ''}{_fmtPrice(change)} ({isUp ? '+' : ''}{pctChange.toFixed(2)}%)
204
+ </span>
205
+ </>
206
+ )}
207
+ </div>
208
+
209
+ {/* ── Overlay indicators ─────────────────────────────────────────── */}
210
+ {overlayInds.length > 0 && (
211
+ <>
212
+ {!collapsed && (
213
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 1, marginTop: 2 }}>
214
+ {overlayInds.map((ind, i) => (
215
+ <IndicatorLabel
216
+ key={ind.id}
217
+ indicator={ind}
218
+ color={ind.config.color ?? overlayColors[i % overlayColors.length] ?? '#2196f3'}
219
+ onRemove={() => onRemove(ind.id)}
220
+ onConfigure={() => onConfigure(ind)}
221
+ />
222
+ ))}
223
+ </div>
224
+ )}
225
+ <button
226
+ onClick={() => setCollapsed((c) => !c)}
227
+ title={collapsed ? 'Show indicators' : 'Hide indicators'}
228
+ style={{
229
+ display: 'flex',
230
+ alignItems: 'center',
231
+ justifyContent: 'center',
232
+ marginTop: 3,
233
+ width: 20,
234
+ height: 14,
235
+ background: 'rgba(100,100,100,0.25)',
236
+ border: 'none',
237
+ borderRadius: 3,
238
+ color: muted,
239
+ fontSize: 9,
240
+ cursor: 'pointer',
241
+ padding: 0,
242
+ lineHeight: 1,
243
+ }}
244
+ >
245
+ {collapsed ? '▾' : '▴'}
246
+ </button>
247
+ </>
248
+ )}
249
+ </div>
250
+ );
251
+ }
252
+
253
+ // ─── ChartCanvas ──────────────────────────────────────────────────────────────────────
254
+
255
+ export type ChartCanvasHandle = {
256
+ /** Returns the full current chart state (indicators, drawings, viewport). */
257
+ getLayoutSnapshot: () => ChartLayout | null;
258
+ /** Replaces the chart state with the supplied layout snapshot. */
259
+ loadLayoutSnapshot: (layout: ChartLayout) => void;
260
+ /** Adds an indicator to the live chart and returns its id. */
261
+ addIndicator: (config: IndicatorConfig) => string | null;
262
+ /** Captures the visible chart area as a PNG data-URL. */
263
+ captureScreenshot: () => Promise<string | null>;
264
+ /**
265
+ * Returns the chart's internal TradingOverlayStore for Unmanaged mode.
266
+ * Use this to push confirmed order / position state into the chart.
267
+ * Returns null before the chart has mounted.
268
+ */
269
+ getOverlayStore: () => TradingOverlayStore | null;
270
+ };
271
+
272
+ export type ChartCanvasProps = {
273
+ symbol: string;
274
+ timeframe: string;
275
+ datafeed?: IDatafeed;
276
+ theme?: ChartTheme;
277
+ /** IANA timezone for axis labels (e.g. "America/New_York"). */
278
+ timezone?: string | undefined;
279
+ /** Indicators to add on mount. Non-overlay indicators each get a sub-pane. */
280
+ initialIndicators?: IndicatorConfig[];
281
+ /**
282
+ * When provided the chart restores this snapshot on mount instead of
283
+ * reading from localStorage. Pass a snapshot captured via getLayoutSnapshot()
284
+ * or a ChartLayout loaded from the server.
285
+ */
286
+ layoutToRestore?: ChartLayout | undefined;
287
+ /** Called whenever an indicator is added or removed, so the parent can trigger a workspace save. */
288
+ onIndicatorsChanged?: () => void;
289
+ /**
290
+ * Initial price-pane fraction (0.3–0.85). Defaults to 0.6.
291
+ * The parent controls persistence; changes are reported via onPriceFractionChange.
292
+ */
293
+ priceFraction?: number;
294
+ /** Called when the user drags the price/sub-pane divider. */
295
+ onPriceFractionChange?: (fraction: number) => void;
296
+ /**
297
+ * Optional callback to resolve an exchange logo URL.
298
+ * Receives the exchange code (e.g. "BINANCE") and returns a URL string,
299
+ * or undefined/null to suppress the logo image entirely.
300
+ * If omitted, no exchange logo is rendered.
301
+ */
302
+ getExchangeLogoUrl?: (exchange: string) => string | null | undefined;
303
+ /**
304
+ * Optional trading bridge callbacks for Unmanaged mode.
305
+ *
306
+ * When provided the chart will invoke these callbacks when the user performs
307
+ * a trading action (drag-to-place, cancel, bracket adjust, etc.).
308
+ * The chart never self-confirms — the host must push confirmed state back
309
+ * through `TradingOverlayStore` (via `upsertOrder` / `setOrders` etc.).
310
+ *
311
+ * Wrap with `createTradingBridgeLogger` during development for structured
312
+ * console output of every emitted intent.
313
+ */
314
+ tradingBridge?: TradingBridgeCallbacks;
315
+ };
316
+
317
+ /** @deprecated Use ChartCanvasProps */
318
+ export type MultiPaneChartProps = ChartCanvasProps;
319
+ /** @deprecated Use ChartCanvasHandle */
320
+ export type MultiPaneChartHandle = ChartCanvasHandle;
321
+
322
+ export const ChartCanvas = forwardRef<ChartCanvasHandle, ChartCanvasProps>(
323
+ function ChartCanvas({
324
+ symbol,
325
+ timeframe,
326
+ datafeed,
327
+ theme = 'dark',
328
+ timezone = 'UTC',
329
+ initialIndicators = [],
330
+ layoutToRestore,
331
+ onIndicatorsChanged,
332
+ priceFraction: priceFractionProp,
333
+ onPriceFractionChange,
334
+ getExchangeLogoUrl,
335
+ tradingBridge,
336
+ }: ChartCanvasProps, ref) {
337
+ const outerRef = useRef<HTMLDivElement>(null);
338
+ const priceRef = useRef<HTMLDivElement>(null);
339
+ const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
340
+ /** Crosshair X pixel (relative to chart column) — updated via mousemove, no re-render needed. */
341
+ const crosshairXRef = useRef<number | null>(null);
342
+ const chartRef = useRef<TChart | null>(null);
343
+ const transformRef = useRef<CoordTransform | null>(null);
344
+
345
+ const [loading, setLoading] = useState(true);
346
+ const [error, setError] = useState<string | null>(null);
347
+ const [bars, setBars] = useState<readonly OHLCV[]>([]);
348
+ const [panes, setPanes] = useState<readonly PaneDescriptor[]>([]);
349
+ const [indicators, setIndicators] = useState<readonly IndicatorInstance[]>([]);
350
+ const [totalHeight, setTotalHeight] = useState(600);
351
+ const [activeTool, setActiveTool] = useState<DrawingToolType>('cursor');
352
+ const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; drawingId: string } | null>(null);
353
+ const [chartCtxMenu, setChartCtxMenu] = useState<{ x: number; y: number } | null>(null);
354
+ const [settingsOpen, setSettingsOpen] = useState(false);
355
+ const [canvasSettings, setCanvasSettings] = useState<CanvasSettings>({
356
+ background: '#000000',
357
+ gridColor: '#2a2a2a',
358
+ gridVisible: true,
359
+ });
360
+ const [configTarget, setConfigTarget] = useState<IndicatorInstance | null>(null);
361
+ // Flag to suppress chart context menu when a drawing context menu was triggered
362
+ const drawingCtxJustFired = useRef(false);
363
+
364
+ const dagRef = useRef<IndicatorDAG | null>(null);
365
+ const [computedResults, setComputedResults] = useState<ReadonlyMap<string, DAGResult>>(
366
+ () => new Map(),
367
+ );
368
+ // Stable ref to the overlay draw function — rebuilt whenever data changes,
369
+ // called directly from the RAF loop to stay in the same frame as candles.
370
+ const drawOverlayRef = useRef<(() => void) | null>(null);
371
+
372
+ const [priceFraction, setPriceFraction] = useState<number>(priceFractionProp ?? 0.6);
373
+
374
+ // ── Imperative handle (tab snapshot save/load) ─────────────────────────────
375
+ useImperativeHandle(ref, () => ({
376
+ getLayoutSnapshot: () => chartRef.current?.saveLayout() ?? null,
377
+ loadLayoutSnapshot: (layout: ChartLayout) => {
378
+ chartRef.current?.loadLayout(layout);
379
+ },
380
+ addIndicator: (config: IndicatorConfig) => chartRef.current?.addIndicator(config) ?? null,
381
+ getOverlayStore: () => chartRef.current?.getTradingOverlayStore() ?? null,
382
+ captureScreenshot: async (): Promise<string | null> => {
383
+ const container = outerRef.current;
384
+ if (!container) return null;
385
+ const containerRect = container.getBoundingClientRect();
386
+ const { width, height } = containerRect;
387
+ if (width === 0 || height === 0) return null;
388
+ const dpr = window.devicePixelRatio || 1;
389
+ const composite = document.createElement('canvas');
390
+ composite.width = Math.round(width * dpr);
391
+ composite.height = Math.round(height * dpr);
392
+ const ctx = composite.getContext('2d');
393
+ if (!ctx) return null;
394
+ // Fill background colour from CSS variable
395
+ const bgColor = getComputedStyle(document.documentElement)
396
+ .getPropertyValue('--bg').trim() || '#000000';
397
+ ctx.fillStyle = bgColor;
398
+ ctx.fillRect(0, 0, composite.width, composite.height);
399
+ // Composite every canvas element inside the chart container
400
+ const canvases = container.querySelectorAll<HTMLCanvasElement>('canvas');
401
+ for (const canvas of canvases) {
402
+ const rect = canvas.getBoundingClientRect();
403
+ const x = Math.round((rect.left - containerRect.left) * dpr);
404
+ const y = Math.round((rect.top - containerRect.top) * dpr);
405
+ const w = Math.round(rect.width * dpr);
406
+ const h = Math.round(rect.height * dpr);
407
+ try { ctx.drawImage(canvas, x, y, w, h); } catch { /* tainted canvas — skip */ }
408
+ }
409
+ return composite.toDataURL('image/png');
410
+ },
411
+ }));
412
+
413
+ // ── Mount: create TChart ────────────────────────────────────────────────────
414
+ useEffect(() => {
415
+ const container = priceRef.current;
416
+ if (!container) return;
417
+
418
+ const chart = new TChart({
419
+ container,
420
+ symbol,
421
+ interval: timeframe as import('@forgecharts/types').Timeframe,
422
+ theme,
423
+ ...(datafeed !== undefined ? { datafeed } : {}),
424
+ });
425
+
426
+ // Add initial indicators — non-overlay ones get sub-panes created automatically
427
+ for (const config of initialIndicators) {
428
+ chart.addIndicator(config);
429
+ }
430
+
431
+ transformRef.current = chart.getTransform();
432
+
433
+ const syncChartState = () => {
434
+ setBars(chart.getBars());
435
+ setPanes(chart.getPanes());
436
+ setIndicators(chart.getIndicators());
437
+ };
438
+
439
+ chart.on('dataLoading', () => {
440
+ setLoading(true);
441
+ setError(null);
442
+ });
443
+ chart.on('dataLoaded', ({ interval: loadedInterval }: { symbol: string; interval: string; count: number }) => {
444
+ setLoading(false);
445
+ syncChartState();
446
+ // Only restore a saved viewport when it is genuinely valid for the current
447
+ // timeframe. Two guards prevent stale data from locking the view:
448
+ // 1. interval must match — compares the *loaded* interval (from event payload,
449
+ // not a stale closure) against the saved layout's interval. This correctly
450
+ // handles the case where the user switches timeframes after mount.
451
+ // 2. span must be ≥ 50 candles — avoids restoring zoomed-in states.
452
+ // In all other cases fall back to fitDefaultView() so the user always sees
453
+ // a sensible ~150-candle window on first load or after a timeframe switch.
454
+ const tfSec = _TF_SECONDS[loadedInterval] ?? 3600;
455
+ const savedSpan = layoutToRestore?.viewport
456
+ ? (layoutToRestore.viewport.timeRange.to - layoutToRestore.viewport.timeRange.from)
457
+ : 0;
458
+ const hasValidViewport =
459
+ !!layoutToRestore?.viewport &&
460
+ layoutToRestore.interval === loadedInterval &&
461
+ savedSpan >= tfSec * 50; // require at least 50 candles visible — avoids restoring zoomed-in states
462
+ if (hasValidViewport) {
463
+ chart.restoreViewport(layoutToRestore!.viewport!);
464
+ chart.resetPriceScale();
465
+ } else {
466
+ chart.fitDefaultView();
467
+ }
468
+ });
469
+ chart.on('dataError', ({ error: msg }: { error: string }) => {
470
+ setLoading(false);
471
+ setError(msg);
472
+ });
473
+ chart.on('barsUpdated', syncChartState);
474
+ chart.on('indicatorAdded', () => { syncChartState(); onIndicatorsChanged?.(); });
475
+ chart.on('indicatorRemoved', () => { syncChartState(); onIndicatorsChanged?.(); });
476
+
477
+ // Auto-revert to cursor after each drawing placement.
478
+ chart.on('drawingCreated', () => {
479
+ setActiveTool('cursor');
480
+ chart.setCrosshairEnabled(false); // back to arrow — no crosshair
481
+ });
482
+
483
+ // Show context menu when user right-clicks a drawing.
484
+ chart.on('drawingContextMenu', ({ id, x, y }: { id: string; x: number; y: number }) => {
485
+ const canvas = priceRef.current;
486
+ if (!canvas) return;
487
+ const rect = canvas.getBoundingClientRect();
488
+ drawingCtxJustFired.current = true;
489
+ setTimeout(() => { drawingCtxJustFired.current = false; }, 0);
490
+ setCtxMenu({ drawingId: id, x: rect.left + x, y: rect.top + y });
491
+ });
492
+
493
+ // Restore layout from the explicit snapshot when provided.
494
+ // Without a snapshot the chart starts empty (no localStorage fallback —
495
+ // persistence is handled by DB auto-save in App.tsx).
496
+ if (layoutToRestore) {
497
+ try {
498
+ chart.loadLayout(layoutToRestore);
499
+ } catch { /* invalid snapshot — ignore */ }
500
+ }
501
+
502
+ chartRef.current = chart;
503
+ syncChartState(); // read panes added synchronously for initialIndicators
504
+
505
+ return () => {
506
+ chart.destroy();
507
+ chartRef.current = null;
508
+ transformRef.current = null;
509
+ };
510
+ // eslint-disable-next-line react-hooks/exhaustive-deps
511
+ }, []);
512
+
513
+ // ── Symbol / timeframe propagation ──────────────────────────────────────────
514
+ useEffect(() => { chartRef.current?.setSymbol(symbol); }, [symbol]);
515
+ useEffect(() => { chartRef.current?.setInterval(timeframe as import('@forgecharts/types').Timeframe); }, [timeframe]);
516
+ useEffect(() => { chartRef.current?.setTimezone(timezone); }, [timezone]);
517
+ useEffect(() => { chartRef.current?.setTheme(theme); }, [theme]);
518
+ // ── DAG: recompute indicators when bars or indicator list changes ────────────
519
+ useEffect(() => {
520
+ if (!dagRef.current) dagRef.current = new IndicatorDAG();
521
+ const dag = dagRef.current;
522
+ dag.sync(indicators);
523
+ let changed = false;
524
+ try {
525
+ changed = dag.execute(bars);
526
+ } catch (e) {
527
+ console.error('[ForgeCharts] Indicator computation error:', e);
528
+ }
529
+ if (changed) setComputedResults(new Map(dag.getAllResults() as Map<string, DAGResult>));
530
+ }, [bars, indicators]);
531
+
532
+ // ── Overlay indicators: draw script/ema/etc lines on the price pane canvas ──
533
+ // The draw function is stored in a ref so the RAF loop can call it directly
534
+ // every frame — bypassing the React state update cycle and keeping the
535
+ // indicator overlay pixel-perfect in sync with the candle layer.
536
+ useEffect(() => {
537
+ drawOverlayRef.current = () => {
538
+ const canvas = overlayCanvasRef.current;
539
+ const transform = transformRef.current;
540
+ if (!canvas || !transform) return;
541
+
542
+ const curPriceH = panes.length > 0 ? totalHeight * priceFraction : totalHeight;
543
+ const dpr = window.devicePixelRatio ?? 1;
544
+ const cssW = canvas.offsetWidth || (canvas.parentElement?.clientWidth ?? 800);
545
+ const physW = Math.round(cssW * dpr);
546
+ const physH = Math.round(curPriceH * dpr);
547
+
548
+ if (canvas.width !== physW || canvas.height !== physH) {
549
+ canvas.width = physW;
550
+ canvas.height = physH;
551
+ }
552
+
553
+ const ctx = canvas.getContext('2d');
554
+ if (!ctx) return;
555
+
556
+ ctx.clearRect(0, 0, physW, physH);
557
+
558
+ const overlayInds = indicators.filter((ind) => ind.config.overlay === true);
559
+ if (overlayInds.length === 0) return;
560
+
561
+ ctx.save();
562
+ ctx.scale(dpr, dpr);
563
+
564
+ const plotW = transform.plotWidth;
565
+ let colorIdx = 0;
566
+
567
+ for (const ind of overlayInds) {
568
+ const result = computedResults.get(ind.id);
569
+ if (!result || result.kind !== 'series' || result.points.length === 0) continue;
570
+
571
+ const color = ind.config.color ?? OVERLAY_COLORS[colorIdx++ % OVERLAY_COLORS.length]!;
572
+ ctx.strokeStyle = color;
573
+ ctx.lineWidth = 1.5;
574
+ ctx.beginPath();
575
+ let started = false;
576
+ for (const p of result.points) {
577
+ const x = transform.timeToX(p.time);
578
+ if (x < -20 || x > plotW + 20) { started = false; continue; }
579
+ const y = transform.priceToY(p.value);
580
+ if (!started) { ctx.moveTo(x, y); started = true; }
581
+ else { ctx.lineTo(x, y); }
582
+ }
583
+ ctx.stroke();
584
+ }
585
+
586
+ ctx.restore();
587
+ };
588
+ // Trigger an immediate repaint whenever the data (bars, indicator config,
589
+ // canvas size) changes — viewport-only changes are handled by the RAF loop.
590
+ drawOverlayRef.current();
591
+ }, [computedResults, totalHeight, priceFraction, panes, indicators]);
592
+ // ── ResizeObserver for total container height ──────────────────────────────
593
+ useEffect(() => {
594
+ const el = outerRef.current;
595
+ if (!el) return;
596
+ const ro = new ResizeObserver(([entry]) => {
597
+ if (entry) setTotalHeight(entry.contentRect.height);
598
+ });
599
+ ro.observe(el);
600
+ return () => ro.disconnect();
601
+ }, []);
602
+
603
+ // ── RAF loop: keep indicator overlay in sync with the candle layer ──────────
604
+ // Calls the draw function directly every animation frame — no React state
605
+ // update roundtrip — so indicators move in the exact same frame as candles.
606
+ useEffect(() => {
607
+ let rafId: number;
608
+ const tick = () => {
609
+ drawOverlayRef.current?.();
610
+ rafId = requestAnimationFrame(tick);
611
+ };
612
+ rafId = requestAnimationFrame(tick);
613
+ return () => cancelAnimationFrame(rafId);
614
+ }, []);
615
+
616
+ // ── Chart-level right-click (canvas background, not a drawing) ────────────
617
+ const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
618
+ e.preventDefault();
619
+ if (drawingCtxJustFired.current) return;
620
+ setChartCtxMenu({ x: e.clientX, y: e.clientY });
621
+ }, []);
622
+
623
+ const handleClearDrawings = useCallback(() => {
624
+ chartRef.current?.clearDrawings();
625
+ }, []);
626
+
627
+ const [drawingsHidden, setDrawingsHidden] = useState(false);
628
+ const [indicatorsHidden, setIndicatorsHidden] = useState(false);
629
+
630
+ const handleVisibilityAction = useCallback((action: VisibilityAction) => {
631
+ const chart = chartRef.current;
632
+ if (!chart) return;
633
+ if (action === 'hideDrawings') {
634
+ const next = !drawingsHidden;
635
+ setDrawingsHidden(next);
636
+ chart.setAllDrawingsVisible(!next);
637
+ } else if (action === 'hideIndicators') {
638
+ const next = !indicatorsHidden;
639
+ setIndicatorsHidden(next);
640
+ chart.setIndicatorsVisible(!next);
641
+ } else if (action === 'hideAll') {
642
+ const nextD = !drawingsHidden;
643
+ const nextI = !indicatorsHidden;
644
+ setDrawingsHidden(nextD);
645
+ setIndicatorsHidden(nextI);
646
+ chart.setAllDrawingsVisible(!nextD);
647
+ chart.setIndicatorsVisible(!nextI);
648
+ }
649
+ // hidePositions: positions system not yet implemented
650
+ }, [drawingsHidden, indicatorsHidden]);
651
+
652
+ const visibilityActiveAction: VisibilityAction | null =
653
+ drawingsHidden && indicatorsHidden ? 'hideAll' :
654
+ drawingsHidden ? 'hideDrawings' :
655
+ indicatorsHidden ? 'hideIndicators' :
656
+ null;
657
+
658
+ const handleVisibilityDeactivate = useCallback(() => {
659
+ const chart = chartRef.current;
660
+ if (!chart) return;
661
+ setDrawingsHidden(false);
662
+ setIndicatorsHidden(false);
663
+ chart.setAllDrawingsVisible(true);
664
+ chart.setIndicatorsVisible(true);
665
+ }, []);
666
+
667
+ const handleClearIndicators = useCallback(() => {
668
+ const chart = chartRef.current;
669
+ if (!chart) return;
670
+ for (const ind of chart.getIndicators()) {
671
+ chart.removeIndicator(ind.id);
672
+ }
673
+ }, []);
674
+
675
+ const handleResetView = useCallback(() => {
676
+ chartRef.current?.scrollToEnd();
677
+ }, []);
678
+
679
+ const handleApplySettings = useCallback((settings: CanvasSettings) => {
680
+ setCanvasSettings(settings);
681
+ const light = _isLightBg(settings.background);
682
+ chartRef.current?.applyOptions({
683
+ colors: {
684
+ background: settings.background,
685
+ backgroundSecondary: light ? '#f5f5f5' : '#0a0a0a',
686
+ text: light ? '#000000' : '#ffffff',
687
+ textMuted: light ? '#666666' : '#888888',
688
+ crosshair: light ? '#000000' : '#ffffff',
689
+ border: light ? '#cccccc' : '#333333',
690
+ ...(settings.gridVisible
691
+ ? { grid: settings.gridColor }
692
+ : { grid: 'transparent' }),
693
+ },
694
+ });
695
+ }, []);
696
+
697
+ // ── Indicator remove / configure ──────────────────────────────────────────
698
+ const handleRemoveIndicator = useCallback((id: string) => {
699
+ chartRef.current?.removeIndicator(id);
700
+ }, []);
701
+
702
+ const handleConfigureIndicator = useCallback((indicator: IndicatorInstance) => {
703
+ setConfigTarget(indicator);
704
+ }, []);
705
+
706
+ const handleSaveConfig = useCallback(
707
+ (newConfig: IndicatorConfig) => {
708
+ const chart = chartRef.current;
709
+ if (!chart || !configTarget) return;
710
+ chart.removeIndicator(configTarget.id);
711
+ chart.addIndicator(newConfig);
712
+ setConfigTarget(null);
713
+ },
714
+ [configTarget],
715
+ );
716
+
717
+ // ── Drawing tool selection ─────────────────────────────────────────────────
718
+ const POINTER_TOOLS: PointerToolType[] = ['cursor', 'crosshair', 'dot', 'demonstration'];
719
+
720
+ const handleToolSelect = useCallback((tool: DrawingToolType) => {
721
+ setActiveTool(tool);
722
+ const chart = chartRef.current;
723
+ if (!chart) return;
724
+
725
+ const isPointerTool = (POINTER_TOOLS as string[]).includes(tool);
726
+ if (isPointerTool) {
727
+ chart.cancelDrawingTool();
728
+ // Only the crosshair tool uses the SDK crosshair lines.
729
+ // dot/demonstration use PointerOverlay for their own visuals.
730
+ chart.setCrosshairEnabled(tool === 'crosshair');
731
+ // Hide the native OS cursor for any tool that provides its own visual.
732
+ chart.setNativeCursorHidden(tool !== 'cursor');
733
+ } else {
734
+ chart.setCrosshairEnabled(false); // drawing tool uses its own cursor
735
+ chart.startDrawingTool(tool as DrawingType);
736
+ }
737
+ // eslint-disable-next-line react-hooks/exhaustive-deps
738
+ }, []);
739
+
740
+ // ── Divider drag: price | sub-pane area split ──────────────────────────────
741
+ const handlePriceDividerDrag = useCallback(
742
+ (dy: number) => {
743
+ if (totalHeight === 0) return;
744
+ setPriceFraction((prev) => {
745
+ const next = Math.min(0.85, Math.max(0.3, prev + dy / totalHeight));
746
+ onPriceFractionChange?.(next);
747
+ return next;
748
+ });
749
+ },
750
+ [totalHeight],
751
+ );
752
+
753
+ // ── Divider drag: between two sub-panes ──────────────────────────────────
754
+ const handleSubPaneDividerDrag = useCallback(
755
+ (paneId: string, dy: number) => {
756
+ const chart = chartRef.current;
757
+ if (!chart || totalHeight === 0) return;
758
+ const subH = totalHeight * (1 - priceFraction);
759
+ if (subH === 0) return;
760
+ const currentPanes = chart.getPanes();
761
+ const pane = currentPanes.find((p: PaneDescriptor) => p.id === paneId);
762
+ if (!pane) return;
763
+ const newFraction = Math.min(0.9, Math.max(0.05, pane.heightFraction + dy / subH));
764
+ chart.resizePane(paneId, newFraction);
765
+ setPanes([...chart.getPanes()]);
766
+ },
767
+ [totalHeight, priceFraction],
768
+ );
769
+
770
+ // ── Derived layout dims ────────────────────────────────────────────────────
771
+ const hasSubPanes = panes.length > 0;
772
+ const priceH = hasSubPanes ? totalHeight * priceFraction : totalHeight;
773
+ const subH = totalHeight * (1 - priceFraction);
774
+
775
+ return (
776
+ <div
777
+ ref={outerRef}
778
+ style={{
779
+ position: 'relative',
780
+ width: '100%',
781
+ height: '100%',
782
+ display: 'flex',
783
+ gap: 8,
784
+ overflow: 'hidden',
785
+ }}
786
+ >
787
+ {/* ── Drawing toolbar: full-height left column ─────────────────────── */}
788
+ <LeftToolbar
789
+ activeTool={activeTool}
790
+ onSelectTool={handleToolSelect}
791
+ onVisibilityAction={handleVisibilityAction}
792
+ visibilityActiveAction={visibilityActiveAction}
793
+ onVisibilityDeactivate={handleVisibilityDeactivate}
794
+ onDeleteClick={() => chartRef.current?.deleteSelectedDrawing()}
795
+ />
796
+
797
+ {/* ── Chart content: price pane + sub-panes ─────────────────────────── */}
798
+ <div
799
+ className={activeTool === 'crosshair' ? 'chart-crosshair-mode' : undefined}
800
+ style={{
801
+ flex: 1,
802
+ display: 'flex',
803
+ flexDirection: 'column',
804
+ overflow: 'hidden',
805
+ position: 'relative',
806
+ minWidth: 0,
807
+ cursor:
808
+ activeTool === 'crosshair' ? 'none' :
809
+ activeTool === 'dot' || activeTool === 'demonstration' ? 'none' :
810
+ activeTool === 'cursor' ? 'default' :
811
+ 'crosshair', // drawing tools
812
+ }}
813
+ onMouseMove={(e) => {
814
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
815
+ crosshairXRef.current = e.clientX - rect.left;
816
+ }}
817
+ onMouseLeave={() => { crosshairXRef.current = null; }}
818
+ >
819
+ {/* ── Price pane ──────────────────────────────────────────────────── */}
820
+ <div
821
+ style={{ position: 'relative', height: `${priceH}px`, flexShrink: 0 }}
822
+ onContextMenu={handleCanvasContextMenu}
823
+ >
824
+ <div ref={priceRef} style={{ width: '100%', height: '100%' }} />
825
+ {/* Overlay canvas: script/indicator lines drawn on the price pane */}
826
+ <canvas
827
+ ref={overlayCanvasRef}
828
+ style={{
829
+ position: 'absolute', top: 0, left: 0,
830
+ width: '100%', height: '100%',
831
+ pointerEvents: 'none', zIndex: 2,
832
+ }}
833
+ /> {/* Pointer tool overlay: dot & demonstration visuals */}
834
+ {(activeTool === 'dot' || activeTool === 'demonstration') && (
835
+ <PointerOverlay
836
+ mode={activeTool}
837
+ width={priceRef.current?.clientWidth ?? 0}
838
+ height={priceH}
839
+ />
840
+ )} {/* Price pane header: OHLCV + overlay indicator legend */}
841
+ <PricePaneHeader
842
+ symbol={symbol}
843
+ timeframe={timeframe}
844
+ bars={bars}
845
+ theme={theme}
846
+ indicators={indicators}
847
+ overlayColors={OVERLAY_COLORS}
848
+ onRemove={handleRemoveIndicator}
849
+ onConfigure={handleConfigureIndicator}
850
+ {...(getExchangeLogoUrl ? { getExchangeLogoUrl } : {})}
851
+ />
852
+ </div>
853
+
854
+ {/* ── Loading / error overlays ───────────────────────────────────────── */}
855
+ {loading && (
856
+ <div
857
+ style={{
858
+ position: 'absolute',
859
+ top: priceH / 2 - 12,
860
+ left: '50%',
861
+ transform: 'translateX(-50%)',
862
+ color: 'var(--text-muted, #787b86)',
863
+ fontSize: 13,
864
+ pointerEvents: 'none',
865
+ zIndex: 10,
866
+ }}
867
+ >
868
+ Loading…
869
+ </div>
870
+ )}
871
+ {error && (
872
+ <div
873
+ style={{
874
+ position: 'absolute',
875
+ top: priceH / 2 - 12,
876
+ left: '50%',
877
+ transform: 'translateX(-50%)',
878
+ color: '#ef5350',
879
+ fontSize: 13,
880
+ pointerEvents: 'none',
881
+ zIndex: 10,
882
+ }}
883
+ >
884
+ {error}
885
+ </div>
886
+ )}
887
+
888
+ {/* ── Indicator sub-panes ─────────────────────────────────────────────── */}
889
+ {hasSubPanes && (
890
+ <>
891
+ <Divider onDrag={handlePriceDividerDrag} />
892
+ {panes.map((pane, idx) => {
893
+ const paneH = Math.max(60, Math.round(subH * pane.heightFraction));
894
+ const paneIndicators = indicators.filter((ind) =>
895
+ pane.indicatorIds.includes(ind.id),
896
+ );
897
+ return (
898
+ <div
899
+ key={pane.id}
900
+ style={{ display: 'flex', flexDirection: 'column' }}
901
+ >
902
+ <IndicatorPane
903
+ indicators={paneIndicators}
904
+ bars={bars}
905
+ transform={transformRef.current}
906
+ height={paneH}
907
+ theme={theme}
908
+ computedResults={computedResults}
909
+ crosshairXRef={crosshairXRef}
910
+ onRemove={handleRemoveIndicator}
911
+ onConfigure={handleConfigureIndicator}
912
+ />
913
+ {idx < panes.length - 1 && (
914
+ <Divider onDrag={(dy) => handleSubPaneDividerDrag(pane.id, dy)} />
915
+ )}
916
+ </div>
917
+ );
918
+ })}
919
+ </>
920
+ )}
921
+ </div>
922
+
923
+ {/* ── Indicator config dialog ────────────────────────────────────────── */}
924
+ {configTarget && (
925
+ <IndicatorConfigDialog
926
+ indicator={configTarget}
927
+ onSave={handleSaveConfig}
928
+ onClose={() => setConfigTarget(null)}
929
+ />
930
+ )}
931
+
932
+ {/* ── Drawing context menu ──────────────────────────────────────────── */}
933
+ {ctxMenu && (
934
+ <>
935
+ {/* Transparent backdrop to dismiss the menu on outside click */}
936
+ <div
937
+ className="drawing-ctx-backdrop"
938
+ onClick={() => setCtxMenu(null)}
939
+ onContextMenu={(e) => { e.preventDefault(); setCtxMenu(null); }}
940
+ />
941
+ <div
942
+ className="drawing-ctx-menu"
943
+ style={{ left: ctxMenu.x, top: ctxMenu.y }}
944
+ >
945
+ <button
946
+ className="drawing-ctx-item"
947
+ onClick={() => {
948
+ chartRef.current?.removeDrawing(ctxMenu.drawingId);
949
+ setCtxMenu(null);
950
+ }}
951
+ >
952
+ Remove
953
+ </button>
954
+ </div>
955
+ </>
956
+ )}
957
+
958
+ {/* ── Chart canvas context menu ─────────────────────────────────────── */}
959
+ {chartCtxMenu && (
960
+ <ChartContextMenu
961
+ x={chartCtxMenu.x}
962
+ y={chartCtxMenu.y}
963
+ onClose={() => setChartCtxMenu(null)}
964
+ onSettings={() => setSettingsOpen(true)}
965
+ onClearDrawings={handleClearDrawings}
966
+ onClearIndicators={handleClearIndicators}
967
+ onResetView={handleResetView}
968
+ />
969
+ )}
970
+
971
+ {/* ── Chart settings dialog ─────────────────────────────────────────── */}
972
+ {settingsOpen && (
973
+ <ChartSettingsDialog
974
+ initialSettings={canvasSettings}
975
+ onApply={handleApplySettings}
976
+ onClose={() => setSettingsOpen(false)}
977
+ />
978
+ )}
979
+ </div>
980
+ );
981
+ });
982
+
983
+ /** @deprecated Use ChartCanvas */
984
+ export const MultiPaneChart = ChartCanvas;