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>
This commit is contained in:
ruv 2026-05-30 12:54:15 -04:00
parent d60410326f
commit 5450bfdc60
5 changed files with 1069 additions and 1 deletions

View File

@ -12,6 +12,7 @@
//! 8× A100 box for this workload at ~1/20th the cost. See scripts/gcp/.
use ruview_swarm::config::SwarmConfig;
use ruview_swarm::integration::telemetry::{DroneFrame, TelemetryRecorder};
use ruview_swarm::marl::candle_ppo::{CandlePpoConfig, CandleTrainer};
use ruview_swarm::marl::observation::LocalObservation;
use ruview_swarm::marl::reward::{RewardCalculator, RewardContext};
@ -25,6 +26,8 @@ struct Args {
steps_per_episode: usize,
checkpoint_dir: String,
checkpoint_every: usize,
telemetry: Option<String>,
telemetry_episode: usize,
}
impl Default for Args {
@ -36,6 +39,8 @@ impl Default for Args {
steps_per_episode: 200,
checkpoint_dir: "./marl-checkpoints".to_string(),
checkpoint_every: 100,
telemetry: None,
telemetry_episode: 0,
}
}
}
@ -71,6 +76,14 @@ fn parse_args() -> Args {
args.checkpoint_every = next().parse().unwrap_or(args.checkpoint_every);
i += 1;
}
"--telemetry" => {
args.telemetry = Some(next());
i += 1;
}
"--telemetry-episode" => {
args.telemetry_episode = next().parse().unwrap_or(args.telemetry_episode);
i += 1;
}
"-h" | "--help" => {
println!(
"train_marl — ruview-swarm MARL training (ADR-148 M4)\n\
@ -80,7 +93,9 @@ fn parse_args() -> Args {
--profile NAME sar|inspection|mine|agriculture (default sar)\n \
--steps N steps per episode (default 200)\n \
--checkpoint-dir D checkpoint output dir (default ./marl-checkpoints)\n \
--checkpoint-every N save every N episodes (default 100)"
--checkpoint-every N save every N episodes (default 100)\n \
--telemetry FILE write JSONL telemetry for viz/swarm_viz.html\n \
--telemetry-episode N which episode's steps to record spatially (default 0)"
);
std::process::exit(0);
}
@ -123,6 +138,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Position3D { x: cfg.mission.area_width_m * 0.6, y: cfg.mission.area_height_m * 0.45, z: 0.0 },
];
// Optional telemetry recorder for the visualizer.
let mut telem = match &args.telemetry {
Some(path) => {
let mut rec = TelemetryRecorder::create(path)?;
rec.meta(
&args.profile,
args.drones,
cfg.mission.area_width_m,
cfg.mission.area_height_m,
&victims,
)?;
println!("telemetry → {path} (spatial steps from episode {})", args.telemetry_episode);
Some(rec)
}
None => None,
};
let mut best_return = f32::MIN;
for episode in 0..args.episodes {
@ -204,6 +236,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
value_buf.push(0.0); // bootstrap value (critic learns this)
done_buf.push(is_last);
}
// Record spatial telemetry for the selected episode only (keeps the
// log small — one episode of step frames + metrics for every episode).
if let Some(rec) = telem.as_mut() {
if episode == args.telemetry_episode {
let scan_w = cfg.planning.csi_scan_width_m;
let frames: Vec<DroneFrame> = drones
.iter()
.map(|d| {
let detected = victims
.iter()
.any(|v| d.state.position.distance_to(v) < scan_w);
DroneFrame::from_state(&d.state, detected)
})
.collect();
let coverage = drones
.iter()
.map(|d| d.probability_grid.coverage_pct())
.sum::<f64>()
/ drones.len().max(1) as f64;
let _ = rec.step(episode, step, step as f64, &frames, coverage);
}
}
}
// PPO update on the episode's rollout.
@ -223,6 +278,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
best_return = mean_return;
}
// Per-episode training-metric telemetry (every episode).
if let Some(rec) = telem.as_mut() {
let _ = rec.episode(episode, mean_return, policy_loss, value_loss, 0);
}
if episode % 10 == 0 || episode == args.episodes - 1 {
println!(
"ep {:>5}/{} mean_return={:>8.3} best={:>8.3} policy_loss={:>8.4} value_loss={:>8.4}",
@ -244,6 +304,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
if let Some(rec) = telem.as_mut() {
rec.flush()?;
if let Some(path) = &args.telemetry {
println!("telemetry written: {path} — open viz/swarm_viz.html and load it");
}
}
println!("training complete. best mean_return={best_return:.3}");
Ok(())
}

View File

@ -3,8 +3,10 @@
pub mod mavlink_messages;
pub mod mission_report;
pub mod swarm_sim;
pub mod telemetry;
pub use mission_report::{MissionReport, SotaComparison, VictimReport};
pub use telemetry::{DroneFrame, TelemetryRecorder};
pub use mavlink_messages::{
SwarmNodeState, SwarmCsiReport, SwarmClusterHeartbeat, SwarmVictimConfirmed, SwarmMsgId,

View File

@ -0,0 +1,183 @@
//! JSONL telemetry recorder for the swarm training/sim visualizer.
//!
//! Emits newline-delimited JSON records consumed by `viz/swarm_viz.html`:
//! - one `meta` record (mission profile, area, ground-truth victims)
//! - many `step` records (per-tick drone positions, coverage, detections)
//! - optional `episode` records (per-episode training metrics)
//!
//! Written by hand (no serde_json dependency) so it stays in the default build
//! and never affects the test/CI surface. The schema is flat and the only
//! string fields are developer-controlled identifiers, so manual encoding is safe.
use crate::types::{DroneState, Position3D};
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
/// Records swarm telemetry to a JSONL file for offline visualization.
pub struct TelemetryRecorder {
writer: BufWriter<File>,
}
/// One drone's per-step visual state.
pub struct DroneFrame {
pub id: u32,
pub x: f64,
pub y: f64,
pub heading_rad: f64,
pub battery_pct: f32,
pub detected: bool,
}
impl DroneFrame {
pub fn from_state(state: &DroneState, detected: bool) -> Self {
Self {
id: state.id.0,
x: state.position.x,
y: state.position.y,
heading_rad: state.heading_rad,
battery_pct: state.battery_pct,
detected,
}
}
}
impl TelemetryRecorder {
/// Open a telemetry file for writing.
pub fn create<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
let file = File::create(path)?;
Ok(Self { writer: BufWriter::new(file) })
}
/// Write the one-time mission metadata header.
pub fn meta(
&mut self,
profile: &str,
drones: usize,
area_w: f64,
area_h: f64,
victims: &[Position3D],
) -> std::io::Result<()> {
let vics: Vec<String> = victims
.iter()
.map(|v| format!("[{:.2},{:.2}]", v.x, v.y))
.collect();
writeln!(
self.writer,
r#"{{"type":"meta","profile":"{}","drones":{},"area_w":{:.2},"area_h":{:.2},"victims":[{}]}}"#,
sanitize(profile),
drones,
area_w,
area_h,
vics.join(",")
)
}
/// Write one simulation step (all drones at this tick).
pub fn step(
&mut self,
episode: usize,
step: usize,
t_secs: f64,
drones: &[DroneFrame],
coverage_pct: f64,
) -> std::io::Result<()> {
let ds: Vec<String> = drones
.iter()
.map(|d| {
format!(
r#"{{"id":{},"x":{:.2},"y":{:.2},"hdg":{:.3},"batt":{:.1},"det":{}}}"#,
d.id, d.x, d.y, d.heading_rad, d.battery_pct, d.detected
)
})
.collect();
writeln!(
self.writer,
r#"{{"type":"step","ep":{},"step":{},"t":{:.2},"coverage":{:.4},"drones":[{}]}}"#,
episode,
step,
t_secs,
coverage_pct,
ds.join(",")
)
}
/// Write one episode's training metrics.
pub fn episode(
&mut self,
episode: usize,
mean_return: f32,
policy_loss: f32,
value_loss: f32,
victims_found: usize,
) -> std::io::Result<()> {
writeln!(
self.writer,
r#"{{"type":"episode","ep":{},"mean_return":{:.4},"policy_loss":{:.4},"value_loss":{:.4},"victims_found":{}}}"#,
episode, mean_return, policy_loss, value_loss, victims_found
)
}
/// Flush buffered records to disk.
pub fn flush(&mut self) -> std::io::Result<()> {
self.writer.flush()
}
}
/// Strip characters that would break the flat JSON string field.
fn sanitize(s: &str) -> String {
s.chars().filter(|c| *c != '"' && *c != '\\' && *c != '\n').collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{NodeId, Velocity3D};
fn tmp_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(name)
}
#[test]
fn test_records_valid_jsonl() {
let path = tmp_path("ruview_telemetry_test.jsonl");
{
let mut rec = TelemetryRecorder::create(&path).unwrap();
rec.meta("sar", 2, 400.0, 400.0, &[Position3D { x: 80.0, y: 120.0, z: 0.0 }])
.unwrap();
let state = DroneState {
id: NodeId(0),
position: Position3D { x: 10.5, y: 20.25, z: -30.0 },
velocity: Velocity3D::default(),
heading_rad: 1.57,
altitude_agl_m: 30.0,
battery_pct: 88.0,
link_quality: 0.9,
timestamp_ms: 0,
};
rec.step(0, 0, 0.0, &[DroneFrame::from_state(&state, true)], 0.05)
.unwrap();
rec.episode(0, 103.7, -61.2, 12643.3, 1).unwrap();
rec.flush().unwrap();
}
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 3, "meta + step + episode = 3 records");
assert!(lines[0].contains(r#""type":"meta""#));
assert!(lines[1].contains(r#""type":"step""#));
assert!(lines[1].contains(r#""det":true"#));
assert!(lines[2].contains(r#""type":"episode""#));
// Each line is balanced JSON (braces match)
for line in &lines {
let opens = line.matches('{').count();
let closes = line.matches('}').count();
assert_eq!(opens, closes, "balanced braces in: {line}");
}
std::fs::remove_file(&path).ok();
}
#[test]
fn test_sanitize_strips_quotes() {
assert_eq!(sanitize("sa\"r\n"), "sar");
}
}

View File

@ -0,0 +1,91 @@
{"type":"meta","profile":"sar","drones":4,"area_w":400.00,"area_h":400.00,"victims":[[80.00,120.00],[240.00,180.00]]}
{"type":"episode","ep":0,"mean_return":123.6877,"policy_loss":4.3082,"value_loss":20052.5762,"victims_found":0}
{"type":"episode","ep":1,"mean_return":123.6877,"policy_loss":-35.5269,"value_loss":19947.0527,"victims_found":0}
{"type":"episode","ep":2,"mean_return":123.6877,"policy_loss":-74.4405,"value_loss":19841.1973,"victims_found":0}
{"type":"episode","ep":3,"mean_return":123.6877,"policy_loss":-116.1118,"value_loss":19727.8633,"victims_found":0}
{"type":"episode","ep":4,"mean_return":123.6877,"policy_loss":-162.6557,"value_loss":19607.3828,"victims_found":0}
{"type":"episode","ep":5,"mean_return":123.6877,"policy_loss":-215.9290,"value_loss":19478.0371,"victims_found":0}
{"type":"episode","ep":6,"mean_return":123.6877,"policy_loss":-279.5587,"value_loss":19331.7852,"victims_found":0}
{"type":"episode","ep":7,"mean_return":123.6877,"policy_loss":-357.7819,"value_loss":19170.1621,"victims_found":0}
{"type":"episode","ep":8,"mean_return":123.6877,"policy_loss":-452.8956,"value_loss":18987.3477,"victims_found":0}
{"type":"episode","ep":9,"mean_return":123.6877,"policy_loss":-565.1311,"value_loss":18776.0879,"victims_found":0}
{"type":"episode","ep":10,"mean_return":123.6877,"policy_loss":-693.7755,"value_loss":18532.1562,"victims_found":0}
{"type":"episode","ep":11,"mean_return":123.6877,"policy_loss":-840.3882,"value_loss":18255.1113,"victims_found":0}
{"type":"episode","ep":12,"mean_return":123.6877,"policy_loss":-1007.8695,"value_loss":17941.9434,"victims_found":0}
{"type":"episode","ep":13,"mean_return":123.6877,"policy_loss":-1198.8174,"value_loss":17589.8066,"victims_found":0}
{"type":"episode","ep":14,"mean_return":123.6877,"policy_loss":-1415.8530,"value_loss":17194.4316,"victims_found":0}
{"type":"episode","ep":15,"mean_return":123.6877,"policy_loss":-1662.0295,"value_loss":16753.6250,"victims_found":0}
{"type":"episode","ep":16,"mean_return":123.6877,"policy_loss":-1940.0228,"value_loss":16267.0869,"victims_found":0}
{"type":"episode","ep":17,"mean_return":123.6877,"policy_loss":-2252.8604,"value_loss":15734.1992,"victims_found":0}
{"type":"episode","ep":18,"mean_return":123.6877,"policy_loss":-2603.2175,"value_loss":15156.0713,"victims_found":0}
{"type":"episode","ep":19,"mean_return":123.6877,"policy_loss":-2993.5083,"value_loss":14534.6807,"victims_found":0}
{"type":"episode","ep":20,"mean_return":123.6877,"policy_loss":-3427.2791,"value_loss":13871.4297,"victims_found":0}
{"type":"episode","ep":21,"mean_return":123.6877,"policy_loss":-3908.3892,"value_loss":13167.9365,"victims_found":0}
{"type":"episode","ep":22,"mean_return":123.6877,"policy_loss":-4439.9995,"value_loss":12428.7100,"victims_found":0}
{"type":"episode","ep":23,"mean_return":123.6877,"policy_loss":-5023.8579,"value_loss":11662.3135,"victims_found":0}
{"type":"episode","ep":24,"mean_return":123.6877,"policy_loss":-5660.2412,"value_loss":10880.2861,"victims_found":0}
{"type":"episode","ep":25,"mean_return":123.6877,"policy_loss":-6351.8081,"value_loss":10091.8340,"victims_found":0}
{"type":"episode","ep":26,"mean_return":123.6877,"policy_loss":-7099.0146,"value_loss":9310.4336,"victims_found":0}
{"type":"episode","ep":27,"mean_return":123.6877,"policy_loss":-7901.1260,"value_loss":8551.1855,"victims_found":0}
{"type":"episode","ep":28,"mean_return":123.6877,"policy_loss":-8758.6396,"value_loss":7828.0469,"victims_found":0}
{"type":"step","ep":29,"step":0,"t":0.00,"coverage":0.0002,"drones":[{"id":0,"x":4.34,"y":4.34,"hdg":-2.356,"batt":100.0,"det":false},{"id":1,"x":202.01,"y":9.71,"hdg":-3.105,"batt":100.0,"det":false},{"id":2,"x":9.71,"y":202.01,"hdg":-1.607,"batt":100.0,"det":false},{"id":3,"x":204.34,"y":204.34,"hdg":-2.356,"batt":100.0,"det":false}]}
{"type":"step","ep":29,"step":1,"t":1.00,"coverage":0.0003,"drones":[{"id":0,"x":7.50,"y":2.50,"hdg":-0.528,"batt":99.9,"det":false},{"id":1,"x":194.01,"y":9.42,"hdg":-3.105,"batt":99.9,"det":false},{"id":2,"x":9.42,"y":194.01,"hdg":-1.607,"batt":99.9,"det":false},{"id":3,"x":198.69,"y":198.69,"hdg":-2.356,"batt":99.9,"det":false}]}
{"type":"step","ep":29,"step":2,"t":2.00,"coverage":0.0005,"drones":[{"id":0,"x":12.50,"y":2.50,"hdg":0.000,"batt":99.9,"det":false},{"id":1,"x":186.02,"y":9.13,"hdg":-3.105,"batt":99.9,"det":false},{"id":2,"x":9.13,"y":186.02,"hdg":-1.607,"batt":99.9,"det":false},{"id":3,"x":193.03,"y":193.03,"hdg":-2.356,"batt":99.9,"det":false}]}
{"type":"step","ep":29,"step":3,"t":3.00,"coverage":0.0006,"drones":[{"id":0,"x":17.50,"y":2.50,"hdg":0.000,"batt":99.9,"det":false},{"id":1,"x":178.02,"y":8.84,"hdg":-3.105,"batt":99.9,"det":false},{"id":2,"x":8.84,"y":178.02,"hdg":-1.607,"batt":99.9,"det":false},{"id":3,"x":187.37,"y":187.37,"hdg":-2.356,"batt":99.9,"det":false}]}
{"type":"step","ep":29,"step":4,"t":4.00,"coverage":0.0008,"drones":[{"id":0,"x":22.50,"y":2.50,"hdg":0.000,"batt":99.8,"det":false},{"id":1,"x":170.03,"y":8.56,"hdg":-3.105,"batt":99.8,"det":false},{"id":2,"x":8.56,"y":170.03,"hdg":-1.607,"batt":99.8,"det":false},{"id":3,"x":181.72,"y":181.72,"hdg":-2.356,"batt":99.8,"det":false}]}
{"type":"step","ep":29,"step":5,"t":5.00,"coverage":0.0009,"drones":[{"id":0,"x":27.50,"y":2.50,"hdg":0.000,"batt":99.8,"det":false},{"id":1,"x":162.03,"y":8.27,"hdg":-3.105,"batt":99.8,"det":false},{"id":2,"x":8.27,"y":162.03,"hdg":-1.607,"batt":99.8,"det":false},{"id":3,"x":176.06,"y":176.06,"hdg":-2.356,"batt":99.8,"det":false}]}
{"type":"step","ep":29,"step":6,"t":6.00,"coverage":0.0011,"drones":[{"id":0,"x":32.50,"y":2.50,"hdg":0.000,"batt":99.8,"det":false},{"id":1,"x":154.04,"y":7.98,"hdg":-3.105,"batt":99.8,"det":false},{"id":2,"x":7.98,"y":154.04,"hdg":-1.607,"batt":99.8,"det":false},{"id":3,"x":170.40,"y":170.40,"hdg":-2.356,"batt":99.8,"det":false}]}
{"type":"step","ep":29,"step":7,"t":7.00,"coverage":0.0013,"drones":[{"id":0,"x":37.50,"y":2.50,"hdg":0.000,"batt":99.7,"det":false},{"id":1,"x":146.04,"y":7.69,"hdg":-3.105,"batt":99.7,"det":false},{"id":2,"x":7.69,"y":146.04,"hdg":-1.607,"batt":99.7,"det":false},{"id":3,"x":164.75,"y":164.75,"hdg":-2.356,"batt":99.7,"det":false}]}
{"type":"step","ep":29,"step":8,"t":8.00,"coverage":0.0014,"drones":[{"id":0,"x":42.50,"y":2.50,"hdg":0.000,"batt":99.7,"det":false},{"id":1,"x":138.05,"y":7.40,"hdg":-3.105,"batt":99.7,"det":false},{"id":2,"x":7.40,"y":138.05,"hdg":-1.607,"batt":99.7,"det":false},{"id":3,"x":159.09,"y":159.09,"hdg":-2.356,"batt":99.7,"det":false}]}
{"type":"step","ep":29,"step":9,"t":9.00,"coverage":0.0016,"drones":[{"id":0,"x":47.50,"y":2.50,"hdg":0.000,"batt":99.7,"det":false},{"id":1,"x":130.05,"y":7.11,"hdg":-3.105,"batt":99.7,"det":false},{"id":2,"x":7.11,"y":130.05,"hdg":-1.607,"batt":99.7,"det":false},{"id":3,"x":153.43,"y":153.43,"hdg":-2.356,"batt":99.7,"det":false}]}
{"type":"step","ep":29,"step":10,"t":10.00,"coverage":0.0017,"drones":[{"id":0,"x":52.50,"y":2.50,"hdg":0.000,"batt":99.6,"det":false},{"id":1,"x":122.06,"y":6.82,"hdg":-3.105,"batt":99.6,"det":false},{"id":2,"x":6.82,"y":122.06,"hdg":-1.607,"batt":99.6,"det":false},{"id":3,"x":147.77,"y":147.77,"hdg":-2.356,"batt":99.6,"det":false}]}
{"type":"step","ep":29,"step":11,"t":11.00,"coverage":0.0019,"drones":[{"id":0,"x":57.50,"y":2.50,"hdg":0.000,"batt":99.6,"det":false},{"id":1,"x":114.06,"y":6.53,"hdg":-3.105,"batt":99.6,"det":false},{"id":2,"x":6.53,"y":114.06,"hdg":-1.607,"batt":99.6,"det":false},{"id":3,"x":142.12,"y":142.12,"hdg":-2.356,"batt":99.6,"det":false}]}
{"type":"step","ep":29,"step":12,"t":12.00,"coverage":0.0020,"drones":[{"id":0,"x":62.50,"y":2.50,"hdg":0.000,"batt":99.6,"det":false},{"id":1,"x":106.07,"y":6.24,"hdg":-3.105,"batt":99.6,"det":false},{"id":2,"x":6.24,"y":106.07,"hdg":-1.607,"batt":99.6,"det":false},{"id":3,"x":136.46,"y":136.46,"hdg":-2.356,"batt":99.6,"det":false}]}
{"type":"step","ep":29,"step":13,"t":13.00,"coverage":0.0022,"drones":[{"id":0,"x":67.50,"y":2.50,"hdg":0.000,"batt":99.5,"det":false},{"id":1,"x":98.07,"y":5.95,"hdg":-3.105,"batt":99.5,"det":false},{"id":2,"x":5.95,"y":98.07,"hdg":-1.607,"batt":99.5,"det":false},{"id":3,"x":130.80,"y":130.80,"hdg":-2.356,"batt":99.5,"det":false}]}
{"type":"step","ep":29,"step":14,"t":14.00,"coverage":0.0023,"drones":[{"id":0,"x":72.50,"y":2.50,"hdg":0.000,"batt":99.5,"det":false},{"id":1,"x":90.08,"y":5.67,"hdg":-3.105,"batt":99.5,"det":false},{"id":2,"x":5.67,"y":90.08,"hdg":-1.607,"batt":99.5,"det":false},{"id":3,"x":125.15,"y":125.15,"hdg":-2.356,"batt":99.5,"det":false}]}
{"type":"step","ep":29,"step":15,"t":15.00,"coverage":0.0025,"drones":[{"id":0,"x":77.50,"y":2.50,"hdg":0.000,"batt":99.5,"det":false},{"id":1,"x":82.08,"y":5.38,"hdg":-3.105,"batt":99.5,"det":false},{"id":2,"x":5.38,"y":82.08,"hdg":-1.607,"batt":99.5,"det":false},{"id":3,"x":119.49,"y":119.49,"hdg":-2.356,"batt":99.5,"det":false}]}
{"type":"step","ep":29,"step":16,"t":16.00,"coverage":0.0027,"drones":[{"id":0,"x":82.50,"y":2.50,"hdg":0.000,"batt":99.4,"det":false},{"id":1,"x":74.09,"y":5.09,"hdg":-3.105,"batt":99.4,"det":false},{"id":2,"x":5.09,"y":74.09,"hdg":-1.607,"batt":99.4,"det":false},{"id":3,"x":113.83,"y":113.83,"hdg":-2.356,"batt":99.4,"det":false}]}
{"type":"step","ep":29,"step":17,"t":17.00,"coverage":0.0028,"drones":[{"id":0,"x":87.50,"y":2.50,"hdg":0.000,"batt":99.4,"det":false},{"id":1,"x":66.09,"y":4.80,"hdg":-3.105,"batt":99.4,"det":false},{"id":2,"x":4.80,"y":66.09,"hdg":-1.607,"batt":99.4,"det":false},{"id":3,"x":108.18,"y":108.18,"hdg":-2.356,"batt":99.4,"det":false}]}
{"type":"step","ep":29,"step":18,"t":18.00,"coverage":0.0030,"drones":[{"id":0,"x":92.50,"y":2.50,"hdg":0.000,"batt":99.4,"det":false},{"id":1,"x":58.10,"y":4.51,"hdg":-3.105,"batt":99.4,"det":false},{"id":2,"x":4.51,"y":58.10,"hdg":-1.607,"batt":99.4,"det":false},{"id":3,"x":102.52,"y":102.52,"hdg":-2.356,"batt":99.4,"det":false}]}
{"type":"step","ep":29,"step":19,"t":19.00,"coverage":0.0031,"drones":[{"id":0,"x":97.50,"y":2.50,"hdg":0.000,"batt":99.3,"det":false},{"id":1,"x":50.10,"y":4.22,"hdg":-3.105,"batt":99.3,"det":false},{"id":2,"x":4.22,"y":50.10,"hdg":-1.607,"batt":99.3,"det":false},{"id":3,"x":96.86,"y":96.86,"hdg":-2.356,"batt":99.3,"det":false}]}
{"type":"step","ep":29,"step":20,"t":20.00,"coverage":0.0033,"drones":[{"id":0,"x":102.50,"y":2.50,"hdg":0.000,"batt":99.3,"det":false},{"id":1,"x":42.11,"y":3.93,"hdg":-3.105,"batt":99.3,"det":false},{"id":2,"x":3.93,"y":42.11,"hdg":-1.607,"batt":99.3,"det":false},{"id":3,"x":91.21,"y":91.21,"hdg":-2.356,"batt":99.3,"det":false}]}
{"type":"step","ep":29,"step":21,"t":21.00,"coverage":0.0034,"drones":[{"id":0,"x":107.50,"y":2.50,"hdg":0.000,"batt":99.3,"det":false},{"id":1,"x":34.11,"y":3.64,"hdg":-3.105,"batt":99.3,"det":false},{"id":2,"x":3.64,"y":34.11,"hdg":-1.607,"batt":99.3,"det":false},{"id":3,"x":85.55,"y":85.55,"hdg":-2.356,"batt":99.3,"det":false}]}
{"type":"step","ep":29,"step":22,"t":22.00,"coverage":0.0036,"drones":[{"id":0,"x":112.50,"y":2.50,"hdg":0.000,"batt":99.2,"det":false},{"id":1,"x":26.12,"y":3.35,"hdg":-3.105,"batt":99.2,"det":false},{"id":2,"x":3.35,"y":26.12,"hdg":-1.607,"batt":99.2,"det":false},{"id":3,"x":79.89,"y":79.89,"hdg":-2.356,"batt":99.2,"det":false}]}
{"type":"step","ep":29,"step":23,"t":23.00,"coverage":0.0037,"drones":[{"id":0,"x":117.50,"y":2.50,"hdg":0.000,"batt":99.2,"det":false},{"id":1,"x":18.13,"y":3.06,"hdg":-3.105,"batt":99.2,"det":false},{"id":2,"x":3.06,"y":18.13,"hdg":-1.607,"batt":99.2,"det":false},{"id":3,"x":74.24,"y":74.24,"hdg":-2.356,"batt":99.2,"det":false}]}
{"type":"step","ep":29,"step":24,"t":24.00,"coverage":0.0039,"drones":[{"id":0,"x":122.50,"y":2.50,"hdg":0.000,"batt":99.2,"det":false},{"id":1,"x":10.13,"y":2.78,"hdg":-3.105,"batt":99.2,"det":false},{"id":2,"x":2.78,"y":10.13,"hdg":-1.607,"batt":99.2,"det":false},{"id":3,"x":68.58,"y":68.58,"hdg":-2.356,"batt":99.2,"det":false}]}
{"type":"step","ep":29,"step":25,"t":25.00,"coverage":0.0041,"drones":[{"id":0,"x":127.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":1,"x":2.50,"y":2.50,"hdg":-3.105,"batt":99.1,"det":false},{"id":2,"x":2.50,"y":2.50,"hdg":-1.607,"batt":99.1,"det":false},{"id":3,"x":62.92,"y":62.92,"hdg":-2.356,"batt":99.1,"det":false}]}
{"type":"step","ep":29,"step":26,"t":26.00,"coverage":0.0042,"drones":[{"id":0,"x":132.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":1,"x":7.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":2,"x":7.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":3,"x":57.26,"y":57.26,"hdg":-2.356,"batt":99.1,"det":false}]}
{"type":"step","ep":29,"step":27,"t":27.00,"coverage":0.0044,"drones":[{"id":0,"x":137.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":1,"x":15.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":2,"x":12.50,"y":2.50,"hdg":0.000,"batt":99.1,"det":false},{"id":3,"x":51.61,"y":51.61,"hdg":-2.356,"batt":99.1,"det":false}]}
{"type":"step","ep":29,"step":28,"t":28.00,"coverage":0.0045,"drones":[{"id":0,"x":142.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":1,"x":22.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":2,"x":17.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":3,"x":45.95,"y":45.95,"hdg":-2.356,"batt":99.0,"det":false}]}
{"type":"step","ep":29,"step":29,"t":29.00,"coverage":0.0046,"drones":[{"id":0,"x":147.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":1,"x":30.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":2,"x":22.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":3,"x":40.29,"y":40.29,"hdg":-2.356,"batt":99.0,"det":false}]}
{"type":"step","ep":29,"step":30,"t":30.00,"coverage":0.0048,"drones":[{"id":0,"x":152.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":1,"x":37.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":2,"x":27.50,"y":2.50,"hdg":0.000,"batt":99.0,"det":false},{"id":3,"x":34.64,"y":34.64,"hdg":-2.356,"batt":99.0,"det":false}]}
{"type":"step","ep":29,"step":31,"t":31.00,"coverage":0.0049,"drones":[{"id":0,"x":157.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":1,"x":45.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":2,"x":32.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":3,"x":28.98,"y":28.98,"hdg":-2.356,"batt":98.9,"det":false}]}
{"type":"step","ep":29,"step":32,"t":32.00,"coverage":0.0051,"drones":[{"id":0,"x":162.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":1,"x":53.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":2,"x":37.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":3,"x":23.32,"y":23.32,"hdg":-2.356,"batt":98.9,"det":false}]}
{"type":"step","ep":29,"step":33,"t":33.00,"coverage":0.0052,"drones":[{"id":0,"x":167.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":1,"x":61.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":2,"x":42.50,"y":2.50,"hdg":0.000,"batt":98.9,"det":false},{"id":3,"x":17.67,"y":17.67,"hdg":-2.356,"batt":98.9,"det":false}]}
{"type":"step","ep":29,"step":34,"t":34.00,"coverage":0.0054,"drones":[{"id":0,"x":172.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":1,"x":69.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":2,"x":47.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":3,"x":12.01,"y":12.01,"hdg":-2.356,"batt":98.8,"det":false}]}
{"type":"step","ep":29,"step":35,"t":35.00,"coverage":0.0055,"drones":[{"id":0,"x":177.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":1,"x":72.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":2,"x":52.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":3,"x":6.35,"y":6.35,"hdg":-2.356,"batt":98.8,"det":false}]}
{"type":"step","ep":29,"step":36,"t":36.00,"coverage":0.0056,"drones":[{"id":0,"x":182.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":1,"x":77.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":2,"x":57.50,"y":2.50,"hdg":0.000,"batt":98.8,"det":false},{"id":3,"x":2.50,"y":2.50,"hdg":-2.356,"batt":98.8,"det":false}]}
{"type":"step","ep":29,"step":37,"t":37.00,"coverage":0.0058,"drones":[{"id":0,"x":187.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":1,"x":82.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":2,"x":62.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":3,"x":7.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false}]}
{"type":"step","ep":29,"step":38,"t":38.00,"coverage":0.0059,"drones":[{"id":0,"x":192.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":1,"x":87.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":2,"x":67.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":3,"x":12.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false}]}
{"type":"step","ep":29,"step":39,"t":39.00,"coverage":0.0061,"drones":[{"id":0,"x":197.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":1,"x":92.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":2,"x":72.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false},{"id":3,"x":17.50,"y":2.50,"hdg":0.000,"batt":98.7,"det":false}]}
{"type":"step","ep":29,"step":40,"t":40.00,"coverage":0.0062,"drones":[{"id":0,"x":202.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":1,"x":97.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":2,"x":77.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":3,"x":22.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false}]}
{"type":"step","ep":29,"step":41,"t":41.00,"coverage":0.0064,"drones":[{"id":0,"x":207.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":1,"x":102.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":2,"x":82.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":3,"x":27.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false}]}
{"type":"step","ep":29,"step":42,"t":42.00,"coverage":0.0066,"drones":[{"id":0,"x":212.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":1,"x":107.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":2,"x":87.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false},{"id":3,"x":32.50,"y":2.50,"hdg":0.000,"batt":98.6,"det":false}]}
{"type":"step","ep":29,"step":43,"t":43.00,"coverage":0.0067,"drones":[{"id":0,"x":217.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":1,"x":112.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":2,"x":92.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":3,"x":37.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false}]}
{"type":"step","ep":29,"step":44,"t":44.00,"coverage":0.0069,"drones":[{"id":0,"x":222.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":1,"x":117.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":2,"x":97.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":3,"x":42.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false}]}
{"type":"step","ep":29,"step":45,"t":45.00,"coverage":0.0070,"drones":[{"id":0,"x":227.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":1,"x":122.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":2,"x":102.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false},{"id":3,"x":47.50,"y":2.50,"hdg":0.000,"batt":98.5,"det":false}]}
{"type":"step","ep":29,"step":46,"t":46.00,"coverage":0.0072,"drones":[{"id":0,"x":232.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":1,"x":127.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":2,"x":107.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":3,"x":52.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false}]}
{"type":"step","ep":29,"step":47,"t":47.00,"coverage":0.0073,"drones":[{"id":0,"x":237.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":1,"x":132.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":2,"x":112.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":3,"x":57.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false}]}
{"type":"step","ep":29,"step":48,"t":48.00,"coverage":0.0075,"drones":[{"id":0,"x":242.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":1,"x":137.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":2,"x":117.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false},{"id":3,"x":62.50,"y":2.50,"hdg":0.000,"batt":98.4,"det":false}]}
{"type":"step","ep":29,"step":49,"t":49.00,"coverage":0.0077,"drones":[{"id":0,"x":247.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":1,"x":142.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":2,"x":122.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":3,"x":67.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false}]}
{"type":"step","ep":29,"step":50,"t":50.00,"coverage":0.0078,"drones":[{"id":0,"x":252.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":1,"x":147.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":2,"x":127.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":3,"x":72.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false}]}
{"type":"step","ep":29,"step":51,"t":51.00,"coverage":0.0080,"drones":[{"id":0,"x":257.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":1,"x":152.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":2,"x":132.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false},{"id":3,"x":77.50,"y":2.50,"hdg":0.000,"batt":98.3,"det":false}]}
{"type":"step","ep":29,"step":52,"t":52.00,"coverage":0.0081,"drones":[{"id":0,"x":262.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":1,"x":157.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":2,"x":137.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":3,"x":82.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false}]}
{"type":"step","ep":29,"step":53,"t":53.00,"coverage":0.0083,"drones":[{"id":0,"x":267.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":1,"x":162.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":2,"x":142.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":3,"x":87.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false}]}
{"type":"step","ep":29,"step":54,"t":54.00,"coverage":0.0084,"drones":[{"id":0,"x":272.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":1,"x":167.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":2,"x":147.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false},{"id":3,"x":92.50,"y":2.50,"hdg":0.000,"batt":98.2,"det":false}]}
{"type":"step","ep":29,"step":55,"t":55.00,"coverage":0.0086,"drones":[{"id":0,"x":277.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":1,"x":172.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":2,"x":152.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":3,"x":97.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false}]}
{"type":"step","ep":29,"step":56,"t":56.00,"coverage":0.0087,"drones":[{"id":0,"x":282.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":1,"x":177.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":2,"x":157.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":3,"x":102.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false}]}
{"type":"step","ep":29,"step":57,"t":57.00,"coverage":0.0089,"drones":[{"id":0,"x":287.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":1,"x":182.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":2,"x":162.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false},{"id":3,"x":107.50,"y":2.50,"hdg":0.000,"batt":98.1,"det":false}]}
{"type":"step","ep":29,"step":58,"t":58.00,"coverage":0.0091,"drones":[{"id":0,"x":292.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":1,"x":187.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":2,"x":167.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":3,"x":112.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false}]}
{"type":"step","ep":29,"step":59,"t":59.00,"coverage":0.0092,"drones":[{"id":0,"x":297.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":1,"x":192.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":2,"x":172.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false},{"id":3,"x":117.50,"y":2.50,"hdg":0.000,"batt":98.0,"det":false}]}
{"type":"episode","ep":29,"mean_return":123.6877,"policy_loss":-9671.3232,"value_loss":7154.7334,"victims_found":0}

View File

@ -0,0 +1,725 @@
<!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>