diff --git a/library/api/geoportail-urbanisme/README.md b/library/api/geoportail-urbanisme/README.md new file mode 100644 index 0000000..7447991 --- /dev/null +++ b/library/api/geoportail-urbanisme/README.md @@ -0,0 +1,83 @@ +# Lizmap Géoportail de l'Urbanisme + +Plugin Lizmap Web Client permettant d'afficher les documents réglementaires d'urbanisme disponibles sur le [Géoportail de l'Urbanisme](https://www.geoportail-urbanisme.gouv.fr). + +## APIs utilisées + +- [geo.api.gouv.fr](https://geo.api.gouv.fr) — récupération du code INSEE depuis des coordonnées +- [Géoportail de l'Urbanisme](https://www.geoportail-urbanisme.gouv.fr/api) — documents réglementaires + +## Mode d'affichage + +Deux modes de fonctionnement sont possibles : `dock` ou `popup`. +La valeur doit être spécifiée via la variable `DISPLAY_MODE`. + +### En mode dock (le plus simple) + +1. Un bouton est ajouté dans la barre d'outils +2. L'utilisateur clique sur le bouton pour ouvrir le panneau +3. L'utilisateur clique ensuite sur la carte +4. Le code interroge l'API geo.api.gouv.fr pour identifier la commune (code INSEE) +5. Le code interroge ensuite l'API du Géoportail de l'Urbanisme pour récupérer les documents disponibles +6. Les documents sont affichés classés par type dans un accordéon + +![Mode dock](mode_dock.png) + +### En mode popup (le plus personnalisable) + +1. Côté QGIS : la configuration Lizmap doit avoir été paramétrée pour qu'un popup s'affiche au clic sur une entité de la couche +2. Côté code : afin de s'assurer que les documents s'affichent dans le popup de la bonne couche, il est nécessaire de préciser l'identifiant de la couche via la variable `LIZMAP_LAYER` +3. L'utilisateur clique sur la carte +4. Le popup de l'entité s'affiche +5. Le code interroge l'API geo.api.gouv.fr pour identifier la commune (code INSEE) +6. Le code interroge ensuite l'API du Géoportail de l'Urbanisme pour récupérer les documents disponibles +7. Les documents sont ajoutés à la fin du popup, ou dans un div spécifique si celui-ci a été précisé via `DOM_DOCUMENTS_ID` + +![Mode popup](mode_popup.png) + +## Installation + +1. Copier le fichier dans le dossier `media/js/default/` de votre projet Lizmap +2. (Si nécessaire) Modifier les constantes en haut du fichier + +## Configuration + +### Mode d'affichage + +| Constante | Valeur | Description | +|---|---|---| +| `DISPLAY_MODE` | `'dock'` | Affichage dans un panneau latéral dédié | +| `DISPLAY_MODE` | `'popup'` | Affichage dans le popup Lizmap | + +### Mode `dock` + +| Constante | Description | +|---|---| +| `DOCK_ID` | Identifiant unique du panneau (sans espaces) | +| `DOCK_TITLE` | Titre affiché dans le panneau | +| `DOCK_ICON` | Icône Bootstrap (ex: `icon-file`) | +| `DOCK_POSITION` | `'dock'` (panneau latéral) ou `'minidock'` (barre d'icônes) | + +### Mode `popup` + +| Constante | Valeur | Description | +|---|---|---| +| `LIZMAP_LAYER` | `id_de_la_couche` | ID de la couche (visible dans QGIS > Propriétés > Information) | +| `DOM_DOCUMENTS_ID` | `null` | Injection à la fin du popup | +| `DOM_DOCUMENTS_ID` | `'mon-div'` | Injection dans un div existant du template QGIS | + +### Avancé + +| Constante | Défaut | Description | +|---|---|---| +| `DEBUG_MODE` | `false` | Active les logs dans la console du navigateur | +| `TIMEOUT` | `5000` | Délai maximum des requêtes API en millisecondes | + +## Dépendances + +- Lizmap Web Client 3.9+ +- Accès internet vers `geo.api.gouv.fr` et `geoportail-urbanisme.gouv.fr` + +## Licence + +Mozilla Public License Version 2.0 diff --git a/library/api/geoportail-urbanisme/lizmap-geoportail-urbanisme_3.9.js b/library/api/geoportail-urbanisme/lizmap-geoportail-urbanisme_3.9.js new file mode 100644 index 0000000..510e1cd --- /dev/null +++ b/library/api/geoportail-urbanisme/lizmap-geoportail-urbanisme_3.9.js @@ -0,0 +1,324 @@ +/** + * @license Mozilla Public License Version 2.0 + * This script has been developed by the "community" + * There isn't any guarantee that this script will work on another version of Lizmap Web Client. + */ + +const lizmapGetDocGU = function() { + + const DEBUG_MODE = false; // Set to false in production + const DISPLAY_MODE = 'popup'; // 'popup' or 'dock' + + // IF DISPLAY_MODE = 'popup' + // Layer ID - used to check if the opened popup is the good one + // You must change this value with the one of your layer, + // you can find it in QGIS + const LIZMAP_LAYER = "identifiant_unique_de_ma_couche"; //ex. Communes_c627fe50_9b56_4fc3_96bf_381287dbd664 + // Si DOM_DOCUMENTS_ID est null alors les documents seront par défaut ajoutés à la fin du popup + // sinon préciser un id de div existante dans le DOM du popup (à créer dans QGIS) + const DOM_DOCUMENTS_ID = null; // 'ex. documents-gu' + + + //IF DISPLAY_MODE = 'dock' + const DOCK_ID = 'getdocgu'; + const DOCK_ICON = 'icon-file'; + const DOCK_POSITION = 'dock'; // 'dock' | 'minidock' + const DOCK_TITLE = 'Documents réglementaires'; + + /** ******************************** + ################################### + DO NOT MODIFY BELOW THIS LINE + ################################### + ******************************** */ + const LIZMAP_POPUP = "#popupcontent .lizmapPopupContent .lizmapPopupSingleFeature"; + const MSG_WAITING = "En attente du Géoportail de l'urbanisme..."; + const MSG_CLICK_MAP = "Cliquez sur une parcelle de la carte pour afficher les documents réglementaires."; + const TIMEOUT = 5000; + + + class LizGetDocGU{ + constructor(){ + // Initialize state + this._lastClickCoord = null; + this._dockOpen = false; + this._loading = false; + + const _map = lizMap.mainLizmap?.map; + if (!_map) return; + + this._boundOnMapClick = this.#onMapClick.bind(this); + _map.on('singleclick', this._boundOnMapClick); + + if (DISPLAY_MODE === 'dock') this.#addDock(); + } + + #initPopupDOM(){ + const container = DOM_DOCUMENTS_ID + ? document.getElementById(DOM_DOCUMENTS_ID) + : document.querySelector(LIZMAP_POPUP); + + if (!container) return null; + // Vérification de la couche uniquement en mode popup + if (!DOM_DOCUMENTS_ID && container.getAttribute('data-layer-id') !== LIZMAP_LAYER) return null; + + if (!container.querySelector('#info-gpu')) { + container.insertAdjacentHTML('beforeend', ` +
+
+ `); + } + return { + divInfoGPU: container.querySelector('#info-gpu'), + divListDoc: container.querySelector('#liste-docs-reglementaires') + }; + } + + #initDockDOM(){ + const container = document.querySelector(`#${DOCK_ID}-content`); + if (!container) return null; + return { + divInfoGPU: container.querySelector(`#${DOCK_ID}-info`), + divListDoc: container.querySelector(`#${DOCK_ID}-list`) + }; + } + + #initDOM() { + if (DISPLAY_MODE === 'popup') return this.#initPopupDOM(); + if (DISPLAY_MODE === 'dock') return this.#initDockDOM(); + } + + #addDock(){ + lizMap.addDock( + DOCK_ID, + DOCK_TITLE, + DOCK_POSITION, + `
+

${MSG_CLICK_MAP}

+
+
`, + DOCK_ICON + ); + } + + #resetDock(){ + const info = document.querySelector(`#${DOCK_ID}-info`); + const list = document.querySelector(`#${DOCK_ID}-list`); + if (info) info.textContent = MSG_CLICK_MAP; + if (list) list.innerHTML = ''; + } + + openDock(){ + if (lizMap?.mainLizmap?.popup) lizMap.mainLizmap.popup.active = false; + this._dockOpen = true; + } + + closeDock(){ + if (lizMap?.mainLizmap?.popup) lizMap.mainLizmap.popup.active = true; + this._dockOpen = false; + this.#resetDock(); + } + + #onMapClick(event){ + const point = new lizMap.ol.geom.Point(event.coordinate); + + // Détection de la projection courante selon la version + const currentProj = lizMap.mainLizmap?.map?.getView()?.getProjection()?.getCode(); + if (!currentProj) return; + + try { + if (currentProj !== 'EPSG:4326') { + point.transform(currentProj, 'EPSG:4326'); + } + } catch(e) { + if (DEBUG_MODE) console.error('Erreur de projection :', e); + return; + } + + this._lastClickCoord = { + lon: point.getCoordinates()[0].toFixed(6), + lat: point.getCoordinates()[1].toFixed(6) + }; + + if (DISPLAY_MODE === 'dock' && this._dockOpen) { + this.getDocGU(); + } + } + + async #getCodeInseeGeoAPI(signal){ + if (!this._lastClickCoord) return; + + const url = `https://geo.api.gouv.fr/communes?lon=${this._lastClickCoord.lon}&lat=${this._lastClickCoord.lat}&fields=code&format=json` + + const geoResponse = await fetch(url, { signal: signal }); + if (!geoResponse.ok) throw new Error('Erreur de communication avec l\'API géographique.'); + + const geoData = await geoResponse.json(); + if (Array.isArray(geoData) && geoData.length > 0) { + const codeInsee = geoData[0]?.code; + if (DEBUG_MODE) console.log('Code INSEE depuis API :', codeInsee); + return codeInsee; + } + return null; + } + + #escapeHtml(str){ + return (str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + async #fetchDocumentId(codeInsee, signal){ + // Récupération de l'ID du document + const response = await fetch( + `https://www.geoportail-urbanisme.gouv.fr/api/document?partition=DU_${encodeURIComponent(codeInsee)}&status=document.production` + ,{ signal: signal } + ); + if (!response.ok) throw new Error('Erreur de communication avec le GPU.'); + + const data = await response.json(); + if (!Array.isArray(data) || !data.length) throw new Error('Aucun document réglementaire disponible sur le GPU.'); + if (!data[0].id) throw new Error('Document GPU introuvable.'); + return data[0].id; + } + + async #fetchDocumentFiles(docId, signal){ + // Récupération des fichiers + const response = await fetch( + `https://www.geoportail-urbanisme.gouv.fr/api/document/${encodeURIComponent(docId)}/files` + ,{ signal: signal } + ); + if (!response.ok) throw new Error('Erreur de communication avec le GPU.'); + + const documents = await response.json(); + if (!Array.isArray(documents)) throw new Error('Format inattendu.'); + + return documents; + } + + #renderDocList(documents, docId, divListDoc, divInfoGPU){ + const parentId = divListDoc.id || 'liste-docs-reglementaires'; + // supprime tout ce qui n'est pas alphanumérique ou underscore + const dataDocsDivId = documents.map(v => ({ + ...v, + path: v.path || 'Autres', + dom_id: (v.path || 'Autres') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9_]/g, '_') + })); + + // Types uniques (ordre préservé) + const docTypes = [...new Map(dataDocsDivId.map(v => [v.path, v])).values()] + .map((v, i) => ({ ...v, dom_id: `${v.dom_id}_${i}` })); + + // Injection dans le DOM + divListDoc.innerHTML = ''; + divInfoGPU.innerHTML = `

Documents réglementaires disponibles sur le GPU (${documents.length})

