Files
swarm-house/prototype/src/App.tsx
T
eSlider cb4664f5ee feat: add IaC layer with shared var/t1 and var/t3 data paths
Ansible provisions the k3d cluster and Flux controllers; Terraform
modules deploy the simulated fleet and ground warehouse. Compose and
k3d share var/t1 (live lake) and var/t3 (warehouse). The prototype
gains a live mode fed by the explorer read-only SQL API.
2026-07-08 20:17:32 +01:00

395 lines
13 KiB
TypeScript

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<string | null>(null);
const [world, setWorld] = useState<World>(() => {
seed(42);
return makeWorld(8, displayAspect());
});
const raf = useRef(0);
const last = useRef(performance.now());
const liveWorld = useRef<World | null>(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<void> => {
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 (
<div style={styles.shell}>
<header style={styles.header}>
<div>
<div style={styles.title}>SWARM HOUSE</div>
<div style={styles.subtitle}>
swarm data plane pose broadcast + opportunistic bulk sync
</div>
</div>
<div style={styles.controls}>
<button
style={{ ...styles.button, ...(mode === "live" ? styles.buttonActive : {}) }}
onClick={() => setMode((m) => (m === "sim" ? "live" : "sim"))}
title={`live source: ${DEFAULT_EXPLORER_URL}`}
>
{mode === "sim" ? "go live" : "back to sim"}
</button>
{mode === "live" && (
<span style={liveError ? styles.liveError : styles.liveOk}>
{liveError ?? `live · ${DEFAULT_EXPLORER_URL}`}
</span>
)}
{mode === "sim" && (
<>
<label style={styles.label}>
drones
<input
type="range"
min={3}
max={24}
value={droneCount}
onChange={(e) => setDroneCount(Number(e.target.value))}
/>
<span style={styles.value}>{droneCount}</span>
</label>
<label style={styles.label}>
speed
<input
type="range"
min={0.5}
max={6}
step={0.5}
value={speedup}
onChange={(e) => setSpeedup(Number(e.target.value))}
/>
<span style={styles.value}>{speedup}x</span>
</label>
<button style={styles.button} onClick={() => setPaused((p) => !p)}>
{paused ? "resume" : "pause"}
</button>
</>
)}
</div>
</header>
<div style={styles.main}>
<svg
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
style={styles.canvas}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
<stop offset="0%" stopColor="#101826" />
<stop offset="100%" stopColor="#0a0f18" />
</radialGradient>
</defs>
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
{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 (
<g key={l.key}>
<line
x1={sx(a.pos.x)}
y1={sy(a.pos.y)}
x2={sx(b.pos.x)}
y2={sy(b.pos.y)}
stroke={hot ? "#39d98a" : "#3d7bd9"}
strokeWidth={hot ? 2.2 : 1}
opacity={0.25 + l.flash * 0.75}
/>
{hot && (
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
</text>
)}
</g>
);
})}
{/* Obstacles */}
{world.obstacles.map((ob) => (
<g key={ob.id}>
<circle
cx={sx(ob.pos.x)}
cy={sy(ob.pos.y)}
r={(ob.radius / AREA) * VIEW}
fill={ob.moving ? "#5a2a35" : "#2a3245"}
stroke={ob.moving ? "#c05a6a" : "#46536e"}
strokeWidth={1.5}
opacity={0.85}
/>
{ob.moving && (
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
transit
</text>
)}
</g>
))}
{/* Drones */}
{world.drones.map((d) => (
<g
key={d.id}
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
>
<polygon points="0,-11 7,9 0,5 -7,9" fill="#e8ecf4" stroke="#7ea0d0" strokeWidth={1} />
<text y={24} style={styles.droneLabel as never} transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}>
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} · {d.battery.toFixed(0)}%
</text>
</g>
))}
</svg>
<aside style={styles.panel}>
<Section title="Mission">
<Stat name="sim time" value={`${world.t.toFixed(0)} s`} />
<Stat name="drones" value={String(world.drones.length)} />
<Stat name="active links" value={String(world.links.size)} />
<Stat name="pose broadcasts" value={String(world.broadcasts)} />
<Stat name="total transferred" value={fmtBytes(world.totalBytes)} />
</Section>
<Section title="Busiest links">
{busiest.map((l) => (
<div key={l.key} style={styles.linkRow}>
<span style={styles.linkName}>
{world.drones[l.a]?.name ?? `dr-${String(l.a + 1).padStart(2, "0")}`} {" "}
{world.drones[l.b]?.name ?? `dr-${String(l.b + 1).padStart(2, "0")}`}
</span>
<span style={styles.linkStat}>
{fmtBytes(l.rate)}/s · {fmtBytes(l.totalUp)} {fmtBytes(l.totalDown)}
</span>
</div>
))}
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
</Section>
<Section title="Legend">
<div style={styles.dim}>blue line pose broadcast (5 Hz, ~46 B)</div>
<div style={styles.dim}>green line bulk sync (sealed partitions)</div>
<div style={styles.dim}>flash broadcast event delivered</div>
<div style={styles.dim}>red circle transit object crossing the area</div>
</Section>
</aside>
</div>
</div>
);
}
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(
<line key={`v${p}`} x1={p} y1={0} x2={p} y2={VIEW} stroke="#1a2436" strokeWidth={1} />,
);
}
for (let p = step; p < VIEW; p += step) {
lines.push(
<line key={`h${p}`} x1={0} y1={p} x2={width} y2={p} stroke="#1a2436" strokeWidth={1} />,
);
}
return <g>{lines}</g>;
}
function Section({ title, children }: { title: string; children: React.ReactNode }): JSX.Element {
return (
<div style={styles.section}>
<div style={styles.sectionTitle}>{title}</div>
{children}
</div>
);
}
function Stat({ name, value }: { name: string; value: string }): JSX.Element {
return (
<div style={styles.statRow}>
<span style={styles.dim}>{name}</span>
<span style={styles.statValue}>{value}</span>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
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" },
};