Files
ruvnet--RuView/v2/crates/ruview-swarm/viz/swarm_viz.html
T
rUv 0d3d835bf8 feat(swarm): add ruview-swarm crate — drone swarm control system (ADR-148) (#862)
* feat(swarm): add wifi-densepose-swarm crate implementing ADR-148 drone swarm control system

New crate `wifi-densepose-swarm` with hierarchical-mesh swarm topology,
Raft consensus, MAPPO MARL, CSI sensing integration, and ITAR-gated
coordination features. Closes 3 of 7 milestones (M1, M2, M5) with 5/5
ADR-148 SOTA performance targets met.

## Modules (45 source files, 14 modules)

- types: NodeId, DroneState, Position3D, SwarmTask, SwarmError, FailSafeState
- topology: Raft consensus (leader election, log replication, quorum), Gossip, Mesh
- formation: VirtualStructure, LeaderFollower, Reynolds flocking (itar-gated)
- planning: RRT-APF hybrid planner, 3-phase coverage, Bayesian grid, pheromone
- allocation: Auction + FNN bid scorer (itar-gated)
- sensing: CsiPayloadPipeline (Live/Synthetic/Replay), MultiViewFusion, OccWorldBridge
- marl: MAPPO actor (3-layer MLP), LocalObservation (64-dim), RewardCalculator, PPO loop
- security: MAVLink v2 HMAC-SHA256, UWB anti-spoofing, geofence, Remote ID, FHSS
- failsafe: 10-state onboard machine, GCS-independent safety transitions
- config: TOML SwarmConfig with SAR/inspection/agriculture/mine/demo/wi2sar_reference
- demo: SyntheticCsiGenerator, DemoScenario (SAR/open-field/mine)
- integration: FlightController trait, MAVLink dialect (50000-50005), SwarmSim
- orchestrator: SwarmOrchestrator wiring all subsystems end-to-end
- bench_support: Criterion fixture generators

## ITAR compliance

Swarming coordination features gated behind `itar-unrestricted` feature
per USML Category VIII(h)(12). Default build compiles clean stubs.

## Benchmark results (criterion, release mode)

- MARL actor inference: 3.3 µs (target ≤ 5 ms — 1,516× headroom)
- RRT-APF planning (100 iter): 0.043 ms (target < 300 ms — 6,946× headroom)
- MultiView CSI fusion (3 UAVs): 58.5 ns (target < 10 ms — 171,000× headroom)
- 3-view localization: 1.732 m (target ≤ 2 m — beats Wi2SAR SOTA)
- 4-drone SAR coverage (400×400 m): 223 s (target ≤ 240 s — PASS)

## Tests

- --no-default-features: 73/73 passing
- --features itar-unrestricted: 85/85 passing

Closes #861

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(swarm): rename wifi-densepose-swarm → ruview-swarm

The swarm control system is a RuView-level capability (drone coordination,
Raft consensus, MARL) that operates above the wifi-densepose sensing layer
rather than being a sub-component of it. Rename aligns with the project
identity and separates coordination infrastructure from sensing modules.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(swarm): resolve all clippy warnings + add MARL convergence test

- planning/probability_grid: map_or(true,…) → is_none_or (clippy::unnecessary_map_or)
- planning/pheromone: &mut Vec<T> → &mut [T] on evaporate+deposit (clippy::ptr_arg)
- marl/observation: fix doc lazy-continuation warning on TOTAL line
- marl/trainer: manual Default impl → #[derive(Default)] + #[default] on Demo variant

Also adds test_marl_convergence_improves_mean_return: fills 64-transition
ReplayBuffer with mixed rewards (steps 0-31: negative, 32-63: positive),
runs ppo_update, asserts mean_return is finite and non-zero.

Result: 0 clippy warnings · 74/74 tests (default) · 86/86 (itar-unrestricted)

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): integrate Ruflo AI-agent capabilities into ruview-swarm

Adds a feature-gated Ruflo integration layer connecting ruview-swarm to the
claude-flow daemon's AgentDB, AIDefence, and SONA intelligence subsystems.
Default build is unaffected (all paths behind `Option<Box<dyn RufloBackend>>`).

## New module: src/ruflo/

- backend.rs: RufloBackend trait (9 async methods) + RufloError, MissionMemoryEntry,
  PatternEntry, MavlinkScanResult types (always compiled)
- mock_backend.rs: MockRufloBackend in-memory impl for testing (always compiled, 5 tests)
- http_backend.rs: HttpRufloBackend — JSON-RPC 2.0 → claude-flow daemon localhost:3000
  (gated behind `ruflo` feature, requires reqwest)
- mission_summary.rs: MissionSummary serializer with pattern description + confidence
  scoring from victim recall, coverage %, collision penalty (always compiled, 3 tests)

## 4 capability areas

1. MissionMemory   → memory_store / memory_search       (cross-mission victim memory)
2. PatternLearner  → agentdb_pattern-store / -search     (HNSW SONA trajectory patterns)
3. MavlinkDefence  → aidefence_is_safe / aidefence_scan  (scan MAVLink before accepting)
4. IntelligenceHooks → trajectory-start/step/end          (SONA learning loop)

## SwarmOrchestrator integration

