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 => ({ '&': '&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);