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.
This commit is contained in:
+88
-28
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live";
|
||||
import {
|
||||
AREA,
|
||||
MAX_ASPECT,
|
||||
@@ -24,17 +25,22 @@ function displayAspect(): number {
|
||||
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);
|
||||
@@ -55,6 +61,7 @@ export default function App(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "sim") return;
|
||||
const loop = (now: number): void => {
|
||||
const dt = Math.min(0.05, (now - last.current) / 1000);
|
||||
last.current = now;
|
||||
@@ -65,7 +72,40 @@ export default function App(): JSX.Element {
|
||||
};
|
||||
raf.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf.current);
|
||||
}, [paused, speedup]);
|
||||
}, [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),
|
||||
@@ -83,32 +123,48 @@ export default function App(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.controls}>
|
||||
<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
|
||||
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>
|
||||
|
||||
@@ -183,7 +239,7 @@ export default function App(): JSX.Element {
|
||||
>
|
||||
<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)})`}>
|
||||
dr-{String(d.id + 1).padStart(2, "0")} · ch{d.channel} · {d.battery.toFixed(0)}%
|
||||
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} · {d.battery.toFixed(0)}%
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
@@ -201,7 +257,8 @@ export default function App(): JSX.Element {
|
||||
{busiest.map((l) => (
|
||||
<div key={l.key} style={styles.linkRow}>
|
||||
<span style={styles.linkName}>
|
||||
dr-{String(l.a + 1).padStart(2, "0")} ↔ dr-{String(l.b + 1).padStart(2, "0")}
|
||||
{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)}
|
||||
@@ -297,6 +354,9 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
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: {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Live mode: real drone poses from the explorer's read-only SQL API.
|
||||
// The same gated /api/query endpoint a peer drone (or an engineer on the
|
||||
// bench) would use — the prototype is just another read-only consumer.
|
||||
|
||||
import { AREA, LINK_RANGE, POSE_RATE_BYTES, type LinkStats, type World } from "./sim";
|
||||
|
||||
export const DEFAULT_EXPLORER_URL =
|
||||
(import.meta.env.VITE_EXPLORER_URL as string | undefined) ?? "http://localhost:30088";
|
||||
|
||||
export interface LivePose {
|
||||
id: string;
|
||||
x: number; // meters, mission frame
|
||||
y: number;
|
||||
yawDeg: number;
|
||||
battery: number | null;
|
||||
tsNs: number;
|
||||
}
|
||||
|
||||
// Latest pose per drone from its own 'sent' state rows, joined with the
|
||||
// latest battery reading from telemetry. arg_max keeps it a single scan.
|
||||
const LIVE_SQL = `
|
||||
WITH pose AS (
|
||||
SELECT drone_id,
|
||||
arg_max(pos_x, ts_ns) AS x,
|
||||
arg_max(pos_y, ts_ns) AS y,
|
||||
arg_max(yaw, ts_ns) AS yaw,
|
||||
max(ts_ns) AS ts_ns
|
||||
FROM state
|
||||
WHERE direction = 'sent'
|
||||
GROUP BY drone_id
|
||||
),
|
||||
batt AS (
|
||||
SELECT drone_id, arg_max(level_pct, ts_ns) AS battery
|
||||
FROM telemetry
|
||||
WHERE sensor = 'battery'
|
||||
GROUP BY drone_id
|
||||
)
|
||||
SELECT p.drone_id, p.x, p.y, p.yaw, p.ts_ns, b.battery
|
||||
FROM pose p LEFT JOIN batt b USING (drone_id)
|
||||
ORDER BY p.drone_id`;
|
||||
|
||||
interface QueryResponse {
|
||||
columns?: string[];
|
||||
rows?: (string | number | null)[][];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function fetchLivePoses(baseUrl: string): Promise<LivePose[]> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/query`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: LIVE_SQL }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`explorer responded ${res.status}`);
|
||||
const data = (await res.json()) as QueryResponse;
|
||||
if (data.error) throw new Error(data.error);
|
||||
return (data.rows ?? []).map((r) => ({
|
||||
id: String(r[0]),
|
||||
x: Number(r[1]),
|
||||
y: Number(r[2]),
|
||||
yawDeg: Number(r[3]),
|
||||
tsNs: Number(r[4]),
|
||||
battery: r[5] === null ? null : Number(r[5]),
|
||||
}));
|
||||
}
|
||||
|
||||
// Scale mission-frame meters into the prototype's square view. The virtual
|
||||
// drones patrol a smaller square than the sim world, so fit the observed
|
||||
// extent (with padding) instead of assuming a size.
|
||||
function fitScale(poses: LivePose[]): number {
|
||||
const extent = Math.max(400, ...poses.map((p) => Math.max(p.x, p.y))) * 1.15;
|
||||
return AREA / extent;
|
||||
}
|
||||
|
||||
function linkKey(a: number, b: number): string {
|
||||
return a < b ? `${a}-${b}` : `${b}-${a}`;
|
||||
}
|
||||
|
||||
/** Convert live poses into the World shape the renderer already speaks. */
|
||||
export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): World {
|
||||
const k = fitScale(poses);
|
||||
const drones = poses.map((p, i) => ({
|
||||
id: i,
|
||||
name: p.id,
|
||||
pos: { x: p.x * k, y: p.y * k },
|
||||
vel: { x: 0, y: 0 },
|
||||
heading: (p.yawDeg * Math.PI) / 180,
|
||||
waypoint: 0,
|
||||
battery: p.battery ?? 100,
|
||||
channel: 0,
|
||||
}));
|
||||
|
||||
// Every in-range pair exchanges pose broadcasts; accumulate totals so the
|
||||
// panel keeps its meaning between polls.
|
||||
const links = new Map<string, LinkStats>();
|
||||
let totalBytes = prev?.totalBytes ?? 0;
|
||||
for (let i = 0; i < drones.length; i++) {
|
||||
for (let j = i + 1; j < drones.length; j++) {
|
||||
const dist = Math.hypot(
|
||||
drones[i].pos.x - drones[j].pos.x,
|
||||
drones[i].pos.y - drones[j].pos.y,
|
||||
);
|
||||
if (dist > LINK_RANGE) continue;
|
||||
const key = linkKey(i, j);
|
||||
const old = prev?.links.get(key);
|
||||
const bytes = POSE_RATE_BYTES * 2 * dtS;
|
||||
totalBytes += bytes;
|
||||
links.set(key, {
|
||||
key,
|
||||
a: i,
|
||||
b: j,
|
||||
totalUp: (old?.totalUp ?? 0) + bytes / 2,
|
||||
totalDown: (old?.totalDown ?? 0) + bytes / 2,
|
||||
rate: POSE_RATE_BYTES * 2,
|
||||
flash: 0.6,
|
||||
bulk: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
t: (prev?.t ?? 0) + dtS,
|
||||
drones,
|
||||
obstacles: [],
|
||||
links,
|
||||
totalBytes,
|
||||
broadcasts: (prev?.broadcasts ?? 0) + links.size * 2,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface Vec {
|
||||
|
||||
export interface Drone {
|
||||
id: number;
|
||||
name?: string; // live mode: real drone_id from the data plane
|
||||
pos: Vec;
|
||||
vel: Vec;
|
||||
heading: number; // radians
|
||||
@@ -58,6 +59,8 @@ const CRUISE = 28;
|
||||
const SEPARATION = 55;
|
||||
const POSE_BYTES = 46;
|
||||
const POSE_HZ = 5;
|
||||
// Steady per-link broadcast throughput (both directions), bytes/s
|
||||
export const POSE_RATE_BYTES = POSE_BYTES * POSE_HZ;
|
||||
const CHANNELS = [1, 6, 11, 36, 40, 44, 149, 157];
|
||||
|
||||
let seedState = 1234;
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user