Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/developer-guide/maps-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions docs/user-guide/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -452,11 +452,13 @@ In **General Settings** of a ArcGIS source type, it is possible to specify the s
* `https://<catalog-url>/rest/services/`

* `https://<catalog-url>/rest/services/<serviceName>/MapServer`

* `https://<catalog-url>/rest/services/<serviceName>/ImageServer`

* `https://<catalog-url>/rest/services/<serviceName>/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* <img src="../img/button/zoom-layer.jpg" class="ms-docbutton"/>: in order to zoom the map to the layer's extent
* Access the [Layer Settings](layer-settings.md#layer-settings) <img src="../img/button/properties.jpg" class="ms-docbutton"/> to view/edit the [General Information](layer-settings.md#general-information) and the [Display](layer-settings.md#ifc-layer) options
Expand Down
66 changes: 65 additions & 1 deletion web/client/api/ArcGIS.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand Down Expand Up @@ -36,6 +38,47 @@ const extentToBoundingBox = (extent) => {
return null;
};

const extentToBoundingBox4326 = (extent) => {
Comment thread
dsuren1 marked this conversation as resolved.
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down
85 changes: 85 additions & 0 deletions web/client/api/__tests__/ArcGIS-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
23 changes: 18 additions & 5 deletions web/client/api/catalog/ArcGIS.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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,
Expand All @@ -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
};
};
Expand Down Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions web/client/api/catalog/__tests__/ArcGIS-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading
Loading