refactor: split MapPage into components
This commit is contained in:
parent
4bf23143e0
commit
83e945c7ff
98
frontend/src/components/Map/index.tsx
Normal file
98
frontend/src/components/Map/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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'
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
50
frontend/src/pages/MapPage/index.tsx
Normal file
50
frontend/src/pages/MapPage/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue