Commit 7d365aad authored by vertighel's avatar vertighel
Browse files

control.html: inject combo viewer panels; extract camera_panel to shared macro



Move camera_panel macro to macros/viewer_panel.html and import it in
both viewer.html and control_panel.html. Replace the old <template>
clone approach in control.html with three pre-rendered combo panels
(combo1/2/3) toggled via d-none on mode switch. initViewer() wires up
each panel's FitsViewer on page load. control.js loses the FitsViewer
import; switchViewer() becomes a simple show/hide by data-combo-view.

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent ed1befb8
Loading
Loading
Loading
Loading
Loading
+84 −11
Original line number Diff line number Diff line
@@ -15,11 +15,15 @@
        <div class="bg-dark p-3 rounded border border-secondary shadow-sm mb-3">
            {{ w.widget_input({
                "label": "Focus",
                "info": "/telescope/focuser",
                "inputs": [{"value": 0}],
                "unit": "µm",
                "buttons": [{"label": "Set", "endpoint": "/telescope/focuser", "method": "PUT"}],
                "info_list": [{"label": "status", "status": "telescope-focuser-movement"}]
            "info_list": [
            {"label": "moving", "status": "telescope-focuser-movement"},
            {"label": "pos", "status": "telescope-focuser-position"},


            ]
            }) }}
        </div>

@@ -59,8 +63,9 @@
            <div class="card-body p-0 bg-black position-relative" style="min-height: 500px;">
                <div class="tab-content h-100">
                    <div class="tab-pane fade show active h-100" id="mon-fits">
                        <!-- We will inject the FitsViewer here dynamically -->
                        <div id="fits-viewer-container" class="h-100"></div>
                        <div id="fits-viewer-container" class="h-100 overflow-auto p-2">
                            {{ ctrl.fits_viewer_section('/api') }}
                        </div>
                    </div>
                    <div class="tab-pane fade" id="mon-webcam">
                        {{ webcam.webcam_panel() }}
@@ -78,15 +83,83 @@
        </div>
    </section>

{{ ctrl.fits_viewer_blueprint() }}

</div>
{% endblock %}

{% block scripts %}
    <script type="module" src="{{ url_for('web.static', filename='js/control.js') }}"></script>
    <script type="module">
    import { FitsViewer } from "{{ url_for('web.static', filename='js/viewer/fits-viewer.js') }}";

    function initViewer(panelId, station, camera, hasLoop, apiBase) {
        const panel = document.getElementById(`viewer-panel-${panelId}`);
        if (!panel) return null;

        const viewer = new FitsViewer(station, panel, apiBase, [camera]);
        viewer.refresh();

        panel.querySelector(`.btn-refresh-${panelId}`)
            ?.addEventListener('click', () => viewer.refresh());

        panel.querySelector(`.btn-reset-${panelId}`)
            ?.addEventListener('click', () => {
                viewer._primary.vmin = 0;
                viewer._primary.vmax = 0;
                viewer.refresh();
            });

        if (hasLoop) {
            const startBtn = document.getElementById(`loop-start-${panelId}`);
            const stopBtn  = document.getElementById(`loop-stop-${panelId}`);
            const expInput = document.getElementById(`loop-exp-${panelId}`);

            function setLoopUI(running) {
                if (startBtn) startBtn.disabled = running;
                if (stopBtn)  stopBtn.disabled  = !running;
            }

            async function loopPost(action, extra = {}) {
                return fetch(`${apiBase}/viewer/${station}/${camera}/loop`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ action, ...extra }),
                });
            }

            startBtn?.addEventListener('click', async () => {
                const exp = parseFloat(expInput?.value) || 1.0;
                const r = await loopPost('start', { exposure: exp });
                if (r.ok) setLoopUI(true);
            });

            stopBtn?.addEventListener('click', async () => {
                await loopPost('stop');
                setLoopUI(false);
            });

            fetch(`${apiBase}/viewer/${station}/${camera}/loop`)
                .then(r => r.json())
                .then(d => setLoopUI(d.running))
                .catch(() => {});
        }

        return viewer;
    }

    const panels = [
        { id: 'ctrl-combo1-sci', station: 'station1', camera: 'scicam',  hasLoop: false },
        { id: 'ctrl-combo1-tec', station: 'station1', camera: 'teccam',  hasLoop: true  },
        { id: 'ctrl-combo2-sci', station: 'station2', camera: 'scicam',  hasLoop: false },
        { id: 'ctrl-combo2-tec', station: 'station2', camera: 'teccam',  hasLoop: true  },
        { id: 'ctrl-combo3-sci', station: 'station3', camera: 'scicam',  hasLoop: false },
        { id: 'ctrl-combo3-tec', station: 'station3', camera: 'teccam',  hasLoop: true  },
    ];

    for (const p of panels) {
        initViewer(p.id, p.station, p.camera, p.hasLoop, '/api');
    }
    </script>
    <script src="{{ url_for('web.static', filename='js/webcam.js') }}"></script>
    <script>window.SYNOPTIC_SVG_URL = "{{ url_for('web.static', filename='img/synoptic.svg') }}";</script>
    <script type="module" src="{{ url_for('web.static', filename='js/synoptic.js') }}"></script>
{% endblock %}
    
