Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef6623f39 | ||
|
|
f008da1427 | ||
|
|
407a70fef1 |
+44
-11
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AREA,
|
AREA,
|
||||||
|
MAX_ASPECT,
|
||||||
|
areaWidth,
|
||||||
fmtBytes,
|
fmtBytes,
|
||||||
makeWorld,
|
makeWorld,
|
||||||
seed,
|
seed,
|
||||||
@@ -9,22 +11,48 @@ import {
|
|||||||
} from "./sim";
|
} from "./sim";
|
||||||
|
|
||||||
const VIEW = 1000;
|
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 {
|
export default function App(): JSX.Element {
|
||||||
const [droneCount, setDroneCount] = useState(8);
|
const [droneCount, setDroneCount] = useState(8);
|
||||||
const [speedup, setSpeedup] = useState(1);
|
const [speedup, setSpeedup] = useState(1);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [aspect, setAspect] = useState(displayAspect);
|
||||||
const [world, setWorld] = useState<World>(() => {
|
const [world, setWorld] = useState<World>(() => {
|
||||||
seed(42);
|
seed(42);
|
||||||
return makeWorld(8);
|
return makeWorld(8, displayAspect());
|
||||||
});
|
});
|
||||||
const raf = useRef(0);
|
const raf = useRef(0);
|
||||||
const last = useRef(performance.now());
|
const last = useRef(performance.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
seed(42);
|
seed(42);
|
||||||
setWorld(makeWorld(droneCount));
|
setWorld(makeWorld(droneCount, aspect));
|
||||||
}, [droneCount]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const loop = (now: number): void => {
|
const loop = (now: number): void => {
|
||||||
@@ -86,7 +114,7 @@ export default function App(): JSX.Element {
|
|||||||
|
|
||||||
<div style={styles.main}>
|
<div style={styles.main}>
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${VIEW} ${VIEW}`}
|
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
|
||||||
style={styles.canvas}
|
style={styles.canvas}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
>
|
>
|
||||||
@@ -96,7 +124,7 @@ export default function App(): JSX.Element {
|
|||||||
<stop offset="100%" stopColor="#0a0f18" />
|
<stop offset="100%" stopColor="#0a0f18" />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width={VIEW} height={VIEW} fill="url(#ground)" />
|
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
|
||||||
{gridLines()}
|
{gridLines()}
|
||||||
|
|
||||||
{/* Mesh links */}
|
{/* Mesh links */}
|
||||||
@@ -141,7 +169,7 @@ export default function App(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
{ob.moving && (
|
{ob.moving && (
|
||||||
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
|
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
|
||||||
mobile
|
transit
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</g>
|
</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}>blue line — pose broadcast (5 Hz, ~46 B)</div>
|
||||||
<div style={styles.dim}>green line — bulk sync (sealed partitions)</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}>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>
|
</Section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,12 +230,17 @@ function sy(y: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gridLines(): JSX.Element {
|
function gridLines(): JSX.Element {
|
||||||
|
const width = sx(areaWidth());
|
||||||
|
const step = VIEW / 10; // one cell per 100 m in both directions
|
||||||
const lines = [];
|
const lines = [];
|
||||||
for (let i = 1; i < 10; i++) {
|
for (let p = step; p < width; p += step) {
|
||||||
const p = (i / 10) * VIEW;
|
|
||||||
lines.push(
|
lines.push(
|
||||||
<line key={`v${i}`} x1={p} y1={0} x2={p} y2={VIEW} stroke="#1a2436" strokeWidth={1} />,
|
<line key={`v${p}`} 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} />,
|
);
|
||||||
|
}
|
||||||
|
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>;
|
return <g>{lines}</g>;
|
||||||
|
|||||||
+80
-25
@@ -45,7 +45,14 @@ export interface World {
|
|||||||
broadcasts: number;
|
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;
|
export const LINK_RANGE = 320;
|
||||||
const CRUISE = 28;
|
const CRUISE = 28;
|
||||||
const SEPARATION = 55;
|
const SEPARATION = 55;
|
||||||
@@ -65,35 +72,85 @@ function rnd(): number {
|
|||||||
return ((seedState >>> 0) % 100000) / 100000;
|
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: 120, y: 120 },
|
||||||
{ x: AREA - 120, y: 150 },
|
{ x: areaX - 120, y: 150 },
|
||||||
{ x: AREA - 150, y: AREA - 120 },
|
{ x: areaX - 150, y: AREA - 120 },
|
||||||
{ x: 150, y: AREA - 150 },
|
{ 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 drones: Drone[] = Array.from({ length: droneCount }, (_, i) => {
|
||||||
const angle = (i / droneCount) * Math.PI * 2;
|
const angle = (i / droneCount) * Math.PI * 2;
|
||||||
return {
|
return {
|
||||||
id: i,
|
id: i,
|
||||||
pos: {
|
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),
|
y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120),
|
||||||
},
|
},
|
||||||
vel: { x: 0, y: 0 },
|
vel: { x: 0, y: 0 },
|
||||||
heading: angle,
|
heading: angle,
|
||||||
waypoint: i % WAYPOINTS.length,
|
waypoint: i % 4,
|
||||||
battery: 90 + rnd() * 10,
|
battery: 90 + rnd() * 10,
|
||||||
channel: CHANNELS[i % CHANNELS.length],
|
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[] = [
|
const obstacles: Obstacle[] = [
|
||||||
{ id: 0, pos: { x: 340, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } },
|
{ id: 0, pos: { x: areaX * 0.34, 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: 1, pos: { x: areaX * 0.7, 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: 2, pos: { x: areaX * 0.56, 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 } },
|
spawnTransit(3),
|
||||||
{ id: 4, pos: { x: 820, y: 620 }, radius: 26, moving: true, vel: { x: -10, y: 9 } },
|
spawnTransit(4),
|
||||||
|
spawnTransit(5),
|
||||||
];
|
];
|
||||||
return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 };
|
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 {
|
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 ax = wp.x - d.pos.x;
|
||||||
let ay = wp.y - d.pos.y;
|
let ay = wp.y - d.pos.y;
|
||||||
const wpDist = Math.hypot(ax, ay);
|
const wpDist = Math.hypot(ax, ay);
|
||||||
let waypoint = d.waypoint;
|
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;
|
ax /= wpDist || 1;
|
||||||
ay /= wpDist || 1;
|
ay /= wpDist || 1;
|
||||||
|
|
||||||
@@ -147,7 +205,7 @@ function steer(d: Drone, world: World, dt: number): Drone {
|
|||||||
vel: { x: vx, y: vy },
|
vel: { x: vx, y: vy },
|
||||||
heading,
|
heading,
|
||||||
pos: {
|
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)),
|
y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)),
|
||||||
},
|
},
|
||||||
battery: Math.max(0, d.battery - dt * 0.05),
|
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 {
|
export function tick(world: World, dt: number): World {
|
||||||
const drones = world.drones.map((d) => steer(d, world, dt));
|
const drones = world.drones.map((d) => steer(d, world, dt));
|
||||||
const obstacles = world.obstacles.map((ob) =>
|
const obstacles = world.obstacles.map((ob) => {
|
||||||
ob.moving
|
if (!ob.moving) return ob;
|
||||||
? {
|
const moved = {
|
||||||
...ob,
|
...ob,
|
||||||
pos: { x: ob.pos.x + ob.vel.x * dt, y: ob.pos.y + ob.vel.y * dt },
|
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,
|
// Once a transit object leaves the area, respawn it at a random edge
|
||||||
y: ob.pos.y < 60 || ob.pos.y > AREA - 60 ? -ob.vel.y : ob.vel.y,
|
return outOfTransit(moved) ? spawnTransit(ob.id) : moved;
|
||||||
},
|
});
|
||||||
}
|
|
||||||
: ob,
|
|
||||||
);
|
|
||||||
|
|
||||||
const links = new Map(world.links);
|
const links = new Map(world.links);
|
||||||
let totalBytes = world.totalBytes;
|
let totalBytes = world.totalBytes;
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ services:
|
|||||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||||
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
||||||
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
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:
|
volumes:
|
||||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
|||||||
Reference in New Issue
Block a user