import React, { createRef, PureComponent } from 'react'
import PropTypes from 'prop-types'
import { Virtuoso } from 'react-virtuoso'
import LayerPanelPage from 'LayerPanel/LayerPanelPage'
import LayerPanelContent from 'LayerPanel/LayerPanelContent'
import LayerPanelList from 'LayerPanel/LayerPanelList'
import LayerPanelListItem from 'LayerPanel/LayerPanelListItem'
import LayerPanelCheckbox from 'LayerPanel/LayerPanelCheckbox'
import LayerPanelExpandableList from 'LayerPanel/_LayerPanelExpandableList'
import LayerPanelActions from 'LayerPanel/LayerPanelActions'
import { ListItem, ListItemText } from 'LayerPanel/LayerPanelListItem/styled'
import { ListItemSecondaryAction } from './styled'
import Collapse from '@material-ui/core/Collapse'
import MoreVertIcon from '@material-ui/icons/MoreVert'
import LayersIcon from '@material-ui/icons/Layers'
import olStroke from 'ol/style/Stroke'
import olFill from 'ol/style/Fill'
import olCircle from 'ol/style/Circle'
import ugh from 'ugh'
import { addSelectInteraction } from 'Map'
import MoreHorizIcon from '@material-ui/icons/MoreHoriz'
import LayerPanelActionOpacity from 'LayerPanel/LayerPanelActionOpacity'
import LayerPanelActionRemove from 'LayerPanel/LayerPanelActionRemove'
import LayerPanelActionExtent from 'LayerPanel/LayerPanelActionExtent'
import LayerPanelActionHeatmap from 'LayerPanel/LayerPanelActionHeatmap'
import LayerPanelActionDuplicate from 'LayerPanel/LayerPanelActionDuplicate'
import TextField from '@material-ui/core/TextField'
import olLayerVector from 'ol/layer/Vector'
import olSourceVector from 'ol/source/Vector'
import olStyleStyle from 'ol/style/Style'
import LayerPanelActionImport from 'LayerPanel/LayerPanelActionImport'
import LayerPanelActionExport from 'LayerPanel/LayerPanelActionExport'
import LayerPanelActionMerge from 'LayerPanel/LayerPanelActionMerge'
import LayerPanelActionMergeFeatures from 'LayerPanel/LayerPanelActionMergeFeatures'
import isEqual from 'lodash.isequal'
import { connectToContext } from 'Provider'
import { convertFileToFeatures } from 'LayerPanel/LayerPanelActionImport/utils'
import VectorLayer from 'classes/VectorLayer'
const INDETERMINATE = 'indeterminate'
/**
* @component
* @category LayerPanel
* @since 0.5.0
*/
class LayerPanelLayersPage extends PureComponent {
constructor (props) {
super(props)
this.state = {
layers: [],
masterCheckboxVisibility: true,
featureListeners: [],
filterText: '',
expandedLayers: []
}
this.virtuoso = createRef(null)
this.scrollIndex = createRef(0)
}
initializeSelect = map => {
const { setHoverStyle, disableHover } = this.props
if (disableHover) return // opt-out
const { stroke = 'red', fill = '#ffffff', color = 'red' } = setHoverStyle()
const style = new olStyleStyle({
stroke: new olStroke({
color: stroke,
width: 3
}),
image: new olCircle({
radius: 5,
fill: new olFill({
color: fill
}),
stroke: new olStroke({
color: color,
width: 2
})
})
})
const { select } = addSelectInteraction(map, '_ol_kit_layer_panel_hover', { style: [style] })
this.selectInteraction = select
}
selectFeatures = features => {
const { disableHover } = this.props
if (disableHover) return
// clear the previously selected feature before adding newly selected feature so only one feature is "selected" at a time
this.selectInteraction.getFeatures().clear()
if (features?.length) {
features.forEach(feature => {
if (feature.get('_ol_kit_feature_visibility')) {
this.selectInteraction.getFeatures().push(feature)
}
})
}
}
componentDidMount = () => {
const { map, layerFilter } = this.props
const layers = map.getLayers()
const handleMapChange = (e, add) => {
const filteredLayers = layerFilter(layers.getArray())
// if we're adding a layer we want to add it to the top of the list but not update any of the other layers zIndex
if (add) {
const safeFilteredLayersLength = filteredLayers ? filteredLayers.length - 1 : 0
filteredLayers[safeFilteredLayersLength] && filteredLayers[safeFilteredLayersLength].setZIndex(filteredLayers.length) // eslint-disable-line
}
this.setState({ layers: filteredLayers.sort(this.zIndexSort) }, this.bindFeatureListeners)
}
// we call this to re-retrieve the layers on mount
handleMapChange()
this.initializeSelect(map)
// we call this to re-adjust the master checkbox as needed
this.handleMasterCheckbox()
// bind the event listeners
this.onAddKey = layers.on('add', (e) => handleMapChange(e, true))
this.onRemoveKey = layers.on('remove', handleMapChange)
}
componentWillUnmount = () => {
const { map } = this.props
const layers = map.getLayers()
// unbind the listeners
layers.unset(this.onAddKey)
layers.unset(this.onRemoveKey)
this.removeFeatureListeners()
}
componentDidUpdate (prevProps, prevState) {
// if there were previous layers but then removed to none set layers to empty and hanlde the mastercheckbox
if (prevState.layers.length > 0 && this.state.layers.length === 0) {
this.setState({ layers: [] })
this.handleMasterCheckbox()
// if you are adding layers when the prevlayers was 0 then the mastercheckbox needs to be dealt with
} else if (prevState.layers.length === 0 && this.state.layers.length > 0) {
this.handleMasterCheckbox()
}
const index = this.scrollIndex.current
setTimeout(() => {
/**
* this is absurd but literally what the author of the lib suggested
* to keep the scroll index updated after a rerender (checkbox click)
* https://github.com/petyosi/react-virtuoso/issues/323#issuecomment-810127282
*/
this.virtuoso.current?.scrollToIndex({
index,
align: 'start',
behavior: 'auto'
})
}, 0)
}
bindFeatureListeners = () => {
const { layers = [] } = this.state
const featureListeners = layers.reduce((listeners = [], layer) => {
if (this.props.shouldHideFeatures(layer)) return
const isVectorLayer = this.isValidVectorLayer(layer)
const canGetSource = typeof layer.getSource === 'function'
const hasVectorSource = canGetSource && layer.getSource() instanceof olSourceVector
if (!isVectorLayer && !hasVectorSource) return [...listeners]
const source = layer.getSource()
const addFeature = source.on('addfeature', this.handleFeatureChange)
const removeFeature = source.on('removefeature', this.handleFeatureChange)
return [...listeners, [source, addFeature], [source, removeFeature]]
}, [])
this.setState({ featureListeners })
}
removeFeatureListeners = () => {
const { featureListeners = [] } = this.state
Array.isArray(featureListeners) && featureListeners.forEach(([source, listener]) => {
source.unset(listener)
})
}
handleFeatureChange = () => {
this.forceUpdate()
}
getVisibleLayers = () => {
return this.state.layers.filter(layer => layer.getVisible())
}
showLayers = (layers) => {
return layers.map(layer => {
layer.setVisible(true)
return layer
})
}
hideLayers = (layers) => {
return layers.map(layer => {
layer.setVisible(false)
return layer
})
}
setVisibilityForAllLayers = (_, visibility) => {
const { layers } = this.state
const updatedLayers = visibility ? this.showLayers(layers) : this.hideLayers(layers)
layers.forEach(layer => this.setVisibilityForAllFeaturesOfLayer(layer, visibility))
this.setState({ layers: updatedLayers }, () => this.handleMasterCheckbox())
}
getFeaturesForLayer = (layer) => {
if (!this.isValidVectorLayer(layer)) return []
if (this.props.shouldHideFeatures(layer)) return []
return layer.getSource().getFeatures().map(feature => {
const isVisible = feature.get('_ol_kit_feature_visibility') === undefined ? true : feature.get('_ol_kit_feature_visibility')
const olkitStyle = feature.get('_ol_kit_feature_style') || feature.getStyle()
const featureOriginalStyle = isEqual(olkitStyle, new olStyleStyle(null)) ? undefined : olkitStyle
const featureStyle = isVisible ? featureOriginalStyle : new olStyleStyle()
feature.set('_ol_kit_feature_visibility', isVisible)
if (feature.get('_ol_kit_feature_style') === undefined) feature.set('_ol_kit_feature_style', featureStyle)
feature.setStyle(featureStyle)
return feature
}).filter((feature) => {
const name = feature.get('name')
return !this.props.enableFilter || !this.state.filterText || !name
? true : name.toLowerCase().includes(this.state.filterText.toLowerCase())
})
}
getVisibleFeaturesForLayer = (layer) => {
return (this.isValidVectorLayer(layer))
? layer.getSource().getFeatures().filter(feature => feature.get('_ol_kit_feature_visibility')) : []
}
setVisibilityForAllFeaturesOfLayer = (layer, visibility) => {
if (this.isValidVectorLayer(layer)) {
layer.getSource().getFeatures().map(feature => {
feature.set('_ol_kit_feature_visibility', visibility)
feature.setStyle(feature.get('_ol_kit_feature_style'))
})
}
}
// remove visible features on the layer
removeFeaturesForLayer = layer => {
const removeFeatures = this.getVisibleFeaturesForLayer(layer)
removeFeatures.map(feature => layer.getSource().removeFeature(feature))
this.handleLayerCheckbox(layer)
}
// this is where all of the checkbox magic happens...
handleMasterCheckbox = () => {
const visibleLayers = this.getVisibleLayers().length
const allLayers = this.state.layers.length
const indeterminateLayers = this.getVisibleLayers().filter(layer => layer.get('_ol_kit_layerpanel_visibility') === INDETERMINATE).length
const masterCheckboxState = indeterminateLayers ? INDETERMINATE : visibleLayers === allLayers && allLayers > 0 ? true : visibleLayers > 0 ? INDETERMINATE : false // eslint-disable-line
this.setState({ masterCheckboxVisibility: masterCheckboxState })
this.forceUpdate()
}
handleLayerCheckbox = (layer, layerCheckboxClick = false) => {
const visibleFeatures = this.getVisibleFeaturesForLayer(layer).length
const totalFeatures = this.isValidVectorLayer(layer) ? layer.getSource().getFeatures().length : 0
const layerVisibility = visibleFeatures === totalFeatures && totalFeatures > 0 ? true : visibleFeatures > 0 ? INDETERMINATE : false // eslint-disable-line
if (layerCheckboxClick && layerVisibility === INDETERMINATE) {
layer.setVisible(true)
layer.set('_ol_kit_layerpanel_visibility', true)
} else if (layerCheckboxClick) {
const lv = !layer.getVisible()
layer.setVisible(lv)
layer.set('_ol_kit_layerpanel_visibility', lv)
} else {
layer.setVisible(layerVisibility === INDETERMINATE ? true : layerVisibility)
layer.set('_ol_kit_layerpanel_visibility', layerVisibility)
}
if (layerCheckboxClick) this.setVisibilityForAllFeaturesOfLayer(layer, layer.getVisible())
this.handleMasterCheckbox()
}
handleFeatureCheckbox = (layer, feature) => {
feature.set('_ol_kit_feature_visibility', !feature.get('_ol_kit_feature_visibility'))
this.handleLayerCheckbox(layer)
}
handleVisibility = (event, layer) => {
event.stopPropagation()
this.handleMasterCheckbox()
this.handleLayerCheckbox(layer, true)
}
handleFilter = (filterText) => {
this.setState({ filterText })
}
isValidVectorLayer = (layer) => {
return (layer instanceof olLayerVector || (layer && layer.isVectorLayer))
}
handleExpandedLayer = (layer) => {
const clonedExpandedLayers = [...this.state.expandedLayers]
const index = clonedExpandedLayers.indexOf(layer.ol_uid)
index > -1 ? clonedExpandedLayers.splice(index, 1) : clonedExpandedLayers.push(layer.ol_uid)
this.setState({ expandedLayers: clonedExpandedLayers })
}
// highest zIndex should be at the front of the list (reverse order of array index)
zIndexSort = (a, b) => b.getZIndex() - a.getZIndex()
reorderLayers = (reorderedLayers) => {
// apply the z-index changes down to all layers
reorderedLayers.map((l, i) => l.setZIndex(reorderedLayers.length - i))
this.setState({ layers: reorderedLayers })
}
onFileImport = file => {
const { map, onFileImport } = this.props
// otherwise, add them to the map ourselves
convertFileToFeatures(file, map).then(({ features, name }) => {
// if a callback to handle imported features is passed, IAs handle add them to the map
if (onFileImport) {
features.forEach(({ featureArray, fileName }) => {
onFileImport(featureArray, `${name}(${fileName})`)
})
// if no onFileImport prop is passed, create a layer, add it and center/zoom the map
} else {
features.forEach(({ featureArray, fileName }) => {
const source = new olSourceVector({ features: featureArray })
const layer = new VectorLayer({ title: `${name}(${fileName})`, source })
map.addLayer(layer)
map.getView().fit(source.getExtent(), map.getSize())
})
}
}).catch(ugh.error)
}
handleRangeChange = visibleRange => {
this.scrollIndex.current = visibleRange.startIndex
}
renderFeatureRow = (index, data) => {
const { feature, layer } = data
const { handleFeatureDoubleClick, translations } = this.props
return (
<ListItem data-testid={`LayerPanel.feature${index}`} key={index} onDoubleClick={() => handleFeatureDoubleClick(feature)}>
<LayerPanelCheckbox
handleClick={(event) => this.handleFeatureCheckbox(layer, feature, event)}
checkboxState={feature.get('_ol_kit_feature_visibility')} />
<ListItemText inset={false} primary={feature.get('name') || `${translations['_ol_kit.LayerPanelListItem.feature']} ${index + 1}`} />
</ListItem>
)
}
formatFeatureRows = (features, layer) => (
Array.from({ length: features.length },
(_, index) => ({
feature: features[index],
layer,
})
)
)
render () {
const {
translations, layerFilter, handleLayerDoubleClick, disableDrag, tabIcon, onLayerRemoved,
onLayerReorder, enableFilter, getMenuItemsForLayer, shouldAllowLayerRemoval, map, onExportFeatures, onMergeLayers, onCreateHeatmap, expandedHeight
} = this.props
const { layers, masterCheckboxVisibility, filterText, expandedLayers } = this.state
const isExpandedLayer = (layer) => !!expandedLayers.find(expandedLayerId => expandedLayerId === layer.ol_uid)
return (
<LayerPanelPage tabIcon={tabIcon}>
<TextField
id='feature-filter-input'
label={translations['_ol_kit.LayerPanelLayersPage.filterText']}
type='text'
style={{ margin: '8px', display: enableFilter ? 'block' : 'none' }}
fullWidth
value={filterText}
onChange={(e) => this.handleFilter(e.target.value)} />
<LayerPanelContent padding={enableFilter ? '0px 15px 58px 15px !important' : '0px 15px'}>
<LayerPanelListItem
id='all_layers'
title={translations['_ol_kit.LayerPanelLayersPage.title']}
translations={translations} >
<LayerPanelCheckbox
checkboxState={masterCheckboxVisibility}
handleClick={this.setVisibilityForAllLayers} />
<ListItemText primary={'All Layers'} />
<ListItemSecondaryAction style={{ right: '0px !important' }}>
<LayerPanelActions
icon={<MoreHorizIcon data-testid='LayerPanel.masterActionsIcon' />}
translations={translations}
layers={layers}
map={map}>
<LayerPanelActionRemove
removeFeaturesForLayer={this.removeFeaturesForLayer}
shouldAllowLayerRemoval={shouldAllowLayerRemoval}
onLayerRemoved={onLayerRemoved} />
<LayerPanelActionImport handleImport={this.onFileImport} />
<LayerPanelActionExport onExportFeatures={onExportFeatures} />
<LayerPanelActionMerge onMergeLayers={onMergeLayers} />
</LayerPanelActions>
</ListItemSecondaryAction>
</LayerPanelListItem>
<LayerPanelList
disableDrag={disableDrag}
onSort={this.zIndexSort}
onReorderedItems={this.reorderLayers}
items={layers}
onLayerReorder={onLayerReorder} >
{layerFilter(layers).filter((layer) => {
const filteredFeatures = this.getFeaturesForLayer(layer)
return !enableFilter ||
!(layer instanceof olLayerVector) ||
this.props.shouldHideFeatures(layer) ? true : filteredFeatures?.length
}).map((layer, i) => {
const features = this.getFeaturesForLayer(layer)
const isExpanded = isExpandedLayer(layer)
const data = this.formatFeatureRows(features, layer)
const initialItemCount = data.length > this.props.initialItemCount ? this.props.initialItemCount : data.length
return (
<div key={i}
onMouseEnter={() => this.selectFeatures(features)}
onMouseLeave={() => this.selectFeatures([])}>
<LayerPanelListItem handleDoubleClick={() => { handleLayerDoubleClick(layer) }}>
{<LayerPanelCheckbox
checkboxState={!layer ? null : layer.get('_ol_kit_layerpanel_visibility') || layer.getVisible()}
handleClick={(e) => this.handleVisibility(e, layer)} />}
{<LayerPanelExpandableList
show={!!features}
open={isExpanded}
handleClick={() => this.handleExpandedLayer(layer)} />}
<ListItemText primary={layer.get('title') || 'Untitled Layer'} />
<ListItemSecondaryAction style={{ right: '0px !important' }}>
<LayerPanelActions
icon={<MoreVertIcon data-testid='LayerPanel.actionsIcon' />}
translations={translations}
layer={layer}
map={map} >
{getMenuItemsForLayer(layer) ||
[<LayerPanelActionRemove key='removeLayer' shouldAllowLayerRemoval={shouldAllowLayerRemoval} />,
<LayerPanelActionExtent key='gotoExtent' />,
<LayerPanelActionDuplicate key='duplicateLayer'/>,
<LayerPanelActionMergeFeatures key='mergeFeatures' />,
<LayerPanelActionHeatmap key='heatmap' layer={layer} onCreateHeatmap={onCreateHeatmap} />,
<LayerPanelActionOpacity key='layerOpacity' />]}
</LayerPanelActions>
</ListItemSecondaryAction>
</LayerPanelListItem>
{isExpanded
? <Collapse in={isExpanded} timeout='auto'>
<Virtuoso
style={{ paddingLeft: '36px', height: features.length * 52 > expandedHeight ? expandedHeight : features.length * 52 }}
data={data}
ref={this.virtuoso}
itemContent={this.renderFeatureRow}
rangeChanged={this.handleRangeChange}
initialItemCount={initialItemCount}
/>
</Collapse>
: null
}
</div>
)
})}
</LayerPanelList>
</LayerPanelContent>
</LayerPanelPage>
)
}
}
LayerPanelLayersPage.defaultProps = {
handleFeatureDoubleClick: () => {},
handleLayerDoubleClick: () => {},
layerFilter: (layers) => layers.filter(layer => !layer.get('_ol_kit_basemap') && layer.get('name') !== 'unselectable'),
shouldHideFeatures: (layer) => false,
shouldAllowLayerRemoval: (layer) => true,
getMenuItemsForLayer: () => false,
onCreateHeatmap: () => {},
tabIcon: <LayersIcon />,
setHoverStyle: () => ({ color: 'red', fill: '#ffffff', stroke: 'red' }),
disableHover: false,
initialItemCount: 6,
expandedHeight: 300
}
LayerPanelLayersPage.propTypes = {
/** Object with key/value pairs for translated strings */
translations: PropTypes.object,
/** An Openlayer map from which the layer panel will derive its layers */
map: PropTypes.object,
/** The icon for the page shown in the left side of the layer panel */
tabIcon: PropTypes.node,
/** A function which takes in layers & returns a subset of those layers (useful to "hide" certain layers) */
layerFilter: PropTypes.func,
/** A boolean which turns on/off filtering of features in the layer panel page */
enableFilter: PropTypes.bool,
/** A callback function passed the features imported from 'kmz', 'kml', 'geojson', 'wkt', 'csv', 'zip', and 'json' file types */
onFileImport: PropTypes.func,
/** A callback function fired when a feature list item is double clicked */
handleFeatureDoubleClick: PropTypes.func,
/** A callback function fired when a feature list header is double clicked */
handleLayerDoubleClick: PropTypes.func,
/** An array of components to be rendered in the LayerPanelHeader action menu */
customActions: PropTypes.array,
/** A callback function to determine if a given layer's features should be kept hidden from the panel page display */
shouldHideFeatures: PropTypes.func,
/** A callback function to determine if a given layer should be allowed to be removed from the panel page display */
shouldAllowLayerRemoval: PropTypes.func,
/** A callback fired when a new heatmap is created */
onCreateHeatmap: PropTypes.func,
/** A callback function that returns the file type and the features that are being exported */
onExportFeatures: PropTypes.func,
/** A callback fired when layers are merged */
onMergeLayers: PropTypes.func,
/** A callback function to set custom Menu Items for a specific layer. Should recieve an array of `@material-ui/core/MenuItem` */
getMenuItemsForLayer: PropTypes.func,
/** A boolean to disable the drag event on the LayerPanelList */
disableDrag: PropTypes.bool,
/** A callback function to inform when layers are reordered */
onLayerReorder: PropTypes.func,
/** A callback function to inform when a layer is removed */
onLayerRemoved: PropTypes.func,
/** Truthy value will disable hover */
disableHover: PropTypes.bool,
/** Pass fill, stroke, and color hover style values */
setHoverStyle: PropTypes.func,
/** Set initial item count to render feature rows in virtualized list */
initialItemCount: PropTypes.number,
/** Set max height for feature scroll area (number equates to pixels) */
expandedHeight: PropTypes.number
}
export default connectToContext(LayerPanelLayersPage)