Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef6623f39 | ||
|
|
f008da1427 | ||
|
|
407a70fef1 |
+44
-11
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AREA,
|
||||
MAX_ASPECT,
|
||||
areaWidth,
|
||||
fmtBytes,
|
||||
makeWorld,
|
||||
seed,
|
||||
@@ -9,22 +11,48 @@ import {
|
||||
} 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));
|
||||
}
|
||||
|
||||
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 [world, setWorld] = useState<World>(() => {
|
||||
seed(42);
|
||||
return makeWorld(8);
|
||||
return makeWorld(8, displayAspect());
|
||||
});
|
||||
const raf = useRef(0);
|
||||
const last = useRef(performance.now());
|
||||
|
||||
useEffect(() => {
|
||||
seed(42);
|
||||
setWorld(makeWorld(droneCount));
|
||||
}, [droneCount]);
|
||||
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(() => {
|
||||
const loop = (now: number): void => {
|
||||
@@ -86,7 +114,7 @@ export default function App(): JSX.Element {
|
||||
|
||||
<div style={styles.main}>
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW} ${VIEW}`}
|
||||
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
|
||||
style={styles.canvas}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
@@ -96,7 +124,7 @@ export default function App(): JSX.Element {
|
||||
<stop offset="100%" stopColor="#0a0f18" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width={VIEW} height={VIEW} fill="url(#ground)" />
|
||||
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
|
||||
{gridLines()}
|
||||
|
||||
{/* Mesh links */}
|
||||
@@ -141,7 +169,7 @@ export default function App(): JSX.Element {
|
||||
/>
|
||||
{ob.moving && (
|
||||
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
|
||||
mobile
|
||||
transit
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
@@ -186,7 +214,7 @@ export default function App(): JSX.Element {
|
||||
<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 — mobile obstacle</div>
|
||||
<div style={styles.dim}>red circle — transit object crossing the area</div>
|
||||
</Section>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -202,12 +230,17 @@ function sy(y: number): number {
|
||||
}
|
||||
|
||||
function gridLines(): JSX.Element {
|
||||
const width = sx(areaWidth());
|
||||
const step = VIEW / 10; // one cell per 100 m in both directions
|
||||
const lines = [];
|
||||
for (let i = 1; i < 10; i++) {
|
||||
const p = (i / 10) * VIEW;
|
||||
for (let p = step; p < width; p += step) {
|
||||
lines.push(
|
||||
<line key={`v${i}`} x1={p} y1={0} x2={p} y2={VIEW} stroke="#1a2436" strokeWidth={1} />,
|
||||
<line key={`h${i}`} x1={0} y1={p} x2={VIEW} y2={p} stroke="#1a2436" strokeWidth={1} />,
|
||||
<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>;
|
||||
|
||||
+81
-26
@@ -45,7 +45,14 @@ export interface World {
|
||||
broadcasts: number;
|
||||
}
|
||||
|
||||
export const AREA = 1000; // meters, square side
|
||||
export const AREA = 1000; // meters, vertical extent of the field
|
||||
export const MAX_ASPECT = 2.2;
|
||||
// Horizontal extent follows the display aspect ratio (set via makeWorld),
|
||||
// so wide monitors get a genuinely wider patrol area, not empty margins.
|
||||
let areaX = AREA;
|
||||
export function areaWidth(): number {
|
||||
return areaX;
|
||||
}
|
||||
export const LINK_RANGE = 320;
|
||||
const CRUISE = 28;
|
||||
const SEPARATION = 55;
|
||||
@@ -65,35 +72,85 @@ function rnd(): number {
|
||||
return ((seedState >>> 0) % 100000) / 100000;
|
||||
}
|
||||
|
||||
const WAYPOINTS: Vec[] = [
|
||||
// Two interleaved patrol routes: even drones circle the perimeter, odd
|
||||
// drones fly an X through the center. Paths cross mid-field, so the mesh
|
||||
// keeps bridging between the front and the back of the formation instead
|
||||
// of stretching into disconnected segments along one loop. Routes are
|
||||
// derived from the current field width, so they stretch on wide displays.
|
||||
function perimeterRoute(): Vec[] {
|
||||
return [
|
||||
{ x: 120, y: 120 },
|
||||
{ x: AREA - 120, y: 150 },
|
||||
{ x: AREA - 150, y: AREA - 120 },
|
||||
{ x: areaX - 120, y: 150 },
|
||||
{ x: areaX - 150, y: AREA - 120 },
|
||||
{ x: 150, y: AREA - 150 },
|
||||
];
|
||||
];
|
||||
}
|
||||
function crossRoute(): Vec[] {
|
||||
return [
|
||||
{ x: 150, y: 150 },
|
||||
{ x: areaX - 150, y: AREA - 150 },
|
||||
{ x: areaX - 150, y: 150 },
|
||||
{ x: 150, y: AREA - 150 },
|
||||
];
|
||||
}
|
||||
function routeOf(d: Drone): Vec[] {
|
||||
return d.id % 2 === 0 ? perimeterRoute() : crossRoute();
|
||||
}
|
||||
|
||||
export function makeWorld(droneCount: number): World {
|
||||
// Transit objects enter at one edge, cross the whole area, and respawn at
|
||||
// a fresh edge once they leave — a stream of through-traffic instead of
|
||||
// obstacles bouncing until they wedge into a corner.
|
||||
const TRANSIT_MARGIN = 120;
|
||||
function spawnTransit(id: number): Obstacle {
|
||||
const speed = 26 + rnd() * 30;
|
||||
const alongX = 120 + rnd() * (areaX - 240);
|
||||
const alongY = 120 + rnd() * (AREA - 240);
|
||||
const skew = (rnd() - 0.5) * speed * 0.9; // diagonal-ish crossings
|
||||
const edge = Math.floor(rnd() * 4);
|
||||
const table: { pos: Vec; vel: Vec }[] = [
|
||||
{ pos: { x: alongX, y: -TRANSIT_MARGIN + 20 }, vel: { x: skew, y: speed } },
|
||||
{ pos: { x: alongX, y: AREA + TRANSIT_MARGIN - 20 }, vel: { x: skew, y: -speed } },
|
||||
{ pos: { x: -TRANSIT_MARGIN + 20, y: alongY }, vel: { x: speed, y: skew } },
|
||||
{ pos: { x: areaX + TRANSIT_MARGIN - 20, y: alongY }, vel: { x: -speed, y: skew } },
|
||||
];
|
||||
const { pos, vel } = table[edge];
|
||||
return { id, pos, radius: 22 + rnd() * 16, moving: true, vel };
|
||||
}
|
||||
function outOfTransit(ob: Obstacle): boolean {
|
||||
return (
|
||||
ob.pos.x < -TRANSIT_MARGIN ||
|
||||
ob.pos.x > areaX + TRANSIT_MARGIN ||
|
||||
ob.pos.y < -TRANSIT_MARGIN ||
|
||||
ob.pos.y > AREA + TRANSIT_MARGIN
|
||||
);
|
||||
}
|
||||
|
||||
export function makeWorld(droneCount: number, aspect = 1): World {
|
||||
areaX = AREA * Math.max(1, Math.min(MAX_ASPECT, aspect));
|
||||
const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => {
|
||||
const angle = (i / droneCount) * Math.PI * 2;
|
||||
return {
|
||||
id: i,
|
||||
pos: {
|
||||
x: AREA / 2 + Math.cos(angle) * (150 + rnd() * 120),
|
||||
x: areaX / 2 + Math.cos(angle) * (150 + rnd() * 120),
|
||||
y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120),
|
||||
},
|
||||
vel: { x: 0, y: 0 },
|
||||
heading: angle,
|
||||
waypoint: i % WAYPOINTS.length,
|
||||
waypoint: i % 4,
|
||||
battery: 90 + rnd() * 10,
|
||||
channel: CHANNELS[i % CHANNELS.length],
|
||||
};
|
||||
});
|
||||
// Static obstacles sit at fixed fractions of the field, so they spread
|
||||
// out instead of clustering left when the field widens
|
||||
const obstacles: Obstacle[] = [
|
||||
{ id: 0, pos: { x: 340, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } },
|
||||
{ id: 1, pos: { x: 700, y: 260 }, radius: 45, moving: false, vel: { x: 0, y: 0 } },
|
||||
{ id: 2, pos: { x: 560, y: 720 }, radius: 70, moving: false, vel: { x: 0, y: 0 } },
|
||||
{ id: 3, pos: { x: 200, y: 800 }, radius: 30, moving: true, vel: { x: 14, y: -6 } },
|
||||
{ id: 4, pos: { x: 820, y: 620 }, radius: 26, moving: true, vel: { x: -10, y: 9 } },
|
||||
{ id: 0, pos: { x: areaX * 0.34, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } },
|
||||
{ id: 1, pos: { x: areaX * 0.7, y: 260 }, radius: 45, moving: false, vel: { x: 0, y: 0 } },
|
||||
{ id: 2, pos: { x: areaX * 0.56, y: 720 }, radius: 70, moving: false, vel: { x: 0, y: 0 } },
|
||||
spawnTransit(3),
|
||||
spawnTransit(4),
|
||||
spawnTransit(5),
|
||||
];
|
||||
return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 };
|
||||
}
|
||||
@@ -103,12 +160,13 @@ function linkKey(a: number, b: number): string {
|
||||
}
|
||||
|
||||
function steer(d: Drone, world: World, dt: number): Drone {
|
||||
const wp = WAYPOINTS[(d.waypoint + d.id) % WAYPOINTS.length];
|
||||
const route = routeOf(d);
|
||||
const wp = route[(d.waypoint + d.id) % route.length];
|
||||
let ax = wp.x - d.pos.x;
|
||||
let ay = wp.y - d.pos.y;
|
||||
const wpDist = Math.hypot(ax, ay);
|
||||
let waypoint = d.waypoint;
|
||||
if (wpDist < 90) waypoint = (d.waypoint + 1) % WAYPOINTS.length;
|
||||
if (wpDist < 90) waypoint = (d.waypoint + 1) % route.length;
|
||||
ax /= wpDist || 1;
|
||||
ay /= wpDist || 1;
|
||||
|
||||
@@ -147,7 +205,7 @@ function steer(d: Drone, world: World, dt: number): Drone {
|
||||
vel: { x: vx, y: vy },
|
||||
heading,
|
||||
pos: {
|
||||
x: Math.max(20, Math.min(AREA - 20, d.pos.x + vx * dt)),
|
||||
x: Math.max(20, Math.min(areaX - 20, d.pos.x + vx * dt)),
|
||||
y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)),
|
||||
},
|
||||
battery: Math.max(0, d.battery - dt * 0.05),
|
||||
@@ -157,18 +215,15 @@ function steer(d: Drone, world: World, dt: number): Drone {
|
||||
|
||||
export function tick(world: World, dt: number): World {
|
||||
const drones = world.drones.map((d) => steer(d, world, dt));
|
||||
const obstacles = world.obstacles.map((ob) =>
|
||||
ob.moving
|
||||
? {
|
||||
const obstacles = world.obstacles.map((ob) => {
|
||||
if (!ob.moving) return ob;
|
||||
const moved = {
|
||||
...ob,
|
||||
pos: { x: ob.pos.x + ob.vel.x * dt, y: ob.pos.y + ob.vel.y * dt },
|
||||
vel: {
|
||||
x: ob.pos.x < 60 || ob.pos.x > AREA - 60 ? -ob.vel.x : ob.vel.x,
|
||||
y: ob.pos.y < 60 || ob.pos.y > AREA - 60 ? -ob.vel.y : ob.vel.y,
|
||||
},
|
||||
}
|
||||
: ob,
|
||||
);
|
||||
};
|
||||
// Once a transit object leaves the area, respawn it at a random edge
|
||||
return outOfTransit(moved) ? spawnTransit(ob.id) : moved;
|
||||
});
|
||||
|
||||
const links = new Map(world.links);
|
||||
let totalBytes = world.totalBytes;
|
||||
|
||||
@@ -58,6 +58,10 @@ services:
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
||||
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
||||
# Air-gap hygiene: no phone-home, no update checks, no plugin fetches
|
||||
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
||||
GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
|
||||
volumes:
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
|
||||
Reference in New Issue
Block a user