mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-20 07:22:03 +00:00
Fix sparse charts and isolate Atlas pointer drags
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user