From fee858fd77071604e3ea3fae2a577c5ddf8d1e72 Mon Sep 17 00:00:00 2001 From: n30nex Date: Tue, 13 Oct 2026 03:57:58 -0400 Subject: [PATCH] Fix sparse charts and isolate Atlas pointer drags --- src/features/atlas/useCardDrag.ts | 21 ++++++++-- src/features/stats/cards.tsx | 12 +++--- tests/features/atlas/useCardDrag.test.tsx | 51 ++++++++++++++++++++--- tests/features/stats/cards.test.tsx | 15 +++++++ 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/features/atlas/useCardDrag.ts b/src/features/atlas/useCardDrag.ts index 378779d..e7f2fd6 100644 --- a/src/features/atlas/useCardDrag.ts +++ b/src/features/atlas/useCardDrag.ts @@ -14,18 +14,24 @@ export function useCardDrag(count: number, onReorder: (from: number, to: number) const cards = useRef(new Map()); const [drag, setDrag] = useState(null); const cleanup = useRef<() => void>(() => {}); + const active = useRef(false); useEffect(() => () => cleanup.current(), []); const start = useCallback((index: number, event: PointerEvent) => { - 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]); diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index 5655e36..fdd50ce 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -34,11 +34,11 @@ const sparkSpacer =
; 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 ( - {runs.filter((run) => run.length > 1).map((run) => ( - - ))} + {runs.filter((run) => run.length > 0).map((run) => { + if (run.length > 1) return ; + const [px, py] = run[0]!.split(","); + return ; + })} {markPeak && ( // zero-length round-capped stroke: stays a circle under preserveAspectRatio="none" diff --git a/tests/features/atlas/useCardDrag.test.tsx b/tests/features/atlas/useCardDrag.test.tsx index 97b0a0a..012dbf1 100644 --- a/tests/features/atlas/useCardDrag.test.tsx +++ b/tests/features/atlas/useCardDrag.test.tsx @@ -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(""); diff --git a/tests/features/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx index 831550c..b142aec 100644 --- a/tests/features/stats/cards.test.tsx +++ b/tests/features/stats/cards.test.tsx @@ -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(); + 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(); + 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); +});