Source: components/Panoramax.js

/**
 * @module components/Panoramax.js
 * @name Panoramax
 * @copyright 2026 3Liz
 * @license MPL-2.0
 */
import { mainLizmap, mainEventDispatcher } from '../modules/Globals.js';
import { PNX_DATE_PALETTE } from '../modules/Panoramax.js';
import { html, render } from 'lit-html';

const DEFAULT_PANORAMAX_URL = 'https://panoramax.openstreetmap.fr/api';

/**
 * @class
 * @name Panoramax
 * @augments HTMLElement
 * @classdesc
 * Dock content for the Panoramax tool. It embeds the `<pnx-photo-viewer>` web
 * component (lazy-loaded the first time the dock is opened) and connects it to
 * the `Panoramax` map module (`mainLizmap.panoramax`):
 * - a click on the coverage layer asks the viewer to load the matching picture,
 * - the picture position/orientation drives the arrow drawn on the map.
 */
export default class Panoramax extends HTMLElement {
    constructor() {
        super();
        this._viewer = null;
        this._psv = null;
    }

    connectedCallback() {
        // Cached suggestions from the last account search (used for UUID lookup).
        this._accounts = [];
        this._searchDebounce = null;
        this._searchAbort = null;

        // Toggle the tool when its dock (whatever its location) is opened/closed.
        this._dockHandler = (e) => {
            if (e.id !== 'panoramax') {
                return;
            }
            if (typeof e.type === 'string' && e.type.endsWith('opened')) {
                this._open();
            } else {
                this._close();
            }
        };
        this._dockEvents = {
            dockopened: this._dockHandler,
            dockclosed: this._dockHandler,
            minidockopened: this._dockHandler,
            minidockclosed: this._dockHandler,
            bottomdockopened: this._dockHandler,
            bottomdockclosed: this._dockHandler,
            rightdockopened: this._dockHandler,
            rightdockclosed: this._dockHandler,
        };
        lizMap.events.on(this._dockEvents);

        // A picture has been selected by clicking the coverage layer on the map.
        this._onPictureSelected = (e) => {
            if (!this._viewer) {
                return;
            }
            this._viewer.select(e.seqId || null, e.picId || null);
        };
        mainEventDispatcher.addListener(this._onPictureSelected, 'panoramax.picture.selected');

        // No precise picture id: load the picture closest to the clicked position.
        this._onPositionSelected = (e) => {
            if (!this._psv) {
                return;
            }
            this._psv.goToPosition(e.lat, e.lon);
        };
        mainEventDispatcher.addListener(this._onPositionSelected, 'panoramax.position.selected');

        // Date filter: read both inputs and push the new range to the module.
        this._onDateChange = () => {
            const start = this.querySelector('input[data-filter="start"]')?.value || null;
            const end   = this.querySelector('input[data-filter="end"]')?.value   || null;
            mainLizmap.panoramax?.setDateFilter(start, end);
        };

        // Picture type filter: push the selected value (or null) to the module.
        this._onTypeChange = () => {
            const type = this.querySelector('select[data-filter="type"]')?.value || null;
            mainLizmap.panoramax?.setTypeFilter(type);
        };

        // Style mode selector: switch between 'classic' and 'date' rendering.
        this._onStyleModeChange = () => {
            const mode = this.querySelector('select[data-style="mode"]')?.value || 'classic';
            mainLizmap.panoramax?.setStyleMode(mode);
            const legend = this.querySelector('.panoramax-date-legend');
            if (legend) { legend.classList.toggle('d-none', mode !== 'date'); }
        };

        // Account filter: apply immediately from cache if exact match, then debounce
        // a search request against the Panoramax API to refresh the datalist.
        this._onAccountInput = () => {
            const input = this.querySelector('input[data-filter="account"]');
            const val = (input?.value || '').trim();

            if (this._searchAbort) {
                this._searchAbort.abort();
                this._searchAbort = null;
            }
            clearTimeout(this._searchDebounce);

            if (!val) {
                this._accounts = [];
                const datalist = this.querySelector('#pnx-accounts-list');
                if (datalist) { datalist.replaceChildren(); }
                mainLizmap.panoramax?.setAccountFilter(null);
                return;
            }

            // Exact match in the current suggestions (e.g. the user picked a value
            // from the datalist): apply the filter and skip any further API request.
            const cached = this._accounts.find(a => a.name === val);
            if (cached) {
                mainLizmap.panoramax?.setAccountFilter(cached.id);
                return;
            }
            mainLizmap.panoramax?.setAccountFilter(null);

            // Debounced API search to refresh the datalist.
            this._searchDebounce = setTimeout(async () => {
                const abort = new AbortController();
                this._searchAbort = abort;
                const accounts = await mainLizmap.panoramax?.searchAccounts(val, abort.signal) || [];
                if (abort.signal.aborted) { return; }
                this._searchAbort = null;
                this._accounts = accounts;

                const datalist = this.querySelector('#pnx-accounts-list');
                if (!datalist) { return; }
                const frag = document.createDocumentFragment();
                for (const { name } of accounts) {
                    const opt = document.createElement('option');
                    opt.value = name;
                    frag.appendChild(opt);
                }
                datalist.replaceChildren(frag);

                // Re-check for exact match now that the list is updated.
                const currentInput = this.querySelector('input[data-filter="account"]');
                const currentVal = (currentInput?.value || '').trim();
                const exact = accounts.find(a => a.name === currentVal);
                mainLizmap.panoramax?.setAccountFilter(exact ? exact.id : null);
            }, 300);
        };
    }

