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,
baseMapStyle,
hasToolbar,
onViewportChange,
...props
}: {
viewportFromUrl?: boolean;
children: React.ReactNode;
boundsFromJson: GeoJSON.Geometry;
baseMapStyle: string;
viewportFromUrl?: boolean
children: React.ReactNode
boundsFromJson: GeoJSON.Geometry
baseMapStyle: string
hasToolbar?: boolean;
onViewportChange: (viewport: Viewport) => void,
}) {
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT);
const [viewportUrl, setViewportUrl] = useViewportFromUrl();
const [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,22 +1,12 @@
import React, { useState, useCallback } from "react";
import _ from "lodash";
import {
Segment,
Menu,
Header,
Label,
Icon,
Table,
Message,
Button,
} from "semantic-ui-react";
import { Layer, Source } from "react-map-gl";
import { of, from, concat } from "rxjs";
import { useObservable } from "rxjs-hooks";
import { switchMap, distinctUntilChanged } from "rxjs/operators";
import { Chart } from "components";
import { pairwise } from "utils";
import { useTranslation } from "react-i18next";
import React, {useState, useCallback} from 'react'
import _ from 'lodash'
import {Segment, Menu, Header, Label, Icon, Table, Message, Button} from 'semantic-ui-react'
import {Layer, Source} from 'react-map-gl'
import {of, from, concat} from 'rxjs'
import {useObservable} from 'rxjs-hooks'
import {switchMap, distinctUntilChanged} from 'rxjs/operators'
import {Chart} from 'components'
import {pairwise} from 'utils'
import type { Location } from "types";
import api from "api";
@ -224,42 +214,42 @@ export default function RoadInfo({
</Message>
)}
{info?.road.zone && (
<Label size="small" color={ZONE_COLORS[info?.road.zone]}>
{info.road.zone && (
<Label size="small" color={ZONE_COLORS[info.road.zone]}>
{t(`general.zone.${info.road.zone}`)}
</Label>
)}
{info?.road.oneway && (
{info.road.oneway && (
<Label size="small" color="blue">
<Icon name="long arrow alternate right" fitted />{" "}
{t("MapPage.roadInfo.oneway")}
</Label>
)}
{info?.road.oneway ? null : (
<Menu size="tiny" fluid secondary>
{info.road.oneway ? null : (
<Menu size="tiny" pointing>
<Menu.Item header>{t("MapPage.roadInfo.direction")}</Menu.Item>
<Menu.Item
name="forwards"
active={direction === "forwards"}
onClick={onClickDirection}
>
{getCardinalDirection(t, info?.forwards?.bearing)}
{getCardinalDirection(t, info.forwards?.bearing)}
</Menu.Item>
<Menu.Item
name="backwards"
active={direction === "backwards"}
onClick={onClickDirection}
>
{getCardinalDirection(t, info?.backwards?.bearing)}
{getCardinalDirection(t, 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">
{t("MapPage.roadInfo.overtakerDistanceDistribution")}
@ -274,7 +264,7 @@ export default function RoadInfo({
return (
<>
{info?.road && (
{info.road && (
<Source id="highlight" type="geojson" data={info.road.geometry}>
<Layer
id="route"
@ -307,10 +297,11 @@ export default function RoadInfo({
</Source>
)}
{content && (
{content && mapInfoPortal && (
createPortal(
<div className={styles.mapInfoBox}>
<Segment loading={loading}>{content}</Segment>
</div>
{content}
</div>, mapInfoPortal))}
)}
</>
);

View file

@ -1,17 +1,20 @@
import React, { useState, useCallback, useMemo } from "react";
import _ from "lodash";
import React, {useState, useCallback, useMemo, useRef} from 'react'
import _ from 'lodash'
import { connect } from "react-redux";
import { Button } from "semantic-ui-react";
import { Layer, Source } from "react-map-gl";
import produce from "immer";
import {Button} from 'semantic-ui-react'
import {Layer, Source} from 'react-map-gl'
import produce from 'immer'
import classNames from "classnames";
import {Page, Map} from 'components'
import {useConfig} from 'config'
import {colorByDistance, borderByZone, colorByCount, reds, isValidAttribute} from 'mapstyles'
import {useMapConfig} from 'reducers/mapConfig'
import api from "api";
import { Page, Map } from "components";
import { useConfig } from "config";
import { colorByDistance, borderByZone, colorByCount, reds, isValidAttribute } from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo from "./RoadInfo";
import RegionInfo from "./RegionInfo";
import LayerSidebar from "./LayerSidebar";
import styles from "./styles.module.less";
@ -27,13 +30,13 @@ const untaggedRoadsLayer = {
"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"],
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 }) {
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 viewportRef = useRef();
const mapInfoPortal = useRef();
const onViewportChange = useCallback(
(viewport) => {
viewportRef.current = viewport;
},
[viewportRef]
);
const onClick = useCallback(
(e) => {
async (e) => {
// check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target;
while (node) {
if (
@ -125,8 +184,22 @@ function MapPage({ login }) {
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]
);
const onCloseRoadInfo = useCallback(() => {
@ -157,8 +230,8 @@ function MapPage({ login }) {
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(), []);
@ -226,9 +299,13 @@ function MapPage({ login }) {
</div>
)}
<div className={styles.map}>
<Map viewportFromUrl onClick={onClick} hasToolbar>
<div className={styles.mapToolbar}>
<Map viewportFromUrl onClick={onClick} onViewportChange={onViewportChange} hasToolbar>
<Button
style={{
position: 'absolute',
left: 44,
top: 9,
}}
primary
icon="bars"
active={layerSidebar}
@ -240,10 +317,20 @@ function MapPage({ login }) {
<Layer key={layer.id} {...layer} />
))}
</Source>
{details?.type === "road" && details?.road?.road && (
<RoadInfo
{...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }}
/>
)}
{details?.type === "region" && details?.region && (
<RegionInfo
region={details.region}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
/>
)}
</Map>
</div>
</div>

View file

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