From 014dbaea22c896e036ba387cfb40c38cbe09b128 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:39:14 +0200 Subject: [PATCH 01/76] Add Translations --- web/client/translations/data.de-DE.json | 41 +++++++++++++++++++++++++ web/client/translations/data.en-US.json | 41 +++++++++++++++++++++++++ web/client/translations/data.es-ES.json | 41 +++++++++++++++++++++++++ web/client/translations/data.fr-FR.json | 41 +++++++++++++++++++++++++ web/client/translations/data.it-IT.json | 41 +++++++++++++++++++++++++ 5 files changed, 205 insertions(+) diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 313ee876063..c4440423fad 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -1567,6 +1567,43 @@ } } }, + "select": { + "title": "Auswählen", + "tooltip": "Auswahlwerkzeug anzeigen", + "description": "Auswahlwerkzeug anzeigen", + "hasReachMaxCount": "Maximale Anzahl von Elementen erreicht", + "selection": "Auswahl", + "allLayers": "Alle Schichten", + "featuresCount": "Featuresanzahl", + "button": { + "select": "Auswahlmodus", + "chooseGeometry": "Wählen", + "selectByPoint": "Punkt", + "selectByLine": "Linie", + "selectByCircle": "Kreis", + "selectByRectangle": "Rechteck", + "selectByPolygon": "Polygon", + "clear": "Löschen", + "zoomTo": "Zoomen auf", + "statistics": "Statistiken", + "createLayer": "Ebene erstellen", + "filterData": "Daten filtern", + "export": "Exportieren", + "exportToCsv": "Exportieren nach CSV", + "exportToJson": "Exportieren nach JSON", + "exportToGeoJson": "Exportieren nach GeoJSON" + }, + "statistics": { + "title": "Statistiken", + "field": "Feld", + "count": "Anzahl der Werte", + "sum": "Summe der Werte", + "min": "Minimum", + "max": "Maximum", + "avg": "Durchschnitt", + "std": "Standardabweichung" + } + }, "snapshot": { "title": "Snapshot Vorschau", "save": "Speichern", @@ -3470,6 +3507,10 @@ "description": "Ermöglicht das Erstellen eines Permalinks der aktuell angezeigten Ressource", "title": "Permalink" }, + "Select": { + "title": "Auswählen", + "tooltip": "Auswahlwerkzeug anzeigen" + }, "StreetView": { "title": "Street-View", "description": "Tool zum Durchsuchen von Google Street View-Bildern auf der Karte" diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 7f4af13a91a..220f35a7045 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -1528,6 +1528,43 @@ } } }, + "select": { + "title": "Select", + "tooltip": "Display the selection tool", + "description": "Display the selection tool", + "hasReachMaxCount": "Maximum number of elements reached", + "selection": "Selection", + "allLayers": "All layers", + "featuresCount": "Features number", + "button": { + "select": "Selection mode", + "chooseGeometry": "Choose", + "selectByPoint": "Point", + "selectByLine": "Line", + "selectByCircle": "Circle", + "selectByRectangle": "Rectangle", + "selectByPolygon": "Polygon", + "clear": "Clear", + "zoomTo": "Zoom to", + "statistics": "Statistics", + "createLayer": "Create layer", + "filterData": "Filter data", + "export": "Export", + "exportToCsv": "Export to CSV", + "exportToJson": "Export to JSON", + "exportToGeoJson": "Export to GeoJSON" + }, + "statistics": { + "title": "Statistics", + "field": "Field", + "count": "Number of values", + "sum": "Sum of values", + "min": "Minimum", + "max": "Maximum", + "avg": "Average", + "std": "Standard deviation" + } + }, "snapshot": { "title": "Snapshot Preview", "save": "Save", @@ -3441,6 +3478,10 @@ "description": "Allows to create a permalink of the current resource in view", "title": "Permalink" }, + "Select": { + "title": "Select", + "tooltip": "Kartenlegende anzeigen" + }, "StreetView": { "title": "Street View", "description": "Street view tool for browsing Google street view images from the map" diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 2ae860ae113..644b2cb660a 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -1528,6 +1528,43 @@ } } }, + "select": { + "title": "Seleccionar", + "tooltip": "Mostrar la herramienta de selección", + "description": "Mostrar la herramienta de selección", + "hasReachMaxCount": "Número máximo de elementos alcanzado", + "selection": "Selección", + "allLayers": "Todas las capas", + "featuresCount": "Número de entidades", + "button": { + "select": "Modo de selección", + "chooseGeometry": "Elegir", + "selectByPoint": "Punto", + "selectByLine": "Línea", + "selectByCircle": "Círculo", + "selectByRectangle": "Rectángulo", + "selectByPolygon": "Polígono", + "clear": "Borrar", + "zoomTo": "Hacer zoom en", + "statistics": "Estadísticas", + "createLayer": "Crear capa", + "filterData": "Filtrar datos", + "export": "Exportar", + "exportToCsv": "Exportar a CSV", + "exportToJson": "Exportar a JSON", + "exportToGeoJson": "Exportar a GeoJSON" + }, + "statistics": { + "title": "Estadísticas", + "field": "Campo", + "count": "Número de valores", + "sum": "Suma de los valores", + "min": "Mínimo", + "max": "Máximo", + "avg": "Promedio", + "std": "Desviación estándar" + } + }, "snapshot": { "title": "Previsualización de la captura del mapa", "save": "Guardar", @@ -3431,6 +3468,10 @@ "description": "Permite crear un enlace permanente del recurso actual a la vista", "title": "Enlace permanente" }, + "Select": { + "title": "Seleccionar", + "tooltip": "Mostrar la herramienta de selección" + }, "StreetView": { "title": "Street View", "description": "Herramienta para buscar imágenes de Google Street View desde el mapa" diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 890f97c50a0..834c6a37871 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -1529,6 +1529,43 @@ } } }, + "select": { + "title": "Sélectionner", + "tooltip": "Afficher l'outil de sélection", + "description": "Afficher l'outil de sélection", + "hasReachMaxCount": "Nombre d'éléments maximum atteints", + "selection": "Sélection", + "allLayers": "Toutes les couches", + "featuresCount": "Nombre d'entités", + "button": { + "select": "Mode de sélection", + "chooseGeometry": "Choisir", + "selectByPoint": "Point", + "selectByLine": "Ligne", + "selectByCircle": "Cercle", + "selectByRectangle": "Rectangle", + "selectByPolygon": "Polygone", + "clear": "Effacer", + "zoomTo": "Zoomer sur", + "statistics": "Statistiques", + "createLayer": "Créer une couche", + "filterData": "Filtrer les données", + "export": "Exporter", + "exportToCsv": "Exporter en CSV", + "exportToJson": "Exporter en JSON", + "exportToGeoJson": "Exporter en GeoJSON" + }, + "statistics": { + "title": "Statistiques", + "field": "Champs", + "count": "Nombre de valeurs", + "sum": "Somme des valeurs", + "min": "Minimum", + "max": "Maximum", + "avg": "Moyenne", + "std": "Écart type" + } + }, "snapshot": { "title": "Prévisualisation de la capture de la carte", "save": "Sauver", @@ -3432,6 +3469,10 @@ "description": "Permet de créer un permalien de la ressource courante en vue", "title": "Lien permanent" }, + "Select": { + "title": "Sélectionner", + "tooltip": "Afficher l'outil de sélection" + }, "StreetView": { "title": "Street View", "description": "Outil pour parcourir les images Google Street View à partir de la carte" diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index e78b24aaade..c19033473ee 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -1528,6 +1528,43 @@ } } }, + "select": { + "title": "Selezionare", + "tooltip": "Mostra lo strumento di selezione", + "description": "Mostra lo strumento di selezione", + "hasReachMaxCount": "Numero massimo di elementi raggiunto", + "selection": "Selezione", + "allLayers": "Tutti i livelli", + "featuresCount": "Numero di entità", + "button": { + "select": "Modalità di selezione", + "chooseGeometry": "Scegliere", + "selectByPoint": "Punto", + "selectByLine": "Linea", + "selectByCircle": "Cerchio", + "selectByRectangle": "Rettangolo", + "selectByPolygon": "Poligono", + "clear": "Cancella", + "zoomTo": "Zoom su", + "statistics": "Statistiche", + "createLayer": "Crea livello", + "filterData": "Filtra dati", + "export": "Esporta", + "exportToCsv": "Esporta in CSV", + "exportToJson": "Esporta in JSON", + "exportToGeoJson": "Esporta in GeoJSON" + }, + "statistics": { + "title": "Statistiche", + "field": "Campo", + "count": "Numero di valori", + "sum": "Somma dei valori", + "min": "Minimo", + "max": "Massimo", + "avg": "Media", + "std": "Deviazione standard" + } + }, "snapshot": { "title": "Istantanea", "save": "Salva", @@ -3433,6 +3470,10 @@ "description": "Permette di creare un permalink della risorsa attualmente in vista", "title": "Permalink" }, + "Select": { + "title": "Selezionare", + "tooltip": "Mostra lo strumento di selezione" + }, "StreetView": { "title": "Street View", "description": "Strumento Street view, per visualizzare le immagini di Google Street View dalla mappa" From 8977b5b976b0bd91fade2f68550fae02c445b788 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:41:20 +0200 Subject: [PATCH 02/76] Add configs --- .../templates/configs/pluginsConfig.json | 11 +++++++++ web/client/configs/localConfig.json | 23 +++++++++++++++++++ web/client/configs/pluginsConfig.json | 11 +++++++++ web/client/configs/simple.json | 23 +++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json index 60be44cc5b0..d330878a0e1 100644 --- a/project/standard/templates/configs/pluginsConfig.json +++ b/project/standard/templates/configs/pluginsConfig.json @@ -146,6 +146,17 @@ "description": "plugins.Permalink.description", "denyUserSelection": true }, + { + "name": "Select", + "glyph": "hand-down", + "title": "plugins.Select.title", + "description": "plugins.Select.description", + "dependencies": [ + "Toolbar", + "BurgerMenu", + "SidebarMenu" + ] + }, { "name": "BackgroundSelector", "title": "plugins.BackgroundSelector.title", diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index c861e0e98da..e1b4558aa94 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -419,6 +419,29 @@ "containerPosition": "header" } }, + { + "name": "Select", + "cfg": { + "highlightOptions": { + "color": "#3388ff", + "dashArray": "", + "fillColor": "#3388ff", + "fillOpacity": 0.2, + "radius": 4, + "weight": 4 + }, + "queryOptions": { + "maxCount": -1 + }, + "selectTools": [ + "Point", + "Line", + "Circle", + "Rectangle", + "Polygon" + ] + } + }, { "name": "SecurityPopup" }, { "name": "Map", diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index a0f2a18dde7..2eebde54123 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -145,6 +145,17 @@ "title": "plugins.Permalink.title", "description": "plugins.Permalink.description" }, + { + "name": "Select", + "glyph": "hand-down", + "title": "plugins.Select.title", + "description": "plugins.Select.description", + "dependencies": [ + "Toolbar", + "BurgerMenu", + "SidebarMenu" + ] + }, { "name": "BackgroundSelector", "title": "plugins.BackgroundSelector.title", diff --git a/web/client/configs/simple.json b/web/client/configs/simple.json index 28eff31633c..c69cfcbd817 100644 --- a/web/client/configs/simple.json +++ b/web/client/configs/simple.json @@ -89,6 +89,29 @@ "zoomControl": false } }, + { + "name": "SelectExtension", + "cfg": { + "highlightOptions": { + "color": "#3388ff", + "dashArray": "", + "fillColor": "#3388ff", + "fillOpacity": 0.2, + "radius": 4, + "weight": 4 + }, + "queryOptions": { + "maxCount": -1 + }, + "selectTools": [ + "Point", + "Line", + "Circle", + "Rectangle", + "Polygon" + ] + } + }, { "name": "Help" }, From dc8f867c5559c75311fff736e8b643953cdae564 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:41:52 +0200 Subject: [PATCH 03/76] Add Select to plugins --- web/client/product/plugins.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index cbc5c76e6cd..d80259defff 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -118,6 +118,7 @@ export const plugins = { SidebarMenuPlugin: toModulePlugin('SidebarMenu', () => import(/* webpackChunkName: 'plugins/sidebarMenu' */ '../plugins/SidebarMenu')), SharePlugin: toModulePlugin('Share', () => import(/* webpackChunkName: 'plugins/share' */ '../plugins/Share')), PermalinkPlugin: toModulePlugin('Permalink', () => import(/* webpackChunkName: 'plugins/permalink' */ '../plugins/Permalink')), + Select: toModulePlugin('Select', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Select')), SnapshotPlugin: toModulePlugin('Snapshot', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Snapshot')), StreetView: toModulePlugin('StreetView', () => import(/* webpackChunkName: 'plugins/streetView' */ '../plugins/StreetView')), StyleEditor: toModulePlugin('StyleEditor', () => import(/* webpackChunkName: 'plugins/styleEditor' */ '../plugins/StyleEditor')), From 21d1a3be4706c7ae8427cdb076a324637599f619 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:42:55 +0200 Subject: [PATCH 04/76] Correction on getCQLGeometryElement --- web/client/utils/FilterUtils.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/client/utils/FilterUtils.js b/web/client/utils/FilterUtils.js index d6ff0bfe83e..22ecb8fe97f 100644 --- a/web/client/utils/FilterUtils.js +++ b/web/client/utils/FilterUtils.js @@ -781,6 +781,7 @@ export const getCQLGeometryElement = function(coordinates, type) { geometry += coordinates.join(" "); break; case "MultiPoint": + case "LineString": coordinates.forEach((position, index) => { geometry += position.join(" "); geometry += index < coordinates.length - 1 ? ", " : ""; From be98cad2fb95d7eb321fb420c045c208e250012e Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:46:35 +0200 Subject: [PATCH 05/76] Add treeHeadr to TOC and LayerTree --- web/client/plugins/TOC/components/LayersTree.jsx | 5 ++++- web/client/plugins/TOC/components/TOC.jsx | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web/client/plugins/TOC/components/LayersTree.jsx b/web/client/plugins/TOC/components/LayersTree.jsx index 94dd1040809..d0a6b677cf8 100644 --- a/web/client/plugins/TOC/components/LayersTree.jsx +++ b/web/client/plugins/TOC/components/LayersTree.jsx @@ -68,6 +68,7 @@ const loopGroupCondition = (groupNode, condition) => { * @prop {string} noFilteredResultsMsgId message id for no result on filter * @prop {object} config optional configuration available for the nodes * @prop {boolean} config.sortable activate the possibility to sort nodes + * @prop {component} treeHeader display a header on top of the layer tree */ const LayersTree = ({ tree, @@ -90,7 +91,8 @@ const LayersTree = ({ nodeToolItems, nodeContentItems, singleDefaultGroup = isSingleDefaultGroup(tree), - theme + theme, + treeHeader }) => { const containerNode = useRef(); @@ -151,6 +153,7 @@ const LayersTree = ({ event.preventDefault(); }} > + {treeHeader ?? null} {(root || []).map((node, index) => { return ( ); } @@ -140,6 +143,7 @@ export function ControlledTOC({ * @prop {boolean} config.layerOptions.hideLegend hide the legend of the layer * @prop {object} config.layerOptions.legendOptions additional options for WMS legend * @prop {boolean} config.layerOptions.hideFilter hide the filter button in the layer nodes + * @prop {component} treeHeader display a header on top of the layer tree */ function TOC({ map = { layers: [], groups: [] }, @@ -154,7 +158,8 @@ function TOC({ singleDefaultGroup, nodeItems, theme, - filterText + filterText, + treeHeader }) { const { layers } = splitMapAndLayers(map) || {}; const tree = denormalizeGroups(layers.flat || [], layers.groups || []).groups; @@ -218,6 +223,7 @@ function TOC({ nodeToolItems={nodeToolItems} nodeContentItems={nodeContentItems} singleDefaultGroup={singleDefaultGroup} + treeHeader={treeHeader} /> ); } From ca0a49445a3298f0fef8db2f99ab27c32378ef47 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:47:34 +0200 Subject: [PATCH 06/76] =?UTF-8?q?tooltip=20=E2=86=92=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/client/translations/data.de-DE.json | 2 +- web/client/translations/data.en-US.json | 2 +- web/client/translations/data.es-ES.json | 2 +- web/client/translations/data.fr-FR.json | 2 +- web/client/translations/data.it-IT.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index c4440423fad..5328975c8f2 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -3509,7 +3509,7 @@ }, "Select": { "title": "Auswählen", - "tooltip": "Auswahlwerkzeug anzeigen" + "description": "Auswahlwerkzeug anzeigen" }, "StreetView": { "title": "Street-View", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 220f35a7045..8a7dd9c67bf 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -3480,7 +3480,7 @@ }, "Select": { "title": "Select", - "tooltip": "Kartenlegende anzeigen" + "description": "Kartenlegende anzeigen" }, "StreetView": { "title": "Street View", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 644b2cb660a..f8464a49ea7 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -3470,7 +3470,7 @@ }, "Select": { "title": "Seleccionar", - "tooltip": "Mostrar la herramienta de selección" + "description": "Mostrar la herramienta de selección" }, "StreetView": { "title": "Street View", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 834c6a37871..64aa029be6a 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -3471,7 +3471,7 @@ }, "Select": { "title": "Sélectionner", - "tooltip": "Afficher l'outil de sélection" + "description": "Afficher l'outil de sélection" }, "StreetView": { "title": "Street View", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index c19033473ee..a89082f8fdd 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -3472,7 +3472,7 @@ }, "Select": { "title": "Selezionare", - "tooltip": "Mostra lo strumento di selezione" + "description": "Mostra lo strumento di selezione" }, "StreetView": { "title": "Street View", From cb7625bb31b485491497e86191a6ddf867d912d2 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:48:55 +0200 Subject: [PATCH 07/76] =?UTF-8?q?Salect=20=E2=86=92=20SelectPlugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/client/product/plugins.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index d80259defff..e97b2f0b193 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -118,7 +118,7 @@ export const plugins = { SidebarMenuPlugin: toModulePlugin('SidebarMenu', () => import(/* webpackChunkName: 'plugins/sidebarMenu' */ '../plugins/SidebarMenu')), SharePlugin: toModulePlugin('Share', () => import(/* webpackChunkName: 'plugins/share' */ '../plugins/Share')), PermalinkPlugin: toModulePlugin('Permalink', () => import(/* webpackChunkName: 'plugins/permalink' */ '../plugins/Permalink')), - Select: toModulePlugin('Select', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Select')), + SelectPlugin: toModulePlugin('Select', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Select')), SnapshotPlugin: toModulePlugin('Snapshot', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Snapshot')), StreetView: toModulePlugin('StreetView', () => import(/* webpackChunkName: 'plugins/streetView' */ '../plugins/StreetView')), StyleEditor: toModulePlugin('StyleEditor', () => import(/* webpackChunkName: 'plugins/styleEditor' */ '../plugins/StyleEditor')), From 1ea363c808cb8871f7bd89b7211884d199bfe6c3 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:59:06 +0200 Subject: [PATCH 08/76] Add select Code --- web/client/actions/select.js | 25 ++ web/client/epics/select.js | 232 ++++++++++++++++++ web/client/plugins/Select.jsx | 88 +++++++ web/client/plugins/select/assets/select.css | 40 +++ .../EllipsisButton/EllipsisButton.css | 57 +++++ .../EllipsisButton/EllipsisButton.jsx | 226 +++++++++++++++++ .../EllipsisButton/Statistics/Statistics.css | 36 +++ .../EllipsisButton/Statistics/Statistics.jsx | 75 ++++++ .../plugins/select/components/Select.jsx | 144 +++++++++++ .../components/SelectHeader/SelectHeader.css | 91 +++++++ .../components/SelectHeader/SelectHeader.jsx | 84 +++++++ web/client/reducers/select.js | 20 ++ web/client/selectors/select.js | 19 ++ web/client/utils/Select.js | 63 +++++ 14 files changed, 1200 insertions(+) create mode 100644 web/client/actions/select.js create mode 100644 web/client/epics/select.js create mode 100644 web/client/plugins/Select.jsx create mode 100644 web/client/plugins/select/assets/select.css create mode 100644 web/client/plugins/select/components/EllipsisButton/EllipsisButton.css create mode 100644 web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx create mode 100644 web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.css create mode 100644 web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.jsx create mode 100644 web/client/plugins/select/components/Select.jsx create mode 100644 web/client/plugins/select/components/SelectHeader/SelectHeader.css create mode 100644 web/client/plugins/select/components/SelectHeader/SelectHeader.jsx create mode 100644 web/client/reducers/select.js create mode 100644 web/client/selectors/select.js create mode 100644 web/client/utils/Select.js diff --git a/web/client/actions/select.js b/web/client/actions/select.js new file mode 100644 index 00000000000..5da21da1ebf --- /dev/null +++ b/web/client/actions/select.js @@ -0,0 +1,25 @@ +export const SELECT_CLEAN_SELECTION = "SELECT:CLEAN_SELECTION"; +export const SELECT_STORE_CFG = "SELECT:STORE_CFG"; +export const ADD_OR_UPDATE_SELECTION = "SELECT:ADD_OR_UPDATE_SELECTION"; + +export function cleanSelection(geomType) { + return { + type: SELECT_CLEAN_SELECTION, + geomType + }; +} + +export function storeConfiguration(cfg) { + return { + type: SELECT_STORE_CFG, + cfg + }; +} + +export function addOrUpdateSelection(layer, geoJsonData) { + return { + type: ADD_OR_UPDATE_SELECTION, + layer, + geoJsonData + }; +} diff --git a/web/client/epics/select.js b/web/client/epics/select.js new file mode 100644 index 00000000000..132b9ae17b0 --- /dev/null +++ b/web/client/epics/select.js @@ -0,0 +1,232 @@ +import { Observable } from 'rxjs'; +import axios from 'axios'; +import assign from 'object-assign'; + +import { SET_CONTROL_PROPERTY, TOGGLE_CONTROL } from '../actions/controls'; +import { UPDATE_NODE, REMOVE_NODE } from '../actions/layers'; +import { changeDrawingStatus, END_DRAWING } from '../actions/draw'; +import { registerEventListener, unRegisterEventListener} from '../actions/map'; +import { shutdownToolOnAnotherToolDrawing } from "../utils/ControlUtils"; +import { describeFeatureType, getFeatureURL } from '../api/WFS'; +import { extractGeometryAttributeName } from '../utils/WFSLayerUtils'; +import { mergeOptionsByOwner, removeAdditionalLayer } from '../actions/additionallayers'; +import { highlightStyleSelector } from '../selectors/mapInfo'; +import { layersSelector, groupsSelector } from '../selectors/layers'; +import { flattenArrayOfObjects, getInactiveNode } from '../utils/LayersUtils'; + +import { optionsToVendorParams } from '../utils/VendorParamsUtils'; +import { selectLayersSelector, isSelectEnabled, filterLayerForSelect, isSelectQueriable, getSelectQueryMaxFeatureCount, getSelectHighlightOptions } from '../selectors/select'; +import { SELECT_CLEAN_SELECTION, ADD_OR_UPDATE_SELECTION, addOrUpdateSelection } from '../actions/select'; +import { buildAdditionalLayerId, buildAdditionalLayerOwnerName, arcgisToGeoJSON, makeCrsValid, customUpdateAdditionalLayer } from '../utils/Select'; + +const queryLayer = (layer, geometry, selectQueryMaxCount) => { + switch (layer.type) { + case 'arcgis': { + const parsedGeometry = JSON.stringify({ + spatialReference: { wkid: geometry.projection.split(':')[1] }, + ...(geometry.type === 'Point' + ? { x: geometry.coordinates[0], y: geometry.coordinates[1] } + : (geometry.type === 'LineString' ? + { 'paths': [geometry.coordinates] } : + { 'rings': geometry.coordinates } + ) + ) + }); + const geometryType = geometry.type === 'Point' ? "esriGeometryPoint" : (geometry.type === 'LineString' ? 'esriGeometryPolyline' : 'esriGeometryPolygon'); + const singleLayerId = parseInt(layer.name ?? '', 10); + return Promise.all((Number.isInteger(singleLayerId) ? layer.options.layers.filter(l => l.id === singleLayerId) : layer.options.layers).map(l => axios.get(`${layer.url}/${l.id}`, { params: { f: 'json'} }) + .then(describe => + axios.get(`${layer.url}/${l.id}/query`, { + params: assign({ + f: "json", + geometry: parsedGeometry, + geometryType: geometryType, + spatialRel: "esriSpatialRelIntersects", + where: '1=1', + outFields: '*' + }, describe.data.advancedQueryCapabilities.supportsPagination && selectQueryMaxCount > -1 ? { resultRecordCount: selectQueryMaxCount } : {} + )}) + .then(response => ({ features: arcgisToGeoJSON(response.data.features, describe.data.name, response.data.fields.find(field => field.type === 'esriFieldTypeOID')?.name ?? response.data.objectIdFieldName ?? 'objectid'), crs: makeCrsValid(describe.data.sourceSpatialReference.wkid.toString()) })) + .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }) + ))) + .then(responses => responses.reduce((acc, response) => { + const features = [...acc.features, ...response.features]; + return {...acc, ...{ + features: selectQueryMaxCount > -1 && features.length > selectQueryMaxCount ? features.slice(0, selectQueryMaxCount) : features, + totalFeatures: acc.totalFeatures + response.features.length, + numberMatched: acc.numberMatched + response.features.length, + numberReturned: acc.numberReturned + response.features.length + }}; + }, { + type: "FeatureCollection", + features: [], + totalFeatures: 0, + numberMatched: 0, + numberReturned: 0, + timeStamp: new Date().toISOString(), + crs: { + type: "name", + properties: { + name: makeCrsValid(responses[0].crs.toString()) // All layer crs in a MapServer/FeatureServer are the same + } + } + })) + .catch(err => { + throw new Error(`Error while querying layer: ${err.message}`); + }) + ; + } + case 'wms': + case 'wfs': { + return describeFeatureType(layer.url, layer.name) + .then(describe => axios + .get(getFeatureURL(layer.url, layer.name, + optionsToVendorParams({ + filterObj: { + spatialField: { + operation: "INTERSECTS", + attribute: extractGeometryAttributeName(describe), + geometry: geometry + } + } + }) + ), { params: assign({ outputFormat: 'application/json' }, + selectQueryMaxCount > -1 ? { + maxFeatures: selectQueryMaxCount, // WFS v1.1.0 + count: selectQueryMaxCount + } : {} + )}) + .then(response => assign(response.data, response.data.crs === null ? {} : + { + crs: { + type: response.data.crs.type, + properties: {...response.data.crs.properties, [response.data.crs.type]: makeCrsValid(response.data.crs.properties[response.data.crs.type])} + } + }) + ) + .catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }) + ).catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }); + } + default: + return new Promise((_, reject) => reject(new Error(`Unsupported layer type: ${layer.type}`))); + } +}; + +export const openSelectEpic = (action$, store) => action$ + .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) + .filter(action => action.control === "select" && isSelectEnabled(store.getState())) + .switchMap(() => Observable.merge( + Observable.of(registerEventListener('click', 'select')), + ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: layer.visibility }))) + )); + +export const closeSelectEpics = (action$, store) => action$ + .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) + .filter(action => action.control === "select" && !isSelectEnabled(store.getState())) + .switchMap(() => Observable.merge( + Observable.of(unRegisterEventListener('click', 'select')), + Observable.of(changeDrawingStatus("clean", "", "select", [], {})), + ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: false })))) + ); + +export const tearDownSelectOnDrawToolActive = (action$, store) => shutdownToolOnAnotherToolDrawing(action$, store, 'select'); + +export const queryLayers = (action$, store) => action$ + .ofType(END_DRAWING) + .filter(action => + action.owner === 'select' && + isSelectEnabled(store.getState()) && + action.geometry + ) + .switchMap(action => { + const state = store.getState(); + const selectQueryMaxCount = getSelectQueryMaxFeatureCount(state); + return Observable.from(selectLayersSelector(state)) + .mergeMap(layer => Observable.concat( + Observable.of(addOrUpdateSelection(layer, {})), + isSelectQueriable(layer) + ? Observable.concat( + Observable.of(addOrUpdateSelection(layer, { loading: true })), + Observable.fromPromise(queryLayer(layer, action.geometry, selectQueryMaxCount)) + .map(geoJsonData => addOrUpdateSelection(layer, geoJsonData)) + .catch(error => Observable.of(addOrUpdateSelection(layer, { error }))) + ) + : Observable.empty() + )); + }); + +export const cleanSelection = (action$, store) => action$ + .ofType(SELECT_CLEAN_SELECTION) + .filter(() => isSelectEnabled(store.getState())) + .switchMap(action => Observable.merge( + Observable.of( + changeDrawingStatus( + action.geomType ? "start" : "clean", + action.geomType || "", + "select", + [], + action.geomType ? { + stopAfterDrawing: true, + editEnabled: false, + drawEnabled: false + } : {} + ) + ), + Observable.from(selectLayersSelector(store.getState())).flatMap(layer => + Observable.merge( + Observable.of(addOrUpdateSelection(layer, {})), + Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { + features: [], + visibility: false + })) + ) + ) + )); + +export const synchroniseLayersAndAdditionalLayers = (action$, store) => action$ + .filter(action => action.type === UPDATE_NODE + && isSelectEnabled(store.getState()) + && Object.hasOwn(action.options || {}, 'visibility') + ) + .concatMap(action => { + const state = store.getState(); + const layersForSelect = layersSelector(state).filter(filterLayerForSelect); + + if (layersForSelect?.find(layer => layer.id === action.node)) { + return Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(action.node), { visibility: action.options.visibility })); + } + + const groups = flattenArrayOfObjects(groupsSelector(state)); + return Observable.from(layersForSelect.filter(layer => layer.group?.startsWith(action.node))) + .mergeMap(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: !getInactiveNode(layer.group, groups) })) + ); + }); + +export const onRemoveLayer = (action$, store) => action$ + .ofType(REMOVE_NODE) + .filter(action => isSelectEnabled(store.getState()) + && action.nodeType === 'layers' + ) + .mergeMap(action => Observable.of(removeAdditionalLayer({ id: buildAdditionalLayerId(action.node), owner: buildAdditionalLayerOwnerName(action.node) }))); + + +export const onSelectionUpdate = (action$, store) => action$ + .ofType(ADD_OR_UPDATE_SELECTION) + .filter(action => isSelectEnabled(store.getState()) && action.layer) + .mergeMap(action => Observable.of(customUpdateAdditionalLayer( + action.layer.id, + action.geoJsonData.features ?? [], + action.layer.visibility && action.geoJsonData.error && !action.geoJsonData.loading, + { ...highlightStyleSelector(store.getState()), ...getSelectHighlightOptions(store.getState())} + ))); + +export default { + openSelectEpic, + closeSelectEpics, + tearDownSelectOnDrawToolActive, + queryLayers, + cleanSelection, + synchroniseLayersAndAdditionalLayers, + onRemoveLayer, + onSelectionUpdate +}; diff --git a/web/client/plugins/Select.jsx b/web/client/plugins/Select.jsx new file mode 100644 index 00000000000..956dd03e650 --- /dev/null +++ b/web/client/plugins/Select.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { get } from 'lodash'; +import { Glyphicon } from 'react-bootstrap'; + +import { createPlugin } from '../utils/PluginsUtils'; +import { layersSelector } from '../selectors/layers'; +import { updateNode, addLayer, changeLayerProperties } from '../actions/layers'; +import { zoomToExtent } from '../actions/map'; +import controls from '../reducers/controls'; +import { toggleControl } from '../actions/controls'; +import Message from '../components/I18N/Message'; + +import SelectComponent from './select/components/Select'; +import epics from '../epics/select'; +import select from '../reducers/select'; +import { storeConfiguration, cleanSelection, addOrUpdateSelection } from '../actions/select'; +import { getSelectSelections, getSelectQueryMaxFeatureCount } from '../selectors/select'; + +export default createPlugin('Select', { + component: connect( + createSelector([ + (state) => get(state, 'controls.select.enabled'), + layersSelector, + getSelectSelections, + getSelectQueryMaxFeatureCount + ], (isVisible, layers, selections, maxFeatureCount) => ({ + isVisible, + layers, + selections, + maxFeatureCount + })), + { + onClose: toggleControl.bind(null, 'select', null), + onUpdateNode: updateNode, + storeConfiguration, + cleanSelection, + addOrUpdateSelection, + zoomToExtent, + addLayer, + changeLayerProperties + } + )(SelectComponent), + options: { + disablePluginIf: "{state('router') && (state('router').endsWith('new') || state('router').includes('newgeostory') || state('router').endsWith('dashboard'))}" + }, + reducers: { + ...controls, + select + }, + epics: epics, + containers: { + BurgerMenu: { + name: 'select', + position: 1000, + priority: 2, + doNotHide: true, + text: , + tooltip: , + icon: , + action: toggleControl.bind(null, 'select', null), + toggle: true + }, + SidebarMenu: { + name: 'select', + position: 1000, + priority: 1, + doNotHide: true, + text: , + tooltip: , + icon: , + action: toggleControl.bind(null, 'select', null), + toggle: true + }, + Toolbar: { + name: 'select', + alwaysVisible: true, + position: 2, + priority: 0, + doNotHide: true, + tooltip: , + icon: , + action: toggleControl.bind(null, 'select', null), + toggle: true + } + } +}); diff --git a/web/client/plugins/select/assets/select.css b/web/client/plugins/select/assets/select.css new file mode 100644 index 00000000000..a59e6e5abd8 --- /dev/null +++ b/web/client/plugins/select/assets/select.css @@ -0,0 +1,40 @@ +.ms-resizable-modal > .modal-content.select-dialog { + top: 0vh; + right: -100vw; +} + +.select-content * .ms-node-title { + font-weight: bold; +} + +.select-content * .ms-node-header-info > .ms-node-header-addons:nth-child(3) { + flex: 1 ; + justify-content: space-between; +} + +.features-count-displayer{ + display: flex; +} + +.title-container { + display: flex; +} + +.title-icon { + height: 100%; + width: auto; + margin-right: 0.5em; +} + +.title-title { + flex-grow: 1; + text-align: center; +} + +.tree-header { + background-color: #E9EDF4; +} + +.features-count { + font-weight: bold; +} diff --git a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.css b/web/client/plugins/select/components/EllipsisButton/EllipsisButton.css new file mode 100644 index 00000000000..66025df79a5 --- /dev/null +++ b/web/client/plugins/select/components/EllipsisButton/EllipsisButton.css @@ -0,0 +1,57 @@ +.ellipsis-container { + position: relative; + display: inline-block; + opacity: 1; +} + +.ellipsis-button { + padding: 2%; + background-color: lightgray; + /* border: none; */ + border: 1px solid #ccc; + border-radius: 50%; + /* font-size: 16px; */ + font-weight: bold; + cursor: pointer; + text-align: center; + line-height: 1; +} + +.ellipsis-button:hover { + background-color: #e0e0e0; +} + +.ellipsis-menu { + position: absolute; + top: 100%; + right: 0; + background-color: white; + border: 1px solid #ccc; + border-radius: 5px; + /* margin-top: 5px; */ + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + z-index: 1; + width: 10vw; +} + +.ellipsis-menu p { + margin: 0; + padding: 5%; + cursor: pointer; +} + +.ellipsis-menu p:hover { + background-color: #f0f0f0; +} + +.export-toggle { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 5px 10px; +} + +.export-toggle span:nth-of-type(2) { + font-weight: bold; +} diff --git a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx b/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx new file mode 100644 index 00000000000..79d67c611d6 --- /dev/null +++ b/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx @@ -0,0 +1,226 @@ +import React, { useState, useEffect, useContext } from 'react'; +import ReactDOM from "react-dom"; +import bbox from '@turf/bbox'; +import { saveAs } from 'file-saver'; +import axios from 'axios'; + +import Message from '../../../../components/I18N/Message'; +import { describeFeatureType } from '../../../../api/WFS'; + +import { SelectRefContext } from '../Select'; +import Statistics from './Statistics/Statistics'; +import './EllipsisButton.css'; + +export default ({ + node = {}, + layers = [], + selectionData = {}, + onAddOrUpdateSelection = () => {}, + onZoomToExtent = () => {}, + onAddLayer = () => {}, + onChangeLayerProperties = () => {} +}) => { + const [menuOpen, setMenuOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [statisticsOpen, setStatisticsOpen] = useState(false); + const [numericFields, setNumericFields] = useState([]); + + const SelectRef = useContext(SelectRefContext); + const ellipsisContainerClass = 'ellipsis-container'; + useEffect(() => { + const selectElement = SelectRef.current?.addEventListener ? SelectRef.current : ReactDOM.findDOMNode(SelectRef.current); + if (!selectElement || !selectElement.addEventListener) { return null; } + const handleClick = e => { + if (menuOpen) { + let parentElement = e.target; + let foundThis = false; + while (!foundThis && parentElement !== e.currentTarget) { + foundThis = parentElement.className === ellipsisContainerClass; + parentElement = parentElement.parentElement; + } + if (!foundThis) { setMenuOpen(false); } + } + }; + selectElement.addEventListener("click", handleClick); + return () => selectElement.removeEventListener("click", handleClick); + }); + + const toggleMenu = () => setMenuOpen(!menuOpen); + const toggleExport = () => setExportOpen(!exportOpen); + + const triggerAction = (action) => { + switch (action) { + case 'clear': { + onAddOrUpdateSelection(node, {}); + break; + } + case 'zoomTo': { + if (selectionData.features?.length > 0) { + const extent = bbox(selectionData); + if (extent) { onZoomToExtent(extent, selectionData.crs.properties[selectionData.crs.type]); } + } + break; + } + case 'createLayer': { + if (selectionData.features?.length > 0) { + const nodeName = node.title + '_Select _'; + let index = 0; + let notFound = false; + while (!notFound) { + index++; + // eslint-disable-next-line no-loop-func + notFound = layers.findIndex(layer => layer.name === (nodeName + index.toString())) === -1; + } + onAddLayer({ + type: 'vector', + visibility: true, + name: nodeName + index.toString(), + hideLoading: true, + // bbox: { + // bounds: bbox({ + // type: "FeatureCollection", + // features: selectionData.features + // }), + // crs: node.bbox.crs + // }, + features: selectionData.features + }); + } + break; + } + case 'exportToGeoJson': { + if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData)], { type: 'application/json' }), node.title + '.json'); } + break; + } + case 'exportToJson': { + if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData.features.map(feature => feature.properties))], { type: 'application/json' }), node.title + '.json'); } + break; + } + case 'exportToCsv': { + if (selectionData.features?.length > 0) { saveAs(new Blob([Object.keys(selectionData.features[0].properties).join(',') + '\n' + selectionData.features.map(feature => Object.values(feature.properties).join(',')).join('\n')], { type: 'text/csv' }), node.title + '.csv'); } + break; + } + case 'filterData': { + const customOnChangeLayerProperties = fieldIdName => onChangeLayerProperties(node.id, { + layerFilter: { + // searchUrl: null, + // featureTypeConfigUrl: null, + // showGeneratedFilter: false, + // attributePanelExpanded: true, + // spatialPanelExpanded: false, + // crossLayerExpanded: false, + // showDetailsPanel: false, + // groupLevels: 5, + // useMapProjection: false, + // toolbarEnabled: true, + groupFields: [ + { + id: 1, + logic: 'OR', + index: 0 + } + ], + // maxFeaturesWPS: 5, + filterFields: selectionData.features.map(feature => ({ + rowId: new Date().getDate(), + groupId: 1, + attribute: fieldIdName, + operator: '=', + value: feature.properties[fieldIdName], + type: 'number', + fieldOptions: { + valuesCount: 0, + currentPage: 1 + }, + exception: null + })) + // spatialField: null, + // simpleFilterFields: [], + // map: null, + // filters: [], + // crossLayerFilter: null, + // autocompleteEnabled: true + } + }); + switch (node.type) { + case 'arcgis': { + // TODO : implement here when MapStore supports filtering for arcgis services + throw new Error(`Unsupported layer type: ${node.type}`); + // break; + } + case 'wms': + case 'wfs': { + describeFeatureType(node.url, node.name) + .then(describe => customOnChangeLayerProperties(describe.featureTypes.find(featureType => node.name.endsWith(featureType.typeName)).properties.find(property => ['xsd:string', 'xsd:int'].find(type => type === property.type) && !property.nillable && property.maxOccurs === 1 && property.minOccurs === 1).name)) + .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }); + break; + } + default: + throw new Error(`Unsupported layer type: ${node.type}`); + } + break; + } + default: + } + toggleMenu(); + }; + + useEffect(() => { + switch (node.type) { + case 'arcgis': { + const arcgisNumericFields = new Set([ 'esriFieldTypeSmallInteger', 'esriFieldTypeInteger', 'esriFieldTypeSingle', 'esriFieldTypeDouble' ]); + const singleLayerId = parseInt(node.name ?? '', 10); + Promise.all((Number.isInteger(singleLayerId) ? node.options.layers.filter(l => l.id === singleLayerId) : node.options.layers).map(l => axios.get(`${node.url}/${l.id}`, { params: { f: 'json'} }) + .then(describe => describe.data.fields.filter(field => field.domain === null && arcgisNumericFields.has(field.type)).map(field => field.name)) + .catch(() => []) + )) + .then(responses => setNumericFields(responses.map(response => response ?? []).flat())) + .catch(() => setNumericFields([])); + break; + } + case 'wms': + case 'wfs': { + describeFeatureType(node.url, node.name) + .then(describe => setNumericFields(describe.featureTypes[0].properties.filter(property => property.localType === 'number').map(property => property.name))) + .catch(() => setNumericFields([])); + break; + } + default: + } + }, []); + + return ( +
+ + {menuOpen && ( +
+

triggerAction('zoomTo')}>

+

{ toggleMenu(); selectionData.features?.length > 0 ? setStatisticsOpen(true) : null;}}>

+

triggerAction('createLayer')}>

+ {node.type !== 'arcgis' &&

triggerAction('filterData')}>

} +
+

+ + {exportOpen ? "−" : "+"} +

+ {exportOpen && ( +
+

triggerAction('exportToGeoJson')}> -

+

triggerAction('exportToJson')}> -

+

triggerAction('exportToCsv')}> -

+
+ )} +
+

triggerAction('clear')}>

+
+ )} + {statisticsOpen && } +
+ ); +}; diff --git a/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.css b/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.css new file mode 100644 index 00000000000..ff8abea2a20 --- /dev/null +++ b/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.css @@ -0,0 +1,36 @@ +.feature-statistics { + display: flex; + flex-direction: column; + padding: 1rem; + width: 100%; + } + + .select-container { + display: flex; + width: 100%; + align-items: center; + } + + .select-container label { + font-weight: bold; + margin-right: 0.5rem; + } + + .select-container select { + flex-grow: 1; + padding: 0.5rem; + border: 1px solid #ccc; + } + + .statistics-table { + width: 100%; + margin-top: 1rem; + } + + .statistics-table td { + padding: 0.5rem; + } + + .statistics-table td:first-child { + font-weight: bold; + } diff --git a/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.jsx b/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.jsx new file mode 100644 index 00000000000..0b04a81a036 --- /dev/null +++ b/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.jsx @@ -0,0 +1,75 @@ +import React, { useState, useMemo } from 'react'; + +import Message from '../../../../../components/I18N/Message'; +import Portal from '../../../../../components/misc/Portal'; +import ResizableModal from '../../../../../components/misc/ResizableModal'; + +import './Statistics.css'; + +export default ({ + fields = [], + features = [], + setStatisticsOpen = () => {} +}) => { + const [selectedField, setSelectedField] = useState(fields.length > 0 ? fields[0] : null); + + const statistics = useMemo(() => { + if (!selectedField) return null; + + const values = features.map(f => f.properties[selectedField]).filter(v => typeof v === "number"); + if (values.length === 0) return null; + + const sum = values.reduce((acc, val) => acc + val, 0); + const min = Math.min(...values); + const max = Math.max(...values); + const mean = sum / values.length; + const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length; + const stdDev = Math.sqrt(variance); + + return { count: values.length, sum, min, max, mean, stdDev }; + }, [features, selectedField]); + + return ( + + } + size="sm" + // eslint-disable-next-line react/jsx-boolean-value + show={true} + onClose={() => setStatisticsOpen(false)} + // draggable={true} + buttons={[{ + text: , + onClick: () => setStatisticsOpen(false), + bsStyle: 'primary' + }]}> +
+
+ + +
+ + {statistics && ( + + + + + + + + + +
{statistics.count}
{statistics.sum.toFixed(6)}
{statistics.min.toFixed(6)}
{statistics.max.toFixed(6)}
{statistics.mean.toFixed(6)}
{statistics.stdDev.toFixed(6)}
+ )} +
+
+
+ ); +}; diff --git a/web/client/plugins/select/components/Select.jsx b/web/client/plugins/select/components/Select.jsx new file mode 100644 index 00000000000..9bcf12dea9f --- /dev/null +++ b/web/client/plugins/select/components/Select.jsx @@ -0,0 +1,144 @@ +import React, { useEffect, createContext, useRef } from 'react'; +import { injectIntl } from 'react-intl'; +import { Glyphicon } from 'react-bootstrap'; + +import { ControlledTOC } from '../../TOC/components/TOC'; +import ResizableModal from '../../../components/misc/ResizableModal'; +import Message from '../../../components/I18N/Message'; +import VisibilityCheck from '../../TOC/components/VisibilityCheck'; +import NodeHeader from '../../TOC/components/NodeHeader'; +import { getLayerTypeGlyph } from '../../../utils/LayersUtils'; +import NodeTool from '../../TOC/components/NodeTool'; +import InlineLoader from '../../TOC/components/InlineLoader'; + +import SelectHeader from './SelectHeader/SelectHeader'; +import EllipsisButton from './EllipsisButton/EllipsisButton'; +import { isSelectQueriable, filterLayerForSelect } from '../../../selectors/select'; +import '../assets/select.css'; + +export const SelectRefContext = createContext(null); + +function applyVersionParamToLegend(layer) { + // we need to pass a parameter that invalidate the cache for GetLegendGraphic + // all layer inside the dataset viewer apply a new _v_ param each time we switch page + return { ...layer, legendParams: { ...layer?.legendParams, _v_: layer?._v_ } }; +} + +export default injectIntl(({ + layers, + onUpdateNode, + onClose, + isVisible, + highlightOptions, + queryOptions, + selectTools, + storeConfiguration, + intl, + selections, + maxFeatureCount, + cleanSelection, + addOrUpdateSelection, + zoomToExtent, + addLayer, + changeLayerProperties +}) => { + const SelectRef = useRef(null); + const filterLayers = layers.filter(filterLayerForSelect); + const customLayerNodeComponent = ({node, config}) => { + const selectionData = selections[node.id] ?? {}; + return ( +
  • + + + onUpdateNode(node.id, 'layers', { isSelectQueriable: checked })} + /> + + + } + afterTitle={ + <> + {selectionData.error ? ( + + ) : ( +
    + {selectionData.features && selectionData.features.length === maxFeatureCount && } /* tooltip={"ouech"} */ glyph="exclamation-mark"/>} +

    {selectionData.loading ? '⊙' : (selectionData.features?.length ?? 0)}

    +
    + )} + + {selectionData.features?.length > 0 && } + + } + /> +
  • + ); + }; + + useEffect(() => storeConfiguration({ highlightOptions, queryOptions }), []); + + return ( + + + icon + + + + } + dialogClassName=" select-dialog" + show={isVisible} + // eslint-disable-next-line react/jsx-boolean-value + draggable={true} + style={{zIndex: 1993}}> + + Object.fromEntries(Object.entries(applyVersionParamToLegend(layer)).filter(([key]) => key !== 'group'))).reverse()} + className="select-content" + theme="legend" + layerNodeComponent={customLayerNodeComponent} + treeHeader={ +
  • + filterLayers.forEach(layer => onUpdateNode(layer.id, 'layers', { isSelectQueriable: checked }))} + /> + } + afterTitle={} + /> +
  • + } + /> +
    +
    + ); +}); diff --git a/web/client/plugins/select/components/SelectHeader/SelectHeader.css b/web/client/plugins/select/components/SelectHeader/SelectHeader.css new file mode 100644 index 00000000000..ce73fcf9121 --- /dev/null +++ b/web/client/plugins/select/components/SelectHeader/SelectHeader.css @@ -0,0 +1,91 @@ +.select-header-container { + margin: 2%; +} + +.head-text { + font-size: small; + font-weight: bold; +} + +.select-header { + display: flex; + justify-content: space-between; + gap: 5%; +} + +.select-button-container { + position: relative; + flex: 1; + max-width: 65%; + border: none; +} + +.select-button { + /* background-color: #005232; */ + background-color: white; + border: 1px solid #F1F1F1; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 2% 5%; + /* color: white; */ + cursor: pointer; +} + +.select-button:hover { + /* background-color: #016e44; */ + background-color: #e0e0e0; +} + +.select-button-text { + flex: 1; + text-align: center; +} + +.select-button-arrow { + margin-left: auto; +} + +.select-button-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: white; + border: 1px solid #ccc; + border-radius: 5px; + margin-top: 1%; + z-index: 1; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.select-button-menu p { + padding: 2%; + margin: 0; + cursor: pointer; +} + +.select-button-menu p:hover { + background-color: #f0f0f0; +} + +.clear-select-button { + padding: 10px 15px; + /* background-color: lightgray; */ + background-color: white; + color: black; + border: 1px solid #989898; + border-radius: 5px; + cursor: pointer; +} + +.clear-select-button:hover { + background-color: #e0e0e0; +} + +.selection { + margin-bottom: 2%; + font-weight: bold; +} diff --git a/web/client/plugins/select/components/SelectHeader/SelectHeader.jsx b/web/client/plugins/select/components/SelectHeader/SelectHeader.jsx new file mode 100644 index 00000000000..a007aeb606d --- /dev/null +++ b/web/client/plugins/select/components/SelectHeader/SelectHeader.jsx @@ -0,0 +1,84 @@ +import React, { useState, useEffect, useContext } from 'react'; +import ReactDOM from "react-dom"; +import { Glyphicon } from 'react-bootstrap'; + +import Message from '../../../../components/I18N/Message'; +import InlineLoader from '../../../../plugins/TOC/components/InlineLoader'; + +import { SelectRefContext } from '../Select'; +import './SelectHeader.css'; + +export default ({ + onCleanSelect, + selectTools +}) => { + const [menuOpen, setMenuOpen] = useState(false); + const [menuClosing, setMenuClosing] = useState(false); + const [selectedTool, setSelectedTool] = useState(null); + + const selectRef = useContext(SelectRefContext); + useEffect(() => { + const selectElement = selectRef.current?.addEventListener ? selectRef.current : ReactDOM.findDOMNode(selectRef.current); + if (!selectElement || !selectElement.addEventListener) { return null; } + const handleClick = () => setMenuClosing(true); + selectElement.addEventListener("click", handleClick); + return () => selectElement.removeEventListener("click", handleClick); + }); + useEffect(() => { + if (menuClosing) { + setMenuClosing(false); + if (menuOpen) { + setTimeout(() => setMenuOpen(false), 50); // In order that onCleanSelect has the time to trigger its action. + } + } + }, [menuClosing]); + + const toggleMenu = () => setMenuOpen(!menuOpen); + + const clean = tool => { + setMenuOpen(false); + if (tool) setSelectedTool(tool); + onCleanSelect(tool?.action ?? null); + }; + + const clearSelection = () => { + clean(); + setSelectedTool(null); + }; + + const allTools = [ + { type: 'Point', action: 'Point', label: 'select.button.selectByPoint', icon: '1-point' }, + { type: 'LineString', action: 'LineString', label: 'select.button.selectByLine', icon: 'polyline' }, + { type: 'Circle', action: 'Circle', label: 'select.button.selectByCircle', icon: '1-circle' }, + { type: 'Rectangle', action: 'BBOX', label: 'select.button.selectByRectangle', icon: 'unchecked' }, + { type: 'Polygon', action: 'Polygon', label: 'select.button.selectByPolygon', icon: 'polygon' } + ]; + const availableTools = allTools.filter(tool => !Array.isArray(selectTools) || selectTools.includes(tool.type === 'LineString' ? 'Line' : tool.type)); + const orderedTools = selectedTool ? [availableTools.find(tool => tool.type === selectedTool.type), ...availableTools.filter(tool => tool.type !== selectedTool.type)] : availableTools; + + return ( +
    +
    +
    +
    + + {menuOpen && ( +
    + {orderedTools.map(tool =>

    clean(tool)}>{' '}

    )} +
    + )} +
    + +
    +   + +   +
    +
    + ); +}; diff --git a/web/client/reducers/select.js b/web/client/reducers/select.js new file mode 100644 index 00000000000..08db0c760f0 --- /dev/null +++ b/web/client/reducers/select.js @@ -0,0 +1,20 @@ +import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION } from '../actions/select'; + +export default function select(state = {cfg: {}, selections: {}}, action) { + switch (action.type) { + case SELECT_STORE_CFG: { + return { + ...state, + cfg: action.cfg + }; + } + case ADD_OR_UPDATE_SELECTION: { + return { + ...state, + selections: {...state.selections, [action.layer.id]: action.geoJsonData} + }; + } + default: + return state; + } +} diff --git a/web/client/selectors/select.js b/web/client/selectors/select.js new file mode 100644 index 00000000000..84a0cc28460 --- /dev/null +++ b/web/client/selectors/select.js @@ -0,0 +1,19 @@ +import { get } from 'lodash'; + +export const filterLayerForSelect = layer => layer && layer.group !== 'background' && ['wms', 'wfs', 'arcgis'].includes(layer.type); + +export const selectLayersSelector = state => (get(state, 'layers.flat') || []).filter(filterLayerForSelect); + +export const isSelectEnabled = state => get(state, "controls.select.enabled"); + +export const hasSelectQueriableProp = node => Object.hasOwn(node, 'isSelectQueriable'); +export const isSelectQueriable = node => hasSelectQueriableProp(node) ? node.isSelectQueriable : !!node?.visibility; + +export const getSelectObj = state => get(state, 'select') ?? {}; +export const getSelectQueryOptions = state => getSelectObj(state).cfg?.queryOptions ?? {}; +export const getSelectQueryMaxFeatureCount = state => { + const queryOptions = getSelectQueryOptions(state); + return Number.isInteger(queryOptions.maxCount) ? queryOptions.maxCount : -1; +}; +export const getSelectHighlightOptions = state => getSelectObj(state).cfg?.highlightOptions ?? {}; +export const getSelectSelections = state => getSelectObj(state).selections ?? {}; diff --git a/web/client/utils/Select.js b/web/client/utils/Select.js new file mode 100644 index 00000000000..2212f19eaf6 --- /dev/null +++ b/web/client/utils/Select.js @@ -0,0 +1,63 @@ +import { updateAdditionalLayer } from '../actions/additionallayers'; +import { applyMapInfoStyle } from '../selectors/mapInfo'; + +export const buildAdditionalLayerName = layerId => `"highlight-select-${layerId}-features"`; +export const buildAdditionalLayerOwnerName = layerId => `Select_${layerId}`; +export const buildAdditionalLayerId = layerId => `${buildAdditionalLayerOwnerName(layerId)}_id`; + +function getGeometryType(geometry) { + if (geometry.x !== undefined && geometry.y !== undefined) { + return "Point"; + } else if (geometry.paths) { + return "LineString"; + } else if (geometry.rings) { + return "Polygon"; + } + return null; +} + +function convertCoordinates(geometry) { + if (geometry.x !== undefined && geometry.y !== undefined) { + return [geometry.x, geometry.y]; + } else if (geometry.paths) { + return geometry.paths[0]; + } else if (geometry.rings) { + return geometry.rings; + } + return null; +} + +export const makeCrsValid = crs => { + const crsSplit = crs.toString().split(':'); + const crsSplitLength = crsSplit.length; + if (crsSplitLength === 1) { + return 'EPSG' + ':' + crsSplit[0]; + } else if (crsSplitLength > 1) { + const geodeticIndex = crsSplit.lastIndexOf(s => s.length > 0, crsSplitLength - 2); + return (geodeticIndex > -1 ? crsSplit[geodeticIndex] : 'EPSG') + ':' + crsSplit[crsSplitLength - 1]; + } + return 'EPSG:4326'; +}; + +export const arcgisToGeoJSON = (arcgisFeatures, layerName, idField) => arcgisFeatures.map(feature => ({ + type: "Feature", + id: `${layerName}.${feature.attributes[idField]}`, + geometry_name: "geometry", + geometry: { + type: getGeometryType(feature.geometry), + coordinates: convertCoordinates(feature.geometry) + }, + properties: feature.attributes +})); + +export const customUpdateAdditionalLayer = (layerId, features, isVisible, highlightStyle) => updateAdditionalLayer( + buildAdditionalLayerId(layerId), + buildAdditionalLayerOwnerName(layerId), + "overlay", + { + type: "vector", + name: buildAdditionalLayerName(layerId), + visibility: isVisible, + features: features.map(applyMapInfoStyle(highlightStyle)) + } +); From e23cb1ebf68313a099b065e1c80e004104a5909b Mon Sep 17 00:00:00 2001 From: pln Date: Fri, 6 Jun 2025 10:40:55 +0200 Subject: [PATCH 09/76] =?UTF-8?q?Select=20=E2=86=92=20LayersSelection=20in?= =?UTF-8?q?=20translations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/client/translations/data.de-DE.json | 4 ++-- web/client/translations/data.en-US.json | 6 +++--- web/client/translations/data.es-ES.json | 4 ++-- web/client/translations/data.fr-FR.json | 4 ++-- web/client/translations/data.it-IT.json | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 5328975c8f2..584a1872d34 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -1567,7 +1567,7 @@ } } }, - "select": { + "layersSelection": { "title": "Auswählen", "tooltip": "Auswahlwerkzeug anzeigen", "description": "Auswahlwerkzeug anzeigen", @@ -3507,7 +3507,7 @@ "description": "Ermöglicht das Erstellen eines Permalinks der aktuell angezeigten Ressource", "title": "Permalink" }, - "Select": { + "LayersSelection": { "title": "Auswählen", "description": "Auswahlwerkzeug anzeigen" }, diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 8a7dd9c67bf..d501f8c227c 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -1528,7 +1528,7 @@ } } }, - "select": { + "layersSelection": { "title": "Select", "tooltip": "Display the selection tool", "description": "Display the selection tool", @@ -3478,9 +3478,9 @@ "description": "Allows to create a permalink of the current resource in view", "title": "Permalink" }, - "Select": { + "LayersSelection": { "title": "Select", - "description": "Kartenlegende anzeigen" + "description": "Display the selection tool" }, "StreetView": { "title": "Street View", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index f8464a49ea7..6726d51763f 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -1528,7 +1528,7 @@ } } }, - "select": { + "layersSelection": { "title": "Seleccionar", "tooltip": "Mostrar la herramienta de selección", "description": "Mostrar la herramienta de selección", @@ -3468,7 +3468,7 @@ "description": "Permite crear un enlace permanente del recurso actual a la vista", "title": "Enlace permanente" }, - "Select": { + "LayersSelection": { "title": "Seleccionar", "description": "Mostrar la herramienta de selección" }, diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 64aa029be6a..a1ab8d5d6c6 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -1529,7 +1529,7 @@ } } }, - "select": { + "layersSelection": { "title": "Sélectionner", "tooltip": "Afficher l'outil de sélection", "description": "Afficher l'outil de sélection", @@ -3469,7 +3469,7 @@ "description": "Permet de créer un permalien de la ressource courante en vue", "title": "Lien permanent" }, - "Select": { + "LayersSelection": { "title": "Sélectionner", "description": "Afficher l'outil de sélection" }, diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index a89082f8fdd..7fdb846d393 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -1528,7 +1528,7 @@ } } }, - "select": { + "layersSelection": { "title": "Selezionare", "tooltip": "Mostra lo strumento di selezione", "description": "Mostra lo strumento di selezione", @@ -3470,7 +3470,7 @@ "description": "Permette di creare un permalink della risorsa attualmente in vista", "title": "Permalink" }, - "Select": { + "LayersSelection": { "title": "Selezionare", "description": "Mostra lo strumento di selezione" }, From 2183893f053ec2d40a6bb20271e4e35517ba8a1b Mon Sep 17 00:00:00 2001 From: pln Date: Fri, 6 Jun 2025 10:43:20 +0200 Subject: [PATCH 10/76] =?UTF-8?q?Select=20=E2=86=92=20LayersSelection=20fo?= =?UTF-8?q?r=20configs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- project/standard/templates/configs/pluginsConfig.json | 6 +++--- web/client/configs/localConfig.json | 2 +- web/client/configs/pluginsConfig.json | 6 +++--- web/client/configs/simple.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json index d330878a0e1..3113d7b7735 100644 --- a/project/standard/templates/configs/pluginsConfig.json +++ b/project/standard/templates/configs/pluginsConfig.json @@ -147,10 +147,10 @@ "denyUserSelection": true }, { - "name": "Select", + "name": "LayersSelection", "glyph": "hand-down", - "title": "plugins.Select.title", - "description": "plugins.Select.description", + "title": "plugins.LayersSelection.title", + "description": "plugins.LayersSelection.description", "dependencies": [ "Toolbar", "BurgerMenu", diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index e1b4558aa94..97969cb8f7b 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -420,7 +420,7 @@ } }, { - "name": "Select", + "name": "LayersSelection", "cfg": { "highlightOptions": { "color": "#3388ff", diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index 2eebde54123..610d8880c44 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -146,10 +146,10 @@ "description": "plugins.Permalink.description" }, { - "name": "Select", + "name": "LayersSelection", "glyph": "hand-down", - "title": "plugins.Select.title", - "description": "plugins.Select.description", + "title": "plugins.LayersSelection.title", + "description": "plugins.LayersSelection.description", "dependencies": [ "Toolbar", "BurgerMenu", diff --git a/web/client/configs/simple.json b/web/client/configs/simple.json index c69cfcbd817..b1f942ffca7 100644 --- a/web/client/configs/simple.json +++ b/web/client/configs/simple.json @@ -90,7 +90,7 @@ } }, { - "name": "SelectExtension", + "name": "LayersSelection", "cfg": { "highlightOptions": { "color": "#3388ff", From 6b790cbf04cb8893fd5c83d7d977d149c603d79a Mon Sep 17 00:00:00 2001 From: pln Date: Fri, 6 Jun 2025 10:43:55 +0200 Subject: [PATCH 11/76] =?UTF-8?q?Select=20=E2=86=92=20LayersSelection=20fo?= =?UTF-8?q?r=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/client/product/plugins.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index e97b2f0b193..6aea7071814 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -87,6 +87,7 @@ export const plugins = { LanguagePlugin: toModulePlugin('Language', () => import(/* webpackChunkName: 'plugins/language' */ '../plugins/Language')), LayerDownload: toModulePlugin('LayerDownload', () => import(/* webpackChunkName: 'plugins/layerDownload' */ '../plugins/LayerDownload')), LayerInfoPlugin: toModulePlugin('LayerInfo', () => import(/* webpackChunkName: 'plugins/layerInfo' */ '../plugins/LayerInfo')), + LayersSelectionPlugin: toModulePlugin('LayersSelection', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/LayersSelection')), LocatePlugin: toModulePlugin('Locate', () => import(/* webpackChunkName: 'plugins/locate' */ '../plugins/Locate')), LongitudinalProfileToolPlugin: toModulePlugin('LongitudinalProfileTool', () => import(/* webpackChunkName: 'plugins/LongitudinalProfileTool' */ '../plugins/LongitudinalProfileTool')), ManagerMenuPlugin: toModulePlugin('ManagerMenu', () => import(/* webpackChunkName: 'plugins/managerMenu' */ '../plugins/manager/ManagerMenu')), @@ -118,7 +119,6 @@ export const plugins = { SidebarMenuPlugin: toModulePlugin('SidebarMenu', () => import(/* webpackChunkName: 'plugins/sidebarMenu' */ '../plugins/SidebarMenu')), SharePlugin: toModulePlugin('Share', () => import(/* webpackChunkName: 'plugins/share' */ '../plugins/Share')), PermalinkPlugin: toModulePlugin('Permalink', () => import(/* webpackChunkName: 'plugins/permalink' */ '../plugins/Permalink')), - SelectPlugin: toModulePlugin('Select', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Select')), SnapshotPlugin: toModulePlugin('Snapshot', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Snapshot')), StreetView: toModulePlugin('StreetView', () => import(/* webpackChunkName: 'plugins/streetView' */ '../plugins/StreetView')), StyleEditor: toModulePlugin('StyleEditor', () => import(/* webpackChunkName: 'plugins/styleEditor' */ '../plugins/StyleEditor')), From bb840f7b2692ea9afc7161aa3f9383885ad29710 Mon Sep 17 00:00:00 2001 From: pln Date: Fri, 6 Jun 2025 10:44:17 +0200 Subject: [PATCH 12/76] =?UTF-8?q?Select=20=E2=86=92=20LayersSelection=20fo?= =?UTF-8?q?r=20the=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{Select.jsx => LayersSelection.jsx} | 20 ++++++------- .../actions/layersSelection.js} | 0 .../assets/select.css | 0 .../EllipsisButton/EllipsisButton.css | 0 .../EllipsisButton/EllipsisButton.jsx | 20 ++++++------- .../EllipsisButton/Statistics/Statistics.css | 0 .../EllipsisButton/Statistics/Statistics.jsx | 16 +++++----- .../components/LayersSelection.jsx} | 10 +++---- .../LayersSelectionHeader.css} | 0 .../LayersSelectionHeader.jsx} | 14 ++++----- .../layersSelection/epics/layersSelection.js} | 30 +++++++++---------- .../reducers/layersSelection.js} | 2 +- .../selectors/layersSelection.js} | 0 .../layersSelection/utils/LayersSelection.js} | 4 +-- 14 files changed, 58 insertions(+), 58 deletions(-) rename web/client/plugins/{Select.jsx => LayersSelection.jsx} (78%) rename web/client/{actions/select.js => plugins/layersSelection/actions/layersSelection.js} (100%) rename web/client/plugins/{select => layersSelection}/assets/select.css (100%) rename web/client/plugins/{select => layersSelection}/components/EllipsisButton/EllipsisButton.css (100%) rename web/client/plugins/{select => layersSelection}/components/EllipsisButton/EllipsisButton.jsx (91%) rename web/client/plugins/{select => layersSelection}/components/EllipsisButton/Statistics/Statistics.css (100%) rename web/client/plugins/{select => layersSelection}/components/EllipsisButton/Statistics/Statistics.jsx (71%) rename web/client/plugins/{select/components/Select.jsx => layersSelection/components/LayersSelection.jsx} (95%) rename web/client/plugins/{select/components/SelectHeader/SelectHeader.css => layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css} (100%) rename web/client/plugins/{select/components/SelectHeader/SelectHeader.jsx => layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx} (86%) rename web/client/{epics/select.js => plugins/layersSelection/epics/layersSelection.js} (89%) rename web/client/{reducers/select.js => plugins/layersSelection/reducers/layersSelection.js} (91%) rename web/client/{selectors/select.js => plugins/layersSelection/selectors/layersSelection.js} (100%) rename web/client/{utils/Select.js => plugins/layersSelection/utils/LayersSelection.js} (91%) diff --git a/web/client/plugins/Select.jsx b/web/client/plugins/LayersSelection.jsx similarity index 78% rename from web/client/plugins/Select.jsx rename to web/client/plugins/LayersSelection.jsx index 956dd03e650..17659e6a1db 100644 --- a/web/client/plugins/Select.jsx +++ b/web/client/plugins/LayersSelection.jsx @@ -12,11 +12,11 @@ import controls from '../reducers/controls'; import { toggleControl } from '../actions/controls'; import Message from '../components/I18N/Message'; -import SelectComponent from './select/components/Select'; -import epics from '../epics/select'; -import select from '../reducers/select'; -import { storeConfiguration, cleanSelection, addOrUpdateSelection } from '../actions/select'; -import { getSelectSelections, getSelectQueryMaxFeatureCount } from '../selectors/select'; +import SelectComponent from './layersSelection/components/LayersSelection'; +import epics from './layersSelection/epics/layersSelection'; +import select from './layersSelection/reducers/layersSelection'; +import { storeConfiguration, cleanSelection, addOrUpdateSelection } from './layersSelection/actions/layersSelection'; +import { getSelectSelections, getSelectQueryMaxFeatureCount } from './layersSelection/selectors/layersSelection'; export default createPlugin('Select', { component: connect( @@ -56,8 +56,8 @@ export default createPlugin('Select', { position: 1000, priority: 2, doNotHide: true, - text: , - tooltip: , + text: , + tooltip: , icon: , action: toggleControl.bind(null, 'select', null), toggle: true @@ -67,8 +67,8 @@ export default createPlugin('Select', { position: 1000, priority: 1, doNotHide: true, - text: , - tooltip: , + text: , + tooltip: , icon: , action: toggleControl.bind(null, 'select', null), toggle: true @@ -79,7 +79,7 @@ export default createPlugin('Select', { position: 2, priority: 0, doNotHide: true, - tooltip: , + tooltip: , icon: , action: toggleControl.bind(null, 'select', null), toggle: true diff --git a/web/client/actions/select.js b/web/client/plugins/layersSelection/actions/layersSelection.js similarity index 100% rename from web/client/actions/select.js rename to web/client/plugins/layersSelection/actions/layersSelection.js diff --git a/web/client/plugins/select/assets/select.css b/web/client/plugins/layersSelection/assets/select.css similarity index 100% rename from web/client/plugins/select/assets/select.css rename to web/client/plugins/layersSelection/assets/select.css diff --git a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.css b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.css similarity index 100% rename from web/client/plugins/select/components/EllipsisButton/EllipsisButton.css rename to web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.css diff --git a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx similarity index 91% rename from web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx rename to web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx index 79d67c611d6..af4985c126a 100644 --- a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx +++ b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx @@ -7,7 +7,7 @@ import axios from 'axios'; import Message from '../../../../components/I18N/Message'; import { describeFeatureType } from '../../../../api/WFS'; -import { SelectRefContext } from '../Select'; +import { SelectRefContext } from '../LayersSelection'; import Statistics from './Statistics/Statistics'; import './EllipsisButton.css'; @@ -196,24 +196,24 @@ export default ({ {menuOpen && (
    -

    triggerAction('zoomTo')}>

    -

    { toggleMenu(); selectionData.features?.length > 0 ? setStatisticsOpen(true) : null;}}>

    -

    triggerAction('createLayer')}>

    - {node.type !== 'arcgis' &&

    triggerAction('filterData')}>

    } +

    triggerAction('zoomTo')}>

    +

    { toggleMenu(); selectionData.features?.length > 0 ? setStatisticsOpen(true) : null;}}>

    +

    triggerAction('createLayer')}>

    + {node.type !== 'arcgis' &&

    triggerAction('filterData')}>

    }

    - + {exportOpen ? "−" : "+"}

    {exportOpen && (
    -

    triggerAction('exportToGeoJson')}> -

    -

    triggerAction('exportToJson')}> -

    -

    triggerAction('exportToCsv')}> -

    +

    triggerAction('exportToGeoJson')}> -

    +

    triggerAction('exportToJson')}> -

    +

    triggerAction('exportToCsv')}> -

    )}
    -

    triggerAction('clear')}>

    +

    triggerAction('clear')}>

    )} {statisticsOpen && } + title={} size="sm" // eslint-disable-next-line react/jsx-boolean-value show={true} @@ -46,7 +46,7 @@ export default ({ }]}>
    - + setSelectedField(e.target.value)} - > - {fields.map((field) => ( ))} - -
    - - {statistics && ( - - - - - - - - - -
    {statistics.count}
    {statistics.sum.toFixed(6)}
    {statistics.min.toFixed(6)}
    {statistics.max.toFixed(6)}
    {statistics.mean.toFixed(6)}
    {statistics.stdDev.toFixed(6)}
    - )} -
    -
    - - ); -}; +import React, { useState, useMemo } from 'react'; + +import Message from '../../../../../components/I18N/Message'; +import Portal from '../../../../../components/misc/Portal'; +import ResizableModal from '../../../../../components/misc/ResizableModal'; + +/** + * A modal component that displays basic statistical calculations + * (count, sum, min, max, mean, standard deviation) + * for a selected numeric field from a list of features. + * + * @param {Object} props - Component props. + * @param {string[]} props.fields - List of available field names. + * @param {Object[]} props.features - List of GeoJSON features. + * @param {Function} props.setStatisticsOpen - Callback to close the statistics modal. + * @returns {JSX.Element} The rendered statistics modal. + */ +export default ({ + fields = [], + features = [], + setStatisticsOpen = () => {} +}) => { + const [selectedField, setSelectedField] = useState(fields.length > 0 ? fields[0] : null); + + const statistics = useMemo(() => { + if (!selectedField) return null; + + const values = features.map(f => f.properties[selectedField]).filter(v => typeof v === "number"); + if (values.length === 0) return null; + + const sum = values.reduce((acc, val) => acc + val, 0); + const min = Math.min(...values); + const max = Math.max(...values); + const mean = sum / values.length; + const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length; + const stdDev = Math.sqrt(variance); + + return { count: values.length, sum, min, max, mean, stdDev }; + }, [features, selectedField]); + + return ( + + } + size="sm" + show + onClose={() => setStatisticsOpen(false)} + // draggable={true} + buttons={[{ + text: , + onClick: () => setStatisticsOpen(false), + bsStyle: 'primary' + }]}> +
    +
    + + +
    + + {statistics && ( + + + + + + + + + +
    {statistics.count}
    {statistics.sum.toFixed(6)}
    {statistics.min.toFixed(6)}
    {statistics.max.toFixed(6)}
    {statistics.mean.toFixed(6)}
    {statistics.stdDev.toFixed(6)}
    + )} +
    +
    +
    + ); +}; diff --git a/web/client/plugins/layersSelection/index.js b/web/client/plugins/layersSelection/index.js index fe19e34d6c5..e616129b432 100644 --- a/web/client/plugins/layersSelection/index.js +++ b/web/client/plugins/layersSelection/index.js @@ -17,7 +17,6 @@ import epics from './epics/layersSelection'; import select from './reducers/layersSelection'; import { storeConfiguration, cleanSelection, addOrUpdateSelection } from './actions/layersSelection'; import { getSelectSelections, getSelectQueryMaxFeatureCount } from './selectors/layersSelection'; -import './layer-selection.less'; /** * Select plugin that enables layer feature selection in the map. diff --git a/web/client/plugins/layersSelection/layer-selection.less b/web/client/plugins/layersSelection/layer-selection.less deleted file mode 100644 index 9a81bbe976b..00000000000 --- a/web/client/plugins/layersSelection/layer-selection.less +++ /dev/null @@ -1,87 +0,0 @@ -@import '../../themes/default/variables.less'; -@import './assets/select.less'; -@import './components/EllipsisButton/EllipsisButton.less'; - -.select-header-container { - margin: 2%; -} - -.head-text { - color : @ms-tray-color; - font-weight: bold; -} - -.select-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 0 0 0; -} - -.select-button-container { - position: relative; - flex: 1; - max-width: 65%; - border: none; -} - - -.select-button { - background-color: @ms-main-bg; - border: 1px solid @ms-main-border-color; - border-radius: 5px; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 3px; - cursor: pointer; -} - -.select-button:hover { - background-color: @ms-main-hover-bg; -} - -.select-button-text { - flex: 1; - text-align: center; -} - -.select-button-arrow { - margin-left: auto; -} - -.select-button-menu { - background-color: @ms-main-bg; - position: absolute; - top: 100%; - left: 0; - right: 0; - border: 1px solid @ms-main-border-color; - border-radius: 5px; - margin-top: 1%; - z-index: 1; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.6); -} - -.select-button-menu p { - padding: 2%; - margin: 0; - cursor: pointer; -} - -.select-button-menu p:hover { - background-color: @ms-main-hover-bg; -} - -.clear-select-button { - background-color: @ms-main-bg; - border: 1px solid @ms-main-border-color; - color: black; - border-radius: 5px; - cursor: pointer; -} - -.clear-select-button:hover { - background-color: @ms-main-hover-bg; -} diff --git a/web/client/themes/default/less/layer-selection.less b/web/client/themes/default/less/layer-selection.less new file mode 100644 index 00000000000..fb06eea7620 --- /dev/null +++ b/web/client/themes/default/less/layer-selection.less @@ -0,0 +1,231 @@ +// @import '../../themes/default/variables.less'; +// @import './assets/select.less'; +// @import './components/EllipsisButton/EllipsisButton.less'; + +// ************** +// Theme +// ************** +#ms-components-theme(@theme-vars) { + .select-button { + .background-color-var(@theme-vars[main-bg]); + .border-color-var(@theme-vars[main-border-color]); + &:hover { + .background-color-var(@theme-vars[main-hover-bg]); + } + } + + .select-button-menu, + .ellipsis-menu { + .background-color-var(@theme-vars[main-bg]); + + p:hover { + .background-color-var(@theme-vars[main-hover-bg]); + } + } + + .clear-select-button, + .ellipsis-button { + .background-color-var(@theme-vars[main-bg]); + .border-color-var(@theme-vars[main-border-color]); + + &:hover { + .background-color-var(@theme-vars[main-hover-bg]); + } + } + + + +} + +// ************** +// Layout +// ************** + +.select-header-container { + margin: 2%; +} + +.select-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0 0 0; +} + +.select-button-container { + position: relative; + flex: 1; + max-width: 65%; + border: none; +} + + +.select-button { + border: 1px solid; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 3px; + cursor: pointer; +} + +.select-button-text { + flex: 1; + text-align: center; +} + +.select-button-arrow { + margin-left: auto; +} + +.select-button-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + border-radius: 5px; + margin-top: 1%; + z-index: 1; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.6); +} + +.select-button-menu p { + padding: 2%; + margin: 0; + cursor: pointer; +} + + +.clear-select-button { + border: 1px solid; + border-radius: 5px; + cursor: pointer; +} + + +// ************** +// elipsis button +// ************** +.ellipsis-container { + position: relative; + display: inline-block; + opacity: 1; +} + +.ellipsis-button { + padding: 2%; + border: 1px solid; + border-radius: 5px; + font-weight: bold; + cursor: pointer; + text-align: center; + line-height: 1; +} + + +.ellipsis-menu { + border: 1px solid; + position: absolute; + top: 100%; + right: 0; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); + z-index: 1; + width: 10vw; +} + +.ellipsis-menu p { + margin: 0; + padding: 5%; + cursor: pointer; +} + +.export-toggle { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 5px 10px; +} + +.export-toggle span:nth-of-type(2) { + font-weight: bold; +} + +/** +SELECT LESS +*/ +.ms-resizable-modal>.modal-content.select-dialog { + top: 0vh; + right: -100vw; +} + + +.select-content * .ms-node-header-info>.ms-node-header-addons:nth-child(3) { + flex: 1; + justify-content: space-between; +} + +.features-count-displayer { + display: flex; +} + +.title-container { + display: flex; +} + +.title-icon { + height: 100%; + width: auto; + margin-right: 0.5em; +} + +.title-title { + flex-grow: 1; + text-align: center; +} + +.features-count { + font-weight: bold; +} + + +/*Statistics*/ +.feature-statistics { + display: flex; + flex-direction: column; + padding: 1rem; + width: 100%; +} + +.select-container { + display: flex; + width: 100%; + align-items: center; +} + +.select-container label { + font-weight: bold; + margin-right: 0.5rem; +} + +.select-container select { + flex-grow: 1; + padding: 0.5rem; + border: 1px solid #ccc; +} + +.statistics-table { + width: 100%; + margin-top: 1rem; +} + +.statistics-table td { + padding: 0.5rem; +} + +.statistics-table td:first-child { + font-weight: bold; +} diff --git a/web/client/themes/default/less/mapstore.less b/web/client/themes/default/less/mapstore.less index 2a0576a4aad..e2d6077bb5e 100644 --- a/web/client/themes/default/less/mapstore.less +++ b/web/client/themes/default/less/mapstore.less @@ -91,3 +91,4 @@ @import "map-popup.less"; @import "map-views.less"; @import "permalink.less"; +@import "layer-selection.less"; From b590f45138b309dc158dd7cae83bcaf30df93b4d Mon Sep 17 00:00:00 2001 From: Benjamin Grenard Date: Fri, 28 Nov 2025 10:53:33 +0100 Subject: [PATCH 28/76] clena code --- web/client/themes/default/less/layer-selection.less | 1 + 1 file changed, 1 insertion(+) diff --git a/web/client/themes/default/less/layer-selection.less b/web/client/themes/default/less/layer-selection.less index fb06eea7620..5daa4f5ef3b 100644 --- a/web/client/themes/default/less/layer-selection.less +++ b/web/client/themes/default/less/layer-selection.less @@ -9,6 +9,7 @@ .select-button { .background-color-var(@theme-vars[main-bg]); .border-color-var(@theme-vars[main-border-color]); + &:hover { .background-color-var(@theme-vars[main-hover-bg]); } From e7c0394724a55f556e6e2d04d982d66998a89956 Mon Sep 17 00:00:00 2001 From: Benjamin Grenard Date: Fri, 28 Nov 2025 14:05:13 +0100 Subject: [PATCH 29/76] rename reducer as layerSelection --- .../LayersSelectionHeader.css | 87 --- .../LayersSelectionHeader.jsx | 1 - .../layersSelection/epics/layersSelection.js | 596 +++++++++--------- web/client/plugins/layersSelection/index.js | 22 +- .../reducers/layersSelection.js | 60 +- .../selectors/layersSelection.js | 176 +++--- 6 files changed, 426 insertions(+), 516 deletions(-) delete mode 100644 web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css diff --git a/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css b/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css deleted file mode 100644 index ac325dc16e4..00000000000 --- a/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css +++ /dev/null @@ -1,87 +0,0 @@ -/* .select-header-container { - margin: 2%; -} - -.head-text { - font-size: small; - font-weight: bold; -} - -.select-header { - display: flex; - justify-content: space-between; - gap: 5%; -} - -.select-button-container { - position: relative; - flex: 1; - max-width: 65%; - border: none; -} - -.select-button { - background-color: white; - border: 1px solid #F1F1F1; - border-radius: 5px; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 2% 5%; - cursor: pointer; -} - -.select-button:hover { - background-color: #e0e0e0; -} - -.select-button-text { - flex: 1; - text-align: center; -} - -.select-button-arrow { - margin-left: auto; -} - -.select-button-menu { - position: absolute; - top: 100%; - left: 0; - right: 0; - background-color: white; - border: 1px solid #ccc; - border-radius: 5px; - margin-top: 1%; - z-index: 1; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); -} - -.select-button-menu p { - padding: 2%; - margin: 0; - cursor: pointer; -} - -.select-button-menu p:hover { - background-color: #f0f0f0; -} - -.clear-select-button { - padding: 10px 15px; - background-color: white; - color: black; - border: 1px solid #989898; - border-radius: 5px; - cursor: pointer; -} - -.clear-select-button:hover { - background-color: #e0e0e0; -} - -.selection { - margin-bottom: 2%; - font-weight: bold; -} */ diff --git a/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx b/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx index f6b78ab1a17..b2118a9ec83 100644 --- a/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx +++ b/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx @@ -6,7 +6,6 @@ import Message from '../../../../components/I18N/Message'; import InlineLoader from '../../../TOC/components/InlineLoader'; import { SelectRefContext } from '../LayersSelection'; -import './LayersSelectionHeader.css'; /** * LayersSelectionHeader provides a toolbar for selecting geometry-based diff --git a/web/client/plugins/layersSelection/epics/layersSelection.js b/web/client/plugins/layersSelection/epics/layersSelection.js index aa52559f4b8..7db91ea0d3e 100644 --- a/web/client/plugins/layersSelection/epics/layersSelection.js +++ b/web/client/plugins/layersSelection/epics/layersSelection.js @@ -1,298 +1,298 @@ -import { Observable } from 'rxjs'; -import axios from 'axios'; -import assign from 'object-assign'; - -import { SET_CONTROL_PROPERTY, TOGGLE_CONTROL } from '../../../actions/controls'; -import { UPDATE_NODE, REMOVE_NODE } from '../../../actions/layers'; -import { changeDrawingStatus, END_DRAWING } from '../../../actions/draw'; -import { registerEventListener, unRegisterEventListener} from '../../../actions/map'; -import { shutdownToolOnAnotherToolDrawing } from "../../../utils/ControlUtils"; -import { describeFeatureType, getFeatureURL } from '../../../api/WFS'; -import { extractGeometryAttributeName } from '../../../utils/WFSLayerUtils'; -import { mergeOptionsByOwner, removeAdditionalLayer } from '../../../actions/additionallayers'; -import { highlightStyleSelector } from '../../../selectors/mapInfo'; -import { layersSelector, groupsSelector } from '../../../selectors/layers'; -import { flattenArrayOfObjects, getInactiveNode } from '../../../utils/LayersUtils'; - -import { optionsToVendorParams } from '../../../utils/VendorParamsUtils'; -import { selectLayersSelector, isSelectEnabled, filterLayerForSelect, isSelectQueriable, getSelectQueryMaxFeatureCount, getSelectHighlightOptions } from '../selectors/layersSelection'; -import { SELECT_CLEAN_SELECTION, ADD_OR_UPDATE_SELECTION, addOrUpdateSelection } from '../actions/layersSelection'; -import { buildAdditionalLayerId, buildAdditionalLayerOwnerName, arcgisToGeoJSON, makeCrsValid, customUpdateAdditionalLayer } from '../utils/LayersSelection'; - -/** - * Queries a given layer based on geometry and type (ArcGIS, WMS, or WFS). - * - * @param {Object} layer - Layer configuration object. - * @param {Object} geometry - Geometry used for spatial filtering. - * @param {number} selectQueryMaxCount - Max features to return. - * @returns {Promise} A Promise resolving to a GeoJSON FeatureCollection. - */ -const queryLayer = (layer, geometry, selectQueryMaxCount) => { - switch (layer.type) { - case 'arcgis': { - const parsedGeometry = JSON.stringify({ - spatialReference: { wkid: geometry.projection.split(':')[1] }, - ...(geometry.type === 'Point' - ? { x: geometry.coordinates[0], y: geometry.coordinates[1] } - : (geometry.type === 'LineString' ? - { 'paths': [geometry.coordinates] } : - { 'rings': geometry.coordinates } - ) - ) - }); - const geometryType = geometry.type === 'Point' ? "esriGeometryPoint" : (geometry.type === 'LineString' ? 'esriGeometryPolyline' : 'esriGeometryPolygon'); - const singleLayerId = parseInt(layer.name ?? '', 10); - return Promise.all((Number.isInteger(singleLayerId) ? layer.options.layers.filter(l => l.id === singleLayerId) : layer.options.layers).map(l => axios.get(`${layer.url}/${l.id}`, { params: { f: 'json'} }) - .then(describe => - axios.get(`${layer.url}/${l.id}/query`, { - params: assign({ - f: "json", - geometry: parsedGeometry, - geometryType: geometryType, - spatialRel: "esriSpatialRelIntersects", - where: '1=1', - outFields: '*' - }, describe.data.advancedQueryCapabilities.supportsPagination && selectQueryMaxCount > -1 ? { resultRecordCount: selectQueryMaxCount } : {} - )}) - .then(response => ({ features: arcgisToGeoJSON(response.data.features, describe.data.name, response.data.fields.find(field => field.type === 'esriFieldTypeOID')?.name ?? response.data.objectIdFieldName ?? 'objectid'), crs: makeCrsValid(describe.data.sourceSpatialReference.wkid.toString()) })) - .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }) - ))) - .then(responses => responses.reduce((acc, response) => { - const features = [...acc.features, ...response.features]; - return {...acc, ...{ - features: selectQueryMaxCount > -1 && features.length > selectQueryMaxCount ? features.slice(0, selectQueryMaxCount) : features, - totalFeatures: acc.totalFeatures + response.features.length, - numberMatched: acc.numberMatched + response.features.length, - numberReturned: acc.numberReturned + response.features.length - }}; - }, { - type: "FeatureCollection", - features: [], - totalFeatures: 0, - numberMatched: 0, - numberReturned: 0, - timeStamp: new Date().toISOString(), - crs: { - type: "name", - properties: { - name: makeCrsValid(responses[0].crs.toString()) // All layer crs in a MapServer/FeatureServer are the same - } - } - })) - .catch(err => { - throw new Error(`Error while querying layer: ${err.message}`); - }) - ; - } - case 'wms': - case 'wfs': { - return describeFeatureType(layer.url, layer.name) - .then(describe => axios - .get(getFeatureURL(layer.url, layer.name, - optionsToVendorParams({ - filterObj: { - spatialField: { - operation: "INTERSECTS", - attribute: extractGeometryAttributeName(describe), - geometry: geometry - } - } - }) - ), { params: assign({ outputFormat: 'application/json' }, - selectQueryMaxCount > -1 ? { - maxFeatures: selectQueryMaxCount, // WFS v1.1.0 - count: selectQueryMaxCount - } : {} - )}) - .then(response => assign(response.data, response.data.crs === null ? {} : - { - crs: { - type: response.data.crs.type, - properties: {...response.data.crs.properties, [response.data.crs.type]: makeCrsValid(response.data.crs.properties[response.data.crs.type])} - } - }) - ) - .catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }) - ).catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }); - } - default: - return new Promise((_, reject) => reject(new Error(`Unsupported layer type: ${layer.type}`))); - } -}; - -/** - * Epic triggered when the Select tool is opened. - * Registers map click event and synchronizes visibility of additional layers. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const openSelectEpic = (action$, store) => action$ - .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) - .filter(action => action.control === "select" && isSelectEnabled(store.getState())) - .switchMap(() => Observable.merge( - Observable.of(registerEventListener('click', 'select')), - ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: layer.visibility }))) - )); - -/** - * Epic triggered when the Select tool is closed. - * Unregisters map events and hides additional layers. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const closeSelectEpics = (action$, store) => action$ - .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) - .filter(action => action.control === "select" && !isSelectEnabled(store.getState())) - .switchMap(() => Observable.merge( - Observable.of(unRegisterEventListener('click', 'select')), - Observable.of(changeDrawingStatus("clean", "", "select", [], {})), - ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: false })))) - ); - -/** - * Shuts down the Select tool if another drawing tool is activated. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const tearDownSelectOnDrawToolActive = (action$, store) => shutdownToolOnAnotherToolDrawing(action$, store, 'select'); - -/** - * Epic triggered at the end of a drawing session. - * Queries layers with the drawn geometry and updates the selection. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const queryLayers = (action$, store) => action$ - .ofType(END_DRAWING) - .filter(action => - action.owner === 'select' && - isSelectEnabled(store.getState()) && - action.geometry - ) - .switchMap(action => { - const state = store.getState(); - const selectQueryMaxCount = getSelectQueryMaxFeatureCount(state); - return Observable.from(selectLayersSelector(state)) - .mergeMap(layer => Observable.concat( - Observable.of(addOrUpdateSelection(layer, {})), - isSelectQueriable(layer) - ? Observable.concat( - Observable.of(addOrUpdateSelection(layer, { loading: true })), - Observable.fromPromise(queryLayer(layer, action.geometry, selectQueryMaxCount)) - .map(geoJsonData => addOrUpdateSelection(layer, geoJsonData)) - .catch(error => Observable.of(addOrUpdateSelection(layer, { error }))) - ) - : Observable.empty() - )); - }); - -/** - * Epic that handles cleaning of selection data and optionally restarts drawing. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const cleanSelection = (action$, store) => action$ - .ofType(SELECT_CLEAN_SELECTION) - .filter(() => isSelectEnabled(store.getState())) - .switchMap(action => Observable.merge( - Observable.of( - changeDrawingStatus( - action.geomType ? "start" : "clean", - action.geomType || "", - "select", - [], - action.geomType ? { - stopAfterDrawing: true, - editEnabled: false, - drawEnabled: false - } : {} - ) - ), - Observable.from(selectLayersSelector(store.getState())).flatMap(layer => - Observable.merge( - Observable.of(addOrUpdateSelection(layer, {})), - Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { - features: [], - visibility: false - })) - ) - ) - )); - -/** - * Epic to synchronize visibility of layers and additional layers when their state changes. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const synchroniseLayersAndAdditionalLayers = (action$, store) => action$ - .filter(action => action.type === UPDATE_NODE - && isSelectEnabled(store.getState()) - && Object.hasOwn(action.options || {}, 'visibility') - ) - .concatMap(action => { - const state = store.getState(); - const layersForSelect = layersSelector(state).filter(filterLayerForSelect); - - if (layersForSelect?.find(layer => layer.id === action.node)) { - return Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(action.node), { visibility: action.options.visibility })); - } - - const groups = flattenArrayOfObjects(groupsSelector(state)); - return Observable.from(layersForSelect.filter(layer => layer.group?.startsWith(action.node))) - .mergeMap(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: !getInactiveNode(layer.group, groups) })) - ); - }); - -/** - * Epic to remove associated additional layers when a source layer is removed. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const onRemoveLayer = (action$, store) => action$ - .ofType(REMOVE_NODE) - .filter(action => isSelectEnabled(store.getState()) - && action.nodeType === 'layers' - ) - .mergeMap(action => Observable.of(removeAdditionalLayer({ id: buildAdditionalLayerId(action.node), owner: buildAdditionalLayerOwnerName(action.node) }))); - -/** - * Epic to update the map layer display with new selection results. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const onSelectionUpdate = (action$, store) => action$ - .ofType(ADD_OR_UPDATE_SELECTION) - .filter(action => isSelectEnabled(store.getState()) && action.layer) - .mergeMap(action => Observable.of(customUpdateAdditionalLayer( - action.layer.id, - action.geoJsonData.features ?? [], - action.layer.visibility && action.geoJsonData.error && !action.geoJsonData.loading, - { ...highlightStyleSelector(store.getState()), ...getSelectHighlightOptions(store.getState())} - ))); - -export default { - openSelectEpic, - closeSelectEpics, - tearDownSelectOnDrawToolActive, - queryLayers, - cleanSelection, - synchroniseLayersAndAdditionalLayers, - onRemoveLayer, - onSelectionUpdate -}; +import { Observable } from 'rxjs'; +import axios from 'axios'; +import assign from 'object-assign'; + +import { SET_CONTROL_PROPERTY, TOGGLE_CONTROL } from '../../../actions/controls'; +import { UPDATE_NODE, REMOVE_NODE } from '../../../actions/layers'; +import { changeDrawingStatus, END_DRAWING } from '../../../actions/draw'; +import { registerEventListener, unRegisterEventListener} from '../../../actions/map'; +import { shutdownToolOnAnotherToolDrawing } from "../../../utils/ControlUtils"; +import { describeFeatureType, getFeatureURL } from '../../../api/WFS'; +import { extractGeometryAttributeName } from '../../../utils/WFSLayerUtils'; +import { mergeOptionsByOwner, removeAdditionalLayer } from '../../../actions/additionallayers'; +import { highlightStyleSelector } from '../../../selectors/mapInfo'; +import { layersSelector, groupsSelector } from '../../../selectors/layers'; +import { flattenArrayOfObjects, getInactiveNode } from '../../../utils/LayersUtils'; + +import { optionsToVendorParams } from '../../../utils/VendorParamsUtils'; +import { selectLayersSelector, isSelectEnabled, filterLayerForSelect, isSelectQueriable, getSelectQueryMaxFeatureCount, getSelectHighlightOptions } from '../selectors/layersSelection'; +import { SELECT_CLEAN_SELECTION, ADD_OR_UPDATE_SELECTION, addOrUpdateSelection } from '../actions/layersSelection'; +import { buildAdditionalLayerId, buildAdditionalLayerOwnerName, arcgisToGeoJSON, makeCrsValid, customUpdateAdditionalLayer } from '../utils/LayersSelection'; + +/** + * Queries a given layer based on geometry and type (ArcGIS, WMS, or WFS). + * + * @param {Object} layer - Layer configuration object. + * @param {Object} geometry - Geometry used for spatial filtering. + * @param {number} selectQueryMaxCount - Max features to return. + * @returns {Promise} A Promise resolving to a GeoJSON FeatureCollection. + */ +const queryLayer = (layer, geometry, selectQueryMaxCount) => { + switch (layer.type) { + case 'arcgis': { + const parsedGeometry = JSON.stringify({ + spatialReference: { wkid: geometry.projection.split(':')[1] }, + ...(geometry.type === 'Point' + ? { x: geometry.coordinates[0], y: geometry.coordinates[1] } + : (geometry.type === 'LineString' ? + { 'paths': [geometry.coordinates] } : + { 'rings': geometry.coordinates } + ) + ) + }); + const geometryType = geometry.type === 'Point' ? "esriGeometryPoint" : (geometry.type === 'LineString' ? 'esriGeometryPolyline' : 'esriGeometryPolygon'); + const singleLayerId = parseInt(layer.name ?? '', 10); + return Promise.all((Number.isInteger(singleLayerId) ? layer.options.layers.filter(l => l.id === singleLayerId) : layer.options.layers).map(l => axios.get(`${layer.url}/${l.id}`, { params: { f: 'json'} }) + .then(describe => + axios.get(`${layer.url}/${l.id}/query`, { + params: assign({ + f: "json", + geometry: parsedGeometry, + geometryType: geometryType, + spatialRel: "esriSpatialRelIntersects", + where: '1=1', + outFields: '*' + }, describe.data.advancedQueryCapabilities.supportsPagination && selectQueryMaxCount > -1 ? { resultRecordCount: selectQueryMaxCount } : {} + )}) + .then(response => ({ features: arcgisToGeoJSON(response.data.features, describe.data.name, response.data.fields.find(field => field.type === 'esriFieldTypeOID')?.name ?? response.data.objectIdFieldName ?? 'objectid'), crs: makeCrsValid(describe.data.sourceSpatialReference.wkid.toString()) })) + .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }) + ))) + .then(responses => responses.reduce((acc, response) => { + const features = [...acc.features, ...response.features]; + return {...acc, ...{ + features: selectQueryMaxCount > -1 && features.length > selectQueryMaxCount ? features.slice(0, selectQueryMaxCount) : features, + totalFeatures: acc.totalFeatures + response.features.length, + numberMatched: acc.numberMatched + response.features.length, + numberReturned: acc.numberReturned + response.features.length + }}; + }, { + type: "FeatureCollection", + features: [], + totalFeatures: 0, + numberMatched: 0, + numberReturned: 0, + timeStamp: new Date().toISOString(), + crs: { + type: "name", + properties: { + name: makeCrsValid(responses[0].crs.toString()) // All layer crs in a MapServer/FeatureServer are the same + } + } + })) + .catch(err => { + throw new Error(`Error while querying layer: ${err.message}`); + }) + ; + } + case 'wms': + case 'wfs': { + return describeFeatureType(layer.url, layer.name) + .then(describe => axios + .get(getFeatureURL(layer.url, layer.name, + optionsToVendorParams({ + filterObj: { + spatialField: { + operation: "INTERSECTS", + attribute: extractGeometryAttributeName(describe), + geometry: geometry + } + } + }) + ), { params: assign({ outputFormat: 'application/json' }, + selectQueryMaxCount > -1 ? { + maxFeatures: selectQueryMaxCount, // WFS v1.1.0 + count: selectQueryMaxCount + } : {} + )}) + .then(response => assign(response.data, response.data.crs === null ? {} : + { + crs: { + type: response.data.crs.type, + properties: {...response.data.crs.properties, [response.data.crs.type]: makeCrsValid(response.data.crs.properties[response.data.crs.type])} + } + }) + ) + .catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }) + ).catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }); + } + default: + return new Promise((_, reject) => reject(new Error(`Unsupported layer type: ${layer.type}`))); + } +}; + +/** + * Epic triggered when the Select tool is opened. + * Registers map click event and synchronizes visibility of additional layers. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const openSelectEpic = (action$, store) => action$ + .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) + .filter(action => action.control === "select" && isSelectEnabled(store.getState())) + .switchMap(() => Observable.merge( + Observable.of(registerEventListener('click', 'select')), + ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: layer.visibility }))) + )); + +/** + * Epic triggered when the Select tool is closed. + * Unregisters map events and hides additional layers. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const closeSelectEpics = (action$, store) => action$ + .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) + .filter(action => action.control === "select" && !isSelectEnabled(store.getState())) + .switchMap(() => Observable.merge( + Observable.of(unRegisterEventListener('click', 'select')), + Observable.of(changeDrawingStatus("clean", "", "select", [], {})), + ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: false })))) + ); + +/** + * Shuts down the Select tool if another drawing tool is activated. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const tearDownSelectOnDrawToolActive = (action$, store) => shutdownToolOnAnotherToolDrawing(action$, store, 'select'); + +/** + * Epic triggered at the end of a drawing session. + * Queries layers with the drawn geometry and updates the selection. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const queryLayers = (action$, store) => action$ + .ofType(END_DRAWING) + .filter(action => + action.owner === 'select' && + isSelectEnabled(store.getState()) && + action.geometry + ) + .switchMap(action => { + const state = store.getState(); + const selectQueryMaxCount = getSelectQueryMaxFeatureCount(state); + return Observable.from(selectLayersSelector(state)) + .mergeMap(layer => Observable.concat( + Observable.of(addOrUpdateSelection(layer, {})), + isSelectQueriable(layer) + ? Observable.concat( + Observable.of(addOrUpdateSelection(layer, { loading: true })), + Observable.fromPromise(queryLayer(layer, action.geometry, selectQueryMaxCount)) + .map(geoJsonData => addOrUpdateSelection(layer, geoJsonData)) + .catch(error => Observable.of(addOrUpdateSelection(layer, { error }))) + ) + : Observable.empty() + )); + }); + +/** + * Epic that handles cleaning of selection data and optionally restarts drawing. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const cleanSelection = (action$, store) => action$ + .ofType(SELECT_CLEAN_SELECTION) + .filter(() => isSelectEnabled(store.getState())) + .switchMap(action => Observable.merge( + Observable.of( + changeDrawingStatus( + action.geomType ? "start" : "clean", + action.geomType || "", + "select", + [], + action.geomType ? { + stopAfterDrawing: true, + editEnabled: false, + drawEnabled: false + } : {} + ) + ), + Observable.from(selectLayersSelector(store.getState())).flatMap(layer => + Observable.merge( + Observable.of(addOrUpdateSelection(layer, {})), + Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { + features: [], + visibility: false + })) + ) + ) + )); + +/** + * Epic to synchronize visibility of layers and additional layers when their state changes. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const synchroniseLayersAndAdditionalLayers = (action$, store) => action$ + .filter(action => action.type === UPDATE_NODE + && isSelectEnabled(store.getState()) + && Object.hasOwn(action.options || {}, 'visibility') + ) + .concatMap(action => { + const state = store.getState(); + const layersForSelect = layersSelector(state).filter(filterLayerForSelect); + + if (layersForSelect?.find(layer => layer.id === action.node)) { + return Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(action.node), { visibility: action.options.visibility })); + } + + const groups = flattenArrayOfObjects(groupsSelector(state)); + return Observable.from(layersForSelect.filter(layer => layer.group?.startsWith(action.node))) + .mergeMap(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: !getInactiveNode(layer.group, groups) })) + ); + }); + +/** + * Epic to remove associated additional layers when a source layer is removed. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const onRemoveLayer = (action$, store) => action$ + .ofType(REMOVE_NODE) + .filter(action => isSelectEnabled(store.getState()) + && action.nodeType === 'layers' + ) + .mergeMap(action => Observable.of(removeAdditionalLayer({ id: buildAdditionalLayerId(action.node), owner: buildAdditionalLayerOwnerName(action.node) }))); + +/** + * Epic to update the map layer display with new selection results. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const onSelectionUpdate = (action$, store) => action$ + .ofType(ADD_OR_UPDATE_SELECTION) + .filter(action => isSelectEnabled(store.getState()) && action.layer) + .mergeMap(action => Observable.of(customUpdateAdditionalLayer( + action.layer.id, + action.geoJsonData.features ?? [], + action.layer.visibility && action.geoJsonData.error && !action.geoJsonData.loading, + { ...highlightStyleSelector(store.getState()), ...getSelectHighlightOptions(store.getState())} + ))); + +export default { + openSelectEpic, + closeSelectEpics, + tearDownSelectOnDrawToolActive, + queryLayers, + cleanSelection, + synchroniseLayersAndAdditionalLayers, + onRemoveLayer, + onSelectionUpdate +}; diff --git a/web/client/plugins/layersSelection/index.js b/web/client/plugins/layersSelection/index.js index e616129b432..3600e336574 100644 --- a/web/client/plugins/layersSelection/index.js +++ b/web/client/plugins/layersSelection/index.js @@ -8,13 +8,12 @@ import { createPlugin } from '../../utils/PluginsUtils'; import { layersSelector } from '../../selectors/layers'; import { updateNode, addLayer, changeLayerProperties } from '../../actions/layers'; import { zoomToExtent } from '../../actions/map'; -import controls from '../../reducers/controls'; import { toggleControl } from '../../actions/controls'; import Message from '../../components/I18N/Message'; import SelectComponent from './components/LayersSelection'; import epics from './epics/layersSelection'; -import select from './reducers/layersSelection'; +import layersSelection from './reducers/layersSelection'; import { storeConfiguration, cleanSelection, addOrUpdateSelection } from './actions/layersSelection'; import { getSelectSelections, getSelectQueryMaxFeatureCount } from './selectors/layersSelection'; @@ -29,7 +28,7 @@ import { getSelectSelections, getSelectQueryMaxFeatureCount } from './selectors/ export default createPlugin('LayersSelection', { component: connect( createSelector([ - (state) => get(state, 'controls.select.enabled'), + (state) => get(state, 'controls.layersSelection.enabled'), layersSelector, getSelectSelections, getSelectQueryMaxFeatureCount @@ -40,7 +39,7 @@ export default createPlugin('LayersSelection', { maxFeatureCount })), { - onClose: toggleControl.bind(null, 'select', null), + onClose: toggleControl.bind(null, 'layersSelection', null), onUpdateNode: updateNode, storeConfiguration, cleanSelection, @@ -54,42 +53,41 @@ export default createPlugin('LayersSelection', { disablePluginIf: "{state('mapType') === 'cesium'}" }, reducers: { - ...controls, - select + select: layersSelection }, epics: epics, containers: { BurgerMenu: { - name: 'select', + name: 'layersSelection', position: 1000, priority: 2, doNotHide: true, text: , tooltip: , icon: , - action: toggleControl.bind(null, 'select', null), + action: toggleControl.bind(null, 'layersSelection', null), toggle: true }, SidebarMenu: { - name: 'select', + name: 'layersSelection', position: 1000, priority: 1, doNotHide: true, text: , tooltip: , icon: , - action: toggleControl.bind(null, 'select', null), + action: toggleControl.bind(null, 'layersSelection', null), toggle: true }, Toolbar: { - name: 'select', + name: 'layersSelection', alwaysVisible: true, position: 2, priority: 0, doNotHide: true, tooltip: , icon: , - action: toggleControl.bind(null, 'select', null), + action: toggleControl.bind(null, 'layersSelection', null), toggle: true } } diff --git a/web/client/plugins/layersSelection/reducers/layersSelection.js b/web/client/plugins/layersSelection/reducers/layersSelection.js index 55eac1ada78..93125ae37f4 100644 --- a/web/client/plugins/layersSelection/reducers/layersSelection.js +++ b/web/client/plugins/layersSelection/reducers/layersSelection.js @@ -1,30 +1,30 @@ -import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION } from '../actions/layersSelection'; - -/** - * Reducer for managing selection configuration and selection data per layer. - * - * @param {Object} state - Current selection state. - * @param {Object} state.cfg - Selection configuration object. - * @param {Object} state.selections - GeoJSON selection results keyed by layer ID. - * @param {Object} action - Redux action. - * @param {string} action.type - Action type. - * @returns {Object} New state after applying the action. - */ -export default function select(state = {cfg: {}, selections: {}}, action) { - switch (action.type) { - case SELECT_STORE_CFG: { - return { - ...state, - cfg: action.cfg - }; - } - case ADD_OR_UPDATE_SELECTION: { - return { - ...state, - selections: {...state.selections, [action.layer.id]: action.geoJsonData} - }; - } - default: - return state; - } -} +import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION } from '../actions/layersSelection'; + +/** + * Reducer for managing selection configuration and selection data per layer. + * + * @param {Object} state - Current selection state. + * @param {Object} state.cfg - Selection configuration object. + * @param {Object} state.selections - GeoJSON selection results keyed by layer ID. + * @param {Object} action - Redux action. + * @param {string} action.type - Action type. + * @returns {Object} New state after applying the action. + */ +export default function LayersSelection(state = {cfg: {}, selections: {}}, action) { + switch (action.type) { + case SELECT_STORE_CFG: { + return { + ...state, + cfg: action.cfg + }; + } + case ADD_OR_UPDATE_SELECTION: { + return { + ...state, + selections: {...state.selections, [action.layer.id]: action.geoJsonData} + }; + } + default: + return state; + } +} diff --git a/web/client/plugins/layersSelection/selectors/layersSelection.js b/web/client/plugins/layersSelection/selectors/layersSelection.js index c5ee7267214..a0efe21a9de 100644 --- a/web/client/plugins/layersSelection/selectors/layersSelection.js +++ b/web/client/plugins/layersSelection/selectors/layersSelection.js @@ -1,88 +1,88 @@ -import { get } from 'lodash'; - -/** - * Filters a layer to determine if it's eligible for selection. - * Excludes background layers and only allows WMS, WFS, or ArcGIS types. - * - * @param {Object} layer - A layer object. - * @returns {boolean} True if the layer is selectable. - */ -export const filterLayerForSelect = layer => layer && layer.group !== 'background' && ['wms', 'wfs', 'arcgis'].includes(layer.type); - -/** - * Retrieves all selectable layers from the Redux state. - * - * @param {Object} state - Redux state. - * @returns {Array} List of selectable layers. - */ -export const selectLayersSelector = state => (get(state, 'layers.flat') || []).filter(filterLayerForSelect); - -/** - * Checks if the select control is currently enabled. - * - * @param {Object} state - Redux state. - * @returns {boolean} True if selection is enabled. - */ -export const isSelectEnabled = state => get(state, "controls.select.enabled"); - -/** - * Checks if a node explicitly has the `isSelectQueriable` property. - * - * @param {Object} node - Layer node or descriptor. - * @returns {boolean} True if the property exists. - */ -export const hasSelectQueriableProp = node => Object.hasOwn(node, 'isSelectQueriable'); - -/** - * Determines whether a layer node is considered selectable. - * If `isSelectQueriable` is defined, it uses that. - * Otherwise, falls back to `visibility` status. - * - * @param {Object} node - Layer node or descriptor. - * @returns {boolean} True if the node is considered selectable. - */ -export const isSelectQueriable = node => hasSelectQueriableProp(node) ? node.isSelectQueriable : !!node?.visibility; - -/** - * Retrieves the entire `select` object from Redux state. - * - * @param {Object} state - Redux state. - * @returns {Object} Selection-related state object. - */ -export const getSelectObj = state => get(state, 'select') ?? {}; - -/** - * Retrieves query options used for selection. - * - * @param {Object} state - Redux state. - * @returns {Object} Query options configuration. - */ -export const getSelectQueryOptions = state => getSelectObj(state).cfg?.queryOptions ?? {}; - -/** - * Gets the maximum number of features to return in a select query. - * Defaults to -1 if not defined or invalid. - * - * @param {Object} state - Redux state. - * @returns {number} Maximum feature count for queries. - */ -export const getSelectQueryMaxFeatureCount = state => { - const queryOptions = getSelectQueryOptions(state); - return Number.isInteger(queryOptions.maxCount) ? queryOptions.maxCount : -1; -}; - -/** - * Retrieves highlight options to apply on selected features. - * - * @param {Object} state - Redux state. - * @returns {Object} Highlight configuration. - */ -export const getSelectHighlightOptions = state => getSelectObj(state).cfg?.highlightOptions ?? {}; - -/** - * Retrieves all current selection data, grouped by layer ID. - * - * @param {Object} state - Redux state. - * @returns {Object} A mapping of layer ID to GeoJSON feature collections. - */ -export const getSelectSelections = state => getSelectObj(state).selections ?? {}; +import { get } from 'lodash'; + +/** + * Filters a layer to determine if it's eligible for selection. + * Excludes background layers and only allows WMS, WFS, or ArcGIS types. + * + * @param {Object} layer - A layer object. + * @returns {boolean} True if the layer is selectable. + */ +export const filterLayerForSelect = layer => layer && layer.group !== 'background' && ['wms', 'wfs', 'arcgis'].includes(layer.type); + +/** + * Retrieves all selectable layers from the Redux state. + * + * @param {Object} state - Redux state. + * @returns {Array} List of selectable layers. + */ +export const selectLayersSelector = state => (get(state, 'layers.flat') || []).filter(filterLayerForSelect); + +/** + * Checks if the select control is currently enabled. + * + * @param {Object} state - Redux state. + * @returns {boolean} True if selection is enabled. + */ +export const isSelectEnabled = state => get(state, "controls.layersSelection.enabled"); + +/** + * Checks if a node explicitly has the `isSelectQueriable` property. + * + * @param {Object} node - Layer node or descriptor. + * @returns {boolean} True if the property exists. + */ +export const hasSelectQueriableProp = node => Object.hasOwn(node, 'isSelectQueriable'); + +/** + * Determines whether a layer node is considered selectable. + * If `isSelectQueriable` is defined, it uses that. + * Otherwise, falls back to `visibility` status. + * + * @param {Object} node - Layer node or descriptor. + * @returns {boolean} True if the node is considered selectable. + */ +export const isSelectQueriable = node => hasSelectQueriableProp(node) ? node.isSelectQueriable : !!node?.visibility; + +/** + * Retrieves the entire `select` object from Redux state. + * + * @param {Object} state - Redux state. + * @returns {Object} Selection-related state object. + */ +export const getSelectObj = state => get(state, 'select') ?? {}; + +/** + * Retrieves query options used for selection. + * + * @param {Object} state - Redux state. + * @returns {Object} Query options configuration. + */ +export const getSelectQueryOptions = state => getSelectObj(state).cfg?.queryOptions ?? {}; + +/** + * Gets the maximum number of features to return in a select query. + * Defaults to -1 if not defined or invalid. + * + * @param {Object} state - Redux state. + * @returns {number} Maximum feature count for queries. + */ +export const getSelectQueryMaxFeatureCount = state => { + const queryOptions = getSelectQueryOptions(state); + return Number.isInteger(queryOptions.maxCount) ? queryOptions.maxCount : -1; +}; + +/** + * Retrieves highlight options to apply on selected features. + * + * @param {Object} state - Redux state. + * @returns {Object} Highlight configuration. + */ +export const getSelectHighlightOptions = state => getSelectObj(state).cfg?.highlightOptions ?? {}; + +/** + * Retrieves all current selection data, grouped by layer ID. + * + * @param {Object} state - Redux state. + * @returns {Object} A mapping of layer ID to GeoJSON feature collections. + */ +export const getSelectSelections = state => getSelectObj(state).selections ?? {}; From d15cff01f284e53a725c24da599a7ca77ffdc61d Mon Sep 17 00:00:00 2001 From: Benjamin Grenard Date: Fri, 28 Nov 2025 14:05:32 +0100 Subject: [PATCH 30/76] rename reducer as layerSelection --- web/client/plugins/layersSelection/reducers/layersSelection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/client/plugins/layersSelection/reducers/layersSelection.js b/web/client/plugins/layersSelection/reducers/layersSelection.js index 93125ae37f4..0bf05a19144 100644 --- a/web/client/plugins/layersSelection/reducers/layersSelection.js +++ b/web/client/plugins/layersSelection/reducers/layersSelection.js @@ -10,7 +10,7 @@ import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION } from '../actions/layersSele * @param {string} action.type - Action type. * @returns {Object} New state after applying the action. */ -export default function LayersSelection(state = {cfg: {}, selections: {}}, action) { +export default function layersSelection(state = {cfg: {}, selections: {}}, action) { switch (action.type) { case SELECT_STORE_CFG: { return { From d572f44dfd6d29f363c7958fb121860279f38afc Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:39:14 +0200 Subject: [PATCH 31/76] Add Translations --- web/client/translations/data.de-DE.json | 41 +++++++++++++++++++++++++ web/client/translations/data.en-US.json | 41 +++++++++++++++++++++++++ web/client/translations/data.es-ES.json | 41 +++++++++++++++++++++++++ web/client/translations/data.fr-FR.json | 41 +++++++++++++++++++++++++ web/client/translations/data.it-IT.json | 41 +++++++++++++++++++++++++ 5 files changed, 205 insertions(+) diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 7f911e160e7..e93664594c8 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -1580,6 +1580,43 @@ } } }, + "select": { + "title": "Auswählen", + "tooltip": "Auswahlwerkzeug anzeigen", + "description": "Auswahlwerkzeug anzeigen", + "hasReachMaxCount": "Maximale Anzahl von Elementen erreicht", + "selection": "Auswahl", + "allLayers": "Alle Schichten", + "featuresCount": "Featuresanzahl", + "button": { + "select": "Auswahlmodus", + "chooseGeometry": "Wählen", + "selectByPoint": "Punkt", + "selectByLine": "Linie", + "selectByCircle": "Kreis", + "selectByRectangle": "Rechteck", + "selectByPolygon": "Polygon", + "clear": "Löschen", + "zoomTo": "Zoomen auf", + "statistics": "Statistiken", + "createLayer": "Ebene erstellen", + "filterData": "Daten filtern", + "export": "Exportieren", + "exportToCsv": "Exportieren nach CSV", + "exportToJson": "Exportieren nach JSON", + "exportToGeoJson": "Exportieren nach GeoJSON" + }, + "statistics": { + "title": "Statistiken", + "field": "Feld", + "count": "Anzahl der Werte", + "sum": "Summe der Werte", + "min": "Minimum", + "max": "Maximum", + "avg": "Durchschnitt", + "std": "Standardabweichung" + } + }, "snapshot": { "title": "Snapshot Vorschau", "save": "Speichern", @@ -3575,6 +3612,10 @@ "description": "Ermöglicht das Erstellen eines Permalinks der aktuell angezeigten Ressource", "title": "Permalink" }, + "Select": { + "title": "Auswählen", + "tooltip": "Auswahlwerkzeug anzeigen" + }, "StreetView": { "title": "Street-View", "description": "Tool zum Durchsuchen von Google Street View-Bildern auf der Karte" diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index c72531bf2af..d4d399ba386 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -1541,6 +1541,43 @@ } } }, + "select": { + "title": "Select", + "tooltip": "Display the selection tool", + "description": "Display the selection tool", + "hasReachMaxCount": "Maximum number of elements reached", + "selection": "Selection", + "allLayers": "All layers", + "featuresCount": "Features number", + "button": { + "select": "Selection mode", + "chooseGeometry": "Choose", + "selectByPoint": "Point", + "selectByLine": "Line", + "selectByCircle": "Circle", + "selectByRectangle": "Rectangle", + "selectByPolygon": "Polygon", + "clear": "Clear", + "zoomTo": "Zoom to", + "statistics": "Statistics", + "createLayer": "Create layer", + "filterData": "Filter data", + "export": "Export", + "exportToCsv": "Export to CSV", + "exportToJson": "Export to JSON", + "exportToGeoJson": "Export to GeoJSON" + }, + "statistics": { + "title": "Statistics", + "field": "Field", + "count": "Number of values", + "sum": "Sum of values", + "min": "Minimum", + "max": "Maximum", + "avg": "Average", + "std": "Standard deviation" + } + }, "snapshot": { "title": "Snapshot Preview", "save": "Save", @@ -3546,6 +3583,10 @@ "description": "Allows to create a permalink of the current resource in view", "title": "Permalink" }, + "Select": { + "title": "Select", + "tooltip": "Kartenlegende anzeigen" + }, "StreetView": { "title": "Street View", "description": "Street view tool for browsing Google street view images from the map" diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index cfe74e30b69..e00e20f207b 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -1541,6 +1541,43 @@ } } }, + "select": { + "title": "Seleccionar", + "tooltip": "Mostrar la herramienta de selección", + "description": "Mostrar la herramienta de selección", + "hasReachMaxCount": "Número máximo de elementos alcanzado", + "selection": "Selección", + "allLayers": "Todas las capas", + "featuresCount": "Número de entidades", + "button": { + "select": "Modo de selección", + "chooseGeometry": "Elegir", + "selectByPoint": "Punto", + "selectByLine": "Línea", + "selectByCircle": "Círculo", + "selectByRectangle": "Rectángulo", + "selectByPolygon": "Polígono", + "clear": "Borrar", + "zoomTo": "Hacer zoom en", + "statistics": "Estadísticas", + "createLayer": "Crear capa", + "filterData": "Filtrar datos", + "export": "Exportar", + "exportToCsv": "Exportar a CSV", + "exportToJson": "Exportar a JSON", + "exportToGeoJson": "Exportar a GeoJSON" + }, + "statistics": { + "title": "Estadísticas", + "field": "Campo", + "count": "Número de valores", + "sum": "Suma de los valores", + "min": "Mínimo", + "max": "Máximo", + "avg": "Promedio", + "std": "Desviación estándar" + } + }, "snapshot": { "title": "Previsualización de la captura del mapa", "save": "Guardar", @@ -3536,6 +3573,10 @@ "description": "Permite crear un enlace permanente del recurso actual a la vista", "title": "Enlace permanente" }, + "Select": { + "title": "Seleccionar", + "tooltip": "Mostrar la herramienta de selección" + }, "StreetView": { "title": "Street View", "description": "Herramienta para buscar imágenes de Google Street View desde el mapa" diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index fd691925a78..2de2c24465d 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -1542,6 +1542,43 @@ } } }, + "select": { + "title": "Sélectionner", + "tooltip": "Afficher l'outil de sélection", + "description": "Afficher l'outil de sélection", + "hasReachMaxCount": "Nombre d'éléments maximum atteints", + "selection": "Sélection", + "allLayers": "Toutes les couches", + "featuresCount": "Nombre d'entités", + "button": { + "select": "Mode de sélection", + "chooseGeometry": "Choisir", + "selectByPoint": "Point", + "selectByLine": "Ligne", + "selectByCircle": "Cercle", + "selectByRectangle": "Rectangle", + "selectByPolygon": "Polygone", + "clear": "Effacer", + "zoomTo": "Zoomer sur", + "statistics": "Statistiques", + "createLayer": "Créer une couche", + "filterData": "Filtrer les données", + "export": "Exporter", + "exportToCsv": "Exporter en CSV", + "exportToJson": "Exporter en JSON", + "exportToGeoJson": "Exporter en GeoJSON" + }, + "statistics": { + "title": "Statistiques", + "field": "Champs", + "count": "Nombre de valeurs", + "sum": "Somme des valeurs", + "min": "Minimum", + "max": "Maximum", + "avg": "Moyenne", + "std": "Écart type" + } + }, "snapshot": { "title": "Prévisualisation de la capture de la carte", "save": "Sauver", @@ -3537,6 +3574,10 @@ "description": "Permet de créer un permalien de la ressource courante en vue", "title": "Lien permanent" }, + "Select": { + "title": "Sélectionner", + "tooltip": "Afficher l'outil de sélection" + }, "StreetView": { "title": "Street View", "description": "Outil pour parcourir les images Google Street View à partir de la carte" diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 746d18460d8..69d2da1e53a 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -1541,6 +1541,43 @@ } } }, + "select": { + "title": "Selezionare", + "tooltip": "Mostra lo strumento di selezione", + "description": "Mostra lo strumento di selezione", + "hasReachMaxCount": "Numero massimo di elementi raggiunto", + "selection": "Selezione", + "allLayers": "Tutti i livelli", + "featuresCount": "Numero di entità", + "button": { + "select": "Modalità di selezione", + "chooseGeometry": "Scegliere", + "selectByPoint": "Punto", + "selectByLine": "Linea", + "selectByCircle": "Cerchio", + "selectByRectangle": "Rettangolo", + "selectByPolygon": "Poligono", + "clear": "Cancella", + "zoomTo": "Zoom su", + "statistics": "Statistiche", + "createLayer": "Crea livello", + "filterData": "Filtra dati", + "export": "Esporta", + "exportToCsv": "Esporta in CSV", + "exportToJson": "Esporta in JSON", + "exportToGeoJson": "Esporta in GeoJSON" + }, + "statistics": { + "title": "Statistiche", + "field": "Campo", + "count": "Numero di valori", + "sum": "Somma dei valori", + "min": "Minimo", + "max": "Massimo", + "avg": "Media", + "std": "Deviazione standard" + } + }, "snapshot": { "title": "Istantanea", "save": "Salva", @@ -3538,6 +3575,10 @@ "description": "Permette di creare un permalink della risorsa attualmente in vista", "title": "Permalink" }, + "Select": { + "title": "Selezionare", + "tooltip": "Mostra lo strumento di selezione" + }, "StreetView": { "title": "Street View", "description": "Strumento Street view, per visualizzare le immagini di Google Street View dalla mappa" From ad8b042532d13ac010b2709524a2852cef99af1f Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:41:20 +0200 Subject: [PATCH 32/76] Add configs --- .../templates/configs/pluginsConfig.json | 11 +++++++++ web/client/configs/localConfig.json | 23 +++++++++++++++++++ web/client/configs/pluginsConfig.json | 11 +++++++++ web/client/configs/simple.json | 23 +++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json index 60be44cc5b0..d330878a0e1 100644 --- a/project/standard/templates/configs/pluginsConfig.json +++ b/project/standard/templates/configs/pluginsConfig.json @@ -146,6 +146,17 @@ "description": "plugins.Permalink.description", "denyUserSelection": true }, + { + "name": "Select", + "glyph": "hand-down", + "title": "plugins.Select.title", + "description": "plugins.Select.description", + "dependencies": [ + "Toolbar", + "BurgerMenu", + "SidebarMenu" + ] + }, { "name": "BackgroundSelector", "title": "plugins.BackgroundSelector.title", diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 556e4dc4b6e..5e88631dea9 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -405,6 +405,29 @@ "containerPosition": "header" } }, + { + "name": "Select", + "cfg": { + "highlightOptions": { + "color": "#3388ff", + "dashArray": "", + "fillColor": "#3388ff", + "fillOpacity": 0.2, + "radius": 4, + "weight": 4 + }, + "queryOptions": { + "maxCount": -1 + }, + "selectTools": [ + "Point", + "Line", + "Circle", + "Rectangle", + "Polygon" + ] + } + }, { "name": "SecurityPopup" }, { "name": "Map", diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index aca03bc9149..29e64a57209 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -148,6 +148,17 @@ "title": "plugins.Permalink.title", "description": "plugins.Permalink.description" }, + { + "name": "Select", + "glyph": "hand-down", + "title": "plugins.Select.title", + "description": "plugins.Select.description", + "dependencies": [ + "Toolbar", + "BurgerMenu", + "SidebarMenu" + ] + }, { "name": "BackgroundSelector", "title": "plugins.BackgroundSelector.title", diff --git a/web/client/configs/simple.json b/web/client/configs/simple.json index def941ce77f..a631230cac6 100644 --- a/web/client/configs/simple.json +++ b/web/client/configs/simple.json @@ -88,6 +88,29 @@ "zoomControl": false } }, + { + "name": "SelectExtension", + "cfg": { + "highlightOptions": { + "color": "#3388ff", + "dashArray": "", + "fillColor": "#3388ff", + "fillOpacity": 0.2, + "radius": 4, + "weight": 4 + }, + "queryOptions": { + "maxCount": -1 + }, + "selectTools": [ + "Point", + "Line", + "Circle", + "Rectangle", + "Polygon" + ] + } + }, { "name": "Help" }, From 9ee69ed19507badc393cf5d93c9bd8dd47521be3 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:41:52 +0200 Subject: [PATCH 33/76] Add Select to plugins --- web/client/product/plugins.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index 792ea2c5336..64447c633b1 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -125,6 +125,7 @@ export const plugins = { SidebarMenuPlugin: toModulePlugin('SidebarMenu', () => import(/* webpackChunkName: 'plugins/sidebarMenu' */ '../plugins/SidebarMenu')), SharePlugin: toModulePlugin('Share', () => import(/* webpackChunkName: 'plugins/share' */ '../plugins/Share')), PermalinkPlugin: toModulePlugin('Permalink', () => import(/* webpackChunkName: 'plugins/permalink' */ '../plugins/Permalink')), + Select: toModulePlugin('Select', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Select')), SnapshotPlugin: toModulePlugin('Snapshot', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Snapshot')), StreetView: toModulePlugin('StreetView', () => import(/* webpackChunkName: 'plugins/streetView' */ '../plugins/StreetView')), StyleEditor: toModulePlugin('StyleEditor', () => import(/* webpackChunkName: 'plugins/styleEditor' */ '../plugins/StyleEditor')), From 830185b843bb2d5149dbc34c0b205897b033a198 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:42:55 +0200 Subject: [PATCH 34/76] Correction on getCQLGeometryElement --- web/client/utils/FilterUtils.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/client/utils/FilterUtils.js b/web/client/utils/FilterUtils.js index e2194a228dc..d179359eefa 100644 --- a/web/client/utils/FilterUtils.js +++ b/web/client/utils/FilterUtils.js @@ -813,6 +813,7 @@ export const getCQLGeometryElement = function(coordinates, type) { geometry += coordinates.join(" "); break; case "MultiPoint": + case "LineString": coordinates.forEach((position, index) => { geometry += position.join(" "); geometry += index < coordinates.length - 1 ? ", " : ""; From 4012a45d558448be28a35393e4bf317bf5cde1cc Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:46:35 +0200 Subject: [PATCH 35/76] Add treeHeadr to TOC and LayerTree --- web/client/plugins/TOC/components/LayersTree.jsx | 5 ++++- web/client/plugins/TOC/components/TOC.jsx | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web/client/plugins/TOC/components/LayersTree.jsx b/web/client/plugins/TOC/components/LayersTree.jsx index 94dd1040809..d0a6b677cf8 100644 --- a/web/client/plugins/TOC/components/LayersTree.jsx +++ b/web/client/plugins/TOC/components/LayersTree.jsx @@ -68,6 +68,7 @@ const loopGroupCondition = (groupNode, condition) => { * @prop {string} noFilteredResultsMsgId message id for no result on filter * @prop {object} config optional configuration available for the nodes * @prop {boolean} config.sortable activate the possibility to sort nodes + * @prop {component} treeHeader display a header on top of the layer tree */ const LayersTree = ({ tree, @@ -90,7 +91,8 @@ const LayersTree = ({ nodeToolItems, nodeContentItems, singleDefaultGroup = isSingleDefaultGroup(tree), - theme + theme, + treeHeader }) => { const containerNode = useRef(); @@ -151,6 +153,7 @@ const LayersTree = ({ event.preventDefault(); }} > + {treeHeader ?? null} {(root || []).map((node, index) => { return ( ); } @@ -140,6 +143,7 @@ export function ControlledTOC({ * @prop {boolean} config.layerOptions.hideLegend hide the legend of the layer * @prop {object} config.layerOptions.legendOptions additional options for WMS legend * @prop {boolean} config.layerOptions.hideFilter hide the filter button in the layer nodes + * @prop {component} treeHeader display a header on top of the layer tree */ function TOC({ map = { layers: [], groups: [] }, @@ -154,7 +158,8 @@ function TOC({ singleDefaultGroup, nodeItems, theme, - filterText + filterText, + treeHeader }) { const { layers } = splitMapAndLayers(map) || {}; const tree = denormalizeGroups(layers.flat || [], layers.groups || []).groups; @@ -218,6 +223,7 @@ function TOC({ nodeToolItems={nodeToolItems} nodeContentItems={nodeContentItems} singleDefaultGroup={singleDefaultGroup} + treeHeader={treeHeader} /> ); } From 7b78fa235ad5eac497d6224335e93f8f41c8e1d0 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:47:34 +0200 Subject: [PATCH 36/76] =?UTF-8?q?tooltip=20=E2=86=92=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/client/translations/data.de-DE.json | 2 +- web/client/translations/data.en-US.json | 2 +- web/client/translations/data.es-ES.json | 2 +- web/client/translations/data.fr-FR.json | 2 +- web/client/translations/data.it-IT.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index e93664594c8..a1504cd7a9e 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -3614,7 +3614,7 @@ }, "Select": { "title": "Auswählen", - "tooltip": "Auswahlwerkzeug anzeigen" + "description": "Auswahlwerkzeug anzeigen" }, "StreetView": { "title": "Street-View", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index d4d399ba386..470958a7082 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -3585,7 +3585,7 @@ }, "Select": { "title": "Select", - "tooltip": "Kartenlegende anzeigen" + "description": "Kartenlegende anzeigen" }, "StreetView": { "title": "Street View", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index e00e20f207b..29a5366b5c5 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -3575,7 +3575,7 @@ }, "Select": { "title": "Seleccionar", - "tooltip": "Mostrar la herramienta de selección" + "description": "Mostrar la herramienta de selección" }, "StreetView": { "title": "Street View", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 2de2c24465d..93ec842f12d 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -3576,7 +3576,7 @@ }, "Select": { "title": "Sélectionner", - "tooltip": "Afficher l'outil de sélection" + "description": "Afficher l'outil de sélection" }, "StreetView": { "title": "Street View", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 69d2da1e53a..45d1ca87eb8 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -3577,7 +3577,7 @@ }, "Select": { "title": "Selezionare", - "tooltip": "Mostra lo strumento di selezione" + "description": "Mostra lo strumento di selezione" }, "StreetView": { "title": "Street View", From cc60750fa43f955ed87733020ec76f87ef64b97c Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:48:55 +0200 Subject: [PATCH 37/76] =?UTF-8?q?Salect=20=E2=86=92=20SelectPlugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/client/product/plugins.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index 64447c633b1..8ebf5d39a12 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -125,7 +125,7 @@ export const plugins = { SidebarMenuPlugin: toModulePlugin('SidebarMenu', () => import(/* webpackChunkName: 'plugins/sidebarMenu' */ '../plugins/SidebarMenu')), SharePlugin: toModulePlugin('Share', () => import(/* webpackChunkName: 'plugins/share' */ '../plugins/Share')), PermalinkPlugin: toModulePlugin('Permalink', () => import(/* webpackChunkName: 'plugins/permalink' */ '../plugins/Permalink')), - Select: toModulePlugin('Select', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Select')), + SelectPlugin: toModulePlugin('Select', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Select')), SnapshotPlugin: toModulePlugin('Snapshot', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Snapshot')), StreetView: toModulePlugin('StreetView', () => import(/* webpackChunkName: 'plugins/streetView' */ '../plugins/StreetView')), StyleEditor: toModulePlugin('StyleEditor', () => import(/* webpackChunkName: 'plugins/styleEditor' */ '../plugins/StyleEditor')), From a35003155b7d5297f74a85fa14d4b15335742d3a Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:59:06 +0200 Subject: [PATCH 38/76] Add select Code --- web/client/actions/select.js | 25 ++ web/client/epics/select.js | 232 ++++++++++++++++++ web/client/plugins/Select.jsx | 88 +++++++ web/client/plugins/select/assets/select.css | 40 +++ .../EllipsisButton/EllipsisButton.css | 57 +++++ .../EllipsisButton/EllipsisButton.jsx | 226 +++++++++++++++++ .../EllipsisButton/Statistics/Statistics.css | 36 +++ .../EllipsisButton/Statistics/Statistics.jsx | 75 ++++++ .../plugins/select/components/Select.jsx | 144 +++++++++++ .../components/SelectHeader/SelectHeader.css | 91 +++++++ .../components/SelectHeader/SelectHeader.jsx | 84 +++++++ web/client/reducers/select.js | 20 ++ web/client/selectors/select.js | 19 ++ web/client/utils/Select.js | 63 +++++ 14 files changed, 1200 insertions(+) create mode 100644 web/client/actions/select.js create mode 100644 web/client/epics/select.js create mode 100644 web/client/plugins/Select.jsx create mode 100644 web/client/plugins/select/assets/select.css create mode 100644 web/client/plugins/select/components/EllipsisButton/EllipsisButton.css create mode 100644 web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx create mode 100644 web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.css create mode 100644 web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.jsx create mode 100644 web/client/plugins/select/components/Select.jsx create mode 100644 web/client/plugins/select/components/SelectHeader/SelectHeader.css create mode 100644 web/client/plugins/select/components/SelectHeader/SelectHeader.jsx create mode 100644 web/client/reducers/select.js create mode 100644 web/client/selectors/select.js create mode 100644 web/client/utils/Select.js diff --git a/web/client/actions/select.js b/web/client/actions/select.js new file mode 100644 index 00000000000..5da21da1ebf --- /dev/null +++ b/web/client/actions/select.js @@ -0,0 +1,25 @@ +export const SELECT_CLEAN_SELECTION = "SELECT:CLEAN_SELECTION"; +export const SELECT_STORE_CFG = "SELECT:STORE_CFG"; +export const ADD_OR_UPDATE_SELECTION = "SELECT:ADD_OR_UPDATE_SELECTION"; + +export function cleanSelection(geomType) { + return { + type: SELECT_CLEAN_SELECTION, + geomType + }; +} + +export function storeConfiguration(cfg) { + return { + type: SELECT_STORE_CFG, + cfg + }; +} + +export function addOrUpdateSelection(layer, geoJsonData) { + return { + type: ADD_OR_UPDATE_SELECTION, + layer, + geoJsonData + }; +} diff --git a/web/client/epics/select.js b/web/client/epics/select.js new file mode 100644 index 00000000000..132b9ae17b0 --- /dev/null +++ b/web/client/epics/select.js @@ -0,0 +1,232 @@ +import { Observable } from 'rxjs'; +import axios from 'axios'; +import assign from 'object-assign'; + +import { SET_CONTROL_PROPERTY, TOGGLE_CONTROL } from '../actions/controls'; +import { UPDATE_NODE, REMOVE_NODE } from '../actions/layers'; +import { changeDrawingStatus, END_DRAWING } from '../actions/draw'; +import { registerEventListener, unRegisterEventListener} from '../actions/map'; +import { shutdownToolOnAnotherToolDrawing } from "../utils/ControlUtils"; +import { describeFeatureType, getFeatureURL } from '../api/WFS'; +import { extractGeometryAttributeName } from '../utils/WFSLayerUtils'; +import { mergeOptionsByOwner, removeAdditionalLayer } from '../actions/additionallayers'; +import { highlightStyleSelector } from '../selectors/mapInfo'; +import { layersSelector, groupsSelector } from '../selectors/layers'; +import { flattenArrayOfObjects, getInactiveNode } from '../utils/LayersUtils'; + +import { optionsToVendorParams } from '../utils/VendorParamsUtils'; +import { selectLayersSelector, isSelectEnabled, filterLayerForSelect, isSelectQueriable, getSelectQueryMaxFeatureCount, getSelectHighlightOptions } from '../selectors/select'; +import { SELECT_CLEAN_SELECTION, ADD_OR_UPDATE_SELECTION, addOrUpdateSelection } from '../actions/select'; +import { buildAdditionalLayerId, buildAdditionalLayerOwnerName, arcgisToGeoJSON, makeCrsValid, customUpdateAdditionalLayer } from '../utils/Select'; + +const queryLayer = (layer, geometry, selectQueryMaxCount) => { + switch (layer.type) { + case 'arcgis': { + const parsedGeometry = JSON.stringify({ + spatialReference: { wkid: geometry.projection.split(':')[1] }, + ...(geometry.type === 'Point' + ? { x: geometry.coordinates[0], y: geometry.coordinates[1] } + : (geometry.type === 'LineString' ? + { 'paths': [geometry.coordinates] } : + { 'rings': geometry.coordinates } + ) + ) + }); + const geometryType = geometry.type === 'Point' ? "esriGeometryPoint" : (geometry.type === 'LineString' ? 'esriGeometryPolyline' : 'esriGeometryPolygon'); + const singleLayerId = parseInt(layer.name ?? '', 10); + return Promise.all((Number.isInteger(singleLayerId) ? layer.options.layers.filter(l => l.id === singleLayerId) : layer.options.layers).map(l => axios.get(`${layer.url}/${l.id}`, { params: { f: 'json'} }) + .then(describe => + axios.get(`${layer.url}/${l.id}/query`, { + params: assign({ + f: "json", + geometry: parsedGeometry, + geometryType: geometryType, + spatialRel: "esriSpatialRelIntersects", + where: '1=1', + outFields: '*' + }, describe.data.advancedQueryCapabilities.supportsPagination && selectQueryMaxCount > -1 ? { resultRecordCount: selectQueryMaxCount } : {} + )}) + .then(response => ({ features: arcgisToGeoJSON(response.data.features, describe.data.name, response.data.fields.find(field => field.type === 'esriFieldTypeOID')?.name ?? response.data.objectIdFieldName ?? 'objectid'), crs: makeCrsValid(describe.data.sourceSpatialReference.wkid.toString()) })) + .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }) + ))) + .then(responses => responses.reduce((acc, response) => { + const features = [...acc.features, ...response.features]; + return {...acc, ...{ + features: selectQueryMaxCount > -1 && features.length > selectQueryMaxCount ? features.slice(0, selectQueryMaxCount) : features, + totalFeatures: acc.totalFeatures + response.features.length, + numberMatched: acc.numberMatched + response.features.length, + numberReturned: acc.numberReturned + response.features.length + }}; + }, { + type: "FeatureCollection", + features: [], + totalFeatures: 0, + numberMatched: 0, + numberReturned: 0, + timeStamp: new Date().toISOString(), + crs: { + type: "name", + properties: { + name: makeCrsValid(responses[0].crs.toString()) // All layer crs in a MapServer/FeatureServer are the same + } + } + })) + .catch(err => { + throw new Error(`Error while querying layer: ${err.message}`); + }) + ; + } + case 'wms': + case 'wfs': { + return describeFeatureType(layer.url, layer.name) + .then(describe => axios + .get(getFeatureURL(layer.url, layer.name, + optionsToVendorParams({ + filterObj: { + spatialField: { + operation: "INTERSECTS", + attribute: extractGeometryAttributeName(describe), + geometry: geometry + } + } + }) + ), { params: assign({ outputFormat: 'application/json' }, + selectQueryMaxCount > -1 ? { + maxFeatures: selectQueryMaxCount, // WFS v1.1.0 + count: selectQueryMaxCount + } : {} + )}) + .then(response => assign(response.data, response.data.crs === null ? {} : + { + crs: { + type: response.data.crs.type, + properties: {...response.data.crs.properties, [response.data.crs.type]: makeCrsValid(response.data.crs.properties[response.data.crs.type])} + } + }) + ) + .catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }) + ).catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }); + } + default: + return new Promise((_, reject) => reject(new Error(`Unsupported layer type: ${layer.type}`))); + } +}; + +export const openSelectEpic = (action$, store) => action$ + .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) + .filter(action => action.control === "select" && isSelectEnabled(store.getState())) + .switchMap(() => Observable.merge( + Observable.of(registerEventListener('click', 'select')), + ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: layer.visibility }))) + )); + +export const closeSelectEpics = (action$, store) => action$ + .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) + .filter(action => action.control === "select" && !isSelectEnabled(store.getState())) + .switchMap(() => Observable.merge( + Observable.of(unRegisterEventListener('click', 'select')), + Observable.of(changeDrawingStatus("clean", "", "select", [], {})), + ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: false })))) + ); + +export const tearDownSelectOnDrawToolActive = (action$, store) => shutdownToolOnAnotherToolDrawing(action$, store, 'select'); + +export const queryLayers = (action$, store) => action$ + .ofType(END_DRAWING) + .filter(action => + action.owner === 'select' && + isSelectEnabled(store.getState()) && + action.geometry + ) + .switchMap(action => { + const state = store.getState(); + const selectQueryMaxCount = getSelectQueryMaxFeatureCount(state); + return Observable.from(selectLayersSelector(state)) + .mergeMap(layer => Observable.concat( + Observable.of(addOrUpdateSelection(layer, {})), + isSelectQueriable(layer) + ? Observable.concat( + Observable.of(addOrUpdateSelection(layer, { loading: true })), + Observable.fromPromise(queryLayer(layer, action.geometry, selectQueryMaxCount)) + .map(geoJsonData => addOrUpdateSelection(layer, geoJsonData)) + .catch(error => Observable.of(addOrUpdateSelection(layer, { error }))) + ) + : Observable.empty() + )); + }); + +export const cleanSelection = (action$, store) => action$ + .ofType(SELECT_CLEAN_SELECTION) + .filter(() => isSelectEnabled(store.getState())) + .switchMap(action => Observable.merge( + Observable.of( + changeDrawingStatus( + action.geomType ? "start" : "clean", + action.geomType || "", + "select", + [], + action.geomType ? { + stopAfterDrawing: true, + editEnabled: false, + drawEnabled: false + } : {} + ) + ), + Observable.from(selectLayersSelector(store.getState())).flatMap(layer => + Observable.merge( + Observable.of(addOrUpdateSelection(layer, {})), + Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { + features: [], + visibility: false + })) + ) + ) + )); + +export const synchroniseLayersAndAdditionalLayers = (action$, store) => action$ + .filter(action => action.type === UPDATE_NODE + && isSelectEnabled(store.getState()) + && Object.hasOwn(action.options || {}, 'visibility') + ) + .concatMap(action => { + const state = store.getState(); + const layersForSelect = layersSelector(state).filter(filterLayerForSelect); + + if (layersForSelect?.find(layer => layer.id === action.node)) { + return Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(action.node), { visibility: action.options.visibility })); + } + + const groups = flattenArrayOfObjects(groupsSelector(state)); + return Observable.from(layersForSelect.filter(layer => layer.group?.startsWith(action.node))) + .mergeMap(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: !getInactiveNode(layer.group, groups) })) + ); + }); + +export const onRemoveLayer = (action$, store) => action$ + .ofType(REMOVE_NODE) + .filter(action => isSelectEnabled(store.getState()) + && action.nodeType === 'layers' + ) + .mergeMap(action => Observable.of(removeAdditionalLayer({ id: buildAdditionalLayerId(action.node), owner: buildAdditionalLayerOwnerName(action.node) }))); + + +export const onSelectionUpdate = (action$, store) => action$ + .ofType(ADD_OR_UPDATE_SELECTION) + .filter(action => isSelectEnabled(store.getState()) && action.layer) + .mergeMap(action => Observable.of(customUpdateAdditionalLayer( + action.layer.id, + action.geoJsonData.features ?? [], + action.layer.visibility && action.geoJsonData.error && !action.geoJsonData.loading, + { ...highlightStyleSelector(store.getState()), ...getSelectHighlightOptions(store.getState())} + ))); + +export default { + openSelectEpic, + closeSelectEpics, + tearDownSelectOnDrawToolActive, + queryLayers, + cleanSelection, + synchroniseLayersAndAdditionalLayers, + onRemoveLayer, + onSelectionUpdate +}; diff --git a/web/client/plugins/Select.jsx b/web/client/plugins/Select.jsx new file mode 100644 index 00000000000..956dd03e650 --- /dev/null +++ b/web/client/plugins/Select.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { get } from 'lodash'; +import { Glyphicon } from 'react-bootstrap'; + +import { createPlugin } from '../utils/PluginsUtils'; +import { layersSelector } from '../selectors/layers'; +import { updateNode, addLayer, changeLayerProperties } from '../actions/layers'; +import { zoomToExtent } from '../actions/map'; +import controls from '../reducers/controls'; +import { toggleControl } from '../actions/controls'; +import Message from '../components/I18N/Message'; + +import SelectComponent from './select/components/Select'; +import epics from '../epics/select'; +import select from '../reducers/select'; +import { storeConfiguration, cleanSelection, addOrUpdateSelection } from '../actions/select'; +import { getSelectSelections, getSelectQueryMaxFeatureCount } from '../selectors/select'; + +export default createPlugin('Select', { + component: connect( + createSelector([ + (state) => get(state, 'controls.select.enabled'), + layersSelector, + getSelectSelections, + getSelectQueryMaxFeatureCount + ], (isVisible, layers, selections, maxFeatureCount) => ({ + isVisible, + layers, + selections, + maxFeatureCount + })), + { + onClose: toggleControl.bind(null, 'select', null), + onUpdateNode: updateNode, + storeConfiguration, + cleanSelection, + addOrUpdateSelection, + zoomToExtent, + addLayer, + changeLayerProperties + } + )(SelectComponent), + options: { + disablePluginIf: "{state('router') && (state('router').endsWith('new') || state('router').includes('newgeostory') || state('router').endsWith('dashboard'))}" + }, + reducers: { + ...controls, + select + }, + epics: epics, + containers: { + BurgerMenu: { + name: 'select', + position: 1000, + priority: 2, + doNotHide: true, + text: , + tooltip: , + icon: , + action: toggleControl.bind(null, 'select', null), + toggle: true + }, + SidebarMenu: { + name: 'select', + position: 1000, + priority: 1, + doNotHide: true, + text: , + tooltip: , + icon: , + action: toggleControl.bind(null, 'select', null), + toggle: true + }, + Toolbar: { + name: 'select', + alwaysVisible: true, + position: 2, + priority: 0, + doNotHide: true, + tooltip: , + icon: , + action: toggleControl.bind(null, 'select', null), + toggle: true + } + } +}); diff --git a/web/client/plugins/select/assets/select.css b/web/client/plugins/select/assets/select.css new file mode 100644 index 00000000000..a59e6e5abd8 --- /dev/null +++ b/web/client/plugins/select/assets/select.css @@ -0,0 +1,40 @@ +.ms-resizable-modal > .modal-content.select-dialog { + top: 0vh; + right: -100vw; +} + +.select-content * .ms-node-title { + font-weight: bold; +} + +.select-content * .ms-node-header-info > .ms-node-header-addons:nth-child(3) { + flex: 1 ; + justify-content: space-between; +} + +.features-count-displayer{ + display: flex; +} + +.title-container { + display: flex; +} + +.title-icon { + height: 100%; + width: auto; + margin-right: 0.5em; +} + +.title-title { + flex-grow: 1; + text-align: center; +} + +.tree-header { + background-color: #E9EDF4; +} + +.features-count { + font-weight: bold; +} diff --git a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.css b/web/client/plugins/select/components/EllipsisButton/EllipsisButton.css new file mode 100644 index 00000000000..66025df79a5 --- /dev/null +++ b/web/client/plugins/select/components/EllipsisButton/EllipsisButton.css @@ -0,0 +1,57 @@ +.ellipsis-container { + position: relative; + display: inline-block; + opacity: 1; +} + +.ellipsis-button { + padding: 2%; + background-color: lightgray; + /* border: none; */ + border: 1px solid #ccc; + border-radius: 50%; + /* font-size: 16px; */ + font-weight: bold; + cursor: pointer; + text-align: center; + line-height: 1; +} + +.ellipsis-button:hover { + background-color: #e0e0e0; +} + +.ellipsis-menu { + position: absolute; + top: 100%; + right: 0; + background-color: white; + border: 1px solid #ccc; + border-radius: 5px; + /* margin-top: 5px; */ + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + z-index: 1; + width: 10vw; +} + +.ellipsis-menu p { + margin: 0; + padding: 5%; + cursor: pointer; +} + +.ellipsis-menu p:hover { + background-color: #f0f0f0; +} + +.export-toggle { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 5px 10px; +} + +.export-toggle span:nth-of-type(2) { + font-weight: bold; +} diff --git a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx b/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx new file mode 100644 index 00000000000..79d67c611d6 --- /dev/null +++ b/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx @@ -0,0 +1,226 @@ +import React, { useState, useEffect, useContext } from 'react'; +import ReactDOM from "react-dom"; +import bbox from '@turf/bbox'; +import { saveAs } from 'file-saver'; +import axios from 'axios'; + +import Message from '../../../../components/I18N/Message'; +import { describeFeatureType } from '../../../../api/WFS'; + +import { SelectRefContext } from '../Select'; +import Statistics from './Statistics/Statistics'; +import './EllipsisButton.css'; + +export default ({ + node = {}, + layers = [], + selectionData = {}, + onAddOrUpdateSelection = () => {}, + onZoomToExtent = () => {}, + onAddLayer = () => {}, + onChangeLayerProperties = () => {} +}) => { + const [menuOpen, setMenuOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [statisticsOpen, setStatisticsOpen] = useState(false); + const [numericFields, setNumericFields] = useState([]); + + const SelectRef = useContext(SelectRefContext); + const ellipsisContainerClass = 'ellipsis-container'; + useEffect(() => { + const selectElement = SelectRef.current?.addEventListener ? SelectRef.current : ReactDOM.findDOMNode(SelectRef.current); + if (!selectElement || !selectElement.addEventListener) { return null; } + const handleClick = e => { + if (menuOpen) { + let parentElement = e.target; + let foundThis = false; + while (!foundThis && parentElement !== e.currentTarget) { + foundThis = parentElement.className === ellipsisContainerClass; + parentElement = parentElement.parentElement; + } + if (!foundThis) { setMenuOpen(false); } + } + }; + selectElement.addEventListener("click", handleClick); + return () => selectElement.removeEventListener("click", handleClick); + }); + + const toggleMenu = () => setMenuOpen(!menuOpen); + const toggleExport = () => setExportOpen(!exportOpen); + + const triggerAction = (action) => { + switch (action) { + case 'clear': { + onAddOrUpdateSelection(node, {}); + break; + } + case 'zoomTo': { + if (selectionData.features?.length > 0) { + const extent = bbox(selectionData); + if (extent) { onZoomToExtent(extent, selectionData.crs.properties[selectionData.crs.type]); } + } + break; + } + case 'createLayer': { + if (selectionData.features?.length > 0) { + const nodeName = node.title + '_Select _'; + let index = 0; + let notFound = false; + while (!notFound) { + index++; + // eslint-disable-next-line no-loop-func + notFound = layers.findIndex(layer => layer.name === (nodeName + index.toString())) === -1; + } + onAddLayer({ + type: 'vector', + visibility: true, + name: nodeName + index.toString(), + hideLoading: true, + // bbox: { + // bounds: bbox({ + // type: "FeatureCollection", + // features: selectionData.features + // }), + // crs: node.bbox.crs + // }, + features: selectionData.features + }); + } + break; + } + case 'exportToGeoJson': { + if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData)], { type: 'application/json' }), node.title + '.json'); } + break; + } + case 'exportToJson': { + if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData.features.map(feature => feature.properties))], { type: 'application/json' }), node.title + '.json'); } + break; + } + case 'exportToCsv': { + if (selectionData.features?.length > 0) { saveAs(new Blob([Object.keys(selectionData.features[0].properties).join(',') + '\n' + selectionData.features.map(feature => Object.values(feature.properties).join(',')).join('\n')], { type: 'text/csv' }), node.title + '.csv'); } + break; + } + case 'filterData': { + const customOnChangeLayerProperties = fieldIdName => onChangeLayerProperties(node.id, { + layerFilter: { + // searchUrl: null, + // featureTypeConfigUrl: null, + // showGeneratedFilter: false, + // attributePanelExpanded: true, + // spatialPanelExpanded: false, + // crossLayerExpanded: false, + // showDetailsPanel: false, + // groupLevels: 5, + // useMapProjection: false, + // toolbarEnabled: true, + groupFields: [ + { + id: 1, + logic: 'OR', + index: 0 + } + ], + // maxFeaturesWPS: 5, + filterFields: selectionData.features.map(feature => ({ + rowId: new Date().getDate(), + groupId: 1, + attribute: fieldIdName, + operator: '=', + value: feature.properties[fieldIdName], + type: 'number', + fieldOptions: { + valuesCount: 0, + currentPage: 1 + }, + exception: null + })) + // spatialField: null, + // simpleFilterFields: [], + // map: null, + // filters: [], + // crossLayerFilter: null, + // autocompleteEnabled: true + } + }); + switch (node.type) { + case 'arcgis': { + // TODO : implement here when MapStore supports filtering for arcgis services + throw new Error(`Unsupported layer type: ${node.type}`); + // break; + } + case 'wms': + case 'wfs': { + describeFeatureType(node.url, node.name) + .then(describe => customOnChangeLayerProperties(describe.featureTypes.find(featureType => node.name.endsWith(featureType.typeName)).properties.find(property => ['xsd:string', 'xsd:int'].find(type => type === property.type) && !property.nillable && property.maxOccurs === 1 && property.minOccurs === 1).name)) + .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }); + break; + } + default: + throw new Error(`Unsupported layer type: ${node.type}`); + } + break; + } + default: + } + toggleMenu(); + }; + + useEffect(() => { + switch (node.type) { + case 'arcgis': { + const arcgisNumericFields = new Set([ 'esriFieldTypeSmallInteger', 'esriFieldTypeInteger', 'esriFieldTypeSingle', 'esriFieldTypeDouble' ]); + const singleLayerId = parseInt(node.name ?? '', 10); + Promise.all((Number.isInteger(singleLayerId) ? node.options.layers.filter(l => l.id === singleLayerId) : node.options.layers).map(l => axios.get(`${node.url}/${l.id}`, { params: { f: 'json'} }) + .then(describe => describe.data.fields.filter(field => field.domain === null && arcgisNumericFields.has(field.type)).map(field => field.name)) + .catch(() => []) + )) + .then(responses => setNumericFields(responses.map(response => response ?? []).flat())) + .catch(() => setNumericFields([])); + break; + } + case 'wms': + case 'wfs': { + describeFeatureType(node.url, node.name) + .then(describe => setNumericFields(describe.featureTypes[0].properties.filter(property => property.localType === 'number').map(property => property.name))) + .catch(() => setNumericFields([])); + break; + } + default: + } + }, []); + + return ( +
    + + {menuOpen && ( +
    +

    triggerAction('zoomTo')}>

    +

    { toggleMenu(); selectionData.features?.length > 0 ? setStatisticsOpen(true) : null;}}>

    +

    triggerAction('createLayer')}>

    + {node.type !== 'arcgis' &&

    triggerAction('filterData')}>

    } +
    +

    + + {exportOpen ? "−" : "+"} +

    + {exportOpen && ( +
    +

    triggerAction('exportToGeoJson')}> -

    +

    triggerAction('exportToJson')}> -

    +

    triggerAction('exportToCsv')}> -

    +
    + )} +
    +

    triggerAction('clear')}>

    +
    + )} + {statisticsOpen && } +
    + ); +}; diff --git a/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.css b/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.css new file mode 100644 index 00000000000..ff8abea2a20 --- /dev/null +++ b/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.css @@ -0,0 +1,36 @@ +.feature-statistics { + display: flex; + flex-direction: column; + padding: 1rem; + width: 100%; + } + + .select-container { + display: flex; + width: 100%; + align-items: center; + } + + .select-container label { + font-weight: bold; + margin-right: 0.5rem; + } + + .select-container select { + flex-grow: 1; + padding: 0.5rem; + border: 1px solid #ccc; + } + + .statistics-table { + width: 100%; + margin-top: 1rem; + } + + .statistics-table td { + padding: 0.5rem; + } + + .statistics-table td:first-child { + font-weight: bold; + } diff --git a/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.jsx b/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.jsx new file mode 100644 index 00000000000..0b04a81a036 --- /dev/null +++ b/web/client/plugins/select/components/EllipsisButton/Statistics/Statistics.jsx @@ -0,0 +1,75 @@ +import React, { useState, useMemo } from 'react'; + +import Message from '../../../../../components/I18N/Message'; +import Portal from '../../../../../components/misc/Portal'; +import ResizableModal from '../../../../../components/misc/ResizableModal'; + +import './Statistics.css'; + +export default ({ + fields = [], + features = [], + setStatisticsOpen = () => {} +}) => { + const [selectedField, setSelectedField] = useState(fields.length > 0 ? fields[0] : null); + + const statistics = useMemo(() => { + if (!selectedField) return null; + + const values = features.map(f => f.properties[selectedField]).filter(v => typeof v === "number"); + if (values.length === 0) return null; + + const sum = values.reduce((acc, val) => acc + val, 0); + const min = Math.min(...values); + const max = Math.max(...values); + const mean = sum / values.length; + const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length; + const stdDev = Math.sqrt(variance); + + return { count: values.length, sum, min, max, mean, stdDev }; + }, [features, selectedField]); + + return ( + + } + size="sm" + // eslint-disable-next-line react/jsx-boolean-value + show={true} + onClose={() => setStatisticsOpen(false)} + // draggable={true} + buttons={[{ + text: , + onClick: () => setStatisticsOpen(false), + bsStyle: 'primary' + }]}> +
    +
    + + +
    + + {statistics && ( + + + + + + + + + +
    {statistics.count}
    {statistics.sum.toFixed(6)}
    {statistics.min.toFixed(6)}
    {statistics.max.toFixed(6)}
    {statistics.mean.toFixed(6)}
    {statistics.stdDev.toFixed(6)}
    + )} +
    +
    +
    + ); +}; diff --git a/web/client/plugins/select/components/Select.jsx b/web/client/plugins/select/components/Select.jsx new file mode 100644 index 00000000000..9bcf12dea9f --- /dev/null +++ b/web/client/plugins/select/components/Select.jsx @@ -0,0 +1,144 @@ +import React, { useEffect, createContext, useRef } from 'react'; +import { injectIntl } from 'react-intl'; +import { Glyphicon } from 'react-bootstrap'; + +import { ControlledTOC } from '../../TOC/components/TOC'; +import ResizableModal from '../../../components/misc/ResizableModal'; +import Message from '../../../components/I18N/Message'; +import VisibilityCheck from '../../TOC/components/VisibilityCheck'; +import NodeHeader from '../../TOC/components/NodeHeader'; +import { getLayerTypeGlyph } from '../../../utils/LayersUtils'; +import NodeTool from '../../TOC/components/NodeTool'; +import InlineLoader from '../../TOC/components/InlineLoader'; + +import SelectHeader from './SelectHeader/SelectHeader'; +import EllipsisButton from './EllipsisButton/EllipsisButton'; +import { isSelectQueriable, filterLayerForSelect } from '../../../selectors/select'; +import '../assets/select.css'; + +export const SelectRefContext = createContext(null); + +function applyVersionParamToLegend(layer) { + // we need to pass a parameter that invalidate the cache for GetLegendGraphic + // all layer inside the dataset viewer apply a new _v_ param each time we switch page + return { ...layer, legendParams: { ...layer?.legendParams, _v_: layer?._v_ } }; +} + +export default injectIntl(({ + layers, + onUpdateNode, + onClose, + isVisible, + highlightOptions, + queryOptions, + selectTools, + storeConfiguration, + intl, + selections, + maxFeatureCount, + cleanSelection, + addOrUpdateSelection, + zoomToExtent, + addLayer, + changeLayerProperties +}) => { + const SelectRef = useRef(null); + const filterLayers = layers.filter(filterLayerForSelect); + const customLayerNodeComponent = ({node, config}) => { + const selectionData = selections[node.id] ?? {}; + return ( +
  • + + + onUpdateNode(node.id, 'layers', { isSelectQueriable: checked })} + /> + + + } + afterTitle={ + <> + {selectionData.error ? ( + + ) : ( +
    + {selectionData.features && selectionData.features.length === maxFeatureCount && } /* tooltip={"ouech"} */ glyph="exclamation-mark"/>} +

    {selectionData.loading ? '⊙' : (selectionData.features?.length ?? 0)}

    +
    + )} + + {selectionData.features?.length > 0 && } + + } + /> +
  • + ); + }; + + useEffect(() => storeConfiguration({ highlightOptions, queryOptions }), []); + + return ( + + + icon + + + + } + dialogClassName=" select-dialog" + show={isVisible} + // eslint-disable-next-line react/jsx-boolean-value + draggable={true} + style={{zIndex: 1993}}> + + Object.fromEntries(Object.entries(applyVersionParamToLegend(layer)).filter(([key]) => key !== 'group'))).reverse()} + className="select-content" + theme="legend" + layerNodeComponent={customLayerNodeComponent} + treeHeader={ +
  • + filterLayers.forEach(layer => onUpdateNode(layer.id, 'layers', { isSelectQueriable: checked }))} + /> + } + afterTitle={} + /> +
  • + } + /> +
    +
    + ); +}); diff --git a/web/client/plugins/select/components/SelectHeader/SelectHeader.css b/web/client/plugins/select/components/SelectHeader/SelectHeader.css new file mode 100644 index 00000000000..ce73fcf9121 --- /dev/null +++ b/web/client/plugins/select/components/SelectHeader/SelectHeader.css @@ -0,0 +1,91 @@ +.select-header-container { + margin: 2%; +} + +.head-text { + font-size: small; + font-weight: bold; +} + +.select-header { + display: flex; + justify-content: space-between; + gap: 5%; +} + +.select-button-container { + position: relative; + flex: 1; + max-width: 65%; + border: none; +} + +.select-button { + /* background-color: #005232; */ + background-color: white; + border: 1px solid #F1F1F1; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 2% 5%; + /* color: white; */ + cursor: pointer; +} + +.select-button:hover { + /* background-color: #016e44; */ + background-color: #e0e0e0; +} + +.select-button-text { + flex: 1; + text-align: center; +} + +.select-button-arrow { + margin-left: auto; +} + +.select-button-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: white; + border: 1px solid #ccc; + border-radius: 5px; + margin-top: 1%; + z-index: 1; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.select-button-menu p { + padding: 2%; + margin: 0; + cursor: pointer; +} + +.select-button-menu p:hover { + background-color: #f0f0f0; +} + +.clear-select-button { + padding: 10px 15px; + /* background-color: lightgray; */ + background-color: white; + color: black; + border: 1px solid #989898; + border-radius: 5px; + cursor: pointer; +} + +.clear-select-button:hover { + background-color: #e0e0e0; +} + +.selection { + margin-bottom: 2%; + font-weight: bold; +} diff --git a/web/client/plugins/select/components/SelectHeader/SelectHeader.jsx b/web/client/plugins/select/components/SelectHeader/SelectHeader.jsx new file mode 100644 index 00000000000..a007aeb606d --- /dev/null +++ b/web/client/plugins/select/components/SelectHeader/SelectHeader.jsx @@ -0,0 +1,84 @@ +import React, { useState, useEffect, useContext } from 'react'; +import ReactDOM from "react-dom"; +import { Glyphicon } from 'react-bootstrap'; + +import Message from '../../../../components/I18N/Message'; +import InlineLoader from '../../../../plugins/TOC/components/InlineLoader'; + +import { SelectRefContext } from '../Select'; +import './SelectHeader.css'; + +export default ({ + onCleanSelect, + selectTools +}) => { + const [menuOpen, setMenuOpen] = useState(false); + const [menuClosing, setMenuClosing] = useState(false); + const [selectedTool, setSelectedTool] = useState(null); + + const selectRef = useContext(SelectRefContext); + useEffect(() => { + const selectElement = selectRef.current?.addEventListener ? selectRef.current : ReactDOM.findDOMNode(selectRef.current); + if (!selectElement || !selectElement.addEventListener) { return null; } + const handleClick = () => setMenuClosing(true); + selectElement.addEventListener("click", handleClick); + return () => selectElement.removeEventListener("click", handleClick); + }); + useEffect(() => { + if (menuClosing) { + setMenuClosing(false); + if (menuOpen) { + setTimeout(() => setMenuOpen(false), 50); // In order that onCleanSelect has the time to trigger its action. + } + } + }, [menuClosing]); + + const toggleMenu = () => setMenuOpen(!menuOpen); + + const clean = tool => { + setMenuOpen(false); + if (tool) setSelectedTool(tool); + onCleanSelect(tool?.action ?? null); + }; + + const clearSelection = () => { + clean(); + setSelectedTool(null); + }; + + const allTools = [ + { type: 'Point', action: 'Point', label: 'select.button.selectByPoint', icon: '1-point' }, + { type: 'LineString', action: 'LineString', label: 'select.button.selectByLine', icon: 'polyline' }, + { type: 'Circle', action: 'Circle', label: 'select.button.selectByCircle', icon: '1-circle' }, + { type: 'Rectangle', action: 'BBOX', label: 'select.button.selectByRectangle', icon: 'unchecked' }, + { type: 'Polygon', action: 'Polygon', label: 'select.button.selectByPolygon', icon: 'polygon' } + ]; + const availableTools = allTools.filter(tool => !Array.isArray(selectTools) || selectTools.includes(tool.type === 'LineString' ? 'Line' : tool.type)); + const orderedTools = selectedTool ? [availableTools.find(tool => tool.type === selectedTool.type), ...availableTools.filter(tool => tool.type !== selectedTool.type)] : availableTools; + + return ( +
    +
    +
    +
    + + {menuOpen && ( +
    + {orderedTools.map(tool =>

    clean(tool)}>{' '}

    )} +
    + )} +
    + +
    +   + +   +
    +
    + ); +}; diff --git a/web/client/reducers/select.js b/web/client/reducers/select.js new file mode 100644 index 00000000000..08db0c760f0 --- /dev/null +++ b/web/client/reducers/select.js @@ -0,0 +1,20 @@ +import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION } from '../actions/select'; + +export default function select(state = {cfg: {}, selections: {}}, action) { + switch (action.type) { + case SELECT_STORE_CFG: { + return { + ...state, + cfg: action.cfg + }; + } + case ADD_OR_UPDATE_SELECTION: { + return { + ...state, + selections: {...state.selections, [action.layer.id]: action.geoJsonData} + }; + } + default: + return state; + } +} diff --git a/web/client/selectors/select.js b/web/client/selectors/select.js new file mode 100644 index 00000000000..84a0cc28460 --- /dev/null +++ b/web/client/selectors/select.js @@ -0,0 +1,19 @@ +import { get } from 'lodash'; + +export const filterLayerForSelect = layer => layer && layer.group !== 'background' && ['wms', 'wfs', 'arcgis'].includes(layer.type); + +export const selectLayersSelector = state => (get(state, 'layers.flat') || []).filter(filterLayerForSelect); + +export const isSelectEnabled = state => get(state, "controls.select.enabled"); + +export const hasSelectQueriableProp = node => Object.hasOwn(node, 'isSelectQueriable'); +export const isSelectQueriable = node => hasSelectQueriableProp(node) ? node.isSelectQueriable : !!node?.visibility; + +export const getSelectObj = state => get(state, 'select') ?? {}; +export const getSelectQueryOptions = state => getSelectObj(state).cfg?.queryOptions ?? {}; +export const getSelectQueryMaxFeatureCount = state => { + const queryOptions = getSelectQueryOptions(state); + return Number.isInteger(queryOptions.maxCount) ? queryOptions.maxCount : -1; +}; +export const getSelectHighlightOptions = state => getSelectObj(state).cfg?.highlightOptions ?? {}; +export const getSelectSelections = state => getSelectObj(state).selections ?? {}; diff --git a/web/client/utils/Select.js b/web/client/utils/Select.js new file mode 100644 index 00000000000..2212f19eaf6 --- /dev/null +++ b/web/client/utils/Select.js @@ -0,0 +1,63 @@ +import { updateAdditionalLayer } from '../actions/additionallayers'; +import { applyMapInfoStyle } from '../selectors/mapInfo'; + +export const buildAdditionalLayerName = layerId => `"highlight-select-${layerId}-features"`; +export const buildAdditionalLayerOwnerName = layerId => `Select_${layerId}`; +export const buildAdditionalLayerId = layerId => `${buildAdditionalLayerOwnerName(layerId)}_id`; + +function getGeometryType(geometry) { + if (geometry.x !== undefined && geometry.y !== undefined) { + return "Point"; + } else if (geometry.paths) { + return "LineString"; + } else if (geometry.rings) { + return "Polygon"; + } + return null; +} + +function convertCoordinates(geometry) { + if (geometry.x !== undefined && geometry.y !== undefined) { + return [geometry.x, geometry.y]; + } else if (geometry.paths) { + return geometry.paths[0]; + } else if (geometry.rings) { + return geometry.rings; + } + return null; +} + +export const makeCrsValid = crs => { + const crsSplit = crs.toString().split(':'); + const crsSplitLength = crsSplit.length; + if (crsSplitLength === 1) { + return 'EPSG' + ':' + crsSplit[0]; + } else if (crsSplitLength > 1) { + const geodeticIndex = crsSplit.lastIndexOf(s => s.length > 0, crsSplitLength - 2); + return (geodeticIndex > -1 ? crsSplit[geodeticIndex] : 'EPSG') + ':' + crsSplit[crsSplitLength - 1]; + } + return 'EPSG:4326'; +}; + +export const arcgisToGeoJSON = (arcgisFeatures, layerName, idField) => arcgisFeatures.map(feature => ({ + type: "Feature", + id: `${layerName}.${feature.attributes[idField]}`, + geometry_name: "geometry", + geometry: { + type: getGeometryType(feature.geometry), + coordinates: convertCoordinates(feature.geometry) + }, + properties: feature.attributes +})); + +export const customUpdateAdditionalLayer = (layerId, features, isVisible, highlightStyle) => updateAdditionalLayer( + buildAdditionalLayerId(layerId), + buildAdditionalLayerOwnerName(layerId), + "overlay", + { + type: "vector", + name: buildAdditionalLayerName(layerId), + visibility: isVisible, + features: features.map(applyMapInfoStyle(highlightStyle)) + } +); From 3358fb22d34506223b6e528bdf05e3df930bb341 Mon Sep 17 00:00:00 2001 From: pln Date: Fri, 6 Jun 2025 10:40:55 +0200 Subject: [PATCH 39/76] =?UTF-8?q?Select=20=E2=86=92=20LayersSelection=20in?= =?UTF-8?q?=20translations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/client/translations/data.de-DE.json | 4 ++-- web/client/translations/data.en-US.json | 6 +++--- web/client/translations/data.es-ES.json | 4 ++-- web/client/translations/data.fr-FR.json | 4 ++-- web/client/translations/data.it-IT.json | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index a1504cd7a9e..5f9fc15289c 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -1580,7 +1580,7 @@ } } }, - "select": { + "layersSelection": { "title": "Auswählen", "tooltip": "Auswahlwerkzeug anzeigen", "description": "Auswahlwerkzeug anzeigen", @@ -3612,7 +3612,7 @@ "description": "Ermöglicht das Erstellen eines Permalinks der aktuell angezeigten Ressource", "title": "Permalink" }, - "Select": { + "LayersSelection": { "title": "Auswählen", "description": "Auswahlwerkzeug anzeigen" }, diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 470958a7082..4a43db3dcd9 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -1541,7 +1541,7 @@ } } }, - "select": { + "layersSelection": { "title": "Select", "tooltip": "Display the selection tool", "description": "Display the selection tool", @@ -3583,9 +3583,9 @@ "description": "Allows to create a permalink of the current resource in view", "title": "Permalink" }, - "Select": { + "LayersSelection": { "title": "Select", - "description": "Kartenlegende anzeigen" + "description": "Display the selection tool" }, "StreetView": { "title": "Street View", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index 29a5366b5c5..6221ff9703e 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -1541,7 +1541,7 @@ } } }, - "select": { + "layersSelection": { "title": "Seleccionar", "tooltip": "Mostrar la herramienta de selección", "description": "Mostrar la herramienta de selección", @@ -3573,7 +3573,7 @@ "description": "Permite crear un enlace permanente del recurso actual a la vista", "title": "Enlace permanente" }, - "Select": { + "LayersSelection": { "title": "Seleccionar", "description": "Mostrar la herramienta de selección" }, diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 93ec842f12d..289f8b3f44f 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -1542,7 +1542,7 @@ } } }, - "select": { + "layersSelection": { "title": "Sélectionner", "tooltip": "Afficher l'outil de sélection", "description": "Afficher l'outil de sélection", @@ -3574,7 +3574,7 @@ "description": "Permet de créer un permalien de la ressource courante en vue", "title": "Lien permanent" }, - "Select": { + "LayersSelection": { "title": "Sélectionner", "description": "Afficher l'outil de sélection" }, diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 45d1ca87eb8..22e7636258c 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -1541,7 +1541,7 @@ } } }, - "select": { + "layersSelection": { "title": "Selezionare", "tooltip": "Mostra lo strumento di selezione", "description": "Mostra lo strumento di selezione", @@ -3575,7 +3575,7 @@ "description": "Permette di creare un permalink della risorsa attualmente in vista", "title": "Permalink" }, - "Select": { + "LayersSelection": { "title": "Selezionare", "description": "Mostra lo strumento di selezione" }, From 2767693edae719e4a81fcb38dfa236a34534a4da Mon Sep 17 00:00:00 2001 From: pln Date: Fri, 6 Jun 2025 10:43:20 +0200 Subject: [PATCH 40/76] =?UTF-8?q?Select=20=E2=86=92=20LayersSelection=20fo?= =?UTF-8?q?r=20configs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- project/standard/templates/configs/pluginsConfig.json | 6 +++--- web/client/configs/localConfig.json | 2 +- web/client/configs/pluginsConfig.json | 6 +++--- web/client/configs/simple.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json index d330878a0e1..3113d7b7735 100644 --- a/project/standard/templates/configs/pluginsConfig.json +++ b/project/standard/templates/configs/pluginsConfig.json @@ -147,10 +147,10 @@ "denyUserSelection": true }, { - "name": "Select", + "name": "LayersSelection", "glyph": "hand-down", - "title": "plugins.Select.title", - "description": "plugins.Select.description", + "title": "plugins.LayersSelection.title", + "description": "plugins.LayersSelection.description", "dependencies": [ "Toolbar", "BurgerMenu", diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 5e88631dea9..e848f09109d 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -406,7 +406,7 @@ } }, { - "name": "Select", + "name": "LayersSelection", "cfg": { "highlightOptions": { "color": "#3388ff", diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index 29e64a57209..d983d55a90d 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -149,10 +149,10 @@ "description": "plugins.Permalink.description" }, { - "name": "Select", + "name": "LayersSelection", "glyph": "hand-down", - "title": "plugins.Select.title", - "description": "plugins.Select.description", + "title": "plugins.LayersSelection.title", + "description": "plugins.LayersSelection.description", "dependencies": [ "Toolbar", "BurgerMenu", diff --git a/web/client/configs/simple.json b/web/client/configs/simple.json index a631230cac6..216ff2ced73 100644 --- a/web/client/configs/simple.json +++ b/web/client/configs/simple.json @@ -89,7 +89,7 @@ } }, { - "name": "SelectExtension", + "name": "LayersSelection", "cfg": { "highlightOptions": { "color": "#3388ff", From 0c8396c9b41f4cb8e6a656ee7c19df960485fdf4 Mon Sep 17 00:00:00 2001 From: pln Date: Fri, 6 Jun 2025 10:43:55 +0200 Subject: [PATCH 41/76] =?UTF-8?q?Select=20=E2=86=92=20LayersSelection=20fo?= =?UTF-8?q?r=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/client/product/plugins.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index 8ebf5d39a12..cd877d72ba1 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -95,6 +95,7 @@ export const plugins = { LanguagePlugin: toModulePlugin('Language', () => import(/* webpackChunkName: 'plugins/language' */ '../plugins/Language')), LayerDownload: toModulePlugin('LayerDownload', () => import(/* webpackChunkName: 'plugins/layerDownload' */ '../plugins/LayerDownload')), LayerInfoPlugin: toModulePlugin('LayerInfo', () => import(/* webpackChunkName: 'plugins/layerInfo' */ '../plugins/LayerInfo')), + LayersSelectionPlugin: toModulePlugin('LayersSelection', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/LayersSelection')), LocatePlugin: toModulePlugin('Locate', () => import(/* webpackChunkName: 'plugins/locate' */ '../plugins/Locate')), LongitudinalProfileToolPlugin: toModulePlugin('LongitudinalProfileTool', () => import(/* webpackChunkName: 'plugins/LongitudinalProfileTool' */ '../plugins/LongitudinalProfileTool')), ManagerMenuPlugin: toModulePlugin('ManagerMenu', () => import(/* webpackChunkName: 'plugins/managerMenu' */ '../plugins/manager/ManagerMenu')), @@ -125,7 +126,6 @@ export const plugins = { SidebarMenuPlugin: toModulePlugin('SidebarMenu', () => import(/* webpackChunkName: 'plugins/sidebarMenu' */ '../plugins/SidebarMenu')), SharePlugin: toModulePlugin('Share', () => import(/* webpackChunkName: 'plugins/share' */ '../plugins/Share')), PermalinkPlugin: toModulePlugin('Permalink', () => import(/* webpackChunkName: 'plugins/permalink' */ '../plugins/Permalink')), - SelectPlugin: toModulePlugin('Select', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Select')), SnapshotPlugin: toModulePlugin('Snapshot', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/Snapshot')), StreetView: toModulePlugin('StreetView', () => import(/* webpackChunkName: 'plugins/streetView' */ '../plugins/StreetView')), StyleEditor: toModulePlugin('StyleEditor', () => import(/* webpackChunkName: 'plugins/styleEditor' */ '../plugins/StyleEditor')), From 5bc14e278fabfe57e289528be11830cd7061925e Mon Sep 17 00:00:00 2001 From: pln Date: Fri, 6 Jun 2025 10:44:17 +0200 Subject: [PATCH 42/76] =?UTF-8?q?Select=20=E2=86=92=20LayersSelection=20fo?= =?UTF-8?q?r=20the=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{Select.jsx => LayersSelection.jsx} | 20 ++++++------- .../actions/layersSelection.js} | 0 .../assets/select.css | 0 .../EllipsisButton/EllipsisButton.css | 0 .../EllipsisButton/EllipsisButton.jsx | 20 ++++++------- .../EllipsisButton/Statistics/Statistics.css | 0 .../EllipsisButton/Statistics/Statistics.jsx | 16 +++++----- .../components/LayersSelection.jsx} | 10 +++---- .../LayersSelectionHeader.css} | 0 .../LayersSelectionHeader.jsx} | 14 ++++----- .../layersSelection/epics/layersSelection.js} | 30 +++++++++---------- .../reducers/layersSelection.js} | 2 +- .../selectors/layersSelection.js} | 0 .../layersSelection/utils/LayersSelection.js} | 4 +-- 14 files changed, 58 insertions(+), 58 deletions(-) rename web/client/plugins/{Select.jsx => LayersSelection.jsx} (78%) rename web/client/{actions/select.js => plugins/layersSelection/actions/layersSelection.js} (100%) rename web/client/plugins/{select => layersSelection}/assets/select.css (100%) rename web/client/plugins/{select => layersSelection}/components/EllipsisButton/EllipsisButton.css (100%) rename web/client/plugins/{select => layersSelection}/components/EllipsisButton/EllipsisButton.jsx (91%) rename web/client/plugins/{select => layersSelection}/components/EllipsisButton/Statistics/Statistics.css (100%) rename web/client/plugins/{select => layersSelection}/components/EllipsisButton/Statistics/Statistics.jsx (71%) rename web/client/plugins/{select/components/Select.jsx => layersSelection/components/LayersSelection.jsx} (95%) rename web/client/plugins/{select/components/SelectHeader/SelectHeader.css => layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css} (100%) rename web/client/plugins/{select/components/SelectHeader/SelectHeader.jsx => layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx} (86%) rename web/client/{epics/select.js => plugins/layersSelection/epics/layersSelection.js} (89%) rename web/client/{reducers/select.js => plugins/layersSelection/reducers/layersSelection.js} (91%) rename web/client/{selectors/select.js => plugins/layersSelection/selectors/layersSelection.js} (100%) rename web/client/{utils/Select.js => plugins/layersSelection/utils/LayersSelection.js} (91%) diff --git a/web/client/plugins/Select.jsx b/web/client/plugins/LayersSelection.jsx similarity index 78% rename from web/client/plugins/Select.jsx rename to web/client/plugins/LayersSelection.jsx index 956dd03e650..17659e6a1db 100644 --- a/web/client/plugins/Select.jsx +++ b/web/client/plugins/LayersSelection.jsx @@ -12,11 +12,11 @@ import controls from '../reducers/controls'; import { toggleControl } from '../actions/controls'; import Message from '../components/I18N/Message'; -import SelectComponent from './select/components/Select'; -import epics from '../epics/select'; -import select from '../reducers/select'; -import { storeConfiguration, cleanSelection, addOrUpdateSelection } from '../actions/select'; -import { getSelectSelections, getSelectQueryMaxFeatureCount } from '../selectors/select'; +import SelectComponent from './layersSelection/components/LayersSelection'; +import epics from './layersSelection/epics/layersSelection'; +import select from './layersSelection/reducers/layersSelection'; +import { storeConfiguration, cleanSelection, addOrUpdateSelection } from './layersSelection/actions/layersSelection'; +import { getSelectSelections, getSelectQueryMaxFeatureCount } from './layersSelection/selectors/layersSelection'; export default createPlugin('Select', { component: connect( @@ -56,8 +56,8 @@ export default createPlugin('Select', { position: 1000, priority: 2, doNotHide: true, - text: , - tooltip: , + text: , + tooltip: , icon: , action: toggleControl.bind(null, 'select', null), toggle: true @@ -67,8 +67,8 @@ export default createPlugin('Select', { position: 1000, priority: 1, doNotHide: true, - text: , - tooltip: , + text: , + tooltip: , icon: , action: toggleControl.bind(null, 'select', null), toggle: true @@ -79,7 +79,7 @@ export default createPlugin('Select', { position: 2, priority: 0, doNotHide: true, - tooltip: , + tooltip: , icon: , action: toggleControl.bind(null, 'select', null), toggle: true diff --git a/web/client/actions/select.js b/web/client/plugins/layersSelection/actions/layersSelection.js similarity index 100% rename from web/client/actions/select.js rename to web/client/plugins/layersSelection/actions/layersSelection.js diff --git a/web/client/plugins/select/assets/select.css b/web/client/plugins/layersSelection/assets/select.css similarity index 100% rename from web/client/plugins/select/assets/select.css rename to web/client/plugins/layersSelection/assets/select.css diff --git a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.css b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.css similarity index 100% rename from web/client/plugins/select/components/EllipsisButton/EllipsisButton.css rename to web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.css diff --git a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx similarity index 91% rename from web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx rename to web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx index 79d67c611d6..af4985c126a 100644 --- a/web/client/plugins/select/components/EllipsisButton/EllipsisButton.jsx +++ b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx @@ -7,7 +7,7 @@ import axios from 'axios'; import Message from '../../../../components/I18N/Message'; import { describeFeatureType } from '../../../../api/WFS'; -import { SelectRefContext } from '../Select'; +import { SelectRefContext } from '../LayersSelection'; import Statistics from './Statistics/Statistics'; import './EllipsisButton.css'; @@ -196,24 +196,24 @@ export default ({ {menuOpen && (
    -

    triggerAction('zoomTo')}>

    -

    { toggleMenu(); selectionData.features?.length > 0 ? setStatisticsOpen(true) : null;}}>

    -

    triggerAction('createLayer')}>

    - {node.type !== 'arcgis' &&

    triggerAction('filterData')}>

    } +

    triggerAction('zoomTo')}>

    +

    { toggleMenu(); selectionData.features?.length > 0 ? setStatisticsOpen(true) : null;}}>

    +

    triggerAction('createLayer')}>

    + {node.type !== 'arcgis' &&

    triggerAction('filterData')}>

    }

    - + {exportOpen ? "−" : "+"}

    {exportOpen && (
    -

    triggerAction('exportToGeoJson')}> -

    -

    triggerAction('exportToJson')}> -

    -

    triggerAction('exportToCsv')}> -

    +

    triggerAction('exportToGeoJson')}> -

    +

    triggerAction('exportToJson')}> -

    +

    triggerAction('exportToCsv')}> -

    )}
    -

    triggerAction('clear')}>

    +

    triggerAction('clear')}>

    )} {statisticsOpen && } + title={} size="sm" // eslint-disable-next-line react/jsx-boolean-value show={true} @@ -46,7 +46,7 @@ export default ({ }]}>
    - + setSelectedField(e.target.value)} - > - {fields.map((field) => ( ))} - -
    - - {statistics && ( - - - - - - - - - -
    {statistics.count}
    {statistics.sum.toFixed(6)}
    {statistics.min.toFixed(6)}
    {statistics.max.toFixed(6)}
    {statistics.mean.toFixed(6)}
    {statistics.stdDev.toFixed(6)}
    - )} -
    -
    - - ); -}; +import React, { useState, useMemo } from 'react'; + +import Message from '../../../../../components/I18N/Message'; +import Portal from '../../../../../components/misc/Portal'; +import ResizableModal from '../../../../../components/misc/ResizableModal'; + +/** + * A modal component that displays basic statistical calculations + * (count, sum, min, max, mean, standard deviation) + * for a selected numeric field from a list of features. + * + * @param {Object} props - Component props. + * @param {string[]} props.fields - List of available field names. + * @param {Object[]} props.features - List of GeoJSON features. + * @param {Function} props.setStatisticsOpen - Callback to close the statistics modal. + * @returns {JSX.Element} The rendered statistics modal. + */ +export default ({ + fields = [], + features = [], + setStatisticsOpen = () => {} +}) => { + const [selectedField, setSelectedField] = useState(fields.length > 0 ? fields[0] : null); + + const statistics = useMemo(() => { + if (!selectedField) return null; + + const values = features.map(f => f.properties[selectedField]).filter(v => typeof v === "number"); + if (values.length === 0) return null; + + const sum = values.reduce((acc, val) => acc + val, 0); + const min = Math.min(...values); + const max = Math.max(...values); + const mean = sum / values.length; + const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length; + const stdDev = Math.sqrt(variance); + + return { count: values.length, sum, min, max, mean, stdDev }; + }, [features, selectedField]); + + return ( + + } + size="sm" + show + onClose={() => setStatisticsOpen(false)} + // draggable={true} + buttons={[{ + text: , + onClick: () => setStatisticsOpen(false), + bsStyle: 'primary' + }]}> +
    +
    + + +
    + + {statistics && ( + + + + + + + + + +
    {statistics.count}
    {statistics.sum.toFixed(6)}
    {statistics.min.toFixed(6)}
    {statistics.max.toFixed(6)}
    {statistics.mean.toFixed(6)}
    {statistics.stdDev.toFixed(6)}
    + )} +
    +
    +
    + ); +}; diff --git a/web/client/plugins/layersSelection/index.js b/web/client/plugins/layersSelection/index.js index fe19e34d6c5..e616129b432 100644 --- a/web/client/plugins/layersSelection/index.js +++ b/web/client/plugins/layersSelection/index.js @@ -17,7 +17,6 @@ import epics from './epics/layersSelection'; import select from './reducers/layersSelection'; import { storeConfiguration, cleanSelection, addOrUpdateSelection } from './actions/layersSelection'; import { getSelectSelections, getSelectQueryMaxFeatureCount } from './selectors/layersSelection'; -import './layer-selection.less'; /** * Select plugin that enables layer feature selection in the map. diff --git a/web/client/plugins/layersSelection/layer-selection.less b/web/client/plugins/layersSelection/layer-selection.less deleted file mode 100644 index 9a81bbe976b..00000000000 --- a/web/client/plugins/layersSelection/layer-selection.less +++ /dev/null @@ -1,87 +0,0 @@ -@import '../../themes/default/variables.less'; -@import './assets/select.less'; -@import './components/EllipsisButton/EllipsisButton.less'; - -.select-header-container { - margin: 2%; -} - -.head-text { - color : @ms-tray-color; - font-weight: bold; -} - -.select-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 0 0 0; -} - -.select-button-container { - position: relative; - flex: 1; - max-width: 65%; - border: none; -} - - -.select-button { - background-color: @ms-main-bg; - border: 1px solid @ms-main-border-color; - border-radius: 5px; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 3px; - cursor: pointer; -} - -.select-button:hover { - background-color: @ms-main-hover-bg; -} - -.select-button-text { - flex: 1; - text-align: center; -} - -.select-button-arrow { - margin-left: auto; -} - -.select-button-menu { - background-color: @ms-main-bg; - position: absolute; - top: 100%; - left: 0; - right: 0; - border: 1px solid @ms-main-border-color; - border-radius: 5px; - margin-top: 1%; - z-index: 1; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.6); -} - -.select-button-menu p { - padding: 2%; - margin: 0; - cursor: pointer; -} - -.select-button-menu p:hover { - background-color: @ms-main-hover-bg; -} - -.clear-select-button { - background-color: @ms-main-bg; - border: 1px solid @ms-main-border-color; - color: black; - border-radius: 5px; - cursor: pointer; -} - -.clear-select-button:hover { - background-color: @ms-main-hover-bg; -} diff --git a/web/client/themes/default/less/layer-selection.less b/web/client/themes/default/less/layer-selection.less new file mode 100644 index 00000000000..fb06eea7620 --- /dev/null +++ b/web/client/themes/default/less/layer-selection.less @@ -0,0 +1,231 @@ +// @import '../../themes/default/variables.less'; +// @import './assets/select.less'; +// @import './components/EllipsisButton/EllipsisButton.less'; + +// ************** +// Theme +// ************** +#ms-components-theme(@theme-vars) { + .select-button { + .background-color-var(@theme-vars[main-bg]); + .border-color-var(@theme-vars[main-border-color]); + &:hover { + .background-color-var(@theme-vars[main-hover-bg]); + } + } + + .select-button-menu, + .ellipsis-menu { + .background-color-var(@theme-vars[main-bg]); + + p:hover { + .background-color-var(@theme-vars[main-hover-bg]); + } + } + + .clear-select-button, + .ellipsis-button { + .background-color-var(@theme-vars[main-bg]); + .border-color-var(@theme-vars[main-border-color]); + + &:hover { + .background-color-var(@theme-vars[main-hover-bg]); + } + } + + + +} + +// ************** +// Layout +// ************** + +.select-header-container { + margin: 2%; +} + +.select-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0 0 0; +} + +.select-button-container { + position: relative; + flex: 1; + max-width: 65%; + border: none; +} + + +.select-button { + border: 1px solid; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 3px; + cursor: pointer; +} + +.select-button-text { + flex: 1; + text-align: center; +} + +.select-button-arrow { + margin-left: auto; +} + +.select-button-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + border-radius: 5px; + margin-top: 1%; + z-index: 1; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.6); +} + +.select-button-menu p { + padding: 2%; + margin: 0; + cursor: pointer; +} + + +.clear-select-button { + border: 1px solid; + border-radius: 5px; + cursor: pointer; +} + + +// ************** +// elipsis button +// ************** +.ellipsis-container { + position: relative; + display: inline-block; + opacity: 1; +} + +.ellipsis-button { + padding: 2%; + border: 1px solid; + border-radius: 5px; + font-weight: bold; + cursor: pointer; + text-align: center; + line-height: 1; +} + + +.ellipsis-menu { + border: 1px solid; + position: absolute; + top: 100%; + right: 0; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); + z-index: 1; + width: 10vw; +} + +.ellipsis-menu p { + margin: 0; + padding: 5%; + cursor: pointer; +} + +.export-toggle { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 5px 10px; +} + +.export-toggle span:nth-of-type(2) { + font-weight: bold; +} + +/** +SELECT LESS +*/ +.ms-resizable-modal>.modal-content.select-dialog { + top: 0vh; + right: -100vw; +} + + +.select-content * .ms-node-header-info>.ms-node-header-addons:nth-child(3) { + flex: 1; + justify-content: space-between; +} + +.features-count-displayer { + display: flex; +} + +.title-container { + display: flex; +} + +.title-icon { + height: 100%; + width: auto; + margin-right: 0.5em; +} + +.title-title { + flex-grow: 1; + text-align: center; +} + +.features-count { + font-weight: bold; +} + + +/*Statistics*/ +.feature-statistics { + display: flex; + flex-direction: column; + padding: 1rem; + width: 100%; +} + +.select-container { + display: flex; + width: 100%; + align-items: center; +} + +.select-container label { + font-weight: bold; + margin-right: 0.5rem; +} + +.select-container select { + flex-grow: 1; + padding: 0.5rem; + border: 1px solid #ccc; +} + +.statistics-table { + width: 100%; + margin-top: 1rem; +} + +.statistics-table td { + padding: 0.5rem; +} + +.statistics-table td:first-child { + font-weight: bold; +} diff --git a/web/client/themes/default/less/mapstore.less b/web/client/themes/default/less/mapstore.less index 4cfb9160d69..0618c11c90b 100644 --- a/web/client/themes/default/less/mapstore.less +++ b/web/client/themes/default/less/mapstore.less @@ -91,4 +91,5 @@ @import "map-popup.less"; @import "map-views.less"; @import "permalink.less"; -@import "itinerary.less"; \ No newline at end of file +@import "itinerary.less"; +@import "layer-selection.less"; From e97ffc7310bf3ed0ed605a35b9046c71b385aa03 Mon Sep 17 00:00:00 2001 From: Benjamin Grenard Date: Fri, 28 Nov 2025 10:53:33 +0100 Subject: [PATCH 58/76] clena code --- web/client/themes/default/less/layer-selection.less | 1 + 1 file changed, 1 insertion(+) diff --git a/web/client/themes/default/less/layer-selection.less b/web/client/themes/default/less/layer-selection.less index fb06eea7620..5daa4f5ef3b 100644 --- a/web/client/themes/default/less/layer-selection.less +++ b/web/client/themes/default/less/layer-selection.less @@ -9,6 +9,7 @@ .select-button { .background-color-var(@theme-vars[main-bg]); .border-color-var(@theme-vars[main-border-color]); + &:hover { .background-color-var(@theme-vars[main-hover-bg]); } From 1976d181235649cba45255bc50bbdef3c318aa49 Mon Sep 17 00:00:00 2001 From: Benjamin Grenard Date: Fri, 28 Nov 2025 14:05:13 +0100 Subject: [PATCH 59/76] rename reducer as layerSelection --- .../LayersSelectionHeader.css | 87 --- .../LayersSelectionHeader.jsx | 1 - .../layersSelection/epics/layersSelection.js | 596 +++++++++--------- web/client/plugins/layersSelection/index.js | 22 +- .../reducers/layersSelection.js | 60 +- .../selectors/layersSelection.js | 176 +++--- 6 files changed, 426 insertions(+), 516 deletions(-) delete mode 100644 web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css diff --git a/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css b/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css deleted file mode 100644 index ac325dc16e4..00000000000 --- a/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css +++ /dev/null @@ -1,87 +0,0 @@ -/* .select-header-container { - margin: 2%; -} - -.head-text { - font-size: small; - font-weight: bold; -} - -.select-header { - display: flex; - justify-content: space-between; - gap: 5%; -} - -.select-button-container { - position: relative; - flex: 1; - max-width: 65%; - border: none; -} - -.select-button { - background-color: white; - border: 1px solid #F1F1F1; - border-radius: 5px; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 2% 5%; - cursor: pointer; -} - -.select-button:hover { - background-color: #e0e0e0; -} - -.select-button-text { - flex: 1; - text-align: center; -} - -.select-button-arrow { - margin-left: auto; -} - -.select-button-menu { - position: absolute; - top: 100%; - left: 0; - right: 0; - background-color: white; - border: 1px solid #ccc; - border-radius: 5px; - margin-top: 1%; - z-index: 1; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); -} - -.select-button-menu p { - padding: 2%; - margin: 0; - cursor: pointer; -} - -.select-button-menu p:hover { - background-color: #f0f0f0; -} - -.clear-select-button { - padding: 10px 15px; - background-color: white; - color: black; - border: 1px solid #989898; - border-radius: 5px; - cursor: pointer; -} - -.clear-select-button:hover { - background-color: #e0e0e0; -} - -.selection { - margin-bottom: 2%; - font-weight: bold; -} */ diff --git a/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx b/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx index f6b78ab1a17..b2118a9ec83 100644 --- a/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx +++ b/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx @@ -6,7 +6,6 @@ import Message from '../../../../components/I18N/Message'; import InlineLoader from '../../../TOC/components/InlineLoader'; import { SelectRefContext } from '../LayersSelection'; -import './LayersSelectionHeader.css'; /** * LayersSelectionHeader provides a toolbar for selecting geometry-based diff --git a/web/client/plugins/layersSelection/epics/layersSelection.js b/web/client/plugins/layersSelection/epics/layersSelection.js index aa52559f4b8..7db91ea0d3e 100644 --- a/web/client/plugins/layersSelection/epics/layersSelection.js +++ b/web/client/plugins/layersSelection/epics/layersSelection.js @@ -1,298 +1,298 @@ -import { Observable } from 'rxjs'; -import axios from 'axios'; -import assign from 'object-assign'; - -import { SET_CONTROL_PROPERTY, TOGGLE_CONTROL } from '../../../actions/controls'; -import { UPDATE_NODE, REMOVE_NODE } from '../../../actions/layers'; -import { changeDrawingStatus, END_DRAWING } from '../../../actions/draw'; -import { registerEventListener, unRegisterEventListener} from '../../../actions/map'; -import { shutdownToolOnAnotherToolDrawing } from "../../../utils/ControlUtils"; -import { describeFeatureType, getFeatureURL } from '../../../api/WFS'; -import { extractGeometryAttributeName } from '../../../utils/WFSLayerUtils'; -import { mergeOptionsByOwner, removeAdditionalLayer } from '../../../actions/additionallayers'; -import { highlightStyleSelector } from '../../../selectors/mapInfo'; -import { layersSelector, groupsSelector } from '../../../selectors/layers'; -import { flattenArrayOfObjects, getInactiveNode } from '../../../utils/LayersUtils'; - -import { optionsToVendorParams } from '../../../utils/VendorParamsUtils'; -import { selectLayersSelector, isSelectEnabled, filterLayerForSelect, isSelectQueriable, getSelectQueryMaxFeatureCount, getSelectHighlightOptions } from '../selectors/layersSelection'; -import { SELECT_CLEAN_SELECTION, ADD_OR_UPDATE_SELECTION, addOrUpdateSelection } from '../actions/layersSelection'; -import { buildAdditionalLayerId, buildAdditionalLayerOwnerName, arcgisToGeoJSON, makeCrsValid, customUpdateAdditionalLayer } from '../utils/LayersSelection'; - -/** - * Queries a given layer based on geometry and type (ArcGIS, WMS, or WFS). - * - * @param {Object} layer - Layer configuration object. - * @param {Object} geometry - Geometry used for spatial filtering. - * @param {number} selectQueryMaxCount - Max features to return. - * @returns {Promise} A Promise resolving to a GeoJSON FeatureCollection. - */ -const queryLayer = (layer, geometry, selectQueryMaxCount) => { - switch (layer.type) { - case 'arcgis': { - const parsedGeometry = JSON.stringify({ - spatialReference: { wkid: geometry.projection.split(':')[1] }, - ...(geometry.type === 'Point' - ? { x: geometry.coordinates[0], y: geometry.coordinates[1] } - : (geometry.type === 'LineString' ? - { 'paths': [geometry.coordinates] } : - { 'rings': geometry.coordinates } - ) - ) - }); - const geometryType = geometry.type === 'Point' ? "esriGeometryPoint" : (geometry.type === 'LineString' ? 'esriGeometryPolyline' : 'esriGeometryPolygon'); - const singleLayerId = parseInt(layer.name ?? '', 10); - return Promise.all((Number.isInteger(singleLayerId) ? layer.options.layers.filter(l => l.id === singleLayerId) : layer.options.layers).map(l => axios.get(`${layer.url}/${l.id}`, { params: { f: 'json'} }) - .then(describe => - axios.get(`${layer.url}/${l.id}/query`, { - params: assign({ - f: "json", - geometry: parsedGeometry, - geometryType: geometryType, - spatialRel: "esriSpatialRelIntersects", - where: '1=1', - outFields: '*' - }, describe.data.advancedQueryCapabilities.supportsPagination && selectQueryMaxCount > -1 ? { resultRecordCount: selectQueryMaxCount } : {} - )}) - .then(response => ({ features: arcgisToGeoJSON(response.data.features, describe.data.name, response.data.fields.find(field => field.type === 'esriFieldTypeOID')?.name ?? response.data.objectIdFieldName ?? 'objectid'), crs: makeCrsValid(describe.data.sourceSpatialReference.wkid.toString()) })) - .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }) - ))) - .then(responses => responses.reduce((acc, response) => { - const features = [...acc.features, ...response.features]; - return {...acc, ...{ - features: selectQueryMaxCount > -1 && features.length > selectQueryMaxCount ? features.slice(0, selectQueryMaxCount) : features, - totalFeatures: acc.totalFeatures + response.features.length, - numberMatched: acc.numberMatched + response.features.length, - numberReturned: acc.numberReturned + response.features.length - }}; - }, { - type: "FeatureCollection", - features: [], - totalFeatures: 0, - numberMatched: 0, - numberReturned: 0, - timeStamp: new Date().toISOString(), - crs: { - type: "name", - properties: { - name: makeCrsValid(responses[0].crs.toString()) // All layer crs in a MapServer/FeatureServer are the same - } - } - })) - .catch(err => { - throw new Error(`Error while querying layer: ${err.message}`); - }) - ; - } - case 'wms': - case 'wfs': { - return describeFeatureType(layer.url, layer.name) - .then(describe => axios - .get(getFeatureURL(layer.url, layer.name, - optionsToVendorParams({ - filterObj: { - spatialField: { - operation: "INTERSECTS", - attribute: extractGeometryAttributeName(describe), - geometry: geometry - } - } - }) - ), { params: assign({ outputFormat: 'application/json' }, - selectQueryMaxCount > -1 ? { - maxFeatures: selectQueryMaxCount, // WFS v1.1.0 - count: selectQueryMaxCount - } : {} - )}) - .then(response => assign(response.data, response.data.crs === null ? {} : - { - crs: { - type: response.data.crs.type, - properties: {...response.data.crs.properties, [response.data.crs.type]: makeCrsValid(response.data.crs.properties[response.data.crs.type])} - } - }) - ) - .catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }) - ).catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }); - } - default: - return new Promise((_, reject) => reject(new Error(`Unsupported layer type: ${layer.type}`))); - } -}; - -/** - * Epic triggered when the Select tool is opened. - * Registers map click event and synchronizes visibility of additional layers. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const openSelectEpic = (action$, store) => action$ - .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) - .filter(action => action.control === "select" && isSelectEnabled(store.getState())) - .switchMap(() => Observable.merge( - Observable.of(registerEventListener('click', 'select')), - ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: layer.visibility }))) - )); - -/** - * Epic triggered when the Select tool is closed. - * Unregisters map events and hides additional layers. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const closeSelectEpics = (action$, store) => action$ - .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) - .filter(action => action.control === "select" && !isSelectEnabled(store.getState())) - .switchMap(() => Observable.merge( - Observable.of(unRegisterEventListener('click', 'select')), - Observable.of(changeDrawingStatus("clean", "", "select", [], {})), - ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: false })))) - ); - -/** - * Shuts down the Select tool if another drawing tool is activated. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const tearDownSelectOnDrawToolActive = (action$, store) => shutdownToolOnAnotherToolDrawing(action$, store, 'select'); - -/** - * Epic triggered at the end of a drawing session. - * Queries layers with the drawn geometry and updates the selection. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const queryLayers = (action$, store) => action$ - .ofType(END_DRAWING) - .filter(action => - action.owner === 'select' && - isSelectEnabled(store.getState()) && - action.geometry - ) - .switchMap(action => { - const state = store.getState(); - const selectQueryMaxCount = getSelectQueryMaxFeatureCount(state); - return Observable.from(selectLayersSelector(state)) - .mergeMap(layer => Observable.concat( - Observable.of(addOrUpdateSelection(layer, {})), - isSelectQueriable(layer) - ? Observable.concat( - Observable.of(addOrUpdateSelection(layer, { loading: true })), - Observable.fromPromise(queryLayer(layer, action.geometry, selectQueryMaxCount)) - .map(geoJsonData => addOrUpdateSelection(layer, geoJsonData)) - .catch(error => Observable.of(addOrUpdateSelection(layer, { error }))) - ) - : Observable.empty() - )); - }); - -/** - * Epic that handles cleaning of selection data and optionally restarts drawing. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const cleanSelection = (action$, store) => action$ - .ofType(SELECT_CLEAN_SELECTION) - .filter(() => isSelectEnabled(store.getState())) - .switchMap(action => Observable.merge( - Observable.of( - changeDrawingStatus( - action.geomType ? "start" : "clean", - action.geomType || "", - "select", - [], - action.geomType ? { - stopAfterDrawing: true, - editEnabled: false, - drawEnabled: false - } : {} - ) - ), - Observable.from(selectLayersSelector(store.getState())).flatMap(layer => - Observable.merge( - Observable.of(addOrUpdateSelection(layer, {})), - Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { - features: [], - visibility: false - })) - ) - ) - )); - -/** - * Epic to synchronize visibility of layers and additional layers when their state changes. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const synchroniseLayersAndAdditionalLayers = (action$, store) => action$ - .filter(action => action.type === UPDATE_NODE - && isSelectEnabled(store.getState()) - && Object.hasOwn(action.options || {}, 'visibility') - ) - .concatMap(action => { - const state = store.getState(); - const layersForSelect = layersSelector(state).filter(filterLayerForSelect); - - if (layersForSelect?.find(layer => layer.id === action.node)) { - return Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(action.node), { visibility: action.options.visibility })); - } - - const groups = flattenArrayOfObjects(groupsSelector(state)); - return Observable.from(layersForSelect.filter(layer => layer.group?.startsWith(action.node))) - .mergeMap(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: !getInactiveNode(layer.group, groups) })) - ); - }); - -/** - * Epic to remove associated additional layers when a source layer is removed. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const onRemoveLayer = (action$, store) => action$ - .ofType(REMOVE_NODE) - .filter(action => isSelectEnabled(store.getState()) - && action.nodeType === 'layers' - ) - .mergeMap(action => Observable.of(removeAdditionalLayer({ id: buildAdditionalLayerId(action.node), owner: buildAdditionalLayerOwnerName(action.node) }))); - -/** - * Epic to update the map layer display with new selection results. - * - * @param {Observable} action$ - Stream of Redux actions. - * @param {Object} store - Redux store. - * @returns {Observable} Epic stream. - */ -export const onSelectionUpdate = (action$, store) => action$ - .ofType(ADD_OR_UPDATE_SELECTION) - .filter(action => isSelectEnabled(store.getState()) && action.layer) - .mergeMap(action => Observable.of(customUpdateAdditionalLayer( - action.layer.id, - action.geoJsonData.features ?? [], - action.layer.visibility && action.geoJsonData.error && !action.geoJsonData.loading, - { ...highlightStyleSelector(store.getState()), ...getSelectHighlightOptions(store.getState())} - ))); - -export default { - openSelectEpic, - closeSelectEpics, - tearDownSelectOnDrawToolActive, - queryLayers, - cleanSelection, - synchroniseLayersAndAdditionalLayers, - onRemoveLayer, - onSelectionUpdate -}; +import { Observable } from 'rxjs'; +import axios from 'axios'; +import assign from 'object-assign'; + +import { SET_CONTROL_PROPERTY, TOGGLE_CONTROL } from '../../../actions/controls'; +import { UPDATE_NODE, REMOVE_NODE } from '../../../actions/layers'; +import { changeDrawingStatus, END_DRAWING } from '../../../actions/draw'; +import { registerEventListener, unRegisterEventListener} from '../../../actions/map'; +import { shutdownToolOnAnotherToolDrawing } from "../../../utils/ControlUtils"; +import { describeFeatureType, getFeatureURL } from '../../../api/WFS'; +import { extractGeometryAttributeName } from '../../../utils/WFSLayerUtils'; +import { mergeOptionsByOwner, removeAdditionalLayer } from '../../../actions/additionallayers'; +import { highlightStyleSelector } from '../../../selectors/mapInfo'; +import { layersSelector, groupsSelector } from '../../../selectors/layers'; +import { flattenArrayOfObjects, getInactiveNode } from '../../../utils/LayersUtils'; + +import { optionsToVendorParams } from '../../../utils/VendorParamsUtils'; +import { selectLayersSelector, isSelectEnabled, filterLayerForSelect, isSelectQueriable, getSelectQueryMaxFeatureCount, getSelectHighlightOptions } from '../selectors/layersSelection'; +import { SELECT_CLEAN_SELECTION, ADD_OR_UPDATE_SELECTION, addOrUpdateSelection } from '../actions/layersSelection'; +import { buildAdditionalLayerId, buildAdditionalLayerOwnerName, arcgisToGeoJSON, makeCrsValid, customUpdateAdditionalLayer } from '../utils/LayersSelection'; + +/** + * Queries a given layer based on geometry and type (ArcGIS, WMS, or WFS). + * + * @param {Object} layer - Layer configuration object. + * @param {Object} geometry - Geometry used for spatial filtering. + * @param {number} selectQueryMaxCount - Max features to return. + * @returns {Promise} A Promise resolving to a GeoJSON FeatureCollection. + */ +const queryLayer = (layer, geometry, selectQueryMaxCount) => { + switch (layer.type) { + case 'arcgis': { + const parsedGeometry = JSON.stringify({ + spatialReference: { wkid: geometry.projection.split(':')[1] }, + ...(geometry.type === 'Point' + ? { x: geometry.coordinates[0], y: geometry.coordinates[1] } + : (geometry.type === 'LineString' ? + { 'paths': [geometry.coordinates] } : + { 'rings': geometry.coordinates } + ) + ) + }); + const geometryType = geometry.type === 'Point' ? "esriGeometryPoint" : (geometry.type === 'LineString' ? 'esriGeometryPolyline' : 'esriGeometryPolygon'); + const singleLayerId = parseInt(layer.name ?? '', 10); + return Promise.all((Number.isInteger(singleLayerId) ? layer.options.layers.filter(l => l.id === singleLayerId) : layer.options.layers).map(l => axios.get(`${layer.url}/${l.id}`, { params: { f: 'json'} }) + .then(describe => + axios.get(`${layer.url}/${l.id}/query`, { + params: assign({ + f: "json", + geometry: parsedGeometry, + geometryType: geometryType, + spatialRel: "esriSpatialRelIntersects", + where: '1=1', + outFields: '*' + }, describe.data.advancedQueryCapabilities.supportsPagination && selectQueryMaxCount > -1 ? { resultRecordCount: selectQueryMaxCount } : {} + )}) + .then(response => ({ features: arcgisToGeoJSON(response.data.features, describe.data.name, response.data.fields.find(field => field.type === 'esriFieldTypeOID')?.name ?? response.data.objectIdFieldName ?? 'objectid'), crs: makeCrsValid(describe.data.sourceSpatialReference.wkid.toString()) })) + .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }) + ))) + .then(responses => responses.reduce((acc, response) => { + const features = [...acc.features, ...response.features]; + return {...acc, ...{ + features: selectQueryMaxCount > -1 && features.length > selectQueryMaxCount ? features.slice(0, selectQueryMaxCount) : features, + totalFeatures: acc.totalFeatures + response.features.length, + numberMatched: acc.numberMatched + response.features.length, + numberReturned: acc.numberReturned + response.features.length + }}; + }, { + type: "FeatureCollection", + features: [], + totalFeatures: 0, + numberMatched: 0, + numberReturned: 0, + timeStamp: new Date().toISOString(), + crs: { + type: "name", + properties: { + name: makeCrsValid(responses[0].crs.toString()) // All layer crs in a MapServer/FeatureServer are the same + } + } + })) + .catch(err => { + throw new Error(`Error while querying layer: ${err.message}`); + }) + ; + } + case 'wms': + case 'wfs': { + return describeFeatureType(layer.url, layer.name) + .then(describe => axios + .get(getFeatureURL(layer.url, layer.name, + optionsToVendorParams({ + filterObj: { + spatialField: { + operation: "INTERSECTS", + attribute: extractGeometryAttributeName(describe), + geometry: geometry + } + } + }) + ), { params: assign({ outputFormat: 'application/json' }, + selectQueryMaxCount > -1 ? { + maxFeatures: selectQueryMaxCount, // WFS v1.1.0 + count: selectQueryMaxCount + } : {} + )}) + .then(response => assign(response.data, response.data.crs === null ? {} : + { + crs: { + type: response.data.crs.type, + properties: {...response.data.crs.properties, [response.data.crs.type]: makeCrsValid(response.data.crs.properties[response.data.crs.type])} + } + }) + ) + .catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }) + ).catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }); + } + default: + return new Promise((_, reject) => reject(new Error(`Unsupported layer type: ${layer.type}`))); + } +}; + +/** + * Epic triggered when the Select tool is opened. + * Registers map click event and synchronizes visibility of additional layers. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const openSelectEpic = (action$, store) => action$ + .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) + .filter(action => action.control === "select" && isSelectEnabled(store.getState())) + .switchMap(() => Observable.merge( + Observable.of(registerEventListener('click', 'select')), + ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: layer.visibility }))) + )); + +/** + * Epic triggered when the Select tool is closed. + * Unregisters map events and hides additional layers. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const closeSelectEpics = (action$, store) => action$ + .ofType(SET_CONTROL_PROPERTY, TOGGLE_CONTROL) + .filter(action => action.control === "select" && !isSelectEnabled(store.getState())) + .switchMap(() => Observable.merge( + Observable.of(unRegisterEventListener('click', 'select')), + Observable.of(changeDrawingStatus("clean", "", "select", [], {})), + ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: false })))) + ); + +/** + * Shuts down the Select tool if another drawing tool is activated. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const tearDownSelectOnDrawToolActive = (action$, store) => shutdownToolOnAnotherToolDrawing(action$, store, 'select'); + +/** + * Epic triggered at the end of a drawing session. + * Queries layers with the drawn geometry and updates the selection. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const queryLayers = (action$, store) => action$ + .ofType(END_DRAWING) + .filter(action => + action.owner === 'select' && + isSelectEnabled(store.getState()) && + action.geometry + ) + .switchMap(action => { + const state = store.getState(); + const selectQueryMaxCount = getSelectQueryMaxFeatureCount(state); + return Observable.from(selectLayersSelector(state)) + .mergeMap(layer => Observable.concat( + Observable.of(addOrUpdateSelection(layer, {})), + isSelectQueriable(layer) + ? Observable.concat( + Observable.of(addOrUpdateSelection(layer, { loading: true })), + Observable.fromPromise(queryLayer(layer, action.geometry, selectQueryMaxCount)) + .map(geoJsonData => addOrUpdateSelection(layer, geoJsonData)) + .catch(error => Observable.of(addOrUpdateSelection(layer, { error }))) + ) + : Observable.empty() + )); + }); + +/** + * Epic that handles cleaning of selection data and optionally restarts drawing. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const cleanSelection = (action$, store) => action$ + .ofType(SELECT_CLEAN_SELECTION) + .filter(() => isSelectEnabled(store.getState())) + .switchMap(action => Observable.merge( + Observable.of( + changeDrawingStatus( + action.geomType ? "start" : "clean", + action.geomType || "", + "select", + [], + action.geomType ? { + stopAfterDrawing: true, + editEnabled: false, + drawEnabled: false + } : {} + ) + ), + Observable.from(selectLayersSelector(store.getState())).flatMap(layer => + Observable.merge( + Observable.of(addOrUpdateSelection(layer, {})), + Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { + features: [], + visibility: false + })) + ) + ) + )); + +/** + * Epic to synchronize visibility of layers and additional layers when their state changes. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const synchroniseLayersAndAdditionalLayers = (action$, store) => action$ + .filter(action => action.type === UPDATE_NODE + && isSelectEnabled(store.getState()) + && Object.hasOwn(action.options || {}, 'visibility') + ) + .concatMap(action => { + const state = store.getState(); + const layersForSelect = layersSelector(state).filter(filterLayerForSelect); + + if (layersForSelect?.find(layer => layer.id === action.node)) { + return Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(action.node), { visibility: action.options.visibility })); + } + + const groups = flattenArrayOfObjects(groupsSelector(state)); + return Observable.from(layersForSelect.filter(layer => layer.group?.startsWith(action.node))) + .mergeMap(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: !getInactiveNode(layer.group, groups) })) + ); + }); + +/** + * Epic to remove associated additional layers when a source layer is removed. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const onRemoveLayer = (action$, store) => action$ + .ofType(REMOVE_NODE) + .filter(action => isSelectEnabled(store.getState()) + && action.nodeType === 'layers' + ) + .mergeMap(action => Observable.of(removeAdditionalLayer({ id: buildAdditionalLayerId(action.node), owner: buildAdditionalLayerOwnerName(action.node) }))); + +/** + * Epic to update the map layer display with new selection results. + * + * @param {Observable} action$ - Stream of Redux actions. + * @param {Object} store - Redux store. + * @returns {Observable} Epic stream. + */ +export const onSelectionUpdate = (action$, store) => action$ + .ofType(ADD_OR_UPDATE_SELECTION) + .filter(action => isSelectEnabled(store.getState()) && action.layer) + .mergeMap(action => Observable.of(customUpdateAdditionalLayer( + action.layer.id, + action.geoJsonData.features ?? [], + action.layer.visibility && action.geoJsonData.error && !action.geoJsonData.loading, + { ...highlightStyleSelector(store.getState()), ...getSelectHighlightOptions(store.getState())} + ))); + +export default { + openSelectEpic, + closeSelectEpics, + tearDownSelectOnDrawToolActive, + queryLayers, + cleanSelection, + synchroniseLayersAndAdditionalLayers, + onRemoveLayer, + onSelectionUpdate +}; diff --git a/web/client/plugins/layersSelection/index.js b/web/client/plugins/layersSelection/index.js index e616129b432..3600e336574 100644 --- a/web/client/plugins/layersSelection/index.js +++ b/web/client/plugins/layersSelection/index.js @@ -8,13 +8,12 @@ import { createPlugin } from '../../utils/PluginsUtils'; import { layersSelector } from '../../selectors/layers'; import { updateNode, addLayer, changeLayerProperties } from '../../actions/layers'; import { zoomToExtent } from '../../actions/map'; -import controls from '../../reducers/controls'; import { toggleControl } from '../../actions/controls'; import Message from '../../components/I18N/Message'; import SelectComponent from './components/LayersSelection'; import epics from './epics/layersSelection'; -import select from './reducers/layersSelection'; +import layersSelection from './reducers/layersSelection'; import { storeConfiguration, cleanSelection, addOrUpdateSelection } from './actions/layersSelection'; import { getSelectSelections, getSelectQueryMaxFeatureCount } from './selectors/layersSelection'; @@ -29,7 +28,7 @@ import { getSelectSelections, getSelectQueryMaxFeatureCount } from './selectors/ export default createPlugin('LayersSelection', { component: connect( createSelector([ - (state) => get(state, 'controls.select.enabled'), + (state) => get(state, 'controls.layersSelection.enabled'), layersSelector, getSelectSelections, getSelectQueryMaxFeatureCount @@ -40,7 +39,7 @@ export default createPlugin('LayersSelection', { maxFeatureCount })), { - onClose: toggleControl.bind(null, 'select', null), + onClose: toggleControl.bind(null, 'layersSelection', null), onUpdateNode: updateNode, storeConfiguration, cleanSelection, @@ -54,42 +53,41 @@ export default createPlugin('LayersSelection', { disablePluginIf: "{state('mapType') === 'cesium'}" }, reducers: { - ...controls, - select + select: layersSelection }, epics: epics, containers: { BurgerMenu: { - name: 'select', + name: 'layersSelection', position: 1000, priority: 2, doNotHide: true, text: , tooltip: , icon: , - action: toggleControl.bind(null, 'select', null), + action: toggleControl.bind(null, 'layersSelection', null), toggle: true }, SidebarMenu: { - name: 'select', + name: 'layersSelection', position: 1000, priority: 1, doNotHide: true, text: , tooltip: , icon: , - action: toggleControl.bind(null, 'select', null), + action: toggleControl.bind(null, 'layersSelection', null), toggle: true }, Toolbar: { - name: 'select', + name: 'layersSelection', alwaysVisible: true, position: 2, priority: 0, doNotHide: true, tooltip: , icon: , - action: toggleControl.bind(null, 'select', null), + action: toggleControl.bind(null, 'layersSelection', null), toggle: true } } diff --git a/web/client/plugins/layersSelection/reducers/layersSelection.js b/web/client/plugins/layersSelection/reducers/layersSelection.js index 55eac1ada78..93125ae37f4 100644 --- a/web/client/plugins/layersSelection/reducers/layersSelection.js +++ b/web/client/plugins/layersSelection/reducers/layersSelection.js @@ -1,30 +1,30 @@ -import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION } from '../actions/layersSelection'; - -/** - * Reducer for managing selection configuration and selection data per layer. - * - * @param {Object} state - Current selection state. - * @param {Object} state.cfg - Selection configuration object. - * @param {Object} state.selections - GeoJSON selection results keyed by layer ID. - * @param {Object} action - Redux action. - * @param {string} action.type - Action type. - * @returns {Object} New state after applying the action. - */ -export default function select(state = {cfg: {}, selections: {}}, action) { - switch (action.type) { - case SELECT_STORE_CFG: { - return { - ...state, - cfg: action.cfg - }; - } - case ADD_OR_UPDATE_SELECTION: { - return { - ...state, - selections: {...state.selections, [action.layer.id]: action.geoJsonData} - }; - } - default: - return state; - } -} +import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION } from '../actions/layersSelection'; + +/** + * Reducer for managing selection configuration and selection data per layer. + * + * @param {Object} state - Current selection state. + * @param {Object} state.cfg - Selection configuration object. + * @param {Object} state.selections - GeoJSON selection results keyed by layer ID. + * @param {Object} action - Redux action. + * @param {string} action.type - Action type. + * @returns {Object} New state after applying the action. + */ +export default function LayersSelection(state = {cfg: {}, selections: {}}, action) { + switch (action.type) { + case SELECT_STORE_CFG: { + return { + ...state, + cfg: action.cfg + }; + } + case ADD_OR_UPDATE_SELECTION: { + return { + ...state, + selections: {...state.selections, [action.layer.id]: action.geoJsonData} + }; + } + default: + return state; + } +} diff --git a/web/client/plugins/layersSelection/selectors/layersSelection.js b/web/client/plugins/layersSelection/selectors/layersSelection.js index c5ee7267214..a0efe21a9de 100644 --- a/web/client/plugins/layersSelection/selectors/layersSelection.js +++ b/web/client/plugins/layersSelection/selectors/layersSelection.js @@ -1,88 +1,88 @@ -import { get } from 'lodash'; - -/** - * Filters a layer to determine if it's eligible for selection. - * Excludes background layers and only allows WMS, WFS, or ArcGIS types. - * - * @param {Object} layer - A layer object. - * @returns {boolean} True if the layer is selectable. - */ -export const filterLayerForSelect = layer => layer && layer.group !== 'background' && ['wms', 'wfs', 'arcgis'].includes(layer.type); - -/** - * Retrieves all selectable layers from the Redux state. - * - * @param {Object} state - Redux state. - * @returns {Array} List of selectable layers. - */ -export const selectLayersSelector = state => (get(state, 'layers.flat') || []).filter(filterLayerForSelect); - -/** - * Checks if the select control is currently enabled. - * - * @param {Object} state - Redux state. - * @returns {boolean} True if selection is enabled. - */ -export const isSelectEnabled = state => get(state, "controls.select.enabled"); - -/** - * Checks if a node explicitly has the `isSelectQueriable` property. - * - * @param {Object} node - Layer node or descriptor. - * @returns {boolean} True if the property exists. - */ -export const hasSelectQueriableProp = node => Object.hasOwn(node, 'isSelectQueriable'); - -/** - * Determines whether a layer node is considered selectable. - * If `isSelectQueriable` is defined, it uses that. - * Otherwise, falls back to `visibility` status. - * - * @param {Object} node - Layer node or descriptor. - * @returns {boolean} True if the node is considered selectable. - */ -export const isSelectQueriable = node => hasSelectQueriableProp(node) ? node.isSelectQueriable : !!node?.visibility; - -/** - * Retrieves the entire `select` object from Redux state. - * - * @param {Object} state - Redux state. - * @returns {Object} Selection-related state object. - */ -export const getSelectObj = state => get(state, 'select') ?? {}; - -/** - * Retrieves query options used for selection. - * - * @param {Object} state - Redux state. - * @returns {Object} Query options configuration. - */ -export const getSelectQueryOptions = state => getSelectObj(state).cfg?.queryOptions ?? {}; - -/** - * Gets the maximum number of features to return in a select query. - * Defaults to -1 if not defined or invalid. - * - * @param {Object} state - Redux state. - * @returns {number} Maximum feature count for queries. - */ -export const getSelectQueryMaxFeatureCount = state => { - const queryOptions = getSelectQueryOptions(state); - return Number.isInteger(queryOptions.maxCount) ? queryOptions.maxCount : -1; -}; - -/** - * Retrieves highlight options to apply on selected features. - * - * @param {Object} state - Redux state. - * @returns {Object} Highlight configuration. - */ -export const getSelectHighlightOptions = state => getSelectObj(state).cfg?.highlightOptions ?? {}; - -/** - * Retrieves all current selection data, grouped by layer ID. - * - * @param {Object} state - Redux state. - * @returns {Object} A mapping of layer ID to GeoJSON feature collections. - */ -export const getSelectSelections = state => getSelectObj(state).selections ?? {}; +import { get } from 'lodash'; + +/** + * Filters a layer to determine if it's eligible for selection. + * Excludes background layers and only allows WMS, WFS, or ArcGIS types. + * + * @param {Object} layer - A layer object. + * @returns {boolean} True if the layer is selectable. + */ +export const filterLayerForSelect = layer => layer && layer.group !== 'background' && ['wms', 'wfs', 'arcgis'].includes(layer.type); + +/** + * Retrieves all selectable layers from the Redux state. + * + * @param {Object} state - Redux state. + * @returns {Array} List of selectable layers. + */ +export const selectLayersSelector = state => (get(state, 'layers.flat') || []).filter(filterLayerForSelect); + +/** + * Checks if the select control is currently enabled. + * + * @param {Object} state - Redux state. + * @returns {boolean} True if selection is enabled. + */ +export const isSelectEnabled = state => get(state, "controls.layersSelection.enabled"); + +/** + * Checks if a node explicitly has the `isSelectQueriable` property. + * + * @param {Object} node - Layer node or descriptor. + * @returns {boolean} True if the property exists. + */ +export const hasSelectQueriableProp = node => Object.hasOwn(node, 'isSelectQueriable'); + +/** + * Determines whether a layer node is considered selectable. + * If `isSelectQueriable` is defined, it uses that. + * Otherwise, falls back to `visibility` status. + * + * @param {Object} node - Layer node or descriptor. + * @returns {boolean} True if the node is considered selectable. + */ +export const isSelectQueriable = node => hasSelectQueriableProp(node) ? node.isSelectQueriable : !!node?.visibility; + +/** + * Retrieves the entire `select` object from Redux state. + * + * @param {Object} state - Redux state. + * @returns {Object} Selection-related state object. + */ +export const getSelectObj = state => get(state, 'select') ?? {}; + +/** + * Retrieves query options used for selection. + * + * @param {Object} state - Redux state. + * @returns {Object} Query options configuration. + */ +export const getSelectQueryOptions = state => getSelectObj(state).cfg?.queryOptions ?? {}; + +/** + * Gets the maximum number of features to return in a select query. + * Defaults to -1 if not defined or invalid. + * + * @param {Object} state - Redux state. + * @returns {number} Maximum feature count for queries. + */ +export const getSelectQueryMaxFeatureCount = state => { + const queryOptions = getSelectQueryOptions(state); + return Number.isInteger(queryOptions.maxCount) ? queryOptions.maxCount : -1; +}; + +/** + * Retrieves highlight options to apply on selected features. + * + * @param {Object} state - Redux state. + * @returns {Object} Highlight configuration. + */ +export const getSelectHighlightOptions = state => getSelectObj(state).cfg?.highlightOptions ?? {}; + +/** + * Retrieves all current selection data, grouped by layer ID. + * + * @param {Object} state - Redux state. + * @returns {Object} A mapping of layer ID to GeoJSON feature collections. + */ +export const getSelectSelections = state => getSelectObj(state).selections ?? {}; From 45cf1ebab48c6edf3ab0ce0c915449d7654cf9c6 Mon Sep 17 00:00:00 2001 From: Benjamin Grenard Date: Fri, 28 Nov 2025 14:05:32 +0100 Subject: [PATCH 60/76] rename reducer as layerSelection --- web/client/plugins/layersSelection/reducers/layersSelection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/client/plugins/layersSelection/reducers/layersSelection.js b/web/client/plugins/layersSelection/reducers/layersSelection.js index 93125ae37f4..0bf05a19144 100644 --- a/web/client/plugins/layersSelection/reducers/layersSelection.js +++ b/web/client/plugins/layersSelection/reducers/layersSelection.js @@ -10,7 +10,7 @@ import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION } from '../actions/layersSele * @param {string} action.type - Action type. * @returns {Object} New state after applying the action. */ -export default function LayersSelection(state = {cfg: {}, selections: {}}, action) { +export default function layersSelection(state = {cfg: {}, selections: {}}, action) { switch (action.type) { case SELECT_STORE_CFG: { return { From 262c84131581fd8ba4a60162d126d5f86b4bff3b Mon Sep 17 00:00:00 2001 From: allyoucanmap Date: Tue, 13 Jan 2026 16:46:14 +0100 Subject: [PATCH 61/76] ui and drawing refactor --- .../templates/configs/pluginsConfig.json | 2 - web/client/configs/pluginsConfig.json | 2 - .../actions/layersSelection.js | 9 +- .../components/EllipsisButton.jsx | 198 ++++++++++++++ .../EllipsisButton/EllipsisButton.jsx | 247 ------------------ .../components/LayersSelection.jsx | 162 +++++++----- .../LayersSelectionHeader.jsx | 95 ------- .../components/LayersSelectionSupport.jsx | 93 +++++++ .../Statistics => }/Statistics.jsx | 36 ++- .../components/layersSelection.css | 19 ++ .../layersSelection/epics/layersSelection.js | 119 ++++----- web/client/plugins/layersSelection/index.js | 35 ++- .../reducers/layersSelection.js | 25 +- .../selectors/layersSelection.js | 14 +- .../layersSelection/utils/LayersSelection.js | 8 + .../themes/default/less/layer-selection.less | 232 ---------------- web/client/themes/default/less/mapstore.less | 1 - web/client/themes/default/less/toc.less | 6 +- .../openlayers/DrawGeometryInteraction.js | 41 ++- 19 files changed, 609 insertions(+), 735 deletions(-) create mode 100644 web/client/plugins/layersSelection/components/EllipsisButton.jsx delete mode 100644 web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx delete mode 100644 web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx create mode 100644 web/client/plugins/layersSelection/components/LayersSelectionSupport.jsx rename web/client/plugins/layersSelection/components/{EllipsisButton/Statistics => }/Statistics.jsx (70%) create mode 100644 web/client/plugins/layersSelection/components/layersSelection.css delete mode 100644 web/client/themes/default/less/layer-selection.less diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json index 3b9237c98c0..c93c35e8757 100644 --- a/project/standard/templates/configs/pluginsConfig.json +++ b/project/standard/templates/configs/pluginsConfig.json @@ -159,8 +159,6 @@ "title": "plugins.LayersSelection.title", "description": "plugins.LayersSelection.description", "dependencies": [ - "Toolbar", - "BurgerMenu", "SidebarMenu" ] }, diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index 5084017c3a9..86eaa21d005 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -161,8 +161,6 @@ "title": "plugins.LayersSelection.title", "description": "plugins.LayersSelection.description", "dependencies": [ - "Toolbar", - "BurgerMenu", "SidebarMenu" ] }, diff --git a/web/client/plugins/layersSelection/actions/layersSelection.js b/web/client/plugins/layersSelection/actions/layersSelection.js index eca93c9c9d7..24cda377939 100644 --- a/web/client/plugins/layersSelection/actions/layersSelection.js +++ b/web/client/plugins/layersSelection/actions/layersSelection.js @@ -1,7 +1,7 @@ export const SELECT_CLEAN_SELECTION = "SELECT:CLEAN_SELECTION"; export const SELECT_STORE_CFG = "SELECT:STORE_CFG"; export const ADD_OR_UPDATE_SELECTION = "SELECT:ADD_OR_UPDATE_SELECTION"; - +export const UPDATE_SELECTION_FEATURE = "SELECT:UPDATE_SELECTION_FEATURE"; /** * Action creator to clean the current selection based on geometry type. * @@ -42,3 +42,10 @@ export function addOrUpdateSelection(layer, geoJsonData) { geoJsonData }; } + +export function updateSelectionFeature(feature) { + return { + type: UPDATE_SELECTION_FEATURE, + feature + }; +} diff --git a/web/client/plugins/layersSelection/components/EllipsisButton.jsx b/web/client/plugins/layersSelection/components/EllipsisButton.jsx new file mode 100644 index 00000000000..7551cba25f8 --- /dev/null +++ b/web/client/plugins/layersSelection/components/EllipsisButton.jsx @@ -0,0 +1,198 @@ +import React, { useState, useEffect } from 'react'; +import bbox from '@turf/bbox'; +import { saveAs } from 'file-saver'; +import axios from 'axios'; + +import Message from '../../../components/I18N/Message'; +import { describeFeatureType } from '../../../api/WFS'; +import Statistics from './Statistics'; +import { DropdownButton, Glyphicon, MenuItem } from 'react-bootstrap'; +import uuidv1 from 'uuid/v1'; + +/** + * EllipsisButton provides a contextual menu for selected layer data. + * It allows users to: + * - Zoom to selection extent + * - View statistics + * - Create a new layer from selection + * - Export data (GeoJSON, JSON, CSV) + * - Apply attribute filters (if supported) + * - Clear the selection + * + * @param {Object} props - Component props. + * @param {Object} props.node - Layer node (descriptor). + * @param {Object} props.selectionData - GeoJSON FeatureCollection. + * @param {Function} props.onAddOrUpdateSelection - Callback to update selection. + * @param {Function} props.onZoomToExtent - Callback to zoom to selection. + * @param {Function} props.onAddLayer - Callback to add a new layer. + * @param {Function} props.onChangeLayerProperties - Callback to change layer properties. + */ +export default ({ + node = {}, + selectionData = {}, + onAddOrUpdateSelection = () => { }, + onZoomToExtent = () => { }, + onAddLayer = () => { }, + onChangeLayerProperties = () => { } +}) => { + + const [statisticsOpen, setStatisticsOpen] = useState(false); + const [numericFields, setNumericFields] = useState([]); + const [primaryKey, setPrimaryKey] = useState(null); + + const triggerAction = (action) => { + switch (action) { + case 'clear': { + onAddOrUpdateSelection(node, {}); + break; + } + case 'zoomTo': { + if (selectionData.features?.length > 0) { + const extent = bbox(selectionData); + if (extent) { onZoomToExtent(extent, selectionData.crs.properties[selectionData.crs.type]); } + } + break; + } + case 'createLayer': { + if (selectionData.features?.length > 0) { + + const nodeName = node.title + '_Select_'; + const layerBbox = bbox(selectionData); + const uniqueId = uuidv1(); + + onAddLayer({ + id: uniqueId, + type: 'vector', + visibility: true, + name: nodeName + uniqueId, + hideLoading: true, + bbox: layerBbox, + features: selectionData.features + }); + } + break; + } + case 'exportToGeoJson': { + if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData)], { type: 'application/json' }), node.title + '.json'); } + break; + } + case 'exportToJson': { + if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData.features.map(feature => feature.properties))], { type: 'application/json' }), node.title + '.json'); } + break; + } + case 'exportToCsv': { + if (selectionData.features?.length > 0) { saveAs(new Blob([Object.keys(selectionData.features[0].properties).join(',') + '\n' + selectionData.features.map(feature => Object.values(feature.properties).join(',')).join('\n')], { type: 'text/csv' }), node.title + '.csv'); } + break; + } + case 'filterData': { + + switch (node.type) { + case 'arcgis': { + // TODO : implement here when MapStore supports filtering for arcgis services + throw new Error(`Unsupported layer type: ${node.type}`); + } + case 'wms': + case 'wfs': { + onChangeLayerProperties(node.id, { + layerFilter: { + groupFields: [ + { + id: 1, + logic: 'OR', + index: 0 + } + ], + filterFields: selectionData.features.map(feature => ({ + rowId: uuidv1(), + groupId: 1, + attribute: primaryKey, + operator: '=', + value: feature.properties[primaryKey], + type: 'number', + fieldOptions: { + valuesCount: 0, + currentPage: 1 + }, + exception: null + })) + } + }); + break; + } + default: + throw new Error(`Unsupported layer type: ${node.type}`); + } + break; + } + default: + } + }; + + useEffect(() => { + switch (node.type) { + case 'arcgis': { + const arcgisNumericFields = new Set(['esriFieldTypeSmallInteger', 'esriFieldTypeInteger', 'esriFieldTypeSingle', 'esriFieldTypeDouble']); + const singleLayerId = parseInt(node.name ?? '', 10); + Promise.all((Number.isInteger(singleLayerId) ? node.options.layers.filter(l => l.id === singleLayerId) : node.options.layers).map(l => axios.get(`${node.url}/${l.id}`, { params: { f: 'json' } }) + .then(describe => describe.data.fields.filter(field => field.domain === null && arcgisNumericFields.has(field.type)).map(field => field.name)) + .catch(() => []) + )) + .then(responses => { + setPrimaryKey(null); + setNumericFields(responses.map(response => response ?? []).flat()); + }) + .catch(() => { + setPrimaryKey(null); + setNumericFields([]); + }); + break; + } + case 'wms': + case 'wfs': { + describeFeatureType(node.url, node.name) + .then(describe => { + const featureType = describe.featureTypes.find(fType => node.name.endsWith(fType.typeName)); + const newNumericFields = featureType.properties.filter(property => property.localType === 'number').map(property => property.name); + // primary key is not always exposed + const newPrimaryKey = featureType.properties + .find(property => + ['xsd:string', 'xsd:int'].includes(property.type) && !property.nillable && property.maxOccurs === 1 && property.minOccurs === 1 + )?.name || null; + setPrimaryKey(newPrimaryKey); + setNumericFields(newNumericFields); + }) + .catch(() => { + setPrimaryKey(null); + setNumericFields([]); + }); + break; + } + default: + } + }, [node.name]); + + return ( + <> + } + className="_border-transparent" + noCaret + > + triggerAction('zoomTo')}> + { selectionData.features?.length > 0 ? setStatisticsOpen(true) : null; }}> + triggerAction('createLayer')}> + {primaryKey ? triggerAction('filterData')}> : null} + triggerAction('exportToGeoJson')}> + triggerAction('exportToJson')}> + triggerAction('exportToCsv')}> + triggerAction('clear')}> + + {statisticsOpen && } + + ); +}; diff --git a/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx deleted file mode 100644 index e88d5d8d4dc..00000000000 --- a/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { useState, useEffect, useContext } from 'react'; -import ReactDOM from "react-dom"; -import bbox from '@turf/bbox'; -import { saveAs } from 'file-saver'; -import axios from 'axios'; - -import Message from '../../../../components/I18N/Message'; -import { describeFeatureType } from '../../../../api/WFS'; - -import { SelectRefContext } from '../LayersSelection'; -import Statistics from './Statistics/Statistics'; - -/** - * EllipsisButton provides a contextual menu for selected layer data. - * It allows users to: - * - Zoom to selection extent - * - View statistics - * - Create a new layer from selection - * - Export data (GeoJSON, JSON, CSV) - * - Apply attribute filters (if supported) - * - Clear the selection - * - * @param {Object} props - Component props. - * @param {Object} props.node - Layer node (descriptor). - * @param {Array} props.layers - All available layers. - * @param {Object} props.selectionData - GeoJSON FeatureCollection. - * @param {Function} props.onAddOrUpdateSelection - Callback to update selection. - * @param {Function} props.onZoomToExtent - Callback to zoom to selection. - * @param {Function} props.onAddLayer - Callback to add a new layer. - * @param {Function} props.onChangeLayerProperties - Callback to change layer properties. - */ -export default ({ - node = {}, - // layers = [], - selectionData = {}, - onAddOrUpdateSelection = () => { }, - onZoomToExtent = () => { }, - onAddLayer = () => { }, - onChangeLayerProperties = () => { } -}) => { - const [menuOpen, setMenuOpen] = useState(false); - const [exportOpen, setExportOpen] = useState(false); - const [statisticsOpen, setStatisticsOpen] = useState(false); - const [numericFields, setNumericFields] = useState([]); - - const SelectRef = useContext(SelectRefContext); - const ellipsisContainerClass = 'ellipsis-container'; - useEffect(() => { - const selectElement = SelectRef.current?.addEventListener ? SelectRef.current : ReactDOM.findDOMNode(SelectRef.current); - if (!selectElement || !selectElement.addEventListener) { return null; } - const handleClick = e => { - if (menuOpen) { - let parentElement = e.target; - let foundThis = false; - while (!foundThis && parentElement !== e.currentTarget) { - foundThis = parentElement.className === ellipsisContainerClass; - parentElement = parentElement.parentElement; - } - if (!foundThis) { setMenuOpen(false); } - } - }; - selectElement.addEventListener("click", handleClick); - return () => selectElement.removeEventListener("click", handleClick); - }); - - const toggleMenu = () => setMenuOpen(!menuOpen); - const toggleExport = () => setExportOpen(!exportOpen); - - /** - * Generate id base on timestamp - * @returns {integer} unique Id - */ - const generateId = () => { - const timestamp = Date.now(); // Get current timestamp in milliseconds - const randomNum = Math.floor(Math.random() * 1000); // Add a random number for extra uniqueness - return `${timestamp}-${randomNum}`; - }; - - const triggerAction = (action) => { - switch (action) { - case 'clear': { - onAddOrUpdateSelection(node, {}); - break; - } - case 'zoomTo': { - if (selectionData.features?.length > 0) { - const extent = bbox(selectionData); - if (extent) { onZoomToExtent(extent, selectionData.crs.properties[selectionData.crs.type]); } - } - break; - } - case 'createLayer': { - if (selectionData.features?.length > 0) { - const nodeName = node.title + '_Select_'; - - const layerBbox = bbox(selectionData); - - // generate id for layer base on timestamp - const uniqueId = generateId(); - - onAddLayer({ - type: 'vector', - visibility: true, - name: nodeName + uniqueId, - hideLoading: true, - bbox: layerBbox, - features: selectionData.features - }); - } - break; - } - case 'exportToGeoJson': { - if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData)], { type: 'application/json' }), node.title + '.json'); } - break; - } - case 'exportToJson': { - if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData.features.map(feature => feature.properties))], { type: 'application/json' }), node.title + '.json'); } - break; - } - case 'exportToCsv': { - if (selectionData.features?.length > 0) { saveAs(new Blob([Object.keys(selectionData.features[0].properties).join(',') + '\n' + selectionData.features.map(feature => Object.values(feature.properties).join(',')).join('\n')], { type: 'text/csv' }), node.title + '.csv'); } - break; - } - case 'filterData': { - const customOnChangeLayerProperties = fieldIdName => onChangeLayerProperties(node.id, { - layerFilter: { - // searchUrl: null, - // featureTypeConfigUrl: null, - // showGeneratedFilter: false, - // attributePanelExpanded: true, - // spatialPanelExpanded: false, - // crossLayerExpanded: false, - // showDetailsPanel: false, - // groupLevels: 5, - // useMapProjection: false, - // toolbarEnabled: true, - groupFields: [ - { - id: 1, - logic: 'OR', - index: 0 - } - ], - // maxFeaturesWPS: 5, - filterFields: selectionData.features.map(feature => ({ - rowId: new Date().getDate(), - groupId: 1, - attribute: fieldIdName, - operator: '=', - value: feature.properties[fieldIdName], - type: 'number', - fieldOptions: { - valuesCount: 0, - currentPage: 1 - }, - exception: null - })) - // spatialField: null, - // simpleFilterFields: [], - // map: null, - // filters: [], - // crossLayerFilter: null, - // autocompleteEnabled: true - } - }); - switch (node.type) { - case 'arcgis': { - // TODO : implement here when MapStore supports filtering for arcgis services - throw new Error(`Unsupported layer type: ${node.type}`); - // break; - } - case 'wms': - case 'wfs': { - describeFeatureType(node.url, node.name) - .then(describe => customOnChangeLayerProperties(describe.featureTypes.find(featureType => node.name.endsWith(featureType.typeName)).properties.find(property => ['xsd:string', 'xsd:int'].find(type => type === property.type) && !property.nillable && property.maxOccurs === 1 && property.minOccurs === 1).name)) - .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }); - break; - } - default: - throw new Error(`Unsupported layer type: ${node.type}`); - } - break; - } - default: - } - toggleMenu(); - }; - - useEffect(() => { - switch (node.type) { - case 'arcgis': { - const arcgisNumericFields = new Set(['esriFieldTypeSmallInteger', 'esriFieldTypeInteger', 'esriFieldTypeSingle', 'esriFieldTypeDouble']); - const singleLayerId = parseInt(node.name ?? '', 10); - Promise.all((Number.isInteger(singleLayerId) ? node.options.layers.filter(l => l.id === singleLayerId) : node.options.layers).map(l => axios.get(`${node.url}/${l.id}`, { params: { f: 'json' } }) - .then(describe => describe.data.fields.filter(field => field.domain === null && arcgisNumericFields.has(field.type)).map(field => field.name)) - .catch(() => []) - )) - .then(responses => setNumericFields(responses.map(response => response ?? []).flat())) - .catch(() => setNumericFields([])); - break; - } - case 'wms': - case 'wfs': { - describeFeatureType(node.url, node.name) - .then(describe => setNumericFields(describe.featureTypes[0].properties.filter(property => property.localType === 'number').map(property => property.name))) - .catch(() => setNumericFields([])); - break; - } - default: - } - }, []); - - return ( -
    - - {menuOpen && ( -
    -

    triggerAction('zoomTo')}>

    -

    { toggleMenu(); selectionData.features?.length > 0 ? setStatisticsOpen(true) : null; }}>

    -

    triggerAction('createLayer')}>

    - {node.type !== 'arcgis' &&

    triggerAction('filterData')}>

    } -
    -

    - - {exportOpen ? "−" : "+"} -

    - {exportOpen && ( -
    -

    triggerAction('exportToGeoJson')}> -

    -

    triggerAction('exportToJson')}> -

    -

    triggerAction('exportToCsv')}> -

    -
    - )} -
    -

    triggerAction('clear')}>

    -
    - )} - {statisticsOpen && } -
    - ); -}; diff --git a/web/client/plugins/layersSelection/components/LayersSelection.jsx b/web/client/plugins/layersSelection/components/LayersSelection.jsx index 86f9dda376e..61c47a2171c 100644 --- a/web/client/plugins/layersSelection/components/LayersSelection.jsx +++ b/web/client/plugins/layersSelection/components/LayersSelection.jsx @@ -1,6 +1,5 @@ -import React, { useEffect, createContext, useRef } from 'react'; -import { injectIntl } from 'react-intl'; -import { Glyphicon } from 'react-bootstrap'; +import React, { useEffect, useState } from 'react'; +import { ControlLabel, Glyphicon } from 'react-bootstrap'; import { ControlledTOC } from '../../TOC/components/TOC'; import ResizableModal from '../../../components/misc/ResizableModal'; @@ -10,16 +9,23 @@ import NodeHeader from '../../TOC/components/NodeHeader'; import { getLayerTypeGlyph } from '../../../utils/LayersUtils'; import NodeTool from '../../TOC/components/NodeTool'; import InlineLoader from '../../TOC/components/InlineLoader'; - -import SelectHeader from './LayersSelectionHeader/LayersSelectionHeader'; -import EllipsisButton from './EllipsisButton/EllipsisButton'; +import EllipsisButton from './EllipsisButton'; import { isSelectQueriable, filterLayerForSelect } from '../selectors/layersSelection'; +import FlexBox from '../../../components/layout/FlexBox'; +import Button from '../../../components/layout/Button'; +import Select from 'react-select'; +import { getMessageById } from '../../../utils/LocaleUtils'; +import PropTypes from 'prop-types'; -/** - * Context used to expose a reference to the ResizableModal component - * so that child components can programmatically interact with it. - */ -export const SelectRefContext = createContext(null); +import './layersSelection.css'; + +const SelectionToolsTypes = { + Point: { type: 'Point', value: 'Point', labelId: 'layersSelection.button.selectByPoint', icon: '1-point' }, + LineString: { type: 'LineString', value: 'LineString', labelId: 'layersSelection.button.selectByLine', icon: 'polyline' }, + Circle: { type: 'Circle', value: 'Circle', labelId: 'layersSelection.button.selectByCircle', icon: '1-circle' }, + Rectangle: { type: 'Rectangle', value: 'BBOX', labelId: 'layersSelection.button.selectByRectangle', icon: 'unchecked' }, + Polygon: { type: 'Polygon', value: 'Polygon', labelId: 'layersSelection.button.selectByPolygon', icon: 'polygon' } +}; /** * Appends or updates a cache-busting `_v_` parameter on the layer's legendParams object. @@ -57,16 +63,21 @@ function applyVersionParamToLegend(layer) { * * @returns {JSX.Element} The rendered Select tool modal. */ -export default injectIntl(({ +const LayersSelection = ({ layers, onUpdateNode, onClose, isVisible, highlightOptions, queryOptions, - selectTools, + selectTools = [ + 'Point', + 'LineString', + 'Circle', + 'Rectangle', + 'Polygon' + ], storeConfiguration, - intl, selections, maxFeatureCount, cleanSelection, @@ -74,8 +85,8 @@ export default injectIntl(({ zoomToExtent, addLayer, changeLayerProperties -}) => { - const SelectRef = useRef(null); +}, context) => { + const filterLayers = layers.filter(filterLayerForSelect); /** @@ -136,49 +147,80 @@ export default injectIntl(({ useEffect(() => storeConfiguration({ highlightOptions, queryOptions }), []); + const [selectedTool, setSelectedTool] = useState(null); + + const selectionOptions = selectTools + .filter(tool => SelectionToolsTypes[tool]) + .map(tool => { + const option = SelectionToolsTypes[tool]; + return { ...option, label: <>{' '} }; + }); + return ( - - - - - - - } - dialogClassName=" select-dialog" - show={isVisible} - draggable - style={{zIndex: 1993}}> - - Object.fromEntries(Object.entries(applyVersionParamToLegend(layer)).filter(([key]) => key !== 'group'))).reverse()} - className="select-content" - theme="legend" - layerNodeComponent={customLayerNodeComponent} - treeHeader={ -
  • - filterLayers.forEach(layer => onUpdateNode(layer.id, 'layers', { isSelectQueriable: checked }))} - /> - } - afterTitle={} - /> -
  • - } - /> -
    -
    + + {' '} + + } + dialogClassName=" select-dialog" + show={isVisible} + draggable + style={{zIndex: 1993}}> + + + + + setSelectedField(e.target.value)} - > - {fields.map((field) => ( ))} - - - + + + + + setSelectedField(event.target.value)}> + {fields.map((field) => ( ))} + + + {statistics && ( - +
    @@ -76,7 +74,7 @@ export default ({
    {statistics.count}
    {statistics.sum.toFixed(6)}
    )} - +
    ); diff --git a/web/client/plugins/layersSelection/components/layersSelection.css b/web/client/plugins/layersSelection/components/layersSelection.css new file mode 100644 index 00000000000..485136fa020 --- /dev/null +++ b/web/client/plugins/layersSelection/components/layersSelection.css @@ -0,0 +1,19 @@ + +.ms-resizable-modal>.modal-content.select-dialog { + top: 0; + right: 50px; + left: unset; +} + +.select-content * .ms-node-header-info>.ms-node-header-addons:nth-child(3) { + flex: 1; + justify-content: space-between; +} + +.features-count-displayer { + display: flex; +} + +.ms-statistics-table td:first-child { + font-weight: bold; +} \ No newline at end of file diff --git a/web/client/plugins/layersSelection/epics/layersSelection.js b/web/client/plugins/layersSelection/epics/layersSelection.js index 7db91ea0d3e..8e0b9fb0e80 100644 --- a/web/client/plugins/layersSelection/epics/layersSelection.js +++ b/web/client/plugins/layersSelection/epics/layersSelection.js @@ -1,10 +1,15 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + import { Observable } from 'rxjs'; import axios from 'axios'; -import assign from 'object-assign'; - import { SET_CONTROL_PROPERTY, TOGGLE_CONTROL } from '../../../actions/controls'; import { UPDATE_NODE, REMOVE_NODE } from '../../../actions/layers'; -import { changeDrawingStatus, END_DRAWING } from '../../../actions/draw'; import { registerEventListener, unRegisterEventListener} from '../../../actions/map'; import { shutdownToolOnAnotherToolDrawing } from "../../../utils/ControlUtils"; import { describeFeatureType, getFeatureURL } from '../../../api/WFS'; @@ -16,7 +21,7 @@ import { flattenArrayOfObjects, getInactiveNode } from '../../../utils/LayersUti import { optionsToVendorParams } from '../../../utils/VendorParamsUtils'; import { selectLayersSelector, isSelectEnabled, filterLayerForSelect, isSelectQueriable, getSelectQueryMaxFeatureCount, getSelectHighlightOptions } from '../selectors/layersSelection'; -import { SELECT_CLEAN_SELECTION, ADD_OR_UPDATE_SELECTION, addOrUpdateSelection } from '../actions/layersSelection'; +import { SELECT_CLEAN_SELECTION, ADD_OR_UPDATE_SELECTION, addOrUpdateSelection, UPDATE_SELECTION_FEATURE } from '../actions/layersSelection'; import { buildAdditionalLayerId, buildAdditionalLayerOwnerName, arcgisToGeoJSON, makeCrsValid, customUpdateAdditionalLayer } from '../utils/LayersSelection'; /** @@ -45,15 +50,15 @@ const queryLayer = (layer, geometry, selectQueryMaxCount) => { return Promise.all((Number.isInteger(singleLayerId) ? layer.options.layers.filter(l => l.id === singleLayerId) : layer.options.layers).map(l => axios.get(`${layer.url}/${l.id}`, { params: { f: 'json'} }) .then(describe => axios.get(`${layer.url}/${l.id}/query`, { - params: assign({ + params: { f: "json", geometry: parsedGeometry, geometryType: geometryType, spatialRel: "esriSpatialRelIntersects", where: '1=1', - outFields: '*' - }, describe.data.advancedQueryCapabilities.supportsPagination && selectQueryMaxCount > -1 ? { resultRecordCount: selectQueryMaxCount } : {} - )}) + outFields: '*', + ...(describe.data.advancedQueryCapabilities.supportsPagination && selectQueryMaxCount > -1 ? { resultRecordCount: selectQueryMaxCount } : {}) + }}) .then(response => ({ features: arcgisToGeoJSON(response.data.features, describe.data.name, response.data.fields.find(field => field.type === 'esriFieldTypeOID')?.name ?? response.data.objectIdFieldName ?? 'objectid'), crs: makeCrsValid(describe.data.sourceSpatialReference.wkid.toString()) })) .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }) ))) @@ -87,32 +92,42 @@ const queryLayer = (layer, geometry, selectQueryMaxCount) => { case 'wms': case 'wfs': { return describeFeatureType(layer.url, layer.name) - .then(describe => axios - .get(getFeatureURL(layer.url, layer.name, - optionsToVendorParams({ - filterObj: { - spatialField: { - operation: "INTERSECTS", - attribute: extractGeometryAttributeName(describe), - geometry: geometry + .then(describe => + axios.get( + getFeatureURL( + layer.url, layer.name, + optionsToVendorParams({ + filterObj: { + spatialField: { + operation: "INTERSECTS", + attribute: extractGeometryAttributeName(describe), + geometry: geometry + } } - } - }) - ), { params: assign({ outputFormat: 'application/json' }, - selectQueryMaxCount > -1 ? { - maxFeatures: selectQueryMaxCount, // WFS v1.1.0 - count: selectQueryMaxCount - } : {} - )}) - .then(response => assign(response.data, response.data.crs === null ? {} : - { - crs: { - type: response.data.crs.type, - properties: {...response.data.crs.properties, [response.data.crs.type]: makeCrsValid(response.data.crs.properties[response.data.crs.type])} - } - }) + }) + ), + { params: { + outputFormat: 'application/json', + // force srs to ensure the zoom to function will work + // without converting unsupported projections client side + srsName: 'EPSG:4326', + ...(selectQueryMaxCount > -1 ? { + maxFeatures: selectQueryMaxCount, // WFS v1.1.0 + count: selectQueryMaxCount + } : {}) + }} ) - .catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }) + .then(response => ({ + ...response.data, + ...(response.data.crs === null ? {} : + { + crs: { + type: response.data.crs.type, + properties: {...response.data.crs.properties, [response.data.crs.type]: makeCrsValid(response.data.crs.properties[response.data.crs.type])} + } + }) + })) + .catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }) ).catch(err => { throw new Error(`Error ${err.status}: ${err.statusText}`); }); } default: @@ -149,7 +164,6 @@ export const closeSelectEpics = (action$, store) => action$ .filter(action => action.control === "select" && !isSelectEnabled(store.getState())) .switchMap(() => Observable.merge( Observable.of(unRegisterEventListener('click', 'select')), - Observable.of(changeDrawingStatus("clean", "", "select", [], {})), ...selectLayersSelector(store.getState()).map(layer => Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { visibility: false })))) ); @@ -171,12 +185,8 @@ export const tearDownSelectOnDrawToolActive = (action$, store) => shutdownToolOn * @returns {Observable} Epic stream. */ export const queryLayers = (action$, store) => action$ - .ofType(END_DRAWING) - .filter(action => - action.owner === 'select' && - isSelectEnabled(store.getState()) && - action.geometry - ) + .ofType(UPDATE_SELECTION_FEATURE) + .filter((action) => action?.feature?.geometry && isSelectEnabled(store.getState())) .switchMap(action => { const state = store.getState(); const selectQueryMaxCount = getSelectQueryMaxFeatureCount(state); @@ -186,7 +196,7 @@ export const queryLayers = (action$, store) => action$ isSelectQueriable(layer) ? Observable.concat( Observable.of(addOrUpdateSelection(layer, { loading: true })), - Observable.fromPromise(queryLayer(layer, action.geometry, selectQueryMaxCount)) + Observable.fromPromise(queryLayer(layer, action.feature.geometry, selectQueryMaxCount)) .map(geoJsonData => addOrUpdateSelection(layer, geoJsonData)) .catch(error => Observable.of(addOrUpdateSelection(layer, { error }))) ) @@ -204,28 +214,13 @@ export const queryLayers = (action$, store) => action$ export const cleanSelection = (action$, store) => action$ .ofType(SELECT_CLEAN_SELECTION) .filter(() => isSelectEnabled(store.getState())) - .switchMap(action => Observable.merge( - Observable.of( - changeDrawingStatus( - action.geomType ? "start" : "clean", - action.geomType || "", - "select", - [], - action.geomType ? { - stopAfterDrawing: true, - editEnabled: false, - drawEnabled: false - } : {} - ) - ), - Observable.from(selectLayersSelector(store.getState())).flatMap(layer => - Observable.merge( - Observable.of(addOrUpdateSelection(layer, {})), - Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { - features: [], - visibility: false - })) - ) + .switchMap(() => Observable.from(selectLayersSelector(store.getState())).flatMap(layer => + Observable.merge( + Observable.of(addOrUpdateSelection(layer, {})), + Observable.of(mergeOptionsByOwner(buildAdditionalLayerOwnerName(layer.id), { + features: [], + visibility: false + })) ) )); diff --git a/web/client/plugins/layersSelection/index.js b/web/client/plugins/layersSelection/index.js index 3600e336574..5ce2499b263 100644 --- a/web/client/plugins/layersSelection/index.js +++ b/web/client/plugins/layersSelection/index.js @@ -1,3 +1,11 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + import React from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; @@ -14,8 +22,10 @@ import Message from '../../components/I18N/Message'; import SelectComponent from './components/LayersSelection'; import epics from './epics/layersSelection'; import layersSelection from './reducers/layersSelection'; -import { storeConfiguration, cleanSelection, addOrUpdateSelection } from './actions/layersSelection'; -import { getSelectSelections, getSelectQueryMaxFeatureCount } from './selectors/layersSelection'; +import { storeConfiguration, cleanSelection, addOrUpdateSelection, updateSelectionFeature } from './actions/layersSelection'; +import { getSelectSelections, getSelectQueryMaxFeatureCount, getSelectDrawType, getSelectionFeature } from './selectors/layersSelection'; +import LayersSelectionSupport from './components/LayersSelectionSupport'; +import { removeAdditionalLayer, updateAdditionalLayer } from '../../actions/additionallayers'; /** * Select plugin that enables layer feature selection in the map. @@ -53,14 +63,14 @@ export default createPlugin('LayersSelection', { disablePluginIf: "{state('mapType') === 'cesium'}" }, reducers: { - select: layersSelection + layersSelection }, epics: epics, containers: { BurgerMenu: { name: 'layersSelection', position: 1000, - priority: 2, + priority: 1, doNotHide: true, text: , tooltip: , @@ -71,7 +81,7 @@ export default createPlugin('LayersSelection', { SidebarMenu: { name: 'layersSelection', position: 1000, - priority: 1, + priority: 2, doNotHide: true, text: , tooltip: , @@ -89,6 +99,21 @@ export default createPlugin('LayersSelection', { icon: , action: toggleControl.bind(null, 'layersSelection', null), toggle: true + }, + Map: { + name: 'LayersSelectionSupport', + Tool: connect( + createSelector([getSelectDrawType, getSelectionFeature], (type, feature) => ({ + type, + feature + })), + { + onChange: updateSelectionFeature, + onUpdateLayer: updateAdditionalLayer, + onRemoveLayer: removeAdditionalLayer + } + )(LayersSelectionSupport), + alwaysRender: true } } }); diff --git a/web/client/plugins/layersSelection/reducers/layersSelection.js b/web/client/plugins/layersSelection/reducers/layersSelection.js index 0bf05a19144..f32280400ea 100644 --- a/web/client/plugins/layersSelection/reducers/layersSelection.js +++ b/web/client/plugins/layersSelection/reducers/layersSelection.js @@ -1,4 +1,12 @@ -import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION } from '../actions/layersSelection'; +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { SELECT_STORE_CFG, ADD_OR_UPDATE_SELECTION, SELECT_CLEAN_SELECTION, UPDATE_SELECTION_FEATURE } from '../actions/layersSelection'; /** * Reducer for managing selection configuration and selection data per layer. @@ -24,6 +32,21 @@ export default function layersSelection(state = {cfg: {}, selections: {}}, actio selections: {...state.selections, [action.layer.id]: action.geoJsonData} }; } + case SELECT_CLEAN_SELECTION: { + return { + ...state, + drawType: action.geomType, + ...((action.geomType !== state.drawType) && { + selectionFeature: null + }) + }; + } + case UPDATE_SELECTION_FEATURE: { + return { + ...state, + selectionFeature: action.feature + }; + } default: return state; } diff --git a/web/client/plugins/layersSelection/selectors/layersSelection.js b/web/client/plugins/layersSelection/selectors/layersSelection.js index a0efe21a9de..cf9ac2e9993 100644 --- a/web/client/plugins/layersSelection/selectors/layersSelection.js +++ b/web/client/plugins/layersSelection/selectors/layersSelection.js @@ -1,3 +1,11 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + import { get } from 'lodash'; /** @@ -49,7 +57,7 @@ export const isSelectQueriable = node => hasSelectQueriableProp(node) ? node.isS * @param {Object} state - Redux state. * @returns {Object} Selection-related state object. */ -export const getSelectObj = state => get(state, 'select') ?? {}; +export const getSelectObj = state => get(state, 'layersSelection') ?? {}; /** * Retrieves query options used for selection. @@ -86,3 +94,7 @@ export const getSelectHighlightOptions = state => getSelectObj(state).cfg?.highl * @returns {Object} A mapping of layer ID to GeoJSON feature collections. */ export const getSelectSelections = state => getSelectObj(state).selections ?? {}; + + +export const getSelectDrawType = state => getSelectObj(state).drawType; +export const getSelectionFeature = state => getSelectObj(state).selectionFeature; diff --git a/web/client/plugins/layersSelection/utils/LayersSelection.js b/web/client/plugins/layersSelection/utils/LayersSelection.js index 52b3ad05eed..b8356fe7ab8 100644 --- a/web/client/plugins/layersSelection/utils/LayersSelection.js +++ b/web/client/plugins/layersSelection/utils/LayersSelection.js @@ -1,3 +1,11 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + import { updateAdditionalLayer } from '../../../actions/additionallayers'; import { applyMapInfoStyle } from '../../../selectors/mapInfo'; diff --git a/web/client/themes/default/less/layer-selection.less b/web/client/themes/default/less/layer-selection.less deleted file mode 100644 index 5daa4f5ef3b..00000000000 --- a/web/client/themes/default/less/layer-selection.less +++ /dev/null @@ -1,232 +0,0 @@ -// @import '../../themes/default/variables.less'; -// @import './assets/select.less'; -// @import './components/EllipsisButton/EllipsisButton.less'; - -// ************** -// Theme -// ************** -#ms-components-theme(@theme-vars) { - .select-button { - .background-color-var(@theme-vars[main-bg]); - .border-color-var(@theme-vars[main-border-color]); - - &:hover { - .background-color-var(@theme-vars[main-hover-bg]); - } - } - - .select-button-menu, - .ellipsis-menu { - .background-color-var(@theme-vars[main-bg]); - - p:hover { - .background-color-var(@theme-vars[main-hover-bg]); - } - } - - .clear-select-button, - .ellipsis-button { - .background-color-var(@theme-vars[main-bg]); - .border-color-var(@theme-vars[main-border-color]); - - &:hover { - .background-color-var(@theme-vars[main-hover-bg]); - } - } - - - -} - -// ************** -// Layout -// ************** - -.select-header-container { - margin: 2%; -} - -.select-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 0 0 0; -} - -.select-button-container { - position: relative; - flex: 1; - max-width: 65%; - border: none; -} - - -.select-button { - border: 1px solid; - border-radius: 5px; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 3px; - cursor: pointer; -} - -.select-button-text { - flex: 1; - text-align: center; -} - -.select-button-arrow { - margin-left: auto; -} - -.select-button-menu { - position: absolute; - top: 100%; - left: 0; - right: 0; - border-radius: 5px; - margin-top: 1%; - z-index: 1; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.6); -} - -.select-button-menu p { - padding: 2%; - margin: 0; - cursor: pointer; -} - - -.clear-select-button { - border: 1px solid; - border-radius: 5px; - cursor: pointer; -} - - -// ************** -// elipsis button -// ************** -.ellipsis-container { - position: relative; - display: inline-block; - opacity: 1; -} - -.ellipsis-button { - padding: 2%; - border: 1px solid; - border-radius: 5px; - font-weight: bold; - cursor: pointer; - text-align: center; - line-height: 1; -} - - -.ellipsis-menu { - border: 1px solid; - position: absolute; - top: 100%; - right: 0; - border-radius: 5px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); - z-index: 1; - width: 10vw; -} - -.ellipsis-menu p { - margin: 0; - padding: 5%; - cursor: pointer; -} - -.export-toggle { - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - padding: 5px 10px; -} - -.export-toggle span:nth-of-type(2) { - font-weight: bold; -} - -/** -SELECT LESS -*/ -.ms-resizable-modal>.modal-content.select-dialog { - top: 0vh; - right: -100vw; -} - - -.select-content * .ms-node-header-info>.ms-node-header-addons:nth-child(3) { - flex: 1; - justify-content: space-between; -} - -.features-count-displayer { - display: flex; -} - -.title-container { - display: flex; -} - -.title-icon { - height: 100%; - width: auto; - margin-right: 0.5em; -} - -.title-title { - flex-grow: 1; - text-align: center; -} - -.features-count { - font-weight: bold; -} - - -/*Statistics*/ -.feature-statistics { - display: flex; - flex-direction: column; - padding: 1rem; - width: 100%; -} - -.select-container { - display: flex; - width: 100%; - align-items: center; -} - -.select-container label { - font-weight: bold; - margin-right: 0.5rem; -} - -.select-container select { - flex-grow: 1; - padding: 0.5rem; - border: 1px solid #ccc; -} - -.statistics-table { - width: 100%; - margin-top: 1rem; -} - -.statistics-table td { - padding: 0.5rem; -} - -.statistics-table td:first-child { - font-weight: bold; -} diff --git a/web/client/themes/default/less/mapstore.less b/web/client/themes/default/less/mapstore.less index 546da24d0d9..dd17ae22036 100644 --- a/web/client/themes/default/less/mapstore.less +++ b/web/client/themes/default/less/mapstore.less @@ -91,5 +91,4 @@ @import "map-popup.less"; @import "map-views.less"; @import "permalink.less"; -@import "layer-selection.less"; @import "itinerary.less"; diff --git a/web/client/themes/default/less/toc.less b/web/client/themes/default/less/toc.less index e7e74d76fb8..e9f25f286c0 100644 --- a/web/client/themes/default/less/toc.less +++ b/web/client/themes/default/less/toc.less @@ -240,14 +240,14 @@ font-size: 12px; padding: 8px; border: 2px solid transparent; - ul { + ul:not(.dropdown-menu) { list-style: none; border-left: 1px solid transparent; padding-left: 16px; display: flex; flex-direction: column; } - & > ul { + & > ul:not(.dropdown-menu) { list-style: none; margin-bottom: 0; padding: 0; @@ -255,7 +255,7 @@ } .ms-node-layer { .shadow-soft(); - ul:not(.ms-legend) { + ul:not(.ms-legend):not(.dropdown-menu) { padding: 4px 8px; } } diff --git a/web/client/utils/openlayers/DrawGeometryInteraction.js b/web/client/utils/openlayers/DrawGeometryInteraction.js index bf7a0a1f75a..b51b4322466 100644 --- a/web/client/utils/openlayers/DrawGeometryInteraction.js +++ b/web/client/utils/openlayers/DrawGeometryInteraction.js @@ -13,7 +13,7 @@ import GeoJSON from 'ol/format/GeoJSON'; import { Point, Polygon, LineString, Circle } from 'ol/geom'; import {circular} from 'ol/geom/Polygon'; import {getDistance} from 'ol/sphere'; -import {transform} from 'ol/proj'; +import {fromUserCoordinate, getUserProjection, transform} from 'ol/proj'; import Feature from 'ol/Feature'; import { squaredDistance } from 'ol/coordinate'; import Style from 'ol/style/Style'; @@ -24,6 +24,7 @@ import tinycolor from 'tinycolor2'; import { never } from 'ol/events/condition'; import { transformLineToArcs, reproject } from '../CoordinatesUtils'; import { generateEditingStyle } from '../DrawUtils'; +import { boundingExtent, getBottomLeft, getBottomRight, getTopLeft, getTopRight } from 'ol/extent'; const geoJSON = new GeoJSON(); @@ -149,6 +150,36 @@ const defaultGeometryFunction = { coordinates: [..._coordinates] }); return _geometry; + }, + 'Rectangle': ({ onDrawing }) => (coordinates, geometry, projection) => { + const extent = boundingExtent(([ + coordinates[0], + coordinates[coordinates.length - 1] + ]) + .map(function(coordinate) { + return fromUserCoordinate(coordinate, projection); + })); + const boxCoordinates = [[ + getBottomLeft(extent), + getBottomRight(extent), + getTopRight(extent), + getTopLeft(extent), + getBottomLeft(extent) + ]]; + let _geometry = geometry; + if (_geometry) { + _geometry.setCoordinates(boxCoordinates); + } else { + _geometry = new Polygon(boxCoordinates); + } + const userProjection = getUserProjection(); + if (userProjection) { + _geometry.transform(projection, userProjection); + } + onDrawing({ + coordinates: [...boxCoordinates] + }); + return _geometry; } }; @@ -162,7 +193,7 @@ function getColor(color, opacity) { /** * Class to manage all the drawing interaction of OpenLayers library - * @param {string} options.type type of drawing, one of: `Point`, `LineString`, `Polygon` or `Circle` + * @param {string} options.type type of drawing, one of: `Point`, `LineString`, `Polygon`, `Rectangle` or `Circle` * @param {object} options.map a Cesium map instance * @param {number} options.coordinatesLength maximum count of drawing coordinates * @param {object} options.style style for drawing geometries, see the `web/client/DrawUtils.js` file @@ -223,9 +254,10 @@ class OpenLayersDrawGeometryInteraction { ? geodesicGeometryFunction[geometryType] : defaultGeometryFunction[geometryType]; + const drawType = ['Rectangle'].includes(geometryType) ? 'Circle' : geometryType; this._draw = new Draw({ source: source, - type: geometryType, + type: drawType, maxPoints: coordinatesLength, stopClick: true, freehandCondition: never, @@ -258,7 +290,8 @@ class OpenLayersDrawGeometryInteraction { }); } if ((olGeometryType === 'Polygon' && ['Polygon', 'Circle'].includes(geometryType)) - || olGeometryType === 'Circle') { + || olGeometryType === 'Circle' + || geometryType === 'Rectangle') { return new Style({ stroke: lineDrawingStyle, fill: areaDrawingStyle From 0243cbeb747eb515fbad739c261f1cbffcee62c8 Mon Sep 17 00:00:00 2001 From: allyoucanmap Date: Tue, 13 Jan 2026 16:56:36 +0100 Subject: [PATCH 62/76] ensure states is cleared on map change --- .../layersSelection/components/LayersSelectionSupport.jsx | 8 +++++--- web/client/plugins/layersSelection/index.js | 3 ++- web/client/product/plugins.js | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/web/client/plugins/layersSelection/components/LayersSelectionSupport.jsx b/web/client/plugins/layersSelection/components/LayersSelectionSupport.jsx index edff628db91..2ca24ae3928 100644 --- a/web/client/plugins/layersSelection/components/LayersSelectionSupport.jsx +++ b/web/client/plugins/layersSelection/components/LayersSelectionSupport.jsx @@ -27,7 +27,8 @@ const LayersSelectionSupport = ({ feature, onChange = () => {}, onUpdateLayer = () => {}, - onRemoveLayer = () => {} + onRemoveLayer = () => {}, + cleanSelection }) => { const DrawGeometrySupport = drawGeometrySupportSupports[mapType]; @@ -35,9 +36,10 @@ const LayersSelectionSupport = ({ useEffect(() => { return () => { onChange(null); - onRemoveLayer({ id: LAYER_SELECTION_LAYER_ID }); + onRemoveLayer({ owner: LAYER_SELECTION_LAYER_ID }); + cleanSelection(); }; - }, [onUpdateLayer, onChange, onRemoveLayer]); + }, [onUpdateLayer, onChange, onRemoveLayer, mapType, cleanSelection]); useEffect(() => { onUpdateLayer(LAYER_SELECTION_LAYER_ID, LAYER_SELECTION_LAYER_ID, 'overlay', { diff --git a/web/client/plugins/layersSelection/index.js b/web/client/plugins/layersSelection/index.js index 5ce2499b263..085ee054407 100644 --- a/web/client/plugins/layersSelection/index.js +++ b/web/client/plugins/layersSelection/index.js @@ -110,7 +110,8 @@ export default createPlugin('LayersSelection', { { onChange: updateSelectionFeature, onUpdateLayer: updateAdditionalLayer, - onRemoveLayer: removeAdditionalLayer + onRemoveLayer: removeAdditionalLayer, + cleanSelection } )(LayersSelectionSupport), alwaysRender: true diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index f93e606e752..c48c6fe2166 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -96,7 +96,7 @@ export const plugins = { LanguagePlugin: toModulePlugin('Language', () => import(/* webpackChunkName: 'plugins/language' */ '../plugins/Language')), LayerDownload: toModulePlugin('LayerDownload', () => import(/* webpackChunkName: 'plugins/layerDownload' */ '../plugins/LayerDownload')), LayerInfoPlugin: toModulePlugin('LayerInfo', () => import(/* webpackChunkName: 'plugins/layerInfo' */ '../plugins/LayerInfo')), - LayersSelectionPlugin: toModulePlugin('LayersSelection', () => import(/* webpackChunkName: 'plugins/snapshot' */ '../plugins/LayersSelection')), + LayersSelectionPlugin: toModulePlugin('LayersSelection', () => import(/* webpackChunkName: 'plugins/layersSelection' */ '../plugins/LayersSelection')), LocatePlugin: toModulePlugin('Locate', () => import(/* webpackChunkName: 'plugins/locate' */ '../plugins/Locate')), LongitudinalProfileToolPlugin: toModulePlugin('LongitudinalProfileTool', () => import(/* webpackChunkName: 'plugins/LongitudinalProfileTool' */ '../plugins/LongitudinalProfileTool')), ManagerMenuPlugin: toModulePlugin('ManagerMenu', () => import(/* webpackChunkName: 'plugins/managerMenu' */ '../plugins/manager/ManagerMenu')), From 6809ed08e84ebb4af936d7225f38697c4a7106cd Mon Sep 17 00:00:00 2001 From: allyoucanmap Date: Tue, 13 Jan 2026 17:17:28 +0100 Subject: [PATCH 63/76] rename plugin directory --- .../actions/layersSelection.js | 102 ++++++------- .../components/EllipsisButton.jsx | 0 .../components/LayersSelection.jsx | 0 .../components/LayersSelectionSupport.jsx | 0 .../components/Statistics.jsx | 0 .../components/layersSelection.css | 0 .../epics/layersSelection.js | 0 .../index.js | 0 .../reducers/layersSelection.js | 0 .../selectors/layersSelection.js | 0 .../utils/LayersSelection.js | 142 +++++++++--------- 11 files changed, 122 insertions(+), 122 deletions(-) rename web/client/plugins/{layersSelection => layersSelection_}/actions/layersSelection.js (96%) rename web/client/plugins/{layersSelection => layersSelection_}/components/EllipsisButton.jsx (100%) rename web/client/plugins/{layersSelection => layersSelection_}/components/LayersSelection.jsx (100%) rename web/client/plugins/{layersSelection => layersSelection_}/components/LayersSelectionSupport.jsx (100%) rename web/client/plugins/{layersSelection => layersSelection_}/components/Statistics.jsx (100%) rename web/client/plugins/{layersSelection => layersSelection_}/components/layersSelection.css (100%) rename web/client/plugins/{layersSelection => layersSelection_}/epics/layersSelection.js (100%) rename web/client/plugins/{layersSelection => layersSelection_}/index.js (100%) rename web/client/plugins/{layersSelection => layersSelection_}/reducers/layersSelection.js (100%) rename web/client/plugins/{layersSelection => layersSelection_}/selectors/layersSelection.js (100%) rename web/client/plugins/{layersSelection => layersSelection_}/utils/LayersSelection.js (97%) diff --git a/web/client/plugins/layersSelection/actions/layersSelection.js b/web/client/plugins/layersSelection_/actions/layersSelection.js similarity index 96% rename from web/client/plugins/layersSelection/actions/layersSelection.js rename to web/client/plugins/layersSelection_/actions/layersSelection.js index 24cda377939..47f1b985977 100644 --- a/web/client/plugins/layersSelection/actions/layersSelection.js +++ b/web/client/plugins/layersSelection_/actions/layersSelection.js @@ -1,51 +1,51 @@ -export const SELECT_CLEAN_SELECTION = "SELECT:CLEAN_SELECTION"; -export const SELECT_STORE_CFG = "SELECT:STORE_CFG"; -export const ADD_OR_UPDATE_SELECTION = "SELECT:ADD_OR_UPDATE_SELECTION"; -export const UPDATE_SELECTION_FEATURE = "SELECT:UPDATE_SELECTION_FEATURE"; -/** - * Action creator to clean the current selection based on geometry type. - * - * @param {string} geomType - The type of geometry to clean (e.g., "Point", "Polygon"). - * @returns {{ type: string, geomType: string }} The action object. - */ -export function cleanSelection(geomType) { - return { - type: SELECT_CLEAN_SELECTION, - geomType - }; -} - -/** - * Action creator to store configuration settings related to selection. - * - * @param {Object} cfg - Configuration object to store. - * @returns {{ type: string, cfg: Object }} The action object. - */ -export function storeConfiguration(cfg) { - return { - type: SELECT_STORE_CFG, - cfg - }; -} - -/** - * Action creator to add or update a layer selection with GeoJSON data. - * - * @param {string} layer - The name or ID of the layer. - * @param {Object} geoJsonData - The GeoJSON data representing the selection. - * @returns {{ type: string, layer: string, geoJsonData: Object }} The action object. - */ -export function addOrUpdateSelection(layer, geoJsonData) { - return { - type: ADD_OR_UPDATE_SELECTION, - layer, - geoJsonData - }; -} - -export function updateSelectionFeature(feature) { - return { - type: UPDATE_SELECTION_FEATURE, - feature - }; -} +export const SELECT_CLEAN_SELECTION = "SELECT:CLEAN_SELECTION"; +export const SELECT_STORE_CFG = "SELECT:STORE_CFG"; +export const ADD_OR_UPDATE_SELECTION = "SELECT:ADD_OR_UPDATE_SELECTION"; +export const UPDATE_SELECTION_FEATURE = "SELECT:UPDATE_SELECTION_FEATURE"; +/** + * Action creator to clean the current selection based on geometry type. + * + * @param {string} geomType - The type of geometry to clean (e.g., "Point", "Polygon"). + * @returns {{ type: string, geomType: string }} The action object. + */ +export function cleanSelection(geomType) { + return { + type: SELECT_CLEAN_SELECTION, + geomType + }; +} + +/** + * Action creator to store configuration settings related to selection. + * + * @param {Object} cfg - Configuration object to store. + * @returns {{ type: string, cfg: Object }} The action object. + */ +export function storeConfiguration(cfg) { + return { + type: SELECT_STORE_CFG, + cfg + }; +} + +/** + * Action creator to add or update a layer selection with GeoJSON data. + * + * @param {string} layer - The name or ID of the layer. + * @param {Object} geoJsonData - The GeoJSON data representing the selection. + * @returns {{ type: string, layer: string, geoJsonData: Object }} The action object. + */ +export function addOrUpdateSelection(layer, geoJsonData) { + return { + type: ADD_OR_UPDATE_SELECTION, + layer, + geoJsonData + }; +} + +export function updateSelectionFeature(feature) { + return { + type: UPDATE_SELECTION_FEATURE, + feature + }; +} diff --git a/web/client/plugins/layersSelection/components/EllipsisButton.jsx b/web/client/plugins/layersSelection_/components/EllipsisButton.jsx similarity index 100% rename from web/client/plugins/layersSelection/components/EllipsisButton.jsx rename to web/client/plugins/layersSelection_/components/EllipsisButton.jsx diff --git a/web/client/plugins/layersSelection/components/LayersSelection.jsx b/web/client/plugins/layersSelection_/components/LayersSelection.jsx similarity index 100% rename from web/client/plugins/layersSelection/components/LayersSelection.jsx rename to web/client/plugins/layersSelection_/components/LayersSelection.jsx diff --git a/web/client/plugins/layersSelection/components/LayersSelectionSupport.jsx b/web/client/plugins/layersSelection_/components/LayersSelectionSupport.jsx similarity index 100% rename from web/client/plugins/layersSelection/components/LayersSelectionSupport.jsx rename to web/client/plugins/layersSelection_/components/LayersSelectionSupport.jsx diff --git a/web/client/plugins/layersSelection/components/Statistics.jsx b/web/client/plugins/layersSelection_/components/Statistics.jsx similarity index 100% rename from web/client/plugins/layersSelection/components/Statistics.jsx rename to web/client/plugins/layersSelection_/components/Statistics.jsx diff --git a/web/client/plugins/layersSelection/components/layersSelection.css b/web/client/plugins/layersSelection_/components/layersSelection.css similarity index 100% rename from web/client/plugins/layersSelection/components/layersSelection.css rename to web/client/plugins/layersSelection_/components/layersSelection.css diff --git a/web/client/plugins/layersSelection/epics/layersSelection.js b/web/client/plugins/layersSelection_/epics/layersSelection.js similarity index 100% rename from web/client/plugins/layersSelection/epics/layersSelection.js rename to web/client/plugins/layersSelection_/epics/layersSelection.js diff --git a/web/client/plugins/layersSelection/index.js b/web/client/plugins/layersSelection_/index.js similarity index 100% rename from web/client/plugins/layersSelection/index.js rename to web/client/plugins/layersSelection_/index.js diff --git a/web/client/plugins/layersSelection/reducers/layersSelection.js b/web/client/plugins/layersSelection_/reducers/layersSelection.js similarity index 100% rename from web/client/plugins/layersSelection/reducers/layersSelection.js rename to web/client/plugins/layersSelection_/reducers/layersSelection.js diff --git a/web/client/plugins/layersSelection/selectors/layersSelection.js b/web/client/plugins/layersSelection_/selectors/layersSelection.js similarity index 100% rename from web/client/plugins/layersSelection/selectors/layersSelection.js rename to web/client/plugins/layersSelection_/selectors/layersSelection.js diff --git a/web/client/plugins/layersSelection/utils/LayersSelection.js b/web/client/plugins/layersSelection_/utils/LayersSelection.js similarity index 97% rename from web/client/plugins/layersSelection/utils/LayersSelection.js rename to web/client/plugins/layersSelection_/utils/LayersSelection.js index b8356fe7ab8..235e9091135 100644 --- a/web/client/plugins/layersSelection/utils/LayersSelection.js +++ b/web/client/plugins/layersSelection_/utils/LayersSelection.js @@ -1,71 +1,71 @@ -/* - * Copyright 2026, GeoSolutions Sas. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { updateAdditionalLayer } from '../../../actions/additionallayers'; -import { applyMapInfoStyle } from '../../../selectors/mapInfo'; - -export const buildAdditionalLayerName = layerId => `"highlight-select-${layerId}-features"`; -export const buildAdditionalLayerOwnerName = layerId => `Select_${layerId}`; -export const buildAdditionalLayerId = layerId => `${buildAdditionalLayerOwnerName(layerId)}_id`; - -function getGeometryType(geometry) { - if (geometry.x !== undefined && geometry.y !== undefined) { - return "Point"; - } else if (geometry.paths) { - return "LineString"; - } else if (geometry.rings) { - return "Polygon"; - } - return null; -} - -function convertCoordinates(geometry) { - if (geometry.x !== undefined && geometry.y !== undefined) { - return [geometry.x, geometry.y]; - } else if (geometry.paths) { - return geometry.paths[0]; - } else if (geometry.rings) { - return geometry.rings; - } - return null; -} - -export const makeCrsValid = crs => { - const crsSplit = crs.toString().split(':'); - const crsSplitLength = crsSplit.length; - if (crsSplitLength === 1) { - return 'EPSG' + ':' + crsSplit[0]; - } else if (crsSplitLength > 1) { - const geodeticIndex = crsSplit.lastIndexOf(s => s.length > 0, crsSplitLength - 2); - return (geodeticIndex > -1 ? crsSplit[geodeticIndex] : 'EPSG') + ':' + crsSplit[crsSplitLength - 1]; - } - return 'EPSG:4326'; -}; - -export const arcgisToGeoJSON = (arcgisFeatures, layerName, idField) => arcgisFeatures.map(feature => ({ - type: "Feature", - id: `${layerName}.${feature.attributes[idField]}`, - geometry_name: "geometry", - geometry: { - type: getGeometryType(feature.geometry), - coordinates: convertCoordinates(feature.geometry) - }, - properties: feature.attributes -})); - -export const customUpdateAdditionalLayer = (layerId, features, isVisible, highlightStyle) => updateAdditionalLayer( - buildAdditionalLayerId(layerId), - buildAdditionalLayerOwnerName(layerId), - "overlay", - { - type: "vector", - name: buildAdditionalLayerName(layerId), - visibility: isVisible, - features: features.map(applyMapInfoStyle(highlightStyle)) - } -); +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { updateAdditionalLayer } from '../../../actions/additionallayers'; +import { applyMapInfoStyle } from '../../../selectors/mapInfo'; + +export const buildAdditionalLayerName = layerId => `"highlight-select-${layerId}-features"`; +export const buildAdditionalLayerOwnerName = layerId => `Select_${layerId}`; +export const buildAdditionalLayerId = layerId => `${buildAdditionalLayerOwnerName(layerId)}_id`; + +function getGeometryType(geometry) { + if (geometry.x !== undefined && geometry.y !== undefined) { + return "Point"; + } else if (geometry.paths) { + return "LineString"; + } else if (geometry.rings) { + return "Polygon"; + } + return null; +} + +function convertCoordinates(geometry) { + if (geometry.x !== undefined && geometry.y !== undefined) { + return [geometry.x, geometry.y]; + } else if (geometry.paths) { + return geometry.paths[0]; + } else if (geometry.rings) { + return geometry.rings; + } + return null; +} + +export const makeCrsValid = crs => { + const crsSplit = crs.toString().split(':'); + const crsSplitLength = crsSplit.length; + if (crsSplitLength === 1) { + return 'EPSG' + ':' + crsSplit[0]; + } else if (crsSplitLength > 1) { + const geodeticIndex = crsSplit.lastIndexOf(s => s.length > 0, crsSplitLength - 2); + return (geodeticIndex > -1 ? crsSplit[geodeticIndex] : 'EPSG') + ':' + crsSplit[crsSplitLength - 1]; + } + return 'EPSG:4326'; +}; + +export const arcgisToGeoJSON = (arcgisFeatures, layerName, idField) => arcgisFeatures.map(feature => ({ + type: "Feature", + id: `${layerName}.${feature.attributes[idField]}`, + geometry_name: "geometry", + geometry: { + type: getGeometryType(feature.geometry), + coordinates: convertCoordinates(feature.geometry) + }, + properties: feature.attributes +})); + +export const customUpdateAdditionalLayer = (layerId, features, isVisible, highlightStyle) => updateAdditionalLayer( + buildAdditionalLayerId(layerId), + buildAdditionalLayerOwnerName(layerId), + "overlay", + { + type: "vector", + name: buildAdditionalLayerName(layerId), + visibility: isVisible, + features: features.map(applyMapInfoStyle(highlightStyle)) + } +); From 5fbb600ac73add34cde65f89e6343d904e8f46d9 Mon Sep 17 00:00:00 2001 From: allyoucanmap Date: Tue, 13 Jan 2026 17:17:54 +0100 Subject: [PATCH 64/76] rename plugin directory --- .../actions/layersSelection.js | 0 .../components/EllipsisButton.jsx | 0 .../components/LayersSelection.jsx | 0 .../components/LayersSelectionSupport.jsx | 0 .../components/Statistics.jsx | 0 .../components/layersSelection.css | 0 .../epics/layersSelection.js | 0 web/client/plugins/{layersSelection_ => LayersSelection}/index.js | 0 .../reducers/layersSelection.js | 0 .../selectors/layersSelection.js | 0 .../utils/LayersSelection.js | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename web/client/plugins/{layersSelection_ => LayersSelection}/actions/layersSelection.js (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/components/EllipsisButton.jsx (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/components/LayersSelection.jsx (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/components/LayersSelectionSupport.jsx (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/components/Statistics.jsx (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/components/layersSelection.css (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/epics/layersSelection.js (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/index.js (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/reducers/layersSelection.js (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/selectors/layersSelection.js (100%) rename web/client/plugins/{layersSelection_ => LayersSelection}/utils/LayersSelection.js (100%) diff --git a/web/client/plugins/layersSelection_/actions/layersSelection.js b/web/client/plugins/LayersSelection/actions/layersSelection.js similarity index 100% rename from web/client/plugins/layersSelection_/actions/layersSelection.js rename to web/client/plugins/LayersSelection/actions/layersSelection.js diff --git a/web/client/plugins/layersSelection_/components/EllipsisButton.jsx b/web/client/plugins/LayersSelection/components/EllipsisButton.jsx similarity index 100% rename from web/client/plugins/layersSelection_/components/EllipsisButton.jsx rename to web/client/plugins/LayersSelection/components/EllipsisButton.jsx diff --git a/web/client/plugins/layersSelection_/components/LayersSelection.jsx b/web/client/plugins/LayersSelection/components/LayersSelection.jsx similarity index 100% rename from web/client/plugins/layersSelection_/components/LayersSelection.jsx rename to web/client/plugins/LayersSelection/components/LayersSelection.jsx diff --git a/web/client/plugins/layersSelection_/components/LayersSelectionSupport.jsx b/web/client/plugins/LayersSelection/components/LayersSelectionSupport.jsx similarity index 100% rename from web/client/plugins/layersSelection_/components/LayersSelectionSupport.jsx rename to web/client/plugins/LayersSelection/components/LayersSelectionSupport.jsx diff --git a/web/client/plugins/layersSelection_/components/Statistics.jsx b/web/client/plugins/LayersSelection/components/Statistics.jsx similarity index 100% rename from web/client/plugins/layersSelection_/components/Statistics.jsx rename to web/client/plugins/LayersSelection/components/Statistics.jsx diff --git a/web/client/plugins/layersSelection_/components/layersSelection.css b/web/client/plugins/LayersSelection/components/layersSelection.css similarity index 100% rename from web/client/plugins/layersSelection_/components/layersSelection.css rename to web/client/plugins/LayersSelection/components/layersSelection.css diff --git a/web/client/plugins/layersSelection_/epics/layersSelection.js b/web/client/plugins/LayersSelection/epics/layersSelection.js similarity index 100% rename from web/client/plugins/layersSelection_/epics/layersSelection.js rename to web/client/plugins/LayersSelection/epics/layersSelection.js diff --git a/web/client/plugins/layersSelection_/index.js b/web/client/plugins/LayersSelection/index.js similarity index 100% rename from web/client/plugins/layersSelection_/index.js rename to web/client/plugins/LayersSelection/index.js diff --git a/web/client/plugins/layersSelection_/reducers/layersSelection.js b/web/client/plugins/LayersSelection/reducers/layersSelection.js similarity index 100% rename from web/client/plugins/layersSelection_/reducers/layersSelection.js rename to web/client/plugins/LayersSelection/reducers/layersSelection.js diff --git a/web/client/plugins/layersSelection_/selectors/layersSelection.js b/web/client/plugins/LayersSelection/selectors/layersSelection.js similarity index 100% rename from web/client/plugins/layersSelection_/selectors/layersSelection.js rename to web/client/plugins/LayersSelection/selectors/layersSelection.js diff --git a/web/client/plugins/layersSelection_/utils/LayersSelection.js b/web/client/plugins/LayersSelection/utils/LayersSelection.js similarity index 100% rename from web/client/plugins/layersSelection_/utils/LayersSelection.js rename to web/client/plugins/LayersSelection/utils/LayersSelection.js From de9b2f0bd42df5fdcfb41460b954abda266c62cb Mon Sep 17 00:00:00 2001 From: allyoucanmap Date: Tue, 13 Jan 2026 17:22:57 +0100 Subject: [PATCH 65/76] include plugin in jsdoc --- build/docma-config.json | 1 + web/client/plugins/LayersSelection/index.js | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/build/docma-config.json b/build/docma-config.json index 866162f11f6..8dc114e5c3c 100644 --- a/build/docma-config.json +++ b/build/docma-config.json @@ -265,6 +265,7 @@ "web/client/plugins/Language.jsx", "web/client/plugins/LayerDownload.jsx", "web/client/plugins/LayerInfo.jsx", + "web/client/plugins/LayersSelection/index.js", "web/client/plugins/Locate.jsx", "web/client/plugins/Login.jsx", "web/client/plugins/LongitudinalProfileTool.jsx", diff --git a/web/client/plugins/LayersSelection/index.js b/web/client/plugins/LayersSelection/index.js index 085ee054407..97d7ecd908c 100644 --- a/web/client/plugins/LayersSelection/index.js +++ b/web/client/plugins/LayersSelection/index.js @@ -29,11 +29,11 @@ import { removeAdditionalLayer, updateAdditionalLayer } from '../../actions/addi /** * Select plugin that enables layer feature selection in the map. - * It connects Redux state and actions to the SelectComponent UI. * Uses selectors to retrieve visibility, layers, selection results, and feature count. - * - * @function - * @returns {Object} A plugin definition object used by the application to render and control the Select tool. + * @class LayersSelection + * @memberof plugins + * @static + * @example */ export default createPlugin('LayersSelection', { component: connect( From 0173f7b55d86513c4b23b0e167dd3b81283cf378 Mon Sep 17 00:00:00 2001 From: allyoucanmap Date: Tue, 13 Jan 2026 18:16:41 +0100 Subject: [PATCH 66/76] ensure the 4326 projection output on arcgis layer --- web/client/plugins/LayersSelection/epics/layersSelection.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/client/plugins/LayersSelection/epics/layersSelection.js b/web/client/plugins/LayersSelection/epics/layersSelection.js index 8e0b9fb0e80..d8a2a520bfa 100644 --- a/web/client/plugins/LayersSelection/epics/layersSelection.js +++ b/web/client/plugins/LayersSelection/epics/layersSelection.js @@ -57,6 +57,9 @@ const queryLayer = (layer, geometry, selectQueryMaxCount) => { spatialRel: "esriSpatialRelIntersects", where: '1=1', outFields: '*', + // output feature to EPSG:4326 + // to make them visible on the vector layer + outSR: '4326', ...(describe.data.advancedQueryCapabilities.supportsPagination && selectQueryMaxCount > -1 ? { resultRecordCount: selectQueryMaxCount } : {}) }}) .then(response => ({ features: arcgisToGeoJSON(response.data.features, describe.data.name, response.data.fields.find(field => field.type === 'esriFieldTypeOID')?.name ?? response.data.objectIdFieldName ?? 'objectid'), crs: makeCrsValid(describe.data.sourceSpatialReference.wkid.toString()) })) From 73f3362ea24b90215f641825029a8bcc8216fced Mon Sep 17 00:00:00 2001 From: allyoucanmap Date: Tue, 13 Jan 2026 18:33:34 +0100 Subject: [PATCH 67/76] format json files --- .../templates/configs/pluginsConfig.json | 4 +- web/client/configs/pluginsConfig.json | 4 +- web/client/translations/data.de-DE.json | 48 +++++++++---------- web/client/translations/data.en-US.json | 48 +++++++++---------- web/client/translations/data.es-ES.json | 48 +++++++++---------- web/client/translations/data.fr-FR.json | 48 +++++++++---------- web/client/translations/data.it-IT.json | 48 +++++++++---------- 7 files changed, 122 insertions(+), 126 deletions(-) diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json index c93c35e8757..eda14def5c9 100644 --- a/project/standard/templates/configs/pluginsConfig.json +++ b/project/standard/templates/configs/pluginsConfig.json @@ -158,9 +158,7 @@ "glyph": "hand-down", "title": "plugins.LayersSelection.title", "description": "plugins.LayersSelection.description", - "dependencies": [ - "SidebarMenu" - ] + "dependencies": ["SidebarMenu"] }, { "name": "BackgroundSelector", diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index 86eaa21d005..e7b8155ee38 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -160,9 +160,7 @@ "glyph": "hand-down", "title": "plugins.LayersSelection.title", "description": "plugins.LayersSelection.description", - "dependencies": [ - "SidebarMenu" - ] + "dependencies": ["SidebarMenu"] }, { "name": "BackgroundSelector", diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 5bfb43acc26..7fa1b65cb5d 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -1594,32 +1594,32 @@ "allLayers": "Alle Schichten", "featuresCount": "Featuresanzahl", "button": { - "select": "Auswahlmodus", - "chooseGeometry": "Wählen", - "selectByPoint": "Punkt", - "selectByLine": "Linie", - "selectByCircle": "Kreis", - "selectByRectangle": "Rechteck", - "selectByPolygon": "Polygon", - "clear": "Löschen", - "zoomTo": "Zoomen auf", - "statistics": "Statistiken", - "createLayer": "Ebene erstellen", - "filterData": "Daten filtern", - "export": "Exportieren", - "exportToCsv": "Exportieren nach CSV", - "exportToJson": "Exportieren nach JSON", - "exportToGeoJson": "Exportieren nach GeoJSON" + "select": "Auswahlmodus", + "chooseGeometry": "Wählen", + "selectByPoint": "Punkt", + "selectByLine": "Linie", + "selectByCircle": "Kreis", + "selectByRectangle": "Rechteck", + "selectByPolygon": "Polygon", + "clear": "Löschen", + "zoomTo": "Zoomen auf", + "statistics": "Statistiken", + "createLayer": "Ebene erstellen", + "filterData": "Daten filtern", + "export": "Exportieren", + "exportToCsv": "Exportieren nach CSV", + "exportToJson": "Exportieren nach JSON", + "exportToGeoJson": "Exportieren nach GeoJSON" }, "statistics": { - "title": "Statistiken", - "field": "Feld", - "count": "Anzahl der Werte", - "sum": "Summe der Werte", - "min": "Minimum", - "max": "Maximum", - "avg": "Durchschnitt", - "std": "Standardabweichung" + "title": "Statistiken", + "field": "Feld", + "count": "Anzahl der Werte", + "sum": "Summe der Werte", + "min": "Minimum", + "max": "Maximum", + "avg": "Durchschnitt", + "std": "Standardabweichung" } }, "snapshot": { diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 7386eb83412..a4c61f93871 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -1555,32 +1555,32 @@ "allLayers": "All layers", "featuresCount": "Features number", "button": { - "select": "Selection mode", - "chooseGeometry": "Choose", - "selectByPoint": "Point", - "selectByLine": "Line", - "selectByCircle": "Circle", - "selectByRectangle": "Rectangle", - "selectByPolygon": "Polygon", - "clear": "Clear", - "zoomTo": "Zoom to", - "statistics": "Statistics", - "createLayer": "Create layer", - "filterData": "Filter data", - "export": "Export", - "exportToCsv": "Export to CSV", - "exportToJson": "Export to JSON", - "exportToGeoJson": "Export to GeoJSON" + "select": "Selection mode", + "chooseGeometry": "Choose", + "selectByPoint": "Point", + "selectByLine": "Line", + "selectByCircle": "Circle", + "selectByRectangle": "Rectangle", + "selectByPolygon": "Polygon", + "clear": "Clear", + "zoomTo": "Zoom to", + "statistics": "Statistics", + "createLayer": "Create layer", + "filterData": "Filter data", + "export": "Export", + "exportToCsv": "Export to CSV", + "exportToJson": "Export to JSON", + "exportToGeoJson": "Export to GeoJSON" }, "statistics": { - "title": "Statistics", - "field": "Field", - "count": "Number of values", - "sum": "Sum of values", - "min": "Minimum", - "max": "Maximum", - "avg": "Average", - "std": "Standard deviation" + "title": "Statistics", + "field": "Field", + "count": "Number of values", + "sum": "Sum of values", + "min": "Minimum", + "max": "Maximum", + "avg": "Average", + "std": "Standard deviation" } }, "snapshot": { diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index bf334140121..30da1dc3aec 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -1555,32 +1555,32 @@ "allLayers": "Todas las capas", "featuresCount": "Número de entidades", "button": { - "select": "Modo de selección", - "chooseGeometry": "Elegir", - "selectByPoint": "Punto", - "selectByLine": "Línea", - "selectByCircle": "Círculo", - "selectByRectangle": "Rectángulo", - "selectByPolygon": "Polígono", - "clear": "Borrar", - "zoomTo": "Hacer zoom en", - "statistics": "Estadísticas", - "createLayer": "Crear capa", - "filterData": "Filtrar datos", - "export": "Exportar", - "exportToCsv": "Exportar a CSV", - "exportToJson": "Exportar a JSON", - "exportToGeoJson": "Exportar a GeoJSON" + "select": "Modo de selección", + "chooseGeometry": "Elegir", + "selectByPoint": "Punto", + "selectByLine": "Línea", + "selectByCircle": "Círculo", + "selectByRectangle": "Rectángulo", + "selectByPolygon": "Polígono", + "clear": "Borrar", + "zoomTo": "Hacer zoom en", + "statistics": "Estadísticas", + "createLayer": "Crear capa", + "filterData": "Filtrar datos", + "export": "Exportar", + "exportToCsv": "Exportar a CSV", + "exportToJson": "Exportar a JSON", + "exportToGeoJson": "Exportar a GeoJSON" }, "statistics": { - "title": "Estadísticas", - "field": "Campo", - "count": "Número de valores", - "sum": "Suma de los valores", - "min": "Mínimo", - "max": "Máximo", - "avg": "Promedio", - "std": "Desviación estándar" + "title": "Estadísticas", + "field": "Campo", + "count": "Número de valores", + "sum": "Suma de los valores", + "min": "Mínimo", + "max": "Máximo", + "avg": "Promedio", + "std": "Desviación estándar" } }, "snapshot": { diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index a5b73e3921e..fc3c218e316 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -1556,32 +1556,32 @@ "allLayers": "Toutes les couches", "featuresCount": "Nombre d'entités", "button": { - "select": "Mode de sélection", - "chooseGeometry": "Choisir", - "selectByPoint": "Point", - "selectByLine": "Ligne", - "selectByCircle": "Cercle", - "selectByRectangle": "Rectangle", - "selectByPolygon": "Polygone", - "clear": "Effacer", - "zoomTo": "Zoomer sur", - "statistics": "Statistiques", - "createLayer": "Créer une couche", - "filterData": "Filtrer les données", - "export": "Exporter", - "exportToCsv": "Exporter en CSV", - "exportToJson": "Exporter en JSON", - "exportToGeoJson": "Exporter en GeoJSON" + "select": "Mode de sélection", + "chooseGeometry": "Choisir", + "selectByPoint": "Point", + "selectByLine": "Ligne", + "selectByCircle": "Cercle", + "selectByRectangle": "Rectangle", + "selectByPolygon": "Polygone", + "clear": "Effacer", + "zoomTo": "Zoomer sur", + "statistics": "Statistiques", + "createLayer": "Créer une couche", + "filterData": "Filtrer les données", + "export": "Exporter", + "exportToCsv": "Exporter en CSV", + "exportToJson": "Exporter en JSON", + "exportToGeoJson": "Exporter en GeoJSON" }, "statistics": { - "title": "Statistiques", - "field": "Champs", - "count": "Nombre de valeurs", - "sum": "Somme des valeurs", - "min": "Minimum", - "max": "Maximum", - "avg": "Moyenne", - "std": "Écart type" + "title": "Statistiques", + "field": "Champs", + "count": "Nombre de valeurs", + "sum": "Somme des valeurs", + "min": "Minimum", + "max": "Maximum", + "avg": "Moyenne", + "std": "Écart type" } }, "snapshot": { diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index f297509f3ab..be0b4156dcb 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -1555,32 +1555,32 @@ "allLayers": "Tutti i livelli", "featuresCount": "Numero di entità", "button": { - "select": "Modalità di selezione", - "chooseGeometry": "Scegliere", - "selectByPoint": "Punto", - "selectByLine": "Linea", - "selectByCircle": "Cerchio", - "selectByRectangle": "Rettangolo", - "selectByPolygon": "Poligono", - "clear": "Cancella", - "zoomTo": "Zoom su", - "statistics": "Statistiche", - "createLayer": "Crea livello", - "filterData": "Filtra dati", - "export": "Esporta", - "exportToCsv": "Esporta in CSV", - "exportToJson": "Esporta in JSON", - "exportToGeoJson": "Esporta in GeoJSON" + "select": "Modalità di selezione", + "chooseGeometry": "Scegliere", + "selectByPoint": "Punto", + "selectByLine": "Linea", + "selectByCircle": "Cerchio", + "selectByRectangle": "Rettangolo", + "selectByPolygon": "Poligono", + "clear": "Cancella", + "zoomTo": "Zoom su", + "statistics": "Statistiche", + "createLayer": "Crea livello", + "filterData": "Filtra dati", + "export": "Esporta", + "exportToCsv": "Esporta in CSV", + "exportToJson": "Esporta in JSON", + "exportToGeoJson": "Esporta in GeoJSON" }, "statistics": { - "title": "Statistiche", - "field": "Campo", - "count": "Numero di valori", - "sum": "Somma dei valori", - "min": "Minimo", - "max": "Massimo", - "avg": "Media", - "std": "Deviazione standard" + "title": "Statistiche", + "field": "Campo", + "count": "Numero di valori", + "sum": "Somma dei valori", + "min": "Minimo", + "max": "Massimo", + "avg": "Media", + "std": "Deviazione standard" } }, "snapshot": { From 86347076e1d9e2149eb32708c0aa6e8bd740b8f7 Mon Sep 17 00:00:00 2001 From: Benjamin Grenard Date: Tue, 27 Jan 2026 16:52:02 +0100 Subject: [PATCH 68/76] add uunit test --- .../__tests__/LayerSelection-test.jsx | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 web/client/plugins/LayersSelection/components/__tests__/LayerSelection-test.jsx diff --git a/web/client/plugins/LayersSelection/components/__tests__/LayerSelection-test.jsx b/web/client/plugins/LayersSelection/components/__tests__/LayerSelection-test.jsx new file mode 100644 index 00000000000..a62f52db951 --- /dev/null +++ b/web/client/plugins/LayersSelection/components/__tests__/LayerSelection-test.jsx @@ -0,0 +1,92 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import expect from 'expect'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { getLayerFromRecord } from '../../../../api/catalog/ArcGIS'; + +import SelectComponent from '../LayersSelection'; + +describe('Select Component', () => { + let container; + + beforeEach((done) => { + document.body.innerHTML = '
    '; + container = document.getElementById('container'); + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(container); + document.body.innerHTML = ''; + setTimeout(done); + }); + + const testRecord = { + name: 1, + title: "TestedLayer", + url: "base/web/client/test-resources/arcgis/arcgis-test-data.json" + }; + const layer = getLayerFromRecord(testRecord, { layerBaseConfig: { group: undefined } }); + + const defaultProps = { + layers: [layer], + onUpdateNode: () => { }, + onClose: () => { }, + isVisible: true, + highlightOptions: {}, + queryOptions: {}, + selectTools: [], + storeConfiguration: () => { }, + selections: {}, + maxFeatureCount: 1, + cleanSelection: () => { }, + addOrUpdateSelection: () => { }, + zoomToExtent: () => { }, + addLayer: () => { }, + changeLayerProperties: () => { } + }; + + + it('should render the component container', () => { + ReactDOM.render(, container); + const componentContainer = container.querySelector('.select-dialog'); + expect(componentContainer).toBeTruthy(); + }); + + it('should render the header when container is visible', () => { + ReactDOM.render(, container); + const componentContainer = container.querySelector('.select-dialog'); + const header = componentContainer.querySelector('.modal-header'); + expect(header).toBeTruthy(); + }); + + it('shouldn\'t render the component when container is not visible', () => { + const props = { ...defaultProps }; + props.isVisible = false; + ReactDOM.render(, container); + const componentContainer = container.querySelector('.select-dialog'); + expect(componentContainer).toBe(null); + }); + + it('should render layers tree', () => { + ReactDOM.render(, container); + const layerTreeIsExisting = container.querySelectorAll('.ms-layers-tree'); + expect(layerTreeIsExisting).toBeTruthy(); + }); + + it('should render layer TestedLayer in the layers tree', () => { + ReactDOM.render(, container); + const msnodestitle = container.querySelectorAll('.ms-node-title'); + const msnodestitleAsArray = Array.from(msnodestitle); + const LookingForTestedLayer = msnodestitleAsArray.filter(obj => obj.innerHTML === 'TestedLayer'); + expect(LookingForTestedLayer.length).toBeGreaterThanOrEqualTo(1); + + }); +}); From ee94b0fec976ad9a3e1556f737d146eec6c5578c Mon Sep 17 00:00:00 2001 From: Benjamin Grenard Date: Tue, 27 Jan 2026 17:30:38 +0100 Subject: [PATCH 69/76] fix docma config --- build/docma-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/docma-config.json b/build/docma-config.json index 8dc114e5c3c..e11d83d7fa0 100644 --- a/build/docma-config.json +++ b/build/docma-config.json @@ -265,7 +265,6 @@ "web/client/plugins/Language.jsx", "web/client/plugins/LayerDownload.jsx", "web/client/plugins/LayerInfo.jsx", - "web/client/plugins/LayersSelection/index.js", "web/client/plugins/Locate.jsx", "web/client/plugins/Login.jsx", "web/client/plugins/LongitudinalProfileTool.jsx", @@ -338,6 +337,7 @@ "web/client/plugins/containers/ToolsContainer.jsx", "web/client/plugins/featuregrid/FeatureEditor.jsx", "web/client/plugins/index.jsdoc", + "web/client/plugins/layersSelection/index.js", "web/client/plugins/manager/Manager.jsx", "web/client/plugins/manager/ManagerMenu.jsx", "web/client/plugins/print/Graticule.jsx", From a60e945737870326d44f8c509b2992d39ad62be6 Mon Sep 17 00:00:00 2001 From: Benjamin Grenard Date: Fri, 27 Feb 2026 10:36:04 +0100 Subject: [PATCH 70/76] fix case --- .../actions/layersSelection.js | 51 --- .../EllipsisButton/EllipsisButton.jsx | 247 --------------- .../EllipsisButton/Statistics/Statistics.jsx | 83 ----- .../components/LayersSelection.jsx | 226 ------------- .../layersSelection/epics/layersSelection.js | 296 ------------------ web/client/plugins/layersSelection/index.js | 120 ------- .../reducers/layersSelection.js | 53 ---- .../selectors/layersSelection.js | 100 ------ .../layersSelection/utils/LayersSelection.js | 71 ----- .../actions/layersSelection.js | 0 .../components/EllipsisButton.jsx | 0 .../components/LayersSelection.jsx | 0 .../LayersSelectionHeader.jsx | 0 .../components/LayersSelectionSupport.jsx | 0 .../components/Statistics.jsx | 0 .../__tests__/LayerSelection-test.jsx | 0 .../components/layersSelection.css | 0 .../epics/layersSelection.js | 0 .../index.js | 0 .../reducers/layersSelection.js | 0 .../selectors/layersSelection.js | 0 .../utils/LayersSelection.js | 0 22 files changed, 1247 deletions(-) delete mode 100644 web/client/plugins/layersSelection/actions/layersSelection.js delete mode 100644 web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx delete mode 100644 web/client/plugins/layersSelection/components/EllipsisButton/Statistics/Statistics.jsx delete mode 100644 web/client/plugins/layersSelection/components/LayersSelection.jsx delete mode 100644 web/client/plugins/layersSelection/epics/layersSelection.js delete mode 100644 web/client/plugins/layersSelection/index.js delete mode 100644 web/client/plugins/layersSelection/reducers/layersSelection.js delete mode 100644 web/client/plugins/layersSelection/selectors/layersSelection.js delete mode 100644 web/client/plugins/layersSelection/utils/LayersSelection.js rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/actions/layersSelection.js (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/components/EllipsisButton.jsx (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/components/LayersSelection.jsx (100%) rename web/client/plugins/{layersSelection => layersSelectionTempForFixCase}/components/LayersSelectionHeader/LayersSelectionHeader.jsx (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/components/LayersSelectionSupport.jsx (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/components/Statistics.jsx (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/components/__tests__/LayerSelection-test.jsx (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/components/layersSelection.css (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/epics/layersSelection.js (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/index.js (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/reducers/layersSelection.js (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/selectors/layersSelection.js (100%) rename web/client/plugins/{LayersSelection => layersSelectionTempForFixCase}/utils/LayersSelection.js (100%) diff --git a/web/client/plugins/layersSelection/actions/layersSelection.js b/web/client/plugins/layersSelection/actions/layersSelection.js deleted file mode 100644 index 24cda377939..00000000000 --- a/web/client/plugins/layersSelection/actions/layersSelection.js +++ /dev/null @@ -1,51 +0,0 @@ -export const SELECT_CLEAN_SELECTION = "SELECT:CLEAN_SELECTION"; -export const SELECT_STORE_CFG = "SELECT:STORE_CFG"; -export const ADD_OR_UPDATE_SELECTION = "SELECT:ADD_OR_UPDATE_SELECTION"; -export const UPDATE_SELECTION_FEATURE = "SELECT:UPDATE_SELECTION_FEATURE"; -/** - * Action creator to clean the current selection based on geometry type. - * - * @param {string} geomType - The type of geometry to clean (e.g., "Point", "Polygon"). - * @returns {{ type: string, geomType: string }} The action object. - */ -export function cleanSelection(geomType) { - return { - type: SELECT_CLEAN_SELECTION, - geomType - }; -} - -/** - * Action creator to store configuration settings related to selection. - * - * @param {Object} cfg - Configuration object to store. - * @returns {{ type: string, cfg: Object }} The action object. - */ -export function storeConfiguration(cfg) { - return { - type: SELECT_STORE_CFG, - cfg - }; -} - -/** - * Action creator to add or update a layer selection with GeoJSON data. - * - * @param {string} layer - The name or ID of the layer. - * @param {Object} geoJsonData - The GeoJSON data representing the selection. - * @returns {{ type: string, layer: string, geoJsonData: Object }} The action object. - */ -export function addOrUpdateSelection(layer, geoJsonData) { - return { - type: ADD_OR_UPDATE_SELECTION, - layer, - geoJsonData - }; -} - -export function updateSelectionFeature(feature) { - return { - type: UPDATE_SELECTION_FEATURE, - feature - }; -} diff --git a/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx deleted file mode 100644 index e88d5d8d4dc..00000000000 --- a/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { useState, useEffect, useContext } from 'react'; -import ReactDOM from "react-dom"; -import bbox from '@turf/bbox'; -import { saveAs } from 'file-saver'; -import axios from 'axios'; - -import Message from '../../../../components/I18N/Message'; -import { describeFeatureType } from '../../../../api/WFS'; - -import { SelectRefContext } from '../LayersSelection'; -import Statistics from './Statistics/Statistics'; - -/** - * EllipsisButton provides a contextual menu for selected layer data. - * It allows users to: - * - Zoom to selection extent - * - View statistics - * - Create a new layer from selection - * - Export data (GeoJSON, JSON, CSV) - * - Apply attribute filters (if supported) - * - Clear the selection - * - * @param {Object} props - Component props. - * @param {Object} props.node - Layer node (descriptor). - * @param {Array} props.layers - All available layers. - * @param {Object} props.selectionData - GeoJSON FeatureCollection. - * @param {Function} props.onAddOrUpdateSelection - Callback to update selection. - * @param {Function} props.onZoomToExtent - Callback to zoom to selection. - * @param {Function} props.onAddLayer - Callback to add a new layer. - * @param {Function} props.onChangeLayerProperties - Callback to change layer properties. - */ -export default ({ - node = {}, - // layers = [], - selectionData = {}, - onAddOrUpdateSelection = () => { }, - onZoomToExtent = () => { }, - onAddLayer = () => { }, - onChangeLayerProperties = () => { } -}) => { - const [menuOpen, setMenuOpen] = useState(false); - const [exportOpen, setExportOpen] = useState(false); - const [statisticsOpen, setStatisticsOpen] = useState(false); - const [numericFields, setNumericFields] = useState([]); - - const SelectRef = useContext(SelectRefContext); - const ellipsisContainerClass = 'ellipsis-container'; - useEffect(() => { - const selectElement = SelectRef.current?.addEventListener ? SelectRef.current : ReactDOM.findDOMNode(SelectRef.current); - if (!selectElement || !selectElement.addEventListener) { return null; } - const handleClick = e => { - if (menuOpen) { - let parentElement = e.target; - let foundThis = false; - while (!foundThis && parentElement !== e.currentTarget) { - foundThis = parentElement.className === ellipsisContainerClass; - parentElement = parentElement.parentElement; - } - if (!foundThis) { setMenuOpen(false); } - } - }; - selectElement.addEventListener("click", handleClick); - return () => selectElement.removeEventListener("click", handleClick); - }); - - const toggleMenu = () => setMenuOpen(!menuOpen); - const toggleExport = () => setExportOpen(!exportOpen); - - /** - * Generate id base on timestamp - * @returns {integer} unique Id - */ - const generateId = () => { - const timestamp = Date.now(); // Get current timestamp in milliseconds - const randomNum = Math.floor(Math.random() * 1000); // Add a random number for extra uniqueness - return `${timestamp}-${randomNum}`; - }; - - const triggerAction = (action) => { - switch (action) { - case 'clear': { - onAddOrUpdateSelection(node, {}); - break; - } - case 'zoomTo': { - if (selectionData.features?.length > 0) { - const extent = bbox(selectionData); - if (extent) { onZoomToExtent(extent, selectionData.crs.properties[selectionData.crs.type]); } - } - break; - } - case 'createLayer': { - if (selectionData.features?.length > 0) { - const nodeName = node.title + '_Select_'; - - const layerBbox = bbox(selectionData); - - // generate id for layer base on timestamp - const uniqueId = generateId(); - - onAddLayer({ - type: 'vector', - visibility: true, - name: nodeName + uniqueId, - hideLoading: true, - bbox: layerBbox, - features: selectionData.features - }); - } - break; - } - case 'exportToGeoJson': { - if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData)], { type: 'application/json' }), node.title + '.json'); } - break; - } - case 'exportToJson': { - if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData.features.map(feature => feature.properties))], { type: 'application/json' }), node.title + '.json'); } - break; - } - case 'exportToCsv': { - if (selectionData.features?.length > 0) { saveAs(new Blob([Object.keys(selectionData.features[0].properties).join(',') + '\n' + selectionData.features.map(feature => Object.values(feature.properties).join(',')).join('\n')], { type: 'text/csv' }), node.title + '.csv'); } - break; - } - case 'filterData': { - const customOnChangeLayerProperties = fieldIdName => onChangeLayerProperties(node.id, { - layerFilter: { - // searchUrl: null, - // featureTypeConfigUrl: null, - // showGeneratedFilter: false, - // attributePanelExpanded: true, - // spatialPanelExpanded: false, - // crossLayerExpanded: false, - // showDetailsPanel: false, - // groupLevels: 5, - // useMapProjection: false, - // toolbarEnabled: true, - groupFields: [ - { - id: 1, - logic: 'OR', - index: 0 - } - ], - // maxFeaturesWPS: 5, - filterFields: selectionData.features.map(feature => ({ - rowId: new Date().getDate(), - groupId: 1, - attribute: fieldIdName, - operator: '=', - value: feature.properties[fieldIdName], - type: 'number', - fieldOptions: { - valuesCount: 0, - currentPage: 1 - }, - exception: null - })) - // spatialField: null, - // simpleFilterFields: [], - // map: null, - // filters: [], - // crossLayerFilter: null, - // autocompleteEnabled: true - } - }); - switch (node.type) { - case 'arcgis': { - // TODO : implement here when MapStore supports filtering for arcgis services - throw new Error(`Unsupported layer type: ${node.type}`); - // break; - } - case 'wms': - case 'wfs': { - describeFeatureType(node.url, node.name) - .then(describe => customOnChangeLayerProperties(describe.featureTypes.find(featureType => node.name.endsWith(featureType.typeName)).properties.find(property => ['xsd:string', 'xsd:int'].find(type => type === property.type) && !property.nillable && property.maxOccurs === 1 && property.minOccurs === 1).name)) - .catch(err => { throw new Error(`Error while querying layer: ${err.message}`); }); - break; - } - default: - throw new Error(`Unsupported layer type: ${node.type}`); - } - break; - } - default: - } - toggleMenu(); - }; - - useEffect(() => { - switch (node.type) { - case 'arcgis': { - const arcgisNumericFields = new Set(['esriFieldTypeSmallInteger', 'esriFieldTypeInteger', 'esriFieldTypeSingle', 'esriFieldTypeDouble']); - const singleLayerId = parseInt(node.name ?? '', 10); - Promise.all((Number.isInteger(singleLayerId) ? node.options.layers.filter(l => l.id === singleLayerId) : node.options.layers).map(l => axios.get(`${node.url}/${l.id}`, { params: { f: 'json' } }) - .then(describe => describe.data.fields.filter(field => field.domain === null && arcgisNumericFields.has(field.type)).map(field => field.name)) - .catch(() => []) - )) - .then(responses => setNumericFields(responses.map(response => response ?? []).flat())) - .catch(() => setNumericFields([])); - break; - } - case 'wms': - case 'wfs': { - describeFeatureType(node.url, node.name) - .then(describe => setNumericFields(describe.featureTypes[0].properties.filter(property => property.localType === 'number').map(property => property.name))) - .catch(() => setNumericFields([])); - break; - } - default: - } - }, []); - - return ( -
    - - {menuOpen && ( -
    -

    triggerAction('zoomTo')}>

    -

    { toggleMenu(); selectionData.features?.length > 0 ? setStatisticsOpen(true) : null; }}>

    -

    triggerAction('createLayer')}>

    - {node.type !== 'arcgis' &&

    triggerAction('filterData')}>

    } -
    -

    - - {exportOpen ? "−" : "+"} -

    - {exportOpen && ( -
    -

    triggerAction('exportToGeoJson')}> -

    -

    triggerAction('exportToJson')}> -

    -

    triggerAction('exportToCsv')}> -

    -
    - )} -
    -

    triggerAction('clear')}>

    -
    - )} - {statisticsOpen && } -
    - ); -}; diff --git a/web/client/plugins/layersSelection/components/EllipsisButton/Statistics/Statistics.jsx b/web/client/plugins/layersSelection/components/EllipsisButton/Statistics/Statistics.jsx deleted file mode 100644 index f2accb92fff..00000000000 --- a/web/client/plugins/layersSelection/components/EllipsisButton/Statistics/Statistics.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { useState, useMemo } from 'react'; - -import Message from '../../../../../components/I18N/Message'; -import Portal from '../../../../../components/misc/Portal'; -import ResizableModal from '../../../../../components/misc/ResizableModal'; - -/** - * A modal component that displays basic statistical calculations - * (count, sum, min, max, mean, standard deviation) - * for a selected numeric field from a list of features. - * - * @param {Object} props - Component props. - * @param {string[]} props.fields - List of available field names. - * @param {Object[]} props.features - List of GeoJSON features. - * @param {Function} props.setStatisticsOpen - Callback to close the statistics modal. - * @returns {JSX.Element} The rendered statistics modal. - */ -export default ({ - fields = [], - features = [], - setStatisticsOpen = () => {} -}) => { - const [selectedField, setSelectedField] = useState(fields.length > 0 ? fields[0] : null); - - const statistics = useMemo(() => { - if (!selectedField) return null; - - const values = features.map(f => f.properties[selectedField]).filter(v => typeof v === "number"); - if (values.length === 0) return null; - - const sum = values.reduce((acc, val) => acc + val, 0); - const min = Math.min(...values); - const max = Math.max(...values); - const mean = sum / values.length; - const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length; - const stdDev = Math.sqrt(variance); - - return { count: values.length, sum, min, max, mean, stdDev }; - }, [features, selectedField]); - - return ( - - } - size="sm" - show - onClose={() => setStatisticsOpen(false)} - // draggable={true} - buttons={[{ - text: , - onClick: () => setStatisticsOpen(false), - bsStyle: 'primary' - }]}> -
    -
    - - -
    - - {statistics && ( - - - - - - - - - -
    {statistics.count}
    {statistics.sum.toFixed(6)}
    {statistics.min.toFixed(6)}
    {statistics.max.toFixed(6)}
    {statistics.mean.toFixed(6)}
    {statistics.stdDev.toFixed(6)}
    - )} -
    -
    -
    - ); -}; diff --git a/web/client/plugins/layersSelection/components/LayersSelection.jsx b/web/client/plugins/layersSelection/components/LayersSelection.jsx deleted file mode 100644 index 61c47a2171c..00000000000 --- a/web/client/plugins/layersSelection/components/LayersSelection.jsx +++ /dev/null @@ -1,226 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { ControlLabel, Glyphicon } from 'react-bootstrap'; - -import { ControlledTOC } from '../../TOC/components/TOC'; -import ResizableModal from '../../../components/misc/ResizableModal'; -import Message from '../../../components/I18N/Message'; -import VisibilityCheck from '../../TOC/components/VisibilityCheck'; -import NodeHeader from '../../TOC/components/NodeHeader'; -import { getLayerTypeGlyph } from '../../../utils/LayersUtils'; -import NodeTool from '../../TOC/components/NodeTool'; -import InlineLoader from '../../TOC/components/InlineLoader'; -import EllipsisButton from './EllipsisButton'; -import { isSelectQueriable, filterLayerForSelect } from '../selectors/layersSelection'; -import FlexBox from '../../../components/layout/FlexBox'; -import Button from '../../../components/layout/Button'; -import Select from 'react-select'; -import { getMessageById } from '../../../utils/LocaleUtils'; -import PropTypes from 'prop-types'; - -import './layersSelection.css'; - -const SelectionToolsTypes = { - Point: { type: 'Point', value: 'Point', labelId: 'layersSelection.button.selectByPoint', icon: '1-point' }, - LineString: { type: 'LineString', value: 'LineString', labelId: 'layersSelection.button.selectByLine', icon: 'polyline' }, - Circle: { type: 'Circle', value: 'Circle', labelId: 'layersSelection.button.selectByCircle', icon: '1-circle' }, - Rectangle: { type: 'Rectangle', value: 'BBOX', labelId: 'layersSelection.button.selectByRectangle', icon: 'unchecked' }, - Polygon: { type: 'Polygon', value: 'Polygon', labelId: 'layersSelection.button.selectByPolygon', icon: 'polygon' } -}; - -/** - * Appends or updates a cache-busting `_v_` parameter on the layer's legendParams object. - * - * @param {Object} layer - The layer object to apply the parameter to. - * @returns {Object} A new layer object with the `_v_` legend param added. - */ -function applyVersionParamToLegend(layer) { - // we need to pass a parameter that invalidate the cache for GetLegendGraphic - // all layer inside the dataset viewer apply a new _v_ param each time we switch page - return { ...layer, legendParams: { ...layer?.legendParams, _v_: layer?._v_ } }; -} - -/** - * Select tool UI component wrapped with react-intl internationalization. - * - * @component - * @param {Object} props - Component props. - * @param {Array} props.layers - List of layers from the map. - * @param {Function} props.onUpdateNode - Redux action to update a layer node. - * @param {Function} props.onClose - Callback for closing the modal. - * @param {Boolean} props.isVisible - Whether the modal is visible. - * @param {Object} props.highlightOptions - Highlighting options for selected features. - * @param {Object} props.queryOptions - Options for querying features. - * @param {Array} props.selectTools - Toolbar tools for the selection module. - * @param {Function} props.storeConfiguration - Saves configuration to the Redux store. - * @param {Object} props.intl - Internationalization object from `injectIntl`. - * @param {Object} props.selections - Selection results grouped by layer ID. - * @param {Number} props.maxFeatureCount - Maximum number of features allowed per selection. - * @param {Function} props.cleanSelection - Action to clear selection results. - * @param {Function} props.addOrUpdateSelection - Action to update the current selection. - * @param {Function} props.zoomToExtent - Action to zoom to the extent of selected features. - * @param {Function} props.addLayer - Action to add a new layer. - * @param {Function} props.changeLayerProperties - Action to update layer properties. - * - * @returns {JSX.Element} The rendered Select tool modal. - */ -const LayersSelection = ({ - layers, - onUpdateNode, - onClose, - isVisible, - highlightOptions, - queryOptions, - selectTools = [ - 'Point', - 'LineString', - 'Circle', - 'Rectangle', - 'Polygon' - ], - storeConfiguration, - selections, - maxFeatureCount, - cleanSelection, - addOrUpdateSelection, - zoomToExtent, - addLayer, - changeLayerProperties -}, context) => { - - const filterLayers = layers.filter(filterLayerForSelect); - - /** - * Renders a custom layer node component inside the TOC. - * - * @param {Object} props - * @param {Object} props.node - The layer node. - * @param {Object} props.config - Configuration options such as locale. - * @returns {JSX.Element} Rendered layer node with feature count, tools, and visibility check. - */ - const customLayerNodeComponent = ({node, config}) => { - const selectionData = selections[node.id] ?? {}; - return ( -
  • - - - onUpdateNode(node.id, 'layers', { isSelectQueriable: checked })} - /> - - - } - afterTitle={ - <> - {selectionData.error ? ( - - ) : ( -
    - {selectionData.features && selectionData.features.length === maxFeatureCount && } /* tooltip={"ouech"} */ glyph="exclamation-mark"/>} -

    {selectionData.loading ? '⊙' : (selectionData.features?.length ?? 0)}

    -
    - )} - - {selectionData.features?.length > 0 && } - - } - /> -
  • - ); - }; - - useEffect(() => storeConfiguration({ highlightOptions, queryOptions }), []); - - const [selectedTool, setSelectedTool] = useState(null); - - const selectionOptions = selectTools - .filter(tool => SelectionToolsTypes[tool]) - .map(tool => { - const option = SelectionToolsTypes[tool]; - return { ...option, label: <>{' '} }; - }); - - return ( - - {' '} - - } - dialogClassName=" select-dialog" - show={isVisible} - draggable - style={{zIndex: 1993}}> - - - - -