Skip to content

Commit

Permalink
geosolutions-it#10040 ArcGIS Interoperability - ArcGIS MapServer Cata…
Browse files Browse the repository at this point in the history
…log and Layer support (geosolutions-it#10330)

---------
Co-authored-by: Igor Dimov <[email protected]>
  • Loading branch information
allyoucanmap authored May 21, 2024
1 parent c4464fc commit 9baf447
Show file tree
Hide file tree
Showing 36 changed files with 1,614 additions and 52 deletions.
35 changes: 35 additions & 0 deletions docs/developer-guide/maps-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 128 additions & 0 deletions web/client/api/ArcGIS.js
Original file line number Diff line number Diff line change
@@ -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 };
});
};
112 changes: 112 additions & 0 deletions web/client/api/__tests__/ArcGIS-test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
85 changes: 85 additions & 0 deletions web/client/api/catalog/ArcGIS.js
Original file line number Diff line number Diff line change
@@ -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;
};
4 changes: 2 additions & 2 deletions web/client/api/catalog/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading

0 comments on commit 9baf447

Please sign in to comment.