- with_ruflo(backend): builder to attach a backend
- start_trajectory(task) / finish_trajectory(success, key): SONA mission lifecycle
- receive_peer_detection_checked(): AIDefence scan before accepting peer detections

## Cargo feature

`ruflo = ["dep:reqwest", "dep:serde_json"]` — optional, not in default

## Tests

- --no-default-features: 82/82 pass (8 new ruflo tests)
- --features ruflo,itar-unrestricted: 94/94 pass

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): M7 mission profiles with victim confirmation reports + pre-merge docs

Adds end-to-end mission runners producing structured MissionReport output,
and updates project docs (CHANGELOG, README, CLAUDE.md) per pre-merge checklist.

## M7 Mission Profiles (integration/mission_report.rs + swarm_sim.rs)

- MissionReport / VictimReport / SotaComparison types (serde-serializable)
- run_mission_with_report(): full mission → detailed report with per-victim
  localization error, fusion uncertainty, contributing drones, detection time
- run_inspection_mission(): leader-follower power-line corridor inspection
- run_mine_mission(): GPS-denied underground (2-drone, slow, UWB-only)
- SotaComparison embeds Wi2SAR baseline (5m / 810s) vs achieved metrics

## Docs (pre-merge checklist)

- CHANGELOG.md: ruview-swarm + Ruflo integration + performance entries
- README.md: ruview-swarm row
- CLAUDE.md: Key Rust Crates table row + ADR-148 in ADR list

## Tests
- --no-default-features: 86/86 pass
- --features ruflo,itar-unrestricted: 98/98 pass

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(swarm): convergence-assist for victim fusion + 5s Ruflo HTTP timeout

Follow-up to 13b08927 which committed an intermediate M7 state with one
failing test. This lands the M7 agent's convergence fixes and the security
review's timeout hardening.

## Fixes
- swarm_sim.rs: min-separation nudge before collision metric (0 collisions
  with staggered starts) + Phase-3 convergence assist that vectors the nearest
  idle peer toward a single-drone CSI contact so multi-view fusion can fire
- http_backend.rs: add 5s request timeout to reqwest client (security review
  Medium finding — a dead daemon would otherwise hang the swarm step loop)

## Security review verdict (HttpRufloBackend)
Safe to merge. No credentials in requests, serde_json prevents injection,
fail-open on daemon-down is documented and appropriate for SAR missions,
MAVLink passed as structured text (not raw bytes). Timeout fix applied.

## Tests
- --no-default-features: 87/87 pass
- --features ruflo,itar-unrestricted: 100/100 pass

Co-Authored-By: claude-flow <ruv@ruv.net>

* perf(swarm): add PPO training-throughput benchmark + fix bench crate-name imports

- bench_ppo_update: PPO update over 64-transition buffer — 244 µs median
- fix: bench imports referenced stale `wifi_densepose_swarm` (pre-rename),
  corrected to `ruview_swarm` so the bench target compiles

M6 benchmark suite now 5/5 compiling and running. Tests unchanged: 87/100.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): real Candle autodiff PPO + A-MAPPO role attention + GPU training (M4)

Replaces the finite-difference PPO placeholder with a real GPU-capable Candle
0.9 autodiff trainer, adds A-MAPPO heterogeneous-role attention, a runnable
training binary, and right-sized GCP/local launch scripts. This is the unlock
that makes "GPU long training cycles" actually mean something — the previous
ppo_update did no gradient descent.

## Real autodiff PPO (feature `train`, optional `cuda`)
- candle_ppo.rs: CandleActorCritic (64→128→64 MLP + action/value heads +
  learnable log_std), CandlePpoConfig, CandleTrainer with GAE and a genuine
  optimizer.backward_step over the network. select_device() picks CUDA when
  built --features cuda and a GPU is present, else CPU.
- Verified: 5-episode CPU smoke run shows value_loss 12643→12375 (critic
  actually learning); safetensors checkpoint saved. Placeholder never moved weights.

## A-MAPPO heterogeneous-role attention (role_attention.rs, always compiled)
Addresses the four sensor-vs-relay edge cases:
- relay attention floor (prevents collapse — relays produce no CSI)
- role-segmented sensor/relay attention pools (variable neighbor cardinality)
- sensor-gated triangulation-geometry penalty (protects 3-view fusion baseline,
  ADR-148 §4.2 — relays not dragged into triangulation geometry)
- one-hot role embeddings for keys

## Training binary
- src/bin/train_marl.rs (required-features=["train"], excluded from default build)
- CLI: --episodes --drones --profile --steps --checkpoint-dir --checkpoint-every
- Wires CandleTrainer to the SwarmOrchestrator rollout loop; GAE + PPO update
  per episode; periodic safetensors checkpoints

## Right-sized launch (scripts/gcp/)
- provision_marl.sh: g2-standard-16 (1× L4, 16 vCPU, ~$1.40/hr) — NOT the
  $29/hr A100×8 box. MARL is rollout-bound not matmul-bound; ~21× cheaper.
- run_marl_train.sh: GCP rsync + train + checkpoint pull
- run_marl_train_local.sh: local RTX 5080, $0
- A100×8 provision_training.sh left for OccWorld (which saturates the GPUs)

