wip
This commit is contained in:
parent
9e9a80a0be
commit
2ecd94baba
|
@ -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(() => {
|
||||||
|
|
33
frontend/src/pages/MapPage/RegionInfo.tsx
Normal file
33
frontend/src/pages/MapPage/RegionInfo.tsx
Normal 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;
|
||||||
|
}
|
|
@ -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))}
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue