/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 = `
instruction${esc(task.instruction)}
` + (task.note ? `
${esc(task.note)}
` : '') + `
object${esc(task.object) || '—'}` + `target${esc(task.target) || '—'}` + `scene${esc(task.scene)}
`; // ---- timeline const tlw = document.createElement('div'); tlw.className = 'su-tlwrap'; tlw.innerHTML = `
` + `
0.00s${DUR.toFixed(2)}s
`; 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 = `${num(st, i)}`; 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 = `
step annotation · current step

–

` + `

`; // ---- 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 = `
${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` : '') + `
` + `
${num(st, i)}. ${esc(st.title || st.label)}
` + (st.token ? `
${esc(st.token)}
` : ''); 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 ? `${esc(st.token)}` : ''); 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 => `
${r[0]}${r[1]}
`).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);