Author SHA1 Message Date
eSlider d744671b86 fix(ci): fetch PR ref on checkout instead of shallow main clone
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Successful in 16s
CI & Release / Trivy scan (pull_request) Successful in 15s
CI & Release / Semantic Release (pull_request) Skipped
Use gitea.ref (refs/pull/N/head) so verify and scan jobs resolve
branch tips without relying on default-branch shallow history.
2026-07-12 12:56:15 +01:00
eSlider 1cc2b9a8f2 fix: fetch PR head SHA after shallow clone in CI
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Successful in 17s
CI & Release / Trivy scan (pull_request) Successful in 15s
CI & Release / Semantic Release (pull_request) Skipped
Shallow clone only includes default-branch history; pull_request
runs must fetch the event commit before checkout.
2026-07-12 12:54:04 +01:00
eSlider 1e5854ee0a 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
Add optional Three.js scene, base-pad lifecycle, mycelial swarms,
UFO transit steering, and algorithm selector (boids, APF, ACO, hypha).
2026-07-12 12:51:25 +01:00
eSlider ed531f9fb6 Link license to readme.md
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
2026-07-09 15:55:04 +01:00
eSlider 336f9c0428 Add license
CI & Release / Verify simulator (push) Successful in 11s
CI & Release / Trivy scan (push) Successful in 8s
CI & Release / Semantic Release (push) Successful in 5s
2026-07-09 15:52:16 +01:00
eSlider 3d13f744e6 docs(journey): link the live visual prototype at swarm.produktor.io
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
2026-07-09 12:47:12 +01:00
eSlider dfb0556eda docs: add design journey, a narrative walk-through linking into the code
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
A chronological companion to the ADRs: how the design came together, from
the first mental model and 2D prototype through the three data tiers, the
relative-pose broadcast, the layered WireGuard/SSH trust model, SQL as the
peer contract, offload and replication, and the end-to-end proof of concept.
Each chapter links straight into the relevant source and decision records.
Flagged as the recommended first read in the README.
2026-07-09 12:30:40 +01:00
14 changed files with 4309 additions and 197 deletions
+8 -4
View File
@@ -24,8 +24,10 @@ jobs:
steps:
- name: Checkout
run: |
git clone --depth=50 https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git .
git checkout ${{ gitea.sha }}
git init .
git remote add origin https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git
git fetch --depth=50 origin "${{ gitea.ref }}"
git checkout FETCH_HEAD
- name: Install dependencies
run: pip install --quiet -r simulator/requirements.txt
@@ -61,8 +63,10 @@ jobs:
steps:
- name: Checkout
run: |
git clone --depth=1 https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git .
git checkout ${{ gitea.sha }}
git init .
git remote add origin https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git
git fetch --depth=1 origin "${{ gitea.ref }}"
git checkout FETCH_HEAD
- name: Install Trivy
run: |
+19
View File
@@ -0,0 +1,19 @@
PROPRIETARY LICENSE
Copyright (c) 2026 Andriy Oblivantsev. All rights reserved.
This material is proprietary and confidential.
No person or organization may, without prior written permission from the copyright holder:
- Publish or republish this material, in whole or in part.
- Copy, reproduce, distribute, transmit, forward, or share this material by any means.
- Use, modify, adapt, translate, create derivative works from, or incorporate this material into any other work.
- Make this material available to any third party, whether publicly or privately.
- Store this material in any repository, archive, or knowledge base accessible to others.
Permission must be obtained in writing before any of the above activities are undertaken.
Unauthorized use, publication, forwarding, reproduction, distribution, or disclosure of this material is strictly prohibited and may result in civil and criminal penalties under applicable copyright and intellectual property laws.
This license grants no rights except the right to view the material for its intended purpose by an authorized recipient.
+4 -1
View File
@@ -33,9 +33,10 @@ This repository describes how to build, deliver, test, and operate that platform
| [09 — Open questions](docs/09-open-questions.md) | Known unknowns and proposed answers |
| [10 — Domain context](docs/10-domain-context.md) | Swarm autonomy principles this design builds on |
| [11 — CI/CD & delivery](docs/11-cicd-delivery.md) | Pipelines, registry, dev containers, fleet releases |
| [12 — Design journey](docs/12-design-journey.md) | **Start here** — a narrative walk-through of how the design came together, linking into the code |
| [ADRs](docs/adr/) | Architecture Decision Records — the decisions behind the above, in the order they were made |
The design principles below are the *what*; the [ADRs](docs/adr/) are the *why and when* — each principle traces to a dated, immutable decision record.
New here? Read the [design journey](docs/12-design-journey.md) first: it tells the story chronologically and links straight into the code and decisions. The design principles below are the *what*; the [ADRs](docs/adr/) are the *why and when* — each principle traces to a dated, immutable decision record.
## Runnable parts
@@ -76,3 +77,5 @@ Contributing (TDD-first workflow): [`CONTRIBUTING.md`](CONTRIBUTING.md).
## CI & releases
Every push runs the pipeline in [`.github/workflows/release.yaml`](.github/workflows/release.yaml): **pytest unit tests**, a compile check, a 10-second virtual smoke flight verified with DuckDB, and a Trivy vulnerability/misconfiguration scan. The simulator Docker build also runs `pytest` — a failing test blocks the image. Pushes to `main` then cut a semantic version from the conventional-commit type (`feat:` → minor, `!`/`BREAKING CHANGE` → major, else patch), tag the commit, and publish a release with a **fleet release manifest** (versioned artifact list with checksums — the delivery unit described in [11 — CI/CD & delivery](docs/11-cicd-delivery.md)) plus a source bundle.
[LICENSE](LICENSE)
+194
View File
@@ -0,0 +1,194 @@
# 12 — Design journey
How this design came together, told in the order the thinking actually happened.
It is a walk-through, not a report. The formal decisions, with options and
trade-offs, live as [Architecture Decision Records](adr/README.md). This is the
story behind them, and each chapter links straight into the code it produced.
---
## 1. Starting point
At the start there was only a rough brief and a few adjacent interests to lean
on. Key rotation matters in every pipeline: a cipher that is safe today may not
be safe next year, so the infrastructure has to leave room to react, without
spending too much latency or bandwidth to do it. That balance, security against
speed and volume, is a recurring trade-off. Geospatial data is older ground:
routing, indexing, fast spatial lookups at scale. So the first instinct was that
the value might be less about new storage and more about optimizing access to
data that already exists.
## 2. From words to a model, then to a picture
Once the shape was clear, an autonomous swarm where each unit talks to any peer
in range and [no controller spans the swarm](adr/ADR-0001-no-swarm-orchestrator.md),
the next step was to make the model visible. A small 2D prototype was enough to
check it: units patrolling, static and moving obstacles, links forming and
breaking, and a rough count of how much data crosses the air. Speed was not the
question yet, only volume, because volume is what you size the infrastructure
for. Starting from a picture gives an anchor before building anything.
> **Read the code**
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L219-L287) — the tick: links form and break by range, pose broadcasts and bulk sync accumulate bytes
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L57-L63) — the pose-frame size and rate that drive the volume estimate
The picture is running, not just described: the visual prototype is live at
[swarm.produktor.io](https://swarm.produktor.io/) (same access as this
repository). It draws units patrolling, links forming and breaking by range, and
a running estimate of how much data crosses the air.
## 3. Naming the limits
Before any code, the limits had to be named. Maximum distance between units in
the air. The fidelity data is stored at. The fidelity it can be transmitted at.
Those drive volume, and volume drives the whole shape of the system. The
conclusion: constrain the transport, but do not constrain on-board capture. Keep
everything locally. Send only what peers truly need.
> **Read more**
> - [01 — Problem statement](01-problem-statement.md) and [03 — Data platform](03-data-platform.md) — the constraints and the storage tiers
## 4. Three tiers inside each unit
That gave [three layers](03-data-platform.md) on every drone:
1. **Raw capture**, never transformed.
2. **ETL / reduction**, where data is cut down to what has to be shared:
millimetres to centimetres, thinning time series where that is enough,
dropping what nobody downstream reads.
3. **A bidirectional interface**: on one side it broadcasts position into the
shared channel, on the other it lets peers pull data.
The ETL is deliberately not over-specified. How sensor data is transformed is a
data-engineering decision. The platform's job is the contract, meaning what gets
stored and in which structure, not to step into that work.
> **Read the code**
> - [`simulator/virtual_drone/writer.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/writer.py#L23-L80) — the Hive-partition layout and the seal step that compacts a flight
> - [`simulator/virtual_drone/sensors.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/sensors.py) — the raw capture that feeds tier one
## 5. What actually needs to sync
The most time-critical item is where each peer is, so every unit has time to
react. The choice was to broadcast [relative pose](04-swarm-sync.md) (x, y, z and
time) instead of absolute coordinates. Relative is cheaper and enough for
coordination. To avoid sending orientation, a unit is modelled as a **sphere**
that bounds its extent. That trades a little compute for much less data, and no
per-shape encoding. If the units are identical, their 3D model can be provisioned
ahead instead of transmitted.
> **Read the code**
> - [`simulator/virtual_drone/broadcast.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/broadcast.py#L1-L40) — the 46-byte pose frame, position quantized on the wire only
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L260-L277) — pose broadcasts at 5 Hz and opportunistic bulk sync, made visible
## 6. Security as nested layers, not one wall
The assumption was an ad-hoc wireless mesh. Broadcasting positions in the clear,
even over encrypted Wi-Fi, is not acceptable: anyone who joins can reconstruct
trajectories over time, and wireless encryption has been broken by ordinary
people for two decades. Relying on that layer alone is risky, and pushing heavy
encryption there is wasteful. So the trust model is [layered](05-network-security.md):
1. Wireless transport (WPA class) as the outer shell.
2. **WireGuard over UDP** as the mesh overlay: proven, peer-authenticated by
static keys, cheap.
3. **SSH inside the tunnel** as the application layer, instead of HTTP/HTTPS. SSH
is one of the most tested remote-access technologies we have. HTTPS would pull
in a certificate authority and extra parts, and it is the first surface a
security researcher probes. [Fewer moving parts, on purpose](adr/ADR-0005-wireguard-beneath-ssh.md).
Identity and encryption solve together at provisioning time: a per-flight,
per-unit key pair recorded in a flight registry. Each unit holds its own key pair
plus the public keys of its peers. A separate key registry for SSH means that if
one WireGuard key leaks, SSH access does not follow automatically.
> **Read the code**
> - [`infra/ansible/drone-provision.yml`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/infra/ansible/drone-provision.yml#L20-L70) — ed25519 identity, peers authorized only through a forced command, WireGuard brought up
> - [05 — Network & security](05-network-security.md) and [ADR-0005](adr/ADR-0005-wireguard-beneath-ssh.md)
## 7. SQL as the contract
Beyond the broadcast, people and peers want richer access. The whole progression
is familiar: fully dynamic APIs, then JSON, OpenAPI/Swagger, GraphQL, then heavier
schemes. Each time the same pain: hard to debug in the moment, and a lot of
infrastructure to support, test, and agree on. The simpler conclusion:
[**SQL is the contract**](adr/ADR-0004-sql-over-ssh-contract.md). Everyone who
touches data already speaks SQL, and we do not know in advance which slice each
of them needs, so give them a declarative, extensible way in rather than guessing
endpoints ahead of time.
Concretely: an SSH connection whose forced command runs a read-only **DuckDB**
query directly. No extra auth handshake between the engine and the data, the way
a long-running database needs. No new port or protocol. When access control is
needed, it rides on Linux file permissions, because the data is discrete
Hive-partitioned files and permissions can be set per file. Not new invention:
coming back to foundations DevOps and sysadmins already trust.
> **Read the code**
> - [`simulator/explorer/server.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/explorer/server.py#L43-L53) — the read-only gate that rejects anything that writes
> - [`simulator/explorer/server.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/explorer/server.py#L111-L131) — the query path a peer or an engineer hits identically
> - [`simulator/tests/test_sql_gate.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/tests/test_sql_gate.py#L13-L35) — the gate is safety-critical, so it is tested first
## 8. Replication and offload
Moving accumulated data uses rsync-class tooling (rclone) over the same SSH
channel. It transfers diffs of the Hive tree efficiently. This matters because a
unit can be lost, and if it is, we want its data to already exist elsewhere.
When peers have a stable link the partition tree is pulled between them, which
gives redundancy and a way to cross-check later. On the ground, when the fleet
returns, the same path unifies every unit's data into a
[local warehouse](06-environments.md). The warehouse is read-mostly for
analytics, so transaction contention is not a concern, which opens a
DuckDB / DuckLake approach with MinIO underneath, the
[same storage model on both ends](adr/ADR-0002-one-storage-format.md). One
uniform structure is what lets an analyst trust a single picture even when a
unit's data has a gap.
> **Read the code**
> - [`infra/terraform/ground/main.tf`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/infra/terraform/ground/main.tf#L124-L160) — the post-flight T1 to T3 offload CronJob
> - [06 — Environments](06-environments.md) and [ADR-0002](adr/ADR-0002-one-storage-format.md)
## 9. A proof of concept, not slides
A concept can be handed over and be wrong. Instead there is a generator: a
virtual drone producing real sensor data into the Hive layout, a small frontend
to watch it fill, and a **Grafana** dashboard on top, not to get ahead of the
analysts, but to see the infrastructure work end to end. Closing the loop
(Terraform, connector, exporter, dashboard) is how a misconfiguration shows up
early. A panel that renders wrong points at exactly which layer broke, before an
analyst has to report that the infra is up but nothing shows. Better to exercise
every layer now and drop any layer or technology later if there is a sound
argument against it.
Watching that loop is also where the [CPU cost surfaced](adr/ADR-0009-bounded-lake-scans.md):
scanning the whole lake on every query does not scale, so the scan is
[bounded to a flight window and cached](07-observability.md).
> **Read the code**
> - [`simulator/monitoring/exporter.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/monitoring/exporter.py#L38-L107) — the DuckDB scan that turns Parquet into Prometheus metrics
> - [`simulator/monitoring/lake.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/monitoring/lake.py#L13-L33) — the flight-window scan that keeps CPU bounded as the lake grows
> - [`simulator/monitoring/grafana/dashboards/`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/monitoring/grafana/dashboards) — the fleet and platform dashboards
> - [`.github/workflows/release.yaml`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/.github/workflows/release.yaml) — tests, smoke flight, Trivy, semantic release
## 10. Working method
A note on how this is meant to be built, not just what. Working together needs a
shared language, and when the working language is nobody's first language,
answering live is expensive. The form that scales is written-first: lay the idea
down carefully, let others read and answer in their own time, keep the history. A
new colleague, or an agent, can then load the full context of why a decision was
made by reading the discussion. That is exactly why the decisions here are
[ADRs](adr/README.md) and why the [contribution flow](../CONTRIBUTING.md) is
test-first: the reasoning is captured where the next person will look for it.
The mental image is a city planner. Draw perfectly straight roads and people
still cross the grass where it is faster. The job is not to decide for them, but
to understand the real paths, the pain points and limits of each team, and pave
those. So constraints get checked with the other DevOps, data engineers, and
analysts before infrastructure is laid, instead of building roads nobody uses.
---
The platform is air-gapped and on-prem by design: nothing leaves the swarm, and
nothing joins it at runtime. Start from the [README](../README.md) for the map,
or the [ADR index](adr/README.md) for the decisions in full.
+3
View File
@@ -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).
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.
## 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.
- **Per-drone label**: id, current Wi-Fi channel (hopping), battery.
- Controls: drone count, simulation speed, pause.
- **3d** — perimeter patrol, altitude, detection beams, 4-drone escort sphere
+66 -1
View File
@@ -9,11 +9,13 @@
"version": "0.1.0",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"three": "^0.172.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.172.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.3"
@@ -1150,6 +1152,13 @@
"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": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1230,6 +1239,35 @@
"@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": {
"version": "4.7.0",
"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"
}
},
"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": {
"version": "2.10.42",
"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": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1507,6 +1559,13 @@
"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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1701,6 +1760,12 @@
"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": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+3 -1
View File
@@ -10,11 +10,13 @@
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"three": "^0.172.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.172.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.3"
+729 -105
View File
@@ -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 { searchWeb, type SearchHit } from "./search";
import {
AREA,
MAX_ASPECT,
SWARM_ALGORITHM_LABELS,
areaWidth,
fmtBytes,
houseRoadNetwork,
makeWorld,
seed,
tick,
type RouteMode,
type SwarmAlgorithm,
type World,
} from "./sim";
import { SwarmView3D } from "./SwarmView3D";
import type { RenderBackend } from "./SwarmScene";
const VIEW = 1000;
const PANEL_W = 320;
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 {
const w = Math.max(320, window.innerWidth - PANEL_W);
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));
}
const LIVE_POLL_MS = 2000;
function routeModeFor(_view3d: boolean): RouteMode {
return "perimeter";
}
export default function App(): JSX.Element {
const [view3d, setView3d] = useState(false);
const [droneCount, setDroneCount] = useState(8);
const [swarmAlgo, setSwarmAlgo] = useState<SwarmAlgorithm>("boids");
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>(() => {
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);
return makeWorld(8, displayAspect());
});
worldRef.current = { ...makeWorld(8, displayAspect(), "perimeter"), swarmAlgo };
}
const raf = useRef(0);
const last = useRef(performance.now());
const liveWorld = useRef<World | null>(null);
useEffect(() => {
const resetWorld = useCallback((): void => {
seed(42);
setWorld(makeWorld(droneCount, aspect));
}, [droneCount, aspect]);
worldRef.current = {
...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(() => {
let timer = 0;
@@ -61,20 +96,21 @@ export default function App(): JSX.Element {
}, []);
useEffect(() => {
if (mode !== "sim") return;
if (view3d || mode !== "sim") return;
const loop = (now: number): void => {
const dt = Math.min(0.05, (now - last.current) / 1000);
last.current = now;
if (!paused && dt > 0) {
setWorld((w) => tick(w, dt * speedup));
worldRef.current = tick(worldRef.current!, dt * speedup);
bump((n) => n + 1);
}
raf.current = requestAnimationFrame(loop);
};
last.current = performance.now();
raf.current = requestAnimationFrame(loop);
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(() => {
if (mode !== "live") return;
let cancelled = false;
@@ -84,7 +120,11 @@ export default function App(): JSX.Element {
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);
if (view3d) {
liveWorld.current = { ...liveWorld.current, routeMode: "perimeter" };
}
worldRef.current = liveWorld.current;
bump((n) => n + 1);
} catch (err) {
if (!cancelled) setLiveError(err instanceof Error ? err.message : String(err));
}
@@ -95,34 +135,80 @@ export default function App(): JSX.Element {
cancelled = true;
window.clearInterval(timer);
};
}, [mode]);
}, [mode, view3d]);
useEffect(() => {
if (mode === "sim") {
liveWorld.current = null;
setLiveError(null);
seed(42);
setWorld(makeWorld(droneCount, aspect));
resetWorld();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on mode change
}, [mode]);
}, [mode, resetWorld]);
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(
() => [...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 (
<div style={styles.shell}>
<div style={{ ...styles.shell, ...(view3d ? styles.shell3d : {}) }}>
<header style={styles.header}>
<div>
<div style={styles.title}>SWARM HOUSE</div>
<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 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
style={{ ...styles.button, ...(mode === "live" ? styles.buttonActive : {}) }}
onClick={() => setMode((m) => (m === "sim" ? "live" : "sim"))}
@@ -137,6 +223,23 @@ export default function App(): JSX.Element {
)}
{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}>
drones
<input
@@ -169,90 +272,165 @@ export default function App(): JSX.Element {
</header>
<div style={styles.main}>
<svg
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
style={styles.canvas}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
<stop offset="0%" stopColor="#101826" />
<stop offset="100%" stopColor="#0a0f18" />
</radialGradient>
</defs>
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
{gridLines()}
{view3d ? (
<SwarmView3D
worldRef={worldRef as React.MutableRefObject<World>}
simEnabled={simEnabled}
onStep={onStep3d}
onUiPulse={onUiPulse}
onBackend={setBackend}
/>
) : (
<svg
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
style={styles.canvas2d}
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) => {
const a = world.drones[l.a];
const b = world.drones[l.b];
if (!a || !b) return null;
const mx = (a.pos.x + b.pos.x) / 2;
const my = (a.pos.y + b.pos.y) / 2;
const hot = l.rate > 20_000;
return (
<g key={l.key}>
<line
x1={sx(a.pos.x)}
y1={sy(a.pos.y)}
x2={sx(b.pos.x)}
y2={sy(b.pos.y)}
stroke={hot ? "#39d98a" : "#3d7bd9"}
strokeWidth={hot ? 2.2 : 1}
opacity={0.25 + l.flash * 0.75}
{activeLinks.map((l) => {
const a = world.drones[l.a];
const b = world.drones[l.b];
if (!a || !b) return null;
const mx = (a.pos.x + b.pos.x) / 2;
const my = (a.pos.y + b.pos.y) / 2;
const hot = l.rate > 20_000;
return (
<g key={l.key}>
<line
x1={sx(a.pos.x)}
y1={sy(a.pos.y)}
x2={sx(b.pos.x)}
y2={sy(b.pos.y)}
stroke={hot ? "#39d98a" : "#3d7bd9"}
strokeWidth={hot ? 2.2 : 1}
opacity={0.25 + l.flash * 0.75}
/>
{hot && (
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
</text>
)}
</g>
);
})}
{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}>
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
</text>
)}
</g>
);
})}
) : (
<StaticHouse
key={ob.id}
id={ob.id}
x={sx(ob.pos.x)}
y={sy(ob.pos.y)}
r={(ob.radius / AREA) * VIEW * 1.15}
/>
),
)}
{/* Obstacles */}
{world.obstacles.map((ob) => (
<g key={ob.id}>
<circle
cx={sx(ob.pos.x)}
cy={sy(ob.pos.y)}
r={(ob.radius / AREA) * VIEW}
fill={ob.moving ? "#5a2a35" : "#2a3245"}
stroke={ob.moving ? "#c05a6a" : "#46536e"}
strokeWidth={1.5}
opacity={0.85}
/>
{ob.moving && (
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
transit
{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})`}
opacity={d.phase === "landed" ? 0.85 : 1}
>
<polygon
points="0,-11 7,9 0,5 -7,9"
fill={d.phase === "landed" ? "#a8c4e8" : "#e8ecf4"}
stroke={d.phase === "return" ? "#ffb347" : "#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)}%
{d.phase === "landed" && d.offloadProgress !== undefined
? ` · ↑${(d.offloadProgress * 100).toFixed(0)}%`
: ""}
{(d.dataBuffer ?? 0) > 0 && d.phase === "patrol"
? ` · ${fmtBytes(d.dataBuffer ?? 0)}`
: ""}
</text>
)}
</g>
))}
{/* Drones */}
{world.drones.map((d) => (
<g
key={d.id}
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
>
<polygon points="0,-11 7,9 0,5 -7,9" fill="#e8ecf4" stroke="#7ea0d0" strokeWidth={1} />
<text y={24} style={styles.droneLabel as never} transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}>
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} · {d.battery.toFixed(0)}%
</text>
</g>
))}
</svg>
</g>
))}
</svg>
)}
<aside style={styles.panel}>
<Section title="Mission">
<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)} />
{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="pose broadcasts" value={String(world.broadcasts)} />
{!view3d && <Stat name="pose broadcasts" value={String(world.broadcasts)} />}
<Stat name="total transferred" value={fmtBytes(world.totalBytes)} />
{view3d && <Stat name="renderer" value={backend.toUpperCase()} />}
</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">
{busiest.map((l) => (
<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>}
</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">
<div style={styles.dim}>blue line pose broadcast (5 Hz, ~46 B)</div>
<div style={styles.dim}>green line bulk sync (sealed partitions)</div>
<div style={styles.dim}>flash broadcast event delivered</div>
<div style={styles.dim}>red circle transit object crossing the area</div>
{view3d ? (
<>
<div style={styles.dim}>green ring perimeter patrol path</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>
</aside>
</div>
@@ -286,9 +533,319 @@ function sy(y: number): number {
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 {
const width = sx(areaWidth());
const step = VIEW / 10; // one cell per 100 m in both directions
const step = VIEW / 10;
const lines = [];
for (let p = step; p < width; p += step) {
lines.push(
@@ -330,6 +887,11 @@ const styles: Record<string, React.CSSProperties> = {
display: "flex",
flexDirection: "column",
},
shell3d: {
height: "100vh",
minHeight: 0,
overflow: "hidden",
},
header: {
display: "flex",
justifyContent: "space-between",
@@ -338,11 +900,31 @@ const styles: Record<string, React.CSSProperties> = {
borderBottom: "1px solid #1a2436",
flexWrap: "wrap",
gap: 12,
minHeight: HEADER_H,
boxSizing: "border-box",
},
title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" },
subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 },
controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" },
label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" },
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" },
button: {
background: "#16233a",
@@ -357,14 +939,15 @@ const styles: Record<string, React.CSSProperties> = {
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" },
main: { display: "flex", flex: 1, gap: 0, minHeight: 0, overflow: "hidden" },
canvas2d: { flex: 1, minWidth: 0, display: "block" },
panel: {
width: 320,
width: PANEL_W,
borderLeft: "1px solid #1a2436",
padding: 16,
overflowY: "auto",
background: "#0a0f18",
scrollbarGutter: "stable",
},
section: { marginBottom: 22 },
sectionTitle: {
@@ -389,6 +972,47 @@ const styles: Record<string, React.CSSProperties> = {
linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 },
dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" },
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" },
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
+77
View File
@@ -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
View File
@@ -79,16 +79,27 @@ function linkKey(a: number, b: number): string {
/** 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,
}));
const drones = poses.map((p, i) => {
const t = (prev?.t ?? 0) + dtS;
const band = 16 + (i % 6) * 4.5;
const alt =
band +
Math.sin(t * 0.62 + i * 0.91) * 4.5 +
Math.sin(p.x * 0.011 + t * 0.45) * 2;
const altVel = Math.cos(t * 0.62 + i * 0.91) * 2.8;
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
// panel keeps its meaning between polls.
@@ -99,6 +110,7 @@ export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number):
const dist = Math.hypot(
drones[i].pos.x - drones[j].pos.x,
drones[i].pos.y - drones[j].pos.y,
(drones[i].alt ?? 0) - (drones[j].alt ?? 0),
);
if (dist > LINK_RANGE) continue;
const key = linkKey(i, j);
+50
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -3,4 +3,24 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
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"),
},
},
},
});