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, color]) => ( + + ))} + + + + + + {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 (
- + Basemap Style setMapConfigFlag('obsRoads.attribute', value)} + /> + + {attribute.endsWith('_count') ? ( + + Maximum value + setMapConfigFlag('obsRoads.maxCount', value)} + /> + + ) : null} + + )} setMapConfigFlag('obsRoads.show', !show)} - label="OBS Roads" + toggle + size="small" + id="obsEvents.show" + style={{float: 'right'}} + checked={showEvents} + onChange={() => setMapConfigFlag('obsEvents.show', !showEvents)} /> + - - Color based on - setMapConfigFlag('obsRoads.maxCount', value)} - /> - - ) : null} + }
) } + 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)} /> - {showUntagged && } - + {layers.map((layer) => ( + + ))} @@ -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) })