Reintroduce event view (fixes #111)

This commit is contained in:
Paul Bienkowski 2021-12-06 08:45:54 +01:00
parent 1669713fc5
commit 3db5132199
8 changed files with 230 additions and 65 deletions

View file

@ -0,0 +1,24 @@
.colorMapLegend {
position: relative;
margin: 1rem;
font-size: 10pt;
letter-spacing: -0.2pt;
}
.tick {
position: absolute;
top: calc(100% + 2px);
left: 0;
transform: translateX(-50%);
text-align: center;
&::before {
content: '';
position: absolute;
left: 0;
top: -4px;
height: 6px;
border-left: 1px solid currentcolor;
left: 50%;
}
}

View file

@ -0,0 +1,30 @@
type ColorMap = [number, string][]
import styles from './ColorMapLegend.module.less'
export default function ColorMapLegend({map}: {map: ColorMap}) {
const min = map[0][0]
const max = map[map.length - 1][0]
const normalizeValue = (v) => (v - min) / (max - min)
return (
<div className={styles.colorMapLegend}>
<svg width="100%" height="20" version="1.1" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient" x1="0" x2="1" y1="0" y2="0">
{map.map(([value, color]) => (
<stop key={value} offset={normalizeValue(value) * 100 + '%'} stop-color={color} />
))}
</linearGradient>
</defs>
<rect id="rect1" x="0" y="0" width="100%" height="100%" fill="url(#gradient)" />
</svg>
{map.map(([value]) => <span className={styles.tick} key={value}
style={{left: normalizeValue(value)*100 + '%'}}
>
{value.toFixed(2)}
</span>)}
</div>
)
}

View file

@ -1,7 +1,8 @@
import React, {useState, useCallback, useMemo, useEffect} from 'react' import React, {useState, useCallback, useMemo, useEffect} from 'react'
import classnames from 'classnames'
import {connect} from 'react-redux' import {connect} from 'react-redux'
import _ from 'lodash' import _ from 'lodash'
import ReactMapGl, {WebMercatorViewport, AttributionControl, NavigationControl} from 'react-map-gl' import ReactMapGl, {WebMercatorViewport, ScaleControl, NavigationControl} from 'react-map-gl'
import turfBbox from '@turf/bbox' import turfBbox from '@turf/bbox'
import {useHistory, useLocation} from 'react-router-dom' import {useHistory, useLocation} from 'react-router-dom'
@ -9,12 +10,14 @@ import {useConfig} from 'config'
import {baseMapStyles} from '../../mapstyles' import {baseMapStyles} from '../../mapstyles'
import styles from './styles.module.less'
const EMPTY_VIEWPORT = {longitude: 0, latitude: 0, zoom: 0} interface Viewport {longitude: number; latitude: number; zoom: number}
const EMPTY_VIEWPORT: Viewport = {longitude: 0, latitude: 0, zoom: 0}
export const withBaseMapStyle = connect((state) => ({baseMapStyle: state.mapConfig?.baseMap?.style ?? 'positron'})) export const withBaseMapStyle = connect((state) => ({baseMapStyle: state.mapConfig?.baseMap?.style ?? 'positron'}))
function parseHash(v) { function parseHash(v: string): Viewport | null {
if (!v) return null if (!v) return null
const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/) const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/)
if (!m) return null if (!m) return null
@ -25,11 +28,11 @@ function parseHash(v) {
} }
} }
function buildHash(v) { function buildHash(v: Viewport): string {
return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}` return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`
} }
function useViewportFromUrl() { function useViewportFromUrl(): [Viewport|null, (v: Viewport) => void] {
const history = useHistory() const history = useHistory()
const location = useLocation() const location = useLocation()
const value = useMemo(() => parseHash(location.hash), [location.hash]) const value = useMemo(() => parseHash(location.hash), [location.hash])
@ -44,7 +47,6 @@ function useViewportFromUrl() {
return [value || EMPTY_VIEWPORT, setter] return [value || EMPTY_VIEWPORT, setter]
} }
function Map({ function Map({
viewportFromUrl, viewportFromUrl,
children, children,
@ -53,8 +55,8 @@ function Map({
...props ...props
}: { }: {
viewportFromUrl?: boolean viewportFromUrl?: boolean
children: React.ReactNode children: React.ReactNode
boundsFromJson: GeoJSON.Geometry boundsFromJson: GeoJSON.Geometry
baseMapStyle: string baseMapStyle: string
}) { }) {
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT) const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
@ -64,7 +66,7 @@ function Map({
const config = useConfig() const config = useConfig()
useEffect(() => { useEffect(() => {
if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) { if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) {
setViewport(config.mapHome) setViewport(config.mapHome)
} }
}, [config, boundsFromJson]) }, [config, boundsFromJson])
@ -87,16 +89,17 @@ function Map({
}, [boundsFromJson]) }, [boundsFromJson])
return ( return (
<ReactMapGl mapStyle={baseMapStyles[baseMapStyle]} width="100%" height="100%" onViewportChange={setViewport} {...viewport} {...props}> <ReactMapGl
<AttributionControl mapStyle={baseMapStyles[baseMapStyle]}
style={{right: 0, bottom: 0}} width="100%"
customAttribution={[ height="100%"
'<a href="https://openstreetmap.org/copyright" target="_blank" rel="nofollow noopener noreferrer">© OpenStreetMap contributors</a>', onViewportChange={setViewport}
'<a href="https://openmaptiles.org/" target="_blank" rel="nofollow noopener noreferrer">© OpenMapTiles</a>', {...viewport}
'<a href="https://openbikesensor.org/" target="_blank" rel="nofollow noopener noreferrer">© OpenBikeSensor</a>', {...props}
]} className={classnames(styles.map, props.className)}
/> >
<NavigationControl style={{left: 10, top: 10}} /> <NavigationControl style={{left: 10, top: 10}} />
<ScaleControl maxWidth={200} unit="metric" style={{left: 10, bottom: 10}} />
{children} {children}
</ReactMapGl> </ReactMapGl>

View file

@ -0,0 +1,5 @@
:global(.mapboxgl-ctrl-scale) {
height: 16px;
line-height: 14px;
background: none;
}

View file

@ -1,4 +1,5 @@
export {default as Avatar} from './Avatar' export {default as Avatar} from './Avatar'
export {default as ColorMapLegend} from './ColorMapLegend'
export {default as FileDrop} from './FileDrop' 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'

View file

@ -1,13 +1,15 @@
import React from 'react' import React from 'react'
import _ from 'lodash' import _ from 'lodash'
import {connect} from 'react-redux' import {connect} from 'react-redux'
import {List, Select, Input, Divider, Checkbox} from 'semantic-ui-react' import {List, Select, Input, Divider, Checkbox, Header} from 'semantic-ui-react'
import { import {
MapConfig, MapConfig,
setMapConfigFlag as setMapConfigFlagAction, setMapConfigFlag as setMapConfigFlagAction,
initialState as defaultMapConfig, initialState as defaultMapConfig,
} from 'reducers/mapConfig' } from 'reducers/mapConfig'
import {colorByDistance} from 'mapstyles'
import {ColorMapLegend} from 'components'
const BASEMAP_STYLE_OPTIONS = [ const BASEMAP_STYLE_OPTIONS = [
{value: 'positron', key: 'positron', text: 'Positron'}, {value: 'positron', key: 'positron', text: 'Positron'},
@ -29,11 +31,15 @@ function LayerSidebar({
mapConfig: MapConfig mapConfig: MapConfig
setMapConfigFlag: (flag: string, value: unknown) => void setMapConfigFlag: (flag: string, value: unknown) => void
}) { }) {
const {baseMap: {style}, obsRoads: {show, showUntagged, attribute, maxCount}} = mapConfig const {
baseMap: {style},
obsRoads: {show: showRoads, showUntagged, attribute, maxCount},
obsEvents: {show: showEvents},
} = mapConfig
return ( return (
<div> <div>
<List> <List relaxed>
<List.Item> <List.Item>
<List.Header>Basemap Style</List.Header> <List.Header>Basemap Style</List.Header>
<Select <Select
@ -45,50 +51,78 @@ function LayerSidebar({
<Divider /> <Divider />
<List.Item> <List.Item>
<Checkbox <Checkbox
checked={showUntagged} toggle
onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)} size="small"
label="Untagged roads" id="obsRoads.show"
style={{float: 'right'}}
checked={showRoads}
onChange={() => setMapConfigFlag('obsRoads.show', !showRoads)}
/> />
<label htmlFor="obsRoads.show">
<Header as="h4">Road segments</Header>
</label>
</List.Item> </List.Item>
{showRoads && (
<>
<List.Item>
<Checkbox
checked={showUntagged}
onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
label="Include roads without data"
/>
</List.Item>
<List.Item>
<List.Header>Color based on</List.Header>
<Select
fluid
options={ROAD_ATTRIBUTE_OPTIONS}
value={attribute}
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)}
/>
</List.Item>
{attribute.endsWith('_count') ? (
<List.Item>
<List.Header>Maximum value</List.Header>
<Input
fluid
type="number"
value={maxCount}
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)}
/>
</List.Item>
) : null}
</>
)}
<Divider /> <Divider />
<List.Item> <List.Item>
<Checkbox <Checkbox
checked={show} toggle
onChange={() => setMapConfigFlag('obsRoads.show', !show)} size="small"
label="OBS Roads" id="obsEvents.show"
style={{float: 'right'}}
checked={showEvents}
onChange={() => setMapConfigFlag('obsEvents.show', !showEvents)}
/> />
<label htmlFor="obsEvents.show">
<Header as="h4">Event points</Header>
</label>
</List.Item> </List.Item>
<List.Item> {showEvents && <><List.Item>
<List.Header>Color based on</List.Header> <ColorMapLegend map={_.chunk(colorByDistance('distance_overtaker')[3].slice(3), 2)} />
<Select
fluid
options={ROAD_ATTRIBUTE_OPTIONS}
value={attribute}
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)}
/>
</List.Item> </List.Item>
{attribute.endsWith('_count') ? ( </>}
<List.Item>
<List.Header>Maximum value</List.Header>
<Input
fluid
type="number"
value={maxCount}
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)}
/>
</List.Item>
) : null}
</List> </List>
</div> </div>
) )
} }
export default connect( export default connect(
(state) => ({ (state) => ({
mapConfig: _.merge( mapConfig: _.merge(
{}, {},
defaultMapConfig, defaultMapConfig,
(state as any).mapConfig as MapConfig, (state as any).mapConfig as MapConfig
// //
), ),
}), }),

View file

@ -3,11 +3,11 @@ import _ from 'lodash'
import {Button} from 'semantic-ui-react' import {Button} from 'semantic-ui-react'
import {Layer, Source} from 'react-map-gl' import {Layer, Source} from 'react-map-gl'
import produce from 'immer' import produce from 'immer'
import {connect} from 'react-redux'
import {Page, Map} from 'components' import {Page, Map} from 'components'
import {useConfig} from 'config' import {useConfig} from 'config'
import {colorByDistance, colorByCount, reds} from 'mapstyles' import {colorByDistance, colorByCount, reds} from 'mapstyles'
import {useMapConfig} from 'reducers/mapConfig'
import RoadInfo from './RoadInfo' import RoadInfo from './RoadInfo'
import LayerSidebar from './LayerSidebar' import LayerSidebar from './LayerSidebar'
@ -48,7 +48,7 @@ const getRoadsLayer = (colorAttribute, maxCount) =>
} else { } else {
draft.filter = draft.filter[1] // remove '!' draft.filter = draft.filter[1] // remove '!'
} }
draft.paint['line-width'][6] = 6 // scale bigger on zoom draft.paint['line-width'][6] = 6 // scale bigger on zoom
draft.paint['line-color'] = colorAttribute.startsWith('distance_') draft.paint['line-color'] = colorAttribute.startsWith('distance_')
? colorByDistance(colorAttribute) ? colorByDistance(colorAttribute)
: colorAttribute.endsWith('_count') : colorAttribute.endsWith('_count')
@ -58,10 +58,52 @@ const getRoadsLayer = (colorAttribute, maxCount) =>
draft.paint['line-opacity'][5] = 13 draft.paint['line-opacity'][5] = 13
}) })
function MapPage({mapConfig}) { const getEventsLayer = () => ({
id: 'obs_events',
type: 'circle',
source: 'obs',
'source-layer': 'obs_events',
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
'circle-color': colorByDistance('distance_overtaker'),
},
minzoom: 11,
})
const getEventsTextLayer = () => ({
id: 'obs_events_text',
type: 'symbol',
minzoom: 18,
source: 'obs',
'source-layer': 'obs_events',
layout: {
'text-field': [
'number-format',
['get', 'distance_overtaker'],
{'min-fraction-digits': 2, 'max-fraction-digits': 2},
],
'text-allow-overlap': true,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
'text-size': 14,
'text-keep-upright': false,
'text-anchor': 'left',
'text-radial-offset': 1,
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180/Math.PI]],
'text-rotation-alignment': 'map',
},
paint: {
'text-halo-color': 'rgba(255, 255, 255, 1)',
'text-halo-width': 1,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
},
})
export default function MapPage() {
const {obsMapSource} = useConfig() || {} const {obsMapSource} = useConfig() || {}
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null) const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
const mapConfig = useMapConfig()
const onClick = useCallback( const onClick = useCallback(
(e) => { (e) => {
let node = e.target let node = e.target
@ -79,13 +121,27 @@ function MapPage({mapConfig}) {
const [layerSidebar, setLayerSidebar] = useState(true) const [layerSidebar, setLayerSidebar] = useState(true)
const showUntagged = mapConfig?.obsRoads?.showUntagged ?? true const {
const roadsLayerColorAttribute = mapConfig?.obsRoads?.attribute ?? 'distance_overtaker_mean' obsRoads: {attribute, maxCount},
const roadsLayerMaxCount = mapConfig?.obsRoads?.maxCount ?? 20 } = mapConfig
const roadsLayer = useMemo(() => getRoadsLayer(roadsLayerColorAttribute, roadsLayerMaxCount), [
roadsLayerColorAttribute, const layers = []
roadsLayerMaxCount,
]) if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayer)
}
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
if (mapConfig.obsRoads.show) {
layers.push(roadsLayer)
}
const eventsLayer = useMemo(() => getEventsLayer(), [])
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
if (mapConfig.obsEvents.show) {
layers.push(eventsLayer)
layers.push(eventsTextLayer)
}
if (!obsMapSource) { if (!obsMapSource) {
return null return null
@ -113,8 +169,9 @@ function MapPage({mapConfig}) {
onClick={() => setLayerSidebar(layerSidebar ? false : true)} onClick={() => setLayerSidebar(layerSidebar ? false : true)}
/> />
<Source id="obs" {...obsMapSource}> <Source id="obs" {...obsMapSource}>
{showUntagged && <Layer key={untaggedRoadsLayer.id} {...untaggedRoadsLayer} />} {layers.map((layer) => (
<Layer key={roadsLayer.id} {...roadsLayer} /> <Layer key={layer.id} {...layer} />
))}
</Source> </Source>
<RoadInfo {...{clickLocation}} /> <RoadInfo {...{clickLocation}} />
@ -124,5 +181,3 @@ function MapPage({mapConfig}) {
</Page> </Page>
) )
} }
export default connect((state) => ({mapConfig: state.mapConfig}))(MapPage)

View file

@ -1,3 +1,5 @@
import {useMemo} from 'react'
import {useSelector} from 'react-redux'
import produce from 'immer' import produce from 'immer'
import _ from 'lodash' import _ from 'lodash'
@ -14,12 +16,15 @@ export type MapConfig = {
baseMap: { baseMap: {
style: BaseMapStyle style: BaseMapStyle
} }
obsRoads:{ obsRoads: {
show: boolean show: boolean
showUntagged: boolean showUntagged: boolean
attribute: RoadAttribute attribute: RoadAttribute
maxCount: number maxCount: number
} }
obsEvents: {
show: boolean
}
} }
export const initialState: MapConfig = { export const initialState: MapConfig = {
@ -32,19 +37,27 @@ export const initialState: MapConfig = {
attribute: 'distance_overtaker_median', attribute: 'distance_overtaker_median',
maxCount: 20, maxCount: 20,
}, },
obsEvents: {
show: false,
},
} }
type MapConfigAction = type MapConfigAction = {type: 'MAP_CONFIG.SET_FLAG'; payload: {flag: string; value: any}}
{type: 'MAP_CONFIG.SET_FLAG', payload: {flag: string, value: any}}
export function setMapConfigFlag(flag: string, value: unknown): MapConfigAction { export function setMapConfigFlag(flag: string, value: unknown): MapConfigAction {
return {type: 'MAP_CONFIG.SET_FLAG', payload: {flag, value}} return {type: 'MAP_CONFIG.SET_FLAG', payload: {flag, value}}
} }
export function useMapConfig() {
const mapConfig = useSelector((state) => state.mapConfig)
const result = useMemo(() => _.merge({}, initialState, mapConfig), [mapConfig])
return result
}
export default function mapConfigReducer(state: MapConfig = initialState, action: MapConfigAction) { export default function mapConfigReducer(state: MapConfig = initialState, action: MapConfigAction) {
switch (action.type) { switch (action.type) {
case 'MAP_CONFIG.SET_FLAG': case 'MAP_CONFIG.SET_FLAG':
return produce(state, draft => { return produce(state, (draft) => {
_.set(draft, action.payload.flag, action.payload.value) _.set(draft, action.payload.flag, action.payload.value)
}) })