refactor: split MapPage into components

This commit is contained in:
Paul Bienkowski 2021-12-03 19:44:12 +01:00
parent 4bf23143e0
commit 83e945c7ff
7 changed files with 160 additions and 147 deletions

View file

@ -0,0 +1,98 @@
import React, {useState, useCallback, useMemo, useEffect} from 'react'
import _ from 'lodash'
import ReactMapGl, {WebMercatorViewport, AttributionControl, NavigationControl} from 'react-map-gl'
import turfBbox from '@turf/bbox'
import {useHistory, useLocation} from 'react-router-dom'
import {useConfig} from 'config'
import {basemap} from '../../mapstyles'
const EMPTY_VIEWPORT = {longitude: 0, latitude: 0, zoom: 0}
function parseHash(v) {
if (!v) return null
const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/)
if (!m) return null
return {
zoom: Number.parseFloat(m[1]),
latitude: Number.parseFloat(m[2]),
longitude: Number.parseFloat(m[3]),
}
}
function buildHash(v) {
return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`
}
function useViewportFromUrl() {
const history = useHistory()
const location = useLocation()
const value = useMemo(() => parseHash(location.hash), [location.hash])
const setter = useCallback(
(v) => {
history.replace({
hash: buildHash(v),
})
},
[history]
)
return [value || EMPTY_VIEWPORT, setter]
}
export default function Map({
viewportFromUrl,
children,
boundsFromJson,
...props
}: {
viewportFromUrl?: boolean
children: React.ReactNode
boundsFromJson: GeoJSON.Geometry
}) {
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
const [viewportUrl, setViewportUrl] = useViewportFromUrl()
const [viewport, setViewport] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState]
const config = useConfig()
useEffect(() => {
if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) {
setViewport(config.mapHome)
}
}, [config, boundsFromJson])
useEffect(() => {
if (boundsFromJson) {
const [minX, minY, maxX, maxY] = turfBbox(boundsFromJson)
const vp = new WebMercatorViewport({width: 1000, height: 800}).fitBounds(
[
[minX, minY],
[maxX, maxY],
],
{
padding: 20,
offset: [0, -100],
}
)
setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude']))
}
}, [boundsFromJson])
return (
<ReactMapGl mapStyle={basemap} width="100%" height="100%" onViewportChange={setViewport} {...viewport} {...props}>
<AttributionControl
style={{right: 0, bottom: 0}}
customAttribution={[
'<a href="https://openstreetmap.org/copyright" target="_blank" rel="nofollow noopener noreferrer">© OpenStreetMap contributors</a>',
'<a href="https://openmaptiles.org/" target="_blank" rel="nofollow noopener noreferrer">© OpenMapTiles</a>',
'<a href="https://openbikesensor.org/" target="_blank" rel="nofollow noopener noreferrer">© OpenBikeSensor</a>',
]}
/>
<NavigationControl style={{left: 10, top: 10}} />
{children}
</ReactMapGl>
)
}

View file

@ -3,6 +3,7 @@ export {default as FileDrop} from './FileDrop'
export {default as FileUploadField} from './FileUploadField' export {default as FileUploadField} from './FileUploadField'
export {default as FormattedDate} from './FormattedDate' export {default as FormattedDate} from './FormattedDate'
export {default as LoginButton} from './LoginButton' export {default as LoginButton} from './LoginButton'
export {default as Map} from './Map'
export {default as Page} from './Page' export {default as Page} from './Page'
export {default as Stats} from './Stats' export {default as Stats} from './Stats'
export {default as StripMarkdown} from './StripMarkdown' export {default as StripMarkdown} from './StripMarkdown'

View file

@ -6,10 +6,9 @@ import {of, from} from 'rxjs'
import {map, switchMap} from 'rxjs/operators' import {map, switchMap} from 'rxjs/operators'
import api from 'api' import api from 'api'
import {Stats, Page} from 'components' import {Stats, Page, Map} from 'components'
import {TrackListItem} from './TracksPage' import {TrackListItem} from './TracksPage'
import {CustomMap} from './MapPage'
import styles from './HomePage.module.less' import styles from './HomePage.module.less'
function MostRecentTrack() { function MostRecentTrack() {
@ -47,7 +46,7 @@ export default function HomePage() {
<Grid.Row> <Grid.Row>
<Grid.Column width={10}> <Grid.Column width={10}>
<div className={styles.welcomeMap}> <div className={styles.welcomeMap}>
<CustomMap mode='roads' /> <Map />
</div> </div>
</Grid.Column> </Grid.Column>
<Grid.Column width={6}> <Grid.Column width={6}>

View file

@ -1,109 +1,13 @@
import React, {useState, useCallback, useMemo, useEffect} from 'react' import React, {useState, useCallback} from 'react'
import _ from 'lodash' import _ from 'lodash'
import {Segment, Menu, Header, Label, Icon, Table} from 'semantic-ui-react' import {Segment, Menu, Header, Label, Icon, Table} from 'semantic-ui-react'
import ReactMapGl, {WebMercatorViewport, AttributionControl, NavigationControl, Layer, Source} from 'react-map-gl' import {Layer, Source} from 'react-map-gl'
import turfBbox from '@turf/bbox'
import {useHistory, useLocation} from 'react-router-dom'
import {of, from, concat} from 'rxjs' import {of, from, concat} from 'rxjs'
import {useObservable} from 'rxjs-hooks' import {useObservable} from 'rxjs-hooks'
import {switchMap, distinctUntilChanged} from 'rxjs/operators' import {switchMap, distinctUntilChanged} from 'rxjs/operators'
import {Page} from 'components'
import {useConfig} from 'config'
import api from 'api' import api from 'api'
import {roadsLayer, basemap} from '../mapstyles'
import styles from './MapPage.module.less'
const EMPTY_VIEWPORT = {longitude: 0, latitude: 0, zoom: 0}
function parseHash(v) {
if (!v) return null
const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/)
if (!m) return null
return {
zoom: Number.parseFloat(m[1]),
latitude: Number.parseFloat(m[2]),
longitude: Number.parseFloat(m[3]),
}
}
function buildHash(v) {
return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`
}
function useViewportFromUrl() {
const history = useHistory()
const location = useLocation()
const value = useMemo(() => parseHash(location.hash), [location.hash])
const setter = useCallback(
(v) => {
history.replace({
hash: buildHash(v),
})
},
[history]
)
return [value || EMPTY_VIEWPORT, setter]
}
export function CustomMap({
viewportFromUrl,
children,
boundsFromJson,
...props
}: {
viewportFromUrl?: boolean
children: React.ReactNode
boundsFromJson: GeoJSON.Geometry
}) {
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
const [viewportUrl, setViewportUrl] = useViewportFromUrl()
const [viewport, setViewport] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState]
const config = useConfig()
useEffect(() => {
if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) {
setViewport(config.mapHome)
}
}, [config, boundsFromJson])
useEffect(() => {
if (boundsFromJson) {
const [minX, minY, maxX, maxY] = turfBbox(boundsFromJson)
const vp = new WebMercatorViewport({width: 1000, height: 800}).fitBounds(
[
[minX, minY],
[maxX, maxY],
],
{
padding: 20,
offset: [0, -100],
}
)
setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude']))
}
}, [boundsFromJson])
return (
<ReactMapGl mapStyle={basemap} width="100%" height="100%" onViewportChange={setViewport} {...viewport} {...props}>
<AttributionControl
style={{right: 0, bottom: 0}}
customAttribution={[
'<a href="https://openstreetmap.org/copyright" target="_blank" rel="nofollow noopener noreferrer">© OpenStreetMap contributors</a>',
'<a href="https://openmaptiles.org/" target="_blank" rel="nofollow noopener noreferrer">© OpenMapTiles</a>',
'<a href="https://openbikesensor.org/" target="_blank" rel="nofollow noopener noreferrer">© OpenBikeSensor</a>',
]}
/>
<NavigationControl style={{left: 10, top: 10}} />
{children}
</ReactMapGl>
)
}
const UNITS = {distanceOvertaker: 'm', distanceStationary: 'm', speed: 'm/s'} const UNITS = {distanceOvertaker: 'm', distanceStationary: 'm', speed: 'm/s'}
const LABELS = {distanceOvertaker: 'Overtaker', distanceStationary: 'Stationary', speed: 'Speed'} const LABELS = {distanceOvertaker: 'Overtaker', distanceStationary: 'Stationary', speed: 'Speed'}
const ZONE_COLORS = {urban: 'olive', rural: 'brown', motorway: 'purple'} const ZONE_COLORS = {urban: 'olive', rural: 'brown', motorway: 'purple'}
@ -144,7 +48,7 @@ function RoadStatsTable({data}) {
) )
} }
function CurrentRoadInfo({clickLocation}) { export default function RoadInfo({clickLocation}) {
const [direction, setDirection] = useState('forwards') const [direction, setDirection] = useState('forwards')
const onClickDirection = useCallback( const onClickDirection = useCallback(
@ -186,7 +90,7 @@ function CurrentRoadInfo({clickLocation}) {
const loading = info == null const loading = info == null
const offsetDirection = info?.road.oneway ? 0 : direction === 'forwards' ? 1 : -1; // TODO: change based on left-hand/right-hand traffic const offsetDirection = info?.road.oneway ? 0 : direction === 'forwards' ? 1 : -1 // TODO: change based on left-hand/right-hand traffic
const content = const content =
!loading && !info.road ? ( !loading && !info.road ? (
@ -234,7 +138,7 @@ function CurrentRoadInfo({clickLocation}) {
'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12], 'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12],
'line-color': '#18FFFF', 'line-color': '#18FFFF',
'line-opacity': 0.5, 'line-opacity': 0.5,
...({ ...{
'line-offset': [ 'line-offset': [
'interpolate', 'interpolate',
['exponential', 1.5], ['exponential', 1.5],
@ -244,8 +148,7 @@ function CurrentRoadInfo({clickLocation}) {
19, 19,
offsetDirection * 8, offsetDirection * 8,
], ],
}) },
}} }}
/> />
</Source> </Source>
@ -259,41 +162,3 @@ function CurrentRoadInfo({clickLocation}) {
</> </>
) )
} }
export default function MapPage() {
const {obsMapSource} = useConfig() || {}
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
const onClick = useCallback(
(e) => {
let node = e.target
while (node) {
if (node?.classList?.contains(styles.mapInfoBox)) {
return
}
node = node.parentNode
}
setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]})
},
[setClickLocation]
)
if (!obsMapSource) {
return null
}
return (
<Page fullScreen>
<div className={styles.mapContainer}>
<CustomMap viewportFromUrl onClick={onClick}>
<Source id="obs" {...obsMapSource}>
<Layer {...roadsLayer} />
</Source>
<CurrentRoadInfo {...{clickLocation}} />
</CustomMap>
</div>
</Page>
)
}

