Spaces:
Running
Running
File size: 12,092 Bytes
81299b2 350de76 81299b2 350de76 81299b2 350de76 81299b2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | /* 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 =
`<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);
|