Commit ed1befb8 authored by vertighel's avatar vertighel
Browse files

Viewer redesign: unified camera_panel macro, new /viewer/<name> routes



Replace per-station viewer pages with 9 named views: scicam1-3,
teccam1-3, combo1-3. A single camera_panel Jinja2 macro renders the
unified snippet (large canvas + panner/explore + nested slot); combo
views inject the teccam panel into the scicam's nested slot via Jinja2
call blocks. Controls reduced to Min/Max + Reset + Auto-update; loop
controls (Exp/Start/Stop) shown only when device= is set in viewer.ini.
Nav dropdown updated to combo1/2/3. FitsViewer auto-update checkbox now
reads data-camera attribute for panel-scoped IDs. Pin numpy==2.3.5.

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 103d0c78
Loading
Loading
Loading
Loading
Loading
+45 −27
Original line number Diff line number Diff line
@@ -288,52 +288,70 @@ async def subsystem_page(subsystem_name, endpoint_name=None):
    )


_VIEWER_MAP = {
    'scicam1': [{'id': 'scicam1', 'station': 'station1', 'camera': 'scicam'}],
    'scicam2': [{'id': 'scicam2', 'station': 'station2', 'camera': 'scicam'}],
    'scicam3': [{'id': 'scicam3', 'station': 'station3', 'camera': 'scicam'}],
    'teccam1': [{'id': 'teccam1', 'station': 'station1', 'camera': 'teccam'}],
    'teccam2': [{'id': 'teccam2', 'station': 'station2', 'camera': 'teccam'}],
    'teccam3': [{'id': 'teccam3', 'station': 'station3', 'camera': 'teccam'}],
    'combo1':  [
        {'id': 'combo1-sci', 'station': 'station1', 'camera': 'scicam'},
        {'id': 'combo1-tec', 'station': 'station1', 'camera': 'teccam'},
    ],
    'combo2':  [
        {'id': 'combo2-sci', 'station': 'station2', 'camera': 'scicam'},
        {'id': 'combo2-tec', 'station': 'station2', 'camera': 'teccam'},
    ],
    'combo3':  [
        {'id': 'combo3-sci', 'station': 'station3', 'camera': 'scicam'},
        {'id': 'combo3-tec', 'station': 'station3', 'camera': 'teccam'},
    ],
}


@web_blueprint.route('/viewer')
async def viewer_index():
    """
    Redirect to the default station viewer.

    Returns
    -------
    Response
        Redirect to ``station1``.
    """
    return redirect(url_for('web.viewer', station='station1'))
    """Redirect to the default combo viewer."""
    return redirect(url_for('web.viewer', name='combo1'))


@web_blueprint.route('/viewer/<string:station>')
async def viewer(station):
@web_blueprint.route('/viewer/<string:name>')
async def viewer(name):
    """
    Render the WebGL FITS viewer for a specific station.

    The template receives the list of cameras configured for this station
    so the front-end can build camera-agnostic panels without hard-coding
    ``scicam`` or ``teccam``.
    Render the FITS viewer for a named view.

    Parameters
    ----------
    station : str
        The station identifier (e.g. ``'station1'``).
    name : str
        One of the keys in ``_VIEWER_MAP`` (e.g. ``'combo1'``, ``'scicam2'``).

    Returns
    -------
    str
        Rendered HTML.
        Rendered HTML, or 404 if the name is unknown.
    """
    cameras = [cam for st, cam in _configured_cameras() if st == station]
    from quart import abort

    panel_defs = _VIEWER_MAP.get(name)
    if panel_defs is None:
        abort(404)

    _vcfg = configparser.ConfigParser()
    _vcfg.read(Path(__file__).parent.parent / 'config' / 'viewer.ini')
    loop_cameras = [
        cam for cam in cameras
        if _vcfg.get(f'{station}/{cam}', 'device', fallback=None)
    ]

    panels = []
    for p in panel_defs:
        has_loop = _vcfg.get(
            f"{p['station']}/{p['camera']}", 'device', fallback=None
        ) is not None
        panels.append({**p, 'has_loop': has_loop})

    return await render_template(
        'viewer.html',
        station=station,
        cameras=cameras,
        loop_cameras=loop_cameras,
        view_name=name,
        panels=panels,
        is_combo=len(panels) > 1,
        api_base='/api',
    )

