658 lines
20 KiB
HTML
658 lines
20 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="en">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<title>Replay Viewer</title>
|
|||
|
|
<style>
|
|||
|
|
:root {
|
|||
|
|
--bg: #0f1117;
|
|||
|
|
--panel: #13151c;
|
|||
|
|
--panel2: #161822;
|
|||
|
|
--border: #2a2d3e;
|
|||
|
|
--text: #e0e0e0;
|
|||
|
|
--dim: #8a8fa3;
|
|||
|
|
--accent: #4a90e2;
|
|||
|
|
--notable: #ffb74d;
|
|||
|
|
--error: #f44336;
|
|||
|
|
}
|
|||
|
|
* { box-sizing: border-box; }
|
|||
|
|
html, body { margin: 0; padding: 0; }
|
|||
|
|
body {
|
|||
|
|
background: var(--bg);
|
|||
|
|
color: var(--text);
|
|||
|
|
font: 13px/1.4 Menlo, Consolas, monospace;
|
|||
|
|
height: 100vh;
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 220px 1fr 380px;
|
|||
|
|
grid-template-rows: 52px 1fr;
|
|||
|
|
grid-template-areas:
|
|||
|
|
"toolbar toolbar toolbar"
|
|||
|
|
"rail stage stream";
|
|||
|
|
}
|
|||
|
|
.toolbar {
|
|||
|
|
grid-area: toolbar;
|
|||
|
|
display: flex;
|
|||
|
|
gap: 12px;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 0 16px;
|
|||
|
|
background: var(--panel2);
|
|||
|
|
border-bottom: 1px solid var(--border);
|
|||
|
|
}
|
|||
|
|
.toolbar button {
|
|||
|
|
background: var(--bg);
|
|||
|
|
color: var(--text);
|
|||
|
|
border: 1px solid var(--border);
|
|||
|
|
padding: 6px 12px;
|
|||
|
|
font: inherit;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
}
|
|||
|
|
.toolbar button:hover { background: #1c1f2b; }
|
|||
|
|
.toolbar button.primary { background: var(--accent); border-color: var(--accent); color: white; }
|
|||
|
|
.toolbar select {
|
|||
|
|
background: var(--bg);
|
|||
|
|
color: var(--text);
|
|||
|
|
border: 1px solid var(--border);
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
font: inherit;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
}
|
|||
|
|
.toolbar input[type=range] { flex: 1; min-width: 200px; }
|
|||
|
|
.toolbar .now { color: var(--dim); min-width: 100px; text-align: right; font-variant-numeric: tabular-nums; }
|
|||
|
|
.toolbar .run-id { color: var(--dim); margin-left: 8px; font-size: 11px; }
|
|||
|
|
|
|||
|
|
.rail {
|
|||
|
|
grid-area: rail;
|
|||
|
|
background: var(--panel);
|
|||
|
|
border-right: 1px solid var(--border);
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding: 12px;
|
|||
|
|
}
|
|||
|
|
.rail h3 {
|
|||
|
|
font-size: 11px;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.08em;
|
|||
|
|
color: var(--dim);
|
|||
|
|
margin: 12px 0 6px 0;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
.rail h3:first-child { margin-top: 0; }
|
|||
|
|
.chip {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
padding: 3px 6px;
|
|||
|
|
margin: 2px 0;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 11px;
|
|||
|
|
user-select: none;
|
|||
|
|
}
|
|||
|
|
.chip:hover { background: #1c1f2b; }
|
|||
|
|
.chip.off { opacity: 0.35; }
|
|||
|
|
.chip .swatch {
|
|||
|
|
width: 10px;
|
|||
|
|
height: 10px;
|
|||
|
|
border-radius: 2px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.chip .count { color: var(--dim); margin-left: auto; font-size: 10px; }
|
|||
|
|
.chip .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|||
|
|
|
|||
|
|
.sev-floor {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 4px;
|
|||
|
|
margin-top: 4px;
|
|||
|
|
}
|
|||
|
|
.sev-floor button {
|
|||
|
|
flex: 1;
|
|||
|
|
background: var(--bg);
|
|||
|
|
color: var(--dim);
|
|||
|
|
border: 1px solid var(--border);
|
|||
|
|
padding: 4px 6px;
|
|||
|
|
font: inherit;
|
|||
|
|
font-size: 10px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border-radius: 2px;
|
|||
|
|
}
|
|||
|
|
.sev-floor button.active { background: var(--accent); color: white; border-color: var(--accent); }
|
|||
|
|
|
|||
|
|
#stage {
|
|||
|
|
grid-area: stage;
|
|||
|
|
position: relative;
|
|||
|
|
overflow: hidden;
|
|||
|
|
background: var(--bg);
|
|||
|
|
}
|
|||
|
|
#swim { width: 100%; height: 100%; display: block; cursor: grab; }
|
|||
|
|
#swim.dragging { cursor: grabbing; }
|
|||
|
|
#stage .tooltip {
|
|||
|
|
position: absolute;
|
|||
|
|
background: var(--panel2);
|
|||
|
|
border: 1px solid var(--border);
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
font-size: 11px;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
pointer-events: none;
|
|||
|
|
display: none;
|
|||
|
|
max-width: 280px;
|
|||
|
|
z-index: 10;
|
|||
|
|
}
|
|||
|
|
#stage .tooltip .k { color: var(--dim); }
|
|||
|
|
|
|||
|
|
.stream {
|
|||
|
|
grid-area: stream;
|
|||
|
|
background: var(--panel);
|
|||
|
|
border-left: 1px solid var(--border);
|
|||
|
|
overflow-y: auto;
|
|||
|
|
font-size: 11px;
|
|||
|
|
}
|
|||
|
|
.stream .header {
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
color: var(--dim);
|
|||
|
|
border-bottom: 1px solid var(--border);
|
|||
|
|
background: var(--panel2);
|
|||
|
|
position: sticky;
|
|||
|
|
top: 0;
|
|||
|
|
z-index: 1;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
.stream .header .stick {
|
|||
|
|
margin-left: auto;
|
|||
|
|
font-size: 10px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: var(--dim);
|
|||
|
|
}
|
|||
|
|
.stream .header .stick.on { color: var(--accent); }
|
|||
|
|
.evt {
|
|||
|
|
padding: 6px 10px;
|
|||
|
|
border-bottom: 1px solid #1f2230;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border-left: 3px solid transparent;
|
|||
|
|
}
|
|||
|
|
.evt:hover { background: #1c1f2b; }
|
|||
|
|
.evt.active { background: #1e2434; }
|
|||
|
|
.evt.dimmed { opacity: 0.3; }
|
|||
|
|
.evt .meta {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 8px;
|
|||
|
|
align-items: baseline;
|
|||
|
|
font-size: 10px;
|
|||
|
|
color: var(--dim);
|
|||
|
|
}
|
|||
|
|
.evt .t { font-variant-numeric: tabular-nums; color: var(--accent); }
|
|||
|
|
.evt .node { color: var(--text); }
|
|||
|
|
.evt .kind { color: var(--text); font-weight: 600; }
|
|||
|
|
.evt pre {
|
|||
|
|
margin: 4px 0 0 0;
|
|||
|
|
color: #b8bcd0;
|
|||
|
|
font-size: 10px;
|
|||
|
|
white-space: pre-wrap;
|
|||
|
|
word-break: break-all;
|
|||
|
|
max-height: 8em;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
.sev-info { border-left-color: var(--accent); }
|
|||
|
|
.sev-notable { border-left-color: var(--notable); }
|
|||
|
|
.sev-error { border-left-color: var(--error); }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="toolbar">
|
|||
|
|
<button id="play" class="primary">▶ Play</button>
|
|||
|
|
<label style="color: var(--dim); font-size: 11px;">speed</label>
|
|||
|
|
<select id="speed">
|
|||
|
|
<option value="0.25">0.25×</option>
|
|||
|
|
<option value="1">1×</option>
|
|||
|
|
<option value="4" selected>4×</option>
|
|||
|
|
<option value="16">16×</option>
|
|||
|
|
<option value="64">64×</option>
|
|||
|
|
<option value="instant">instant</option>
|
|||
|
|
</select>
|
|||
|
|
<input id="scrub" type="range" min="0" max="10000" value="0">
|
|||
|
|
<span class="now" id="now">0.0 s</span>
|
|||
|
|
<span class="run-id" id="run-id"></span>
|
|||
|
|
</div>
|
|||
|
|
<aside class="rail">
|
|||
|
|
<h3>Nodes</h3>
|
|||
|
|
<div id="nodes"></div>
|
|||
|
|
<h3>Severity floor</h3>
|
|||
|
|
<div class="sev-floor" id="sev-floor">
|
|||
|
|
<button data-sev="info" class="active">info</button>
|
|||
|
|
<button data-sev="notable">notable</button>
|
|||
|
|
<button data-sev="error">error</button>
|
|||
|
|
</div>
|
|||
|
|
<h3>Event kinds</h3>
|
|||
|
|
<div id="kinds"></div>
|
|||
|
|
</aside>
|
|||
|
|
<main id="stage">
|
|||
|
|
<svg id="swim" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none"></svg>
|
|||
|
|
<div class="tooltip" id="tooltip"></div>
|
|||
|
|
</main>
|
|||
|
|
<aside class="stream">
|
|||
|
|
<div class="header">
|
|||
|
|
<span>raw event stream</span>
|
|||
|
|
<span id="stream-count"></span>
|
|||
|
|
<span class="stick on" id="stick">⤓ follow</span>
|
|||
|
|
</div>
|
|||
|
|
<div id="stream-list"></div>
|
|||
|
|
</aside>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
"use strict";
|
|||
|
|
|
|||
|
|
const SEV_ORDER = { info: 0, notable: 1, error: 2 };
|
|||
|
|
|
|||
|
|
const state = {
|
|||
|
|
bundle: null, // BundleView from /api/bundle
|
|||
|
|
playhead: 0, // ms (timeline space, 0 = bundle t_start)
|
|||
|
|
playing: false,
|
|||
|
|
speed: 4,
|
|||
|
|
lastFrameWall: 0,
|
|||
|
|
view: { x: 0, w: 0 }, // x = leftmost t_ms shown, w = visible width in t_ms
|
|||
|
|
lane: { count: 0, h: 0, top: 24 },
|
|||
|
|
hiddenNodes: new Set(),
|
|||
|
|
hiddenKinds: new Set(),
|
|||
|
|
sevFloor: 'info',
|
|||
|
|
followStream: true,
|
|||
|
|
laneRows: new Map(), // node_label -> { y_center }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|||
|
|
const svg = document.getElementById('swim');
|
|||
|
|
const stage = document.getElementById('stage');
|
|||
|
|
const tooltip = document.getElementById('tooltip');
|
|||
|
|
const streamList = document.getElementById('stream-list');
|
|||
|
|
const streamCount = document.getElementById('stream-count');
|
|||
|
|
const playBtn = document.getElementById('play');
|
|||
|
|
const scrub = document.getElementById('scrub');
|
|||
|
|
const nowLabel = document.getElementById('now');
|
|||
|
|
const speedSel = document.getElementById('speed');
|
|||
|
|
const stickBtn = document.getElementById('stick');
|
|||
|
|
|
|||
|
|
async function load() {
|
|||
|
|
const r = await fetch('/api/bundle');
|
|||
|
|
const b = await r.json();
|
|||
|
|
state.bundle = b;
|
|||
|
|
document.getElementById('run-id').textContent =
|
|||
|
|
`${b.run_id} · ${b.source_kind} · ${b.nodes.length} nodes · ${b.events.length} events`;
|
|||
|
|
state.view.x = 0;
|
|||
|
|
state.view.w = Math.max(b.t_end_ms, 1000);
|
|||
|
|
scrub.max = Math.max(b.t_end_ms, 1);
|
|||
|
|
buildRail();
|
|||
|
|
layoutSvg();
|
|||
|
|
renderSwim();
|
|||
|
|
renderStream();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildRail() {
|
|||
|
|
const nodesEl = document.getElementById('nodes');
|
|||
|
|
nodesEl.innerHTML = '';
|
|||
|
|
for (const n of state.bundle.nodes) {
|
|||
|
|
const chip = document.createElement('div');
|
|||
|
|
chip.className = 'chip';
|
|||
|
|
chip.dataset.label = n.label;
|
|||
|
|
chip.innerHTML = `<span class="swatch" style="background:${n.color}"></span>` +
|
|||
|
|
`<span class="lbl">${n.label}</span>` +
|
|||
|
|
`<span class="count">${n.role ?? ''}</span>`;
|
|||
|
|
chip.onclick = () => {
|
|||
|
|
if (state.hiddenNodes.has(n.label)) state.hiddenNodes.delete(n.label);
|
|||
|
|
else state.hiddenNodes.add(n.label);
|
|||
|
|
chip.classList.toggle('off', state.hiddenNodes.has(n.label));
|
|||
|
|
renderSwim();
|
|||
|
|
renderStream();
|
|||
|
|
};
|
|||
|
|
nodesEl.appendChild(chip);
|
|||
|
|
}
|
|||
|
|
const kindsEl = document.getElementById('kinds');
|
|||
|
|
kindsEl.innerHTML = '';
|
|||
|
|
for (const k of state.bundle.event_kinds) {
|
|||
|
|
const chip = document.createElement('div');
|
|||
|
|
chip.className = 'chip';
|
|||
|
|
chip.innerHTML = `<span class="swatch" style="background:${k.color}"></span>` +
|
|||
|
|
`<span class="lbl">${k.kind}</span>` +
|
|||
|
|
`<span class="count">${k.count}</span>`;
|
|||
|
|
chip.onclick = () => {
|
|||
|
|
if (state.hiddenKinds.has(k.kind)) state.hiddenKinds.delete(k.kind);
|
|||
|
|
else state.hiddenKinds.add(k.kind);
|
|||
|
|
chip.classList.toggle('off', state.hiddenKinds.has(k.kind));
|
|||
|
|
renderSwim();
|
|||
|
|
renderStream();
|
|||
|
|
};
|
|||
|
|
kindsEl.appendChild(chip);
|
|||
|
|
}
|
|||
|
|
const floor = document.getElementById('sev-floor');
|
|||
|
|
floor.querySelectorAll('button').forEach(b => {
|
|||
|
|
b.onclick = () => {
|
|||
|
|
state.sevFloor = b.dataset.sev;
|
|||
|
|
floor.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
|
|||
|
|
renderSwim();
|
|||
|
|
renderStream();
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function layoutSvg() {
|
|||
|
|
const rect = stage.getBoundingClientRect();
|
|||
|
|
const w = Math.max(rect.width, 100);
|
|||
|
|
const h = Math.max(rect.height, 100);
|
|||
|
|
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|||
|
|
svg.setAttribute('width', w);
|
|||
|
|
svg.setAttribute('height', h);
|
|||
|
|
const visible = state.bundle.nodes.filter(n => !state.hiddenNodes.has(n.label));
|
|||
|
|
state.lane.count = visible.length;
|
|||
|
|
state.lane.top = 28;
|
|||
|
|
const usable = h - state.lane.top - 12;
|
|||
|
|
state.lane.h = state.lane.count > 0 ? usable / state.lane.count : usable;
|
|||
|
|
state.laneRows.clear();
|
|||
|
|
visible.forEach((n, i) => {
|
|||
|
|
state.laneRows.set(n.label, {
|
|||
|
|
yCenter: state.lane.top + state.lane.h * (i + 0.5),
|
|||
|
|
color: n.color,
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function tToX(t_ms, w) {
|
|||
|
|
return ((t_ms - state.view.x) / state.view.w) * w;
|
|||
|
|
}
|
|||
|
|
function xToT(x, w) {
|
|||
|
|
return state.view.x + (x / w) * state.view.w;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function severityPass(sev) {
|
|||
|
|
return SEV_ORDER[sev] >= SEV_ORDER[state.sevFloor];
|
|||
|
|
}
|
|||
|
|
function eventVisible(ev) {
|
|||
|
|
if (state.hiddenNodes.has(ev.node_label)) return false;
|
|||
|
|
if (state.hiddenKinds.has(ev.kind)) return false;
|
|||
|
|
if (!severityPass(ev.severity)) return false;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function kindColor(kind) {
|
|||
|
|
const ki = state.bundle.event_kinds.find(k => k.kind === kind);
|
|||
|
|
return ki ? ki.color : '#8a8fa3';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderSwim() {
|
|||
|
|
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|||
|
|
const rect = stage.getBoundingClientRect();
|
|||
|
|
const w = Math.max(rect.width, 100);
|
|||
|
|
const h = Math.max(rect.height, 100);
|
|||
|
|
|
|||
|
|
// Lane backgrounds + labels
|
|||
|
|
for (const [label, row] of state.laneRows) {
|
|||
|
|
const bg = document.createElementNS(SVG_NS, 'rect');
|
|||
|
|
bg.setAttribute('x', 0);
|
|||
|
|
bg.setAttribute('y', row.yCenter - state.lane.h / 2);
|
|||
|
|
bg.setAttribute('width', w);
|
|||
|
|
bg.setAttribute('height', state.lane.h);
|
|||
|
|
bg.setAttribute('fill', '#13151c');
|
|||
|
|
bg.setAttribute('opacity', '0.3');
|
|||
|
|
svg.appendChild(bg);
|
|||
|
|
|
|||
|
|
const sep = document.createElementNS(SVG_NS, 'line');
|
|||
|
|
sep.setAttribute('x1', 0);
|
|||
|
|
sep.setAttribute('x2', w);
|
|||
|
|
sep.setAttribute('y1', row.yCenter + state.lane.h / 2);
|
|||
|
|
sep.setAttribute('y2', row.yCenter + state.lane.h / 2);
|
|||
|
|
sep.setAttribute('stroke', '#2a2d3e');
|
|||
|
|
svg.appendChild(sep);
|
|||
|
|
|
|||
|
|
const text = document.createElementNS(SVG_NS, 'text');
|
|||
|
|
text.setAttribute('x', 8);
|
|||
|
|
text.setAttribute('y', row.yCenter - state.lane.h / 2 + 14);
|
|||
|
|
text.setAttribute('fill', row.color);
|
|||
|
|
text.setAttribute('font-size', '11');
|
|||
|
|
text.setAttribute('font-weight', '600');
|
|||
|
|
text.textContent = label;
|
|||
|
|
svg.appendChild(text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Time axis ticks
|
|||
|
|
const tickCount = 8;
|
|||
|
|
for (let i = 0; i <= tickCount; i++) {
|
|||
|
|
const t = state.view.x + (state.view.w * i / tickCount);
|
|||
|
|
const x = (i / tickCount) * w;
|
|||
|
|
const tk = document.createElementNS(SVG_NS, 'line');
|
|||
|
|
tk.setAttribute('x1', x); tk.setAttribute('x2', x);
|
|||
|
|
tk.setAttribute('y1', 0); tk.setAttribute('y2', 16);
|
|||
|
|
tk.setAttribute('stroke', '#2a2d3e');
|
|||
|
|
svg.appendChild(tk);
|
|||
|
|
const lbl = document.createElementNS(SVG_NS, 'text');
|
|||
|
|
lbl.setAttribute('x', x + 3);
|
|||
|
|
lbl.setAttribute('y', 12);
|
|||
|
|
lbl.setAttribute('fill', '#8a8fa3');
|
|||
|
|
lbl.setAttribute('font-size', '10');
|
|||
|
|
lbl.textContent = formatT(t);
|
|||
|
|
svg.appendChild(lbl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Snapshot ticks (thin marks at bottom of each lane)
|
|||
|
|
for (const snap of state.bundle.snapshots) {
|
|||
|
|
if (state.hiddenNodes.has(snap.node_label)) continue;
|
|||
|
|
const row = state.laneRows.get(snap.node_label);
|
|||
|
|
if (!row) continue;
|
|||
|
|
const x = tToX(snap.t_ms, w);
|
|||
|
|
if (x < -2 || x > w + 2) continue;
|
|||
|
|
const m = document.createElementNS(SVG_NS, 'rect');
|
|||
|
|
m.setAttribute('x', x - 0.5);
|
|||
|
|
m.setAttribute('y', row.yCenter + state.lane.h / 2 - 4);
|
|||
|
|
m.setAttribute('width', 1);
|
|||
|
|
m.setAttribute('height', 3);
|
|||
|
|
m.setAttribute('fill', '#aed581');
|
|||
|
|
m.setAttribute('opacity', '0.8');
|
|||
|
|
svg.appendChild(m);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Event dots
|
|||
|
|
for (let i = 0; i < state.bundle.events.length; i++) {
|
|||
|
|
const ev = state.bundle.events[i];
|
|||
|
|
if (!eventVisible(ev)) continue;
|
|||
|
|
const row = state.laneRows.get(ev.node_label);
|
|||
|
|
if (!row) continue;
|
|||
|
|
const x = tToX(ev.t_ms, w);
|
|||
|
|
if (x < -4 || x > w + 4) continue;
|
|||
|
|
const dot = document.createElementNS(SVG_NS, 'circle');
|
|||
|
|
dot.setAttribute('cx', x);
|
|||
|
|
dot.setAttribute('cy', row.yCenter);
|
|||
|
|
const r = ev.severity === 'error' ? 4 : ev.severity === 'notable' ? 3 : 2;
|
|||
|
|
dot.setAttribute('r', r);
|
|||
|
|
dot.setAttribute('fill', kindColor(ev.kind));
|
|||
|
|
dot.setAttribute('opacity', ev.t_ms <= state.playhead ? '1' : '0.45');
|
|||
|
|
dot.dataset.idx = i;
|
|||
|
|
dot.style.cursor = 'pointer';
|
|||
|
|
dot.addEventListener('mouseenter', e => showTooltip(e, ev));
|
|||
|
|
dot.addEventListener('mouseleave', hideTooltip);
|
|||
|
|
dot.addEventListener('click', () => jumpTo(ev.t_ms, i));
|
|||
|
|
svg.appendChild(dot);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Playhead
|
|||
|
|
const phX = tToX(state.playhead, w);
|
|||
|
|
if (phX >= 0 && phX <= w) {
|
|||
|
|
const ph = document.createElementNS(SVG_NS, 'line');
|
|||
|
|
ph.setAttribute('x1', phX); ph.setAttribute('x2', phX);
|
|||
|
|
ph.setAttribute('y1', 16); ph.setAttribute('y2', h);
|
|||
|
|
ph.setAttribute('stroke', '#ffb74d');
|
|||
|
|
ph.setAttribute('stroke-width', '1.5');
|
|||
|
|
svg.appendChild(ph);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatT(ms) {
|
|||
|
|
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
|||
|
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|||
|
|
const m = Math.floor(ms / 60_000);
|
|||
|
|
const s = ((ms % 60_000) / 1000).toFixed(0);
|
|||
|
|
return `${m}m${s}s`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showTooltip(e, ev) {
|
|||
|
|
tooltip.style.display = 'block';
|
|||
|
|
tooltip.innerHTML =
|
|||
|
|
`<div><b>${ev.kind}</b> <span class="k">@ ${formatT(ev.t_ms)}</span></div>` +
|
|||
|
|
`<div class="k">${ev.node_label} · ${ev.severity}</div>`;
|
|||
|
|
const rect = stage.getBoundingClientRect();
|
|||
|
|
tooltip.style.left = (e.clientX - rect.left + 10) + 'px';
|
|||
|
|
tooltip.style.top = (e.clientY - rect.top + 10) + 'px';
|
|||
|
|
}
|
|||
|
|
function hideTooltip() { tooltip.style.display = 'none'; }
|
|||
|
|
|
|||
|
|
function renderStream() {
|
|||
|
|
const filtered = state.bundle.events
|
|||
|
|
.map((ev, i) => ({ ev, i }))
|
|||
|
|
.filter(({ ev }) => eventVisible(ev));
|
|||
|
|
streamCount.textContent = `(${filtered.length})`;
|
|||
|
|
streamList.innerHTML = '';
|
|||
|
|
let activeRow = null;
|
|||
|
|
for (const { ev, i } of filtered) {
|
|||
|
|
const row = document.createElement('div');
|
|||
|
|
row.className = `evt sev-${ev.severity}`;
|
|||
|
|
row.dataset.idx = i;
|
|||
|
|
row.dataset.t = ev.t_ms;
|
|||
|
|
if (ev.t_ms > state.playhead) row.classList.add('dimmed');
|
|||
|
|
const fieldsPretty = JSON.stringify(ev.fields, null, 2);
|
|||
|
|
row.innerHTML =
|
|||
|
|
`<div class="meta">` +
|
|||
|
|
`<span class="t">${formatT(ev.t_ms)}</span>` +
|
|||
|
|
`<span class="node" style="color:${nodeColor(ev.node_label)}">${ev.node_label}</span>` +
|
|||
|
|
`<span class="kind">${ev.kind}</span>` +
|
|||
|
|
`</div>` +
|
|||
|
|
`<pre>${escapeHtml(fieldsPretty)}</pre>`;
|
|||
|
|
row.onclick = () => jumpTo(ev.t_ms, i);
|
|||
|
|
streamList.appendChild(row);
|
|||
|
|
if (ev.t_ms <= state.playhead) activeRow = row;
|
|||
|
|
}
|
|||
|
|
if (activeRow) {
|
|||
|
|
activeRow.classList.add('active');
|
|||
|
|
if (state.followStream) {
|
|||
|
|
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function nodeColor(label) {
|
|||
|
|
const n = state.bundle.nodes.find(x => x.label === label);
|
|||
|
|
return n ? n.color : '#e0e0e0';
|
|||
|
|
}
|
|||
|
|
function escapeHtml(s) {
|
|||
|
|
return s.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function jumpTo(t_ms, evIdx) {
|
|||
|
|
state.playhead = t_ms;
|
|||
|
|
scrub.value = t_ms;
|
|||
|
|
nowLabel.textContent = formatT(t_ms);
|
|||
|
|
renderSwim();
|
|||
|
|
highlightStream(evIdx);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function highlightStream(evIdx) {
|
|||
|
|
streamList.querySelectorAll('.evt').forEach(r => {
|
|||
|
|
r.classList.toggle('active', Number(r.dataset.idx) === evIdx);
|
|||
|
|
r.classList.toggle('dimmed', Number(r.dataset.t) > state.playhead);
|
|||
|
|
});
|
|||
|
|
const target = streamList.querySelector(`.evt[data-idx="${evIdx}"]`);
|
|||
|
|
if (target && state.followStream) target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── playback ─────────────────────────────────────────────────────────────
|
|||
|
|
function tick(wallNow) {
|
|||
|
|
if (!state.playing) return;
|
|||
|
|
const dt = wallNow - state.lastFrameWall;
|
|||
|
|
state.lastFrameWall = wallNow;
|
|||
|
|
if (state.speed === 'instant') {
|
|||
|
|
state.playhead = state.bundle.t_end_ms;
|
|||
|
|
state.playing = false;
|
|||
|
|
playBtn.textContent = '▶ Play';
|
|||
|
|
} else {
|
|||
|
|
state.playhead += dt * Number(state.speed);
|
|||
|
|
if (state.playhead >= state.bundle.t_end_ms) {
|
|||
|
|
state.playhead = state.bundle.t_end_ms;
|
|||
|
|
state.playing = false;
|
|||
|
|
playBtn.textContent = '▶ Play';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
scrub.value = state.playhead;
|
|||
|
|
nowLabel.textContent = formatT(state.playhead);
|
|||
|
|
renderSwim();
|
|||
|
|
renderStream();
|
|||
|
|
if (state.playing) requestAnimationFrame(tick);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
playBtn.onclick = () => {
|
|||
|
|
if (state.playing) {
|
|||
|
|
state.playing = false;
|
|||
|
|
playBtn.textContent = '▶ Play';
|
|||
|
|
} else {
|
|||
|
|
if (state.playhead >= state.bundle.t_end_ms) state.playhead = 0;
|
|||
|
|
state.playing = true;
|
|||
|
|
state.lastFrameWall = performance.now();
|
|||
|
|
playBtn.textContent = '⏸ Pause';
|
|||
|
|
requestAnimationFrame(tick);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
speedSel.onchange = () => { state.speed = speedSel.value === 'instant' ? 'instant' : Number(speedSel.value); };
|
|||
|
|
scrub.oninput = () => {
|
|||
|
|
state.playhead = Number(scrub.value);
|
|||
|
|
nowLabel.textContent = formatT(state.playhead);
|
|||
|
|
renderSwim();
|
|||
|
|
renderStream();
|
|||
|
|
};
|
|||
|
|
stickBtn.onclick = () => {
|
|||
|
|
state.followStream = !state.followStream;
|
|||
|
|
stickBtn.classList.toggle('on', state.followStream);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ─── zoom/pan ─────────────────────────────────────────────────────────────
|
|||
|
|
svg.addEventListener('wheel', e => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const rect = stage.getBoundingClientRect();
|
|||
|
|
const w = rect.width;
|
|||
|
|
const mouseT = xToT(e.clientX - rect.left, w);
|
|||
|
|
const factor = e.deltaY > 0 ? 1.2 : 0.8;
|
|||
|
|
const newW = Math.max(50, Math.min(state.bundle.t_end_ms * 1.5, state.view.w * factor));
|
|||
|
|
// Anchor zoom around mouseT.
|
|||
|
|
state.view.x = mouseT - (mouseT - state.view.x) * (newW / state.view.w);
|
|||
|
|
state.view.w = newW;
|
|||
|
|
state.view.x = Math.max(-state.view.w * 0.05, Math.min(state.bundle.t_end_ms, state.view.x));
|
|||
|
|
renderSwim();
|
|||
|
|
}, { passive: false });
|
|||
|
|
|
|||
|
|
let dragStart = null;
|
|||
|
|
svg.addEventListener('pointerdown', e => {
|
|||
|
|
dragStart = { x: e.clientX, viewX: state.view.x };
|
|||
|
|
svg.classList.add('dragging');
|
|||
|
|
svg.setPointerCapture(e.pointerId);
|
|||
|
|
});
|
|||
|
|
svg.addEventListener('pointermove', e => {
|
|||
|
|
if (!dragStart) return;
|
|||
|
|
const rect = stage.getBoundingClientRect();
|
|||
|
|
const dxT = ((e.clientX - dragStart.x) / rect.width) * state.view.w;
|
|||
|
|
state.view.x = dragStart.viewX - dxT;
|
|||
|
|
renderSwim();
|
|||
|
|
});
|
|||
|
|
svg.addEventListener('pointerup', e => {
|
|||
|
|
dragStart = null;
|
|||
|
|
svg.classList.remove('dragging');
|
|||
|
|
try { svg.releasePointerCapture(e.pointerId); } catch (_) {}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
window.addEventListener('resize', () => {
|
|||
|
|
layoutSvg();
|
|||
|
|
renderSwim();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
load().catch(err => {
|
|||
|
|
document.body.innerHTML = `<pre style="color:#f44336;padding:20px">failed to load bundle: ${err}</pre>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Re-layout once after first paint to settle SVG dimensions.
|
|||
|
|
setTimeout(() => { layoutSvg(); renderSwim(); }, 50);
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|