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,
boundsFromJson,
baseMapStyle,
onViewportChange,
...props
}: {
viewportFromUrl?: boolean
children: React.ReactNode
boundsFromJson: GeoJSON.Geometry
baseMapStyle: string
onViewportChange: (viewport: Viewport) => void,
}) {
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()
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 {createPortal} from 'react-dom'
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 {of, from, concat} from 'rxjs'
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 onClickDirection = useCallback(
@ -122,72 +123,42 @@ export default function RoadInfo({clickLocation}) {
[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]
)
const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1 // TODO: change based on left-hand/right-hand traffic
if (!clickLocation) {
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.'
) : (
const content = (
<>
<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 && (
<Label size="small" color={ZONE_COLORS[info?.road.zone]}>
{info?.road.zone}
{info.road.zone && (
<Label size="small" color={ZONE_COLORS[info.road.zone]}>
{info.road.zone}
</Label>
)}
{info?.road.oneway && (
{info.road.oneway && (
<Label size="small" color="blue">
<Icon name="long arrow alternate right" fitted /> oneway
</Label>
)}
{info?.road.oneway ? null : (
<Menu size="tiny" fluid secondary>
{info.road.oneway ? null : (
<Menu size="tiny" pointing>
<Menu.Item header>Direction</Menu.Item>
<Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
{getCardinalDirection(info?.forwards?.bearing)}
{getCardinalDirection(info.forwards?.bearing)}
</Menu.Item>
<Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
{getCardinalDirection(info?.backwards?.bearing)}
{getCardinalDirection(info.backwards?.bearing)}
</Menu.Item>
</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>
<HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
@ -198,7 +169,7 @@ export default function RoadInfo({clickLocation}) {
return (
<>
{info?.road && (
{info.road && (
<Source id="highlight" type="geojson" data={info.road.geometry}>
<Layer
id="route"
@ -223,10 +194,11 @@ export default function RoadInfo({clickLocation}) {
</Source>
)}
{content && (
{content && mapInfoPortal && (
createPortal(
<div className={styles.mapInfoBox}>
<Segment loading={loading}>{content}</Segment>
</div>
{content}
</div>, mapInfoPortal))}
)}
</>
)

View file

@ -1,171 +1,255 @@
import React, {useState, useCallback, useMemo} from 'react'
import _ from 'lodash'
import {Button} from 'semantic-ui-react'
import {Layer, Source} from 'react-map-gl'
import produce from 'immer'
import React, { useState, useCallback, useMemo, useRef } from "react";
import _ from "lodash";
import { Button } from "semantic-ui-react";
import { Layer, Source } from "react-map-gl";
import produce from "immer";
import {Page, Map} from 'components'
import {useConfig} from 'config'
import {colorByDistance, colorByCount, getRegionLayers} from 'mapstyles'
import {useMapConfig} from 'reducers/mapConfig'
import api from "api";
import { Page, Map } from "components";
import { useConfig } from "config";
import { colorByDistance, colorByCount, getRegionLayers } from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo from './RoadInfo'
import LayerSidebar from './LayerSidebar'
import styles from './styles.module.less'
import RoadInfo 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',
id: "obs_roads_untagged",
type: "line",
source: "obs",
"source-layer": "obs_roads",
minzoom: 12,
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
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-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-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 getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => {
draft.id = 'obs_roads_normal'
if (colorAttribute.endsWith('_count')) {
draft.id = "obs_roads_normal";
if (colorAttribute.endsWith("_count")) {
// delete draft.filter
draft.filter = ['to-boolean', ['get', colorAttribute]]
draft.filter = ["to-boolean", ["get", colorAttribute]];
} else {
draft.filter = draft.filter[1] // remove '!'
draft.filter = draft.filter[1]; // remove '!'
}
draft.minzoom = 10
draft.paint['line-width'][6] = 6 // scale bigger on zoom
draft.paint['line-color'] = colorAttribute.startsWith('distance_')
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)
: '#DDD'
: "#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],
},
})
});
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() {
const {obsMapSource} = useConfig() || {}
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
const { obsMapSource } = useConfig() || {};
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(
(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 (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;
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);
}
},
[setDetails]
);
const [layerSidebar, setLayerSidebar] = useState(true)
const [layerSidebar, setLayerSidebar] = useState(true);
const {
obsRoads: {attribute, maxCount},
} = mapConfig
obsRoads: { attribute, maxCount },
} = mapConfig;
const layers = []
const layers = [];
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) {
layers.push(roadsLayer)
layers.push(roadsLayer);
}
const regionLayers = useMemo(() => getRegionLayers(), [])
layers.push(...regionLayers)
const regionLayers = useMemo(() => getRegionLayers(), []);
layers.push(...regionLayers);
const eventsLayer = useMemo(() => getEventsLayer(), [])
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
const eventsLayer = useMemo(() => getEventsLayer(), []);
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
if (mapConfig.obsEvents.show) {
layers.push(eventsLayer)
layers.push(eventsTextLayer)
layers.push(eventsLayer);
layers.push(eventsTextLayer);
}
if (!obsMapSource) {
return null
return null;
}
return (
<Page fullScreen title="Map">
<div className={styles.mapContainer}>
<div className={styles.mapContainer} ref={mapInfoPortal}>
{layerSidebar && (
<div className={styles.mapSidebar}>
<LayerSidebar />
</div>
)}
<div className={styles.map}>
<Map viewportFromUrl onClick={onClick}>
<Map
viewportFromUrl
onClick={onClick}
onViewportChange={onViewportChange}
>
<Button
style={{
position: 'absolute',
position: "absolute",
left: 44,
top: 9,
}}
@ -180,10 +264,24 @@ export default function MapPage() {
))}
</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>
</div>
</div>
</Page>
)
);
}

View file

@ -20,11 +20,38 @@
}
.mapInfoBox {
position: absolute;
right: 0;
top: 0;
max-height: 100%;
width: 36rem;
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;
}
}