diff --git a/web/client/components/map/cesium/Layer.jsx b/web/client/components/map/cesium/Layer.jsx index c6ae4bfcb4e..1f2b21c1efc 100644 --- a/web/client/components/map/cesium/Layer.jsx +++ b/web/client/components/map/cesium/Layer.jsx @@ -9,7 +9,7 @@ import React from 'react'; import Layers from '../../../utils/cesium/Layers'; import PropTypes from 'prop-types'; -import { round, isNil, castArray, isFunction } from 'lodash'; +import { round, isNil, castArray, isFunction, isNumber } from 'lodash'; import { getResolutions } from '../../../utils/MapUtils'; import axios from '../../../libs/ajax'; import { getProxyCacheByUrl } from '../../../utils/ProxyUtils'; @@ -39,6 +39,7 @@ class CesiumLayer extends React.Component { // in particular for detached layers (eg. Vector, WFS, 3D Tiles, ...) const visibility = this.getVisibilityOption(this.props); this._isMounted = true; + this.autorefreshTick = -1; this.createLayer(this.props.type, { ...this.props.options, visibility }, this.props.position, this.props.map, this.props.securityToken); if (this.props.options && this.layer && visibility) { this.addLayer(this.props); @@ -82,6 +83,10 @@ class CesiumLayer extends React.Component { } } this.updateLayer(newProps, this.props); + + if (newProps.autorefreshTicks) { + this.tryAutorefresh(newProps); + } } componentWillUnmount() { @@ -473,6 +478,17 @@ class CesiumLayer extends React.Component { } this.props.map.scene.requestRender(); }; + + tryAutorefresh = (newProps) => { + const layerId = newProps.options.id; + const autorefreshTicks = newProps.autorefreshTicks; + const visible = this.getVisibilityOption(newProps); + + if (visible && isNumber(autorefreshTicks[layerId]) && this.autorefreshTick < autorefreshTicks[layerId]) { + this.autorefreshTick = autorefreshTicks[layerId]; + Layers.refreshLayer(this.props.type, this.layer); + } + }; } export default CesiumLayer; diff --git a/web/client/components/map/leaflet/Layer.jsx b/web/client/components/map/leaflet/Layer.jsx index 6efc12c025c..9d967feaf40 100644 --- a/web/client/components/map/leaflet/Layer.jsx +++ b/web/client/components/map/leaflet/Layer.jsx @@ -10,6 +10,7 @@ import PropTypes from 'prop-types'; import Layers from '../../../utils/leaflet/Layers'; import isEqual from 'lodash/isEqual'; import isNil from 'lodash/isNil'; +import { isNumber } from 'lodash'; class LeafletLayer extends React.Component { static propTypes = { map: PropTypes.object, @@ -32,6 +33,7 @@ class LeafletLayer extends React.Component { componentDidMount() { this.valid = true; + this.autorefreshTick = -1; this.createLayer( this.props.type, this.props.options, @@ -55,6 +57,10 @@ class LeafletLayer extends React.Component { this.updateZIndex(newProps.position); } this.updateLayer(newProps, this.props); + + if (newProps.autorefreshTicks) { + this.tryAutorefresh(newProps); + } } shouldComponentUpdate(newProps) { @@ -221,6 +227,17 @@ class LeafletLayer extends React.Component { } return false; }; + + tryAutorefresh = (newProps) => { + const layerId = newProps.options.id; + const autorefreshTicks = newProps.autorefreshTicks; + const visible = this.getVisibilityOption(newProps); + + if (visible && isNumber(autorefreshTicks[layerId]) && this.autorefreshTick < autorefreshTicks[layerId]) { + this.autorefreshTick = autorefreshTicks[layerId]; + Layers.refreshLayer(this.props.type, this.layer); + } + }; } export default LeafletLayer; diff --git a/web/client/components/map/openlayers/Layer.jsx b/web/client/components/map/openlayers/Layer.jsx index c25ad48a511..242bebe8a05 100644 --- a/web/client/components/map/openlayers/Layer.jsx +++ b/web/client/components/map/openlayers/Layer.jsx @@ -46,12 +46,14 @@ export default class OpenlayersLayer extends React.Component { onCreationError: () => {}, onWarning: () => {}, srs: "EPSG:3857" + }; componentDidMount() { this.valid = true; this.tilestoload = 0; this.imagestoload = 0; + this.autorefreshTick = -1; this.createLayer( this.props.type, this.props.options, @@ -75,6 +77,9 @@ export default class OpenlayersLayer extends React.Component { if (this.props.options) { this.updateLayer(newProps, this.props); } + if (newProps.autorefreshTicks) { + this.tryAutorefresh(newProps.options.id, newProps.autorefreshTicks); + } } componentWillUnmount() { @@ -93,9 +98,7 @@ export default class OpenlayersLayer extends React.Component { this.props.map.removeLayer(this.layer); } } - if (this.refreshTimer) { - clearInterval(this.refreshTimer); - } + Layers.removeLayer(this.props.type, this.props.options, this.props.map, this.props.mapId, this.layer); } @@ -344,13 +347,6 @@ export default class OpenlayersLayer extends React.Component { this.props.onLayerLoad(options.id, {error: true}); } }); - - if (options.refresh) { - let counter = 0; - this.refreshTimer = setInterval(() => { - this.layer.getSource().updateParams(Object.assign({}, options.params, {_refreshCounter: counter++})); - }, options.refresh); - } } }; @@ -359,4 +355,11 @@ export default class OpenlayersLayer extends React.Component { this.valid = valid; return valid; }; + + tryAutorefresh = (layerId, autorefreshTicks) => { + if (this.layer?.getVisible() && isNumber(autorefreshTicks[layerId]) && this.autorefreshTick < autorefreshTicks[layerId]) { + this.autorefreshTick = autorefreshTicks[layerId]; + Layers.refreshLayer(this.props.type, this.layer); + } + } } diff --git a/web/client/components/map/openlayers/plugins/ArcGISFeatureLayer.js b/web/client/components/map/openlayers/plugins/ArcGISFeatureLayer.js index 98df9c737a0..5fe3f068fa3 100644 --- a/web/client/components/map/openlayers/plugins/ArcGISFeatureLayer.js +++ b/web/client/components/map/openlayers/plugins/ArcGISFeatureLayer.js @@ -137,6 +137,12 @@ Layers.registerType('arcgis-feature', { layer.setMaxResolution(options.maxResolution === undefined ? Infinity : options.maxResolution); } }, + refresh: (layer) => { + const source = layer.getSource(); + if (source) { + source.refresh(); + } + }, render: () => { return null; } diff --git a/web/client/components/map/openlayers/plugins/ArcGISLayer.js b/web/client/components/map/openlayers/plugins/ArcGISLayer.js index 0fcb35f2644..de320b8f177 100644 --- a/web/client/components/map/openlayers/plugins/ArcGISLayer.js +++ b/web/client/components/map/openlayers/plugins/ArcGISLayer.js @@ -60,6 +60,16 @@ registerType('arcgis', { layer.getSource().setTileLoadFunction(tileLoadFunction(newOptions)); } }, + refresh: (layer) => { + const source = layer.getSource(); + if (source) { + source.updateParams( + Object.assign({}, source.getParams(), { + _refreshCounter: Date.now() + }) + ); + } + }, render: () => { return null; } diff --git a/web/client/components/map/openlayers/plugins/FlatGeobufLayer.js b/web/client/components/map/openlayers/plugins/FlatGeobufLayer.js index 4760eb24fec..6f7da2fb5f4 100644 --- a/web/client/components/map/openlayers/plugins/FlatGeobufLayer.js +++ b/web/client/components/map/openlayers/plugins/FlatGeobufLayer.js @@ -241,5 +241,11 @@ Layers.registerType(FGB_LAYER_TYPE, { } return null; + }, + refresh: (layer) => { + const source = layer.getSource(); + if (source) { + source.refresh(); + } } }); diff --git a/web/client/components/map/openlayers/plugins/TMSLayer.js b/web/client/components/map/openlayers/plugins/TMSLayer.js index 5dcfe493b73..81f685b6771 100644 --- a/web/client/components/map/openlayers/plugins/TMSLayer.js +++ b/web/client/components/map/openlayers/plugins/TMSLayer.js @@ -89,6 +89,16 @@ Layers.registerType('tms', { || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { layer.getSource().setTileLoadFunction(tileLoadFunction(newOptions)); } + }, + refresh: (layer) => { + const source = layer.getSource(); + if (source) { + // Looks like the cache of the browser is not cleared with source.clear() or source.refresh(). + // So, if the map is static, the same tiles are requested, + // hence the browser doesn't request new tiles to the server (no https request made). + // if you disable caching in the browser's dev tools, the new tiles are requested to the server. + source.refresh(); + } } }); diff --git a/web/client/components/map/openlayers/plugins/TileProviderLayer.js b/web/client/components/map/openlayers/plugins/TileProviderLayer.js index e83d0fdfeb4..2798b3163a0 100644 --- a/web/client/components/map/openlayers/plugins/TileProviderLayer.js +++ b/web/client/components/map/openlayers/plugins/TileProviderLayer.js @@ -118,6 +118,16 @@ Layers.registerType('tileprovider', { || !isEqual(oldOptions.requestRuleRefreshHash, newOptions.requestRuleRefreshHash)) { layer.getSource().setTileLoadFunction(tileLoadFunction(newOptions)); } + }, + refresh: (layer) => { + const source = layer.getSource(); + if (source) { + // Looks like the cache of the browser is not cleared with source.clear() or source.refresh(). + // So, if the map is static, the same tiles are requested, + // hence the browser doesn't request new tiles to the server (no https request made). + // if you disable caching in the browser's dev tools, the new tiles are requested to the server. + source.refresh(); + } } }); diff --git a/web/client/components/map/openlayers/plugins/WFSLayer.js b/web/client/components/map/openlayers/plugins/WFSLayer.js index af2fd406356..9c8496c6543 100644 --- a/web/client/components/map/openlayers/plugins/WFSLayer.js +++ b/web/client/components/map/openlayers/plugins/WFSLayer.js @@ -190,6 +190,12 @@ Layers.registerType('wfs', { layer.setMaxResolution(options.maxResolution === undefined ? Infinity : options.maxResolution); } }, + refresh: (layer) => { + const source = layer.getSource(); + if (source) { + source.refresh(); + } + }, render: () => { return null; } diff --git a/web/client/components/map/openlayers/plugins/WMSLayer.js b/web/client/components/map/openlayers/plugins/WMSLayer.js index d44ea44aa37..d0efbe7526b 100644 --- a/web/client/components/map/openlayers/plugins/WMSLayer.js +++ b/web/client/components/map/openlayers/plugins/WMSLayer.js @@ -305,5 +305,26 @@ Layers.registerType('wms', { } } return null; + }, + refresh: (layer) => { + // *source.refresh() doesn't trigger an HTTPS request to reload tiles + // *source.updateParams() with a dummy parameter forces the source to reload tiles + const wmsSource = layer.get('wmsSource'); + if (wmsSource) { + wmsSource.updateParams( + Object.assign({}, wmsSource.getParams(), { + _refreshCounter: Date.now() + }) + ); + } + + const vectorSource = layer.getSource(); + if (vectorSource) { + vectorSource.updateParams( + Object.assign({}, vectorSource.getParams(), { + _refreshCounter: Date.now() + }) + ); + } } }); diff --git a/web/client/components/map/openlayers/plugins/WMTSLayer.js b/web/client/components/map/openlayers/plugins/WMTSLayer.js index 48731414875..8ce0a0364ab 100644 --- a/web/client/components/map/openlayers/plugins/WMTSLayer.js +++ b/web/client/components/map/openlayers/plugins/WMTSLayer.js @@ -202,6 +202,17 @@ const updateLayer = (layer, newOptions, oldOptions) => { return null; }; +const refreshLayer = (layer) => { + const source = layer.getSource(); + if (source) { + // Looks like the cache of the browser is not cleared with source.clear() or source.refresh(). + // So, if the map is static, the same tiles are requested, + // hence the browser doesn't request new tiles to the server (no https request made). + // if you disable caching in the browser's dev tools, the new tiles are requested to the server. + source.refresh(); + } +}; + const hasSRS = (srs, layer) => { const { tileMatrixSetName, tileMatrixSet } = WMTSUtils.getTileMatrix(layer, srs); if (tileMatrixSet) { @@ -214,4 +225,4 @@ const compatibleLayer = layer => head(CoordinatesUtils.getEquivalentSRS(layer.srs || 'EPSG:3857').filter(srs => hasSRS(srs, layer))) ? true : false; -Layers.registerType('wmts', { create: createLayer, update: updateLayer, isCompatible: compatibleLayer }); +Layers.registerType('wmts', { create: createLayer, update: updateLayer, refresh: refreshLayer, isCompatible: compatibleLayer }); diff --git a/web/client/components/widgets/enhancers/dependenciesToAutorefresh.js b/web/client/components/widgets/enhancers/dependenciesToAutorefresh.js new file mode 100644 index 00000000000..76de56c1642 --- /dev/null +++ b/web/client/components/widgets/enhancers/dependenciesToAutorefresh.js @@ -0,0 +1,55 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { compose, withProps } from 'recompose'; +import Rx from 'rxjs'; +import { isNumber } from 'lodash'; + +import propsStreamFactory from '../../misc/enhancers/propsStreamFactory'; + +let currentTicks = {}; + +/** + * Set the property `shouldAutorefresh` to true if the widget should autorefresh based on the autorefreshTicks and layer.id. + * @param {object} props the widget props + * @param {{[prop: string]: number}} props.autorefreshTicks an object with the layer.id as key and the ticks as value + * @param {object} props.layer the layer object where to perform the requests, used to get the layer.id to check against the autorefreshTicks + * @returns {Rx.Observable<{shouldAutorefresh: boolean}>} an observable that emits an object with the property shouldAutorefresh (boolean) + * @example + * // in a widget component + * const MyWidget = ({shouldAutorefresh}) => { + * useEffect(() => { + * if (shouldAutorefresh) { + * // trigger data fetch + * } + * }, [shouldAutorefresh]); + */ +const dataStreamFactory = ($props) => + $props + .switchMap( + ({autorefreshTicks, layer}) => { + let shouldAutorefresh = false; + + if (layer?.id && Object.keys(autorefreshTicks).length && isNumber(autorefreshTicks[layer.id]) && + currentTicks[layer.id] !== autorefreshTicks[layer.id]) { + currentTicks[layer.id] = autorefreshTicks[layer.id]; + shouldAutorefresh = true; + } + + return Rx.Observable.of({ + shouldAutorefresh + }); + } + ); + +export default compose( + withProps( () => ({ + dataStreamFactory + })), + propsStreamFactory +); diff --git a/web/client/components/widgets/enhancers/wpsCounter.js b/web/client/components/widgets/enhancers/wpsCounter.js index 5857c96de17..2aeb35a38c5 100644 --- a/web/client/components/widgets/enhancers/wpsCounter.js +++ b/web/client/components/widgets/enhancers/wpsCounter.js @@ -23,7 +23,6 @@ const sameOptions = (o1 = {}, o2 = {}) => import { getWpsUrl } from '../../../utils/LayersUtils'; import { checkMapSyncWithWidgetOfMapType } from '../../../utils/WidgetsUtils'; - /** * Stream of props -> props to retrieve data from WPS aggregate process on params changes. * Can be used with widgets and charts to auto-update data on property changes. @@ -32,20 +31,32 @@ import { checkMapSyncWithWidgetOfMapType } from '../../../utils/WidgetsUtils'; */ const dataStreamFactory = ($props) => $props - .filter(({layer = {}, options, dependencies, mapSync, dependenciesMap, widgets}) => { + .filter(({layer = {}, options, dependencies, mapSync, dependenciesMap, widgets, shouldAutorefresh }) => { + const isValid = layer.name && getWpsUrl(layer) && options && options.aggregateFunction && options.aggregationAttribute; + + if (shouldAutorefresh) { + return isValid; + } + // Check if mapSync is enabled (true), dependencyMap has mapSync dependency to Map widget and dependencies.viewport is null or falsy // If this condition is true, return false to filter out the event. // This prevents an extra API call from being triggered when the viewport is not available. if (mapSync && checkMapSyncWithWidgetOfMapType(widgets, dependenciesMap) && !dependencies?.viewport) { return false; } - return layer.name && getWpsUrl(layer) && options && options.aggregateFunction && options.aggregationAttribute; + return isValid; }) .distinctUntilChanged( - ({layer = {}, options = {}, filter}, newProps) => - (newProps.layer && layer.name === newProps.layer.name && layer.loadingError === newProps.layer.loadingError) + ({layer = {}, options = {}, filter }, newProps) => { + if (newProps.shouldAutorefresh) { + return false; + } + + return (newProps.layer && layer.name === newProps.layer.name && layer.loadingError === newProps.layer.loadingError) && sameOptions(options, newProps.options) - && sameFilter(filter, newProps.filter)) + && sameFilter(filter, newProps.filter); + } + ) .switchMap( ({layer = {}, options, filter, onLoad = () => {}, onLoadError = () => {}}) => wpsAggregate( diff --git a/web/client/components/widgets/widget/enhancedWidgets.js b/web/client/components/widgets/widget/enhancedWidgets.js index 4e82e9a7af0..9752853bd56 100644 --- a/web/client/components/widgets/widget/enhancedWidgets.js +++ b/web/client/components/widgets/widget/enhancedWidgets.js @@ -32,6 +32,7 @@ import BaseCounterWidget from './CounterWidget'; import BaseLegendWidget from './LegendWidget'; import BaseFilterWidget from './FilterWidget'; import dependenciesToShapes from '../enhancers/dependenciesToShapes'; +import dependenciesToAutorefresh from '../enhancers/dependenciesToAutorefresh'; // // connect widgets to dependencies, remote services and add base icons/tools @@ -110,6 +111,7 @@ export const CounterWidget = compose( dependenciesToWidget, dependenciesToFilter, dependenciesToOptions, + dependenciesToAutorefresh, wpsCounter, counterWidget )(BaseCounterWidget); diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json index 2da686f10d5..b3e72320bd1 100644 --- a/web/client/configs/pluginsConfig.json +++ b/web/client/configs/pluginsConfig.json @@ -618,6 +618,14 @@ "title": "plugins.Isochrone.title", "description": "plugins.Isochrone.description", "dependencies": ["SidebarMenu"] + }, + { + "name": "AutoRefresh", + "glyph": "refresh", + "title": "plugins.AutoRefresh.title", + "description": "plugins.AutoRefresh.description", + "dependencies": ["MapFooter"], + "defaultConfig": {} } ] } diff --git a/web/client/plugins/AutoRefresh/AutoRefresh.jsx b/web/client/plugins/AutoRefresh/AutoRefresh.jsx new file mode 100644 index 00000000000..64a3100a4f3 --- /dev/null +++ b/web/client/plugins/AutoRefresh/AutoRefresh.jsx @@ -0,0 +1,99 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import usePluginItems from "../../hooks/usePluginItems"; +import AutoRefreshContainer from './containers/AutoRefresh'; +import { createPlugin } from '../../utils/PluginsUtils'; +import { createStructuredSelector } from 'reselect'; +import { layersSelector } from '../../selectors/layers'; +import { userRoleSelector } from '../../selectors/security'; +import { CONTROL_NAME } from './constants'; +import { autorefreshUpdateAvailableLayers, autorefreshStart, autorefreshStop, autorefreshUpdateActiveLayer } from './actions/autorefresh'; +import { updateNode } from '../../actions/layers'; +import { autorefreshArchivedTicksSelector, autorefreshAvailableLayersSelector, autorefreshEnabledSelector, autorefreshLayersSelector } from './selectors/autorefresh'; +import autorefresh from './reducers/autorefresh'; +import { + autorefreshStartEpicCreation, + autorefreshUpdateNodeEpicCreation, + autorefreshRemoveNodeEpicCreation, + autorefreshActiveLayerChangeEpicCreation, + autorefreshMapVisualisationModeChangeEpicCreation +} from './epics/autorefresh'; +import { mapTypeSelector } from '../../selectors/maptype'; + +const AutoRefresh = ({ items, ...props }, context) => { + const { loadedPlugins } = context; + const configuredItems = usePluginItems({ items, loadedPlugins }); + + return ( + + ); +}; + +AutoRefresh.contextTypes = { + loadedPlugins: PropTypes.object +}; + +const autoRefreshConnect = connect( + createStructuredSelector({ + userRoles: userRoleSelector, + mapType: mapTypeSelector, + layers: layersSelector, + + enabled: autorefreshEnabledSelector, + availableLayers: autorefreshAvailableLayersSelector, + activeLayers: autorefreshLayersSelector, + ticks: autorefreshArchivedTicksSelector + }), { + onStart: autorefreshStart, + onStop: autorefreshStop, + onUpdateLayer: autorefreshUpdateActiveLayer, + onUpdateAvailableLayers: autorefreshUpdateAvailableLayers, + onUpdateNode: updateNode + } +); + +const AutoRefreshComponent = autoRefreshConnect(AutoRefresh); + +AutoRefreshComponent.propTypes = { + items: PropTypes.array +}; + +export default createPlugin( + 'AutoRefresh', + { + component: AutoRefreshComponent, + reducers: { + autorefresh + }, + epics: { + autorefreshStartEpicCreation, + autorefreshUpdateNodeEpicCreation, + autorefreshRemoveNodeEpicCreation, + autorefreshActiveLayerChangeEpicCreation, + autorefreshMapVisualisationModeChangeEpicCreation + }, + containers: { + SidebarMenu: {}, + BurgerMenu: {}, + MapFooter: { + name: CONTROL_NAME, + position: 20, + target: 'right-footer', + priority: 1 + } + } + } +); diff --git a/web/client/plugins/AutoRefresh/actions/autorefresh.js b/web/client/plugins/AutoRefresh/actions/autorefresh.js new file mode 100644 index 00000000000..7b89a225692 --- /dev/null +++ b/web/client/plugins/AutoRefresh/actions/autorefresh.js @@ -0,0 +1,65 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +export const AUTOREFRESH_START = 'AUTOREFRESH:START'; +export const AUTOREFRESH_STOP = 'AUTOREFRESH:STOP'; +export const AUTOREFRESH_TICK = 'AUTOREFRESH:TICK'; +export const AUTOREFRESH_UPDATE_ACTIVE_LAYER = 'AUTOREFRESH:UPDATE_ACTIVE_LAYER'; +export const AUTOREFRESH_DELETE_ACTIVE_LAYER = 'AUTOREFRESH:DELETE_ACTIVE_LAYER'; +export const AUTOREFRESH_UPDATE_AVAILABLE_LAYERS = 'AUTOREFRESH:UPDATE_AVAILABLE_LAYERS'; +export const AUTOREFRESH_UPDATE_ACTIVE_LAYERS = 'AUTOREFRESH:UPDATE_ACTIVE_LAYERS'; + + +export const autorefreshStart = () => { + return { + type: AUTOREFRESH_START, + enabled: true + }; +}; + +export const autorefreshTick = (ticks) => { + return { + type: AUTOREFRESH_TICK, + ticks + }; +}; + +export const autorefreshStop = () => { + return { + type: AUTOREFRESH_STOP, + enabled: false + }; +}; + +export const autorefreshUpdateActiveLayer = (layer) => { + return { + type: AUTOREFRESH_UPDATE_ACTIVE_LAYER, + layer + }; +}; + +export const autorefreshDeleteActiveLayer = (layerId) => { + return { + type: AUTOREFRESH_DELETE_ACTIVE_LAYER, + layerId + }; +}; + +export const autorefreshUpdateAvailableLayers = (layers) => { + return { + type: AUTOREFRESH_UPDATE_AVAILABLE_LAYERS, + availableLayers: layers + }; +}; + +export const autorefreshUpdateActiveLayers = (layers) => { + return { + type: AUTOREFRESH_UPDATE_ACTIVE_LAYERS, + activeLayers: layers + }; +}; diff --git a/web/client/plugins/AutoRefresh/components/AutoRefreshForm.jsx b/web/client/plugins/AutoRefresh/components/AutoRefreshForm.jsx new file mode 100644 index 00000000000..18f70cd7ee1 --- /dev/null +++ b/web/client/plugins/AutoRefresh/components/AutoRefreshForm.jsx @@ -0,0 +1,96 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { Glyphicon, ControlLabel, Form, FormGroup, FormControl, InputGroup } from "react-bootstrap"; + +import tooltip from '../../../components/misc/enhancers/tooltip'; +import ButtonRB from '../../../components/misc/Button'; +import Message from '../../../components/I18N/Message'; +import { AUTOREFRESH_STEP_INTERVAL_IN_SECONDS, formatDate } from '../constants'; + +const Button = tooltip(ButtonRB); + +const AutoRefreshForm = ({ + defaultRefreshInterval, + minRefreshInterval, + availableLayers = {}, + activeLayers = {}, + ticks = {}, + handleAddLayer, + handleRemoveLayer, + handleIntervalChange +}) => { + + const onIntervalChange = (event, layer) => { + const { value } = event.target || {}; + const numericValue = Number(value); + + handleIntervalChange(numericValue < minRefreshInterval ? minRefreshInterval : numericValue, layer.id); + }; + + const onAddLayer = (e) => { + if (e.target.value === "none") { + return; + } + + handleAddLayer(e.target.value, defaultRefreshInterval); + e.target.value = "none"; + }; + + + return ( +
+
+ + + + + + + {Object.values(availableLayers).map(l => ())} + + +
+
+ {Object.values(activeLayers).map(layer => { + + return ( +
+ + {layer.title} + + onIntervalChange(e, layer)} + min={minRefreshInterval} + step={AUTOREFRESH_STEP_INTERVAL_IN_SECONDS} + /> + + s + + + {ticks[layer.id] && layer.visibility && { formatDate(ticks[layer.id]) }} +
+ ); + })} +
+
+ ); +}; + +export default AutoRefreshForm; diff --git a/web/client/plugins/AutoRefresh/components/AutoRefreshInformations.jsx b/web/client/plugins/AutoRefresh/components/AutoRefreshInformations.jsx new file mode 100644 index 00000000000..6ffc8ab1ad3 --- /dev/null +++ b/web/client/plugins/AutoRefresh/components/AutoRefreshInformations.jsx @@ -0,0 +1,78 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useCallback } from 'react'; +import { Glyphicon, Dropdown, MenuItem } from 'react-bootstrap'; + +import Message from '../../../components/I18N/Message'; +import tooltip from '../../../components/misc/enhancers/tooltip'; +import ButtonRB from '../../../components/misc/Button'; +import { formatDate } from '../constants'; + +const Button = tooltip(ButtonRB); + +const AutoRefreshInformationsMenu = React.forwardRef((props, ref) => { + return ( +
+ + + {props.children} +
+ ); +}); + + +const AutoRefreshInformations = ({ + layers = [], + ticks = {} +}) => { + const getFullyQualifiedLayerTitle = useCallback((layer) => { + const title = layer.title; + const interval = `(${layer.autorefreshInterval}s)`; + const lastUpdate = ticks[layer.id] && layer.visibility ? formatDate(ticks[layer.id]) : ''; + return `${title} ${interval} ${lastUpdate}`; + }, [ticks]); + + return (
+ + + +
+ {layers.length === 0 && } + {layers.length > 0 && + layers.map(l => (
+ {l.title} + {ticks[l.id] && l.visibility && {formatDate(ticks[l.id])}} + {l.autorefreshInterval}s +
))} +
+
+
+
); +}; + +export default AutoRefreshInformations; diff --git a/web/client/plugins/AutoRefresh/components/AutoRefreshMenu.jsx b/web/client/plugins/AutoRefresh/components/AutoRefreshMenu.jsx new file mode 100644 index 00000000000..44794a6c07f --- /dev/null +++ b/web/client/plugins/AutoRefresh/components/AutoRefreshMenu.jsx @@ -0,0 +1,30 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; + +const AutoRefreshMenu = React.forwardRef((props, ref) => { + return ( +
+ {props.children} +
+ ); +}); + +export default AutoRefreshMenu; diff --git a/web/client/plugins/AutoRefresh/components/AutoRefreshSettings.jsx b/web/client/plugins/AutoRefresh/components/AutoRefreshSettings.jsx new file mode 100644 index 00000000000..028c8648b0f --- /dev/null +++ b/web/client/plugins/AutoRefresh/components/AutoRefreshSettings.jsx @@ -0,0 +1,65 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { Glyphicon, Dropdown } from 'react-bootstrap'; + +import Message from '../../../components/I18N/Message'; +import AutoRefreshMenu from './AutoRefreshMenu'; +import AutoRefreshForm from './AutoRefreshForm'; +import { NodeTypes } from '../../../utils/LayersUtils'; +import { generateAutorefreshLayerOptions } from '../constants'; +import tooltip from '../../../components/misc/enhancers/tooltip'; +import ButtonRB from '../../../components/misc/Button'; + +const Button = tooltip(ButtonRB); + +const AutoRefreshSettings = ({ + defaultRefreshInterval, + minimumRefreshInterval, + availableLayers, + activeLayers, + ticks, + onUpdateNode +}) => { + + const handleIntervalChange = (interval, layerId) => { + onUpdateNode(layerId, NodeTypes.LAYER, generateAutorefreshLayerOptions(interval)); + }; + + const handleAddLayer = (layerId, interval) => { + onUpdateNode(layerId, NodeTypes.LAYER, generateAutorefreshLayerOptions(interval)); + }; + + const handleRemoveLayer = (layerId) =>{ + onUpdateNode(layerId, NodeTypes.LAYER, generateAutorefreshLayerOptions(-1)); + }; + + return ( + + + + + ); +}; + +export default AutoRefreshSettings; diff --git a/web/client/plugins/AutoRefresh/constants.js b/web/client/plugins/AutoRefresh/constants.js new file mode 100644 index 00000000000..2db4cad92f7 --- /dev/null +++ b/web/client/plugins/AutoRefresh/constants.js @@ -0,0 +1,35 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +export const CONTROL_NAME = "autorefresh"; +export const AUTOREFRESH_STEP_INTERVAL_IN_SECONDS = 5; +export const AUTOREFRESH_MINIMUM_REFRESH_INTERVAL = 30000; +export const AUTOREFRESH_DEFAULT_REFRESH_INTERVAL = 60000; + +export const generateAutorefreshLayerOptions = (interval) => ({ + autorefreshInterval: interval +}); + +export const formatDate = (timestamp) => { + if (!timestamp) { + return null; + } + + const date = new Date(timestamp); + + const options = { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone + }; + return `(${date.toLocaleString(navigator.language, options)})`; +}; diff --git a/web/client/plugins/AutoRefresh/containers/AutoRefresh.jsx b/web/client/plugins/AutoRefresh/containers/AutoRefresh.jsx new file mode 100644 index 00000000000..a4ad6ad773c --- /dev/null +++ b/web/client/plugins/AutoRefresh/containers/AutoRefresh.jsx @@ -0,0 +1,102 @@ +/* + * 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, { useEffect, useState } from 'react'; +import {Checkbox } from "react-bootstrap"; + +import { + hasAutoRefreshCapability +} from '../../../utils/LayersUtils'; +import Message from '../../../components/I18N/Message'; +import { + AUTOREFRESH_DEFAULT_REFRESH_INTERVAL, + AUTOREFRESH_MINIMUM_REFRESH_INTERVAL +} from '../constants'; +import AutoRefreshInformations from '../components/AutoRefreshInformations'; +import AutoRefreshSettings from '../components/AutoRefreshSettings'; + +// Do not consider background layers, since they are not expected to be updated frequently +// and they are not visible in the layer switcher, +// so they cannot be selected by the user in the settings +const LAYER_GROUPS_TO_IGNORE = ['background']; +const AUTHORIZED_ACCESS_ROLES = ['ADMIN']; + +/** + * AutoRefresh container component. It manages the state and the logic of the AutoRefresh plugin. + * @param {object} props - The props of the container + * @param {number} props.defaultRefreshInterval - The default refresh interval in milliseconds + * @param {number} props.minimumRefreshInterval - The minimum refresh interval in milliseconds + */ +const AutoRefreshContainer = ({ + // Configured by the user when added to a Context + defaultRefreshInterval = AUTOREFRESH_DEFAULT_REFRESH_INTERVAL, + minimumRefreshInterval = AUTOREFRESH_MINIMUM_REFRESH_INTERVAL, + + // Common store + userRoles, + mapType, + layers, + + // Common actions + onUpdateNode, + + // AutoRefresh store + enabled, + availableLayers, + activeLayers, + ticks, + + // AutoRefresh actions + onStart, + onStop, + onUpdateAvailableLayers +}) => { + const [lastUpdatedText] = useState(null); + + const handleAutorefreshActivated = (event) => { + const { checked } = event.target || {}; + if (checked) { + onStart(); + } else { + onStop(); + } + }; + + useEffect(() => { + const availables = layers.filter(l => !LAYER_GROUPS_TO_IGNORE.includes(l.group) && hasAutoRefreshCapability(l.type, mapType)); + + onUpdateAvailableLayers(availables.reduce((acc, layer) => { + acc[layer.id] = layer; + return acc; + }, {})); + }, [layers]); + + + return (
+ + + + + {/* Non-admin users see the layers information's panel, + Admin users see the settings panel + */} + {AUTHORIZED_ACCESS_ROLES.includes(userRoles) && } + {!AUTHORIZED_ACCESS_ROLES.includes(userRoles) && } +
); +}; + +export default AutoRefreshContainer; diff --git a/web/client/plugins/AutoRefresh/epics/autorefresh.js b/web/client/plugins/AutoRefresh/epics/autorefresh.js new file mode 100644 index 00000000000..ba6557c1e93 --- /dev/null +++ b/web/client/plugins/AutoRefresh/epics/autorefresh.js @@ -0,0 +1,143 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Observable } from 'rxjs'; + +import { AUTOREFRESH_TICK, + AUTOREFRESH_START, + AUTOREFRESH_STOP, + autorefreshUpdateActiveLayer, + autorefreshDeleteActiveLayer, + autorefreshStart, + autorefreshStop, + AUTOREFRESH_UPDATE_ACTIVE_LAYER, + AUTOREFRESH_DELETE_ACTIVE_LAYER, + autorefreshUpdateAvailableLayers, + autorefreshUpdateActiveLayers +} from "../actions/autorefresh"; +import { REMOVE_NODE, UPDATE_NODE } from '../../../actions/layers'; +import { hasAutoRefreshCapability, NodeTypes } from '../../../utils/LayersUtils'; +import { VISUALIZATION_MODE_CHANGED } from '../../../actions/maptype'; +import { layersSelector } from '../../../selectors/layers'; +import { mapTypeSelector } from '../../../selectors/maptype'; + +export const autorefreshStartEpicCreation = (action$, store) => action$ + .ofType(AUTOREFRESH_START) + .switchMap(() => { + const ticks = {}; + const activeLayers = store.getState()?.autorefresh.activeLayers || {}; + Object.values(activeLayers).forEach(layer => { + const interval = layer.autorefreshInterval || -1; + if (interval > 0) { + ticks[interval] = { + ...(ticks[interval] || {}), + [layer.id]: Date.now() + }; + } + }); + + return Observable.from( + Object.keys(ticks).map(interval => + Observable.interval(interval * 1000) + .map(() => ({ + type: AUTOREFRESH_TICK, + ticks: Object.keys(ticks[interval]).reduce((acc, layerId) => { + acc[layerId] = Date.now(); + return acc; + }, {}) + })) + .takeUntil(action$.ofType(AUTOREFRESH_STOP)) + ) + ).mergeAll(); + }); + +export const autorefreshActiveLayerChangeEpicCreation = (action$, store) => action$ + .ofType(AUTOREFRESH_UPDATE_ACTIVE_LAYER, AUTOREFRESH_DELETE_ACTIVE_LAYER) + .debounceTime(200) + .switchMap(() => store.getState().autorefresh.enabled ? Observable.of( + autorefreshStop(), + autorefreshStart() + ) : Observable.of(autorefreshStop())); + +/** + * Follow update on nodes (layers) to update the AutoRefresh plugin accordingly + * (add/update layer on AutoRefresh) + * @param {*} action$ + * @param {*} store + * @returns + */ +export const autorefreshUpdateNodeEpicCreation = (action$, store) => action$ + .ofType(UPDATE_NODE) + .filter(nodeConfig => nodeConfig.nodeType === NodeTypes.LAYER) + .filter(nodeConfig => { + const activeLayers = store.getState()?.autorefresh.activeLayers || {}; + const isActiveLayer = activeLayers[nodeConfig.node] !== undefined; + const isAutorefreshIntervalChange = 'autorefreshInterval' in nodeConfig.options; + + return isActiveLayer && isAutorefreshIntervalChange; + }) + .switchMap((nodeConfig) => { + const autorefreshInterval = 'autorefreshInterval' in nodeConfig.options ? + nodeConfig.options.autorefreshInterval : + layersSelector(store.getState()) + .find(l => l.id === nodeConfig.node)?.autorefreshInterval || -1; + + return Observable.of( + autorefreshUpdateActiveLayer({ + id: nodeConfig.node, + autorefreshInterval + }) + ); + }); + +/** + * Follow removal of nodes (layers) to update the AutoRefresh plugin accordingly + * (remove layer on AutoRefresh) + * @param {*} action$ + * @returns + */ +export const autorefreshRemoveNodeEpicCreation = (action$) => action$ + .ofType(REMOVE_NODE) + .filter(nodeConfig => nodeConfig.nodeType === NodeTypes.LAYER) + .switchMap((nodeConfig) => { + return Observable.of( + autorefreshDeleteActiveLayer(nodeConfig.node) + ); + }); + +/** + * Follow changes in the map visualization mode (2D/3D) to update the AutoRefresh plugin accordingly + * (update available and active layers on AutoRefresh) + * @param {*} action$ + * @param {*} store + * @returns + */ +export const autorefreshMapVisualisationModeChangeEpicCreation = (action$, store) => action$ + .ofType(VISUALIZATION_MODE_CHANGED) + .switchMap(() => { + const layers = layersSelector(store.getState()); + const mapType = mapTypeSelector(store.getState()); + + const availableLayers = layers.filter(l => hasAutoRefreshCapability(l.type, mapType)); + const activeLayers = availableLayers.filter(l => l.autorefreshInterval > 0).reduce((acc, layer) => { + acc[layer.id] = { + id: layer.id, + autorefreshInterval: layer.autorefreshInterval + }; + return acc; + }, {}); + + return Observable.of( + autorefreshStop(), + autorefreshUpdateActiveLayers(activeLayers), + autorefreshUpdateAvailableLayers(availableLayers.reduce((acc, layer) => { + acc[layer.id] = layer; + return acc; + }, {})) + ); + }); diff --git a/web/client/plugins/AutoRefresh/index.js b/web/client/plugins/AutoRefresh/index.js new file mode 100644 index 00000000000..6af648436d4 --- /dev/null +++ b/web/client/plugins/AutoRefresh/index.js @@ -0,0 +1,11 @@ +/* + * 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 AutoRefresh from './AutoRefresh'; + +export default AutoRefresh; diff --git a/web/client/plugins/AutoRefresh/reducers/autorefresh.js b/web/client/plugins/AutoRefresh/reducers/autorefresh.js new file mode 100644 index 00000000000..2f8cc7e9e1c --- /dev/null +++ b/web/client/plugins/AutoRefresh/reducers/autorefresh.js @@ -0,0 +1,118 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + AUTOREFRESH_TICK, + AUTOREFRESH_START, + AUTOREFRESH_STOP, + AUTOREFRESH_UPDATE_ACTIVE_LAYER, + AUTOREFRESH_UPDATE_AVAILABLE_LAYERS, + AUTOREFRESH_DELETE_ACTIVE_LAYER, + AUTOREFRESH_UPDATE_ACTIVE_LAYERS} from "../actions/autorefresh"; + + +const defaultState = { + enabled: false, + availableLayers: {}, + activeLayers: {}, + ticks: {}, + archivedTicks: {} +}; + +const autorefresh = (state = {...defaultState}, action) => { + const activeLayers = {...state.activeLayers}; + + switch (action.type) { + case AUTOREFRESH_START: + return { + ...state, + enabled: true, + ticks: {} + }; + case AUTOREFRESH_STOP: + return { + ...state, + enabled: false, + archivedTicks: { + ...state.archivedTicks, + ...state.ticks + } + }; + case AUTOREFRESH_TICK: + return { + ...state, + ticks: action.ticks, + archivedTicks: { + ...state.archivedTicks, + ...action.ticks + } + }; + case AUTOREFRESH_UPDATE_ACTIVE_LAYERS: + return { + ...state, + activeLayers: action.activeLayers, + enabled: Object.keys(action.activeLayers).length > 0 && state.enabled ? state.enabled : false + }; + case AUTOREFRESH_UPDATE_AVAILABLE_LAYERS: + const availableLayers = { + ...state.availableLayers, + ...action.availableLayers + }; + const actives = { + ...state.activeLayers + }; + + Object.keys(availableLayers).forEach(layerId => { + if (availableLayers[layerId].autorefreshInterval > -1) { + actives[layerId] = availableLayers[layerId]; + } + if (actives[layerId]) { + delete availableLayers[layerId]; + } + }); + + return { + ...state, + availableLayers: availableLayers, + activeLayers: actives + }; + case AUTOREFRESH_UPDATE_ACTIVE_LAYER: + if (action.layer.autorefreshInterval === -1) { + delete activeLayers[action.layer.id]; + } else { + activeLayers[action.layer.id] = { + ...activeLayers[action.layer.id], + ...action.layer + }; + } + + return { + ...state, + activeLayers, + enabled: Object.keys(activeLayers).length > 0 && state.enabled ? state.enabled : false + }; + case AUTOREFRESH_DELETE_ACTIVE_LAYER: + const al = { + ...state.availableLayers + }; + + delete activeLayers[action.layerId]; + delete al[action.layerId]; + + return { + ...state, + activeLayers, + availableLayers: al, + enabled: Object.keys(activeLayers).length > 0 && state.enabled ? state.enabled : false + }; + default: + return state; + } +}; + +export default autorefresh; diff --git a/web/client/plugins/AutoRefresh/selectors/autorefresh.js b/web/client/plugins/AutoRefresh/selectors/autorefresh.js new file mode 100644 index 00000000000..ddbcf272cac --- /dev/null +++ b/web/client/plugins/AutoRefresh/selectors/autorefresh.js @@ -0,0 +1,16 @@ +import get from "lodash/get"; +import { CONTROL_NAME } from "../constants"; +import { createControlEnabledSelector } from "../../../selectors/controls"; + +export const enabledSelector = createControlEnabledSelector(CONTROL_NAME); + +export const autorefreshEnabledSelector = (state) => get(state, `${CONTROL_NAME}.enabled`, false); + +export const autorefreshLayersSelector = (state) => get(state, `${CONTROL_NAME}.activeLayers`, {}); + +export const autorefreshTicksSelector = (state) => get(state, `${CONTROL_NAME}.ticks`, {}); + +export const autorefreshArchivedTicksSelector = (state) => get(state, `${CONTROL_NAME}.archivedTicks`, {}); + +export const autorefreshAvailableLayersSelector = (state) => get(state, `${CONTROL_NAME}.availableLayers`, {}); + diff --git a/web/client/plugins/Widgets.jsx b/web/client/plugins/Widgets.jsx index 4a7da5815d9..9fc11d59700 100644 --- a/web/client/plugins/Widgets.jsx +++ b/web/client/plugins/Widgets.jsx @@ -43,6 +43,7 @@ const RIGHT_MARGIN = 55; import WidgetsViewBase from '../components/widgets/view/WidgetsView'; import {mapLayoutValuesSelector} from "../selectors/maplayout"; +import { autorefreshTicksSelector } from './AutoRefresh/selectors/autorefresh'; const WidgetsView = compose( @@ -57,7 +58,8 @@ compose( state => state.browser && state.browser.mobile, getFloatingWidgets, getTblWidgetZoomLoader, - (id, widgets, layouts, maximized, dependencies, mapLayout, isMobileAgent, dropdownWidgets, recordZoomLoading) => ({ + autorefreshTicksSelector, + (id, widgets, layouts, maximized, dependencies, mapLayout, isMobileAgent, dropdownWidgets, recordZoomLoading, autorefreshTicks) => ({ id, widgets, layouts, @@ -66,7 +68,8 @@ compose( mapLayout, isMobileAgent, dropdownWidgets, - recordZoomLoading + recordZoomLoading, + autorefreshTicks }) ), { editWidget, diff --git a/web/client/plugins/map/index.js b/web/client/plugins/map/index.js index 34bc61a911b..5813cc5fa11 100644 --- a/web/client/plugins/map/index.js +++ b/web/client/plugins/map/index.js @@ -35,6 +35,7 @@ import { snappingLayerSelector } from "../../selectors/draw"; import { mapPopupsSelector } from '../../selectors/mapPopups'; +import { autorefreshTicksSelector } from '../AutoRefresh/selectors/autorefresh'; const Empty = () => { return ; }; @@ -99,7 +100,9 @@ const pluginsCreator = (mapType, actions) => { changeSelectionState })(components.SelectionSupport || Empty); - const LLayer = connect(null, {onWarning: warning})( components.Layer || Empty); + const LLayer = connect((state) => ({ + autorefreshTicks: autorefreshTicksSelector(state) + }), {onWarning: warning})( components.Layer || Empty); const PopupSupport = connect( createSelector( diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index 2dda3344e89..9ba36ec8cdc 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -150,7 +150,8 @@ export const plugins = { ZoomOutPlugin: toModulePlugin('ZoomOut', () => import(/* webpackChunkName: 'plugins/zoomOut' */ '../plugins/ZoomOut')), AddWidgetDashboardPlugin: toModulePlugin('AddWidgetDashboard', () => import(/* webpackChunkName: 'plugins/AddWidgetDashboard' */ '../plugins/AddWidgetDashboard')), MapConnectionDashboardPlugin: toModulePlugin('MapConnectionDashboard', () => import(/* webpackChunkName: 'plugins/MapConnectionDashboard' */ '../plugins/MapConnectionDashboard')), - IPManagerPlugin: toModulePlugin('IPManager', () => import(/* webpackChunkName: 'plugins/IPManager' */ '../plugins/ResourcesCatalog/IPManager')) + IPManagerPlugin: toModulePlugin('IPManager', () => import(/* webpackChunkName: 'plugins/IPManager' */ '../plugins/ResourcesCatalog/IPManager')), + AutoRefreshPlugin: toModulePlugin('AutoRefresh', () => import(/* webpackChunkName: 'plugins/AutoRefresh' */ '../plugins/AutoRefresh')) }; const pluginsDefinition = { diff --git a/web/client/themes/default/less/autorefresh.less b/web/client/themes/default/less/autorefresh.less new file mode 100644 index 00000000000..02c45322256 --- /dev/null +++ b/web/client/themes/default/less/autorefresh.less @@ -0,0 +1,176 @@ +// ************** +// Theme +// ************** +#ms-components-theme(@theme-vars) { + .ms-autorefresh-divider { + .border-color-var(@theme-vars[main-border-color]); + } +} + +// ************** +// Layout +// ************** +.ms-autorefresh-wrapper { + display: flex; + gap: 0.5ch; + align-items: center; + height: 24px; + border-width: 1px; + border-style: solid; + padding: 2px; + border-radius: 4px; + border-color: var(--ms-main-border-color); + background-color: var(--ms-main-bg); +} + +.ms-autorefresh-button { + display: flex; + align-items: center; + justify-content: center; + height: 20px; + width: 20px; + background-color: transparent; + border: none; + box-shadow: none; + text-decoration: none; + color: var(--ms-main-color); + + &:hover, + &:focus, + &:active, + &:active:focus { + background-color: var(--ms-main-hover-bg); + box-shadow: none; + outline: none; + border: none; + color: inherit; + text-decoration: none; + } +} + +.ms-autorefresh-informations { + display: flex; + align-items: center; +} + +.ms-autorefresh-form-container{ + min-width: 300px; + overflow: hidden; + + .ms-autorefresh-form-group { + display: grid; + align-items: center; + column-gap: 0.5ch; + grid-template-areas: + "button title input" + "button summary input"; + grid-template-columns: auto 1fr 100px; + + &.ms-autorefresh-form-group-hidden { + .ms-autorefresh-form-group__title, + .ms-autorefresh-form-group__input, + .ms-autorefresh-form-group__summary { + opacity: 0.6; + } + } + + &.ms-autorefresh-form-group-inactive { + .ms-autorefresh-form-group__title { + grid-row-end: summary; + } + } + + .ms-autorefresh-form-group__button { + grid-area: button; + } + .ms-autorefresh-form-group__title { + display: flex; + align-items: center; + grid-area: title; + font-size: 12px; + + .glyphicon-eye-close { + margin-right: 0.5ch; + } + } + + .ms-autorefresh-form-group__input { + grid-area: input; + } + .ms-autorefresh-form-group__summary { + grid-area: summary; + font-size: 10px; + } + } + + > form { + display: flex; + flex-direction: column; + gap: 1ch; + } +} + +.ms-autorefresh-layer-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0; + margin: 0; + + span { + vertical-align: sub; + } +} + +.ms-autorefresh-layers-summary { + display: grid; + flex-direction: column; + gap: 0.5ch; + max-height: 50vh; + overflow-y: auto; + + .ms-autorefresh-layer-summary__row{ + display: grid; + grid-template-areas: + "title interval" + "summary interval"; + grid-template-columns: 1fr 70px; + column-gap: 1ch; + align-items: center; + + .glyphicon { + margin-right: 0; + } + + em { + font-size: 10px; + grid-area: summary; + } + + .ms-autorefresh-layer-summary__row__title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + grid-area: title; + } + + .ms-autorefresh-layer-summary__row__interval { + grid-area: interval; + } + } + + .ms-autorefresh-layer-summary__row-hidden { + opacity: 0.6; + } + + .ms-autorefresh-layer-summary__row-inactive { + .ms-autorefresh-layer-summary__row__title { + grid-row-end: summary; + } + } +} + +#ms-autorefresh-selector { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} diff --git a/web/client/themes/default/less/mapstore.less b/web/client/themes/default/less/mapstore.less index ca6cb97062a..4f46962fc2a 100644 --- a/web/client/themes/default/less/mapstore.less +++ b/web/client/themes/default/less/mapstore.less @@ -11,6 +11,7 @@ @import "ag-grid.less"; @import "autocomplete.less"; +@import "autorefresh.less"; @import "cookie.less"; @import "code-editor.less"; @import "colorrangeselector.less"; diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 73f3d64d61c..0169614df80 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -49,6 +49,17 @@ "descriptionAdmin": "Please contact the administrator" }, "autorefresh": { + "selector":"Set layers auto refresh interval", + "label": { + "default":"Activate AutoRefresh" , + "lastUpdated": "Last Refresh", + "addLayer": "Add layer", + "addLayerPlaceholder": "Select...", + "removeLayer": "Remove layer", + "layersSummary": "Layers automatically refreshed", + "noLayers":"None found", + "informations": "Which layers are configured" + }, "of": "of", "updating": "Updating...", "layers": "layers" @@ -3912,6 +3923,10 @@ "Isochrone": { "description": "This plugin allows to calculate isochrones and isodistances", "title": "Isochrone" + }, + "AutoRefresh": { + "description": "This plugin allows to set an auto refresh time interval on layers", + "title": "Auto Refresh" } }, "contextCreator": { diff --git a/web/client/utils/LayersUtils.js b/web/client/utils/LayersUtils.js index 3d7adee06cb..de99bd49a0c 100644 --- a/web/client/utils/LayersUtils.js +++ b/web/client/utils/LayersUtils.js @@ -109,7 +109,6 @@ const isSupportedLayerFunc = (layer, maptype) => { return Layers.isSupported(layer.type) && !layer.invalid; }; - const checkInvalidParam = (layer) => { return layer && layer.invalid ? Object.assign({}, layer, {invalid: false}) : layer; }; @@ -739,7 +738,8 @@ export const saveLayer = (layer) => { legendOptions: layer.legendOptions, tileSize: layer.tileSize, version: layer.version, - expanded: layer.expanded || false + expanded: layer.expanded || false, + autorefreshInterval: layer.autorefreshInterval }, layer?.enableInteractiveLegend !== undefined ? { enableInteractiveLegend: layer?.enableInteractiveLegend } : {}, layer?.enableDynamicLegend !== undefined ? { enableDynamicLegend: layer?.enableDynamicLegend } : {}, @@ -1227,6 +1227,19 @@ export const isBackgroundCompatibleWithProjection = (background, projection) => return valid; }; +/** + * Checks if a layer has auto-refresh capability based on its type and the map type. + * @param {string} layerType - The type of the layer + * @param {string} maptype - The type of the map + * @returns {boolean} True if the layer has auto-refresh capability + */ +export const hasAutoRefreshCapability = (layerType, maptype) => { + const LayersUtil = require('./' + maptype + '/Layers'); + const Layers = LayersUtil.default || LayersUtil; + + return Layers.hasAutoRefreshCapability(layerType); +}; + LayersUtils = { getGroupByName, getLayerId, diff --git a/web/client/utils/cesium/Layers.js b/web/client/utils/cesium/Layers.js index 4a1a7865ead..55244567584 100644 --- a/web/client/utils/cesium/Layers.js +++ b/web/client/utils/cesium/Layers.js @@ -41,6 +41,34 @@ const Layers = { }, isSupported(type) { return !!layerTypes[type]; + }, + /** + * Call the refresh method of the layer implementation located in web/client/components/map/cesium/plugins/[layerType] + * where layerType = WMSLayer, WFSLayer, WMTSLayer, ArcGISLayer, etc + * if implemented, that is used for autorefresh of layers. + * @param {string} type + * @param {object} layer + * @returns {void} + */ + refreshLayer(type, layer) { + var layerCreator = layerTypes[type]; + if (layerCreator && layerCreator.refresh) { + layerCreator.refresh(layer); + } + }, + /** + * Check if the layer implementation located in web/client/components/map/cesium/plugins/[layerType] + * where layerType = WMSLayer, WFSLayer, WMTSLayer, ArcGISLayer, etc + * implements the refresh method, that is used for autorefresh of layers. + * @param {string} type + * @returns {boolean} + */ + hasAutoRefreshCapability(type) { + const layerCreator = layerTypes[type]; + if (layerCreator && layerCreator.refresh) { + return true; + } + return false; } }; diff --git a/web/client/utils/leaflet/Layers.js b/web/client/utils/leaflet/Layers.js index eebf15ed120..d689f5d07eb 100644 --- a/web/client/utils/leaflet/Layers.js +++ b/web/client/utils/leaflet/Layers.js @@ -46,6 +46,34 @@ var Layers = { } return true; }, + /** + * Call the refresh method of the layer implementation located in web/client/components/map/leaflet/plugins/[layerType] + * where layerType = WMSLayer, WFSLayer, WMTSLayer, ArcGISLayer, etc + * if implemented, that is used for autorefresh of layers. + * @param {string} type + * @param {object} layer + * @returns {void} + */ + refreshLayer(type, layer) { + var layerCreator = layerTypes[type]; + if (layerCreator && layerCreator.refresh) { + layerCreator.refresh(layer); + } + }, + /** + * Check if the layer implementation located in web/client/components/map/leaflet/plugins/[layerType] + * where layerType = WMSLayer, WFSLayer, WMTSLayer, ArcGISLayer, etc + * implements the refresh method, that is used for autorefresh of layers. + * @param {string} type + * @returns {boolean} + */ + hasAutoRefreshCapability(type) { + const layerCreator = layerTypes[type]; + if (layerCreator && layerCreator.refresh) { + return true; + } + return false; + }, isSupported(type) { return !!layerTypes[type]; } diff --git a/web/client/utils/openlayers/Layers.js b/web/client/utils/openlayers/Layers.js index e12891b238f..47fa5fa2959 100644 --- a/web/client/utils/openlayers/Layers.js +++ b/web/client/utils/openlayers/Layers.js @@ -82,14 +82,46 @@ export const isCompatible = function(type, options) { return true; }; +/** + * Call the refresh method of the layer implementation located in web/client/components/map/openlayers/plugins/[layerType] + * where layerType = WMSLayer, WFSLayer, WMTSLayer, ArcGISLayer, etc + * if implemented, that is used for autorefresh of layers. + * @param {string} type + * @param {object} layer + * @returns {void} + */ +export const refreshLayer = function(type, layer) { + var layerCreator = layerTypes[type]; + if (layerCreator && layerCreator.refresh) { + layerCreator.refresh(layer); + } +}; + +/** + * Check if the layer implementation located in web/client/components/map/openlayers/plugins/[layerType] + * where layerType = WMSLayer, WFSLayer, WMTSLayer, ArcGISLayer, etc + * implements the refresh method, that is used for autorefresh of layers. + * @param {string} type + * @returns {boolean} + */ +export const hasAutoRefreshCapability = function(type) { + const layerCreator = layerTypes[type]; + if (layerCreator && layerCreator.refresh) { + return true; + } + return false; +}; + export default { registerType, createLayer, + refreshLayer, updateLayer, removeLayer, renderLayer, isValid, isSupported, - isCompatible + isCompatible, + hasAutoRefreshCapability };