`; + for (const docType of docTypes) { + const docsOfType = dataDocsDivId.filter(d => d.path === docType.path); + + const liItems = docsOfType.map(doc => { + const url = `https://www.geoportail-urbanisme.gouv.fr/api/document/${docId}/files/${encodeURIComponent(doc.name)}`; + const safeTitle = this.#escapeHtml(doc.title || 'Sans titre'); + return `
  • ${safeTitle}
  • `; + }).join(''); + + const safePath = this.#escapeHtml(docType.path); + + const HTML_TEMPLATE = `
    +
    + + ${safePath} (${docsOfType.length}) + +
    +
    +
    +
      ${liItems}
    +
    +
    +
    `; + divListDoc.insertAdjacentHTML('beforeend', HTML_TEMPLATE); + } + } + + async #getDocGUAPI(codeInsee, divListDoc, divInfoGPU, signal){ + const docId = await this.#fetchDocumentId(codeInsee, signal); + const documents = await this.#fetchDocumentFiles(docId, signal); + this.#renderDocList(documents, docId, divListDoc, divInfoGPU); + } + + async getDocGU(){ + if (this._loading) return; + this._loading = true; + this._controller = new AbortController(); + const timeout = setTimeout(() => this._controller.abort(), TIMEOUT); + let divInfoGPU = null; + + try { + const dom = this.#initDOM(); + if (!dom) return; + divInfoGPU = dom.divInfoGPU; + const divListDoc = dom.divListDoc; + divInfoGPU.textContent = MSG_WAITING; + divListDoc.innerHTML = ''; + + const codeInsee = await this.#getCodeInseeGeoAPI(this._controller.signal); + if (!codeInsee) { + divInfoGPU.textContent = 'Commune non identifiée. Veuillez cliquer sur la parcelle.'; + return; + } + await this.#getDocGUAPI(codeInsee, divListDoc, divInfoGPU, this._controller.signal); + } catch(err) { + if (DEBUG_MODE && err.name !== 'AbortError') console.error('Erreur :', err); + if (!divInfoGPU) return; + divInfoGPU.textContent = err.name === 'AbortError' + ? 'Délai dépassé. Veuillez réessayer.' + : err.message; + } finally { + clearTimeout(timeout); + this._loading = false; + } + } + } + + /** + * Lizmap event + */ + let lizGetDocGU; + + lizMap.events.on({ + 'uicreated': function(e) { + lizGetDocGU = new LizGetDocGU(); + }, + // Options DISPLAY_MODE = 'popup' + 'lizmappopupdisplayed': async function(e) { + if (DISPLAY_MODE === 'popup' && lizGetDocGU) { + await lizGetDocGU.getDocGU(); + } + }, + // Option DISPLAY_MODE = 'dock' + 'dockopened': function(e) { + if (DISPLAY_MODE === 'dock' && e.id === DOCK_ID && lizGetDocGU) + lizGetDocGU.openDock(); + }, + 'dockclosed': function(e) { + if (DISPLAY_MODE === 'dock' && e.id === DOCK_ID && lizGetDocGU) + lizGetDocGU.closeDock(); + }, + + // Option DISPLAY_MODE = 'dock' ou 'minidock' + 'minidockopened': function(e) { + if (DISPLAY_MODE === 'dock' && e.id === DOCK_ID && lizGetDocGU) + lizGetDocGU.openDock(); + }, + 'minidockclosed': function(e) { + if (DISPLAY_MODE === 'dock' && e.id === DOCK_ID && lizGetDocGU) + lizGetDocGU.closeDock(); + } + }) +}(); diff --git a/library/api/geoportail-urbanisme/mode_dock.png b/library/api/geoportail-urbanisme/mode_dock.png new file mode 100644 index 0000000..9d0d6a1 Binary files /dev/null and b/library/api/geoportail-urbanisme/mode_dock.png differ diff --git a/library/api/geoportail-urbanisme/mode_popup.png b/library/api/geoportail-urbanisme/mode_popup.png new file mode 100644 index 0000000..0040005 Binary files /dev/null and b/library/api/geoportail-urbanisme/mode_popup.png differ diff --git a/library/api/panoramax/README.md b/library/api/panoramax/README.md index 6083b80..6363905 100644 --- a/library/api/panoramax/README.md +++ b/library/api/panoramax/README.md @@ -24,13 +24,20 @@ chargera de la reprojection. ![alt text](image-1.png) ## Utilisation +Deux fichiers JavaScript sont disponibles : +- **panoramax3_3.9.js** : utilise l'API V3 de Panoramax (historique, stable) +- **panoramax4_3.9.js** : utilise l'API V4 de Panoramax (Web Components, recommandé) + +**Nous recommandons d'utiliser la V4** qui est plus moderne et mieux maintenue. Pour utiliser le script Panoramax dans votre projet : 1. assurez-vous d'avoir une couche Panoramax présente dans votre projet QGIS -2. copier `panoramax_3.8.js` dans le dossier `media/js` de votre projet +2. copier `panoramax3_3.9.js` ou `panoramax4_3.9.js` dans le dossier `media/js/default` de votre répertoire (ou `media/js/nom du projet`) 3. le bouton Panoramax s'affichera dans Lizmap Web Client -4. En cliquant sur ce bouton, les photos associées aux points de la couche Panoramax seront affichées. - Un clic sur un point permet d'afficher la photo correspondante. +4. en cliquant sur ce bouton, un dock s'ouvrira affichant le visualiseur de photos +5. cliquez sur un point de la couche Panoramax sur la carte pour afficher la photo correspondante + - une flèche directionnelle indique l'azimut de la photo + - la carte se centre automatiquement sur le point ## Personnalisation @@ -63,13 +70,21 @@ projection for this script to work. QGIS will handle the reprojection. ## Usage +Two JavaScript files are available: +- **panoramax3_3.9.js**: uses the Panoramax V3 API (legacy, stable) +- **panoramax4_3.9.js**: uses the Panoramax V4 API (Web Components, recommended) + +**We recommend using V4**, which is more modern and better maintained. -1. copy the panoramax_3.8.js file to the media/js folder of your QGIS project. -2. verify that the Panoramax layer (or group) exists in your QGIS project. -3. open Lizmap Web Client and ensure the version installed is 3.8 or higher. -4. the Panoramax button should now appear in the Lizmap interface, allowing you to view the photos related to the points - in the Panoramax layer. +To use the Panoramax script in your project: +1. ensure you have a Panoramax layer present in your QGIS project +2. copy `panoramax3_3.9.js` or `panoramax4_3.9.js` to the `media/js/default` folder of your project directory (or `media/js/project name`) +3. the Panoramax button will appear in Lizmap Web Client +4. by clicking on this button, a dock will open displaying the photo viewer +5. click on a point in the Panoramax layer on the map to display the corresponding photo + - a directional arrow indicates the photo's azimuth + - the map automatically centers on the point ## Customization diff --git a/library/api/panoramax/panoramax_3.8.js b/library/api/panoramax/panoramax3_3.9.js similarity index 67% rename from library/api/panoramax/panoramax_3.8.js rename to library/api/panoramax/panoramax3_3.9.js index a8661d7..ce5098a 100644 --- a/library/api/panoramax/panoramax_3.8.js +++ b/library/api/panoramax/panoramax3_3.9.js @@ -15,6 +15,9 @@ const lizmapPanoramax = function() { // Dock position: can be dock, minidock const DOCK_POSITION = 'dock'; + + // BUFFER RADIUS FOR PANORAMAX SEARCH (in map units) + const BUFFER_RADIUS = 3; // Title of the dock const DOCK_TITLE = 'Panoramax'; @@ -54,7 +57,10 @@ const lizmapPanoramax = function() { ################################### ******************************** */ - // HTML Content + const PANORAMAX_JS_URL = 'https://cdn.jsdelivr.net/npm/@panoramax/web-viewer@3.2.3/build/index.min.js'; + const PANORAMAX_CSS_URL = 'https://cdn.jsdelivr.net/npm/@panoramax/web-viewer@3.2.3/build/index.min.css' + + // HTML Content const DOM_ID_PANORAMAX = "LizPanoramax-viewer"; const HTML_TEMPLATE = `

    ${POPUP_TEXT}

    @@ -67,7 +73,14 @@ const lizmapPanoramax = function() { constructor(){ //Check if Panoramax Layer exists. If not, display an error message and exit directly this.panoramaxLayer = this.#getPanoramaxLayer(); - + this.panoramaxDockOpen = false; + + this.mapClickHandler = null; + this.panoViewerListeners = { + 'psv:view-rotated': null, + 'psv:picture-loaded': null + }; + if(!this.panoramaxLayer) { // check if there is a panoramax layer in the project const error = `No Panoramax layer available. @@ -88,10 +101,7 @@ const lizmapPanoramax = function() { } // everything is good we can display the "normal" dock content - this.#addMapEvent(); - this.#addLizmapDock(HTML_TEMPLATE); - //this.#setPanoramaxLayerVisibility(false); - this.#addPanoramaxHeadingLayer(); + this.#addLizmapDock(HTML_TEMPLATE); }); } @@ -101,37 +111,50 @@ const lizmapPanoramax = function() { */ async #loadScripts() { return new Promise(resolve => { - - const panoramax_js = 'https://cdn.jsdelivr.net/npm/@panoramax/web-viewer@3.2.3/build/index.min.js'; - const panoramax_css = 'https://cdn.jsdelivr.net/npm/@panoramax/web-viewer@3.2.3/build/index.min.css' - - if (!document.querySelector(`script[src="${panoramax_js}"]`)) { - // Chargement du script JS - const script = document.createElement('script'); - script.src = panoramax_js; - script.setAttribute('type', 'text/javascript'); - - script.onload = function () { - resolve(true); - }; - - script.onerror = function () { - if (DEBUG_MODE) console.error("Échec du chargement du script."); - resolve(false); - }; - - document.head.appendChild(script); - + + let scriptLoaded = !!document.querySelector(`script[src="${PANORAMAX_JS_URL}"]`); + let cssLoaded = !!document.querySelector(`link[href="${PANORAMAX_CSS_URL}"]`); + + + let jsPromise, cssPromise; + + if (!scriptLoaded) { + jsPromise = new Promise(jsResolve => { + const script = document.createElement('script'); + script.src = PANORAMAX_JS_URL; + script.type = 'text/javascript'; + script.onload = () => jsResolve(true); + script.onerror = () => { + if (DEBUG_MODE) console.error("Échec du chargement du script."); + jsResolve(false); + }; + document.head.appendChild(script); + }); + } else { + jsPromise = Promise.resolve(true); } - - if (!document.querySelector(`link[href="${panoramax_css}"]`)) { - // Chargement du CSS - const style = document.createElement("link"); - style.href = panoramax_css; - style.rel = 'stylesheet'; - style.type = 'text/css'; - document.head.appendChild(style); - } + + if (!cssLoaded) { + cssPromise = new Promise(cssResolve => { + const style = document.createElement("link"); + style.href = PANORAMAX_CSS_URL; + style.rel = 'stylesheet'; + style.type = 'text/css'; + style.onload = () => cssResolve(true); + style.onerror = () => { + if (DEBUG_MODE) console.error("Échec du chargement du CSS."); + cssResolve(false); + }; + document.head.appendChild(style); + }); + } else { + cssPromise = Promise.resolve(true); + } + + Promise.all([jsPromise, cssPromise]).then(results => { + // Si l'un des deux a échoué, on retourne false + resolve(results.every(Boolean)); + }); }); } @@ -189,6 +212,18 @@ const lizmapPanoramax = function() { * @param {Panoramax Picture} picture */ #setPanoramaxHeadingLayerHeading(picture){ + // Vérifie l'existence de la couche et de sa source avant de tenter de les utiliser + if(!this.layerArrowHeadingSource || !this.layerArrowHeading) { + if (DEBUG_MODE) console.warn("Heading layer not initialized"); + return; + } + + // Valider les données + if(!picture.geometry?.coordinates || !picture.properties) { + if (DEBUG_MODE) console.warn("Invalid picture data", picture); + return; + } + const oldFeature = this.layerArrowHeadingSource.getFeatures()?.[0]; if (oldFeature) { this.layerArrowHeadingSource.removeFeature(oldFeature); @@ -200,7 +235,8 @@ const lizmapPanoramax = function() { picture.geometry.coordinates[1] ]).transform('EPSG:4326', lizMap.map.projection.projCode) })); - const r = picture.properties["view:azimuth"] * (Math.PI/180); + const azimuth = picture.properties["view:azimuth"] ?? 0; + const r = azimuth * (Math.PI/180); this.layerArrowHeading.getStyle().getImage().setRotation(r); this.layerArrowHeading.changed(); } @@ -219,11 +255,16 @@ const lizmapPanoramax = function() { * Init all the method */ initPanoramaxDock(){ - if(this.panoramaxLayer){ + if(this.panoramaxLayer && !this.panoramaxDockOpen){ lizMap.mainLizmap.popup.active = false; - this.panoramaxDockOpen = true; + this.panoramaxDockOpen = true; + this.#addPanoramaxHeadingLayer(); this.#setPanoramaxLayerVisibility(true); - this.#addPanoramaxViewer(); + //Attendre que le DOM soit prêt + setTimeout(() => { + this.#addPanoramaxViewer(); + this.#addMapEvent(); + }, 100); } } @@ -231,51 +272,103 @@ const lizmapPanoramax = function() { * Add Panoramax Viewer */ #addPanoramaxViewer(){ - this.panoViewer = new Panoramax.Viewer( - DOM_ID_PANORAMAX, - PANORAMAX_INSTANCE, - { - hash:false, // !!! do not change => change Lizmap URL - map: false - } - ); - this.#addPanoramaxViewerEvent(); + if (!window.Panoramax) { + if (DEBUG_MODE) console.error("Panoramax global not available"); + return; + } + const viewerContainer = document.getElementById(DOM_ID_PANORAMAX); + if (!viewerContainer) { + if (DEBUG_MODE) console.error("Viewer container not found in DOM"); + return; + } + if(this.panoViewer) { + if (DEBUG_MODE) console.warn("Panoramax Viewer already initialized"); + return; + } + try { + this.panoViewer = new Panoramax.Viewer( + DOM_ID_PANORAMAX, + PANORAMAX_INSTANCE, + { + hash:false, // !!! do not change => change Lizmap URL + map: false + } + ); + this.#addPanoramaxViewerEvent(); + } catch (error) { + if (DEBUG_MODE) console.error("Error initializing Panoramax Viewer", error); + throw new Error("Error initializing Panoramax Viewer"); + } } /** * Add all Panoramax Viewer Events */ #addPanoramaxViewerEvent(){ - this.panoViewer.addEventListener('psv:view-rotated', (e) => { + // Stocker les listeners AVEC les bonnes fonctions + this.panoViewerListeners['psv:view-rotated'] = (e) => { if(e.explicitOriginalTarget._selectedPicId){ - let r = e.detail.x * (Math.PI/180); + const azimuth = e.detail.x ?? 0; // Valeur par défaut + let r = azimuth * (Math.PI/180); this.layerArrowHeading.getStyle().getImage().setRotation(r); this.layerArrowHeading.changed(); } - }); + }; - this.panoViewer.addEventListener('psv:picture-loaded', (e) => { - let r = e.detail.x * (Math.PI/180); - if(this.layerArrowHeadingSource.getFeatures()[0] && e.detail.lon && e.detail.lat){ + this.panoViewerListeners['psv:picture-loaded'] = (e) => { + const azimuth = e.detail.x ?? 0; // Valeur par défaut + let r = azimuth * (Math.PI/180); + if(this.layerArrowHeadingSource.getFeatures()[0] + && typeof e.detail?.lon === 'number' + && typeof e.detail?.lat === 'number' ){ const coords = lizMap.ol.proj.transform([e.detail.lon, e.detail.lat], 'EPSG:4326', lizMap.mainLizmap.projection); lizMap.mainLizmap.map.getView().setCenter(coords); this.layerArrowHeadingSource.getFeatures()[0].getGeometry().setCoordinates(coords); } this.layerArrowHeading.getStyle().getImage().setRotation(r); this.layerArrowHeading.changed(); - }); + }; + + this.panoViewer.addEventListener('psv:view-rotated', this.panoViewerListeners['psv:view-rotated']); + this.panoViewer.addEventListener('psv:picture-loaded', this.panoViewerListeners['psv:picture-loaded']); } /** * Fetch picture on single map cick */ #addMapEvent(){ - lizMap.mainLizmap.map.on('singleclick', e => { + // Créer une méthode nommée pour pouvoir la désabonner + this.mapClickHandler = (e) => { //Fire event only if panoramax dock is opened if(this.panoramaxDockOpen){ - const extent =this.#getBufferedExtent(e.coordinate); + const extent = this.#getBufferedExtent(e.coordinate); this.#getPanoramaxPicture(extent); } + }; + lizMap.mainLizmap.map.on('singleclick', this.mapClickHandler); + } + + /** + * Remove map click event listener + */ + #removeMapEvent(){ + if(this.mapClickHandler){ + lizMap.mainLizmap.map.un('singleclick', this.mapClickHandler); + this.mapClickHandler = null; + } + } + + /** + * Remove all Panoramax Viewer Events + */ + #removePanoramaxViewerEvent(){ + if(!this.panoViewer) return; + + Object.keys(this.panoViewerListeners).forEach(eventName => { + if(this.panoViewerListeners[eventName]){ + this.panoViewer.removeEventListener(eventName, this.panoViewerListeners[eventName]); + this.panoViewerListeners[eventName] = null; + } }); } @@ -287,13 +380,12 @@ const lizmapPanoramax = function() { #getBufferedExtent(p){ const point = new lizMap.ol.geom.Point(p); const extent = point.getExtent(); - const radius = 3; - const bufferedExtent = new lizMap.ol.extent.buffer(extent,radius); + const bufferedExtent = new lizMap.ol.extent.buffer(extent,BUFFER_RADIUS); const pbl = new lizMap.ol.geom.Point([bufferedExtent[0], bufferedExtent[1]]); //bottom left const pur = new lizMap.ol.geom.Point([bufferedExtent[2], bufferedExtent[3]]); //upper right - if(lizMap.map.projection.projCode != "EPSG:4326"){ + if(lizMap.map.projection.projCode !== "EPSG:4326"){ // reproject extent to 4326 pbl.transform(lizMap.map.projection.projCode, 'EPSG:4326'); pur.transform(lizMap.map.projection.projCode, 'EPSG:4326'); @@ -323,7 +415,7 @@ const lizmapPanoramax = function() { throw new Error(error); } const picture = await response.json(); - if(picture.features.length){ + if(picture?.features?.length > 0 && this.panoViewer){ this.panoViewer.select(null, picture.features[0].id, true); this.#setPanoramaxHeadingLayerHeading(picture.features[0]); } @@ -340,10 +432,18 @@ const lizmapPanoramax = function() { if(this.panoramaxLayer) { lizMap.mainLizmap.popup.active = true; this.panoramaxDockOpen = false; + + // Remove map click listener + this.#removeMapEvent(); + + // Remove Panoramax viewer listeners + this.#removePanoramaxViewerEvent(); + + // Clear layer used for heading arrow this.layerArrowHeadingSource.clear(); //Hide layer - lizPanoramax.#setPanoramaxLayerVisibility(false); + this.#setPanoramaxLayerVisibility(false); // Remove all viewer references if (this.panoViewer) { @@ -363,6 +463,8 @@ const lizmapPanoramax = function() { /** * Lizmap event */ + let lizPanoramax; + lizMap.events.on({ 'uicreated': function(e) { lizPanoramax = new LizPanoramax(); @@ -370,24 +472,24 @@ const lizmapPanoramax = function() { //MINI DOCK 'minidockopened': e => { - if (e.id === DOCK_ID) { + if (e.id === DOCK_ID && lizPanoramax) { lizPanoramax.initPanoramaxDock(); } }, 'minidockclosed': e => { - if (e.id === DOCK_ID) { + if (e.id === DOCK_ID && lizPanoramax) { lizPanoramax.removePanoramaxDock(); } }, //DOCK 'dockopened': e => { - if (e.id === DOCK_ID) { + if (e.id === DOCK_ID && lizPanoramax) { lizPanoramax.initPanoramaxDock(); } }, 'dockclosed': e => { - if (e.id === DOCK_ID) { + if (e.id === DOCK_ID && lizPanoramax) { lizPanoramax.removePanoramaxDock(); } }, diff --git a/library/api/panoramax/panoramax4_3.9.js b/library/api/panoramax/panoramax4_3.9.js new file mode 100644 index 0000000..0b5b155 --- /dev/null +++ b/library/api/panoramax/panoramax4_3.9.js @@ -0,0 +1,527 @@ +/** + * @license Mozilla Public License Version 2.0 + * This script has been developed by the "community" + * There isn't any guarantee that this script will work on another version of Lizmap Web Client. + */ + +const lizmapPanoramax = function() { + + // ID of the dock (do not change) + const DOCK_ID = 'panoramax'; + + // Icon of the dock menu and used before each link + // See https://getbootstrap.com/2.3.2/base-css.html#icons + const DOCK_ICON = 'icon-camera'; + + // Dock position: can be dock, minidock + const DOCK_POSITION = 'dock'; + + // Title of the dock + const DOCK_TITLE = 'Panoramax'; + + // ARROW ICON PROPERTIES + const ARROW_ICON_SIZE = 0.3; + const ARROW_ICON_COLOR = "#e4e8e6"; + + const PANORAMAX_INSTANCE = 'https://api.panoramax.xyz/api'; + + const CONTENT_TEXT = { + "fr" : "Veuillez cliquer sur un point de la couche Panoramax pour afficher les photos." + ,"en" : "Please click on a point in the Panoramax layer to display the photos." + ,"it" : "Per favore, fai clic su un punto del livello Panoramax per visualizzare le foto." + ,"es" : "Por favor, haz clic en un punto de la capa Panoramax para mostrar las fotos." + ,"de" : "Bitte klicken Sie auf einen Punkt in der Panoramax-Schicht, um die Fotos anzuzeigen." + ,"pt" : "Por favor, clique em um ponto da camada Panoramax para exibir as fotos." + ,"nl" : "Klik alstublieft op een punt in de Panoramax-laag om de foto's te bekijken." + ,"pl" : "Kliknij punkt na warstwie Panoramax, aby wyświetlić zdjęcia." + }; + + //Change text depending on navigator language + const DEFAULT_LANGUAGE = "en" + const NAVIGATOR_LANGUAGE = navigator.language ? navigator.language.slice(0, 2) : DEFAULT_LANGUAGE; + //IF the navigator.language is not listed in CONTENT_TEXT => switch to the DEFAULT_LANGUAGE + const POPUP_TEXT = CONTENT_TEXT[NAVIGATOR_LANGUAGE] || CONTENT_TEXT[DEFAULT_LANGUAGE]; + + const DEBUG_MODE = false; + + /** ******************************** + ################################### + DO NOT MODIFY BELOW THIS LINE + ################################### + ******************************** */ + + const PANORAMAX_JS_URL = 'https://cdn.jsdelivr.net/npm/@panoramax/web-viewer@4.4.0/build/index.min.js'; + const PANORAMAX_CSS_URL = 'https://cdn.jsdelivr.net/npm/@panoramax/web-viewer@4.4.0/build/index.min.css' + + // HTML Content + const PHOTO_VIEWER = ` + `; + + const HTML_TEMPLATE = `
    +

    ${POPUP_TEXT}

    + ${PHOTO_VIEWER} +
    `; + + const SVG_ARROW = ``; + + const PANORAMAX_SOURCES = { + IGN: { + url: 'https://panoramax.ign.fr/api/map/{z}/{x}/{y}.mvt', + maxZoom: 15, + }, + OSM: { + url: 'https://panoramax.openstreetmap.fr/api/map/{z}/{x}/{y}.mvt', + maxZoom: 15, + } + }; + + const PANORAMAX_LAYER_CONFIG = { + name: 'Panoramax Images', + visibleOnStartUp: false, + zIndex: 100 + }; + + class LizPanoramax{ + constructor(){ + // Initialize state + this.panoramaxDockOpen = false; + this.panoramaxVectorLayers = null; + this.panoramaxLayersGroup = null; + + // Initialize map handlers + this.mapClickHandler = null; + this.panoramaxLayerClickHandler = null; + + // Initialize viewer listeners + this.panoViewerListeners = { + 'psv:view-rotated': null + }; + + // Load external scripts (Panoramax viewer) + this.#loadScripts().then(success => { + if(!success){ + const error = "Panoramax external script not fully loaded"; + if (DEBUG_MODE) console.error(error); + this.#addLizmapDock(`

    ${error}

    `); + return; + } + + // Scripts loaded successfully, display dock content + this.#addLizmapDock(HTML_TEMPLATE); + + // Create Vector Tile layers (no longer dependent on QGIS) + if (!this.panoramaxVectorLayers) { + this.panoramaxVectorLayers = this.#addPanoramaxVectorLayers(); + + if (DEBUG_MODE) { + console.log('Panoramax Vector Tile layers created and registered'); + } + } + }); + } + + /** + * LOAD PANORAMAX EXTERNAL JS AND CSS + * @returns {Promise} + */ + async #loadScripts() { + return new Promise(resolve => { + + let scriptLoaded = !!document.querySelector(`script[src="${PANORAMAX_JS_URL}"]`); + let cssLoaded = !!document.querySelector(`link[href="${PANORAMAX_CSS_URL}"]`); + + let jsPromise, cssPromise; + + if (!scriptLoaded) { + jsPromise = new Promise(jsResolve => { + const script = document.createElement('script'); + script.src = PANORAMAX_JS_URL; + script.type = 'text/javascript'; + script.onload = () => jsResolve(true); + script.onerror = () => { + if (DEBUG_MODE) console.error("Échec du chargement du script."); + jsResolve(false); + }; + document.head.appendChild(script); + }); + } else { + jsPromise = Promise.resolve(true); + } + + if (!cssLoaded) { + cssPromise = new Promise(cssResolve => { + const style = document.createElement("link"); + style.href = PANORAMAX_CSS_URL; + style.rel = 'stylesheet'; + style.type = 'text/css'; + style.onload = () => cssResolve(true); + style.onerror = () => { + if (DEBUG_MODE) console.error("Échec du chargement du CSS."); + cssResolve(false); + }; + document.head.appendChild(style); + }); + } else { + cssPromise = Promise.resolve(true); + } + + Promise.race([ + Promise.all([jsPromise, cssPromise]), + new Promise((_, reject) => + setTimeout(() => reject(new Error('CDN timeout')), 10000) + ) + ]).then(results => { + resolve(results.every(Boolean)); + }).catch(error => { + if (DEBUG_MODE) console.error('Script loading failed:', error); + resolve(false); + }); + }); + } + + /** + * Add Lizmap Dock + * @param {*} htmlContent + */ + #addLizmapDock(htmlContent){ + lizMap.addDock( + DOCK_ID, + DOCK_TITLE, + DOCK_POSITION, + htmlContent, + DOCK_ICON + ); + } + + /** + * Create and add Panoramax Vector Tile layers to the map + * @returns {Array} Array of panoramax layers + */ + #addPanoramaxVectorLayers() { + const layers = []; + + Object.entries(PANORAMAX_SOURCES).forEach(([key, config]) => { + + // Create VectorTile source + const source = new lizMap.ol.source.VectorTile({ + url: config.url, + format: new lizMap.ol.format.MVT(), + maxZoom: config.maxZoom, + tileGridStrategy: 'all' + }); + + // Create VectorTile layer + const layer = new lizMap.ol.layer.VectorTile({ + title: `${PANORAMAX_LAYER_CONFIG}.name ${key}`, + source: source, + zIndex: PANORAMAX_LAYER_CONFIG.zIndex, + style: this.#setPanoramaxLayerStyle(), + projection: 'EPSG:3857' + }); + + lizMap.mainLizmap.map.addLayer(layer); + layer.setVisible(PANORAMAX_LAYER_CONFIG.visibleOnStartUp); + layers.push(layer); + }); + return layers; + } + + /** + * Define styling for Panoramax layers + * NOTE : COULD BE PASSED AS PARAMETERS FOR THE NEXT VERSION + * @returns {Function} Style function + */ + #setPanoramaxLayerStyle() { + // Default style + const fill = new lizMap.ol.style.Fill({ + color: 'rgba(255,255,255,0.4)', + }); + const stroke = new lizMap.ol.style.Stroke({ + color: '#3399CC', + width: 1.25, + }); + const style = new lizMap.ol.style.Style({ + image: new lizMap.ol.style.Circle({ + fill: fill, + stroke: stroke, + radius: 5, + }), + fill: fill, + stroke: stroke, + }); + return style; + } + + /** + * Toggle Panoramax layers visibility + * @param {boolean} visible + */ + #setPanoramaxLayersVisibility(visible) { + if (this.panoramaxVectorLayers) { + this.panoramaxVectorLayers.forEach((layer) => { + layer.setVisible(visible); + }); + } + } + + /** + * Add click handler to Panoramax vector tile layers + */ + #addPanoramaxLayerClickEvent() { + this.panoramaxLayerClickHandler = (e) => { + if (!this.panoramaxDockOpen) return; + + const features = lizMap.mainLizmap.map.getFeaturesAtPixel(e.pixel, + function (feature) { + return feature; + },{ + layerFilter: (layer) => { + return this.panoramaxVectorLayers && + this.panoramaxVectorLayers.some(({ layer: l }) => l === layer); + } + }); + + if (features.length > 0) { + const feature = features[0]; + if(feature.getProperties()?.id){ + const pictureId = feature.getProperties().id; + this.panoViewer?.select?.(null, pictureId, true); + this.#setPanoramaxHeadingLayer(feature); + } + } + }; + lizMap.mainLizmap.map.on('singleclick', this.panoramaxLayerClickHandler); + } + + /** + * Remove Vector Tile layer click handler + */ + #removePanoramaxLayerClickEvent() { + if (this.panoramaxLayerClickHandler) { + lizMap.mainLizmap.map.un('singleclick', this.panoramaxLayerClickHandler); + this.panoramaxLayerClickHandler = null; + } + } + + /** + * add layer to draw that will be used to draw arrow direction + * @returns Openlayers Layer + */ + #addPanoramaxHeadingLayer(){ + this.layerArrowHeadingSource = new lizMap.ol.source.Vector({ wrapX: false }); + this.layerArrowHeading = new lizMap.ol.layer.Vector({ + title: 'panoramax-pov', + source: this.layerArrowHeadingSource, + style: new lizMap.ol.style.Style({ + image: new lizMap.ol.style.Icon({ + src: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(SVG_ARROW), + scale: ARROW_ICON_SIZE + }) + }), + }); + this.layerArrowHeading.setZIndex(1001); + lizMap.mainLizmap.map.addLayer(this.layerArrowHeading); + return this.layerArrowHeading + } + + /** + * Set Arrow Heading depending on picture parameter + * @param {Panoramax Picture} picture + */ + #setPanoramaxHeadingLayer(picFeature){ + // Vérifie l'existence de la couche et de sa source avant de tenter de les utiliser + if(!this.layerArrowHeadingSource || !this.layerArrowHeading) { + if (DEBUG_MODE) console.warn("Heading layer not initialized"); + return; + } + + if (!picFeature?.flatCoordinates_ || !Array.isArray(picFeature.flatCoordinates_) || picFeature.flatCoordinates_.length != 2){ + if (DEBUG_MODE) console.warn("Wrong picture coordinates", picFeature.flatCoordinates_); + return; + } + + // Accès à propriété privée OpenLayers + // Il doit y avoir un moyen plus simple d'accéder aux coordonnées + // ex. getCoordinates() -> mais retourne une erreur + const [picLon, picLat] = picFeature.flatCoordinates_; + + const oldFeature = this.layerArrowHeadingSource.getFeatures()?.[0]; + if (oldFeature) { + this.layerArrowHeadingSource.removeFeature(oldFeature); + } + + this.layerArrowHeadingSource.addFeature(new lizMap.ol.Feature({ + geometry: new lizMap.ol.geom.Point([picLon, picLat]) + })); + + lizMap.mainLizmap.map.getView().animate({ + center: [picLon, picLat], + duration: 750, + }); + + const azimuth = picFeature.getProperties?.()?.heading ?? 0; + const r = azimuth * (Math.PI/180); + this.layerArrowHeading.getStyle().getImage().setRotation(r); + this.layerArrowHeading.changed(); + } + + + /** + * Init all the method + */ + initPanoramaxDock(){ + if(!this.panoramaxDockOpen){ + // Vérifier que popup existe avant d'y accéder + if (lizMap?.mainLizmap?.popup) { + lizMap.mainLizmap.popup.active = false; + } + this.panoramaxDockOpen = true; + + // Charger et enregistrer les couches VectorTile + if (!this.panoramaxVectorLayers) { + this.panoramaxVectorLayers = this.#addPanoramaxVectorLayers(); + } + + // Afficher les couches + this.#setPanoramaxLayersVisibility(true); + + // Ajouter les couches flèche de direction + this.#addPanoramaxHeadingLayer(); + + // Attendre que le DOM soit prêt + setTimeout(() => { + this.#addPanoramaxViewer(); + this.#addPanoramaxLayerClickEvent(); + }, 100); + } + } + + /** + * Add Panoramax Viewer + */ + async #addPanoramaxViewer(){ + const viewerElement = document.querySelector('#panoramax_dock_content pnx-photo-viewer'); + if (!viewerElement) { + if (DEBUG_MODE) console.error("Viewer element not found"); + return; + } + // Attendre que le Photo Sphere Viewer soit vraiment prêt + try { + await viewerElement.oncePSVReady(); + } catch (error) { + console.error('Viewer initialization failed:', error); + return; // ou afficher message utilisateur + } + + // Réinitialiser le composant + this.panoViewer = viewerElement; + this.panoViewer.select(null, null, false); + this.#addPanoramaxViewerEvent(); + } + + /** + * Add all Panoramax Viewer Events + */ + #addPanoramaxViewerEvent(){ + this.panoViewerListeners['psv:view-rotated'] = (e) => { + if(e.detail?.x){ + const azimuth = e.detail.x ?? 0; // Valeur par défaut + let r = azimuth * (Math.PI/180); + this.layerArrowHeading.getStyle().getImage().setRotation(r); + this.layerArrowHeading.changed(); + } + }; + this.panoViewer.addEventListener('psv:view-rotated', this.panoViewerListeners['psv:view-rotated']); + } + + + /** + * Remove all Panoramax Viewer Events + */ + #removePanoramaxViewerEvent(){ + if(!this.panoViewer) return; + + Object.keys(this.panoViewerListeners).forEach(eventName => { + if(this.panoViewerListeners[eventName]){ + this.panoViewer.removeEventListener(eventName, this.panoViewerListeners[eventName]); + this.panoViewerListeners[eventName] = null; + } + }); + } + + + /** + * Remove all Panoramax object and instance + */ + removePanoramaxDock(){ + this.panoramaxDockOpen = false; + lizMap.mainLizmap.popup.active = true; + + // Remove all event listeners + this.#removePanoramaxLayerClickEvent(); + this.#removePanoramaxViewerEvent(); + + // Clear heading layer + this.layerArrowHeadingSource.clear(); + + // Hide Vector Tile layers + this.#setPanoramaxLayersVisibility(false); + + // Cleanup Panoramax viewer + if (this.panoViewer) { + this.panoViewer.psv?.stopSequence?.(); + const panoViewer = document.querySelector('#panoramax_dock_content pnx-photo-viewer'); + if(panoViewer){ + panoViewer.remove(); + document.querySelector("#panoramax_dock_content").insertAdjacentHTML('beforeend', PHOTO_VIEWER); + } + this.panoViewer = null; + } + } + } + + /** + * Lizmap event + */ + let lizPanoramax; + + lizMap.events.on({ + 'uicreated': function(e) { + lizPanoramax = new LizPanoramax(); + }, + + //MINI DOCK + 'minidockopened': e => { + if (e.id === DOCK_ID && lizPanoramax) { + lizPanoramax.initPanoramaxDock(); + } + }, + 'minidockclosed': e => { + if (e.id === DOCK_ID && lizPanoramax) { + lizPanoramax.removePanoramaxDock(); + } + }, + + //DOCK + 'dockopened': e => { + if (e.id === DOCK_ID && lizPanoramax) { + lizPanoramax.initPanoramaxDock(); + } + }, + 'dockclosed': e => { + if (e.id === DOCK_ID && lizPanoramax) { + lizPanoramax.removePanoramaxDock(); + } + }, + }); + + return { + 'id': DOCK_ID, + 'title': DOCK_TITLE, + } + +}(); \ No newline at end of file diff --git a/library/tools/show_statistics_on_selection/README.md b/library/tools/show_statistics_on_selection/README.md index 8fa41d3..57e0a0c 100644 --- a/library/tools/show_statistics_on_selection/README.md +++ b/library/tools/show_statistics_on_selection/README.md @@ -1,55 +1,709 @@ -# Show some statistics on the selected features for some fields +# Statistiques sur la sélection -The script will show a small window on the right side of the map with computed statistics on the current layers selected features. +**Français** · [English](#selection-statistics) -![Show statistics on selection GIF demo](./show_statistics_on_selection.gif) +Affiche des statistiques agrégées sur les entités sélectionnées, au bas du +panneau de sélection de Lizmap. -You should add both the **CSS** file `show_statistics_on_selection.css` and the **JS** file `show_statistics_on_selection.js` in your `media/js/project_name` folder, as described in [Lizmap Web Client documentation](https://docs.lizmap.com/current/en/publish/customization/javascript.html?#adding-your-own-javascript) +Cible : **Lizmap Web Client 3.9**. Réécriture en JavaScript natif du script +communautaire d'origine, prévu pour LWC 3.6. -You can configure the layers, fields and aggregate functions by updating the `statistics_config` variable. +![Le bloc de statistiques sous le panneau de sélection de Lizmap](./statistics.jpg) + +## Exemple complet + +La capture ci-dessus correspond exactement à cette configuration, sur cinq +parcelles sélectionnées : ```javascript - // Aggregate functions can be: - // count, sum, average, minimum, maximum - var statistics_config = { - 'layers': { - 'Parcelles': { - fields: { - 'geo_parcelle': ['count'], - 'surface_geo': ['sum', 'minimum', 'maximum'] +const STATISTICS_CONFIG = { + layers: { + 'Parcelles': { + label: 'Parcelles cadastrales', + fields: { + 'id_source': { + aggregates: ['count'], + label: 'Nombre de parcelles' + }, + 'numero': { + aggregates: ['list'], + label: 'Numéro' + }, + 'contenance': { + aggregates: ['sum', 'minimum', 'maximum'], + label: 'Surface', + format: { type: 'area', unit: 'm2' } } - }, - 'Sections': { - fields: { - 'geo_section': ['count'], - 'ogc_fid': ['minimum', 'maximum'] + } + } + } +}; +``` + +| Champ | Agrégat | Résultat | +|---|---|---| +| Nombre de parcelles | Nombre | `5` | +| Numéro | Liste | `691, 692, 757, 758, 759` | +| Surface | Somme | `1 398 m²` | +| Surface | Minimum | `230 m²` | +| Surface | Maximum | `333 m²` | + +Trois choses à y lire. Le comptage porte sur `id_source`, la clé primaire, parce +que `count` compte les valeurs **renseignées** d'un champ, pas les entités +sélectionnées. Les numéros sortent triés naturellement, sans qu'on ait rien +demandé, `sort: 'natural'` étant le défaut. Et l'icône d'impression, à droite du +titre du bloc, n'apparaît que si une mise en page a été configurée. + +## Installation + +Copier `show_statistics_on_selection_3.9.js` et `show_statistics_on_selection.css` +dans le dossier `media/js//` de votre projet, comme décrit dans la +[documentation Lizmap](https://docs.lizmap.com/current/fr/publish/customization/javascript.html). + +Le module ne s'initialise qu'à l'ouverture du panneau de sélection, ce qui évite +d'alourdir le chargement des cartes où il ne sert pas. + +**La couche doit être publiée en WFS.** Sans elle dans les capacités du service, +le module ne peut pas récupérer les entités. + +## Réglages généraux + +En tête du fichier JS, au-dessus de la ligne « DO NOT MODIFY BELOW THIS LINE » : + +| Constante | Rôle | +|---|---| +| `DEBUG_MODE` | Traces de diagnostic en console, préfixées `[stats]`. Mettre à `false` en production. | +| `LOCALE` | `'auto'` suit la langue de l'interface Lizmap, puis celle du navigateur. Une étiquette BCP 47 (`'fr-FR'`, `'en-GB'`) force la langue. | +| `FALLBACK_LOCALE` | Langue de repli quand la langue active n'est pas traduite. | +| `CURRENCY` | Code ISO 4217 pour `format.type: 'currency'`. Surchargeable par champ. | +| `PANEL_TITLE` | Titre du bloc. `null` utilise le titre traduit. | +| `MAX_FEATURES` | Au-delà, le module affiche un message au lieu de calculer. | +| `FETCH_QGIS_ALIASES` | Récupérer les alias de champs du projet QGIS. Coûte une requête par couche, au démarrage uniquement. Inutile si tous vos champs ont un `label` dans la configuration. | + +## Configuration des couches + +```javascript +const STATISTICS_CONFIG = { + layers: { + 'Parcelles': { // nom exact de la couche dans QGIS + label: 'Parcelles cadastrales', // optionnel, défaut : nom de la couche + fields: { + 'contenance': { + aggregates: ['sum', 'maximum'], + label: 'Surface', + format: { type: 'area', unit: 'ha', decimals: 2 } + }, + 'nb_bati': ['sum'] // forme courte + } + } + } +}; +``` + +Une couche absente du projet, un agrégat inconnu ou un `format.type` invalide +produisent un avertissement `[stats]` au démarrage, et l'entrée concernée est +ignorée sans bloquer le reste. + +Le libellé d'un champ est résolu dans cet ordre : `label` de la configuration, +puis alias QGIS si `FETCH_QGIS_ALIASES` est actif et l'alias non vide, puis nom +du champ. + +Les agrégats s'affichent dans l'ordre où vous les écrivez. + +## Agrégats + +### Numériques + +`count`, `sum`, `average`, `minimum`, `maximum`. + +Les valeurs non renseignées sont écartées avant calcul, et les valeurs non +convertibles en nombre sont ignorées. Si rien d'exploitable ne subsiste, +l'affichage montre un tiret et un avertissement est émis une fois en console. + +**Attention à `count`** : il compte les entités dont **ce champ** a une valeur +renseignée. Pour compter les entités sélectionnées, appliquez-le à un champ +jamais nul, typiquement la clé primaire. + +### Texte + +Applicables à n'importe quel type de champ. + +#### `list` — énumération des valeurs + +```javascript +'numero': { + aggregates: ['list'], + label: 'Numéro', + list: { separator: ', ', maxItems: 50, sort: 'natural', distinct: true } +} +``` + +``` +Numéro 213, 243, 244, 276 +``` + +| Option | Défaut | Rôle | +|---|---|---| +| `separator` | `', '` | Séparateur entre valeurs. | +| `maxItems` | `50` | Au-delà : `… et N autres`. | +| `sort` | `'natural'` | `'natural'` trie `2, 10, 100` ; `'alpha'` trie `10, 100, 2` ; `'none'` garde l'ordre de la sélection. | +| `distinct` | `true` | Dédoublonner la liste. | + +Le tri naturel est le défaut parce que les numéros cadastraux arrivent sous forme +de chaînes : un tri alphabétique donnerait `10, 100, 2, 244`. + +`list.distinct` est un booléen de dédoublonnage, à ne pas confondre avec +l'agrégat `distinct`, qui renvoie un compte. Les deux peuvent coexister sur un +même champ. + +#### `frequency` — répartition + +```javascript +'degre_risk_inon': { + aggregates: ['frequency'], + label: 'Aléa inondation', + frequency: { maxItems: 10 } +} +``` + +``` +Aléa inondation + Moyen 11 + Faible 9 + Fort 4 + (non renseigné) 6 +``` + +Chaque valeur distincte avec son nombre d'occurrences, triée par occurrences +décroissantes. Seul agrégat produisant plusieurs lignes. + +Les valeurs non renseignées sont **conservées** — leur nombre est une +information — et toujours placées en dernier, hors du classement. Elles ne sont +jamais emportées par la troncature `maxItems`. + +#### `distinct` — nombre de valeurs différentes + +``` +Section Valeurs distinctes 7 +``` + +Valeurs non renseignées écartées. Se combine bien avec `list` : +`{ aggregates: ['distinct', 'list'] }` donne le compte et l'énumération. + +## Formatage + +| `format.type` | Options | Rendu en `fr-FR` | Rendu en `en-US` | +|---|---|---|---| +| `number` (défaut) | `decimals` | `12 345,6` | `12,345.6` | +| `area`, `unit: 'm2'` | — | `124 310 m²` | `124,310 m²` | +| `area`, `unit: 'ha'` | `decimals` | `12,43 ha` | `12.43 ha` | +| `currency` | `decimals`, `currency` | `185 000 €` | `€185,000` | + +`unit: 'auto'` bascule en hectares au-delà de 10 000 m². Si `decimals` est omis, +0 à 2 décimales sont affichées selon la valeur. + +En `unit: 'm2'`, la valeur est toujours rendue entière : `decimals` y est ignoré, +une surface en mètres carrés étant un entier. + +Le placement du symbole monétaire suit la langue active : suffixé en français, +préfixé en anglais. `format.currency` permet de surcharger `CURRENCY` sur un +champ donné. + +Le format s'applique au couple champ + agrégat : `count`, `distinct` et +`frequency` produisent des effectifs, toujours rendus en entiers nus, même si le +champ déclare un format. + +## Traductions + +`LOCALE` choisit la langue, `TRANSLATIONS` contient les textes. Pour ajouter une +langue, copiez un bloc et traduisez les valeurs : + +```javascript +const TRANSLATIONS = { + fr: { panelTitle: 'Statistiques de la sélection', count: 'Nombre', /* ... */ }, + en: { panelTitle: 'Selection statistics', count: 'Count', /* ... */ }, + es: { count: 'Recuento' } // bloc partiel : le reste retombe sur FALLBACK_LOCALE +}; +``` + +La résolution cherche l'étiquette exacte (`pt-BR`), puis la sous-étiquette +primaire (`pt`), puis `FALLBACK_LOCALE`. Les clés manquantes retombent +**individuellement** sur la langue de repli, donc une traduction partielle +fonctionne. + +`LOCALE` alimente aussi le tri et le formatage des nombres, l'ordre alphabétique +dépendant de la langue. + +Les `label` de `STATISTICS_CONFIG` ne sont pas traduits : ce sont des données de +votre projet, pas des chaînes du module. + +## Impression de la sélection + +Désactivée par défaut. Une fois activée, chaque bloc de couche affiche un bouton +d'impression qui génère un PDF contenant la carte, la sélection surlignée, un +titre et les statistiques du bloc. + +### Préparer la mise en page dans QGIS + +Créez une mise en page dédiée contenant : + +| Élément | Contrainte | +|---|---| +| Une carte | Son identifiant est libre : le script le lit dans le projet. | +| Une étiquette pour le titre | Identifiant sans accent ni espace : il devient un nom de paramètre HTTP. | +| Une étiquette pour les statistiques | Même règle, et **cochez « rendu HTML »** dessus. | + +Le rendu HTML est le seul point qui change vraiment le résultat : avec, les +statistiques sont un tableau à deux colonnes ; sans, un texte aligné à l'espace +qui se décale dès qu'une valeur est plus longue que les autres. Le module gère +les deux et détecte automatiquement lequel s'applique. + +Si la mise en page contient plusieurs cartes, la première est utilisée et un +avertissement le signale. + +**Republiez le projet depuis le plugin Lizmap** après toute modification de mise +en page. Le fichier `.cfg` est la seule source que Lizmap lit ; enregistrer le +`.qgs` ne suffit pas. + +### Activer + +```javascript +const PRINT_LAYOUT = 'Impression sélection'; // nom exact dans QGIS, null pour désactiver +const PRINT_TITLE_LABEL_ID = 'stats_title'; +const PRINT_CONTENT_LABEL_ID = 'stats_content'; +const PRINT_DPI = 100; +``` + +Un titre par couche, facultatif : + +```javascript +'Parcelles': { + label: 'Parcelles cadastrales', // titre du bloc à l'écran + printTitle: 'Extrait cadastral - parcelles sélectionnées', // titre imprimé + fields: { ... } +} +``` + +`printTitle` est utile quand un intitulé court convient au panneau et une +formulation complète à un A3. Omis, le `label` est utilisé. + +La mise en page dédiée est automatiquement masquée de la liste d'impression de +Lizmap, où elle ne servirait à rien : elle attend des valeurs d'étiquettes que +seul ce module fournit. + +### Ce qui est imprimé + +**La carte garde l'échelle de l'écran** : une parcelle sort à la taille où vous +la voyez. C'est le comportement du panneau d'impression de Lizmap. + +Le cadre de la mise en page étant physiquement plus petit que l'écran, il montre +donc **moins de terrain** que la vue, centré au même endroit — et non la totalité +de ce qui est affiché. + +Les couches imprimées sont celles visibles à l'écran, fond de carte compris. Si +cette information n'est pas accessible, le module se rabat silencieusement sur +les couches enregistrées dans la mise en page, et l'impression fonctionne quand +même. + +Le PDF est téléchargé sous un nom dérivé du titre et de la date, par exemple +`extrait-cadastral-parcelles-selectionnees-2026-08-04.pdf`. + +### Diagnostic + +Une mise en page introuvable, un identifiant d'étiquette inexistant ou une mise +en page sans carte produisent un avertissement `[stats]` au démarrage **listant +les valeurs disponibles**, et le bouton n'apparaît pas. Le reste du module +continue de fonctionner. + +## Diagnostic + +`window.lizStats` expose les classes du module pour inspection en console : + +```javascript +window.lizStats.Aggregator.compute('L', 'f', 'sum', [100, null, '200']); +// { kind: 'numeric', value: 300 } + +window.lizStats.instance(); // l'instance courante, ou null +``` + +Avec `DEBUG_MODE = true`, les traces `[stats]` indiquent la requête WFS émise, le +nombre d'entités reçues et les couches configurées au démarrage. + +## Fonctionnement + +À chaque changement de sélection, le module émet **une seule** requête WFS, +filtrée par `FEATUREID` sur les identifiants fournis par l'événement Lizmap, et +limitée aux champs configurés par `PROPERTYNAME`. Les alias de champs sont +chargés une fois au démarrage, pas à chaque sélection. + +Une sélection sur une couche absente de `STATISTICS_CONFIG` est ignorée sans +aucun traitement. + +## Limites connues + +- Sur deux sélections très rapprochées, l'affichage peut brièvement montrer les + chiffres de la première ; il se corrige à la sélection suivante. +- Les alias QGIS sont mis en cache au démarrage. Une republication du projet + pendant qu'une carte est ouverte n'est prise en compte qu'au rechargement de + la page. +- Au-delà de `MAX_FEATURES` entités sélectionnées, le module affiche un message + plutôt que de calculer. +- L'impression envoie les couches visibles, mais pas leurs styles ni leurs + opacités : un style secondaire ou une transparence de l'écran ne sont pas + reproduits sur le PDF. +- `AbortSignal.timeout()` demande Firefox 100+ ou Chrome 103+. + +## Licence + +Mozilla Public License Version 2.0 + +--- +--- + +# Selection statistics + +[Français](#statistiques-sur-la-sélection) · **English** + +Displays aggregated statistics about the selected features, at the bottom of +Lizmap's selection panel. + +Targets **Lizmap Web Client 3.9**. Vanilla JavaScript rewrite of the original +community script, written for LWC 3.6. + +![The statistics block below Lizmap's selection panel](./statistics.jpg) + +## Worked example + +The screenshot above is exactly what this configuration produces, on five +selected parcels: + +```javascript +const STATISTICS_CONFIG = { + layers: { + 'Parcelles': { + label: 'Parcelles cadastrales', + fields: { + 'id_source': { + aggregates: ['count'], + label: 'Nombre de parcelles' + }, + 'numero': { + aggregates: ['list'], + label: 'Numéro' + }, + 'contenance': { + aggregates: ['sum', 'minimum', 'maximum'], + label: 'Surface', + format: { type: 'area', unit: 'm2' } } } } - }; + } +}; ``` -The `layers` keys can contains one or several layers objects. +| Field | Aggregate | Result | +|---|---|---| +| Nombre de parcelles | Count | `5` | +| Numéro | List | `691, 692, 757, 758, 759` | +| Surface | Sum | `1 398 m²` | +| Surface | Minimum | `230 m²` | +| Surface | Maximum | `333 m²` | + +Three things worth noticing. The count is applied to `id_source`, the primary +key, because `count` counts the **filled-in** values of a field, not the +selected features. The numbers come out naturally sorted without asking, since +`sort: 'natural'` is the default. And the print icon, to the right of the block +title, only appears once a print layout has been configured. -* For each **layer**, you should add a new key with its name (as written in QGIS layers panel), for example `Parcelles`. -* This key contains an object with a `fields` key listing the fields for which to calculate the stats. -* For each field, you can have an array of one or several aggregate functions, among `count`, `sum`, `average`, `minimum`, `maximum` +## Installation -In the example above, the statistics window shows: +Copy `show_statistics_on_selection_3.9.js` and `show_statistics_on_selection.css` +into your project's `media/js//` folder, as described in the +[Lizmap documentation](https://docs.lizmap.com/current/en/publish/customization/javascript.html). -* the number of the `geo_parcelle` field, which is the primary key of the table: this will show the number of selected features -* the sum of the parcels area taken from the field `surface_geo`, and the minimum and maximum area. +The module only initialises when the selection panel is opened, so it costs +nothing on maps where it is not used. -and the statistics for the other `Sections` layer: number of selected features (count of `geo_section`), and minimum/maximum of the `ogc_fid` field +**The layer must be published through WFS.** Without it in the service +capabilities, the module cannot fetch the features. -You can also adapt the locales for the aggregated functions labels. At present, only one translation is possible: +## General settings + +At the top of the JS file, above the "DO NOT MODIFY BELOW THIS LINE" marker: + +| Constant | Purpose | +|---|---| +| `DEBUG_MODE` | Console diagnostics, prefixed `[stats]`. Set to `false` in production. | +| `LOCALE` | `'auto'` follows the Lizmap interface language, then the browser. Any BCP 47 tag (`'fr-FR'`, `'en-GB'`) forces one. | +| `FALLBACK_LOCALE` | Language used when the active one has no translation. | +| `CURRENCY` | ISO 4217 code for `format.type: 'currency'`. Overridable per field. | +| `PANEL_TITLE` | Block title. `null` uses the translated one. | +| `MAX_FEATURES` | Above this count, the module shows a message instead of computing. | +| `FETCH_QGIS_ALIASES` | Fetch the field aliases from the QGIS project. One request per layer, at startup only. Pointless if every field carries a `label` in the config. | + +## Layer configuration ```javascript - var aggregate_function_locales = { - 'count': 'Count', - 'sum': 'Sum', - 'average': 'Average', - 'minimum': 'Minimum', - 'maximum': 'Maximum' +const STATISTICS_CONFIG = { + layers: { + 'Parcelles': { // exact layer name as in QGIS + label: 'Parcelles cadastrales', // optional, default: layer name + fields: { + 'contenance': { + aggregates: ['sum', 'maximum'], + label: 'Surface', + format: { type: 'area', unit: 'ha', decimals: 2 } + }, + 'nb_bati': ['sum'] // short form + } + } } +}; +``` + +A layer missing from the project, an unknown aggregate or an invalid +`format.type` produces a `[stats]` warning at startup, and that entry alone is +skipped. + +A field label is resolved in this order: the config `label`, then the QGIS alias +if `FETCH_QGIS_ALIASES` is on and the alias is not empty, then the field name. + +Aggregates are displayed in the order you write them. + +## Aggregates + +### Numeric + +`count`, `sum`, `average`, `minimum`, `maximum`. + +Unfilled values are discarded before computing, and values that cannot be +converted to a number are ignored. If nothing usable remains, the cell shows a +dash and a warning is emitted once in the console. + +**Mind `count`**: it counts the features where **that field** is filled in. To +count the selected features, apply it to a never-null field, typically the +primary key. + +### Text + +Applicable to any field type. + +#### `list` — enumerate the values + +```javascript +'numero': { + aggregates: ['list'], + label: 'Numéro', + list: { separator: ', ', maxItems: 50, sort: 'natural', distinct: true } +} +``` + +``` +Numéro 213, 243, 244, 276 +``` + +| Option | Default | Purpose | +|---|---|---| +| `separator` | `', '` | Between values. | +| `maxItems` | `50` | Beyond it: `… and N more`. | +| `sort` | `'natural'` | `'natural'` sorts `2, 10, 100`; `'alpha'` sorts `10, 100, 2`; `'none'` keeps the selection order. | +| `distinct` | `true` | Deduplicate the list. | + +Natural sort is the default because cadastral numbers arrive as strings: an +alphabetical sort would give `10, 100, 2, 244`. + +`list.distinct` is a deduplication flag, not to be confused with the `distinct` +aggregate, which returns a count. Both may sit on the same field. + +#### `frequency` — distribution + +```javascript +'degre_risk_inon': { + aggregates: ['frequency'], + label: 'Aléa inondation', + frequency: { maxItems: 10 } +} +``` + +``` +Aléa inondation + Moyen 11 + Faible 9 + Fort 4 + (not set) 6 +``` + +Each distinct value with its number of occurrences, sorted by descending count. +The only aggregate producing several rows. + +Unfilled values are **kept** — their number is information — and always listed +last, outside the ranking. They are never dropped by the `maxItems` truncation. + +#### `distinct` — how many different values + +``` +Section Distinct values 7 +``` + +Unfilled values discarded. Combines well with `list`: +`{ aggregates: ['distinct', 'list'] }` gives both the count and the enumeration. + +## Formatting + +| `format.type` | Options | Rendered in `fr-FR` | Rendered in `en-US` | +|---|---|---|---| +| `number` (default) | `decimals` | `12 345,6` | `12,345.6` | +| `area`, `unit: 'm2'` | — | `124 310 m²` | `124,310 m²` | +| `area`, `unit: 'ha'` | `decimals` | `12,43 ha` | `12.43 ha` | +| `currency` | `decimals`, `currency` | `185 000 €` | `€185,000` | + +`unit: 'auto'` switches to hectares above 10 000 m². When `decimals` is omitted, +0 to 2 fraction digits are shown depending on the value. + +With `unit: 'm2'` the value is always rendered whole: `decimals` is ignored +there, since an area in square metres is an integer. + +The currency symbol is placed according to the active language: suffixed in +French, prefixed in English. `format.currency` overrides `CURRENCY` on a given +field. + +The format applies to the field/aggregate pair, not the field alone: `count`, +`distinct` and `frequency` yield head counts and always render as bare integers, +even on a field declaring a format. + +## Translations + +`LOCALE` picks the language, `TRANSLATIONS` holds the strings. To add a +language, copy a block and translate the values: + +```javascript +const TRANSLATIONS = { + fr: { panelTitle: 'Statistiques de la sélection', count: 'Nombre', /* ... */ }, + en: { panelTitle: 'Selection statistics', count: 'Count', /* ... */ }, + es: { count: 'Recuento' } // partial block: the rest falls back to FALLBACK_LOCALE +}; ``` + +Lookup tries the exact tag (`pt-BR`), then the primary subtag (`pt`), then +`FALLBACK_LOCALE`. Missing keys fall back **individually**, so a partial +translation works. + +`LOCALE` also drives sorting and number formatting, alphabetical order being +language-dependent. + +The `label` values in `STATISTICS_CONFIG` are not translated: they are your +project's data, not the module's strings. + +## Printing the selection + +Disabled by default. Once enabled, each layer block shows a print button that +generates a PDF containing the map, the highlighted selection, a title and the +block's statistics. + +### Preparing the QGIS layout + +Create a dedicated layout containing: + +| Item | Constraint | +|---|---| +| One map | Its id is free: the script reads it from the project. | +| A label for the title | Id without accents or spaces: it becomes an HTTP parameter name. | +| A label for the statistics | Same rule, and **tick "render as HTML"** on it. | + +The HTML rendering is the one setting that really changes the result: with it, +the statistics are a two-column table; without it, space-aligned text that drifts +as soon as one value is longer than the others. The module handles both and +detects which applies. + +If the layout holds several maps, the first one is used and a warning says so. + +**Republish the project from the Lizmap plugin** after any layout change. The +`.cfg` file is the only source Lizmap reads; saving the `.qgs` is not enough. + +### Enabling + +```javascript +const PRINT_LAYOUT = 'Impression sélection'; // exact name in QGIS, null to disable +const PRINT_TITLE_LABEL_ID = 'stats_title'; +const PRINT_CONTENT_LABEL_ID = 'stats_content'; +const PRINT_DPI = 100; +``` + +An optional per-layer title: + +```javascript +'Parcelles': { + label: 'Parcelles cadastrales', // block title on screen + printTitle: 'Extrait cadastral - parcelles sélectionnées', // printed title + fields: { ... } +} +``` + +`printTitle` helps when a short wording suits the panel and a full one suits an +A3 sheet. Omitted, the `label` is used. + +The dedicated layout is automatically hidden from Lizmap's own print list, where +it would be useless: it expects label values only this module supplies. + +### What gets printed + +**The map keeps the screen scale**: a parcel comes out the size you see it. This +is what Lizmap's own print panel does. + +Since the layout frame is physically smaller than the screen, it therefore shows +**less ground** than the view, centred on the same spot — not the whole of what +is displayed. + +The printed layers are the ones visible on screen, base map included. If that +information is not reachable, the module silently falls back to the layers saved +in the layout, and printing still works. + +The PDF is downloaded under a name derived from the title and the date, for +instance `extrait-cadastral-parcelles-selectionnees-2026-08-04.pdf`. + +### Troubleshooting + +A missing layout, an unknown label id or a layout without a map produce a +`[stats]` warning at startup **listing what is available**, and the button does +not appear. The rest of the module keeps working. + +## Diagnostics + +`window.lizStats` exposes the module classes for console inspection: + +```javascript +window.lizStats.Aggregator.compute('L', 'f', 'sum', [100, null, '200']); +// { kind: 'numeric', value: 300 } + +window.lizStats.instance(); // the current instance, or null +``` + +With `DEBUG_MODE = true`, the `[stats]` traces report the WFS request sent, how +many features came back, and the layers configured at startup. + +## How it works + +On every selection change the module issues **one** WFS request, filtered by +`FEATUREID` on the identifiers carried by the Lizmap event, and narrowed to the +configured fields by `PROPERTYNAME`. Field aliases are loaded once at startup, +not on every selection. + +A selection on a layer absent from `STATISTICS_CONFIG` is ignored entirely. + +## Known limitations + +- On two selections in quick succession, the panel may briefly show the figures + of the first one; it corrects itself on the next selection. +- QGIS aliases are cached at startup. Republishing the project while a map is + open is only picked up after a page reload. +- Above `MAX_FEATURES` selected features, the module shows a message instead of + computing. +- Printing sends the visible layers, but neither their styles nor their + opacities: a secondary style or an on-screen transparency is not reproduced on + the PDF. +- `AbortSignal.timeout()` requires Firefox 100+ or Chrome 103+. + +## License + +Mozilla Public License Version 2.0 diff --git a/library/tools/show_statistics_on_selection/show_statistics_on_selection.css b/library/tools/show_statistics_on_selection/show_statistics_on_selection.css index 272d919..9da5d0e 100644 --- a/library/tools/show_statistics_on_selection/show_statistics_on_selection.css +++ b/library/tools/show_statistics_on_selection/show_statistics_on_selection.css @@ -1,35 +1,78 @@ -#content #lizmap-selection-statistics { - max-width: 300px; - max-height: 500px; - overflow: auto; - position: absolute; - bottom: 250px; - right: 10px; - z-index: 1000; - border: 2px solid white; - border-radius: 4px; - box-shadow: 2px 2px 2px 2px #aaa; +/** + * Selection statistics - Lizmap Web Client 3.9 + * + * The panel is appended at the bottom of Lizmap's selection minidock, so + * Lizmap owns the layout: only the block and its table are styled here. + * + * var(--name, fallback) follows the LWC theme when it exposes the variable, + * and falls back to neutral values otherwise. + */ + +#lizmap-selection-statistics { + margin-top: 6px; + padding: 4px 2px; + border-top: 1px solid var(--color-border, #ccc); font-size: 12px; - background-color: #F0F0F0; + background-color: var(--color-bg, #f8f8f8); } -#content.mobile #lizmap-selection-statistics { - bottom: revert; - right: revert; - left: 40px; - top: 10px; + +/* Bootstrap may set a more specific display, so make `hidden` explicit. */ +#lizmap-selection-statistics[hidden], +.lizmap-stats-block[hidden] { + display: none; } -#lizmap-selection-statistics div { - margin-bottom: 10px; - padding: 2px; + +.lizmap-stats-title { + margin-bottom: 4px; + font-weight: bold; } + +.lizmap-stats-block { + margin-bottom: 8px; +} + +/* Layer name on the left, print control on the right. */ +.lizmap-stats-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + min-height: 22px; +} + +.lizmap-stats-print { + flex: none; + padding: 1px 5px; + line-height: 1; +} + +.lizmap-stats-block:last-child { + margin-bottom: 0; +} + .lizmap-selection-statistics-table { - margin-bottom: 0px; - background-color: #d8d8d8; + margin-bottom: 4px; + background-color: transparent; +} + +.lizmap-selection-statistics-table th { + text-align: center; + font-weight: bold; +} + +.lizmap-selection-statistics-table th, +.lizmap-selection-statistics-table td { + padding: 2px 4px; + line-height: 1.3; + border-top: 1px solid var(--color-border, #ddd); } -.lizmap-selection-statistics-table tr th{ - margin-bottom: 0px; + +.lizmap-stats-value { + text-align: right; + white-space: nowrap; } -.lizmap-selection-statistics-table tr th, -.lizmap-selection-statistics-table tr td { - line-height: 10px; + +.lizmap-stats-message { + margin: 2px 0; + color: var(--color-danger, #a94442); } diff --git a/library/tools/show_statistics_on_selection/show_statistics_on_selection_3.9.js b/library/tools/show_statistics_on_selection/show_statistics_on_selection_3.9.js new file mode 100644 index 0000000..185b372 --- /dev/null +++ b/library/tools/show_statistics_on_selection/show_statistics_on_selection_3.9.js @@ -0,0 +1,1625 @@ +/** + * @license Mozilla Public License Version 2.0 + * This script has been developed by the "community" + * There isn't any guarantee that this script will work on another version of Lizmap Web Client. + * @author Arnaud Vandecasteele + * + * Selection statistics - Lizmap Web Client 3.9 + * + * Displays aggregated statistics about the selected features of the layers + * declared in STATISTICS_CONFIG. + * + * Six classes, each with one job. Only SelectionStatistics wires the Lizmap + * events; only WfsFetcher and SelectionPrinter talk to the server; StatsPanel + * touches the DOM and nothing else. + * + * Aggregator pure computation over a list of raw field values + * ValueFormatter renders one aggregated value in the active locale + * WfsFetcher QGIS Server access: field aliases, selected features + * StatsPanel DOM rendering; knows neither network nor aggregates + * SelectionPrinter builds and sends the GetPrint request + * SelectionStatistics orchestration, and the only holder of lizMap events + * + * + * STARTUP + * ------- + * + * minidockopened('selectiontool') + * `-> SelectionStatistics.init() + * +-> #normalizeConfig() validates STATISTICS_CONFIG + * +-> SelectionPrinter.validate() no layout configured -> no button + * | `-> #hideFromLizmapPrint() + * +-> StatsPanel.create() + * | appended to #selectiontool + * `-> WfsFetcher.loadAliases() once per layer, in parallel + * + * ON EVERY SELECTION CHANGE + * ------------------------- + * + * layerSelectionChanged + * `-> SelectionStatistics.onSelectionChanged(event) + * | + * +-- layer not configured -> ignored, nothing happens + * +-- selection emptied -> StatsPanel.clearLayer() + * +-- over MAX_FEATURES -> StatsPanel.renderLayer(message) + * | + * `-- otherwise + * +-> WfsFetcher.fetchFeatures() one POST, FEATUREID + * +-> #buildBlock() + * | +-> WfsFetcher.aliasesOf() + * | +-> Aggregator.compute() -> { kind, value | rows } + * | `-> ValueFormatter.format() -> display string + * `-> StatsPanel.renderLayer(block) + * + * ON A PRINT CLICK + * ---------------- + * + * StatsPanel print button + * `-> onPrint(layerName) callback, set only if printing works + * `-> SelectionStatistics.#print() + * +-> StatsPanel.blockOf() what is currently on screen + * `-> SelectionPrinter.print() + * +-> #frame() screen scale, centred extent + * +-> #visibleLayers() base map first, then overlays + * +-> #selectionToken() highlights the selection + * +-> #serialise() -> #toHtml() or #toText() + * `-> #download() blob -> file + * + * + * ON PANEL CLOSE + * -------------- + * + * minidockclosed('selectiontool') + * `-> SelectionStatistics.destroyPanel() + * `-> StatsPanel.destroy() the instance survives, the DOM goes + * + * The panel is also removed when the last block empties, and rebuilt on + * demand by renderLayer() when its node is missing or detached. +*/ + +(function () { + 'use strict'; + + /* ============================================================ + * CONFIGURATION - adapt to your project + * ============================================================ */ + + /** Diagnostic traces in the console. Keep false in production. */ + const DEBUG_MODE = false; + + /** + * Interface language. + * 'auto' follows the Lizmap interface (), then the browser. + * Any BCP 47 tag forces one, e.g. 'fr-FR', 'en-GB', 'de-DE'. + */ + const LOCALE = 'auto'; + + /** Language used when the active one has no translation below. */ + const FALLBACK_LOCALE = 'en'; + + /** ISO 4217 code for format.type === 'currency'. Overridable per field. */ + const CURRENCY = 'EUR'; + + /** Panel title. null uses the translated one below. */ + const PANEL_TITLE = null; + + /** Above this count the module shows a message instead of computing. */ + const MAX_FEATURES = 5000; + + /** + * Fetch the field aliases defined in the QGIS project. + * Cost: one request per configured layer, at startup only. + */ + const FETCH_QGIS_ALIASES = true; + + /** + * How long to wait for the QGIS alias callback before giving up, in ms. + * On timeout the config labels are used and startup carries on. + */ + const ALIAS_TIMEOUT_MS = 10000; + + /** + * How long to wait for the WFS feature request before giving up, in ms. + * On timeout the layer block shows the error message instead of staying + * frozen on the previous selection figures. + */ + const REQUEST_TIMEOUT_MS = 30000; + + /** + * How long to wait for the PDF, in ms. Deliberately generous: a layout with + * several maps takes far longer to render than a feature query. + */ + const PRINT_TIMEOUT_MS = 120000; + + /** + * Print layout used by the print button, as named in the QGIS project. + * null disables the feature entirely: no button, no validation, no request. + */ + const PRINT_LAYOUT = null; + + /** + * Layout label id receiving the title. + * Avoid accents and spaces: it becomes an HTTP parameter name. + */ + const PRINT_TITLE_LABEL_ID = 'stats_title'; + + /** + * Layout label id receiving the statistics. + * Tick "render as HTML" on that label in QGIS for a proper two-column table. + */ + const PRINT_CONTENT_LABEL_ID = 'stats_content'; + + /** + * DPI used to compute the scale denominator of the printed map. Lizmap uses it + */ + const PRINT_DPI = 100; + + /** + * Layers and fields to aggregate. + * + * Each layer key is the layer name as written in the QGIS layers panel. + * A layer absent from the project, an unknown aggregate or an invalid + * format is reported in the console at startup and skipped, never fatal. + * + * 'LayerName': { + * label: 'Shown above the block', // optional, default: layer name + * printTitle: 'Shown on the printed sheet', // optional, default: label + * fields: { ... } + * } + * + * FIELD SHAPES + * + * 'field': ['sum'] short form + * 'field': { aggregates: ['sum'], label, format, list, frequency } + * + * The option sub-objects are named after the aggregate they configure: + * `list: {...}` is read only when 'list' is requested, same for frequency. + * Declaring one without its aggregate warns at startup. + * + * AGGREGATES (rendered examples below are fr-FR; wording follows LOCALE) + * + * count how many features have this field filled in -> 42 + * To count selected features, use a never-null field + * such as the primary key: count on an empty field + * returns 0, which is correct but rarely what you want. + * + * sum total of the numeric values -> 12,43 ha + * average mean of the numeric values -> 1 776 m² + * minimum smallest numeric value -> 210 m² + * maximum largest numeric value -> 4 100 m² + * Non-numeric values are discarded. If nothing is + * usable the cell shows a dash and warns once. + * + * list enumerates the values -> 2, 10, 100, 244 + * distinct how many different values -> 7 + * frequency each value with its count, one row each -> Moyen 11 + * + * LIST OPTIONS list: { separator, maxItems, sort, distinct } + * + * separator between values default ', ' + * maxItems beyond it, appends "… et N autres" default 50 + * sort 'natural' 2, 10, 100 <- cadastral numbers arrive as + * 'alpha' 10, 100, 2 strings, hence this default + * 'none' selection order + * distinct deduplicate the list default true + * + * Careful: `list.distinct` deduplicates, the `distinct` aggregate + * returns a count. Both may sit on the same field. + * + * FREQUENCY OPTIONS frequency: { maxItems } + * + * maxItems beyond it, the tail is merged into one row default 10 + * Unfilled values are kept, always listed last, and never truncated + * away: their number is information, unlike in numeric aggregates. + * + * FORMAT format: { type, unit, decimals, currency } + * + * type 'number' 12 345,6 decimals: omitted -> 0 to 2 + * type 'area' unit 'm2' 124 310 m² <- raw value, no conversion + * unit 'ha' 12,43 ha <- divides by 10 000 + * unit 'auto' switches to ha above 10 000 m² + * type 'currency' 185 000 € currency: ISO code, default CURRENCY + * + * With unit 'm2' the value is always rendered whole: `decimals` is + * ignored there, since an area in square metres is an integer. + * + * The format applies to the field/aggregate pair, not the field alone: + * count, distinct and frequency yield head counts and always render as + * bare integers, even on a field declared as an area or a currency. + ============ CONFIG EXAMPLE replace with your own layers and fields ============ + const STATISTICS_CONFIG = { + layers: { + 'Parcelles': { + label: 'Parcelles cadastrales', + fields: { + 'id_source': { + aggregates: ['count'], + label: 'Nombre de parcelles' + }, + 'numero': { + aggregates: ['list'], + label: 'Numéro', + list: { separator: ', ', maxItems: 50, sort: 'natural', distinct: true } + }, + 'contenance': { + aggregates: ['sum', 'minimum', 'maximum'], + label: 'Surface', + format: { type: 'area', decimals: 2 } + } + } + } + } + }; + */ + const STATISTICS_CONFIG = { + layers: { + } + }; + + /** + * User-facing strings, keyed by language subtag. + * To add a language, copy a block and translate the values. + * Partial blocks are fine: missing keys fall back to FALLBACK_LOCALE. + * {n} and {max} are substituted at display time. + */ + const TRANSLATIONS = { + fr: { + panelTitle: 'Statistiques de la sélection', + count: 'Nombre', + sum: 'Somme', + average: 'Moyenne', + minimum: 'Minimum', + maximum: 'Maximum', + list: 'Liste', + distinct: 'Valeurs distinctes', + noValue: '-', + emptyValue: '(non renseigné)', + andNMore: '… et {n} autres', + tooManyFeatures: 'Sélection trop volumineuse ({n} entités, maximum {max})', + error: 'Impossible de calculer les statistiques', + print: 'Imprimer', + printError: 'Impossible de générer l\'impression' + }, + en: { + panelTitle: 'Selection statistics', + count: 'Count', + sum: 'Sum', + average: 'Average', + minimum: 'Minimum', + maximum: 'Maximum', + list: 'List', + distinct: 'Distinct values', + noValue: '-', + emptyValue: '(not set)', + andNMore: '… and {n} more', + tooManyFeatures: 'Selection too large ({n} features, maximum {max})', + error: 'Unable to compute statistics', + print: 'Print', + printError: 'Unable to generate the print' + } + }; + + /* ============================================================ + * DO NOT MODIFY BELOW THIS LINE + * ============================================================ */ + + /** Supported aggregates. Used to validate STATISTICS_CONFIG. */ + const AGGREGATES = { + count: { labelKey: 'count' }, + sum: { labelKey: 'sum' }, + average: { labelKey: 'average' }, + minimum: { labelKey: 'minimum' }, + maximum: { labelKey: 'maximum' }, + distinct: { labelKey: 'distinct' }, + list: { labelKey: 'list', + defaults: { separator: ', ', maxItems: 50, sort: 'natural', distinct: true } }, + frequency: { defaults: { maxItems: 10 } } + }; + + /** Accepted values for format.type. */ + const KNOWN_FORMATS = ['number', 'area', 'currency']; + + /** + * USED IN PRINT + * Standardized rendering pixel size in metres, from the OGC WMS spec. + * Dividing a view resolution by it yields the scale denominator of the + * screen. Lizmap reaches the same value through a DPI constant. + */ + const OGC_PIXEL_SIZE_M = 0.00028; + + /** BCP 47 tag. Drives both the strings and every Intl formatter. */ + const ACTIVE_LOCALE = (LOCALE && LOCALE !== 'auto') + ? LOCALE + : (document.documentElement.lang || navigator.language || FALLBACK_LOCALE); + + /** + * Strings for the active language. + * Lookup order: exact tag, then primary subtag, then FALLBACK_LOCALE. + * Keys are merged over the fallback, so a partial translation still works. + */ + const T = (function () { + const fallback = TRANSLATIONS[FALLBACK_LOCALE] || {}; + const primary = ACTIVE_LOCALE.toLowerCase().split('-')[0]; + const strings = TRANSLATIONS[ACTIVE_LOCALE] || TRANSLATIONS[primary]; + + if (!strings) { + console.warn( + `[stats] no translation for "${ACTIVE_LOCALE}", ` + + `falling back to "${FALLBACK_LOCALE}"` + ); + } + + // Always a fresh object, never the TRANSLATIONS entry itself. + return Object.assign({}, fallback, strings || {}); + })(); + + /** Natural sort: "2" before "10". Collation order follows the active locale. */ + const NATURAL_COLLATOR = new Intl.Collator(ACTIVE_LOCALE, { numeric: true, sensitivity: 'base' }); + + /** Strict alphabetical sort, in the active locale's collation order. */ + const ALPHA_COLLATOR = new Intl.Collator(ACTIVE_LOCALE, { sensitivity: 'base' }); + + /** Trace gated by DEBUG_MODE. */ + function debug(...args) { + if (DEBUG_MODE) { + console.log('[stats]', ...args); + } + } + + /** + * Reduces a flat list of raw field values to one aggregated result. + * + * Numeric aggregates (count, sum, average, minimum, maximum) drop anything + * that is not a finite number and return null rather than NaN or Infinity. + * Textual ones (list, frequency, distinct) apply to any field type and + * handle sorting, deduplication and truncation. + * + * No DOM, no network. Inspectable from the console via window.lizStats. + */ + + class Aggregator { + + /** layer|field|aggregate keys already reported, so we warn only once. */ + static #warned = new Set(); + + /** + * @param {string} layerName layer name, used in messages + * @param {string} fieldName field name, used in messages + * @param {string} aggregate one of AGGREGATES + * @param {Array} values raw values, one per selected feature + * @param {Object} [options] `list` or `frequency` options from the config + * @returns {{kind: string, value?: *, rows?: Array}} + */ + static compute(layerName, fieldName, aggregate, values, options) { + switch (aggregate) { + case 'count': + return { kind: 'count', value: Aggregator.#defined(values).length }; + + case 'distinct': + return { kind: 'count', value: new Set(Aggregator.#defined(values)).size }; + + case 'list': + return { kind: 'text', value: Aggregator.#list(values, options) }; + + case 'frequency': + return { kind: 'rows', rows: Aggregator.#frequency(values, options) }; + + default: + return { + kind: 'numeric', + value: Aggregator.#numeric(layerName, fieldName, aggregate, values) + }; + } + } + + /** + * The single definition of "not filled in". Every aggregate relies on it, + * so count, distinct, list and frequency can never disagree on what an + * empty value is. + */ + static #isEmpty(value) { + return value === null || value === undefined || value === ''; + } + + /** Filled values only. */ + static #defined(values) { + return values.filter(v => !Aggregator.#isEmpty(v)); + } + + /** + * Numeric aggregates. Discards anything not convertible to a finite number. + * Returns null when nothing is usable: never NaN, never Infinity. + */ + static #numeric(layerName, fieldName, aggregate, values) { + const numbers = []; + for (const value of Aggregator.#defined(values)) { + // The comma swap handles decimal commas coming from the database, + // which is a data quirk, not a locale concern. + const n = typeof value === 'number' + ? value + : Number(String(value).replace(',', '.')); + if (Number.isFinite(n)) { + numbers.push(n); + } + } + + if (numbers.length === 0) { + Aggregator.#warnOnce(layerName, fieldName, aggregate); + return null; + } + + const total = numbers.reduce((a, b) => a + b, 0); + + switch (aggregate) { + case 'sum': + return total; + case 'average': + return total / numbers.length; + case 'minimum': + return numbers.reduce((a, b) => (b < a ? b : a)); + case 'maximum': + return numbers.reduce((a, b) => (b > a ? b : a)); + default: + return null; + } + } + + /** Value enumeration, deduplicated and sorted by default. */ + static #list(values, options) { + const o = Object.assign({}, AGGREGATES.list.defaults, options); + + let items = Aggregator.#defined(values).map(v => String(v)); + if (items.length === 0) { + return null; + } + + if (o.distinct) { + items = [...new Set(items)]; + } + if (o.sort === 'natural') { + items.sort(NATURAL_COLLATOR.compare); + } else if (o.sort === 'alpha') { + items.sort(ALPHA_COLLATOR.compare); + } + + let text = items.slice(0, o.maxItems).join(o.separator); + if (items.length > o.maxItems) { + text += o.separator + T.andNMore.replace('{n}', items.length - o.maxItems); + } + return text; + } + + /** + * Distribution: each distinct value with its number of occurrences, + * sorted by descending count. + */ + static #frequency(values, options) { + const o = Object.assign({}, AGGREGATES.frequency.defaults, options); + + const counts = new Map(); + let emptyCount = 0; + + for (const value of values) { + if (Aggregator.#isEmpty(value)) { + emptyCount++; + continue; + } + const key = String(value); + counts.set(key, (counts.get(key) || 0) + 1); + } + + const rows = [...counts.entries()] + .map(([value, count]) => ({ value, count })) + .sort((a, b) => (b.count - a.count) || NATURAL_COLLATOR.compare(a.value, b.value)); + + let shown = rows; + if (rows.length > o.maxItems) { + shown = rows.slice(0, o.maxItems); + const remaining = rows.slice(o.maxItems).reduce((total, r) => total + r.count, 0); + shown.push({ + value: T.andNMore.replace('{n}', rows.length - o.maxItems), + count: remaining + }); + } + + // Unfilled values are a residual category, not a real one: always last, + // never ranked among actual values, never dropped by truncation. + if (emptyCount > 0) { + shown.push({ value: T.emptyValue, count: emptyCount }); + } + + return shown; + } + + /** One warning per layer/field/aggregate, so the console stays readable. */ + static #warnOnce(layerName, fieldName, aggregate) { + const key = layerName + '|' + fieldName + '|' + aggregate; + if (Aggregator.#warned.has(key)) { + return; + } + Aggregator.#warned.add(key); + console.warn( + `[stats] no usable numeric value for "${aggregate}" ` + + `on ${layerName}.${fieldName}: is this field really numeric?` + ); + } + } + + /** + * Renders an aggregated result as a display string, in the active locale. + * + * Dispatches on the `kind` produced by Aggregator, never on the aggregate + * name: a new aggregate needs no change here as long as it reports a known + * kind. Head counts are always bare integers, whatever format the field + * declares, since counting parcels yields neither hectares nor euros. + * + * Three formats are supported through the field config: plain `number`, + * `area` in square metres or hectares, and `currency`. Everything goes + * through Intl.NumberFormat, so separators, decimal marks and the position + * of the currency symbol follow the language rather than being hardcoded. + * + * A null value renders as the localised dash: the class never emits NaN, + * Infinity or an empty cell. + * + * Formatter instances are cached by their serialised options, since + * building one is expensive and a single render asks for a handful of + * distinct shapes. + * + * No DOM, no network. + */ + class ValueFormatter { + + /** Cached Intl.NumberFormat instances, keyed by their options. */ + static #formatters = new Map(); + + /** + * @param {{kind: string, value?: *}} result output of Aggregator.compute + * @param {Object} [format] { type, unit, decimals, currency } from the config + * @returns {string} + */ + static format(result, format) { + switch (result.kind) { + case 'count': + // A head count is always a bare integer: no unit, no currency. + return ValueFormatter.#numberFormat({ maximumFractionDigits: 0 }) + .format(result.value); + + case 'text': + return result.value === null ? T.noValue : result.value; + + case 'numeric': + return ValueFormatter.#numeric(result.value, format); + + default: + return T.noValue; + } + } + + static #numberFormat(options) { + const key = JSON.stringify(options); + if (!ValueFormatter.#formatters.has(key)) { + ValueFormatter.#formatters.set(key, new Intl.NumberFormat(ACTIVE_LOCALE, options)); + } + return ValueFormatter.#formatters.get(key); + } + + /** Undefined decimals means 0 to 2 fraction digits, depending on the value. */ + static #fractionOptions(decimals) { + return decimals === undefined + ? { minimumFractionDigits: 0, maximumFractionDigits: 2 } + : { minimumFractionDigits: decimals, maximumFractionDigits: decimals }; + } + + static #numeric(value, format) { + if (value === null) { + return T.noValue; + } + + const f = Object.assign({ type: 'number', unit: 'm2' }, format); + const fraction = ValueFormatter.#fractionOptions(f.decimals); + + switch (f.type) { + case 'area': { + // m2 and ha are SI symbols: identical in every language. + const asHectares = f.unit === 'ha' || (f.unit === 'auto' && value > 10000); + return asHectares + ? ValueFormatter.#numberFormat(fraction).format(value / 10000) + ' ha' + : ValueFormatter.#numberFormat({ maximumFractionDigits: 0 }) + .format(value) + ' m²'; + } + + case 'currency': + // style:'currency' places the symbol per locale, which a plain + // suffix cannot do: "185 000 €" in fr-FR, "€185,000" in en-US. + return ValueFormatter.#numberFormat(Object.assign( + { style: 'currency', currency: f.currency || CURRENCY }, + fraction + )).format(value); + + default: + return ValueFormatter.#numberFormat(fraction).format(value); + } + } + } + + /** + * Data access. The only class that talks to QGIS Server, and the only one + * that knows how a Lizmap layer name maps onto a WFS type name. + * + * Two jobs with deliberately opposite error policies. Loading field aliases + * never throws: they are cosmetic, the config labels take over, and a + * missing alias must not stop the module from starting. Fetching features + * does throw: without them there are no statistics, and the caller has to + * show that rather than leave stale figures on screen. + * + * Aliases are read once per layer at startup and cached; features are + * fetched once per selection, filtered by FEATUREID and narrowed by + * PROPERTYNAME to the configured fields alone. + * + * Both calls are bounded in time, by different means: the alias API is + * callback-based and cannot be aborted, so a timer stops the wait, while + * the feature request carries an AbortSignal that actually cancels it. + * + * Knows nothing about aggregation or rendering. + */ + class WfsFetcher { + + constructor() { + /** @type {Map} layer name -> { field: alias } */ + this._aliases = new Map(); + } + + /** + * Cached aliases for a layer, or an empty object when none were loaded. + * Synchronous: the rendering path must not await. + * + * @param {string} layerName + * @returns {Object} + */ + aliasesOf(layerName) { + return this._aliases.get(layerName) || {}; + } + + /** + * Field aliases defined in the QGIS project. + * Called once per layer at startup, never during a selection. + * Never throws: on failure the config labels take over. + * + * @param {string} layerName + * @returns {Promise} + */ + async loadAliases(layerName) { + if (!FETCH_QGIS_ALIASES) { + return {}; + } + if (this._aliases.has(layerName)) { + return this._aliases.get(layerName); + } + + const aliases = await new Promise((resolve) => { + // A callback API that never fires would leave init() awaiting + // forever, so give up after a bounded wait. A promise settles + // once: the timer and the callback can race freely, whichever + // lands first wins and the other call is a no-op. + const timer = setTimeout(() => { + debug('timed out while loading aliases of', layerName); + resolve({}); + }, ALIAS_TIMEOUT_MS); + + try { + lizMap.getFeatureData( + layerName, null, null, 'none', false, 0, 1, + (aName, aFilter, cFeatures, cAliases) => { + clearTimeout(timer); + resolve(cAliases || {}); + } + ); + } catch (error) { + clearTimeout(timer); + debug('failed to load aliases of', layerName, error); + resolve({}); + } + }); + + debug('aliases of', layerName, aliases); + this._aliases.set(layerName, aliases); + return aliases; + } + + /** + * Selected features, filtered by FEATUREID and limited to the useful fields. + * Throws on network failure or unusable response. + * + * @param {string} layerName + * @param {Array} featureIds identifiers taken from e.featureIds + * @param {string[]} fieldNames fields configured for this layer + * @returns {Promise} GeoJSON features + */ + async fetchFeatures(layerName, featureIds, fieldNames) { + const request = lizMap.getVectorLayerWfsUrl(layerName, null, null, null, false); + + // WFS expects "typename.id", not "layerName.id". They often match, + // but relying on that is exactly what broke the original script. + const typename = lizMap.config.layers[layerName]?.typename || layerName; + + const options = Object.assign({}, request.options, { + OUTPUTFORMAT: 'GeoJSON', + GEOMETRYNAME: 'none', + PROPERTYNAME: fieldNames.join(','), + FEATUREID: featureIds.map(id => typename + '.' + id).join(',') + }); + + debug('WFS request', request.url, options); + + const response = await window.fetch(request.url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(options), + // Without this, a server that never answers leaves the panel + // frozen on the previous figures, with no message at all. + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + + if (!response.ok) { + throw new Error(`WFS ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + if (!data || !Array.isArray(data.features)) { + throw new Error('WFS response has no `features` array'); + } + + debug(data.features.length, 'features received for', layerName); + return data.features; + } + } + + /** + * Builds and sends the QGIS Server GetPrint request. + * + * The whole feature rests on one server-side mechanism: a layout label + * carrying an id becomes a named parameter of the request. Lizmap uses it + * for its own editable print fields; this class builds the request + * directly instead of driving Lizmap's print panel through the DOM. + * + * validate() checks the layout, its two labels and its map against the + * project once at startup, names what is available on failure, and hides + * the dedicated layout from Lizmap's own print list. print() keeps the + * screen scale, ships the visible layers and the selection token, and + * serialises the on-screen block as HTML or aligned text depending on the + * label's own "render as HTML" setting. + * + * Escapes every database value it puts into markup: the textContent + * guarantee protecting the panel does not apply when producing a string. + */ + class SelectionPrinter { + + constructor() { + /** Layout descriptor from lizMap.mainLizmap.config.printTemplates. */ + this._template = null; + /** Main map item of that layout: id, width and height in mm. */ + this._map = null; + /** Whether the content label renders HTML, per its QGIS setting. */ + this._htmlLabel = false; + } + + /** + * Checks the print configuration against the Lizmap project. + * Every failure names what is available, so a wrong id is diagnosed + * from the console rather than by guesswork. + * + * @returns {boolean} true when printing can be offered + */ + validate() { + if (!PRINT_LAYOUT) { + return false; // Feature disabled: stay silent. + } + + const templates = lizMap.mainLizmap?.config?.printTemplates || []; + + const template = templates.find(t => t.title === PRINT_LAYOUT); + if (!template) { + console.warn( + `[stats] print layout "${PRINT_LAYOUT}" not found, available: ` + + (templates.map(t => t.title).join(', ') || 'none') + ); + return false; + } + + const labels = template.labels || []; + const ids = labels.map(l => l.id); + for (const id of [PRINT_TITLE_LABEL_ID, PRINT_CONTENT_LABEL_ID]) { + if (!ids.includes(id)) { + console.warn( + `[stats] label "${id}" not found in layout "${PRINT_LAYOUT}", available: ` + + (ids.join(', ') || 'none') + ); + return false; + } + } + + const maps = template.maps || []; + if (maps.length === 0) { + console.warn(`[stats] layout "${PRINT_LAYOUT}" has no map item`); + return false; + } + if (maps.length > 1) { + console.warn( + `[stats] layout "${PRINT_LAYOUT}" has ${maps.length} maps, using "${maps[0].id}"` + ); + } + + this._template = template; + this._map = maps[0]; + + const contentLabel = labels.find(l => l.id === PRINT_CONTENT_LABEL_ID); + this._htmlLabel = Boolean(contentLabel && contentLabel.htmlState); + + debug('print ready:', PRINT_LAYOUT, '| map', this._map.id, + '| html label', this._htmlLabel); + + this.#hideFromLizmapPrint(templates); + return true; + } + + /** + * Removes the dedicated layout from Lizmap's own print panel, where it + * would only confuse: it expects label values this module supplies. + * + * Print.js filters its dropdown on `layouts.list[i].enabled`, sharing + * indices with `printTemplates`. Clearing that flag excludes the layout + * from every future render. Removing the rendered