@wick-charts/react 0.3.5 → 0.4.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/README.md +6 -6
- package/dist/index.cjs +2 -2
- package/dist/index.d.ts +889 -335
- package/dist/index.js +4761 -3949
- package/package.json +3 -3
- package/src/BarSeries.tsx +18 -56
- package/src/CandlestickSeries.tsx +11 -57
- package/src/ChartContainer.tsx +97 -51
- package/src/EdgeLoader.tsx +187 -0
- package/src/LineSeries.tsx +18 -56
- package/src/PieSeries.tsx +1 -2
- package/src/index.ts +14 -4
- package/src/ui/Crosshair.tsx +5 -1
- package/src/ui/InfoBar.tsx +35 -1
- package/src/ui/Sparkline.tsx +95 -2
- package/src/ui/TimeAxis.tsx +13 -72
- package/src/ui/Title.tsx +33 -29
- package/src/ui/YAxis.tsx +11 -67
- package/src/ui/axisFade.ts +0 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wick-charts/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "High-performance canvas timeseries charts for React — candlestick, line, bar, pie. Tree-shakeable, zero runtime deps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"url": "git+https://github.com/mo4islona/wick-charts.git",
|
|
9
9
|
"directory": "packages/react"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://
|
|
11
|
+
"homepage": "https://wick-charts.eeff.io/",
|
|
12
12
|
"bugs": "https://github.com/mo4islona/wick-charts/issues",
|
|
13
13
|
"keywords": [
|
|
14
14
|
"charts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"react-dom": ">=18.0.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@wick-charts/core": "^0.
|
|
52
|
+
"@wick-charts/core": "^0.4.0"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"build": "vite build"
|
package/src/BarSeries.tsx
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type BarSeriesOptions,
|
|
5
|
+
EMPTY_SYNC_STATE,
|
|
6
|
+
type SeriesSyncState,
|
|
7
|
+
type TimePoint,
|
|
8
|
+
syncSeriesLayer,
|
|
9
|
+
} from '@wick-charts/core';
|
|
4
10
|
|
|
5
11
|
import { useChartInstance } from './context';
|
|
6
12
|
|
|
@@ -13,30 +19,19 @@ export interface BarSeriesProps {
|
|
|
13
19
|
id?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
/** Only fall back to a full `setSeriesData` replace when more than this many new
|
|
17
|
-
* points appear in a single tick — otherwise streamed updates would always look
|
|
18
|
-
* like bulk loads and the renderer would clear its entrance-animation entries. */
|
|
19
|
-
const BULK_THRESHOLD = 20;
|
|
20
|
-
|
|
21
22
|
export function BarSeries({ data, options, id: idProp }: BarSeriesProps) {
|
|
22
23
|
const chart = useChartInstance();
|
|
23
24
|
const seriesRef = useRef<string | null>(null);
|
|
24
|
-
const
|
|
25
|
-
const prevFirstTimesRef = useRef<(number | null)[]>([]);
|
|
26
|
-
const prevLastTimesRef = useRef<(number | null)[]>([]);
|
|
25
|
+
const prevSyncRef = useRef<SeriesSyncState[]>([]);
|
|
27
26
|
|
|
28
27
|
useLayoutEffect(() => {
|
|
29
|
-
const id = chart.
|
|
28
|
+
const id = chart.addSeries('bar', { ...options, layers: data.length, id: idProp });
|
|
30
29
|
seriesRef.current = id;
|
|
31
|
-
|
|
32
|
-
prevFirstTimesRef.current = new Array(data.length).fill(null);
|
|
33
|
-
prevLastTimesRef.current = new Array(data.length).fill(null);
|
|
30
|
+
prevSyncRef.current = new Array(data.length).fill(EMPTY_SYNC_STATE);
|
|
34
31
|
return () => {
|
|
35
32
|
chart.removeSeries(id);
|
|
36
33
|
seriesRef.current = null;
|
|
37
|
-
|
|
38
|
-
prevFirstTimesRef.current = [];
|
|
39
|
-
prevLastTimesRef.current = [];
|
|
34
|
+
prevSyncRef.current = [];
|
|
40
35
|
};
|
|
41
36
|
}, [chart, data.length, idProp]);
|
|
42
37
|
|
|
@@ -46,44 +41,13 @@ export function BarSeries({ data, options, id: idProp }: BarSeriesProps) {
|
|
|
46
41
|
|
|
47
42
|
chart.batch(() => {
|
|
48
43
|
for (let i = 0; i < data.length; i++) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
prevFirstTimesRef.current[i] = null;
|
|
57
|
-
prevLastTimesRef.current[i] = null;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const firstTime = normalizeTime(layer[0].time);
|
|
62
|
-
const lastTime = normalizeTime(layer[layer.length - 1].time);
|
|
63
|
-
const prevLast = prevLastTimesRef.current[i] ?? null;
|
|
64
|
-
const shifted = prevFirst !== null && prevFirst !== firstTime;
|
|
65
|
-
const added = layer.length - prevLen;
|
|
66
|
-
const hasNewLast = prevLast !== null && prevLast !== lastTime;
|
|
67
|
-
|
|
68
|
-
// Rolling-window slide (maxPoints cap): drop oldest, append newest,
|
|
69
|
-
// length unchanged. Sync prefix then appendData the new tail so the
|
|
70
|
-
// entrance animation fires instead of getting wiped by setSeriesData.
|
|
71
|
-
if (shifted && added === 0 && hasNewLast) {
|
|
72
|
-
chart.setSeriesData(id, layer.slice(0, -1), i);
|
|
73
|
-
chart.appendData(id, layer[layer.length - 1], i);
|
|
74
|
-
} else if (prevLen === 0 || layer.length < prevLen || added > BULK_THRESHOLD || shifted) {
|
|
75
|
-
chart.setSeriesData(id, layer, i);
|
|
76
|
-
} else if (layer.length === prevLen) {
|
|
77
|
-
chart.updateData(id, layer[layer.length - 1], i);
|
|
78
|
-
} else {
|
|
79
|
-
for (let j = prevLen; j < layer.length; j++) {
|
|
80
|
-
chart.appendData(id, layer[j], i);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
prevLensRef.current[i] = layer.length;
|
|
85
|
-
prevFirstTimesRef.current[i] = firstTime;
|
|
86
|
-
prevLastTimesRef.current[i] = lastTime;
|
|
44
|
+
prevSyncRef.current[i] = syncSeriesLayer({
|
|
45
|
+
chart,
|
|
46
|
+
id,
|
|
47
|
+
data: data[i],
|
|
48
|
+
prev: prevSyncRef.current[i] ?? EMPTY_SYNC_STATE,
|
|
49
|
+
layerIndex: i,
|
|
50
|
+
});
|
|
87
51
|
}
|
|
88
52
|
});
|
|
89
53
|
}, [chart, data]);
|
|
@@ -98,9 +62,7 @@ export function BarSeries({ data, options, id: idProp }: BarSeriesProps) {
|
|
|
98
62
|
options?.barWidthRatio,
|
|
99
63
|
options?.stacking,
|
|
100
64
|
options?.entryAnimation,
|
|
101
|
-
options?.enterAnimation,
|
|
102
65
|
options?.entryMs,
|
|
103
|
-
options?.enterMs,
|
|
104
66
|
options?.smoothMs,
|
|
105
67
|
]);
|
|
106
68
|
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
type CandlestickSeriesOptions,
|
|
5
|
+
EMPTY_SYNC_STATE,
|
|
6
|
+
type OHLCInput,
|
|
7
|
+
type SeriesSyncState,
|
|
8
|
+
syncSeriesLayer,
|
|
9
|
+
} from '@wick-charts/core';
|
|
5
10
|
|
|
6
11
|
import { useChartInstance } from './context';
|
|
7
12
|
|
|
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
13
|
export interface CandlestickSeriesProps {
|
|
15
14
|
/** OHLC candles to render. Each element carries `time/open/high/low/close` and an optional `volume`. */
|
|
16
15
|
data: OHLCInput[];
|
|
@@ -23,19 +22,15 @@ export interface CandlestickSeriesProps {
|
|
|
23
22
|
export function CandlestickSeries({ data, options, id: idProp }: CandlestickSeriesProps) {
|
|
24
23
|
const chart = useChartInstance();
|
|
25
24
|
const seriesRef = useRef<string | null>(null);
|
|
26
|
-
const
|
|
27
|
-
const prevFirstTimeRef = useRef<number | null>(null);
|
|
28
|
-
const prevLastTimeRef = useRef<number | null>(null);
|
|
25
|
+
const prevSyncRef = useRef<SeriesSyncState>(EMPTY_SYNC_STATE);
|
|
29
26
|
|
|
30
27
|
useLayoutEffect(() => {
|
|
31
|
-
const id = chart.
|
|
28
|
+
const id = chart.addSeries('candlestick', { ...options, id: idProp });
|
|
32
29
|
seriesRef.current = id;
|
|
33
30
|
return () => {
|
|
34
31
|
chart.removeSeries(id);
|
|
35
32
|
seriesRef.current = null;
|
|
36
|
-
|
|
37
|
-
prevFirstTimeRef.current = null;
|
|
38
|
-
prevLastTimeRef.current = null;
|
|
33
|
+
prevSyncRef.current = EMPTY_SYNC_STATE;
|
|
39
34
|
};
|
|
40
35
|
}, [chart, idProp]);
|
|
41
36
|
|
|
@@ -43,46 +38,7 @@ export function CandlestickSeries({ data, options, id: idProp }: CandlestickSeri
|
|
|
43
38
|
const id = seriesRef.current;
|
|
44
39
|
if (!id) return;
|
|
45
40
|
|
|
46
|
-
|
|
47
|
-
// Explicit clear
|
|
48
|
-
chart.setSeriesData(id, []);
|
|
49
|
-
prevLenRef.current = 0;
|
|
50
|
-
prevFirstTimeRef.current = null;
|
|
51
|
-
prevLastTimeRef.current = null;
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const prevLen = prevLenRef.current;
|
|
56
|
-
const prevFirst = prevFirstTimeRef.current;
|
|
57
|
-
const prevLast = prevLastTimeRef.current;
|
|
58
|
-
const firstTime = normalizeTime(data[0].time);
|
|
59
|
-
const lastTime = normalizeTime(data[data.length - 1].time);
|
|
60
|
-
const shifted = prevFirst !== null && prevFirst !== firstTime;
|
|
61
|
-
const added = data.length - prevLen;
|
|
62
|
-
const hasNewLast = prevLast !== null && prevLast !== lastTime;
|
|
63
|
-
|
|
64
|
-
// Rolling-window slide: same array length but first AND last timestamps
|
|
65
|
-
// advanced (old point dropped, new point appended). Must NOT fall through
|
|
66
|
-
// to a full `setSeriesData` — that would wipe the entrance-animation
|
|
67
|
-
// entries. Sync the stable prefix, then appendData the fresh tail so the
|
|
68
|
-
// renderer registers an entry for just the new point.
|
|
69
|
-
if (shifted && added === 0 && hasNewLast) {
|
|
70
|
-
chart.setSeriesData(id, data.slice(0, -1));
|
|
71
|
-
chart.appendData(id, data[data.length - 1]);
|
|
72
|
-
} else if (prevLen === 0 || data.length < prevLen || added > BULK_THRESHOLD || shifted) {
|
|
73
|
-
chart.setSeriesData(id, data);
|
|
74
|
-
} else if (data.length === prevLen) {
|
|
75
|
-
// Same length, same timestamps — last candle updated in place.
|
|
76
|
-
chart.updateData(id, data[data.length - 1]);
|
|
77
|
-
} else {
|
|
78
|
-
for (let i = prevLen; i < data.length; i++) {
|
|
79
|
-
chart.appendData(id, data[i]);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
prevLenRef.current = data.length;
|
|
84
|
-
prevFirstTimeRef.current = firstTime;
|
|
85
|
-
prevLastTimeRef.current = lastTime;
|
|
41
|
+
prevSyncRef.current = syncSeriesLayer({ chart, id, data, prev: prevSyncRef.current });
|
|
86
42
|
}, [chart, data]);
|
|
87
43
|
|
|
88
44
|
useEffect(() => {
|
|
@@ -100,9 +56,7 @@ export function CandlestickSeries({ data, options, id: idProp }: CandlestickSeri
|
|
|
100
56
|
options?.down?.wick,
|
|
101
57
|
options?.bodyWidthRatio,
|
|
102
58
|
options?.entryAnimation,
|
|
103
|
-
options?.enterAnimation,
|
|
104
59
|
options?.entryMs,
|
|
105
|
-
options?.enterMs,
|
|
106
60
|
options?.smoothMs,
|
|
107
61
|
]);
|
|
108
62
|
|
package/src/ChartContainer.tsx
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
ChartInstance,
|
|
18
18
|
type ChartOptions,
|
|
19
19
|
type ChartTheme,
|
|
20
|
+
type EdgeReachedInfo,
|
|
21
|
+
type VisibleRangeSpec,
|
|
20
22
|
} from '@wick-charts/core';
|
|
21
23
|
|
|
22
24
|
type PerfOption = NonNullable<ChartOptions['perf']>;
|
|
@@ -62,6 +64,30 @@ export interface ChartContainerProps {
|
|
|
62
64
|
*/
|
|
63
65
|
left?: number | { intervals: number };
|
|
64
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* Viewport-level streaming behavior. Captured at mount only — changing this
|
|
69
|
+
* prop after the chart is created is ignored.
|
|
70
|
+
*/
|
|
71
|
+
viewport?: {
|
|
72
|
+
/**
|
|
73
|
+
* Width of the visible window in data bars, set on the first data load
|
|
74
|
+
* to `maxVisibleBars * dataInterval`. While the dataset is smaller than
|
|
75
|
+
* this width, streaming ticks render into the empty right-side gap and
|
|
76
|
+
* the viewport stays put; once the data reaches the right edge, the
|
|
77
|
+
* viewport pans forward to keep the latest bar pinned (tail-scroll).
|
|
78
|
+
* Default: 200.
|
|
79
|
+
*/
|
|
80
|
+
maxVisibleBars?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Initial visible range applied before the first paint with data. Same
|
|
83
|
+
* shape as the imperative `chart.setVisibleRange` — pass a bar count
|
|
84
|
+
* (e.g. `35`), an explicit `{from, to}` window, or `{from, bars}` for
|
|
85
|
+
* a warm-up pair. The standard alternative is calling
|
|
86
|
+
* `setVisibleRange` from a `useEffect`, but that runs post-paint and
|
|
87
|
+
* makes the chart visibly re-zoom on the next RAF. Captured at mount.
|
|
88
|
+
*/
|
|
89
|
+
initialRange?: VisibleRangeSpec;
|
|
90
|
+
};
|
|
65
91
|
/** Show the chart background gradient. Defaults to true. */
|
|
66
92
|
gradient?: boolean;
|
|
67
93
|
/** Enable zoom, pan, and crosshair interactions. Defaults to true. */
|
|
@@ -81,33 +107,16 @@ export interface ChartContainerProps {
|
|
|
81
107
|
*/
|
|
82
108
|
headerLayout?: 'overlay' | 'inline';
|
|
83
109
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* - **Chart-level (this prop)** — `animations.points.{enterMs, smoothMs,
|
|
90
|
-
* pulseMs}` and `animations.viewport.{reboundMs, yAxisMs,
|
|
91
|
-
* inputResponseMs}`. Acts as the default for every series.
|
|
92
|
-
* - **Per-series** — `<LineSeries options={{ entryMs, smoothMs, pulseMs }}>`
|
|
93
|
-
* (and the analogous CandlestickSeries / BarSeries options). Overrides
|
|
94
|
-
* the chart-level default for that one series. Note the spelling:
|
|
95
|
-
* `entryMs` per-series, `enterMs` chart-level — historical artefact,
|
|
96
|
-
* both refer to the same animation.
|
|
110
|
+
* Animation control. `true` / omitted uses built-in defaults; `false`
|
|
111
|
+
* disables every category. Per-series options on `<LineSeries>` /
|
|
112
|
+
* `<CandlestickSeries>` / `<BarSeries>` override these chart-level
|
|
113
|
+
* defaults unless the category here is explicitly `false`.
|
|
97
114
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* - `true` / omitted — built-in defaults (every settling animation 250 ms,
|
|
104
|
-
* pulse cycle 600 ms, input ease 0 / off).
|
|
105
|
-
* - `false` — disables every animation category.
|
|
106
|
-
* - `{ points: false }` / `{ viewport: false }` — disables a category.
|
|
107
|
-
*
|
|
108
|
-
* Runtime updates: changing this prop after mount calls
|
|
109
|
-
* `chart.setAnimations(...)` so the new durations take effect on the next
|
|
110
|
-
* animation / render.
|
|
115
|
+
* **Init-only by reference identity.** A new `animations` object
|
|
116
|
+
* recreates the underlying `ChartInstance` (and its canvas). Wrap the
|
|
117
|
+
* value in `useMemo(() => ({...}), [deps])` so an unstable parent
|
|
118
|
+
* render doesn't tear down the chart every commit. In dev mode the
|
|
119
|
+
* container emits a console warning when it detects >3 recreates / s.
|
|
111
120
|
*/
|
|
112
121
|
animations?: boolean | AnimationsConfig;
|
|
113
122
|
/**
|
|
@@ -120,6 +129,18 @@ export interface ChartContainerProps {
|
|
|
120
129
|
* Only read at mount; changing this prop after the chart is created is ignored.
|
|
121
130
|
*/
|
|
122
131
|
perf?: PerfOption;
|
|
132
|
+
/**
|
|
133
|
+
* Fired after the user releases a pan/zoom gesture that pulled the viewport
|
|
134
|
+
* past a data edge by more than ~10% of the visible range. Hosts typically
|
|
135
|
+
* respond by prefetching more history.
|
|
136
|
+
*
|
|
137
|
+
* For threshold-based prefetch (load *before* the user fully overshoots),
|
|
138
|
+
* use `<EdgeLoader>` instead — that component subscribes to `viewportChange`
|
|
139
|
+
* and arms when the visible range nears the data edge.
|
|
140
|
+
*
|
|
141
|
+
* Captured at mount only; changing the prop identity later is ignored.
|
|
142
|
+
*/
|
|
143
|
+
onEdgeReached?: (info: EdgeReachedInfo) => void;
|
|
123
144
|
/** Inline style for the chart's outer wrapper element. */
|
|
124
145
|
style?: CSSProperties;
|
|
125
146
|
/** Extra class for the chart's outer wrapper element. */
|
|
@@ -214,18 +235,22 @@ export function ChartContainer({
|
|
|
214
235
|
theme,
|
|
215
236
|
axis,
|
|
216
237
|
padding,
|
|
238
|
+
viewport,
|
|
217
239
|
gradient = true,
|
|
218
240
|
interactive,
|
|
219
241
|
grid,
|
|
220
242
|
headerLayout = 'overlay',
|
|
221
243
|
perf,
|
|
222
244
|
animations,
|
|
245
|
+
onEdgeReached,
|
|
223
246
|
style,
|
|
224
247
|
className,
|
|
225
248
|
}: ChartContainerProps) {
|
|
226
249
|
// Mount-only: capture the initial perf option in a ref so later renders with
|
|
227
250
|
// a new object identity don't recreate the chart or remount the HUD.
|
|
228
251
|
const perfRef = useRef(perf);
|
|
252
|
+
// Same mount-only capture for the edge callback — the chart binds it once.
|
|
253
|
+
const onEdgeReachedRef = useRef(onEdgeReached);
|
|
229
254
|
const contextTheme = useThemeOptional();
|
|
230
255
|
const resolvedTheme = theme ?? contextTheme ?? undefined;
|
|
231
256
|
|
|
@@ -233,39 +258,65 @@ export function ChartContainer({
|
|
|
233
258
|
const chartRef = useRef<ChartInstance | null>(null);
|
|
234
259
|
const [_, setRevision] = useState(0);
|
|
235
260
|
|
|
236
|
-
//
|
|
261
|
+
// Dev-only: warn when `animations` reference identity changes more than
|
|
262
|
+
// three times per second. The most common cause is a parent re-rendering
|
|
263
|
+
// with a fresh inline object; ChartInstance is no longer reconfigurable
|
|
264
|
+
// post-construction, so each new reference is a full canvas teardown.
|
|
265
|
+
const recreateStampsRef = useRef<number[]>([]);
|
|
266
|
+
|
|
267
|
+
// useLayoutEffect — synchronous, runs before paint. Re-runs when
|
|
268
|
+
// `animations` changes by reference: chart-level animation timings, the
|
|
269
|
+
// Y transition factory and per-series timings are init-only contracts
|
|
270
|
+
// in Phase 2, so a new `animations` object is a full rebuild.
|
|
271
|
+
//
|
|
272
|
+
// TODO(api): this identity-based init-only contract is fragile — any
|
|
273
|
+
// streaming caller that forgets to wrap `animations` in useMemo gets
|
|
274
|
+
// a destroy+recreate on every tick (see docs/pages/stress/streaming.tsx
|
|
275
|
+
// for the worked example: ~30 rebuilds/sec at 32ms intervals). Either
|
|
276
|
+
// diff structurally here, or split the init-only sub-fields (the Y
|
|
277
|
+
// transition factory and per-series timings) into a separate prop with
|
|
278
|
+
// a name that signals "stable identity required", so the common
|
|
279
|
+
// chart-level timings can stay live-updatable.
|
|
237
280
|
useLayoutEffect(() => {
|
|
238
281
|
if (!containerRef.current) return;
|
|
239
|
-
if (chartRef.current) return;
|
|
240
282
|
|
|
241
283
|
const options: ChartOptions = {};
|
|
242
284
|
if (axis) options.axis = axis;
|
|
243
285
|
if (resolvedTheme) options.theme = resolvedTheme;
|
|
244
286
|
if (padding) options.padding = padding;
|
|
287
|
+
if (viewport) options.viewport = viewport;
|
|
245
288
|
if (interactive !== undefined) options.interactive = interactive;
|
|
246
289
|
if (grid !== undefined) options.grid = grid;
|
|
247
290
|
if (perfRef.current !== undefined) options.perf = perfRef.current;
|
|
248
291
|
if (animations !== undefined) options.animations = animations;
|
|
292
|
+
if (onEdgeReachedRef.current) options.onEdgeReached = onEdgeReachedRef.current;
|
|
249
293
|
chartRef.current = new ChartInstance(containerRef.current, options);
|
|
250
294
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
295
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
296
|
+
const now = performance.now();
|
|
297
|
+
const stamps = recreateStampsRef.current;
|
|
298
|
+
stamps.push(now);
|
|
299
|
+
while (stamps.length > 0 && now - stamps[0] > 1000) stamps.shift();
|
|
300
|
+
if (stamps.length > 3) {
|
|
301
|
+
console.warn(
|
|
302
|
+
'[wick-charts] <ChartContainer> recreated the chart >3 times in the last second. ' +
|
|
303
|
+
'The `animations` prop is init-only — wrap it in useMemo(() => ({...}), [deps]) ' +
|
|
304
|
+
'so a stable reference identity prevents tear-down on every render.',
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The init path above already propagated `grid` into the chart. The
|
|
310
|
+
// effect below also writes it for live updates, but it needs to fire
|
|
311
|
+
// on the same commit so an initial `grid={{visible:false}}` isn't
|
|
312
|
+
// silently reset.
|
|
254
313
|
setRevision((r) => r + 1);
|
|
255
314
|
|
|
256
315
|
return () => {
|
|
257
|
-
// Destroy synchronously. A previous revision deferred this through
|
|
258
|
-
// `setTimeout(..., 0)` to "tolerate StrictMode" but the guard was
|
|
259
|
-
// broken: in the StrictMode remount sequence (cleanup → second mount →
|
|
260
|
-
// timeout), the check `if (!chartRef.current) instance.destroy()`
|
|
261
|
-
// always saw the second instance and skipped the destroy — leaking
|
|
262
|
-
// the first ChartInstance's canvases (hence 4 canvases per chart in
|
|
263
|
-
// dev). StrictMode exists precisely to exercise cleanup; a correct
|
|
264
|
-
// `destroy` is cheap enough to run on every cycle.
|
|
265
316
|
chartRef.current?.destroy();
|
|
266
317
|
chartRef.current = null;
|
|
267
318
|
};
|
|
268
|
-
}, []);
|
|
319
|
+
}, [animations]);
|
|
269
320
|
|
|
270
321
|
useEffect(() => {
|
|
271
322
|
if (chartRef.current && resolvedTheme) {
|
|
@@ -279,16 +330,6 @@ export function ChartContainer({
|
|
|
279
330
|
}
|
|
280
331
|
}, [axis?.y?.width, axis?.y?.min, axis?.y?.max, axis?.y?.visible, axis?.x?.height, axis?.x?.visible]);
|
|
281
332
|
|
|
282
|
-
useEffect(() => {
|
|
283
|
-
if (chartRef.current && animations !== undefined) {
|
|
284
|
-
chartRef.current.setAnimations(animations);
|
|
285
|
-
}
|
|
286
|
-
// Dep array is the JSON shape of the config — covers both the boolean
|
|
287
|
-
// shorthand and the full object. Cheap to stringify (the object is tiny)
|
|
288
|
-
// and lets callers pass a fresh reference each render without thrashing
|
|
289
|
-
// animator state when nothing has actually changed.
|
|
290
|
-
}, [JSON.stringify(animations)]);
|
|
291
|
-
|
|
292
333
|
// Top-overlay height (title + info bar) — measured below. Declared here so
|
|
293
334
|
// the padding effect can fold it into `padding.top`.
|
|
294
335
|
const topOverlayRef = useRef<HTMLDivElement>(null);
|
|
@@ -301,7 +342,12 @@ export function ChartContainer({
|
|
|
301
342
|
// fire redundant `chart.setPadding(...)` calls (headerExtra stays 0).
|
|
302
343
|
const headerExtra = headerLayout === 'overlay' ? topOverlayHeight : 0;
|
|
303
344
|
|
|
304
|
-
useEffect
|
|
345
|
+
// useLayoutEffect (not useEffect) so the header-height fold-in lands
|
|
346
|
+
// before the browser paints the chart for the first time. With
|
|
347
|
+
// `useEffect` the padding update would fire AFTER paint, causing a
|
|
348
|
+
// visible "chart drawn, then everything shifts down by the header
|
|
349
|
+
// height on the next frame" jump on initial mount.
|
|
350
|
+
useLayoutEffect(() => {
|
|
305
351
|
const current = chartRef.current;
|
|
306
352
|
if (!current) return;
|
|
307
353
|
const userTop = padding?.top ?? 20;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { EdgeSide } from '@wick-charts/core';
|
|
4
|
+
|
|
5
|
+
import { useChartInstance } from './context';
|
|
6
|
+
|
|
7
|
+
/** Argument shape passed to the {@link EdgeLoader} render-prop. */
|
|
8
|
+
export interface EdgeLoaderRenderArgs {
|
|
9
|
+
/**
|
|
10
|
+
* CSS pixels in the chart's overlay coordinate space, anchored at the data
|
|
11
|
+
* edge (`data.from` for `side='left'`, `data.to` for `side='right'`).
|
|
12
|
+
* The overlay div is positioned with `inset: 0`, so this value can be used
|
|
13
|
+
* directly as `style={{ left: x }}` or `transform: translateX(...)`.
|
|
14
|
+
*/
|
|
15
|
+
x: number;
|
|
16
|
+
side: EdgeSide;
|
|
17
|
+
/** True between {@link EdgeLoaderProps.onTrigger} firing and its Promise resolving. */
|
|
18
|
+
isLoading: boolean;
|
|
19
|
+
/** Time coordinate (ms) of the data edge — convenient for "fetch history before T" requests. */
|
|
20
|
+
boundaryTime: number;
|
|
21
|
+
/** Becomes `false` after `onTrigger` resolves with the literal value `false`. */
|
|
22
|
+
hasMore: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EdgeLoaderProps {
|
|
26
|
+
/** Which edge to watch. */
|
|
27
|
+
side: EdgeSide;
|
|
28
|
+
/**
|
|
29
|
+
* Bars from the edge that arms the trigger. Multiplied by the chart's data
|
|
30
|
+
* interval. Default `5`.
|
|
31
|
+
*/
|
|
32
|
+
threshold?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Called when the visible range moves within {@link EdgeLoaderProps.threshold}
|
|
35
|
+
* bars of the data edge. Returning a Promise toggles `isLoading` for its
|
|
36
|
+
* lifetime. **Resolve with `false`** to signal "no more data" — the loader
|
|
37
|
+
* stops watching and switches the optional canvas indicator to its
|
|
38
|
+
* `'no-data'` state. Any other resolve value (including `undefined`) means
|
|
39
|
+
* "keep watching for the next near-edge event".
|
|
40
|
+
*/
|
|
41
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: void allows callers to write `() => fetch()` without an explicit return
|
|
42
|
+
onTrigger: () => void | Promise<unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* - `'canvas'` (default): drive the chart's built-in canvas spinner via
|
|
45
|
+
* {@link ChartInstance.setEdgeState}. Renders inside the chart area at
|
|
46
|
+
* the data boundary.
|
|
47
|
+
* - `'custom'`: skip the canvas indicator. Use the render-prop `children`
|
|
48
|
+
* to draw your own DOM/SVG loader.
|
|
49
|
+
*/
|
|
50
|
+
indicator?: 'canvas' | 'custom';
|
|
51
|
+
/**
|
|
52
|
+
* Optional render-prop. Receives the live edge state — render whatever
|
|
53
|
+
* positioned overlay you want, or return `null`.
|
|
54
|
+
*/
|
|
55
|
+
children?: (args: EdgeLoaderRenderArgs) => ReactNode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Subscribes to the chart's viewport and triggers a fetch when the visible
|
|
60
|
+
* range nears the chosen data edge. Handles the boilerplate every load-on-scroll
|
|
61
|
+
* site otherwise has to re-implement: arming after first user pan, deduping
|
|
62
|
+
* via Promise tracking, and exposing the boundary's pixel coordinate so a
|
|
63
|
+
* loader can be anchored to "the wall of available history".
|
|
64
|
+
*
|
|
65
|
+
* Place as a child of `<ChartContainer>`.
|
|
66
|
+
*/
|
|
67
|
+
export function EdgeLoader({ side, threshold = 5, onTrigger, indicator = 'canvas', children }: EdgeLoaderProps) {
|
|
68
|
+
const chart = useChartInstance();
|
|
69
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
70
|
+
const [hasMore, setHasMore] = useState(true);
|
|
71
|
+
// Bump on viewportChange / overlayChange so the render-prop re-runs with
|
|
72
|
+
// the latest pixel x. State, not ref, because we want the re-render.
|
|
73
|
+
const [, setTick] = useState(0);
|
|
74
|
+
|
|
75
|
+
const triggerRef = useRef(onTrigger);
|
|
76
|
+
triggerRef.current = onTrigger;
|
|
77
|
+
// Stash `children` in a ref so the effect doesn't have to rebind listeners
|
|
78
|
+
// on every render-prop identity change, and so the bump-on-change gate can
|
|
79
|
+
// read the latest value without putting `children` in deps.
|
|
80
|
+
const hasChildrenRef = useRef(children !== undefined);
|
|
81
|
+
hasChildrenRef.current = children !== undefined;
|
|
82
|
+
const inflight = useRef(false);
|
|
83
|
+
// Largest "distance from edge" (in time units) seen so far — gate the
|
|
84
|
+
// trigger on it crossing the threshold once, so the initial fit-to-data
|
|
85
|
+
// (where visible === data) doesn't fire the loader on mount.
|
|
86
|
+
const armed = useRef(false);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!hasMore) return;
|
|
90
|
+
|
|
91
|
+
const distanceFromEdge = (): number | null => {
|
|
92
|
+
const visible = chart.getVisibleRange();
|
|
93
|
+
const data = chart.getDataRange();
|
|
94
|
+
if (!data) return null;
|
|
95
|
+
|
|
96
|
+
return side === 'left' ? visible.from - data.from : data.to - visible.to;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const fire = () => {
|
|
100
|
+
if (inflight.current || !hasMore) return;
|
|
101
|
+
|
|
102
|
+
inflight.current = true;
|
|
103
|
+
setIsLoading(true);
|
|
104
|
+
if (indicator === 'canvas') chart.setEdgeState(side, 'loading');
|
|
105
|
+
|
|
106
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: matches onTrigger's return type exactly
|
|
107
|
+
let result: void | Promise<unknown>;
|
|
108
|
+
try {
|
|
109
|
+
result = triggerRef.current();
|
|
110
|
+
} catch (err) {
|
|
111
|
+
inflight.current = false;
|
|
112
|
+
setIsLoading(false);
|
|
113
|
+
if (indicator === 'canvas') chart.setEdgeState(side, 'idle');
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const finish = (value: unknown) => {
|
|
118
|
+
inflight.current = false;
|
|
119
|
+
setIsLoading(false);
|
|
120
|
+
if (value === false) {
|
|
121
|
+
setHasMore(false);
|
|
122
|
+
if (indicator === 'canvas') chart.setEdgeState(side, 'no-data');
|
|
123
|
+
} else if (indicator === 'canvas') {
|
|
124
|
+
chart.setEdgeState(side, 'idle');
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
129
|
+
(result as Promise<unknown>).then(finish, () => finish(undefined));
|
|
130
|
+
} else {
|
|
131
|
+
finish(undefined);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onChange = () => {
|
|
136
|
+
const interval = chart.getDataInterval();
|
|
137
|
+
const distance = distanceFromEdge();
|
|
138
|
+
if (distance === null) return;
|
|
139
|
+
|
|
140
|
+
if (!armed.current) {
|
|
141
|
+
// Wait until the visible range has moved away from the edge once —
|
|
142
|
+
// then we know the chart isn't in its mount-time fit-to-data state.
|
|
143
|
+
if (distance > threshold * interval) {
|
|
144
|
+
armed.current = true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (distance <= threshold * interval) {
|
|
151
|
+
fire();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Bump only when a render-prop is consuming the live pixel-x — the
|
|
155
|
+
// canvas-indicator path is driven entirely by chart.setEdgeState and
|
|
156
|
+
// doesn't need a React re-render on every pan/zoom frame.
|
|
157
|
+
if (hasChildrenRef.current) setTick((n) => n + 1);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
chart.on('viewportChange', onChange);
|
|
161
|
+
chart.on('overlayChange', onChange);
|
|
162
|
+
onChange();
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
chart.off('viewportChange', onChange);
|
|
166
|
+
chart.off('overlayChange', onChange);
|
|
167
|
+
};
|
|
168
|
+
}, [chart, side, threshold, indicator, hasMore]);
|
|
169
|
+
|
|
170
|
+
// Reset the canvas indicator when this loader unmounts so a remount with a
|
|
171
|
+
// fresh side / threshold doesn't inherit stale state.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
return () => {
|
|
174
|
+
if (indicator === 'canvas') chart.setEdgeState(side, 'idle');
|
|
175
|
+
};
|
|
176
|
+
}, [chart, side, indicator]);
|
|
177
|
+
|
|
178
|
+
if (!children) return null;
|
|
179
|
+
|
|
180
|
+
const data = chart.getDataRange();
|
|
181
|
+
if (!data) return null;
|
|
182
|
+
|
|
183
|
+
const boundaryTime = side === 'left' ? data.from : data.to;
|
|
184
|
+
const x = chart.timeScale.timeToX(boundaryTime);
|
|
185
|
+
|
|
186
|
+
return <>{children({ x, side, isLoading, boundaryTime, hasMore })}</>;
|
|
187
|
+
}
|