react-mapfilter
Version:
A React Component for viewing and filtering GeoJSON
394 lines (345 loc) • 11.4 kB
JavaScript
import debug from 'debug'
import PropTypes from 'prop-types'
import React from 'react'
import mapboxgl from 'mapbox-gl'
import deepEqual from 'deep-equal'
import assign from 'object-assign'
import featureFilter from 'feature-filter-geojson'
import { withStyles } from '@material-ui/core/styles'
import * as MFPropTypes from '../../util/prop_types'
import { getBoundsOrWorld } from '../../util/map_helpers'
import config from '../../../config.json'
import Popup from './Popup'
require('mapbox-gl/dist/mapbox-gl.css')
/* Mapbox [API access token](https://www.mapbox.com/help/create-api-access-token/) */
mapboxgl.accessToken = config.mapboxToken
const log = debug('mf:mapview')
const styles = {
root: {
width: '100%',
height: '100%',
position: 'absolute'
},
map: {
width: '100%',
height: '100%'
}
}
const emptyGeoJson = {
type: 'FeatureCollection',
features: []
}
const labelStyleLayer = {
id: 'labels',
type: 'symbol',
source: 'features',
layout: {
'text-field': '',
'text-allow-overlap': true,
'text-ignore-placement': true,
'text-size': 9,
'text-font': [
'DIN Offc Pro Bold',
'Arial Unicode MS Bold'
]
},
paint: {
'text-color': '#fff',
'text-halo-color': 'rgba(100,100,100, 0.3)',
'text-halo-width': 0.3
}
}
const pointStyleLayer = {
id: 'points',
type: 'circle',
source: 'features',
paint: {
// make circles larger as the user zooms from z12 to z22
'circle-radius': {
'base': 1.5,
'stops': [[7, 5], [18, 25]]
},
'circle-color': {
'property': '__mf_color',
'type': 'identity'
},
'circle-opacity': 0.75,
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff',
'circle-stroke-opacity': 0.9
}
}
const pointHoverStyleLayer = {
id: 'points-hover',
type: 'circle',
source: 'hover',
paint: assign({}, pointStyleLayer.paint, {
'circle-opacity': 1,
'circle-stroke-width': 2.5,
'circle-stroke-color': '#ffffff',
'circle-stroke-opacity': 1
})
}
const noop = (x) => x
class MapView extends React.Component {
static defaultProps = {
center: [0, 0],
zoom: 0,
features: [],
showFeatureDetail: noop,
moveMap: noop,
interactive: true,
labelPoints: false,
mapControls: []
}
static propTypes = {
/* map center point [lon, lat] */
center: PropTypes.array,
/* Geojson FeatureCollection of features to show on map */
features: PropTypes.arrayOf(MFPropTypes.mapViewFeature).isRequired,
/* Current filter (See https://www.mapbox.com/mapbox-gl-style-spec/#types-filter) */
filter: MFPropTypes.mapboxFilter,
/**
* - NOT yet dynamic e.g. if you change it the map won't change
* Map style. This must be an an object conforming to the schema described in the [style reference](https://mapbox.com/mapbox-gl-style-spec/), or a URL to a JSON style. To load a style from the Mapbox API, you can use a URL of the form `mapbox://styles/:owner/:style`, where `:owner` is your Mapbox account name and `:style` is the style ID. Or you can use one of the predefined Mapbox styles.
*/
mapStyle: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
labelPoints: PropTypes.bool,
/**
* Triggered when a marker is clicked. Called with a (cloned) GeoJson feature
* object of the marker that was clicked.
*/
showFeatureDetail: PropTypes.func,
/* Triggered when map is moved, called with map center [lng, lat] */
moveMap: PropTypes.func.isRequired,
fieldMapping: MFPropTypes.fieldMapping,
/* map zoom */
zoom: PropTypes.number,
interactive: PropTypes.bool,
mapControls: PropTypes.arrayOf(PropTypes.shape({
onAdd: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired
}))
}
state = {}
handleMapMoveOrZoom = (e) => {
if (e.internal) return
this.props.moveMap({
center: this.map.getCenter().toArray(),
zoom: this.map.getZoom(),
bearing: this.map.getBearing()
})
}
handleMapClick = (e) => {
// if (!this.map.loaded()) return
var features = this.map.queryRenderedFeatures(
e.point,
{layers: ['points-hover']}
)
if (!features.length) return
this.setState({lngLat: null})
this.props.showFeatureDetail(features[0].properties.__mf_id)
}
handleMouseMove = (e) => {
if (!this.map.loaded()) return
var features = this.map.queryRenderedFeatures(
e.point,
{layers: ['points', 'points-hover']}
)
this.map.getCanvas().style.cursor = (features.length) ? 'pointer' : ''
if (!features.length) {
this.setState({lngLat: null})
this.map.getSource('hover').setData(emptyGeoJson)
return
}
this.map.getSource('hover').setData(features[0])
this.setState({lngLat: features[0].geometry.coordinates})
this.setState({id: features[0].properties.__mf_id})
}
ready (fn) {
if (this.map.loaded() && !this._styleDirty) {
fn()
} else {
this.map.once('load', () => fn.call(this))
}
}
render () {
const {classes} = this.props
return (
<div className={classes.root}>
<div
ref={(el) => (this.mapContainer = el)}
className={classes.map}
/>
{this.state.lngLat && <Popup map={this.map} {...this.state} />}
</div>
)
}
centerMap (geojson) {
this.map.fitBounds(getBoundsOrWorld(geojson), {padding: 15, duration: 0})
if (this.map.getZoom() > 13) {
this.map.setZoom(13)
}
}
// The first time our component mounts, render a new map into `mapDiv`
// with settings from props.
componentDidMount () {
const { center, interactive, mapStyle, zoom, mapControls } = this.props
const mapDiv = document.createElement('div')
mapDiv.style.height = '100%'
mapDiv.style.width = '100%'
this.mapContainer.appendChild(mapDiv)
const map = window.map = this.map = new mapboxgl.Map({
style: mapStyle,
container: mapDiv,
center: center || [0, 0],
zoom: zoom || 0
})
map._prevStyle = mapStyle
if (!interactive) {
map.scrollZoom.disable()
}
// Add zoom and rotation controls to the map.
map.addControl(new mapboxgl.NavigationControl())
map.dragRotate.disable()
map.touchZoomRotate.disableRotation()
this.geojson = this.getGeoJson(this.props)
map.once('load', () => {
if (interactive) {
map.on('moveend', this.handleMapMoveOrZoom)
map.on('click', this.handleMapClick)
map.on('mousemove', this.handleMouseMove)
}
this.setupLayers(this.props)
})
map.once('style.load', () => {
mapControls.forEach(function (control) {
map.addControl.bind(map)(control)
})
})
// If no map center or zoom passed, set map extent to extent of marker layer
if (!center || !zoom) {
this.centerMap(this.geojson)
}
}
componentWillReceiveProps (nextProps) {
this.updateIfNeeded(nextProps, this.props)
}
componentWillUnmount () {
this.map.off('moveend', this.handleMapMoveOrZoom)
this.map.off('click', this.handleMapClick)
this.map.off('mousemove', this.handleMouseMove)
this.map.remove()
}
setupLayers (props) {
const {filter, labelPoints} = props
this.map.addSource('features', {type: 'geojson', data: this.geojson})
// TODO: Should choose style based on whether features are point, line or polygon
this.map.addSource('hover', {type: 'geojson', data: emptyGeoJson})
this.map.addLayer(pointStyleLayer)
this.map.addLayer(pointHoverStyleLayer)
this.map.addLayer(labelStyleLayer)
this.map.setFilter('points', filter)
this.map.setFilter('labels', filter)
if (labelPoints) {
this.map.setLayoutProperty('labels', 'text-field', '{__mf_label}')
this.map.setPaintProperty('points', 'circle-radius', 7)
}
}
updateIfNeeded (nextProps, props = {}) {
const {disableScrollToZoom} = props
if (this.map._prevStyle !== nextProps.mapStyle) {
log('updating style')
this._styleDirty = true
this.map.setStyle(nextProps.mapStyle)
this.map._prevStyle = nextProps.mapStyle
this.map.once('style.load', () => {
this.setupLayers(nextProps)
this._styleDirty = false
if (!this.map._loaded) return
this.map.fire('load')
})
}
var shouldMapZoom = this.map.getZoom() !== nextProps.zoom
var shouldMapMove = !deepEqual(this.map.getCenter().toArray(), nextProps.center)
if (shouldMapZoom || shouldMapMove) {
this.map.flyTo({center: nextProps.center, zoom: nextProps.zoom}, {internal: true})
}
let shouldDataUpdate = nextProps.features !== props.features ||
nextProps.fieldMapping !== props.fieldMapping ||
nextProps.colorIndex !== props.colorIndex ||
(nextProps.filter !== props.filter && props.labelPoints)
this.ready(() => {
if (shouldDataUpdate) {
this.geojson = this.getGeoJson(nextProps)
log('updating source', this.geojson)
this.map.getSource('features').setData(this.geojson)
this.centerMap(this.geojson)
}
this.updateFilterIfNeeded(nextProps.filter)
if (disableScrollToZoom !== nextProps.disableScrollToZoom) {
nextProps.disableScrollToZoom ? this.map.scrollZoom.disable() : this.map.scrollZoom.enable()
}
const textField = nextProps.labelPoints ? '{__mf_label}' : ''
if (this.map.getLayoutProperty('labels', 'text-field') !== textField) {
log('updating labels "' + textField + '"')
this.map.setLayoutProperty('labels', 'text-field', textField)
}
})
}
/**
* Moves the map to a new position if it is different from the current position
* @param {array} center new coordinates for center of map
* @param {number} zoom new zoom level for map
* @return {boolean} true if map has moved, otherwise false
*/
moveIfNeeded (center, zoom) {
const currentPosition = {
center: this.map.getCenter().toArray(),
zoom: this.map.getZoom()
}
const newMapPosition = {
center,
zoom
}
const shouldMapMove = center && zoom &&
!deepEqual(currentPosition, newMapPosition)
if (shouldMapMove) {
log('Moving map')
this.map.jumpTo(newMapPosition)
return true
}
return false
}
updateFilterIfNeeded (filter) {
if (filter !== this.props.filter && filter) {
log('updating filter')
this.map.setFilter('points', filter)
this.map.setFilter('labels', filter)
}
}
// Construct GeoJSON for map
getGeoJson ({features = [], fieldMapping = {}, colorIndex = {}, filter = []}) {
let i = 0
const ff = featureFilter(filter)
return {
type: 'FeatureCollection',
features: features
.filter(f => f.geometry)
.map(f => {
const newFeature = {
type: 'feature',
geometry: f.geometry,
properties: assign({}, f.properties, {
__mf_id: f.id,
__mf_color: colorIndex[f.properties[fieldMapping.color] || f.properties[fieldMapping.color + '.0']]
})
}
if (ff(f)) newFeature.properties.__mf_label = config.labelChars.charAt(i++)
return newFeature
})
}
}
}
MapView.MfViewId = 'map'
export default withStyles(styles)(MapView)