Fix sparse charts and isolate Atlas pointer drags

This commit is contained in:
n30nex
2026-10-13 03:57:58 -04:00
parent a2fc5362cf
commit fee858fd77
4 changed files with 84 additions and 15 deletions
+17 -4
View File
@@ -14,18 +14,24 @@ export function useCardDrag(count: number, onReorder: (from: number, to: number)
const cards = useRef(new Map<number, HTMLElement>());
const [drag, setDrag] = useState<Drag | null>(null);
const cleanup = useRef<() => void>(() => {});
const active = useRef(false);
useEffect(() => () => cleanup.current(), []);
const start = useCallback((index: number, event: PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
if (event.button !== 0 || active.current) return;
event.preventDefault();
active.current = true;
const pointerId = event.pointerId;
const handle = event.currentTarget;
handle.setPointerCapture?.(pointerId);
const rects = Array.from({ length: count }, (_, i) => cards.current.get(i)?.getBoundingClientRect() ?? new DOMRect());
const origin = { x: event.clientX, y: event.clientY };
let current: Drag = { from: index, over: index, dx: 0, dy: 0, rects };
setDrag(current);
const onMove = (e: globalThis.PointerEvent) => {
if (e.pointerId !== pointerId) return;
const hit = rects.findIndex((r) => e.clientX >= r.left && e.clientX < r.right && e.clientY >= r.top && e.clientY < r.bottom);
current = { ...current, dx: e.clientX - origin.x, dy: e.clientY - origin.y, over: hit === -1 ? current.over : hit };
setDrag(current);
@@ -35,20 +41,27 @@ export function useCardDrag(count: number, onReorder: (from: number, to: number)
setDrag(null);
if (commit && current.over !== current.from) onReorder(current.from, current.over);
};
const onUp = () => end(true);
const onUp = (e: globalThis.PointerEvent) => { if (e.pointerId === pointerId) end(true); };
const onCancel = () => end(false);
const onPointerCancel = (e: globalThis.PointerEvent) => { if (e.pointerId === pointerId) end(false); };
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") end(false);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel);
window.addEventListener("pointercancel", onPointerCancel);
window.addEventListener("keydown", onKey);
window.addEventListener("blur", onCancel);
handle.addEventListener("lostpointercapture", onPointerCancel);
cleanup.current = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel);
window.removeEventListener("pointercancel", onPointerCancel);
window.removeEventListener("keydown", onKey);
window.removeEventListener("blur", onCancel);
handle.removeEventListener("lostpointercapture", onPointerCancel);
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
active.current = false;
cleanup.current = () => {};
};
}, [count, onReorder]);
+7 -5
View File
@@ -34,11 +34,11 @@ const sparkSpacer = <div className="mt-1.5 h-[20px]" />;
function Sparkline({ values, color, markPeak }: { values: (number | null)[]; color: string; markPeak?: boolean }) {
const known = values.filter((v): v is number => v !== null);
if (values.length < 2 || known.length < 2) return sparkSpacer;
if (known.length === 0) return sparkSpacer;
const max = Math.max(...known);
const min = Math.min(...known);
const range = max - min || 1;
const x = (i: number) => (i / (values.length - 1)) * SPARK_W;
const x = (i: number) => values.length === 1 ? SPARK_W / 2 : (i / (values.length - 1)) * SPARK_W;
const y = (v: number) => SPARK_H - 1 - ((v - min) / range) * (SPARK_H - 2);
// A null hour breaks the line, so an outage shows as a gap rather than a straight join.
const runs: string[][] = [[]];
@@ -49,9 +49,11 @@ function Sparkline({ values, color, markPeak }: { values: (number | null)[]; col
const peak = values.indexOf(max);
return (
<svg width="100%" height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} preserveAspectRatio="none" className="mt-1.5 overflow-visible" aria-hidden>
{runs.filter((run) => run.length > 1).map((run) => (
<polyline key={run[0]} fill="none" stroke={color} strokeWidth="1.5" points={run.join(" ")} />
))}
{runs.filter((run) => run.length > 0).map((run) => {
if (run.length > 1) return <polyline key={run[0]} fill="none" stroke={color} strokeWidth="1.5" points={run.join(" ")} />;
const [px, py] = run[0]!.split(",");
return <line key={run[0]} data-sample x1={px} x2={px} y1={py} y2={py} stroke={color} strokeWidth="3" strokeLinecap="round" vectorEffect="non-scaling-stroke" />;
})}
{markPeak && (
// zero-length round-capped stroke: stays a circle under preserveAspectRatio="none"
<line data-peak x1={x(peak)} x2={x(peak)} y1={y(max)} y2={y(max)} stroke={color} strokeWidth="5" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
+45 -6
View File
@@ -25,15 +25,54 @@ function mount() {
({ left: index * 100, right: index * 100 + 100, top: 0, bottom: 100, width: 100, height: 100, x: index * 100, y: 0, toJSON() {} }) as DOMRect;
});
}
const move = (x: number, y = 50) => fireEvent(window, new MouseEvent("pointermove", { clientX: x, clientY: y }));
const drop = () => fireEvent(window, new MouseEvent("pointerup"));
const move = (x: number, y = 50) => pointer("pointermove", window, 0, x, y);
const drop = () => pointer("pointerup", window, 0);
const pointer = (type: string, target: Element | Window, id: number, x = 50, y = 50) => {
const event = new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y });
Object.defineProperty(event, "pointerId", { value: id });
fireEvent(target, event);
};
beforeEach(() => vi.clearAllMocks());
describe("useCardDrag", () => {
it("ignores movement and release from another pointer", () => {
mount();
pointer("pointerdown", screen.getByLabelText("handle 0"), 1);
pointer("pointermove", window, 2, 250);
pointer("pointerup", window, 2);
expect(onReorder).not.toHaveBeenCalled();
pointer("pointermove", window, 1, 150);
pointer("pointerup", window, 1);
expect(onReorder).toHaveBeenCalledExactlyOnceWith(0, 1);
});
it("ignores a second drag start and removes the first drag's listeners on drop", () => {
mount();
pointer("pointerdown", screen.getByLabelText("handle 0"), 1);
pointer("pointerdown", screen.getByLabelText("handle 1"), 2, 150);
pointer("pointermove", window, 1, 250);
pointer("pointerup", window, 1);
expect(onReorder).toHaveBeenCalledExactlyOnceWith(0, 2);
pointer("pointermove", window, 2, 150);
pointer("pointerup", window, 2);
expect(onReorder).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("card-0").style.transform).toBe("");
});
it("cancels when the window loses focus", () => {
mount();
pointer("pointerdown", screen.getByLabelText("handle 0"), 1);
pointer("pointermove", window, 1, 250);
fireEvent.blur(window);
pointer("pointerup", window, 1);
expect(onReorder).not.toHaveBeenCalled();
expect(screen.getByTestId("card-0").style.transform).toBe("");
});
it("moves the dragged card with the pointer and slides the others out of its way", () => {
mount();
fireEvent.pointerDown(screen.getByLabelText("handle 0"), { clientX: 50, clientY: 50 });
pointer("pointerdown", screen.getByLabelText("handle 0"), 0);
move(250);
expect(screen.getByTestId("card-0")).toHaveStyle({ transform: "translate(200px, 0px)" });
expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(-100px, 0px)" });
@@ -45,7 +84,7 @@ describe("useCardDrag", () => {
it("slides cards forward when dragging towards the start", () => {
mount();
fireEvent.pointerDown(screen.getByLabelText("handle 2"), { clientX: 250, clientY: 50 });
pointer("pointerdown", screen.getByLabelText("handle 2"), 0, 250);
move(120);
expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(100px, 0px)" });
expect(screen.getByTestId("card-0").style.transform).toBe("");
@@ -55,7 +94,7 @@ describe("useCardDrag", () => {
it("does nothing on a drop in the same slot or outside every card", () => {
mount();
fireEvent.pointerDown(screen.getByLabelText("handle 1"), { clientX: 150, clientY: 50 });
pointer("pointerdown", screen.getByLabelText("handle 1"), 0, 150);
move(160);
move(160, 900);
drop();
@@ -64,7 +103,7 @@ describe("useCardDrag", () => {
it("cancels on Escape", () => {
mount();
fireEvent.pointerDown(screen.getByLabelText("handle 0"), { clientX: 50, clientY: 50 });
pointer("pointerdown", screen.getByLabelText("handle 0"), 0);
move(250);
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.getByTestId("card-0").style.transform).toBe("");
+15
View File
@@ -22,3 +22,18 @@ it("draws a presence strip with one bar per run of reporting hours", () => {
const bars = [...container.querySelectorAll("rect")];
expect(bars.map((b) => [b.getAttribute("x"), b.getAttribute("width")])).toEqual([["0", "48"], ["72", "24"]]);
});
it("keeps isolated readings visible without connecting across missing hours", () => {
const { container } = render(<StatCard label="Signal" value="2 dB" accent="green" spark={[0, null, 4]} />);
expect(container.querySelectorAll("polyline")).toHaveLength(0);
const points = [...container.querySelectorAll("[data-sample]")];
expect(points.map(point => point.getAttribute("x1"))).toEqual(["0", "120"]);
});
it("draws a single known value at a finite position, including zero", () => {
const { container } = render(<StatCard label="Signal" value="0 dB" accent="green" spark={[0]} />);
const point = container.querySelector("[data-sample]");
expect(point).not.toBeNull();
expect(point?.getAttribute("x1")).toBe("60");
expect(Number.isFinite(Number(point?.getAttribute("y1")))).toBe(true);
});