@wick-charts/react 0.2.2 → 0.3.0

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 CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@wick-charts/react",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
+ "description": "High-performance canvas timeseries charts for React — candlestick, line, bar, pie. Tree-shakeable, zero runtime deps.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mo4islona/wick-charts.git",
9
+ "directory": "packages/react"
10
+ },
11
+ "homepage": "https://mo4islona.github.io/wick-charts/",
12
+ "bugs": "https://github.com/mo4islona/wick-charts/issues",
13
+ "keywords": [
14
+ "charts",
15
+ "charting",
16
+ "canvas",
17
+ "candlestick",
18
+ "line-chart",
19
+ "bar-chart",
20
+ "pie-chart",
21
+ "timeseries",
22
+ "react",
23
+ "visualization"
24
+ ],
4
25
  "type": "module",
5
26
  "sideEffects": false,
6
27
  "main": "./dist/index.cjs",
@@ -14,7 +35,11 @@
14
35
  }
15
36
  },
16
37
  "files": [
17
- "dist"
38
+ "dist",
39
+ "src",
40
+ "LICENSE",
41
+ "!src/__tests__",
42
+ "!src/**/*.test.*"
18
43
  ],
19
44
  "publishConfig": {
20
45
  "access": "public"
@@ -24,7 +49,7 @@
24
49
  "react-dom": ">=18.0.0"
25
50
  },
26
51
  "devDependencies": {
27
- "@wick-charts/core": "^0.2.2"
52
+ "@wick-charts/core": "^0.3.0"
28
53
  },
