Show regions on map page, and move on-click info panel into a proper sidebar

This commit is contained in:
Paul Bienkowski 2023-03-12 12:43:08 +01:00
parent 7ae4ebebb6
commit 518bcd81ef
10 changed files with 692 additions and 661 deletions

View file

@ -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 min = map[0][0]
const max = map[map.length - 1][0] const max = map[map.length - 1][0]
const normalizeValue = (v) => (v - min) / (max - min) const normalizeValue = (v) => (v - min) / (max - min)
@ -81,7 +81,7 @@ export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap,
</svg> </svg>
{tickValues.map(([value]) => ( {tickValues.map(([value]) => (
<span className={styles.tick} key={value} style={{left: normalizeValue(value) * 100 + '%'}}> <span className={styles.tick} key={value} style={{left: normalizeValue(value) * 100 + '%'}}>
{value.toFixed(2)} {value.toFixed(digits)}
</span> </span>
))} ))}
</div> </div>

View file

@ -1,75 +1,70 @@
import React, { useState, useCallback, useMemo, useEffect } from "react"; import React, {useState, useCallback, useMemo, useEffect} from 'react'
import classnames from "classnames"; import classnames from 'classnames'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import _ from "lodash"; import _ from 'lodash'
import ReactMapGl, { import ReactMapGl, {WebMercatorViewport, ScaleControl, NavigationControl, AttributionControl} from 'react-map-gl'
WebMercatorViewport, import turfBbox from '@turf/bbox'
ScaleControl, import {useHistory, useLocation} from 'react-router-dom'
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 {useCallbackRef} from '../../utils'
import { baseMapStyles } from "../../mapstyles"; import {baseMapStyles} from '../../mapstyles'
import styles from "./styles.module.less"; import styles from './styles.module.less'
interface Viewport { interface Viewport {
longitude: number; longitude: number
latitude: number; latitude: number
zoom: 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) => ({ export const withBaseMapStyle = connect((state) => ({
baseMapStyle: state.mapConfig?.baseMap?.style ?? "positron", baseMapStyle: state.mapConfig?.baseMap?.style ?? 'positron',
})); }))
function parseHash(v: string): Viewport | null { 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
return { return {
zoom: Number.parseFloat(m[1]), zoom: Number.parseFloat(m[1]),
latitude: Number.parseFloat(m[2]), latitude: Number.parseFloat(m[2]),
longitude: Number.parseFloat(m[3]), longitude: Number.parseFloat(m[3]),
}; }
} }
function buildHash(v: Viewport): string { 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) => { const setViewportToHash = _.debounce((history, viewport) => {
history.replace({ history.replace({
hash: buildHash(viewport), hash: buildHash(viewport),
}); })
}, 200); }, 200)
function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] { function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] {
const history = useHistory(); const history = useHistory()
const location = useLocation(); 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 // when the location hash changes, set the new value to the cache
useEffect(() => { useEffect(() => {
setCachedValue(parseHash(location.hash)); setCachedValue(parseHash(location.hash))
}, [location.hash]); }, [location.hash])
const setter = useCallback( const setter = useCallback(
(v) => { (v) => {
setCachedValue(v); setCachedValue(v)
setViewportToHash(history, v); setViewportToHash(history, v)
}, },
[history] [history]
); )
return [cachedValue || EMPTY_VIEWPORT, setter]; return [cachedValue || EMPTY_VIEWPORT, setter]
} }
function Map({ function Map({
@ -78,57 +73,54 @@ function Map({
boundsFromJson, boundsFromJson,
baseMapStyle, baseMapStyle,
hasToolbar, hasToolbar,
onViewportChange,
...props ...props
}: { }: {
viewportFromUrl?: boolean; viewportFromUrl?: boolean
children: React.ReactNode; children: React.ReactNode
boundsFromJson: GeoJSON.Geometry; boundsFromJson: GeoJSON.Geometry
baseMapStyle: string; baseMapStyle: string
hasToolbar?: boolean; hasToolbar?: boolean
onViewportChange: (viewport: Viewport) => void
}) { }) {
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT); const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
const [viewportUrl, setViewportUrl] = useViewportFromUrl(); const [viewportUrl, setViewportUrl] = useViewportFromUrl()
const [viewport, setViewport] = viewportFromUrl const [viewport, setViewport_] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState]
? [viewportUrl, setViewportUrl] const setViewport = useCallback(
: [viewportState, setViewportState]; (viewport: Viewport) => {
setViewport_(viewport)
onViewportChange?.(viewport)
},
[setViewport_, onViewportChange]
)
const config = useConfig(); const config = useConfig()
useEffect(() => { useEffect(() => {
if ( if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) {
config?.mapHome && setViewport(config.mapHome)
viewport?.latitude === 0 &&
viewport?.longitude === 0 &&
!boundsFromJson
) {
setViewport(config.mapHome);
} }
}, [config, boundsFromJson]); }, [config, boundsFromJson])
const mapSourceHosts = useMemo( 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] [config?.obsMapSource]
); )
const transformRequest = useCallbackRef((url, resourceType) => { const transformRequest = useCallbackRef((url, resourceType) => {
if (resourceType === "Tile" && mapSourceHosts.includes(new URL(url).host)) { if (resourceType === 'Tile' && mapSourceHosts.includes(new URL(url).host)) {
return { return {
url, url,
credentials: "include", credentials: 'include',
};
} }
}); }
})
useEffect(() => { useEffect(() => {
if (boundsFromJson) { if (boundsFromJson) {
const bbox = turfBbox(boundsFromJson); const bbox = turfBbox(boundsFromJson)
if (bbox.every((v) => Math.abs(v) !== Infinity)) { if (bbox.every((v) => Math.abs(v) !== Infinity)) {
const [minX, minY, maxX, maxY] = bbox; const [minX, minY, maxX, maxY] = bbox
const vp = new WebMercatorViewport({ const vp = new WebMercatorViewport({
width: 1000, width: 1000,
height: 800, height: 800,
@ -141,11 +133,11 @@ function Map({
padding: 20, padding: 20,
offset: [0, -100], offset: [0, -100],
} }
); )
setViewport(_.pick(vp, ["zoom", "latitude", "longitude"])); setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude']))
} }
} }
}, [boundsFromJson]); }, [boundsFromJson])
return ( return (
<ReactMapGl <ReactMapGl
@ -161,15 +153,11 @@ function Map({
> >
<AttributionControl style={{top: 0, right: 0}} /> <AttributionControl style={{top: 0, right: 0}} />
<NavigationControl style={{left: 16, top: hasToolbar ? 64 : 16}} /> <NavigationControl style={{left: 16, top: hasToolbar ? 64 : 16}} />
<ScaleControl <ScaleControl maxWidth={200} unit="metric" style={{left: 16, bottom: 16}} />
maxWidth={200}
unit="metric"
style={{ left: 16, bottom: 16 }}
/>
{children} {children}
</ReactMapGl> </ReactMapGl>
); )
} }
export default withBaseMapStyle(Map); export default withBaseMapStyle(Map)

View file

@ -1,4 +1,5 @@
export {default as Avatar} from './Avatar' export {default as Avatar} from './Avatar'
export {default as Chart} from './Chart'
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend' export {default as ColorMapLegend, DiscreteColorMapLegend} 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'
@ -6,7 +7,7 @@ export {default as FormattedDate} from './FormattedDate'
export {default as LoginButton} from './LoginButton' export {default as LoginButton} from './LoginButton'
export {default as Map} from './Map' export {default as Map} from './Map'
export {default as Page} from './Page' export {default as Page} from './Page'
export {default as RegionStats} from './RegionStats'
export {default as Stats} from './Stats' export {default as Stats} from './Stats'
export {default as StripMarkdown} from './StripMarkdown' export {default as StripMarkdown} from './StripMarkdown'
export {default as Chart} from './Chart'
export {default as Visibility} from './Visibility' export {default as Visibility} from './Visibility'

View file

@ -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 => { export const trackLayerRaw = produce(trackLayer, draft => {
// draft.paint['line-color'] = '#81D4FA' // draft.paint['line-color'] = '#81D4FA'
draft.paint['line-width'][4] = 1 draft.paint['line-width'][4] = 1

View file

@ -1,69 +1,56 @@
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 { import {List, Select, Input, Divider, Label, Checkbox, Header} from 'semantic-ui-react'
List, import {useTranslation} from 'react-i18next'
Select,
Input,
Divider,
Label,
Checkbox,
Header,
} from "semantic-ui-react";
import { useTranslation } from "react-i18next";
import { import {
MapConfig, MapConfig,
setMapConfigFlag as setMapConfigFlagAction, setMapConfigFlag as setMapConfigFlagAction,
initialState as defaultMapConfig, initialState as defaultMapConfig,
} from "reducers/mapConfig"; } from 'reducers/mapConfig'
import { colorByDistance, colorByCount, viridisSimpleHtml } from "mapstyles"; import {colorByDistance, colorByCount, viridisSimpleHtml} from 'mapstyles'
import { ColorMapLegend, DiscreteColorMapLegend } from "components"; import {ColorMapLegend, DiscreteColorMapLegend} from 'components'
const BASEMAP_STYLE_OPTIONS = ["positron", "bright"]; const BASEMAP_STYLE_OPTIONS = ['positron', 'bright']
const ROAD_ATTRIBUTE_OPTIONS = [ const ROAD_ATTRIBUTE_OPTIONS = [
"distance_overtaker_mean", 'distance_overtaker_mean',
"distance_overtaker_min", 'distance_overtaker_min',
"distance_overtaker_max", 'distance_overtaker_max',
"distance_overtaker_median", 'distance_overtaker_median',
"overtaking_event_count", 'overtaking_event_count',
"usage_count", 'usage_count',
"zone", 'zone',
]; ]
const DATE_FILTER_MODES = ["none", "range", "threshold"]; const DATE_FILTER_MODES = ['none', 'range', 'threshold']
type User = Object; type User = Object
function LayerSidebar({ function LayerSidebar({
mapConfig, mapConfig,
login, login,
setMapConfigFlag, setMapConfigFlag,
}: { }: {
login: User | null; login: User | null
mapConfig: MapConfig; mapConfig: MapConfig
setMapConfigFlag: (flag: string, value: unknown) => void; setMapConfigFlag: (flag: string, value: unknown) => void
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation()
const { const {
baseMap: {style}, baseMap: {style},
obsRoads: {show: showRoads, showUntagged, attribute, maxCount}, obsRoads: {show: showRoads, showUntagged, attribute, maxCount},
obsEvents: {show: showEvents}, obsEvents: {show: showEvents},
filters: { obsRegions: {show: showRegions},
currentUser: filtersCurrentUser, filters: {currentUser: filtersCurrentUser, dateMode, startDate, endDate, thresholdAfter},
dateMode, } = mapConfig
startDate,
endDate,
thresholdAfter,
},
} = mapConfig;
return ( return (
<div> <div>
<List relaxed> <List relaxed>
<List.Item> <List.Item>
<List.Header>{t("MapPage.sidebar.baseMap.style.label")}</List.Header> <List.Header>{t('MapPage.sidebar.baseMap.style.label')}</List.Header>
<Select <Select
options={BASEMAP_STYLE_OPTIONS.map((value) => ({ options={BASEMAP_STYLE_OPTIONS.map((value) => ({
value, value,
@ -71,23 +58,50 @@ function LayerSidebar({
text: t(`MapPage.sidebar.baseMap.style.${value}`), text: t(`MapPage.sidebar.baseMap.style.${value}`),
}))} }))}
value={style} value={style}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('baseMap.style', value)}
setMapConfigFlag("baseMap.style", value)
}
/> />
</List.Item> </List.Item>
<Divider /> <Divider />
<List.Item>
<Checkbox
toggle
size="small"
id="obsRegions.show"
style={{float: 'right'}}
checked={showRegions}
onChange={() => setMapConfigFlag('obsRegions.show', !showRegions)}
/>
<label htmlFor="obsRegions.show">
<Header as="h4">Regions</Header>
</label>
</List.Item>
{showRegions && (
<>
<List.Item>Color regions based on event count</List.Item>
<List.Item>
<ColorMapLegend
twoTicks
map={[
[0, '#00897B00'],
[5000, '#00897BFF'],
]}
digits={0}
/>
</List.Item>
</>
)}
<Divider />
<List.Item> <List.Item>
<Checkbox <Checkbox
toggle toggle
size="small" size="small"
id="obsRoads.show" id="obsRoads.show"
style={{ float: "right" }} style={{float: 'right'}}
checked={showRoads} checked={showRoads}
onChange={() => setMapConfigFlag("obsRoads.show", !showRoads)} onChange={() => setMapConfigFlag('obsRoads.show', !showRoads)}
/> />
<label htmlFor="obsRoads.show"> <label htmlFor="obsRoads.show">
<Header as="h4">{t("MapPage.sidebar.obsRoads.title")}</Header> <Header as="h4">{t('MapPage.sidebar.obsRoads.title')}</Header>
</label> </label>
</List.Item> </List.Item>
{showRoads && ( {showRoads && (
@ -95,16 +109,12 @@ function LayerSidebar({
<List.Item> <List.Item>
<Checkbox <Checkbox
checked={showUntagged} checked={showUntagged}
onChange={() => onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
setMapConfigFlag("obsRoads.showUntagged", !showUntagged) label={t('MapPage.sidebar.obsRoads.showUntagged.label')}
}
label={t("MapPage.sidebar.obsRoads.showUntagged.label")}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header> <List.Header>{t('MapPage.sidebar.obsRoads.attribute.label')}</List.Header>
{t("MapPage.sidebar.obsRoads.attribute.label")}
</List.Header>
<Select <Select
fluid fluid
options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({ options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({
@ -113,74 +123,50 @@ function LayerSidebar({
text: t(`MapPage.sidebar.obsRoads.attribute.${value}`), text: t(`MapPage.sidebar.obsRoads.attribute.${value}`),
}))} }))}
value={attribute} value={attribute}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)}
setMapConfigFlag("obsRoads.attribute", value)
}
/> />
</List.Item> </List.Item>
{attribute.endsWith("_count") ? ( {attribute.endsWith('_count') ? (
<> <>
<List.Item> <List.Item>
<List.Header> <List.Header>{t('MapPage.sidebar.obsRoads.maxCount.label')}</List.Header>
{t("MapPage.sidebar.obsRoads.maxCount.label")}
</List.Header>
<Input <Input
fluid fluid
type="number" type="number"
value={maxCount} value={maxCount}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)}
setMapConfigFlag("obsRoads.maxCount", value)
}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<ColorMapLegend <ColorMapLegend
map={_.chunk( map={_.chunk(
colorByCount( colorByCount('obsRoads.maxCount', mapConfig.obsRoads.maxCount, viridisSimpleHtml).slice(3),
"obsRoads.maxCount",
mapConfig.obsRoads.maxCount,
viridisSimpleHtml
).slice(3),
2 2
)} )}
twoTicks twoTicks
/> />
</List.Item> </List.Item>
</> </>
) : attribute.endsWith("zone") ? ( ) : attribute.endsWith('zone') ? (
<> <>
<List.Item> <List.Item>
<Label <Label size="small" style={{background: 'blue', color: 'white'}}>
size="small" {t('general.zone.urban')} (1.5&nbsp;m)
style={{ background: "blue", color: "white" }}
>
{t("general.zone.urban")} (1.5&nbsp;m)
</Label> </Label>
<Label <Label size="small" style={{background: 'cyan', color: 'black'}}>
size="small" {t('general.zone.rural')}(2&nbsp;m)
style={{ background: "cyan", color: "black" }}
>
{t("general.zone.rural")}(2&nbsp;m)
</Label> </Label>
</List.Item> </List.Item>
</> </>
) : ( ) : (
<> <>
<List.Item> <List.Item>
<List.Header> <List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
{_.upperFirst(t("general.zone.urban"))} <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header> <List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
{_.upperFirst(t("general.zone.rural"))} <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item> </List.Item>
</> </>
)} )}
@ -192,40 +178,36 @@ function LayerSidebar({
toggle toggle
size="small" size="small"
id="obsEvents.show" id="obsEvents.show"
style={{ float: "right" }} style={{float: 'right'}}
checked={showEvents} checked={showEvents}
onChange={() => setMapConfigFlag("obsEvents.show", !showEvents)} onChange={() => setMapConfigFlag('obsEvents.show', !showEvents)}
/> />
<label htmlFor="obsEvents.show"> <label htmlFor="obsEvents.show">
<Header as="h4">{t("MapPage.sidebar.obsEvents.title")}</Header> <Header as="h4">{t('MapPage.sidebar.obsEvents.title')}</Header>
</label> </label>
</List.Item> </List.Item>
{showEvents && ( {showEvents && (
<> <>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header> <List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
<DiscreteColorMapLegend <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header> <List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
<DiscreteColorMapLegend <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item> </List.Item>
</> </>
)} )}
<Divider /> <Divider />
<List.Item> <List.Item>
<Header as="h4">{t("MapPage.sidebar.filters.title")}</Header> <Header as="h4">{t('MapPage.sidebar.filters.title')}</Header>
</List.Item> </List.Item>
{login && ( {login && (
<> <>
<List.Item> <List.Item>
<Header as="h5">{t("MapPage.sidebar.filters.userData")}</Header> <Header as="h5">{t('MapPage.sidebar.filters.userData')}</Header>
</List.Item> </List.Item>
<List.Item> <List.Item>
@ -234,15 +216,13 @@ function LayerSidebar({
size="small" size="small"
id="filters.currentUser" id="filters.currentUser"
checked={filtersCurrentUser} checked={filtersCurrentUser}
onChange={() => onChange={() => setMapConfigFlag('filters.currentUser', !filtersCurrentUser)}
setMapConfigFlag("filters.currentUser", !filtersCurrentUser) label={t('MapPage.sidebar.filters.currentUser')}
}
label={t("MapPage.sidebar.filters.currentUser")}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<Header as="h5">{t("MapPage.sidebar.filters.dateRange")}</Header> <Header as="h5">{t('MapPage.sidebar.filters.dateRange')}</Header>
</List.Item> </List.Item>
<List.Item> <List.Item>
@ -253,14 +233,12 @@ function LayerSidebar({
key: value, key: value,
text: t(`MapPage.sidebar.filters.dateMode.${value}`), text: t(`MapPage.sidebar.filters.dateMode.${value}`),
}))} }))}
value={dateMode ?? "none"} value={dateMode ?? 'none'}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.dateMode', value)}
setMapConfigFlag("filters.dateMode", value)
}
/> />
</List.Item> </List.Item>
{dateMode == "range" && ( {dateMode == 'range' && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -268,16 +246,14 @@ function LayerSidebar({
step="7" step="7"
size="small" size="small"
id="filters.startDate" id="filters.startDate"
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
setMapConfigFlag("filters.startDate", value)
}
value={startDate ?? null} value={startDate ?? null}
label={t("MapPage.sidebar.filters.start")} label={t('MapPage.sidebar.filters.start')}
/> />
</List.Item> </List.Item>
)} )}
{dateMode == "range" && ( {dateMode == 'range' && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -285,16 +261,14 @@ function LayerSidebar({
step="7" step="7"
size="small" size="small"
id="filters.endDate" id="filters.endDate"
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.endDate', value)}
setMapConfigFlag("filters.endDate", value)
}
value={endDate ?? null} value={endDate ?? null}
label={t("MapPage.sidebar.filters.end")} label={t('MapPage.sidebar.filters.end')}
/> />
</List.Item> </List.Item>
)} )}
{dateMode == "threshold" && ( {dateMode == 'threshold' && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -303,42 +277,33 @@ function LayerSidebar({
size="small" size="small"
id="filters.startDate" id="filters.startDate"
value={startDate ?? null} value={startDate ?? null}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
setMapConfigFlag("filters.startDate", value) label={t('MapPage.sidebar.filters.threshold')}
}
label={t("MapPage.sidebar.filters.threshold")}
/> />
</List.Item> </List.Item>
)} )}
{dateMode == "threshold" && ( {dateMode == 'threshold' && (
<List.Item> <List.Item>
<span> <span>
{t("MapPage.sidebar.filters.before")}{" "} {t('MapPage.sidebar.filters.before')}{' '}
<Checkbox <Checkbox
toggle toggle
size="small" size="small"
checked={thresholdAfter ?? false} checked={thresholdAfter ?? false}
onChange={() => onChange={() => setMapConfigFlag('filters.thresholdAfter', !thresholdAfter)}
setMapConfigFlag(
"filters.thresholdAfter",
!thresholdAfter
)
}
id="filters.thresholdAfter" id="filters.thresholdAfter"
/>{" "} />{' '}
{t("MapPage.sidebar.filters.after")} {t('MapPage.sidebar.filters.after')}
</span> </span>
</List.Item> </List.Item>
)} )}
</> </>
)} )}
{!login && ( {!login && <List.Item>{t('MapPage.sidebar.filters.needsLogin')}</List.Item>}
<List.Item>{t("MapPage.sidebar.filters.needsLogin")}</List.Item>
)}
</List> </List>
</div> </div>
); )
} }
export default connect( export default connect(
@ -353,4 +318,4 @@ export default connect(
}), }),
{setMapConfigFlag: setMapConfigFlagAction} {setMapConfigFlag: setMapConfigFlagAction}
// //
)(LayerSidebar); )(LayerSidebar)

