Skip to content
25 changes: 25 additions & 0 deletions web/client/components/TOC/fragments/settings/Display.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import ThreeDTilesSettings from './ThreeDTilesSettings';
import ModelTransformation from './ModelTransformation';
import StyleBasedWMSJsonLegend from '../../../../plugins/TOC/components/StyleBasedWMSJsonLegend';
import VectorLegend from '../../../../plugins/TOC/components/VectorLegend';
import { isMapServerUrl } from '../../../../utils/ArcGISUtils';

export default class extends React.Component {
static propTypes = {
Expand Down Expand Up @@ -401,6 +402,30 @@ export default class extends React.Component {
</Col>}
</div>
</Row>}
{this.props.element.type === "arcgis" && isMapServerUrl(this.props.element.url) &&
<Row>
<div className={"legend-options"}>
<Col xs={12} className={"legend-label"}>
<label key="legend-options-title" className="control-label"><Message msgId="layerProperties.legendOptions.title" /></label>
</Col>
<Col xs={12} className="first-selectize">
<FormGroup>
{!hideDynamicLegend && <Checkbox
data-qa="display-dynamic-legend-filter"
value="enableDynamicLegend"
key="enableDynamicLegend"
disabled={enableInteractiveLegend}
onChange={(e) => {
this.props.onChange("enableDynamicLegend", e.target.checked);
}}
checked={enableDynamicLegend || enableInteractiveLegend} >
<Message msgId="layerProperties.enableDynamicLegend.label" />
&nbsp;<InfoPopover text={<Message msgId="layerProperties.enableDynamicLegend.info" />} />
</Checkbox>}
</FormGroup>
</Col>
</div>
</Row>}
</Grid>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,4 +531,46 @@ describe('test Layer Properties Display module component', () => {
expect(spy.calls[0].arguments[0]).toEqual("enableDynamicLegend");
expect(spy.calls[0].arguments[1]).toEqual(true);
});

it('tests arcgis Layer Properties Legend component', () => {
const l = {
name: 'arcgisLayer',
title: 'ArcGIS Layer',
visibility: true,
storeIndex: 9,
type: 'arcgis',
url: '/rest/services/MapServer'
};
const settings = {
options: {
opacity: 1
}
};
const handlers = {
onChange() { }
};
let spy = expect.spyOn(handlers, "onChange");

const comp = ReactDOM.render(
<Display element={l} settings={settings} onChange={handlers.onChange} />,
document.getElementById("container")
);

expect(comp).toBeTruthy();

const legendOptions = document.querySelector('.legend-options');

expect(legendOptions).toBeTruthy();

const dynamicLegendCheckbox = document.querySelector(".legend-options input[data-qa='display-dynamic-legend-filter']");
expect(dynamicLegendCheckbox).toBeTruthy();
expect(dynamicLegendCheckbox.checked).toBeFalsy();

dynamicLegendCheckbox.checked = true;
ReactTestUtils.Simulate.change(dynamicLegendCheckbox);

expect(spy).toHaveBeenCalled();
expect(spy.calls[0].arguments[0]).toEqual("enableDynamicLegend");
expect(spy.calls[0].arguments[1]).toEqual(true);
});
});
138 changes: 118 additions & 20 deletions web/client/plugins/TOC/components/ArcGISLegend.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,151 @@
* LICENSE file in the root directory of this source tree.
*/

import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo, useRef } 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';
import { getLayerIds, isMapServerUrl } from '../../../utils/ArcGISUtils';

/**
* ArcGISLegend renders legend from a MapServer or ImageServer service
* @prop {object} node layer node options
* ArcGISLegend renders a legend from a MapServer or ImageServer service.
* It fetches legend data from the specified service URL and displays the legend items for each layer.
* The component supports dynamic legends and custom layer visibility based on the provided bounding box and layer options.
*
* @component
* @param {Object} props - The component's props.
* @param {Object} props.node - The layer node object that contains the configuration for the legend.
* @param {number} [props.legendWidth=12] - The width of the legend symbols (in pixels). Default is 12.
* @param {number} [props.legendHeight=12] - The height of the legend symbols (in pixels). Default is 12.
* @param {Object} [props.mapBbox={}] - The map bounding box, which defines the geographic extent for fetching the legend data. The `mapBbox.bounds` should contain the bounding coordinates and `mapBbox.crs` should contain the coordinate reference system.
* @param {Function} [props.onChange=() => {}] - A callback function that is called when the legend state changes, e.g., when no visible layers are found or the legend data is fetched successfully. It receives an object with `legendEmpty` as a property, indicating whether the legend has any visible layers.
*
* @returns {React.Element} The rendered component.
*/
function ArcGISLegend({
node = {}
node = {},
legendWidth = 12,
legendHeight = 12,
mapBbox: mapBboxProp = {},
onChange = () => { }
}) {
const [legendData, setLegendData] = useState(null);
const [error, setError] = useState(false);
const legendUrl = node.url ? `${trimEnd(node.url, '/')}/legend` : '';
const enableDynamicLegend = node.enableDynamicLegend && isMapServerUrl(node.url);
const mapBbox = enableDynamicLegend ? mapBboxProp : undefined;
const legendUrl = node.url ? `${trimEnd(node.url, '/')}/${enableDynamicLegend ? 'queryLegends' : 'legend'}` : '';


// Get Layers id
// @returns Array of ids
function getLayersId() {
const supportedLayerIds = node.name !== undefined ? getLayerIds(node.name, node?.options?.layers || []) : [];
return supportedLayerIds;
}

// Get an array of legend layers
// @param {*} layerIds Array of ids
// @returns Layers
function getLegendLayers(layerIds) {
const legendLayers = (legendData?.layers || [])
.filter(({ layerId }) => node.name === undefined ? true : layerIds.includes(`${layerId}`));
return legendLayers;
}

const legendNotVisible = useMemo(() => {
const supportedLayerIds = getLayersId();
if (supportedLayerIds !== undefined) {
const legendLayers = getLegendLayers(supportedLayerIds);
return !legendLayers.some(legendLayer => legendLayer?.legend?.length > 0);
}
return true;
}, [legendData, node]);

const source = useRef();
const requestTimeout = useRef();

const createToken = () => {
if (source.current) {
source.current?.cancel();
source.current = undefined;
}
const cancelToken = axios.CancelToken;
source.current = cancelToken.source();
};

const clearRequestTimeout = () => {
if (requestTimeout.current) {
clearTimeout(requestTimeout.current);
requestTimeout.current = undefined;
}
};

useEffect(() => {
return () => {
clearRequestTimeout();
createToken();
};
}, []);

useEffect(() => {
clearRequestTimeout();
createToken();
if (legendUrl) {
axios.get(legendUrl, {
setError(false);
requestTimeout.current = setTimeout(() => axios.get(legendUrl, {
params: {
f: 'json'
}
f: 'json',
...(node.enableDynamicLegend && {
bbox: Object.values(mapBbox.bounds ?? {}).join(',') || '',
bboxSR: mapBbox?.crs?.split(':')[1] ?? '',
size: `${(node.legendOptions?.legendWidth ?? legendWidth)},${(node.legendOptions?.legendHeight ?? legendHeight)}`,
format: 'png',
transparent: false,
timeRelation: 'esriTimeRelationOverlaps',
returnVisibleOnly: true
})
},
cancelToken: source.current.token
})
.then(({ data }) => setLegendData(data))
.catch(() => setError(true));
.then(({ data }) => {
const legendEmpty = data.layers.every(layer => layer.legend.length === 0);
onChange({ legendEmpty });
setLegendData(data);
})
.catch((err) => {
if (!axios.isCancel(err)) {
onChange({ legendEmpty: true });
setError(true);
}
}), 300);
}
}, [legendUrl]);
}, [legendUrl, mapBbox]);

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

