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.5 kB
/* Page logic for the pick-and-place sample viewers: camera grid, synchronised playback with
* autoplay + loop, the state readout, the joint-effort strip and the charts.
* The instruction header, step timeline and current-step panel come from steps-ui.js, which
* the older sample pages use as well. All page data lives in the JSON blocks of the page. */
(function () {
const J = id => { const e = document.getElementById(id); return e ? JSON.parse(e.textContent) : null; };
const S = J('series'), STEPS = J('steps') || [], CAMS = J('cams') || [],
FILES = J('files') || [], TASK = J('task') || {};
const has = k => S[k] !== undefined;
const DUR = S.dur != null ? S.dur : (S.t ? S.t[S.n - 1] : (S.n - 1) / S.fps);
const END_A = S.side === 'L' ? S.end_l : S.end_r; // active arm, derived
const cmdDelta = i => { // |command - state|, derived
let m = 0;
for (const c of S.joint_idx) m = Math.max(m, Math.abs(S.act_joints[i][c] - S.joints[i][c]));
return m;
};
const frameAt = t => Math.max(0, Math.min(S.n - 1, Math.round(t * S.fps)));
const SIDE = S.side, SIDEL = SIDE === 'L' ? 'left' : 'right';
if (window.self !== window.top) { // embedded in the gallery shell
const b = document.getElementById('backlink');
if (b) b.style.display = 'none';
}
// ---- file list
const fm = document.getElementById('fileManifest');
FILES.forEach(f => {
const d = document.createElement('span');
d.className = 'file-chip';
d.innerHTML = f.name + '<span class="sz">' + f.size + '</span>';
fm.appendChild(d);
});
// ---- cameras
const camgrid = document.getElementById('camgrid'), videos = [];
CAMS.forEach(c => {
const d = document.createElement('div');
d.className = 'cam';
d.innerHTML = `<div class="lbl">${c.label}</div>` +
`<video muted playsinline preload="auto" src="${c.src}"></video>`;
camgrid.appendChild(d);
videos.push(d.querySelector('video'));
});
const master = videos[Math.max(0, CAMS.findIndex(c => c.key === 'head_color'))];
// ---- playback: autoplay once every tile can render, then loop
const playBtn = document.getElementById('play'), seek = document.getElementById('seek'),
tlabel = document.getElementById('tlabel');
let playing = false;
const seekTo = t => videos.forEach(v => { try { v.currentTime = t; } catch (e) {} });
function setPlaying(p) {
playing = p;
playBtn.textContent = p ? '❚❚' : '▶︎';
if (p) { videos.forEach(v => v.play().catch(() => {})); requestAnimationFrame(loop); }
else videos.forEach(v => v.pause());
}
playBtn.onclick = () => setPlaying(!playing);
let started = false, iv = setInterval(tryStart, 400);
function tryStart() {
if (started || playing || !videos.length || !videos.every(v => v.readyState >= 2)) return;
started = true; clearInterval(iv);
videos.forEach(v => v.muted = true);
setPlaying(true);
}
videos.forEach(v => { v.addEventListener('canplay', tryStart); v.addEventListener('loadeddata', tryStart); });
window.addEventListener('load', tryStart);
setTimeout(() => clearInterval(iv), 90000);
setInterval(() => {
if (playing && master.currentTime >= DUR - 0.08) {
seekTo(0); videos.forEach(v => v.play().catch(() => {}));
}
}, 200);
seek.oninput = () => { const t = seek.value / 1000 * DUR; seekTo(t); update(); };
function resync() {
const t = master.currentTime;
videos.forEach(v => { if (v !== master && Math.abs(v.currentTime - t) > 0.2) { try { v.currentTime = t; } catch (e) {} } });
}
master.addEventListener('seeked', resync);
setInterval(() => { if (!master.paused) resync(); }, 1000);
function loop() { update(); if (playing) requestAnimationFrame(loop); }
// ---- shared step UI: instruction header, timeline, current-step panel, step list
const list = document.getElementById('stepList');
const items = STEPS.map(() => {
const d = document.createElement('div');
d.className = 'step-item';
list.appendChild(d);
return d;
});
const steps = StepsUI.mount({
series: S, steps: STEPS, task: TASK, colorBy: 'token', listItems: items,
seek: t => { seekTo(t); update(); },
hosts: {
instr: document.getElementById('suInstr'),
timeline: document.getElementById('suTl'),
panel: document.getElementById('suPanel'),
},
});
// ---- chart labels that depend on which arm moved
document.getElementById('lblJoints').textContent =
SIDEL + '-arm joint positions (rad) · 7 DoF (j1 shoulder → j7 wrist)' +
(has('act_joints') ? ' · solid = state, dashed = teleop command' : '');
document.getElementById('lblEnd').innerHTML = SIDEL +
'-arm end-effector position (m) — <i style="background:#e0616f"></i>x ' +
'<i style="background:#41c76b"></i>y <i style="background:#4f8ff7"></i>z';
if (has('act_grip_l')) document.getElementById('lblGrip').innerHTML += ' · dashed = command';
// ---- joint-effort strip
function heat(v) {
v = Math.max(0, Math.min(1, v));
const s = [[30, 40, 120], [40, 140, 200], [60, 200, 120], [230, 210, 60], [220, 60, 50]];
const x = v * (s.length - 1), i = Math.floor(x), f = x - i,
a = s[i], b = s[Math.min(i + 1, s.length - 1)];
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * f)},${Math.round(a[1] + (b[1] - a[1]) * f)},` +
`${Math.round(a[2] + (b[2] - a[2]) * f)})`;
}
const cEff = document.getElementById('cEff');
if (has('eff')) {
document.getElementById('effInfo').textContent =
'(7 joints/arm · both arms shown · peak ' + S.eff_max + ' Nm)';
document.getElementById('effPeak').textContent = S.eff_max;
}
function drawEffort(i) {
if (!has('eff')) return;
const ctx = cEff.getContext('2d'), dpr = devicePixelRatio || 1,
w = cEff.clientWidth, h = cEff.clientHeight || 46;
cEff.width = w * dpr; cEff.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h);
const vals = S.eff[i] || [], K = vals.length, gap = 18, cw = (w - gap - 4) / K;
for (let k = 0; k < K; k++) {
const x = 2 + k * cw + (k >= 7 ? gap : 0), v = Math.abs(vals[k]) / Math.max(1e-6, S.eff_max);
ctx.fillStyle = v > 0.02 ? heat(v) : '#1b1f2a';
ctx.fillRect(x, 4, cw - 1.5, h - 16);
ctx.strokeStyle = '#0b0d12'; ctx.lineWidth = 1;
ctx.strokeRect(x, 4, cw - 1.5, h - 16);
}
ctx.font = '10px ui-monospace';
ctx.fillStyle = SIDE === 'L' ? '#41c76b' : '#6b7686'; ctx.fillText('ARM L', 2, h - 3);
ctx.fillStyle = SIDE === 'R' ? '#e0616f' : '#6b7686'; ctx.fillText('ARM R', 2 + 7 * cw + gap, h - 3);
}
// ---- charts
const fmt = v => (Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2));
function mkDraw(canvas, series, cols, yMin, yMax, opt) {
opt = opt || {};
const fill = opt.fill, dash = opt.dash || [];
let off = null, ow = 0, oh = 0; // the curves never change: draw them once
function paint(w, h, dpr) {
off = document.createElement('canvas');
off.width = w * dpr; off.height = h * dpr;
const ctx = off.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
STEPS.forEach((st, i) => {
ctx.fillStyle = steps.colorOf(st, i) + '18';
ctx.fillRect(st.start_s / DUR * w, 0, (st.end_s - st.start_s) / DUR * w, h);
});
ctx.strokeStyle = '#232733'; ctx.lineWidth = 1;
for (let g = 0; g <= 4; g++) {
const y = (h - 14) * g / 4 + 7;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
const X = k => k / (S.n - 1) * w,
Y = v => (h - 14) - ((v - yMin) / Math.max(1e-9, yMax - yMin)) * (h - 14) + 7;
series.forEach((c, ci) => {
ctx.strokeStyle = cols[ci % cols.length];
ctx.lineWidth = dash[ci] ? 1 : 1.4;
ctx.setLineDash(dash[ci] ? [3, 3] : []);
ctx.beginPath();
c.forEach((v, k) => { const x = X(k), y = Y(v); k ? ctx.lineTo(x, y) : ctx.moveTo(x, y); });
ctx.stroke();
ctx.setLineDash([]);
if (fill) {
ctx.lineTo(X(c.length - 1), Y(yMin)); ctx.lineTo(X(0), Y(yMin)); ctx.closePath();
ctx.fillStyle = cols[ci % cols.length] + '33'; ctx.fill();
}
});
ctx.font = '9px ui-monospace'; ctx.fillStyle = '#6b7686'; ctx.textAlign = 'right';
ctx.fillText(fmt(yMax), w - 3, 10); ctx.fillText(fmt(yMin), w - 3, h - 3);
}
return function (t) {
const dpr = devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight || 90;
if (!off || ow !== w || oh !== h) { paint(w, h, dpr); ow = w; oh = h; }
canvas.width = w * dpr; canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(off, 0, 0); // cached curves
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const px = (t / DUR) * w; // only the cursor is drawn per frame
ctx.strokeStyle = '#ffffffaa'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.stroke();
};
}
const rng = ss => {
let a = Infinity, b = -Infinity;
ss.forEach(c => c.forEach(v => { a = Math.min(a, v); b = Math.max(b, v); }));
return [a, b];
};
const draws = [];
const jcols = ['#e0616f', '#e0955f', '#e0d15f', '#7fe05f', '#5fe0c0', '#5f9fe0', '#b46fe0'];
let jser = S.joint_idx.map(c => S.joints.map(r => r[c])), jdash = jser.map(() => false);
if (has('act_joints')) S.joint_idx.forEach(c => { jser.push(S.act_joints.map(r => r[c])); jdash.push(true); });
draws.push(mkDraw(document.getElementById('cJoints'), jser, jcols, ...rng(jser), { dash: jdash }));
if (has('end_l')) {
const er = [0, 1, 2].map(c => END_A.map(r => r[c]));
draws.push(mkDraw(document.getElementById('cEnd'), er, ['#e0616f', '#41c76b', '#4f8ff7'], ...rng(er)));
}
if (has('grip_l')) {
let gs = [S.grip_l, S.grip_r], gd = [false, false];
if (has('act_grip_l')) { gs.push(S.act_grip_l, S.act_grip_r); gd.push(true, true); }
draws.push(mkDraw(document.getElementById('cGrip'), gs, ['#41c76b', '#e0616f'], 0, 1, { dash: gd }));
}
if (has('fmag_l')) {
const [, fx] = rng([S.fmag_l, S.fmag_r]);
draws.push(mkDraw(document.getElementById('cForce'), [S.fmag_l, S.fmag_r], ['#41c76b', '#e0616f'], 0, fx || 1));
}
if (has('src')) draws.push(mkDraw(document.getElementById('cSrc'), [S.src], ['#e0a53e'], 0, 1.05, { fill: true }));
['cForce', 'cJoints', 'cEnd', 'cGrip', 'cSrc'].forEach(id => {
const c = document.getElementById(id);
if (!c) return;
c.addEventListener('click', e => {
const r = c.getBoundingClientRect();
seekTo((e.clientX - r.left) / r.width * DUR); update();
});
});
// ---- per-frame readout
function update() {
const t = master.currentTime, i = frameAt(t);
const si = steps.update(t), st = STEPS[si];
const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
set('roTime', t.toFixed(2) + 's');
set('roFrame', i + ' / ' + (S.n - 1));
set('roStep', st.id + '. ' + st.token);
set('roGrip', has('grip_l') ? S.grip_l[i].toFixed(2) + ' / ' + S.grip_r[i].toFixed(2) : '–');
set('roEndL', has('end_l') ? S.end_l[i].map(x => x.toFixed(3)).join(' / ') : '–');
set('roEndR', has('end_l') ? S.end_r[i].map(x => x.toFixed(3)).join(' / ') : '–');
set('roF', has('fmag_l') ? S.fmag_l[i].toFixed(1) + ' / ' + S.fmag_r[i].toFixed(1) : '–');
set('roSrc', has('src') ? (S.src[i] === 1 ? 'OPERATOR' : 'idle / other') : '–');
set('roSide', SIDEL + ' arm');
set('roCmd', has('act_joints') ? cmdDelta(i).toFixed(3) : '–');
if (has('eff')) {
const e = S.eff[i];
let bk = 0;
for (let k = 0; k < e.length; k++) if (Math.abs(e[k]) > Math.abs(e[bk])) bk = k;
set('roTau', S.joint_names[bk] + ' ' + e[bk].toFixed(1) + ' Nm');
}
set('roProg', Math.round((t - st.start_s) / Math.max(1e-6, st.end_s - st.start_s) * 100) +
'% of step ' + st.id);
seek.value = Math.round(t / DUR * 1000);
tlabel.textContent = t.toFixed(2) + ' / ' + DUR.toFixed(2) + 's';
drawEffort(i);
draws.forEach(d => d(t));
}
window.addEventListener('resize', update);
master.addEventListener('loadedmetadata', update);
master.addEventListener('seeked', update);
window.addEventListener('load', update);
update();
})();