This commit is contained in:
Paul Bienkowski 2022-04-30 18:56:21 +02:00
parent f429ed32f3
commit 7ad3896cdd
5 changed files with 289 additions and 153 deletions

View file

@ -56,17 +56,23 @@ function Map({
children, children,
boundsFromJson, boundsFromJson,
baseMapStyle, baseMapStyle,
onViewportChange,
...props ...props
}: { }: {
viewportFromUrl?: boolean viewportFromUrl?: boolean
children: React.ReactNode children: React.ReactNode
boundsFromJson: GeoJSON.Geometry boundsFromJson: GeoJSON.Geometry
baseMapStyle: string baseMapStyle: string
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 ? [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(() => { useEffect(() => {

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,6 +1,7 @@
import React, {useState, useCallback} from 'react' import React, {useState, useCallback} from 'react'
import {createPortal} from 'react-dom'
import _ from 'lodash' import _ from 'lodash'
import {Segment, Menu, Header, Label, Icon, Table} from 'semantic-ui-react' import {Menu, Header, Label, Icon, Table, Button} from 'semantic-ui-react'
import {Layer, Source} from 'react-map-gl' import {Layer, Source} from 'react-map-gl'
import {of, from, concat} from 'rxjs' import {of, from, concat} from 'rxjs'
import {useObservable} from 'rxjs-hooks' import {useObservable} from 'rxjs-hooks'
@ -110,7 +111,7 @@ function HistogramChart({bins, counts}) {
) )
} }
export default function RoadInfo({clickLocation}) { export default function RoadInfo({roadInfo: info, mapInfoPortal, onClose}) {
const [direction, setDirection] = useState('forwards') const [direction, setDirection] = useState('forwards')
const onClickDirection = useCallback( const onClickDirection = useCallback(
@ -122,72 +123,42 @@ export default function RoadInfo({clickLocation}) {
[setDirection] [setDirection]
) )
const info = useObservable( const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1 // TODO: change based on left-hand/right-hand traffic
(_$, 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) { const content = (
return null
}
const loading = info == null
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">{loading ? '...' : info?.road.name || 'Unnamed way'}</Header> <div className={styles.closeHeader}>
<Header as="h3">{info.road.name || 'Unnamed way'}</Header>
<Button primary icon onClick={onClose}><Icon name='close' /></Button>
</div>
{info?.road.zone && ( {info.road.zone && (
<Label size="small" color={ZONE_COLORS[info?.road.zone]}> <Label size="small" color={ZONE_COLORS[info.road.zone]}>
{info?.road.zone} {info.road.zone}
</Label> </Label>
)} )}
{info?.road.oneway && ( {info.road.oneway && (
<Label size="small" color="blue"> <Label size="small" color="blue">
<Icon name="long arrow alternate right" fitted /> oneway <Icon name="long arrow alternate right" fitted /> oneway
</Label> </Label>
)} )}
{info?.road.oneway ? null : ( {info.road.oneway ? null : (
<Menu size="tiny" fluid secondary> <Menu size="tiny" pointing>
<Menu.Item header>Direction</Menu.Item> <Menu.Item header>Direction</Menu.Item>
<Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}> <Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
{getCardinalDirection(info?.forwards?.bearing)} {getCardinalDirection(info.forwards?.bearing)}
</Menu.Item> </Menu.Item>
<Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}> <Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
{getCardinalDirection(info?.backwards?.bearing)} {getCardinalDirection(info.backwards?.bearing)}
</Menu.Item> </Menu.Item>
</Menu> </Menu>
)} )}
{info?.[direction] && <RoadStatsTable data={info[direction]} />} {info[direction] && <RoadStatsTable data={info[direction]} />}
{info?.[direction]?.distanceOvertaker?.histogram && ( {info[direction]?.distanceOvertaker?.histogram && (
<> <>
<Header as="h5">Overtaker distance distribution</Header> <Header as="h5">Overtaker distance distribution</Header>
<HistogramChart {...info[direction]?.distanceOvertaker?.histogram} /> <HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
@ -198,7 +169,7 @@ export default function RoadInfo({clickLocation}) {
return ( return (
<> <>
{info?.road && ( {info.road && (
<Source id="highlight" type="geojson" data={info.road.geometry}> <Source id="highlight" type="geojson" data={info.road.geometry}>
<Layer <Layer
id="route" id="route"
@ -223,10 +194,11 @@ export default function RoadInfo({clickLocation}) {
</Source> </Source>
)} )}
{content && ( {content && mapInfoPortal && (
createPortal(
<div className={styles.mapInfoBox}> <div className={styles.mapInfoBox}>
<Segment loading={loading}>{content}</Segment> {content}
</div> </div>, mapInfoPortal))}
)} )}
</> </>
) )

View file

@ -1,171 +1,255 @@
import React, {useState, useCallback, useMemo} from 'react' import React, { useState, useCallback, useMemo, useRef } from "react";
import _ from 'lodash' import _ from "lodash";
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 {Page, Map} from 'components' import api from "api";
import {useConfig} from 'config' import { Page, Map } from "components";
import {colorByDistance, colorByCount, getRegionLayers} from 'mapstyles' import { useConfig } from "config";
import {useMapConfig} from 'reducers/mapConfig' import { colorByDistance, colorByCount, getRegionLayers } from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo from './RoadInfo' import RoadInfo 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",
minzoom: 12, minzoom: 12,
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], 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 getRoadsLayer = (colorAttribute, maxCount) => const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.id = 'obs_roads_normal' draft.id = "obs_roads_normal";
if (colorAttribute.endsWith('_count')) { if (colorAttribute.endsWith("_count")) {
// delete draft.filter // delete draft.filter
draft.filter = ['to-boolean', ['get', colorAttribute]] draft.filter = ["to-boolean", ["get", colorAttribute]];
} else { } else {
draft.filter = draft.filter[1] // remove '!' draft.filter = draft.filter[1]; // remove '!'
} }
draft.minzoom = 10 draft.minzoom = 10;
draft.paint['line-width'][6] = 6 // scale bigger on zoom draft.paint["line-width"][6] = 6; // scale bigger on zoom
draft.paint['line-color'] = colorAttribute.startsWith('distance_') draft.paint["line-color"] = colorAttribute.startsWith("distance_")
? colorByDistance(colorAttribute) ? colorByDistance(colorAttribute)
: colorAttribute.endsWith('_count') : colorAttribute.endsWith("_count")
? colorByCount(colorAttribute, maxCount) ? colorByCount(colorAttribute, maxCount)
: '#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;
};
}
interface ArrayStats {
statistics: {
count: number;
mean: number;
min: number;
max: number;
median: number;
};
histogram: {
bins: number[];
counts: number[];
};
values: number[];
}
interface RoadDirectionInfo {
bearing: number;
distanceOvertaker: ArrayStats;
distanceStationary: ArrayStats;
speed: ArrayStats;
}
interface RoadInfo {
road: {
way_id: number;
zone: "urban" | "rural" | null;
name: string;
directionality: -1 | 0 | 1;
oneway: boolean;
geometry: Object;
};
forwards: RoadDirectionInfo;
backwards: RoadDirectionInfo;
}
type Details =
| { type: "road"; road: Object }
| { type: "region"; region: RegionInfo };
export default function MapPage() { export default function MapPage() {
const {obsMapSource} = useConfig() || {} const { obsMapSource } = useConfig() || {};
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | 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 (node?.classList?.contains(styles.mapInfoBox)) { if (node?.classList?.contains(styles.mapInfoBox)) {
return return;
} }
node = node.parentNode node = node.parentNode;
} }
setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]}) const { zoom } = viewportRef.current;
},
[setClickLocation]
)
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 { const {
obsRoads: {attribute, maxCount}, obsRoads: { attribute, maxCount },
} = mapConfig } = mapConfig;
const layers = [] const layers = [];
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) { if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayer) layers.push(untaggedRoadsLayer);
} }
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount]) const roadsLayer = useMemo(
() => getRoadsLayer(attribute, maxCount),
[attribute, maxCount]
);
if (mapConfig.obsRoads.show) { if (mapConfig.obsRoads.show) {
layers.push(roadsLayer) layers.push(roadsLayer);
} }
const regionLayers = useMemo(() => getRegionLayers(), []) const regionLayers = useMemo(() => getRegionLayers(), []);
layers.push(...regionLayers) layers.push(...regionLayers);
const eventsLayer = useMemo(() => getEventsLayer(), []) const eventsLayer = useMemo(() => getEventsLayer(), []);
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []) const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
if (mapConfig.obsEvents.show) { if (mapConfig.obsEvents.show) {
layers.push(eventsLayer) layers.push(eventsLayer);
layers.push(eventsTextLayer) layers.push(eventsTextLayer);
} }
if (!obsMapSource) { if (!obsMapSource) {
return null return null;
} }
return ( return (
<Page fullScreen title="Map"> <Page fullScreen title="Map">
<div className={styles.mapContainer}> <div className={styles.mapContainer} ref={mapInfoPortal}>
{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}> <Map
viewportFromUrl
onClick={onClick}
onViewportChange={onViewportChange}
>
<Button <Button
style={{ style={{
position: 'absolute', position: "absolute",
left: 44, left: 44,
top: 9, top: 9,
}} }}
@ -180,10 +264,24 @@ export default function MapPage() {
))} ))}
</Source> </Source>
<RoadInfo {...{clickLocation}} /> {details?.type === "road" && details?.road?.road && (
<RoadInfo
roadInfo={details.road}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
/>
)}
{details?.type === "region" && details?.region && (
<RegionInfo
region={details.region}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
/>
)}
</Map> </Map>
</div> </div>
</div> </div>
</Page> </Page>
) );
} }

View file

@ -20,11 +20,38 @@
} }
.mapInfoBox { .mapInfoBox {
position: absolute;
right: 0;
top: 0;
max-height: 100%;
width: 36rem; width: 36rem;
overflow: auto; overflow: auto;
margin: 20px; border-left: 1px solid @borderColor;
background: white;
padding: 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;
}
} }