feat: prototype perimeter patrol, 3D view, and swarm algorithms
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Failing after 5s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Failing after 5s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
Add optional Three.js scene, base-pad lifecycle, mycelial swarms, UFO transit steering, and algorithm selector (boids, APF, ACO, hypha).
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
A 2D top-down view of the swarm data plane from [04 — Swarm sync](../docs/04-swarm-sync.md): drones patrol an area with static and mobile obstacles, exchange 5 Hz pose broadcasts (blue link flashes), and run opportunistic bulk sync of sealed partitions (green links with live rate and cumulative up/down counters).
|
A 2D top-down view of the swarm data plane from [04 — Swarm sync](../docs/04-swarm-sync.md): drones patrol an area with static and mobile obstacles, exchange 5 Hz pose broadcasts (blue link flashes), and run opportunistic bulk sync of sealed partitions (green links with live rate and cumulative up/down counters).
|
||||||
|
|
||||||
|
Optional **3d** checkbox in the header switches to a Three.js perimeter patrol scene (WebGPU/WebGL, shader terrain, transit escort, search panel). Off by default.
|
||||||
|
|
||||||
No backend — a pure client-side simulation of the same behavioral model the Python simulator implements over real Parquet and UDP.
|
No backend — a pure client-side simulation of the same behavioral model the Python simulator implements over real Parquet and UDP.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
@@ -17,3 +19,4 @@ npm run dev # http://localhost:5173
|
|||||||
- **Busy links turn green** and show current rate plus total transferred (`↑` / `↓`) — the bandwidth budget from the sync design made visible.
|
- **Busy links turn green** and show current rate plus total transferred (`↑` / `↓`) — the bandwidth budget from the sync design made visible.
|
||||||
- **Per-drone label**: id, current Wi-Fi channel (hopping), battery.
|
- **Per-drone label**: id, current Wi-Fi channel (hopping), battery.
|
||||||
- Controls: drone count, simulation speed, pause.
|
- Controls: drone count, simulation speed, pause.
|
||||||
|
- **3d** — perimeter patrol, altitude, detection beams, 4-drone escort sphere
|
||||||
|
|||||||
Generated
+66
-1
@@ -9,11 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"three": "^0.172.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/three": "^0.172.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3"
|
||||||
@@ -1150,6 +1152,13 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1230,6 +1239,35 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.172.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.172.0.tgz",
|
||||||
|
"integrity": "sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": "*",
|
||||||
|
"@webgpu/types": "*",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~0.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1251,6 +1289,13 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@webgpu/types": {
|
||||||
|
"version": "0.1.71",
|
||||||
|
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
|
||||||
|
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.42",
|
"version": "2.10.42",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
||||||
@@ -1428,6 +1473,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1507,6 +1559,13 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "0.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
|
||||||
|
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -1701,6 +1760,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.172.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz",
|
||||||
|
"integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
|||||||
@@ -10,11 +10,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"three": "^0.172.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/three": "^0.172.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3"
|
||||||
|
|||||||
+729
-105
@@ -1,23 +1,28 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live";
|
import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live";
|
||||||
|
import { searchWeb, type SearchHit } from "./search";
|
||||||
import {
|
import {
|
||||||
AREA,
|
AREA,
|
||||||
MAX_ASPECT,
|
MAX_ASPECT,
|
||||||
|
SWARM_ALGORITHM_LABELS,
|
||||||
areaWidth,
|
areaWidth,
|
||||||
fmtBytes,
|
fmtBytes,
|
||||||
|
houseRoadNetwork,
|
||||||
makeWorld,
|
makeWorld,
|
||||||
seed,
|
seed,
|
||||||
tick,
|
tick,
|
||||||
|
type RouteMode,
|
||||||
|
type SwarmAlgorithm,
|
||||||
type World,
|
type World,
|
||||||
} from "./sim";
|
} from "./sim";
|
||||||
|
import { SwarmView3D } from "./SwarmView3D";
|
||||||
|
import type { RenderBackend } from "./SwarmScene";
|
||||||
|
|
||||||
const VIEW = 1000;
|
const VIEW = 1000;
|
||||||
const PANEL_W = 320;
|
const PANEL_W = 320;
|
||||||
const HEADER_H = 70;
|
const HEADER_H = 70;
|
||||||
|
const LIVE_POLL_MS = 2000;
|
||||||
|
|
||||||
// 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 {
|
function displayAspect(): number {
|
||||||
const w = Math.max(320, window.innerWidth - PANEL_W);
|
const w = Math.max(320, window.innerWidth - PANEL_W);
|
||||||
const h = Math.max(320, window.innerHeight - HEADER_H);
|
const h = Math.max(320, window.innerHeight - HEADER_H);
|
||||||
@@ -25,27 +30,57 @@ function displayAspect(): number {
|
|||||||
return Math.max(1, Math.min(MAX_ASPECT, q));
|
return Math.max(1, Math.min(MAX_ASPECT, q));
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIVE_POLL_MS = 2000;
|
function routeModeFor(_view3d: boolean): RouteMode {
|
||||||
|
return "perimeter";
|
||||||
|
}
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
|
const [view3d, setView3d] = useState(false);
|
||||||
const [droneCount, setDroneCount] = useState(8);
|
const [droneCount, setDroneCount] = useState(8);
|
||||||
|
const [swarmAlgo, setSwarmAlgo] = useState<SwarmAlgorithm>("boids");
|
||||||
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 [aspect, setAspect] = useState(displayAspect);
|
||||||
const [mode, setMode] = useState<"sim" | "live">("sim");
|
const [mode, setMode] = useState<"sim" | "live">("sim");
|
||||||
const [liveError, setLiveError] = useState<string | null>(null);
|
const [liveError, setLiveError] = useState<string | null>(null);
|
||||||
const [world, setWorld] = useState<World>(() => {
|
const [backend, setBackend] = useState<RenderBackend>("webgl");
|
||||||
|
const [searchQ, setSearchQ] = useState("drone swarm perimeter patrol");
|
||||||
|
const [searchBusy, setSearchBusy] = useState(false);
|
||||||
|
const [searchErr, setSearchErr] = useState<string | null>(null);
|
||||||
|
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||||
|
const [, bump] = useState(0);
|
||||||
|
|
||||||
|
const worldRef = useRef<World | null>(null);
|
||||||
|
const swarmAlgoRef = useRef(swarmAlgo);
|
||||||
|
swarmAlgoRef.current = swarmAlgo;
|
||||||
|
if (worldRef.current === null) {
|
||||||
seed(42);
|
seed(42);
|
||||||
return makeWorld(8, displayAspect());
|
worldRef.current = { ...makeWorld(8, displayAspect(), "perimeter"), swarmAlgo };
|
||||||
});
|
}
|
||||||
|
|
||||||
const raf = useRef(0);
|
const raf = useRef(0);
|
||||||
const last = useRef(performance.now());
|
const last = useRef(performance.now());
|
||||||
const liveWorld = useRef<World | null>(null);
|
const liveWorld = useRef<World | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const resetWorld = useCallback((): void => {
|
||||||
seed(42);
|
seed(42);
|
||||||
setWorld(makeWorld(droneCount, aspect));
|
worldRef.current = {
|
||||||
}, [droneCount, aspect]);
|
...makeWorld(droneCount, aspect, routeModeFor(view3d)),
|
||||||
|
swarmAlgo: swarmAlgoRef.current,
|
||||||
|
};
|
||||||
|
bump((n) => n + 1);
|
||||||
|
}, [droneCount, aspect, view3d]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (worldRef.current) {
|
||||||
|
worldRef.current = { ...worldRef.current, swarmAlgo };
|
||||||
|
bump((n) => n + 1);
|
||||||
|
}
|
||||||
|
}, [swarmAlgo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetWorld();
|
||||||
|
}, [resetWorld]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
@@ -61,20 +96,21 @@ export default function App(): JSX.Element {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "sim") return;
|
if (view3d || mode !== "sim") return;
|
||||||
const loop = (now: number): void => {
|
const loop = (now: number): void => {
|
||||||
const dt = Math.min(0.05, (now - last.current) / 1000);
|
const dt = Math.min(0.05, (now - last.current) / 1000);
|
||||||
last.current = now;
|
last.current = now;
|
||||||
if (!paused && dt > 0) {
|
if (!paused && dt > 0) {
|
||||||
setWorld((w) => tick(w, dt * speedup));
|
worldRef.current = tick(worldRef.current!, dt * speedup);
|
||||||
|
bump((n) => n + 1);
|
||||||
}
|
}
|
||||||
raf.current = requestAnimationFrame(loop);
|
raf.current = requestAnimationFrame(loop);
|
||||||
};
|
};
|
||||||
|
last.current = performance.now();
|
||||||
raf.current = requestAnimationFrame(loop);
|
raf.current = requestAnimationFrame(loop);
|
||||||
return () => cancelAnimationFrame(raf.current);
|
return () => cancelAnimationFrame(raf.current);
|
||||||
}, [paused, speedup, mode]);
|
}, [view3d, paused, speedup, mode]);
|
||||||
|
|
||||||
// Live mode: poll the explorer's read-only SQL API for real poses
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "live") return;
|
if (mode !== "live") return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -84,7 +120,11 @@ export default function App(): JSX.Element {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setLiveError(poses.length === 0 ? "no drone data in the lake yet" : null);
|
setLiveError(poses.length === 0 ? "no drone data in the lake yet" : null);
|
||||||
liveWorld.current = toLiveWorld(poses, liveWorld.current, LIVE_POLL_MS / 1000);
|
liveWorld.current = toLiveWorld(poses, liveWorld.current, LIVE_POLL_MS / 1000);
|
||||||
setWorld(liveWorld.current);
|
if (view3d) {
|
||||||
|
liveWorld.current = { ...liveWorld.current, routeMode: "perimeter" };
|
||||||
|
}
|
||||||
|
worldRef.current = liveWorld.current;
|
||||||
|
bump((n) => n + 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setLiveError(err instanceof Error ? err.message : String(err));
|
if (!cancelled) setLiveError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
@@ -95,34 +135,80 @@ export default function App(): JSX.Element {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [mode]);
|
}, [mode, view3d]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "sim") {
|
if (mode === "sim") {
|
||||||
liveWorld.current = null;
|
liveWorld.current = null;
|
||||||
setLiveError(null);
|
setLiveError(null);
|
||||||
seed(42);
|
resetWorld();
|
||||||
setWorld(makeWorld(droneCount, aspect));
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on mode change
|
}, [mode, resetWorld]);
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
|
const simEnabled = useCallback(() => mode === "sim" && !paused, [mode, paused]);
|
||||||
|
|
||||||
|
const onStep3d = useCallback(
|
||||||
|
(dt: number) => {
|
||||||
|
worldRef.current = tick(worldRef.current!, dt * speedup);
|
||||||
|
},
|
||||||
|
[speedup],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onUiPulse = useCallback(() => bump((n) => n + 1), []);
|
||||||
|
|
||||||
|
const runSearch = async (): Promise<void> => {
|
||||||
|
setSearchBusy(true);
|
||||||
|
setSearchErr(null);
|
||||||
|
try {
|
||||||
|
const res = await searchWeb(searchQ);
|
||||||
|
setHits(res.results);
|
||||||
|
} catch (err) {
|
||||||
|
setSearchErr(err instanceof Error ? err.message : String(err));
|
||||||
|
setHits([]);
|
||||||
|
} finally {
|
||||||
|
setSearchBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!view3d) return;
|
||||||
|
void runSearch();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [view3d]);
|
||||||
|
|
||||||
|
const world = worldRef.current!;
|
||||||
const activeLinks = useMemo(
|
const activeLinks = useMemo(
|
||||||
() => [...world.links.values()].sort((a, b) => b.rate - a.rate),
|
() => [...world.links.values()].sort((a, b) => b.rate - a.rate),
|
||||||
[world.links],
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[world.links, world.t],
|
||||||
|
);
|
||||||
|
const busiest = activeLinks.slice(0, view3d ? 5 : 6);
|
||||||
|
const roads = useMemo(
|
||||||
|
() => houseRoadNetwork(world.obstacles),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[world.obstacles, aspect],
|
||||||
);
|
);
|
||||||
const busiest = activeLinks.slice(0, 6);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.shell}>
|
<div style={{ ...styles.shell, ...(view3d ? styles.shell3d : {}) }}>
|
||||||
<header style={styles.header}>
|
<header style={styles.header}>
|
||||||
<div>
|
<div>
|
||||||
<div style={styles.title}>SWARM HOUSE</div>
|
<div style={styles.title}>SWARM HOUSE</div>
|
||||||
<div style={styles.subtitle}>
|
<div style={styles.subtitle}>
|
||||||
swarm data plane — pose broadcast + opportunistic bulk sync
|
{view3d
|
||||||
|
? `perimeter patrol — ${backend.toUpperCase()} renderer · shader ground grid`
|
||||||
|
: "perimeter patrol — even spread on observation loop"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.controls}>
|
<div style={styles.controls}>
|
||||||
|
<label style={styles.checkLabel} title="Three.js perimeter patrol view">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={view3d}
|
||||||
|
onChange={(e) => setView3d(e.target.checked)}
|
||||||
|
/>
|
||||||
|
3d
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
style={{ ...styles.button, ...(mode === "live" ? styles.buttonActive : {}) }}
|
style={{ ...styles.button, ...(mode === "live" ? styles.buttonActive : {}) }}
|
||||||
onClick={() => setMode((m) => (m === "sim" ? "live" : "sim"))}
|
onClick={() => setMode((m) => (m === "sim" ? "live" : "sim"))}
|
||||||
@@ -137,6 +223,23 @@ export default function App(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
{mode === "sim" && (
|
{mode === "sim" && (
|
||||||
<>
|
<>
|
||||||
|
<label style={styles.label}>
|
||||||
|
swarm
|
||||||
|
<select
|
||||||
|
style={styles.select}
|
||||||
|
value={swarmAlgo}
|
||||||
|
onChange={(e) => setSwarmAlgo(e.target.value as SwarmAlgorithm)}
|
||||||
|
title="Natural swarm algorithm for perimeter patrol"
|
||||||
|
>
|
||||||
|
{(Object.entries(SWARM_ALGORITHM_LABELS) as [SwarmAlgorithm, string][]).map(
|
||||||
|
([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label style={styles.label}>
|
<label style={styles.label}>
|
||||||
drones
|
drones
|
||||||
<input
|
<input
|
||||||
@@ -169,90 +272,165 @@ export default function App(): JSX.Element {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style={styles.main}>
|
<div style={styles.main}>
|
||||||
<svg
|
{view3d ? (
|
||||||
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
|
<SwarmView3D
|
||||||
style={styles.canvas}
|
worldRef={worldRef as React.MutableRefObject<World>}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
simEnabled={simEnabled}
|
||||||
>
|
onStep={onStep3d}
|
||||||
<defs>
|
onUiPulse={onUiPulse}
|
||||||
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
|
onBackend={setBackend}
|
||||||
<stop offset="0%" stopColor="#101826" />
|
/>
|
||||||
<stop offset="100%" stopColor="#0a0f18" />
|
) : (
|
||||||
</radialGradient>
|
<svg
|
||||||
</defs>
|
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
|
||||||
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
|
style={styles.canvas2d}
|
||||||
{gridLines()}
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
|
||||||
|
<stop offset="0%" stopColor="#101826" />
|
||||||
|
<stop offset="100%" stopColor="#0a0f18" />
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="tronGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="2.8" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="ufoGlow" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
|
<feGaussianBlur stdDeviation="3.5" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
|
||||||
|
{gridLines()}
|
||||||
|
{world.basePad && (
|
||||||
|
<LaunchPad pad={world.basePad} t={world.t} queue={world.landingQueue?.length ?? 0} />
|
||||||
|
)}
|
||||||
|
<TronRoads segments={roads} t={world.t} />
|
||||||
|
|
||||||
{/* Mesh links */}
|
{activeLinks.map((l) => {
|
||||||
{activeLinks.map((l) => {
|
const a = world.drones[l.a];
|
||||||
const a = world.drones[l.a];
|
const b = world.drones[l.b];
|
||||||
const b = world.drones[l.b];
|
if (!a || !b) return null;
|
||||||
if (!a || !b) return null;
|
const mx = (a.pos.x + b.pos.x) / 2;
|
||||||
const mx = (a.pos.x + b.pos.x) / 2;
|
const my = (a.pos.y + b.pos.y) / 2;
|
||||||
const my = (a.pos.y + b.pos.y) / 2;
|
const hot = l.rate > 20_000;
|
||||||
const hot = l.rate > 20_000;
|
return (
|
||||||
return (
|
<g key={l.key}>
|
||||||
<g key={l.key}>
|
<line
|
||||||
<line
|
x1={sx(a.pos.x)}
|
||||||
x1={sx(a.pos.x)}
|
y1={sy(a.pos.y)}
|
||||||
y1={sy(a.pos.y)}
|
x2={sx(b.pos.x)}
|
||||||
x2={sx(b.pos.x)}
|
y2={sy(b.pos.y)}
|
||||||
y2={sy(b.pos.y)}
|
stroke={hot ? "#39d98a" : "#3d7bd9"}
|
||||||
stroke={hot ? "#39d98a" : "#3d7bd9"}
|
strokeWidth={hot ? 2.2 : 1}
|
||||||
strokeWidth={hot ? 2.2 : 1}
|
opacity={0.25 + l.flash * 0.75}
|
||||||
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{world.obstacles.map((ob) =>
|
||||||
|
ob.moving ? (
|
||||||
|
<TransitUfo
|
||||||
|
key={ob.id}
|
||||||
|
x={sx(ob.pos.x)}
|
||||||
|
y={sy(ob.pos.y)}
|
||||||
|
r={(ob.radius / AREA) * VIEW * 0.95}
|
||||||
|
heading={Math.atan2(ob.vel.y, ob.vel.x)}
|
||||||
/>
|
/>
|
||||||
{hot && (
|
) : (
|
||||||
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
|
<StaticHouse
|
||||||
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
|
key={ob.id}
|
||||||
</text>
|
id={ob.id}
|
||||||
)}
|
x={sx(ob.pos.x)}
|
||||||
</g>
|
y={sy(ob.pos.y)}
|
||||||
);
|
r={(ob.radius / AREA) * VIEW * 1.15}
|
||||||
})}
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Obstacles */}
|
{world.drones.map((d) => (
|
||||||
{world.obstacles.map((ob) => (
|
<g
|
||||||
<g key={ob.id}>
|
key={d.id}
|
||||||
<circle
|
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
|
||||||
cx={sx(ob.pos.x)}
|
opacity={d.phase === "landed" ? 0.85 : 1}
|
||||||
cy={sy(ob.pos.y)}
|
>
|
||||||
r={(ob.radius / AREA) * VIEW}
|
<polygon
|
||||||
fill={ob.moving ? "#5a2a35" : "#2a3245"}
|
points="0,-11 7,9 0,5 -7,9"
|
||||||
stroke={ob.moving ? "#c05a6a" : "#46536e"}
|
fill={d.phase === "landed" ? "#a8c4e8" : "#e8ecf4"}
|
||||||
strokeWidth={1.5}
|
stroke={d.phase === "return" ? "#ffb347" : "#7ea0d0"}
|
||||||
opacity={0.85}
|
strokeWidth={1}
|
||||||
/>
|
/>
|
||||||
{ob.moving && (
|
<text
|
||||||
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
|
y={24}
|
||||||
transit
|
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)}%
|
||||||
|
{d.phase === "landed" && d.offloadProgress !== undefined
|
||||||
|
? ` · ↑${(d.offloadProgress * 100).toFixed(0)}%`
|
||||||
|
: ""}
|
||||||
|
{(d.dataBuffer ?? 0) > 0 && d.phase === "patrol"
|
||||||
|
? ` · ${fmtBytes(d.dataBuffer ?? 0)}`
|
||||||
|
: ""}
|
||||||
</text>
|
</text>
|
||||||
)}
|
</g>
|
||||||
</g>
|
))}
|
||||||
))}
|
</svg>
|
||||||
|
)}
|
||||||
{/* 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}>
|
<aside style={styles.panel}>
|
||||||
<Section title="Mission">
|
<Section title="Mission">
|
||||||
<Stat name="sim time" value={`${world.t.toFixed(0)} s`} />
|
<Stat name="sim time" value={`${world.t.toFixed(0)} s`} />
|
||||||
|
{view3d && <Stat name="field" value={`${areaWidth()} × ${AREA} m`} />}
|
||||||
<Stat name="drones" value={String(world.drones.length)} />
|
<Stat name="drones" value={String(world.drones.length)} />
|
||||||
|
{mode === "sim" && world.swarmAlgo && (
|
||||||
|
<Stat name="swarm algo" value={SWARM_ALGORITHM_LABELS[world.swarmAlgo]} />
|
||||||
|
)}
|
||||||
<Stat name="active links" value={String(world.links.size)} />
|
<Stat name="active links" value={String(world.links.size)} />
|
||||||
<Stat name="pose broadcasts" value={String(world.broadcasts)} />
|
{!view3d && <Stat name="pose broadcasts" value={String(world.broadcasts)} />}
|
||||||
<Stat name="total transferred" value={fmtBytes(world.totalBytes)} />
|
<Stat name="total transferred" value={fmtBytes(world.totalBytes)} />
|
||||||
|
{view3d && <Stat name="renderer" value={backend.toUpperCase()} />}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{mode === "sim" && world.flightStats && (
|
||||||
|
<Section title="Flight stats (8 s window)">
|
||||||
|
<Stat name="avg speed" value={`${world.flightStats.avgSpeed.toFixed(1)} m/s`} />
|
||||||
|
<Stat name="idle" value={`${world.flightStats.idlePct.toFixed(0)}%`} />
|
||||||
|
<Stat name="spin / hover" value={`${world.flightStats.spinPct.toFixed(0)}%`} />
|
||||||
|
<Stat name="pass over" value={`${world.flightStats.passOverPct.toFixed(0)}%`} />
|
||||||
|
<Stat name="bypass" value={`${world.flightStats.bypassPct.toFixed(0)}%`} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "sim" && world.basePad && (
|
||||||
|
<Section title="Base pad">
|
||||||
|
<Stat name="offloaded Σ" value={fmtBytes(world.basePad.totalOffloaded)} />
|
||||||
|
<Stat
|
||||||
|
name="on pad"
|
||||||
|
value={
|
||||||
|
world.basePad.offloadingIds.length > 0
|
||||||
|
? world.basePad.offloadingIds.map((id) => `dr-${id + 1}`).join(", ")
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat name="landing queue" value={String(world.landingQueue?.length ?? 0)} />
|
||||||
|
<Stat name="max concurrent" value="2" />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="Busiest links">
|
<Section title="Busiest links">
|
||||||
{busiest.map((l) => (
|
{busiest.map((l) => (
|
||||||
<div key={l.key} style={styles.linkRow}>
|
<div key={l.key} style={styles.linkRow}>
|
||||||
@@ -267,11 +445,80 @@ export default function App(): JSX.Element {
|
|||||||
))}
|
))}
|
||||||
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
|
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{view3d && (
|
||||||
|
<Section title="Search">
|
||||||
|
<form
|
||||||
|
style={styles.searchForm}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void runSearch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={searchQ}
|
||||||
|
onChange={(e) => setSearchQ(e.target.value)}
|
||||||
|
placeholder="query search.produktor.io"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button style={styles.button} type="submit" disabled={searchBusy}>
|
||||||
|
{searchBusy ? "…" : "search"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{searchErr && <div style={styles.liveError}>{searchErr}</div>}
|
||||||
|
<div style={styles.hits}>
|
||||||
|
{hits.map((h) => (
|
||||||
|
<a
|
||||||
|
key={h.url}
|
||||||
|
href={h.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={styles.hitLink}
|
||||||
|
>
|
||||||
|
<div style={styles.hitTitle}>{h.title}</div>
|
||||||
|
<div style={styles.hitSnippet}>{h.content || h.url}</div>
|
||||||
|
<div style={styles.hitMeta}>
|
||||||
|
score {h.score.toFixed(1)} · {h.engines.slice(0, 2).join(", ")}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{!searchBusy && hits.length === 0 && !searchErr && (
|
||||||
|
<div style={styles.dim}>no results</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="Legend">
|
<Section title="Legend">
|
||||||
<div style={styles.dim}>blue line — pose broadcast (5 Hz, ~46 B)</div>
|
{view3d ? (
|
||||||
<div style={styles.dim}>green line — bulk sync (sealed partitions)</div>
|
<>
|
||||||
<div style={styles.dim}>flash — broadcast event delivered</div>
|
<div style={styles.dim}>green ring — perimeter patrol path</div>
|
||||||
<div style={styles.dim}>red circle — transit object crossing the area</div>
|
<div style={styles.dim}>amber hex pad — base station · offload & recharge</div>
|
||||||
|
<div style={styles.dim}>house mesh — static ground obstacle</div>
|
||||||
|
<div style={styles.dim}>cyan vector — tron road between houses</div>
|
||||||
|
<div style={styles.dim}>blue line — pose broadcast mesh link</div>
|
||||||
|
<div style={styles.dim}>green line — bulk sync (hot link)</div>
|
||||||
|
<div style={styles.dim}>link flash — transit intel shared over mesh</div>
|
||||||
|
<div style={styles.dim}>cyan beam — direct detection flashlight (spotter only)</div>
|
||||||
|
<div style={styles.dim}>even spread on perimeter, keep ≥1 mesh link</div>
|
||||||
|
<div style={styles.dim}>cyan saucer — transit UFO · orange when fleet tracks</div>
|
||||||
|
<div style={styles.dim}>drag orbit · scroll zoom</div>
|
||||||
|
<div style={styles.dim}>hypha / mycelium — link-flow routing on perimeter</div>
|
||||||
|
<div style={styles.dim}>frontier — pathfinders + exploit/prune</div>
|
||||||
|
<div style={styles.dim}>wave — travelling coverage pulse</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}>flash — broadcast event delivered</div>
|
||||||
|
<div style={styles.dim}>roof footprint — static house (top-down)</div>
|
||||||
|
<div style={styles.dim}>cyan vector — tron road between houses</div>
|
||||||
|
<div style={styles.dim}>cyan saucer — transit UFO crossing the area</div>
|
||||||
|
<div style={styles.dim}>amber hex — base pad · data offload & recharge</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,9 +533,319 @@ function sy(y: number): number {
|
|||||||
return (y / AREA) * VIEW;
|
return (y / AREA) * VIEW;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LaunchPad({
|
||||||
|
pad,
|
||||||
|
t,
|
||||||
|
queue,
|
||||||
|
}: {
|
||||||
|
pad: NonNullable<World["basePad"]>;
|
||||||
|
t: number;
|
||||||
|
queue: number;
|
||||||
|
}): JSX.Element {
|
||||||
|
const cx = sx(pad.pos.x);
|
||||||
|
const cy = sy(pad.pos.y);
|
||||||
|
const r = (pad.radius / AREA) * VIEW;
|
||||||
|
const pulse = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * 3.2));
|
||||||
|
const offloading = pad.offloadingIds.length > 0;
|
||||||
|
const hex = Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const a = (i / 6) * Math.PI * 2 - Math.PI / 6;
|
||||||
|
return `${cx + Math.cos(a) * r},${cy + Math.sin(a) * r * 0.92}`;
|
||||||
|
}).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<polygon points={hex} fill="#0f1a28" stroke="#ffb347" strokeWidth={2} opacity={0.92} />
|
||||||
|
<polygon
|
||||||
|
points={hex}
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffdd88"
|
||||||
|
strokeWidth={1}
|
||||||
|
opacity={0.35 + pulse * 0.25}
|
||||||
|
/>
|
||||||
|
<circle cx={cx} cy={cy} r={r * 0.12} fill="#ffb347" opacity={0.85 + pulse * 0.15} />
|
||||||
|
<text x={cx} y={cy + 4} style={styles.padLabel as never}>
|
||||||
|
BASE
|
||||||
|
</text>
|
||||||
|
{offloading && (
|
||||||
|
<>
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r * 0.55}
|
||||||
|
fill="none"
|
||||||
|
stroke="#39d98a"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="8 6"
|
||||||
|
opacity={0.5 + pulse * 0.4}
|
||||||
|
/>
|
||||||
|
<text x={cx} y={cy - r * 0.72} style={styles.padOffload as never}>
|
||||||
|
offloading…
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{queue > 0 && !offloading && (
|
||||||
|
<text x={cx} y={cy - r * 0.72} style={styles.padQueue as never}>
|
||||||
|
queue {queue}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TronRoads({
|
||||||
|
segments,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
segments: ReturnType<typeof houseRoadNetwork>;
|
||||||
|
t: number;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
const pulse = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * 2.6 + i * 0.65));
|
||||||
|
const x1 = sx(seg.ax);
|
||||||
|
const y1 = sy(seg.ay);
|
||||||
|
const x2 = sx(seg.bx);
|
||||||
|
const y2 = sy(seg.by);
|
||||||
|
return (
|
||||||
|
<g key={`${seg.ax}-${seg.ay}-${seg.bx}-${seg.by}-${i}`}>
|
||||||
|
<line
|
||||||
|
x1={x1}
|
||||||
|
y1={y1}
|
||||||
|
x2={x2}
|
||||||
|
y2={y2}
|
||||||
|
stroke="#00eeff"
|
||||||
|
strokeWidth={5}
|
||||||
|
opacity={0.12 * pulse}
|
||||||
|
filter="url(#tronGlow)"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={x1}
|
||||||
|
y1={y1}
|
||||||
|
x2={x2}
|
||||||
|
y2={y2}
|
||||||
|
stroke="#00eeff"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
opacity={0.35 + 0.45 * pulse}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={x1}
|
||||||
|
y1={y1}
|
||||||
|
x2={x2}
|
||||||
|
y2={y2}
|
||||||
|
stroke="#aafcff"
|
||||||
|
strokeWidth={0.6}
|
||||||
|
opacity={0.65 + 0.25 * pulse}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StaticHouse({
|
||||||
|
id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
r,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
r: number;
|
||||||
|
}): JSX.Element {
|
||||||
|
const variant = id % 3;
|
||||||
|
const roofLight = ["#5a6880", "#627088", "#6a7890"][variant];
|
||||||
|
const roofDark = ["#46566c", "#4e6078", "#566880"][variant];
|
||||||
|
const eaves = ["#7a8898", "#8290a0", "#8a98a8"][variant];
|
||||||
|
const rot = (id * 29) % 360;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x} ${y}) rotate(${rot})`}>
|
||||||
|
<ellipse cx={0} cy={r * 0.06} rx={r * 0.88} ry={r * 0.72} fill="#03060c" opacity={0.28} />
|
||||||
|
|
||||||
|
{variant === 0 && (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x={-r * 0.82}
|
||||||
|
y={-r * 0.58}
|
||||||
|
width={r * 1.64}
|
||||||
|
height={r * 1.16}
|
||||||
|
rx={r * 0.06}
|
||||||
|
fill={roofDark}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={1.1}
|
||||||
|
/>
|
||||||
|
<rect x={-r * 0.82} y={-r * 0.58} width={r * 0.82} height={r * 1.16} fill={roofLight} opacity={0.92} />
|
||||||
|
<line
|
||||||
|
x1={0}
|
||||||
|
y1={-r * 0.58}
|
||||||
|
x2={0}
|
||||||
|
y2={r * 0.58}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={0.9}
|
||||||
|
opacity={0.75}
|
||||||
|
/>
|
||||||
|
<rect x={r * 0.28} y={-r * 0.22} width={r * 0.14} height={r * 0.14} fill="#4a5464" stroke={eaves} strokeWidth={0.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 1 && (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x={-r * 0.78}
|
||||||
|
y={-r * 0.52}
|
||||||
|
width={r * 1.28}
|
||||||
|
height={r * 1.04}
|
||||||
|
rx={r * 0.05}
|
||||||
|
fill={roofDark}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={1.1}
|
||||||
|
/>
|
||||||
|
<rect x={-r * 0.78} y={-r * 0.52} width={r * 0.64} height={r * 1.04} fill={roofLight} opacity={0.9} />
|
||||||
|
<line
|
||||||
|
x1={-r * 0.14}
|
||||||
|
y1={-r * 0.52}
|
||||||
|
x2={-r * 0.14}
|
||||||
|
y2={r * 0.52}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={0.85}
|
||||||
|
opacity={0.7}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={r * 0.12}
|
||||||
|
y={-r * 0.18}
|
||||||
|
width={r * 0.62}
|
||||||
|
height={r * 0.56}
|
||||||
|
rx={r * 0.04}
|
||||||
|
fill={roofDark}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<rect x={r * 0.12} y={-r * 0.18} width={r * 0.31} height={r * 0.56} fill={roofLight} opacity={0.88} />
|
||||||
|
<line
|
||||||
|
x1={r * 0.43}
|
||||||
|
y1={-r * 0.18}
|
||||||
|
x2={r * 0.43}
|
||||||
|
y2={r * 0.38}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={0.75}
|
||||||
|
opacity={0.65}
|
||||||
|
/>
|
||||||
|
<rect x={-r * 0.48} y={-r * 0.08} width={r * 0.12} height={r * 0.12} fill="#4a5464" stroke={eaves} strokeWidth={0.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 2 && (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x={-r * 0.88}
|
||||||
|
y={-r * 0.48}
|
||||||
|
width={r * 1.76}
|
||||||
|
height={r * 0.96}
|
||||||
|
rx={r * 0.05}
|
||||||
|
fill={roofDark}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={1.1}
|
||||||
|
/>
|
||||||
|
<rect x={-r * 0.88} y={-r * 0.48} width={r * 0.88} height={r * 0.96} fill={roofLight} opacity={0.9} />
|
||||||
|
<line
|
||||||
|
x1={0}
|
||||||
|
y1={-r * 0.48}
|
||||||
|
x2={0}
|
||||||
|
y2={r * 0.48}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={0.85}
|
||||||
|
opacity={0.7}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={-r * 0.88}
|
||||||
|
y1={0}
|
||||||
|
x2={r * 0.88}
|
||||||
|
y2={0}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={0.65}
|
||||||
|
opacity={0.45}
|
||||||
|
/>
|
||||||
|
<rect x={r * 0.52} y={-r * 0.12} width={r * 0.1} height={r * 0.1} fill="#4a5464" stroke={eaves} strokeWidth={0.5} />
|
||||||
|
<rect
|
||||||
|
x={-r * 0.22}
|
||||||
|
y={-r * 0.12}
|
||||||
|
width={r * 0.18}
|
||||||
|
height={r * 0.12}
|
||||||
|
fill="#ffe099"
|
||||||
|
opacity={0.55}
|
||||||
|
stroke={eaves}
|
||||||
|
strokeWidth={0.4}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransitUfo({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
r,
|
||||||
|
heading,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
r: number;
|
||||||
|
heading: number;
|
||||||
|
alt?: number;
|
||||||
|
}): JSX.Element {
|
||||||
|
const deg = (heading * 180) / Math.PI;
|
||||||
|
const lights = Array.from({ length: 8 }, (_, i) => {
|
||||||
|
const a = (i / 8) * Math.PI * 2;
|
||||||
|
return { cx: Math.cos(a) * r * 0.82, cy: Math.sin(a) * r * 0.26, key: i };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x} ${y})`}>
|
||||||
|
<ellipse
|
||||||
|
cx={0}
|
||||||
|
cy={r * 0.42}
|
||||||
|
rx={r * 1.05}
|
||||||
|
ry={r * 0.32}
|
||||||
|
fill="#000000"
|
||||||
|
opacity={0.2}
|
||||||
|
/>
|
||||||
|
<g transform={`rotate(${deg})`} filter="url(#ufoGlow)">
|
||||||
|
<ellipse rx={r} ry={r * 0.33} fill="#0a1828" stroke="#22ddff" strokeWidth={1.4} opacity={0.95} />
|
||||||
|
<ellipse rx={r * 0.88} ry={r * 0.19} cy={r * 0.03} fill="#2a5070" opacity={0.9} />
|
||||||
|
<ellipse
|
||||||
|
rx={r * 0.42}
|
||||||
|
ry={r * 0.28}
|
||||||
|
cy={-r * 0.12}
|
||||||
|
fill="#6ab0e8"
|
||||||
|
stroke="#b8f0ff"
|
||||||
|
strokeWidth={1}
|
||||||
|
opacity={0.92}
|
||||||
|
/>
|
||||||
|
<ellipse rx={r * 0.16} ry={r * 0.1} cy={-r * 0.1} fill="#ccffff" opacity={0.9} />
|
||||||
|
{lights.map((l) => (
|
||||||
|
<circle key={l.key} cx={l.cx} cy={l.cy} r={r * 0.055} fill="#00ffdd" opacity={0.95} />
|
||||||
|
))}
|
||||||
|
<ellipse
|
||||||
|
rx={r * 0.54}
|
||||||
|
ry={r * 0.12}
|
||||||
|
cy={r * 0.08}
|
||||||
|
fill="none"
|
||||||
|
stroke="#66eeff"
|
||||||
|
strokeWidth={1}
|
||||||
|
opacity={0.65}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function gridLines(): JSX.Element {
|
function gridLines(): JSX.Element {
|
||||||
const width = sx(areaWidth());
|
const width = sx(areaWidth());
|
||||||
const step = VIEW / 10; // one cell per 100 m in both directions
|
const step = VIEW / 10;
|
||||||
const lines = [];
|
const lines = [];
|
||||||
for (let p = step; p < width; p += step) {
|
for (let p = step; p < width; p += step) {
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -330,6 +887,11 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
},
|
},
|
||||||
|
shell3d: {
|
||||||
|
height: "100vh",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -338,11 +900,31 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
borderBottom: "1px solid #1a2436",
|
borderBottom: "1px solid #1a2436",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
|
minHeight: HEADER_H,
|
||||||
|
boxSizing: "border-box",
|
||||||
},
|
},
|
||||||
title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" },
|
title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" },
|
||||||
subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 },
|
subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 },
|
||||||
controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" },
|
controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" },
|
||||||
label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" },
|
label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" },
|
||||||
|
select: {
|
||||||
|
background: "#16233a",
|
||||||
|
color: "#c8d8f0",
|
||||||
|
border: "1px solid #2a3c5c",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "4px 8px",
|
||||||
|
fontSize: 12,
|
||||||
|
maxWidth: 180,
|
||||||
|
},
|
||||||
|
checkLabel: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 6,
|
||||||
|
alignItems: "center",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#8fb4e8",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
},
|
||||||
value: { minWidth: 30, color: "#dde4f0" },
|
value: { minWidth: 30, color: "#dde4f0" },
|
||||||
button: {
|
button: {
|
||||||
background: "#16233a",
|
background: "#16233a",
|
||||||
@@ -357,14 +939,15 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
buttonActive: { background: "#1a3a2a", color: "#39d98a", borderColor: "#2a5c3c" },
|
buttonActive: { background: "#1a3a2a", color: "#39d98a", borderColor: "#2a5c3c" },
|
||||||
liveOk: { fontSize: 11, color: "#39d98a" },
|
liveOk: { fontSize: 11, color: "#39d98a" },
|
||||||
liveError: { fontSize: 11, color: "#c05a6a" },
|
liveError: { fontSize: 11, color: "#c05a6a" },
|
||||||
main: { display: "flex", flex: 1, gap: 0, minHeight: 0 },
|
main: { display: "flex", flex: 1, gap: 0, minHeight: 0, overflow: "hidden" },
|
||||||
canvas: { flex: 1, minWidth: 0, display: "block" },
|
canvas2d: { flex: 1, minWidth: 0, display: "block" },
|
||||||
panel: {
|
panel: {
|
||||||
width: 320,
|
width: PANEL_W,
|
||||||
borderLeft: "1px solid #1a2436",
|
borderLeft: "1px solid #1a2436",
|
||||||
padding: 16,
|
padding: 16,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
background: "#0a0f18",
|
background: "#0a0f18",
|
||||||
|
scrollbarGutter: "stable",
|
||||||
},
|
},
|
||||||
section: { marginBottom: 22 },
|
section: { marginBottom: 22 },
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
@@ -389,6 +972,47 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 },
|
linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 },
|
||||||
dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" },
|
dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" },
|
||||||
linkLabel: { fill: "#39d98a", fontSize: 10, textAnchor: "middle", fontFamily: "inherit" },
|
linkLabel: { fill: "#39d98a", fontSize: 10, textAnchor: "middle", fontFamily: "inherit" },
|
||||||
|
padLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "#1a1208",
|
||||||
|
fontWeight: 700,
|
||||||
|
textAnchor: "middle",
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
},
|
||||||
|
padOffload: {
|
||||||
|
fontSize: 10,
|
||||||
|
fill: "#39d98a",
|
||||||
|
textAnchor: "middle",
|
||||||
|
},
|
||||||
|
padQueue: {
|
||||||
|
fontSize: 10,
|
||||||
|
fill: "#ffb347",
|
||||||
|
textAnchor: "middle",
|
||||||
|
},
|
||||||
droneLabel: { fill: "#6a7a95", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
droneLabel: { fill: "#6a7a95", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
||||||
obLabel: { fill: "#c05a6a", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
obLabel: { fill: "#c05a6a", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
||||||
|
searchForm: { display: "flex", gap: 8, marginBottom: 10 },
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
background: "#0f1624",
|
||||||
|
border: "1px solid #1a2436",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: "#dde4f0",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "7px 10px",
|
||||||
|
},
|
||||||
|
hits: { display: "flex", flexDirection: "column", gap: 8 },
|
||||||
|
hitLink: {
|
||||||
|
display: "block",
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #141d2e",
|
||||||
|
background: "#0c121c",
|
||||||
|
},
|
||||||
|
hitTitle: { fontSize: 11, color: "#8fb4e8", marginBottom: 4, lineHeight: 1.35 },
|
||||||
|
hitSnippet: { fontSize: 10, color: "#6a7a95", lineHeight: 1.45 },
|
||||||
|
hitMeta: { fontSize: 9, color: "#5a6a85", marginTop: 5 },
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { mountSwarmScene, type RenderBackend } from "./SwarmScene";
|
||||||
|
import type { World } from "./sim";
|
||||||
|
|
||||||
|
export interface SwarmView3DProps {
|
||||||
|
worldRef: React.MutableRefObject<World>;
|
||||||
|
simEnabled: () => boolean;
|
||||||
|
onStep: (dt: number) => void;
|
||||||
|
onUiPulse: () => void;
|
||||||
|
onBackend: (backend: RenderBackend) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwarmView3D({
|
||||||
|
worldRef,
|
||||||
|
simEnabled,
|
||||||
|
onStep,
|
||||||
|
onUiPulse,
|
||||||
|
onBackend,
|
||||||
|
}: SwarmView3DProps): JSX.Element {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const simRef = useRef({ simEnabled, onStep });
|
||||||
|
simRef.current = { simEnabled, onStep };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!container || !canvas) return;
|
||||||
|
|
||||||
|
let handle: { dispose(): void; backend: RenderBackend } | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void mountSwarmScene(container, canvas, () => worldRef.current, {
|
||||||
|
enabled: () => simRef.current.simEnabled(),
|
||||||
|
step: (dt) => simRef.current.onStep(dt),
|
||||||
|
onUiPulse,
|
||||||
|
uiIntervalMs: 250,
|
||||||
|
}).then((h) => {
|
||||||
|
if (cancelled) {
|
||||||
|
h.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle = h;
|
||||||
|
onBackend(h.backend);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
handle?.dispose();
|
||||||
|
};
|
||||||
|
}, [worldRef, onUiPulse, onBackend]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={styles.viewport}>
|
||||||
|
<canvas ref={canvasRef} style={styles.canvas} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
viewport: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
canvas: {
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "block",
|
||||||
|
touchAction: "none",
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
};
|
||||||
+22
-10
@@ -79,16 +79,27 @@ function linkKey(a: number, b: number): string {
|
|||||||
/** Convert live poses into the World shape the renderer already speaks. */
|
/** Convert live poses into the World shape the renderer already speaks. */
|
||||||
export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): World {
|
export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): World {
|
||||||
const k = fitScale(poses);
|
const k = fitScale(poses);
|
||||||
const drones = poses.map((p, i) => ({
|
const drones = poses.map((p, i) => {
|
||||||
id: i,
|
const t = (prev?.t ?? 0) + dtS;
|
||||||
name: p.id,
|
const band = 16 + (i % 6) * 4.5;
|
||||||
pos: { x: p.x * k, y: p.y * k },
|
const alt =
|
||||||
vel: { x: 0, y: 0 },
|
band +
|
||||||
heading: (p.yawDeg * Math.PI) / 180,
|
Math.sin(t * 0.62 + i * 0.91) * 4.5 +
|
||||||
waypoint: 0,
|
Math.sin(p.x * 0.011 + t * 0.45) * 2;
|
||||||
battery: p.battery ?? 100,
|
const altVel = Math.cos(t * 0.62 + i * 0.91) * 2.8;
|
||||||
channel: 0,
|
return {
|
||||||
}));
|
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,
|
||||||
|
alt,
|
||||||
|
altVel,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Every in-range pair exchanges pose broadcasts; accumulate totals so the
|
// Every in-range pair exchanges pose broadcasts; accumulate totals so the
|
||||||
// panel keeps its meaning between polls.
|
// panel keeps its meaning between polls.
|
||||||
@@ -99,6 +110,7 @@ export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number):
|
|||||||
const dist = Math.hypot(
|
const dist = Math.hypot(
|
||||||
drones[i].pos.x - drones[j].pos.x,
|
drones[i].pos.x - drones[j].pos.x,
|
||||||
drones[i].pos.y - drones[j].pos.y,
|
drones[i].pos.y - drones[j].pos.y,
|
||||||
|
(drones[i].alt ?? 0) - (drones[j].alt ?? 0),
|
||||||
);
|
);
|
||||||
if (dist > LINK_RANGE) continue;
|
if (dist > LINK_RANGE) continue;
|
||||||
const key = linkKey(i, j);
|
const key = linkKey(i, j);
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export interface SearchHit {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
engines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
query: string;
|
||||||
|
results: SearchHit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawSearchResult {
|
||||||
|
url?: string;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
score?: number;
|
||||||
|
engines?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawSearchResponse {
|
||||||
|
query?: string;
|
||||||
|
results?: RawSearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchWeb(query: string): Promise<SearchResponse> {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return { query: q, results: [] };
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ q, format: "json" });
|
||||||
|
const res = await fetch(`/api/search?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(`search responded ${res.status}`);
|
||||||
|
|
||||||
|
const data = (await res.json()) as RawSearchResponse;
|
||||||
|
const results = (data.results ?? [])
|
||||||
|
.filter((r): r is RawSearchResult & { url: string; title: string } =>
|
||||||
|
Boolean(r.url && r.title),
|
||||||
|
)
|
||||||
|
.map((r) => ({
|
||||||
|
url: r.url,
|
||||||
|
title: r.title,
|
||||||
|
content: (r.content ?? "").slice(0, 220),
|
||||||
|
score: r.score ?? 0,
|
||||||
|
engines: r.engines ?? [],
|
||||||
|
}))
|
||||||
|
.slice(0, 12);
|
||||||
|
|
||||||
|
return { query: data.query ?? q, results };
|
||||||
|
}
|
||||||
+1680
-75
File diff suppressed because it is too large
Load Diff
@@ -3,4 +3,24 @@ import react from "@vitejs/plugin-react";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api/search": {
|
||||||
|
target: "https://search.produktor.io",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p) => p.replace(/^\/api\/search/, "/search"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api/search": {
|
||||||
|
target: "https://search.produktor.io",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p) => p.replace(/^\/api\/search/, "/search"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user