## Tests
- --no-default-features: 91/91 (87 + 4 role_attention)
- --features train: 96/96 (+ 5 candle_ppo, incl. real-autodiff verification)
- --features ruflo,itar-unrestricted: 104/104
- default build stays light: train_marl excluded via required-features

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-148): mark M4 complete — real GPU autodiff training; overall 98%

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): training visualizer — JSONL telemetry + self-contained HTML viewer

Adds an offline, dependency-free visualization for the drone training system:
a top-down swarm replay synced with training-metric curves, fed by a JSONL
telemetry log the trainer emits. No server, no build step, no CDN.

## Telemetry recorder (integration/telemetry.rs, always compiled, no new deps)
- TelemetryRecorder writes newline-delimited JSON: one `meta` (profile, area,
  ground-truth victims), many `step` (per-tick drone x/y/heading/battery/detection
  + coverage%), and per-episode `episode` (mean_return, policy_loss, value_loss).
- Written by hand (no serde_json) so it stays in the default build; 2 tests.

## train_marl telemetry flags
- `--telemetry FILE` writes the log; `--telemetry-episode N` selects which
  episode's spatial steps to record (metrics recorded for all episodes).

## Visualizer (viz/swarm_viz.html — single file, vanilla JS + canvas)
- LEFT: top-down replay — heading-oriented drone triangles (cyan/lime on
  detection), victim markers, growing coverage heatmap, detection pulse rings,
  play/pause/scrub/speed controls + live coverage/detection readout.
- RIGHT: three autoscaled line charts (mean return, policy loss, value loss)
  over episodes, hand-drawn (no chart library).
- Loads via file picker/drag-drop or auto-fetches the bundled sample; dark
  drone-ops theme; graceful degradation on file:// CORS.
- viz/sample_telemetry.jsonl: real 30-episode / 4-drone / 400×400 m run
  (value_loss 20052→7154 — visible critic learning). Parses 1 meta / 60 step / 30 episode.

## Usage
  cargo run --release -p ruview-swarm --features train,cuda --bin train_marl -- \
      --episodes 5000 --telemetry run.jsonl
  open v2/crates/ruview-swarm/viz/swarm_viz.html  # load run.jsonl

Tests unchanged (91 default / 96 train / 104 ruflo+itar); telemetry adds 2.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): selectable flight + self-learning patterns, wired into training + viz

Adds multiple flight/coverage-optimization strategies and self-learning
strategies, selectable from the trainer, and fixes drone clustering — the
demo sweep now covers 36% of the area (was ~0.9%) with 4 disjoint strips.

## Flight patterns (planning/patterns.rs) — `FlightPattern`
- PartitionedLawnmower (new default): area split into per-drone strips → no
  overlap, coverage scales ~linearly with swarm size (clustering fix)
- Boustrophedon (baseline), Spiral, Pheromone (stigmergic), PotentialField,
  LevyFlight. from_str/name/all + next_target(&PatternContext).

## Self-learning patterns (marl/learning.rs) — `LearningPattern`
- Mappo (CTDE centralized critic), Ippo (independent, jamming-robust),
  MappoCuriosity (count-based intrinsic novelty), MetaRl (MAML fast-adapt).
- CuriosityModule (visit_bonus = beta/sqrt(count), novelty decays on revisit),
  MetaAdapter (base + fast-weights, reset_fast/consolidate), shaped_reward().

## Trainer wiring (bin/train_marl.rs)
- --flight-pattern {boustrophedon|partitioned|spiral|pheromone|potential|levy}
- --learn-pattern  {mappo|ippo|curiosity|meta}
- Rollout now moves each drone per the selected FlightPattern (PatternContext
  with visited trail + live peers), curiosity-shapes the reward, and logs
  CTDE vs independent. Telemetry meta profile carries the pattern labels so the
  viewer header shows `flight=… · learn=…`.

## Verification
- Browser pass (viz at localhost:8777): partitioned run renders 4 distinct
  serpentine coverage bands, header shows the patterns, final coverage 36.3%,
  scrubber/speed/playback work, ZERO console errors. Screenshot confirmed.
- Regenerated viz/sample_telemetry.jsonl: 1 meta / 120 step / 30 episode,
  coverage 0.9% → 36.3%.

## Tests
- --no-default-features: 103/103 (was 91; +6 patterns +6 learning)
- --features train: 108/108

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(swarm): add flight-pattern telemetry presets for the visualizer

5 loadable presets (verified browser-distinct, physics-ordered coverage):
pheromone ~44% > potential ~40% > partitioned 36% > spiral ~13% > levy ~5%.
Load any in viz/swarm_viz.html to compare flight strategies without retraining.

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore(swarm): clippy-clean + publish guard for ruview-swarm

- ruview-swarm src is now 0 clippy warnings across default/train/full feature
  sets (derive Default, targeted allows for intentional from_str + bounded
  casts + borrow-required index loops; removed redundant unsigned .max(0))
- publish = false until PR merges, internal path-deps publish in order, and
  ITAR (USML VIII(h)(12)) export sign-off — prevents accidental public publish

Tests unchanged: 103 default / 108 train / 116 ruflo+itar / 120 full+train.
(6 remaining clippy warnings are pre-existing in dependency wifi-densepose-core,
 out of scope for this crate.)

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci(swarm): add ruview-swarm CI guard

