@wick-charts/react 0.3.4 → 0.3.6

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.4",
3
+ "version": "0.3.6",
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": {
@@ -49,7 +49,7 @@
49
49
  "react-dom": ">=18.0.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@wick-charts/core": "^0.3.4"
52
+ "@wick-charts/core": "^0.3.6"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "vite build"
@@ -17,6 +17,7 @@ import {
17
17
  ChartInstance,
18
18
  type ChartOptions,
19
19
  type ChartTheme,
20
+ type EdgeReachedInfo,
20
21
  } from '@wick-charts/core';
21
22
 
22
23
  type PerfOption = NonNullable<ChartOptions['perf']>;
@@ -120,6 +121,18 @@ export interface ChartContainerProps {
120
121
  * Only read at mount; changing this prop after the chart is created is ignored.
121
122
  */
122
123
  perf?: PerfOption;
124
+ /**
125
+ * Fired after the user releases a pan/zoom gesture that pulled the viewport
126
+ * past a data edge by more than ~10% of the visible range. Hosts typically
127
+ * respond by prefetching more history.
128
+ *
129
+ * For threshold-based prefetch (load *before* the user fully overshoots),
130
+ * use `<EdgeLoader>` instead — that component subscribes to `viewportChange`
131
+ * and arms when the visible range nears the data edge.
132
+ *
133
+ * Captured at mount only; changing the prop identity later is ignored.
134
+ */
135
+ onEdgeReached?: (info: EdgeReachedInfo) => void;
123
136
  /** Inline style for the chart's outer wrapper element. */
124
137
  style?: CSSProperties;
125
138
  /** Extra class for the chart's outer wrapper element. */
@@ -220,12 +233,15 @@ export function ChartContainer({
220
233
  headerLayout = 'overlay',
221
234
  perf,
222
235
  animations,
236
+ onEdgeReached,
223
237
  style,
224
238
  className,
225
239
  }: ChartContainerProps) {
226
240
  // Mount-only: capture the initial perf option in a ref so later renders with
227
241
  // a new object identity don't recreate the chart or remount the HUD.
228
242
  const perfRef = useRef(perf);
243
+ // Same mount-only capture for the edge callback — the chart binds it once.
244
+ const onEdgeReachedRef = useRef(onEdgeReached);
229
245
  const contextTheme = useThemeOptional();
230
246
  const resolvedTheme = theme ?? contextTheme ?? undefined;
231
247
 
@@ -246,6 +262,7 @@ export function ChartContainer({
246
262
  if (grid !== undefined) options.grid = grid;
247
263
  if (perfRef.current !== undefined) options.perf = perfRef.current;
248
264
  if (animations !== undefined) options.animations = animations;
265
+ if (onEdgeReachedRef.current) options.onEdgeReached = onEdgeReachedRef.current;
249
266
  chartRef.current = new ChartInstance(containerRef.current, options);
250
267
 
251
268
  // Note: the init path above already propagated `grid` into the chart. The
@@ -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
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,9 @@ export type {
22
22
  ChartOptions,
23
23
  ChartTheme,
24
24
  CrosshairPosition,
25
+ EdgeReachedInfo,
26
+ EdgeSide,
27
+ EdgeState,
25
28
  HoverInfo,
26
29
  LegendItem,
27
30
  /** @deprecated Use {@link TimePoint} instead. */
@@ -80,6 +83,7 @@ export {
80
83
  gruvbox,
81
84
  handwritten,
82
85
  highContrast,
86
+ isDarkBg,
83
87
  lavenderMist,
84
88
  lightPink,
85
89
  lightTheme,
@@ -107,6 +111,8 @@ export { CandlestickSeries } from './CandlestickSeries';
107
111
  export { ChartContainer } from './ChartContainer';
108
112
  // React hooks
109
113
  export { useChartInstance } from './context';
114
+ export type { EdgeLoaderProps, EdgeLoaderRenderArgs } from './EdgeLoader';
115
+ export { EdgeLoader } from './EdgeLoader';
110
116
  export { LineSeries } from './LineSeries';
111
117
  export { PieSeries } from './PieSeries';
112
118
  export {
package/src/ui/Title.tsx CHANGED
@@ -41,7 +41,7 @@ export function Title({ children, sub, style }: TitleProps) {
41
41
  display: 'flex',
42
42
  alignItems: 'baseline',
43
43
  gap: 6,
44
- padding: '6px 8px 4px',
44
+ padding: '6px 8px 0',
45
45
  flexShrink: 0,
46
46
  fontFamily: theme.typography.fontFamily,
47
47
  fontSize: theme.typography.fontSize,