View file

@ -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 = (
<>
<div className={styles.closeHeader}>
<Header as="h3">{region.properties.name || "Unnamed region"}</Header>
<Button primary icon onClick={onClose}>
<Icon name="close" />
</Button>
</div>
<List>
<List.Item>
<List.Header>Number of events</List.Header>
<List.Content>{region.properties.overtaking_event_count ?? 0}</List.Content>
</List.Item>
</List>
</>
);
return content && mapInfoPortal
? createPortal(
<div className={styles.mapInfoBox}>{content}</div>,
mapInfoPortal
)
: null;
}

View file

@ -1,74 +1,57 @@
import React, { useState, useCallback } from "react"; import React, {useState, useCallback} from 'react'
import _ from "lodash"; import {createPortal} from 'react-dom'
import { import _ from 'lodash'
Segment, import {Segment, Menu, Header, Label, Icon, Table, Message, Button} from 'semantic-ui-react'
Menu, import {Layer, Source} from 'react-map-gl'
Header, import {of, from, concat} from 'rxjs'
Label, import {useObservable} from 'rxjs-hooks'
Icon, import {switchMap, distinctUntilChanged} from 'rxjs/operators'
Table, import {Chart} from 'components'
Message, import {pairwise} from 'utils'
Button, import {useTranslation} from 'react-i18next'
} 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 type {Location} from 'types'
import api from "api"; import api from 'api'
import { colorByDistance, borderByZone } from "mapstyles"; import {colorByDistance, borderByZone} from 'mapstyles'
import styles from "./styles.module.less"; import styles from './styles.module.less'
function selectFromColorMap(colormap, value) { function selectFromColorMap(colormap, value) {
let last = null; let last = null
for (let i = 0; i < colormap.length; i += 2) { for (let i = 0; i < colormap.length; i += 2) {
if (colormap[i + 1] > value) { if (colormap[i + 1] > value) {
return colormap[i]; return colormap[i]
} }
} }
return colormap[colormap.length - 1]; return colormap[colormap.length - 1]
} }
const UNITS = { const UNITS = {
distanceOvertaker: "m", distanceOvertaker: 'm',
distanceStationary: "m", distanceStationary: 'm',
speed: "km/h", speed: 'km/h',
}; }
const ZONE_COLORS = { urban: "blue", rural: "cyan", motorway: "purple" }; const ZONE_COLORS = {urban: 'blue', rural: 'cyan', motorway: 'purple'}
const CARDINAL_DIRECTIONS = [ const CARDINAL_DIRECTIONS = ['north', 'northEast', 'east', 'southEast', 'south', 'southWest', 'west', 'northWest']
"north",
"northEast",
"east",
"southEast",
"south",
"southWest",
"west",
"northWest",
];
const getCardinalDirection = (t, bearing) => { const getCardinalDirection = (t, bearing) => {
if (bearing == null) { if (bearing == null) {
return t("MapPage.roadInfo.cardinalDirections.unknown"); return t('MapPage.roadInfo.cardinalDirections.unknown')
} else { } else {
const n = CARDINAL_DIRECTIONS.length; const n = CARDINAL_DIRECTIONS.length
const i = Math.floor(((bearing / 360.0) * n + 0.5) % n); const i = Math.floor(((bearing / 360.0) * n + 0.5) % n)
const name = CARDINAL_DIRECTIONS[i]; const name = CARDINAL_DIRECTIONS[i]
return t(`MapPage.roadInfo.cardinalDirections.${name}`); return t(`MapPage.roadInfo.cardinalDirections.${name}`)
}
} }
};
function RoadStatsTable({data}) { function RoadStatsTable({data}) {
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<Table size="small" compact> <Table size="small" compact>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell textAlign="right"></Table.HeaderCell> <Table.HeaderCell textAlign="right"></Table.HeaderCell>
{["distanceOvertaker", "distanceStationary", "speed"].map((prop) => ( {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
<Table.HeaderCell key={prop} textAlign="right"> <Table.HeaderCell key={prop} textAlign="right">
{t(`MapPage.roadInfo.${prop}`)} {t(`MapPage.roadInfo.${prop}`)}
</Table.HeaderCell> </Table.HeaderCell>
@ -76,42 +59,36 @@ function RoadStatsTable({ data }) {
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{["count", "min", "median", "max", "mean"].map((stat) => ( {['count', 'min', 'median', 'max', 'mean'].map((stat) => (
<Table.Row key={stat}> <Table.Row key={stat}>
<Table.Cell> {t(`MapPage.roadInfo.${stat}`)}</Table.Cell> <Table.Cell> {t(`MapPage.roadInfo.${stat}`)}</Table.Cell>
{["distanceOvertaker", "distanceStationary", "speed"].map( {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
(prop) => (
<Table.Cell key={prop} textAlign="right"> <Table.Cell key={prop} textAlign="right">
{( {(data[prop]?.statistics?.[stat] * (prop === `speed` && stat != 'count' ? 3.6 : 1)).toFixed(
data[prop]?.statistics?.[stat] * stat === 'count' ? 0 : 2
(prop === `speed` && stat != "count" ? 3.6 : 1)
).toFixed(stat === "count" ? 0 : 2)}
{stat !== "count" && ` ${UNITS[prop]}`}
</Table.Cell>
)
)} )}
{stat !== 'count' && ` ${UNITS[prop]}`}
</Table.Cell>
))}
</Table.Row> </Table.Row>
))} ))}
</Table.Body> </Table.Body>
</Table> </Table>
); )
} }
function HistogramChart({bins, counts, zone}) { function HistogramChart({bins, counts, zone}) {
const diff = bins[1] - bins[0]; const diff = bins[1] - bins[0]
const colortype = zone === "rural" ? 3 : 5; const colortype = zone === 'rural' ? 3 : 5
const data = _.zip( const data = _.zip(
bins.slice(0, bins.length - 1).map((v) => v + diff / 2), bins.slice(0, bins.length - 1).map((v) => v + diff / 2),
counts counts
).map((value) => ({ ).map((value) => ({
value, value,
itemStyle: { itemStyle: {
color: selectFromColorMap( color: selectFromColorMap(colorByDistance()[3][colortype].slice(2), value[0]),
colorByDistance()[3][colortype].slice(2),
value[0]
),
}, },
})); }))
return ( return (
<Chart <Chart
@ -119,7 +96,7 @@ function HistogramChart({ bins, counts, zone }) {
option={{ option={{
grid: {top: 30, bottom: 30, right: 30, left: 30}, grid: {top: 30, bottom: 30, right: 30, left: 30},
xAxis: { xAxis: {
type: "value", type: 'value',
axisLabel: {formatter: (v) => `${Math.round(v * 100)} cm`}, axisLabel: {formatter: (v) => `${Math.round(v * 100)} cm`},
min: 0, min: 0,
max: 2.5, max: 2.5,
@ -127,7 +104,7 @@ function HistogramChart({ bins, counts, zone }) {
yAxis: {}, yAxis: {},
series: [ series: [
{ {
type: "bar", type: 'bar',
data, data,
barMaxWidth: 20, barMaxWidth: 20,
@ -135,92 +112,83 @@ 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({ export default function RoadInfo({
clickLocation, roadInfo: info,
hasFilters, hasFilters,
onClose, onClose,
mapInfoPortal,
}: { }: {
clickLocation: Location | null; roadInfo: RoadInfoType
hasFilters: boolean; hasFilters: boolean
onClose: () => void; onClose: () => void
mapInfoPortal: HTMLElement
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation()
const [direction, setDirection] = useState("forwards"); const [direction, setDirection] = useState('forwards')
const onClickDirection = useCallback( const onClickDirection = useCallback(
(e, {name}) => { (e, {name}) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setDirection(name); setDirection(name)
}, },
[setDirection] [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]
);
if (!clickLocation) { // TODO: change based on left-hand/right-hand traffic
return null; const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1
}
const loading = info == null; const content = (
const offsetDirection = info?.road?.oneway
? 0
: direction === "forwards"
? 1
: -1; // TODO: change based on left-hand/right-hand traffic
const content =
!loading && !info.road ? (
"No road found."
) : (
<> <>
<Header as="h3"> <div className={styles.closeHeader}>
{loading <Header as="h3">{info?.road.name || t('MapPage.roadInfo.unnamedWay')}</Header>
? "..." <Button primary icon onClick={onClose}>
: info?.road.name || t("MapPage.roadInfo.unnamedWay")} <Icon name="close" />
</Button>
<Button </div>
style={{ float: "right" }}
onClick={onClose}
title={t("MapPage.roadInfo.closeTooltip")}
size="small"
icon="close"
basic
/>
</Header>
{hasFilters && ( {hasFilters && (
<Message info icon> <Message info icon>
<Icon name="info circle" small /> <Icon name="info circle" small />
<Message.Content> <Message.Content>{t('MapPage.roadInfo.hintFiltersNotApplied')}</Message.Content>
{t("MapPage.roadInfo.hintFiltersNotApplied")}
</Message.Content>
</Message> </Message>
)} )}
@ -232,26 +200,17 @@ export default function RoadInfo({
{info?.road.oneway && ( {info?.road.oneway && (
<Label size="small" color="blue"> <Label size="small" color="blue">
<Icon name="long arrow alternate right" fitted />{" "} <Icon name="long arrow alternate right" fitted /> {t('MapPage.roadInfo.oneway')}
{t("MapPage.roadInfo.oneway")}
</Label> </Label>
)} )}
{info?.road.oneway ? null : ( {info?.road.oneway ? null : (
<Menu size="tiny" fluid secondary> <Menu size="tiny" pointing>
<Menu.Item header>{t("MapPage.roadInfo.direction")}</Menu.Item> <Menu.Item header>{t('MapPage.roadInfo.direction')}</Menu.Item>
<Menu.Item <Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
name="forwards"
active={direction === "forwards"}
onClick={onClickDirection}
>
{getCardinalDirection(t, info?.forwards?.bearing)} {getCardinalDirection(t, info?.forwards?.bearing)}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
name="backwards"
active={direction === "backwards"}
onClick={onClickDirection}
>
{getCardinalDirection(t, info?.backwards?.bearing)} {getCardinalDirection(t, info?.backwards?.bearing)}
</Menu.Item> </Menu.Item>
</Menu> </Menu>
@ -261,16 +220,12 @@ export default function RoadInfo({
{info?.[direction]?.distanceOvertaker?.histogram && ( {info?.[direction]?.distanceOvertaker?.histogram && (
<> <>
<Header as="h5"> <Header as="h5">{t('MapPage.roadInfo.overtakerDistanceDistribution')}</Header>
{t("MapPage.roadInfo.overtakerDistanceDistribution")} <HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
</Header>
<HistogramChart
{...info[direction]?.distanceOvertaker?.histogram}
/>
</> </>
)} )}
</> </>
); )
return ( return (
<> <>
@ -280,22 +235,14 @@ export default function RoadInfo({
id="route" id="route"
type="line" type="line"
paint={{ paint={{
"line-width": [ 'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12],
"interpolate", 'line-color': '#18FFFF',
["linear"], 'line-opacity': 0.5,
["zoom"],
14,
6,
17,
12,
],
"line-color": "#18FFFF",
"line-opacity": 0.5,
...{ ...{
"line-offset": [ 'line-offset': [
"interpolate", 'interpolate',
["exponential", 1.5], ['exponential', 1.5],
["zoom"], ['zoom'],
12, 12,
offsetDirection, offsetDirection,
19, 19,
@ -307,11 +254,7 @@ export default function RoadInfo({
</Source> </Source>
)} )}
{content && ( {content && mapInfoPortal && createPortal(<div className={styles.mapInfoBox}>{content}</div>, mapInfoPortal)}
<div className={styles.mapInfoBox}>
<Segment loading={loading}>{content}</Segment>
</div>
)}
</> </>
); )
} }

View file

@ -1,241 +1,254 @@
import React, { useState, useCallback, useMemo } from "react"; import React, {useState, useCallback, useMemo, useRef} from 'react'
import _ from "lodash"; import _ from 'lodash'
import { connect } from "react-redux"; import {connect} from 'react-redux'
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 classNames from "classnames"; import classNames from 'classnames'
import type { Location } from "types"; import api from 'api'
import { Page, Map } from "components"; import type {Location} from 'types'
import { useConfig } from "config"; import {Page, Map} from 'components'
import { import {useConfig} from 'config'
colorByDistance, import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles'
colorByCount, import {useMapConfig} from 'reducers/mapConfig'
borderByZone,
reds,
isValidAttribute,
} from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo from "./RoadInfo"; import RoadInfo, {RoadInfoType} from './RoadInfo'
import LayerSidebar from "./LayerSidebar"; import RegionInfo from './RegionInfo'
import styles from "./styles.module.less"; import LayerSidebar from './LayerSidebar'
import styles from './styles.module.less'
const untaggedRoadsLayer = { const untaggedRoadsLayer = {
id: "obs_roads_untagged", id: 'obs_roads_untagged',
type: "line", type: 'line',
source: "obs", source: 'obs',
"source-layer": "obs_roads", 'source-layer': 'obs_roads',
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]], minzoom: 12,
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
layout: { layout: {
"line-cap": "round", 'line-cap': 'round',
"line-join": "round", 'line-join': 'round',
}, },
paint: { paint: {
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2], 'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2],
"line-color": "#ABC", 'line-color': '#ABC',
"line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1], // "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
"line-offset": [ 'line-offset': [
"interpolate", 'interpolate',
["exponential", 1.5], ['exponential', 1.5],
["zoom"], ['zoom'],
12, 12,
["get", "offset_direction"], ['get', 'offset_direction'],
19, 19,
["*", ["get", "offset_direction"], 8], ['*', ['get', 'offset_direction'], 8],
], ],
}, },
minzoom: 12, minzoom: 12,
}; }
const getUntaggedRoadsLayer = (colorAttribute, maxCount) => const getUntaggedRoadsLayer = (colorAttribute) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.filter = ["!", isValidAttribute(colorAttribute)]; draft.filter = ['!', isValidAttribute(colorAttribute)]
}); })
const getRoadsLayer = (colorAttribute, maxCount) => const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.id = "obs_roads_normal"; draft.id = 'obs_roads_normal'
draft.filter = isValidAttribute(colorAttribute); draft.filter = isValidAttribute(colorAttribute)
draft.paint["line-width"][6] = 6; // scale bigger on zoom draft.minzoom = 10
draft.paint["line-color"] = colorAttribute.startsWith("distance_") draft.paint['line-width'][6] = 6 // scale bigger on zoom
draft.paint['line-color'] = colorAttribute.startsWith('distance_')
? colorByDistance(colorAttribute) ? colorByDistance(colorAttribute)
: colorAttribute.endsWith("_count") : colorAttribute.endsWith('_count')
? colorByCount(colorAttribute, maxCount) ? colorByCount(colorAttribute, maxCount)
: colorAttribute.endsWith("zone") : colorAttribute.endsWith('zone')
? borderByZone() ? borderByZone()
: "#DDD"; : '#DDD'
draft.paint["line-opacity"][3] = 12; // draft.paint["line-opacity"][3] = 12;
draft.paint["line-opacity"][5] = 13; // draft.paint["line-opacity"][5] = 13;
}); })
const getEventsLayer = () => ({ const getEventsLayer = () => ({
id: "obs_events", id: 'obs_events',
type: "circle", type: 'circle',
source: "obs", source: 'obs',
"source-layer": "obs_events", 'source-layer': 'obs_events',
paint: { paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8], 'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
"circle-color": colorByDistance("distance_overtaker"), 'circle-color': colorByDistance('distance_overtaker'),
}, },
minzoom: 11, minzoom: 11,
}); })
const getEventsTextLayer = () => ({ const getEventsTextLayer = () => ({
id: "obs_events_text", id: 'obs_events_text',
type: "symbol", type: 'symbol',
minzoom: 18, minzoom: 18,
source: "obs", source: 'obs',
"source-layer": "obs_events", 'source-layer': 'obs_events',
layout: { layout: {
"text-field": [ 'text-field': [
"number-format", 'number-format',
["get", "distance_overtaker"], ['get', 'distance_overtaker'],
{ "min-fraction-digits": 2, "max-fraction-digits": 2 }, {'min-fraction-digits': 2, 'max-fraction-digits': 2},
], ],
"text-allow-overlap": true, 'text-allow-overlap': true,
"text-font": ["Open Sans Bold", "Arial Unicode MS Regular"], 'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
"text-size": 14, 'text-size': 14,
"text-keep-upright": false, 'text-keep-upright': false,
"text-anchor": "left", 'text-anchor': 'left',
"text-radial-offset": 1, 'text-radial-offset': 1,
"text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]], 'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]],
"text-rotation-alignment": "map", 'text-rotation-alignment': 'map',
}, },
paint: { paint: {
"text-halo-color": "rgba(255, 255, 255, 1)", 'text-halo-color': 'rgba(255, 255, 255, 1)',
"text-halo-width": 1, 'text-halo-width': 1,
"text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1], 'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
}, },
}); })
interface RegionInfo {
properties: {
admin_level: number
name: string
overtaking_event_count: number
}
}
type Details = {type: 'road'; road: RoadInfoType} | {type: 'region'; region: RegionInfo}
function MapPage({login}) { function MapPage({login}) {
const { obsMapSource, banner } = useConfig() || {}; const {obsMapSource, banner} = useConfig() || {}
const [clickLocation, setClickLocation] = useState<Location | null>(null); const [details, setDetails] = useState<null | Details>(null)
const mapConfig = useMapConfig(); 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( const onClick = useCallback(
(e) => { async (e) => {
let node = e.target; // check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target
while (node) { while (node) {
if ( if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) {
[styles.mapInfoBox, styles.mapToolbar].some((className) => return
node?.classList?.contains(className)
)
) {
return;
} }
node = node.parentNode; node = node.parentNode
} }
setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] }); const {zoom} = viewportRef.current
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,
}, },
[setClickLocation] })
); setDetails(road?.road ? {type: 'road', road} : null)
const onCloseRoadInfo = useCallback(() => { }
setClickLocation(null); },
}, [setClickLocation]); [setDetails]
)
const [layerSidebar, setLayerSidebar] = useState(true); const [layerSidebar, setLayerSidebar] = useState(true)
const { const {
obsRoads: {attribute, maxCount}, obsRoads: {attribute, maxCount},
} = mapConfig; } = mapConfig
const layers = []; const layers = []
const untaggedRoadsLayerCustom = useMemo( const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
() => getUntaggedRoadsLayer(attribute),
[attribute]
);
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) { if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayerCustom); layers.push(untaggedRoadsLayerCustom)
} }
const roadsLayer = useMemo( const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
() => getRoadsLayer(attribute, maxCount),
[attribute, maxCount]
);
if (mapConfig.obsRoads.show) { if (mapConfig.obsRoads.show) {
layers.push(roadsLayer); layers.push(roadsLayer)
} }
const eventsLayer = useMemo(() => getEventsLayer(), []); const regionLayers = useMemo(() => getRegionLayers(), [])
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []); if (mapConfig.obsRegions.show) {
layers.push(...regionLayers)
}
const eventsLayer = useMemo(() => getEventsLayer(), [])
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
if (mapConfig.obsEvents.show) { if (mapConfig.obsEvents.show) {
layers.push(eventsLayer); layers.push(eventsLayer)
layers.push(eventsTextLayer); layers.push(eventsTextLayer)
} }
const onToggleLayerSidebarButtonClick = useCallback( const onToggleLayerSidebarButtonClick = useCallback(
(e) => { (e) => {
e.stopPropagation(); e.stopPropagation()
e.preventDefault(); e.preventDefault()
console.log("toggl;e"); console.log('toggl;e')
setLayerSidebar((v) => !v); setLayerSidebar((v) => !v)
}, },
[setLayerSidebar] [setLayerSidebar]
); )
if (!obsMapSource) { if (!obsMapSource) {
return null; return null
} }
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => { const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
const query = new URLSearchParams(); const query = new URLSearchParams()
if (login) { if (login) {
if (mapConfig.filters.currentUser) { 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) { if (mapConfig.filters.startDate) {
query.append("start", mapConfig.filters.startDate); query.append('start', mapConfig.filters.startDate)
} }
if (mapConfig.filters.endDate) { 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) { if (mapConfig.filters.startDate) {
query.append( query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
mapConfig.filters.thresholdAfter ? "start" : "end",
mapConfig.filters.startDate
);
} }
} }
} }
const queryString = String(query); const queryString = String(query)
return tileUrl + (queryString ? "?" : "") + queryString; return tileUrl + (queryString ? '?' : '') + queryString
}); })
const hasFilters: boolean = const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none')
login &&
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
return ( return (
<Page fullScreen title="Map"> <Page fullScreen title="Map">
<div <div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}>
className={classNames(
styles.mapContainer,
banner ? styles.hasBanner : null
)}
>
{layerSidebar && ( {layerSidebar && (
<div className={styles.mapSidebar}> <div className={styles.mapSidebar}>
<LayerSidebar /> <LayerSidebar />
</div> </div>
)} )}
<div className={styles.map}> <div className={styles.map}>
<Map viewportFromUrl onClick={onClick} hasToolbar> <Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}>
<div className={styles.mapToolbar}> <div className={styles.mapToolbar}>
<Button <Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} />
primary
icon="bars"
active={layerSidebar}
onClick={onToggleLayerSidebarButtonClick}
/>
</div> </div>
<Source id="obs" {...obsMapSource} tiles={tiles}> <Source id="obs" {...obsMapSource} tiles={tiles}>
{layers.map((layer) => ( {layers.map((layer) => (
@ -243,14 +256,23 @@ function MapPage({ login }) {
))} ))}
</Source> </Source>
{details?.type === 'road' && details?.road?.road && (
<RoadInfo <RoadInfo
{...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }} roadInfo={details.road}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
{...{hasFilters}}
/> />
)}
{details?.type === 'region' && details?.region && (
<RegionInfo region={details.region} mapInfoPortal={mapInfoPortal.current} onClose={onCloseDetails} />
)}
</Map> </Map>
</div> </div>
</div> </div>
</Page> </Page>
); )
} }
export default connect((state) => ({ login: state.login }))(MapPage); export default connect((state) => ({login: state.login}))(MapPage)

View file

@ -24,12 +24,11 @@
} }
.mapInfoBox { .mapInfoBox {
position: absolute;
right: 16px;
top: 32px;
max-height: 100%;
width: 36rem; width: 36rem;
overflow: auto; overflow: auto;
border-left: 1px solid @borderColor;
background: white;
padding: 16px;
} }
.mapToolbar { .mapToolbar {
@ -37,3 +36,32 @@
left: 16px; left: 16px;
top: 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;
}
}

