import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; /** * Cross-chart sync for a group of charts sharing an x-axis domain. Wrap them in one * ; both behaviours mirror across every chart: a hover indicator * (vertical line at the hovered x), and drag-to-zoom (a selection rectangle that * commits the range via `onZoom`, e.g. to set the Time/Date filter). * * Chart.Bar reads this via useChartSync, a no-op when no provider is present. * Drag-to-zoom is active only when `onZoom` is provided. */ type ChartSyncXValue = number | string | null; /** Committed zoom range (epoch ms). */ export type ChartZoomRange = { start: number; end: number }; /** In-progress drag selection (raw x values; not yet ordered). */ type ZoomSelection = { start: number | string; current: number | string }; type ChartSyncContextValue = { /** The x-axis value currently hovered in any chart in the group. */ activeX: ChartSyncXValue; setActiveX: (x: ChartSyncXValue) => void; /** Whether drag-to-zoom is active (an onZoom handler was provided). */ zoomEnabled: boolean; /** Current drag selection, mirrored across charts; null when not dragging. */ zoomSelection: ZoomSelection | null; startZoom: (x: number | string) => void; updateZoom: (x: number | string) => void; /** Finish the drag and commit the range (adds `bucketWidthMs` so the last bucket is included). */ endZoom: (bucketWidthMs?: number) => void; cancelZoom: () => void; }; const ChartSyncContext = createContext(null); /** * Turn a raw drag selection into an ordered, inclusive range. Returns null for * a non-drag (start === current) or non-numeric selection. */ export function computeZoomRange( start: number | string, current: number | string, bucketWidthMs = 0 ): ChartZoomRange | null { const a = Number(start); const b = Number(current); if (!Number.isFinite(a) || !Number.isFinite(b) || a === b) return null; const width = Number.isFinite(bucketWidthMs) ? bucketWidthMs : 0; return { start: Math.min(a, b), end: Math.max(a, b) + width }; } export function ChartSyncProvider({ children, onZoom, }: { children: React.ReactNode; /** Called with the selected range when a drag-to-zoom completes. */ onZoom?: (range: ChartZoomRange) => void; }) { const [activeX, setActiveXState] = useState(null); const [zoomSelection, setZoomSelection] = useState(null); // Track selection synchronously so endZoom (fired on mouseup) reads the latest. const selectionRef = useRef(null); const setActiveX = useCallback((x: ChartSyncXValue) => setActiveXState(x), []); const startZoom = useCallback((x: number | string) => { const next = { start: x, current: x }; selectionRef.current = next; setZoomSelection(next); }, []); const updateZoom = useCallback((x: number | string) => { const prev = selectionRef.current; if (!prev) return; const next = { start: prev.start, current: x }; selectionRef.current = next; setZoomSelection(next); }, []); const cancelZoom = useCallback(() => { selectionRef.current = null; setZoomSelection(null); }, []); const endZoom = useCallback( (bucketWidthMs = 0) => { const sel = selectionRef.current; selectionRef.current = null; setZoomSelection(null); if (!sel) return; const range = computeZoomRange(sel.start, sel.current, bucketWidthMs); if (range) onZoom?.(range); }, [onZoom] ); const value = useMemo( () => ({ activeX, setActiveX, zoomEnabled: onZoom != null, zoomSelection, startZoom, updateZoom, endZoom, cancelZoom, }), [activeX, setActiveX, onZoom, zoomSelection, startZoom, updateZoom, endZoom, cancelZoom] ); return {children}; } /** Returns the sync context, or null when not inside a ChartSyncProvider. */ export function useChartSync(): ChartSyncContextValue | null { return useContext(ChartSyncContext); }