+6 −6
Original line number Diff line number Diff line
@@ -87,18 +87,18 @@
                    </a>
                    <ul class="dropdown-menu dropdown-menu-dark shadow" aria-labelledby="viewerDropdown">
                        <li>
                            <a class="dropdown-item" href="{{ url_for('web.viewer', station='station1') }}">
                                Station 1
                            <a class="dropdown-item" href="{{ url_for('web.viewer', name='combo1') }}">
                                Combo 1
                            </a>
                        </li>
                        <li>
                            <a class="dropdown-item" href="{{ url_for('web.viewer', station='station2') }}">
                                Station 2
                            <a class="dropdown-item" href="{{ url_for('web.viewer', name='combo2') }}">
                                Combo 2
                            </a>
                        </li>
                        <li>
                            <a class="dropdown-item" href="{{ url_for('web.viewer', station='station3') }}">
                                Station 3
                            <a class="dropdown-item" href="{{ url_for('web.viewer', name='combo3') }}">
                                Combo 3
                            </a>
                        </li>
                    </ul>
+129 −180
Original line number Diff line number Diff line
{% extends "base.html" %}

{% block content %}
{#
  viewer.html — FITS image viewer
  Variables expected from the route:
    station  : str          e.g. 'station1'
    cameras  : list[str]    e.g. ['scicam', 'allsky']  (from viewer.ini)
    api_base : str          e.g. '/api'
#}

<div class="d-flex justify-content-between align-items-center mb-3">
    <h4 class="mb-0 text-uppercase tracking-widest"
        style="letter-spacing:.1em; font-size:.9rem; opacity:.6;">
        Image Viewer — {{ station | title }}
    </h4>

    <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>

        {# Refresh button — fetches current images on demand #}
        <button id="btn-refresh-all"
                class="btn btn-sm btn-outline-secondary"
                title="Fetch current images now">
            ↺ Refresh
        </button>
    </div>
</div>

<div id="panel-{{ station }}"
{# ─────────────────────────────────────────────────────────────────────────────
   camera_panel macro
   Renders one unified camera snippet: large canvas left, panner+explore right.
   When called with {% call %} syntax, the caller's body is injected into the
   nested slot at the bottom of the right column (used for combo views).
────────────────────────────────────────────────────────────────────────────── #}
{% 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 }}">

    {# ── Two-column layout: primary camera left, secondary stack right ── #}
    <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: primary camera (first in list) ── #}
        {% set primary = cameras[0] if cameras else 'scicam' %}
        {# ── 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;"
                 data-camera="{{ primary }}">

                 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>

                <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;">
@@ -65,17 +40,16 @@
                        <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 #}
            {# Controls strip #}
            <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="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>
@@ -84,32 +58,41 @@
                        <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">
                        <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>
                    </div>

                    <div class="col-auto d-flex align-items-center gap-1 ms-auto">
                    <div class="col-auto d-flex align-items-center gap-1">
                        <input type="checkbox" class="ctrl-auto form-check-input"
                               id="ctrl-auto-primary" checked>
                               id="ctrl-auto-{{ panel_id }}"
                               data-camera="{{ camera }}" checked>
                        <label class="text-muted small"
                               for="ctrl-auto-primary">Auto&nbsp;update</label>
                               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: panoramic + explore + secondary cameras ── #}
        {# ── Right: explore + panner + nested slot ── #}
        <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"
@@ -119,7 +102,7 @@
                    <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="col-12 mt-1">
                    <div class="input-group input-group-sm">
                        <span class="input-group-text"
                              style="font-size:.65rem; padding:2px 6px;
@@ -134,138 +117,104 @@
                </div>
            </div>

            {# Secondary cameras (everything after the first) #}
            {% for cam in cameras[1:] %}
            <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>
            {# Nested viewer slot — filled by {% call %} block in combo views #}
            {% if caller is defined %}
            <div class="viewer-nested-slot">{{ caller() }}</div>
            {% endif %}

                <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>
                    {% if cam in loop_cameras %}
                    <div class="input-group input-group-sm mt-1">
                        <input type="number"
                               id="loop-exp-{{ cam }}"
                               class="form-control"
                               value="1" min="0.1" step="0.5"
                               title="Exposure (s)"
                               style="background:#111; border-color:#333; font-size:.7rem; max-width:70px;">
                        <button id="loop-start-{{ cam }}"
                                class="btn btn-primary"
                                style="font-size:.65rem;">Start</button>
                        <button id="loop-stop-{{ cam }}"
                                class="btn btn-outline-secondary"
                                style="font-size:.65rem;"
                                disabled>Stop</button>
{% endmacro %}


{% block content %}
<div class="mb-3">
    <h4 class="mb-0 text-uppercase"
        style="letter-spacing:.1em; font-size:.9rem; opacity:.6;">
        Image Viewer — {{ view_name | upper }}
    </h4>
</div>

{% if is_combo %}
    {% set p0 = panels[0] %}
    {% set p1 = panels[1] %}
    {% call camera_panel(p0.id, p0.station, p0.camera, p0.has_loop, api_base) %}
        {{ camera_panel(p1.id, p1.station, p1.camera, p1.has_loop, api_base) }}
    {% endcall %}
{% else %}
    {% set p = panels[0] %}
    {{ camera_panel(p.id, p.station, p.camera, p.has_loop, api_base) }}
{% endif %}
                </div>
            </div>
            {% endfor %}

        </div>
    </div>
</div>
{% endblock %}


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

const panel   = document.getElementById('panel-{{ station }}');
const station = panel.dataset.station;
const apiBase = panel.dataset.apiBase;

// Camera list injected by the route (from viewer.ini)
const cameras = {{ cameras | tojson }};
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, cameras);
    const viewer = new FitsViewer(station, panel, apiBase, [camera]);
    viewer.refresh();

// ── Refresh button ──────────────────────────────────────────────────────
document.getElementById('btn-refresh-all').addEventListener('click', () => {
    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();
        });

// ── Teccam acquisition loop controls ────────────────────────────────────
const loopCameras = {{ loop_cameras | tojson }};
    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(cam, running) {
    const startBtn = document.getElementById(`loop-start-${cam}`);
    const stopBtn  = document.getElementById(`loop-stop-${cam}`);
    if (!startBtn) return;
    startBtn.disabled = running;
    stopBtn.disabled  = !running;
        function setLoopUI(running) {
            if (startBtn) startBtn.disabled = running;
            if (stopBtn)  stopBtn.disabled  = !running;
        }

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

for (const cam of loopCameras) {
    const startBtn = document.getElementById(`loop-start-${cam}`);
    const stopBtn  = document.getElementById(`loop-stop-${cam}`);
    if (!startBtn) continue;

    startBtn.addEventListener('click', async () => {
        const exp = parseFloat(document.getElementById(`loop-exp-${cam}`).value) || 1.0;
        const r = await loopPost(cam, { action: 'start', exposure: exp });
        if (r.ok) setLoopUI(cam, true);
        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(cam, { action: 'stop' });
        setLoopUI(cam, false);
        stopBtn?.addEventListener('click', async () => {
            await loopPost('stop');
            setLoopUI(false);
        });

    // Restore state if a loop was already running (e.g. after page reload)
    fetch(`${apiBase}/viewer/${station}/${cam}/loop`)
        fetch(`${apiBase}/viewer/${station}/${camera}/loop`)
            .then(r => r.json())
        .then(d => setLoopUI(cam, d.running))
            .then(d => setLoopUI(d.running))
            .catch(() => {});
    }

    return viewer;
}

const panels  = {{ panels | tojson }};
const apiBase = '{{ api_base }}';

for (const p of panels) {
    initViewer(p.id, p.station, p.camera, p.has_loop, apiBase);
}
</script>
{% endblock %}
+5 −4
Original line number Diff line number Diff line
@@ -654,10 +654,11 @@ export class FitsViewer {
        // Auto-update checkboxes
        this.root.querySelectorAll('.ctrl-auto').forEach(chk => {
            chk.addEventListener('change', e => {
                // primary uses fixed id "ctrl-auto-primary"; secondary uses "ctrl-auto-<cam>"
                const cam = chk.id === 'ctrl-auto-primary'
                const cam = chk.dataset.camera ?? (
                    chk.id === 'ctrl-auto-primary'
                        ? this._primaryCam
                    : chk.id.replace('ctrl-auto-', '');
                        : chk.id.replace('ctrl-auto-', '')
                );
                if (cam === this._primaryCam) {
                    this._primary.autoUpdate = e.target.checked;
                } else if (this._secondary[cam]) {
+1 −1
Original line number Diff line number Diff line
@@ -39,7 +39,7 @@ dependencies = [
    "telnetlib3",
    "requests",
    "astropy>=7.2.0",
    "numpy>=2.3.5",
    "numpy==2.3.5",
    "numba>=0.63.1",
    "loguru",
    "gnuplotlib", # If focus.py