@wick-charts/react 0.2.1 → 0.2.3
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/README.md +10 -12
- package/dist/index.cjs +2 -2
- package/dist/index.d.ts +5 -11
- package/dist/index.js +195 -197
- package/package.json +28 -3
- package/src/BarSeries.tsx +107 -0
- package/src/CandlestickSeries.tsx +107 -0
- package/src/ChartContainer.tsx +427 -0
- package/src/LineSeries.tsx +110 -0
- package/src/PieSeries.tsx +59 -0
- package/src/ThemeContext.tsx +21 -0
- package/src/context.ts +13 -0
- package/src/index.ts +120 -0
- package/src/store-bridge.ts +100 -0
- package/src/ui/Crosshair.tsx +61 -0
- package/src/ui/InfoBar.tsx +194 -0
- package/src/ui/Legend.tsx +274 -0
- package/src/ui/NumberFlow.tsx +118 -0
- package/src/ui/PieLegend.tsx +152 -0
- package/src/ui/PieTooltip.tsx +204 -0
- package/src/ui/Sparkline.tsx +214 -0
- package/src/ui/TimeAxis.tsx +112 -0
- package/src/ui/Title.tsx +62 -0
- package/src/ui/Tooltip.tsx +324 -0
- package/src/ui/YAxis.tsx +122 -0
- package/src/ui/YLabel.tsx +167 -0
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wick-charts/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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.
|
|
52
|
+
"@wick-charts/core": "^0.2.3"
|
|
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,107 @@
|
|
|
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
|
+
options?.upColor,
|
|
93
|
+
options?.downColor,
|
|
94
|
+
options?.wickUpColor,
|
|
95
|
+
options?.wickDownColor,
|
|
96
|
+
options?.bodyWidthRatio,
|
|
97
|
+
options?.bodyGradient,
|
|
98
|
+
options?.candleGradient,
|
|
99
|
+
options?.entryAnimation,
|
|
100
|
+
options?.enterAnimation,
|
|
101
|
+
options?.entryMs,
|
|
102
|
+
options?.enterMs,
|
|
103
|
+
options?.smoothMs,
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
@@ -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
|
+
}
|