@ugrc/layer-selector
Version:
This is a react component for adding a quick base map selector with a happy path for using [UGRC's Discover Service](https://gis.utah.gov/discover).
603 lines (536 loc) • 17.5 kB
JSX
import Basemap from '@arcgis/core/Basemap.js';
import FeatureLayer from '@arcgis/core/layers/FeatureLayer.js';
import LOD from '@arcgis/core/layers/support/LOD.js';
import TileInfo from '@arcgis/core/layers/support/TileInfo.js';
import TileLayer from '@arcgis/core/layers/TileLayer.js';
import WebTileLayer from '@arcgis/core/layers/WebTileLayer.js';
import classNames from 'clsx';
import PropTypes from 'prop-types';
import { useEffect, useRef, useState } from 'react';
import { setTileInfosForApplianceLayers } from './Discover';
import { createDefaultTileInfo } from './TileInfo';
import icon from './layers.svg';
const commonFactories = {
FeatureLayer,
WebTileLayer,
TileLayer,
};
const ExpandableContainer = (props) => {
const [expanded, setExpanded] = useState(props.expanded);
const imageClasses = classNames('layer-selector__toggle', {
'layer-selector--hidden': expanded,
});
const fromClasses = classNames({ 'layer-selector--hidden': !expanded });
return (
<div
className="layer-selector esri-component esri-widget"
// onFocus={() => setExpanded(true)}
// onBlur={() => setExpanded(false)}
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
onMouseOver={() => setExpanded(true)}
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
onMouseOut={() => setExpanded(false)}
aria-haspopup="true"
>
<input type="image" className={imageClasses} src={icon} alt="layers" />
<form className={fromClasses}>{props.children}</form>
</div>
);
};
ExpandableContainer.propTypes = {
children: PropTypes.object,
expanded: PropTypes.bool,
};
const LayerSelectorItem = (props) => {
const inputOptions = {
type: props.layerType === 'baselayer' ? 'radio' : 'checkbox',
name: props.layerType,
value: props.id,
};
return (
<div className="layer-selector-item radio checkbox">
<label className="layer-selector--item">
<input
{...inputOptions}
checked={props.selected}
onChange={(event) => props.onChange(event, props)}
/>
<span className="layer-selector-item--text">{inputOptions.value}</span>
</label>
</div>
);
};
const imageryAttributionJsonUrl =
'https://mapserv.utah.gov/cdn/attribution/imagery.json';
/**
* Takes layer tokens from `applianceLayers` keys and resolves them to `layerFactory` objects with
* `esri/layer/WebTiledLayer` factories.
* @private
* @param {string} layerType - the type of layer `overlay` or `baselayer`.
* @param {string[]|layerFactory[]} layerFactories - An array of layer tokens or layer factories.
* @returns {layerFactory[]} an array of resolved layer Factory objects.
*/
const createLayerFactories = (
layerType,
layerFactories,
WebTiledLayer,
quadWord,
applianceLayers,
) => {
const resolvedInfos = [];
layerFactories.forEach((li) => {
if (
typeof li === 'string' ||
li instanceof String ||
li.token ||
typeof li.token === 'string' ||
li.token instanceof String
) {
const id = li.token || li;
if (!quadWord) {
console.warn(
'layer-selector::You chose to use a layer token `' +
id +
'` without setting ' +
'your `quadWord` from Discover. The requests for tiles will fail to ' +
' authenticate. Pass `quadWord` into the constructor of this widget.',
);
return false;
}
var layer = applianceLayers[id];
if (!layer) {
console.warn(
'layer-selector::The layer token `' +
id +
'` was not found. Please use one of ' +
'the supported tokens (' +
Object.keys(applianceLayers).join(', ') +
') or pass in the information on how to create your custom layer ' +
'(`{Factory, url, id}`).',
);
return false;
}
const linked = [layer.linked, li.linked].reduce((acc, value, index) => {
if (value) {
acc = acc.concat(value);
}
if (index === 1 && acc.length === 0) {
return null;
}
return acc;
}, []);
resolvedInfos.push({
Factory: WebTiledLayer,
urlTemplate: layer.urlPattern,
linked,
id,
selected: !!li.selected,
copyright: layer.copyright,
layerType,
// TODO: not supported in 4.x WebTileLayer copyright
// hasAttributionData: layer.hasAttributionData,
// attributionDataUrl: layer.attributionDataUrl
});
} else {
if (!Object.prototype.hasOwnProperty.call(li, 'layerType')) {
li.layerType = layerType;
}
if (!li.selected) {
li.selected = false;
}
if (typeof li.Factory === 'string') {
li.Factory = commonFactories[li.Factory];
if (!li.Factory) {
throw new Error(`Unknown layer factory: ${li.Factory}`);
}
}
resolvedInfos.push(li);
}
});
return resolvedInfos;
};
const ConditionalWrapper = ({ condition, wrapper, children }) =>
condition ? wrapper(children) : children;
const defaultProps = {
makeExpandable: true,
position: 'top-right',
showOpacitySlider: false,
};
const LayerSelector = (props) => {
props = {
...defaultProps,
...props,
};
const [layers, setLayers] = useState({
baseLayers: [],
overlays: [],
});
const [linkedLayers, setLinkedLayers] = useState([]);
const [managedLayers, setManagedLayers] = useState({});
const selectorNode = useRef();
function onChangeOpacity(event) {
const sliderOpacity = parseFloat(event.target.value) / 100.0;
if (props.showOpacitySlider) {
for (const layerId in managedLayers) {
const managedLayer = managedLayers[layerId];
if (managedLayer.layer) {
const originalOpacity = managedLayer.layer.originalOpacity ?? 1;
const newOpacity = originalOpacity - (1 - sliderOpacity);
managedLayer.layer.opacity = newOpacity < 0 ? 0 : newOpacity;
}
}
}
}
useEffect(() => {
const managedLayersDraft = { ...managedLayers };
const layerItems = layers.baseLayers.concat(layers.overlays);
layerItems.forEach((layerItem) => {
let layerList = null;
switch (layerItem.layerType) {
case 'baselayer':
if (!props.view.map.basemap?.baseLayers) {
props.view.map.basemap = {
baseLayers: [],
id: 'layer-selector',
title: 'layer-selector',
};
}
layerList = props.view.map.basemap.baseLayers;
break;
case 'overlay':
layerList = props.view.map.layers;
break;
default:
throw new Error(`unknown layerType: ${layerItem.layerType}`);
}
if (layerItem.selected === false) {
var managedLayer = managedLayersDraft[layerItem.id] || {};
if (!managedLayer.layer) {
managedLayer.layer = layerList.getItemAt(
layerList.indexOf(layerItem.layer),
);
}
if (managedLayer.layer) {
layerList.remove(managedLayer.layer);
}
return;
}
if (Object.keys(managedLayersDraft).indexOf(layerItem.id) < 0) {
managedLayersDraft[layerItem.id] = {
layerType: layerItem.layerType,
};
}
if (!managedLayersDraft[layerItem.id].layer) {
managedLayersDraft[layerItem.id].layer = new layerItem.Factory({
originalOpacity: layerItem.opacity,
...layerItem,
});
}
if (layerItem.selected === true) {
if (!layerList.includes(managedLayersDraft[layerItem.id].layer)) {
layerList.add(managedLayersDraft[layerItem.id].layer);
}
} else {
layerList.remove(managedLayersDraft[layerItem.id].layer);
}
// When you set the zoom on a map view without a cached layer, it has no effect on the scale of the map.
// This is a hack to re-apply the zoom after adding the first cached layer. This works because the
// map view is an observable
managedLayersDraft[layerItem.id].layer.when('loaded', () => {
const currentScale =
managedLayersDraft[layerItem.id].layer.tileInfo.lods[props.view.zoom]
.scale;
if (props.view.zoom > -1 && props.view.scale !== currentScale) {
// eslint-disable-next-line no-self-assign
props.view.zoom = props.view.zoom;
}
});
setManagedLayers(managedLayersDraft);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layers]);
useEffect(() => {
if (!props.baseLayers || props.baseLayers.length < 1) {
console.warn(
'layer-selector::`baseLayers` is null or empty. Make sure you have spelled it correctly ' +
'and are passing it into the constructor of this widget.',
);
return;
}
}, [props.baseLayers]);
useEffect(() => {
const applianceLayerTemplates = {
Imagery: {
urlPattern: `https://discover.agrc.utah.gov/login/path/${props.quadWord}/tiles/utah/{level}/{col}/{row}`,
hasAttributionData: true,
copyright: 'Hexagon',
attributionDataUrl: imageryAttributionJsonUrl,
},
Topo: {
urlPattern: `https://discover.agrc.utah.gov/login/path/${props.quadWord}/tiles/topo_basemap/{level}/{col}/{row}`,
copyright: 'UGRC',
},
Terrain: {
urlPattern: `https://discover.agrc.utah.gov/login/path/${props.quadWord}/tiles/terrain_basemap/{level}/{col}/{row}`,
copyright: 'UGRC',
},
Lite: {
urlPattern: `https://discover.agrc.utah.gov/login/path/${props.quadWord}/tiles/lite_basemap/{level}/{col}/{row}`,
copyright: 'UGRC',
},
'Color IR': {
urlPattern: `https://discover.agrc.utah.gov/login/path/${props.quadWord}/tiles/naip_2021_nrg/{level}/{col}/{row}`,
copyright: 'UGRC',
},
Hybrid: {
urlPattern: `https://discover.agrc.utah.gov/login/path/${props.quadWord}/tiles/utah/{level}/{col}/{row}`,
linked: ['Overlay'],
copyright: 'Hexagon, UGRC',
hasAttributionData: true,
attributionDataUrl: imageryAttributionJsonUrl,
},
Overlay: {
urlPattern: `https://discover.agrc.utah.gov/login/path/${props.quadWord}/tiles/overlay_basemap/{level}/{col}/{row}`,
// no attribution for overlay layers since it just duplicates the base map attribution
},
'Address Points': {
urlPattern: `https://discover.agrc.utah.gov/login/path/${props.quadWord}/tiles/address_points_basemap/{level}/{col}/{row}`,
},
};
try {
props.view.map.basemap = new Basemap();
} catch {
console.warn(
'layer-selector::You must pass a view with a map to the constructor of this widget.',
);
}
const defaultTileInfo = createDefaultTileInfo(LOD);
const applianceLayers = setTileInfosForApplianceLayers(
applianceLayerTemplates,
defaultTileInfo,
TileInfo,
);
const baseLayers =
createLayerFactories(
'baselayer',
props.baseLayers,
WebTileLayer,
props.quadWord,
applianceLayers,
) || [];
let overlays = props.overlays || [];
let defaultSelection = null;
let hasHybrid = false;
let linkedLayersBuilder = [];
baseLayers.forEach((layer) => {
if (layer.selected === true) {
defaultSelection = layer;
}
if ((layer.id || layer.token) === 'Hybrid') {
hasHybrid = true;
}
if (layer.linked) {
linkedLayersBuilder = linkedLayersBuilder.concat(layer.linked);
}
});
setLinkedLayers(linkedLayersBuilder);
// set default basemap to index 0 if not specified by the user
if (!defaultSelection && baseLayers.length > 0) {
baseLayers[0].selected = true;
defaultSelection = baseLayers[0];
}
// insert overlay to first spot because hybrid
if (
hasHybrid &&
overlays[0] !== 'Overlay' &&
overlays[0]?.id !== 'Overlay'
) {
overlays.splice(0, 0, 'Overlay');
}
overlays =
createLayerFactories(
'overlay',
overlays,
WebTileLayer,
props.quadWord,
applianceLayers,
) || [];
// set visibility of linked layers to match
if (defaultSelection?.linked && defaultSelection.linked.length > 0) {
overlays.forEach((layer) => {
if (defaultSelection.linked.includes(layer.id)) {
layer.selected = true;
}
});
}
setLayers({
baseLayers,
overlays,
});
props.view.ui.add(selectorNode.current, props.position);
}, [
props.baseLayers,
props.overlays,
props.position,
props.quadWord,
props.view.map,
props.view.ui,
]);
const onItemChanged = (event, props) => {
console.log('LayerSelector.onItemChanged', props);
const overlays = layers.overlays;
const baseLayers = layers.baseLayers;
if (props.layerType === 'baselayer') {
baseLayers.forEach((item) => {
item.selected = item.id === props.id ? event.target.checked : false;
});
const selectedItem = baseLayers.filter((layer) => layer.selected)[0];
overlays.forEach((item) => {
if (linkedLayers.includes(item.id)) {
item.selected = false;
}
});
if (selectedItem.linked && selectedItem.linked.length > 0) {
overlays.forEach((item) => {
if (selectedItem.linked.includes(item.id)) {
item.selected = true;
}
return item;
});
}
} else if (props.layerType === 'overlay') {
overlays.forEach((item) => {
if (item.id === props.id) {
item.selected = event.target.checked;
}
});
}
setLayers({
overlays,
baseLayers,
});
};
return (
<div ref={selectorNode}>
<ConditionalWrapper
condition={props.makeExpandable}
wrapper={(children) => (
<ExpandableContainer>{children}</ExpandableContainer>
)}
>
<div className="layer-selector--layers">
{layers.baseLayers.map((item, index) => (
<LayerSelectorItem
id={item.name || item.id || 'unknown'}
layerType="baselayer"
selected={item.selected}
onChange={onItemChanged}
key={index}
/>
))}
{layers.overlays.length ? (
<hr className="layer-selector-separator" />
) : null}
{layers.overlays.map((item) => (
<LayerSelectorItem
id={item.name || item.id || 'unknown'}
layerType="overlay"
selected={item.selected}
onChange={onItemChanged}
key={item.id || item}
/>
))}
{props.showOpacitySlider ? (
<>
<hr className="layer-selector-separator" />
<input
type="range"
min="0"
max="100"
step="1"
defaultValue="100"
onChange={onChangeOpacity}
/>
</>
) : null}
</div>
</ConditionalWrapper>
</div>
);
};
LayerSelector.propTypes = {
view: PropTypes.object.isRequired,
quadWord: PropTypes.string,
baseLayers: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.oneOf([
'Hybrid',
'Lite',
'Terrain',
'Topo',
'Color IR',
'Address Points',
'Overlay',
'Imagery',
]),
PropTypes.shape({
Factory: PropTypes.oneOfType([PropTypes.func, PropTypes.string])
.isRequired,
urlTemplate: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string.isRequired,
tileInfo: PropTypes.object,
linked: PropTypes.arrayOf(PropTypes.string),
}),
PropTypes.shape({
token: PropTypes.oneOf([
'Hybrid',
'Lite',
'Terrain',
'Topo',
'Color IR',
'Address Points',
'Overlay',
]).isRequired,
selected: PropTypes.bool,
linked: PropTypes.arrayOf(PropTypes.string),
}),
]),
).isRequired,
overlays: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.oneOf(['Address Points', 'Overlay']),
PropTypes.shape({
Factory: PropTypes.oneOfType([PropTypes.func, PropTypes.string])
.isRequired,
urlTemplate: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string.isRequired,
tileInfo: PropTypes.object,
linked: PropTypes.arrayOf(PropTypes.string),
}),
]),
),
position: PropTypes.oneOf([
'bottom-leading',
'bottom-left',
'bottom-right',
'bottom-trailing',
'top-leading',
'top-left',
'top-right',
'top-trailing',
]),
makeExpandable: PropTypes.bool,
layerType: PropTypes.string,
id: PropTypes.string,
showOpacitySlider: PropTypes.bool,
};
LayerSelectorItem.propTypes = {
onChange: PropTypes.func.isRequired,
selected: PropTypes.bool.isRequired,
layerType: PropTypes.oneOf(['baselayer', 'overlay']).isRequired,
id: PropTypes.string.isRequired,
};
export { ExpandableContainer, LayerSelectorItem };
export default LayerSelector;