@wick-charts/react 0.3.6 → 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 +782 -344
- package/dist/index.js +4854 -4146
- package/package.json +3 -3
- package/src/BarSeries.tsx +18 -56
- package/src/CandlestickSeries.tsx +11 -57
- package/src/ChartContainer.tsx +80 -51
- package/src/LineSeries.tsx +18 -56
- package/src/PieSeries.tsx +1 -2
- package/src/index.ts +8 -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
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ChartOptions,
|
|
19
19
|
type ChartTheme,
|
|
20
20
|
type EdgeReachedInfo,
|
|
21
|
+
type VisibleRangeSpec,
|
|
21
22
|
} from '@wick-charts/core';
|
|
22
23
|
|
|
23
24
|
type PerfOption = NonNullable<ChartOptions['perf']>;
|
|
@@ -63,6 +64,30 @@ export interface ChartContainerProps {
|
|
|
63
64
|
*/
|
|
64
65
|
left?: number | { intervals: number };
|
|
65
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
|
+
};
|
|
66
91
|
/** Show the chart background gradient. Defaults to true. */
|
|
67
92
|
gradient?: boolean;
|
|
68
93
|
/** Enable zoom, pan, and crosshair interactions. Defaults to true. */
|
|
@@ -82,33 +107,16 @@ export interface ChartContainerProps {
|
|
|
82
107
|
*/
|
|
83
108
|
headerLayout?: 'overlay' | 'inline';
|
|
84
109
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* - **Chart-level (this prop)** — `animations.points.{enterMs, smoothMs,
|
|
91
|
-
* pulseMs}` and `animations.viewport.{reboundMs, yAxisMs,
|
|
92
|
-
* inputResponseMs}`. Acts as the default for every series.
|
|
93
|
-
* - **Per-series** — `<LineSeries options={{ entryMs, smoothMs, pulseMs }}>`
|
|
94
|
-
* (and the analogous CandlestickSeries / BarSeries options). Overrides
|
|
95
|
-
* the chart-level default for that one series. Note the spelling:
|
|
96
|
-
* `entryMs` per-series, `enterMs` chart-level — historical artefact,
|
|
97
|
-
* both refer to the same animation.
|
|
98
|
-
*
|
|
99
|
-
* Resolution: per-series option wins over chart-level numeric value.
|
|
100
|
-
* Chart-level wins only when its category is explicitly `false` — that's
|
|
101
|
-
* a hard disable that overrides per-series too.
|
|
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`.
|
|
102
114
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* Runtime updates: changing this prop after mount calls
|
|
110
|
-
* `chart.setAnimations(...)` so the new durations take effect on the next
|
|
111
|
-
* 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.
|
|
112
120
|
*/
|
|
113
121
|
animations?: boolean | AnimationsConfig;
|
|
114
122
|
/**
|
|
@@ -227,6 +235,7 @@ export function ChartContainer({
|
|
|
227
235
|
theme,
|
|
228
236
|
axis,
|
|
229
237
|
padding,
|
|
238
|
+
viewport,
|
|
230
239
|
gradient = true,
|
|
231
240
|
interactive,
|
|
232
241
|
grid,
|
|
@@ -249,15 +258,33 @@ export function ChartContainer({
|
|
|
249
258
|
const chartRef = useRef<ChartInstance | null>(null);
|
|
250
259
|
const [_, setRevision] = useState(0);
|
|
251
260
|
|
|
252
|
-
//
|
|
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.
|
|
253
280
|
useLayoutEffect(() => {
|
|
254
281
|
if (!containerRef.current) return;
|
|
255
|
-
if (chartRef.current) return;
|
|
256
282
|
|
|
257
283
|
const options: ChartOptions = {};
|
|
258
284
|
if (axis) options.axis = axis;
|
|
259
285
|
if (resolvedTheme) options.theme = resolvedTheme;
|
|
260
286
|
if (padding) options.padding = padding;
|
|
287
|
+
if (viewport) options.viewport = viewport;
|
|
261
288
|
if (interactive !== undefined) options.interactive = interactive;
|
|
262
289
|
if (grid !== undefined) options.grid = grid;
|
|
263
290
|
if (perfRef.current !== undefined) options.perf = perfRef.current;
|
|
@@ -265,24 +292,31 @@ export function ChartContainer({
|
|
|
265
292
|
if (onEdgeReachedRef.current) options.onEdgeReached = onEdgeReachedRef.current;
|
|
266
293
|
chartRef.current = new ChartInstance(containerRef.current, options);
|
|
267
294
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
271
313
|
setRevision((r) => r + 1);
|
|
272
314
|
|
|
273
315
|
return () => {
|
|
274
|
-
// Destroy synchronously. A previous revision deferred this through
|
|
275
|
-
// `setTimeout(..., 0)` to "tolerate StrictMode" but the guard was
|
|
276
|
-
// broken: in the StrictMode remount sequence (cleanup → second mount →
|
|
277
|
-
// timeout), the check `if (!chartRef.current) instance.destroy()`
|
|
278
|
-
// always saw the second instance and skipped the destroy — leaking
|
|
279
|
-
// the first ChartInstance's canvases (hence 4 canvases per chart in
|
|
280
|
-
// dev). StrictMode exists precisely to exercise cleanup; a correct
|
|
281
|
-
// `destroy` is cheap enough to run on every cycle.
|
|
282
316
|
chartRef.current?.destroy();
|
|
283
317
|
chartRef.current = null;
|
|
284
318
|
};
|
|
285
|
-
}, []);
|
|
319
|
+
}, [animations]);
|
|
286
320
|
|
|
287
321
|
useEffect(() => {
|
|
288
322
|
if (chartRef.current && resolvedTheme) {
|
|
@@ -296,16 +330,6 @@ export function ChartContainer({
|
|
|
296
330
|
}
|
|
297
331
|
}, [axis?.y?.width, axis?.y?.min, axis?.y?.max, axis?.y?.visible, axis?.x?.height, axis?.x?.visible]);
|
|
298
332
|
|
|
299
|
-
useEffect(() => {
|
|
300
|
-
if (chartRef.current && animations !== undefined) {
|
|
301
|
-
chartRef.current.setAnimations(animations);
|
|
302
|
-
}
|
|
303
|
-
// Dep array is the JSON shape of the config — covers both the boolean
|
|
304
|
-
// shorthand and the full object. Cheap to stringify (the object is tiny)
|
|
305
|
-
// and lets callers pass a fresh reference each render without thrashing
|
|
306
|
-
// animator state when nothing has actually changed.
|
|
307
|
-
}, [JSON.stringify(animations)]);
|
|
308
|
-
|
|
309
333
|
// Top-overlay height (title + info bar) — measured below. Declared here so
|
|
310
334
|
// the padding effect can fold it into `padding.top`.
|
|
311
335
|
const topOverlayRef = useRef<HTMLDivElement>(null);
|
|
@@ -318,7 +342,12 @@ export function ChartContainer({
|
|
|
318
342
|
// fire redundant `chart.setPadding(...)` calls (headerExtra stays 0).
|
|
319
343
|
const headerExtra = headerLayout === 'overlay' ? topOverlayHeight : 0;
|
|
320
344
|
|
|
321
|
-
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(() => {
|
|
322
351
|
const current = chartRef.current;
|
|
323
352
|
if (!current) return;
|
|
324
353
|
const userTop = padding?.top ?? 20;
|
package/src/LineSeries.tsx
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
EMPTY_SYNC_STATE,
|
|
5
|
+
type LineSeriesOptions,
|
|
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 LineSeriesProps {
|
|
|
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 LineSeries({ data, options, id: idProp }: LineSeriesProps) {
|
|
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('line', { ...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 LineSeries({ data, options, id: idProp }: LineSeriesProps) {
|
|
|
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]);
|
|
@@ -101,9 +65,7 @@ export function LineSeries({ data, options, id: idProp }: LineSeriesProps) {
|
|
|
101
65
|
options?.pulse,
|
|
102
66
|
options?.stacking,
|
|
103
67
|
options?.entryAnimation,
|
|
104
|
-
options?.enterAnimation,
|
|
105
68
|
options?.entryMs,
|
|
106
|
-
options?.enterMs,
|
|
107
69
|
options?.smoothMs,
|
|
108
70
|
]);
|
|
109
71
|
|
package/src/PieSeries.tsx
CHANGED
|
@@ -19,7 +19,7 @@ export function PieSeries({ data, options, id: idProp }: PieSeriesProps) {
|
|
|
19
19
|
const seriesRef = useRef<string | null>(null);
|
|
20
20
|
|
|
21
21
|
useLayoutEffect(() => {
|
|
22
|
-
const id = chart.
|
|
22
|
+
const id = chart.addSeries('pie', { ...options, id: idProp });
|
|
23
23
|
seriesRef.current = id;
|
|
24
24
|
return () => {
|
|
25
25
|
chart.removeSeries(id);
|
|
@@ -48,7 +48,6 @@ export function PieSeries({ data, options, id: idProp }: PieSeriesProps) {
|
|
|
48
48
|
options?.sliceLabels?.labelGap,
|
|
49
49
|
options?.sliceLabels?.distance,
|
|
50
50
|
options?.sliceLabels?.railWidth,
|
|
51
|
-
options?.sliceLabels?.balanceSides,
|
|
52
51
|
]);
|
|
53
52
|
|
|
54
53
|
useLayoutEffect(() => {
|
package/src/index.ts
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
export type {
|
|
12
|
+
AnimationTime,
|
|
12
13
|
AnimationsConfig,
|
|
13
14
|
AxisBound,
|
|
14
15
|
AxisConfig,
|
|
15
16
|
BarSeriesOptions,
|
|
16
17
|
/** @deprecated Use {@link StackingMode} instead. */
|
|
17
|
-
BarStacking,
|
|
18
18
|
BuildHoverSnapshotsArgs,
|
|
19
19
|
BuildLastSnapshotsArgs,
|
|
20
20
|
CandlestickSeriesOptions,
|
|
@@ -28,7 +28,6 @@ export type {
|
|
|
28
28
|
HoverInfo,
|
|
29
29
|
LegendItem,
|
|
30
30
|
/** @deprecated Use {@link TimePoint} instead. */
|
|
31
|
-
LineData,
|
|
32
31
|
LineSeriesOptions,
|
|
33
32
|
NavigatorCandlePoint,
|
|
34
33
|
NavigatorControllerParams,
|
|
@@ -54,6 +53,9 @@ export type {
|
|
|
54
53
|
TooltipFormatter,
|
|
55
54
|
TooltipPosition,
|
|
56
55
|
TooltipPositionArgs,
|
|
56
|
+
Transition,
|
|
57
|
+
TransitionContext,
|
|
58
|
+
TransitionFactory,
|
|
57
59
|
Typography,
|
|
58
60
|
ValueFormatter,
|
|
59
61
|
VisibleRange,
|
|
@@ -72,7 +74,6 @@ export {
|
|
|
72
74
|
catppuccin,
|
|
73
75
|
computeTooltipPosition,
|
|
74
76
|
createTheme,
|
|
75
|
-
darkTheme,
|
|
76
77
|
detectInterval,
|
|
77
78
|
dracula,
|
|
78
79
|
formatCompact,
|
|
@@ -82,11 +83,11 @@ export {
|
|
|
82
83
|
githubLight,
|
|
83
84
|
gruvbox,
|
|
84
85
|
handwritten,
|
|
86
|
+
hermite,
|
|
85
87
|
highContrast,
|
|
86
88
|
isDarkBg,
|
|
87
89
|
lavenderMist,
|
|
88
90
|
lightPink,
|
|
89
|
-
lightTheme,
|
|
90
91
|
materialPalenight,
|
|
91
92
|
minimalLight,
|
|
92
93
|
mintBreeze,
|
|
@@ -95,6 +96,7 @@ export {
|
|
|
95
96
|
normalizeTime,
|
|
96
97
|
oneDarkPro,
|
|
97
98
|
panda,
|
|
99
|
+
parseAnimationTime,
|
|
98
100
|
peachCream,
|
|
99
101
|
quietLight,
|
|
100
102
|
resolveAxisFontSize,
|
|
@@ -102,7 +104,9 @@ export {
|
|
|
102
104
|
resolveCandlestickBodyColor,
|
|
103
105
|
rosePineDawn,
|
|
104
106
|
sandDune,
|
|
107
|
+
snap,
|
|
105
108
|
solarizedLight,
|
|
109
|
+
spring,
|
|
106
110
|
} from '@wick-charts/core';
|
|
107
111
|
|
|
108
112
|
export { BarSeries } from './BarSeries';
|
package/src/ui/Crosshair.tsx
CHANGED
|
@@ -11,6 +11,10 @@ export function Crosshair() {
|
|
|
11
11
|
|
|
12
12
|
const theme = chart.getTheme();
|
|
13
13
|
const dataInterval = chart.getDataInterval();
|
|
14
|
+
// Format the time pill at the axis's *resolved* tick granularity, not the raw
|
|
15
|
+
// data interval — otherwise a time-of-day badge floats among date labels when
|
|
16
|
+
// zoomed out. Falls back to dataInterval on a degenerate range.
|
|
17
|
+
const tickInterval = chart.timeScale.niceTickValues(dataInterval).tickInterval || dataInterval;
|
|
14
18
|
|
|
15
19
|
const labelStyle = {
|
|
16
20
|
// Blend the theme's labelBackground at 80% opacity so the axis grid
|
|
@@ -54,7 +58,7 @@ export function Crosshair() {
|
|
|
54
58
|
...labelStyle,
|
|
55
59
|
}}
|
|
56
60
|
>
|
|
57
|
-
{formatTime(position.time,
|
|
61
|
+
{formatTime(position.time, tickInterval)}
|
|
58
62
|
</div>
|
|
59
63
|
</>
|
|
60
64
|
);
|