Skip to content

Commit d284d33

Browse files
authored
Merge pull request #1 from hunterg325/add-react-wrapper
Add a modern React wrapper around ChartGPU with full lifecycle management, typed events, and improved example performance
2 parents c1dae81 + 2b67d42 commit d284d33

4 files changed

Lines changed: 493 additions & 5 deletions

File tree

examples/main.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,9 @@ function MultiSeriesExample() {
112112
{
113113
type: 'line',
114114
name: 'Revenue',
115-
data: Array.from({ length: 500000 }, (_, i) => ({
115+
data: Array.from({ length: 50 }, (_, i) => ({
116116
x: i,
117-
y: 100 + Math.random() * 5000000 + i * 2,
117+
y: 100 + Math.random() * 50 + i * 2,
118118
})),
119119
lineStyle: {
120120
width: 2,
@@ -124,9 +124,9 @@ function MultiSeriesExample() {
124124
{
125125
type: 'line',
126126
name: 'Expenses',
127-
data: Array.from({ length: 500000 }, (_, i) => ({
127+
data: Array.from({ length: 50 }, (_, i) => ({
128128
x: i,
129-
y: 80 + Math.random() * 60 + i * 1.5,
129+
y: 80 + Math.random() * 50 + i * 1.5,
130130
})),
131131
lineStyle: {
132132
width: 2,
@@ -136,7 +136,7 @@ function MultiSeriesExample() {
136136
{
137137
type: 'line',
138138
name: 'Profit',
139-
data: Array.from({ length: 500000 }, (_, i) => ({
139+
data: Array.from({ length: 49 }, (_, i) => ({
140140
x: i,
141141
y: 20 + Math.random() * 40 + i * 0.5,
142142
})),

src/ChartGPU.tsx

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import {
2+
useEffect,
3+
useRef,
4+
useImperativeHandle,
5+
forwardRef,
6+
useCallback,
7+
} from 'react';
8+
import { ChartGPU as ChartGPULib } from 'chartgpu';
9+
import type {
10+
ChartGPUProps,
11+
ChartGPUHandle,
12+
ChartInstance,
13+
ClickParams,
14+
MouseOverParams,
15+
} from './types';
16+
import type { ChartGPUOptions } from 'chartgpu';
17+
18+
/**
19+
* Debounce utility for throttling frequent calls.
20+
*/
21+
function debounce<T extends (...args: any[]) => void>(
22+
fn: T,
23+
delayMs: number
24+
): (...args: Parameters<T>) => void {
25+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
26+
return (...args: Parameters<T>) => {
27+
if (timeoutId !== null) {
28+
clearTimeout(timeoutId);
29+
}
30+
timeoutId = setTimeout(() => {
31+
fn(...args);
32+
}, delayMs);
33+
};
34+
}
35+
36+
/**
37+
* ChartGPU React component.
38+
*
39+
* A modern React wrapper for the ChartGPU library with full lifecycle management,
40+
* event handling, and imperative API access via ref.
41+
*
42+
* Features:
43+
* - Async initialization with StrictMode safety
44+
* - Automatic resize handling via ResizeObserver
45+
* - Theme support with options override
46+
* - Declarative event handlers
47+
* - Zoom change detection via polling
48+
* - Imperative methods via forwardRef
49+
*
50+
* Example usage:
51+
* ```tsx
52+
* const chartRef = useRef<ChartGPUHandle>(null);
53+
*
54+
* <ChartGPU
55+
* ref={chartRef}
56+
* options={{ series: [...], xAxis: {...}, yAxis: {...} }}
57+
* theme="dark"
58+
* onReady={(chart) => console.log('Chart ready:', chart)}
59+
* onClick={(params) => console.log('Clicked:', params)}
60+
* />
61+
* ```
62+
*/
63+
export const ChartGPU = forwardRef<ChartGPUHandle, ChartGPUProps>(
64+
(
65+
{
66+
options,
67+
theme,
68+
style,
69+
className,
70+
onReady,
71+
onClick,
72+
onMouseOver,
73+
onMouseOut,
74+
onZoomChange,
75+
},
76+
ref
77+
) => {
78+
const containerRef = useRef<HTMLDivElement>(null);
79+
const instanceRef = useRef<ChartInstance | null>(null);
80+
const mountedRef = useRef<boolean>(false);
81+
const resizeObserverRef = useRef<ResizeObserver | null>(null);
82+
const zoomPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
83+
null
84+
);
85+
const lastZoomRangeRef = useRef<{ start: number; end: number } | null>(
86+
null
87+
);
88+
89+
// Expose imperative handle
90+
useImperativeHandle(
91+
ref,
92+
() => ({
93+
getChart: () => instanceRef.current,
94+
appendData: (seriesIndex: number, newPoints) => {
95+
const instance = instanceRef.current;
96+
if (instance && !instance.disposed) {
97+
instance.appendData(seriesIndex, newPoints);
98+
}
99+
},
100+
setOption: (newOptions: ChartGPUOptions) => {
101+
const instance = instanceRef.current;
102+
if (instance && !instance.disposed) {
103+
instance.setOption(newOptions);
104+
}
105+
},
106+
}),
107+
[]
108+
);
109+
110+
// Build effective options with theme override if provided
111+
const getEffectiveOptions = useCallback((): ChartGPUOptions => {
112+
if (theme !== undefined) {
113+
return {
114+
...options,
115+
theme: theme as ChartGPUOptions['theme'],
116+
};
117+
}
118+
return options;
119+
}, [options, theme]);
120+
121+
// Initialize chart on mount
122+
useEffect(() => {
123+
if (!containerRef.current) return;
124+
125+
mountedRef.current = true;
126+
let chartInstance: ChartInstance | null = null;
127+
128+
const initChart = async () => {
129+
try {
130+
if (!containerRef.current) return;
131+
132+
const effectiveOptions = getEffectiveOptions();
133+
chartInstance = await ChartGPULib.create(
134+
containerRef.current,
135+
effectiveOptions
136+
);
137+
138+
// StrictMode safety: only update state if still mounted
139+
if (mountedRef.current) {
140+
instanceRef.current = chartInstance;
141+
onReady?.(chartInstance);
142+
} else {
143+
// Component unmounted during async create - dispose immediately
144+
chartInstance.dispose();
145+
}
146+
} catch (error) {
147+
if (mountedRef.current) {
148+
console.error('Failed to create ChartGPU instance:', error);
149+
}
150+
}
151+
};
152+
153+
initChart();
154+
155+
// Cleanup on unmount
156+
return () => {
157+
mountedRef.current = false;
158+
159+
if (instanceRef.current && !instanceRef.current.disposed) {
160+
instanceRef.current.dispose();
161+
instanceRef.current = null;
162+
}
163+
};
164+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
165+
166+
// Update chart when options or theme change
167+
useEffect(() => {
168+
const instance = instanceRef.current;
169+
if (!instance || instance.disposed) return;
170+
171+
const effectiveOptions = getEffectiveOptions();
172+
instance.setOption(effectiveOptions);
173+
}, [options, theme, getEffectiveOptions]);
174+
175+
// Register/unregister click event handler
176+
useEffect(() => {
177+
const instance = instanceRef.current;
178+
if (!instance || instance.disposed || !onClick) return;
179+
180+
const handler = (payload: ClickParams) => {
181+
onClick(payload);
182+
};
183+
184+
instance.on('click', handler);
185+
186+
return () => {
187+
if (instance && !instance.disposed) {
188+
instance.off('click', handler);
189+
}
190+
};
191+
}, [onClick]);
192+
193+
// Register/unregister mouseover event handler
194+
useEffect(() => {
195+
const instance = instanceRef.current;
196+
if (!instance || instance.disposed || !onMouseOver) return;
197+
198+
const handler = (payload: MouseOverParams) => {
199+
onMouseOver(payload);
200+
};
201+
202+
instance.on('mouseover', handler);
203+
204+
return () => {
205+
if (instance && !instance.disposed) {
206+
instance.off('mouseover', handler);
207+
}
208+
};
209+
}, [onMouseOver]);
210+
211+
// Register/unregister mouseout event handler
212+
useEffect(() => {
213+
const instance = instanceRef.current;
214+
if (!instance || instance.disposed || !onMouseOut) return;
215+
216+
const handler = () => {
217+
onMouseOut();
218+
};
219+
220+
instance.on('mouseout', handler);
221+
222+
return () => {
223+
if (instance && !instance.disposed) {
224+
instance.off('mouseout', handler);
225+
}
226+
};
227+
}, [onMouseOut]);
228+
229+
// Set up ResizeObserver for responsive sizing (debounced 100ms)
230+
useEffect(() => {
231+
const instance = instanceRef.current;
232+
const container = containerRef.current;
233+
if (!instance || instance.disposed || !container) return;
234+
235+
const debouncedResize = debounce(() => {
236+
if (instance && !instance.disposed) {
237+
instance.resize();
238+
}
239+
}, 100);
240+
241+
const observer = new ResizeObserver(() => {
242+
debouncedResize();
243+
});
244+
245+
observer.observe(container);
246+
resizeObserverRef.current = observer;
247+
248+
return () => {
249+
observer.disconnect();
250+
resizeObserverRef.current = null;
251+
};
252+
}, [instanceRef.current]); // Re-run when instance changes
253+
254+
// Set up zoom change polling (100ms interval)
255+
useEffect(() => {
256+
const instance = instanceRef.current;
257+
if (!instance || instance.disposed || !onZoomChange) return;
258+
259+
const checkZoomChange = () => {
260+
if (!instance || instance.disposed) return;
261+
262+
const currentRange = instance.getZoomRange();
263+
const lastRange = lastZoomRangeRef.current;
264+
265+
// Check if zoom range changed
266+
if (currentRange !== null) {
267+
if (
268+
lastRange === null ||
269+
lastRange.start !== currentRange.start ||
270+
lastRange.end !== currentRange.end
271+
) {
272+
lastZoomRangeRef.current = currentRange;
273+
onZoomChange(currentRange);
274+
}
275+
} else {
276+
// Range is null (no zoom), reset last range
277+
if (lastRange !== null) {
278+
lastZoomRangeRef.current = null;
279+
}
280+
}
281+
};
282+
283+
const intervalId = setInterval(checkZoomChange, 100);
284+
zoomPollIntervalRef.current = intervalId;
285+
286+
// Initial check
287+
checkZoomChange();
288+
289+
return () => {
290+
if (zoomPollIntervalRef.current) {
291+
clearInterval(zoomPollIntervalRef.current);
292+
zoomPollIntervalRef.current = null;
293+
}
294+
lastZoomRangeRef.current = null;
295+
};
296+
}, [onZoomChange, instanceRef.current]); // Re-run when instance or callback changes
297+
298+
return (
299+
<div
300+
ref={containerRef}
301+
className={className}
302+
style={style}
303+
/>
304+
);
305+
}
306+
);
307+
308+
ChartGPU.displayName = 'ChartGPU';

src/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,57 @@
33
* React bindings for ChartGPU - WebGPU-powered charting library
44
*/
55

6+
// Primary ChartGPU component (Story 6.18)
7+
export { ChartGPU } from './ChartGPU';
8+
9+
// Wrapper types for the ChartGPU component
10+
export type {
11+
ChartGPUProps,
12+
ChartGPUHandle,
13+
ChartInstance,
14+
ClickParams,
15+
MouseOverParams,
16+
} from './types';
17+
18+
/**
19+
* @deprecated Use `ChartGPU` instead. `ChartGPUChart` is kept for backward compatibility.
20+
* Will be removed in a future major version.
21+
*/
622
export { ChartGPUChart } from './ChartGPUChart';
23+
24+
/**
25+
* @deprecated Use `ChartGPUProps` instead. `ChartGPUChartProps` is kept for backward compatibility.
26+
* Will be removed in a future major version.
27+
*/
728
export type { ChartGPUChartProps } from './ChartGPUChart';
829

930
// Re-export types from chartgpu for convenience
31+
// This provides a single import point for all ChartGPU types
1032
export type {
33+
// Core instance and options
1134
ChartGPUInstance,
1235
ChartGPUOptions,
36+
37+
// Event payloads
1338
ChartGPUEventPayload,
1439
ChartGPUCrosshairMovePayload,
40+
41+
// Series configurations
1542
AreaSeriesConfig,
1643
LineSeriesConfig,
1744
BarSeriesConfig,
1845
PieSeriesConfig,
1946
ScatterSeriesConfig,
2047
SeriesConfig,
48+
49+
// Data types
2150
DataPoint,
51+
52+
// Theme configuration
53+
ThemeConfig,
54+
ThemeName,
55+
56+
// Axis and grid configurations
57+
AxisConfig,
58+
GridConfig,
2259
} from 'chartgpu';

0 commit comments

Comments
 (0)