/**
* @module modules/Panoramax.js
* @name Panoramax
* @copyright 2026 3Liz
* @license MPL-2.0
*/
import { mainLizmap, mainEventDispatcher } from './Globals.js';
import { transform } from 'ol/proj.js';
import { Vector as VectorSource } from 'ol/source.js';
import VectorTileSource from 'ol/source/VectorTile.js';
import { Vector as VectorLayer } from 'ol/layer.js';
import VectorTileLayer from 'ol/layer/VectorTile.js';
import MVT from 'ol/format/MVT.js';
import Feature from 'ol/Feature.js';
import Point from 'ol/geom/Point.js';
import Style from 'ol/style/Style.js';
import Icon from 'ol/style/Icon.js';
import CircleStyle from 'ol/style/Circle.js';
import Stroke from 'ol/style/Stroke.js';
import Fill from 'ol/style/Fill.js';
/** Default Panoramax instance used when `panoramaxUrl` is not set in the config */
const DEFAULT_PANORAMAX_URL = 'https://panoramax.openstreetmap.fr/api';
/** Default (classic) colour for the coverage layer — matches COLORS.BASE on panoramax.openstreetmap.fr */
const PNX_COLOR = '#FF6F00';
/** Colour applied to the selected sequence — matches COLORS.SELECTED on panoramax.openstreetmap.fr */
const PNX_SELECTED_COLOR = '#1E88E5';
/**
* Date-based palette for the coverage layer (matches panoramax.openstreetmap.fr colours).
* Index: 0 = > 2 years (oldest), 1 = < 2 years, 2 = < 1 year, 3 = < 1 month (newest),
* 4 = no date field (BASE colour).
*/
export const PNX_DATE_PALETTE = ['#fecc5c', '#fd8d3c', '#f03b20', '#bd0026', '#FF6F00'];
/**
* Arrow icon pointing to the North (heading 0). Rotated clockwise to match the
* heading (in radians) reported by the Panoramax photo viewer.
*/
const ARROW_SVG = 'data:image/svg+xml,'
+ "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' width='48' height='48'%3E"
+ "%3Cpath d='M24 3 41 43 24 33 7 43Z' fill='%231700e2' stroke='%23ffffff' stroke-width='2.5' stroke-linejoin='round'/%3E"
+ '%3C/svg%3E';
/**
* @class
* @name Panoramax
* @classdesc
* Handle the map side of the Panoramax tool:
* - the Panoramax MVT coverage layer (added to the layer tree as an external layer),
* - the orientation arrow drawn on the map and kept in sync with the photo viewer,
* - the detection of the clicked picture/sequence on the coverage layer.
*
* The UI side (the `<pnx-photo-viewer>` web component) lives in the
* `components/Panoramax.js` custom element which drives this module.
*/
export default class Panoramax {
/**
* Create a Panoramax instance
* @param {object} map - OpenLayers map (mainLizmap.map)
* @param {object} options - The Lizmap config options
* @param {object} lizmap3 - The old lizmap object
*/
constructor(map, options, lizmap3) {
this._map = map;
this._lizmap3 = lizmap3;
this._active = false;
// Date-range filter state (null = no filter).
// - sequences layer uses the `date` field ("YYYY-MM-DD")
// - pictures layer uses the `ts` field ("YYYY-MM-DDTHH:mm:ss")
this._filterStart = null;
this._filterEnd = null;
// `ts` includes time, so the max comparison uses end+1 day to include
// all pictures taken on the end day (mirrors Panoramax's own logic).
this._filterEndPlusOne = null;
// Picture type filter ("flat" | "equirectangular" | null = no filter).
this._filterType = null;
// Account filter (UUID string | null = no filter).
this._filterAccount = null;
// STAC API base URL and the derived MVT tiles URL
this._url = (options.panoramaxUrl || DEFAULT_PANORAMAX_URL).replace(/\/+$/, '');
const tilesUrl = this._url + '/map/{z}/{x}/{y}.mvt';
// Cached styles for the coverage layer (created once, reused every frame).
this._pointStyle = new Style({
image: new CircleStyle({
radius: 4,
fill: new Fill({ color: PNX_COLOR }),
stroke: new Stroke({ color: '#ffffff', width: 1 }),
}),
});
this._lineStyle = new Style({
stroke: new Stroke({ color: PNX_COLOR, width: 3 }),
});
// Date-based styles (one per palette entry, for points and lines).
this._datePointStyles = PNX_DATE_PALETTE.map(color => new Style({
image: new CircleStyle({
radius: 4,
fill: new Fill({ color }),
stroke: new Stroke({ color: '#ffffff', width: 1 }),
}),
}));
this._dateLineStyles = PNX_DATE_PALETTE.map(color => new Style({
stroke: new Stroke({ color, width: 3 }),
}));
// Selected sequence style (applied on top of any theme).
this._selectedPointStyle = new Style({
image: new CircleStyle({
radius: 4,
fill: new Fill({ color: PNX_SELECTED_COLOR }),
stroke: new Stroke({ color: '#ffffff', width: 1 }),
}),
});
this._selectedLineStyle = new Style({
stroke: new Stroke({ color: PNX_SELECTED_COLOR, width: 3 }),
});
// Currently selected sequence UUID (null = none).
this._selectedSeqId = null;
// Current style mode ('classic' | 'date') and cached ISO date boundaries.
this._styleMode = 'classic';
this._dateBoundary1month = null;
this._dateBoundary1year = null;
this._dateBoundary2years = null;
// Panoramax coverage layer (MVT). The tiles are served in EPSG:3857 and
// reprojected by OpenLayers to the current map view projection if needed.
this._olLayer = new VectorTileLayer({
declutter: true,
source: new VectorTileSource({
format: new MVT(),
projection: 'EPSG:3857',
url: tilesUrl,
}),
style: (feature) => {
const isPoint = feature.getType() === 'Point' || feature.getType() === 'MultiPoint';
// Apply date filter when at least one bound is set.
if (this._filterStart || this._filterEnd) {
// sequences → `date` ("YYYY-MM-DD"), pictures → `ts` ("YYYY-MM-DDTHH:mm:ss")
const dateStr = isPoint ? feature.get('ts') : feature.get('date');
if (dateStr) {
if (this._filterStart && dateStr < this._filterStart) {
return null;
}
if (this._filterEnd) {
const maxComp = isPoint ? this._filterEndPlusOne : this._filterEnd;
if (dateStr > maxComp) {
return null;
}
}
}
}
if (this._filterType) {
const t = feature.get('type');
if (t && t !== this._filterType) {
return null;
}
}
if (this._filterAccount) {
if (feature.get('account_id') !== this._filterAccount) {
return null;
}
}
if (this._selectedSeqId && this._belongsToSelectedSeq(feature, isPoint)) {
return isPoint ? this._selectedPointStyle : this._selectedLineStyle;
}
if (this._styleMode === 'date') {
const dateStr = isPoint ? feature.get('ts') : feature.get('date');
const idx = this._getDateColorIndex(dateStr);
return isPoint ? this._datePointStyles[idx] : this._dateLineStyles[idx];
}
return isPoint ? this._pointStyle : this._lineStyle;
},
});
// Add the layer to the layer tree through an external group. This must be
// done once and synchronously (createExternalGroup then addOlLayer in the
// same tick) because the map wiring relies on the last added group/layer.
const extGroup = mainLizmap.state.rootMapGroup.createExternalGroup('Panoramax');
this._olLayerState = extGroup.addOlLayer('panoramax', this._olLayer);
// Start hidden: the layer becomes visible when the tool is activated.
this._olLayerState.checked = false;
// Orientation arrow, kept on a dedicated tool layer above the map.
this._arrowPoint = new Point([0, 0]);
this._arrowFeature = new Feature({ geometry: this._arrowPoint });
this._arrowIcon = new Icon({
src: ARROW_SVG,
rotation: 0,
rotateWithView: true,
anchor: [0.5, 0.5],
scale: 0.7,
});
this._arrowFeature.setStyle(new Style({ image: this._arrowIcon }));
this._arrowLayer = new VectorLayer({
source: new VectorSource({ wrapX: false }),
});
this._arrowLayer.getSource().addFeature(this._arrowFeature);
this._arrowLayer.setProperties({ name: 'LizmapPanoramaxArrowLayer' });
this._arrowLayer.setVisible(false);
map.addToolLayer(this._arrowLayer);
// requestAnimationFrame coalescing for the (high frequency) heading updates
this._pendingHeading = null;
this._rafId = 0;
// Detect clicks on the coverage layer (only when the tool is active)
this._onMapClick = (evt) => this._handleClick(evt);
this._map.on('singleclick', this._onMapClick);
}
/**
* Whether the tool is currently active (dock open).
* @type {boolean}
*/
get active() {
return this._active;
}
/**
* Activate the tool: show the coverage layer and start handling map clicks.
*/
activate() {
this._active = true;
this._olLayerState.checked = true;
}
/**
* Deactivate the tool: hide the coverage layer, stop handling map clicks and hide the arrow.
*/
deactivate() {
this._active = false;
this._olLayerState.checked = false;
this._selectedSeqId = null;
this.clearArrow();
}
/**
* Hide the orientation arrow and cancel any pending heading update.
*/
clearArrow() {
this._arrowLayer.setVisible(false);
if (this._rafId) {
window.cancelAnimationFrame(this._rafId);
this._rafId = 0;
}
this._pendingHeading = null;
}
/**
* Move the arrow to the picture location and orient it.
* Called when a picture is loaded in the viewer.
* @param {number} lon - picture longitude (EPSG:4326)
* @param {number} lat - picture latitude (EPSG:4326)
* @param {number} headingDeg - heading in degrees (0 = North, clockwise)
*/
updateArrow(lon, lat, headingDeg) {
if (typeof lon !== 'number' || typeof lat !== 'number' || isNaN(lon) || isNaN(lat)) {
return;
}
const xy = transform([lon, lat], 'EPSG:4326', this._map.getView().getProjection());
this._arrowPoint.setCoordinates(xy);
if (typeof headingDeg === 'number' && !isNaN(headingDeg)) {
this._arrowIcon.setRotation(headingDeg * Math.PI / 180);
}
this._arrowFeature.changed();
this._arrowLayer.setVisible(true);
}
/**
* Update only the arrow heading (when the user rotates the view in the photo
* viewer). Updates are coalesced with requestAnimationFrame to avoid stutter.
* @param {number} headingDeg - heading in degrees (0 = North, clockwise)
*/
updateHeading(headingDeg) {
if (typeof headingDeg !== 'number' || isNaN(headingDeg)) {
return;
}
this._pendingHeading = headingDeg;
if (this._rafId) {
return;
}
this._rafId = window.requestAnimationFrame(() => {
this._rafId = 0;
if (this._pendingHeading === null) {
return;
}
this._arrowIcon.setRotation(this._pendingHeading * Math.PI / 180);
this._arrowFeature.changed();
this._pendingHeading = null;
});
}
/**
* Handle a click on the map: if a Panoramax feature is hit, ask the viewer to
* load the corresponding picture (by id) or the nearest one (by position).
* @param {object} evt - OpenLayers map browser event
*/
_handleClick(evt) {
if (!this._active) {
return;
}
const feature = this._map.forEachFeatureAtPixel(
evt.pixel,
(f) => f,
{
layerFilter: (layer) => layer === this._olLayer,
hitTolerance: 5,
}
);
if (!feature) {
return;
}
const props = feature.getProperties();
const fid = feature.getId();
const type = feature.getType();
let picId = null;
let seqId;
if (type === 'Point' || type === 'MultiPoint') {
// A picture point: load it directly by its id
picId = props.id ?? props.picture_id ?? props.pic_id ?? fid ?? null;
seqId = props.sequence_id ?? props.seq_id ?? null;
if (!seqId && props.sequences) {
let sequences = props.sequences;
// MVT RenderFeature serializes JS arrays as JSON strings (e.g. '["uuid1"]').
if (typeof sequences === 'string' && sequences.trimStart().startsWith('[')) {
try { sequences = JSON.parse(sequences); } catch { sequences = []; }
}
seqId = Array.isArray(sequences)
? (sequences[0] ?? null)
: (String(sequences) || null);
}
} else {
// A sequence line: its feature id is the sequence UUID.
seqId = fid ?? props.id ?? null;
}
// Highlight the clicked sequence immediately (the viewer will confirm the
// exact seq id via setSelectedSequence() once the picture is loaded).
if (seqId !== this._selectedSeqId) {
this._selectedSeqId = seqId;
this._olLayer.changed();
}
if (picId) {
mainEventDispatcher.dispatch({
type: 'panoramax.picture.selected',
seqId: seqId || null,
picId: picId,
});
} else {
// No precise picture id (e.g. a sequence line at low zoom): load the
// picture closest to the clicked position.
const lonlat = transform(evt.coordinate, this._map.getView().getProjection(), 'EPSG:4326');
mainEventDispatcher.dispatch({
type: 'panoramax.position.selected',
lon: lonlat[0],
lat: lonlat[1],
});
}
}
/**
* Filter the coverage layer to only show pictures/sequences in a date range.
* Either bound can be null to leave that side open.
* @param {string|null} startDate - ISO date string "YYYY-MM-DD", or null
* @param {string|null} endDate - ISO date string "YYYY-MM-DD", or null
*/
setDateFilter(startDate, endDate) {
this._filterStart = startDate || null;
this._filterEnd = endDate || null;
if (endDate) {
// Advance by one day so that pictures with a timestamp on `endDate`
// (e.g. "2024-03-15T14:30:00") are included in the result.
const d = new Date(endDate);
d.setDate(d.getDate() + 1);
this._filterEndPlusOne = d.toISOString().split('T')[0];
} else {
this._filterEndPlusOne = null;
}
// Mark the layer dirty so the style function is re-evaluated on the next
// render frame. The tiles themselves remain cached (no network requests).
this._olLayer.changed();
}
/**
* Filter the coverage layer to only show features of a given type.
* @param {string|null} type - "flat", "equirectangular", or null (no filter)
*/
setTypeFilter(type) {
this._filterType = type || null;
this._olLayer.changed();
}
/**
* Filter the coverage layer to only show features belonging to a given account.
* @param {string|null} accountId - account UUID, or null (no filter)
*/
setAccountFilter(accountId) {
this._filterAccount = accountId || null;
this._olLayer.changed();
}
/**
* Highlight the given sequence on the coverage layer (blue, matching
* COLORS.SELECTED on panoramax.openstreetmap.fr). Called by the component
* whenever the viewer loads a picture so the map stays in sync.
* @param {string|null} seqId - sequence UUID, or null to clear the selection
*/
setSelectedSequence(seqId) {
const next = seqId || null;
if (next === this._selectedSeqId) { return; }
this._selectedSeqId = next;
this._olLayer.changed();
}
/**
* Return true if `feature` belongs to the currently selected sequence.
* Works for both sequence lines (id === seqId) and picture points (sequences
* array contains seqId).
* @param {object} feature
* @param {boolean} isPoint
* @returns {boolean}
*/
_belongsToSelectedSeq(feature, isPoint) {
if (!this._selectedSeqId) { return false; }
if (!isPoint) {
return feature.getId() === this._selectedSeqId
|| feature.get('id') === this._selectedSeqId;
}
let sequences = feature.get('sequences');
if (!sequences) { return false; }
if (typeof sequences === 'string') {
if (sequences.trimStart().startsWith('[')) {
try { sequences = JSON.parse(sequences); } catch { return false; }
} else {
return sequences === this._selectedSeqId;
}
}
return Array.isArray(sequences) && sequences.includes(this._selectedSeqId);
}
/**
* Switch the coverage layer style between 'classic' (uniform brand colour) and
* 'date' (colour-coded by capture date, matching panoramax.openstreetmap.fr).
* @param {string} mode - 'classic' or 'date'
*/
setStyleMode(mode) {
this._styleMode = (mode === 'date') ? 'date' : 'classic';
if (this._styleMode === 'date') {
const now = Date.now();
this._dateBoundary1month = new Date(now - 30 * 24 * 3600 * 1000).toISOString().slice(0, 10);
this._dateBoundary1year = new Date(now - 365 * 24 * 3600 * 1000).toISOString().slice(0, 10);
this._dateBoundary2years = new Date(now - 730 * 24 * 3600 * 1000).toISOString().slice(0, 10);
}
this._olLayer.changed();
}
/**
* Map a feature's date string to a PNX_DATE_PALETTE index.
* @param {string|undefined} dateStr - ISO date/datetime string, or falsy
* @returns {number} 0–4 (4 = no date)
*/
_getDateColorIndex(dateStr) {
if (!dateStr) { return 4; }
if (dateStr >= this._dateBoundary1month) { return 3; }
if (dateStr >= this._dateBoundary1year) { return 2; }
if (dateStr >= this._dateBoundary2years) { return 1; }
return 0;
}
/**
* Search for contributor accounts whose name matches the given query.
* Hits the Panoramax `/users/search?q=` endpoint. Returns an empty array on error
* or when the request is aborted.
* @param {string} query - search term (at least 1 character)
* @param {AbortSignal} [signal]
* @returns {Promise<Array<{id: string, name: string}>>}
*/
async searchAccounts(query, signal) {
try {
const url = `${this._url}/users/search?q=${encodeURIComponent(query)}`;
const r = await fetch(url, { headers: { Accept: 'application/json' }, signal });
const data = await r.json();
const list = data.features || [];
return list
.map(u => ({ id: u.id, name: u.label || u.id }))
.filter(u => u.name && u.id);
} catch {
return [];
}
}
}