/* global L, Event */
import React from 'react'
import { Portal } from 'react-portal'
import Supercluster from 'supercluster'
import { connect } from 'react-redux'
import * as selectors from '../selectors'
import 'leaflet'
import Sites from './presentational/Map/Sites.jsx'
import Shapes from './presentational/Map/Shapes.jsx'
import Events from './presentational/Map/Events.jsx'
import Clusters from './presentational/Map/Clusters.jsx'
import SelectedEvents from './presentational/Map/SelectedEvents.jsx'
import Narratives from './presentational/Map/Narratives'
import DefsMarkers from './presentational/Map/DefsMarkers.jsx'
import LoadingOverlay from '../components/Overlay/Loading'
import { mapClustersToLocations, isIdentical, isLatitude, isLongitude } from '../common/utilities'
// NB: important constants for map, TODO: make statics
const supportedMapboxMap = ['streets', 'satellite']
const defaultToken = 'your_token'
class Map extends React.Component {
constructor () {
super()
this.projectPoint = this.projectPoint.bind(this)
this.onClusterSelect = this.onClusterSelect.bind(this)
this.loadClusterData = this.loadClusterData.bind(this)
this.svgRef = React.createRef()
this.map = null
this.superclusterIndex = null
this.state = {
mapTransformX: 0,
mapTransformY: 0,
indexLoaded: false,
clusters: []
}
this.styleLocation = this.styleLocation.bind(this)
}
componentDidMount () {
if (this.map === null) {
this.initializeMap()
}
window.dispatchEvent(new Event('resize'))
}
componentWillReceiveProps (nextProps) {
if (!isIdentical(nextProps.domain.locations, this.props.domain.locations)) {
this.loadClusterData(nextProps.domain.locations)
}
// Set appropriate zoom for narrative
const { bounds } = nextProps.app.map
if (!isIdentical(bounds, this.props.app.map.bounds) &&
bounds !== null) {
this.map.fitBounds(bounds)
} else {
if (!isIdentical(nextProps.app.selected, this.props.app.selected)) {
// Fly to first of events selected
const eventPoint = (nextProps.app.selected.length > 0) ? nextProps.app.selected[0] : null
if (eventPoint !== null && eventPoint.latitude && eventPoint.longitude) {
// this.map.setView([eventPoint.latitude, eventPoint.longitude])
this.map.setView([eventPoint.latitude, eventPoint.longitude], this.map.getZoom(), {
'animate': true,
'pan': {
'duration': 0.7
}
})
}
}
}
}
initializeMap () {
/**
* Creates a Leaflet map and a tilelayer for the map background
*/
const { map: mapConfig, cluster: clusterConfig } = this.props.app
const map =
L.map(this.props.ui.dom.map)
.setView(mapConfig.anchor, mapConfig.startZoom)
.setMinZoom(mapConfig.minZoom)
.setMaxZoom(mapConfig.maxZoom)
.setMaxBounds(mapConfig.maxBounds)
// Initialize supercluster index
this.superclusterIndex = new Supercluster({
radius: clusterConfig.radius,
maxZoom: clusterConfig.maxZoom,
minZoom: clusterConfig.minZoom
})
let firstLayer
if ((supportedMapboxMap.indexOf(this.props.ui.tiles) !== -1) && process.env.MAPBOX_TOKEN && process.env.MAPBOX_TOKEN !== defaultToken) {
firstLayer = L.tileLayer(
`http://a.tiles.mapbox.com/v4/mapbox.${this.props.ui.tiles}/{z}/{x}/{y}@2x.png?access_token=${process.env.MAPBOX_TOKEN}`
)
} else if (process.env.MAPBOX_TOKEN && process.env.MAPBOX_TOKEN !== defaultToken) {
firstLayer = L.tileLayer(
`http://a.tiles.mapbox.com/styles/v1/${this.props.ui.tiles}/tiles/{z}/{x}/{y}?access_token=${process.env.MAPBOX_TOKEN}`
)
} else {
firstLayer = L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
)
}
firstLayer.addTo(map)
map.keyboard.disable()
map.zoomControl.remove()
map.on('moveend', () => {
this.updateClusters()
this.alignLayers()
})
map.on('move zoomend viewreset', () => this.alignLayers())
map.on('zoomstart', () => { if (this.svgRef.current !== null) this.svgRef.current.classList.add('hide') })
map.on('zoomend', () => { if (this.svgRef.current !== null) this.svgRef.current.classList.remove('hide') })
window.addEventListener('resize', () => { this.alignLayers() })
this.map = map
}
getMapDetails () {
const bounds = this.map.getBounds()
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]
const zoom = this.map.getZoom()
return [bbox, zoom]
}
updateClusters () {
const [bbox, zoom] = this.getMapDetails()
if (this.superclusterIndex && this.state.indexLoaded) {
this.setState({
clusters: this.superclusterIndex.getClusters(bbox, zoom)
})
}
}
loadClusterData (locations) {
if (locations && locations.length > 0 && this.superclusterIndex) {
const convertedLocations = locations.reduce((acc, loc) => {
const { longitude, latitude } = loc
const validCoordinates = isLatitude(latitude) && isLongitude(longitude)
if (validCoordinates) {
const feature = {
type: 'Feature',
properties: {
cluster: false,
id: loc.label
},
geometry: {
type: 'Point',
coordinates: [longitude, latitude]
}
}
acc.push(feature)
}
return acc
}, [])
this.superclusterIndex.load(convertedLocations)
this.setState({ indexLoaded: true }, () => {
this.updateClusters()
})
} else {
this.setState({ clusters: [] })
}
}
alignLayers () {
const mapNode = document.querySelector('.leaflet-map-pane')
if (mapNode === null) return { transformX: 0, transformY: 0 }
// We'll get the transform of the leaflet container,
// which will let us offset the SVG by the same quantity
const transform = window
.getComputedStyle(mapNode)
.getPropertyValue('transform')
// Offset with leaflet map transform boundaries
this.setState({
mapTransformX: +transform.split(',')[4],
mapTransformY: +transform.split(',')[5].split(')')[0]
})
}
projectPoint (location) {
const latLng = new L.LatLng(location[0], location[1])
return {
x: this.map.latLngToLayerPoint(latLng).x + this.state.mapTransformX,
y: this.map.latLngToLayerPoint(latLng).y + this.state.mapTransformY
}
}
onClusterSelect (e) {
const { id } = e.target
const { longitude, latitude } = e.target.attributes
const expansionZoom = Math.max(this.superclusterIndex.getClusterExpansionZoom(parseInt(id)), this.superclusterIndex.options.minZoom)
this.map.flyTo(new L.LatLng(latitude.value, longitude.value), expansionZoom)
}
getClientDims () {
const boundingClient = document.querySelector(`#${this.props.ui.dom.map}`).getBoundingClientRect()
return {
width: boundingClient.width,
height: boundingClient.height
}
}
renderTiles () {
const pane = this.map.getPanes().overlayPane
const { width, height } = this.getClientDims()
return this.map ? (
) : null
}
renderSites () {
return (
)
}
renderShapes () {
return (
)
}
renderNarratives () {
const hasNarratives = this.props.domain.narratives.length > 0
return (
)
}
/**
* Determines additional styles on the for each location.
* A location consists of an array of events (see selectors). The function
* also has full access to the domain and redux state to derive values if
* necessary. The function should return an array, where the value at the
* first index is a styles object for the SVG at the location, and the value
* at the second index is an optional additional component that renders in
* the div.
*/
styleLocation (location) {
return [null, null]
}
styleCluster (cluster) {
return [null, null]
}
renderEvents () {
const individualClusters = this.state.clusters.filter(cl => !cl.properties.cluster)
const filteredLocations = mapClustersToLocations(individualClusters, this.props.domain.locations)
return (
)
}
renderClusters () {
const allClusters = this.state.clusters.filter(cl => cl.properties.cluster)
return (
)
}
renderSelected () {
return (
)
}
renderMarkers () {
return (
)
}
render () {
const { isShowingSites, isFetchingDomain } = this.props.app.flags
const classes = this.props.app.narrative ? 'map-wrapper narrative-mode' : 'map-wrapper'
const innerMap = this.map ? (
{this.renderTiles()}
{this.renderMarkers()}
{isShowingSites ? this.renderSites() : null}
{this.renderShapes()}
{this.renderNarratives()}
{this.renderEvents()}
{this.renderClusters()}
{this.renderSelected()}
) : null
return (
)
}
}
function mapStateToProps (state) {
return {
domain: {
locations: selectors.selectLocations(state),
narratives: selectors.selectNarratives(state),
categories: selectors.getCategories(state),
sites: selectors.selectSites(state),
shapes: selectors.selectShapes(state)
},
app: {
views: state.app.associations.views,
selected: selectors.selectSelected(state),
highlighted: state.app.highlighted,
map: state.app.map,
cluster: state.app.cluster,
language: state.app.language,
loading: state.app.loading,
narrative: state.app.associations.narrative,
flags: {
isShowingSites: state.app.flags.isShowingSites,
isFetchingDomain: state.app.flags.isFetchingDomain
}
},
ui: {
tiles: state.ui.tiles,
dom: state.ui.dom,
narratives: state.ui.style.narratives,
mapSelectedEvents: state.ui.style.selectedEvents,
shapes: state.ui.style.shapes,
eventRadius: state.ui.eventRadius,
radial: state.ui.style.clusters.radial
},
features: selectors.getFeatures(state)
}
}
export default connect(mapStateToProps)(Map)