const legendLayers = (legendData?.layers || [])
.filter(({ layerId }) => node.name === undefined ? true : supportedLayerIds.includes(`${layerId}`));
const legendLayers = getLegendLayers(supportedLayerIds);
const loading = !legendData && !error;

return (
<div className="ms-arcgis-legend">
{legendLayers.map(({ legendGroups, legend, layerName }) => {
{legendNotVisible && !loading && !error ? (
<div className="ms-no-visible-layers-in-extent">
<Message msgId="widgets.errors.noLegend" />
</div>
) : null}
{!legendNotVisible && legendLayers.map(({ legendGroups, legend, layerName }, index) => {
const legendItems = legendGroups
? legendGroups.map(legendGroup => legend.filter(item => item.groupId === legendGroup.id)).flat()
: legend;
const maxWidth = max(legendItems.map(item => item.width));
return (<>
return (<React.Fragment key={index}>
{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">
const keyItem = `${item.id || item.label}-${idx}`;
return (<li key={keyItem} className="ms-legend-rule">
<div className="ms-legend-icon" style={{ minWidth: maxWidth }}>
<img
src={`data:${item.contentType};base64,${item.imageData}`}
Expand All @@ -64,10 +162,10 @@ function ArcGISLegend({
</li>);
})}
</ul>
</>);
</React.Fragment>);
})}
{loading && <Loader size={12} style={{display: 'inline-block'}}/>}
{error && <Message msgId="layerProperties.legenderror" />}
{error && !loading ? <Message msgId="layerProperties.legenderror" /> : null}
</div>
);
}
Expand Down
1 change: 1 addition & 0 deletions web/client/plugins/TOC/components/DefaultLayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const NodeLegend = ({
<li>
{visible ? <ArcGISLegend
node={node}
{...config?.layerOptions?.legendOptions}
/> : null}
</li>
</>
Expand Down
36 changes: 36 additions & 0 deletions web/client/plugins/TOC/components/__tests__/ArcGISLegend-test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,40 @@ describe('ArcGISLegend', () => {
})
.catch(done);
});
it('should request using queryLegend when enableDynamicLegend is set to true', done => {
const node = { url: '/rest/services/test/MapServer', enableDynamicLegend: true };
const mockData = { layers: [{ layerId: 0, layerName: 'Layer', legend: [{ id: 'sym1', label: 'Water', contentType: 'image/png', imageData: 'iVBORw0', width: 12, height: 12 }] }] };

mockAxios.onGet(/queryLegends/).reply(200, mockData);

act(() => {
const container = document.getElementById("container");
ReactDOM.render(<ArcGISLegend node={node} />, container);
});

waitFor(() => expect(document.querySelector('.ms-legend-rule')).toBeTruthy())
.then(() => {
const legendRule = document.querySelector('.ms-legend-rule');
expect(legendRule.innerText).toBe('Water');
done();
})
.catch(done);
});
it('should display the empty layer message when legend items are not provided', done => {
const node = { url: '/rest/services/test/MapServer', enableDynamicLegend: true };
const mockData = { layers: [{ layerId: 0, layerName: 'Layer', legend: [] }] };

mockAxios.onGet(/queryLegends/).reply(200, mockData);

act(() => {
const container = document.getElementById("container");
ReactDOM.render(<ArcGISLegend node={node} />, container);
});

waitFor(() => expect(document.querySelector('.ms-no-visible-layers-in-extent')).toBeTruthy())
.then(() => {
done();
})
.catch(done);
});
});