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
+
+
+
+### 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`
+
+
+
+## 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', `
+
`;
+ 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.

## 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 = `
`);
+ 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)
-
+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.
+
+
+## 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.
+
+
+
+## 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