View file

@ -0,0 +1,50 @@
import React, {useState, useCallback} from 'react'
import _ from 'lodash'
import {Layer, Source} from 'react-map-gl'
import {Page, Map} from 'components'
import {useConfig} from 'config'
import {roadsLayer} from '../../mapstyles'
import RoadInfo from './RoadInfo'
import styles from './styles.module.less'
export default function MapPage() {
const {obsMapSource} = useConfig() || {}
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
const onClick = useCallback(
(e) => {
let node = e.target
while (node) {
if (node?.classList?.contains(styles.mapInfoBox)) {
return
}
node = node.parentNode
}
setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]})
},
[setClickLocation]
)
if (!obsMapSource) {
return null
}
return (
<Page fullScreen>
<div className={styles.mapContainer}>
<Map viewportFromUrl onClick={onClick}>
<Source id="obs" {...obsMapSource}>
<Layer {...roadsLayer} />
</Source>
<RoadInfo {...{clickLocation}} />
</Map>
</div>
</Page>
)
}

View file

@ -2,7 +2,7 @@ import React from 'react'
import {Source, Layer} from 'react-map-gl' import {Source, Layer} from 'react-map-gl'
import type {TrackData} from 'types' import type {TrackData} from 'types'
import {CustomMap} from '../MapPage' import {Map} from 'components'
import {colorByDistance, trackLayer} from '../../mapstyles' import {colorByDistance, trackLayer} from '../../mapstyles'
@ -24,7 +24,7 @@ export default function TrackMap({
return ( return (
<div style={props.style}> <div style={props.style}>
<CustomMap boundsFromJson={trackData.track}> <Map boundsFromJson={trackData.track}>
{showTrack && ( {showTrack && (
<Source id="route" type="geojson" data={trackData.track}> <Source id="route" type="geojson" data={trackData.track}>
<Layer id="route" {...trackLayer} /> <Layer id="route" {...trackLayer} />
@ -73,7 +73,7 @@ export default function TrackMap({
))} ))}
</Source> </Source>
)} )}
</CustomMap> </Map>
</div> </div>
) )
} }