diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json index 60be44cc5b..dd6c74984f 100644 --- a/project/standard/templates/configs/pluginsConfig.json +++ b/project/standard/templates/configs/pluginsConfig.json @@ -139,6 +139,17 @@ "children": ["Permalink"], "autoEnableChildren": ["Permalink"] }, + { + "name": "DynamicLegend", + "glyph": "align-left", + "title": "plugins.DynamicLegend.title", + "description": "plugins.DynamicLegend.description", + "dependencies": ["SidebarMenu"], + "defaultConfig": { + "isFloating": false, + "flatLegend": false + } + }, { "name": "Permalink", "glyph": "link", @@ -146,6 +157,17 @@ "description": "plugins.Permalink.description", "denyUserSelection": true }, + { + "name": "LayersSelection", + "glyph": "hand-down", + "title": "plugins.LayersSelection.title", + "description": "plugins.LayersSelection.description", + "dependencies": [ + "Toolbar", + "BurgerMenu", + "SidebarMenu" + ] + }, { "name": "BackgroundSelector", "title": "plugins.BackgroundSelector.title", diff --git a/web/client/components/TOC/fragments/settings/Display.jsx b/web/client/components/TOC/fragments/settings/Display.jsx index a4b617188a..7c9e4b75e2 100644 --- a/web/client/components/TOC/fragments/settings/Display.jsx +++ b/web/client/components/TOC/fragments/settings/Display.jsx @@ -30,6 +30,7 @@ import ThreeDTilesSettings from './ThreeDTilesSettings'; import ModelTransformation from './ModelTransformation'; import StyleBasedWMSJsonLegend from '../../../../plugins/TOC/components/StyleBasedWMSJsonLegend'; import VectorLegend from '../../../../plugins/TOC/components/VectorLegend'; +import { isMapServerUrl } from '../../../../utils/ArcGISUtils'; export default class extends React.Component { static propTypes = { @@ -401,6 +402,30 @@ export default class extends React.Component { } } + {this.props.element.type === "arcgis" && isMapServerUrl(this.props.element.url) && + +
+ + + + + + {!hideDynamicLegend && { + this.props.onChange("enableDynamicLegend", e.target.checked); + }} + checked={enableDynamicLegend || enableInteractiveLegend} > + +  } /> + } + + +
+
} ); } diff --git a/web/client/components/TOC/fragments/settings/__tests__/Display-test.jsx b/web/client/components/TOC/fragments/settings/__tests__/Display-test.jsx index 41ffbd02a0..002d1b9f35 100644 --- a/web/client/components/TOC/fragments/settings/__tests__/Display-test.jsx +++ b/web/client/components/TOC/fragments/settings/__tests__/Display-test.jsx @@ -531,4 +531,45 @@ describe('test Layer Properties Display module component', () => { expect(spy.calls[0].arguments[0]).toEqual("enableDynamicLegend"); expect(spy.calls[0].arguments[1]).toEqual(true); }); + + it('tests arcgis Layer Properties Legend component', () => { + const l = { + name: 'arcgisLayer', + title: 'ArcGIS Layer', + visibility: true, + storeIndex: 9, + type: 'arcgis', + url: 'https://sampleserver.arcgisonline.com/arcgis/rest/services' + }; + const settings = { + options: { + opacity: 1 + } + }; + const handlers = { + onChange() {} + }; + let spy = expect.spyOn(handlers, "onChange"); + + const comp = ReactDOM.render( + , + document.getElementById("container") + ); + + expect(comp).toBeTruthy(); + + const legendOptions = document.querySelector('.legend-options'); + expect(legendOptions).toBeTruthy(); + + const dynamicLegendCheckbox = document.querySelector(".legend-options input[data-qa='display-dynamic-legend-filter']"); + expect(dynamicLegendCheckbox).toBeTruthy(); + expect(dynamicLegendCheckbox.checked).toBeFalsy(); + + dynamicLegendCheckbox.checked = true; + ReactTestUtils.Simulate.change(dynamicLegendCheckbox); + + expect(spy).toHaveBeenCalled(); + expect(spy.calls[0].arguments[0]).toEqual("enableDynamicLegend"); + expect(spy.calls[0].arguments[1]).toEqual(true); + }); }); diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index c861e0e98d..53ddc69f9e 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -413,12 +413,42 @@ ], "desktop": [ "Details", + { + "name": "DynamicLegend", + "cfg": { + "isFloating": false, + "flatLegend": false + } + }, { "name": "BrandNavbar", "cfg": { "containerPosition": "header" } }, + { + "name": "LayersSelection", + "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..b452cc0c8c 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -139,12 +139,34 @@ "children": ["Permalink"], "autoEnableChildren": ["Permalink"] }, + { + "name": "DynamicLegend", + "glyph": "align-left", + "title": "plugins.DynamicLegend.title", + "description": "plugins.DynamicLegend.description", + "dependencies": ["SidebarMenu"], + "defaultConfig": { + "isFloating": false, + "flatLegend": false + } + }, { "name": "Permalink", "glyph": "link", "title": "plugins.Permalink.title", "description": "plugins.Permalink.description" }, + { + "name": "LayersSelection", + "glyph": "hand-down", + "title": "plugins.LayersSelection.title", + "description": "plugins.LayersSelection.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..ad5328576a 100644 --- a/web/client/configs/simple.json +++ b/web/client/configs/simple.json @@ -76,6 +76,13 @@ } ], "desktop": [ + { + "name": "DynamicLegend", + "cfg": { + "isFloating": false, + "flatLegend": false + } + }, { "name": "Map", "cfg": { @@ -89,6 +96,29 @@ "zoomControl": false } }, + { + "name": "LayersSelection", + "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" }, diff --git a/web/client/plugins/LayersSelection.jsx b/web/client/plugins/LayersSelection.jsx new file mode 100644 index 0000000000..8bb8499c95 --- /dev/null +++ b/web/client/plugins/LayersSelection.jsx @@ -0,0 +1,96 @@ +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 './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'; + +/** + * Select plugin that enables layer feature selection in the map. + * It connects Redux state and actions to the SelectComponent UI. + * Uses selectors to retrieve visibility, layers, selection results, and feature count. + * + * @function + * @returns {Object} A plugin definition object used by the application to render and control the Select tool. + */ +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/TOC/components/ArcGISLegend.jsx b/web/client/plugins/TOC/components/ArcGISLegend.jsx index f6978c60a0..23418fb588 100644 --- a/web/client/plugins/TOC/components/ArcGISLegend.jsx +++ b/web/client/plugins/TOC/components/ArcGISLegend.jsx @@ -1,5 +1,5 @@ /* - * Copyright 2024, GeoSolutions Sas. + * Copyright 2025, GeoSolutions Sas. * All rights reserved. * * This source code is licensed under the BSD-style license found in the @@ -9,40 +9,79 @@ import React, { useState, useEffect } from 'react'; import trimEnd from 'lodash/trimEnd'; import max from 'lodash/max'; +import assign from 'object-assign'; import axios from '../../../libs/ajax'; import Message from '../../../components/I18N/Message'; import Loader from '../../../components/misc/Loader'; import { getLayerIds } from '../../../utils/ArcGISUtils'; /** - * ArcGISLegend renders legend from a MapServer or ImageServer service - * @prop {object} node layer node options + * ArcGISLegend renders a legend from a MapServer or ImageServer service. + * It fetches legend data from the specified service URL and displays the legend items for each layer. + * The component supports dynamic legends and custom layer visibility based on the provided bounding box and layer options. + * + * @component + * @param {Object} props - The component's props. + * @param {Object} props.node - The layer node object that contains the configuration for the legend. + * @param {number} [props.legendWidth=12] - The width of the legend symbols (in pixels). Default is 12. + * @param {number} [props.legendHeight=12] - The height of the legend symbols (in pixels). Default is 12. + * @param {Object} [props.mapBbox={}] - The map bounding box, which defines the geographic extent for fetching the legend data. The `mapBbox.bounds` should contain the bounding coordinates and `mapBbox.crs` should contain the coordinate reference system. + * @param {Function} [props.onChange=() => {}] - A callback function that is called when the legend state changes, e.g., when no visible layers are found or the legend data is fetched successfully. It receives an object with `legendEmpty` as a property, indicating whether the legend has any visible layers. + * + * @returns {React.Element} The rendered component. */ function ArcGISLegend({ - node = {} + node = {}, + legendWidth = 12, + legendHeight = 12, + mapBbox = {}, + onChange = () => {} }) { const [legendData, setLegendData] = useState(null); const [error, setError] = useState(false); - const legendUrl = node.url ? `${trimEnd(node.url, '/')}/legend` : ''; + const legendUrl = node.url ? `${trimEnd(node.url, '/')}/${node.enableDynamicLegend ? 'queryLegends' : 'legend'}` : ''; useEffect(() => { if (legendUrl) { axios.get(legendUrl, { - params: { + params: assign({ f: 'json' - } + }, node.enableDynamicLegend ? { + bbox: Object.values(mapBbox.bounds ?? {}).join(',') || '', + bboxSR: mapBbox?.crs?.split(':')[1] ?? '', + // layers: 'show:' + node.options.layers.map(layer => layer.id).join(','), + size: `${(node.legendOptions?.legendWidth ?? legendWidth)},${(node.legendOptions?.legendHeight ?? legendHeight)}`, + format: 'png', + transparent: false, + timeRelation: 'esriTimeRelationOverlaps', + returnVisibleOnly: true + } : {}) }) - .then(({ data }) => setLegendData(data)) - .catch(() => setError(true)); + .then(({ data }) => { + const legendEmpty = data.layers.every(layer => layer.legend.length === 0); + onChange({ legendEmpty }); + setLegendData(data); + }) + .catch(() => { + console.error('API call failed'); // Debugging + onChange({ legendEmpty: true }); + setError(true); + }); } - }, [legendUrl]); + }, [legendUrl, mapBbox]); const supportedLayerIds = node.name !== undefined ? getLayerIds(node.name, node?.options?.layers || []) : []; const legendLayers = (legendData?.layers || []) .filter(({ layerId }) => node.name === undefined ? true : supportedLayerIds.includes(`${layerId}`)); const loading = !legendData && !error; + const noVisibleLayers = legendLayers.length === 0; return (
+ {noVisibleLayers && ( +
+ +
+ )} {legendLayers.map(({ legendGroups, legend, layerName }) => { const legendItems = legendGroups ? legendGroups.map(legendGroup => legend.filter(item => item.groupId === legendGroup.id)).flat() @@ -51,8 +90,9 @@ function ArcGISLegend({ return (<> {legendLayers.length > 1 &&
{layerName}
}
    - {legendItems.map((item, idx) => { - return (
  • + {legendItems.map((item) => { + const keyItem = item.id || item.label; + return (
  • ); })} - {loading && } + {loading && } {error && }
    ); diff --git a/web/client/plugins/TOC/components/DefaultLayer.jsx b/web/client/plugins/TOC/components/DefaultLayer.jsx index 673660267d..1f266ea587 100644 --- a/web/client/plugins/TOC/components/DefaultLayer.jsx +++ b/web/client/plugins/TOC/components/DefaultLayer.jsx @@ -96,6 +96,8 @@ const NodeLegend = ({
  • {visible ? : null}
  • 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 ( {} }; state = { error: false @@ -132,9 +135,11 @@ class Legend extends React.Component { validateImg = (img) => { // GeoServer response is a 1x2 px size when legend is not available. // In this case we need to show the "Legend Not available" message - if (img.height <= 1 && img.width <= 2) { + const imgError = img.height <= 1 && img.width <= 2; + if (imgError) { this.onImgError(); } + this.props.onChange({ legendEmpty: imgError }); } } diff --git a/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx b/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx index a99f542437..b9924bbc56 100644 --- a/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx +++ b/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx @@ -76,7 +76,6 @@ class StyleBasedWMSJsonLegend extends React.Component { const currEnableDynamicLegend = this.props?.layer?.enableDynamicLegend; const prevEnableDynamicLegend = prevProps?.layer?.enableDynamicLegend; - const [prevFilter, currFilter] = [prevProps?.layer, this.props?.layer] .map(_layer => getLayerFilterByLegendFormat(_layer, LEGEND_FORMAT.JSON)); @@ -104,8 +103,11 @@ class StyleBasedWMSJsonLegend extends React.Component { } this.setState({ loading: true }); getJsonWMSLegend(jsonLegendUrl).then(data => { + const legendEmpty = data.length === 0 || data[0].rules.length === 0; + this.props.onChange({ legendEmpty: legendEmpty }); this.setState({ jsonLegend: data[0], loading: false }); }).catch(() => { + this.props.onChange({ legendEmpty: true }); this.setState({ error: true, loading: false }); }); } diff --git a/web/client/plugins/TOC/components/TOC.jsx b/web/client/plugins/TOC/components/TOC.jsx index c53bc8d795..e465f4748c 100644 --- a/web/client/plugins/TOC/components/TOC.jsx +++ b/web/client/plugins/TOC/components/TOC.jsx @@ -54,9 +54,11 @@ import { * @prop {object} config.groupOptions.tooltipOptions options for group title tooltip * @prop {object} config.layerOptions specific options for layer nodes * @prop {object} config.layerOptions.tooltipOptions options for layer title tooltip + * @prop {boolean} config.layerOptions.enableDynamicLegend make the legend dynamic * @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 */ export function ControlledTOC({ tree, @@ -77,7 +79,8 @@ export function ControlledTOC({ nodeToolItems, nodeContentItems, singleDefaultGroup, - theme + theme, + treeHeader }) { return ( ); } @@ -137,9 +141,11 @@ export function ControlledTOC({ * @prop {object} config.groupOptions.tooltipOptions options for group title tooltip * @prop {object} config.layerOptions specific options for layer nodes * @prop {object} config.layerOptions.tooltipOptions options for layer title tooltip + * @prop {boolean} config.layerOptions.enableDynamicLegend make the legend dynamic * @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 +160,8 @@ function TOC({ singleDefaultGroup, nodeItems, theme, - filterText + filterText, + treeHeader }) { const { layers } = splitMapAndLayers(map) || {}; const tree = denormalizeGroups(layers.flat || [], layers.groups || []).groups; @@ -218,6 +225,7 @@ function TOC({ nodeToolItems={nodeToolItems} nodeContentItems={nodeContentItems} singleDefaultGroup={singleDefaultGroup} + treeHeader={treeHeader} /> ); } diff --git a/web/client/plugins/TOC/components/WMSLegend.jsx b/web/client/plugins/TOC/components/WMSLegend.jsx index bad721cc0f..d880c73ce9 100644 --- a/web/client/plugins/TOC/components/WMSLegend.jsx +++ b/web/client/plugins/TOC/components/WMSLegend.jsx @@ -97,6 +97,7 @@ class WMSLegend extends React.Component { } legendOptions={this.props.WMSLegendOptions} {...this.getLegendProps()} + onChange={this.props.onChange} />
); diff --git a/web/client/plugins/TOC/components/__tests__/ArcGISLegend-test.jsx b/web/client/plugins/TOC/components/__tests__/ArcGISLegend-test.jsx index e447d47163..73b76860e5 100644 --- a/web/client/plugins/TOC/components/__tests__/ArcGISLegend-test.jsx +++ b/web/client/plugins/TOC/components/__tests__/ArcGISLegend-test.jsx @@ -1,66 +1,97 @@ -/* - * Copyright 2024, GeoSolutions Sas. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ import React from 'react'; - import ReactDOM from 'react-dom'; import ArcGISLegend from '../ArcGISLegend'; -import expect from 'expect'; +import mockAxios from 'axios-mock-adapter'; +import axios from 'axios'; import { act } from 'react-dom/test-utils'; -import axios from '../../../../libs/ajax'; -import MockAdapter from 'axios-mock-adapter'; -import { waitFor } from '@testing-library/react'; - -describe('ArcGISLegend', () => { - let mockAxios; - beforeEach((done) => { - mockAxios = new MockAdapter(axios); - document.body.innerHTML = '
'; - setTimeout(done); +import assert from 'assert'; +import expect from 'expect'; + +const axiosMock = new mockAxios(axios); + +describe('ArcGISLegend', function() { + + this.timeout(100000); + let container; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('id', 'container'); + document.body.appendChild(container); }); - afterEach((done) => { - mockAxios.restore(); - ReactDOM.unmountComponentAtNode(document.getElementById("container")); + afterEach(() => { + ReactDOM.unmountComponentAtNode(container); document.body.innerHTML = ''; - setTimeout(done); + axiosMock.reset(); }); - it('should render with defaults', () => { - act(() => { - ReactDOM.render(, document.getElementById("container")); + + it('should display a loader during loading', async () => { + const node = { url: 'https://fake.server.com/arcgis/rest/services/test/MapServer' }; + + axiosMock.onGet(/legend/).reply(() => { + return new Promise(() => {}); }); - expect(document.querySelector('.ms-arcgis-legend')).toBeTruthy(); + + await act(async () => { + ReactDOM.render(, document.getElementById("container")); + }); + + expect(document.getElementById("container").querySelector('.mapstore-loader')).toExist(); }); - it('should show the legend container when the legend request succeed', (done) => { - mockAxios.onGet().reply(200, { layers: [{ layerId: 1, legend: [{ contentType: 'image/png', imageData: 'imageData', label: 'Label', width: 30, height: 20 }] }] }); + + it('displays legend items after fetch', done => { + const node = { url: 'https://fake.server.com/arcgis/rest/services/test/MapServer' }; + const mockLegendData = { layers: [{ layerId:0, layerName:'Layer', legend:[{id:'sym1', label:'Water', contentType:'image/png', imageData:'iVBORw0KGgoAAAANSUhEUgAAAAUA', width:12, height:12}]}] }; + + axiosMock.onGet(/legend/).reply(200, mockLegendData); + act(() => { - ReactDOM.render(, document.getElementById("container")); + ReactDOM.render(, container); }); - waitFor(() => expect(document.querySelector('.mapstore-small-size-loader')).toBeFalsy()) - .then(() => { - expect(document.querySelector('.ms-legend')).toBeTruthy(); - const img = document.querySelector('.ms-legend img'); - expect(img.getAttribute('src')).toBe('data:image/png;base64,imageData'); - expect(img.getAttribute('width')).toBe('30'); - expect(img.getAttribute('height')).toBe('20'); + + setTimeout(() => { + try { + const item = container.querySelector('.ms-legend-rule'); + assert(item.textContent.includes('Water')); done(); - }) - .catch(done); + } catch (err) { + done(err); + } + }, 100); }); - it('should show error message when the legend request fails', (done) => { - mockAxios.onGet().reply(500); - act(() => { - ReactDOM.render(, document.getElementById("container")); - }); - waitFor(() => expect(document.querySelector('.mapstore-small-size-loader')).toBeFalsy()) - .then(() => { - expect(document.querySelector('.ms-arcgis-legend').innerText).toBe('layerProperties.legenderror'); + + it('should display error message on failed fetch', done => { + const node = { url: 'https://fake.server.com/arcgis/rest/services/test/MapServer' }; + axiosMock.onGet(/legend/).reply(500); + act(() => ReactDOM.render(, container)); + setTimeout(() => { + try { + const msg = container.querySelector('.ms-arcgis-legend').textContent; + assert(msg.includes('legenderror')); done(); - }) - .catch(done); + } catch (err) { + done(err); + } + }, 100); }); + + it('should call onChange with legendEmpty status', done => { + const node = { url: 'https://fake.server.com/arcgis/rest/services/test/MapServer' }; + const mockData = { layers: [{ layerId:0, layerName:'Layer', legend:[{ id:'sym1', label:'Water', contentType:'image/png', imageData:'iVBORw0', width:12, height:12 }] }] }; + + axiosMock.onGet(/legend/).reply(200, mockData); + + const onChangeSpy = (status) => { + try { + assert(status.legendEmpty === false); + done(); + } catch (err) { + done(err); + } + }; + + act(() => ReactDOM.render(, container)); + }); + }); diff --git a/web/client/plugins/__tests__/DynamicLegend-test.jsx b/web/client/plugins/__tests__/DynamicLegend-test.jsx new file mode 100644 index 0000000000..423bba1363 --- /dev/null +++ b/web/client/plugins/__tests__/DynamicLegend-test.jsx @@ -0,0 +1,541 @@ +/** + * Copyright 2019, 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 {DragDropContext as dragDropContext} from 'react-dnd'; +import TestBackend from 'react-dnd-test-backend'; + +// import TOCPlugin from '../TOC'; +import DynamicLegend from '../dynamicLegend'; +import { getPluginForTest } from './pluginsTestUtils'; +import AddGroup from '../AddGroup'; +import MetadataExplorer from '../MetadataExplorer'; +import LayerInfo from '../LayerInfo'; +import FeatureEditor from '../FeatureEditor'; +import TOCItemsSettings from '../TOCItemsSettings'; +import FilterLayer from '../FilterLayer'; +import WidgetsBuilder from '../WidgetsBuilder'; + +const dndContext = dragDropContext(TestBackend); + +const getTOCItems = (plugins) => { + return Object.keys(plugins) + .map((key) => plugins?.[key]?.[`${key}Plugin`]?.TOC).flat(); +}; + +describe('DynamicLegend Plugin', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('Shows DynamicLegend plugin', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + controls: { + addgroup: { + enabled: true + } + } + }); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render(, document.getElementById("container")); + expect(document.getElementsByClassName('ms-toc-container').length).toBe(1); + }); + + it('DynamicLegend shows annotations layer in openlayers mapType', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{id: 'default', title: 'Default', nodes: ['annotations']}], + flat: [{id: 'annotations', title: 'Annotations'}] + }, + mapType: { + mapType: 'openlayers' + } + }); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelector('.ms-node-layer .ms-node-title').textContent).toBe('Annotations'); + expect(document.querySelector('.ms-node-group .ms-node-title').textContent).toBe('Default'); + expect(document.querySelectorAll('.ms-toc-filter input').length).toBe(1); + }); + + it('DynamicLegend hides filter layer if no groups and no layers are present', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ id: 'default', title: 'Default', nodes: [] }], + flat: [] + }, + maptype: { + mapType: 'openlayers' + } + }); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelectorAll('.ms-toc-filter input').length).toBe(0); + }); + it('DynamicLegend hides filter layer if a group with no layers are present', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [], + flat: [] + }, + maptype: { + mapType: 'openlayers' + } + }); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelectorAll('.ms-toc-filter input').length).toBe(0); + }); + it('DynamicLegend use custom group node', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ + expanded: true, + id: 'custom-group', + name: 'Default', + nodes: [ 'layer_01', 'layer_02' ], + title: 'Default' + }], + flat: [{ + id: 'layer_01', + title: 'title_01', + group: 'custom-group' + }, { + id: 'layer_02', + title: 'title_02', + group: 'custom-group' + }] + }, + maptype: { + mapType: 'openlayers' + } + }); + const GroupNode = ({ node }) => { + return
{node.title}
; + }; + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render(, document.getElementById("container")); + const groupNodes = document.querySelectorAll('.custom-group-node'); + expect(groupNodes.length).toBe(1); + const [ groupNode ] = groupNodes; + expect(groupNode.innerHTML).toBe('Default'); + }); + it('DynamicLegend use custom layer node', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ + expanded: true, + id: 'Default', + name: 'Default', + nodes: [ 'layer_01', 'layer_02' ], + title: 'Default' + }], + flat: [{ + id: 'layer_01', + title: 'title_01' + }, { + id: 'layer_02', + title: 'title_02' + }] + }, + maptype: { + mapType: 'openlayers' + } + }); + const LayerNode = ({ node }) => { + return
{node.title}
; + }; + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render(, document.getElementById("container")); + const groupNodes = document.querySelectorAll('.custom-layer-node'); + expect(groupNodes.length).toBe(2); + const [ layerNode01, layerNode02 ] = groupNodes; + expect(layerNode01.innerHTML).toBe('title_01'); + expect(layerNode02.innerHTML).toBe('title_02'); + }); + it('Update layer title and description button', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ + expanded: true, + id: 'Default', + name: 'Default', + nodes: [ 'layer_01', 'layer_02', 'layer_03' ], + title: 'Default' + }], + flat: [{ + id: 'layer_01', + title: 'title_01', + type: 'tileprovider' + }, { + id: 'layer_02', + title: 'title_02', + type: 'wmts' + }, { + id: 'layer_03', + title: 'title_03', + type: 'wms', + group: 'background' + }] + }, + maptype: { + mapType: 'openlayers' + }, + security: { + user: { + role: 'ADMIN' + } + } + }); + const WrappedPlugin = dndContext(Plugin); + const items = getTOCItems({ MetadataExplorer, AddGroup, LayerInfo }); + ReactDOM.render(, document.getElementById("container")); + const toolbarNode = document.getElementsByClassName('ms-toc-toolbar')[0]; + expect(toolbarNode).toBeTruthy(); + const buttons = toolbarNode.getElementsByTagName('button'); + expect(buttons.length).toBe(4); + }); + it('Update layer title and description button is hidden when there are no valid layers for updating', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ + expanded: true, + id: 'Default', + name: 'Default', + nodes: [ 'layer_01', 'layer_02', 'layer_03' ], + title: 'Default' + }], + flat: [{ + id: 'layer_01', + title: 'title_01', + type: 'tileprovider' + }, { + id: 'layer_02', + title: 'title_02', + type: 'wmts', + group: 'background' + }, { + id: 'layer_03', + title: 'title_03', + type: 'wms', + group: 'background' + }] + }, + maptype: { + mapType: 'openlayers' + }, + security: { + user: { + role: 'ADMIN' + } + } + }); + const WrappedPlugin = dndContext(Plugin); + const items = getTOCItems({ MetadataExplorer, AddGroup, LayerInfo }); + ReactDOM.render(, document.getElementById("container")); + const toolbar = document.getElementsByClassName('ms-toc-toolbar')[0]; + expect(toolbar).toBeTruthy(); + const buttons = toolbar.getElementsByTagName('button'); + expect(buttons.length).toBe(3); + }); + describe('render items from other plugins', () => { + const TOOL_BUTTON_SELECTOR = '.ms-toc-toolbar button'; + const SELECTED_LAYER_STATE = { + layers: { + flat: [ + { + id: 'topp:states__6', + format: 'image/png8', + search: { + url: 'https://something/geoserver/wfs', + type: 'wfs' + }, + name: 'topp:states', + type: 'wms', + url: 'https://something/geoserver/wms', + bbox: { + crs: 'EPSG:4326', + bounds: { + minx: -124.73142200000001, + miny: 24.955967, + maxx: -66.969849, + maxy: 49.371735 + } + }, + visibility: true + } + ], + groups: [ + { + id: 'Default', + title: 'Default', + name: 'Default', + nodes: [ + 'topp:states__6' + ], + expanded: true + } + ], + selected: [ + 'topp:states__6' + ], + settings: { + expanded: false, + node: null, + nodeType: null, + options: {} + }, + layerMetadata: { + expanded: false, + metadataRecord: {}, + maskLoading: false + } + } + }; + describe('target: toolbar', () => { + it('render custom plugin', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ id: 'default', title: 'Default', nodes: [] }], + flat: [] + }, + maptype: { + mapType: 'openlayers' + } + }); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render( + }]} />, document.getElementById("container")); + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBe(2); + expect(document.querySelector(`${TOOL_BUTTON_SELECTOR}#toolbarCustomButton`)).toBeTruthy(); + }); + it('selector do not show the button when return false', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ id: 'default', title: 'Default', nodes: [] }], + flat: [] + }, + maptype: { + mapType: 'openlayers' + } + }); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render( { + return false; + }, + Component: () => + }]} />, document.getElementById("container")); + expect(document.querySelector(`${TOOL_BUTTON_SELECTOR}#toolbarCustomButton`)).toNotExist(); + }); + it('selector reads status, selectedGroups, selectedLayers', () => { + const { Plugin } = getPluginForTest(DynamicLegend, SELECTED_LAYER_STATE); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render( { + return status === "LAYER" && selectedGroups.length === 0 && selectedLayers[0].id === "topp:states__6"; + }, + Component: () => + }]} />, document.getElementById("container")); + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBeGreaterThan(0); // other buttons are shown. + expect(document.querySelector(`${TOOL_BUTTON_SELECTOR}#toolbarCustomButton`)).toBeTruthy(); + }); + it('Component receives the property \`status\`', () => { + const { Plugin } = getPluginForTest(DynamicLegend, SELECTED_LAYER_STATE); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render( { + expect(status === "LAYER" && selectedGroups.length === 0 && selectedLayers[0].id === "topp:states__6").toBeTruthy(); + return ; + } + }]} />, document.getElementById("container")); + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBeGreaterThan(0); // other buttons are shown. + expect(document.querySelector(`${TOOL_BUTTON_SELECTOR}#toolbarCustomButton-LAYER`)).toBeTruthy(); + }); + }); + + it('AddLayer and AddGroup do not show without proper plugins', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ id: 'default', title: 'Default', nodes: [] }], + flat: [] + }, + maptype: { + mapType: 'openlayers' + } + }); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBe(1); + }); + it('render AddLayer', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ id: 'default', title: 'Default', nodes: [] }], + flat: [] + }, + maptype: { + mapType: 'openlayers' + } + }); + const WrappedPlugin = dndContext(Plugin); + const items = getTOCItems({ MetadataExplorer }); + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBe(2); + expect(document.querySelector(`${TOOL_BUTTON_SELECTOR} .glyphicon-add-layer`)).toBeTruthy(); + }); + it('render AddGroup', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + groups: [{ id: 'default', title: 'Default', nodes: [] }], + flat: [] + }, + maptype: { + mapType: 'openlayers' + } + }); + const WrappedPlugin = dndContext(Plugin); + const items = getTOCItems({ AddGroup }); + ReactDOM.render(, document.getElementById("container")); + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBe(2); + expect(document.querySelector(`${TOOL_BUTTON_SELECTOR} .glyphicon-add-folder`)).toBeTruthy(); + }); + const ZOOM_TO_SELECTOR = `${TOOL_BUTTON_SELECTOR} .glyphicon-zoom-to`; + const FEATURES_GRID_SELECTOR = `${TOOL_BUTTON_SELECTOR} .glyphicon-features-grid`; + const REMOVE_SELECTOR = `${TOOL_BUTTON_SELECTOR } .glyphicon-trash`; + const SETTINGS_SELECTOR = `${TOOL_BUTTON_SELECTOR} .glyphicon-wrench`; + const FILTER_LAYER_SELECTOR = `${TOOL_BUTTON_SELECTOR} .glyphicon-filter-layer`; + const WIDGET_BUILDER_SELECTOR = `${TOOL_BUTTON_SELECTOR} .glyphicon-stats`; + it('render default tools zoomToLayer, remove layer, for selected layer', () => { + const { Plugin } = getPluginForTest(DynamicLegend, SELECTED_LAYER_STATE); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render(, document.getElementById("container")); + // check zoom and remove selector + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBe(3); + expect(document.querySelector(ZOOM_TO_SELECTOR)).toBeTruthy(); + expect(document.querySelector(REMOVE_SELECTOR)).toBeTruthy(); + + }); + it('render FeatureEditor', () => { + const { Plugin } = getPluginForTest(DynamicLegend, SELECTED_LAYER_STATE); + const WrappedPlugin = dndContext(Plugin); + const items = getTOCItems({ FeatureEditor }); + ReactDOM.render(, document.getElementById("container")); + // check tools + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBe(4); + expect(document.querySelector(ZOOM_TO_SELECTOR)).toBeTruthy(); + expect(document.querySelector(FEATURES_GRID_SELECTOR)).toBeTruthy(); + expect(document.querySelector(REMOVE_SELECTOR)).toBeTruthy(); + }); + it('render TOCItemsSettings', () => { + const { Plugin } = getPluginForTest(DynamicLegend, SELECTED_LAYER_STATE); + const WrappedPlugin = dndContext(Plugin); + const items = getTOCItems({ TOCItemsSettings }); + ReactDOM.render(, document.getElementById("container")); + // check tools + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBe(4); + expect(document.querySelector(ZOOM_TO_SELECTOR)).toBeTruthy(); + expect(document.querySelector(SETTINGS_SELECTOR)).toBeTruthy(); + expect(document.querySelector(REMOVE_SELECTOR)).toBeTruthy(); + }); + it('render FilterLayer', () => { + const { Plugin } = getPluginForTest(DynamicLegend, SELECTED_LAYER_STATE); + const WrappedPlugin = dndContext(Plugin); + const items = getTOCItems({ FilterLayer }); + ReactDOM.render(, document.getElementById("container")); + // check tools + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBe(4); + expect(document.querySelector(ZOOM_TO_SELECTOR)).toBeTruthy(); + expect(document.querySelector(FILTER_LAYER_SELECTOR)).toBeTruthy(); + expect(document.querySelector(REMOVE_SELECTOR)).toBeTruthy(); + }); + it('render WidgetsBuilder', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { ...SELECTED_LAYER_STATE, controls: { widgetBuilder: {available: true}}}); + const WrappedPlugin = dndContext(Plugin); + const items = getTOCItems({ WidgetsBuilder }); + ReactDOM.render(, document.getElementById("container")); + // check tools + + expect(document.querySelector(ZOOM_TO_SELECTOR)).toBeTruthy("zoom doesn't exist"); + expect(document.querySelector(WIDGET_BUILDER_SELECTOR)).toBeTruthy("widget doesn't exist"); + expect(document.querySelector(REMOVE_SELECTOR)).toBeTruthy("remove doesn't exist"); + }); + it('should render the zoom to layer button if there is a vector layer with valid features', () => { + const { Plugin } = getPluginForTest(DynamicLegend, { + layers: { + flat: [ + { + id: 'topp:states__6', + name: 'topp:states', + type: 'vector', + visibility: true, + features: [{ + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [0, 0] + } + }] + } + ], + groups: [ + { + id: 'Default', + title: 'Default', + name: 'Default', + nodes: [ + 'topp:states__6' + ], + expanded: true + } + ], + selected: [ + 'topp:states__6' + ], + settings: { + expanded: false, + node: null, + nodeType: null, + options: {} + }, + layerMetadata: { + expanded: false, + metadataRecord: {}, + maskLoading: false + } + } + }); + const WrappedPlugin = dndContext(Plugin); + ReactDOM.render(, document.getElementById("container")); + // check zoom and remove selector + expect(document.querySelectorAll(TOOL_BUTTON_SELECTOR).length).toBe(3); + expect(document.querySelector(ZOOM_TO_SELECTOR)).toBeTruthy(); + expect(document.querySelector(REMOVE_SELECTOR)).toBeTruthy(); + }); + }); +}); diff --git a/web/client/plugins/dynamicLegend/DynamicLegend.jsx b/web/client/plugins/dynamicLegend/DynamicLegend.jsx new file mode 100644 index 0000000000..054267cb41 --- /dev/null +++ b/web/client/plugins/dynamicLegend/DynamicLegend.jsx @@ -0,0 +1,94 @@ +/* +* 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 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 { groupsSelector, layersSelector } from '../../selectors/layers'; +import { currentZoomLevelSelector, mapBboxSelector, currentResolutionSelector } from '../../selectors/map'; +import { updateNode } from '../../actions/layers'; +import controls from '../../reducers/controls'; +import { toggleControl } from '../../actions/controls'; +import Message from '../../components/I18N/Message'; + +import DynamicLegend from './components/DynamicLegend'; + +/** + * DynamicLegend plugin initialization and configuration. + * Connects the DynamicLegend component to Redux and registers the plugin in multiple UI containers. + */ +const DynamicLegendPlugin = connect( + createSelector([ + (state) => get(state, 'controls.dynamic-legend.enabled'), + groupsSelector, + layersSelector, + currentZoomLevelSelector, + mapBboxSelector, + currentResolutionSelector + ], (isVisible, groups, layers, currentZoomLvl, mapBbox, resolution) => ({ + isVisible, + groups, + layers: layers.filter(layer => layer.group !== 'background'), + currentZoomLvl, + mapBbox, + resolution + })), + { + onClose: toggleControl.bind(null, 'dynamic-legend', null), + onUpdateNode: updateNode + } +)(DynamicLegend); + +/** + * Plugin registration using MapStore's plugin system. + */ +export default createPlugin('DynamicLegend', { + component: DynamicLegendPlugin, + reducers: { controls }, + epics: {}, + containers: { + // review containers + BurgerMenu: { + name: 'dynamic-legend', + position: 1000, + priority: 2, + doNotHide: true, + text: , + tooltip: , + icon: , + action: toggleControl.bind(null, 'dynamic-legend', null), + toggle: true + }, + SidebarMenu: { + name: 'dynamic-legend', + position: 1000, + priority: 1, + doNotHide: true, + text: , + tooltip: , + icon: , + action: toggleControl.bind(null, 'dynamic-legend', null), + toggle: true + }, + Toolbar: { + name: 'dynamic-legend', + alwaysVisible: true, + position: 2, + priority: 0, + doNotHide: true, + tooltip: , + icon: , + action: toggleControl.bind(null, 'dynamic-legend', null), + toggle: true + } + } +}); diff --git a/web/client/plugins/dynamicLegend/assets/dynamicLegend.css b/web/client/plugins/dynamicLegend/assets/dynamicLegend.css new file mode 100644 index 0000000000..8eb750ba04 --- /dev/null +++ b/web/client/plugins/dynamicLegend/assets/dynamicLegend.css @@ -0,0 +1,8 @@ +.ms-resizable-modal > .modal-content.legend-dialog { + top: 0vh; + right: -100vw; +} + +.legend-content * .ms-node-title { + font-weight: bold !important +} diff --git a/web/client/plugins/dynamicLegend/components/CustomGroupNodeComponent.jsx b/web/client/plugins/dynamicLegend/components/CustomGroupNodeComponent.jsx new file mode 100644 index 0000000000..f739853fe6 --- /dev/null +++ b/web/client/plugins/dynamicLegend/components/CustomGroupNodeComponent.jsx @@ -0,0 +1,25 @@ +/* +* 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 React from 'react'; +import DefaultGroup from '../../TOC/components/DefaultGroup'; + +/** + * Custom group node component wrapper. + * Avoids unnecessary remounting of group nodes. + * + * @param {Object} props - Properties passed to the group component. + * @returns {JSX.Element} + */ +const CustomGroupNodeComponent = props => { + return ( + + ); +}; + +export default CustomGroupNodeComponent; diff --git a/web/client/plugins/dynamicLegend/components/CustomLayerNodeComponent.jsx b/web/client/plugins/dynamicLegend/components/CustomLayerNodeComponent.jsx new file mode 100644 index 0000000000..dabfb12b64 --- /dev/null +++ b/web/client/plugins/dynamicLegend/components/CustomLayerNodeComponent.jsx @@ -0,0 +1,36 @@ +/* +* 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 React from 'react'; +import DefaultLayer from '../../TOC/components/DefaultLayer'; +import { keepLayer } from '../utils/DynamicLegendUtils'; + +/** + * Custom layer node component that filters and extends layers for dynamic legend display. + * + * @param {Object} props - Properties including the node to render. + * @param {Object} props.node - Layer node data. + * @returns {JSX.Element|null} + */ +const CustomLayerNodeComponent = ({ node, ...props }) => { + if (!keepLayer(node)) { + return null; + } + return ( + + ); +}; + +export default CustomLayerNodeComponent; diff --git a/web/client/plugins/dynamicLegend/components/DynamicLegend.jsx b/web/client/plugins/dynamicLegend/components/DynamicLegend.jsx new file mode 100644 index 0000000000..ce403ec6fb --- /dev/null +++ b/web/client/plugins/dynamicLegend/components/DynamicLegend.jsx @@ -0,0 +1,107 @@ +/* +* 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 React from 'react'; +import Message from '../../../components/I18N/Message'; +import ResizableModal from '../../../components/misc/ResizableModal'; +import { ControlledTOC } from '../../TOC/components/TOC'; +import { getNodeStyle } from '../utils/DynamicLegendUtils'; +import ResponsivePanel from "../../../components/misc/panels/ResponsivePanel"; + +import CustomGroupNodeComponent from './CustomGroupNodeComponent'; +import CustomLayerNodeComponent from './CustomLayerNodeComponent'; +import '../assets/dynamicLegend.css'; + +/** + * Main component for the DynamicLegend plugin. + * + * @param {Object} props + * @param {Function} props.onUpdateNode - Function to update a layer node. + * @param {number} props.currentZoomLvl - Current zoom level of the map. + * @param {Function} props.onClose - Callback to close the panel. + * @param {boolean} props.isVisible - Whether the panel is visible. + * @param {Array} props.groups - Layer groups. + * @param {Array} props.layers - Map layers. + * @param {Object} props.mapBbox - Current map bounding box. + * @param {number} props.resolution - Current map resolution. + * @param {number} [props.size=550] - Width of the docked panel. + * @param {Object} [props.dockStyle={}] - Custom dock style. + * @param {boolean} [props.isFloating=false] - Whether to render in a floating modal. + * @param {boolean} [props.flatLegend=false] - Whether to display a flat legend instead of grouped. + * @returns {JSX.Element} + */ +const DynamicLegend = ({ + onUpdateNode, + currentZoomLvl, + onClose, + isVisible, + groups, + mapBbox, + resolution, + size = 550, + dockStyle = {}, + layers = [], + isFloating = false, + flatLegend = false +}) => { + const ContainerComponent = isFloating ? ResizableModal : ResponsivePanel; + return ( + , + dialogClassName: "legend-dialog", + show: isVisible, + draggable: true, + style: { zIndex: 1993 } + } : { + containerStyle: dockStyle, + containerId: "dynamic-legend-container", + containerClassName: "dock-container", + className: "dynamic-legend-dock-panel", + open: isVisible, + position: "right", + size, + glyph: "align-left", + title: , + onClose, + style: dockStyle + })} + > + {layers.length === 0 && } + {layers.length !== 0 && getNodeStyle(node, nodeType, resolution)} + className="legend-content" + theme="legend" + onChange={onUpdateNode} + groupNodeComponent={CustomGroupNodeComponent} + layerNodeComponent={CustomLayerNodeComponent} + config={{ + sortable: false, + showFullTitle: true, + hideOpacitySlider: true, + hideVisibilityButton: true, + expanded: true, + zoom: currentZoomLvl, + layerOptions: { + legendOptions: { + WMSLegendOptions: "countMatched:true;fontAntiAliasing:true;", + legendWidth: 12, + legendHeight: 12, + mapBbox + } + } + }} + />} + + ); +}; + +export default DynamicLegend; diff --git a/web/client/plugins/dynamicLegend/index.js b/web/client/plugins/dynamicLegend/index.js new file mode 100644 index 0000000000..bee12984bd --- /dev/null +++ b/web/client/plugins/dynamicLegend/index.js @@ -0,0 +1,13 @@ +/* +* 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. +*/ + +/** + * Entry point for the DynamicLegend plugin. + * Exports the default component. + */ +export { default } from './DynamicLegend'; diff --git a/web/client/plugins/dynamicLegend/utils/DynamicLegendUtils.js b/web/client/plugins/dynamicLegend/utils/DynamicLegendUtils.js new file mode 100644 index 0000000000..9487ec87e3 --- /dev/null +++ b/web/client/plugins/dynamicLegend/utils/DynamicLegendUtils.js @@ -0,0 +1,57 @@ +/* +* 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 { flattenArrayOfObjects, NodeTypes, isInsideResolutionsLimits } from '../../../utils/LayersUtils'; + +/** + * Determines if a layer should be included in the dynamic legend. + * + * @param {Object} layer - The layer object. + * @returns {boolean} True if layer is not a background layer and is of type 'wms' or 'arcgis'. + */ +export const keepLayer = layer => (layer.group !== 'background' && ['wms', 'arcgis'].includes(layer.type)); + +/** + * Determines if a legend should be displayed for a layer based on resolution and visibility. + * + * @param {Object} node - The layer node. + * @param {number} resolution - Current map resolution. + * @returns {boolean} True if the legend is visible. + */ +export const isLegendLayerVisible = (node, resolution) => { + return node.visibility !== false && !node.legendEmpty && isInsideResolutionsLimits(node, resolution); +}; + +/** + * Determines if a group legend should be shown based on visible child layers. + * + * @param {Object} node - The group node. + * @param {number} resolution - Current map resolution. + * @returns {boolean} True if any child layers are visible and applicable. + */ +export const isLegendGroupVisible = (node, resolution) => { + const flattenChildren = flattenArrayOfObjects(node.nodes); + const flattenLayers = flattenChildren.filter(child => !child.nodes); + const childrenLayersVisible = flattenLayers.some(child => isLegendLayerVisible(child, resolution)); + return node.visibility !== false && flattenLayers.length > 0 && childrenLayersVisible; +}; + +/** + * Gets a style object based on node visibility and resolution. + * + * @param {Object} node - Node to evaluate. + * @param {string} nodeType - Node type ('group' or 'layer'). + * @param {number} resolution - Current map resolution. + * @returns {Object} CSS style object ({ display: 'none' } if not visible). + */ +export const getNodeStyle = (node, nodeType, resolution) => { + const visible = nodeType === NodeTypes.GROUP + ? isLegendGroupVisible(node, resolution) + : isLegendLayerVisible(node, resolution); + return visible ? { } : { display: 'none' }; +}; diff --git a/web/client/plugins/layersSelection/actions/layersSelection.js b/web/client/plugins/layersSelection/actions/layersSelection.js new file mode 100644 index 0000000000..eca93c9c9d --- /dev/null +++ b/web/client/plugins/layersSelection/actions/layersSelection.js @@ -0,0 +1,44 @@ +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"; + +/** + * 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 + }; +} diff --git a/web/client/plugins/layersSelection/assets/select.css b/web/client/plugins/layersSelection/assets/select.css new file mode 100644 index 0000000000..a59e6e5abd --- /dev/null +++ b/web/client/plugins/layersSelection/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/layersSelection/components/EllipsisButton/EllipsisButton.css b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.css new file mode 100644 index 0000000000..66025df79a --- /dev/null +++ b/web/client/plugins/layersSelection/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/layersSelection/components/EllipsisButton/EllipsisButton.jsx b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx new file mode 100644 index 0000000000..7e28f17efe --- /dev/null +++ b/web/client/plugins/layersSelection/components/EllipsisButton/EllipsisButton.jsx @@ -0,0 +1,245 @@ +import React, { useState, useEffect, useContext } from 'react'; +import ReactDOM from "react-dom"; +import bbox from '@turf/bbox'; +import { saveAs } from 'file-saver'; +import axios from 'axios'; + +import Message from '../../../../components/I18N/Message'; +import { describeFeatureType } from '../../../../api/WFS'; + +import { SelectRefContext } from '../LayersSelection'; +import Statistics from './Statistics/Statistics'; +import './EllipsisButton.css'; + +/** + * EllipsisButton provides a contextual menu for selected layer data. + * It allows users to: + * - Zoom to selection extent + * - View statistics + * - Create a new layer from selection + * - Export data (GeoJSON, JSON, CSV) + * - Apply attribute filters (if supported) + * - Clear the selection + * + * @param {Object} props - Component props. + * @param {Object} props.node - Layer node (descriptor). + * @param {Array} props.layers - All available layers. + * @param {Object} props.selectionData - GeoJSON FeatureCollection. + * @param {Function} props.onAddOrUpdateSelection - Callback to update selection. + * @param {Function} props.onZoomToExtent - Callback to zoom to selection. + * @param {Function} props.onAddLayer - Callback to add a new layer. + * @param {Function} props.onChangeLayerProperties - Callback to change layer properties. + */ +export default ({ + node = {}, + layers = [], + selectionData = {}, + onAddOrUpdateSelection = () => {}, + onZoomToExtent = () => {}, + onAddLayer = () => {}, + onChangeLayerProperties = () => {} +}) => { + const [menuOpen, setMenuOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [statisticsOpen, setStatisticsOpen] = useState(false); + const [numericFields, setNumericFields] = useState([]); + + const SelectRef = useContext(SelectRefContext); + const ellipsisContainerClass = 'ellipsis-container'; + useEffect(() => { + const selectElement = SelectRef.current?.addEventListener ? SelectRef.current : ReactDOM.findDOMNode(SelectRef.current); + if (!selectElement || !selectElement.addEventListener) { return null; } + const handleClick = e => { + if (menuOpen) { + let parentElement = e.target; + let foundThis = false; + while (!foundThis && parentElement !== e.currentTarget) { + foundThis = parentElement.className === ellipsisContainerClass; + parentElement = parentElement.parentElement; + } + if (!foundThis) { setMenuOpen(false); } + } + }; + selectElement.addEventListener("click", handleClick); + return () => selectElement.removeEventListener("click", handleClick); + }); + + const toggleMenu = () => setMenuOpen(!menuOpen); + const toggleExport = () => setExportOpen(!exportOpen); + + 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/layersSelection/components/EllipsisButton/Statistics/Statistics.css b/web/client/plugins/layersSelection/components/EllipsisButton/Statistics/Statistics.css new file mode 100644 index 0000000000..ff8abea2a2 --- /dev/null +++ b/web/client/plugins/layersSelection/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/layersSelection/components/EllipsisButton/Statistics/Statistics.jsx b/web/client/plugins/layersSelection/components/EllipsisButton/Statistics/Statistics.jsx new file mode 100644 index 0000000000..c0abc6f8b1 --- /dev/null +++ b/web/client/plugins/layersSelection/components/EllipsisButton/Statistics/Statistics.jsx @@ -0,0 +1,86 @@ +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'; + +/** + * 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" + // 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/layersSelection/components/LayersSelection.jsx b/web/client/plugins/layersSelection/components/LayersSelection.jsx new file mode 100644 index 0000000000..71295dc3ff --- /dev/null +++ b/web/client/plugins/layersSelection/components/LayersSelection.jsx @@ -0,0 +1,187 @@ +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 './LayersSelectionHeader/LayersSelectionHeader'; +import EllipsisButton from './EllipsisButton/EllipsisButton'; +import { isSelectQueriable, filterLayerForSelect } from '../selectors/layersSelection'; +import '../assets/select.css'; + +/** + * Context used to expose a reference to the ResizableModal component + * so that child components can programmatically interact with it. + */ +export const SelectRefContext = createContext(null); + +/** + * 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. + */ +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); + + /** + * 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 }), []); + + 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/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css b/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.css new file mode 100644 index 0000000000..ce73fcf912 --- /dev/null +++ b/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.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/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx b/web/client/plugins/layersSelection/components/LayersSelectionHeader/LayersSelectionHeader.jsx new file mode 100644 index 0000000000..c9726a39a6 --- /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'; +import './LayersSelectionHeader.css'; + +/** + * 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: '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/plugins/layersSelection/epics/layersSelection.js b/web/client/plugins/layersSelection/epics/layersSelection.js new file mode 100644 index 0000000000..aa52559f4b --- /dev/null +++ b/web/client/plugins/layersSelection/epics/layersSelection.js @@ -0,0 +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 +}; diff --git a/web/client/plugins/layersSelection/reducers/layersSelection.js b/web/client/plugins/layersSelection/reducers/layersSelection.js new file mode 100644 index 0000000000..55eac1ada7 --- /dev/null +++ b/web/client/plugins/layersSelection/reducers/layersSelection.js @@ -0,0 +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; + } +} diff --git a/web/client/plugins/layersSelection/selectors/layersSelection.js b/web/client/plugins/layersSelection/selectors/layersSelection.js new file mode 100644 index 0000000000..c5ee726721 --- /dev/null +++ b/web/client/plugins/layersSelection/selectors/layersSelection.js @@ -0,0 +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 ?? {}; diff --git a/web/client/plugins/layersSelection/utils/LayersSelection.js b/web/client/plugins/layersSelection/utils/LayersSelection.js new file mode 100644 index 0000000000..52b3ad05ee --- /dev/null +++ b/web/client/plugins/layersSelection/utils/LayersSelection.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)) + } +); diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index cbc5c76e6c..ab1cad3209 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -70,6 +70,7 @@ export const plugins = { DashboardImport: toModulePlugin('DashboardImport', () => import( /* webpackChunkName: 'plugins/dashboardImport' */'../plugins/DashboardImport')), DetailsPlugin: toModulePlugin('Details', () => import(/* webpackChunkName: 'plugins/details' */ '../plugins/Details')), DrawerMenuPlugin: toModulePlugin('DrawerMenu', () => import(/* webpackChunkName: 'plugins/drawerMenu' */ '../plugins/DrawerMenu')), + DynamicLegendPlugin: toModulePlugin('DynamicLegend', () => import(/* webpackChunkName: 'plugins/dynamicLegend' */ '../plugins/DynamicLegend')), ExpanderPlugin: toModulePlugin('Expander', () => import(/* webpackChunkName: 'plugins/expander' */ '../plugins/Expander')), FilterLayerPlugin: toModulePlugin('FilterLayer', () => import(/* webpackChunkName: 'plugins/filterLayer' */ '../plugins/FilterLayer')), FullScreenPlugin: toModulePlugin('FullScreen', () => import(/* webpackChunkName: 'plugins/fullScreen' */ '../plugins/FullScreen')), @@ -87,6 +88,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')), diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 313ee87606..25a8803a46 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -1535,6 +1535,12 @@ "height": "Höhe" } }, + "dynamiclegend": { + "title": "Legende", + "tooltip": "Kartenlegende anzeigen", + "emptyLegend": "Keine Ebene zum Anzeigen", + "noLayersVisibleInExtent": "Keine Schicht im aktuellen Bereich sichtbar" + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Permalink generiert", @@ -1567,6 +1573,43 @@ } } }, + "layersSelection": { + "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", @@ -3466,10 +3509,18 @@ "description": "Ermöglicht das Teilen der Karte auf verschiedene Arten (Link, QR-Code, Einbettung, soziale Netzwerke ...)", "title": "Freigabe-Werkzeug" }, + "DynamicLegend": { + "title": "Legende", + "description": "Kartenlegende anzeigen" + }, "Permalink": { "description": "Ermöglicht das Erstellen eines Permalinks der aktuell angezeigten Ressource", "title": "Permalink" }, + "LayersSelection": { + "title": "Auswählen", + "description": "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..5eef8f1392 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -1496,6 +1496,12 @@ "height": "height" } }, + "dynamiclegend": { + "title": "Legend", + "tooltip": "Display the map legend", + "emptyLegend": "No layer to display", + "noLayersVisibleInExtent": "No visible layers in the current extent" + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Permalink generated", @@ -1528,6 +1534,43 @@ } } }, + "layersSelection": { + "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", @@ -3437,10 +3480,18 @@ "description": "Allows to share the map in various ways (link, QR-Code, embed, social networks...)", "title": "Share Tool" }, + "DynamicLegend": { + "title": "Legend", + "description": "Display the map legend" + }, "Permalink": { "description": "Allows to create a permalink of the current resource in view", "title": "Permalink" }, + "LayersSelection": { + "title": "Select", + "description": "Display the selection tool" + }, "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..b9ddcff73c 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -1496,6 +1496,12 @@ "height": "altura" } }, + "dynamiclegend": { + "title": "Leyenda", + "tooltip": "Mostrar la leyenda del mapa", + "emptyLegend": "Ninguna capa para mostrar", + "noLayersVisibleInExtent": "No hay capas visibles en el área actual" + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Enlace permanente generado", @@ -1528,6 +1534,43 @@ } } }, + "layersSelection": { + "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", @@ -3427,10 +3470,18 @@ "description": "Permite compartir el mapa de varias maneras (enlace, código QR, incrustar, redes sociales ...)", "title": "Compartir herramienta" }, + "DynamicLegend": { + "title": "Leyenda", + "description": "Mostrar la leyenda del mapa" + }, "Permalink": { "description": "Permite crear un enlace permanente del recurso actual a la vista", "title": "Enlace permanente" }, + "LayersSelection": { + "title": "Seleccionar", + "description": "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..629745b12f 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -1497,6 +1497,12 @@ "height": "la taille" } }, + "dynamiclegend": { + "title": "Légende", + "tooltip": "Affiche la légende de la carte", + "emptyLegend": "Aucune couche à afficher", + "noLayersVisibleInExtent": "Aucune couche visible dans l'étendue actuelle." + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Permalien généré", @@ -1529,6 +1535,43 @@ } } }, + "layersSelection": { + "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", @@ -3428,10 +3471,18 @@ "description": "Permet de partager la carte de diverses manières (lien, QR-Code, embarqué, réseaux sociaux ...)", "title": "Outil de partage" }, + "DynamicLegend": { + "title": "Légende", + "description": "Affiche la légende de la carte" + }, "Permalink": { "description": "Permet de créer un permalien de la ressource courante en vue", "title": "Lien permanent" }, + "LayersSelection": { + "title": "Sélectionner", + "description": "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..048b221784 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -1496,6 +1496,12 @@ "height": "altezza" } }, + "dynamiclegend": { + "title": "Legenda", + "tooltip": "Visualizza la legenda della mappa", + "emptyLegend": "Nessun livello da visualizzare", + "noLayersVisibleInExtent": "Nessuno strato visibile nell'area attuale" + }, "permalink": { "title": "Permalink", "shareLinkTitle": "Permalink generato", @@ -1528,6 +1534,43 @@ } } }, + "layersSelection": { + "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", @@ -3429,10 +3472,18 @@ "description": "Permette di condividere la mappa in diversi modi (link, QR-Code, embed, social network ...)", "title": "Strumento di condivisione" }, + "DynamicLegend": { + "title": "Legenda", + "description": "Visualizza la legenda della mappa" + }, "Permalink": { "description": "Permette di creare un permalink della risorsa attualmente in vista", "title": "Permalink" }, + "LayersSelection": { + "title": "Selezionare", + "description": "Mostra lo strumento di selezione" + }, "StreetView": { "title": "Street View", "description": "Strumento Street view, per visualizzare le immagini di Google Street View dalla mappa" 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 ? ", " : ""; diff --git a/web/client/utils/LegendUtils.js b/web/client/utils/LegendUtils.js index cd9a0bb8d6..bc33ab8f5a 100644 --- a/web/client/utils/LegendUtils.js +++ b/web/client/utils/LegendUtils.js @@ -121,4 +121,3 @@ export default { getWMSLegendConfig, updateLayerWithLegendFilters }; -