diff --git a/frontend/src/components/ColorMapLegend.tsx b/frontend/src/components/ColorMapLegend.tsx index f0c4d48..ca09860 100644 --- a/frontend/src/components/ColorMapLegend.tsx +++ b/frontend/src/components/ColorMapLegend.tsx @@ -59,7 +59,7 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) { ) } -export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap, twoTicks?: boolean}) { +export default function ColorMapLegend({map, twoTicks = false, digits=2}: {map: ColorMap, twoTicks?: boolean, digits?: number}) { const min = map[0][0] const max = map[map.length - 1][0] const normalizeValue = (v) => (v - min) / (max - min) @@ -81,7 +81,7 @@ export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap, {tickValues.map(([value]) => ( - {value.toFixed(2)} + {value.toFixed(digits)} ))} diff --git a/frontend/src/components/Map/index.tsx b/frontend/src/components/Map/index.tsx index 149fb92..657b748 100644 --- a/frontend/src/components/Map/index.tsx +++ b/frontend/src/components/Map/index.tsx @@ -1,75 +1,70 @@ -import React, { useState, useCallback, useMemo, useEffect } from "react"; -import classnames from "classnames"; -import { connect } from "react-redux"; -import _ from "lodash"; -import ReactMapGl, { - WebMercatorViewport, - ScaleControl, - NavigationControl, - AttributionControl, -} from "react-map-gl"; -import turfBbox from "@turf/bbox"; -import { useHistory, useLocation } from "react-router-dom"; +import React, {useState, useCallback, useMemo, useEffect} from 'react' +import classnames from 'classnames' +import {connect} from 'react-redux' +import _ from 'lodash' +import ReactMapGl, {WebMercatorViewport, ScaleControl, NavigationControl, AttributionControl} from 'react-map-gl' +import turfBbox from '@turf/bbox' +import {useHistory, useLocation} from 'react-router-dom' -import { useConfig } from "config"; +import {useConfig} from 'config' -import { useCallbackRef } from "../../utils"; -import { baseMapStyles } from "../../mapstyles"; +import {useCallbackRef} from '../../utils' +import {baseMapStyles} from '../../mapstyles' -import styles from "./styles.module.less"; +import styles from './styles.module.less' interface Viewport { - longitude: number; - latitude: number; - zoom: number; + longitude: number + latitude: number + zoom: number } -const EMPTY_VIEWPORT: Viewport = { longitude: 0, latitude: 0, zoom: 0 }; +const EMPTY_VIEWPORT: Viewport = {longitude: 0, latitude: 0, zoom: 0} export const withBaseMapStyle = connect((state) => ({ - baseMapStyle: state.mapConfig?.baseMap?.style ?? "positron", -})); + baseMapStyle: state.mapConfig?.baseMap?.style ?? 'positron', +})) function parseHash(v: string): Viewport | null { - if (!v) return null; - const m = v.match(/^#([0-9\.]+)\/([0-9\.\-]+)\/([0-9\.\-]+)$/); - if (!m) return null; + 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: Viewport): string { - return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`; + return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}` } const setViewportToHash = _.debounce((history, viewport) => { history.replace({ hash: buildHash(viewport), - }); -}, 200); + }) +}, 200) function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] { - const history = useHistory(); - const location = useLocation(); + const history = useHistory() + const location = useLocation() - const [cachedValue, setCachedValue] = useState(parseHash(location.hash)); + const [cachedValue, setCachedValue] = useState(parseHash(location.hash)) // when the location hash changes, set the new value to the cache useEffect(() => { - setCachedValue(parseHash(location.hash)); - }, [location.hash]); + setCachedValue(parseHash(location.hash)) + }, [location.hash]) const setter = useCallback( (v) => { - setCachedValue(v); - setViewportToHash(history, v); + setCachedValue(v) + setViewportToHash(history, v) }, [history] - ); + ) - return [cachedValue || EMPTY_VIEWPORT, setter]; + return [cachedValue || EMPTY_VIEWPORT, setter] } function Map({ @@ -78,57 +73,54 @@ function Map({ boundsFromJson, baseMapStyle, hasToolbar, + onViewportChange, ...props }: { - viewportFromUrl?: boolean; - children: React.ReactNode; - boundsFromJson: GeoJSON.Geometry; - baseMapStyle: string; - hasToolbar?: boolean; + viewportFromUrl?: boolean + children: React.ReactNode + boundsFromJson: GeoJSON.Geometry + baseMapStyle: string + hasToolbar?: boolean + onViewportChange: (viewport: Viewport) => void }) { - const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT); - const [viewportUrl, setViewportUrl] = useViewportFromUrl(); + const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT) + const [viewportUrl, setViewportUrl] = useViewportFromUrl() - const [viewport, setViewport] = viewportFromUrl - ? [viewportUrl, setViewportUrl] - : [viewportState, setViewportState]; + const [viewport, setViewport_] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState] + const setViewport = useCallback( + (viewport: Viewport) => { + setViewport_(viewport) + onViewportChange?.(viewport) + }, + [setViewport_, onViewportChange] + ) - const config = useConfig(); + const config = useConfig() useEffect(() => { - if ( - config?.mapHome && - viewport?.latitude === 0 && - viewport?.longitude === 0 && - !boundsFromJson - ) { - setViewport(config.mapHome); + if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) { + setViewport(config.mapHome) } - }, [config, boundsFromJson]); + }, [config, boundsFromJson]) const mapSourceHosts = useMemo( - () => - _.uniq( - config?.obsMapSource?.tiles?.map( - (tileUrl: string) => new URL(tileUrl).host - ) ?? [] - ), + () => _.uniq(config?.obsMapSource?.tiles?.map((tileUrl: string) => new URL(tileUrl).host) ?? []), [config?.obsMapSource] - ); + ) const transformRequest = useCallbackRef((url, resourceType) => { - if (resourceType === "Tile" && mapSourceHosts.includes(new URL(url).host)) { + if (resourceType === 'Tile' && mapSourceHosts.includes(new URL(url).host)) { return { url, - credentials: "include", - }; + credentials: 'include', + } } - }); + }) useEffect(() => { if (boundsFromJson) { - const bbox = turfBbox(boundsFromJson); + const bbox = turfBbox(boundsFromJson) if (bbox.every((v) => Math.abs(v) !== Infinity)) { - const [minX, minY, maxX, maxY] = bbox; + const [minX, minY, maxX, maxY] = bbox const vp = new WebMercatorViewport({ width: 1000, height: 800, @@ -141,11 +133,11 @@ function Map({ padding: 20, offset: [0, -100], } - ); - setViewport(_.pick(vp, ["zoom", "latitude", "longitude"])); + ) + setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude'])) } } - }, [boundsFromJson]); + }, [boundsFromJson]) return ( - - - + + + {children} - ); + ) } -export default withBaseMapStyle(Map); +export default withBaseMapStyle(Map) diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js index e5e4c3f..8ea61ec 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 Chart} from './Chart' export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend' export {default as FileDrop} from './FileDrop' export {default as FileUploadField} from './FileUploadField' @@ -6,7 +7,7 @@ export {default as FormattedDate} from './FormattedDate' export {default as LoginButton} from './LoginButton' export {default as Map} from './Map' export {default as Page} from './Page' +export {default as RegionStats} from './RegionStats' export {default as Stats} from './Stats' export {default as StripMarkdown} from './StripMarkdown' -export {default as Chart} from './Chart' export {default as Visibility} from './Visibility' diff --git a/frontend/src/mapstyles/index.js b/frontend/src/mapstyles/index.js index 30067d0..4e75ad4 100644 --- a/frontend/src/mapstyles/index.js +++ b/frontend/src/mapstyles/index.js @@ -124,6 +124,60 @@ export const trackLayer = { }, } +export const getRegionLayers = (adminLevel = 6, baseColor = "#00897B", maxValue = 5000) => [{ + id: 'region', + "type": "fill", + "source": "obs", + "source-layer": "obs_regions", + "minzoom": 0, + "maxzoom": 10, + "filter": [ + "all", + ["==", "admin_level", adminLevel], + [">", "overtaking_event_count", 0], + ], + "paint": { + "fill-color": baseColor, + "fill-antialias": true, + "fill-opacity": [ + "interpolate", + ["linear"], + [ + "log10", + [ + "get", + "overtaking_event_count" + ] + ], + 0, + 0, + Math.log10(maxValue), + 0.9 + ] + }, +}, +{ + id: 'region-border', + "type": "line", + "source": "obs", + "source-layer": "obs_regions", + "minzoom": 0, + "maxzoom": 10, + "filter": [ + "all", + ["==", "admin_level", adminLevel], + [">", "overtaking_event_count", 0], + ], + "paint": { + "line-width": 1, + "line-color": baseColor, + }, + "layout": { + "line-join": "round", + "line-cap": "round" + } +}] + export const trackLayerRaw = produce(trackLayer, draft => { // draft.paint['line-color'] = '#81D4FA' draft.paint['line-width'][4] = 1 diff --git a/frontend/src/pages/MapPage/LayerSidebar.tsx b/frontend/src/pages/MapPage/LayerSidebar.tsx index 7da55ce..0120bc3 100644 --- a/frontend/src/pages/MapPage/LayerSidebar.tsx +++ b/frontend/src/pages/MapPage/LayerSidebar.tsx @@ -1,69 +1,56 @@ -import React from "react"; -import _ from "lodash"; -import { connect } from "react-redux"; -import { - List, - Select, - Input, - Divider, - Label, - Checkbox, - Header, -} from "semantic-ui-react"; -import { useTranslation } from "react-i18next"; +import React from 'react' +import _ from 'lodash' +import {connect} from 'react-redux' +import {List, Select, Input, Divider, Label, Checkbox, Header} from 'semantic-ui-react' +import {useTranslation} from 'react-i18next' import { MapConfig, setMapConfigFlag as setMapConfigFlagAction, initialState as defaultMapConfig, -} from "reducers/mapConfig"; -import { colorByDistance, colorByCount, viridisSimpleHtml } from "mapstyles"; -import { ColorMapLegend, DiscreteColorMapLegend } from "components"; +} from 'reducers/mapConfig' +import {colorByDistance, colorByCount, viridisSimpleHtml} from 'mapstyles' +import {ColorMapLegend, DiscreteColorMapLegend} from 'components' -const BASEMAP_STYLE_OPTIONS = ["positron", "bright"]; +const BASEMAP_STYLE_OPTIONS = ['positron', 'bright'] const ROAD_ATTRIBUTE_OPTIONS = [ - "distance_overtaker_mean", - "distance_overtaker_min", - "distance_overtaker_max", - "distance_overtaker_median", - "overtaking_event_count", - "usage_count", - "zone", -]; + 'distance_overtaker_mean', + 'distance_overtaker_min', + 'distance_overtaker_max', + 'distance_overtaker_median', + 'overtaking_event_count', + 'usage_count', + 'zone', +] -const DATE_FILTER_MODES = ["none", "range", "threshold"]; +const DATE_FILTER_MODES = ['none', 'range', 'threshold'] -type User = Object; +type User = Object function LayerSidebar({ mapConfig, login, setMapConfigFlag, }: { - login: User | null; - mapConfig: MapConfig; - setMapConfigFlag: (flag: string, value: unknown) => void; + login: User | null + mapConfig: MapConfig + setMapConfigFlag: (flag: string, value: unknown) => void }) { - const { t } = useTranslation(); + const {t} = useTranslation() const { - baseMap: { style }, - obsRoads: { show: showRoads, showUntagged, attribute, maxCount }, - obsEvents: { show: showEvents }, - filters: { - currentUser: filtersCurrentUser, - dateMode, - startDate, - endDate, - thresholdAfter, - }, - } = mapConfig; + baseMap: {style}, + obsRoads: {show: showRoads, showUntagged, attribute, maxCount}, + obsEvents: {show: showEvents}, + obsRegions: {show: showRegions}, + filters: {currentUser: filtersCurrentUser, dateMode, startDate, endDate, thresholdAfter}, + } = mapConfig return (
- {t("MapPage.sidebar.baseMap.style.label")} + {t('MapPage.sidebar.baseMap.style.label')} ({ @@ -113,74 +123,50 @@ function LayerSidebar({ text: t(`MapPage.sidebar.obsRoads.attribute.${value}`), }))} value={attribute} - onChange={(_e, { value }) => - setMapConfigFlag("obsRoads.attribute", value) - } + onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)} /> - {attribute.endsWith("_count") ? ( + {attribute.endsWith('_count') ? ( <> - - {t("MapPage.sidebar.obsRoads.maxCount.label")} - + {t('MapPage.sidebar.obsRoads.maxCount.label')} - setMapConfigFlag("obsRoads.maxCount", value) - } + onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)} /> - ) : attribute.endsWith("zone") ? ( + ) : attribute.endsWith('zone') ? ( <> - ) : ( <> - - {_.upperFirst(t("general.zone.urban"))} - - + {_.upperFirst(t('general.zone.urban'))} + - - {_.upperFirst(t("general.zone.rural"))} - - + {_.upperFirst(t('general.zone.rural'))} + )} @@ -192,40 +178,36 @@ function LayerSidebar({ toggle size="small" id="obsEvents.show" - style={{ float: "right" }} + style={{float: 'right'}} checked={showEvents} - onChange={() => setMapConfigFlag("obsEvents.show", !showEvents)} + onChange={() => setMapConfigFlag('obsEvents.show', !showEvents)} /> {showEvents && ( <> - {_.upperFirst(t("general.zone.urban"))} - + {_.upperFirst(t('general.zone.urban'))} + - {_.upperFirst(t("general.zone.rural"))} - + {_.upperFirst(t('general.zone.rural'))} + )} -
{t("MapPage.sidebar.filters.title")}
+
{t('MapPage.sidebar.filters.title')}
{login && ( <> -
{t("MapPage.sidebar.filters.userData")}
+
{t('MapPage.sidebar.filters.userData')}
@@ -234,15 +216,13 @@ function LayerSidebar({ size="small" id="filters.currentUser" checked={filtersCurrentUser} - onChange={() => - setMapConfigFlag("filters.currentUser", !filtersCurrentUser) - } - label={t("MapPage.sidebar.filters.currentUser")} + onChange={() => setMapConfigFlag('filters.currentUser', !filtersCurrentUser)} + label={t('MapPage.sidebar.filters.currentUser')} /> -
{t("MapPage.sidebar.filters.dateRange")}
+
{t('MapPage.sidebar.filters.dateRange')}
@@ -253,14 +233,12 @@ function LayerSidebar({ key: value, text: t(`MapPage.sidebar.filters.dateMode.${value}`), }))} - value={dateMode ?? "none"} - onChange={(_e, { value }) => - setMapConfigFlag("filters.dateMode", value) - } + value={dateMode ?? 'none'} + onChange={(_e, {value}) => setMapConfigFlag('filters.dateMode', value)} /> - {dateMode == "range" && ( + {dateMode == 'range' && ( - setMapConfigFlag("filters.startDate", value) - } + onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)} value={startDate ?? null} - label={t("MapPage.sidebar.filters.start")} + label={t('MapPage.sidebar.filters.start')} /> )} - {dateMode == "range" && ( + {dateMode == 'range' && ( - setMapConfigFlag("filters.endDate", value) - } + onChange={(_e, {value}) => setMapConfigFlag('filters.endDate', value)} value={endDate ?? null} - label={t("MapPage.sidebar.filters.end")} + label={t('MapPage.sidebar.filters.end')} /> )} - {dateMode == "threshold" && ( + {dateMode == 'threshold' && ( - setMapConfigFlag("filters.startDate", value) - } - label={t("MapPage.sidebar.filters.threshold")} + onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)} + label={t('MapPage.sidebar.filters.threshold')} /> )} - {dateMode == "threshold" && ( + {dateMode == 'threshold' && ( - {t("MapPage.sidebar.filters.before")}{" "} + {t('MapPage.sidebar.filters.before')}{' '} - setMapConfigFlag( - "filters.thresholdAfter", - !thresholdAfter - ) - } + onChange={() => setMapConfigFlag('filters.thresholdAfter', !thresholdAfter)} id="filters.thresholdAfter" - />{" "} - {t("MapPage.sidebar.filters.after")} + />{' '} + {t('MapPage.sidebar.filters.after')} )} )} - {!login && ( - {t("MapPage.sidebar.filters.needsLogin")} - )} + {!login && {t('MapPage.sidebar.filters.needsLogin')}}
- ); + ) } export default connect( @@ -351,6 +316,6 @@ export default connect( ), login: state.login, }), - { setMapConfigFlag: setMapConfigFlagAction } + {setMapConfigFlag: setMapConfigFlagAction} // -)(LayerSidebar); +)(LayerSidebar) diff --git a/frontend/src/pages/MapPage/RegionInfo.tsx b/frontend/src/pages/MapPage/RegionInfo.tsx new file mode 100644 index 0000000..cf29948 --- /dev/null +++ b/frontend/src/pages/MapPage/RegionInfo.tsx @@ -0,0 +1,33 @@ +import React, { useState, useCallback } from "react"; +import { createPortal } from "react-dom"; +import _ from "lodash"; +import { List, Header, Icon, Button } from "semantic-ui-react"; + +import styles from "./styles.module.less"; + +export default function RegionInfo({ region, mapInfoPortal, onClose }) { + const content = ( + <> +
+
{region.properties.name || "Unnamed region"}
+ +
+ + + + Number of events + {region.properties.overtaking_event_count ?? 0} + + + + ); + + return content && mapInfoPortal + ? createPortal( +
{content}
, + mapInfoPortal + ) + : null; +} diff --git a/frontend/src/pages/MapPage/RoadInfo.tsx b/frontend/src/pages/MapPage/RoadInfo.tsx index a4c988a..80116cc 100644 --- a/frontend/src/pages/MapPage/RoadInfo.tsx +++ b/frontend/src/pages/MapPage/RoadInfo.tsx @@ -1,74 +1,57 @@ -import React, { useState, useCallback } from "react"; -import _ from "lodash"; -import { - Segment, - Menu, - Header, - Label, - Icon, - Table, - Message, - Button, -} from "semantic-ui-react"; -import { Layer, Source } from "react-map-gl"; -import { of, from, concat } from "rxjs"; -import { useObservable } from "rxjs-hooks"; -import { switchMap, distinctUntilChanged } from "rxjs/operators"; -import { Chart } from "components"; -import { pairwise } from "utils"; -import { useTranslation } from "react-i18next"; +import React, {useState, useCallback} from 'react' +import {createPortal} from 'react-dom' +import _ from 'lodash' +import {Segment, Menu, Header, Label, Icon, Table, Message, Button} from 'semantic-ui-react' +import {Layer, Source} from 'react-map-gl' +import {of, from, concat} from 'rxjs' +import {useObservable} from 'rxjs-hooks' +import {switchMap, distinctUntilChanged} from 'rxjs/operators' +import {Chart} from 'components' +import {pairwise} from 'utils' +import {useTranslation} from 'react-i18next' -import type { Location } from "types"; -import api from "api"; -import { colorByDistance, borderByZone } from "mapstyles"; +import type {Location} from 'types' +import api from 'api' +import {colorByDistance, borderByZone} from 'mapstyles' -import styles from "./styles.module.less"; +import styles from './styles.module.less' function selectFromColorMap(colormap, value) { - let last = null; + let last = null for (let i = 0; i < colormap.length; i += 2) { if (colormap[i + 1] > value) { - return colormap[i]; + return colormap[i] } } - return colormap[colormap.length - 1]; + return colormap[colormap.length - 1] } const UNITS = { - distanceOvertaker: "m", - distanceStationary: "m", - speed: "km/h", -}; -const ZONE_COLORS = { urban: "blue", rural: "cyan", motorway: "purple" }; -const CARDINAL_DIRECTIONS = [ - "north", - "northEast", - "east", - "southEast", - "south", - "southWest", - "west", - "northWest", -]; + distanceOvertaker: 'm', + distanceStationary: 'm', + speed: 'km/h', +} +const ZONE_COLORS = {urban: 'blue', rural: 'cyan', motorway: 'purple'} +const CARDINAL_DIRECTIONS = ['north', 'northEast', 'east', 'southEast', 'south', 'southWest', 'west', 'northWest'] const getCardinalDirection = (t, bearing) => { if (bearing == null) { - return t("MapPage.roadInfo.cardinalDirections.unknown"); + return t('MapPage.roadInfo.cardinalDirections.unknown') } else { - const n = CARDINAL_DIRECTIONS.length; - const i = Math.floor(((bearing / 360.0) * n + 0.5) % n); - const name = CARDINAL_DIRECTIONS[i]; - return t(`MapPage.roadInfo.cardinalDirections.${name}`); + const n = CARDINAL_DIRECTIONS.length + const i = Math.floor(((bearing / 360.0) * n + 0.5) % n) + const name = CARDINAL_DIRECTIONS[i] + return t(`MapPage.roadInfo.cardinalDirections.${name}`) } -}; +} -function RoadStatsTable({ data }) { - const { t } = useTranslation(); +function RoadStatsTable({data}) { + const {t} = useTranslation() return ( - {["distanceOvertaker", "distanceStationary", "speed"].map((prop) => ( + {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => ( {t(`MapPage.roadInfo.${prop}`)} @@ -76,58 +59,52 @@ function RoadStatsTable({ data }) { - {["count", "min", "median", "max", "mean"].map((stat) => ( + {['count', 'min', 'median', 'max', 'mean'].map((stat) => ( {t(`MapPage.roadInfo.${stat}`)} - {["distanceOvertaker", "distanceStationary", "speed"].map( - (prop) => ( - - {( - data[prop]?.statistics?.[stat] * - (prop === `speed` && stat != "count" ? 3.6 : 1) - ).toFixed(stat === "count" ? 0 : 2)} - {stat !== "count" && ` ${UNITS[prop]}`} - - ) - )} + {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => ( + + {(data[prop]?.statistics?.[stat] * (prop === `speed` && stat != 'count' ? 3.6 : 1)).toFixed( + stat === 'count' ? 0 : 2 + )} + {stat !== 'count' && ` ${UNITS[prop]}`} + + ))} ))}
- ); + ) } -function HistogramChart({ bins, counts, zone }) { - const diff = bins[1] - bins[0]; - const colortype = zone === "rural" ? 3 : 5; +function HistogramChart({bins, counts, zone}) { + const diff = bins[1] - bins[0] + const colortype = zone === 'rural' ? 3 : 5 const data = _.zip( bins.slice(0, bins.length - 1).map((v) => v + diff / 2), counts ).map((value) => ({ value, itemStyle: { - color: selectFromColorMap( - colorByDistance()[3][colortype].slice(2), - value[0] - ), + color: selectFromColorMap(colorByDistance()[3][colortype].slice(2), value[0]), }, - })); + })) return ( `${Math.round(v * 100)} cm` }, + type: 'value', + axisLabel: {formatter: (v) => `${Math.round(v * 100)} cm`}, min: 0, max: 2.5, }, yAxis: {}, series: [ { - type: "bar", + type: 'bar', data, barMaxWidth: 20, @@ -135,142 +112,120 @@ function HistogramChart({ bins, counts, zone }) { ], }} /> - ); + ) +} + +interface ArrayStats { + statistics: { + count: number + mean: number + min: number + max: number + median: number + } + histogram: { + bins: number[] + counts: number[] + } + values: number[] +} + +export interface RoadDirectionInfo { + bearing: number + distanceOvertaker: ArrayStats + distanceStationary: ArrayStats + speed: ArrayStats +} + +export interface RoadInfoType { + road: { + way_id: number + zone: 'urban' | 'rural' | null + name: string + directionality: -1 | 0 | 1 + oneway: boolean + geometry: Object + } + forwards: RoadDirectionInfo + backwards: RoadDirectionInfo } export default function RoadInfo({ - clickLocation, + roadInfo: info, hasFilters, onClose, + mapInfoPortal, }: { - clickLocation: Location | null; - hasFilters: boolean; - onClose: () => void; + roadInfo: RoadInfoType + hasFilters: boolean + onClose: () => void + mapInfoPortal: HTMLElement }) { - const { t } = useTranslation(); - const [direction, setDirection] = useState("forwards"); + const {t} = useTranslation() + const [direction, setDirection] = useState('forwards') const onClickDirection = useCallback( - (e, { name }) => { - e.preventDefault(); - e.stopPropagation(); - setDirection(name); + (e, {name}) => { + e.preventDefault() + e.stopPropagation() + setDirection(name) }, [setDirection] - ); + ) - const info = useObservable( - (_$, inputs$) => - inputs$.pipe( - distinctUntilChanged(_.isEqual), - switchMap(([location]) => - location - ? concat( - of(null), - from( - api.get("/mapdetails/road", { - query: { - ...location, - radius: 100, - }, - }) - ) - ) - : of(null) - ) - ), - null, - [clickLocation] - ); + // TODO: change based on left-hand/right-hand traffic + const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1 - if (!clickLocation) { - return null; - } + const content = ( + <> +
+
{info?.road.name || t('MapPage.roadInfo.unnamedWay')}
+ +
- const loading = info == null; + {hasFilters && ( + + + {t('MapPage.roadInfo.hintFiltersNotApplied')} + + )} - const offsetDirection = info?.road?.oneway - ? 0 - : direction === "forwards" - ? 1 - : -1; // TODO: change based on left-hand/right-hand traffic + {info?.road.zone && ( + + )} - const content = - !loading && !info.road ? ( - "No road found." - ) : ( - <> -
- {loading - ? "..." - : info?.road.name || t("MapPage.roadInfo.unnamedWay")} + {info?.road.oneway && ( + + )} -
+ {info?.road.oneway ? null : ( + + {t('MapPage.roadInfo.direction')} + + {getCardinalDirection(t, info?.forwards?.bearing)} + + + {getCardinalDirection(t, info?.backwards?.bearing)} + + + )} - {hasFilters && ( - - - - {t("MapPage.roadInfo.hintFiltersNotApplied")} - - - )} + {info?.[direction] && } - {info?.road.zone && ( - - )} - - {info?.road.oneway && ( - - )} - - {info?.road.oneway ? null : ( - - {t("MapPage.roadInfo.direction")} - - {getCardinalDirection(t, info?.forwards?.bearing)} - - - {getCardinalDirection(t, info?.backwards?.bearing)} - - - )} - - {info?.[direction] && } - - {info?.[direction]?.distanceOvertaker?.histogram && ( - <> -
- {t("MapPage.roadInfo.overtakerDistanceDistribution")} -
- - - )} - - ); + {info?.[direction]?.distanceOvertaker?.histogram && ( + <> +
{t('MapPage.roadInfo.overtakerDistanceDistribution')}
+ + + )} + + ) return ( <> @@ -280,22 +235,14 @@ export default function RoadInfo({ id="route" type="line" paint={{ - "line-width": [ - "interpolate", - ["linear"], - ["zoom"], - 14, - 6, - 17, - 12, - ], - "line-color": "#18FFFF", - "line-opacity": 0.5, + 'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12], + 'line-color': '#18FFFF', + 'line-opacity': 0.5, ...{ - "line-offset": [ - "interpolate", - ["exponential", 1.5], - ["zoom"], + 'line-offset': [ + 'interpolate', + ['exponential', 1.5], + ['zoom'], 12, offsetDirection, 19, @@ -307,11 +254,7 @@ export default function RoadInfo({ )} - {content && ( -
- {content} -
- )} + {content && mapInfoPortal && createPortal(
{content}
, mapInfoPortal)} - ); + ) } diff --git a/frontend/src/pages/MapPage/index.tsx b/frontend/src/pages/MapPage/index.tsx index 921130c..a2afaaa 100644 --- a/frontend/src/pages/MapPage/index.tsx +++ b/frontend/src/pages/MapPage/index.tsx @@ -1,241 +1,254 @@ -import React, { useState, useCallback, useMemo } from "react"; -import _ from "lodash"; -import { connect } from "react-redux"; -import { Button } from "semantic-ui-react"; -import { Layer, Source } from "react-map-gl"; -import produce from "immer"; -import classNames from "classnames"; +import React, {useState, useCallback, useMemo, useRef} from 'react' +import _ from 'lodash' +import {connect} from 'react-redux' +import {Button} from 'semantic-ui-react' +import {Layer, Source} from 'react-map-gl' +import produce from 'immer' +import classNames from 'classnames' -import type { Location } from "types"; -import { Page, Map } from "components"; -import { useConfig } from "config"; -import { - colorByDistance, - colorByCount, - borderByZone, - reds, - isValidAttribute, -} from "mapstyles"; -import { useMapConfig } from "reducers/mapConfig"; +import api from 'api' +import type {Location} from 'types' +import {Page, Map} from 'components' +import {useConfig} from 'config' +import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles' +import {useMapConfig} from 'reducers/mapConfig' -import RoadInfo from "./RoadInfo"; -import LayerSidebar from "./LayerSidebar"; -import styles from "./styles.module.less"; +import RoadInfo, {RoadInfoType} from './RoadInfo' +import RegionInfo from './RegionInfo' +import LayerSidebar from './LayerSidebar' +import styles from './styles.module.less' const untaggedRoadsLayer = { - id: "obs_roads_untagged", - type: "line", - source: "obs", - "source-layer": "obs_roads", - filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]], + id: 'obs_roads_untagged', + type: 'line', + source: 'obs', + 'source-layer': 'obs_roads', + minzoom: 12, + filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], layout: { - "line-cap": "round", - "line-join": "round", + 'line-cap': 'round', + 'line-join': 'round', }, paint: { - "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2], - "line-color": "#ABC", - "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1], - "line-offset": [ - "interpolate", - ["exponential", 1.5], - ["zoom"], + 'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2], + 'line-color': '#ABC', + // "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1], + 'line-offset': [ + 'interpolate', + ['exponential', 1.5], + ['zoom'], 12, - ["get", "offset_direction"], + ['get', 'offset_direction'], 19, - ["*", ["get", "offset_direction"], 8], + ['*', ['get', 'offset_direction'], 8], ], }, minzoom: 12, -}; +} -const getUntaggedRoadsLayer = (colorAttribute, maxCount) => +const getUntaggedRoadsLayer = (colorAttribute) => produce(untaggedRoadsLayer, (draft) => { - draft.filter = ["!", isValidAttribute(colorAttribute)]; - }); + draft.filter = ['!', isValidAttribute(colorAttribute)] + }) const getRoadsLayer = (colorAttribute, maxCount) => produce(untaggedRoadsLayer, (draft) => { - draft.id = "obs_roads_normal"; - draft.filter = isValidAttribute(colorAttribute); - draft.paint["line-width"][6] = 6; // scale bigger on zoom - draft.paint["line-color"] = colorAttribute.startsWith("distance_") + draft.id = 'obs_roads_normal' + draft.filter = isValidAttribute(colorAttribute) + draft.minzoom = 10 + draft.paint['line-width'][6] = 6 // scale bigger on zoom + draft.paint['line-color'] = colorAttribute.startsWith('distance_') ? colorByDistance(colorAttribute) - : colorAttribute.endsWith("_count") + : colorAttribute.endsWith('_count') ? colorByCount(colorAttribute, maxCount) - : colorAttribute.endsWith("zone") + : colorAttribute.endsWith('zone') ? borderByZone() - : "#DDD"; - draft.paint["line-opacity"][3] = 12; - draft.paint["line-opacity"][5] = 13; - }); + : '#DDD' + // draft.paint["line-opacity"][3] = 12; + // draft.paint["line-opacity"][5] = 13; + }) const getEventsLayer = () => ({ - id: "obs_events", - type: "circle", - source: "obs", - "source-layer": "obs_events", + 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"), + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8], + 'circle-color': colorByDistance('distance_overtaker'), }, minzoom: 11, -}); +}) const getEventsTextLayer = () => ({ - id: "obs_events_text", - type: "symbol", + id: 'obs_events_text', + type: 'symbol', minzoom: 18, - source: "obs", - "source-layer": "obs_events", + source: 'obs', + 'source-layer': 'obs_events', layout: { - "text-field": [ - "number-format", - ["get", "distance_overtaker"], - { "min-fraction-digits": 2, "max-fraction-digits": 2 }, + '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", + '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], + 'text-halo-color': 'rgba(255, 255, 255, 1)', + 'text-halo-width': 1, + 'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1], }, -}); +}) -function MapPage({ login }) { - const { obsMapSource, banner } = useConfig() || {}; - const [clickLocation, setClickLocation] = useState(null); +interface RegionInfo { + properties: { + admin_level: number + name: string + overtaking_event_count: number + } +} - const mapConfig = useMapConfig(); +type Details = {type: 'road'; road: RoadInfoType} | {type: 'region'; region: RegionInfo} + +function MapPage({login}) { + const {obsMapSource, banner} = useConfig() || {} + const [details, setDetails] = useState(null) + + const onCloseDetails = useCallback(() => setDetails(null), [setDetails]) + + const mapConfig = useMapConfig() + + const viewportRef = useRef() + const mapInfoPortal = useRef() + + const onViewportChange = useCallback( + (viewport) => { + viewportRef.current = viewport + }, + [viewportRef] + ) const onClick = useCallback( - (e) => { - let node = e.target; + async (e) => { + // check if we clicked inside the mapInfoBox, if so, early exit + let node = e.target while (node) { - if ( - [styles.mapInfoBox, styles.mapToolbar].some((className) => - node?.classList?.contains(className) - ) - ) { - return; + if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) { + return } - node = node.parentNode; + node = node.parentNode } - setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] }); - }, - [setClickLocation] - ); - const onCloseRoadInfo = useCallback(() => { - setClickLocation(null); - }, [setClickLocation]); + const {zoom} = viewportRef.current - const [layerSidebar, setLayerSidebar] = useState(true); + if (zoom < 10) { + const clickedRegion = e.features?.find((f) => f.source === 'obs' && f.sourceLayer === 'obs_regions') + setDetails(clickedRegion ? {type: 'region', region: clickedRegion} : null) + } else { + const road = await api.get('/mapdetails/road', { + query: { + longitude: e.lngLat[0], + latitude: e.lngLat[1], + radius: 100, + }, + }) + setDetails(road?.road ? {type: 'road', road} : null) + } + }, + [setDetails] + ) + + const [layerSidebar, setLayerSidebar] = useState(true) const { - obsRoads: { attribute, maxCount }, - } = mapConfig; + obsRoads: {attribute, maxCount}, + } = mapConfig - const layers = []; + const layers = [] - const untaggedRoadsLayerCustom = useMemo( - () => getUntaggedRoadsLayer(attribute), - [attribute] - ); + const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute]) if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) { - layers.push(untaggedRoadsLayerCustom); + layers.push(untaggedRoadsLayerCustom) } - const roadsLayer = useMemo( - () => getRoadsLayer(attribute, maxCount), - [attribute, maxCount] - ); + const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount]) if (mapConfig.obsRoads.show) { - layers.push(roadsLayer); + layers.push(roadsLayer) } - const eventsLayer = useMemo(() => getEventsLayer(), []); - const eventsTextLayer = useMemo(() => getEventsTextLayer(), []); + const regionLayers = useMemo(() => getRegionLayers(), []) + if (mapConfig.obsRegions.show) { + layers.push(...regionLayers) + } + + const eventsLayer = useMemo(() => getEventsLayer(), []) + const eventsTextLayer = useMemo(() => getEventsTextLayer(), []) + if (mapConfig.obsEvents.show) { - layers.push(eventsLayer); - layers.push(eventsTextLayer); + layers.push(eventsLayer) + layers.push(eventsTextLayer) } const onToggleLayerSidebarButtonClick = useCallback( (e) => { - e.stopPropagation(); - e.preventDefault(); - console.log("toggl;e"); - setLayerSidebar((v) => !v); + e.stopPropagation() + e.preventDefault() + console.log('toggl;e') + setLayerSidebar((v) => !v) }, [setLayerSidebar] - ); + ) if (!obsMapSource) { - return null; + return null } const tiles = obsMapSource?.tiles?.map((tileUrl: string) => { - const query = new URLSearchParams(); + const query = new URLSearchParams() if (login) { if (mapConfig.filters.currentUser) { - query.append("user", login.id); + query.append('user', login.id) } - if (mapConfig.filters.dateMode === "range") { + if (mapConfig.filters.dateMode === 'range') { if (mapConfig.filters.startDate) { - query.append("start", mapConfig.filters.startDate); + query.append('start', mapConfig.filters.startDate) } if (mapConfig.filters.endDate) { - query.append("end", mapConfig.filters.endDate); + query.append('end', mapConfig.filters.endDate) } - } else if (mapConfig.filters.dateMode === "threshold") { + } else if (mapConfig.filters.dateMode === 'threshold') { if (mapConfig.filters.startDate) { - query.append( - mapConfig.filters.thresholdAfter ? "start" : "end", - mapConfig.filters.startDate - ); + query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate) } } } - const queryString = String(query); - return tileUrl + (queryString ? "?" : "") + queryString; - }); + const queryString = String(query) + return tileUrl + (queryString ? '?' : '') + queryString + }) - const hasFilters: boolean = - login && - (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none"); + const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none') return ( -
+
{layerSidebar && (
)}
- +
-
{layers.map((layer) => ( @@ -243,14 +256,23 @@ function MapPage({ login }) { ))} - + {details?.type === 'road' && details?.road?.road && ( + + )} + + {details?.type === 'region' && details?.region && ( + + )}
- ); + ) } -export default connect((state) => ({ login: state.login }))(MapPage); +export default connect((state) => ({login: state.login}))(MapPage) diff --git a/frontend/src/pages/MapPage/styles.module.less b/frontend/src/pages/MapPage/styles.module.less index 9af8b42..a88c095 100644 --- a/frontend/src/pages/MapPage/styles.module.less +++ b/frontend/src/pages/MapPage/styles.module.less @@ -24,12 +24,11 @@ } .mapInfoBox { - position: absolute; - right: 16px; - top: 32px; - max-height: 100%; width: 36rem; overflow: auto; + border-left: 1px solid @borderColor; + background: white; + padding: 16px; } .mapToolbar { @@ -37,3 +36,32 @@ left: 16px; top: 16px; } + +.closeHeader { + display: flex; + align-items: baseline; + justify-content: space-between; +} + + +@media @mobile { + .mapContainer { + height: auto; + min-height: calc(100vh - @menuHeight); + flex-direction: column; + } + + .map { + height: 60vh; + } + + .mapSidebar { + width: auto; + height: auto; + } + + .mapInfoBox { + width: auto; + height: auto; + } +} diff --git a/frontend/src/reducers/mapConfig.ts b/frontend/src/reducers/mapConfig.ts index 2ec5398..56151d3 100644 --- a/frontend/src/reducers/mapConfig.ts +++ b/frontend/src/reducers/mapConfig.ts @@ -1,95 +1,92 @@ -import { useMemo } from "react"; -import { useSelector } from "react-redux"; -import produce from "immer"; -import _ from "lodash"; +import {useMemo} from 'react' +import {useSelector} from 'react-redux' +import produce from 'immer' +import _ from 'lodash' -type BaseMapStyle = "positron" | "bright"; +type BaseMapStyle = 'positron' | 'bright' type RoadAttribute = - | "distance_overtaker_mean" - | "distance_overtaker_min" - | "distance_overtaker_max" - | "distance_overtaker_median" - | "overtaking_event_count" - | "usage_count" - | "zone"; + | 'distance_overtaker_mean' + | 'distance_overtaker_min' + | 'distance_overtaker_max' + | 'distance_overtaker_median' + | 'overtaking_event_count' + | 'usage_count' + | 'zone' export type MapConfig = { baseMap: { - style: BaseMapStyle; - }; + style: BaseMapStyle + } obsRoads: { - show: boolean; - showUntagged: boolean; - attribute: RoadAttribute; - maxCount: number; - }; + show: boolean + showUntagged: boolean + attribute: RoadAttribute + maxCount: number + } obsEvents: { - show: boolean; - }; + show: boolean + } + obsRegions: { + show: boolean + } filters: { - currentUser: boolean; - dateMode: "none" | "range" | "threshold"; - startDate?: null | string; - endDate?: null | string; - thresholdAfter?: null | boolean; - }; -}; + currentUser: boolean + dateMode: 'none' | 'range' | 'threshold' + startDate?: null | string + endDate?: null | string + thresholdAfter?: null | boolean + } +} export const initialState: MapConfig = { baseMap: { - style: "positron", + style: 'positron', }, obsRoads: { show: true, showUntagged: true, - attribute: "distance_overtaker_median", + attribute: 'distance_overtaker_median', maxCount: 20, }, obsEvents: { show: false, }, + obsRegions: { + show: true, + }, filters: { currentUser: false, - dateMode: "none", + dateMode: 'none', startDate: null, endDate: null, thresholdAfter: true, }, -}; +} 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 { - return { type: "MAP_CONFIG.SET_FLAG", payload: { flag, value } }; +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; + 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) { - case "MAP_CONFIG.SET_FLAG": + case 'MAP_CONFIG.SET_FLAG': return produce(state, (draft) => { - _.set(draft, action.payload.flag, action.payload.value); - }); + _.set(draft, action.payload.flag, action.payload.value) + }) default: - return state; + return state } }