From 2dc04fd01bcc31f89d034763d43a9c49e473405d Mon Sep 17 00:00:00 2001 From: Suren Date: Wed, 8 Apr 2026 20:24:08 +0530 Subject: [PATCH 1/2] #12146: ArcGIS support for FeatureService --- docs/developer-guide/maps-configuration.md | 31 ++ docs/user-guide/catalog.md | 8 +- web/client/api/ArcGIS.js | 66 +++- web/client/api/__tests__/ArcGIS-test.js | 85 +++++ web/client/api/catalog/ArcGIS.js | 23 +- .../api/catalog/__tests__/ArcGIS-test.js | 39 +++ web/client/components/map/cesium/Layer.jsx | 78 ++++- web/client/components/map/cesium/Map.jsx | 4 + .../map/cesium/plugins/ArcGISFeatureLayer.js | 296 ++++++++++++++++++ .../components/map/cesium/plugins/WFSLayer.js | 122 +++++--- .../map/cesium/plugins/WMTSLayer.js | 2 +- .../components/map/cesium/plugins/index.js | 1 + .../components/map/openlayers/Layer.jsx | 19 ++ .../map/openlayers/__tests__/Layer-test.jsx | 45 +++ .../openlayers/plugins/ArcGISFeatureLayer.js | 199 ++++++++++++ .../map/openlayers/plugins/WFSLayer.js | 8 +- .../map/openlayers/plugins/index.js | 1 + web/client/epics/__tests__/catalog-test.js | 23 ++ web/client/epics/catalog.js | 9 + .../plugins/TOC/components/DefaultLayer.jsx | 2 +- .../__tests__/DefaultLayer-test.jsx | 19 ++ .../plugins/styleeditor/VectorStyleEditor.jsx | 15 +- .../__tests__/defaultSettingsTabs-test.js | 7 + .../tocitemssettings/defaultSettingsTabs.js | 2 +- web/client/utils/ArcGISUtils.js | 26 ++ web/client/utils/LayersUtils.js | 5 +- web/client/utils/MapInfoUtils.js | 3 +- web/client/utils/StyleEditorUtils.js | 2 +- web/client/utils/StyleUtils.js | 2 +- .../utils/__tests__/ArcGISUtils-test.js | 19 +- .../utils/cesium/TiledBillboardCollection.js | 258 ++++++++++++--- .../TiledBillboardCollection-test.js | 97 +++++- .../utils/mapinfo/__tests__/arcgis-test.js | 51 +++ web/client/utils/mapinfo/arcgis.js | 26 +- 34 files changed, 1482 insertions(+), 111 deletions(-) create mode 100644 web/client/components/map/cesium/plugins/ArcGISFeatureLayer.js create mode 100644 web/client/components/map/openlayers/plugins/ArcGISFeatureLayer.js diff --git a/docs/developer-guide/maps-configuration.md b/docs/developer-guide/maps-configuration.md index 51c7f572b9e..120a8b47340 100644 --- a/docs/developer-guide/maps-configuration.md +++ b/docs/developer-guide/maps-configuration.md @@ -1357,6 +1357,37 @@ Where: } ``` +#### ArcGIS FeatureServer layer + +This layer type allows to render an ArcGIS FeatureServer layer as a vector layer with client-side styling support. + +An ArcGIS FeatureServer provides vector features via the ArcGIS REST API. The layer is identified by the `arcgis-feature` type. Features are fetched in GeoJSON format and support pagination when the service's `maxRecordCount` is exceeded. e.g. + +```json +{ + "type": "arcgis-feature", + "url": "https://arcgis-example/rest/services/MyService/FeatureServer", + "name": "0", + "title": "Title", + "group": "", + "visibility": true +} +``` + +Where: + +- `url` is the URL of the FeatureServer source. +- `name` (optional) the sub-layer id to query. Defaults to `0` if not specified. +- `strategy` (optional) the loading strategy. Possible values are `tile` (default), `bbox`, and `all`. + - `tile`: loads features using a tile grid, best for large datasets. + - `bbox`: loads features based on the current map view extent. + - `all`: loads all features at once. +- `geometryType` (optional) the GeoJSON geometry type (e.g. `Point`, `MultiPoint`, `Polygon`, `MultiPolygon`, `LineString`, `MultiLineString`). When available, it determines the rendering approach in Cesium (billboard for points, primitives for other geometries). +- `maxRecordCount` (optional) the maximum number of features per request page. Used for pagination when the service has a transfer limit. + +!!! note + The `arcgis-feature` layer supports the MapStore Style Editor, allowing users to apply and modify vector styles at runtime. Styles are preserved across feature loads. + #### FlatGeobuf(FGB) layer This type of layer shows vector file in FlatGeobuf format also inside the Cesium viewer. diff --git a/docs/user-guide/catalog.md b/docs/user-guide/catalog.md index 276981970a3..e3dd536a0d1 100644 --- a/docs/user-guide/catalog.md +++ b/docs/user-guide/catalog.md @@ -440,7 +440,7 @@ In **General Settings** of a IFC source type, it is possible to specify the serv ### ArcGIS Catalog -An [**ArcGIS Server Services Directory**](https://developers.arcgis.com/rest/services-reference/enterprise/get-started-with-the-services-directory/) is a RESTful representation of all the services running on an ArcGIS Server site. MapStore allows adding ArcGIS [Map Service](https://developers.arcgis.com/rest/services-reference/enterprise/map-service/) and [Image Service](https://developers.arcgis.com/rest/services-reference/enterprise/image-service/) types through its *Catalog* tool where a specific source type can be configured. +An [**ArcGIS Server Services Directory**](https://developers.arcgis.com/rest/services-reference/enterprise/get-started-with-the-services-directory/) is a RESTful representation of all the services running on an ArcGIS Server site. MapStore allows adding ArcGIS [Map Service](https://developers.arcgis.com/rest/services-reference/enterprise/map-service/), [Image Service](https://developers.arcgis.com/rest/services-reference/enterprise/image-service/) and [Feature Service](https://developers.arcgis.com/rest/services-reference/enterprise/feature-service/) types through its *Catalog* tool where a specific source type can be configured. In **General Settings** of a ArcGIS source type, it is possible to specify the service `Title` and its `URL`. @@ -452,11 +452,13 @@ In **General Settings** of a ArcGIS source type, it is possible to specify the s * `https:///rest/services/` * `https:///rest/services//MapServer` - + * `https:///rest/services//ImageServer` + * `https:///rest/services//FeatureServer` + !!! Note - The tool capabilities currently available for layers come from ArcGIS service are: + The tool capabilities currently available for layers from ArcGIS service are: * *Zoom to selected layer extent* : in order to zoom the map to the layer's extent * Access the [Layer Settings](layer-settings.md#layer-settings) to view/edit the [General Information](layer-settings.md#general-information) and the [Display](layer-settings.md#ifc-layer) options diff --git a/web/client/api/ArcGIS.js b/web/client/api/ArcGIS.js index ff3e994863a..1c1115365c3 100644 --- a/web/client/api/ArcGIS.js +++ b/web/client/api/ArcGIS.js @@ -7,8 +7,10 @@ */ import axios from '../libs/ajax'; +import Proj4js from 'proj4'; import { reprojectBbox } from '../utils/CoordinatesUtils'; import trimEnd from 'lodash/trimEnd'; +import { isFeatureServerUrl, esriGeometryTypeToGeoJSON } from '../utils/ArcGISUtils'; let _cache = {}; @@ -36,6 +38,47 @@ const extentToBoundingBox = (extent) => { return null; }; +const extentToBoundingBox4326 = (extent) => { + if (!extent) return null; + + const wkt = extent?.spatialReference?.wkt; + const wkid = extent?.spatialReference?.latestWkid || extent?.spatialReference?.wkid; + const rawBbox = [extent.xmin, extent.ymin, extent.xmax, extent.ymax]; + + let projectedExtent = null; + + if (wkt) { + // ArcGIS services may use custom/non-EPSG projections defined only by WKT; + // register under a synthetic name so Proj4js can resolve it for reprojection + const projName = 'ESRI_WKT_' + (wkid || 'custom'); + if (!Proj4js.defs(projName)) { + Proj4js.defs(projName, wkt); + } + projectedExtent = reprojectBbox(rawBbox, projName, 'EPSG:4326'); + } else if (wkid && String(wkid) !== '4326') { + try { + projectedExtent = reprojectBbox(rawBbox, `EPSG:${wkid}`, 'EPSG:4326'); + } catch (e) { + projectedExtent = rawBbox; + } + } else { + projectedExtent = rawBbox; + } + + if (projectedExtent) { + return { + bounds: { + minx: projectedExtent[0], + miny: projectedExtent[1], + maxx: projectedExtent[2], + maxy: projectedExtent[3] + }, + crs: 'EPSG:4326' + }; + } + return null; +}; + const getCommonProperties = (data) => { return { version: data?.currentVersion, @@ -104,7 +147,7 @@ const getData = (url, params = {}) => { const { layers, services } = data || {}; if (services) { return searchAndPaginate( - services.filter(service => ['MapServer', 'ImageServer'].includes(service.type)).map((service) => { + services.filter(service => ['MapServer', 'ImageServer', 'FeatureServer'].includes(service.type)).map((service) => { return { url: `${trimEnd(url, '/')}/${service.name}/${service.type}`, version: data.currentVersion, @@ -113,6 +156,27 @@ const getData = (url, params = {}) => { }; }), params); } + + if (isFeatureServerUrl(url)) { + const bbox = extentToBoundingBox4326(data?.initialExtent) || extentToBoundingBox4326(data?.fullExtent); + const queryCapable = (data?.capabilities || '').includes('Query'); + const maxRecordCount = data?.maxRecordCount; + const featureRecords = (layers || []) + .filter(() => queryCapable) + .map((layer) => ({ + ...layer, + url, + version: data?.currentVersion, + queryable: true, + geometryType: layer.geometryType + ? esriGeometryTypeToGeoJSON(layer.geometryType) + : undefined, + ...(maxRecordCount && { maxRecordCount }), + bbox + })); + return searchAndPaginate(featureRecords, params); + } + // Map is similar to WMS GetMap capability for MapServer const mapExportSupported = (data?.capabilities || '').includes('Map') || (data?.capabilities || '').includes('Image'); const commonProperties = { diff --git a/web/client/api/__tests__/ArcGIS-test.js b/web/client/api/__tests__/ArcGIS-test.js index 5f1fb0a307b..715f5640c8d 100644 --- a/web/client/api/__tests__/ArcGIS-test.js +++ b/web/client/api/__tests__/ArcGIS-test.js @@ -185,5 +185,90 @@ describe('Test ArcGIS API', () => { }) .catch(done); }); + it('should include FeatureServer in services list', (done) => { + mockAxios.onGet().reply(() => [200, { + currentVersion: 10.91, + services: [ + { name: 'Features', type: 'FeatureServer' }, + { name: 'Map', type: 'MapServer' } + ] + }]); + getCapabilities('/arcgis/rest/services-fs/', 1, 30, '') + .then((data) => { + expect(data.numberOfRecordsMatched).toBe(2); + expect(data.records.find(r => r.description === 'FeatureServer')).toBeTruthy(); + expect(data.records.find(r => r.description === 'FeatureServer').url).toBe('/arcgis/rest/services-fs/Features/FeatureServer'); + done(); + }) + .catch(done); + }); + it('should parse FeatureServer sub-layers with geometry type and maxRecordCount', (done) => { + mockAxios.onGet().reply(() => [200, { + currentVersion: 10.91, + capabilities: 'Query', + maxRecordCount: 2000, + layers: [ + { id: 0, name: 'Points', geometryType: 'esriGeometryPoint' }, + { id: 1, name: 'Polygons', geometryType: 'esriGeometryPolygon' } + ], + initialExtent: { + xmin: -10, ymin: -5, xmax: 10, ymax: 5, + spatialReference: { wkid: 4326 } + } + }]); + getCapabilities('/arcgis/rest/services/Test/FeatureServer', 1, 30, '') + .then((data) => { + expect(data.numberOfRecordsMatched).toBe(2); + const [pointLayer, polygonLayer] = data.records; + expect(pointLayer.geometryType).toBe('Point'); + expect(pointLayer.queryable).toBe(true); + expect(pointLayer.maxRecordCount).toBe(2000); + expect(polygonLayer.geometryType).toBe('MultiPolygon'); + expect(pointLayer.bbox).toBeTruthy(); + expect(pointLayer.bbox.crs).toBe('EPSG:4326'); + done(); + }) + .catch(done); + }); + it('should prefer initialExtent over fullExtent for FeatureServer', (done) => { + mockAxios.onGet().reply(() => [200, { + capabilities: 'Query', + layers: [{ id: 0, name: 'Layer0', geometryType: 'esriGeometryPoint' }], + initialExtent: { + xmin: -10, ymin: -5, xmax: 10, ymax: 5, + spatialReference: { wkid: 4326 } + }, + fullExtent: { + xmin: -180, ymin: -90, xmax: 180, ymax: 90, + spatialReference: { wkid: 4326 } + } + }]); + getCapabilities('/arcgis/rest/services/ExtentPref/FeatureServer', 1, 30, '') + .then((data) => { + expect(data.records[0].bbox.bounds.minx).toBe(-10); + expect(data.records[0].bbox.bounds.maxx).toBe(10); + done(); + }) + .catch(done); + }); + it('should reproject non-4326 extent to EPSG:4326', (done) => { + mockAxios.onGet().reply(() => [200, { + capabilities: 'Query', + layers: [{ id: 0, name: 'Layer0', geometryType: 'esriGeometryPoint' }], + initialExtent: { + xmin: -8238310, ymin: 4969609, xmax: -8227517, ymax: 4981706, + spatialReference: { wkid: 3857 } + } + }]); + getCapabilities('/arcgis/rest/services/Reprojected/FeatureServer', 1, 30, '') + .then((data) => { + const { bounds, crs } = data.records[0].bbox; + expect(crs).toBe('EPSG:4326'); + expect(bounds.minx).toBeLessThan(0); + expect(bounds.miny).toBeGreaterThan(0); + done(); + }) + .catch(done); + }); }); }); diff --git a/web/client/api/catalog/ArcGIS.js b/web/client/api/catalog/ArcGIS.js index 8fe5d5bff87..1dbfc89894d 100644 --- a/web/client/api/catalog/ArcGIS.js +++ b/web/client/api/catalog/ArcGIS.js @@ -9,6 +9,7 @@ import { Observable } from 'rxjs'; import { isValidURL } from '../../utils/URLUtils'; import { preprocess as commonPreprocess } from './common'; import { getCapabilities } from '../ArcGIS'; +import { isFeatureServerUrl } from '../../utils/ArcGISUtils'; function validateUrl(serviceUrl) { if (isValidURL(serviceUrl)) { @@ -21,8 +22,9 @@ const recordToLayer = (record, { layerBaseConfig }) => { if (!record) { return null; } + const isFeatureServer = isFeatureServerUrl(record.url); return { - type: 'arcgis', + type: isFeatureServer ? 'arcgis-feature' : 'arcgis', url: record.url, title: record.title, format: record.format, @@ -34,9 +36,18 @@ const recordToLayer = (record, { layerBaseConfig }) => { ...(record.bbox && { bbox: record.bbox }), - options: { - layers: record.layers - }, + ...(isFeatureServer + ? { + ...(record.geometryType && { geometryType: record.geometryType }), + ...(record.maxRecordCount && { maxRecordCount: record.maxRecordCount }), + strategy: 'tile' + } + : { + options: { + layers: record.layers + } + } + ), ...layerBaseConfig }; }; @@ -65,7 +76,9 @@ export const getCatalogRecords = (response) => { format: record.format, layers: record.layers, queryable: record.queryable, - bbox: record.bbox + bbox: record.bbox, + ...(record.geometryType && { geometryType: record.geometryType }), + ...(record.maxRecordCount && { maxRecordCount: record.maxRecordCount }) }; }) : null; diff --git a/web/client/api/catalog/__tests__/ArcGIS-test.js b/web/client/api/catalog/__tests__/ArcGIS-test.js index 420d4c81027..1063c6eb07a 100644 --- a/web/client/api/catalog/__tests__/ArcGIS-test.js +++ b/web/client/api/catalog/__tests__/ArcGIS-test.js @@ -111,4 +111,43 @@ describe('Test ArcGIS Catalog API', () => { } done(); }); + it('should get layer from FeatureServer record with arcgis-feature type', (done) => { + const testRecord = { + name: 0, + title: 'Test FeatureServer Layer', + url: 'https://test.arcgis.com/rest/services/Test/FeatureServer', + geometryType: 'MultiPolygon', + maxRecordCount: 2000 + }; + try { + const layer = getLayerFromRecord(testRecord, { layerBaseConfig: {} }); + expect(layer.type).toBe('arcgis-feature'); + expect(layer.strategy).toBe('tile'); + expect(layer.geometryType).toBe('MultiPolygon'); + expect(layer.maxRecordCount).toBe(2000); + expect(layer.name).toBe('0'); + expect(layer.visibility).toBe(true); + expect(layer.options).toBeFalsy(); + } catch (e) { + done(e); + } + done(); + }); + it('should get catalog records with FeatureServer fields', (done) => { + const testRecord = { + id: 0, + name: 'PolygonLayer', + url: 'https://test.arcgis.com/rest/services/Test/FeatureServer', + geometryType: 'MultiPolygon', + maxRecordCount: 1000 + }; + try { + const records = getCatalogRecords({ records: [testRecord] }); + expect(records[0].geometryType).toBe('MultiPolygon'); + expect(records[0].maxRecordCount).toBe(1000); + } catch (e) { + done(e); + } + done(); + }); }); diff --git a/web/client/components/map/cesium/Layer.jsx b/web/client/components/map/cesium/Layer.jsx index b557f4f70b1..c6ae4bfcb4e 100644 --- a/web/client/components/map/cesium/Layer.jsx +++ b/web/client/components/map/cesium/Layer.jsx @@ -20,6 +20,8 @@ class CesiumLayer extends React.Component { type: PropTypes.string, options: PropTypes.object, onCreationError: PropTypes.func, + onLayerLoading: PropTypes.func, + onLayerLoad: PropTypes.func, position: PropTypes.number, securityToken: PropTypes.string, zoom: PropTypes.number, @@ -27,6 +29,11 @@ class CesiumLayer extends React.Component { onImageryLayersTreeUpdate: PropTypes.func }; + static defaultProps = { + onLayerLoading: () => {}, + onLayerLoad: () => {} + }; + componentDidMount() { // initial visibility should also take into account the visibility limits // in particular for detached layers (eg. Vector, WFS, 3D Tiles, ...) @@ -67,6 +74,7 @@ class CesiumLayer extends React.Component { const oldProvider = this.provider; const newLayer = this.layer.updateParams(newProps.options.params); this.layer = newLayer; + this.setLayerLoading(this.layer, newProps.options); this.addLayer(newProps); setTimeout(() => { this.removeLayer(oldProvider); @@ -222,6 +230,65 @@ class CesiumLayer extends React.Component { } }; + setLayerLoading = (layer, options) => { + if (layer) { + let loadTimeout; + layer.loader = { + onLayerLoading: () => { + if (loadTimeout) { + clearTimeout(loadTimeout); + loadTimeout = undefined; + } + this.props.onLayerLoading(options.id); + }, + onLayerLoad: (error) => { + if (loadTimeout) { + clearTimeout(loadTimeout); + } + loadTimeout = setTimeout(() => { + this.props.onLayerLoad(options.id, error); + }, 300); + } + }; + // wrap requestImage on imagery providers to track per-layer loading + if (!layer.detached && typeof layer.requestImage === 'function') { + this._wrapRequestImage(layer, options); + } + } + }; + + _wrapRequestImage = (imageryProvider, options) => { + const orig = imageryProvider.requestImage.bind(imageryProvider); + let pending = 0; + let loadTimeout; + imageryProvider.requestImage = (x, y, level, request) => { + const result = orig(x, y, level, request); + if (result && typeof result.then === 'function') { + if (pending === 0) { + if (loadTimeout) { + clearTimeout(loadTimeout); + loadTimeout = undefined; + } + this.props.onLayerLoading(options.id); + } + pending++; + const onComplete = (error) => { + pending--; + if (pending === 0) { + if (loadTimeout) { + clearTimeout(loadTimeout); + } + loadTimeout = setTimeout(() => { + this.props.onLayerLoad(options.id, error); + }, 300); + } + }; + result.then(() => onComplete(), () => onComplete({error: true})); + } + return result; + }; + }; + createLayer = (type, options, position, map, securityToken) => { if (type) { const isProxy = options?.url ? getProxyCacheByUrl(castArray(options.url)[0]) : undefined; @@ -242,6 +309,7 @@ class CesiumLayer extends React.Component { this.layer.then((resolvedLayer) => { this.layer = resolvedLayer; this.layer.layerId = options.id; + this.setLayerLoading(this.layer, options); this.provider = map.imageryLayers.addImageryProvider(resolvedLayer); if (this.layer) { this.layer.layerName = options.name; @@ -257,6 +325,7 @@ class CesiumLayer extends React.Component { if (this.layer) { this.layer.layerName = options.name; this.layer.layerId = options.id; + this.setLayerLoading(this.layer, options); } if (this.layer === null) { this.props.onCreationError(options); @@ -291,7 +360,7 @@ class CesiumLayer extends React.Component { if (this._isMounted) { this.removeLayer(); this.layer = resolvedLayer; - + this.setLayerLoading(this.layer, newProps.options); this.provider = this.props.map.imageryLayers.addImageryProvider(resolvedLayer); this.provider._position = this.props.position; if (newProps.options.opacity !== undefined) { @@ -306,6 +375,7 @@ class CesiumLayer extends React.Component { this.removeLayer(); this.layer = newLayer; + this.setLayerLoading(this.layer, newProps.options); if (newProps.options.visibility) { this.addLayer(newProps); } @@ -358,6 +428,7 @@ class CesiumLayer extends React.Component { const newLayer = this.layer.updateParams(Object.assign({}, this.props.options.params, {_refreshCounter: counter++})); this.removeLayer(); this.layer = newLayer; + this.setLayerLoading(this.layer, this.props.options); this.addLayerInternal(newProps); this.props.map.scene.requestRender(); }, this.props.options.refresh); @@ -369,6 +440,11 @@ class CesiumLayer extends React.Component { }; addLayer = (newProps) => { + // For detached layers (WFS, arcgis-feature, 3D Tiles, ...) + // skip async proxy detection to allow the layer to be added synchronously + if (this.layer?.detached) { + return this._addLayer(newProps); + } if (this._isProxy === undefined && newProps?.options?.url) { const urls = castArray(newProps.options.url); return axios(urls[0], { noProxy: true }) diff --git a/web/client/components/map/cesium/Map.jsx b/web/client/components/map/cesium/Map.jsx index 8e0ddcc16ef..b7c9961da0e 100644 --- a/web/client/components/map/cesium/Map.jsx +++ b/web/client/components/map/cesium/Map.jsx @@ -38,6 +38,8 @@ class CesiumMap extends React.Component { projection: PropTypes.string, onMapViewChanges: PropTypes.func, onCreationError: PropTypes.func, + onLayerLoading: PropTypes.func, + onLayerLoad: PropTypes.func, onClick: PropTypes.func, onMouseMove: PropTypes.func, mapOptions: PropTypes.object, @@ -486,6 +488,8 @@ class CesiumMap extends React.Component { map: map, projection: mapProj, onCreationError: this.props.onCreationError, + onLayerLoading: this.props.onLayerLoading, + onLayerLoad: this.props.onLayerLoad, zoom: this.props.zoom, imageryLayersTreeUpdatedCount: this.state.imageryLayersTreeUpdatedCount, onImageryLayersTreeUpdate: debounce(() => diff --git a/web/client/components/map/cesium/plugins/ArcGISFeatureLayer.js b/web/client/components/map/cesium/plugins/ArcGISFeatureLayer.js new file mode 100644 index 00000000000..cb06dcfb188 --- /dev/null +++ b/web/client/components/map/cesium/plugins/ArcGISFeatureLayer.js @@ -0,0 +1,296 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as Cesium from 'cesium'; +import isEqual from 'lodash/isEqual'; +import trimEnd from 'lodash/trimEnd'; + +import Layers from '../../../../utils/cesium/Layers'; +import { + getStyle, + layerToGeoStylerStyle +} from '../../../../utils/VectorStyleUtils'; +import { applyDefaultStyleToVectorLayer } from '../../../../utils/StyleUtils'; +import GeoJSONStyledFeatures from '../../../../utils/cesium/GeoJSONStyledFeatures'; +import TiledBillboardCollection from '../../../../utils/cesium/TiledBillboardCollection'; +import axios from '../../../../libs/ajax'; + +const buildQueryUrl = (options) => { + const baseUrl = trimEnd(options.url, '/'); + const layerId = options.name !== undefined ? options.name : '0'; + return `${baseUrl}/${layerId}/query`; +}; + +const DEFAULT_PAGE_SIZE = 1000; + +const fetchPaginatedFeatures = (url, baseParams, authSourceId, pageSize) => { + const recordCount = pageSize || DEFAULT_PAGE_SIZE; + const allFeatures = []; + const seenIds = new Set(); + const fetchPage = (offset) => { + return axios.get(url, { + params: { ...baseParams, resultOffset: offset, resultRecordCount: recordCount }, + _msAuthSourceId: authSourceId + }).then(({ data }) => { + const newFeatures = (data?.features || []).filter(f => { + const id = f.id ?? f.properties?.OBJECTID; + if (id !== null && id !== undefined && seenIds.has(id)) return false; + if (id !== null && id !== undefined) seenIds.add(id); + return true; + }); + if (newFeatures.length) { + allFeatures.push(...newFeatures); + } + const exceeded = data?.exceededTransferLimit + || data?.properties?.exceededTransferLimit; + if (exceeded && newFeatures.length > 0) { + return fetchPage(offset + (data?.features?.length || 0)); + } + return { + type: 'FeatureCollection', + features: allFeatures + }; + }).catch(() => ({ + type: 'FeatureCollection', + features: allFeatures + })); + }; + return fetchPage(0); +}; + +const getEffectiveStrategy = (options) => options?.strategy || 'tile'; + +const isPointGeometry = (options) => !options?.geometryType || ['Point', 'MultiPoint'].includes(options.geometryType); + +const createLoader = (options) => { + const strategy = getEffectiveStrategy(options); + const baseParams = { + where: '1=1', + outFields: '*', + outSR: 4326, + f: 'geojson' + }; + + if (strategy === 'bbox' || strategy === 'tile') { + return (extent) => { + const [xmin, ymin, xmax, ymax] = extent; + return fetchPaginatedFeatures(buildQueryUrl(options), { + ...baseParams, + geometry: `${xmin},${ymin},${xmax},${ymax}`, + geometryType: 'esriGeometryEnvelope', + spatialRel: 'esriSpatialRelIntersects', + inSR: 4326 + }, options.security?.sourceId, options.maxRecordCount).then((data) => ({ data })); + }; + } + return () => fetchPaginatedFeatures( + buildQueryUrl(options), baseParams, options.security?.sourceId, options.maxRecordCount + ).then((data) => ({ data })); +}; + +const applyStyle = (styledFeatures, options, features) => { + layerToGeoStylerStyle(options) + .then((style) => { + getStyle(applyDefaultStyleToVectorLayer({ + ...options, + features, + style + }), 'cesium') + .then((styleFunc) => { + styledFeatures.setStyleFunction(styleFunc); + }); + }); +}; + +const createLayer = (options, map) => { + if (!options.visibility) { + return { + detached: true, + styledFeatures: undefined, + remove: () => {} + }; + } + + let styledFeatures; + let loader; + let loadingBbox; + let bboxTimeout; + let tiledPrimitive; + let currentOptions = options; + + let loadCount = 0; + let _layerRef = null; + const notifyLoading = () => { + if (loadCount === 0) { + _layerRef?.loader?.onLayerLoading?.(); + } + loadCount++; + }; + const notifyLoaded = (error) => { + loadCount--; + if (loadCount === 0) { + _layerRef?.loader?.onLayerLoad?.(error); + } + }; + + const add = () => { + const strategy = getEffectiveStrategy(currentOptions); + loader = createLoader(currentOptions); + + if (strategy !== 'tile') { + styledFeatures = new GeoJSONStyledFeatures({ + features: [], + id: currentOptions?.id, + map, + opacity: currentOptions.opacity, + queryable: currentOptions.queryable === undefined || currentOptions.queryable + }); + } + + if (strategy === 'bbox') { + loadingBbox = () => { + if (bboxTimeout) { + clearTimeout(bboxTimeout); + bboxTimeout = undefined; + } + bboxTimeout = setTimeout(() => { + const viewRectangle = map.camera.computeViewRectangle(); + const cameraPitch = Math.abs(Cesium.Math.toDegrees(map.camera.pitch)); + if (viewRectangle && cameraPitch > 60) { + notifyLoading(); + loader([ + Cesium.Math.toDegrees(viewRectangle.west), + Cesium.Math.toDegrees(viewRectangle.south), + Cesium.Math.toDegrees(viewRectangle.east), + Cesium.Math.toDegrees(viewRectangle.north) + ]) + .then(({ data: collection }) => { + styledFeatures.setFeatures(collection.features); + applyStyle(styledFeatures, currentOptions, collection.features); + notifyLoaded(); + }) + .catch(() => notifyLoaded({ error: true })); + } + }, 300); + }; + map.camera.moveEnd.addEventListener(loadingBbox); + } else if (strategy === 'tile') { + const tileLoadFn = (tileDef) => { + notifyLoading(); + return loader([ + Cesium.Math.toDegrees(tileDef.rectangle.west), + Cesium.Math.toDegrees(tileDef.rectangle.south), + Cesium.Math.toDegrees(tileDef.rectangle.east), + Cesium.Math.toDegrees(tileDef.rectangle.north) + ]).then(({ data: collection }) => { + notifyLoaded(); + return collection; + }).catch((e) => { + notifyLoaded({ error: true }); + throw e; + }); + }; + + tiledPrimitive = new TiledBillboardCollection({ + map, + tileType: isPointGeometry(currentOptions) ? 'billboard' : 'feature', + msId: currentOptions.id, + opacity: currentOptions.opacity, + minimumLevel: currentOptions.minimumLevel || 0, + maximumLevel: currentOptions.maximumLevel || 18, + debugTiles: false, + queryable: currentOptions.queryable === undefined || currentOptions.queryable, + style: currentOptions.style, + styleOptions: isPointGeometry(currentOptions) ? undefined : currentOptions, + tileWidth: currentOptions?.tileSize || 512, + loadTile: tileLoadFn + }); + tiledPrimitive.load(); + } else { + notifyLoading(); + loader() + .then(({ data: collection }) => { + styledFeatures.setFeatures(collection.features); + applyStyle(styledFeatures, currentOptions, collection.features); + notifyLoaded(); + }) + .catch(() => notifyLoaded({ error: true })); + } + }; + + const layerObj = { + detached: true, + styledFeatures, + tiledPrimitive, + setCurrentOptions: (opts) => { currentOptions = opts; }, + loader: null, + add: () => { + add(); + layerObj.styledFeatures = styledFeatures; + layerObj.tiledPrimitive = tiledPrimitive; + }, + remove: () => { + if (styledFeatures) { + styledFeatures.destroy(); + styledFeatures = undefined; + } + if (tiledPrimitive) { + tiledPrimitive.destroy(); + tiledPrimitive = undefined; + } + if (loadingBbox) { + map.camera.moveEnd.removeEventListener(loadingBbox); + } + } + }; + _layerRef = layerObj; + return layerObj; +}; + +Layers.registerType('arcgis-feature', { + create: createLayer, + update: (layer, newOptions, oldOptions, map) => { + if (layer?.setCurrentOptions) { + layer.setCurrentOptions(newOptions); + } + if ( + oldOptions.forceProxy !== newOptions.forceProxy + || !isEqual(oldOptions.security, newOptions.security) + || oldOptions.strategy !== newOptions.strategy + ) { + return createLayer(newOptions, map); + } + if (!isEqual(newOptions.style, oldOptions.style)) { + if (layer?.styledFeatures) { + layerToGeoStylerStyle(newOptions) + .then((style) => { + getStyle(applyDefaultStyleToVectorLayer({ + ...newOptions, + features: layer?.styledFeatures?._originalFeatures, + style + }), 'cesium') + .then((styleFunc) => { + layer.styledFeatures.setStyleFunction(styleFunc); + }); + }); + } + if (layer?.tiledPrimitive) { + layer.tiledPrimitive.setStyleFunction(newOptions.style); + } + } + if (newOptions.opacity !== oldOptions.opacity) { + if (layer?.styledFeatures) { + layer.styledFeatures.setOpacity(newOptions.opacity); + } + if (layer?.tiledPrimitive) { + layer.tiledPrimitive.setOpacity(newOptions.opacity); + } + } + return null; + } +}); diff --git a/web/client/components/map/cesium/plugins/WFSLayer.js b/web/client/components/map/cesium/plugins/WFSLayer.js index 8ee6c1bc352..7974ff5649d 100644 --- a/web/client/components/map/cesium/plugins/WFSLayer.js +++ b/web/client/components/map/cesium/plugins/WFSLayer.js @@ -78,6 +78,21 @@ const createLayer = (options, map) => { let bboxTimeout; let tiledPrimitive; + let loadCount = 0; + let _layerRef = null; + const notifyLoading = () => { + if (loadCount === 0) { + _layerRef?.loader?.onLayerLoading?.(); + } + loadCount++; + }; + const notifyLoaded = (error) => { + loadCount--; + if (loadCount === 0) { + _layerRef?.loader?.onLayerLoad?.(error); + } + }; + const add = () => { loader = createLoader(options); if (options?.strategy === 'bbox' && options?.serverType === ServerTypes.NO_VENDOR) { @@ -90,40 +105,41 @@ const createLayer = (options, map) => { const viewRectangle = map.camera.computeViewRectangle(); const cameraPitch = Math.abs(Cesium.Math.toDegrees(map.camera.pitch)); if (viewRectangle && cameraPitch > 60) { - loader([ + const result = loader([ Cesium.Math.toDegrees(viewRectangle.west), Cesium.Math.toDegrees(viewRectangle.south), Cesium.Math.toDegrees(viewRectangle.east), Cesium.Math.toDegrees(viewRectangle.north) - ]) - .then(({ data: collection }) => { - styledFeatures.setFeatures(collection.features); - layerToGeoStylerStyle(options) - .then((style) => { - getStyle(applyDefaultStyleToVectorLayer({ - ...options, - features: collection.features, - style - }), 'cesium') - .then((styleFunc) => { - styledFeatures.setStyleFunction(styleFunc); - }); - }); - }); + ]); + if (result && typeof result.then === 'function') { + notifyLoading(); + result + .then(({ data: collection }) => { + styledFeatures.setFeatures(collection.features); + layerToGeoStylerStyle(options) + .then((style) => { + getStyle(applyDefaultStyleToVectorLayer({ + ...options, + features: collection.features, + style + }), 'cesium') + .then((styleFunc) => { + styledFeatures.setStyleFunction(styleFunc); + }); + }); + notifyLoaded(); + }) + .catch(() => notifyLoaded({ error: true })); + } } }, 300); }; map.camera.moveEnd.addEventListener(loadingBbox); } else if (options?.strategy === 'tile' && options?.serverType === ServerTypes.NO_VENDOR) { - // Note that 'TiledBillboardCollection' can only be used for point geometric features for now, So WFS with other than point geometry should not be used for now on strategy === 'tile' tiledPrimitive = new TiledBillboardCollection({ map, - features: [], - id: options?.id, + tileType: 'billboard', opacity: options.opacity, - // the maximum and minimum levels refers to the request done by TiledBillboardCollection - // these values are different from maximum and minimum resolutions to avoid visibility issue when the camera is tilted - // we should review resolutions behavior to match similar zoom level when camera is tilted minimumLevel: options.minimumLevel || 17, maximumLevel: options.maximumLevel || 17, msId: options.id, @@ -131,35 +147,51 @@ const createLayer = (options, map) => { queryable: options.queryable === undefined || options.queryable, style: options.style, tileWidth: options?.tileSize || 512, - loadTile: (tile) => loader([ - Cesium.Math.toDegrees(tile.rectangle.west), - Cesium.Math.toDegrees(tile.rectangle.south), - Cesium.Math.toDegrees(tile.rectangle.east), - Cesium.Math.toDegrees(tile.rectangle.north) - ]).then(({ data: collection }) => collection) + loadTile: (tile) => { + notifyLoading(); + return loader([ + Cesium.Math.toDegrees(tile.rectangle.west), + Cesium.Math.toDegrees(tile.rectangle.south), + Cesium.Math.toDegrees(tile.rectangle.east), + Cesium.Math.toDegrees(tile.rectangle.north) + ]).then(({ data: collection }) => { + notifyLoaded(); + return collection; + }).catch((e) => { + notifyLoaded({ error: true }); + throw e; + }); + } }); tiledPrimitive.load(); } else { - loader() - .then(({ data: collection }) => { - styledFeatures.setFeatures(collection.features); - layerToGeoStylerStyle(options) - .then((style) => { - getStyle(applyDefaultStyleToVectorLayer({ - ...options, - features: collection.features, - style - }), 'cesium') - .then((styleFunc) => { - styledFeatures.setStyleFunction(styleFunc); - }); - }); - }); + const result = loader(); + if (result && typeof result.then === 'function') { + notifyLoading(); + result + .then(({ data: collection }) => { + styledFeatures.setFeatures(collection.features); + layerToGeoStylerStyle(options) + .then((style) => { + getStyle(applyDefaultStyleToVectorLayer({ + ...options, + features: collection.features, + style + }), 'cesium') + .then((styleFunc) => { + styledFeatures.setStyleFunction(styleFunc); + }); + }); + notifyLoaded(); + }) + .catch(() => notifyLoaded({ error: true })); + } } }; - return { + const layerObj = { detached: true, styledFeatures, + loader: null, add, remove: () => { if (styledFeatures) { @@ -175,6 +207,8 @@ const createLayer = (options, map) => { } } }; + _layerRef = layerObj; + return layerObj; }; Layers.registerType('wfs', { diff --git a/web/client/components/map/cesium/plugins/WMTSLayer.js b/web/client/components/map/cesium/plugins/WMTSLayer.js index c69da8ff7d9..3f3124d20d3 100644 --- a/web/client/components/map/cesium/plugins/WMTSLayer.js +++ b/web/client/components/map/cesium/plugins/WMTSLayer.js @@ -159,7 +159,7 @@ const createLayer = options => { const cesiumOptions = wmtsToCesiumOptions(options); layer = new Cesium.WebMapTileServiceImageryProvider(cesiumOptions); const orig = layer.requestImage; - layer.requestImage = (x, y, level) => cesiumOptions.isValid(x, y, level) ? orig.bind(layer)( x, y, level) : new Promise( () => undefined); + layer.requestImage = (x, y, level) => cesiumOptions.isValid(x, y, level) ? orig.bind(layer)( x, y, level) : undefined; layer.updateParams = (params) => { const newOptions = Object.assign({}, options, { params: Object.assign({}, options.params || {}, params) diff --git a/web/client/components/map/cesium/plugins/index.js b/web/client/components/map/cesium/plugins/index.js index 94717571ac5..1c256278849 100644 --- a/web/client/components/map/cesium/plugins/index.js +++ b/web/client/components/map/cesium/plugins/index.js @@ -21,6 +21,7 @@ import './TerrainLayer'; import './ModelLayer'; import './ElevationLayer'; import './ArcGISLayer'; +import './ArcGISFeatureLayer'; import './FlatGeobufLayer'; import './COGLayer'; diff --git a/web/client/components/map/openlayers/Layer.jsx b/web/client/components/map/openlayers/Layer.jsx index 014bd8bd122..c25ad48a511 100644 --- a/web/client/components/map/openlayers/Layer.jsx +++ b/web/client/components/map/openlayers/Layer.jsx @@ -325,6 +325,25 @@ export default class OpenlayersLayer extends React.Component { this.layer.getSource().on('vectorerror', () => { this.props.onLayerLoad(options.id, {error: true}); }); + this.featurestoload = 0; + this.layer.getSource().on('featuresloadstart', () => { + if (this.featurestoload === 0) { + this.props.onLayerLoading(options.id); + } + this.featurestoload++; + }); + this.layer.getSource().on('featuresloadend', () => { + this.featurestoload--; + if (this.featurestoload === 0) { + this.props.onLayerLoad(options.id); + } + }); + this.layer.getSource().on('featuresloaderror', () => { + this.featurestoload--; + if (this.featurestoload === 0) { + this.props.onLayerLoad(options.id, {error: true}); + } + }); if (options.refresh) { let counter = 0; diff --git a/web/client/components/map/openlayers/__tests__/Layer-test.jsx b/web/client/components/map/openlayers/__tests__/Layer-test.jsx index 53e92908e79..e77d4476906 100644 --- a/web/client/components/map/openlayers/__tests__/Layer-test.jsx +++ b/web/client/components/map/openlayers/__tests__/Layer-test.jsx @@ -3527,4 +3527,49 @@ describe('Openlayers layer', () => { LAYERS: 'show:1' }); }); + it('should call onLayerLoading and onLayerLoad on featuresloadstart/end events', () => { + const options = { + type: 'osm', + visibility: true + }; + let loadingCalled = false; + let loadCalled = false; + const layer = ReactDOM.render( + { loadingCalled = true; }} + onLayerLoad={() => { loadCalled = true; }} + map={map}/>, document.getElementById("container")); + expect(layer).toBeTruthy(); + const olLayer = map.getLayers().item(0); + const source = olLayer.getSource(); + source.dispatchEvent('featuresloadstart'); + expect(loadingCalled).toBe(true); + source.dispatchEvent('featuresloadend'); + expect(loadCalled).toBe(true); + }); + it('should track concurrent featuresload events with counter', () => { + const options = { + type: 'osm', + visibility: true + }; + let loadingCount = 0; + let loadCount = 0; + const layer = ReactDOM.render( + { loadingCount++; }} + onLayerLoad={() => { loadCount++; }} + map={map}/>, document.getElementById("container")); + expect(layer).toBeTruthy(); + const olLayer = map.getLayers().item(0); + const source = olLayer.getSource(); + source.dispatchEvent('featuresloadstart'); + source.dispatchEvent('featuresloadstart'); + expect(loadingCount).toBe(1); + source.dispatchEvent('featuresloadend'); + expect(loadCount).toBe(0); + source.dispatchEvent('featuresloadend'); + expect(loadCount).toBe(1); + }); }); diff --git a/web/client/components/map/openlayers/plugins/ArcGISFeatureLayer.js b/web/client/components/map/openlayers/plugins/ArcGISFeatureLayer.js new file mode 100644 index 00000000000..f243e7f751c --- /dev/null +++ b/web/client/components/map/openlayers/plugins/ArcGISFeatureLayer.js @@ -0,0 +1,199 @@ +/* + * 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 isEqual from 'lodash/isEqual'; +import trimEnd from 'lodash/trimEnd'; + +import VectorSource from 'ol/source/Vector'; +import VectorLayer from 'ol/layer/Vector'; +import { bbox, all, tile } from 'ol/loadingstrategy.js'; +import { createXYZ } from 'ol/tilegrid.js'; +import GeoJSON from 'ol/format/GeoJSON'; + +import { getStyle } from '../VectorStyle'; +import Layers from '../../../../utils/openlayers/Layers'; +import axios from '../../../../libs/ajax'; +import { reprojectBbox } from '../../../../utils/CoordinatesUtils'; +import { applyDefaultStyleToVectorLayer } from '../../../../utils/StyleUtils'; + +const buildQueryUrl = (options) => { + const baseUrl = trimEnd(options.url, '/'); + const layerId = options.name !== undefined ? options.name : '0'; + return `${baseUrl}/${layerId}/query`; +}; + +const DEFAULT_PAGE_SIZE = 1000; + +const fetchPaginatedFeatures = (url, baseParams, authSourceId, pageSize) => { + const recordCount = pageSize || DEFAULT_PAGE_SIZE; + const allFeatures = []; + const seenIds = new Set(); + const fetchPage = (offset) => { + return axios.get(url, { + params: { + ...baseParams, + resultOffset: offset, + resultRecordCount: recordCount + }, + _msAuthSourceId: authSourceId + }).then(response => { + const data = response?.data; + const newFeatures = (data?.features || []).filter(f => { + const id = f.id ?? f.properties?.OBJECTID; + if (id !== null && id !== undefined && seenIds.has(id)) return false; + if (id !== null && id !== undefined) seenIds.add(id); + return true; + }); + if (newFeatures.length) { + allFeatures.push(...newFeatures); + } + const exceeded = data?.exceededTransferLimit + || data?.properties?.exceededTransferLimit; + if (exceeded && newFeatures.length > 0) { + return fetchPage(offset + (data?.features?.length || 0)); + } + return { + type: 'FeatureCollection', + features: allFeatures + }; + }).catch(() => ({ + type: 'FeatureCollection', + features: allFeatures + })); + }; + return fetchPage(0); +}; + +const getStrategy = (options) => { + if (options.strategy === 'all') { + return all; + } + if (options.strategy === 'bbox') { + return bbox; + } + return tile(createXYZ({ tileSize: options?.tileSize || 512 })); +}; + +const getEffectiveStrategy = (options) => options?.strategy || 'tile'; + +const createLoader = (source, options) => (extent, resolution, projection, success, failure) => { + const projCode = projection.getCode(); + const params = { + where: '1=1', + outFields: '*', + outSR: 4326, + f: 'geojson' + }; + const strategy = getEffectiveStrategy(options); + if (strategy === 'bbox' || strategy === 'tile') { + const bbox4326 = reprojectBbox(extent, projCode, 'EPSG:4326'); + const [xmin, ymin, xmax, ymax] = bbox4326; + params.geometry = `${xmin},${ymin},${xmax},${ymax}`; + params.geometryType = 'esriGeometryEnvelope'; + params.spatialRel = 'esriSpatialRelIntersects'; + params.inSR = 4326; + } + fetchPaginatedFeatures( + buildQueryUrl(options), + params, + options.security?.sourceId, + options.maxRecordCount + ) + .then((collection) => { + const features = source.getFormat().readFeatures(collection, { + dataProjection: 'EPSG:4326', + featureProjection: projCode + }); + source.addFeatures(features); + source.set('@featureCollection', collection); + success(features); + options.onLoadEnd && options.onLoadEnd(); + }) + .catch(() => { + source.removeLoadedExtent(extent); + failure(); + }); +}; + +const getArcGISFeatureStyle = (layer, options, map) => { + const collection = layer.getSource().get('@featureCollection') || {}; + return getStyle( + applyDefaultStyleToVectorLayer({ + ...options, + features: collection.features, + asPromise: true + }) + ) + .then((style) => { + if (style) { + if (style.__geoStylerStyle) { + style({ map, features: collection.features }) + .then((olStyle) => layer.setStyle(olStyle)); + } else { + layer.setStyle(style); + } + } + }); +}; + +const updateStyle = (layer, options, map) => getArcGISFeatureStyle(layer, options, map); + +Layers.registerType('arcgis-feature', { + create: (options, map) => { + const source = new VectorSource({ + strategy: getStrategy(options), + format: new GeoJSON() + }); + let layer; + source.setLoader( + createLoader(source, { + ...options, + onLoadEnd: () => updateStyle(layer, layer._msCurrentOptions || options, map) + }) + ); + layer = new VectorLayer({ + msId: options.id, + source, + visible: options.visibility !== false, + zIndex: options.zIndex, + opacity: options.opacity, + minResolution: options.minResolution, + maxResolution: options.maxResolution + }); + layer._msCurrentOptions = options; + updateStyle(layer, options, map); + return layer; + }, + update: (layer, options = {}, oldOptions = {}, map) => { + layer._msCurrentOptions = options; + const source = layer.getSource(); + if (!isEqual(oldOptions.security, options.security) + || !isEqual(oldOptions.requestRuleRefreshHash, options.requestRuleRefreshHash) + || oldOptions.strategy !== options.strategy + ) { + source.setLoader(createLoader(source, { + ...options, + onLoadEnd: () => updateStyle(layer, layer._msCurrentOptions || options, map) + })); + source.clear(); + source.refresh(); + } + if (options.style !== oldOptions.style || options.styleName !== oldOptions.styleName) { + updateStyle(layer, options, map); + } + if (oldOptions.minResolution !== options.minResolution) { + layer.setMinResolution(options.minResolution === undefined ? 0 : options.minResolution); + } + if (oldOptions.maxResolution !== options.maxResolution) { + layer.setMaxResolution(options.maxResolution === undefined ? Infinity : options.maxResolution); + } + }, + render: () => { + return null; + } +}); diff --git a/web/client/components/map/openlayers/plugins/WFSLayer.js b/web/client/components/map/openlayers/plugins/WFSLayer.js index 0465eb8ef01..8b540017d68 100644 --- a/web/client/components/map/openlayers/plugins/WFSLayer.js +++ b/web/client/components/map/openlayers/plugins/WFSLayer.js @@ -23,13 +23,14 @@ import { optionsToVendorParams } from '../../../../utils/VendorParamsUtils'; import { needsReload, needsCredentials, getConfig } from '../../../../utils/WFSLayerUtils'; import { applyDefaultStyleToVectorLayer } from '../../../../utils/StyleUtils'; -const createLoader = (source, options) => (extent, resolution, projection) => { +const createLoader = (source, options) => (extent, resolution, projection, success, failure) => { let proj = projection.getCode(); let req; let filters = []; const onError = () => { source.removeLoadedExtent(extent); source.dispatchEvent('vectorerror'); + failure && failure(); }; if (options.serverType === ServerTypes.NO_VENDOR) { @@ -66,10 +67,11 @@ const createLoader = (source, options) => (extent, resolution, projection) => { req.then(response => { if (response.status === 200) { - source.addFeatures( - source.getFormat().readFeatures(response.data)); + const features = source.getFormat().readFeatures(response.data); + source.addFeatures(features); source.set('@wfsFeatureCollection', response.data); options.onLoadEnd && options.onLoadEnd(); + success && success(features); } else { onError(); } diff --git a/web/client/components/map/openlayers/plugins/index.js b/web/client/components/map/openlayers/plugins/index.js index 051747f30f7..0cef04f2d2d 100644 --- a/web/client/components/map/openlayers/plugins/index.js +++ b/web/client/components/map/openlayers/plugins/index.js @@ -21,5 +21,6 @@ export default { COGLayer: require('./COGLayer').default, ElevationLayer: require('./ElevationLayer').default, ArcGISLayer: require('./ArcGISLayer').default, + ArcGISFeatureLayer: require('./ArcGISFeatureLayer').default, FlatGeobufLayer: require('./FlatGeobufLayer').default }; diff --git a/web/client/epics/__tests__/catalog-test.js b/web/client/epics/__tests__/catalog-test.js index c9a9e702885..69f068006c6 100644 --- a/web/client/epics/__tests__/catalog-test.js +++ b/web/client/epics/__tests__/catalog-test.js @@ -1350,6 +1350,29 @@ describe('catalog Epics', () => { done(); }, {}); }); + it('should dispatch ADD_LAYER then CHANGE_LAYER_PROPERTIES with default style for arcgis-feature', (done) => { + const layer = { + type: 'arcgis-feature', + url: '/arcgis/rest/services/Test/FeatureServer', + title: 'Test FeatureServer', + name: '0', + geometryType: 'MultiPolygon', + visibility: true + }; + const NUM_ACTIONS = 2; + testEpic(addTimeoutEpic(addLayerAndDescribeEpic, 0), NUM_ACTIONS, + addLayerAndDescribe(layer), + (actions) => { + try { + expect(actions[0].type).toBe(ADD_LAYER); + expect(actions[1].type).toBe(CHANGE_LAYER_PROPERTIES); + expect(actions[1].newProperties.style).toBeTruthy(); + } catch (e) { + done(e); + } + done(); + }, {}); + }); }); describe('addLayersFromCatalogsEpic geojson', () => { it('should add layer with title from geojson', (done) => { diff --git a/web/client/epics/catalog.js b/web/client/epics/catalog.js index d11e2f614a7..18a644791c1 100644 --- a/web/client/epics/catalog.js +++ b/web/client/epics/catalog.js @@ -335,6 +335,15 @@ export default (API) => ({ .merge(Rx.Observable.from(actions)) .catch((e) => Rx.Observable.of(describeError(layer, e))); } + if (layer.type === 'arcgis-feature') { + const geometryType = layer.geometryType || 'GeometryCollection'; + return Rx.Observable.concat( + Rx.Observable.from(actions), + Rx.Observable.of(changeLayerProperties(id, { + style: createDefaultStyle({ geometryType }) + })) + ); + } if (layer.type === 'model') { const properties = layer?.features?.[0]?.properties || {}; if (properties?.projectedCrsNotSupported || !properties?.projectedCrs) { diff --git a/web/client/plugins/TOC/components/DefaultLayer.jsx b/web/client/plugins/TOC/components/DefaultLayer.jsx index 7fe4f39dc4d..ce4d0022d18 100644 --- a/web/client/plugins/TOC/components/DefaultLayer.jsx +++ b/web/client/plugins/TOC/components/DefaultLayer.jsx @@ -53,7 +53,7 @@ const NodeLegend = ({ return null; } const layerType = node?.type; - if (['wfs', 'vector'].includes(layerType)) { + if (['wfs', 'vector', 'arcgis-feature'].includes(layerType)) { const hasStyle = node?.style?.format === 'geostyler' && node?.style?.body?.rules?.length > 0; return hasStyle ? ( diff --git a/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx b/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx index 0322a4d1f9c..60ee0240e8b 100644 --- a/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx +++ b/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx @@ -477,4 +477,23 @@ describe('test DefaultLayer module component', () => { const filter = document.querySelector('.glyphicon-filter'); expect(filter).toBeFalsy(); }); + it('should render VectorLegend for arcgis-feature layer with style', () => { + const layer = { + id: 'layer00', + name: '0', + title: 'ArcGIS Feature Layer', + visibility: true, + type: 'arcgis-feature', + expanded: true, + style: { + format: 'geostyler', + body: { + rules: [{ name: 'rule0', symbolizers: [{ kind: 'Fill', color: '#ff0000' }] }] + } + } + }; + ReactDOM.render(, document.getElementById("container")); + const legendItems = document.querySelectorAll('.ms-legend'); + expect(legendItems.length).toBeGreaterThan(0); + }); }); diff --git a/web/client/plugins/styleeditor/VectorStyleEditor.jsx b/web/client/plugins/styleeditor/VectorStyleEditor.jsx index 13f695ad244..b7e57e31da3 100644 --- a/web/client/plugins/styleeditor/VectorStyleEditor.jsx +++ b/web/client/plugins/styleeditor/VectorStyleEditor.jsx @@ -69,7 +69,11 @@ const capabilitiesRequest = { }); return featureProps; }) - : Promise.resolve({}) + : Promise.resolve({}), + 'arcgis-feature': (layer) => Promise.resolve({ + geometryType: layer.geometryType, + properties: {} + }) }; function VectorStyleEditor({ @@ -222,9 +226,14 @@ function VectorStyleEditor({ return geojson.current; }); } + if (layer.type === 'arcgis-feature') { + return Promise.resolve({ type: 'FeatureCollection', features: layer.features || [] }); + } return Promise.resolve({ type: 'FeatureCollection', features: [] }); } + const supportedLayers = ['vector', 'wfs', 'arcgis-feature']; + return ( { }, done); }); + it('VectorStyleEditor should return a component for arcgis-feature layer', () => { + const result = getStyleTabPlugin({ + ...BASE_STYLE_TEST_DATA, + element: { type: 'arcgis-feature' } + }); + expect(result.Component).toBeTruthy(); + }); }); diff --git a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js index 3765766aa25..6eab274d125 100644 --- a/web/client/plugins/tocitemssettings/defaultSettingsTabs.js +++ b/web/client/plugins/tocitemssettings/defaultSettingsTabs.js @@ -49,7 +49,7 @@ const isCOGStylableLayer = ({element = {}} = {}) => element.type === "cog"; const isWMS = ({element = {}} = {}) => element.type === "wms"; const isWFS = ({element = {}} = {}) => element.type === "wfs"; const isVectorStylableLayer = ({element = {}} = {}) => - ["wfs", "3dtiles", "vector", "flatgeobuf"].includes(element.type) + ["wfs", "3dtiles", "vector", "flatgeobuf", "arcgis-feature"].includes(element.type) && !isAnnotationLayer(element); const isStylableLayer = (props) => diff --git a/web/client/utils/ArcGISUtils.js b/web/client/utils/ArcGISUtils.js index 6113024a3f9..042ed4ec0da 100644 --- a/web/client/utils/ArcGISUtils.js +++ b/web/client/utils/ArcGISUtils.js @@ -18,6 +18,32 @@ export const isImageServerUrl = (serviceUrl = '') => serviceUrl.includes('ImageS * @return {boolean} */ export const isMapServerUrl = (serviceUrl = '') => serviceUrl.includes('MapServer'); + +/** + * Check if a service url is of type FeatureServer + * @param {string} serviceUrl service url + * @return {boolean} true if the service url is of type FeatureServer + */ +export const isFeatureServerUrl = (serviceUrl = '') => serviceUrl.includes('FeatureServer'); + +/** + * Map of ESRI geometry types to GeoJSON geometry types + */ +const ESRI_GEOMETRY_TYPE_MAP = { + esriGeometryPoint: 'Point', + esriGeometryMultipoint: 'MultiPoint', + esriGeometryPolyline: 'MultiLineString', + esriGeometryPolygon: 'MultiPolygon', + esriGeometryEnvelope: 'Polygon' +}; + +/** + * Convert ESRI geometry type string to GeoJSON geometry type + * @param {string} esriType ESRI geometry type + * @return {string} GeoJSON geometry type, or 'GeometryCollection' if unknown + */ +export const esriGeometryTypeToGeoJSON = (esriType) => ESRI_GEOMETRY_TYPE_MAP[esriType] || 'GeometryCollection'; + /** * Return all the sub layers ids given a layer id and layers structure * @param {string|number} id identifier of the starting layer diff --git a/web/client/utils/LayersUtils.js b/web/client/utils/LayersUtils.js index 0ed910445b1..58df487e228 100644 --- a/web/client/utils/LayersUtils.js +++ b/web/client/utils/LayersUtils.js @@ -759,7 +759,10 @@ export const saveLayer = (layer) => { !isNil(layer.disableFeaturesEditing) ? { disableFeaturesEditing: layer.disableFeaturesEditing } : {}, layer.pointCloudShading ? { pointCloudShading: layer.pointCloudShading } : {}, !isNil(layer.sourceMetadata) ? { sourceMetadata: layer.sourceMetadata } : {}, - layer?.enableImageryOverlay !== undefined ? { enableImageryOverlay: layer.enableImageryOverlay } : {}); + layer?.enableImageryOverlay !== undefined ? { enableImageryOverlay: layer.enableImageryOverlay } : {}, + layer.strategy ? { strategy: layer.strategy } : {}, + layer.geometryType ? { geometryType: layer.geometryType } : {}, + layer.maxRecordCount ? { maxRecordCount: layer.maxRecordCount } : {}); }; /** diff --git a/web/client/utils/MapInfoUtils.js b/web/client/utils/MapInfoUtils.js index be5db4b4208..c61a80ad189 100644 --- a/web/client/utils/MapInfoUtils.js +++ b/web/client/utils/MapInfoUtils.js @@ -379,7 +379,8 @@ export const services = { 'model': model, 'arcgis': arcgis, 'flatgeobuf': flatgeobuf, - 'cog': cog + 'cog': cog, + 'arcgis-feature': arcgis }; /** * To get the custom viewer with the given type diff --git a/web/client/utils/StyleEditorUtils.js b/web/client/utils/StyleEditorUtils.js index 749a3e41903..86994bc77e7 100644 --- a/web/client/utils/StyleEditorUtils.js +++ b/web/client/utils/StyleEditorUtils.js @@ -646,7 +646,7 @@ export function getVectorLayerAttributes(layer) { if (layer?.type === 'wfs') { return getAttributes(layer.properties, layer?.fields); } - if (layer?.type === 'vector') { + if (['vector', 'arcgis-feature'].includes(layer?.type)) { const propertiesKeys = Object.keys(layer.properties || {}); const attributes = propertiesKeys .filter(key => isNumber(layer.properties[key]) || isString(layer.properties[key])) diff --git a/web/client/utils/StyleUtils.js b/web/client/utils/StyleUtils.js index 9c45eb2d697..c4e9448257e 100644 --- a/web/client/utils/StyleUtils.js +++ b/web/client/utils/StyleUtils.js @@ -154,7 +154,7 @@ export const applyDefaultStyleToVectorLayer = (layer, customStyle) => { return layer; } - const geometryType = getFeatureCollectionSingleGeometryType({ features }); + const geometryType = layer?.geometryType || getFeatureCollectionSingleGeometryType({ features }); const markerStyle = !!(customStyle?.marker && ['Point', 'MultiPoint'].includes(geometryType)); const fillColor = customStyle && tinycolor(customStyle.fill).toHexString(); const fillOpacity = customStyle?.fill?.a; diff --git a/web/client/utils/__tests__/ArcGISUtils-test.js b/web/client/utils/__tests__/ArcGISUtils-test.js index 41ed97b4854..123f7daf48f 100644 --- a/web/client/utils/__tests__/ArcGISUtils-test.js +++ b/web/client/utils/__tests__/ArcGISUtils-test.js @@ -10,9 +10,11 @@ import expect from 'expect'; import { isImageServerUrl, isMapServerUrl, + isFeatureServerUrl, getLayerIds, getQueryLayerIds, - esriToGeoJSONFeature + esriToGeoJSONFeature, + esriGeometryTypeToGeoJSON } from '../ArcGISUtils'; const layers = [ @@ -86,6 +88,21 @@ describe('ArcGISUtils', () => { expect(isMapServerUrl('https://localhost/arcgis/rest/services/Name/ImageServer')).toBeFalsy(); expect(isMapServerUrl('https://localhost/arcgis/rest/services/Name/MapServer')).toBeTruthy(); }); + it('isFeatureServerUrl', () => { + expect(isFeatureServerUrl()).toBeFalsy(); + expect(isFeatureServerUrl('https://localhost/arcgis/rest/services/Name/MapServer')).toBeFalsy(); + expect(isFeatureServerUrl('https://localhost/arcgis/rest/services/Name/FeatureServer')).toBeTruthy(); + expect(isFeatureServerUrl('https://localhost/arcgis/rest/services/Name/FeatureServer/0')).toBeTruthy(); + }); + it('esriGeometryTypeToGeoJSON', () => { + expect(esriGeometryTypeToGeoJSON('esriGeometryPoint')).toBe('Point'); + expect(esriGeometryTypeToGeoJSON('esriGeometryMultipoint')).toBe('MultiPoint'); + expect(esriGeometryTypeToGeoJSON('esriGeometryPolyline')).toBe('MultiLineString'); + expect(esriGeometryTypeToGeoJSON('esriGeometryPolygon')).toBe('MultiPolygon'); + expect(esriGeometryTypeToGeoJSON('esriGeometryEnvelope')).toBe('Polygon'); + expect(esriGeometryTypeToGeoJSON('unknownType')).toBe('GeometryCollection'); + expect(esriGeometryTypeToGeoJSON(undefined)).toBe('GeometryCollection'); + }); it('getLayerIds', () => { expect(getLayerIds(1)).toEqual(['1']); expect(getLayerIds('1')).toEqual(['1']); diff --git a/web/client/utils/cesium/TiledBillboardCollection.js b/web/client/utils/cesium/TiledBillboardCollection.js index 43d4613fd73..c9374f60675 100644 --- a/web/client/utils/cesium/TiledBillboardCollection.js +++ b/web/client/utils/cesium/TiledBillboardCollection.js @@ -10,7 +10,9 @@ import * as Cesium from 'cesium'; import max from 'lodash/max'; import uuid from 'uuid'; import { createPolylinePrimitive } from './PrimitivesUtils'; -import { getStyle } from '../VectorStyleUtils'; +import GeoJSONStyledFeatures from './GeoJSONStyledFeatures'; +import { getStyle, layerToGeoStylerStyle } from '../VectorStyleUtils'; +import { applyDefaultStyleToVectorLayer } from '../StyleUtils'; /** * Calculates the optimal tile level based on texel spacing @@ -54,6 +56,7 @@ export function getLevelWithMaximumTexelSpacing( */ export const makeTile = (cartographic, imageryLevel, tilingScheme) => { const coords = tilingScheme.positionToTileXY(cartographic, imageryLevel); + if (!coords) return null; const id = `${coords.x}:${coords.y}:${imageryLevel}`; const radiansRectangle = tilingScheme.tileXYToRectangle(coords.x, coords.y, imageryLevel); return { @@ -156,16 +159,107 @@ class BillboardsTile { billboard.show = false; }); } + + setOpacity(opacity) { + this._opacity = opacity; + } + + destroy() { + // BillboardsTile billboards are owned by the shared BillboardCollection + // which is cleaned up by the parent TiledBillboardCollection.destroy() + } +} + +/** + * Represents a tile that renders features of any geometry type + * using GeoJSONStyledFeatures. Each tile owns its own rendering + * primitives so show/hide is achieved by toggling visibility. + * + * @class FeaturesTile + */ +class FeaturesTile { + constructor(options) { + this._id = options.id; + this._map = options.map; + this._msId = options.msId; + this._opacity = options.opacity; + this._queryable = options.queryable; + this._style = options.style; + this._visible = true; + this._features = []; + this._styledFeatures = null; + } + + addFeatures(features, styleOptions) { + this._features = features; + if (!features || features.length === 0) { + return Promise.resolve(); + } + this._styledFeatures = new GeoJSONStyledFeatures({ + features, + id: `${this._msId}:tile:${this._id}`, + map: this._map, + opacity: this._opacity, + queryable: this._queryable + }); + return layerToGeoStylerStyle(styleOptions) + .then((style) => getStyle(applyDefaultStyleToVectorLayer({ + ...styleOptions, + features, + style + }), 'cesium')) + .then((styleFunc) => { + if (this._styledFeatures) { + this._styledFeatures.setStyleFunction(styleFunc); + } + }); + } + + show() { + if (!this._visible && this._styledFeatures) { + this._styledFeatures._primitives.show = true; + this._styledFeatures._dataSource.show = true; + this._visible = true; + } + } + + hide() { + if (this._visible && this._styledFeatures) { + this._styledFeatures._primitives.show = false; + this._styledFeatures._dataSource.show = false; + this._visible = false; + } + } + + setOpacity(opacity) { + this._opacity = opacity; + if (this._styledFeatures) { + this._styledFeatures.setOpacity(opacity); + } + } + + destroy() { + if (this._styledFeatures) { + this._styledFeatures.destroy(); + this._styledFeatures = null; + } + this._features = []; + } } /** * A tiled billboard collection that manages billboards across multiple tile levels. * This class provides efficient loading and rendering of billboards by organizing * them into tiles and only loading tiles that are currently visible. + * Supports both billboard-based rendering (points) via `tileType: 'billboard'` + * and full-geometry rendering (polygons, polylines, points) via `tileType: 'feature'` (default). * * @class TiledBillboardCollection * @param {Object} options - Configuration options * @param {Cesium.Scene} options.map - The Cesium map instance + * @param {string} [options.tileType='feature'] - Tile rendering strategy: 'billboard' for points-only + * using a shared BillboardCollection, + * or 'feature' for all geometry types using GeoJSONStyledFeatures per tile * @param {boolean} [options.debugTiles=false] - Whether to show tile boundaries for debugging * @param {number} [options.tileWidth=512] - Width of tiles in pixels * @param {number} [options.minimumLevel=0] - Minimum terrain tile level at which billboards are displayed. @@ -178,8 +272,10 @@ class BillboardsTile { * at lower terrain detail levels. * @param {Function} [options.loadTile] - Function to load tile data, should return Promise with features * @param {Object} [options.style] - Style configuration for billboards + * @param {Object} [options.styleOptions] - Additional style options (used by 'feature' tileType) * @param {string} [options.msId] - MapStore identifier * @param {number} [options.opacity=1.0] - Opacity for billboards + * @param {boolean} [options.queryable=true] - Whether features are queryable * */ function TiledBillboardCollection(options) { @@ -189,6 +285,7 @@ function TiledBillboardCollection(options) { } this._map = options.map; + this._tileType = options.tileType || 'feature'; this._debugTiles = options.debugTiles ?? false; this._tileWidth = options.tileWidth ?? 512; this._minimumLevel = options.minimumLevel ?? 0; @@ -199,16 +296,22 @@ function TiledBillboardCollection(options) { this._tilingScheme = new Cesium.WebMercatorTilingScheme(); this._globe = this._map?.scene?.globe; - this._rectangle = Cesium.Rectangle.MAX_VALUE; - this._staticPrimitivesCollection = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); this._map.scene.primitives.add(this._staticPrimitivesCollection); - this._staticBillboardCollection = new Cesium.BillboardCollection({ scene: this._map.scene }); - this._map.scene.primitives.add(this._staticBillboardCollection); + + if (this._tileType === 'billboard') { + this._staticBillboardCollection = new Cesium.BillboardCollection({ scene: this._map.scene }); + this._map.scene.primitives.add(this._staticBillboardCollection); + } this._tileCache = {}; this._prevTiles = []; + this._opacity = options.opacity; + this._msId = options.msId; + this._style = options.style; + this._styleOptions = options.styleOptions || {}; + const maxNumberOfTile = 32; let timeout; this._update = () => { @@ -223,7 +326,7 @@ function TiledBillboardCollection(options) { if (!this._removed) { // _tilesToRender is a private property not exposed by the API // https://community.cesium.com/t/does-quadtreeprimitive-still-support-in-cesium-1-32/5422/4 - const tilesToRender = [...this._globe?._surface?._tilesToRender]; + const tilesToRender = [...(this._globe?._surface?._tilesToRender || [])]; const maximumLevel = max(tilesToRender.map(tileToRender => tileToRender.level)); let target = this._map.scene.globe.pick(new Cesium.Ray(this._map.camera.position, this._map.camera.direction), this._map.scene); @@ -233,14 +336,19 @@ function TiledBillboardCollection(options) { new Cesium.Cartesian3(target.x, target.y, target.z) ); const errorRatio = 1.0; - const targetGeometricError = errorRatio * this._map?.terrainProvider?.getLevelMaximumGeometricError(maximumLevel); - - let imageryLevel = getLevelWithMaximumTexelSpacing( - this._tilingScheme, - targetGeometricError, - center.latitude, - this._tileWidth - ); + // When terrain tiles haven't loaded yet, fall back to minimumLevel + let imageryLevel; + if (maximumLevel !== undefined) { + const targetGeometricError = errorRatio * this._map?.terrainProvider?.getLevelMaximumGeometricError(maximumLevel); + imageryLevel = getLevelWithMaximumTexelSpacing( + this._tilingScheme, + targetGeometricError, + center.latitude, + this._tileWidth + ); + } else { + imageryLevel = this._minimumLevel; + } if (imageryLevel > this._maximumLevel) { imageryLevel = this._maximumLevel; } @@ -252,10 +360,14 @@ function TiledBillboardCollection(options) { const centerTile = makeTile(center, imageryLevel, this._tilingScheme); const topLeftTile = makeTile(topLeft, imageryLevel, this._tilingScheme); - const bottomLeftTile = makeTile(bottomRight, imageryLevel, this._tilingScheme); + const bottomRightTile = makeTile(bottomRight, imageryLevel, this._tilingScheme); - for (let y = topLeftTile.y; y < bottomLeftTile.y + 1; y++) { - for (let x = topLeftTile.x; x < bottomLeftTile.x + 1; x++) { + if (!centerTile || !topLeftTile || !bottomRightTile) { + return; + } + + for (let y = topLeftTile.y; y < bottomRightTile.y + 1; y++) { + for (let x = topLeftTile.x; x < bottomRightTile.x + 1; x++) { const id = `${x}:${y}:${imageryLevel}`; const radiansRectangle = this._tilingScheme.tileXYToRectangle(x, y, imageryLevel); tiles.push({ @@ -310,29 +422,28 @@ function TiledBillboardCollection(options) { ); } if (!this._tileCache[tile.id]) { - this._tileCache[tile.id] = new BillboardsTile({ - id: tile.id, - collection: this._staticBillboardCollection, - style: this._style, - msId: options.msId, - map: this._map, - opacity: options.opacity, - queryable: this._queryable - }); + this._tileCache[tile.id] = this._createTile(tile); this._loadTile(tile) - .then(({ features }) => { - if (!this._removed) { - this._tileCache[tile.id].addFeatures(features) - .then(() => { - if (this._callId === tile.callId) { - this._tileCache[tile.id].show(); - this._map.scene.requestRender(); - } - }); + .then(({ features }) => + (!this._removed && this._tileCache[tile.id]) + ? (this._tileType === 'billboard' + ? this._tileCache[tile.id].addFeatures(features) + : this._tileCache[tile.id].addFeatures(features, this._styleOptions)) + : undefined + ) + .then(() => { + if (!this._removed && this._tileCache[tile.id]) { + if (this._tileType === 'billboard') { + if (this._callId === tile.callId) { + this._tileCache[tile.id].show(); + } + } + this._map.scene.requestRender(); } }) .catch(() => { - if (!this._removed) { + if (!this._removed && this._tileCache[tile.id]) { + this._tileCache[tile.id].destroy(); delete this._tileCache[tile.id]; } }); @@ -347,27 +458,76 @@ function TiledBillboardCollection(options) { }; this._map.camera.moveEnd.addEventListener(this._update); - this._style = options.style; } +TiledBillboardCollection.prototype._createTile = function(tile) { + if (this._tileType === 'billboard') { + return new BillboardsTile({ + id: tile.id, + collection: this._staticBillboardCollection, + style: this._style, + msId: this._msId, + map: this._map, + opacity: this._opacity, + queryable: this._queryable + }); + } + return new FeaturesTile({ + id: tile.id, + msId: this._msId, + map: this._map, + opacity: this._opacity, + queryable: this._queryable, + style: this._style + }); +}; + /** * Destroys the tiled billboard collection and cleans up resources. * Removes all billboards, clears the tile cache, and removes event listeners. */ TiledBillboardCollection.prototype.destroy = function() { this._removed = true; + Object.keys(this._tileCache).forEach(tileId => { + if (this._tileCache[tileId]) { + this._tileCache[tileId].destroy(); + } + }); this._tileCache = {}; this._prevTiles = []; this._map.camera.moveEnd.removeEventListener(this._update); this._staticPrimitivesCollection.removeAll(); this._map.scene.primitives.remove(this._staticPrimitivesCollection); - this._staticBillboardCollection.removeAll(); - this._map.scene.primitives.remove(this._staticBillboardCollection); + if (this._staticBillboardCollection) { + this._staticBillboardCollection.removeAll(); + this._map.scene.primitives.remove(this._staticBillboardCollection); + } +}; + +/** + * Updates opacity for all cached tiles. + * For feature tiles, delegates to GeoJSONStyledFeatures.setOpacity. + * For billboard tiles, stores new opacity for subsequent style applications. + * @param {number} opacity - The new opacity value (0.0 to 1.0) + */ +TiledBillboardCollection.prototype.setOpacity = function(opacity) { + this._opacity = opacity; + Object.keys(this._tileCache).forEach(tileId => { + const tile = this._tileCache[tileId]; + if (tile) { + tile.setOpacity(opacity); + } + }); + if (this._tileType === 'billboard') { + this.setStyleFunction(this._style); + } }; /** * Sets a new style function for the billboards. * Updates the style configuration and applies new styling to existing billboards. + * For billboard tiles, updates billboard properties; for feature tiles, + * re-applies styling to GeoJSONStyledFeatures in each cached tile. * Note: this is tested programatically, currenly not used anywhere. As tile starts to support with serverType other than 'no-vendor', can be used. * @param {Object} newStyle - The new style configuration to apply * @@ -379,7 +539,9 @@ TiledBillboardCollection.prototype.setStyleFunction = function(newStyle) { // Update existing billboards with new style Object.keys(this._tileCache).forEach(tileId => { const tile = this._tileCache[tileId]; - if (tile && tile._billboards) { + if (!tile) return; + + if (this._tileType === 'billboard' && tile._billboards) { // Update the style for this tile tile._style = newStyle; @@ -423,6 +585,22 @@ TiledBillboardCollection.prototype.setStyleFunction = function(newStyle) { } } }); + } else if (this._tileType === 'feature' && tile._styledFeatures) { + tile._style = newStyle; + layerToGeoStylerStyle({ ...this._styleOptions, style: newStyle }) + .then((style) => getStyle(applyDefaultStyleToVectorLayer({ + ...this._styleOptions, + features: tile._features, + style + }), 'cesium')) + .then((styleFunc) => { + if (tile._styledFeatures) { + tile._styledFeatures.setStyleFunction(styleFunc); + } + }) + .catch((error) => { + console.warn('Failed to update feature tile style:', error); + }); } }); }; diff --git a/web/client/utils/cesium/__tests__/TiledBillboardCollection-test.js b/web/client/utils/cesium/__tests__/TiledBillboardCollection-test.js index 0fcd3353548..62261f08964 100644 --- a/web/client/utils/cesium/__tests__/TiledBillboardCollection-test.js +++ b/web/client/utils/cesium/__tests__/TiledBillboardCollection-test.js @@ -192,9 +192,10 @@ describe('TiledBillboardCollection', () => { }); - it('should create TiledBillboardCollection with custom options', () => { + it('should create TiledBillboardCollection with billboard tileType and custom options', () => { const customCollection = new TiledBillboardCollection({ map: mockMap, + tileType: 'billboard', debugTiles: true, tileWidth: 256, minimumLevel: 5, @@ -208,6 +209,7 @@ describe('TiledBillboardCollection', () => { }); customCollection.load(); + expect(customCollection._tileType).toBe('billboard'); expect(customCollection._debugTiles).toBe(true); expect(customCollection._tileWidth).toBe(256); expect(customCollection._minimumLevel).toBe(5); @@ -215,6 +217,15 @@ describe('TiledBillboardCollection', () => { expect(customCollection._style).toEqual({ symbolizers: [{ kind: 'Icon' }] }); }); + it('should default to feature tileType', () => { + const collection = new TiledBillboardCollection({ + map: mockMap, + style: { symbolizers: [{ kind: 'Icon' }] } + }); + + expect(collection._tileType).toBe('feature'); + }); + it('should handle loadTile function that returns features', (done) => { const mockFeatures = [ { @@ -226,17 +237,99 @@ describe('TiledBillboardCollection', () => { const customCollection = new TiledBillboardCollection({ map: mockMap, + tileType: 'billboard', loadTile: () => Promise.resolve({ features: mockFeatures }), style: { symbolizers: [{ kind: 'Icon' }] } }); expect(customCollection._loadTile).toBeTruthy(); - // Test the loadTile function with a mock tile const mockTile = { id: 'test-tile', x: 0, y: 0, z: 10 }; customCollection._loadTile(mockTile).then((result) => { expect(result).toEqual({ features: mockFeatures }); done(); }).catch(done); }); + + it('should only create BillboardCollection when tileType is billboard', () => { + const billboardCollection = new TiledBillboardCollection({ + map: mockMap, + tileType: 'billboard', + style: { symbolizers: [{ kind: 'Icon' }] } + }); + expect(billboardCollection._staticBillboardCollection).toBeTruthy(); + + const featureCollection = new TiledBillboardCollection({ + map: mockMap, + tileType: 'feature', + style: { symbolizers: [{ kind: 'Fill' }] } + }); + expect(featureCollection._staticBillboardCollection).toBeFalsy(); + }); + + it('should create BillboardsTile via _createTile when tileType is billboard', () => { + const collection = new TiledBillboardCollection({ + map: mockMap, + tileType: 'billboard', + style: { symbolizers: [{ kind: 'Icon' }] }, + msId: 'test-layer', + opacity: 0.8 + }); + const tile = collection._createTile({ id: 'test-tile' }); + expect(tile).toBeTruthy(); + expect(tile._collection).toBe(collection._staticBillboardCollection); + expect(tile._style).toEqual({ symbolizers: [{ kind: 'Icon' }] }); + expect(tile._opacity).toBe(0.8); + expect(tile._msId).toBe('test-layer'); + }); + + it('should create FeaturesTile via _createTile when tileType is feature', () => { + const collection = new TiledBillboardCollection({ + map: mockMap, + tileType: 'feature', + style: { symbolizers: [{ kind: 'Fill' }] }, + msId: 'test-layer', + opacity: 0.7 + }); + const tile = collection._createTile({ id: 'test-tile' }); + expect(tile).toBeTruthy(); + expect(tile._style).toEqual({ symbolizers: [{ kind: 'Fill' }] }); + expect(tile._map).toBe(mockMap); + expect(tile._opacity).toBe(0.7); + expect(tile._msId).toBe('test-layer'); + }); + + it('should store styleOptions for feature tileType', () => { + const styleOptions = { geometryType: 'MultiPolygon' }; + const collection = new TiledBillboardCollection({ + map: mockMap, + tileType: 'feature', + style: { symbolizers: [{ kind: 'Fill' }] }, + styleOptions + }); + expect(collection._styleOptions).toEqual(styleOptions); + }); + + it('should update style via setStyleFunction', () => { + const collection = new TiledBillboardCollection({ + map: mockMap, + tileType: 'feature', + style: { symbolizers: [{ kind: 'Fill', color: '#ff0000' }] } + }); + const newStyle = { symbolizers: [{ kind: 'Fill', color: '#00ff00' }] }; + collection.setStyleFunction(newStyle); + expect(collection._style).toEqual(newStyle); + }); + + it('should clean up on destroy', () => { + const collection = new TiledBillboardCollection({ + map: mockMap, + tileType: 'billboard', + style: { symbolizers: [{ kind: 'Icon' }] } + }); + collection.destroy(); + expect(collection._removed).toBe(true); + expect(Object.keys(collection._tileCache).length).toBe(0); + expect(collection._prevTiles.length).toBe(0); + }); }); diff --git a/web/client/utils/mapinfo/__tests__/arcgis-test.js b/web/client/utils/mapinfo/__tests__/arcgis-test.js index 8b84aeebc24..acc43ed5fb5 100644 --- a/web/client/utils/mapinfo/__tests__/arcgis-test.js +++ b/web/client/utils/mapinfo/__tests__/arcgis-test.js @@ -154,4 +154,55 @@ describe('mapinfo arcgis utils', () => { done(); }).catch(done); }); + + it('should return GeoJSON features from getIdentifyFlow for FeatureServer', (done) => { + const layer = { + title: 'Test Layer', + type: 'arcgis-feature', + url: 'https://test.url/FeatureServer', + name: 0 + }; + const bounds = [-76.69, 34.67, -76.34, 34.96]; + mockAxios.onGet().reply((req) => { + try { + expect(req.url).toBe('https://test.url/FeatureServer/0/query'); + expect(req.params.f).toBe('geojson'); + expect(req.params.inSR).toBe(4326); + expect(req.params.outSR).toBe(4326); + expect(req.params.outFields).toBe('*'); + expect(req.params.geometryType).toBe('esriGeometryEnvelope'); + expect(req.params.spatialRel).toBe('esriSpatialRelIntersects'); + } catch (e) { + done(e); + } + return [200, { features: [{ type: 'Feature', properties: { id: 1 }, geometry: { type: 'Point', coordinates: [0, 0] } }] }]; + }); + arcgis.getIdentifyFlow(layer, 'https://test.url/FeatureServer', { bounds }) + .toPromise() + .then((response) => { + expect(response.data.crs).toBe('EPSG:4326'); + expect(response.data.features.length).toBe(1); + expect(response.data.features[0].type).toBe('Feature'); + done(); + }).catch(done); + }); + + it('should default layerId to 0 for FeatureServer when name is undefined', (done) => { + const layer = { + title: 'Test Layer', + type: 'arcgis-feature', + url: 'https://test.url/FeatureServer' + }; + const bounds = [-76.69, 34.67, -76.34, 34.96]; + mockAxios.onGet().reply((req) => { + expect(req.url).toBe('https://test.url/FeatureServer/0/query'); + return [200, { features: [] }]; + }); + arcgis.getIdentifyFlow(layer, 'https://test.url/FeatureServer', { bounds }) + .toPromise() + .then((response) => { + expect(response.data.features).toEqual([]); + done(); + }).catch(done); + }); }); diff --git a/web/client/utils/mapinfo/arcgis.js b/web/client/utils/mapinfo/arcgis.js index d93259b63a1..0e470fb5bdc 100644 --- a/web/client/utils/mapinfo/arcgis.js +++ b/web/client/utils/mapinfo/arcgis.js @@ -11,7 +11,7 @@ import { getCurrentResolution } from '../MapUtils'; import { reproject, getProjectedBBox, reprojectBbox, fitBoundsToProjectionExtent } from '../CoordinatesUtils'; import { isObject, isNil, trimEnd } from 'lodash'; import axios from '../../libs/ajax'; -import { esriToGeoJSONFeature, getQueryLayerIds } from '../ArcGISUtils'; +import { esriToGeoJSONFeature, getQueryLayerIds, isFeatureServerUrl } from '../ArcGISUtils'; export default { buildRequest: (layer, { point, map, currentLocale } = {}) => { @@ -46,6 +46,30 @@ export default { }; }, getIdentifyFlow: (layer, baseURL, { bounds } = {}) => { + const isFeatureServer = isFeatureServerUrl(baseURL); + + if (isFeatureServer) { + const layerId = layer.name !== undefined ? `${layer.name}` : '0'; + const params = { + f: 'geojson', + geometry: bounds.join(','), + geometryType: 'esriGeometryEnvelope', + spatialRel: 'esriSpatialRelIntersects', + inSR: 4326, + outSR: 4326, + outFields: '*' + }; + return Observable.defer(() => + axios.get(`${baseURL}/${layerId}/query`, { params }) + .then((response) => ({ + data: { + crs: 'EPSG:4326', + features: response?.data?.features || [] + } + })) + ); + } + const params = { f: 'json', geometry: bounds.join(','), From 3dadf4bea7faf9debc77ed41b32de90f527ec0a0 Mon Sep 17 00:00:00 2001 From: Suren Date: Thu, 9 Apr 2026 15:43:44 +0530 Subject: [PATCH 2/2] code refactor --- .../map/openlayers/plugins/WFSLayer.js | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/web/client/components/map/openlayers/plugins/WFSLayer.js b/web/client/components/map/openlayers/plugins/WFSLayer.js index 8b540017d68..af2fd406356 100644 --- a/web/client/components/map/openlayers/plugins/WFSLayer.js +++ b/web/client/components/map/openlayers/plugins/WFSLayer.js @@ -35,24 +35,24 @@ const createLoader = (source, options) => (extent, resolution, projection, succe if (options.serverType === ServerTypes.NO_VENDOR) { if (needsCredentials(options)) { - req = new Promise((resolve, reject) => {reject();}); - } else { - if (options?.strategy === 'bbox' || options?.strategy === 'tile') { - // here bbox filter is - const [left, bottom, right, top] = extent; + source.dispatchEvent('vectorerror'); + failure && failure(); + return; + } + if (options?.strategy === 'bbox' || options?.strategy === 'tile') { + const [left, bottom, right, top] = extent; - filters = [{ - spatialField: { - operation: 'BBOX', - geometry: { - projection: proj, - extent: [[left, bottom, right, top]] // use array because bbox is buggy - } + filters = [{ + spatialField: { + operation: 'BBOX', + geometry: { + projection: proj, + extent: [[left, bottom, right, top]] // use array because bbox is buggy } - }]; - } - req = getFeatureLayer(options, {filters, proj}, getConfig(options)); + } + }]; } + req = getFeatureLayer(options, {filters, proj}, getConfig(options)); } else { const params = optionsToVendorParams(options); const config = getConfig(options);