+24 −177
Original line number Diff line number Diff line
@@ -207,186 +207,33 @@
</div>
{% endmacro %}

{% macro fits_viewer_blueprint() %}
{% from "macros/viewer_panel.html" import camera_panel %}

{% macro fits_viewer_section(api_base='/api') %}
{#
    Template cloned by JS to populate the monitor area.
    Preserves aspect ratio and provides all necessary controls for FitsViewer.
    Pre-renders combo1/2/3. JS shows the active one, hides the others.
    combo1=station1, combo2=station2, combo3=station3.
#}
<template id="tpl-fits-viewer">

    <div class="d-flex align-items-center gap-2">
        {# Colormap selector (applies to all cameras) #}
        <label class="text-muted small" for="global-colormap">Colormap</label>
        <select id="global-colormap" class="form-select form-select-sm ctrl-colormap"
                style="width:110px; background:#111; border-color:#333;">
            <option value="viridis" selected>Viridis</option>
            <option value="brbg">BrBG</option>
        </select>

        {# Auto-update toggle for the primary camera #}
        <div class="d-flex align-items-center gap-1 ms-auto">
            <input type="checkbox" class="ctrl-auto form-check-input"
                   id="ctrl-auto-primary" checked>
            <label class="text-muted small" for="ctrl-auto-primary">Auto</label>
        </div>

        {# Display latest — fetches current FITS from disk on demand #}
        <button id="btn-refresh-all"
                class="btn btn-sm btn-outline-secondary"
                title="Fetch latest image from disk">
            Display latest
        </button>
    </div>

<div id="panel-{{ station }}"
     data-station="{{ station }}"
     data-api-base="{{ api_base }}">

    {# ── Two-column layout: primary camera left, secondary stack right ── #}
    <div class="row g-2 align-items-start">

        {# ── Left: primary camera (first in list) ── #}
        {% set primary = "scicam" %}
        <div class="col-xl-9 col-lg-8">

            <div class="viewer-canvas-wrap position-relative"
                 style="background:#000; overflow:hidden; border-radius:6px;"
                 data-camera="{{ primary }}">

                <canvas class="cv-main d-block" width="800" height="800"
                        style="width:100%; cursor:crosshair; transform-origin:0 0;">
                </canvas>

                <svg class="overlay-main position-absolute top-0 start-0 w-100 h-100"
                     style="pointer-events:none; overflow:visible;"
                     xmlns="http://www.w3.org/2000/svg">
                </svg>

                <div class="cv-crosshair position-absolute"
                     style="pointer-events:none; width:12px; height:12px;
                            transform:translate(-50%,-50%); display:none;">
                    <svg width="12" height="12" viewBox="0 0 12 12">
                        <line x1="6" y1="0" x2="6" y2="12" stroke="#ffff00" stroke-width="1"/>
                        <line x1="0" y1="6" x2="12" y2="6" stroke="#ffff00" stroke-width="1"/>
                    </svg>
                </div>

                <div class="position-absolute bottom-0 start-0 m-2">
                    <span class="badge bg-black bg-opacity-75 info-main"
                          style="font-family:monospace; font-size:.65rem;"></span>
                </div>
            </div>

            {# Controls strip for primary camera #}
            <div class="viewer-controls mt-2 p-2 rounded"
                 style="background:#0d0d0d; border:1px solid #222;">
                <div class="row g-2 align-items-center">

                    <div class="col-auto d-flex align-items-center gap-1">
                        <label class="text-muted small">Min</label>
                        <input type="number" class="ctrl-vmin form-control form-control-sm"
                               style="width:90px; background:#111; border-color:#333;" step="any">
                        <label class="text-muted small">Max</label>
                        <input type="number" class="ctrl-vmax form-control form-control-sm"
                               style="width:90px; background:#111; border-color:#333;" step="any">
                    </div>

                    <div class="col-auto">
                        <div class="btn-group btn-group-sm">
                            <button class="ctrl-png btn btn-outline-secondary active"
                                    data-camera="{{ primary }}">PNG</button>
                            <button class="ctrl-fits btn btn-outline-secondary"
                                    data-camera="{{ primary }}">FITS</button>
                        </div>
{# combo1 — station1 — visible by default #}
<div data-combo-view="combo1">
{% call camera_panel('ctrl-combo1-sci', 'station1', 'scicam', false, api_base) %}
    {{ camera_panel('ctrl-combo1-tec', 'station1', 'teccam', true, api_base) }}
{% endcall %}
</div>

{# combo2 — station2 — hidden #}
<div data-combo-view="combo2" class="d-none">
{% call camera_panel('ctrl-combo2-sci', 'station2', 'scicam', false, api_base) %}
    {{ camera_panel('ctrl-combo2-tec', 'station2', 'teccam', true, api_base) }}
{% endcall %}
</div>
            </div>
        </div>

        {# ── Right: panoramic + explore + secondary cameras ── #}
        <div class="col-xl-3 col-lg-4 d-flex flex-column gap-2">

            {# Panoramic + Explore pair (always for the primary camera) #}
            <div class="row g-1">
                <div class="col-6" style="background:#000; border-radius:4px; overflow:hidden;">
                    <canvas class="cv-explore d-block" width="256" height="256"
                            style="width:100%; image-rendering:pixelated;"></canvas>
                </div>
                <div class="col-6" style="background:#000; border-radius:4px; overflow:hidden;">
                    <canvas class="cv-panoramic d-block" width="256" height="256"
                            style="width:100%; height:auto;"></canvas>
                </div>
                <div class="col-12 d-flex align-items-center gap-1 mt-1">
                    <div class="input-group input-group-sm">
                        <span class="input-group-text"
                              style="font-size:.65rem; padding:2px 6px;
                                     background:#1a1a1a; border-color:#333; color:#aaa;">
                            Box
                        </span>
                        <input type="number" class="ctrl-explore-w form-control form-control-sm"
                               value="70" step="10"
                               style="background:#111; border-color:#333;
                                      font-size:.7rem; color:#b0ffb0;">
                    </div>
{# combo3 — station3 — hidden #}
<div data-combo-view="combo3" class="d-none">
{% call camera_panel('ctrl-combo3-sci', 'station3', 'scicam', false, api_base) %}
    {{ camera_panel('ctrl-combo3-tec', 'station3', 'teccam', true, api_base) }}
{% endcall %}
</div>
            </div>

            {# Secondary cameras (everything after the first) #}
            {% set cam = 'teccam' %}
            <div data-secondary-camera="{{ cam }}">
                <div class="text-muted small mb-1"
                     style="font-size:.65rem; letter-spacing:.08em; text-transform:uppercase;">
                    {{ cam }}
                    <span class="badge bg-black bg-opacity-75 info-sec-{{ cam }} ms-1"
                          style="font-family:monospace; font-size:.6rem;"></span>
                </div>
                <div class="position-relative"
                     style="background:#000; border-radius:4px; overflow:hidden;">
                    <canvas class="cv-sec-{{ cam }} d-block"
                            width="256" height="256" style="width:100%;"></canvas>
                    <svg class="overlay-sec-{{ cam }} position-absolute top-0 start-0 w-100 h-100"
                         style="pointer-events:none; overflow:visible;"
                         xmlns="http://www.w3.org/2000/svg">
                    </svg>
                </div>

                <div class="mt-1 p-1 rounded"
                     style="background:#0d0d0d; border:1px solid #1a1a1a;">
                    <div class="d-flex flex-wrap gap-1 align-items-center">
                        <input type="number"
                               class="ctrl-vmin-sec-{{ cam }} form-control form-control-sm"
                               style="width:72px; background:#111; border-color:#333; font-size:.7rem;"
                               step="any" placeholder="min">
                        <input type="number"
                               class="ctrl-vmax-sec-{{ cam }} form-control form-control-sm"
                               style="width:72px; background:#111; border-color:#333; font-size:.7rem;"
                               step="any" placeholder="max">
                        <div class="btn-group btn-group-sm">
                            <button class="ctrl-png btn btn-outline-secondary"
                                    data-camera="{{ cam }}"
                                    style="font-size:.65rem;">PNG</button>
                            <button class="ctrl-fits btn btn-outline-secondary"
                                    data-camera="{{ cam }}"
                                    style="font-size:.65rem;">FITS</button>
                        </div>
                        <div class="d-flex align-items-center gap-1">
                            <input type="checkbox"
                                   class="ctrl-auto form-check-input"
                                   id="ctrl-auto-{{ cam }}" checked>
                            <label class="text-muted"
                                   style="font-size:.65rem;"
                                   for="ctrl-auto-{{ cam }}">Auto</label>
                        </div>
                    </div>
                </div>
            </div>

        </div>
    </div>

  
</template>
{% endmacro %}


+130 −0
Original line number Diff line number Diff line
{# macros/viewer_panel.html
   Shared camera_panel macro — imported by viewer.html and control_panel.html.

   Usage (single):
     {{ camera_panel('scicam1', 'station1', 'scicam', false, '/api') }}

   Usage (combo — teccam nested in scicam's right column):
     {% call camera_panel('combo1-sci', 'station1', 'scicam', false, '/api') %}
         {{ camera_panel('combo1-tec', 'station1', 'teccam', true, '/api') }}
     {% endcall %}
#}

{% macro camera_panel(panel_id, station, camera, has_loop, api_base) %}
<div id="viewer-panel-{{ panel_id }}"
     class="viewer-panel"
     data-station="{{ station }}"
     data-camera="{{ camera }}"
     data-api-base="{{ api_base }}">

    <div class="d-flex justify-content-between align-items-center mb-2">
        <span class="text-uppercase text-muted"
              style="font-size:.75rem; letter-spacing:.1em;">
            {{ station | title }} / {{ camera }}
        </span>
        <button class="btn btn-sm btn-outline-secondary btn-refresh-{{ panel_id }}"
                title="Fetch current image">↺ Refresh</button>
    </div>

    <div class="row g-2 align-items-start">

        {# ── Left: main image canvas ── #}
        <div class="col-xl-9 col-lg-8">

            <div class="viewer-canvas-wrap position-relative"
                 style="background:#000; overflow:hidden; border-radius:6px;">
                <canvas class="cv-main d-block" width="800" height="800"
                        style="width:100%; cursor:crosshair; transform-origin:0 0;">
                </canvas>
                <div class="cv-crosshair position-absolute"
                     style="pointer-events:none; width:12px; height:12px;
                            transform:translate(-50%,-50%); display:none;">
                    <svg width="12" height="12" viewBox="0 0 12 12">
                        <line x1="6" y1="0" x2="6" y2="12" stroke="#ffff00" stroke-width="1"/>
                        <line x1="0" y1="6" x2="12" y2="6" stroke="#ffff00" stroke-width="1"/>
                    </svg>
                </div>
                <div class="position-absolute bottom-0 start-0 m-2">
                    <span class="badge bg-black bg-opacity-75 info-main"
                          style="font-family:monospace; font-size:.65rem;"></span>
                </div>
            </div>

            <div class="viewer-controls mt-2 p-2 rounded"
                 style="background:#0d0d0d; border:1px solid #222;">
                <div class="row g-2 align-items-center flex-wrap">

                    <div class="col-auto d-flex align-items-center gap-1">
                        <label class="text-muted small">Min</label>
                        <input type="number" class="ctrl-vmin form-control form-control-sm"
                               style="width:90px; background:#111; border-color:#333;" step="any">
                        <label class="text-muted small">Max</label>
                        <input type="number" class="ctrl-vmax form-control form-control-sm"
                               style="width:90px; background:#111; border-color:#333;" step="any">
                        <button class="btn btn-sm btn-outline-secondary btn-reset-{{ panel_id }}">
                            Reset
                        </button>
                    </div>

                    <div class="col-auto d-flex align-items-center gap-1">
                        <input type="checkbox" class="ctrl-auto form-check-input"
                               id="ctrl-auto-{{ panel_id }}"
                               data-camera="{{ camera }}" checked>
                        <label class="text-muted small"
                               for="ctrl-auto-{{ panel_id }}">Auto&nbsp;update</label>
                    </div>

                    {% if has_loop %}
                    <div class="col-auto">
                        <div class="input-group input-group-sm">
                            <input type="number" id="loop-exp-{{ panel_id }}"
                                   class="form-control" value="1" min="0.1" step="0.5"
                                   title="Exposure (s)"
                                   style="background:#111; border-color:#333; max-width:70px;">
                            <button id="loop-start-{{ panel_id }}"
                                    class="btn btn-primary">Start</button>
                            <button id="loop-stop-{{ panel_id }}"
                                    class="btn btn-outline-secondary" disabled>Stop</button>
                        </div>
                    </div>
                    {% endif %}

                </div>
            </div>
        </div>

        {# ── Right: explore + panner + nested slot ── #}
        <div class="col-xl-3 col-lg-4 d-flex flex-column gap-2">

            <div class="row g-1">
                <div class="col-6" style="background:#000; border-radius:4px; overflow:hidden;">
                    <canvas class="cv-explore d-block" width="256" height="256"
                            style="width:100%; image-rendering:pixelated;"></canvas>
                </div>
                <div class="col-6" style="background:#000; border-radius:4px; overflow:hidden;">
                    <canvas class="cv-panoramic d-block" width="256" height="256"
                            style="width:100%; height:auto;"></canvas>
                </div>
                <div class="col-12 mt-1">
                    <div class="input-group input-group-sm">
                        <span class="input-group-text"
                              style="font-size:.65rem; padding:2px 6px;
                                     background:#1a1a1a; border-color:#333; color:#aaa;">Box</span>
                        <input type="number" class="ctrl-explore-w form-control form-control-sm"
                               value="70" step="10"
                               style="background:#111; border-color:#333;
                                      font-size:.7rem; color:#b0ffb0;">
                    </div>
                </div>
            </div>

            {# Nested viewer slot — filled by {% call %} block in combo views #}
            {% if caller is defined %}
            <div class="viewer-nested-slot">{{ caller() }}</div>
            {% endif %}

        </div>

    </div>
</div>
{% endmacro %}
+1 −129

File changed.

Preview size limit exceeded, changes collapsed.

+14 −18
Original line number Diff line number Diff line
// control.js
// Logic for the Control page: Mode switching, Stage movement, Expose payload.

import { FitsViewer } from './viewer/fits-viewer.js';
import { showToast, setInputState } from './ui.js';

// ---------------------------------------------------------------------------
@@ -48,8 +47,6 @@ const FRAMING_PRESETS = [
// ---------------------------------------------------------------------------

document.addEventListener('DOMContentLoaded', () => {
    const viewerContainer = document.getElementById('fits-viewer-container');
    const viewerTemplate  = document.getElementById('tpl-fits-viewer');
    const stationLabel   = document.getElementById('active-station-id');
    const modeLabel      = document.getElementById('current-mode-label');
    const framingPresets = document.getElementById('framing-presets');
@@ -58,7 +55,6 @@ document.addEventListener('DOMContentLoaded', () => {
    const btnExpose      = document.getElementById('btn-camera-expose');

    let activeStation = 'station1';
    let activeViewer  = null;

    // Disable mode buttons for stations not yet available
    Object.entries(MODE_CONFIG).forEach(([station, cfg]) => {
@@ -101,8 +97,8 @@ document.addEventListener('DOMContentLoaded', () => {
        if (stationLabel) stationLabel.textContent = station.toUpperCase();
        if (modeLabel)    modeLabel.textContent     = cfg.label;

        // 5. Switch FITS viewer (camera list changes per mode)
        switchViewer(station, cfg);
        // 5. Switch FITS viewer to the matching combo panel
        switchViewer(station);

        // 6. Keep expose button aware of active station
        if (btnExpose) btnExpose.dataset.activeStation = station;
@@ -113,13 +109,13 @@ document.addEventListener('DOMContentLoaded', () => {
        btnExpose?.closest('.card.bg-dark')?.setAttribute('data-subsystem', cameraSubsystem);
    }

    function switchViewer(station, cfg) {
        if (!viewerTemplate || !viewerContainer) return;
        viewerContainer.innerHTML = '';
        viewerContainer.appendChild(viewerTemplate.content.cloneNode(true));
    const comboMap = { station1: 'combo1', station2: 'combo2', station3: 'combo3' };

        activeViewer = new FitsViewer(station, viewerContainer, '/api', cfg.viewerCameras);
        activeViewer.refresh();
    function switchViewer(station) {
        const target = comboMap[station];
        document.querySelectorAll('[data-combo-view]').forEach(el => {
            el.classList.toggle('d-none', el.dataset.comboView !== target);
        });
    }

    // Mode radio buttons