diff --git a/frontend/src/components/ColorMapLegend.module.less b/frontend/src/components/ColorMapLegend.module.less
new file mode 100644
index 0000000..f6d22a7
--- /dev/null
+++ b/frontend/src/components/ColorMapLegend.module.less
@@ -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%;
+ }
+}
diff --git a/frontend/src/components/ColorMapLegend.tsx b/frontend/src/components/ColorMapLegend.tsx
new file mode 100644
index 0000000..b6ddd50
--- /dev/null
+++ b/frontend/src/components/ColorMapLegend.tsx
@@ -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 (
+
+
+ {map.map(([value]) =>
+ {value.toFixed(2)}
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Map/index.tsx b/frontend/src/components/Map/index.tsx
index 120325e..598cdcd 100644
--- a/frontend/src/components/Map/index.tsx
+++ b/frontend/src/components/Map/index.tsx
@@ -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,
@@ -53,8 +55,8 @@ function Map({
...props
}: {
viewportFromUrl?: boolean
- children: React.ReactNode
- boundsFromJson: GeoJSON.Geometry
+ children: React.ReactNode
+ boundsFromJson: GeoJSON.Geometry
baseMapStyle: string
}) {
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
@@ -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 (
-
- © OpenStreetMap contributors',
- '© OpenMapTiles',
- '© OpenBikeSensor',
- ]}
- />
+
+
{children}
diff --git a/frontend/src/components/Map/styles.module.less b/frontend/src/components/Map/styles.module.less
new file mode 100644
index 0000000..3680a9e
--- /dev/null
+++ b/frontend/src/components/Map/styles.module.less
@@ -0,0 +1,5 @@
+:global(.mapboxgl-ctrl-scale) {
+ height: 16px;
+ line-height: 14px;
+ background: none;
+}
diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js
index 15443b9..ae1cc8a 100644
--- a/frontend/src/components/index.js
+++ b/frontend/src/components/index.js
@@ -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'
diff --git a/frontend/src/pages/MapPage/LayerSidebar.tsx b/frontend/src/pages/MapPage/LayerSidebar.tsx
index 25d8a72..079876f 100644
--- a/frontend/src/pages/MapPage/LayerSidebar.tsx
+++ b/frontend/src/pages/MapPage/LayerSidebar.tsx
@@ -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 (
)
}
+
export default connect(
(state) => ({
mapConfig: _.merge(
{},
defaultMapConfig,
- (state as any).mapConfig as MapConfig,
+ (state as any).mapConfig as MapConfig
//
),
}),
diff --git a/frontend/src/pages/MapPage/index.tsx b/frontend/src/pages/MapPage/index.tsx
index 8d6be94..5dfb5b3 100644
--- a/frontend/src/pages/MapPage/index.tsx
+++ b/frontend/src/pages/MapPage/index.tsx
@@ -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'
@@ -48,7 +48,7 @@ const getRoadsLayer = (colorAttribute, maxCount) =>
} else {
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_')
? colorByDistance(colorAttribute)
: colorAttribute.endsWith('_count')
@@ -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)}
/>
@@ -124,5 +181,3 @@ function MapPage({mapConfig}) {
)
}
-
-export default connect((state) => ({mapConfig: state.mapConfig}))(MapPage)
diff --git a/frontend/src/reducers/mapConfig.ts b/frontend/src/reducers/mapConfig.ts
index b20de37..f779238 100644
--- a/frontend/src/reducers/mapConfig.ts
+++ b/frontend/src/reducers/mapConfig.ts
@@ -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)
})