88 lines
2.5 KiB
TypeScript
88 lines
2.5 KiB
TypeScript
import { useCallback, useEffect, useRef } from 'react';
|
|
import uPlot from 'uplot';
|
|
|
|
export type BuildFn = (
|
|
el: HTMLDivElement,
|
|
w: number,
|
|
h: number,
|
|
legendRef: React.RefObject<HTMLDivElement>,
|
|
) => 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,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
deps: any[],
|
|
): {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
legendRef: React.RefObject<HTMLDivElement>;
|
|
} {
|
|
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 });
|
|
});
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/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 };
|
|
}
|