@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,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;
|