// Render per-frame signals (contact_l, contact_r, grasp, motion, trust) into 5 mini SVGs. // Each SVG is 200x18 viewBox. We scale to 0..1 vertically (motion uses log(1+x)/log(1+max)). const VB_W = 200, VB_H = 18; function clearSvg(svg) { while (svg.firstChild) svg.removeChild(svg.firstChild); } function pathFromSeries(values, normalize) { if (!values || values.length === 0) return ''; const n = values.length; let pts = []; for (let i = 0; i < n; i++) { const x = (i / Math.max(n - 1, 1)) * VB_W; const v = normalize(values[i]); const y = VB_H - v * VB_H; pts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`); } return pts.join(' '); } function fillBars(svg, values, normalize, color) { // For binary trust: render as horizontal bars where True const n = values.length; if (n === 0) return; const w = VB_W / n; let i = 0; while (i < n) { if (values[i]) { let j = i; while (j < n && values[j]) j++; const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', (i * w).toFixed(2)); rect.setAttribute('y', '4'); rect.setAttribute('width', ((j - i) * w).toFixed(2)); rect.setAttribute('height', String(VB_H - 8)); rect.setAttribute('fill', color); rect.setAttribute('opacity', '0.7'); svg.appendChild(rect); i = j; } else { i++; } } } function drawArea(svg, values, normalize, fill, stroke) { const n = values.length; if (n === 0) return; let pts = [`M0,${VB_H}`]; for (let i = 0; i < n; i++) { const x = (i / Math.max(n - 1, 1)) * VB_W; const v = Math.max(0, Math.min(1, normalize(values[i]))); const y = VB_H - v * VB_H; pts.push(`L${x.toFixed(2)},${y.toFixed(2)}`); } pts.push(`L${VB_W},${VB_H}Z`); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', pts.join(' ')); path.setAttribute('fill', fill); path.setAttribute('stroke', stroke); path.setAttribute('stroke-width', '0.8'); svg.appendChild(path); } function addPlayhead(svg, frac) { const x = frac * VB_W; const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', x.toFixed(2)); line.setAttribute('x2', x.toFixed(2)); line.setAttribute('y1', '0'); line.setAttribute('y2', String(VB_H)); line.setAttribute('class', 'signal-playhead'); line.setAttribute('stroke', 'var(--accent)'); line.setAttribute('stroke-width', '1'); svg.appendChild(line); return line; } export class SignalPanel { constructor() { this.svgs = { contact_l: document.getElementById('sig-contact-l'), contact_r: document.getElementById('sig-contact-r'), grasp: document.getElementById('sig-grasp'), motion: document.getElementById('sig-motion'), trust: document.getElementById('sig-trust'), }; this.vals = { contact_l: document.getElementById('sig-val-contact-l'), contact_r: document.getElementById('sig-val-contact-r'), grasp: document.getElementById('sig-val-grasp'), motion: document.getElementById('sig-val-motion'), trust: document.getElementById('sig-val-trust'), }; this.playheads = {}; this.data = null; } setData(timeseries) { this.data = timeseries; if (!timeseries) { Object.values(this.svgs).forEach(clearSvg); Object.values(this.vals).forEach(v => v.textContent = '-'); return; } const scene = timeseries.scene || {}; this._render('contact_l', scene.contact_l || [], v => v, 'rgba(217,119,87,0.25)', 'oklch(0.68 0.18 40)'); this._render('contact_r', scene.contact_r || [], v => v, 'rgba(106,148,96,0.25)', 'oklch(0.58 0.12 150)'); this._render('grasp', scene.grasp || [], v => v, 'rgba(160,88,72,0.30)', 'oklch(0.58 0.18 30)'); // motion: log scale const motion = scene.motion || []; const maxMotion = Math.max(1, ...motion.map(v => v || 0)); this._render('motion', motion, v => Math.log1p(v) / Math.log1p(maxMotion), 'rgba(120,120,160,0.22)', 'oklch(0.55 0.10 240)'); // trust: union over per-object trust booleans (if any object trusted that frame, mark trusted) const perObj = timeseries.per_object || {}; const n = timeseries.n_frames || (motion.length || 0); const trustUnion = new Array(n).fill(false); Object.values(perObj).forEach(o => { const arr = o.trust || []; for (let i = 0; i < n; i++) if (arr[i]) trustUnion[i] = true; }); clearSvg(this.svgs.trust); fillBars(this.svgs.trust, trustUnion, v => v ? 1 : 0, 'oklch(0.68 0.18 40)'); // playhead overlay this.playheads.trust = addPlayhead(this.svgs.trust, 0); this._lastTrust = trustUnion; } _render(key, series, normalize, fill, stroke) { const svg = this.svgs[key]; clearSvg(svg); drawArea(svg, series, normalize, fill, stroke); this.playheads[key] = addPlayhead(svg, 0); } setPlayheadFrame(frameIdx) { if (!this.data) return; const n = this.data.n_frames || 1; const frac = Math.max(0, Math.min(1, frameIdx / Math.max(n - 1, 1))); Object.values(this.playheads).forEach(line => { if (!line) return; const x = (frac * VB_W).toFixed(2); line.setAttribute('x1', x); line.setAttribute('x2', x); }); // update value labels const scene = this.data.scene || {}; const f = Math.max(0, Math.min(n - 1, Math.round(frameIdx))); const fmt = (v) => (v == null ? '-' : (typeof v === 'number' ? v.toFixed(2) : String(v))); this.vals.contact_l.textContent = fmt(scene.contact_l && scene.contact_l[f]); this.vals.contact_r.textContent = fmt(scene.contact_r && scene.contact_r[f]); this.vals.grasp.textContent = fmt(scene.grasp && scene.grasp[f]); this.vals.motion.textContent = fmt(scene.motion && scene.motion[f]); this.vals.trust.textContent = (this._lastTrust && this._lastTrust[f]) ? 'yes' : 'no'; } } // ─── Per-object state timeline (static / grasped / moving) ──────── // Rendered into #state-timeline-rows from signals.json.per_object[oid].state // One row per object: prompt label + horizontal SVG strip + per-frame value. // Colors must match the .state-chip classes in styles.css. const STATE_COLOR = { static: 'oklch(0.78 0.01 240)', // light gray grasped_l: 'oklch(0.70 0.16 50)', // warm orange = left hand grasped_r: 'oklch(0.60 0.20 20)', // red-orange = right hand grasped_both: 'oklch(0.42 0.20 15)', // deep red = both hands moving: 'oklch(0.55 0.10 240)', // cool blue // back-compat: clips processed before the L/R split write plain "grasped" grasped: 'oklch(0.58 0.18 30)', }; export class StateTimelinePanel { constructor() { this.container = document.getElementById('state-timeline-rows'); this.rows = []; // [{oid, svg, valEl, playhead, states}] this.nFrames = 0; } setData(timeseries, scene) { if (!this.container) return; this.container.innerHTML = ''; this.rows = []; this.nFrames = (timeseries && timeseries.n_frames) || 0; const perObj = (timeseries && timeseries.per_object) || {}; const objs = (scene && scene.reconstruction && scene.reconstruction.objects) || []; // Need at least one object with a state array to render anything const haveState = Object.values(perObj).some(o => Array.isArray(o.state)); if (!haveState) { const empty = document.createElement('div'); empty.style.cssText = 'font-size:10px;color:var(--ink-3);padding:4px 0;'; empty.textContent = 'No state data; clip may pre-date the state-machine refresh.'; this.container.appendChild(empty); return; } objs.forEach(o => { const oidKey = String(o.id); const po = perObj[oidKey] || {}; const states = po.state || []; if (!states.length) return; const row = document.createElement('div'); row.className = 'state-row'; const label = document.createElement('div'); label.className = 'state-label'; label.style.color = o.color_hex || 'inherit'; label.title = o.prompt || `obj ${o.id}`; label.textContent = (o.prompt || `obj ${o.id}`).slice(0, 18); row.appendChild(label); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('viewBox', `0 0 ${VB_W} ${VB_H}`); // Paint segments const n = states.length; let i = 0; const w = VB_W / Math.max(n, 1); while (i < n) { const s = states[i]; let j = i; while (j < n && states[j] === s) j++; const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', (i * w).toFixed(2)); rect.setAttribute('y', '2'); rect.setAttribute('width', ((j - i) * w).toFixed(2)); rect.setAttribute('height', String(VB_H - 4)); rect.setAttribute('fill', STATE_COLOR[s] || 'oklch(0.78 0.01 240)'); svg.appendChild(rect); i = j; } const playhead = addPlayhead(svg, 0); row.appendChild(svg); const valEl = document.createElement('div'); valEl.className = 'state-value'; valEl.textContent = '-'; row.appendChild(valEl); this.container.appendChild(row); this.rows.push({ oid: o.id, svg, valEl, playhead, states }); }); } setPlayheadFrame(frameIdx) { if (!this.rows.length) return; const n = this.nFrames || 1; const frac = Math.max(0, Math.min(1, frameIdx / Math.max(n - 1, 1))); const x = (frac * VB_W).toFixed(2); const f = Math.max(0, Math.min(n - 1, Math.round(frameIdx))); for (const r of this.rows) { if (r.playhead) { r.playhead.setAttribute('x1', x); r.playhead.setAttribute('x2', x); } r.valEl.textContent = r.states[f] || '-'; } } }