diff --git a/src/index.ts b/src/index.ts index 7acfa49..60cb719 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,10 @@ export type { MouseOverParams, } from './types'; +// useChartGPU hook (Story 6.19) +export { useChartGPU } from './useChartGPU'; +export type { UseChartGPUResult } from './useChartGPU'; + /** * @deprecated Use `ChartGPU` instead. `ChartGPUChart` is kept for backward compatibility. * Will be removed in a future major version. diff --git a/src/useChartGPU.ts b/src/useChartGPU.ts new file mode 100644 index 0000000..d7934bb --- /dev/null +++ b/src/useChartGPU.ts @@ -0,0 +1,186 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChartGPU as ChartGPULib } from 'chartgpu'; +import type { ChartGPUOptions } from 'chartgpu'; +import type { ChartInstance } from './types'; + +/** + * Result object returned by the useChartGPU hook. + */ +export interface UseChartGPUResult { + /** + * The ChartGPU instance once initialized, null before initialization. + */ + chart: ChartInstance | null; + + /** + * True when the chart has been successfully initialized and is ready to use. + */ + isReady: boolean; + + /** + * Error object if initialization failed or WebGPU is not supported. + * Null when no error has occurred. + */ + error: Error | null; +} + +/** + * Debounce utility for throttling frequent calls. + */ +function debounce void>( + fn: T, + delayMs: number +): (...args: Parameters) => void { + let timeoutId: ReturnType | null = null; + return (...args: Parameters) => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => { + fn(...args); + }, delayMs); + }; +} + +/** + * React hook for managing a ChartGPU instance. + * + * Provides lifecycle management, automatic resize handling, and error handling + * for ChartGPU charts in React applications. + * + * Features: + * - Async initialization with StrictMode safety + * - WebGPU support detection + * - Automatic resize handling via ResizeObserver (debounced 100ms) + * - Options updates via setOption + * - Proper cleanup on unmount + * + * @param containerRef - React ref to the container element where the chart will be rendered + * @param options - ChartGPU configuration options + * @returns Object containing chart instance, ready state, and error state + * + * @example + * ```tsx + * const containerRef = useRef(null); + * const { chart, isReady, error } = useChartGPU(containerRef, { + * series: [{ type: 'line', data: [...] }], + * xAxis: { type: 'linear' }, + * yAxis: { type: 'linear' } + * }); + * + * if (error) return
WebGPU not supported
; + * if (!isReady) return
Loading...
; + * + * return
; + * ``` + */ +export function useChartGPU( + containerRef: React.RefObject, + options: ChartGPUOptions +): UseChartGPUResult { + const [chart, setChart] = useState(null); + const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(null); + + const mountedRef = useRef(false); + const resizeObserverRef = useRef(null); + + // Initialize chart on mount + useEffect(() => { + // WebGPU support check + if (!('gpu' in navigator)) { + setError(new Error('WebGPU not supported in this browser')); + return; + } + + if (!containerRef.current) { + return; + } + + mountedRef.current = true; + let chartInstance: ChartInstance | null = null; + + const initChart = async () => { + try { + if (!containerRef.current) return; + + chartInstance = await ChartGPULib.create( + containerRef.current, + options + ); + + // StrictMode safety: only update state if still mounted + if (mountedRef.current) { + setChart(chartInstance); + setIsReady(true); + setError(null); + } else { + // Component unmounted during async create - dispose immediately + chartInstance.dispose(); + } + } catch (err) { + if (mountedRef.current) { + // Normalize error to Error instance + const normalizedError = + err instanceof Error ? err : new Error(String(err)); + setError(normalizedError); + setIsReady(false); + } + } + }; + + initChart(); + + // Cleanup on unmount + return () => { + mountedRef.current = false; + setIsReady(false); + + if (chartInstance && !chartInstance.disposed) { + chartInstance.dispose(); + } + + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + resizeObserverRef.current = null; + } + }; + // Intentionally omitting containerRef.current from dependencies to avoid re-initialization + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Update chart when options change + useEffect(() => { + if (!chart || chart.disposed) return; + + chart.setOption(options); + }, [chart, options]); + + // Set up ResizeObserver for responsive sizing (debounced 100ms) + useEffect(() => { + const container = containerRef.current; + if (!chart || chart.disposed || !container) return; + + const debouncedResize = debounce(() => { + if (chart && !chart.disposed) { + chart.resize(); + } + }, 100); + + const observer = new ResizeObserver(() => { + debouncedResize(); + }); + + observer.observe(container); + resizeObserverRef.current = observer; + + return () => { + observer.disconnect(); + resizeObserverRef.current = null; + }; + // Intentionally omitting containerRef.current from dependencies + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chart]); + + return { chart, isReady, error }; +}