Files

89 lines
2.5 KiB
TypeScript

import { useCallback, useEffect, useRef, type DependencyList } from 'react';
import type uPlot from 'uplot';
export type BuildFn = (
el: HTMLDivElement,
w: number,
h: number,
legendRef: React.RefObject<HTMLDivElement | null>,
) => uPlot | null;
/** Converts a data index to the pixel x position uPlot expects for setCursor */
function idxToPixel(plot: uPlot, idx: number): number {
const x = plot.data[0]?.[idx];
if (x == null) return -10;
return plot.valToPos(x, 'x');
}
/**
* Manages full uPlot lifecycle:
* - builds/rebuilds on dep change or resize
* - mounts legend into legendRef container
* - pins cursor to last data point on init (legend shows latest values)
* - restores last-point legend on mouseleave
*/
export function usePlot(
build: BuildFn,
deps: DependencyList,
): {
containerRef: React.RefObject<HTMLDivElement | null>;
legendRef: React.RefObject<HTMLDivElement | null>;
} {
const containerRef = useRef<HTMLDivElement | null>(null);
const legendRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<uPlot | null>(null);
const lastIdxRef = useRef<number>(0);
const rebuild = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const w = el.clientWidth,
h = el.clientHeight;
if (w < 10 || h < 10) return;
plotRef.current?.destroy();
if (legendRef.current) legendRef.current.innerHTML = '';
const plot = build(el, w, h, legendRef);
plotRef.current = plot;
if (plot) {
const lastIdx = Math.max(0, (plot.data[0]?.length ?? 1) - 1);
lastIdxRef.current = lastIdx;
// Pin legend to latest data point
plot.setCursor({ left: idxToPixel(plot, lastIdx), top: -10 });
// Defer mouseleave — prevents React hydration events firing before lastIdxRef set
requestAnimationFrame(() => {
plot.over.addEventListener('mouseleave', () => {
const p = plotRef.current;
if (!p) return;
p.setCursor({ left: idxToPixel(p, lastIdxRef.current), top: -10 });
});
});
}
// deps is intentionally dynamic — passed by parent to allow external rebuild triggers
// eslint-disable-next-line react-x/exhaustive-deps
}, deps);
useEffect(() => {
rebuild();
return () => {
plotRef.current?.destroy();
plotRef.current = null;
};
}, [rebuild]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(rebuild);
ro.observe(el);
return () => ro.disconnect();
}, [rebuild]);
return { containerRef, legendRef };
}