Files
2026-07-13 13:32:57 +08:00

122 lines
4.1 KiB
TypeScript

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
* <ChartSyncProvider>; 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<ChartSyncContextValue | null>(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<ChartSyncXValue>(null);
const [zoomSelection, setZoomSelection] = useState<ZoomSelection | null>(null);
// Track selection synchronously so endZoom (fired on mouseup) reads the latest.
const selectionRef = useRef<ZoomSelection | null>(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<ChartSyncContextValue>(
() => ({
activeX,
setActiveX,
zoomEnabled: onZoom != null,
zoomSelection,
startZoom,
updateZoom,
endZoom,
cancelZoom,
}),
[activeX, setActiveX, onZoom, zoomSelection, startZoom, updateZoom, endZoom, cancelZoom]
);
return <ChartSyncContext.Provider value={value}>{children}</ChartSyncContext.Provider>;
}
/** Returns the sync context, or null when not inside a ChartSyncProvider. */
export function useChartSync(): ChartSyncContextValue | null {
return useContext(ChartSyncContext);
}