@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wick-charts/react",
3
- "version": "0.3.5",
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://mo4islona.github.io/wick-charts/",
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.3.5"
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 { type BarSeriesOptions, type TimePoint, normalizeTime } from '@wick-charts/core';
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 prevLensRef = useRef<number[]>([]);
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.addBarSeries({ ...options, layers: data.length, id: idProp });
28
+ const id = chart.addSeries('bar', { ...options, layers: data.length, id: idProp });
30
29
  seriesRef.current = id;
31
- prevLensRef.current = new Array(data.length).fill(0);
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
- prevLensRef.current = [];
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
- const layer = data[i];
50
- const prevLen = prevLensRef.current[i] ?? 0;
51
- const prevFirst = prevFirstTimesRef.current[i] ?? null;
52
-
53
- if (layer.length === 0) {
54
- chart.setSeriesData(id, [], i);
55
- prevLensRef.current[i] = 0;
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 type { CandlestickSeriesOptions, OHLCInput } from '@wick-charts/core';
4
- import { normalizeTime } from '@wick-charts/core';
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 prevLenRef = useRef(0);
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.addCandlestickSeries({ ...options, id: idProp });
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
- prevLenRef.current = 0;
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
- if (data.length === 0) {
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
 
@@ -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
- * Chart-level animation configuration. See {@link AnimationsConfig} for the
85
- * full shape.
86
- *
87
- * Two layers remember which is which:
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
- * Resolution: per-series option wins over chart-level numeric value.
99
- * Chart-level wins only when its category is explicitly `false` — that's
100
- * a hard disable that overrides per-series too.
101
- *
102
- * Shorthands:
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
- // useLayoutEffect synchronous, runs before paint.
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
- // Note: the init path above already propagated `grid` into the chart. The
252
- // effect below handles live updates, but also needs to run on the same
253
- // commit so an initial `grid={{visible:false}}` isn't silently reset.
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
+ }