This commit is contained in:
Paul Bienkowski 2022-04-30 18:56:21 +02:00 committed by gluap
parent 9e9a80a0be
commit 2ecd94baba
No known key found for this signature in database
5 changed files with 209 additions and 62 deletions

View file

@ -78,20 +78,24 @@ 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((viewport: Viewport) => {
: [viewportState, setViewportState]; 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,22 +1,12 @@
import React, { useState, useCallback } from "react"; import React, {useState, useCallback} from 'react'
import _ from "lodash"; import _ from 'lodash'
import { import {Segment, Menu, Header, Label, Icon, Table, Message, Button} from 'semantic-ui-react'
Segment, import {Layer, Source} from 'react-map-gl'
Menu, import {of, from, concat} from 'rxjs'
Header, import {useObservable} from 'rxjs-hooks'
Label, import {switchMap, distinctUntilChanged} from 'rxjs/operators'
Icon, import {Chart} from 'components'
Table, import {pairwise} from 'utils'
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 type { Location } from "types";
import api from "api"; import api from "api";
@ -224,42 +214,42 @@ export default function RoadInfo({
</Message> </Message>
)} )}
{info?.road.zone && ( {info.road.zone && (
<Label size="small" color={ZONE_COLORS[info?.road.zone]}> <Label size="small" color={ZONE_COLORS[info.road.zone]}>
{t(`general.zone.${info.road.zone}`)} {t(`general.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 />{" "} <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" name="forwards"
active={direction === "forwards"} active={direction === "forwards"}
onClick={onClickDirection} onClick={onClickDirection}
> >
{getCardinalDirection(t, info?.forwards?.bearing)} {getCardinalDirection(t, info.forwards?.bearing)}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
name="backwards" name="backwards"
active={direction === "backwards"} active={direction === "backwards"}
onClick={onClickDirection} onClick={onClickDirection}
> >
{getCardinalDirection(t, info?.backwards?.bearing)} {getCardinalDirection(t, 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"> <Header as="h5">
{t("MapPage.roadInfo.overtakerDistanceDistribution")} {t("MapPage.roadInfo.overtakerDistanceDistribution")}
@ -274,7 +264,7 @@ export default function RoadInfo({
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"
@ -307,10 +297,11 @@ export default function RoadInfo({
</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,17 +1,20 @@
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 { Layer, Source } from "react-map-gl"; import {Button} from 'semantic-ui-react'
import produce from "immer"; import {Layer, Source} from 'react-map-gl'
import produce from 'immer'
import classNames from "classnames"; import classNames from "classnames";
import {Page, Map} from 'components' import api from "api";
import {useConfig} from 'config' import { Page, Map } from "components";
import {colorByDistance, borderByZone, colorByCount, reds, isValidAttribute} from 'mapstyles' import { useConfig } from "config";
import {useMapConfig} from 'reducers/mapConfig' import { colorByDistance, borderByZone, colorByCount, reds, isValidAttribute } from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo from "./RoadInfo"; import RoadInfo from "./RoadInfo";
import RegionInfo from "./RegionInfo";
import LayerSidebar from "./LayerSidebar"; import LayerSidebar from "./LayerSidebar";
import styles from "./styles.module.less"; import styles from "./styles.module.less";
@ -27,13 +30,13 @@ const untaggedRoadsLayer = {
"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,
@ -105,14 +108,70 @@ const getEventsTextLayer = () => ({
}, },
}); });
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 };
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 onCloseDetails = useCallback(() => setDetails(null), [setDetails]);
const mapConfig = useMapConfig(); 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) => {
// check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target; let node = e.target;
while (node) { while (node) {
if ( if (
@ -125,8 +184,22 @@ function MapPage({ login }) {
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] [setClickLocation]
); );
const onCloseRoadInfo = useCallback(() => { const onCloseRoadInfo = useCallback(() => {
@ -157,8 +230,8 @@ function MapPage({ login }) {
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(), []);
@ -226,9 +299,13 @@ function MapPage({ login }) {
</div> </div>
)} )}
<div className={styles.map}> <div className={styles.map}>
<Map viewportFromUrl onClick={onClick} hasToolbar> <Map viewportFromUrl onClick={onClick} onViewportChange={onViewportChange} hasToolbar>
<div className={styles.mapToolbar}>
<Button <Button
style={{
position: 'absolute',
left: 44,
top: 9,
}}
primary primary
icon="bars" icon="bars"
active={layerSidebar} active={layerSidebar}
@ -240,10 +317,20 @@ function MapPage({ login }) {
<Layer key={layer.id} {...layer} /> <Layer key={layer.id} {...layer} />
))} ))}
</Source> </Source>
{details?.type === "road" && details?.road?.road && (
<RoadInfo <RoadInfo
{...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }} {...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }}
/> />
)}
{details?.type === "region" && details?.region && (
<RegionInfo
region={details.region}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
/>
)}
</Map> </Map>
</div> </div>
</div> </div>

View file

@ -36,4 +36,36 @@
position: absolute; position: absolute;
left: 16px; left: 16px;
top: 16px; top: 16px;
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;
}
} }