Files
factorio-signal-exporter/web/components/ChartCard/usePlot.ts
Caesar2011 20ed6ee9fb Initial web
2026-05-17 19:55:53 +02:00

80 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 };
}