Initial web
This commit is contained in:
80
web/components/ChartCard/usePlot.ts
Normal file
80
web/components/ChartCard/usePlot.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user