    disconnectedCallback() {
        lizMap.events.off(this._dockEvents);
        mainEventDispatcher.removeListener(this._onPictureSelected, 'panoramax.picture.selected');
        mainEventDispatcher.removeListener(this._onPositionSelected, 'panoramax.position.selected');
        clearTimeout(this._searchDebounce);
        if (this._searchAbort) { this._searchAbort.abort(); }
        this._unwirePSV();
        if (this._viewer && typeof this._viewer.destroy === 'function') {
            this._viewer.destroy();
        }
        this._viewer = null;
    }

    /**
     * Open the tool: activate the map module, and lazy-load + render the viewer
     * the first time.
     */
    async _open() {
        const module = mainLizmap.panoramax;
        if (!module) {
            return;
        }
        module.activate();

        if (this._viewer) {
            return;
        }

        const endpoint = mainLizmap.config.options.panoramaxUrl || DEFAULT_PANORAMAX_URL;
        this._externalBaseUrl = new URL(endpoint).origin;
        this._currentLon = null;
        this._currentLat = null;

        // Lazy-load the Panoramax viewer bundle in its own chunk (only when used).
        // Importing the package registers the <pnx-photo-viewer> custom element.
        await import(/* webpackChunkName: 'panoramax-viewer' */ '@panoramax/web-viewer');

        render(
            html`
            <div class="d-flex flex-column">
                <pnx-photo-viewer
                    class="panoramax-viewer"
                    endpoint="${endpoint}"
                    url-parameters="false"
                    widgets="false"
                ></pnx-photo-viewer>
                <div class="panoramax-filters border-top flex-shrink-0">
                    <div class="d-flex align-items-center justify-content-between px-2 py-1">
                        <span class="fw-semibold small"><i class="icon-filter"></i></span>
                        <a class="panoramax-open-external btn btn-sm btn-link p-0 d-none"
                           target="_blank" rel="noopener noreferrer">${lizDict['panoramax.open.external']}</a>
                    </div>
                    <div class="d-flex align-items-center gap-2 px-2 py-1">
                        <i class="icon-calendar text-muted flex-shrink-0"></i>
                        <input type="date" class="form-control form-control-sm" data-filter="start"
                            aria-label="${lizDict['panoramax.filter.date.start']}"
                            title="${lizDict['panoramax.filter.date.start']}"
                            @change=${this._onDateChange}>
                        <span class="text-muted fs-4">⇨</span>
                        <input type="date" class="form-control form-control-sm" data-filter="end"
                            aria-label="${lizDict['panoramax.filter.date.end']}"
                            title="${lizDict['panoramax.filter.date.end']}"
                            @change=${this._onDateChange}>
                    </div>
                    <div class="d-flex align-items-center gap-2 px-2 pb-1">
                        <i class="icon-picture text-muted flex-shrink-0"></i>
                        <select class="form-select form-select-sm w-auto" data-filter="type"
                            aria-label="${lizDict['panoramax.filter.type']}"
                            title="${lizDict['panoramax.filter.type']}"
                            @change=${this._onTypeChange}>
                            <option value="">—</option>
                            <option value="flat">${lizDict['panoramax.filter.type.flat']}</option>
                            <option value="equirectangular">360°</option>
                        </select>
                    </div>
                    <div class="d-flex align-items-center gap-2 px-2 pb-1">
                        <i class="icon-user text-muted flex-shrink-0"></i>
                        <input type="text" class="form-control form-control-sm" data-filter="account"
                            aria-label="${lizDict['panoramax.filter.account']}"
                            title="${lizDict['panoramax.filter.account']}"
                            placeholder="${lizDict['panoramax.filter.account.placeholder']}"
                            list="pnx-accounts-list"
                            autocomplete="off"
                            @input=${this._onAccountInput}>
                        <datalist id="pnx-accounts-list"></datalist>
                    </div>
                </div>
                <div class="panoramax-map-style border-top flex-shrink-0 bg-body-tertiary px-2 py-2">
                    <div class="d-flex align-items-center gap-2">
                        <i class="icon-adjust text-muted flex-shrink-0"></i>
                        <span class="small text-muted flex-shrink-0">${lizDict['panoramax.layer.style']}</span>
                        <select class="form-select form-select-sm w-auto" data-style="mode"
                            aria-label="${lizDict['panoramax.layer.style']}"
                            @change=${this._onStyleModeChange}>
                            <option value="classic">${lizDict['panoramax.layer.style.classic']}</option>
                            <option value="date">${lizDict['panoramax.layer.style.date']}</option>
                        </select>
                    </div>
                    <div class="panoramax-date-legend d-none mt-2">
                        <div class="d-flex flex-column gap-1 small">
                            ${[
                                { color: PNX_DATE_PALETTE[3], label: lizDict['panoramax.layer.style.age.4'] },
                                { color: PNX_DATE_PALETTE[2], label: lizDict['panoramax.layer.style.age.3'] },
                                { color: PNX_DATE_PALETTE[1], label: lizDict['panoramax.layer.style.age.2'] },
                                { color: PNX_DATE_PALETTE[0], label: lizDict['panoramax.layer.style.age.1'] },
                            ].map(({ color, label }) => html`
                                <div class="d-flex align-items-center gap-2">
                                    <span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:${color};border:1px solid rgba(0,0,0,.2);flex-shrink:0"></span>
                                    <span class="text-muted">${label}</span>
                                </div>
                            `)}
                        </div>
                    </div>
                </div>
            </div>`,
            this
        );
        this._viewer = this.querySelector('pnx-photo-viewer');

        // Wire the inner Photo (Photo Sphere Viewer) events once it is ready.
        this._viewer.oncePSVReady().then(() => {
            this._wirePSV();
        });
    }

