diff --git a/docs/superpowers/specs/2026-07-18-operate-navigable-instruments-design.md b/docs/superpowers/specs/2026-07-18-operate-navigable-instruments-design.md new file mode 100644 index 00000000..0c758408 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-operate-navigable-instruments-design.md @@ -0,0 +1,139 @@ +# Operate: navigable steps + real instruments + +> **SUPERSEDED (2026-07-20).** The premise below — that Operate is a guided +> sequence the software owns, made navigable rather than removed — was dropped. +> Operate is now three independent, always-live surfaces (Bottom cam / SPIM head +> / Acquisition) with no steps, phases or run-mode chooser; gating comes only +> from live hardware state. See `feat(operate): three instrument surfaces, no +> workflow`. Kept as the record of why the step model was abandoned rather than +> repaired: each round made disclosure more navigable without questioning the +> sequence itself. The safety analysis here (F-drive floor, XY interlock, the +> 15fps TDR cap, the half-frame bug) all still holds and carried over. + +**Date:** 2026-07-18 +**Context:** v1 release prep. Ryan is the first external user; he tests on the diSPIM with live samples tomorrow morning. +**Status:** approved shape, scoped for one night. + +## The problem + +Keshu's verdict after driving the Operate view: *"Operate itself is the rigid one."* His acceptance +criteria for this work, in his words: **you can go back and forth, the views are not cluttered, and it +looks professional.** + +Three findings from the 2026-07-18 audit set the scope. + +**Rigidity has one cause.** Progressive disclosure was implemented as an exclusive CSS selector list +(`operate.css:172-182` renders exactly one `.op-group`), and *every* control lives inside a +`.op-group`. The design has no concept of a control that outlives a step. `setStep()` is only ever +called forward; the header stepper is styled as a navigable breadcrumb but its click handler responds +to one of eight nodes (`operate.js:887-892`). Disclosure became imprisonment. + +The data model is already correct: progress (`_states`, monotonic) is separate from the view cursor +(`_step`, free). Nothing structurally prevents backward navigation — no code path does it. So this is +a small change, not a rewrite. + +**Ryan lands on the wrong surface.** The devices tab defaults to Map (`devices.js:149`); the finished +guided flow hides behind an unlabeled fifth button. + +**Z is rendered as text, not as an instrument.** The device layer already returns +`{position, min, max, distance_to_floor}` on every axis call (`device_layer.py:2615-2636`) and the +frontend discards `min`/`max`. A 1 Hz `fdrive`/`bottom_z` position stream exists with zero +subscribers. The floor gauge fakes its scale with a hardcoded `MAXD=500` against real travel of +30–25000 µm. + +## The shape + +Keep the paged model. Make it reversible, decluttered, and instrumented. + +### 1. The stepper becomes a real control + +- Every node is clickable and moves `_step` in either direction. +- `_states` stays monotonic — progress is a record of what has happened, not a permission system. +- **Gates key off live hardware state, never step position.** Centering stays blocked while + `_headLowered`; down-nudges stay greyed near the floor. Revisiting a step never re-arms a motion + the hardware state forbids. This is what makes free navigation safe. +- Nodes are real ``).join('') + + s.map(v => ``).join(''); + } + host.querySelectorAll('[data-nudge]').forEach(b => { + const d = Number(b.dataset.nudge); + b.disabled = busy || (M ? !M.stepAllowed(d, st.floor) : false); }); - meta.appendChild(track); - row.appendChild(meta); + } - const sc = document.createElement('span'); sc.className = 'op-bstate'; sc.textContent = st; - row.appendChild(sc); - D['op-board'].appendChild(row); - }); + function renderTicks() { + const host = $(cfg.root + '-ticks'); + if (!host || !cfg.ticks) return; + const key = `${st.min},${st.max}`; + if (host.dataset.k === key) return; + host.dataset.k = key; + if (st.status !== 'ok' || st.min == null || st.max == null) { host.innerHTML = ''; return; } + host.innerHTML = cfg.ticks + .filter(t => t > st.min && t < st.max) + .map(t => { + const f = M ? M.gaugeFraction(t, st.min, st.max, cfg.scale) : null; + return f == null ? '' : `${t}`; + }).join(''); + } + + function render() { + const g = $(cfg.gauge); + if (g) { + g.dataset.status = st.status; + g.classList.toggle('is-near-floor', + st.status === 'ok' && st.floor != null && st.floor < 100); + } + const read = $(cfg.root + '-pos'); + if (read) { + read.textContent = st.status === 'absent' ? 'n/a' + : (st.pos == null ? '—' : st.pos.toFixed(1)); + } + const mark = $(cfg.root + '-mark'); + // Null fraction means "do not draw a marker" — a marker parked at + // the bottom of the track reads as "at the limit", which is a lie + // when the truth is that nothing is known yet. + const frac = (st.status === 'ok' && M) + ? M.gaugeFraction(st.pos, st.min, st.max, cfg.scale) : null; + if (mark) { + if (frac == null) mark.style.display = 'none'; + else { mark.style.display = 'block'; mark.style.bottom = `${(frac * 100).toFixed(2)}%`; } + } + const lo = $(cfg.root + '-min'), hi = $(cfg.root + '-max'); + if (lo) lo.textContent = st.min == null ? '—' : Math.round(st.min); + if (hi) hi.textContent = st.max == null ? '—' : Math.round(st.max); + + const track = $(cfg.root + '-track'); + if (track) { + track.setAttribute('aria-valuetext', + st.status === 'absent' ? 'axis not present' + : st.pos == null ? 'unknown' : `${st.pos.toFixed(1)} micrometres`); + } + // An absent or unreachable axis says so wherever this gauge has room + // for a line of text — the banded axis uses its band caption, the + // plain one its foot. + const statusText = st.status === 'absent' ? 'axis not present on this rig' + : st.status === 'error' ? 'position unavailable' : null; + const band = $(cfg.root + '-band'); + if (band) band.textContent = statusText || bandLabel(); + const floor = $(cfg.root + '-floor'); + if (floor) floor.textContent = st.floor == null ? '—' : Math.round(st.floor); + const foot = $(cfg.root + '-foot'); + if (foot) foot.textContent = statusText || ''; + renderTicks(); + renderNudges(); + renderLock(); + } + + async function nudge(delta) { + if (M && !M.stepAllowed(delta, st.floor)) { + toast('Too close to the floor for that step'); + return; + } + busy = true; renderNudges(); + try { + absorb(await postJSON(cfg.nudge, { delta })); + if (cfg.onDown && delta < 0) cfg.onDown(); + if (cfg.onUp && delta > 0) cfg.onUp(st); + } catch (e) { + // Even a refused nudge reports the real position — take it. + if (e && e.data && e.data.position != null) absorb(e.data); + toast(`${cfg.label} nudge blocked (${e.status || e.message})`); + } finally { busy = false; renderNudges(); } + } + + async function refresh() { + try { absorb(await getJSON(cfg.get)); } catch (e) { fail(e); } + } + + function wire() { + const host = $(cfg.root + '-nudge'); + if (host) { + host.addEventListener('click', e => { + const b = e.target.closest('[data-nudge]'); + if (b && !b.disabled) nudge(Number(b.dataset.nudge)); + }); + } + const track = $(cfg.root + '-track'); + if (track) { + track.addEventListener('keydown', e => { + const s = steps(); + const fine = s[s.length - 1], coarse = s[0]; + const map = { ArrowUp: fine, ArrowDown: -fine, PageUp: coarse, PageDown: -coarse }; + if (map[e.key] == null) return; + e.preventDefault(); + nudge(map[e.key]); + }); + } + } + + return { + wire, refresh, absorb, absorbTelemetry, render, nudge, + floor: () => st.floor, + status: () => st.status, + }; } - function renderMiniMap() { - const svg = D['op-minimap']; if (!svg) return; - while (svg.firstChild) svg.removeChild(svg.firstChild); - const pts = _embryos.map(e => resolveXY(e)).filter(Boolean); - if (_lastXY) pts.push({ x: _lastXY.X, y: _lastXY.Y }); - if (!pts.length) return; - let xMin = Math.min(...pts.map(p => p.x)), xMax = Math.max(...pts.map(p => p.x)); - let yMin = Math.min(...pts.map(p => p.y)), yMax = Math.max(...pts.map(p => p.y)); - const span = Math.max(xMax - xMin, yMax - yMin, 100), padf = span * 0.18; - const cx = (xMin + xMax) / 2, cy = (yMin + yMax) / 2, half = span / 2 + padf; - const toX = x => ((x - (cx - half)) / (2 * half)) * 100; - const toY = y => 100 - ((y - (cy - half)) / (2 * half)) * 100; // flip Y (stage +Y up) - const r = 2.4; - _embryos.forEach(emb => { - const xy = resolveXY(emb); if (!xy) return; - const st = _states[emb.id] || 'marked'; - const c = document.createElementNS(SVG_NS, 'circle'); - c.setAttribute('cx', toX(xy.x)); c.setAttribute('cy', toY(xy.y)); c.setAttribute('r', r); - const col = st === 'imaged' ? 'var(--accent-green)' : (st === 'focused' || st === 'calibrated') ? 'var(--accent-orange)' - : (st === 'centered' || st === 'lowering') ? 'var(--accent)' : ROLE_NEUTRAL; - c.setAttribute('fill', col); - if (emb.id === _selected) { c.setAttribute('stroke', 'var(--text)'); c.setAttribute('stroke-width', '1.2'); } - svg.appendChild(c); + const bz = makeZAxis({ + root: 'op-bz', gauge: 'op-gauge-bz', label: 'Bottom-Z', + get: '/api/devices/stage/bottom_z', + nudge: '/api/devices/stage/bottom_z/nudge', + steps: [10, 1], scale: 'linear', bands: false, + }); + const fd = makeZAxis({ + root: 'op-fd', gauge: 'op-gauge-fd', label: 'F-drive', + get: '/api/devices/spim/fdrive', + nudge: '/api/devices/spim/fdrive/nudge', + bands: true, scale: 'log', ticks: [100, 1000, 10000], + steps: [50, 10, 5], + onDown: () => setHeadLowered(true), + }); + + // ══ THE INTERLOCK, MADE VISIBLE ═════════════════════════════════════════ + // Enforcement alone is not enough: with click-to-center always available, + // the operator must be able to see WHY a click will not do anything. The + // affordance withdraws itself (locked cursor) and the banner says so. + function renderLock() { + const eng = isEngaged(); + const d = fd.floor(); + ['bottom', 'spim'].forEach(p => { + const el = $(`op-lock-${p}`); + if (el) el.hidden = !eng; + const dd = $(`op-lock-${p}-d`); + if (dd) dd.textContent = d == null ? '—' : Math.round(d); }); - if (_lastXY) { // stage crosshair - const X = toX(_lastXY.X), Y = toY(_lastXY.Y); - [[X - 4, Y, X + 4, Y], [X, Y - 4, X, Y + 4]].forEach(([x1, y1, x2, y2]) => { - const l = document.createElementNS(SVG_NS, 'line'); - l.setAttribute('x1', x1); l.setAttribute('y1', y1); l.setAttribute('x2', x2); l.setAttribute('y2', y2); - l.setAttribute('stroke', 'var(--accent-cyan)'); l.setAttribute('stroke-width', '0.8'); - svg.appendChild(l); - }); + const cam = $('op-cam-bottom'); + if (cam) cam.classList.toggle('is-locked', eng); + drawMarkers(); + } + // Always reachable, unlike the old design where clearing the latch lived on + // a step you might never arrive at — lower the head, never reach it, and XY + // stayed locked forever with no escape short of clearing sessionStorage. + async function backOff() { + try { + fd.absorb(await postJSON('/api/devices/spim/fdrive/nudge', { delta: 100 })); + // A retract that FAILS must not report the head as up: that is the + // state the XY chokepoint trusts before commanding an absolute move. + setHeadLowered(false); + toast('Backed off 100 µm'); + } catch (e) { + if (e && e.data && e.data.position != null) fd.absorb(e.data); + toast(`Back-off failed (${e.status || e.message}) — head still down`); + } + } + + // ══ VIEWPORT ════════════════════════════════════════════════════════════ + function frameOf(p) { + if (!p || !Array.isArray(p.shape)) return null; + return { w: p.shape[1], h: p.shape[0], downsample: p.downsample || 1 }; + } + function stageOf(p) { + if (p && Array.isArray(p.stage_position) && p.stage_position.length === 2) return p.stage_position; + // The device layer OMITS stage_position rather than defaulting it to + // [0,0] when it cannot know it. Honour that: fall back to the position + // stream, never to the origin. + if (_xy && Number.isFinite(_xy.x) && Number.isFinite(_xy.y)) return [_xy.x, _xy.y]; + return null; + } + function setImg(imgId, phId, p) { + const img = $(imgId), ph = $(phId); + if (!img || !p || !p.jpeg_b64) return; + img.src = `data:${p.mime || 'image/jpeg'};base64,${p.jpeg_b64}`; + if (!img.classList.contains('has-frame')) { + img.classList.add('has-frame'); + if (ph) ph.style.display = 'none'; + } + // Match the viewport box to the frame's aspect so the border hugs the + // image. naturalWidth is 0 until the data URL decodes, so fall back to a + // one-shot load listener. + if (img.naturalWidth && img.naturalHeight) setCamAspect(img); + else img.addEventListener('load', () => setCamAspect(img), { once: true }); + } + function setCamAspect(img) { + const fit = img.closest('.op-cam-fit'); + if (fit && img.naturalWidth && img.naturalHeight) { + fit.style.setProperty('--cam-ar', `${img.naturalWidth} / ${img.naturalHeight}`); } } + function clearImg(imgId, phId, text) { + const img = $(imgId), ph = $(phId); + if (img) img.classList.remove('has-frame'); + if (ph) { ph.style.display = ''; if (text) ph.textContent = text; } + } + // Stopping the stream freezes the last frame in place rather than clearing + // it: an operator wants to keep reading what is on the sample surface after + // ending live view. Only fall back to the placeholder when no frame was + // ever shown. The "LIVE" badge dropping (renderSubnavMeta) is the cue that + // the frame is now static. + function freezeImg(imgId, phId, text) { + const img = $(imgId); + if (img && img.classList.contains('has-frame')) return; + clearImg(imgId, phId, text); + } - // ── viewport overlays ────────────────────────────────────────────────── + // Letterbox geometry for an object-fit: contain image, in CSS pixels. + // Measured off the CANVAS, not its host: it is the element being drawn into, + // and using one source for geometry and for the backing store keeps them + // from disagreeing. function renderedRect() { - const sb = D['op-cam-stage'].getBoundingClientRect(); - const fw = _frozenFrame ? _frozenFrame.w : (_lastFrame ? _lastFrame.shape[1] : sb.width); - const fh = _frozenFrame ? _frozenFrame.h : (_lastFrame ? _lastFrame.shape[0] : sb.height); + const c = $('op-mark-canvas'); + if (!c) return null; + const sb = c.getBoundingClientRect(); + if (!(sb.width > 0 && sb.height > 0)) return null; + const f = frameOf(_lastBottom); + const fw = f ? f.w : sb.width, fh = f ? f.h : sb.height; const ar = fw / fh, sar = sb.width / sb.height; let w, h; - if (ar > sar) { w = sb.width; h = sb.width / ar; } else { h = sb.height; w = sb.height * ar; } + if (ar > sar) { w = sb.width; h = sb.width / ar; } + else { h = sb.height; w = sb.height * ar; } return { x: (sb.width - w) / 2, y: (sb.height - h) / 2, w, h, fw, fh, sb }; } function canvasCtx() { - const sb = D['op-cam-stage'].getBoundingClientRect(); - D['op-mark-canvas'].width = Math.round(sb.width); D['op-mark-canvas'].height = Math.round(sb.height); - const ctx = D['op-mark-canvas'].getContext('2d'); - ctx.clearRect(0, 0, D['op-mark-canvas'].width, D['op-mark-canvas'].height); + const c = $('op-mark-canvas'); + if (!c) return null; + const r = c.getBoundingClientRect(); + if (!(r.width > 0 && r.height > 0)) return null; + // The backing store must track the CSS box or everything drawn is + // scaled — a stale height renders circles as ellipses. Scaling by dpr + // keeps it crisp on fractional-ratio displays; the transform then lets + // every drawing call stay in CSS pixels. + const dpr = window.devicePixelRatio || 1; + const w = Math.round(r.width * dpr), h = Math.round(r.height * dpr); + if (c.width !== w || c.height !== h) { c.width = w; c.height = h; } + const ctx = c.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, r.width, r.height); return ctx; } - function drawOverlay(step) { - if (!D['op-mark-canvas']) return; - if (step === 'a2') return drawMarkers(); - const ctx = canvasCtx(); - if (step === 'b1') drawReticle(ctx); - else if (step === 'b2') drawFloorGauge(ctx); + // Project a stage-space marker onto the current frame, then onto the canvas. + function markerToCanvas(m, r) { + const f = frameOf(_lastBottom), cap = stageOf(_lastBottom); + if (!f || !cap || !M) return null; + const px = M.stageToFrame(m.stageX, m.stageY, f, cap); + if (!px) return null; + return { cx: r.x + (px[0] / r.fw) * r.w, cy: r.y + (px[1] / r.fh) * r.h, px }; } - function drawMarkers() { - const r = renderedRect(); const ctx = canvasCtx(); - _markers.forEach((m, i) => { - const cx = r.x + (m.fx / r.fw) * r.w, cy = r.y + (m.fy / r.fh) * r.h; - ctx.beginPath(); ctx.arc(cx, cy, 11, 0, 7); ctx.lineWidth = 2; ctx.strokeStyle = '#34d399'; ctx.stroke(); - ctx.beginPath(); ctx.moveTo(cx - 6, cy); ctx.lineTo(cx + 6, cy); ctx.moveTo(cx, cy - 6); ctx.lineTo(cx, cy + 6); ctx.stroke(); - ctx.fillStyle = '#34d399'; ctx.font = '600 11px Inter Tight, sans-serif'; ctx.fillText(String(i + 1), cx + 13, cy - 8); + // Registered embryos, projected onto the current frame. These are the + // click-to-center targets; pending markers are a separate, editable set. + function embryoPoints(r) { + const f = frameOf(_lastBottom), cap = stageOf(_lastBottom); + if (!f || !cap || !M) return []; + const out = []; + _embryos.forEach(emb => { + const xy = resolveXY(emb); + if (!xy) return; + const px = M.stageToFrame(xy.x, xy.y, f, cap); + if (!px) return; + out.push({ emb, cx: r.x + (px[0] / r.fw) * r.w, cy: r.y + (px[1] / r.fh) * r.h }); }); + return out; } - function drawReticle(ctx) { - const sb = D['op-mark-canvas']; const cx = sb.width / 2, cy = sb.height / 2; - ctx.strokeStyle = 'rgba(96,165,250,0.85)'; ctx.lineWidth = 1.2; - ctx.beginPath(); ctx.moveTo(cx - 22, cy); ctx.lineTo(cx + 22, cy); ctx.moveTo(cx, cy - 22); ctx.lineTo(cx, cy + 22); ctx.stroke(); - ctx.beginPath(); ctx.arc(cx, cy, 10, 0, 7); ctx.stroke(); - // SPIM-FOV footprint box (light-sheet FOV << bottom FOV) - const r = renderedRect(); const bw = r.w * 0.22, bh = r.h * 0.22; - ctx.setLineDash([5, 3]); ctx.strokeStyle = 'rgba(34,211,238,0.7)'; - ctx.strokeRect(cx - bw / 2, cy - bh / 2, bw, bh); ctx.setLineDash([]); - ctx.fillStyle = 'rgba(34,211,238,0.85)'; ctx.font = '600 10px Inter Tight, sans-serif'; - ctx.fillText('SPIM FOV', cx - bw / 2, cy - bh / 2 - 4); - } - function drawFloorGauge(ctx) { - const sb = D['op-mark-canvas']; const pad = 24, h = 26, y = sb.height - pad - h, w = sb.width - 2 * pad, x = pad; - const floor = _fdFloor; - ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(x, y, w, h); - ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.strokeRect(x, y, w, h); - // fill: full when far from floor, shrinks toward floor - const MAXD = 500; - const frac = floor == null ? 0 : Math.max(0, Math.min(1, floor / MAXD)); - const col = floor == null ? '#555' : floor <= 30 ? '#ef4444' : floor < 150 ? '#fb923c' : '#4ade80'; - ctx.fillStyle = col; ctx.fillRect(x + 2, y + 2, (w - 4) * frac, h - 4); - ctx.fillStyle = '#fff'; ctx.font = '700 13px Inter Tight, sans-serif'; - ctx.fillText(floor == null ? 'distance to floor —' : `${Math.round(floor)} µm to floor (30 µm hard floor)`, x + 8, y + h - 8); - } - - // ── bottom-cam frames ────────────────────────────────────────────────── - function onBottomFrame(p) { - if (!p || !p.jpeg_b64) return; - _lastFrame = p; - if (p.focus_score != null) { _bzScore = p.focus_score; if (D['op-bz-score']) D['op-bz-score'].textContent = Number(p.focus_score).toFixed(3); } - const cam = cameraForStep(effectiveStep()); - if (cam === 'bottom' && !_marking) setImg(p); - } - function onLightsheetFrame(p) { - if (!p || !p.jpeg_b64) return; - _lastSpimFrame = p; - if (p.focus_score != null) { _spimScore = p.focus_score; if (D['op-spim-score']) D['op-spim-score'].textContent = Number(p.focus_score).toFixed(3); } - if (cameraForStep(effectiveStep()) === 'spim') setImg(p); - } - - // ── A1 focus bottom ──────────────────────────────────────────────────── - async function toggleCamera() { - D['op-cam-toggle'].disabled = true; - try { - const ep = _camOn ? '/api/devices/bottom_camera/stream/stop' : '/api/devices/bottom_camera/stream/start'; - const d = await postJSON(ep, {}); applyCam(!!d.streaming); - } catch (e) { toast(`Camera toggle failed (${e.status || e.message})`); } - finally { D['op-cam-toggle'].disabled = false; } - } - function applyCam(on) { - _camOn = on; - D['op-cam-toggle'].textContent = on ? 'Stop camera' : 'Start camera'; - D['op-cam-toggle'].classList.toggle('op-btn-on', on); - if (!on && !_marking) { D['op-cam-img'].classList.remove('has-frame'); D['op-cam-ph'].style.display = ''; D['op-cam-ph'].textContent = 'Camera off'; } - renderStatus(); - } - async function nudgeBottomZ(delta) { - if (_marking) { toast('Finish marking first'); return; } - try { const d = await postJSON('/api/devices/stage/bottom_z/nudge', { delta }); if (d.position != null) D['op-bz-pos'].textContent = Number(d.position).toFixed(1); } - catch (e) { toast(`Bottom-Z nudge blocked (${e.status || e.message})`); } - } - - // ── A2 mark ──────────────────────────────────────────────────────────── - function umPerPxDisplay() { return BASE_UM_PER_PX * ((_frozenFrame && _frozenFrame.downsample) || 1); } - function frameToStage(fx, fy) { - const u = umPerPxDisplay(), cx = _frozenFrame.w / 2, cy = _frozenFrame.h / 2; - return [_captureStage[0] + (fx - cx) * u, _captureStage[1] - (fy - cy) * u]; - } - function enterMarking(cands) { - if (!_lastFrame) { toast('Start the camera first'); return false; } - // Absolute stage origin for pixel→stage conversion. Prefer the XY - // stamped on the live frame by the device layer; fall back to the - // position stream. NEVER default to [0, 0] — that silently converts - // clicks to offsets from stage origin, so embryos land hundreds of µm - // off and calibration images empty field. Block marking instead. - const capStage = (Array.isArray(_lastFrame.stage_position) && _lastFrame.stage_position.length === 2) - ? _lastFrame.stage_position - : (_lastXY ? [_lastXY.X, _lastXY.Y] : null); - if (!capStage) { toast('Stage position unknown — wait for the position readout, then mark'); return false; } - _marking = true; - _frozenFrame = { w: _lastFrame.shape[1], h: _lastFrame.shape[0], downsample: _lastFrame.downsample || 1 }; - _captureStage = capStage; - _frozenSrc = `data:${_lastFrame.mime || 'image/jpeg'};base64,${_lastFrame.jpeg_b64}`; - D['op-cam-img'].src = _frozenSrc; D['op-cam-img'].classList.add('has-frame'); D['op-cam-ph'].style.display = 'none'; - _markers = []; - (cands || []).forEach(c => { - const ds = _frozenFrame.downsample; - const fx = c.pixel_x != null ? c.pixel_x / ds : _frozenFrame.w / 2; - const fy = c.pixel_y != null ? c.pixel_y / ds : _frozenFrame.h / 2; - const s = (c.stage_x_um != null && c.stage_y_um != null) ? [c.stage_x_um, c.stage_y_um] : frameToStage(fx, fy); - _markers.push({ fx, fy, stageX: s[0], stageY: s[1], source: 'sam' }); + + function drawMarkers() { + // Render dispatch, not gating: the canvas has no size while its pane is + // hidden, so there is nothing to draw onto. + if (_pane !== 'bottom') return; + const ctx = canvasCtx(); + if (!ctx) return; + const r = renderedRect(); + if (!r) return; + + // Registered embryos first, so pending markers draw over them. + embryoPoints(r).forEach(({ emb, cx, cy }) => { + const sel = emb.id === _selected; + ctx.save(); + ctx.strokeStyle = isEngaged() ? '#7d8899' : (sel ? '#93c5fd' : '#60a5fa'); + ctx.fillStyle = ctx.strokeStyle; + ctx.lineWidth = sel ? 2 : 1.2; + if (isEngaged()) ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.arc(cx, cy, 13, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + ctx.beginPath(); ctx.arc(cx, cy, 2.5, 0, Math.PI * 2); ctx.fill(); + ctx.font = '600 11px Inter Tight, sans-serif'; + ctx.fillText(labelFor(emb), cx + 16, cy + 4); + ctx.restore(); + }); + const locked = isEngaged(); + const colour = locked ? '#7d8899' : '#4ade80'; + _markers.forEach((m, i) => { + const p = markerToCanvas(m, r); + if (!p) return; + const { cx, cy } = p; + ctx.save(); + ctx.strokeStyle = colour; + ctx.lineWidth = locked ? 1.2 : 2; + if (locked) ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.arc(cx, cy, 11, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(cx - 6, cy); ctx.lineTo(cx + 6, cy); + ctx.moveTo(cx, cy - 6); ctx.lineTo(cx, cy + 6); + ctx.stroke(); + ctx.fillStyle = colour; + ctx.font = '600 11px Inter Tight, sans-serif'; + ctx.fillText(String(i + 1), cx + 13, cy - 8); + ctx.restore(); }); - D['op-cam-stage'].classList.add('is-marking'); - renderStep(); updateMarkCount(); - return true; - } - function exitMarking() { - _marking = false; _markers = []; _frozenSrc = null; _frozenFrame = null; - D['op-cam-stage'].classList.remove('is-marking'); - } - function updateMarkCount() { - const n = _markers.length; - if (D['op-mark-count']) D['op-mark-count'].textContent = `(${n})`; - if (D['op-confirm']) D['op-confirm'].disabled = n === 0; - if (D['op-clear']) D['op-clear'].disabled = n === 0; } + function onCanvasClick(e) { - if (!_marking) return; - const r = renderedRect(), rect = D['op-mark-canvas'].getBoundingClientRect(); + const r = renderedRect(); + const c = $('op-mark-canvas'); + if (!r || !c) return; + const rect = c.getBoundingClientRect(); const cxv = e.clientX - rect.left, cyv = e.clientY - rect.top; + + // Click a pending marker to remove it. for (let i = 0; i < _markers.length; i++) { - const mx = r.x + (_markers[i].fx / r.fw) * r.w, my = r.y + (_markers[i].fy / r.fh) * r.h; - if (Math.hypot(cxv - mx, cyv - my) <= MARK_HIT_PX) { _markers.splice(i, 1); drawMarkers(); updateMarkCount(); return; } + const p = markerToCanvas(_markers[i], r); + if (p && Math.hypot(cxv - p.cx, cyv - p.cy) <= MARK_HIT_PX) { + // Editing the set invalidates the "N candidates added" note — it + // was the detection-time count, not the current one. + _markers.splice(i, 1); drawMarkers(); renderMarkCount(); setDetectNote(''); return; + } + } + // Click a registered embryo to select it and centre the stage on it. + // Always available — the interlock lives in moveStageTo, not here. + for (const p of embryoPoints(r)) { + if (Math.hypot(cxv - p.cx, cyv - p.cy) <= MARK_HIT_PX) { + centerOnEmbryo(p.emb); + return; + } } if (cxv < r.x || cxv > r.x + r.w || cyv < r.y || cyv > r.y + r.h) return; - const fx = ((cxv - r.x) / r.w) * r.fw, fy = ((cyv - r.y) / r.h) * r.fh, s = frameToStage(fx, fy); - _markers.push({ fx, fy, stageX: s[0], stageY: s[1], source: 'manual' }); - drawMarkers(); updateMarkCount(); + + const f = frameOf(_lastBottom), cap = stageOf(_lastBottom); + if (!f) { toast('Start the camera first'); return; } + // NEVER default the capture position to [0,0]: that silently converts + // clicks into offsets from stage origin, so embryos land hundreds of µm + // away and calibration images empty field. Refuse to mark instead. + if (!cap) { toast('Stage position unknown — wait for the readout, then mark'); return; } + + const fx = ((cxv - r.x) / r.w) * r.fw, fy = ((cyv - r.y) / r.h) * r.fh; + const s = M && M.frameToStage(fx, fy, f, cap); + if (!s) { toast('Cannot place a marker without a stage position'); return; } + _markers.push({ stageX: s[0], stageY: s[1], source: 'manual' }); + drawMarkers(); renderMarkCount(); + } + + async function centerOnEmbryo(emb) { + const xy = resolveXY(emb); + if (!xy) { toast('That embryo has no recorded position'); return; } + selectEmbryo(emb.id); + await moveStageTo(xy.x, xy.y, `Centred on embryo ${labelFor(emb)}`); + } + + function renderMarkCount() { + const n = _markers.length; + const c = $('op-mark-count'); if (c) c.textContent = n; + const ok = $('op-confirm'); if (ok) ok.disabled = n === 0; + const cl = $('op-clear'); if (cl) cl.disabled = n === 0; + } + function setDetectNote(text) { + const note = $('op-detect-note'); if (note) note.textContent = text || ''; + } + + // ══ BOTTOM PANE ═════════════════════════════════════════════════════════ + async function toggleBottomCam() { + const b = $('op-cam-toggle'); if (b) b.disabled = true; + try { + const ep = _bottomOn ? '/api/devices/bottom_camera/stream/stop' + : '/api/devices/bottom_camera/stream/start'; + const d = await postJSON(ep, {}); + applyBottomCam(!!d.streaming); + _bottomWasOn = _bottomOn; + } catch (e) { toast(`Camera toggle failed (${e.status || e.message})`); } + finally { if (b) b.disabled = false; } + } + function applyBottomCam(on) { + _bottomOn = on; + const b = $('op-cam-toggle'); + if (b) { b.textContent = on ? 'Stop camera' : 'Start camera'; b.classList.toggle('is-on', on); } + if (!on) freezeImg('op-img-bottom', 'op-ph-bottom', 'Camera off'); + renderSubnavMeta(); + } + + function setBusyText(t) { + const el = document.querySelector('#op-busy-bottom .op-cam-busy-txt'); + if (el) el.textContent = t; } async function runDetect() { - if (!_marking && !enterMarking([])) return; - D['op-detect'].disabled = true; D['op-detect'].textContent = 'Detecting…'; + const b = $('op-detect'); + if (b) { b.disabled = true; b.textContent = 'Detecting…'; } + const busy = $('op-busy-bottom'); if (busy) busy.hidden = false; + const note = $('op-detect-note'); + // Detect on the frame already on screen when there is one — the operator + // is looking at it, and re-capturing would disturb the LED/room light. + const shown = $('op-img-bottom'); + let hasFrame = !!(shown && shown.classList.contains('has-frame')); try { - const d = await postJSON('/api/devices/detect_embryos', {}); + // Phase 1 — when the viewport is empty, capture and SHOW the image + // FIRST (no SAM yet), so the operator sees what detection will run on + // before it runs, rather than the image appearing only at the end. + if (!hasFrame) { + setBusyText('Capturing…'); + const cap = await postJSON('/api/devices/detect_embryos', { capture_only: true }); + if (cap.frame && cap.frame.jpeg_b64) { + _lastBottom = cap.frame; + setImg('op-img-bottom', 'op-ph-bottom', cap.frame); + hasFrame = true; + } + } + // Phase 2 — run SAM on the frame now on screen, then overlay results. + setBusyText('Detecting…'); + const d = await postJSON('/api/devices/detect_embryos', { use_last_frame: hasFrame }); + if (d.frame && d.frame.jpeg_b64) { + _lastBottom = d.frame; + setImg('op-img-bottom', 'op-ph-bottom', d.frame); + } const cands = Array.isArray(d.embryos) ? d.embryos : []; - if (_lastFrame && d.stage_position) { _lastFrame.stage_position = d.stage_position; _captureStage = d.stage_position; } + const f = frameOf(_lastBottom); + const cap = d.stage_position || stageOf(_lastBottom); + // A fresh detection REPLACES the previous auto-detected set rather + // than piling onto it (re-running would otherwise double the marks). + // Manual marks are kept — only 'sam' ones are cleared. + _markers = _markers.filter(m => m.source !== 'sam'); + let added = 0; cands.forEach(c => { - const ds = _frozenFrame.downsample; - const fx = c.pixel_x != null ? c.pixel_x / ds : _frozenFrame.w / 2; - const fy = c.pixel_y != null ? c.pixel_y / ds : _frozenFrame.h / 2; - const s = (c.stage_x_um != null && c.stage_y_um != null) ? [c.stage_x_um, c.stage_y_um] : frameToStage(fx, fy); - _markers.push({ fx, fy, stageX: s[0], stageY: s[1], source: 'sam' }); + let sx = c.stage_x_um, sy = c.stage_y_um; + if ((sx == null || sy == null) && f && cap && M && c.pixel_x != null && c.pixel_y != null) { + const s = M.frameToStage(c.pixel_x / f.downsample, c.pixel_y / f.downsample, f, cap); + if (s) { sx = s[0]; sy = s[1]; } + } + if (sx == null || sy == null) return; + _markers.push({ stageX: sx, stageY: sy, source: 'sam' }); + added++; }); - drawMarkers(); updateMarkCount(); - toast(`Detected ${cands.length} candidate${cands.length === 1 ? '' : 's'}`); - } catch (e) { toast(`Detect failed (${e.status || e.message})`); } - finally { D['op-detect'].disabled = false; D['op-detect'].textContent = 'Detect (SAM)'; } + drawMarkers(); renderMarkCount(); + if (note) note.textContent = `${added} candidate${added === 1 ? '' : 's'} added — edit them on the image, then register.`; + toast(`Detected ${added} candidate${added === 1 ? '' : 's'}`); + } catch (e) { + if (e.status === 503) { + if (note) note.textContent = 'Automatic detection is unavailable on this rig — mark by clicking the image.'; + } else { + toast(`Detect failed (${e.status || e.message})`); + } + } finally { + if (b) { b.disabled = false; b.textContent = 'Detect automatically'; } + if (busy) busy.hidden = true; + } } + async function confirmMarks() { if (!_markers.length) return; - if (!Array.isArray(_captureStage) || _captureStage.length !== 2) { - toast('Stage position unknown — cannot register markers'); return; - } - D['op-confirm'].disabled = true; + const f = frameOf(_lastBottom), cap = stageOf(_lastBottom); + if (!cap) { toast('Stage position unknown — cannot register markers'); return; } + const b = $('op-confirm'); if (b) b.disabled = true; try { - const markers = _markers.map(m => ({ stage_x_um: m.stageX, stage_y_um: m.stageY, pixel_x: m.fx, pixel_y: m.fy, source: m.source })); + // The payload shape is a hard contract with _persist_detection_labels + // (routes/data.py), which turns every confirm into training data for + // the localiser that will replace SAM. Pixel coords are projected + // from stage space against the frame being submitted. + const markers = _markers.map(m => { + const px = (f && M) ? M.stageToFrame(m.stageX, m.stageY, f, cap) : null; + return { + stage_x_um: m.stageX, stage_y_um: m.stageY, + pixel_x: px ? px[0] : undefined, pixel_y: px ? px[1] : undefined, + source: m.source, + }; + }); const d = await postJSON('/api/devices/embryos/confirm', { - markers, image_b64: _frozenSrc ? _frozenSrc.split(',')[1] : undefined, - frame: _frozenFrame ? { w: _frozenFrame.w, h: _frozenFrame.h, downsample: _frozenFrame.downsample } : undefined, - stage_position: _captureStage, + markers, + image_b64: _lastBottom ? _lastBottom.jpeg_b64 : undefined, + frame: f ? { w: f.w, h: f.h, downsample: f.downsample } : undefined, + stage_position: cap, }); - toast(`Registered ${(d.registered || []).length} embryo${(d.registered || []).length === 1 ? '' : 's'}`); - exitMarking(); // EMBRYOS_UPDATE refreshes the board, then auto-select first - } catch (e) { toast(`Confirm failed (${e.status || e.message})`); D['op-confirm'].disabled = false; } - } - - // ── B1 center ────────────────────────────────────────────────────────── - function selectEmbryo(id) { - _selected = id; - if (id) _step = stepForState(_states[id] || 'marked'); - renderStep(); - } - async function centerOnSelected() { - if (_headLowered) { toast('Retract the SPIM head first'); return; } - const emb = _embryos.find(e => e.id === _selected), xy = emb ? resolveXY(emb) : null; - if (!xy) return; - D['op-center'].disabled = true; D['op-center'].textContent = 'Centering…'; - try { - await postJSON('/api/devices/stage/move', { x: xy.x, y: xy.y }); - advanceState(_selected, 'centered'); setStep('b2'); - toast(`Centred on embryo ${labelFor(emb)}`); - } catch (e) { toast(`Center failed (${e.status || e.message})`); D['op-center'].disabled = false; D['op-center'].textContent = 'Center stage on embryo'; } - } - function advanceState(id, st) { - if (!id) return; - if (STATE_RANK[st] > STATE_RANK[_states[id] || 'marked']) _states[id] = st; - renderStep(); - } - - // ── B2 lower (F-drive, fenced) ───────────────────────────────────────── - function gateFdriveNudges() { - // auto-grey down-nudges that would exceed remaining distance-to-floor - if (D['op-fd-d100']) D['op-fd-d100'].disabled = _fdFloor != null && _fdFloor < 100; - if (D['op-fd-d10']) D['op-fd-d10'].disabled = _fdFloor != null && _fdFloor < 10; - } - async function nudgeFdrive(delta) { - if (delta < 0 && _fdFloor != null && Math.abs(delta) > _fdFloor) { toast('Too close to the floor for that step'); return; } - try { - const d = await postJSON('/api/devices/spim/fdrive/nudge', { delta }); - if (d.position != null) { _fdPos = d.position; D['op-fd-pos'].textContent = Number(d.position).toFixed(1); } - if (d.distance_to_floor != null) { _fdFloor = d.distance_to_floor; D['op-fd-floor'].textContent = Number(d.distance_to_floor).toFixed(0); } - if (delta < 0) { _headLowered = true; if (_selected) advanceState(_selected, 'lowering'); } - renderStep(); // refresh gauge + gates + status - } catch (e) { toast(`F-drive nudge blocked (${e.status || e.message})`); } + const n = (d.registered || []).length; + _markers = []; + drawMarkers(); renderMarkCount(); + setDetectNote(`Registered ${n} embryo${n === 1 ? '' : 's'} — they're in the roster and on the SPIM head.`); + toast(`Registered ${n} embryo${n === 1 ? '' : 's'}`); + } catch (e) { + toast(`Register failed (${e.status || e.message})`); + if (b) b.disabled = false; + } } - // ── B3 focus SPIM ────────────────────────────────────────────────────── + // ══ SPIM PANE ═══════════════════════════════════════════════════════════ async function toggleSpim() { - D['op-spim-toggle'].disabled = true; + const b = $('op-spim-toggle'); if (b) b.disabled = true; try { const ep = _spimOn ? '/api/devices/lightsheet/live/stop' : '/api/devices/lightsheet/live/start'; - const d = await postJSON(ep, {}); _spimOn = !!d.streaming; - D['op-spim-toggle'].textContent = _spimOn ? 'Stop view' : 'Start view'; - D['op-spim-toggle'].classList.toggle('op-btn-on', _spimOn); - if (!_spimOn) { D['op-cam-img'].classList.remove('has-frame'); D['op-cam-ph'].style.display = ''; } - renderStatus(); + const d = await postJSON(ep, {}); + applySpim(!!d.streaming); + _spimWasOn = _spimOn; } catch (e) { toast(`SPIM view toggle failed (${e.status || e.message})`); } - finally { D['op-spim-toggle'].disabled = false; } + finally { if (b) b.disabled = false; } } + function applySpim(on) { + _spimOn = on; + const b = $('op-spim-toggle'); + if (b) { b.textContent = on ? 'Stop view' : 'Start view'; b.classList.toggle('is-on', on); } + if (!on) freezeImg('op-img-spim', 'op-ph-spim', 'View off'); + renderSubnavMeta(); + } + let _lsTimer = null; function postLsParams() { if (_lsTimer) clearTimeout(_lsTimer); - _lsTimer = setTimeout(() => { postJSON('/api/devices/lightsheet/live/params', { galvo: _galvo, piezo: _piezo, exposure: 20, side: 'A' }).catch(() => {}); }, 120); + _lsTimer = setTimeout(() => { + postJSON('/api/devices/lightsheet/live/params', + { galvo: _galvo, piezo: _piezo, exposure: 20, side: 'A' }).catch(() => {}); + }, 120); + } + function nudgeGalvo(d) { + _galvo = Math.max(-5, Math.min(5, _galvo + d)); + const el = $('op-gv'); if (el) el.textContent = _galvo.toFixed(1); + postLsParams(); + } + function nudgePiezo(d) { + _piezo = Math.max(0, Math.min(200, _piezo + d)); + const el = $('op-pz'); if (el) el.textContent = _piezo.toFixed(0); + postLsParams(); } - function nudgeGalvo(d) { _galvo = Math.max(-5, Math.min(5, _galvo + d)); D['op-gv'].textContent = _galvo.toFixed(1); postLsParams(); } - function nudgePiezo(d) { _piezo = Math.max(0, Math.min(200, _piezo + d)); D['op-pz'].textContent = _piezo.toFixed(0); postLsParams(); } async function toggleLed() { _ledOn = !_ledOn; - D['op-led'].classList.toggle('op-btn-toggle', true); D['op-led'].setAttribute('aria-pressed', _ledOn ? 'true' : 'false'); - D['op-led'].classList.toggle('op-btn-on', _ledOn); - try { await postJSON('/api/devices/led/set', { state: _ledOn ? 'Open' : 'Closed' }); } catch (e) { toast(`LED failed (${e.status || e.message})`); } - renderStatus(); + applyLed(); + try { await postJSON('/api/devices/led/set', { state: _ledOn ? 'Open' : 'Closed' }); } + catch (e) { toast(`LED failed (${e.status || e.message})`); } + } + function applyLed() { + const b = $('op-led'); + if (b) { + b.setAttribute('aria-pressed', _ledOn ? 'true' : 'false'); + b.classList.toggle('is-emitting', _ledOn); + } + renderSubnavMeta(); } async function forceLedOff() { if (!_ledOn) return; - _ledOn = false; D['op-led'].setAttribute('aria-pressed', 'false'); D['op-led'].classList.remove('op-btn-on'); + _ledOn = false; applyLed(); try { await postJSON('/api/devices/led/set', { state: 'Closed' }); } catch (_) {} - renderStatus(); - } - function markInFocus() { - if (!_selected) return; - advanceState(_selected, 'focused'); forceLedOff(); setStep('bc'); - toast(`Embryo ${labelFor(_embryos.find(e => e.id === _selected))} in focus`); } - // ── B-cal calibrate (piezo-galvo) ────────────────────────────────────── async function calibrateSelected() { - if (!_selected) return; - if (STATE_RANK[_states[_selected] || 'marked'] < STATE_RANK.focused) { toast('Focus the embryo first'); return; } - D['op-calibrate'].disabled = true; D['op-calibrate'].textContent = 'Calibrating…'; - if (D['op-cal-result']) D['op-cal-result'].textContent = 'sweeping…'; + if (!_selected) { toast('Select an embryo first'); return; } + const b = $('op-calibrate'), out = $('op-cal-result'); + if (b) { b.disabled = true; b.textContent = 'Calibrating…'; } + if (out) out.textContent = 'sweeping…'; try { const d = await postJSON(`/api/devices/embryos/${_selected}/calibrate`, {}); const cal = d.calibration || {}; const slope = cal.slope_um_per_deg, r2 = cal.r_squared; - if (D['op-cal-result']) { - D['op-cal-result'].textContent = (slope != null) + if (out) { + out.textContent = (slope != null) ? `${Number(slope).toFixed(1)} µm/deg${r2 != null ? ` · R² ${Number(r2).toFixed(2)}` : ''}` : 'done'; } - advanceState(_selected, 'calibrated'); setStep('b4'); - toast(`Calibrated embryo ${labelFor(_embryos.find(e => e.id === _selected))}`); } catch (e) { - if (D['op-cal-result']) D['op-cal-result'].textContent = 'failed'; + if (out) out.textContent = 'failed'; toast(`Calibrate failed (${e.status || e.message})`); - } finally { - D['op-calibrate'].disabled = false; D['op-calibrate'].textContent = 'Calibrate this embryo'; - } - } - function skipCalibration() { - if (!_selected) return; - advanceState(_selected, 'calibrated'); setStep('b4'); - toast('Calibration skipped'); + } finally { if (b) { b.disabled = false; b.textContent = 'Calibrate piezo–galvo'; } } } - // ── B4 acquire ───────────────────────────────────────────────────────── - async function acquireSelected() { - if (!_selected || STATE_RANK[_states[_selected] || 'marked'] < STATE_RANK.focused) { toast('Focus the embryo first'); return; } - D['op-acquire'].disabled = true; D['op-acquire'].textContent = 'Acquiring…'; _acquiring = true; renderStatus(); - try { - await postJSON('/api/devices/acquire/volume', { num_slices: 50, exposure_ms: 10.0 }); - advanceState(_selected, 'imaged'); setStep('b5'); - toast('Volume acquired'); - } catch (e) { toast(`Acquire failed (${e.status || e.message})`); } - finally { _acquiring = false; D['op-acquire'].disabled = false; D['op-acquire'].textContent = 'Acquire volume'; await forceLedOff(); renderStatus(); } + function renderSpimTarget() { + const el = $('op-spim-target'); + if (!el) return; + const emb = _embryos.find(e => e.id === _selected); + el.textContent = emb ? `Selected: embryo ${labelFor(emb)}` : 'No embryo selected'; } - // ── B5 retract & advance ─────────────────────────────────────────────── - async function retractAndAdvance() { - D['op-retract'].disabled = true; - try { - await postJSON('/api/devices/spim/fdrive/nudge', { delta: 100 }).catch(() => {}); - _headLowered = false; _fdFloor = null; - const next = _embryos.find(e => _states[e.id] !== 'imaged'); - if (next) { selectEmbryo(next.id); toast(`Next: embryo ${labelFor(next)}`); } - else { - // Done imaging — return to the Run chooser (not Focus) so another - // run mode can be chosen for the same marked set. - _selected = null; _step = null; _runState = 'choose'; renderStep(); - toast('All embryos imaged'); - } - } finally { D['op-retract'].disabled = false; } + // ══ ACQUISITION PANE ════════════════════════════════════════════════════ + function selectEmbryo(id) { + _selected = id; + renderRoster(); renderSpimTarget(); renderSingle(); renderEmbryoRail(); } - // ── embryo SSOT ──────────────────────────────────────────────────────── - function onEmbryosUpdate(p) { - const wasEmpty = _embryos.length === 0; - _embryos = (p && Array.isArray(p.embryos)) ? p.embryos : []; - const ids = new Set(_embryos.map(e => e.id)); - Object.keys(_states).forEach(id => { if (!ids.has(id)) delete _states[id]; }); - Object.keys(_roles).forEach(id => { if (!ids.has(id)) delete _roles[id]; }); - _embryos.forEach(e => { - if (!_states[e.id]) _states[e.id] = 'marked'; - // seed role from the embryo if present, else default to subject ('test') - if (!_roles[e.id]) _roles[e.id] = (e.role && e.role !== 'unassigned') ? e.role : 'test'; - }); - if (_selected && !ids.has(_selected)) { _selected = null; _step = null; } - // After the first marking confirm, enter the Phase C Run chooser - // (NOT the old auto-dive into the manual loop). - if (wasEmpty && _embryos.length && !_selected && !_marking && _runState === null) { - _runState = 'choose'; + // Shared embryo list, left of every instrument surface. Reads the canonical + // _embryos (bootstrapped from /api/embryos/current, kept live by + // EMBRYOS_UPDATE), so it is the same set on Bottom / SPIM / Acquire and it + // survives a refresh. + function renderEmbryoRail() { + const host = $('op-erail-list'); + const count = $('op-erail-count'); + if (count) count.textContent = _embryos.length; + if (!host) return; + host.innerHTML = ''; + if (!_embryos.length) { + const box = document.createElement('div'); + box.className = 'op-erail-empty'; + box.textContent = 'No embryos yet — detect on the bottom camera, then register.'; + host.appendChild(box); + return; } - renderStep(); + _embryos.forEach(emb => { + const xy = resolveXY(emb); + const row = document.createElement('div'); + row.className = 'op-erow' + (emb.id === _selected ? ' is-sel' : ''); + row.tabIndex = 0; + row.dataset.embryo = emb.id; + row.innerHTML = + '' + + `Embryo ${escapeHtml(labelFor(emb))}` + + `${xy ? `${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}` : '—'}` + + '' + + ``; + host.appendChild(row); + }); } - // ── Phase C: Run chooser + run-spine ─────────────────────────────────── - function escapeHtml(s) { - return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); - } - - function renderChooser() { - if (D['op-rolechips']) { - D['op-rolechips'].innerHTML = ''; - _embryos.forEach(emb => { - const role = _roles[emb.id] || 'test'; - const chip = document.createElement('button'); - chip.type = 'button'; - chip.className = 'op-rolechip' + (role === 'calibration' ? ' is-reference' : ''); - chip.textContent = `${labelFor(emb)} · ${role === 'calibration' ? 'ref' : 'subj'}`; - chip.title = 'Click to toggle subject / reference'; - chip.addEventListener('click', () => { - _roles[emb.id] = (_roles[emb.id] === 'calibration') ? 'test' : 'calibration'; - renderChooser(); - }); - D['op-rolechips'].appendChild(chip); - }); + async function deleteEmbryo(id) { + try { + const res = await fetch(`/api/embryos/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!res.ok) throw Object.assign(new Error('delete failed'), { status: res.status }); + // EMBRYOS_UPDATE will reconcile every view; prune optimistically so + // the row disappears immediately even before the event lands. + _embryos = _embryos.filter(e => e.id !== id); + if (_selected === id) _selected = _embryos.length ? _embryos[0].id : null; + renderEmbryoRail(); renderRoster(); renderSpimTarget(); renderSingle(); drawMarkers(); + } catch (e) { + toast(`Delete failed (${e.status || e.message})`); } - document.querySelectorAll('.op-modepanel').forEach(p => p.classList.toggle('is-shown', p.dataset.mode === _runMode)); - if (D['op-tl-stop'] && D['op-tl-condval']) { - const s = D['op-tl-stop'].value; - D['op-tl-condval'].style.display = (s === 'timepoints' || s === 'duration') ? '' : 'none'; + } + + function renderRoster() { + const host = $('op-roster'); + const count = $('op-roster-count'); + if (count) count.textContent = _embryos.length; + if (!host) return; + host.innerHTML = ''; + if (!_embryos.length) { + const box = document.createElement('div'); + box.className = 'op-empty'; + box.innerHTML = 'No embryos marked yet.' + + ''; + host.appendChild(box); + return; } - if (_runMode === 'library') loadLibrary(); - if (_runMode === 'plan') loadPlanItems(); + _embryos.forEach(emb => { + const xy = resolveXY(emb); + const role = (emb.role && emb.role !== 'unassigned') ? emb.role : 'test'; + const row = document.createElement('div'); + row.className = 'op-rrow' + (emb.id === _selected ? ' is-sel' : ''); + row.tabIndex = 0; + row.dataset.embryo = emb.id; + row.innerHTML = + `Embryo ${escapeHtml(labelFor(emb))}` + + `${xy ? `${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}` : '—'}` + + `` + + ``; + host.appendChild(row); + }); + } + + // Roles are read from the canonical embryo list and written through the + // endpoint — deliberately NOT mirrored in a local map, which in the old + // design drifted from _embryos and needed a reconciliation loop. + async function toggleRole(id) { + const emb = _embryos.find(e => e.id === id); + if (!emb) return; + const cur = (emb.role && emb.role !== 'unassigned') ? emb.role : 'test'; + const next = cur === 'calibration' ? 'test' : 'calibration'; + emb.role = next; + renderRoster(); + const roles = {}; + _embryos.forEach(e => { roles[e.id] = (e.role && e.role !== 'unassigned') ? e.role : 'test'; }); + try { await postJSON('/api/embryos/roles', { roles }); } + catch (e) { toast(`Roles failed (${e.status || e.message})`); } + } + + function setMode(m) { + _mode = m; + document.querySelectorAll('#op-modes [data-mode]').forEach(b => + b.classList.toggle('is-on', b.dataset.mode === m)); + ['single', 'adaptive', 'library', 'agent'].forEach(k => { + const p = $(`op-panel-${k}`); + if (p) p.hidden = k !== m; + }); + if (m === 'library') loadLibrary(); + renderSingle(); + } + + function renderSingle() { + const t = $('op-single-target'), d = $('op-single-delta'); + const emb = _embryos.find(e => e.id === _selected); + if (t) t.textContent = emb ? `embryo ${labelFor(emb)}` : 'none selected'; + if (!d) return; + const xy = emb ? resolveXY(emb) : null; + // A fact, not a gate: the operator is told how far off the stage is and + // decides for themselves. Acquire is never disabled on this. + if (!xy || !_xy) { d.textContent = '—'; return; } + const dx = xy.x - _xy.x, dy = xy.y - _xy.y; + d.textContent = `${Math.round(Math.hypot(dx, dy))} µm away`; } async function loadLibrary() { - if (!D['op-lib-list']) return; + const host = $('op-lib-list'); + if (!host) return; try { const d = await getJSON('/api/tactic_library'); const items = (d && d.tactics) || []; - if (!items.length) { D['op-lib-list'].innerHTML = '
No saved tactics
'; return; } - D['op-lib-list'].innerHTML = ''; - items.forEach(t => { - const b = document.createElement('button'); - b.type = 'button'; - b.className = 'op-libitem' + (t.id === _selectedLib ? ' is-sel' : ''); - b.innerHTML = `${escapeHtml(t.name || t.id)}${escapeHtml(t.kind || '')}`; - b.addEventListener('click', () => { _selectedLib = t.id; loadLibrary(); }); - D['op-lib-list'].appendChild(b); - }); - } catch (_) { D['op-lib-list'].innerHTML = '
Library unavailable
'; } - } - function loadPlanItems() { - // Plan resolution lives in the agent (resume_plan + execute_plan_item); - // Start hands the roster to the agent to attach + continue the right item. - if (D['op-plan-list']) { - D['op-plan-list'].innerHTML = '
Start hands these embryos to the agent to attach a plan item and continue imaging.
'; - } + if (!items.length) { host.innerHTML = '
No saved tactics
'; return; } + host.innerHTML = items.map(t => + ``).join(''); + } catch (_) { host.innerHTML = '
Library unavailable
'; } } - async function applyRoles() { - const roles = {}; - _embryos.forEach(e => { roles[e.id] = _roles[e.id] || 'test'; }); - try { await postJSON('/api/embryos/roles', { roles }); } catch (e) { toast(`Roles failed (${e.status || e.message})`); } + function subjectIds() { + const subs = _embryos.filter(e => e.role !== 'calibration').map(e => e.id); + return subs.length ? subs : _embryos.map(e => e.id); } async function startRun() { - // Persist the chooser's role toggles for EVERY mode (manual included). - await applyRoles(); - if (_runMode === 'manual') { - // record a cosmetic oneshot tactic so even a manual sweep shows on the spine - postJSON('/api/operate/run-tactic', { - tactic: { kind: 'oneshot', name: 'Manual sweep', structure: { note: 'manual one-by-one' } }, - embryo_ids: _embryos.map(e => e.id), - }).catch(() => {}); - _runState = null; - if (_embryos.length) selectEmbryo(_embryos[0].id); - return; - } - const subjects = _embryos.filter(e => (_roles[e.id] || 'test') !== 'calibration').map(e => e.id); - const embryo_ids = subjects.length ? subjects : _embryos.map(e => e.id); - if (_runMode === 'adaptive') { - const interval = Math.max(1, Number(D['op-tl-interval'].value) || 120); - const stopSel = D['op-tl-stop'].value; - const monitoring = D['op-tl-monitor'].value; - // Send the combined stop form the orchestrator parser understands - // ('timepoints:N' / 'duration:Xh'); a bare 'timepoints' silently - // degrades to manual (never stops). - let stop_condition = stopSel; - if (stopSel === 'timepoints') stop_condition = `timepoints:${Math.max(1, Number(D['op-tl-condval'].value) || 1)}`; - else if (stopSel === 'duration') stop_condition = `duration:${Math.max(1, Number(D['op-tl-condval'].value) || 1)}h`; - const body = { embryo_ids, interval_seconds: interval, stop_condition, monitoring_mode: monitoring }; - D['op-run-start'].disabled = true; D['op-run-start'].textContent = 'Starting…'; - try { - await postJSON('/api/devices/timelapse/start', body); - _runMeta = { mode: 'adaptive', interval, stop: stop_condition, monitoring, n: embryo_ids.length }; - _runState = 'running'; _runPaused = false; - toast(`Adaptive timelapse started — ${embryo_ids.length} subject${embryo_ids.length === 1 ? '' : 's'}`); - renderStep(); - } catch (e) { - toast(`Start failed (${e.status || e.message})`); - } finally { D['op-run-start'].disabled = false; D['op-run-start'].textContent = 'Start run'; } - return; - } - const roster = _embryos.map(e => { - const xy = resolveXY(e); - const r = _roles[e.id] === 'calibration' ? 'reference' : 'subject'; - return `${labelFor(e)}${xy ? ` (${xy.x.toFixed(0)},${xy.y.toFixed(0)})` : ''} [${r}]`; - }).join(', '); - - if (_runMode === 'library') { - if (!_selectedLib) { toast('Pick a saved tactic'); return; } - D['op-run-start'].disabled = true; D['op-run-start'].textContent = 'Starting…'; - try { - const d = await postJSON('/api/operate/run-tactic', { library_id: _selectedLib, embryo_ids }); - if (d.success) { - _runMeta = { mode: 'library', n: embryo_ids.length }; - _runState = 'running'; _runPaused = false; toast('Tactic started'); renderStep(); - } else { toast(`Run failed: ${(d.result && d.result.message) || '?'}`); } - } catch (e) { toast(`Start failed (${e.status || e.message})`); } - finally { D['op-run-start'].disabled = false; D['op-run-start'].textContent = 'Start run'; } - return; - } - if (_runMode === 'plan' || _runMode === 'agent') { - // The agent owns plan resolution + composed tactics; hand off the roster. - const prompt = _runMode === 'plan' - ? `Continue a plan on these ${_embryos.length} marked embryos — attach this session to the right plan item and start imaging: ${roster}.` - : `I marked ${_embryos.length} embryos: ${roster}. Propose and start an Operation Plan to image them.`; - if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { - AgentChat.togglePanel(true); - if (AgentChat.runCommand) setTimeout(() => AgentChat.runCommand(prompt), 300); - } else { toast('Agent chat unavailable'); } - _runState = 'running'; renderStep(); - return; - } + const b = $('op-run-start'); + const done = () => { if (b) { b.disabled = false; b.textContent = 'Start'; } }; + if (b) { b.disabled = true; b.textContent = 'Starting…'; } + try { + if (_mode === 'single') { + if (!_selected) { toast('Select an embryo first'); return; } + _acquiring = true; renderSubnavMeta(); + try { + await postJSON('/api/devices/acquire/volume', { + num_slices: Math.max(1, Number(($('op-vol-slices') || {}).value) || 50), + exposure_ms: Math.max(1, Number(($('op-vol-exp') || {}).value) || 10), + }); + toast('Volume acquired'); + } finally { _acquiring = false; await forceLedOff(); renderSubnavMeta(); } + return; + } + if (_mode === 'adaptive') { + const interval = Math.max(1, Number(($('op-tl-interval') || {}).value) || 120); + const sel = ($('op-tl-stop') || {}).value || 'manual'; + const val = Math.max(1, Number(($('op-tl-condval') || {}).value) || 1); + // The orchestrator parses the COMBINED form ('timepoints:N' / + // 'duration:Xh'). A bare 'timepoints' silently degrades to manual, + // i.e. a timelapse that never stops. + let stop_condition = sel; + if (sel === 'timepoints') stop_condition = `timepoints:${val}`; + else if (sel === 'duration') stop_condition = `duration:${val}h`; + await postJSON('/api/devices/timelapse/start', { + embryo_ids: subjectIds(), + interval_seconds: interval, + stop_condition, + monitoring_mode: ($('op-tl-monitor') || {}).value || 'idle', + }); + toast('Adaptive timelapse started'); + renderRun(); + return; + } + if (_mode === 'library') { + if (!_selectedLib) { toast('Pick a saved tactic'); return; } + const d = await postJSON('/api/operate/run-tactic', + { library_id: _selectedLib, embryo_ids: subjectIds() }); + if (d.success) { toast('Tactic started'); renderRun(); } + else toast(`Run failed: ${(d.result && d.result.message) || '?'}`); + return; + } + if (_mode === 'agent') { + const roster = _embryos.map(e => { + const xy = resolveXY(e); + const r = e.role === 'calibration' ? 'reference' : 'subject'; + return `${labelFor(e)}${xy ? ` (${xy.x.toFixed(0)},${xy.y.toFixed(0)})` : ''} [${r}]`; + }).join(', '); + const prompt = `I marked ${_embryos.length} embryos: ${roster}. ` + + 'Propose and start an Operation Plan to image them.'; + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + AgentChat.togglePanel(true); + if (AgentChat.runCommand) setTimeout(() => AgentChat.runCommand(prompt), 300); + } else toast('Agent chat unavailable'); + } + } catch (e) { + toast(`Start failed (${e.status || e.message})`); + } finally { done(); } } - async function renderRunSpine() { - if (!D['op-runspine']) return; + // Run presence is DERIVED from the server, not from a client flag. The old + // design kept it in memory, so F5 during a running timelapse lost the whole + // panel — and left a client state machine that could re-grow into steps. + async function renderRun() { + const host = $('op-runspine'), actions = $('op-run-actions'); + if (!host) return; let tactics = []; - try { const d = await getJSON('/api/operation_plan'); tactics = (d && d.plan && d.plan.tactics) || []; } catch (_) {} - D['op-runspine'].innerHTML = ''; - if (tactics.length) { - tactics.forEach(t => D['op-runspine'].appendChild(tacticCard(t))); - } else if (_runMeta) { - // Fallback card when the plan fetch is empty/failed — shaped per mode - // (a library run carries no interval/stop/monitoring). - const n = _runMeta.n || 0; - const subj = `${n} subject${n === 1 ? '' : 's'}`; - const card = document.createElement('div'); - card.className = 'op-tcard st-active'; - if (_runMeta.mode === 'adaptive') { - const stopTxt = (_runMeta.stop === 'manual' || _runMeta.stop == null) ? 'until stopped' : _runMeta.stop; - const ivl = _runMeta.interval != null ? `${escapeHtml(String(_runMeta.interval))}s · ` : ''; - card.innerHTML = '
Adaptive timelapseactive
' - + `
standing_timelapse · ${escapeHtml(_runMeta.monitoring || 'idle')}
` - + `
${ivl}${subj} · ${escapeHtml(stopTxt)}
`; - } else { - card.innerHTML = '
Tacticactive
' - + `
${subj}
`; - } - D['op-runspine'].appendChild(card); - } else { - D['op-runspine'].innerHTML = '
Run active.
'; + try { + const d = await getJSON('/api/operation_plan'); + tactics = (d && d.plan && d.plan.tactics) || []; + } catch (_) { /* leave empty */ } + const live = tactics.filter(t => t.state === 'active' || t.state === 'paused'); + if (actions) actions.hidden = live.length === 0; + if (!tactics.length) { + host.innerHTML = '
Nothing running.
'; + return; } - if (D['op-run-pause']) D['op-run-pause'].textContent = _runPaused ? 'Resume' : 'Pause'; + host.innerHTML = tactics.map(tacticCard).join(''); + _runPaused = live.some(t => t.state === 'paused'); + const p = $('op-run-pause'); + if (p) p.textContent = _runPaused ? 'Resume' : 'Pause'; } function tacticCard(t) { - const card = document.createElement('div'); const state = t.state || 'planned'; - card.className = 'op-tcard st-' + state; const struct = t.structure || {}; const meta = []; if (struct.cadence_s != null) meta.push(`${struct.cadence_s}s`); if (struct.interval != null) meta.push(`${struct.interval}s`); if (struct.status) meta.push(struct.status); if (t.live && t.live.signal != null) meta.push(`signal ${t.live.signal}`); - card.innerHTML = `
${escapeHtml(t.name || t.id)}${escapeHtml(state)}
` - + `
${escapeHtml(t.kind || '')}
` - + (meta.length ? `
${escapeHtml(meta.join(' · '))}
` : '') - + (t.rationale ? `
${escapeHtml(t.rationale)}
` : ''); - return card; + return `
` + + `
${escapeHtml(t.name || t.id)}` + + `${escapeHtml(state)}
` + + `
${escapeHtml(t.kind || '')}
` + + (meta.length ? `
${escapeHtml(meta.join(' · '))}
` : '') + + (t.rationale ? `
${escapeHtml(t.rationale)}
` : '') + + '
'; } - async function pauseRun() { try { - if (_runPaused) { await postJSON('/api/devices/timelapse/resume', {}); _runPaused = false; toast('Resumed'); } - else { await postJSON('/api/devices/timelapse/pause', {}); _runPaused = true; toast('Paused'); } - renderRunSpine(); + await postJSON(_runPaused ? '/api/devices/timelapse/resume' : '/api/devices/timelapse/pause', {}); + toast(_runPaused ? 'Resumed' : 'Paused'); } catch (e) { toast(`Pause/resume failed (${e.status || e.message})`); } + renderRun(); } async function stopRun() { if (!window.confirm('Stop the run?')) return; - try { await postJSON('/api/devices/timelapse/stop', { reason: 'operator' }); } catch (e) { toast(`Stop failed (${e.status || e.message})`); } - _runState = 'choose'; _runMeta = null; _runPaused = false; - toast('Run stopped'); renderStep(); + try { await postJSON('/api/devices/timelapse/stop', { reason: 'operator' }); toast('Run stopped'); } + catch (e) { toast(`Stop failed (${e.status || e.message})`); } + renderRun(); + } + + // ══ PANES ═══════════════════════════════════════════════════════════════ + // Camera ownership is keyed on VISIBILITY, not on a step. Both cameras + // contend for MMCore, and the client swaps .src per frame with no throttle, + // so two live decoders is the condition that risks a Video-TDR freeze. "The + // camera is live while you are looking at it" guarantees at most one. + const PANES = { + bottom: { + onEnter() { if (_bottomWasOn && !_bottomOn) toggleBottomCam(); drawMarkers(); }, + onLeave() { _bottomWasOn = _bottomOn; if (_bottomOn) stopBottom(); }, + render() { renderMarkCount(); drawMarkers(); bz.render(); }, + }, + spim: { + onEnter() { if (_spimWasOn && !_spimOn) toggleSpim(); }, + onLeave() { _spimWasOn = _spimOn; if (_spimOn) stopSpim(); forceLedOff(); }, + render() { renderSpimTarget(); fd.render(); }, + }, + acquire: { + onEnter() { renderRun(); }, + onLeave() {}, + render() { renderRoster(); renderSingle(); }, + }, + }; + function stopBottom() { + fetch('/api/devices/bottom_camera/stream/stop', { method: 'POST' }).catch(() => {}); + applyBottomCam(false); + } + function stopSpim() { + fetch('/api/devices/lightsheet/live/stop', { method: 'POST' }).catch(() => {}); + applySpim(false); } - function openInOperations() { - const nav = [...document.querySelectorAll('[data-tab]')].find(n => /operations/i.test(n.textContent || '')); - if (nav) nav.click(); else toast('Operations tab not found'); + + function showPane(name) { + if (!PANES[name] || name === _pane) return; + const prev = _pane; + _pane = name; + if (PANES[prev]) PANES[prev].onLeave(); + ['bottom', 'spim', 'acquire'].forEach(p => { + const el = $(`op-pane-${p}`); + if (el) el.hidden = p !== name; + }); + // Drives the CSS that hides the shared rail on Acquisition (its own + // roster is richer) while keeping it on Bottom / SPIM. + const body = $('op-body'); if (body) body.dataset.pane = name; + if (typeof updateViewButtons === 'function') updateViewButtons('operate-subtab-switcher', name); + PANES[name].onEnter(); + PANES[name].render(); + renderEmbryoRail(); + renderLock(); } - // ── lifecycle ────────────────────────────────────────────────────────── - function surveyMore() { _selected = null; _step = null; _runState = null; if (_marking) exitMarking(); renderStep(); } + function renderSubnavMeta() { + const el = $('op-subnav-meta'); + if (!el) return; + const bits = []; + if (_bottomOn) bits.push('BOTTOM ● LIVE'); + if (_spimOn) bits.push('SPIM ● LIVE'); + if (_ledOn) bits.push('LED EMITTING'); + if (_acquiring) bits.push('LASER EMITTING'); + el.textContent = bits.join(' · '); + } + + // ══ EVENTS ══════════════════════════════════════════════════════════════ + function onBottomFrame(p) { + // Bail when hidden, or a hidden Operate keeps base64-decoding every + // frame behind whatever is on screen and races for stream ownership. + if (!_active || _pane !== 'bottom' || !p || !p.jpeg_b64) return; + _lastBottom = p; + if (p.focus_score != null) { + const el = $('op-bz-score'); + if (el) el.textContent = Number(p.focus_score).toFixed(3); + } + setImg('op-img-bottom', 'op-ph-bottom', p); + drawMarkers(); + } + function onSpimFrame(p) { + if (!_active || _pane !== 'spim' || !p || !p.jpeg_b64) return; + if (p.focus_score != null) { + const el = $('op-spim-score'); + if (el) el.textContent = Number(p.focus_score).toFixed(3); + } + setImg('op-img-spim', 'op-ph-spim', p); + } + function onEmbryosUpdate(p) { + _embryos = (p && Array.isArray(p.embryos)) ? p.embryos : []; + if (_selected && !_embryos.some(e => e.id === _selected)) _selected = null; + // The embryo list is shared across all three panes; keep a live + // selection whenever it is non-empty so SPIM/Acquire aren't a dead-end + // ("No embryo selected") right after registering. The operator can still + // switch by clicking a registered embryo (bottom) or a roster row. + if (!_selected && _embryos.length) _selected = _embryos[0].id; + // Render the shared rail even when the Operate view isn't the active tab, + // so switching to it (or refreshing) shows the list immediately rather + // than waiting for the next mutation event. + renderEmbryoRail(); + if (!_active) return; + renderRoster(); renderSpimTarget(); renderSingle(); + } function wire() { - if (_wired) return; _wired = true; - cacheDom(); - D['op-cam-toggle'].addEventListener('click', toggleCamera); - D['op-mark-canvas'].addEventListener('click', onCanvasClick); - D['op-bz-nudge'].addEventListener('click', e => { const b = e.target.closest('[data-bz]'); if (b) nudgeBottomZ(Number(b.dataset.bz)); }); - D['op-tomark'].addEventListener('click', () => { if (enterMarking([])) renderStep(); }); - D['op-detect'].addEventListener('click', runDetect); - D['op-confirm'].addEventListener('click', confirmMarks); - D['op-clear'].addEventListener('click', () => { _markers = []; drawMarkers(); updateMarkCount(); }); - D['op-center'].addEventListener('click', centerOnSelected); - D['op-fd-nudge'].addEventListener('click', e => { const b = e.target.closest('[data-fd]'); if (b) nudgeFdrive(Number(b.dataset.fd)); }); - D['op-tofocus'].addEventListener('click', () => setStep('b3')); - D['op-spim-toggle'].addEventListener('click', toggleSpim); - D['op-led'].addEventListener('click', toggleLed); - document.querySelectorAll('[data-gv]').forEach(b => b.addEventListener('click', () => nudgeGalvo(Number(b.dataset.gv)))); - document.querySelectorAll('[data-pz]').forEach(b => b.addEventListener('click', () => nudgePiezo(Number(b.dataset.pz)))); - D['op-infocus'].addEventListener('click', markInFocus); - if (D['op-calibrate']) D['op-calibrate'].addEventListener('click', calibrateSelected); - if (D['op-cal-skip']) D['op-cal-skip'].addEventListener('click', skipCalibration); - D['op-acquire'].addEventListener('click', acquireSelected); - D['op-retract'].addEventListener('click', retractAndAdvance); - D['op-survey-btn'].addEventListener('click', surveyMore); - // Re-enter the Run chooser by clicking the "Run" stepper node — so a - // different run mode can be chosen for an already-marked set (e.g. after - // a manual sweep). Without this the chooser is a one-time gate. - if (D['op-stepper']) D['op-stepper'].addEventListener('click', e => { - const n = e.target.closest('.op-node'); - if (n && n.dataset.node === 'run' && _embryos.length && !_marking && _runState !== 'running') { - _selected = null; _step = null; _runState = 'choose'; renderStep(); - } - }); - // Phase C: Run chooser + run-spine - document.querySelectorAll('input[name="op-mode"]').forEach(r => - r.addEventListener('change', () => { _runMode = r.value; renderChooser(); })); - if (D['op-tl-stop']) D['op-tl-stop'].addEventListener('change', renderChooser); - if (D['op-run-start']) D['op-run-start'].addEventListener('click', startRun); - if (D['op-run-pause']) D['op-run-pause'].addEventListener('click', pauseRun); - if (D['op-run-stop']) D['op-run-stop'].addEventListener('click', stopRun); - if (D['op-run-open']) D['op-run-open'].addEventListener('click', openInOperations); - window.addEventListener('resize', () => { if (_active) drawOverlay(effectiveStep()); }); + if (_wired) return; + _wired = true; + + if (typeof initViewSwitcher === 'function') { + // Click delegation only — deliberately NO `views` option. + // + // initViewSwitcher's `views` binds BARE number keys on document, and + // 1-6 are already global main-tab navigation (KeyboardShortcuts in + // app.js), with system/calibration/plan switchers claiming 1-3 too. + // Binding them here either steals a main-tab key or, with a guard + // tight enough to be safe, never fires at all — verified in-browser: + // app.js handles the keypress first and moves state.tab, so the + // guard then correctly refuses. Three peer surfaces are fine to + // click between; a shortcut would need a modifier to be honest. + initViewSwitcher('operate-subtab-switcher', showPane); + } + + bz.wire(); fd.wire(); + + const cam = $('op-cam-toggle'); if (cam) cam.addEventListener('click', toggleBottomCam); + const det = $('op-detect'); if (det) det.addEventListener('click', runDetect); + const ok = $('op-confirm'); if (ok) ok.addEventListener('click', confirmMarks); + const cl = $('op-clear'); + if (cl) cl.addEventListener('click', () => { _markers = []; drawMarkers(); renderMarkCount(); setDetectNote(''); }); + const canvas = $('op-mark-canvas'); if (canvas) canvas.addEventListener('click', onCanvasClick); + + // Shared embryo rail: click a row to select (delete button is guarded + // first so removing a false positive doesn't also select it). + const erail = $('op-erail-list'); + if (erail) { + erail.addEventListener('click', (e) => { + const del = e.target.closest('[data-del]'); + if (del) { e.stopPropagation(); deleteEmbryo(del.dataset.del); return; } + const row = e.target.closest('[data-embryo]'); + if (row) selectEmbryo(row.dataset.embryo); + }); + erail.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const row = e.target.closest('[data-embryo]'); + if (row) { e.preventDefault(); selectEmbryo(row.dataset.embryo); } + }); + } + + const sp = $('op-spim-toggle'); if (sp) sp.addEventListener('click', toggleSpim); + const led = $('op-led'); if (led) led.addEventListener('click', toggleLed); + const cal = $('op-calibrate'); if (cal) cal.addEventListener('click', calibrateSelected); + document.querySelectorAll('[data-gv]').forEach(b => + b.addEventListener('click', () => nudgeGalvo(Number(b.dataset.gv)))); + document.querySelectorAll('[data-pz]').forEach(b => + b.addEventListener('click', () => nudgePiezo(Number(b.dataset.pz)))); + document.querySelectorAll('[data-backoff]').forEach(b => + b.addEventListener('click', backOff)); + + const modes = $('op-modes'); + if (modes) { + modes.addEventListener('click', e => { + const b = e.target.closest('[data-mode]'); + if (b) setMode(b.dataset.mode); + }); + } + const stop = $('op-tl-stop'); + if (stop) { + stop.addEventListener('change', () => { + const w = $('op-tl-condwrap'); + if (w) w.hidden = stop.value === 'manual'; + }); + } + const lib = $('op-lib-list'); + if (lib) { + lib.addEventListener('click', e => { + const b = e.target.closest('[data-lib]'); + if (b) { _selectedLib = b.dataset.lib; loadLibrary(); } + }); + } + const roster = $('op-roster'); + if (roster) { + roster.addEventListener('click', e => { + const r = e.target.closest('[data-role-for]'); + if (r) { e.stopPropagation(); toggleRole(r.dataset.roleFor); return; } + const c = e.target.closest('[data-center]'); + if (c) { + e.stopPropagation(); + const emb = _embryos.find(x => x.id === c.dataset.center); + if (emb) centerOnEmbryo(emb); + return; + } + const g = e.target.closest('[data-goto]'); + if (g) { showPane(g.dataset.goto); return; } + const row = e.target.closest('[data-embryo]'); + if (row) selectEmbryo(row.dataset.embryo); + }); + roster.addEventListener('keydown', e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const row = e.target.closest('[data-embryo]'); + if (row) { e.preventDefault(); selectEmbryo(row.dataset.embryo); } + }); + } + const start = $('op-run-start'); if (start) start.addEventListener('click', startRun); + const pause = $('op-run-pause'); if (pause) pause.addEventListener('click', pauseRun); + const stopb = $('op-run-stop'); if (stopb) stopb.addEventListener('click', stopRun); + + window.addEventListener('resize', () => { if (_active && _pane === 'bottom') drawMarkers(); }); + // The viewport also changes size without a window resize — revealing the + // pane, the agent panel opening, the first frame arriving. Without this + // the overlay keeps whatever size it was first drawn at and every marker + // sits in the wrong place until the next frame happens to redraw it. + const camBox = $('op-cam-bottom'); + if (camBox && typeof ResizeObserver !== 'undefined') { + new ResizeObserver(() => { if (_active && _pane === 'bottom') drawMarkers(); }).observe(camBox); + } if (typeof ClientEventBus !== 'undefined') { ClientEventBus.on('BOTTOM_CAMERA_FRAME', onBottomFrame); - ClientEventBus.on('LIGHTSHEET_FRAME', onLightsheetFrame); + ClientEventBus.on('LIGHTSHEET_FRAME', onSpimFrame); ClientEventBus.on('EMBRYOS_UPDATE', onEmbryosUpdate); ClientEventBus.on('DEVICE_STATE_UPDATE', p => { - const pos = p && p.positions; if (!pos) return; - if (pos.xy_stage && Array.isArray(pos.xy_stage)) { _lastXY = { X: pos.xy_stage[0], Y: pos.xy_stage[1] }; if (_active) renderMiniMap(); } + const pos = p && p.positions; + if (!pos) return; + if (Array.isArray(pos.xy_stage)) _xy = { x: pos.xy_stage[0], y: pos.xy_stage[1] }; + if (!_active) return; + for (const v of Object.values(pos)) { + if (!v || typeof v !== 'object' || v.Position == null) continue; + const val = Number(v.Position); + if (!Number.isFinite(val)) continue; + if (v.kind === 'fdrive') fd.absorbTelemetry(val); + else if (v.kind === 'bottom_z') bz.absorbTelemetry(val); + } + if (_pane === 'acquire') renderSingle(); }); } } async function activate() { wire(); - if (_active) return; _active = true; - try { const s = await getJSON('/api/embryos/current'); onEmbryosUpdate(s); } catch (_) { renderStep(); } - try { const b = await getJSON('/api/devices/stage/bottom_z'); if (b.position != null) D['op-bz-pos'].textContent = Number(b.position).toFixed(1); } catch (_) {} - try { const f = await getJSON('/api/devices/spim/fdrive'); if (f.position != null) { _fdPos = f.position; D['op-fd-pos'].textContent = Number(f.position).toFixed(1); } if (f.distance_to_floor != null) { _fdFloor = f.distance_to_floor; D['op-fd-floor'].textContent = Number(f.distance_to_floor).toFixed(0); } } catch (_) {} - renderStep(); + if (_active) return; + _active = true; + showPaneInitial(); + try { onEmbryosUpdate(await getJSON('/api/embryos/current')); } catch (_) {} + await Promise.all([bz.refresh(), fd.refresh()]); + renderLock(); + renderSubnavMeta(); + } + function showPaneInitial() { + ['bottom', 'spim', 'acquire'].forEach(p => { + const el = $(`op-pane-${p}`); + if (el) el.hidden = p !== _pane; + }); + if (typeof updateViewButtons === 'function') updateViewButtons('operate-subtab-switcher', _pane); + if (PANES[_pane]) { PANES[_pane].onEnter(); PANES[_pane].render(); } } function deactivate() { - if (!_active) return; _active = false; - if (_camOn) { fetch('/api/devices/bottom_camera/stream/stop', { method: 'POST' }).catch(() => {}); applyCam(false); } - if (_spimOn) { fetch('/api/devices/lightsheet/live/stop', { method: 'POST' }).catch(() => {}); _spimOn = false; D['op-spim-toggle'].textContent = 'Start view'; D['op-spim-toggle'].classList.remove('op-btn-on'); } + if (!_active) return; + _active = false; + // Remember what was running so returning restores it, but leave nothing + // decoding behind a hidden view. + _bottomWasOn = _bottomOn; _spimWasOn = _spimOn; + if (_bottomOn) stopBottom(); + if (_spimOn) stopSpim(); forceLedOff(); - if (_marking) exitMarking(); } return { activate, deactivate }; diff --git a/gently/ui/web/static/js/replay-recorder.js b/gently/ui/web/static/js/replay-recorder.js index a1d3127f..fbccd282 100644 --- a/gently/ui/web/static/js/replay-recorder.js +++ b/gently/ui/web/static/js/replay-recorder.js @@ -37,7 +37,7 @@ // Live camera gets base64 data-URI src swaps at frame rate — recording // it would add ~100KB+ per frame on the main thread. Blocked from capture; // the bus-summary action records that frames were flowing instead. - var BLOCK_SELECTOR = "#op-cam-img"; + var BLOCK_SELECTOR = "#op-img-bottom, #op-img-spim"; // Machine-driven, high-churn regions: the live map re-renders at stage-poll // rate, the 3D occupancy canvas animates, the temperature graph redraws on // every reading. In 'balanced' fidelity these are blocked from the DOM stream @@ -352,6 +352,40 @@ window.addEventListener("hashchange", emitRoute); } + // A blockSelector that matches nothing does not error — it silently stops + // blocking. The failure mode is severe and invisible: rename the camera + // and rrweb starts capturing a base64 data-URI `src` swap at frame rate on the + // main thread. So check once at boot that every selector still binds. + // + // Self-calibrating, because this recorder also runs on launch/login/settings + // where none of these elements exist: only report when SOME selector in the + // list matched, which is what identifies the page as the one the list belongs + // to. Blind spot: every selector rotting simultaneously reads as "wrong page" + // and stays silent — they live in different subsystems, so that is far less + // likely than the false positives a page sentinel would produce. + function auditSelectors(selectorList) { + var sels = String(selectorList) + .split(",") + .map(function (s) { return s.trim(); }) + .filter(Boolean); + var missing = []; + var hit = 0; + sels.forEach(function (s) { + var found = false; + try { + found = !!document.querySelector(s); + } catch (e) { + // An invalid selector never matches, so rrweb would not block it either. + missing.push(s + " (invalid)"); + return; + } + if (found) hit++; + else missing.push(s); + }); + if (!hit || !missing.length) return null; // wrong page, or all good + return missing; + } + /* ---- boot ---- */ function start() { @@ -383,6 +417,14 @@ } catch (e) { return disable("rrweb.record failed: " + (e && e.message)); } + STATE.blockMisses = auditSelectors(block); + if (STATE.blockMisses) { + console.warn( + "[gently-replay] blockSelector no longer matches — high-churn regions " + + "may now be recorded at frame rate: " + + STATE.blockMisses.join(", ") + ); + } } document.addEventListener("click", onClick, true); document.addEventListener("submit", onSubmit, true); @@ -392,6 +434,9 @@ url: String(location.href), viewport: window.innerWidth + "x" + window.innerHeight, fidelity: fidelity, + // Present only when a block selector has rotted, so a postmortem explains + // an unexpectedly huge or janky recording instead of leaving it a mystery. + blockSelectorMisses: STATE.blockMisses || undefined, }); window.addEventListener("pagehide", finalFlush); document.addEventListener("visibilitychange", function () { diff --git a/gently/ui/web/static/js/shell.js b/gently/ui/web/static/js/shell.js index c85b2474..bc5d279f 100644 --- a/gently/ui/web/static/js/shell.js +++ b/gently/ui/web/static/js/shell.js @@ -43,6 +43,10 @@ const Shell = (() => { if (typeof ClientEventBus !== 'undefined') { ClientEventBus.on('TAB_CHANGED', (tabName) => setActive(tabName)); ClientEventBus.on('CONNECTION_STATUS', (s) => renderStrip(s)); + // Embryo count lives in state.embryos; re-render the strip whenever it + // changes (including the initial bootstrap) so the header doesn't sit + // at the pre-load 0. + ClientEventBus.on('EMBRYOS_UPDATE', () => renderStrip()); } const chatBtn = document.getElementById('v2-rail-chat'); diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js index cd2ba5d5..8b0fca54 100644 --- a/gently/ui/web/static/js/websocket.js +++ b/gently/ui/web/static/js/websocket.js @@ -36,11 +36,21 @@ function loadInitialData() { }) .catch(e => console.warn('Failed to load calibration:', e)); - fetch('/api/embryos') + // Bootstrap from the CURRENT (in-memory experiment) embryos, not the disk + // store: embryos registered this session live in memory until a volume is + // acquired, so /api/embryos (disk-backed) reads 0 and every "N embryos" + // count shows 0 on load. Map to id strings to keep state.embryos's shape + // (viewer.js does .includes()/.push() on it). + fetch('/api/embryos/current') .then(r => r.json()) .then(data => { - state.embryos = data.embryos || []; + const list = data.embryos || []; + state.embryos = list.map(e => (e && e.id) ? e.id : e); if (typeof updateStatusbar === 'function') updateStatusbar(); + // Fan out so every count-renderer (header strip, etc.) refreshes off + // the bootstrap, not just the footer statusbar. Shape matches the + // server-pushed EMBRYOS_UPDATE ({embryos: [...]}). + if (typeof ClientEventBus !== 'undefined') ClientEventBus.emit('EMBRYOS_UPDATE', { embryos: list }); }) .catch(e => console.warn('Failed to load embryos:', e)); } diff --git a/gently/ui/web/templates/_header.html b/gently/ui/web/templates/_header.html index cf4a4d03..ddb3e6bc 100644 --- a/gently/ui/web/templates/_header.html +++ b/gently/ui/web/templates/_header.html @@ -28,6 +28,20 @@ {# Agent lives in the docked right sidebar (collapses to a spark rail) — no header toggle. Ctrl/Cmd+J still opens it. #} {% endif %} + + @@ -76,6 +90,36 @@ {% endif %} + + + + + diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 05a15078..bf088472 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -463,8 +463,8 @@

Notebook

Device state

- - + + @@ -522,209 +522,259 @@

Device