View file

@ -1,95 +1,92 @@
import { useMemo } from "react"; import {useMemo} from 'react'
import { useSelector } from "react-redux"; import {useSelector} from 'react-redux'
import produce from "immer"; import produce from 'immer'
import _ from "lodash"; import _ from 'lodash'
type BaseMapStyle = "positron" | "bright"; type BaseMapStyle = 'positron' | 'bright'
type RoadAttribute = type RoadAttribute =
| "distance_overtaker_mean" | 'distance_overtaker_mean'
| "distance_overtaker_min" | 'distance_overtaker_min'
| "distance_overtaker_max" | 'distance_overtaker_max'
| "distance_overtaker_median" | 'distance_overtaker_median'
| "overtaking_event_count" | 'overtaking_event_count'
| "usage_count" | 'usage_count'
| "zone"; | 'zone'
export type MapConfig = { 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: { obsEvents: {
show: boolean; show: boolean
}; }
obsRegions: {
show: boolean
}
filters: { filters: {
currentUser: boolean; currentUser: boolean
dateMode: "none" | "range" | "threshold"; dateMode: 'none' | 'range' | 'threshold'
startDate?: null | string; startDate?: null | string
endDate?: null | string; endDate?: null | string
thresholdAfter?: null | boolean; thresholdAfter?: null | boolean
}; }
}; }
export const initialState: MapConfig = { export const initialState: MapConfig = {
baseMap: { baseMap: {
style: "positron", style: 'positron',
}, },
obsRoads: { obsRoads: {
show: true, show: true,
showUntagged: true, showUntagged: true,
attribute: "distance_overtaker_median", attribute: 'distance_overtaker_median',
maxCount: 20, maxCount: 20,
}, },
obsEvents: { obsEvents: {
show: false, show: false,
}, },
obsRegions: {
show: true,
},
filters: { filters: {
currentUser: false, currentUser: false,
dateMode: "none", dateMode: 'none',
startDate: null, startDate: null,
endDate: null, endDate: null,
thresholdAfter: true, thresholdAfter: true,
}, },
}; }
type MapConfigAction = { type MapConfigAction = {
type: "MAP_CONFIG.SET_FLAG"; type: 'MAP_CONFIG.SET_FLAG'
payload: { flag: string; value: any }; payload: {flag: string; value: any}
}; }
export function setMapConfigFlag( export function setMapConfigFlag(flag: string, value: unknown): MapConfigAction {
flag: string, return {type: 'MAP_CONFIG.SET_FLAG', payload: {flag, value}}
value: unknown
): MapConfigAction {
return { type: "MAP_CONFIG.SET_FLAG", payload: { flag, value } };
} }
export function useMapConfig() { export function useMapConfig() {
const mapConfig = useSelector((state) => state.mapConfig); const mapConfig = useSelector((state) => state.mapConfig)
const result = useMemo( const result = useMemo(() => _.merge({}, initialState, mapConfig), [mapConfig])
() => _.merge({}, initialState, mapConfig), return result
[mapConfig]
);
return result;
} }
export default function mapConfigReducer( export default function mapConfigReducer(state: MapConfig = initialState, action: MapConfigAction) {
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)
}); })
default: default:
return state; return state
} }
} }