Skip to content

Commit

Permalink
Fix #10339 Legend support for ArcGIS MapServer/ImageServer layers (#1…
Browse files Browse the repository at this point in the history
…0352)


---------

Co-authored-by: Lorenzo Natali <lorenzo.natali@geosolutionsgroup.com>
  • Loading branch information
allyoucanmap and offtherailz authored Jun 11, 2024
1 parent 0774b09 commit 79f5729
Show file tree
Hide file tree
Showing 17 changed files with 471 additions and 86 deletions.
24 changes: 15 additions & 9 deletions docs/developer-guide/maps-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1308,27 +1308,33 @@ 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.:
An ArcGIS MapServer source is a composition of different layers to create a map. The layer is identified by the `arcgis` type, containing `url` and `options.layers` properties . e.g.

```javascript
```json
{
"type": "arcgis",
"name": "0",
"url": "https://arcgis-example/rest/services/MyService/MapServer"
"url": "https://arcgis-example/rest/services/MyService/MapServer",
"options": {
"layers": [{ "id": 0 }, { "id": 1 }]
},
"title": "Title",
"group": "",
"visibility": true,
"queriable": true,
"visibility": 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.:
Where:

```javascript
- `url` is the URL of the MapServer source.
- `options.layers` is the list of object containing the ids of the layers. Required to support `queriable` option and legend support.
- `name` (optional). When present, the MapStore layer will show only the layer with the id specified in the `name` attribute. e.g.

```json
{
"type": "arcgis",
"name": "0",
"url": "https://arcgis-example/rest/services/MyService/MapServer"
"url": "https://arcgis-example/rest/services/MyService/MapServer",
"options": {
"layers": [{ "id": 0 }, { "id": 1 }]
Expand Down
2 changes: 1 addition & 1 deletion web/client/api/ArcGIS.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const getData = (url, params = {}) => {
const commonProperties = {
url,
version: data?.currentVersion,
layers,
format: (data.supportedImageFormatTypes || '')
.split(',')
.filter(format => /PNG|JPG|GIF/.test(format))[0] || 'PNG32'
Expand All @@ -93,7 +94,6 @@ const getData = (url, params = {}) => {
description: data.description || data.serviceDescription,
bbox,
queryable: (data?.capabilities || '').includes('Data'),
layers,
...commonProperties
}
] : []),
Expand Down
8 changes: 3 additions & 5 deletions web/client/api/catalog/ArcGIS.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,9 @@ const recordToLayer = (record, { layerBaseConfig }) => {
...(record.bbox && {
bbox: record.bbox
}),
...(record.layers && {
options: {
layers: record.layers
}
}),
options: {
layers: record.layers
},
...layerBaseConfig
};
};
Expand Down
2 changes: 1 addition & 1 deletion web/client/components/map/cesium/plugins/ArcGISLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class ArcGisMapAndImageServerImageryProvider extends Cesium.ArcGisMapServerImage
Layers.registerType('arcgis', (options) => {
return new ArcGisMapAndImageServerImageryProvider({
url: options.url,
...(options.name && { layers: `${options.name}` }),
...(options.name !== undefined && { 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
Expand Down
2 changes: 1 addition & 1 deletion web/client/components/map/leaflet/plugins/ArcGISLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ registerType('arcgis', (options) => {
return LEsri.dynamicMapLayer({
url: options.url,
opacity: options.opacity || 1,
...(options.name && { layers: [`${options.name}`] }),
...(options.name !== undefined && { layers: [`${options.name}`] }),
format: options.format
});
});
2 changes: 1 addition & 1 deletion web/client/components/styleeditor/RuleLegendIcon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ function RuleLegendIcon({
}) {
const symbolizer = parseSymbolizerExpressions(rule?.symbolizers?.[0] || {}, { properties: {} });
const Icon = icon[symbolizer.kind];
return Icon ? <div className="ms-rule-legend-icon"><Icon symbolizer={symbolizer}/></div> : null;
return Icon ? <div className="ms-legend-icon"><Icon symbolizer={symbolizer}/></div> : null;
}

export default RuleLegendIcon;
2 changes: 1 addition & 1 deletion web/client/components/styleeditor/WMSJsonLegendIcon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ function WMSJsonLegendIcon({
icons.push({Icon: icon[kind], symbolizer: symbolizer[kind]});
});
});
return icons.length ? <> {icons.map(({ Icon, symbolizer }, idx) => <div key={'icons-wms-json' + idx} className="ms-rule-legend-icon"><Icon symbolizer={symbolizer}/></div>)} </> : null;
return icons.length ? <> {icons.map(({ Icon, symbolizer }, idx) => <div key={'icons-wms-json' + idx} className="ms-legend-icon"><Icon symbolizer={symbolizer}/></div>)} </> : null;
}

export default WMSJsonLegendIcon;
75 changes: 75 additions & 0 deletions web/client/plugins/TOC/components/ArcGISLegend.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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 React, { useState, useEffect } from 'react';
import trimEnd from 'lodash/trimEnd';
import max from 'lodash/max';
import axios from '../../../libs/ajax';
import Message from '../../../components/I18N/Message';
import Loader from '../../../components/misc/Loader';
import { getLayerIds } from '../../../utils/ArcGISUtils';

/**
* ArcGISLegend renders legend from a MapServer or ImageServer service
* @prop {object} node layer node options
*/
function ArcGISLegend({
node = {}
}) {
const [legendData, setLegendData] = useState(null);
const [error, setError] = useState(false);
const legendUrl = node.url ? `${trimEnd(node.url, '/')}/legend` : '';
useEffect(() => {
if (legendUrl) {
axios.get(legendUrl, {
params: {
f: 'json'
}
})
.then(({ data }) => setLegendData(data))
.catch(() => setError(true));
}
}, [legendUrl]);

const supportedLayerIds = node.name !== undefined ? getLayerIds(node.name, node?.options?.layers || []) : [];

const legendLayers = (legendData?.layers || [])
.filter(({ layerId }) => node.name === undefined ? true : supportedLayerIds.includes(`${layerId}`));
const loading = !legendData && !error;
return (
<div className="ms-arcgis-legend">
{legendLayers.map(({ legendGroups, legend, layerName }) => {
const legendItems = legendGroups
? legendGroups.map(legendGroup => legend.filter(item => item.groupId === legendGroup.id)).flat()
: legend;
const maxWidth = max(legendItems.map(item => item.width));
return (<>
{legendLayers.length > 1 && <div className="ms-legend-title">{layerName}</div>}
<ul className="ms-legend">
{legendItems.map((item, idx) => {
return (<li key={idx} className="ms-legend-rule">
<div className="ms-legend-icon" style={{ minWidth: maxWidth }}>
<img
src={`data:${item.contentType};base64,${item.imageData}`}
width={item.width}
height={item.height}
/>
</div>
{item.label}
</li>);
})}
</ul>
</>);
})}
{loading && <Loader size={12} style={{display: 'inline-block'}}/>}
{error && <Message msgId="layerProperties.legenderror" />}
</div>
);
}

export default ArcGISLegend;
12 changes: 12 additions & 0 deletions web/client/plugins/TOC/components/DefaultLayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import DragNode from './DragNode';
import { VisualizationModes } from '../../../utils/MapTypeUtils';
import InlineLoader from './InlineLoader';
import WMSLegend from './WMSLegend';
import ArcGISLegend from './ArcGISLegend';
import OpacitySlider from './OpacitySlider';
import VectorLegend from './VectorLegend';
import VisibilityCheck from './VisibilityCheck';
Expand Down Expand Up @@ -114,6 +115,17 @@ const DefaultLayerNode = ({
</>
);
}
if (layerType === 'arcgis') {
return (
<>
<li>
<ArcGISLegend
node={node}
/>
</li>
</>
);
}
return null;
};

Expand Down
4 changes: 2 additions & 2 deletions web/client/plugins/TOC/components/VectorLegend.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function VectorLegend({ style }) {

const renderRules = (rules) => {
return (rules || []).map((rule) => {
return (<div className="ms-vector-legend-rule" key={rule.ruleId}>
return (<div className="ms-legend-rule" key={rule.ruleId}>
<RuleLegendIcon rule={rule} />
<span>{rule.name || ''}</span>
</div>);
Expand All @@ -25,7 +25,7 @@ function VectorLegend({ style }) {

return <>
{
style.format === 'geostyler' && <div className="ms-vector-legend">
style.format === 'geostyler' && <div className="ms-legend">
{renderRules(style.body.rules)}
</div>
}
Expand Down
66 changes: 66 additions & 0 deletions web/client/plugins/TOC/components/__tests__/ArcGISLegend-test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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 React from 'react';

import ReactDOM from 'react-dom';
import ArcGISLegend from '../ArcGISLegend';
import expect from 'expect';
import { act } from 'react-dom/test-utils';
import axios from '../../../../libs/ajax';
import MockAdapter from 'axios-mock-adapter';
import { waitFor } from '@testing-library/react';

describe('ArcGISLegend', () => {
let mockAxios;
beforeEach((done) => {
mockAxios = new MockAdapter(axios);
document.body.innerHTML = '<div id="container"></div>';
setTimeout(done);
});

afterEach((done) => {
mockAxios.restore();
ReactDOM.unmountComponentAtNode(document.getElementById("container"));
document.body.innerHTML = '';
setTimeout(done);
});
it('should render with defaults', () => {
act(() => {
ReactDOM.render(<ArcGISLegend/>, document.getElementById("container"));
});
expect(document.querySelector('.ms-arcgis-legend')).toBeTruthy();
});
it('should show the legend container when the legend request succeed', (done) => {
mockAxios.onGet().reply(200, { layers: [{ layerId: 1, legend: [{ contentType: 'image/png', imageData: 'imageData', label: 'Label', width: 30, height: 20 }] }] });
act(() => {
ReactDOM.render(<ArcGISLegend node={{ name: 1, url: '/rest/MapServer' }}/>, document.getElementById("container"));
});
waitFor(() => expect(document.querySelector('.mapstore-small-size-loader')).toBeFalsy())
.then(() => {
expect(document.querySelector('.ms-legend')).toBeTruthy();
const img = document.querySelector('.ms-legend img');
expect(img.getAttribute('src')).toBe('data:image/png;base64,imageData');
expect(img.getAttribute('width')).toBe('30');
expect(img.getAttribute('height')).toBe('20');
done();
})
.catch(done);
});
it('should show error message when the legend request fails', (done) => {
mockAxios.onGet().reply(500);
act(() => {
ReactDOM.render(<ArcGISLegend node={{ url: '/rest/MapServer' }}/>, document.getElementById("container"));
});
waitFor(() => expect(document.querySelector('.mapstore-small-size-loader')).toBeFalsy())
.then(() => {
expect(document.querySelector('.ms-arcgis-legend').innerText).toBe('layerProperties.legenderror');
done();
})
.catch(done);
});
});
26 changes: 13 additions & 13 deletions web/client/plugins/TOC/components/__tests__/VectorLegend-test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ describe('VectorLegend module component', () => {
}
};
ReactDOM.render(<VectorLegend style={style} />, document.getElementById('container'));
const ruleElements = document.querySelectorAll('.ms-vector-legend-rule');
const ruleElements = document.querySelectorAll('.ms-legend-rule');
expect(ruleElements.length).toBe(1);
const textElement = ruleElements[0].getElementsByTagName('span');
expect(textElement[0].innerHTML).toBe('Some Line');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-rule-legend-icon');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-legend-icon');
expect(iconContainerElement.length).toBe(1);
const expectedSVG = '<svg viewBox="0 0 50 50"><path d="M 7 7 L 43 43" stroke="#8426c9" stroke-width="7" stroke-dasharray="18 18" stroke-linecap="butt" stroke-linejoin="round" stroke-opacity="0.5"></path></svg>';
expect(iconContainerElement[0].innerHTML).toBe(expectedSVG);
Expand Down Expand Up @@ -80,11 +80,11 @@ describe('VectorLegend module component', () => {
}
};
ReactDOM.render(<VectorLegend style={style} />, document.getElementById('container'));
const ruleElements = document.querySelectorAll('.ms-vector-legend-rule');
const ruleElements = document.querySelectorAll('.ms-legend-rule');
expect(ruleElements.length).toBe(1);
const textElement = ruleElements[0].getElementsByTagName('span');
expect(textElement[0].innerHTML).toBe('Some Line');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-rule-legend-icon');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-legend-icon');
expect(iconContainerElement.length).toBe(1);
const expectedSVG = '<svg viewBox="0 0 50 50"><path d="M 1 1 L 49 49" stroke="#8426c9" stroke-width="1" stroke-linecap="butt" stroke-linejoin="round" stroke-opacity="0.5"></path></svg>';
expect(iconContainerElement[0].innerHTML).toBe(expectedSVG);
Expand Down Expand Up @@ -113,11 +113,11 @@ describe('VectorLegend module component', () => {
}
};
ReactDOM.render(<VectorLegend style={style} />, document.getElementById('container'));
const ruleElements = document.querySelectorAll('.ms-vector-legend-rule');
const ruleElements = document.querySelectorAll('.ms-legend-rule');
expect(ruleElements.length).toBe(1);
const textElement = ruleElements[0].getElementsByTagName('span');
expect(textElement[0].innerHTML).toBe('Some Line');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-rule-legend-icon');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-legend-icon');
expect(iconContainerElement.length).toBe(1);
const expectedSVG = '<svg viewBox="0 0 50 50"><path d="M 7 7 L 43 43" stroke="#8426c9" stroke-width="7" stroke-linecap="butt" stroke-linejoin="round" stroke-opacity="0.5"></path></svg>';
expect(iconContainerElement[0].innerHTML).toBe(expectedSVG);
Expand Down Expand Up @@ -148,11 +148,11 @@ describe('VectorLegend module component', () => {
}
};
ReactDOM.render(<VectorLegend style={style} />, document.getElementById('container'));
const ruleElements = document.querySelectorAll('.ms-vector-legend-rule');
const ruleElements = document.querySelectorAll('.ms-legend-rule');
expect(ruleElements.length).toBe(1);
const textElement = ruleElements[0].getElementsByTagName('span');
expect(textElement[0].innerHTML).toBe('Some polygon');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-rule-legend-icon');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-legend-icon');
expect(iconContainerElement.length).toBe(1);
const expectedSVG = '<svg viewBox="0 0 50 50"><path d="M 1 1 L 1 49 L 49 49 L 49 1 L 1 1" fill="#28ee50" opacity="1" stroke="#17ad31" stroke-width="6" stroke-opacity="1"></path></svg>';
expect(iconContainerElement[0].innerHTML).toBe(expectedSVG);
Expand Down Expand Up @@ -186,11 +186,11 @@ describe('VectorLegend module component', () => {
}
};
ReactDOM.render(<VectorLegend style={style} />, document.getElementById('container'));
const ruleElements = document.querySelectorAll('.ms-vector-legend-rule');
const ruleElements = document.querySelectorAll('.ms-legend-rule');
expect(ruleElements.length).toBe(1);
const textElement = ruleElements[0].getElementsByTagName('span');
expect(textElement[0].innerHTML).toBe('Some mark');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-rule-legend-icon');
const iconContainerElement = ruleElements[0].querySelectorAll('.ms-legend-icon');
expect(iconContainerElement.length).toBe(1);
const expectedSVG = '<svg viewBox="0 0 50 50" style="transform: rotate(55deg);"></svg>';
expect(iconContainerElement[0].innerHTML).toBe(expectedSVG);
Expand Down Expand Up @@ -220,7 +220,7 @@ describe('VectorLegend module component', () => {
}
};
ReactDOM.render(<VectorLegend style={style} />, document.getElementById('container'));
const ruleElements = document.querySelectorAll('.ms-vector-legend-rule');
const ruleElements = document.querySelectorAll('.ms-legend-rule');
expect(ruleElements.length).toBe(1);
const textElement = ruleElements[0].getElementsByTagName('span');
expect(textElement[0].getAttribute('class')).toBe('glyphicon glyphicon-point');
Expand Down Expand Up @@ -283,7 +283,7 @@ describe('VectorLegend module component', () => {
}
};
ReactDOM.render(<VectorLegend style={style} />, document.getElementById('container'));
const ruleElements = document.querySelectorAll('.ms-vector-legend-rule');
const ruleElements = document.querySelectorAll('.ms-legend-rule');
expect(ruleElements.length).toBe(3);
});

Expand All @@ -310,7 +310,7 @@ describe('VectorLegend module component', () => {
}
};
ReactDOM.render(<VectorLegend style={style} />, document.getElementById('container'));
const ruleElements = document.querySelectorAll('.ms-vector-legend-rule');
const ruleElements = document.querySelectorAll('.ms-legend-rule');
const textElement = ruleElements[0].getElementsByTagName('span');
expect(textElement[0].innerHTML).toBe('');
});
Expand Down
Loading

0 comments on commit 79f5729

Please sign in to comment.