SampleViewer / teleopSample /lib /steps-ui.js
darren.zhang
Claude Opus 5 (1M context)
teleopSample: cut what a pick-and-place page loads by ~60%
350de76
Raw History Blame Contribute Delete
12.1 kB
/* Step UI shared by every sample viewer on this Space.
*
* Builds the three blocks that describe a teleop episode's steps — the instruction /
* object / target / scene header, the segmented step timeline over the scrubber, and the
* current-step annotation panel — plus the per-step measurements behind them.
*
* The pick-and-place pages hand it their own containers ('inline' mode) and pre-computed
* per-step measurements; the older pages let it insert the blocks itself ('inject' mode)
* and it derives the measurements from the page's series. Both get the same layout.
*
* StepsUI.mount({series, steps, task, mode, hosts, numbering}) -> {update(t), stepAt(t)}
*/
(function (global) {
const COLORS = ["#8a8f98", "#4f8ff7", "#41c76b", "#e0a53e", "#b46fe0", "#e0616f",
"#e08f5f", "#5fc9e0"];
const TOKC = {
idle: '#8a8f98', reach_to_object: '#4f8ff7', fine_align: '#6f9fd8', grasp_close: '#41c76b',
lift: '#e0a53e', transport: '#b46fe0', align_over_target: '#e0d15f',
lower_onto_target: '#e0955f', release: '#e0616f', retreat: '#5fe0c0',
carry_away: '#b46fe0', hold: '#8a8f98',
};
const esc = s => String(s == null ? '' : s)
.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const CSS = `
.su-instr{margin:10px 22px 0;padding:11px 15px;background:#12151c;border:1px solid #232733;border-radius:8px;font-size:13px;line-height:1.65}
.su-instr .k{font-size:10.5px;letter-spacing:.04em;text-transform:uppercase;color:#9aa1ad;margin-right:8px}
.su-instr .obj{color:#7fb2ff}.su-instr .tgt{color:#7fe0a0}
.su-tlwrap{margin:12px 22px 0;padding:9px 14px;background:#12151c;border:1px solid #232733;border-radius:8px}
.su-tl{position:relative;display:flex;height:26px;border-radius:5px;overflow:hidden;cursor:pointer}
.su-tl .seg{position:relative;opacity:.85;transition:opacity .12s}
.su-tl .seg.on{opacity:1;box-shadow:inset 0 0 0 2px #fff}
.su-tl .seg span{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10.5px;color:#0b0d12;font-weight:700}
.su-tl .cur{position:absolute;top:0;bottom:0;width:2px;background:#fff;pointer-events:none}
.su-tlnums{display:flex;justify-content:space-between;font-size:10.5px;color:#6b7686;margin-top:4px;font-variant-numeric:tabular-nums}
.su-annot{margin:12px 22px 0;padding:14px 18px;background:#12151c;border:1px solid #232733;border-radius:8px}
.su-annot .t{font-size:11px;letter-spacing:.04em;color:#9aa1ad;text-transform:uppercase;margin-bottom:10px}
.su-annot h3{margin:0 0 2px;font-size:16px}
.su-annot h3 .tok{font-size:11.5px;color:#6f92c8;font-family:ui-monospace,monospace;margin-left:8px}
.su-annot .when{font-size:12px;color:#8890a0;font-variant-numeric:tabular-nums;margin-bottom:8px}
.su-annot p{margin:0 0 6px;line-height:1.6;font-size:13.5px;color:#dfe4ec}
.su-sgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:7px 26px;margin-top:10px}
.su-sg{display:flex;justify-content:space-between;border-bottom:1px dashed #232733;padding-bottom:3px;font-size:12.5px}
.su-sg .k{color:#9aa1ad}.su-sg .v{font-variant-numeric:tabular-nums}
.su-item .t{font-size:11px;color:#8890a0;font-variant-numeric:tabular-nums}
.su-item .title{font-size:13.5px;margin:2px 0;line-height:1.35}
.su-item .tok{font-size:10.5px;color:#6f92c8;font-family:ui-monospace,monospace}`;
function styles() {
if (document.getElementById('su-css')) return;
const e = document.createElement('style');
e.id = 'su-css'; e.textContent = CSS;
document.head.appendChild(e);
}
/** End-effector / gripper measurements over a frame range, from the page's own series. */
function measure(S, a, b) {
const END = S.end || S.end_pos; // [n][6] = left xyz, right xyz
const LR = S.end_l && S.end_r; // the newer pages ship them apart
a = Math.max(0, a); b = Math.min(S.n - 1, b);
const of = (i, o) => (LR ? (o ? S.end_r[i][o - 3] : S.end_l[i][o]) : END[i][o]);
const arm = o => {
let path = 0, vmax = 0;
for (let i = a + 1; i <= b; i++) {
const d = Math.hypot(of(i, o) - of(i - 1, o), of(i, o + 1) - of(i - 1, o + 1),
of(i, o + 2) - of(i - 1, o + 2));
path += d; vmax = Math.max(vmax, d * S.fps);
}
return {
path, vmax, dz: of(b, o + 2) - of(a, o + 2),
dxy: Math.hypot(of(b, o) - of(a, o), of(b, o + 1) - of(a, o + 1)),
vmean: path / Math.max(1e-6, (b - a) / S.fps),
};
};
const L = arm(0), R = arm(3);
return { L, R, act: L.path >= R.path ? L : R, side: L.path >= R.path ? 'left' : 'right' };
}
function rows(S, st, a, b) {
const out = [];
const f = (v, n) => (v == null ? '–' : Number(v).toFixed(n == null ? 2 : n));
if (st.gripper) {
out.push(['gripper open', `${f(st.gripper.start)} → ${f(st.gripper.end)} (min ${f(st.gripper.min)})`]);
} else if (S.grip_l) {
out.push(['gripper L', `${f(S.grip_l[a])} → ${f(S.grip_l[b])}`]);
out.push(['gripper R', `${f(S.grip_r[a])} → ${f(S.grip_r[b])}`]);
}
const ee = st.ee || measure(S, a, b).act;
out.push(['ee path', f(ee.travel_m != null ? ee.travel_m : ee.path, 3) + ' m']);
const dz = ee.dz_m != null ? ee.dz_m : ee.dz;
out.push(['ee Δz', (dz >= 0 ? '+' : '') + f(dz, 3) + ' m']);
out.push(['ee Δxy', f(ee.dxy_m != null ? ee.dxy_m : ee.dxy, 3) + ' m']);
out.push(['speed mean / max',
`${f(ee.v_mean != null ? ee.v_mean : ee.vmean)} / ${f(ee.v_max != null ? ee.v_max : ee.vmax)} m/s`]);
if (st.contact) out.push(['contact |F| peak', f(st.contact.force_peak_N, 1) + ' N']);
if (st.effort) out.push(['peak |τ|', `${st.effort.peak_joint} ${f(st.effort.peak_Nm, 1)} Nm`]);
if (st.tracking) out.push(['cmd err mean / max',
`${f(st.tracking.cmd_err_mean_rad, 3)} / ${f(st.tracking.cmd_err_max_rad, 3)} rad`]);
if (!st.ee && S.intervened) {
const n = S.intervened.slice(a, b + 1).reduce((p, c) => p + (c ? 1 : 0), 0);
out.push(['operator-sourced frames', Math.round(100 * n / (b - a + 1)) + '%']);
}
return out;
}
function mount(cfg) {
styles();
const S = cfg.series, STEPS = cfg.steps || [], task = cfg.task || {};
if (!S || !STEPS.length) return null;
const DUR = S.dur != null ? S.dur : (S.t ? S.t[S.n - 1] : (S.n - 1) / S.fps);
const frameAt = t => Math.max(0, Math.min(S.n - 1, Math.round(t * S.fps)));
const num = (st, i) => (cfg.numbering === 'id' && st.id != null ? st.id : i + 1);
const col = (st, i) => (cfg.colorBy === 'token' && TOKC[st.token]) || COLORS[i % COLORS.length];
const seek = cfg.seek || (t => document.querySelectorAll('video')
.forEach(v => { try { v.currentTime = t; } catch (e) {} }));
// ---- instruction header
const instr = document.createElement('div');
instr.className = 'su-instr';
instr.innerHTML =
`<div><span class="k">instruction</span>${esc(task.instruction)}</div>` +
(task.note ? `<div class="su-note">${esc(task.note)}</div>` : '') +
`<div style="margin-top:6px"><span class="k">object</span><span class="obj">${esc(task.object) || '—'}</span>` +
`<span class="k" style="margin-left:14px">target</span><span class="tgt">${esc(task.target) || '—'}</span>` +
`<span class="k" style="margin-left:14px">scene</span>${esc(task.scene)}</div>`;
// ---- timeline
const tlw = document.createElement('div');
tlw.className = 'su-tlwrap';
tlw.innerHTML = `<div class="su-tl"><div class="cur"></div></div>` +
`<div class="su-tlnums"><span>0.00s</span><span>${DUR.toFixed(2)}s</span></div>`;
const tl = tlw.querySelector('.su-tl'), cur = tlw.querySelector('.cur');
STEPS.forEach((st, i) => {
const sg = document.createElement('div');
sg.className = 'seg';
sg.style.background = col(st, i);
sg.style.width = (100 * (st.end_s - st.start_s) / DUR) + '%';
sg.title = `${num(st, i)}. ${st.title || st.label} ${st.start_s.toFixed(2)}–${st.end_s.toFixed(2)}s`;
sg.innerHTML = `<span>${num(st, i)}</span>`;
sg.onclick = e => { e.stopPropagation(); seek(st.start_s + 0.01); };
tl.insertBefore(sg, cur);
});
tl.onclick = e => { const r = tl.getBoundingClientRect(); seek((e.clientX - r.left) / r.width * DUR); };
// ---- current-step panel
const an = document.createElement('div');
an.className = 'su-annot';
an.innerHTML = `<div class="t">step annotation · current step</div><h3 class="lbl">–</h3>` +
`<div class="when"></div><p class="desc"></p><div class="su-sgrid sig"></div>`;
// ---- place them
const H = cfg.hosts || {};
const put = (node, host, how) => {
if (!host) return;
if (how === 'fill') { host.innerHTML = ''; host.appendChild(node); }
else if (how === 'after') host.parentNode.insertBefore(node, host.nextSibling);
else host.parentNode.insertBefore(node, host);
};
put(instr, H.instr || H.instrAfter, H.instr ? 'fill' : 'after');
put(tlw, H.timeline || H.timelineBefore, H.timeline ? 'fill' : 'before');
put(an, H.panel || H.panelAfter, H.panel ? 'fill' : 'after');
// ---- step list rows (same format on every page)
const list = cfg.listItems ? [...cfg.listItems] : [];
list.forEach((el, i) => {
const st = STEPS[i];
if (!st) return;
const a = frameAt(st.start_s), b = frameAt(st.end_s);
el.classList.add('su-item');
el.style.borderLeftColor = col(st, i);
el.innerHTML =
`<div class="t">${st.start_s.toFixed(2)}s – ${st.end_s.toFixed(2)}s · ` +
`${(st.end_s - st.start_s).toFixed(2)}s · ${b - a + 1}f` +
(st.success_s != null ? ` · ✓ ${st.success_s.toFixed(2)}s` : '') + `</div>` +
`<div class="title">${num(st, i)}. ${esc(st.title || st.label)}</div>` +
(st.token ? `<div class="tok">${esc(st.token)}</div>` : '');
el.onclick = () => seek(st.start_s + 0.01);
});
const lbl = an.querySelector('.lbl'), when = an.querySelector('.when');
const desc = an.querySelector('.desc'), sig = an.querySelector('.sig');
let curIdx = -1;
function stepAt(t) {
for (let i = 0; i < STEPS.length; i++) if (t >= STEPS[i].start_s && t < STEPS[i].end_s) return i;
return t < STEPS[0].start_s ? 0 : STEPS.length - 1;
}
function update(t) {
const i = stepAt(t), st = STEPS[i];
cur.style.left = (100 * t / DUR) + '%';
if (i === curIdx) return i;
curIdx = i;
const a = st.start_frame != null ? st.start_frame : frameAt(st.start_s);
const b = st.end_frame != null ? st.end_frame : frameAt(st.end_s);
lbl.innerHTML = `${num(st, i)}. ${esc(st.title || st.label)}` +
(st.token ? `<span class="tok">${esc(st.token)}</span>` : '');
when.textContent =
`${st.start_s.toFixed(2)}s – ${st.end_s.toFixed(2)}s · ${(st.end_s - st.start_s).toFixed(2)}s · ` +
`frames ${a}–${b} (${b - a + 1})` +
(st.success_s != null ? ` · success at ${st.success_s.toFixed(2)}s` : '');
let text = st.description || (cfg.describe && cfg.describe(st, i));
if (!text) {
const m = measure(S, a, b);
text = `${m.side} arm · ${m.act.path.toFixed(3)} m of end-effector travel in ` +
`${(st.end_s - st.start_s).toFixed(2)}s (mean ${m.act.vmean.toFixed(2)}, peak ` +
`${m.act.vmax.toFixed(2)} m/s), height change ${m.act.dz >= 0 ? '+' : ''}${m.act.dz.toFixed(2)} m.`;
}
desc.textContent = text;
sig.innerHTML = rows(S, st, a, b).map(r =>
`<div class="su-sg"><span class="k">${r[0]}</span><span class="v">${r[1]}</span></div>`).join('');
tl.querySelectorAll('.seg').forEach((e, k) => e.classList.toggle('on', k === i));
list.forEach((e, k) => e.classList.toggle('active', k === i));
return i;
}
return { update, stepAt, measure: (a, b) => measure(S, a, b), colorOf: col, DUR, frameAt };
}
global.StepsUI = { mount, measure, COLORS, TOKC };
})(window);