From 9baf447a66b6bb9b3ba1df31461dff8dd2dacc4f Mon Sep 17 00:00:00 2001 From: stefano bovio Date: Tue, 21 May 2024 16:23:29 +0200 Subject: [PATCH] #10040 ArcGIS Interoperability - ArcGIS MapServer Catalog and Layer support (#10330) --------- Co-authored-by: Igor Dimov <90094775+Igi-ID@users.noreply.github.com> --- docs/developer-guide/maps-configuration.md | 35 ++ package.json | 1 + web/client/api/ArcGIS.js | 128 ++++++ web/client/api/__tests__/ArcGIS-test.js | 112 +++++ web/client/api/catalog/ArcGIS.js | 85 ++++ web/client/api/catalog/Model.js | 4 +- web/client/api/catalog/ThreeDTiles.js | 4 +- .../api/catalog/__tests__/ArcGIS-test.js | 81 ++++ web/client/api/catalog/index.js | 4 +- .../catalog/editor/MainFormUtils.js | 3 +- .../geostory/contents/WebPageWrapper.jsx | 3 +- .../map/cesium/__tests__/Layer-test.jsx | 15 + .../map/cesium/plugins/ArcGISLayer.js | 78 ++++ .../components/map/cesium/plugins/index.js | 1 + .../map/leaflet/__tests__/Layer-test.jsx | 16 + .../map/leaflet/plugins/ArcGISLayer.js | 20 + .../components/map/leaflet/plugins/index.js | 3 +- .../map/openlayers/__tests__/Layer-test.jsx | 17 + .../map/openlayers/plugins/ArcGISLayer.js | 43 ++ .../map/openlayers/plugins/index.js | 3 +- .../components/styleeditor/ModelInput.jsx | 6 +- web/client/epics/catalog.js | 15 + web/client/epics/widgets.js | 2 +- web/client/plugins/MetadataExplorer.jsx | 2 +- .../arcgis/arcgis-layer-test-data.json | 104 +++++ .../arcgis/arcgis-test-data.json | 403 ++++++++++++++++++ .../test-resources/wmc/exported-context.wmc | 8 +- web/client/utils/CoordinatesUtils.js | 84 ++-- web/client/utils/LocaleUtils.js | 8 +- web/client/utils/MapInfoUtils.js | 4 +- web/client/utils/PrintUtils.js | 33 ++ web/client/utils/URLUtils.js | 12 +- .../utils/__tests__/CoordinatesUtils-test.js | 18 +- web/client/utils/__tests__/PrintUtils-test.js | 39 +- .../utils/mapinfo/__tests__/arcgis-test.js | 157 +++++++ web/client/utils/mapinfo/arcgis.js | 115 +++++ 36 files changed, 1614 insertions(+), 52 deletions(-) create mode 100644 web/client/api/ArcGIS.js create mode 100644 web/client/api/__tests__/ArcGIS-test.js create mode 100644 web/client/api/catalog/ArcGIS.js create mode 100644 web/client/api/catalog/__tests__/ArcGIS-test.js create mode 100644 web/client/components/map/cesium/plugins/ArcGISLayer.js create mode 100644 web/client/components/map/leaflet/plugins/ArcGISLayer.js create mode 100644 web/client/components/map/openlayers/plugins/ArcGISLayer.js create mode 100644 web/client/test-resources/arcgis/arcgis-layer-test-data.json create mode 100644 web/client/test-resources/arcgis/arcgis-test-data.json create mode 100644 web/client/utils/mapinfo/__tests__/arcgis-test.js create mode 100644 web/client/utils/mapinfo/arcgis.js diff --git a/docs/developer-guide/maps-configuration.md b/docs/developer-guide/maps-configuration.md index 8feed84a74..f83b8ba5dc 100644 --- a/docs/developer-guide/maps-configuration.md +++ b/docs/developer-guide/maps-configuration.md @@ -1305,6 +1305,41 @@ i.e. } ``` +#### ArcGIS MapServer layer + +This layer type allows to render an ArcGIS MapServer layer. +An ArcGIS MapServer source is a composition of different layers to create a map. + +We have two type of configuration, the first one allow to render only a single layer of the source using the `name` property. The `name` property must match a valid layer id of the ArcGIS MapServer service, e.g.: + +```javascript +{ + "type": "arcgis", + "name": "0", + "url": "https://arcgis-example/rest/services/MyService/MapServer" + "title": "Title", + "group": "", + "visibility": true, + "queriable": true, +} +``` + +The second options is to render all the layers of the source service. In this case is important to add also the `options.layers` when the ``queryable` property is true because the id of the listed layer will be used for the query, e.g.: + +```javascript +{ + "type": "arcgis", + "url": "https://arcgis-example/rest/services/MyService/MapServer", + "options": { + "layers": [{ "id": 0 }, { "id": 1 }] + }, + "title": "Title", + "group": "", + "visibility": true, + "queriable": true +} +``` + ## Layer groups Inside the map configuration, near the `layers` entry, you can find also the `groups` entry. This array contains information about the groups in the TOC. diff --git a/package.json b/package.json index 8d2360cf03..450999cd80 100644 --- a/package.json +++ b/package.json @@ -186,6 +186,7 @@ "embed-video": "2.0.4", "es6-object-assign": "1.1.0", "es6-promise": "2.3.0", + "esri-leaflet": "3.0.12", "eventlistener": "0.0.1", "file-saver": "1.3.3", "filtrex": "2.1.0", diff --git a/web/client/api/ArcGIS.js b/web/client/api/ArcGIS.js new file mode 100644 index 0000000000..82cab69d5a --- /dev/null +++ b/web/client/api/ArcGIS.js @@ -0,0 +1,128 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import axios from '../libs/ajax'; +import { reprojectBbox } from '../utils/CoordinatesUtils'; +import trimEnd from 'lodash/trimEnd'; + +let _cache = {}; + +const extentToBoundingBox = (extent) => { + const wkid = extent?.spatialReference?.wkt + ? '4326' + : extent?.spatialReference?.latestWkid || extent?.spatialReference?.wkid; + const projectedExtent = extent?.spatialReference?.wkt + ? reprojectBbox([extent?.xmin, extent?.ymin, extent?.xmax, extent?.ymax], extent.spatialReference.wkt, 'EPSG:4326') + : extent + ? [extent?.xmin, extent?.ymin, extent?.xmax, extent?.ymax] + : null; + + if (projectedExtent) { + return { + bounds: { + minx: projectedExtent[0], + miny: projectedExtent[1], + maxx: projectedExtent[2], + maxy: projectedExtent[3] + }, + crs: `EPSG:${wkid}` + }; + } + return null; +}; + +/** + * Retrieve layer metadata. + * + * @param {string} layerUrl - url of the rest service + * @param {string} layerName - id of the layer + * @returns layer metadata + */ +export const getLayerMetadata = (layerUrl, layerName) => { + return axios.get(`${trimEnd(layerUrl, '/')}/${layerName}`, { params: { f: 'json' }}) + .then(({ data }) => { + const bbox = extentToBoundingBox(data?.extent); + return { + ...(bbox && { bbox }), + data + }; + }); +}; +export const searchAndPaginate = (records, params) => { + const { startPosition, maxRecords, text } = params; + const filteredLayers = records?.filter(layer => !text || layer?.name.toLowerCase().indexOf(text.toLowerCase()) !== -1); + return { + numberOfRecordsMatched: filteredLayers.length, + numberOfRecordsReturned: Math.min(maxRecords, filteredLayers.length), + records: filteredLayers.filter((layer, index) => index >= startPosition - 1 && index < startPosition - 1 + maxRecords) + }; +}; +const getData = (url, params = {}) => { + const request = _cache[url] + ? () => Promise.resolve(_cache[url]) + : () => axios.get(url, { + params: { + f: 'json' + } + }).then(({ data }) => { + _cache[url] = data; + return data; + }); + return request() + .then((data) => { + const { layers } = data || {}; + // Map is similar to WMS GetMap capability for MapServer + const mapExportSupported = (data?.capabilities || '').includes('Map'); + const commonProperties = { + url, + version: data?.currentVersion, + format: (data.supportedImageFormatTypes || '') + .split(',') + .filter(format => /PNG|JPG|GIF/.test(format))[0] || 'PNG32' + }; + const bbox = extentToBoundingBox(data?.fullExtent); + const records = [ + ...((mapExportSupported) ? [ + { + name: data?.documentInfo?.Title || data.name || params?.info?.options?.service?.title || data.mapName, + description: data.description || data.serviceDescription, + bbox, + queryable: (data?.capabilities || '').includes('Data'), + layers, + ...commonProperties + } + ] : []), + ...(mapExportSupported && layers ? layers : []).map((layer) => { + return { + ...layer, + ...commonProperties, + queryable: (data?.capabilities || '').includes('Data') + }; + }) + ]; + return searchAndPaginate(records, params); + }); +}; +/** + * Retrieve arcgis service capabilities. + * @param {string} url - url of the rest service + * @param {number} startPosition - pagination start position + * @param {number} maxRecords - maximum number of records + * @param {string} text - search text + * @param {object} info + * @returns {object} + * - numberOfRecordsMatched + * - numberOfRecordsReturned + * - {array} records - records list + */ +export const getCapabilities = (url, startPosition, maxRecords, text, info) => { + return getData(url, { startPosition, maxRecords, text, info }) + .then(({ numberOfRecordsMatched, numberOfRecordsReturned, records }) => { + return { numberOfRecordsMatched, numberOfRecordsReturned, records }; + }); +}; diff --git a/web/client/api/__tests__/ArcGIS-test.js b/web/client/api/__tests__/ArcGIS-test.js new file mode 100644 index 0000000000..309d362a13 --- /dev/null +++ b/web/client/api/__tests__/ArcGIS-test.js @@ -0,0 +1,112 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +import { getCapabilities, getLayerMetadata } from '../ArcGIS'; +import expect from 'expect'; + +describe('Test ArcGIS API', () => { + const _url = 'base/web/client/test-resources/arcgis/arcgis-test-data.json'; + + it('should extract capabilities from arcgis service data', (done) => { + getCapabilities(_url, 1, 30, '').then((data) => { + const { numberOfRecordsMatched, numberOfRecordsReturned, records } = data; + const { type, url, name, version, defaultVisibility } = records[1]; + try { + expect(numberOfRecordsMatched).toBeTruthy(); + expect(numberOfRecordsMatched).toBe(25); + + expect(numberOfRecordsReturned).toBeTruthy(); + expect(numberOfRecordsReturned).toBe(25); + + expect(type).toBeTruthy(); + expect(type).toBe('Group Layer'); + + expect(url).toBeTruthy(); + expect(url).toBe(_url); + + expect(name).toBeTruthy(); + expect(name).toBe('Active Projects'); + + expect(version).toBeTruthy(); + expect(version).toBe(10.81); + + expect(defaultVisibility).toEqual(true); + } catch (e) { + done(e); + } + done(); + }); + }); + it('should search and paginate arcgis service data', (done) => { + const startPosition = 1; + const maxRecords = 4; + const text = 'Outreach'; + getCapabilities(_url, startPosition, maxRecords, text).then((data) => { + const { numberOfRecordsMatched, numberOfRecordsReturned, records } = data; + try { + expect(numberOfRecordsMatched).toBeTruthy(); + expect(numberOfRecordsMatched).toBe(4); + + expect(numberOfRecordsReturned).toBeTruthy(); + expect(numberOfRecordsReturned).toBe(4); + + records?.forEach(element => { + const { type, url, name, version, defaultVisibility } = element; + + expect(type).toBeTruthy(); + expect(type).toBe('Feature Layer'); + + expect(url).toBeTruthy(); + expect(url).toBe(_url); + + expect(name).toBeTruthy(); + expect(String(name).includes(text)).toBeTruthy(); + + expect(version).toBeTruthy(); + expect(version).toBe(10.81); + + expect(defaultVisibility).toEqual(true); + }); + } catch (e) { + done(e); + } + done(); + }); + }); + it('should retrieve arcgis layer metadata', (done) => { + const layerPath = 'base/web/client/test-resources/arcgis'; + const layerName = 'arcgis-layer-test-data.json'; + getLayerMetadata(layerPath, layerName).then(({ data }) => { + const { advancedQueryCapabilities, supportedQueryFormats, capabilities, extent, name, type } = data; + try { + expect(advancedQueryCapabilities).toBeTruthy(); + + expect(supportedQueryFormats).toBeTruthy(); + expect(supportedQueryFormats).toBe('JSON, geoJSON, PBF'); + + expect(capabilities).toBeTruthy(); + expect(capabilities).toBe('Map,Query'); + + expect(extent).toBeTruthy(); + expect(extent.spatialReference).toBeTruthy(); + expect(extent.xmax).toBeTruthy(); + expect(extent.xmin).toBeTruthy(); + expect(extent.ymax).toBeTruthy(); + expect(extent.ymin).toBeTruthy(); + + expect(name).toBeTruthy(); + expect(name).toBe('Active Projects'); + + expect(type).toBeTruthy(); + expect(type).toBe('Group Layer'); + } catch (e) { + done(e); + } + done(); + }); + }); +}); diff --git a/web/client/api/catalog/ArcGIS.js b/web/client/api/catalog/ArcGIS.js new file mode 100644 index 0000000000..b362f8c032 --- /dev/null +++ b/web/client/api/catalog/ArcGIS.js @@ -0,0 +1,85 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +import { Observable } from 'rxjs'; +import { isValidURL } from '../../utils/URLUtils'; +import { preprocess as commonPreprocess } from './common'; +import { getCapabilities } from '../ArcGIS'; + +function validateUrl(serviceUrl) { + if (isValidURL(serviceUrl)) { + return serviceUrl.includes('MapServer'); + } + return false; +} + +const recordToLayer = (record) => { + if (!record) { + return null; + } + return { + type: 'arcgis', + url: record.url, + title: record.title, + format: record.format, + queryable: record.queryable, + visibility: true, + ...(record.name !== undefined && { + name: `${record.name}` + }), + ...(record.bbox && { + bbox: record.bbox + }), + ...(record.layers && { + options: { + layers: record.layers + } + }) + }; +}; + +const getRecords = (url, startPosition, maxRecords, text, info) => { + return getCapabilities(url, startPosition, maxRecords, text, info); +}; + +export const preprocess = commonPreprocess; +export const testService = (service) => Observable.of(service); +export const textSearch = (url, startPosition, maxRecords, text, info) => getRecords(url, startPosition, maxRecords, text, info); +export const getCatalogRecords = (response) => { + return response?.records + ? response.records.map(record => { + const identifier = `${record.id !== undefined ? `Layer:${record.id}` : 'Group'}:${record.name}`; + return { + serviceType: 'arcgis', + isValid: true, + description: record.description, + title: record.name, + identifier, + url: record.url, + thumbnail: record.thumbnail ?? null, + references: [], + name: record.id, + format: record.format, + layers: record.layers, + queryable: record.queryable, + bbox: record.bbox + }; + }) + : null; +}; +export const getLayerFromRecord = (record, options, asPromise) => { + const layer = recordToLayer(record, options); + return asPromise ? Promise.resolve(layer) : layer; +}; +export const validate = (service) => { + if (service.title && validateUrl(service.url)) { + return Observable.of(service); + } + const error = new Error("catalog.config.notValidURLTemplate"); + // insert valid URL; + throw error; +}; diff --git a/web/client/api/catalog/Model.js b/web/client/api/catalog/Model.js index 694af06a85..2663a3f184 100644 --- a/web/client/api/catalog/Model.js +++ b/web/client/api/catalog/Model.js @@ -7,12 +7,12 @@ */ import { Observable } from 'rxjs'; -import { isValidURLTemplate } from '../../utils/URLUtils'; +import { isValidURL } from '../../utils/URLUtils'; import { preprocess as commonPreprocess } from './common'; import { getCapabilities } from '../Model'; function validateUrl(serviceUrl) { - if (isValidURLTemplate(serviceUrl)) { + if (isValidURL(serviceUrl)) { const parts = serviceUrl.split(/\./g); // remove query params const ext = (parts[parts.length - 1] || '').split(/\?/g)[0]; diff --git a/web/client/api/catalog/ThreeDTiles.js b/web/client/api/catalog/ThreeDTiles.js index ba107034dc..a31b2232db 100644 --- a/web/client/api/catalog/ThreeDTiles.js +++ b/web/client/api/catalog/ThreeDTiles.js @@ -7,12 +7,12 @@ */ import { Observable } from 'rxjs'; -import { isValidURLTemplate } from '../../utils/URLUtils'; +import { isValidURL } from '../../utils/URLUtils'; import { preprocess as commonPreprocess } from './common'; import { getCapabilities } from '../ThreeDTiles'; function validateUrl(serviceUrl) { - if (isValidURLTemplate(serviceUrl)) { + if (isValidURL(serviceUrl)) { const parts = serviceUrl.split(/\./g); // remove query params const ext = (parts[parts.length - 1] || '').split(/\?/g)[0]; diff --git a/web/client/api/catalog/__tests__/ArcGIS-test.js b/web/client/api/catalog/__tests__/ArcGIS-test.js new file mode 100644 index 0000000000..501d688af4 --- /dev/null +++ b/web/client/api/catalog/__tests__/ArcGIS-test.js @@ -0,0 +1,81 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +import { getCatalogRecords, getLayerFromRecord } from '../ArcGIS'; +import expect from 'expect'; + +describe('Test ArcGIS Catalog API', () => { + it('should get catalog records', (done) => { + const testRecord = { + id: 1, + name: "Outreach", + type: "Feature Layer", + description: 'description', + url: "base/web/client/test-resources/arcgis/arcgis-test-data.json" + }; + try { + const records = getCatalogRecords({ records: [ testRecord ] }); + const { serviceType, description, title, identifier, url, name } = records[0]; + + expect(serviceType).toBeTruthy(); + expect(serviceType).toBe('arcgis'); + + expect(description).toBeTruthy(); + expect(description).toBe(testRecord.description); + + expect(title).toBeTruthy(); + expect(title).toBe(testRecord.name); + + expect(url).toBeTruthy(); + expect(url).toBe(testRecord.url); + + expect(identifier).toBeTruthy(); + expect(identifier).toBe(`Layer:${testRecord.id}:${testRecord.name}`); + + expect(name).toBeTruthy(); + expect(name).toBe(testRecord.id); + } catch (e) { + done(e); + } + done(); + }); + it('should get layer from record', (done) => { + const testRecord = { + name: 1, + title: "Outreach", + url: "base/web/client/test-resources/arcgis/arcgis-test-data.json" + }; + try { + const layer = getLayerFromRecord( testRecord ); + const { type, url, name, title, visibility } = layer; + + expect(type).toBeTruthy(); + expect(type).toBe('arcgis'); + + expect(url).toBeTruthy(); + expect(url).toBe(testRecord.url); + + expect(name).toBeTruthy(); + expect(name).toBe(`${testRecord.name}`); + + expect(title).toBeTruthy(); + expect(title).toBe(testRecord.title); + + expect(visibility).toBeTruthy(); + } catch (e) { + done(e); + } + done(); + }); +}); diff --git a/web/client/api/catalog/index.js b/web/client/api/catalog/index.js index db1bb88aec..0f4084ac17 100644 --- a/web/client/api/catalog/index.js +++ b/web/client/api/catalog/index.js @@ -17,6 +17,7 @@ import * as backgrounds from './backgrounds'; import * as threeDTiles from './ThreeDTiles'; import * as cog from './COG'; import * as model from './Model'; // todo: will change to model +import * as arcgis from './ArcGIS'; /** * APIs collection for catalog. * Each entry must implement: @@ -52,5 +53,6 @@ export default { 'backgrounds': backgrounds, '3dtiles': threeDTiles, 'cog': cog, - 'model': model + 'model': model, + 'arcgis': arcgis }; diff --git a/web/client/components/catalog/editor/MainFormUtils.js b/web/client/components/catalog/editor/MainFormUtils.js index 7c2ce4e3f7..3a40191d0e 100644 --- a/web/client/components/catalog/editor/MainFormUtils.js +++ b/web/client/components/catalog/editor/MainFormUtils.js @@ -9,7 +9,8 @@ export const defaultPlaceholder = (service) => { "tms": "e.g. https://mydomain.com/geoserver/gwc/service/tms/1.0.0", "3dtiles": "e.g. https://mydomain.com/tileset.json", "cog": "e.g. https://mydomain.com/cog.tif", - "model": "e.g. https://mydomain.com/filename.ifc" + "model": "e.g. https://mydomain.com/filename.ifc", + "arcgis": "e.g. https://mydomain.com/arcgis/rest/services//MapServer" }; for ( const [key, value] of Object.entries(urlPlaceholder)) { if ( key === service.type) { diff --git a/web/client/components/geostory/contents/WebPageWrapper.jsx b/web/client/components/geostory/contents/WebPageWrapper.jsx index 6854f7e07c..bdb9c2dd9d 100644 --- a/web/client/components/geostory/contents/WebPageWrapper.jsx +++ b/web/client/components/geostory/contents/WebPageWrapper.jsx @@ -18,7 +18,6 @@ import PropTypes from 'prop-types'; import WebPage from './WebPage'; import { compose, withHandlers } from 'recompose'; import { isValidURL } from '../../../utils/URLUtils'; -import { getConfigProp } from '../../../utils/ConfigUtils'; import VisibilityContainer from '../common/VisibilityContainer'; import Loader from '../../misc/Loader'; @@ -104,7 +103,7 @@ export class WebPageWrapper extends React.PureComponent { save = () => { const { url } = this.state; - const error = !isValidURL(url, getConfigProp("GeoStoryValidIframeURLRegex")); + const error = !isValidURL(url); this.setState({ error }); if (!error) { this.props.onChange(url); diff --git a/web/client/components/map/cesium/__tests__/Layer-test.jsx b/web/client/components/map/cesium/__tests__/Layer-test.jsx index 8683e841ac..573d8fd830 100644 --- a/web/client/components/map/cesium/__tests__/Layer-test.jsx +++ b/web/client/components/map/cesium/__tests__/Layer-test.jsx @@ -27,6 +27,7 @@ import '../plugins/VectorLayer'; import '../plugins/WFSLayer'; import '../plugins/TerrainLayer'; import '../plugins/ElevationLayer'; +import '../plugins/ArcGISLayer'; import {setStore} from '../../../../utils/SecurityUtils'; import ConfigUtils from '../../../../utils/ConfigUtils'; @@ -1622,4 +1623,18 @@ describe('Cesium layer', () => { expect(cmp.layer).toBeTruthy(); expect(cmp.layer.getElevation).toBeTruthy(); }); + it('creates a arcgis layer', () => { + const options = { + type: 'arcgis', + url: 'http://arcgis/MapServer/', + name: '1', + visibility: true + }; + ReactDOM.render( + , document.getElementById("container")); + expect(map.imageryLayers.length).toBe(1); + expect(map.imageryLayers._layers[0]._imageryProvider._resource._url).toBe('http://arcgis/MapServer/'); + expect(map.imageryLayers._layers[0]._imageryProvider.layerName).toBe('1'); + }); }); diff --git a/web/client/components/map/cesium/plugins/ArcGISLayer.js b/web/client/components/map/cesium/plugins/ArcGISLayer.js new file mode 100644 index 0000000000..65fab5889f --- /dev/null +++ b/web/client/components/map/cesium/plugins/ArcGISLayer.js @@ -0,0 +1,78 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import Layers from '../../../../utils/cesium/Layers'; +import * as Cesium from 'cesium'; + +// this override is needed to apply the selected format +// and to detect an ImageServer and to apply the correct exportImage path +function buildImageResource(imageryProvider, x, y, level, request) { + const nativeRectangle = imageryProvider._tilingScheme.tileXYToNativeRectangle( + x, + y, + level + ); + const bbox = `${nativeRectangle.west},${nativeRectangle.south},${nativeRectangle.east},${nativeRectangle.north}`; + + const query = { + bbox: bbox, + size: `${imageryProvider._tileWidth},${imageryProvider._tileHeight}`, + format: imageryProvider._format || 'png32', + transparent: true, + f: 'image' + }; + + if ( + imageryProvider._tilingScheme.projection instanceof Cesium.GeographicProjection + ) { + query.bboxSR = 4326; + query.imageSR = 4326; + } else { + query.bboxSR = 3857; + query.imageSR = 3857; + } + if (imageryProvider.layers) { + query.layers = `show:${imageryProvider.layers}`; + } + const resource = imageryProvider._resource.getDerivedResource({ + url: imageryProvider._resource.url.includes('ImageServer') ? 'exportImage' : 'export', + request: request, + queryParameters: query + }); + return resource; +} + +class ArcGisMapAndImageServerImageryProvider extends Cesium.ArcGisMapServerImageryProvider { + constructor(options) { + super(options); + this._format = options.format; + } + requestImage = function( + x, + y, + level, + request + ) { + return Cesium.ImageryProvider.loadImage( + this, + buildImageResource(this, x, y, level, request) + ); + } +} + +Layers.registerType('arcgis', (options) => { + return new ArcGisMapAndImageServerImageryProvider({ + url: options.url, + ...(options.name && { layers: `${options.name}` }), + format: options.format, + // we need to disable this when using layers ids + // the usage of tiles will add an additional request to metadata + // and render the map tiles representing all the layers available in the MapServer + usePreCachedTilesIfAvailable: false + }); +}); diff --git a/web/client/components/map/cesium/plugins/index.js b/web/client/components/map/cesium/plugins/index.js index 8619c265b3..a7d5ea30af 100644 --- a/web/client/components/map/cesium/plugins/index.js +++ b/web/client/components/map/cesium/plugins/index.js @@ -21,5 +21,6 @@ import './WFSLayer'; import './TerrainLayer'; import './ModelLayer'; import './ElevationLayer'; +import './ArcGISLayer'; export default {}; diff --git a/web/client/components/map/leaflet/__tests__/Layer-test.jsx b/web/client/components/map/leaflet/__tests__/Layer-test.jsx index 6fa3ef9e28..ee9440fe3a 100644 --- a/web/client/components/map/leaflet/__tests__/Layer-test.jsx +++ b/web/client/components/map/leaflet/__tests__/Layer-test.jsx @@ -29,6 +29,7 @@ import '../plugins/MapQuest'; import '../plugins/WFSLayer'; import '../plugins/VectorLayer'; import '../plugins/ElevationLayer'; +import '../plugins/ArcGISLayer'; let mockAxios; @@ -1778,4 +1779,19 @@ describe('Leaflet layer', () => { expect(cmp.layer).toBeTruthy(); expect(cmp.layer.getElevation).toBeTruthy(); }); + it('creates a arcgis layer', () => { + const options = { + type: 'arcgis', + url: 'http://arcgis/MapServer/', + name: '1', + visibility: true + }; + const cmp = ReactDOM.render( + , document.getElementById("container")); + expect(cmp).toBeTruthy(); + expect(cmp.layer).toBeTruthy(); + expect(cmp.layer.options.url).toBe('http://arcgis/MapServer/'); + expect(cmp.layer.options.layers[0]).toBe('1'); + }); }); diff --git a/web/client/components/map/leaflet/plugins/ArcGISLayer.js b/web/client/components/map/leaflet/plugins/ArcGISLayer.js new file mode 100644 index 0000000000..e19b66bd1c --- /dev/null +++ b/web/client/components/map/leaflet/plugins/ArcGISLayer.js @@ -0,0 +1,20 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { registerType } from '../../../../utils/leaflet/Layers'; +import * as LEsri from 'esri-leaflet'; + +registerType('arcgis', (options) => { + // dynamicMapLayer works as a single tile request + return LEsri.dynamicMapLayer({ + url: options.url, + opacity: options.opacity || 1, + ...(options.name && { layers: [`${options.name}`] }), + format: options.format + }); +}); diff --git a/web/client/components/map/leaflet/plugins/index.js b/web/client/components/map/leaflet/plugins/index.js index de230afc2c..e8ea5ce020 100644 --- a/web/client/components/map/leaflet/plugins/index.js +++ b/web/client/components/map/leaflet/plugins/index.js @@ -19,5 +19,6 @@ module.exports = { WMSLayer: require('./WMSLayer'), WMTSLayer: require('./WMTSLayer'), VectorLayer: require('./VectorLayer'), - ElevationLayer: require('./ElevationLayer') + ElevationLayer: require('./ElevationLayer'), + ArcGISLayer: require('./ArcGISLayer') }; diff --git a/web/client/components/map/openlayers/__tests__/Layer-test.jsx b/web/client/components/map/openlayers/__tests__/Layer-test.jsx index f4ad0501cc..e79ee0ed97 100644 --- a/web/client/components/map/openlayers/__tests__/Layer-test.jsx +++ b/web/client/components/map/openlayers/__tests__/Layer-test.jsx @@ -25,6 +25,7 @@ import '../plugins/TMSLayer'; import '../plugins/WFSLayer'; import '../plugins/WFS3Layer'; import '../plugins/ElevationLayer'; +import '../plugins/ArcGISLayer'; import { setStore, @@ -3367,4 +3368,20 @@ describe('Openlayers layer', () => { expect(cmp.layer).toBeTruthy(); expect(cmp.layer.get('getElevation')).toBeTruthy(); }); + it('creates a arcgis layer', () => { + const options = { + type: 'arcgis', + url: 'http://arcgis/MapServer/', + name: '1', + visibility: true + }; + ReactDOM.render( + , document.getElementById("container")); + expect(map.getLayers().getLength()).toBe(1); + expect(map.getLayers().item(0).getSource().urls[0]).toBe('http://arcgis/MapServer/'); + expect(map.getLayers().item(0).getSource().params_).toEqual({ + LAYERS: 'show:1' + }); + }); }); diff --git a/web/client/components/map/openlayers/plugins/ArcGISLayer.js b/web/client/components/map/openlayers/plugins/ArcGISLayer.js new file mode 100644 index 0000000000..43f938db39 --- /dev/null +++ b/web/client/components/map/openlayers/plugins/ArcGISLayer.js @@ -0,0 +1,43 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { registerType } from '../../../../utils/openlayers/Layers'; + +import TileLayer from 'ol/layer/Tile'; +import TileArcGISRest from 'ol/source/TileArcGISRest'; + +registerType('arcgis', { + create: (options) => { + return new TileLayer({ + msId: options.id, + opacity: options.opacity !== undefined ? options.opacity : 1, + visible: options.visibility !== false, + zIndex: options.zIndex, + minResolution: options.minResolution, + maxResolution: options.maxResolution, + source: new TileArcGISRest({ + params: { + ...(options.name !== undefined && { LAYERS: `show:${options.name}` }), + ...(options.format && { format: options.format }) + }, + url: options.url + }) + }); + }, + update: (layer, newOptions, oldOptions) => { + if (oldOptions.minResolution !== newOptions.minResolution) { + layer.setMinResolution(newOptions.minResolution === undefined ? 0 : newOptions.minResolution); + } + if (oldOptions.maxResolution !== newOptions.maxResolution) { + layer.setMaxResolution(newOptions.maxResolution === undefined ? Infinity : newOptions.maxResolution); + } + }, + render: () => { + return null; + } +}); diff --git a/web/client/components/map/openlayers/plugins/index.js b/web/client/components/map/openlayers/plugins/index.js index 5523c70116..028fd239f3 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 { WMSLayer: require('./WMSLayer').default, WMTSLayer: require('./WMTSLayer').default, COGLayer: require('./COGLayer').default, - ElevationLayer: require('./ElevationLayer').default + ElevationLayer: require('./ElevationLayer').default, + ArcGISLayer: require('./ArcGISLayer').default }; diff --git a/web/client/components/styleeditor/ModelInput.jsx b/web/client/components/styleeditor/ModelInput.jsx index 799d87c429..f46e70aa00 100644 --- a/web/client/components/styleeditor/ModelInput.jsx +++ b/web/client/components/styleeditor/ModelInput.jsx @@ -10,7 +10,7 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { FormGroup, Glyphicon as GlyphiconRB } from 'react-bootstrap'; import tooltip from '../misc/enhancers/tooltip'; -import { isValidURLTemplate } from '../../utils/URLUtils'; +import { isValidURL } from '../../utils/URLUtils'; import DebouncedFormControl from '../misc/DebouncedFormControl'; const Glyphicon = tooltip(GlyphiconRB); @@ -32,7 +32,7 @@ function ModelInput({ }) { const [moduleUrl, setModuleUrl] = useState(value); - const isValid = isValidURLTemplate(moduleUrl); + const isValid = isValidURL(moduleUrl); useEffect(() => { onError(!isValid); @@ -40,7 +40,7 @@ function ModelInput({ const onModuleSourceChange = (newModelUrl) => { setModuleUrl(newModelUrl); - if (isValidURLTemplate(newModelUrl)) { + if (isValidURL(newModelUrl)) { onChange(newModelUrl); } }; diff --git a/web/client/epics/catalog.js b/web/client/epics/catalog.js index 52b961a957..1aa4a1c129 100644 --- a/web/client/epics/catalog.js +++ b/web/client/epics/catalog.js @@ -75,6 +75,7 @@ import { extractGeometryType } from '../utils/WFSLayerUtils'; import { createDefaultStyle } from '../utils/StyleUtils'; import { removeDuplicateLines } from '../utils/StringUtils'; import { logError } from '../utils/DebugUtils'; +import { getLayerMetadata } from '../api/ArcGIS'; const onErrorRecordSearch = (isNewService, errObj) => { logError({message: errObj}); @@ -346,6 +347,20 @@ export default (API) => ({ ); } } + if (layer.type === 'arcgis' && layer.name !== undefined) { + return Rx.Observable.defer(() => getLayerMetadata(layer.url, layer.name)) + .switchMap(({ data, ...layerOptions }) => { + const newLayer = { + ...layer, + ...layerOptions + }; + return Rx.Observable.from([ + addNewLayer({...newLayer, id}), + ...(newLayer.bbox ? [zoomToExtent(newLayer.bbox.bounds, newLayer.bbox.crs)] : []) + ]); + }) + .catch((e) => Rx.Observable.of(describeError(layer, e))); + } return Rx.Observable.from(actions); }) .catch(() => { diff --git a/web/client/epics/widgets.js b/web/client/epics/widgets.js index d08053aa6a..e1c5b71cc3 100644 --- a/web/client/epics/widgets.js +++ b/web/client/epics/widgets.js @@ -333,7 +333,7 @@ export const onOpenFilterEditorEpic = (action$, store) => .switchMap(() => { const state = store.getState(); const layer = getWidgetLayer(state); - const zoom = defaultGetZoomForExtent(reprojectBbox(layer.bbox.bounds, "EPSG:4326", "EPSG:3857", true), DEFAULT_MAP_SETTINGS.size, 0, 21, 96, DEFAULT_MAP_SETTINGS.resolutions); + const zoom = defaultGetZoomForExtent(reprojectBbox(layer.bbox.bounds, "EPSG:4326", "EPSG:3857"), DEFAULT_MAP_SETTINGS.size, 0, 21, 96, DEFAULT_MAP_SETTINGS.resolutions); const map = { ...DEFAULT_MAP_SETTINGS, zoom, diff --git a/web/client/plugins/MetadataExplorer.jsx b/web/client/plugins/MetadataExplorer.jsx index 58806605dc..860fe457b5 100644 --- a/web/client/plugins/MetadataExplorer.jsx +++ b/web/client/plugins/MetadataExplorer.jsx @@ -185,7 +185,7 @@ class MetadataExplorerComponent extends React.Component { static defaultProps = { id: "mapstore-metadata-explorer", - serviceTypes: [{ name: "csw", label: "CSW" }, { name: "wms", label: "WMS" }, { name: "wmts", label: "WMTS" }, { name: "tms", label: "TMS", allowedProviders: DEFAULT_ALLOWED_PROVIDERS }, { name: "wfs", label: "WFS" }, { name: "3dtiles", label: "3D Tiles" }, {name: "model", label: "IFC Model"}], + serviceTypes: [{ name: "csw", label: "CSW" }, { name: "wms", label: "WMS" }, { name: "wmts", label: "WMTS" }, { name: "tms", label: "TMS", allowedProviders: DEFAULT_ALLOWED_PROVIDERS }, { name: "wfs", label: "WFS" }, { name: "3dtiles", label: "3D Tiles" }, {name: "model", label: "IFC Model"}, { name: "arcgis", label: "ArcGIS MapServer" }], active: false, wrap: false, modal: true, diff --git a/web/client/test-resources/arcgis/arcgis-layer-test-data.json b/web/client/test-resources/arcgis/arcgis-layer-test-data.json new file mode 100644 index 0000000000..71154371b0 --- /dev/null +++ b/web/client/test-resources/arcgis/arcgis-layer-test-data.json @@ -0,0 +1,104 @@ +{ + "currentVersion": 10.81, + "cimVersion": "2.6.0", + "id": 1, + "name": "Active Projects", + "type": "Group Layer", + "description": "", + "geometryType": null, + "copyrightText": "", + "parentLayer": null, + "subLayers": [ + { + "id": 11, + "name": "Post-Prelim" + }, + { + "id": 12, + "name": "Outreach" + }, + { + "id": 13, + "name": "Preliminary" + }, + { + "id": 14, + "name": "Data Development" + }, + { + "id": 15, + "name": "Discovery" + } + ], + "minScale": 0, + "maxScale": 0, + "defaultVisibility": true, + "extent": { + "xmin": -1.98519659761E7, + "ymin": -1643352.0163999982, + "xmax": 1.6269834825400002E7, + "ymax": 1.0537038417599998E7, + "spatialReference": { + "wkid": 102100, + "latestWkid": 3857, + "xyTolerance": 0.001, + "zTolerance": 0.001, + "mTolerance": 0.001, + "falseX": -20037700, + "falseY": -30241100, + "xyUnits": 10000, + "falseZ": -100000, + "zUnits": 10000, + "falseM": -100000, + "mUnits": 10000 + } + }, + "hasAttachments": false, + "htmlPopupType": "esriServerHTMLPopupTypeNone", + "displayField": "", + "typeIdField": null, + "subtypeFieldName": null, + "subtypeField": null, + "defaultSubtypeCode": null, + "fields": null, + "geometryField": {}, + "indexes": [], + "subtypes": [], + "relationships": [], + "canModifyLayer": false, + "canScaleSymbols": false, + "hasLabels": false, + "capabilities": "Map,Query", + "supportsStatistics": false, + "supportsExceedsLimitStatistics": false, + "supportsAdvancedQueries": false, + "supportedQueryFormats": "JSON, geoJSON, PBF", + "isDataVersioned": false, + "ownershipBasedAccessControlForFeatures": {"allowOthersToQuery": true}, + "useStandardizedQueries": true, + "advancedQueryCapabilities": { + "useStandardizedQueries": true, + "supportsStatistics": false, + "supportsPercentileStatistics": false, + "supportsHavingClause": false, + "supportsOrderBy": false, + "supportsDistinct": false, + "supportsCountDistinct": false, + "supportsPagination": false, + "supportsTrueCurve": true, + "supportsQueryWithDatumTransformation": true, + "supportsReturningQueryExtent": true, + "supportsQueryWithDistance": true, + "supportsSqlExpression": false + }, + "supportsDatumTransformation": true, + "dateFieldsTimeReference": null, + "hasMetadata": true, + "isDataArchived": false, + "archivingInfo": { + "supportsQueryWithHistoricMoment": false, + "startArchivingMoment": -1 + }, + "supportsCoordinatesQuantization": true, + "supportsDynamicLegends": false + } \ No newline at end of file diff --git a/web/client/test-resources/arcgis/arcgis-test-data.json b/web/client/test-resources/arcgis/arcgis-test-data.json new file mode 100644 index 0000000000..3b0d956508 --- /dev/null +++ b/web/client/test-resources/arcgis/arcgis-test-data.json @@ -0,0 +1,403 @@ +{ + "currentVersion": 10.81, + "cimVersion": "2.6.0", + "serviceDescription": "", + "mapName": "Layers", + "description": "", + "copyrightText": "", + "supportsDynamicLayers": true, + "layers": [ + { + "id": 1, + "name": "Active Projects", + "parentLayerId": -1, + "defaultVisibility": true, + "subLayerIds": [ + 11, + 12, + 13, + 14, + 15 + ], + "minScale": 0, + "maxScale": 0, + "type": "Group Layer", + "supportsDynamicLegends": false + }, + { + "id": 11, + "name": "Post-Prelim", + "parentLayerId": 1, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 12, + "name": "Outreach", + "parentLayerId": 1, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 13, + "name": "Preliminary", + "parentLayerId": 1, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 14, + "name": "Data Development", + "parentLayerId": 1, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 15, + "name": "Discovery", + "parentLayerId": 1, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 2, + "name": "On-hold Projects", + "parentLayerId": -1, + "defaultVisibility": false, + "subLayerIds": [ + 21, + 22, + 23, + 24, + 25 + ], + "minScale": 0, + "maxScale": 0, + "type": "Group Layer", + "supportsDynamicLegends": false + }, + { + "id": 21, + "name": "Post-Prelim", + "parentLayerId": 2, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 22, + "name": "Outreach", + "parentLayerId": 2, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 23, + "name": "Preliminary", + "parentLayerId": 2, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 24, + "name": "Data Development", + "parentLayerId": 2, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 25, + "name": "Discovery", + "parentLayerId": 2, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 3, + "name": "Completed Projects", + "parentLayerId": -1, + "defaultVisibility": false, + "subLayerIds": [ + 31, + 32, + 33, + 34, + 35 + ], + "minScale": 0, + "maxScale": 0, + "type": "Group Layer", + "supportsDynamicLegends": false + }, + { + "id": 31, + "name": "Post-Prelim", + "parentLayerId": 3, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 32, + "name": "Outreach", + "parentLayerId": 3, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 33, + "name": "Preliminary", + "parentLayerId": 3, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 34, + "name": "Data Development", + "parentLayerId": 3, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 35, + "name": "Discovery", + "parentLayerId": 3, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 4, + "name": "Closed Projects", + "parentLayerId": -1, + "defaultVisibility": false, + "subLayerIds": [ + 41, + 42, + 43, + 44, + 45 + ], + "minScale": 0, + "maxScale": 0, + "type": "Group Layer", + "supportsDynamicLegends": false + }, + { + "id": 41, + "name": "Post-Prelim", + "parentLayerId": 4, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 42, + "name": "Outreach", + "parentLayerId": 4, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 43, + "name": "Preliminary", + "parentLayerId": 4, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 44, + "name": "Data Development", + "parentLayerId": 4, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + }, + { + "id": 45, + "name": "Discovery", + "parentLayerId": 4, + "defaultVisibility": true, + "subLayerIds": null, + "minScale": 0, + "maxScale": 0, + "type": "Feature Layer", + "geometryType": "esriGeometryPolygon", + "supportsDynamicLegends": true + } + ], + "tables": [], + "spatialReference": { + "wkid": 102100, + "latestWkid": 3857, + "xyTolerance": 0.001, + "zTolerance": 0.001, + "mTolerance": 0.001, + "falseX": -20037700, + "falseY": -30241100, + "xyUnits": 10000, + "falseZ": -100000, + "zUnits": 10000, + "falseM": -100000, + "mUnits": 10000 + }, + "singleFusedMapCache": false, + "initialExtent": { + "xmin": -1.509001841697525E7, + "ymin": 3590963.222914189, + "xmax": -7361356.073269645, + "ymax": 9234581.29276976, + "spatialReference": { + "wkid": 102100, + "latestWkid": 3857, + "xyTolerance": 0.001, + "zTolerance": 0.001, + "mTolerance": 0.001, + "falseX": -20037700, + "falseY": -30241100, + "xyUnits": 10000, + "falseZ": -100000, + "zUnits": 10000, + "falseM": -100000, + "mUnits": 10000 + } + }, + "fullExtent": { + "xmin": -1.98519659761E7, + "ymin": -1643352.0163999982, + "xmax": 1.6269834825400002E7, + "ymax": 1.0537038417599998E7, + "spatialReference": { + "wkid": 102100, + "latestWkid": 3857, + "xyTolerance": 0.001, + "zTolerance": 0.001, + "mTolerance": 0.001, + "falseX": -20037700, + "falseY": -30241100, + "xyUnits": 10000, + "falseZ": -100000, + "zUnits": 10000, + "falseM": -100000, + "mUnits": 10000 + } + }, + "minScale": 0, + "maxScale": 0, + "units": "esriMeters", + "supportedImageFormatTypes": "PNG32,PNG24,PNG,JPG,DIB,TIFF,EMF,PS,PDF,GIF,SVG,SVGZ,BMP", + "documentInfo": { + "Title": "Untitled.aprx", + "Author": "", + "Comments": "", + "Subject": "", + "Category": "", + "AntialiasingMode": "None", + "TextAntialiasingMode": "Force", + "Keywords": "" + }, + "capabilities": "Map,Query,Data", + "supportedQueryFormats": "JSON, geoJSON, PBF", + "exportTilesAllowed": false, + "referenceScale": 0.0, + "supportsDatumTransformation": true, + "archivingInfo": {"supportsHistoricMoment": false}, + "supportsClipping": true, + "supportsSpatialFilter": true, + "supportsQueryDataElements": true, + "maxRecordCount": 1000, + "maxImageHeight": 4096, + "maxImageWidth": 4096, + "supportedExtensions": "", + "resampling": false + } \ No newline at end of file diff --git a/web/client/test-resources/wmc/exported-context.wmc b/web/client/test-resources/wmc/exported-context.wmc index 08e18a5f23..de46607aa5 100644 --- a/web/client/test-resources/wmc/exported-context.wmc +++ b/web/client/test-resources/wmc/exported-context.wmc @@ -67,7 +67,7 @@ - + false true false @@ -91,7 +91,7 @@ - + true true false @@ -130,7 +130,7 @@ - + false true false @@ -149,7 +149,7 @@ image/png - + false true false diff --git a/web/client/utils/CoordinatesUtils.js b/web/client/utils/CoordinatesUtils.js index 37d6314e34..11c600ae65 100644 --- a/web/client/utils/CoordinatesUtils.js +++ b/web/client/utils/CoordinatesUtils.js @@ -23,7 +23,8 @@ import { isNumber, slice, head, - last + last, + isNaN } from 'lodash'; import turfCircle from '@turf/circle'; @@ -383,33 +384,38 @@ export const normalizeLng = (lng) => { * @param dest {string} SRS of the returned bbox * @return {array} [minx, miny, maxx, maxy] */ -export const reprojectBbox = function(bbox, source, dest, normalize = true) { - let points; - if (isArray(bbox)) { - points = { - sw: [bbox[0], bbox[1]], - ne: [bbox[2], bbox[3]] - }; - } else { - points = { - sw: [bbox.minx, bbox.miny], - ne: [bbox.maxx, bbox.maxy] - }; +export const reprojectBbox = (bbox, source, dest) => { + const sourceProj = source && Proj4js.defs(source); + const destProj = dest && Proj4js.defs(dest); + if (!(sourceProj && destProj)) { + return null; } - let projPoints = []; - for (let p in points) { - if (points.hasOwnProperty(p)) { - const projected = CoordinatesUtils.reproject(points[p], source, dest, normalize); - if (projected) { - let {x, y} = projected; - projPoints.push(x); - projPoints.push(y); - } else { - return null; - } + const points = isArray(bbox) + ? [[bbox[0], bbox[1]], [bbox[2], bbox[3]]] + : [[bbox.minx, bbox.miny], [bbox.maxx, bbox.maxy]]; + const crsMaxExtent = getProjection(dest).extent; + const maxExtent = [[crsMaxExtent[0], crsMaxExtent[1]], [crsMaxExtent[2], crsMaxExtent[3]]]; + const projectedPoints = points.map((point, idx) => { + // return projected point not normalized + // so we can detect NaN or values + const { x, y } = CoordinatesUtils.reproject(point, source, dest, false) || {}; + const [defaultX, defaultY] = maxExtent[idx]; + if (x !== undefined || y !== undefined) { + // if value is NaN probably is because it is outside the maximum extent + // normalize option for reproject is falling back to 0 that could works for single point + // but not bounds + return [ + isNaN(x) || x === undefined ? defaultX : x, + isNaN(y) || y === undefined ? defaultY : y + ]; } - } - return projPoints; + // if null could means that latitude conversion could not be computed + // in particular EPSG:4326 to EPSG:3857 with lat -90 or 90 + // we could fallback on the limit of the current projection + return [defaultX, defaultY]; + }); + const extent = projectedPoints.flat(); + return extent.length === 4 ? extent : null; }; export const bboxToFeatureGeometry = (bbox) => { const bboxObj = isArray(bbox) ? { @@ -1079,6 +1085,32 @@ export const checkIfLayerFitsExtentForProjection = (layer = {}) => { return ((minx >= crsMinX) && (minY >= crsMinY) && (maxX <= crsMaxX) && (maxY <= crsMaxY)); }; +/** + * Return new bounds fitting the maximum extent of a given projection + * @param bounds {object|array} minimum and maximum value of the bounds [minx, miny, maxx, maxy] or {minx, miny, maxx, maxy} + * @param projection {string} projection code of the bounds + * @return {object|array} parsed bounds that fit the given projection maximum extent + */ +export const fitBoundsToProjectionExtent = (bounds, projection) => { + const [crsMinX, crsMinY, crsMaxX, crsMaxY] = getProjection(projection).extent; + if (isArray(bounds)) { + const [ minx, miny, maxx, maxy ] = bounds; + return [ + minx < crsMinX ? crsMinX : minx, + miny < crsMinY ? crsMinY : miny, + maxx > crsMaxX ? crsMaxX : maxx, + maxy > crsMaxY ? crsMaxY : maxy + ]; + } + const { minx, miny, maxx, maxy } = bounds; + return { + minx: minx < crsMinX ? crsMinX : minx, + miny: miny < crsMinY ? crsMinY : miny, + maxx: maxx > crsMaxX ? crsMaxX : maxx, + maxy: maxy > crsMaxY ? crsMaxY : maxy + }; +}; + /** * Generates longitude and latitude value from the point * @param {object} point with latlng data diff --git a/web/client/utils/LocaleUtils.js b/web/client/utils/LocaleUtils.js index b809edb698..82f1867dae 100644 --- a/web/client/utils/LocaleUtils.js +++ b/web/client/utils/LocaleUtils.js @@ -1,3 +1,4 @@ +import { isString } from 'lodash'; /* * Copyright 2018, GeoSolutions Sas. @@ -188,7 +189,12 @@ export const getDateFormat = (locale) => { return DATE_FORMATS[locale] || DATE_FORMATS.default; }; export const getMessageById = function(messages, msgId) { - var message = messages; + if (!isString(msgId)) { + console.warn('Expected String, but got ' + typeof msgId); + return ''; + } + + let message = messages; msgId.split('.').forEach(part => { message = message ? message[part] : null; }); diff --git a/web/client/utils/MapInfoUtils.js b/web/client/utils/MapInfoUtils.js index 87ef36aa55..9a70038035 100644 --- a/web/client/utils/MapInfoUtils.js +++ b/web/client/utils/MapInfoUtils.js @@ -20,6 +20,7 @@ import wmts from './mapinfo/wmts'; import vector from './mapinfo/vector'; import threeDTiles from './mapinfo/threeDTiles'; import model from './mapinfo/model'; +import arcgis from './mapinfo/arcgis'; let MapInfoUtils; /** * Map of info modes which are used to display feature info data (identify tools). @@ -370,7 +371,8 @@ export const services = { 'wmts': wmts, 'vector': vector, '3dtiles': threeDTiles, - 'model': model + 'model': model, + 'arcgis': arcgis }; /** * To get the custom viewer with the given type diff --git a/web/client/utils/PrintUtils.js b/web/client/utils/PrintUtils.js index 0d0d9d10bb..6b3aa53322 100644 --- a/web/client/utils/PrintUtils.js +++ b/web/client/utils/PrintUtils.js @@ -34,6 +34,7 @@ import head from "lodash/head"; import isNil from "lodash/isNil"; import get from "lodash/get"; import min from "lodash/min"; +import trimEnd from 'lodash/trimEnd'; import { getGridGeoJson } from "./grids/MapGridsUtils"; @@ -878,6 +879,38 @@ export const specCreators = { }; } + }, + arcgis: { + map: (layer, spec, state) => { + const layout = head(state?.print?.capabilities.layouts.filter((l) => l.name === getLayoutName(spec))); + const ratio = getResolutionMultiplier(layout?.map?.width, spec.size?.width ?? 370) ?? 1; + const resolutions = getResolutionsForProjection(spec.projection).map(r => r * ratio); + const resolution = resolutions[spec.scaleZoom]; + const extent = calculateExtent(spec.center, resolution, spec.size, spec.projection); + const sr = spec.projection + .replace('EPSG:', '') + .replace('900913', '3857'); + return { + type: 'Image', + opacity: layer.opacity ?? 1.0, + name: layer.name ?? -1, + baseURL: url.format({ + ...url.parse(`${trimEnd(layer.url, '/')}/${layer.url.includes('ImageServer') ? 'exportImage' : 'export'}`), + query: { + F: 'image', + ...(layer.name !== undefined && { LAYERS: `show:${layer.name}` }), + FORMAT: layer.format || 'PNG32', + TRANSPARENT: true, + SIZE: `${layout?.map?.width},${layout?.map?.height}`, + bbox: extent.join(','), + BBOXSR: sr, + IMAGESR: sr, + DPI: 90 + } + }), + extent + }; + } } }; diff --git a/web/client/utils/URLUtils.js b/web/client/utils/URLUtils.js index dc3d47972b..29c3909186 100644 --- a/web/client/utils/URLUtils.js +++ b/web/client/utils/URLUtils.js @@ -87,9 +87,15 @@ export const getQueryParams = (url) => { * @param {string} url - url to validate * @param {RegExp} regexp - optional custom regexp */ -export const isValidURL = (url, regexp = /^(http(s{0,1}):\/\/)+?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/) => { - const regex = new RegExp(regexp); - return regex.test(url); +export const isValidURL = (url, regexp) => { + if (regexp) { + const regex = new RegExp(regexp); + return regex.test(url); + } + return URL.canParse(url, + url.indexOf('/') === 0 + ? window?.location?.href + : undefined); }; /** diff --git a/web/client/utils/__tests__/CoordinatesUtils-test.js b/web/client/utils/__tests__/CoordinatesUtils-test.js index 8d73c4a703..502eb1bf3b 100644 --- a/web/client/utils/__tests__/CoordinatesUtils-test.js +++ b/web/client/utils/__tests__/CoordinatesUtils-test.js @@ -41,7 +41,8 @@ import { convertRadianToDegrees, convertDegreesToRadian, transformExtentToObj, - transformExtentToArray + transformExtentToArray, + fitBoundsToProjectionExtent } from '../CoordinatesUtils'; import Proj4js from 'proj4'; @@ -102,6 +103,11 @@ describe('CoordinatesUtils', () => { expect(projbbox[i]).toNotBe(bbox[i]); } }); + it('should convert with reprojectBbox using max extent as fallback value', () => { + const bbox = reprojectBbox([44, 60, 45, 90], 'EPSG:4326', 'EPSG:900913'); + expect(bbox[2] > bbox[0]).toBe(true); + expect(bbox[3] > bbox[1]).toBe(true); + }); it('test getAvailableCRS', () => { const defs = Object.keys(Proj4js.defs); const toCheck = Object.keys(getAvailableCRS()); @@ -961,4 +967,14 @@ describe('CoordinatesUtils', () => { const val = valueIsApproximatelyEqual(rad, 100); expect(val).toBe(true); }); + it('bounds should not exceed the maximum extent of the projection', ()=> { + expect(fitBoundsToProjectionExtent([-10, -10, 10, 10 ], 'EPSG:4326')) + .toEqual([-10, -10, 10, 10]); + expect(fitBoundsToProjectionExtent([-190, -91, 190, 91 ], 'EPSG:4326')) + .toEqual([-180, -90, 180, 90]); + expect(fitBoundsToProjectionExtent({ minx: -10, miny: -10, maxx: 10, maxy: 10 }, 'EPSG:4326')) + .toEqual({ minx: -10, miny: -10, maxx: 10, maxy: 10 }); + expect(fitBoundsToProjectionExtent({ minx: -190, miny: -91, maxx: 190, maxy: 91 }, 'EPSG:4326')) + .toEqual({ minx: -180, miny: -90, maxx: 180, maxy: 90 }); + }); }); diff --git a/web/client/utils/__tests__/PrintUtils-test.js b/web/client/utils/__tests__/PrintUtils-test.js index ad9d53d65d..dfc976324d 100644 --- a/web/client/utils/__tests__/PrintUtils-test.js +++ b/web/client/utils/__tests__/PrintUtils-test.js @@ -299,6 +299,15 @@ const mapFishVectorLayer = { } }; +const arcgisLayer = { + type: 'arcgis', + title: 'Title', + url: 'http://argis/MapServer', + name: 0, + format: 'PNG', + visibility: true +}; + const testSpec = { "antiAliasing": true, "iconSize": 24, @@ -635,7 +644,8 @@ describe('PrintUtils', () => { "id": "mapnik__0", "loading": false, "loadingError": false - } + }, + arcgis: arcgisLayer }; it('check opacity for all layers to be 1 for undefined, therwise its value', () => { Object.keys(specCreators).map( k => { @@ -783,6 +793,33 @@ describe('PrintUtils', () => { expect(layerSpec.format).toBe("png"); // format is mandatory }); }); + describe('ArcGIS', () => { + it('ArcGIS MapServer', () => { + const testLayer = arcgisLayer; + const layerSpec = specCreators.arcgis.map( + testLayer, + { + projection: "EPSG:900913", + sheet: 'A4', + size: { width: 250, height: 250 }, + scaleZoom: 10, + center: { x: 0, y: 0, crs: "EPSG:3857" } + }, + { + print: { + capabilities: { + layouts: [{ name: 'A4_no_legend', map: { width: 500, height: 500 } }] + } + } + }); + expect(layerSpec.type).toEqual("Image"); + expect(layerSpec.opacity).toBeTruthy(); + expect(decodeURIComponent(layerSpec.baseURL)) + .toBe('http://argis/MapServer/export?F=image&LAYERS=show:0&FORMAT=PNG&TRANSPARENT=true&SIZE=500,500&bbox=-50958.01884969075,-50958.018849691456,50958.01884969075,50958.018849690045&BBOXSR=3857&IMAGESR=3857&DPI=90'); + expect(layerSpec.name).toBe(0); + expect(layerSpec.extent).toEqual([ -50958.01884969075, -50958.018849691456, 50958.01884969075, 50958.018849690045 ]); + }); + }); describe('transformers', () => { beforeEach(() => { resetDefaultPrintingService(); diff --git a/web/client/utils/mapinfo/__tests__/arcgis-test.js b/web/client/utils/mapinfo/__tests__/arcgis-test.js new file mode 100644 index 0000000000..51f9ddebae --- /dev/null +++ b/web/client/utils/mapinfo/__tests__/arcgis-test.js @@ -0,0 +1,157 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import expect from 'expect'; +import arcgis from '../arcgis'; +import axios from '../../../libs/ajax'; +import MockAdapter from 'axios-mock-adapter'; + +let mockAxios; + +describe('mapinfo arcgis utils', () => { + beforeEach((done) => { + mockAxios = new MockAdapter(axios); + setTimeout(done); + }); + + afterEach((done) => { + mockAxios.restore(); + setTimeout(done); + }); + + it('should create a request with empty features', () => { + const layer = { + title: "Title", + type: 'arcgis', + url: "https://test.url", + visibility: true, + name: 0 + }; + const map = { + "projection": "EPSG:900913", + "zoom": 6, + "resolution": 2445.98490512564 + }; + const point = { + "latlng": { + "lat": 34.81411782090338, + "lng": -76.51422049947232 + }, + "intersectedFeatures": [] + }; + const request = arcgis.buildRequest(layer, { point, map, currentLocale: 'en-US' }); + expect(request).toEqual({ + request: { + outputFormat: 'application/json', + bounds: [ + -76.69000174947232, + 34.669673599384076, + -76.33843924947232, + 34.95830926327919 + ] + }, + metadata: { + title: 'Title' + }, + url: 'https://test.url' + }); + }); + it('should return the response object from getIdentifyFlow', (done) => { + const layer = { + title: "Title", + type: 'arcgis', + url: "https://test.url", + visibility: true, + name: 0 + }; + const bounds = [ + -76.69000174947232, + 34.669673599384076, + -76.33843924947232, + 34.95830926327919 + ]; + mockAxios.onGet().reply((req) => { + try { + const parts = req.url.split('?url='); + expect(decodeURIComponent(parts[parts.length - 1])) + .toBe('https://test.url/0/query?f=json&geometry=-76.69000174947232%2C34.669673599384076%2C-76.33843924947232%2C34.95830926327919&inSR=4326&outSR=4326&outFields=*'); + } catch (e) { + done(e); + } + return [200, { features: [{ attributes: { name: 'Feature01' }, geometry: { x: 0, y: 0 } }] }]; + }); + arcgis.getIdentifyFlow(layer, 'https://test.url', { bounds }) + .toPromise() + .then((response) => { + expect(response).toEqual({ + data: { + crs: 'EPSG:4326', + features: [{ + type: 'Feature', + properties: { name: 'Feature01' }, + geometry: { + type: 'Point', + coordinates: [0, 0, 0] + } + }] + } + }); + done(); + }).catch(done); + }); + it('should return the response object from getIdentifyFlow with layer group', (done) => { + const layer = { + title: "Title", + type: 'arcgis', + url: "https://test.url", + visibility: true, + options: { + layers: [ + { id: 0 }, + { id: 1 } + ] + } + }; + const bounds = [ + -76.69000174947232, + 34.669673599384076, + -76.33843924947232, + 34.95830926327919 + ]; + let count = 0; + mockAxios.onGet().reply(() => { + count++; + return [200, { features: [{ attributes: { name: `Feature0${count}` }, geometry: { x: count, y: 0 } }] }]; + }); + arcgis.getIdentifyFlow(layer, 'https://test.url', { bounds }) + .toPromise() + .then((response) => { + expect(response).toEqual({ + data: { + crs: 'EPSG:4326', + features: [{ + type: 'Feature', + properties: { name: 'Feature01' }, + geometry: { + type: 'Point', + coordinates: [1, 0, 0] + } + }, { + type: 'Feature', + properties: { name: 'Feature02' }, + geometry: { + type: 'Point', + coordinates: [2, 0, 0] + } + }] + } + }); + done(); + }).catch(done); + }); +}); diff --git a/web/client/utils/mapinfo/arcgis.js b/web/client/utils/mapinfo/arcgis.js new file mode 100644 index 0000000000..4ae87263b3 --- /dev/null +++ b/web/client/utils/mapinfo/arcgis.js @@ -0,0 +1,115 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Observable } from 'rxjs'; +import { getCurrentResolution } from '../MapUtils'; +import { reproject, getProjectedBBox, reprojectBbox, fitBoundsToProjectionExtent } from '../CoordinatesUtils'; +import { isObject, isNil, trimEnd } from 'lodash'; +import axios from '../../libs/ajax'; + +const esriToGeoJSONGeometry = (geometry) => { + if (!geometry) { + return null; + } + if (geometry.x !== undefined && geometry.y !== undefined) { + return { + type: 'Point', + coordinates: [geometry.x, geometry.y, geometry.z || 0] + }; + } + if (geometry.points) { + return { + type: 'MultiPoint', + coordinates: geometry.points.map(([x, y, z]) => [x, y, z || 0]) + }; + } + if (geometry.paths) { + return { + type: 'MultiLineString', + coordinates: geometry.paths.map(path => path.map(([x, y, z]) => [x, y, z || 0])) + }; + } + if (geometry.rings) { + return { + type: 'Polygon', + coordinates: geometry.rings.map(ring => ring.map(([x, y, z]) => [x, y, z || 0])) + }; + } + return null; +}; + +const esriToGeoJSONFeature = (feature) => { + return { + type: 'Feature', + properties: feature.attributes, + geometry: esriToGeoJSONGeometry(feature.geometry) + }; +}; + +export default { + buildRequest: (layer, { point, map, currentLocale } = {}) => { + const heightBBox = 16; + const widthBBox = 16; + const size = [heightBBox, widthBBox]; + const rotation = 0; + const resolution = isNil(map.resolution) + ? getCurrentResolution(Math.ceil(map.zoom), 0, 21, 96) + : map.resolution; + const wrongLng = point.latlng.lng; + // longitude restricted to the [-180°,+180°] range + const lngCorrected = wrongLng - 360 * Math.floor(wrongLng / 360 + 0.5); + const center = { x: lngCorrected, y: point.latlng.lat }; + const centerProjected = reproject(center, 'EPSG:4326', map.projection); + const bounds = fitBoundsToProjectionExtent( + getProjectedBBox(centerProjected, resolution, rotation, size, null), + map.projection + ); + const bounds4326 = reprojectBbox(bounds, map.projection, 'EPSG:4326'); + return { + request: { + outputFormat: 'application/json', + bounds: bounds4326 + }, + metadata: { + title: isObject(layer.title) + ? layer.title[currentLocale] || layer.title.default + : layer.title + }, + url: trimEnd(layer.url, '/') + }; + }, + getIdentifyFlow: (layer, baseURL, { bounds } = {}) => { + const params = { + f: 'json', + geometry: bounds.join(','), + inSR: 4326, + outSR: 4326, + outFields: '*' + }; + const layerIds = layer?.options?.layers + ? layer.options.layers.map(({ id }) => id) + : [layer.name]; + return Observable.defer(() => + Promise.all( + layerIds.map((layerId) => + axios.get(`${baseURL}/${layerId}/query`, { params }) + .then((response) => { + return (response?.data?.features || []).map(esriToGeoJSONFeature); + }) + ) + ).then((features) => { + return { + data: { + crs: 'EPSG:4326', + features: features.flat() + } + }; + }) + ); + } +};