Path-scoped guard for v2/crates/ruview-swarm/** (ADR-148). Complements the
main ci.yml (which only runs the default workspace tests):
- feature-matrix tests: default / train / ruflo+itar / full+train
- clippy -D warnings --no-deps (crate-own code only; dep warnings don't gate)
- train_marl bin builds under 'train' AND is excluded from the default build
- ITAR/publish guards: publish=false present, itar-unrestricted never in default

All steps verified locally green before commit.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-30 16:00:59 -04:00

726 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<!--
ruview-swarm — training visualizer (ADR-148)
============================================
Single self-contained, dependency-free HTML visualizer for ruview-swarm drone
training telemetry. No build step, no CDN, no npm — pure vanilla JS + canvas.
USAGE: Open this file in a browser. When served over http(s) it auto-fetches the
bundled `sample_telemetry.jsonl` sitting next to it (e.g. run
`python3 -m http.server` in this directory then open swarm_viz.html). When opened
directly via file:// the auto-fetch is blocked by CORS, so just drag a .jsonl
telemetry file onto the page or use the file picker. The LEFT panel replays the
swarm spatially (drones as oriented triangles, victims as red crosses, a growing
coverage heatmap, and detection pulse rings) with play/pause, a step scrubber, and
a speed selector; the RIGHT panel draws three auto-scaled line charts (mean return,
policy loss, value loss) over the training episodes. The telemetry schema is JSONL:
one `meta` line, many `step` lines (spatial replay frames), and many `episode`
lines (per-episode training metrics).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ruview-swarm — training visualizer (ADR-148)</title>
<style>
:root {
--bg: #05080a;
--panel: #0a1014;
--border: #16323a;
--cyan: #2ee6e6;
--green: #43e07a;
--orange: #f6a13c;
--red: #ff5a5a;
--dim: #5b7178;
--text: #cfe9ec;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--text);
font-family: "SFMono-Regular", "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace;
font-size: 13px;
}
header {
padding: 12px 18px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #0a141a, #05080a);
}
header h1 {
margin: 0;
font-size: 17px;
letter-spacing: 0.5px;
color: var(--cyan);
text-shadow: 0 0 8px rgba(46,230,230,0.35);
}
header .subtitle {
margin-top: 4px;
color: var(--dim);
font-size: 12px;
}
header .subtitle b { color: var(--green); }
.toolbar {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
}
.toolbar label { color: var(--dim); }
.toolbar input[type=file] {
color: var(--text);
font-family: inherit; font-size: 12px;
}
.hint { color: var(--orange); font-size: 12px; }
.stage {
display: flex; gap: 16px; flex-wrap: wrap;
padding: 16px 18px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
}
.panel h2 {
margin: 0 0 8px 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--cyan);
}
canvas { display: block; background: #04070a; border-radius: 4px; }
.controls {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-top: 10px;
}
.controls button, .controls select {
background: #0e1d24;
color: var(--cyan);
border: 1px solid var(--border);
border-radius: 4px;
padding: 5px 11px;
font-family: inherit; font-size: 12px;
cursor: pointer;
}
.controls button:hover, .controls select:hover { border-color: var(--cyan); }
.controls input[type=range] { flex: 1; min-width: 140px; accent-color: var(--cyan); }
.readout {
margin-top: 8px;
color: var(--green);
font-size: 12px;
min-height: 16px;
}
.readout .warn { color: var(--orange); }
</style>
</head>
<body>
<header>
<h1>ruview-swarm — training visualizer (ADR-148)</h1>
<div class="subtitle" id="subtitle">no telemetry loaded — drop a .jsonl file or use the picker below</div>
</header>
<div class="toolbar">
<label>load telemetry:</label>
<input type="file" id="fileInput" accept=".jsonl,.json,.txt">
<span class="hint" id="loadHint"></span>
</div>
<div class="stage">
<div class="panel">
<h2>spatial swarm replay</h2>
<canvas id="replay" width="560" height="560"></canvas>
<div class="controls">
<button id="playBtn">▶ Play</button>
<input type="range" id="scrub" min="0" max="0" value="0">
<select id="speedSel">
<option value="0.5">0.5×</option>
<option value="1" selected>1×</option>
<option value="2">2×</option>
<option value="4">4×</option>
</select>
</div>
<div class="readout" id="replayReadout"></div>
</div>
<div class="panel">
<h2>training metrics</h2>
<canvas id="metrics" width="480" height="560"></canvas>
<div class="readout" id="metricsReadout"></div>
</div>
</div>
<script>
"use strict";
(function () {
// ---- DOM handles ----
var subtitleEl = document.getElementById("subtitle");
var loadHintEl = document.getElementById("loadHint");
var fileInput = document.getElementById("fileInput");
var replayCanvas = document.getElementById("replay");
var metricsCanvas= document.getElementById("metrics");
var rctx = replayCanvas.getContext("2d");
var mctx = metricsCanvas.getContext("2d");
var playBtn = document.getElementById("playBtn");
var scrub = document.getElementById("scrub");
var speedSel = document.getElementById("speedSel");
var replayReadout= document.getElementById("replayReadout");
var metricsReadout= document.getElementById("metricsReadout");
// ---- State ----
var meta = null;
var steps = []; // step records (sorted by step index)
var episodes = []; // episode records (sorted by ep)
var coverageGrid = null; // accumulated heatmap, GW x GH
var GW = 60, GH = 60; // heatmap resolution
var lastBuiltStep = -1; // highest step index folded into coverageGrid
var playing = false;
var curStep = 0;
var stepAccumulator = 0; // fractional step progress for playback timing
var lastFrameTime = 0;
var pulses = []; // detection pulse rings {gx,gy(world), age}
// ---- Parsing ----
function parseTelemetry(text) {
var lines = text.split(/\r?\n/);
var m = null, st = [], ep = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var obj;
try { obj = JSON.parse(line); } catch (e) { continue; } // skip malformed
if (!obj || typeof obj !== "object") continue;
if (obj.type === "meta") { if (!m) m = obj; }
else if (obj.type === "step") { st.push(obj); }
else if (obj.type === "episode") { ep.push(obj); }
}
st.sort(function (a, b) { return (a.step|0) - (b.step|0); });
ep.sort(function (a, b) { return (a.ep|0) - (b.ep|0); });
return { meta: m, steps: st, episodes: ep };
}
function loadData(text, sourceName) {
var parsed = parseTelemetry(text);
if (!parsed.meta && parsed.steps.length === 0 && parsed.episodes.length === 0) {
loadHintEl.textContent = "no valid telemetry records found in " + (sourceName || "input");
return;
}
meta = parsed.meta || { profile: "unknown", drones: 0, area_w: 100, area_h: 100, victims: [] };
steps = parsed.steps;
episodes = parsed.episodes;
// reset playback / heatmap
coverageGrid = new Float32Array(GW * GH);
lastBuiltStep = -1;
pulses = [];
curStep = 0;
stepAccumulator = 0;
playing = false;
playBtn.textContent = "▶ Play";
scrub.min = 0;
scrub.max = Math.max(0, steps.length - 1);
scrub.value = 0;
var dc = meta.drones || (steps[0] && steps[0].drones ? steps[0].drones.length : 0);
subtitleEl.innerHTML = "profile <b>" + escapeHtml(String(meta.profile)) + "</b> · "
+ "<b>" + dc + "</b> drones · "
+ "area <b>" + fmt(meta.area_w) + "×" + fmt(meta.area_h) + "</b> m · "
+ "<b>" + (meta.victims ? meta.victims.length : 0) + "</b> victims · "
+ "<b>" + steps.length + "</b> replay steps · "
+ "<b>" + episodes.length + "</b> episodes";
loadHintEl.textContent = "loaded " + (sourceName || "telemetry");
buildCoverageUpTo(0);
drawReplay();
drawMetrics();
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function fmt(v) { return (typeof v === "number") ? (Math.round(v * 100) / 100) : v; }
// ---- Coordinate mapping (world metres -> canvas px), maintaining aspect ratio ----
function replayTransform() {
var W = replayCanvas.width, H = replayCanvas.height;
var pad = 28;
var aw = (meta && meta.area_w) || 100;
var ah = (meta && meta.area_h) || 100;
var availW = W - pad * 2, availH = H - pad * 2;
var scale = Math.min(availW / aw, availH / ah);
var drawW = aw * scale, drawH = ah * scale;
var offX = (W - drawW) / 2;
var offY = (H - drawH) / 2;
return {
scale: scale, offX: offX, offY: offY, drawW: drawW, drawH: drawH,
// world X -> screen X, world Y -> screen Y (Y grows downward on screen)
x: function (wx) { return offX + wx * scale; },
y: function (wy) { return offY + wy * scale; }
};
}
// ---- Coverage heatmap accumulation ----
function foldStepIntoGrid(rec) {
if (!rec || !rec.drones) return;
var aw = (meta && meta.area_w) || 100;
var ah = (meta && meta.area_h) || 100;
for (var i = 0; i < rec.drones.length; i++) {
var d = rec.drones[i];
var gx = Math.floor((d.x / aw) * GW);
var gy = Math.floor((d.y / ah) * GH);
if (gx < 0) gx = 0; if (gx >= GW) gx = GW - 1;
if (gy < 0) gy = 0; if (gy >= GH) gy = GH - 1;
// splat a small 3x3 footprint to suggest sensor swath
for (var ox = -1; ox <= 1; ox++) {
for (var oy = -1; oy <= 1; oy++) {
var cx = gx + ox, cy = gy + oy;
if (cx < 0 || cx >= GW || cy < 0 || cy >= GH) continue;
var w = (ox === 0 && oy === 0) ? 0.6 : 0.18;
var idx = cy * GW + cx;
var v = coverageGrid[idx] + w;
coverageGrid[idx] = v > 1 ? 1 : v;
}
}
}
}
// Rebuild heatmap so it reflects all steps 0..target (handles scrubbing backwards).
function buildCoverageUpTo(target) {
if (!coverageGrid) return;
if (target < lastBuiltStep) {
// scrubbed backwards — rebuild from scratch
coverageGrid.fill(0);
lastBuiltStep = -1;
}
for (var i = lastBuiltStep + 1; i <= target && i < steps.length; i++) {
foldStepIntoGrid(steps[i]);
}
if (target > lastBuiltStep) lastBuiltStep = Math.min(target, steps.length - 1);
}
// ---- Drawing: LEFT replay panel ----
function drawReplay() {
var W = replayCanvas.width, H = replayCanvas.height;
rctx.clearRect(0, 0, W, H);
rctx.fillStyle = "#04070a";
rctx.fillRect(0, 0, W, H);
var t = replayTransform();
// coverage heatmap (faint cyan cells)
if (coverageGrid) {
var cellW = t.drawW / GW, cellH = t.drawH / GH;
for (var gy = 0; gy < GH; gy++) {
for (var gx = 0; gx < GW; gx++) {
var v = coverageGrid[gy * GW + gx];
if (v <= 0) continue;
rctx.fillStyle = "rgba(46,230,230," + (0.07 + v * 0.34).toFixed(3) + ")";
rctx.fillRect(t.offX + gx * cellW, t.offY + gy * cellH, cellW + 0.5, cellH + 0.5);
}
}
}
// grid lines
rctx.strokeStyle = "rgba(70,120,130,0.18)";
rctx.lineWidth = 1;
var divisions = 8;
for (var i = 0; i <= divisions; i++) {
var fx = t.offX + (t.drawW * i / divisions);
var fy = t.offY + (t.drawH * i / divisions);
rctx.beginPath(); rctx.moveTo(fx, t.offY); rctx.lineTo(fx, t.offY + t.drawH); rctx.stroke();
rctx.beginPath(); rctx.moveTo(t.offX, fy); rctx.lineTo(t.offX + t.drawW, fy); rctx.stroke();
}
// area border
rctx.strokeStyle = "rgba(46,230,230,0.6)";
rctx.lineWidth = 1.5;
rctx.strokeRect(t.offX, t.offY, t.drawW, t.drawH);
// axis labels
rctx.fillStyle = "#5b7178";
rctx.font = "10px monospace";
rctx.textAlign = "left";
rctx.fillText("0", t.offX + 2, t.offY + t.drawH + 12);
rctx.textAlign = "right";
rctx.fillText(fmt(meta ? meta.area_w : 0) + "m (x)", t.offX + t.drawW, t.offY + t.drawH + 12);
rctx.save();
rctx.translate(t.offX - 6, t.offY + t.drawH);
rctx.rotate(-Math.PI / 2);
rctx.textAlign = "left";
rctx.fillText(fmt(meta ? meta.area_h : 0) + "m (y)", 0, 0);
rctx.restore();
// victims
if (meta && meta.victims) {
for (var v = 0; v < meta.victims.length; v++) {
var vx = t.x(meta.victims[v][0]), vy = t.y(meta.victims[v][1]);
rctx.strokeStyle = "#ff5a5a";
rctx.lineWidth = 2;
var s = 7;
rctx.beginPath();
rctx.moveTo(vx - s, vy); rctx.lineTo(vx + s, vy);
rctx.moveTo(vx, vy - s); rctx.lineTo(vx, vy + s);
rctx.stroke();
rctx.beginPath();
rctx.arc(vx, vy, s + 2, 0, Math.PI * 2);
rctx.strokeStyle = "rgba(255,90,90,0.5)";
rctx.lineWidth = 1;
rctx.stroke();
rctx.fillStyle = "#ff8a8a";
rctx.font = "10px monospace";
rctx.textAlign = "left";
rctx.fillText("victim " + v, vx + s + 4, vy - 4);
}
}
// detection pulses (expanding rings)
for (var p = pulses.length - 1; p >= 0; p--) {
var pu = pulses[p];
var px = t.x(pu.wx), py = t.y(pu.wy);
var r = 6 + pu.age * 40;
var alpha = 1 - pu.age;
if (alpha <= 0) { pulses.splice(p, 1); continue; }
rctx.beginPath();
rctx.arc(px, py, r, 0, Math.PI * 2);
rctx.strokeStyle = "rgba(67,224,122," + (alpha * 0.8).toFixed(3) + ")";
rctx.lineWidth = 2;
rctx.stroke();
}
// drones
var rec = steps[curStep];
var activeDetections = 0;
if (rec && rec.drones) {
for (var di = 0; di < rec.drones.length; di++) {
var d = rec.drones[di];
var dx = t.x(d.x), dy = t.y(d.y);
var detecting = !!d.det;
if (detecting) activeDetections++;
// oriented triangle along hdg (screen Y down => use hdg directly)
var hdg = (typeof d.hdg === "number") ? d.hdg : 0;
var size = 9;
var col = detecting ? "#b6ff3c" : "#2ee6e6";
rctx.save();
rctx.translate(dx, dy);
rctx.rotate(hdg);
rctx.beginPath();
rctx.moveTo(size, 0);
rctx.lineTo(-size * 0.7, size * 0.6);
rctx.lineTo(-size * 0.4, 0);
rctx.lineTo(-size * 0.7, -size * 0.6);
rctx.closePath();
rctx.fillStyle = col;
rctx.globalAlpha = detecting ? 1 : 0.92;
rctx.fill();
rctx.globalAlpha = 1;
if (detecting) {
rctx.strokeStyle = "rgba(182,255,60,0.9)";
rctx.lineWidth = 1;
rctx.stroke();
}
rctx.restore();
// id label
rctx.fillStyle = col;
rctx.font = "10px monospace";
rctx.textAlign = "center";
rctx.fillText(String(d.id), dx, dy - 13);
// battery bar under drone
var bw = 18, bh = 3;
var bx = dx - bw / 2, by = dy + 11;
var batt = (typeof d.batt === "number") ? Math.max(0, Math.min(100, d.batt)) : 0;
rctx.fillStyle = "rgba(255,255,255,0.12)";
rctx.fillRect(bx, by, bw, bh);
// green -> red interpolation by battery
var g = Math.round(2.24 * batt); // 0..224
var rr = Math.round(255 - 1.9 * batt); // 255..65
rctx.fillStyle = "rgb(" + rr + "," + g + ",60)";
rctx.fillRect(bx, by, bw * (batt / 100), bh);
}
}
// step readout
var cov = rec && typeof rec.coverage === "number" ? rec.coverage : 0;
var total = steps.length;
if (total === 0) {
replayReadout.innerHTML = '<span class="warn">no replay steps in telemetry</span>';
} else {
replayReadout.textContent =
"step " + (curStep + 1) + "/" + total +
" · ep " + (rec ? rec.ep : "—") +
" · t=" + (rec && typeof rec.t === "number" ? rec.t.toFixed(2) : "—") +
" · coverage " + (cov * 100).toFixed(1) + "%" +
" · active detections " + activeDetections;
}
}
// ---- Drawing: RIGHT metrics panel ----
function lineChart(x, y, w, h, title, color, values) {
// axes box
mctx.strokeStyle = "rgba(70,120,130,0.4)";
mctx.lineWidth = 1;
mctx.strokeRect(x, y, w, h);
mctx.fillStyle = color;
mctx.font = "11px monospace";
mctx.textAlign = "left";
mctx.fillText(title, x + 4, y - 5);
if (!values || values.length === 0) {
mctx.fillStyle = "#5b7178";
mctx.fillText("(no data)", x + w / 2 - 28, y + h / 2);
return;
}
var min = Infinity, max = -Infinity;
for (var i = 0; i < values.length; i++) {
var v = values[i];
if (typeof v !== "number" || !isFinite(v)) continue;
if (v < min) min = v;
if (v > max) max = v;
}
if (!isFinite(min)) { min = 0; max = 1; }
if (min === max) { min -= 1; max += 1; }
var range = max - min;
var n = values.length;
function px(i) { return x + (n === 1 ? w / 2 : (i / (n - 1)) * w); }
function py(v) { return y + h - ((v - min) / range) * h; }
// zero line if it falls within range
if (min < 0 && max > 0) {
var zy = py(0);
mctx.strokeStyle = "rgba(120,140,150,0.25)";
mctx.setLineDash([3, 3]);
mctx.beginPath(); mctx.moveTo(x, zy); mctx.lineTo(x + w, zy); mctx.stroke();
mctx.setLineDash([]);
}
// the line
mctx.strokeStyle = color;
mctx.lineWidth = 1.6;
mctx.beginPath();
var started = false;
for (var j = 0; j < n; j++) {
var vv = values[j];
if (typeof vv !== "number" || !isFinite(vv)) continue;
var X = px(j), Y = py(vv);
if (!started) { mctx.moveTo(X, Y); started = true; }
else mctx.lineTo(X, Y);
}
mctx.stroke();
// latest marker dot
var lastV = values[n - 1];
if (typeof lastV === "number" && isFinite(lastV)) {
mctx.fillStyle = color;
mctx.beginPath();
mctx.arc(px(n - 1), py(lastV), 3.2, 0, Math.PI * 2);
mctx.fill();
}
// min/max annotations
mctx.fillStyle = "#5b7178";
mctx.font = "9px monospace";
mctx.textAlign = "right";
mctx.fillText(fmtNum(max), x + w - 3, y + 10);
mctx.fillText(fmtNum(min), x + w - 3, y + h - 3);
// episode axis labels
mctx.textAlign = "left";
mctx.fillText("ep 0", x + 2, y + h + 11);
mctx.textAlign = "right";
mctx.fillText("ep " + (n - 1), x + w, y + h + 11);
}
function fmtNum(v) {
if (!isFinite(v)) return "—";
var a = Math.abs(v);
if (a >= 1000) return v.toFixed(0);
if (a >= 1) return v.toFixed(1);
return v.toFixed(3);
}
function drawMetrics() {
var W = metricsCanvas.width, H = metricsCanvas.height;
mctx.clearRect(0, 0, W, H);
mctx.fillStyle = "#04070a";
mctx.fillRect(0, 0, W, H);
// legend
mctx.font = "10px monospace";
mctx.textAlign = "left";
var legend = [["mean return", "#43e07a"], ["policy loss", "#f6a13c"], ["value loss", "#ff5a5a"]];
var lx = 14;
for (var l = 0; l < legend.length; l++) {
mctx.fillStyle = legend[l][1];
mctx.fillRect(lx, 8, 9, 9);
mctx.fillStyle = "#cfe9ec";
mctx.fillText(legend[l][0], lx + 13, 16);
lx += mctx.measureText(legend[l][0]).width + 36;
}
var ret = episodes.map(function (e) { return e.mean_return; });
var pol = episodes.map(function (e) { return e.policy_loss; });
var val = episodes.map(function (e) { return e.value_loss; });
var marginL = 14, marginR = 14, top = 38, gap = 30;
var chartW = W - marginL - marginR;
var chartH = (H - top - gap * 3) / 3;
var y0 = top;
lineChart(marginL, y0, chartW, chartH, "mean return", "#43e07a", ret);
var y1 = y0 + chartH + gap;
lineChart(marginL, y1, chartW, chartH, "policy loss", "#f6a13c", pol);
var y2 = y1 + chartH + gap;
lineChart(marginL, y2, chartW, chartH, "value loss (autoscaled)", "#ff5a5a", val);
if (episodes.length === 0) {
metricsReadout.innerHTML = '<span class="warn">no episode metrics in telemetry</span>';
} else {
var last = episodes[episodes.length - 1];
var found = 0;
for (var i = 0; i < episodes.length; i++) {
if (typeof episodes[i].victims_found === "number" && episodes[i].victims_found > found)
found = episodes[i].victims_found;
}
metricsReadout.textContent =
episodes.length + " episodes · latest ep " + last.ep +
" · return " + fmtNum(last.mean_return) +
" · policy " + fmtNum(last.policy_loss) +
" · value " + fmtNum(last.value_loss) +
" · max victims found " + found;
}
}
// ---- Playback loop ----
function frame(now) {
if (playing && steps.length > 1) {
if (!lastFrameTime) lastFrameTime = now;
var dt = (now - lastFrameTime) / 1000;
lastFrameTime = now;
var speed = parseFloat(speedSel.value) || 1;
var stepsPerSec = 6 * speed; // base playback rate
stepAccumulator += dt * stepsPerSec;
while (stepAccumulator >= 1) {
stepAccumulator -= 1;
advanceStep(1);
if (curStep >= steps.length - 1) {
curStep = steps.length - 1;
playing = false;
playBtn.textContent = "▶ Play";
break;
}
}
} else {
lastFrameTime = now;
}
// age pulses
for (var i = 0; i < pulses.length; i++) pulses[i].age += 0.03;
drawReplay();
requestAnimationFrame(frame);
}
function advanceStep(delta) {
var prev = curStep;
curStep += delta;
if (curStep < 0) curStep = 0;
if (curStep > steps.length - 1) curStep = steps.length - 1;
scrub.value = curStep;
buildCoverageUpTo(curStep);
spawnPulsesForStep(curStep);
}
function spawnPulsesForStep(idx) {
var rec = steps[idx];
if (!rec || !rec.drones) return;
for (var i = 0; i < rec.drones.length; i++) {
var d = rec.drones[i];
if (d.det) pulses.push({ wx: d.x, wy: d.y, age: 0 });
}
}
// ---- Controls wiring ----
playBtn.addEventListener("click", function () {
if (steps.length <= 1) return;
playing = !playing;
playBtn.textContent = playing ? "❚❚ Pause" : "▶ Play";
if (playing && curStep >= steps.length - 1) {
// restart from beginning
curStep = 0;
coverageGrid && coverageGrid.fill(0);
lastBuiltStep = -1;
pulses = [];
buildCoverageUpTo(0);
scrub.value = 0;
}
lastFrameTime = 0;
});
scrub.addEventListener("input", function () {
playing = false;
playBtn.textContent = "▶ Play";
curStep = parseInt(scrub.value, 10) || 0;
buildCoverageUpTo(curStep);
spawnPulsesForStep(curStep);
drawReplay();
});
speedSel.addEventListener("change", function () { lastFrameTime = 0; });
fileInput.addEventListener("change", function (ev) {
var f = ev.target.files && ev.target.files[0];
if (!f) return;
var reader = new FileReader();
reader.onload = function () { loadData(String(reader.result), f.name); };
reader.onerror = function () { loadHintEl.textContent = "could not read file"; };
reader.readAsText(f);
});
// drag & drop onto the page
window.addEventListener("dragover", function (e) { e.preventDefault(); });
window.addEventListener("drop", function (e) {
e.preventDefault();
var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (!f) return;
var reader = new FileReader();
reader.onload = function () { loadData(String(reader.result), f.name); };
reader.readAsText(f);
});
// ---- Auto-fetch bundled sample (graceful on file:// CORS failure) ----
function tryAutoFetch() {
if (typeof fetch !== "function") {
loadHintEl.textContent = "drop a .jsonl file or use the picker";
return;
}
fetch("sample_telemetry.jsonl")
.then(function (r) {
if (!r.ok) throw new Error("status " + r.status);
return r.text();
})
.then(function (text) { loadData(text, "sample_telemetry.jsonl"); })
.catch(function () {
loadHintEl.textContent = "auto-load blocked (file://) — drop a .jsonl file or use the picker";
// draw empty frames so canvases aren't blank
drawReplay();
drawMetrics();
});
}
// boot
drawReplay();
drawMetrics();
tryAutoFetch();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>