From 014dbaea22c896e036ba387cfb40c38cbe09b128 Mon Sep 17 00:00:00 2001 From: pln Date: Tue, 20 May 2025 10:39:14 +0200 Subject: [PATCH 01/30] 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 313ee87606..c4440423fa 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 7f4af13a91..220f35a704 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 2ae860ae11..644b2cb660 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 890f97c50a..834c6a3787 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 e78b24aaad..c19033473e 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/30] 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 60be44cc5b..d330878a0e 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 c861e0e98d..e1b4558aa9 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 a0f2a18dde..2eebde5412 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 28eff31633..c69cfcbd81 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/30] 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 cbc5c76e6c..d80259deff 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/30] 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 d6ff0bfe83..22ecb8fe97 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/30] 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 94dd104080..d0a6b677cf 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/30] =?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 c4440423fa..5328975c8f 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 220f35a704..8a7dd9c67b 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 644b2cb660..f8464a49ea 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 834c6a3787..64aa029be6 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 c19033473e..a89082f8fd 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/30] =?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 d80259deff..e97b2f0b19 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/30] 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 0000000000..5da21da1eb --- /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 0000000000..132b9ae17b --- /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 0000000000..956dd03e65 --- /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 0000000000..a59e6e5abd --- /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 0000000000..66025df79a --- /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 0000000000..79d67c611d --- /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 0000000000..ff8abea2a2 --- /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 0000000000..0b04a81a03 --- /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 0000000000..9bcf12dea9 --- /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 0000000000..ce73fcf912 --- /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 0000000000..a007aeb606 --- /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 0000000000..08db0c760f --- /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 0000000000..84a0cc2846 --- /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 0000000000..2212f19eaf --- /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/30] =?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 5328975c8f..584a1872d3 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 8a7dd9c67b..d501f8c227 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 f8464a49ea..6726d51763 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 64aa029be6..a1ab8d5d6c 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 a89082f8fd..7fdb846d39 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/30] =?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 d330878a0e..3113d7b773 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 e1b4558aa9..97969cb8f7 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 2eebde5412..610d8880c4 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 c69cfcbd81..b1f942ffca 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/30] =?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 e97b2f0b19..6aea707181 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/30] =?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 956dd03e65..17659e6a1d 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 79d67c611d..af4985c126 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 fe19e34d6c..e616129b43 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 9a81bbe976..0000000000 --- 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 0000000000..fb06eea762 --- /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 2a0576a4aa..e2d6077bb5 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/30] 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 fb06eea762..5daa4f5ef3 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/30] 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 ac325dc16e..0000000000 --- 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 f6b78ab1a1..b2118a9ec8 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 aa52559f4b..7db91ea0d3 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 e616129b43..3600e33657 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 55eac1ada7..93125ae37f 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 c5ee726721..a0efe21a9d 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/30] 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 93125ae37f..0bf05a1914 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 {