726 lines
24 KiB
HTML
726 lines
24 KiB
HTML
<!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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|