diff --git a/build/docma-config.json b/build/docma-config.json
index 866162f11f6..8dc114e5c3c 100644
--- a/build/docma-config.json
+++ b/build/docma-config.json
@@ -265,6 +265,7 @@
"web/client/plugins/Language.jsx",
"web/client/plugins/LayerDownload.jsx",
"web/client/plugins/LayerInfo.jsx",
+ "web/client/plugins/LayersSelection/index.js",
"web/client/plugins/Locate.jsx",
"web/client/plugins/Login.jsx",
"web/client/plugins/LongitudinalProfileTool.jsx",
diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json
index bc6b9875374..eda14def5c9 100644
--- a/project/standard/templates/configs/pluginsConfig.json
+++ b/project/standard/templates/configs/pluginsConfig.json
@@ -153,6 +153,13 @@
"description": "plugins.Permalink.description",
"denyUserSelection": true
},
+ {
+ "name": "LayersSelection",
+ "glyph": "hand-down",
+ "title": "plugins.LayersSelection.title",
+ "description": "plugins.LayersSelection.description",
+ "dependencies": ["SidebarMenu"]
+ },
{
"name": "BackgroundSelector",
"title": "plugins.BackgroundSelector.title",
diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json
index 1ce8f863a29..e7b8155ee38 100644
--- a/web/client/configs/pluginsConfig.json
+++ b/web/client/configs/pluginsConfig.json
@@ -155,6 +155,13 @@
"title": "plugins.Permalink.title",
"description": "plugins.Permalink.description"
},
+ {
+ "name": "LayersSelection",
+ "glyph": "hand-down",
+ "title": "plugins.LayersSelection.title",
+ "description": "plugins.LayersSelection.description",
+ "dependencies": ["SidebarMenu"]
+ },
{
"name": "BackgroundSelector",
"title": "plugins.BackgroundSelector.title",
diff --git a/web/client/plugins/LayersSelection/actions/__tests__/layersSelection-test.js b/web/client/plugins/LayersSelection/actions/__tests__/layersSelection-test.js
new file mode 100644
index 00000000000..03eb9c2659e
--- /dev/null
+++ b/web/client/plugins/LayersSelection/actions/__tests__/layersSelection-test.js
@@ -0,0 +1,59 @@
+
+import expect from 'expect';
+import {
+ cleanSelection,
+ SELECT_CLEAN_SELECTION,
+ storeConfiguration,
+ SELECT_STORE_CFG,
+ addOrUpdateSelection,
+ ADD_OR_UPDATE_SELECTION,
+ updateSelectionFeature,
+ UPDATE_SELECTION_FEATURE
+} from '../layersSelection';
+
+describe('LayersSelection Actions', () => {
+ describe('cleanSelection', () => {
+ it('should create action with a geometry type Point', () => {
+ const geomType = 'Point';
+ const action = cleanSelection(geomType);
+
+ expect(action).toEqual({
+ type: SELECT_CLEAN_SELECTION,
+ geomType
+ });
+ });
+
+ it('should create action of a store Configuration', () => {
+ const cfg = {};
+ const action = storeConfiguration(cfg);
+
+ expect(action).toEqual({
+ type: SELECT_STORE_CFG,
+ cfg
+ });
+ });
+
+ it('should create action to add or update selection', () => {
+ const layer = 'name layer';
+ const geoJsonData = {};
+ const action = addOrUpdateSelection(layer, geoJsonData);
+
+ expect(action).toEqual({
+ type: ADD_OR_UPDATE_SELECTION,
+ layer,
+ geoJsonData
+ });
+ });
+
+ it('should create action to update feature selection', () => {
+ const feature = {};
+ const action = updateSelectionFeature(feature);
+
+ expect(action).toEqual({
+ type: UPDATE_SELECTION_FEATURE,
+ feature
+ });
+ });
+
+ });
+});
diff --git a/web/client/plugins/LayersSelection/actions/layersSelection.js b/web/client/plugins/LayersSelection/actions/layersSelection.js
new file mode 100644
index 00000000000..47f1b985977
--- /dev/null
+++ b/web/client/plugins/LayersSelection/actions/layersSelection.js
@@ -0,0 +1,51 @@
+export const SELECT_CLEAN_SELECTION = "SELECT:CLEAN_SELECTION";
+export const SELECT_STORE_CFG = "SELECT:STORE_CFG";
+export const ADD_OR_UPDATE_SELECTION = "SELECT:ADD_OR_UPDATE_SELECTION";
+export const UPDATE_SELECTION_FEATURE = "SELECT:UPDATE_SELECTION_FEATURE";
+/**
+ * Action creator to clean the current selection based on geometry type.
+ *
+ * @param {string} geomType - The type of geometry to clean (e.g., "Point", "Polygon").
+ * @returns {{ type: string, geomType: string }} The action object.
+ */
+export function cleanSelection(geomType) {
+ return {
+ type: SELECT_CLEAN_SELECTION,
+ geomType
+ };
+}
+
+/**
+ * Action creator to store configuration settings related to selection.
+ *
+ * @param {Object} cfg - Configuration object to store.
+ * @returns {{ type: string, cfg: Object }} The action object.
+ */
+export function storeConfiguration(cfg) {
+ return {
+ type: SELECT_STORE_CFG,
+ cfg
+ };
+}
+
+/**
+ * Action creator to add or update a layer selection with GeoJSON data.
+ *
+ * @param {string} layer - The name or ID of the layer.
+ * @param {Object} geoJsonData - The GeoJSON data representing the selection.
+ * @returns {{ type: string, layer: string, geoJsonData: Object }} The action object.
+ */
+export function addOrUpdateSelection(layer, geoJsonData) {
+ return {
+ type: ADD_OR_UPDATE_SELECTION,
+ layer,
+ geoJsonData
+ };
+}
+
+export function updateSelectionFeature(feature) {
+ return {
+ type: UPDATE_SELECTION_FEATURE,
+ feature
+ };
+}
diff --git a/web/client/plugins/LayersSelection/components/EllipsisButton.jsx b/web/client/plugins/LayersSelection/components/EllipsisButton.jsx
new file mode 100644
index 00000000000..7551cba25f8
--- /dev/null
+++ b/web/client/plugins/LayersSelection/components/EllipsisButton.jsx
@@ -0,0 +1,198 @@
+import React, { useState, useEffect } from 'react';
+import bbox from '@turf/bbox';
+import { saveAs } from 'file-saver';
+import axios from 'axios';
+
+import Message from '../../../components/I18N/Message';
+import { describeFeatureType } from '../../../api/WFS';
+import Statistics from './Statistics';
+import { DropdownButton, Glyphicon, MenuItem } from 'react-bootstrap';
+import uuidv1 from 'uuid/v1';
+
+/**
+ * EllipsisButton provides a contextual menu for selected layer data.
+ * It allows users to:
+ * - Zoom to selection extent
+ * - View statistics
+ * - Create a new layer from selection
+ * - Export data (GeoJSON, JSON, CSV)
+ * - Apply attribute filters (if supported)
+ * - Clear the selection
+ *
+ * @param {Object} props - Component props.
+ * @param {Object} props.node - Layer node (descriptor).
+ * @param {Object} props.selectionData - GeoJSON FeatureCollection.
+ * @param {Function} props.onAddOrUpdateSelection - Callback to update selection.
+ * @param {Function} props.onZoomToExtent - Callback to zoom to selection.
+ * @param {Function} props.onAddLayer - Callback to add a new layer.
+ * @param {Function} props.onChangeLayerProperties - Callback to change layer properties.
+ */
+export default ({
+ node = {},
+ selectionData = {},
+ onAddOrUpdateSelection = () => { },
+ onZoomToExtent = () => { },
+ onAddLayer = () => { },
+ onChangeLayerProperties = () => { }
+}) => {
+
+ const [statisticsOpen, setStatisticsOpen] = useState(false);
+ const [numericFields, setNumericFields] = useState([]);
+ const [primaryKey, setPrimaryKey] = useState(null);
+
+ const triggerAction = (action) => {
+ switch (action) {
+ case 'clear': {
+ onAddOrUpdateSelection(node, {});
+ break;
+ }
+ case 'zoomTo': {
+ if (selectionData.features?.length > 0) {
+ const extent = bbox(selectionData);
+ if (extent) { onZoomToExtent(extent, selectionData.crs.properties[selectionData.crs.type]); }
+ }
+ break;
+ }
+ case 'createLayer': {
+ if (selectionData.features?.length > 0) {
+
+ const nodeName = node.title + '_Select_';
+ const layerBbox = bbox(selectionData);
+ const uniqueId = uuidv1();
+
+ onAddLayer({
+ id: uniqueId,
+ type: 'vector',
+ visibility: true,
+ name: nodeName + uniqueId,
+ hideLoading: true,
+ bbox: layerBbox,
+ features: selectionData.features
+ });
+ }
+ break;
+ }
+ case 'exportToGeoJson': {
+ if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData)], { type: 'application/json' }), node.title + '.json'); }
+ break;
+ }
+ case 'exportToJson': {
+ if (selectionData.features?.length > 0) { saveAs(new Blob([JSON.stringify(selectionData.features.map(feature => feature.properties))], { type: 'application/json' }), node.title + '.json'); }
+ break;
+ }
+ case 'exportToCsv': {
+ if (selectionData.features?.length > 0) { saveAs(new Blob([Object.keys(selectionData.features[0].properties).join(',') + '\n' + selectionData.features.map(feature => Object.values(feature.properties).join(',')).join('\n')], { type: 'text/csv' }), node.title + '.csv'); }
+ break;
+ }
+ case 'filterData': {
+
+ switch (node.type) {
+ case 'arcgis': {
+ // TODO : implement here when MapStore supports filtering for arcgis services
+ throw new Error(`Unsupported layer type: ${node.type}`);
+ }
+ case 'wms':
+ case 'wfs': {
+ onChangeLayerProperties(node.id, {
+ layerFilter: {
+ groupFields: [
+ {
+ id: 1,
+ logic: 'OR',
+ index: 0
+ }
+ ],
+ filterFields: selectionData.features.map(feature => ({
+ rowId: uuidv1(),
+ groupId: 1,
+ attribute: primaryKey,
+ operator: '=',
+ value: feature.properties[primaryKey],
+ type: 'number',
+ fieldOptions: {
+ valuesCount: 0,
+ currentPage: 1
+ },
+ exception: null
+ }))
+ }
+ });
+ break;
+ }
+ default:
+ throw new Error(`Unsupported layer type: ${node.type}`);
+ }
+ break;
+ }
+ default:
+ }
+ };
+
+ useEffect(() => {
+ switch (node.type) {
+ case 'arcgis': {
+ const arcgisNumericFields = new Set(['esriFieldTypeSmallInteger', 'esriFieldTypeInteger', 'esriFieldTypeSingle', 'esriFieldTypeDouble']);
+ const singleLayerId = parseInt(node.name ?? '', 10);
+ Promise.all((Number.isInteger(singleLayerId) ? node.options.layers.filter(l => l.id === singleLayerId) : node.options.layers).map(l => axios.get(`${node.url}/${l.id}`, { params: { f: 'json' } })
+ .then(describe => describe.data.fields.filter(field => field.domain === null && arcgisNumericFields.has(field.type)).map(field => field.name))
+ .catch(() => [])
+ ))
+ .then(responses => {
+ setPrimaryKey(null);
+ setNumericFields(responses.map(response => response ?? []).flat());
+ })
+ .catch(() => {
+ setPrimaryKey(null);
+ setNumericFields([]);
+ });
+ break;
+ }
+ case 'wms':
+ case 'wfs': {
+ describeFeatureType(node.url, node.name)
+ .then(describe => {
+ const featureType = describe.featureTypes.find(fType => node.name.endsWith(fType.typeName));
+ const newNumericFields = featureType.properties.filter(property => property.localType === 'number').map(property => property.name);
+ // primary key is not always exposed
+ const newPrimaryKey = featureType.properties
+ .find(property =>
+ ['xsd:string', 'xsd:int'].includes(property.type) && !property.nillable && property.maxOccurs === 1 && property.minOccurs === 1
+ )?.name || null;
+ setPrimaryKey(newPrimaryKey);
+ setNumericFields(newNumericFields);
+ })
+ .catch(() => {
+ setPrimaryKey(null);
+ setNumericFields([]);
+ });
+ break;
+ }
+ default:
+ }
+ }, [node.name]);
+
+ return (
+ <>
+ }
+ className="_border-transparent"
+ noCaret
+ >
+
+
+
+ {primaryKey ? : null}
+
+
+
+
+
+ {statisticsOpen && }
+ >
+ );
+};
diff --git a/web/client/plugins/LayersSelection/components/LayersSelection.jsx b/web/client/plugins/LayersSelection/components/LayersSelection.jsx
new file mode 100644
index 00000000000..61c47a2171c
--- /dev/null
+++ b/web/client/plugins/LayersSelection/components/LayersSelection.jsx
@@ -0,0 +1,226 @@
+import React, { useEffect, useState } from 'react';
+import { ControlLabel, Glyphicon } from 'react-bootstrap';
+
+import { ControlledTOC } from '../../TOC/components/TOC';
+import ResizableModal from '../../../components/misc/ResizableModal';
+import Message from '../../../components/I18N/Message';
+import VisibilityCheck from '../../TOC/components/VisibilityCheck';
+import NodeHeader from '../../TOC/components/NodeHeader';
+import { getLayerTypeGlyph } from '../../../utils/LayersUtils';
+import NodeTool from '../../TOC/components/NodeTool';
+import InlineLoader from '../../TOC/components/InlineLoader';
+import EllipsisButton from './EllipsisButton';
+import { isSelectQueriable, filterLayerForSelect } from '../selectors/layersSelection';
+import FlexBox from '../../../components/layout/FlexBox';
+import Button from '../../../components/layout/Button';
+import Select from 'react-select';
+import { getMessageById } from '../../../utils/LocaleUtils';
+import PropTypes from 'prop-types';
+
+import './layersSelection.css';
+
+const SelectionToolsTypes = {
+ Point: { type: 'Point', value: 'Point', labelId: 'layersSelection.button.selectByPoint', icon: '1-point' },
+ LineString: { type: 'LineString', value: 'LineString', labelId: 'layersSelection.button.selectByLine', icon: 'polyline' },
+ Circle: { type: 'Circle', value: 'Circle', labelId: 'layersSelection.button.selectByCircle', icon: '1-circle' },
+ Rectangle: { type: 'Rectangle', value: 'BBOX', labelId: 'layersSelection.button.selectByRectangle', icon: 'unchecked' },
+ Polygon: { type: 'Polygon', value: 'Polygon', labelId: 'layersSelection.button.selectByPolygon', icon: 'polygon' }
+};
+
+/**
+ * Appends or updates a cache-busting `_v_` parameter on the layer's legendParams object.
+ *
+ * @param {Object} layer - The layer object to apply the parameter to.
+ * @returns {Object} A new layer object with the `_v_` legend param added.
+ */
+function applyVersionParamToLegend(layer) {
+ // we need to pass a parameter that invalidate the cache for GetLegendGraphic
+ // all layer inside the dataset viewer apply a new _v_ param each time we switch page
+ return { ...layer, legendParams: { ...layer?.legendParams, _v_: layer?._v_ } };
+}
+
+/**
+ * Select tool UI component wrapped with react-intl internationalization.
+ *
+ * @component
+ * @param {Object} props - Component props.
+ * @param {Array} props.layers - List of layers from the map.
+ * @param {Function} props.onUpdateNode - Redux action to update a layer node.
+ * @param {Function} props.onClose - Callback for closing the modal.
+ * @param {Boolean} props.isVisible - Whether the modal is visible.
+ * @param {Object} props.highlightOptions - Highlighting options for selected features.
+ * @param {Object} props.queryOptions - Options for querying features.
+ * @param {Array} props.selectTools - Toolbar tools for the selection module.
+ * @param {Function} props.storeConfiguration - Saves configuration to the Redux store.
+ * @param {Object} props.intl - Internationalization object from `injectIntl`.
+ * @param {Object} props.selections - Selection results grouped by layer ID.
+ * @param {Number} props.maxFeatureCount - Maximum number of features allowed per selection.
+ * @param {Function} props.cleanSelection - Action to clear selection results.
+ * @param {Function} props.addOrUpdateSelection - Action to update the current selection.
+ * @param {Function} props.zoomToExtent - Action to zoom to the extent of selected features.
+ * @param {Function} props.addLayer - Action to add a new layer.
+ * @param {Function} props.changeLayerProperties - Action to update layer properties.
+ *
+ * @returns {JSX.Element} The rendered Select tool modal.
+ */
+const LayersSelection = ({
+ layers,
+ onUpdateNode,
+ onClose,
+ isVisible,
+ highlightOptions,
+ queryOptions,
+ selectTools = [
+ 'Point',
+ 'LineString',
+ 'Circle',
+ 'Rectangle',
+ 'Polygon'
+ ],
+ storeConfiguration,
+ selections,
+ maxFeatureCount,
+ cleanSelection,
+ addOrUpdateSelection,
+ zoomToExtent,
+ addLayer,
+ changeLayerProperties
+}, context) => {
+
+ const filterLayers = layers.filter(filterLayerForSelect);
+
+ /**
+ * Renders a custom layer node component inside the TOC.
+ *
+ * @param {Object} props
+ * @param {Object} props.node - The layer node.
+ * @param {Object} props.config - Configuration options such as locale.
+ * @returns {JSX.Element} Rendered layer node with feature count, tools, and visibility check.
+ */
+ const customLayerNodeComponent = ({node, config}) => {
+ const selectionData = selections[node.id] ?? {};
+ return (
+
+
+
+ onUpdateNode(node.id, 'layers', { isSelectQueriable: checked })}
+ />
+
+ >
+ }
+ afterTitle={
+ <>
+ {selectionData.error ? (
+
+ ) : (
+
+ {selectionData.features && selectionData.features.length === maxFeatureCount &&
} /* tooltip={"ouech"} */ glyph="exclamation-mark"/>}
+
{selectionData.loading ? '⊙' : (selectionData.features?.length ?? 0)}
+
+ )}
+
+ {selectionData.features?.length > 0 && }
+ >
+ }
+ />
+
+ );
+ };
+
+ useEffect(() => storeConfiguration({ highlightOptions, queryOptions }), []);
+
+ const [selectedTool, setSelectedTool] = useState(null);
+
+ const selectionOptions = selectTools
+ .filter(tool => SelectionToolsTypes[tool])
+ .map(tool => {
+ const option = SelectionToolsTypes[tool];
+ return { ...option, label: <>{' '}> };
+ });
+
+ return (
+
+ {' '}
+
+ >}
+ dialogClassName=" select-dialog"
+ show={isVisible}
+ draggable
+ style={{zIndex: 1993}}>
+
+
+
+
+
+
+
+
+ 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={}
+ />
+
+ }
+ />
+
+ );
+};
+
+LayersSelection.contextTypes = {
+ messages: PropTypes.object
+};
+
+export default LayersSelection;
diff --git a/web/client/plugins/LayersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx b/web/client/plugins/LayersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx
new file mode 100644
index 00000000000..b2118a9ec83
--- /dev/null
+++ b/web/client/plugins/LayersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx
@@ -0,0 +1,95 @@
+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 '../../../TOC/components/InlineLoader';
+
+import { SelectRefContext } from '../LayersSelection';
+
+/**
+ * LayersSelectionHeader provides a toolbar for selecting geometry-based
+ * selection tools (point, line, polygon, etc.) and for clearing selections.
+ *
+ * @param {Object} props - Component props.
+ * @param {Function} props.onCleanSelect - Callback to reset or apply selection tool.
+ * @param {Array} props.selectTools - List of enabled selection tool types.
+ * E.g., ['Point', 'Polygon', 'Rectangle']
+ *
+ * @returns {JSX.Element} The selection tool header UI.
+ */
+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: 'layersSelection.button.selectByPoint', icon: '1-point' },
+ { type: 'LineString', action: 'LineString', label: 'layersSelection.button.selectByLine', icon: 'polyline' },
+ { type: 'Circle', action: 'Circle', label: 'layersSelection.button.selectByCircle', icon: '1-circle' },
+ { type: 'Rectangle', action: 'BBOX', label: 'layersSelection.button.selectByRectangle', icon: 'unchecked' },
+ { type: 'Polygon', action: 'Polygon', label: 'layersSelection.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/plugins/LayersSelection/components/LayersSelectionSupport.jsx b/web/client/plugins/LayersSelection/components/LayersSelectionSupport.jsx
new file mode 100644
index 00000000000..2ca24ae3928
--- /dev/null
+++ b/web/client/plugins/LayersSelection/components/LayersSelectionSupport.jsx
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2026, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { lazy, Suspense, useEffect } from 'react';
+import { MapLibraries } from '../../../utils/MapTypeUtils';
+import { circleToPolygon } from '../../../utils/DrawGeometryUtils';
+import { createDefaultStyle } from '../../../utils/StyleUtils';
+
+const drawGeometrySupportSupports = {
+ // TODO: include support of Rectangle in Cesium support
+ // TODO: ensure only 2D coordinates are provided to the filter
+ // [MapLibraries.CESIUM]: lazy(() => import(/* webpackChunkName: 'supports/cesiumDrawGeometrySupport' */ '../../../components/map/cesium/DrawGeometrySupport')),
+ [MapLibraries.OPENLAYERS]: lazy(() => import(/* webpackChunkName: 'supports/olDrawGeometrySupport' */ '../../../components/map/openlayers/DrawGeometrySupport'))
+};
+
+const LAYER_SELECTION_LAYER_ID = 'LAYER_SELECTION_LAYER_ID';
+
+const LayersSelectionSupport = ({
+ map,
+ mapType,
+ type,
+ feature,
+ onChange = () => {},
+ onUpdateLayer = () => {},
+ onRemoveLayer = () => {},
+ cleanSelection
+}) => {
+
+ const DrawGeometrySupport = drawGeometrySupportSupports[mapType];
+
+ useEffect(() => {
+ return () => {
+ onChange(null);
+ onRemoveLayer({ owner: LAYER_SELECTION_LAYER_ID });
+ cleanSelection();
+ };
+ }, [onUpdateLayer, onChange, onRemoveLayer, mapType, cleanSelection]);
+
+ useEffect(() => {
+ onUpdateLayer(LAYER_SELECTION_LAYER_ID, LAYER_SELECTION_LAYER_ID, 'overlay', {
+ id: LAYER_SELECTION_LAYER_ID,
+ type: 'vector',
+ visibility: true,
+ features: feature ? [feature] : [],
+ style: feature ? createDefaultStyle({
+ fillColor: '#f2f2f2',
+ fillOpacity: 0.3,
+ strokeColor: '#ffcc33',
+ strokeOpacity: 1,
+ strokeWidth: 2,
+ radius: 10,
+ geometryType: feature?.geometry?.type
+ }) : undefined
+ });
+ }, [feature]);
+
+ if (!DrawGeometrySupport) {
+ return null;
+ }
+ const geometryType = type === 'BBOX' ? 'Rectangle' : type;
+ return (
+
+ []}
+ onDrawEnd={({ feature: newFeature }) => {
+ const geometry = newFeature?.properties?.radius !== undefined
+ ? circleToPolygon(newFeature.geometry.coordinates, newFeature.properties.radius, false)
+ : newFeature.geometry;
+ onChange({
+ ...newFeature,
+ properties: {
+ ...newFeature?.properties,
+ drawType: geometryType
+ },
+ geometry: {
+ ...geometry,
+ projection: 'EPSG:4326'
+ }
+ });
+ }}
+ />
+
+ );
+};
+
+export default LayersSelectionSupport;
diff --git a/web/client/plugins/LayersSelection/components/Statistics.jsx b/web/client/plugins/LayersSelection/components/Statistics.jsx
new file mode 100644
index 00000000000..0a82cd6e1ec
--- /dev/null
+++ b/web/client/plugins/LayersSelection/components/Statistics.jsx
@@ -0,0 +1,81 @@
+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 { ControlLabel, FormControl, Glyphicon } from 'react-bootstrap';
+import FlexBox from '../../../components/layout/FlexBox';
+
+/**
+ * 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)}
+ buttons={[{
+ text: ,
+ onClick: () => setStatisticsOpen(false),
+ bsStyle: 'primary'
+ }]}>
+
+
+
+
+ setSelectedField(event.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)} |
+
+
+ )}
+
+
+
+ );
+};
diff --git a/web/client/plugins/LayersSelection/components/__tests__/EllipsisButton-test.jsx b/web/client/plugins/LayersSelection/components/__tests__/EllipsisButton-test.jsx
new file mode 100644
index 00000000000..5c213767afe
--- /dev/null
+++ b/web/client/plugins/LayersSelection/components/__tests__/EllipsisButton-test.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import EllipsisButton from '../EllipsisButton';
+import expect from 'expect';
+import TestUtils from 'react-dom/test-utils';
+
+describe('Ellipsis button component', () => {
+ let container;
+ const defaultProps = {
+ node: {},
+ selectionData: {},
+ onAddOrUpdateSelection: () => { },
+ onZoomToExtent: () => { },
+ onAddLayer: () => { },
+ onChangeLayerProperties: () => { }
+ };
+
+ beforeEach((done) => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(container);
+ document.body.removeChild(container);
+ setTimeout(done);
+ });
+
+ const renderComponent = (props = {}) => {
+ const component = React.createElement(EllipsisButton, {
+ ...defaultProps,
+ ...props
+ });
+ return ReactDOM.render(component, container);
+ };
+
+ const renderComponentWithFakeFeature = (props = {}) => {
+ const GeoJsonfeatures = {
+ "type": "FeatureCollection",
+ "crs": {
+ "type": "name",
+ "properties": {
+ "name": "EPSG:4326"
+ }
+ },
+ "features": [
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [102.0, 0.5]
+ },
+ "properties": {
+ "name": "Sample Point"
+ }
+ }
+ ]
+ };
+
+ props.selectionData = GeoJsonfeatures;
+
+ const component = React.createElement(EllipsisButton, {
+ ...defaultProps,
+ ...props
+ });
+ return ReactDOM.render(component, container);
+ };
+
+ describe('render EllipsisButton component', () => {
+ it('should render with default props', () => {
+ renderComponent();
+ expect(container.querySelector('._border-transparent')).toBeTruthy();
+ expect(container.querySelector('.dropdown-toggle')).toBeTruthy();
+ });
+
+ it('should render with default props', () => {
+ renderComponent();
+ expect(container.querySelector('._border-transparent')).toBeTruthy();
+ expect(container.querySelector('.dropdown-toggle')).toBeTruthy();
+ });
+
+ it('should not render statistics when any features are selected', () => {
+ renderComponent();
+ const spanList = [...container.querySelectorAll('span')];
+ const span = spanList.find(el => el.textContent.trim() === 'layersSelection.button.statistics');
+ const link = span.parentElement;
+ TestUtils.Simulate.click(link);
+
+ const modal = document.getElementById('ms-resizable-modal');
+
+ expect(modal).toBeFalsy();
+ });
+
+ it('should render statistics when a feature at least is selected', () => {
+ renderComponentWithFakeFeature();
+ const spanList = [...container.querySelectorAll('span')];
+ const span = spanList.find(el => el.textContent.trim() === 'layersSelection.button.statistics');
+ const link = span.parentElement;
+ TestUtils.Simulate.click(link);
+
+ const modal = document.getElementById('ms-resizable-modal');
+ expect(modal).toBeTruthy();
+ });
+
+ });
+});
diff --git a/web/client/plugins/LayersSelection/components/__tests__/LayerSelection-test.jsx b/web/client/plugins/LayersSelection/components/__tests__/LayerSelection-test.jsx
new file mode 100644
index 00000000000..a62f52db951
--- /dev/null
+++ b/web/client/plugins/LayersSelection/components/__tests__/LayerSelection-test.jsx
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import expect from 'expect';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { getLayerFromRecord } from '../../../../api/catalog/ArcGIS';
+
+import SelectComponent from '../LayersSelection';
+
+describe('Select Component', () => {
+ let container;
+
+ beforeEach((done) => {
+ document.body.innerHTML = '';
+ container = document.getElementById('container');
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(container);
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+
+ const testRecord = {
+ name: 1,
+ title: "TestedLayer",
+ url: "base/web/client/test-resources/arcgis/arcgis-test-data.json"
+ };
+ const layer = getLayerFromRecord(testRecord, { layerBaseConfig: { group: undefined } });
+
+ const defaultProps = {
+ layers: [layer],
+ onUpdateNode: () => { },
+ onClose: () => { },
+ isVisible: true,
+ highlightOptions: {},
+ queryOptions: {},
+ selectTools: [],
+ storeConfiguration: () => { },
+ selections: {},
+ maxFeatureCount: 1,
+ cleanSelection: () => { },
+ addOrUpdateSelection: () => { },
+ zoomToExtent: () => { },
+ addLayer: () => { },
+ changeLayerProperties: () => { }
+ };
+
+
+ it('should render the component container', () => {
+ ReactDOM.render(, container);
+ const componentContainer = container.querySelector('.select-dialog');
+ expect(componentContainer).toBeTruthy();
+ });
+
+ it('should render the header when container is visible', () => {
+ ReactDOM.render(, container);
+ const componentContainer = container.querySelector('.select-dialog');
+ const header = componentContainer.querySelector('.modal-header');
+ expect(header).toBeTruthy();
+ });
+
+ it('shouldn\'t render the component when container is not visible', () => {
+ const props = { ...defaultProps };
+ props.isVisible = false;
+ ReactDOM.render(, container);
+ const componentContainer = container.querySelector('.select-dialog');
+ expect(componentContainer).toBe(null);
+ });
+
+ it('should render layers tree', () => {
+ ReactDOM.render(, container);
+ const layerTreeIsExisting = container.querySelectorAll('.ms-layers-tree');
+ expect(layerTreeIsExisting).toBeTruthy();
+ });
+
+ it('should render layer TestedLayer in the layers tree', () => {
+ ReactDOM.render(, container);
+ const msnodestitle = container.querySelectorAll('.ms-node-title');
+ const msnodestitleAsArray = Array.from(msnodestitle);
+ const LookingForTestedLayer = msnodestitleAsArray.filter(obj => obj.innerHTML === 'TestedLayer');
+ expect(LookingForTestedLayer.length).toBeGreaterThanOrEqualTo(1);
+
+ });
+});
diff --git a/web/client/plugins/LayersSelection/components/__tests__/Statistics-test.jsx b/web/client/plugins/LayersSelection/components/__tests__/Statistics-test.jsx
new file mode 100644
index 00000000000..5a5b7a31e42
--- /dev/null
+++ b/web/client/plugins/LayersSelection/components/__tests__/Statistics-test.jsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import Statistics from '../Statistics';
+import expect from 'expect';
+
+describe('Statistics component', () => {
+ let container;
+ const defaultProps = {
+ fields: [],
+ features: [],
+ setStatisticsOpen: () => { }
+ };
+
+ const MockGeoJsonfeatures = {
+ "type": "FeatureCollection",
+ "crs": {
+ "type": "name",
+ "properties": {
+ "name": "EPSG:4326"
+ }
+ },
+ "features": [
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [102.0, 0.5]
+ },
+ "properties": {
+ "name": "Sample Point",
+ "value": 10
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [102.0, 0.5]
+ },
+ "properties": {
+ "name": "Sample Point",
+ "value": 12
+ }
+ }
+ ]
+ };
+
+ beforeEach((done) => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(container);
+ document.body.removeChild(container);
+ setTimeout(done);
+ });
+
+
+ const renderComponentWithFakeFeature = (props = {}) => {
+
+
+ props.fields = ['value'];
+ props.features = MockGeoJsonfeatures.features;
+
+ const component = React.createElement(Statistics, {
+ ...defaultProps,
+ ...props
+ });
+ return ReactDOM.render(component, container);
+ };
+
+ describe('render Statistics component', () => {
+ it('should render a statistics table', () => {
+ renderComponentWithFakeFeature();
+
+ // Verifying that the table rows are displayed based on passed props
+ // Should check into document because it's a modal added outside the DOM hierarchy component
+ const table = document.querySelector('.ms-statistics-table');
+ expect(table).toBeTruthy();
+ });
+
+ it('should render a max value of the mocked GeoJSON', () => {
+ renderComponentWithFakeFeature();
+
+ // Verifying that the table rows are displayed based on passed props
+ // Should check into document because it's a modal added outside the DOM hierarchy component
+ const table = document.querySelector('.ms-statistics-table');
+ const allTrs = table.querySelectorAll('tr');
+ const allTrsAsArray = [...allTrs];
+ const trOfMax = allTrsAsArray.find(el => {
+ const spans = [...el.querySelectorAll('span')];
+ const span = spans.find(elSpan => elSpan.textContent.trim() === 'layersSelection.statistics.max');
+ if (span) {
+ return true;
+ }
+ return false;
+
+ });
+ const LinesOftrMax = trOfMax.querySelectorAll('td');
+ const LinesOftrMaxAsArray = [...LinesOftrMax];
+ // Max is store in second line
+ const max = LinesOftrMaxAsArray[1].innerHTML;
+
+ // GET MAX
+ const maxCalculated = MockGeoJsonfeatures.features.reduce((maxValue, current) => {
+ return (current.properties.value > maxValue.properties.value) ? current : maxValue;
+ }, MockGeoJsonfeatures.features[0]);
+
+ expect(max).toEqual(maxCalculated.properties.value);
+ });
+
+ });
+});
diff --git a/web/client/plugins/LayersSelection/components/layersSelection.css b/web/client/plugins/LayersSelection/components/layersSelection.css
new file mode 100644
index 00000000000..485136fa020
--- /dev/null
+++ b/web/client/plugins/LayersSelection/components/layersSelection.css
@@ -0,0 +1,19 @@
+
+.ms-resizable-modal>.modal-content.select-dialog {
+ top: 0;
+ right: 50px;
+ left: unset;
+}
+
+.select-content * .ms-node-header-info>.ms-node-header-addons:nth-child(3) {
+ flex: 1;
+ justify-content: space-between;
+}
+
+.features-count-displayer {
+ display: flex;
+}
+
+.ms-statistics-table td:first-child {
+ font-weight: bold;
+}
\ No newline at end of file
diff --git a/web/client/plugins/LayersSelection/epics/layersSelection.js b/web/client/plugins/LayersSelection/epics/layersSelection.js
new file mode 100644
index 00000000000..d8a2a520bfa
--- /dev/null
+++ b/web/client/plugins/LayersSelection/epics/layersSelection.js
@@ -0,0 +1,296 @@
+/*
+ * Copyright 2026, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { Observable } from 'rxjs';
+import axios from 'axios';
+import { SET_CONTROL_PROPERTY, TOGGLE_CONTROL } from '../../../actions/controls';
+import { UPDATE_NODE, REMOVE_NODE } from '../../../actions/layers';
+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, UPDATE_SELECTION_FEATURE } 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