29
54
  "scripts": {
30
55
  "build": "vite build"
@@ -0,0 +1,107 @@
1
+ import { useEffect, useLayoutEffect, useRef } from 'react';
2
+
3
+ import { type BarSeriesOptions, type TimePoint, normalizeTime } from '@wick-charts/core';
4
+
5
+ import { useChartInstance } from './context';
6
+
7
+ export interface BarSeriesProps {
8
+ /** Array of datasets — one per layer. A single-layer bar chart uses `[data]`. */
9
+ data: TimePoint[][];
10
+ options?: Partial<BarSeriesOptions>;
11
+ /** Stable series ID — same value across remounts. */
12
+ id?: string;
13
+ }
14
+
15
+ /** Only fall back to a full `setSeriesData` replace when more than this many new
16
+ * points appear in a single tick — otherwise streamed updates would always look
17
+ * like bulk loads and the renderer would clear its entrance-animation entries. */
18
+ const BULK_THRESHOLD = 20;
19
+
20
+ export function BarSeries({ data, options, id: idProp }: BarSeriesProps) {
21
+ const chart = useChartInstance();
22
+ const seriesRef = useRef<string | null>(null);
23
+ const prevLensRef = useRef<number[]>([]);
24
+ const prevFirstTimesRef = useRef<(number | null)[]>([]);
25
+ const prevLastTimesRef = useRef<(number | null)[]>([]);
26
+
27
+ useLayoutEffect(() => {
28
+ const id = chart.addBarSeries({ ...options, layers: data.length, id: idProp });
29
+ seriesRef.current = id;
30
+ prevLensRef.current = new Array(data.length).fill(0);
31
+ prevFirstTimesRef.current = new Array(data.length).fill(null);
32
+ prevLastTimesRef.current = new Array(data.length).fill(null);
33
+ return () => {
34
+ chart.removeSeries(id);
35
+ seriesRef.current = null;
36
+ prevLensRef.current = [];
37
+ prevFirstTimesRef.current = [];
38
+ prevLastTimesRef.current = [];
39
+ };
40
+ }, [chart, data.length, idProp]);
41
+
42
+ useLayoutEffect(() => {
43
+ const id = seriesRef.current;
44
+ if (!id) return;
45
+
46
+ chart.batch(() => {
47
+ for (let i = 0; i < data.length; i++) {
48
+ const layer = data[i];
49
+ const prevLen = prevLensRef.current[i] ?? 0;
50
+ const prevFirst = prevFirstTimesRef.current[i] ?? null;
51
+
52
+ if (layer.length === 0) {
53
+ chart.setSeriesData(id, [], i);
54
+ prevLensRef.current[i] = 0;
55
+ prevFirstTimesRef.current[i] = null;
56
+ prevLastTimesRef.current[i] = null;
57
+ continue;
58
+ }
59
+
60
+ const firstTime = normalizeTime(layer[0].time);
61
+ const lastTime = normalizeTime(layer[layer.length - 1].time);
62
+ const prevLast = prevLastTimesRef.current[i] ?? null;
63
+ const shifted = prevFirst !== null && prevFirst !== firstTime;
64
+ const added = layer.length - prevLen;
65
+ const hasNewLast = prevLast !== null && prevLast !== lastTime;
66
+
67
+ // Rolling-window slide (maxPoints cap): drop oldest, append newest,
68
+ // length unchanged. Sync prefix then appendData the new tail so the
69
+ // entrance animation fires instead of getting wiped by setSeriesData.
70
+ if (shifted && added === 0 && hasNewLast) {
71
+ chart.setSeriesData(id, layer.slice(0, -1), i);
72
+ chart.appendData(id, layer[layer.length - 1], i);
73
+ } else if (prevLen === 0 || layer.length < prevLen || added > BULK_THRESHOLD || shifted) {
74
+ chart.setSeriesData(id, layer, i);
75
+ } else if (layer.length === prevLen) {
76
+ chart.updateData(id, layer[layer.length - 1], i);
77
+ } else {
78
+ for (let j = prevLen; j < layer.length; j++) {
79
+ chart.appendData(id, layer[j], i);
80
+ }
81
+ }
82
+
83
+ prevLensRef.current[i] = layer.length;
84
+ prevFirstTimesRef.current[i] = firstTime;
85
+ prevLastTimesRef.current[i] = lastTime;
86
+ }
87
+ });
88
+ }, [chart, data]);
89
+
90
+ useEffect(() => {
91
+ if (seriesRef.current && options) {
92
+ chart.updateSeriesOptions(seriesRef.current, options);
93
+ }
94
+ }, [
95
+ chart,
96
+ options?.colors?.join(','),
97
+ options?.barWidthRatio,
98
+ options?.stacking,
99
+ options?.entryAnimation,
100
+ options?.enterAnimation,
101
+ options?.entryMs,
102
+ options?.enterMs,
103
+ options?.smoothMs,
104
+ ]);
105
+
106
+ return null;
107
+ }
@@ -0,0 +1,108 @@
1
+ import { useEffect, useLayoutEffect, useRef } from 'react';
2
+
3
+ import type { CandlestickSeriesOptions, OHLCInput } from '@wick-charts/core';
4
+ import { normalizeTime } from '@wick-charts/core';
5
+
6
+ import { useChartInstance } from './context';
7
+
8
+ /** Only fall back to a full `setSeriesData` replace when more than this many new
9
+ * candles appear in a single tick. Streamed bursts (OHLCStream emits up to ~8
10
+ * per 500ms) must stay under this so their appendData path still fires entrance
11
+ * animations; history loads (50/batch) deliberately exceed it. */
12
+ const BULK_THRESHOLD = 20;
13
+
14
+ export interface CandlestickSeriesProps {
15
+ data: OHLCInput[];
16
+ options?: Partial<CandlestickSeriesOptions>;
17
+ /** Stable series ID — same value across remounts. */
18
+ id?: string;
19
+ }
20
+
21
+ export function CandlestickSeries({ data, options, id: idProp }: CandlestickSeriesProps) {
22
+ const chart = useChartInstance();
23
+ const seriesRef = useRef<string | null>(null);
24
+ const prevLenRef = useRef(0);
25
+ const prevFirstTimeRef = useRef<number | null>(null);
26
+ const prevLastTimeRef = useRef<number | null>(null);
27
+
28
+ useLayoutEffect(() => {
29
+ const id = chart.addCandlestickSeries({ ...options, id: idProp });
30
+ seriesRef.current = id;
31
+ return () => {
32
+ chart.removeSeries(id);
33
+ seriesRef.current = null;
34
+ prevLenRef.current = 0;
35
+ prevFirstTimeRef.current = null;
36
+ prevLastTimeRef.current = null;
37
+ };
38
+ }, [chart, idProp]);
39
+
40
+ useLayoutEffect(() => {
41
+ const id = seriesRef.current;
42
+ if (!id) return;
43
+
44
+ if (data.length === 0) {
45
+ // Explicit clear
46
+ chart.setSeriesData(id, []);
47
+ prevLenRef.current = 0;
48
+ prevFirstTimeRef.current = null;
49
+ prevLastTimeRef.current = null;
50
+ return;
51
+ }
52
+
53
+ const prevLen = prevLenRef.current;
54
+ const prevFirst = prevFirstTimeRef.current;
55
+ const prevLast = prevLastTimeRef.current;
56
+ const firstTime = normalizeTime(data[0].time);
57
+ const lastTime = normalizeTime(data[data.length - 1].time);
58
+ const shifted = prevFirst !== null && prevFirst !== firstTime;
59
+ const added = data.length - prevLen;
60
+ const hasNewLast = prevLast !== null && prevLast !== lastTime;
61
+
62
+ // Rolling-window slide: same array length but first AND last timestamps
63
+ // advanced (old point dropped, new point appended). Must NOT fall through
64
+ // to a full `setSeriesData` — that would wipe the entrance-animation
65
+ // entries. Sync the stable prefix, then appendData the fresh tail so the
66
+ // renderer registers an entry for just the new point.
67
+ if (shifted && added === 0 && hasNewLast) {
68
+ chart.setSeriesData(id, data.slice(0, -1));
69
+ chart.appendData(id, data[data.length - 1]);
70
+ } else if (prevLen === 0 || data.length < prevLen || added > BULK_THRESHOLD || shifted) {
71
+ chart.setSeriesData(id, data);
72
+ } else if (data.length === prevLen) {
73
+ // Same length, same timestamps — last candle updated in place.
74
+ chart.updateData(id, data[data.length - 1]);
75
+ } else {
76
+ for (let i = prevLen; i < data.length; i++) {
77
+ chart.appendData(id, data[i]);
78
+ }
79
+ }
80
+
81
+ prevLenRef.current = data.length;
82
+ prevFirstTimeRef.current = firstTime;
83
+ prevLastTimeRef.current = lastTime;
84
+ }, [chart, data]);
85
+
86
+ useEffect(() => {
87
+ if (seriesRef.current && options) {
88
+ chart.updateSeriesOptions(seriesRef.current, options);
89
+ }
90
+ }, [
91
+ chart,
92
+ // Tuple bodies are new array refs on every render (preset `autoGradient()`
93
+ // output, inline literals, etc.) and would misfire `Object.is`. Collapse
94
+ // to a stable string the same way BarSeries/LineSeries handle `colors`.
95
+ Array.isArray(options?.up?.body) ? options.up.body.join(',') : options?.up?.body,
96
+ Array.isArray(options?.down?.body) ? options.down.body.join(',') : options?.down?.body,
97
+ options?.up?.wick,
98
+ options?.down?.wick,
99
+ options?.bodyWidthRatio,
100
+ options?.entryAnimation,
101
+ options?.enterAnimation,
102
+ options?.entryMs,
103
+ options?.enterMs,
104
+ options?.smoothMs,
105
+ ]);
106
+
107
+ return null;
108
+ }
@@ -0,0 +1,427 @@
1
+ import {
2
+ type CSSProperties,
3
+ Children,
4
+ Fragment,
5
+ type ReactElement,
6
+ type ReactNode,
7
+ isValidElement,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+
14
+ import { type AxisConfig, ChartInstance, type ChartOptions, type ChartTheme } from '@wick-charts/core';
15
+
16
+ type PerfOption = NonNullable<ChartOptions['perf']>;
17
+
18
+ import { ChartContext } from './context';
19
+ import { ThemeProvider, useThemeOptional } from './ThemeContext';
20
+ import { InfoBar } from './ui/InfoBar';
21
+ import { Legend, type LegendProps } from './ui/Legend';
22
+ import { PieLegend, type PieLegendProps } from './ui/PieLegend';
23
+ import { Title } from './ui/Title';
24
+
25
+ /** Props for the {@link ChartContainer} component. */
26
+ export interface ChartContainerProps {
27
+ /** Series components and UI overlays (Tooltip, TimeAxis, etc.) rendered inside the chart. */
28
+ children?: ReactNode;
29
+ /** Visual theme. Changing this at runtime will update all themed elements. */
30
+ theme?: ChartTheme;
31
+ /** Grouped axis configuration (Y/X visibility, bounds, sizing). */
32
+ axis?: AxisConfig;
33
+ /**
34
+ * Viewport padding. `top`/`bottom` are in pixels. `left`/`right` accept either pixels (`50`)
35
+ * or data intervals (`{ intervals: 3 }`). Set to 0 for edge-to-edge sparklines. Applied on mount only.
36
+ * Defaults: `{ top: 20, bottom: 20, right: { intervals: 3 }, left: { intervals: 0 } }`.
37
+ */
38
+ padding?: {
39
+ top?: number;
40
+ bottom?: number;
41
+ right?: number | { intervals: number };
42
+ left?: number | { intervals: number };
43
+ };
44
+ /** Show the chart background gradient. Defaults to true. */
45
+ gradient?: boolean;
46
+ /** Enable zoom, pan, and crosshair interactions. Defaults to true. */
47
+ interactive?: boolean;
48
+ /** Background grid configuration. Default: `{ visible: true }`. */
49
+ grid?: { visible: boolean };
50
+ /**
51
+ * How `<Title>` and `<InfoBar>` are positioned relative to the canvas.
52
+ * - `'overlay'` (default): absolute overlays on top of the canvas — the grid
53
+ * and Y-axis labels render full-height behind the header strip.
54
+ * - `'inline'`: flex siblings above the canvas — the canvas (and grid) are
55
+ * shifted down by the measured header height, so nothing renders behind
56
+ * the title. The chart background still spans the full container.
57
+ */
58
+ headerLayout?: 'overlay' | 'inline';
59
+ /**
60
+ * Enable runtime performance instrumentation. Off by default.
61
+ *
62
+ * - `true` — attach a {@link PerfMonitor} and render a visible HUD overlay on this chart.
63
+ * - `{ hud: true, windowMs, maxSamples, ... }` — same, with monitor options.
64
+ * - `{ hud: false, monitor }` — attach to an existing monitor without rendering the HUD.
65
+ *
66
+ * Only read at mount; changing this prop after the chart is created is ignored.
67
+ */
68
+ perf?: PerfOption;
69
+ style?: CSSProperties;
70
+ className?: string;
71
+ }
72
+
73
+ /**
74
+ * Split children into `<Title>`, `<Legend>`, `<InfoBar>`, and the rest.
75
+ *
76
+ * Transparently walks through `<React.Fragment>` wrappers so the caller can
77
+ * use normal React patterns — e.g. wrapping children in a conditional
78
+ * fragment or returning fragments from parent components — and still get
79
+ * hoisting. Deeper component boundaries are left alone on purpose: a custom
80
+ * component that internally renders a `<Title>` / `<InfoBar>` is its own DOM
81
+ * subtree and should stay there.
82
+ *
83
+ * Exported for testing — this is pure React-children iteration with no DOM
84
+ * dependencies, so it can be asserted in Node.
85
+ */
86
+ export function siftContainerChildren(children: ReactNode): {
87
+ titleEl: ReactElement | null;
88
+ legendEl: ReactElement<LegendProps> | null;
89
+ pieLegendEl: ReactElement<PieLegendProps> | null;
90
+ tooltipLegendEl: ReactElement | null;
91
+ overlay: ReactNode[];
92
+ } {
93
+ let titleEl: ReactElement | null = null;
94
+ let legendEl: ReactElement<LegendProps> | null = null;
95
+ let pieLegendEl: ReactElement<PieLegendProps> | null = null;
96
+ let tooltipLegendEl: ReactElement | null = null;
97
+ const overlay: ReactNode[] = [];
98
+
99
+ const visit = (child: ReactNode): void => {
100
+ if (isValidElement(child) && child.type === Fragment) {
101
+ // Unwrap fragments recursively — fragments don't produce DOM nodes,
102
+ // so a Title/Legend/InfoBar nested in one is still a layout-level sibling.
103
+ Children.forEach((child as ReactElement<{ children?: ReactNode }>).props.children, visit);
104
+ return;
105
+ }
106
+ if (isValidElement(child)) {
107
+ if (child.type === Title) {
108
+ titleEl = child;
109
+ return;
110
+ }
111
+ if (child.type === Legend) {
112
+ legendEl = child as ReactElement<LegendProps>;
113
+ return;
114
+ }
115
+ if (child.type === PieLegend) {
116
+ // `position='overlay'` opts back into the old absolute-positioned
117
+ // layout, so we leave it in the overlay array for that path only.
118
+ const typed = child as ReactElement<PieLegendProps>;
119
+ if (typed.props.position === 'overlay') {
120
+ overlay.push(child);
121
+ } else {
122
+ pieLegendEl = typed;
123
+ }
124
+ return;
125
+ }
126
+ if (child.type === InfoBar) {
127
+ tooltipLegendEl = child;
128
+ return;
129
+ }
130
+ }
131
+ overlay.push(child);
132
+ };
133
+
134
+ Children.forEach(children, visit);
135
+ return { titleEl, legendEl, pieLegendEl, tooltipLegendEl, overlay };
136
+ }
137
+
138
+ /**
139
+ * Top-level React wrapper that creates a {@link ChartInstance} and provides it to children via context.
140
+ * Owns the DOM container and canvas lifecycle; renders children as an overlay layer.
141
+ *
142
+ * Detects `<Title>`, `<InfoBar>`, and `<Legend>` children and positions them as:
143
+ * - Title + InfoBar — absolutely-positioned *overlays* stacked at the top of the canvas
144
+ * block, so the canvas (and therefore the grid) fills the full container height. The stacked
145
+ * height is measured and fed back into `chart.setPadding({ top })` so series data stays below
146
+ * them.
147
+ * - Legend — flex sibling at the bottom (or right, when `position="right"`), so its height is
148
+ * reserved by browser layout.
149
+ */
150
+ export function ChartContainer({
151
+ children,
152
+ theme,
153
+ axis,
154
+ padding,
155
+ gradient = true,
156
+ interactive,
157
+ grid,
158
+ headerLayout = 'overlay',
159
+ perf,
160
+ style,
161
+ className,
162
+ }: ChartContainerProps) {
163
+ // Mount-only: capture the initial perf option in a ref so later renders with
164
+ // a new object identity don't recreate the chart or remount the HUD.
165
+ const perfRef = useRef(perf);
166
+ const contextTheme = useThemeOptional();
167
+ const resolvedTheme = theme ?? contextTheme ?? undefined;
168
+
169
+ const containerRef = useRef<HTMLDivElement>(null);
170
+ const chartRef = useRef<ChartInstance | null>(null);
171
+ const [_, setRevision] = useState(0);
172
+
173
+ // useLayoutEffect — synchronous, runs before paint.
174
+ useLayoutEffect(() => {
175
+ if (!containerRef.current) return;
176
+ if (chartRef.current) return;
177
+
178
+ const options: ChartOptions = {};
179
+ if (axis) options.axis = axis;
180
+ if (resolvedTheme) options.theme = resolvedTheme;
181
+ if (padding) options.padding = padding;
182
+ if (interactive !== undefined) options.interactive = interactive;
183
+ if (grid !== undefined) options.grid = grid;
184
+ if (perfRef.current !== undefined) options.perf = perfRef.current;
185
+ chartRef.current = new ChartInstance(containerRef.current, options);
186
+
187
+ // Note: the init path above already propagated `grid` into the chart. The
188
+ // effect below handles live updates, but also needs to run on the same
189
+ // commit so an initial `grid={{visible:false}}` isn't silently reset.
190
+ setRevision((r) => r + 1);
191
+
192
+ return () => {
193
+ // Destroy synchronously. A previous revision deferred this through
194
+ // `setTimeout(..., 0)` to "tolerate StrictMode" but the guard was
195
+ // broken: in the StrictMode remount sequence (cleanup → second mount →
196
+ // timeout), the check `if (!chartRef.current) instance.destroy()`
197
+ // always saw the second instance and skipped the destroy — leaking
198
+ // the first ChartInstance's canvases (hence 4 canvases per chart in
199
+ // dev). StrictMode exists precisely to exercise cleanup; a correct
200
+ // `destroy` is cheap enough to run on every cycle.
201
+ chartRef.current?.destroy();
202
+ chartRef.current = null;
203
+ };
204
+ }, []);
205
+
206
+ useEffect(() => {
207
+ if (chartRef.current && resolvedTheme) {
208
+ chartRef.current.setTheme(resolvedTheme);
209
+ }
210
+ }, [resolvedTheme]);
211
+
212
+ useEffect(() => {
213
+ if (chartRef.current && axis) {
214
+ chartRef.current.setAxis(axis);
215
+ }
216
+ }, [axis?.y?.width, axis?.y?.min, axis?.y?.max, axis?.y?.visible, axis?.x?.height, axis?.x?.visible]);
217
+
218
+ // Top-overlay height (title + info bar) — measured below. Declared here so
219
+ // the padding effect can fold it into `padding.top`.
220
+ const topOverlayRef = useRef<HTMLDivElement>(null);
221
+ const [topOverlayHeight, setTopOverlayHeight] = useState(0);
222
+
223
+ // In 'inline' mode the canvas itself is shorter (browser flex reserves the
224
+ // header height), so adding topOverlayHeight here would double-shift the
225
+ // data. Only the overlay mode needs the fold-in. Depend on `headerExtra`
226
+ // below instead of `topOverlayHeight` so inline-mode header resizes don't
227
+ // fire redundant `chart.setPadding(...)` calls (headerExtra stays 0).
228
+ const headerExtra = headerLayout === 'overlay' ? topOverlayHeight : 0;
229
+
230
+ useEffect(() => {
231
+ const current = chartRef.current;
232
+ if (!current) return;
233
+ const userTop = padding?.top ?? 20;
234
+ const merged: ChartOptions['padding'] = {
235
+ top: userTop + headerExtra,
236
+ ...(padding?.bottom !== undefined ? { bottom: padding.bottom } : {}),
237
+ ...(padding?.right !== undefined ? { right: padding.right } : {}),
238
+ ...(padding?.left !== undefined ? { left: padding.left } : {}),
239
+ };
240
+ current.setPadding(merged);
241
+ }, [
242
+ padding?.top,
243
+ padding?.bottom,
244
+ typeof padding?.right === 'object' ? padding.right.intervals : padding?.right,
245
+ typeof padding?.left === 'object' ? padding.left.intervals : padding?.left,
246
+ headerExtra,
247
+ ]);
248
+
249
+ useEffect(() => {
250
+ if (chartRef.current && grid !== undefined) {
251
+ chartRef.current.setGrid(grid);
252
+ }
253
+ }, [grid?.visible]);
254
+
255
+ const chart = chartRef.current;
256
+
257
+ const { titleEl, legendEl, pieLegendEl, tooltipLegendEl, overlay } = siftContainerChildren(children);
258
+ const legendPosition = legendEl?.props.position ?? 'bottom';
259
+ const pieLegendPosition = pieLegendEl?.props.position ?? 'bottom';
260
+ // Either legend type can pull the layout into row-mode. `Legend` and
261
+ // `PieLegend` are mutually exclusive in practice (line vs pie chart), so we
262
+ // just OR the two position checks.
263
+ const isLegendRight = legendPosition === 'right' || pieLegendPosition === 'right';
264
+
265
+ const effectiveTheme = resolvedTheme ?? chart?.getTheme();
266
+ const [gtop, gbot] = effectiveTheme?.chartGradient ?? ['transparent', 'transparent'];
267
+ const bg = effectiveTheme?.background ?? 'transparent';
268
+ const backgroundStyle = gradient ? `linear-gradient(to bottom, ${gtop} 0%, ${bg} 70%, ${gbot} 100%)` : bg;
269
+
270
+ // Measure the stacked overlay (Title + InfoBar) height and feed it
271
+ // into the padding effect above so data stays below them even though the
272
+ // canvas itself fills the whole container. Only needed in 'overlay' mode —
273
+ // 'inline' mode lets browser flex layout reserve header height directly,
274
+ // so we skip the ResizeObserver entirely and clear any stale measurement.
275
+ useLayoutEffect(() => {
276
+ if (headerLayout !== 'overlay') {
277
+ setTopOverlayHeight(0);
278
+ return;
279
+ }
280
+ const el = topOverlayRef.current;
281
+ if (!el) {
282
+ // When neither Title nor InfoBar is present the overlay wrapper
283
+ // isn't rendered — clear any stale measured height so `padding.top`
284
+ // drops back to the user's configured value on the next effect run.
285
+ setTopOverlayHeight(0);
286
+ return;
287
+ }
288
+ const update = () => setTopOverlayHeight(el.getBoundingClientRect().height);
289
+ update();
290
+ const ro = new ResizeObserver(update);
291
+ ro.observe(el);
292
+ return () => ro.disconnect();
293
+ // `chart !== null` is in deps so the measurement re-runs once the
294
+ // ChartInstance is attached — on the first pass the overlay wrapper is
295
+ // gated behind `chart && (...)` so the ref is null; without this dep
296
+ // React wouldn't re-fire when the overlay finally mounts.
297
+ }, [titleEl !== null, tooltipLegendEl !== null, headerLayout, chart !== null]);
298
+
299
+ const headerStack = (titleEl || tooltipLegendEl) && (
300
+ <div
301
+ data-chart-header=""
302
+ data-chart-top-overlay={headerLayout === 'overlay' ? '' : undefined}
303
+ ref={topOverlayRef}
304
+ style={
305
+ headerLayout === 'overlay'
306
+ ? {
307
+ position: 'absolute',
308
+ top: 0,
309
+ left: 0,
310
+ right: 0,
311
+ // Lower than the series-overlay layer below, so the floating
312
+ // <Tooltip> glass panel renders *above* Title/InfoBar
313
+ // when the cursor hovers near them.
314
+ zIndex: 2,
315
+ pointerEvents: 'none',
316
+ display: 'flex',
317
+ flexDirection: 'column',
318
+ }
319
+ : {
320
+ flexShrink: 0,
321
+ display: 'flex',
322
+ flexDirection: 'column',
323
+ pointerEvents: 'none',
324
+ }
325
+ }
326
+ >
327
+ {titleEl}
328
+ {tooltipLegendEl}
329
+ </div>
330
+ );
331
+
332
+ const chartInner = (
333
+ <div
334
+ ref={containerRef}
335
+ style={{
336
+ position: 'relative',
337
+ flex: 1,
338
+ minWidth: 0,
339
+ minHeight: 0,
340
+ overflow: 'hidden',
341
+ }}
342
+ >
343
+ {chart && (
344
+ <ChartContext.Provider value={chart}>
345
+ <ThemeProvider value={resolvedTheme ?? chart.getTheme()}>
346
+ {headerLayout === 'overlay' && headerStack}
347
+ <div
348
+ data-chart-series-overlay=""
349
+ style={{
350
+ position: 'absolute',
351
+ inset: 0,
352
+ pointerEvents: 'none',
353
+ zIndex: 3,
354
+ }}
355
+ >
356
+ {overlay}
357
+ </div>
358
+ </ThemeProvider>
359
+ </ChartContext.Provider>
360
+ )}
361
+ </div>
362
+ );
363
+
364
+ const canvasBlock =
365
+ headerLayout === 'inline' ? (
366
+ <div
367
+ data-chart-canvas-block=""
368
+ style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, minHeight: 0 }}
369
+ >
370
+ {chart && headerStack && (
371
+ <ChartContext.Provider value={chart}>
372
+ <ThemeProvider value={resolvedTheme ?? chart.getTheme()}>{headerStack}</ThemeProvider>
373
+ </ChartContext.Provider>
374
+ )}
375
+ {chartInner}
376
+ </div>
377
+ ) : (
378
+ chartInner
379
+ );
380
+
381
+ const hoistedLegend = chart && legendEl && (
382
+ <ChartContext.Provider value={chart}>
383
+ <ThemeProvider value={resolvedTheme ?? chart.getTheme()}>{legendEl}</ThemeProvider>
384
+ </ChartContext.Provider>
385
+ );
386
+
387
+ const hoistedPieLegend = chart && pieLegendEl && (
388
+ <ChartContext.Provider value={chart}>
389
+ <ThemeProvider value={resolvedTheme ?? chart.getTheme()}>{pieLegendEl}</ThemeProvider>
390
+ </ChartContext.Provider>
391
+ );
392
+
393
+ return (
394
+ <div
395
+ className={className}
396
+ style={{
397
+ display: 'flex',
398
+ flexDirection: 'column',
399
+ width: '100%',
400
+ height: '100%',
401
+ overflow: 'hidden',
402
+ background: backgroundStyle,
403
+ ...style,
404
+ }}
405
+ >
406
+ {/* One stable wrapper for both legend positions. Keeping the tree
407
+ structure identical means React reconciles canvasBlock in place
408
+ when `isLegendRight` flips, preserving the canvas element and
409
+ letting its ResizeObserver re-layout the chart in response to
410
+ the new flex bounds. A branching <> ↔ <div> swap would remount
411
+ the canvas and throw away chart state. */}
412
+ <div
413
+ style={{
414
+ display: 'flex',
415
+ flexDirection: isLegendRight ? 'row' : 'column',
416
+ flex: 1,
417
+ minHeight: 0,
418
+ minWidth: 0,
419
+ }}
420
+ >
421
+ {canvasBlock}
422
+ {hoistedLegend}
423
+ {hoistedPieLegend}
424
+ </div>
425
+ </div>
426
+ );
427
+ }