Loading noctua/web/pages/control.html +7 −4 Original line number Diff line number Diff line Loading @@ -173,12 +173,13 @@ </section> {{ ctrl.guider_panel("imaging", [("cam","scicam"), ("teccam","teccam")], [("cam","scicam","ctrl-combo1-sci"), ("teccam","teccam","ctrl-combo1-tec")], "camera", has_actuator_select=True) }} {{ ctrl.guider_panel("spectro", [("teccam2","teccam2")], [("teccam2","teccam2","ctrl-combo2-tec")], "camera2") }} </article> Loading Loading @@ -232,6 +233,7 @@ <script type="module" src="{{ url_for('web.static', filename='js/guider-panel.js') }}"></script> <script type="module"> import { FitsViewer } from "{{ url_for('web.static', filename='js/viewer/fits-viewer.js') }}"; import { register as registerViewer } from "{{ url_for('web.static', filename='js/viewer/viewer-registry.js') }}"; function initViewer(panelId, station, camera, hasLoop, apiBase) { const panel = document.getElementById(`viewer-panel-${panelId}`); Loading Loading @@ -298,7 +300,8 @@ ]; for (const p of panels) { initViewer(p.id, p.station, p.camera, p.hasLoop, '/api'); const viewer = initViewer(p.id, p.station, p.camera, p.hasLoop, '/api'); if (viewer) registerViewer(p.id, viewer); } </script> <script src="{{ url_for('web.static', filename='js/webcam.js') }}"></script> Loading noctua/web/pages/macros/control_panel.html +9 −4 Original line number Diff line number Diff line Loading @@ -78,8 +78,11 @@ <select class="form-select form-select-sm bg-black" id="guider-camera-{{ panel_id }}" {% if cameras | length == 1 %}disabled{% endif %}> {% for val, lbl in cameras %} <option value="{{ val }}" {{ 'selected' if loop.first }}>{{ lbl }}</option> {% for cam_tuple in cameras %} {% set val = cam_tuple[0] %} {% set lbl = cam_tuple[1] %} {% set vid = cam_tuple[2] if cam_tuple | length > 2 else '' %} <option value="{{ val }}" {% if vid %}data-viewer-id="{{ vid }}"{% endif %} {{ 'selected' if loop.first }}>{{ lbl }}</option> {% endfor %} </select> </div> Loading Loading @@ -178,7 +181,9 @@ <!-- Start / Stop --> <fieldset class="row mt-2"> <div class="col-md offset-md-2"> <button class="col-md-2 btn btn-sm btn-outline-secondary" id="btn-{{ panel_id }}-pick" type="button">Pick</button> <div class="col-md"> <div class="input-group"> <button class="btn btn-primary flex-fill" type="button" id="btn-{{ panel_id }}-guide-start">Guide</button> Loading noctua/web/static/css/viewer.css +12 −0 Original line number Diff line number Diff line Loading @@ -101,3 +101,15 @@ } /* ── Panoramic: yellow rect rendered via canvas 2d, no extra CSS needed ─── */ /* ── Pick mode: viewer wrapper border feedback ───────────────────────────── */ .viewer-canvas-wrap.pick-warning { border-color: var(--bs-warning); box-shadow: 0 0 0 2px var(--bs-warning), 0 4px 24px rgba(0, 0, 0, .7); } .viewer-canvas-wrap.pick-success { border-color: var(--bs-success); box-shadow: 0 0 0 2px var(--bs-success), 0 4px 24px rgba(0, 0, 0, .7); } noctua/web/static/js/guider-panel.js +358 −31 Original line number Diff line number Diff line // guider-panel.js // Guider panel UI — camera/exptime reactivity, target coords, Guide button dispatch. // Guider panel UI — camera/exptime reactivity, target coords, Guide button dispatch, // and interactive coordinate picking via the FITS viewer overlay. // // Auto-discovers panel instances from the DOM: any element whose id matches // "btn-{panelId}-guide-start" triggers initialisation of that panel. import { showToast } from './ui.js'; import { get as getViewer } from './viewer/viewer-registry.js'; // --- Constants --- Loading @@ -28,26 +30,28 @@ const ACTUATOR_PARAMS = { * from disk — exptime is irrelevant and could mislead the operator). * Re-enables it when an active (triggering) camera is selected. * * @param {string} panelId - Panel identifier ("imaging" or "spectro"). * @param {string} camera - Selected camera device name. * @param {string} panelId * @param {string} camera */ function onCameraChange(panelId, camera) { const el = document.getElementById(`guider-exptime-val-${panelId}`); if (!el) return; const passive = PASSIVE_CAMERAS.has(camera); el.disabled = passive; if (passive) el.value = ''; } // --- 2. Target → custom coords --- // --- 2. Target → coord fields --- /** * Enables the TX/TY coordinate inputs when target mode is "custom". * Clears and disables them for "center" and "stick". * Enables/disables TX/TY and auto-populates them based on target mode: * center → image centre (width/2, height/2) from the registered viewer * stick → copy current CX/CY values * custom → enable, keep current values * other → disable, clear * * @param {string} panelId - Panel identifier. * @param {string} target - Selected target mode ("center", "stick", or "custom"). * @param {string} panelId * @param {string} target */ function onTargetChange(panelId, target) { const tx = document.getElementById(`guider-tx-${panelId}`); Loading @@ -57,7 +61,21 @@ function onTargetChange(panelId, target) { const custom = target === 'custom'; tx.disabled = !custom; ty.disabled = !custom; if (!custom) { if (target === 'center') { const viewer = getViewerForPanel(panelId); const { width, height } = viewer?.getImageSize() ?? { width: 0, height: 0 }; if (width && height) { tx.value = Math.round(width / 2); ty.value = Math.round(height / 2); } else { tx.value = ''; ty.value = ''; } } else if (target === 'stick') { tx.value = document.getElementById(`guider-cx-${panelId}`)?.value ?? ''; ty.value = document.getElementById(`guider-cy-${panelId}`)?.value ?? ''; } else if (!custom) { tx.value = ''; ty.value = ''; } Loading @@ -69,15 +87,8 @@ function onTargetChange(panelId, target) { * Reads all inputs in a guider panel and returns a params object * ready to POST to /api/guider/. * * Target handling: * "center" / "stick" → sent as-is. * "custom" → sent as [tx, ty] pixel array if both fields are filled. * * Actuator handling: * "ao_x_offload" is translated to { actuator: "ao_x", ao_offload: true }. * * @param {string} panelId - Panel identifier. * @returns {object} Params dict for the guider API. * @param {string} panelId * @returns {object} */ function collectParams(panelId) { const get = id => document.getElementById(id); Loading Loading @@ -119,10 +130,10 @@ function collectParams(panelId) { } /** * POSTs guider params to /api/guider/, showing a spinner on the button while pending. * POSTs guider params to /api/guider/, showing a spinner while pending. * * @param {HTMLButtonElement} btn - The Guide button element. * @param {object} params - Params from collectParams(). * @param {HTMLButtonElement} btn * @param {object} params */ async function postGuider(btn, params) { const orig = btn.innerHTML; Loading @@ -147,26 +158,342 @@ async function postGuider(btn, params) { } } // --- 4. Init --- // --- 4. Pick mode --- /** * Wires all event listeners for one guider panel instance and applies * the initial UI state (exptime/coords may already be disabled on load). * Per-panel pick + annotation state. * * Pick phases: * idle — no active pick; annotation may be visible. * picking-center — Pick clicked; awaiting first left-click (sets center). * picking-target — center set; awaiting second left-click (sets target). * * sessionClicked: true once the first left-click fires in the current session. * false → right-click is a destructive cancel (clears annotation). * true → right-click is a non-destructive exit (keeps box, discards target). * * @param {string} panelId - Panel identifier derived from the Guide button ID. * Persistent annotation (survives pick exit unless destructive cancel): * annotating, centerX/Y, boxPx, targetX/Y. * * @type {Object.<string, object>} */ const _pickState = {}; /** Return the FitsViewer for the currently selected guider camera. */ function getViewerForPanel(panelId) { const cameraEl = document.getElementById(`guider-camera-${panelId}`); const opt = cameraEl?.options[cameraEl.selectedIndex]; const viewerId = opt?.dataset?.viewerId; return viewerId ? getViewer(viewerId) : null; } /** * Unified overlay drawer for all pick phases and the persistent annotation. * Reads live from state — install once, call viewer.redrawOverlay() on state changes. * * Draws: * - Green box at (centerX, centerY) with side boxPx, when annotating. * - Green X at (targetX, targetY), only when phase=idle. * - Yellow dashed line from center to mouse, only when phase=picking-target. * - Yellow "Pick" label, whenever phase ≠ idle. * * @param {object} state - The mutable per-panel state object. * @returns {function} */ function makeDrawer(state) { return (ctx, toCanvas) => { // Green box. if (state.annotating && state.centerX !== null) { const half = (state.boxPx ?? 256) / 2; const tl = toCanvas(state.centerX - half, state.centerY - half); const br = toCanvas(state.centerX + half, state.centerY + half); ctx.strokeStyle = '#198754'; ctx.lineWidth = 2; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); } // Green X at target — only when idle (not while a new target is being picked). if (state.annotating && state.targetX !== null && state.phase === 'idle') { const t = toCanvas(state.targetX, state.targetY); const s = 8; ctx.strokeStyle = '#198754'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(t.x - s, t.y - s); ctx.lineTo(t.x + s, t.y + s); ctx.moveTo(t.x + s, t.y - s); ctx.lineTo(t.x - s, t.y + s); ctx.stroke(); } // Yellow dashed line from center to mouse — picking-target only. if (state.phase === 'picking-target' && state.centerX !== null && state.mousePos) { const c = toCanvas(state.centerX, state.centerY); const m = toCanvas(state.mousePos.x, state.mousePos.y); ctx.strokeStyle = '#ffc107'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(m.x, m.y); ctx.stroke(); ctx.setLineDash([]); } // "Pick" label — any active phase. if (state.phase !== 'idle') { ctx.font = 'bold 18px monospace'; ctx.fillStyle = '#ffc107'; ctx.fillText('Pick', 16, 32); } }; } /** * Remove the mousemove listener and pick callback from the viewer. * Does not touch the annotation state or the overlay drawer. * * @param {object} state */ function teardownSession(state) { if (state.mouseMoveHandler) { state.viewer?.removeMouseMoveListener(state.mouseMoveHandler); state.mouseMoveHandler = null; } state.viewer?.clearPick(); state.mousePos = null; state.sessionClicked = false; } /** * Wire the Pick button for one guider panel. * * State machine: * * [idle] * Pick click → picking-center (arm) * * [picking-center] * Pick click → picking-center (re-arm, discard phase-2 if any) * right-click → idle DESTRUCTIVE (clears annotation) * left-click → picking-target (center set, box drawn) * * [picking-target] * Pick click → picking-center (re-arm, keep existing box) * right-click → idle NON-DESTRUCTIVE (box stays, no target) * left-click → idle (target set, box+X drawn) * * @param {string} panelId */ function initPickMode(panelId) { const btn = document.getElementById(`btn-${panelId}-pick`); if (!btn) return; const state = _pickState[panelId]; // ── Unified pick callback (defined once, re-registered on each Pick click) ── const pickCallback = ({ imageX, imageY, cancelled }) => { const wrap = state.viewer?.root.querySelector('.viewer-canvas-wrap'); if (cancelled) { if (!state.sessionClicked) { // ── Destructive: right-click before any left-click this session ── teardownSession(state); state.phase = 'idle'; state.annotating = false; state.centerX = state.centerY = null; state.targetX = state.targetY = null; state.viewer?.clearOverlayDrawer(); state.viewer = null; wrap?.classList.remove('pick-warning', 'pick-success'); for (const id of [ `guider-cx-${panelId}`, `guider-cy-${panelId}`, `guider-tx-${panelId}`, `guider-ty-${panelId}`, ]) { document.getElementById(id)?.classList.remove('border-warning', 'border-success'); } } else { // ── Non-destructive: right-click after a left-click ────────────── // Box annotation persists; target (if any from previous pick) gone. teardownSession(state); state.phase = 'idle'; wrap?.classList.remove('pick-warning', 'pick-success'); state.viewer?.redrawOverlay(); for (const id of [`guider-tx-${panelId}`, `guider-ty-${panelId}`]) { document.getElementById(id)?.classList.remove('border-warning'); } } btn.classList.remove('btn-warning'); btn.classList.add('btn-outline-secondary'); return; } if (state.phase === 'picking-center') { // ── Phase 1 → 2: center picked ────────────────────────────────────── state.sessionClicked = true; state.centerX = imageX; state.centerY = imageY; state.boxPx = parseInt( document.getElementById(`guider-box-val-${panelId}`)?.value ) || 256; state.annotating = true; state.targetX = null; // discard old target while picking new one state.phase = 'picking-target'; const cxEl = document.getElementById(`guider-cx-${panelId}`); const cyEl = document.getElementById(`guider-cy-${panelId}`); if (cxEl) { cxEl.value = Math.round(imageX); cxEl.classList.remove('border-warning'); cxEl.classList.add('border-success'); } if (cyEl) { cyEl.value = Math.round(imageY); cyEl.classList.remove('border-warning'); cyEl.classList.add('border-success'); } wrap?.classList.remove('pick-warning'); wrap?.classList.add('pick-success'); state.mouseMoveHandler = ({ imageX: mx, imageY: my }) => { state.mousePos = { x: mx, y: my }; state.viewer.redrawOverlay(); }; state.viewer.addMouseMoveListener(state.mouseMoveHandler); state.viewer.redrawOverlay(); } else if (state.phase === 'picking-target') { // ── Phase 2 → idle: target picked ─────────────────────────────────── state.targetX = imageX; state.targetY = imageY; state.phase = 'idle'; // Enable TX/TY and fill them. const targetSel = document.getElementById(`guider-target-${panelId}`); if (targetSel) { targetSel.value = 'custom'; onTargetChange(panelId, 'custom'); } const txEl = document.getElementById(`guider-tx-${panelId}`); const tyEl = document.getElementById(`guider-ty-${panelId}`); if (txEl) { txEl.value = Math.round(imageX); txEl.classList.remove('border-warning'); txEl.classList.add('border-success'); } if (tyEl) { tyEl.value = Math.round(imageY); tyEl.classList.remove('border-warning'); tyEl.classList.add('border-success'); } teardownSession(state); wrap?.classList.remove('pick-success'); state.viewer.redrawOverlay(); // box + X, no dashed line btn.classList.remove('btn-warning'); btn.classList.add('btn-outline-secondary'); } }; // ── Pick button click: arm / re-arm picking-center ────────────────────────── btn.addEventListener('click', () => { const viewer = getViewerForPanel(panelId); if (!viewer) { showToast('No viewer registered for this camera', 'warning'); return; } // Tear down any in-progress session on the current viewer. teardownSession(state); // If the camera was switched, clear the annotation on the old viewer. if (state.viewer && state.viewer !== viewer) { state.viewer.clearOverlayDrawer(); state.viewer.root.querySelector('.viewer-canvas-wrap') ?.classList.remove('pick-warning', 'pick-success'); state.annotating = false; state.centerX = state.centerY = null; state.targetX = state.targetY = null; } state.viewer = viewer; state.phase = 'picking-center'; state.sessionClicked = false; state.mousePos = null; // (Re-)install drawer — needed when coming back after a destructive cancel. viewer.setOverlayDrawer(makeDrawer(state)); viewer.redrawOverlay(); const wrap = viewer.root.querySelector('.viewer-canvas-wrap'); if (wrap) { wrap.classList.remove('pick-success'); wrap.classList.add('pick-warning'); } btn.classList.remove('btn-outline-secondary'); btn.classList.add('btn-warning'); for (const id of [ `guider-cx-${panelId}`, `guider-cy-${panelId}`, `guider-tx-${panelId}`, `guider-ty-${panelId}`, ]) { const el = document.getElementById(id); if (el) { el.classList.remove('border-success'); el.classList.add('border-warning'); } } viewer.setPick(pickCallback); }); } // --- 5. Init --- /** * Wires all event listeners for one guider panel instance and sets initial UI state. * * @param {string} panelId */ function initPanel(panelId) { const cameraEl = document.getElementById(`guider-camera-${panelId}`); const targetEl = document.getElementById(`guider-target-${panelId}`); const guideBtn = document.getElementById(`btn-${panelId}-guide-start`); const cxEl = document.getElementById(`guider-cx-${panelId}`); const cyEl = document.getElementById(`guider-cy-${panelId}`); _pickState[panelId] = { phase: 'idle', viewer: null, mouseMoveHandler: null, mousePos: null, sessionClicked: false, annotating: false, centerX: null, centerY: null, boxPx: 256, targetX: null, targetY: null, }; cameraEl?.addEventListener('change', e => onCameraChange(panelId, e.target.value)); targetEl?.addEventListener('change', e => onTargetChange(panelId, e.target.value)); guideBtn?.addEventListener('click', () => postGuider(guideBtn, collectParams(panelId))); // Apply initial disabled state derived from current select values // Keep TX/TY in sync with CX/CY when target=stick. // syncStickTarget listeners are added before redrawIfAnnotating so that // TX/TY are updated before the overlay redraw reads them. const syncStickTarget = () => { if (targetEl?.value === 'stick') onTargetChange(panelId, 'stick'); }; cxEl?.addEventListener('input', syncStickTarget); cyEl?.addEventListener('input', syncStickTarget); // Live overlay update when coord/box inputs change (idle + annotating only). const redrawIfAnnotating = () => { const state = _pickState[panelId]; if (!state.annotating || state.phase !== 'idle' || !state.viewer) return; const cx = parseFloat(document.getElementById(`guider-cx-${panelId}`)?.value); const cy = parseFloat(document.getElementById(`guider-cy-${panelId}`)?.value); const bx = parseInt( document.getElementById(`guider-box-val-${panelId}`)?.value) || 256; const tx = parseFloat(document.getElementById(`guider-tx-${panelId}`)?.value); const ty = parseFloat(document.getElementById(`guider-ty-${panelId}`)?.value); if (!isNaN(cx)) state.centerX = cx; if (!isNaN(cy)) state.centerY = cy; state.boxPx = bx; if (!isNaN(tx) && !isNaN(ty)) { state.targetX = tx; state.targetY = ty; } state.viewer.redrawOverlay(); }; for (const id of [ `guider-cx-${panelId}`, `guider-cy-${panelId}`, `guider-box-val-${panelId}`, `guider-tx-${panelId}`, `guider-ty-${panelId}`, ]) { document.getElementById(id)?.addEventListener('input', redrawIfAnnotating); } if (cameraEl) onCameraChange(panelId, cameraEl.value); if (targetEl) onTargetChange(panelId, targetEl.value); initPickMode(panelId); } document.addEventListener('DOMContentLoaded', () => { Loading noctua/web/static/js/viewer/fits-viewer.js +124 −2 File changed.Preview size limit exceeded, changes collapsed. Show changes Loading
noctua/web/pages/control.html +7 −4 Original line number Diff line number Diff line Loading @@ -173,12 +173,13 @@ </section> {{ ctrl.guider_panel("imaging", [("cam","scicam"), ("teccam","teccam")], [("cam","scicam","ctrl-combo1-sci"), ("teccam","teccam","ctrl-combo1-tec")], "camera", has_actuator_select=True) }} {{ ctrl.guider_panel("spectro", [("teccam2","teccam2")], [("teccam2","teccam2","ctrl-combo2-tec")], "camera2") }} </article> Loading Loading @@ -232,6 +233,7 @@ <script type="module" src="{{ url_for('web.static', filename='js/guider-panel.js') }}"></script> <script type="module"> import { FitsViewer } from "{{ url_for('web.static', filename='js/viewer/fits-viewer.js') }}"; import { register as registerViewer } from "{{ url_for('web.static', filename='js/viewer/viewer-registry.js') }}"; function initViewer(panelId, station, camera, hasLoop, apiBase) { const panel = document.getElementById(`viewer-panel-${panelId}`); Loading Loading @@ -298,7 +300,8 @@ ]; for (const p of panels) { initViewer(p.id, p.station, p.camera, p.hasLoop, '/api'); const viewer = initViewer(p.id, p.station, p.camera, p.hasLoop, '/api'); if (viewer) registerViewer(p.id, viewer); } </script> <script src="{{ url_for('web.static', filename='js/webcam.js') }}"></script> Loading
noctua/web/pages/macros/control_panel.html +9 −4 Original line number Diff line number Diff line Loading @@ -78,8 +78,11 @@ <select class="form-select form-select-sm bg-black" id="guider-camera-{{ panel_id }}" {% if cameras | length == 1 %}disabled{% endif %}> {% for val, lbl in cameras %} <option value="{{ val }}" {{ 'selected' if loop.first }}>{{ lbl }}</option> {% for cam_tuple in cameras %} {% set val = cam_tuple[0] %} {% set lbl = cam_tuple[1] %} {% set vid = cam_tuple[2] if cam_tuple | length > 2 else '' %} <option value="{{ val }}" {% if vid %}data-viewer-id="{{ vid }}"{% endif %} {{ 'selected' if loop.first }}>{{ lbl }}</option> {% endfor %} </select> </div> Loading Loading @@ -178,7 +181,9 @@ <!-- Start / Stop --> <fieldset class="row mt-2"> <div class="col-md offset-md-2"> <button class="col-md-2 btn btn-sm btn-outline-secondary" id="btn-{{ panel_id }}-pick" type="button">Pick</button> <div class="col-md"> <div class="input-group"> <button class="btn btn-primary flex-fill" type="button" id="btn-{{ panel_id }}-guide-start">Guide</button> Loading
noctua/web/static/css/viewer.css +12 −0 Original line number Diff line number Diff line Loading @@ -101,3 +101,15 @@ } /* ── Panoramic: yellow rect rendered via canvas 2d, no extra CSS needed ─── */ /* ── Pick mode: viewer wrapper border feedback ───────────────────────────── */ .viewer-canvas-wrap.pick-warning { border-color: var(--bs-warning); box-shadow: 0 0 0 2px var(--bs-warning), 0 4px 24px rgba(0, 0, 0, .7); } .viewer-canvas-wrap.pick-success { border-color: var(--bs-success); box-shadow: 0 0 0 2px var(--bs-success), 0 4px 24px rgba(0, 0, 0, .7); }
noctua/web/static/js/guider-panel.js +358 −31 Original line number Diff line number Diff line // guider-panel.js // Guider panel UI — camera/exptime reactivity, target coords, Guide button dispatch. // Guider panel UI — camera/exptime reactivity, target coords, Guide button dispatch, // and interactive coordinate picking via the FITS viewer overlay. // // Auto-discovers panel instances from the DOM: any element whose id matches // "btn-{panelId}-guide-start" triggers initialisation of that panel. import { showToast } from './ui.js'; import { get as getViewer } from './viewer/viewer-registry.js'; // --- Constants --- Loading @@ -28,26 +30,28 @@ const ACTUATOR_PARAMS = { * from disk — exptime is irrelevant and could mislead the operator). * Re-enables it when an active (triggering) camera is selected. * * @param {string} panelId - Panel identifier ("imaging" or "spectro"). * @param {string} camera - Selected camera device name. * @param {string} panelId * @param {string} camera */ function onCameraChange(panelId, camera) { const el = document.getElementById(`guider-exptime-val-${panelId}`); if (!el) return; const passive = PASSIVE_CAMERAS.has(camera); el.disabled = passive; if (passive) el.value = ''; } // --- 2. Target → custom coords --- // --- 2. Target → coord fields --- /** * Enables the TX/TY coordinate inputs when target mode is "custom". * Clears and disables them for "center" and "stick". * Enables/disables TX/TY and auto-populates them based on target mode: * center → image centre (width/2, height/2) from the registered viewer * stick → copy current CX/CY values * custom → enable, keep current values * other → disable, clear * * @param {string} panelId - Panel identifier. * @param {string} target - Selected target mode ("center", "stick", or "custom"). * @param {string} panelId * @param {string} target */ function onTargetChange(panelId, target) { const tx = document.getElementById(`guider-tx-${panelId}`); Loading @@ -57,7 +61,21 @@ function onTargetChange(panelId, target) { const custom = target === 'custom'; tx.disabled = !custom; ty.disabled = !custom; if (!custom) { if (target === 'center') { const viewer = getViewerForPanel(panelId); const { width, height } = viewer?.getImageSize() ?? { width: 0, height: 0 }; if (width && height) { tx.value = Math.round(width / 2); ty.value = Math.round(height / 2); } else { tx.value = ''; ty.value = ''; } } else if (target === 'stick') { tx.value = document.getElementById(`guider-cx-${panelId}`)?.value ?? ''; ty.value = document.getElementById(`guider-cy-${panelId}`)?.value ?? ''; } else if (!custom) { tx.value = ''; ty.value = ''; } Loading @@ -69,15 +87,8 @@ function onTargetChange(panelId, target) { * Reads all inputs in a guider panel and returns a params object * ready to POST to /api/guider/. * * Target handling: * "center" / "stick" → sent as-is. * "custom" → sent as [tx, ty] pixel array if both fields are filled. * * Actuator handling: * "ao_x_offload" is translated to { actuator: "ao_x", ao_offload: true }. * * @param {string} panelId - Panel identifier. * @returns {object} Params dict for the guider API. * @param {string} panelId * @returns {object} */ function collectParams(panelId) { const get = id => document.getElementById(id); Loading Loading @@ -119,10 +130,10 @@ function collectParams(panelId) { } /** * POSTs guider params to /api/guider/, showing a spinner on the button while pending. * POSTs guider params to /api/guider/, showing a spinner while pending. * * @param {HTMLButtonElement} btn - The Guide button element. * @param {object} params - Params from collectParams(). * @param {HTMLButtonElement} btn * @param {object} params */ async function postGuider(btn, params) { const orig = btn.innerHTML; Loading @@ -147,26 +158,342 @@ async function postGuider(btn, params) { } } // --- 4. Init --- // --- 4. Pick mode --- /** * Wires all event listeners for one guider panel instance and applies * the initial UI state (exptime/coords may already be disabled on load). * Per-panel pick + annotation state. * * Pick phases: * idle — no active pick; annotation may be visible. * picking-center — Pick clicked; awaiting first left-click (sets center). * picking-target — center set; awaiting second left-click (sets target). * * sessionClicked: true once the first left-click fires in the current session. * false → right-click is a destructive cancel (clears annotation). * true → right-click is a non-destructive exit (keeps box, discards target). * * @param {string} panelId - Panel identifier derived from the Guide button ID. * Persistent annotation (survives pick exit unless destructive cancel): * annotating, centerX/Y, boxPx, targetX/Y. * * @type {Object.<string, object>} */ const _pickState = {}; /** Return the FitsViewer for the currently selected guider camera. */ function getViewerForPanel(panelId) { const cameraEl = document.getElementById(`guider-camera-${panelId}`); const opt = cameraEl?.options[cameraEl.selectedIndex]; const viewerId = opt?.dataset?.viewerId; return viewerId ? getViewer(viewerId) : null; } /** * Unified overlay drawer for all pick phases and the persistent annotation. * Reads live from state — install once, call viewer.redrawOverlay() on state changes. * * Draws: * - Green box at (centerX, centerY) with side boxPx, when annotating. * - Green X at (targetX, targetY), only when phase=idle. * - Yellow dashed line from center to mouse, only when phase=picking-target. * - Yellow "Pick" label, whenever phase ≠ idle. * * @param {object} state - The mutable per-panel state object. * @returns {function} */ function makeDrawer(state) { return (ctx, toCanvas) => { // Green box. if (state.annotating && state.centerX !== null) { const half = (state.boxPx ?? 256) / 2; const tl = toCanvas(state.centerX - half, state.centerY - half); const br = toCanvas(state.centerX + half, state.centerY + half); ctx.strokeStyle = '#198754'; ctx.lineWidth = 2; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); } // Green X at target — only when idle (not while a new target is being picked). if (state.annotating && state.targetX !== null && state.phase === 'idle') { const t = toCanvas(state.targetX, state.targetY); const s = 8; ctx.strokeStyle = '#198754'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(t.x - s, t.y - s); ctx.lineTo(t.x + s, t.y + s); ctx.moveTo(t.x + s, t.y - s); ctx.lineTo(t.x - s, t.y + s); ctx.stroke(); } // Yellow dashed line from center to mouse — picking-target only. if (state.phase === 'picking-target' && state.centerX !== null && state.mousePos) { const c = toCanvas(state.centerX, state.centerY); const m = toCanvas(state.mousePos.x, state.mousePos.y); ctx.strokeStyle = '#ffc107'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(m.x, m.y); ctx.stroke(); ctx.setLineDash([]); } // "Pick" label — any active phase. if (state.phase !== 'idle') { ctx.font = 'bold 18px monospace'; ctx.fillStyle = '#ffc107'; ctx.fillText('Pick', 16, 32); } }; } /** * Remove the mousemove listener and pick callback from the viewer. * Does not touch the annotation state or the overlay drawer. * * @param {object} state */ function teardownSession(state) { if (state.mouseMoveHandler) { state.viewer?.removeMouseMoveListener(state.mouseMoveHandler); state.mouseMoveHandler = null; } state.viewer?.clearPick(); state.mousePos = null; state.sessionClicked = false; } /** * Wire the Pick button for one guider panel. * * State machine: * * [idle] * Pick click → picking-center (arm) * * [picking-center] * Pick click → picking-center (re-arm, discard phase-2 if any) * right-click → idle DESTRUCTIVE (clears annotation) * left-click → picking-target (center set, box drawn) * * [picking-target] * Pick click → picking-center (re-arm, keep existing box) * right-click → idle NON-DESTRUCTIVE (box stays, no target) * left-click → idle (target set, box+X drawn) * * @param {string} panelId */ function initPickMode(panelId) { const btn = document.getElementById(`btn-${panelId}-pick`); if (!btn) return; const state = _pickState[panelId]; // ── Unified pick callback (defined once, re-registered on each Pick click) ── const pickCallback = ({ imageX, imageY, cancelled }) => { const wrap = state.viewer?.root.querySelector('.viewer-canvas-wrap'); if (cancelled) { if (!state.sessionClicked) { // ── Destructive: right-click before any left-click this session ── teardownSession(state); state.phase = 'idle'; state.annotating = false; state.centerX = state.centerY = null; state.targetX = state.targetY = null; state.viewer?.clearOverlayDrawer(); state.viewer = null; wrap?.classList.remove('pick-warning', 'pick-success'); for (const id of [ `guider-cx-${panelId}`, `guider-cy-${panelId}`, `guider-tx-${panelId}`, `guider-ty-${panelId}`, ]) { document.getElementById(id)?.classList.remove('border-warning', 'border-success'); } } else { // ── Non-destructive: right-click after a left-click ────────────── // Box annotation persists; target (if any from previous pick) gone. teardownSession(state); state.phase = 'idle'; wrap?.classList.remove('pick-warning', 'pick-success'); state.viewer?.redrawOverlay(); for (const id of [`guider-tx-${panelId}`, `guider-ty-${panelId}`]) { document.getElementById(id)?.classList.remove('border-warning'); } } btn.classList.remove('btn-warning'); btn.classList.add('btn-outline-secondary'); return; } if (state.phase === 'picking-center') { // ── Phase 1 → 2: center picked ────────────────────────────────────── state.sessionClicked = true; state.centerX = imageX; state.centerY = imageY; state.boxPx = parseInt( document.getElementById(`guider-box-val-${panelId}`)?.value ) || 256; state.annotating = true; state.targetX = null; // discard old target while picking new one state.phase = 'picking-target'; const cxEl = document.getElementById(`guider-cx-${panelId}`); const cyEl = document.getElementById(`guider-cy-${panelId}`); if (cxEl) { cxEl.value = Math.round(imageX); cxEl.classList.remove('border-warning'); cxEl.classList.add('border-success'); } if (cyEl) { cyEl.value = Math.round(imageY); cyEl.classList.remove('border-warning'); cyEl.classList.add('border-success'); } wrap?.classList.remove('pick-warning'); wrap?.classList.add('pick-success'); state.mouseMoveHandler = ({ imageX: mx, imageY: my }) => { state.mousePos = { x: mx, y: my }; state.viewer.redrawOverlay(); }; state.viewer.addMouseMoveListener(state.mouseMoveHandler); state.viewer.redrawOverlay(); } else if (state.phase === 'picking-target') { // ── Phase 2 → idle: target picked ─────────────────────────────────── state.targetX = imageX; state.targetY = imageY; state.phase = 'idle'; // Enable TX/TY and fill them. const targetSel = document.getElementById(`guider-target-${panelId}`); if (targetSel) { targetSel.value = 'custom'; onTargetChange(panelId, 'custom'); } const txEl = document.getElementById(`guider-tx-${panelId}`); const tyEl = document.getElementById(`guider-ty-${panelId}`); if (txEl) { txEl.value = Math.round(imageX); txEl.classList.remove('border-warning'); txEl.classList.add('border-success'); } if (tyEl) { tyEl.value = Math.round(imageY); tyEl.classList.remove('border-warning'); tyEl.classList.add('border-success'); } teardownSession(state); wrap?.classList.remove('pick-success'); state.viewer.redrawOverlay(); // box + X, no dashed line btn.classList.remove('btn-warning'); btn.classList.add('btn-outline-secondary'); } }; // ── Pick button click: arm / re-arm picking-center ────────────────────────── btn.addEventListener('click', () => { const viewer = getViewerForPanel(panelId); if (!viewer) { showToast('No viewer registered for this camera', 'warning'); return; } // Tear down any in-progress session on the current viewer. teardownSession(state); // If the camera was switched, clear the annotation on the old viewer. if (state.viewer && state.viewer !== viewer) { state.viewer.clearOverlayDrawer(); state.viewer.root.querySelector('.viewer-canvas-wrap') ?.classList.remove('pick-warning', 'pick-success'); state.annotating = false; state.centerX = state.centerY = null; state.targetX = state.targetY = null; } state.viewer = viewer; state.phase = 'picking-center'; state.sessionClicked = false; state.mousePos = null; // (Re-)install drawer — needed when coming back after a destructive cancel. viewer.setOverlayDrawer(makeDrawer(state)); viewer.redrawOverlay(); const wrap = viewer.root.querySelector('.viewer-canvas-wrap'); if (wrap) { wrap.classList.remove('pick-success'); wrap.classList.add('pick-warning'); } btn.classList.remove('btn-outline-secondary'); btn.classList.add('btn-warning'); for (const id of [ `guider-cx-${panelId}`, `guider-cy-${panelId}`, `guider-tx-${panelId}`, `guider-ty-${panelId}`, ]) { const el = document.getElementById(id); if (el) { el.classList.remove('border-success'); el.classList.add('border-warning'); } } viewer.setPick(pickCallback); }); } // --- 5. Init --- /** * Wires all event listeners for one guider panel instance and sets initial UI state. * * @param {string} panelId */ function initPanel(panelId) { const cameraEl = document.getElementById(`guider-camera-${panelId}`); const targetEl = document.getElementById(`guider-target-${panelId}`); const guideBtn = document.getElementById(`btn-${panelId}-guide-start`); const cxEl = document.getElementById(`guider-cx-${panelId}`); const cyEl = document.getElementById(`guider-cy-${panelId}`); _pickState[panelId] = { phase: 'idle', viewer: null, mouseMoveHandler: null, mousePos: null, sessionClicked: false, annotating: false, centerX: null, centerY: null, boxPx: 256, targetX: null, targetY: null, }; cameraEl?.addEventListener('change', e => onCameraChange(panelId, e.target.value)); targetEl?.addEventListener('change', e => onTargetChange(panelId, e.target.value)); guideBtn?.addEventListener('click', () => postGuider(guideBtn, collectParams(panelId))); // Apply initial disabled state derived from current select values // Keep TX/TY in sync with CX/CY when target=stick. // syncStickTarget listeners are added before redrawIfAnnotating so that // TX/TY are updated before the overlay redraw reads them. const syncStickTarget = () => { if (targetEl?.value === 'stick') onTargetChange(panelId, 'stick'); }; cxEl?.addEventListener('input', syncStickTarget); cyEl?.addEventListener('input', syncStickTarget); // Live overlay update when coord/box inputs change (idle + annotating only). const redrawIfAnnotating = () => { const state = _pickState[panelId]; if (!state.annotating || state.phase !== 'idle' || !state.viewer) return; const cx = parseFloat(document.getElementById(`guider-cx-${panelId}`)?.value); const cy = parseFloat(document.getElementById(`guider-cy-${panelId}`)?.value); const bx = parseInt( document.getElementById(`guider-box-val-${panelId}`)?.value) || 256; const tx = parseFloat(document.getElementById(`guider-tx-${panelId}`)?.value); const ty = parseFloat(document.getElementById(`guider-ty-${panelId}`)?.value); if (!isNaN(cx)) state.centerX = cx; if (!isNaN(cy)) state.centerY = cy; state.boxPx = bx; if (!isNaN(tx) && !isNaN(ty)) { state.targetX = tx; state.targetY = ty; } state.viewer.redrawOverlay(); }; for (const id of [ `guider-cx-${panelId}`, `guider-cy-${panelId}`, `guider-box-val-${panelId}`, `guider-tx-${panelId}`, `guider-ty-${panelId}`, ]) { document.getElementById(id)?.addEventListener('input', redrawIfAnnotating); } if (cameraEl) onCameraChange(panelId, cameraEl.value); if (targetEl) onTargetChange(panelId, targetEl.value); initPickMode(panelId); } document.addEventListener('DOMContentLoaded', () => { Loading
noctua/web/static/js/viewer/fits-viewer.js +124 −2 File changed.Preview size limit exceeded, changes collapsed. Show changes