@conveyal/commute
Version:
Commute analysis
600 lines (545 loc) • 18.1 kB
JavaScript
import hslToHex from 'colorvert/hsl/hex'
import {toLeaflet} from '@conveyal/lonlat'
import {geom, io, precision, simplify} from 'jsts'
import humanizeDuration from 'humanize-duration'
import {Browser, icon, latLngBounds, point} from 'leaflet'
import React, {Component, PropTypes} from 'react'
import {Circle, GeoJSON, Map as LeafletMap, Marker, Popup, TileLayer} from 'react-leaflet'
import {Button} from 'react-bootstrap'
import Heatmap from 'react-leaflet-heatmap-layer'
import Icon from '../util/icon'
import Legend from './legend'
import MarkerCluster from './marker-cluster'
import { humanizeDistance } from '../../utils'
import {entityMapToEntityArray} from '../../utils/entities'
const geoJsonReader = new io.GeoJSONReader()
const geoJsonWriter = new io.GeoJSONWriter()
const homeIconUrl = `${process.env.STATIC_HOST}assets/home-2.png`
const homeIcon = icon({
iconUrl: homeIconUrl,
iconSize: [32, 37],
iconAnchor: [16, 37]
})
const homeIconSelectedUrl = `${process.env.STATIC_HOST}assets/home-2-selected.png`
const homeIconSelected = icon({
iconUrl: homeIconSelectedUrl,
iconSize: [32, 37],
iconAnchor: [16, 37]
})
const homeIconSelectedOffset = point(0, -20)
export default class SiteMap extends Component {
static propTypes = {
activeTab: PropTypes.string,
commuters: PropTypes.array,
handleSelectCommuter: PropTypes.func,
isMultiSite: PropTypes.bool,
isReport: PropTypes.bool,
mapDisplayMode: PropTypes.string,
polygonStore: PropTypes.object,
selectedCommuter: PropTypes.object,
setMapDisplayMode: PropTypes.func,
site: PropTypes.object,
sites: PropTypes.array
}
resized () {
this.refs['leafletMap'].leafletElement.invalidateSize(false)
}
_mapSitesAndCommuters = () => {
const {
commuters,
handleSelectCommuter,
isMultiSite,
isReport,
site,
sites,
selectedCommuter
} = this.props
const commuterMarkers = []
const siteMarkers = []
let focusMarker, sitesToMakeMarkersFor
if (isMultiSite) {
sitesToMakeMarkersFor = sites
} else {
sitesToMakeMarkersFor = [site]
}
const firstSiteCoordinates = toLeaflet(sitesToMakeMarkersFor[0].coordinate)
const bounds = latLngBounds([firstSiteCoordinates, firstSiteCoordinates])
sitesToMakeMarkersFor.forEach((siteToMakeMarkerFor) => {
const sitePosition = toLeaflet(siteToMakeMarkerFor.coordinate)
// add site marker
siteMarkers.push(
<Marker
key={`site-marker-${siteToMakeMarkerFor._id}`}
position={sitePosition}
zIndexOffset={9000}
/>
)
bounds.extend(sitePosition)
})
// add all commuters to site
commuters.forEach((commuter) => {
if (commuter.coordinate.lat === 0) return // don't include commuters not geocoded yet
const commuterPosition = toLeaflet(commuter.coordinate)
const isSelectedCommuter = selectedCommuter && selectedCommuter._id === commuter._id
const commuterMarkerKey = `commuter-marker-${commuter._id}`
if (isSelectedCommuter) {
focusMarker = {
id: commuterMarkerKey,
latLng: commuterPosition
}
}
commuterMarkers.push(
<Marker
commuterName={commuter.name} // used when creating MarkerCluster
icon={isSelectedCommuter ? homeIconSelected : homeIcon}
key={commuterMarkerKey}
onClick={() => {
if (!isReport) {
handleSelectCommuter(commuter, true)
}
}}
position={commuterPosition}
zIndexOffset={1234}
>
{!!handleSelectCommuter &&
<Popup
offset={homeIconSelectedOffset}
>
<h4>{commuter.name}</h4>
</Popup>
}
</Marker>
)
bounds.extend(commuterPosition)
})
// return position and zoom if no commuters or commuters haven't loaded yet
if (commuterMarkers.length === 0 && siteMarkers.length === 1) {
return {
commuterMarkers,
siteMarkers,
position: firstSiteCoordinates,
zoom: 11
}
}
return {
bounds,
commuterMarkers,
focusMarker,
siteMarkers
}
}
render () {
const {
activeTab,
analysisMapStyle,
analysisMode,
commuterRingRadius,
commuters,
isMultiSite,
isReport,
isochroneCutoff,
mapDisplayMode,
polygonStore,
rideMatchMapStyle,
selectedCommuter,
setMapDisplayMode,
site
} = this.props
const hasCommuters = commuters.length > 0
/************************************************************************
map stuff
************************************************************************/
const mapLegendProps = {
html: '<h4>Legend</h4><table><tbody>',
position: 'bottomright'
}
// add marker to legend
const siteIconUrl = 'https://unpkg.com/leaflet@1.0.2/dist/images/marker-icon-2x.png'
mapLegendProps.html += `<tr><td><img src="${siteIconUrl}" style="width: 25px;"/></td><td>Site</td></tr>`
const {
bounds,
commuterMarkers,
focusMarker,
position,
siteMarkers,
zoom
} = this._mapSitesAndCommuters()
const clusterMarkers = []
const commuterRings = []
function doClusterMarkerWork () {
if (!isReport) {
mapLegendProps.html += `<tr>
<td>
<img src="${homeIconUrl}" />
</td>
<td>Single Commuter</td>
</tr>`
}
mapLegendProps.html += `<tr>
<td>
<img src="${process.env.STATIC_HOST}assets/cluster.png" style="width: 40px;"/>
</td>
<td>Cluster of Commuters</td>
</tr>`
commuterMarkers.forEach((marker) => {
clusterMarkers.push({
id: marker.key,
isReport,
latLng: marker.props.position,
markerOptions: marker.props,
onClick: marker.props.onClick,
popupHtml: `<h4>${marker.props.commuterName}</h4>`
})
})
}
if (hasCommuters) {
if (activeTab === 'ridematches') {
if (rideMatchMapStyle === 'marker-clusters') {
doClusterMarkerWork()
} else if (rideMatchMapStyle === 'heatmap') {
mapLegendProps.html += `<tr>
<td
rowspan="2"
style="background-image: url(${process.env.STATIC_HOST}assets/heatmap-gradient.png);
background-size: contain;"
/>
<td>Less Commuters</td>
</tr>
<tr>
<td>More Commuters</td>
</tr>`
} else if (rideMatchMapStyle === 'commuter-rings') {
mapLegendProps.html += `<tr>
<td>
<img src="${homeIconUrl}" />
</td>
<td>Commuter</td>
</tr>
<tr>
<td>
<div style="border: 3px solid #3388ff; border-radius: 20px; overflow: hidden;">
<div style="background-color: #3388ff; opacity: 0.2; height: 25px;">
</div>
</div>
</td>
<td>${humanizeDistance(commuterRingRadius, 2)} Radius</td>
</tr>`
const meters = commuterRingRadius * 1609.34
commuterMarkers.forEach((marker) => {
commuterRings.push(
<Circle
center={marker.props.position}
key={`commuter-circle-${marker.key}`}
radius={meters}
/>
)
})
}
} else {
doClusterMarkerWork()
}
}
if ((!(activeTab === 'ridematches') || (
activeTab === 'ridematches' && rideMatchMapStyle !== 'heatmap'
)) && selectedCommuter) {
mapLegendProps.html += `<tr>
<td>
<img src="${homeIconSelectedUrl}" />
</td>
<td>${selectedCommuter.name}</td>
</tr>`
}
// isochrones
const isochrones = []
if (!isMultiSite &&
activeTab === 'analysis' &&
site.calculationStatus === 'successfully') {
// travel times calculated successfully
const curIsochrones = getIsochrones({
analysisMapStyle,
analysisMode,
isochroneCutoff,
polygonStore,
site
})
curIsochrones
.filter((isochrone) => isochrone.properties.time <= isochroneCutoff)
.forEach((isochrone) => {
const geojsonProps = {
data: Object.assign(isochrone, { type: 'Feature' }),
key: `isochrone-${analysisMapStyle}-${analysisMode}-${isochrone.properties.time}`,
onEachFeature
}
if (isochroneStyleStrategies[analysisMapStyle]) {
Object.assign(geojsonProps, isochroneStyleStrategies[analysisMapStyle])
}
isochrones.push(
<GeoJSON {...geojsonProps} />
)
})
mapLegendProps.html += getIsochroneLegendHtml({ analysisMapStyle, isochroneCutoff, analysisMode })
}
mapLegendProps.html += '</tbody></table>'
return (
<LeafletMap ref='leafletMap'
center={position}
bounds={bounds}
zoom={zoom}
>
<TileLayer
url={Browser.retina &&
process.env.LEAFLET_RETINA_URL
? process.env.LEAFLET_RETINA_URL
: process.env.LEAFLET_TILE_URL}
attribution={process.env.LEAFLET_ATTRIBUTION}
/>
{siteMarkers}
{(activeTab !== 'ridematches' ||
(activeTab === 'ridematches' && rideMatchMapStyle === 'marker-clusters')) &&
<MarkerCluster
focusMarker={focusMarker}
newMarkerData={clusterMarkers}
singleMarkerMode={isReport}
/>
}
{activeTab === 'ridematches' && rideMatchMapStyle === 'heatmap' &&
<Heatmap
intensityExtractor={m => 1000}
latitudeExtractor={m => m.props.position.lat}
longitudeExtractor={m => m.props.position.lng}
points={commuterMarkers}
/>
}
{activeTab === 'ridematches' && rideMatchMapStyle === 'commuter-rings' &&
commuterRings}
{activeTab === 'ridematches' && rideMatchMapStyle === 'commuter-rings' &&
commuterMarkers}
{isochrones}
<Legend {...mapLegendProps} />
{!isReport && mapDisplayMode === 'STANDARD' &&
<div className='map-size-buttons-container'>
<Button bsSize='small' onClick={() => { setMapDisplayMode('HIDDEN') }}>
<Icon type='compress' /> Hide Map
</Button>
<Button bsSize='small' onClick={() => { setMapDisplayMode('FULLSCREEN') }}>
<Icon type='expand' /> Fullscreen
</Button>
</div>
}
{!isReport && mapDisplayMode === 'FULLSCREEN' &&
<div className='map-size-buttons-container'>
<Button bsSize='small' onClick={() => { setMapDisplayMode('STANDARD') }}>
<Icon type='times' />
</Button>
</div>
}
</LeafletMap>
)
}
}
function capitalize (s) {
return s.charAt(0).toUpperCase() + s.slice(1)
}
/** getIsochroneLegendHtml **/
function getIsochroneLegendHtml ({ analysisMapStyle, isochroneCutoff, analysisMode }) {
let html = `<tr><td colspan="2">Travel Time (by ${capitalize(analysisMode.toLowerCase())})</td></tr>`
const strategy = getIsochroneStrategies[analysisMapStyle]
if (strategy === 'single isochrone') {
html += `<tr>
<td>
<div style="border: 1px solid black;">
<div style="background-color: ${fillColor[analysisMapStyle]}; opacity: 0.4;">
</div>
</div>
</td>
<td>0 - ${shortEnglishHumanizer(isochroneCutoff * 1000)}</td>
</tr>`
} else if (strategy === 'inverted isochrone') {
html += `<tr>
<td><div style="border: 3px solid #3388FF;"> </div></td>
<td>0 - ${shortEnglishHumanizer(isochroneCutoff * 1000)}</td>
</tr>`
} else {
const timeGap = 900
for (let curTime = timeGap; curTime <= 7200; curTime += timeGap) {
html += `<tr>
<td style="background-color: ${fillColor[analysisMapStyle](curTime)}; opacity: 0.4;"></td>
<td>${shortEnglishHumanizer((curTime - timeGap) * 1000)} - ${shortEnglishHumanizer(curTime * 1000)}</td>
</tr>`
}
}
return html
}
/** getIsochrones **/
const getIsochroneCache = {}
function getIsochrones ({ analysisMapStyle, analysisMode, isochroneCutoff, polygonStore, site }) {
const strategy = getIsochroneStrategies[analysisMapStyle]
const cacheQuery = [
site.coordinate.lat,
site.coordinate.lng,
analysisMode,
strategy === (strategy.indexOf('minute') > -1) ? strategy : `${strategy}-${isochroneCutoff}`
].join('-')
if (getIsochroneCache[cacheQuery]) {
return getIsochroneCache[cacheQuery]
}
const allPolygons = entityMapToEntityArray(polygonStore)
// single extent isochrone
if (strategy.indexOf('minute') === -1) {
// diff isochrones to get 5 minute isochrones
for (let i = 0; i < allPolygons.length; i++) {
const curPolygon = allPolygons[i]
if (curPolygon.mode === analysisMode &&
curPolygon.siteId === site._id &&
curPolygon.properties.time === isochroneCutoff) {
if (strategy === 'single isochrone') {
getIsochroneCache[cacheQuery] = [curPolygon]
return [curPolygon]
} else if (strategy === 'inverted isochrone') {
// diff against massive polygon
const hugePolygon = {
coordinates: [[
[-179.9999, -89.9999],
[-179.9999, 89.9999],
[179.9999, 89.9999],
[179.9999, -89.9999],
[-179.9999, -89.9999]
]],
type: 'Polygon'
}
const hugePolygonGeometry = geoJsonReader.read(JSON.stringify(hugePolygon))
const invertedGeom = hugePolygonGeometry.difference(reduceAndSimplifyGeometry(curPolygon.geometry))
const invertedIsochrone = {
geometry: geoJsonWriter.write(invertedGeom),
properties: curPolygon.properties,
type: 'Feature'
}
getIsochroneCache[cacheQuery] = [invertedIsochrone]
return [invertedIsochrone]
}
}
}
// no match found
return []
}
const sitePolygons = allPolygons
.filter((polygon) => polygon.mode === analysisMode && polygon.siteId === site._id)
.sort((a, b) => a.properties.time - b.properties.time)
let timeGap = 900
if (strategy === '5-minute isochrones') {
timeGap = 300
}
// diff isochrones to get desired isochrones
const isochrones = []
let traversedIsochrone
sitePolygons
.filter((polygon) => polygon.properties.time % timeGap === 0)
.forEach((polygon) => {
const curFeatureGeometry = reduceAndSimplifyGeometry(polygon.geometry)
let isochroneGeometry
if (!traversedIsochrone) {
isochroneGeometry = curFeatureGeometry
} else {
isochroneGeometry = curFeatureGeometry.difference(traversedIsochrone)
}
isochrones.push({
geometry: geoJsonWriter.write(isochroneGeometry),
properties: polygon.properties,
type: 'Feature'
})
traversedIsochrone = curFeatureGeometry
})
getIsochroneCache[cacheQuery] = isochrones
return isochrones
}
const getIsochroneStrategies = {
'blue-incremental': '5-minute isochrones',
'blue-incremental-15-minute': '15-minute isochrones',
'blue-solid': 'single isochrone',
'green-red-diverging': '5-minute isochrones',
'inverted': 'inverted isochrone'
}
const fillColor = {
'blue-incremental': (time) => hslToHex(240, 100, time * 0.00942 + 27.1739),
'blue-incremental-15-minute': (time) => hslToHex(240, 100, Math.floor(time / 900) * 8.125 + 30),
'blue-solid': '#000099',
'green-red-diverging': (time) => hslToHex(time * -0.017391304347826 + 125.217391304348, 100, 50),
'inverted': '#000099'
}
const isochroneStyleStrategies = {
'blue-incremental': {
fillOpacity: 0.4,
stroke: false,
style: (feature) => {
return {
fillColor: fillColor['blue-incremental'](feature.properties.time)
}
}
},
'blue-incremental-15-minute': {
color: '#000000',
fillOpacity: 0.4,
stroke: true,
style: (feature) => {
return {
fillColor: fillColor['blue-incremental'](feature.properties.time)
}
},
weight: 1
},
'blue-solid': {
color: fillColor['blue-solid'],
fillColor: fillColor['blue-solid'],
fillOpacity: 0.2,
opacity: 0.5,
stroke: true,
weight: 1
},
'green-red-diverging': {
fillOpacity: 0.4,
stroke: false,
style: (feature) => {
return {
fillColor: fillColor['green-red-diverging'](feature.properties.time)
}
}
}
}
function onEachFeature (feature, layer) {
if (feature.properties) {
let pop = '<p>'
Object.keys(feature.properties).forEach((name) => {
const val = feature.properties[name]
pop += name.toUpperCase()
pop += ': '
pop += name.toUpperCase() === 'TIME' ? humanizeDuration(val * 1000) : val
pop += '<br />'
})
pop += '</p>'
layer.bindPopup(pop)
}
}
function reduceAndSimplifyGeometry (inputGeomerty) {
const geometry = geoJsonReader.read(JSON.stringify(inputGeomerty))
const precisionModel = new geom.PrecisionModel(10000)
const precisionReducer = new precision.GeometryPrecisionReducer(precisionModel)
return precisionReducer.reduce(simplify.DouglasPeuckerSimplifier.simplify(geometry, 0.001))
}
const shortEnglishHumanizer = humanizeDuration.humanizer({
language: 'shortEn',
languages: {
shortEn: {
y: () => 'y',
mo: () => 'mo',
w: () => 'w',
d: () => 'd',
h: () => 'h',
m: () => 'm',
s: () => 's',
ms: () => 'ms'
}
},
spacer: ''
})