import { useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live"; import { AREA, MAX_ASPECT, areaWidth, fmtBytes, makeWorld, seed, tick, type World, } from "./sim"; const VIEW = 1000; const PANEL_W = 320; const HEADER_H = 70; // Patrol field aspect ratio for the current window: wide monitors get a // wider field instead of empty side margins. Quantized to 0.25 steps so // minor resizes don't rebuild the world. function displayAspect(): number { const w = Math.max(320, window.innerWidth - PANEL_W); const h = Math.max(320, window.innerHeight - HEADER_H); const q = Math.round((w / h) * 4) / 4; return Math.max(1, Math.min(MAX_ASPECT, q)); } const LIVE_POLL_MS = 1000; export default function App(): JSX.Element { const [droneCount, setDroneCount] = useState(8); const [speedup, setSpeedup] = useState(1); const [paused, setPaused] = useState(false); const [aspect, setAspect] = useState(displayAspect); const [mode, setMode] = useState<"sim" | "live">("sim"); const [liveError, setLiveError] = useState(null); const [world, setWorld] = useState(() => { seed(42); return makeWorld(8, displayAspect()); }); const raf = useRef(0); const last = useRef(performance.now()); const liveWorld = useRef(null); useEffect(() => { seed(42); setWorld(makeWorld(droneCount, aspect)); }, [droneCount, aspect]); useEffect(() => { let timer = 0; const onResize = (): void => { window.clearTimeout(timer); timer = window.setTimeout(() => setAspect(displayAspect()), 250); }; window.addEventListener("resize", onResize); return () => { window.clearTimeout(timer); window.removeEventListener("resize", onResize); }; }, []); useEffect(() => { if (mode !== "sim") return; const loop = (now: number): void => { const dt = Math.min(0.05, (now - last.current) / 1000); last.current = now; if (!paused && dt > 0) { setWorld((w) => tick(w, dt * speedup)); } raf.current = requestAnimationFrame(loop); }; raf.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf.current); }, [paused, speedup, mode]); // Live mode: poll the explorer's read-only SQL API for real poses useEffect(() => { if (mode !== "live") return; let cancelled = false; const poll = async (): Promise => { try { const poses = await fetchLivePoses(DEFAULT_EXPLORER_URL); if (cancelled) return; setLiveError(poses.length === 0 ? "no drone data in the lake yet" : null); liveWorld.current = toLiveWorld(poses, liveWorld.current, LIVE_POLL_MS / 1000); setWorld(liveWorld.current); } catch (err) { if (!cancelled) setLiveError(err instanceof Error ? err.message : String(err)); } }; void poll(); const timer = window.setInterval(() => void poll(), LIVE_POLL_MS); return () => { cancelled = true; window.clearInterval(timer); }; }, [mode]); useEffect(() => { if (mode === "sim") { liveWorld.current = null; setLiveError(null); seed(42); setWorld(makeWorld(droneCount, aspect)); } // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on mode change }, [mode]); const activeLinks = useMemo( () => [...world.links.values()].sort((a, b) => b.rate - a.rate), [world.links], ); const busiest = activeLinks.slice(0, 6); return (
SWARM HOUSE
swarm data plane — pose broadcast + opportunistic bulk sync
{mode === "live" && ( {liveError ?? `live · ${DEFAULT_EXPLORER_URL}`} )} {mode === "sim" && ( <> )}
{gridLines()} {/* Mesh links */} {activeLinks.map((l) => { const a = world.drones[l.a]; const b = world.drones[l.b]; if (!a || !b) return null; const mx = (a.pos.x + b.pos.x) / 2; const my = (a.pos.y + b.pos.y) / 2; const hot = l.rate > 20_000; return ( {hot && ( {fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)} )} ); })} {/* Obstacles */} {world.obstacles.map((ob) => ( {ob.moving && ( transit )} ))} {/* Drones */} {world.drones.map((d) => ( {d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} · {d.battery.toFixed(0)}% ))}
); } function sx(x: number): number { return (x / AREA) * VIEW; } function sy(y: number): number { return (y / AREA) * VIEW; } function gridLines(): JSX.Element { const width = sx(areaWidth()); const step = VIEW / 10; // one cell per 100 m in both directions const lines = []; for (let p = step; p < width; p += step) { lines.push( , ); } for (let p = step; p < VIEW; p += step) { lines.push( , ); } return {lines}; } function Section({ title, children }: { title: string; children: React.ReactNode }): JSX.Element { return (
{title}
{children}
); } function Stat({ name, value }: { name: string; value: string }): JSX.Element { return (
{name} {value}
); } const styles: Record = { shell: { minHeight: "100vh", background: "#070b12", color: "#dde4f0", fontFamily: "'JetBrains Mono', ui-monospace, monospace", display: "flex", flexDirection: "column", }, header: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "14px 22px", borderBottom: "1px solid #1a2436", flexWrap: "wrap", gap: 12, }, title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" }, subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 }, controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" }, label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" }, value: { minWidth: 30, color: "#dde4f0" }, button: { background: "#16233a", color: "#8fb4e8", border: "1px solid #2a3c5c", borderRadius: 6, padding: "6px 16px", fontFamily: "inherit", fontSize: 12, cursor: "pointer", }, buttonActive: { background: "#1a3a2a", color: "#39d98a", borderColor: "#2a5c3c" }, liveOk: { fontSize: 11, color: "#39d98a" }, liveError: { fontSize: 11, color: "#c05a6a" }, main: { display: "flex", flex: 1, gap: 0, minHeight: 0 }, canvas: { flex: 1, minWidth: 0, display: "block" }, panel: { width: 320, borderLeft: "1px solid #1a2436", padding: 16, overflowY: "auto", background: "#0a0f18", }, section: { marginBottom: 22 }, sectionTitle: { fontSize: 11, letterSpacing: 2, color: "#5a6a85", textTransform: "uppercase", marginBottom: 10, borderBottom: "1px solid #1a2436", paddingBottom: 5, }, statRow: { display: "flex", justifyContent: "space-between", padding: "3px 0", fontSize: 12 }, statValue: { color: "#8fb4e8" }, linkRow: { display: "flex", flexDirection: "column", padding: "4px 0", fontSize: 11, borderBottom: "1px dotted #141d2e", }, linkName: { color: "#c8d2e4" }, linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 }, dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" }, linkLabel: { fill: "#39d98a", fontSize: 10, textAnchor: "middle", fontFamily: "inherit" }, droneLabel: { fill: "#6a7a95", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" }, obLabel: { fill: "#c05a6a", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" }, };