    /**
     * Close the tool: deactivate the map module (stops click handling, hides arrow).
     */
    _close() {
        if (mainLizmap.panoramax) {
            mainLizmap.panoramax.deactivate();
        }
    }

    /**
     * Listen to the photo viewer events to drive the map arrow.
     */
    _wirePSV() {
        if (!this._viewer || this._psv) {
            return;
        }
        this._psv = this._viewer.psv;
        if (!this._psv) {
            return;
        }

        // A picture is (being) loaded: place and orient the arrow.
        // detail = { picId, lon, lat, x (heading 0-360°, 0 = North), y, z, first }
        this._onPicture = (ev) => {
            const d = ev.detail || {};
            mainLizmap.panoramax?.updateArrow(d.lon, d.lat, d.x);
            if (typeof d.lon === 'number') { this._currentLon = d.lon; }
            if (typeof d.lat === 'number') { this._currentLat = d.lat; }
            this._updateExternalLink();
            if (ev.type === 'picture-loaded') {
                const seqId = this._psv.getPictureMetadata()?.sequence?.id || null;
                mainLizmap.panoramax?.setSelectedSequence(seqId);
            }
        };
        // The user rotated the view: update only the arrow heading.
        // detail = { x (heading 0-360°, 0 = North), y, z }
        this._onViewRotated = (ev) => {
            const d = ev.detail || {};
            mainLizmap.panoramax?.updateHeading(d.x);
            this._updateExternalLink();
        };

        this._psv.addEventListener('picture-loading', this._onPicture);
        this._psv.addEventListener('picture-loaded', this._onPicture);
        this._psv.addEventListener('view-rotated', this._onViewRotated);
    }

    /**
     * Remove the photo viewer event listeners.
     */
    _unwirePSV() {
        if (!this._psv) {
            return;
        }
        this._psv.removeEventListener('picture-loading', this._onPicture);
        this._psv.removeEventListener('picture-loaded', this._onPicture);
        this._psv.removeEventListener('view-rotated', this._onViewRotated);
        this._psv = null;
        const link = this.querySelector('a.panoramax-open-external');
        if (link) { link.classList.add('d-none'); }
        this._currentLon = null;
        this._currentLat = null;
    }

    /**
     * Build and set the href of the "Open in Panoramax" link from the current
     * viewer state (picture id, sequence id, position, heading/pitch/zoom).
     */
    _updateExternalLink() {
        const link = this.querySelector('a.panoramax-open-external');
        if (!link || !this._psv) { return; }

        const picId = this._psv.getPictureId();
        if (!picId || this._currentLon === null || this._currentLat === null) {
            link.classList.add('d-none');
            return;
        }

        const seqId = this._psv.getPictureMetadata()?.sequence?.id;
        const { x, y, z } = this._psv.getXYZ();
        const zoom = 17;
        const lat = this._currentLat.toFixed(6);
        const lon = this._currentLon.toFixed(6);
        const xStr = x.toFixed(2);
        const yStr = y.toFixed(2);
        const zStr = Math.round(z || 0);

        const styleMode = this.querySelector('select[data-style="mode"]')?.value || 'classic';

        let url = `${this._externalBaseUrl}/?focus=pic&map=${zoom}/${lat}/${lon}&pic=${picId}`;
        if (seqId) { url += `&seq=${seqId}`; }
        url += `&xyz=${xStr}/${yStr}/${zStr}`;
        if (styleMode === 'date') { url += '&theme=age'; }

        link.href = url;
        link.classList.remove('d-none');
    }
}