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 classnames from 'classnames'
import {connect} from 'react-redux'
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 {useHistory, useLocation} from 'react-router-dom'
@ -9,12 +10,14 @@ import {useConfig} from 'config'
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'}))
function parseHash(v) {
function parseHash(v: string): Viewport | null {
if (!v) return null
const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/)
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}`
}
function useViewportFromUrl() {
function useViewportFromUrl(): [Viewport|null, (v: Viewport) => void] {
const history = useHistory()
const location = useLocation()
const value = useMemo(() => parseHash(location.hash), [location.hash])
@ -44,7 +47,6 @@ function useViewportFromUrl() {
return [value || EMPTY_VIEWPORT, setter]
}
function Map({
viewportFromUrl,
children,
@ -64,7 +66,7 @@ function Map({
const config = useConfig()
useEffect(() => {
if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) {
if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) {
setViewport(config.mapHome)
}
}, [config, boundsFromJson])
@ -87,16 +89,17 @@ function Map({
}, [boundsFromJson])
return (
<ReactMapGl mapStyle={baseMapStyles[baseMapStyle]} 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>',
]}
/>
<ReactMapGl
mapStyle={baseMapStyles[baseMapStyle]}
width="100%"
height="100%"
onViewportChange={setViewport}
{...viewport}
{...props}
className={classnames(styles.map, props.className)}
>
<NavigationControl style={{left: 10, top: 10}} />
<ScaleControl maxWidth={200} unit="metric" style={{left: 10, bottom: 10}} />
{children}
</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 ColorMapLegend} from './ColorMapLegend'
export {default as FileDrop} from './FileDrop'
export {default as FileUploadField} from './FileUploadField'
export {default as FormattedDate} from './FormattedDate'

View file

@ -1,13 +1,15 @@
import React from 'react'
import _ from 'lodash'
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 {
MapConfig,
setMapConfigFlag as setMapConfigFlagAction,
initialState as defaultMapConfig,
} from 'reducers/mapConfig'
import {colorByDistance} from 'mapstyles'
import {ColorMapLegend} from 'components'
const BASEMAP_STYLE_OPTIONS = [
{value: 'positron', key: 'positron', text: 'Positron'},
@ -29,11 +31,15 @@ function LayerSidebar({
mapConfig: MapConfig
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 (
<div>
<List>
<List relaxed>
<List.Item>
<List.Header>Basemap Style</List.Header>
<Select
@ -45,17 +51,24 @@ function LayerSidebar({
<Divider />
<List.Item>
<Checkbox
checked={showUntagged}
onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
label="Untagged roads"
toggle
size="small"
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>
<Divider />
{showRoads && (
<>
<List.Item>
<Checkbox
checked={show}
onChange={() => setMapConfigFlag('obsRoads.show', !show)}
label="OBS Roads"
checked={showUntagged}
onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
label="Include roads without data"
/>
</List.Item>
<List.Item>
@ -78,17 +91,38 @@ function LayerSidebar({
/>
</List.Item>
) : null}
</>
)}
<Divider />
<List.Item>
<Checkbox
toggle
size="small"
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>
{showEvents && <><List.Item>
<ColorMapLegend map={_.chunk(colorByDistance('distance_overtaker')[3].slice(3), 2)} />
</List.Item>
</>}
</List>
</div>
)
}
export default connect(
(state) => ({
mapConfig: _.merge(
{},
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 {Layer, Source} from 'react-map-gl'
import produce from 'immer'
import {connect} from 'react-redux'
import {Page, Map} from 'components'
import {useConfig} from 'config'
import {colorByDistance, colorByCount, reds} from 'mapstyles'
import {useMapConfig} from 'reducers/mapConfig'
import RoadInfo from './RoadInfo'
import LayerSidebar from './LayerSidebar'
@ -58,10 +58,52 @@ const getRoadsLayer = (colorAttribute, maxCount) =>
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 [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
const mapConfig = useMapConfig()
const onClick = useCallback(
(e) => {
let node = e.target
@ -79,13 +121,27 @@ function MapPage({mapConfig}) {
const [layerSidebar, setLayerSidebar] = useState(true)
const showUntagged = mapConfig?.obsRoads?.showUntagged ?? true
const roadsLayerColorAttribute = mapConfig?.obsRoads?.attribute ?? 'distance_overtaker_mean'
const roadsLayerMaxCount = mapConfig?.obsRoads?.maxCount ?? 20
const roadsLayer = useMemo(() => getRoadsLayer(roadsLayerColorAttribute, roadsLayerMaxCount), [
roadsLayerColorAttribute,
roadsLayerMaxCount,
])
const {
obsRoads: {attribute, maxCount},
} = mapConfig
const layers = []
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) {
return null
@ -113,8 +169,9 @@ function MapPage({mapConfig}) {
onClick={() => setLayerSidebar(layerSidebar ? false : true)}
/>
<Source id="obs" {...obsMapSource}>
{showUntagged && <Layer key={untaggedRoadsLayer.id} {...untaggedRoadsLayer} />}
<Layer key={roadsLayer.id} {...roadsLayer} />
{layers.map((layer) => (
<Layer key={layer.id} {...layer} />
))}
</Source>
<RoadInfo {...{clickLocation}} />
@ -124,5 +181,3 @@ function MapPage({mapConfig}) {
</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 _ from 'lodash'
@ -14,12 +16,15 @@ export type MapConfig = {
baseMap: {
style: BaseMapStyle
}
obsRoads:{
obsRoads: {
show: boolean
showUntagged: boolean
attribute: RoadAttribute
maxCount: number
}
obsEvents: {
show: boolean
}
}
export const initialState: MapConfig = {
@ -32,19 +37,27 @@ export const initialState: MapConfig = {
attribute: 'distance_overtaker_median',
maxCount: 20,
},
obsEvents: {
show: false,
},
}
type MapConfigAction =
{type: 'MAP_CONFIG.SET_FLAG', payload: {flag: string, value: any}}
type MapConfigAction = {type: 'MAP_CONFIG.SET_FLAG'; payload: {flag: string; value: any}}
export function setMapConfigFlag(flag: string, value: unknown): MapConfigAction {
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) {
switch (action.type) {
case 'MAP_CONFIG.SET_FLAG':
return produce(state, draft => {
return produce(state, (draft) => {
_.set(draft, action.payload.flag, action.payload.value)
})