Show regions on map page, and move on-click info panel into a proper sidebar
This commit is contained in:
parent
7ae4ebebb6
commit
518bcd81ef
|
@ -59,7 +59,7 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap, twoTicks?: boolean}) {
|
export default function ColorMapLegend({map, twoTicks = false, digits=2}: {map: ColorMap, twoTicks?: boolean, digits?: number}) {
|
||||||
const min = map[0][0]
|
const min = map[0][0]
|
||||||
const max = map[map.length - 1][0]
|
const max = map[map.length - 1][0]
|
||||||
const normalizeValue = (v) => (v - min) / (max - min)
|
const normalizeValue = (v) => (v - min) / (max - min)
|
||||||
|
@ -81,7 +81,7 @@ export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap,
|
||||||
</svg>
|
</svg>
|
||||||
{tickValues.map(([value]) => (
|
{tickValues.map(([value]) => (
|
||||||
<span className={styles.tick} key={value} style={{left: normalizeValue(value) * 100 + '%'}}>
|
<span className={styles.tick} key={value} style={{left: normalizeValue(value) * 100 + '%'}}>
|
||||||
{value.toFixed(2)}
|
{value.toFixed(digits)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,75 +1,70 @@
|
||||||
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
import React, {useState, useCallback, useMemo, useEffect} from 'react'
|
||||||
import classnames from "classnames";
|
import classnames from 'classnames'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import ReactMapGl, {
|
import ReactMapGl, {WebMercatorViewport, ScaleControl, NavigationControl, AttributionControl} from 'react-map-gl'
|
||||||
WebMercatorViewport,
|
import turfBbox from '@turf/bbox'
|
||||||
ScaleControl,
|
import {useHistory, useLocation} from 'react-router-dom'
|
||||||
NavigationControl,
|
|
||||||
AttributionControl,
|
|
||||||
} from "react-map-gl";
|
|
||||||
import turfBbox from "@turf/bbox";
|
|
||||||
import { useHistory, useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useConfig } from "config";
|
import {useConfig} from 'config'
|
||||||
|
|
||||||
import { useCallbackRef } from "../../utils";
|
import {useCallbackRef} from '../../utils'
|
||||||
import { baseMapStyles } from "../../mapstyles";
|
import {baseMapStyles} from '../../mapstyles'
|
||||||
|
|
||||||
import styles from "./styles.module.less";
|
import styles from './styles.module.less'
|
||||||
|
|
||||||
interface Viewport {
|
interface Viewport {
|
||||||
longitude: number;
|
longitude: number
|
||||||
latitude: number;
|
latitude: number
|
||||||
zoom: number;
|
zoom: number
|
||||||
}
|
}
|
||||||
const EMPTY_VIEWPORT: Viewport = { longitude: 0, latitude: 0, zoom: 0 };
|
const EMPTY_VIEWPORT: Viewport = {longitude: 0, latitude: 0, zoom: 0}
|
||||||
|
|
||||||
export const withBaseMapStyle = connect((state) => ({
|
export const withBaseMapStyle = connect((state) => ({
|
||||||
baseMapStyle: state.mapConfig?.baseMap?.style ?? "positron",
|
baseMapStyle: state.mapConfig?.baseMap?.style ?? 'positron',
|
||||||
}));
|
}))
|
||||||
|
|
||||||
function parseHash(v: string): Viewport | null {
|
function parseHash(v: string): Viewport | null {
|
||||||
if (!v) return null;
|
if (!v) return null
|
||||||
const m = v.match(/^#([0-9\.]+)\/([0-9\.\-]+)\/([0-9\.\-]+)$/);
|
const m = v.match(/^#([0-9\.]+)\/([0-9\.\-]+)\/([0-9\.\-]+)$/)
|
||||||
if (!m) return null;
|
if (!m) return null
|
||||||
return {
|
return {
|
||||||
zoom: Number.parseFloat(m[1]),
|
zoom: Number.parseFloat(m[1]),
|
||||||
latitude: Number.parseFloat(m[2]),
|
latitude: Number.parseFloat(m[2]),
|
||||||
longitude: Number.parseFloat(m[3]),
|
longitude: Number.parseFloat(m[3]),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHash(v: Viewport): string {
|
function buildHash(v: Viewport): string {
|
||||||
return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`;
|
return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const setViewportToHash = _.debounce((history, viewport) => {
|
const setViewportToHash = _.debounce((history, viewport) => {
|
||||||
history.replace({
|
history.replace({
|
||||||
hash: buildHash(viewport),
|
hash: buildHash(viewport),
|
||||||
});
|
})
|
||||||
}, 200);
|
}, 200)
|
||||||
|
|
||||||
function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] {
|
function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] {
|
||||||
const history = useHistory();
|
const history = useHistory()
|
||||||
const location = useLocation();
|
const location = useLocation()
|
||||||
|
|
||||||
const [cachedValue, setCachedValue] = useState(parseHash(location.hash));
|
const [cachedValue, setCachedValue] = useState(parseHash(location.hash))
|
||||||
|
|
||||||
// when the location hash changes, set the new value to the cache
|
// when the location hash changes, set the new value to the cache
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCachedValue(parseHash(location.hash));
|
setCachedValue(parseHash(location.hash))
|
||||||
}, [location.hash]);
|
}, [location.hash])
|
||||||
|
|
||||||
const setter = useCallback(
|
const setter = useCallback(
|
||||||
(v) => {
|
(v) => {
|
||||||
setCachedValue(v);
|
setCachedValue(v)
|
||||||
setViewportToHash(history, v);
|
setViewportToHash(history, v)
|
||||||
},
|
},
|
||||||
[history]
|
[history]
|
||||||
);
|
)
|
||||||
|
|
||||||
return [cachedValue || EMPTY_VIEWPORT, setter];
|
return [cachedValue || EMPTY_VIEWPORT, setter]
|
||||||
}
|
}
|
||||||
|
|
||||||
function Map({
|
function Map({
|
||||||
|
@ -78,57 +73,54 @@ 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(
|
||||||
: [viewportState, setViewportState];
|
(viewport: Viewport) => {
|
||||||
|
setViewport_(viewport)
|
||||||
|
onViewportChange?.(viewport)
|
||||||
|
},
|
||||||
|
[setViewport_, onViewportChange]
|
||||||
|
)
|
||||||
|
|
||||||
const config = useConfig();
|
const config = useConfig()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) {
|
||||||
config?.mapHome &&
|
setViewport(config.mapHome)
|
||||||
viewport?.latitude === 0 &&
|
|
||||||
viewport?.longitude === 0 &&
|
|
||||||
!boundsFromJson
|
|
||||||
) {
|
|
||||||
setViewport(config.mapHome);
|
|
||||||
}
|
}
|
||||||
}, [config, boundsFromJson]);
|
}, [config, boundsFromJson])
|
||||||
|
|
||||||
const mapSourceHosts = useMemo(
|
const mapSourceHosts = useMemo(
|
||||||
() =>
|
() => _.uniq(config?.obsMapSource?.tiles?.map((tileUrl: string) => new URL(tileUrl).host) ?? []),
|
||||||
_.uniq(
|
|
||||||
config?.obsMapSource?.tiles?.map(
|
|
||||||
(tileUrl: string) => new URL(tileUrl).host
|
|
||||||
) ?? []
|
|
||||||
),
|
|
||||||
[config?.obsMapSource]
|
[config?.obsMapSource]
|
||||||
);
|
)
|
||||||
|
|
||||||
const transformRequest = useCallbackRef((url, resourceType) => {
|
const transformRequest = useCallbackRef((url, resourceType) => {
|
||||||
if (resourceType === "Tile" && mapSourceHosts.includes(new URL(url).host)) {
|
if (resourceType === 'Tile' && mapSourceHosts.includes(new URL(url).host)) {
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (boundsFromJson) {
|
if (boundsFromJson) {
|
||||||
const bbox = turfBbox(boundsFromJson);
|
const bbox = turfBbox(boundsFromJson)
|
||||||
if (bbox.every((v) => Math.abs(v) !== Infinity)) {
|
if (bbox.every((v) => Math.abs(v) !== Infinity)) {
|
||||||
const [minX, minY, maxX, maxY] = bbox;
|
const [minX, minY, maxX, maxY] = bbox
|
||||||
const vp = new WebMercatorViewport({
|
const vp = new WebMercatorViewport({
|
||||||
width: 1000,
|
width: 1000,
|
||||||
height: 800,
|
height: 800,
|
||||||
|
@ -141,11 +133,11 @@ function Map({
|
||||||
padding: 20,
|
padding: 20,
|
||||||
offset: [0, -100],
|
offset: [0, -100],
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
setViewport(_.pick(vp, ["zoom", "latitude", "longitude"]));
|
setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude']))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [boundsFromJson]);
|
}, [boundsFromJson])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactMapGl
|
<ReactMapGl
|
||||||
|
@ -153,23 +145,19 @@ function Map({
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
onViewportChange={setViewport}
|
onViewportChange={setViewport}
|
||||||
{...{ transformRequest }}
|
{...{transformRequest}}
|
||||||
{...viewport}
|
{...viewport}
|
||||||
{...props}
|
{...props}
|
||||||
className={classnames(styles.map, props.className)}
|
className={classnames(styles.map, props.className)}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
<AttributionControl style={{ top: 0, right: 0 }} />
|
<AttributionControl style={{top: 0, right: 0}} />
|
||||||
<NavigationControl style={{ left: 16, top: hasToolbar ? 64 : 16 }} />
|
<NavigationControl style={{left: 16, top: hasToolbar ? 64 : 16}} />
|
||||||
<ScaleControl
|
<ScaleControl maxWidth={200} unit="metric" style={{left: 16, bottom: 16}} />
|
||||||
maxWidth={200}
|
|
||||||
unit="metric"
|
|
||||||
style={{ left: 16, bottom: 16 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</ReactMapGl>
|
</ReactMapGl>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withBaseMapStyle(Map);
|
export default withBaseMapStyle(Map)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export {default as Avatar} from './Avatar'
|
export {default as Avatar} from './Avatar'
|
||||||
|
export {default as Chart} from './Chart'
|
||||||
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend'
|
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend'
|
||||||
export {default as FileDrop} from './FileDrop'
|
export {default as FileDrop} from './FileDrop'
|
||||||
export {default as FileUploadField} from './FileUploadField'
|
export {default as FileUploadField} from './FileUploadField'
|
||||||
|
@ -6,7 +7,7 @@ export {default as FormattedDate} from './FormattedDate'
|
||||||
export {default as LoginButton} from './LoginButton'
|
export {default as LoginButton} from './LoginButton'
|
||||||
export {default as Map} from './Map'
|
export {default as Map} from './Map'
|
||||||
export {default as Page} from './Page'
|
export {default as Page} from './Page'
|
||||||
|
export {default as RegionStats} from './RegionStats'
|
||||||
export {default as Stats} from './Stats'
|
export {default as Stats} from './Stats'
|
||||||
export {default as StripMarkdown} from './StripMarkdown'
|
export {default as StripMarkdown} from './StripMarkdown'
|
||||||
export {default as Chart} from './Chart'
|
|
||||||
export {default as Visibility} from './Visibility'
|
export {default as Visibility} from './Visibility'
|
||||||
|
|
|
@ -124,6 +124,60 @@ export const trackLayer = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getRegionLayers = (adminLevel = 6, baseColor = "#00897B", maxValue = 5000) => [{
|
||||||
|
id: 'region',
|
||||||
|
"type": "fill",
|
||||||
|
"source": "obs",
|
||||||
|
"source-layer": "obs_regions",
|
||||||
|
"minzoom": 0,
|
||||||
|
"maxzoom": 10,
|
||||||
|
"filter": [
|
||||||
|
"all",
|
||||||
|
["==", "admin_level", adminLevel],
|
||||||
|
[">", "overtaking_event_count", 0],
|
||||||
|
],
|
||||||
|
"paint": {
|
||||||
|
"fill-color": baseColor,
|
||||||
|
"fill-antialias": true,
|
||||||
|
"fill-opacity": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
[
|
||||||
|
"log10",
|
||||||
|
[
|
||||||
|
"get",
|
||||||
|
"overtaking_event_count"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Math.log10(maxValue),
|
||||||
|
0.9
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'region-border',
|
||||||
|
"type": "line",
|
||||||
|
"source": "obs",
|
||||||
|
"source-layer": "obs_regions",
|
||||||
|
"minzoom": 0,
|
||||||
|
"maxzoom": 10,
|
||||||
|
"filter": [
|
||||||
|
"all",
|
||||||
|
["==", "admin_level", adminLevel],
|
||||||
|
[">", "overtaking_event_count", 0],
|
||||||
|
],
|
||||||
|
"paint": {
|
||||||
|
"line-width": 1,
|
||||||
|
"line-color": baseColor,
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"line-join": "round",
|
||||||
|
"line-cap": "round"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
|
||||||
export const trackLayerRaw = produce(trackLayer, draft => {
|
export const trackLayerRaw = produce(trackLayer, draft => {
|
||||||
// draft.paint['line-color'] = '#81D4FA'
|
// draft.paint['line-color'] = '#81D4FA'
|
||||||
draft.paint['line-width'][4] = 1
|
draft.paint['line-width'][4] = 1
|
||||||
|
|
|
@ -1,69 +1,56 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import {
|
import {List, Select, Input, Divider, Label, Checkbox, Header} from 'semantic-ui-react'
|
||||||
List,
|
import {useTranslation} from 'react-i18next'
|
||||||
Select,
|
|
||||||
Input,
|
|
||||||
Divider,
|
|
||||||
Label,
|
|
||||||
Checkbox,
|
|
||||||
Header,
|
|
||||||
} from "semantic-ui-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MapConfig,
|
MapConfig,
|
||||||
setMapConfigFlag as setMapConfigFlagAction,
|
setMapConfigFlag as setMapConfigFlagAction,
|
||||||
initialState as defaultMapConfig,
|
initialState as defaultMapConfig,
|
||||||
} from "reducers/mapConfig";
|
} from 'reducers/mapConfig'
|
||||||
import { colorByDistance, colorByCount, viridisSimpleHtml } from "mapstyles";
|
import {colorByDistance, colorByCount, viridisSimpleHtml} from 'mapstyles'
|
||||||
import { ColorMapLegend, DiscreteColorMapLegend } from "components";
|
import {ColorMapLegend, DiscreteColorMapLegend} from 'components'
|
||||||
|
|
||||||
const BASEMAP_STYLE_OPTIONS = ["positron", "bright"];
|
const BASEMAP_STYLE_OPTIONS = ['positron', 'bright']
|
||||||
|
|
||||||
const ROAD_ATTRIBUTE_OPTIONS = [
|
const ROAD_ATTRIBUTE_OPTIONS = [
|
||||||
"distance_overtaker_mean",
|
'distance_overtaker_mean',
|
||||||
"distance_overtaker_min",
|
'distance_overtaker_min',
|
||||||
"distance_overtaker_max",
|
'distance_overtaker_max',
|
||||||
"distance_overtaker_median",
|
'distance_overtaker_median',
|
||||||
"overtaking_event_count",
|
'overtaking_event_count',
|
||||||
"usage_count",
|
'usage_count',
|
||||||
"zone",
|
'zone',
|
||||||
];
|
]
|
||||||
|
|
||||||
const DATE_FILTER_MODES = ["none", "range", "threshold"];
|
const DATE_FILTER_MODES = ['none', 'range', 'threshold']
|
||||||
|
|
||||||
type User = Object;
|
type User = Object
|
||||||
|
|
||||||
function LayerSidebar({
|
function LayerSidebar({
|
||||||
mapConfig,
|
mapConfig,
|
||||||
login,
|
login,
|
||||||
setMapConfigFlag,
|
setMapConfigFlag,
|
||||||
}: {
|
}: {
|
||||||
login: User | null;
|
login: User | null
|
||||||
mapConfig: MapConfig;
|
mapConfig: MapConfig
|
||||||
setMapConfigFlag: (flag: string, value: unknown) => void;
|
setMapConfigFlag: (flag: string, value: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const {
|
const {
|
||||||
baseMap: { style },
|
baseMap: {style},
|
||||||
obsRoads: { show: showRoads, showUntagged, attribute, maxCount },
|
obsRoads: {show: showRoads, showUntagged, attribute, maxCount},
|
||||||
obsEvents: { show: showEvents },
|
obsEvents: {show: showEvents},
|
||||||
filters: {
|
obsRegions: {show: showRegions},
|
||||||
currentUser: filtersCurrentUser,
|
filters: {currentUser: filtersCurrentUser, dateMode, startDate, endDate, thresholdAfter},
|
||||||
dateMode,
|
} = mapConfig
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
thresholdAfter,
|
|
||||||
},
|
|
||||||
} = mapConfig;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<List relaxed>
|
<List relaxed>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{t("MapPage.sidebar.baseMap.style.label")}</List.Header>
|
<List.Header>{t('MapPage.sidebar.baseMap.style.label')}</List.Header>
|
||||||
<Select
|
<Select
|
||||||
options={BASEMAP_STYLE_OPTIONS.map((value) => ({
|
options={BASEMAP_STYLE_OPTIONS.map((value) => ({
|
||||||
value,
|
value,
|
||||||
|
@ -71,23 +58,50 @@ function LayerSidebar({
|
||||||
text: t(`MapPage.sidebar.baseMap.style.${value}`),
|
text: t(`MapPage.sidebar.baseMap.style.${value}`),
|
||||||
}))}
|
}))}
|
||||||
value={style}
|
value={style}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('baseMap.style', value)}
|
||||||
setMapConfigFlag("baseMap.style", value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<List.Item>
|
||||||
|
<Checkbox
|
||||||
|
toggle
|
||||||
|
size="small"
|
||||||
|
id="obsRegions.show"
|
||||||
|
style={{float: 'right'}}
|
||||||
|
checked={showRegions}
|
||||||
|
onChange={() => setMapConfigFlag('obsRegions.show', !showRegions)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="obsRegions.show">
|
||||||
|
<Header as="h4">Regions</Header>
|
||||||
|
</label>
|
||||||
|
</List.Item>
|
||||||
|
{showRegions && (
|
||||||
|
<>
|
||||||
|
<List.Item>Color regions based on event count</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
<ColorMapLegend
|
||||||
|
twoTicks
|
||||||
|
map={[
|
||||||
|
[0, '#00897B00'],
|
||||||
|
[5000, '#00897BFF'],
|
||||||
|
]}
|
||||||
|
digits={0}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Divider />
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
toggle
|
toggle
|
||||||
size="small"
|
size="small"
|
||||||
id="obsRoads.show"
|
id="obsRoads.show"
|
||||||
style={{ float: "right" }}
|
style={{float: 'right'}}
|
||||||
checked={showRoads}
|
checked={showRoads}
|
||||||
onChange={() => setMapConfigFlag("obsRoads.show", !showRoads)}
|
onChange={() => setMapConfigFlag('obsRoads.show', !showRoads)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="obsRoads.show">
|
<label htmlFor="obsRoads.show">
|
||||||
<Header as="h4">{t("MapPage.sidebar.obsRoads.title")}</Header>
|
<Header as="h4">{t('MapPage.sidebar.obsRoads.title')}</Header>
|
||||||
</label>
|
</label>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{showRoads && (
|
{showRoads && (
|
||||||
|
@ -95,16 +109,12 @@ function LayerSidebar({
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={showUntagged}
|
checked={showUntagged}
|
||||||
onChange={() =>
|
onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
|
||||||
setMapConfigFlag("obsRoads.showUntagged", !showUntagged)
|
label={t('MapPage.sidebar.obsRoads.showUntagged.label')}
|
||||||
}
|
|
||||||
label={t("MapPage.sidebar.obsRoads.showUntagged.label")}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>
|
<List.Header>{t('MapPage.sidebar.obsRoads.attribute.label')}</List.Header>
|
||||||
{t("MapPage.sidebar.obsRoads.attribute.label")}
|
|
||||||
</List.Header>
|
|
||||||
<Select
|
<Select
|
||||||
fluid
|
fluid
|
||||||
options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({
|
options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({
|
||||||
|
@ -113,74 +123,50 @@ function LayerSidebar({
|
||||||
text: t(`MapPage.sidebar.obsRoads.attribute.${value}`),
|
text: t(`MapPage.sidebar.obsRoads.attribute.${value}`),
|
||||||
}))}
|
}))}
|
||||||
value={attribute}
|
value={attribute}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)}
|
||||||
setMapConfigFlag("obsRoads.attribute", value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{attribute.endsWith("_count") ? (
|
{attribute.endsWith('_count') ? (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>
|
<List.Header>{t('MapPage.sidebar.obsRoads.maxCount.label')}</List.Header>
|
||||||
{t("MapPage.sidebar.obsRoads.maxCount.label")}
|
|
||||||
</List.Header>
|
|
||||||
<Input
|
<Input
|
||||||
fluid
|
fluid
|
||||||
type="number"
|
type="number"
|
||||||
value={maxCount}
|
value={maxCount}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)}
|
||||||
setMapConfigFlag("obsRoads.maxCount", value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<ColorMapLegend
|
<ColorMapLegend
|
||||||
map={_.chunk(
|
map={_.chunk(
|
||||||
colorByCount(
|
colorByCount('obsRoads.maxCount', mapConfig.obsRoads.maxCount, viridisSimpleHtml).slice(3),
|
||||||
"obsRoads.maxCount",
|
|
||||||
mapConfig.obsRoads.maxCount,
|
|
||||||
viridisSimpleHtml
|
|
||||||
).slice(3),
|
|
||||||
2
|
2
|
||||||
)}
|
)}
|
||||||
twoTicks
|
twoTicks
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
) : attribute.endsWith("zone") ? (
|
) : attribute.endsWith('zone') ? (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Label
|
<Label size="small" style={{background: 'blue', color: 'white'}}>
|
||||||
size="small"
|
{t('general.zone.urban')} (1.5 m)
|
||||||
style={{ background: "blue", color: "white" }}
|
|
||||||
>
|
|
||||||
{t("general.zone.urban")} (1.5 m)
|
|
||||||
</Label>
|
</Label>
|
||||||
<Label
|
<Label size="small" style={{background: 'cyan', color: 'black'}}>
|
||||||
size="small"
|
{t('general.zone.rural')}(2 m)
|
||||||
style={{ background: "cyan", color: "black" }}
|
|
||||||
>
|
|
||||||
{t("general.zone.rural")}(2 m)
|
|
||||||
</Label>
|
</Label>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>
|
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
|
||||||
{_.upperFirst(t("general.zone.urban"))}
|
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
|
||||||
</List.Header>
|
|
||||||
<DiscreteColorMapLegend
|
|
||||||
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>
|
<List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
|
||||||
{_.upperFirst(t("general.zone.rural"))}
|
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
|
||||||
</List.Header>
|
|
||||||
<DiscreteColorMapLegend
|
|
||||||
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -192,40 +178,36 @@ function LayerSidebar({
|
||||||
toggle
|
toggle
|
||||||
size="small"
|
size="small"
|
||||||
id="obsEvents.show"
|
id="obsEvents.show"
|
||||||
style={{ float: "right" }}
|
style={{float: 'right'}}
|
||||||
checked={showEvents}
|
checked={showEvents}
|
||||||
onChange={() => setMapConfigFlag("obsEvents.show", !showEvents)}
|
onChange={() => setMapConfigFlag('obsEvents.show', !showEvents)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="obsEvents.show">
|
<label htmlFor="obsEvents.show">
|
||||||
<Header as="h4">{t("MapPage.sidebar.obsEvents.title")}</Header>
|
<Header as="h4">{t('MapPage.sidebar.obsEvents.title')}</Header>
|
||||||
</label>
|
</label>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{showEvents && (
|
{showEvents && (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header>
|
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
|
||||||
<DiscreteColorMapLegend
|
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
|
||||||
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header>
|
<List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
|
||||||
<DiscreteColorMapLegend
|
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
|
||||||
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Header as="h4">{t("MapPage.sidebar.filters.title")}</Header>
|
<Header as="h4">{t('MapPage.sidebar.filters.title')}</Header>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
{login && (
|
{login && (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Header as="h5">{t("MapPage.sidebar.filters.userData")}</Header>
|
<Header as="h5">{t('MapPage.sidebar.filters.userData')}</Header>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
<List.Item>
|
<List.Item>
|
||||||
|
@ -234,15 +216,13 @@ function LayerSidebar({
|
||||||
size="small"
|
size="small"
|
||||||
id="filters.currentUser"
|
id="filters.currentUser"
|
||||||
checked={filtersCurrentUser}
|
checked={filtersCurrentUser}
|
||||||
onChange={() =>
|
onChange={() => setMapConfigFlag('filters.currentUser', !filtersCurrentUser)}
|
||||||
setMapConfigFlag("filters.currentUser", !filtersCurrentUser)
|
label={t('MapPage.sidebar.filters.currentUser')}
|
||||||
}
|
|
||||||
label={t("MapPage.sidebar.filters.currentUser")}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Header as="h5">{t("MapPage.sidebar.filters.dateRange")}</Header>
|
<Header as="h5">{t('MapPage.sidebar.filters.dateRange')}</Header>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
<List.Item>
|
<List.Item>
|
||||||
|
@ -253,14 +233,12 @@ function LayerSidebar({
|
||||||
key: value,
|
key: value,
|
||||||
text: t(`MapPage.sidebar.filters.dateMode.${value}`),
|
text: t(`MapPage.sidebar.filters.dateMode.${value}`),
|
||||||
}))}
|
}))}
|
||||||
value={dateMode ?? "none"}
|
value={dateMode ?? 'none'}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('filters.dateMode', value)}
|
||||||
setMapConfigFlag("filters.dateMode", value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
{dateMode == "range" && (
|
{dateMode == 'range' && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
@ -268,16 +246,14 @@ function LayerSidebar({
|
||||||
step="7"
|
step="7"
|
||||||
size="small"
|
size="small"
|
||||||
id="filters.startDate"
|
id="filters.startDate"
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
|
||||||
setMapConfigFlag("filters.startDate", value)
|
|
||||||
}
|
|
||||||
value={startDate ?? null}
|
value={startDate ?? null}
|
||||||
label={t("MapPage.sidebar.filters.start")}
|
label={t('MapPage.sidebar.filters.start')}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dateMode == "range" && (
|
{dateMode == 'range' && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
@ -285,16 +261,14 @@ function LayerSidebar({
|
||||||
step="7"
|
step="7"
|
||||||
size="small"
|
size="small"
|
||||||
id="filters.endDate"
|
id="filters.endDate"
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('filters.endDate', value)}
|
||||||
setMapConfigFlag("filters.endDate", value)
|
|
||||||
}
|
|
||||||
value={endDate ?? null}
|
value={endDate ?? null}
|
||||||
label={t("MapPage.sidebar.filters.end")}
|
label={t('MapPage.sidebar.filters.end')}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dateMode == "threshold" && (
|
{dateMode == 'threshold' && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
@ -303,42 +277,33 @@ function LayerSidebar({
|
||||||
size="small"
|
size="small"
|
||||||
id="filters.startDate"
|
id="filters.startDate"
|
||||||
value={startDate ?? null}
|
value={startDate ?? null}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
|
||||||
setMapConfigFlag("filters.startDate", value)
|
label={t('MapPage.sidebar.filters.threshold')}
|
||||||
}
|
|
||||||
label={t("MapPage.sidebar.filters.threshold")}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dateMode == "threshold" && (
|
{dateMode == 'threshold' && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<span>
|
<span>
|
||||||
{t("MapPage.sidebar.filters.before")}{" "}
|
{t('MapPage.sidebar.filters.before')}{' '}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
toggle
|
toggle
|
||||||
size="small"
|
size="small"
|
||||||
checked={thresholdAfter ?? false}
|
checked={thresholdAfter ?? false}
|
||||||
onChange={() =>
|
onChange={() => setMapConfigFlag('filters.thresholdAfter', !thresholdAfter)}
|
||||||
setMapConfigFlag(
|
|
||||||
"filters.thresholdAfter",
|
|
||||||
!thresholdAfter
|
|
||||||
)
|
|
||||||
}
|
|
||||||
id="filters.thresholdAfter"
|
id="filters.thresholdAfter"
|
||||||
/>{" "}
|
/>{' '}
|
||||||
{t("MapPage.sidebar.filters.after")}
|
{t('MapPage.sidebar.filters.after')}
|
||||||
</span>
|
</span>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!login && (
|
{!login && <List.Item>{t('MapPage.sidebar.filters.needsLogin')}</List.Item>}
|
||||||
<List.Item>{t("MapPage.sidebar.filters.needsLogin")}</List.Item>
|
|
||||||
)}
|
|
||||||
</List>
|
</List>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default connect(
|
||||||
|
@ -351,6 +316,6 @@ export default connect(
|
||||||
),
|
),
|
||||||
login: state.login,
|
login: state.login,
|
||||||
}),
|
}),
|
||||||
{ setMapConfigFlag: setMapConfigFlagAction }
|
{setMapConfigFlag: setMapConfigFlagAction}
|
||||||
//
|
//
|
||||||
)(LayerSidebar);
|
)(LayerSidebar)
|
||||||
|
|
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,74 +1,57 @@
|
||||||
import React, { useState, useCallback } from "react";
|
import React, {useState, useCallback} from 'react'
|
||||||
import _ from "lodash";
|
import {createPortal} from 'react-dom'
|
||||||
import {
|
import _ from 'lodash'
|
||||||
Segment,
|
import {Segment, Menu, Header, Label, Icon, Table, Message, Button} from 'semantic-ui-react'
|
||||||
Menu,
|
import {Layer, Source} from 'react-map-gl'
|
||||||
Header,
|
import {of, from, concat} from 'rxjs'
|
||||||
Label,
|
import {useObservable} from 'rxjs-hooks'
|
||||||
Icon,
|
import {switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||||
Table,
|
import {Chart} from 'components'
|
||||||
Message,
|
import {pairwise} from 'utils'
|
||||||
Button,
|
import {useTranslation} from 'react-i18next'
|
||||||
} 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'
|
||||||
import { colorByDistance, borderByZone } from "mapstyles";
|
import {colorByDistance, borderByZone} from 'mapstyles'
|
||||||
|
|
||||||
import styles from "./styles.module.less";
|
import styles from './styles.module.less'
|
||||||
|
|
||||||
function selectFromColorMap(colormap, value) {
|
function selectFromColorMap(colormap, value) {
|
||||||
let last = null;
|
let last = null
|
||||||
for (let i = 0; i < colormap.length; i += 2) {
|
for (let i = 0; i < colormap.length; i += 2) {
|
||||||
if (colormap[i + 1] > value) {
|
if (colormap[i + 1] > value) {
|
||||||
return colormap[i];
|
return colormap[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return colormap[colormap.length - 1];
|
return colormap[colormap.length - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNITS = {
|
const UNITS = {
|
||||||
distanceOvertaker: "m",
|
distanceOvertaker: 'm',
|
||||||
distanceStationary: "m",
|
distanceStationary: 'm',
|
||||||
speed: "km/h",
|
speed: 'km/h',
|
||||||
};
|
}
|
||||||
const ZONE_COLORS = { urban: "blue", rural: "cyan", motorway: "purple" };
|
const ZONE_COLORS = {urban: 'blue', rural: 'cyan', motorway: 'purple'}
|
||||||
const CARDINAL_DIRECTIONS = [
|
const CARDINAL_DIRECTIONS = ['north', 'northEast', 'east', 'southEast', 'south', 'southWest', 'west', 'northWest']
|
||||||
"north",
|
|
||||||
"northEast",
|
|
||||||
"east",
|
|
||||||
"southEast",
|
|
||||||
"south",
|
|
||||||
"southWest",
|
|
||||||
"west",
|
|
||||||
"northWest",
|
|
||||||
];
|
|
||||||
const getCardinalDirection = (t, bearing) => {
|
const getCardinalDirection = (t, bearing) => {
|
||||||
if (bearing == null) {
|
if (bearing == null) {
|
||||||
return t("MapPage.roadInfo.cardinalDirections.unknown");
|
return t('MapPage.roadInfo.cardinalDirections.unknown')
|
||||||
} else {
|
} else {
|
||||||
const n = CARDINAL_DIRECTIONS.length;
|
const n = CARDINAL_DIRECTIONS.length
|
||||||
const i = Math.floor(((bearing / 360.0) * n + 0.5) % n);
|
const i = Math.floor(((bearing / 360.0) * n + 0.5) % n)
|
||||||
const name = CARDINAL_DIRECTIONS[i];
|
const name = CARDINAL_DIRECTIONS[i]
|
||||||
return t(`MapPage.roadInfo.cardinalDirections.${name}`);
|
return t(`MapPage.roadInfo.cardinalDirections.${name}`)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
function RoadStatsTable({ data }) {
|
function RoadStatsTable({data}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
return (
|
return (
|
||||||
<Table size="small" compact>
|
<Table size="small" compact>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell textAlign="right"></Table.HeaderCell>
|
<Table.HeaderCell textAlign="right"></Table.HeaderCell>
|
||||||
{["distanceOvertaker", "distanceStationary", "speed"].map((prop) => (
|
{['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
|
||||||
<Table.HeaderCell key={prop} textAlign="right">
|
<Table.HeaderCell key={prop} textAlign="right">
|
||||||
{t(`MapPage.roadInfo.${prop}`)}
|
{t(`MapPage.roadInfo.${prop}`)}
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
|
@ -76,58 +59,52 @@ function RoadStatsTable({ data }) {
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{["count", "min", "median", "max", "mean"].map((stat) => (
|
{['count', 'min', 'median', 'max', 'mean'].map((stat) => (
|
||||||
<Table.Row key={stat}>
|
<Table.Row key={stat}>
|
||||||
<Table.Cell> {t(`MapPage.roadInfo.${stat}`)}</Table.Cell>
|
<Table.Cell> {t(`MapPage.roadInfo.${stat}`)}</Table.Cell>
|
||||||
{["distanceOvertaker", "distanceStationary", "speed"].map(
|
{['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
|
||||||
(prop) => (
|
<Table.Cell key={prop} textAlign="right">
|
||||||
<Table.Cell key={prop} textAlign="right">
|
{(data[prop]?.statistics?.[stat] * (prop === `speed` && stat != 'count' ? 3.6 : 1)).toFixed(
|
||||||
{(
|
stat === 'count' ? 0 : 2
|
||||||
data[prop]?.statistics?.[stat] *
|
)}
|
||||||
(prop === `speed` && stat != "count" ? 3.6 : 1)
|
{stat !== 'count' && ` ${UNITS[prop]}`}
|
||||||
).toFixed(stat === "count" ? 0 : 2)}
|
</Table.Cell>
|
||||||
{stat !== "count" && ` ${UNITS[prop]}`}
|
))}
|
||||||
</Table.Cell>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistogramChart({ bins, counts, zone }) {
|
function HistogramChart({bins, counts, zone}) {
|
||||||
const diff = bins[1] - bins[0];
|
const diff = bins[1] - bins[0]
|
||||||
const colortype = zone === "rural" ? 3 : 5;
|
const colortype = zone === 'rural' ? 3 : 5
|
||||||
const data = _.zip(
|
const data = _.zip(
|
||||||
bins.slice(0, bins.length - 1).map((v) => v + diff / 2),
|
bins.slice(0, bins.length - 1).map((v) => v + diff / 2),
|
||||||
counts
|
counts
|
||||||
).map((value) => ({
|
).map((value) => ({
|
||||||
value,
|
value,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: selectFromColorMap(
|
color: selectFromColorMap(colorByDistance()[3][colortype].slice(2), value[0]),
|
||||||
colorByDistance()[3][colortype].slice(2),
|
|
||||||
value[0]
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}));
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Chart
|
<Chart
|
||||||
style={{ height: 240 }}
|
style={{height: 240}}
|
||||||
option={{
|
option={{
|
||||||
grid: { top: 30, bottom: 30, right: 30, left: 30 },
|
grid: {top: 30, bottom: 30, right: 30, left: 30},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "value",
|
type: 'value',
|
||||||
axisLabel: { formatter: (v) => `${Math.round(v * 100)} cm` },
|
axisLabel: {formatter: (v) => `${Math.round(v * 100)} cm`},
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 2.5,
|
max: 2.5,
|
||||||
},
|
},
|
||||||
yAxis: {},
|
yAxis: {},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: "bar",
|
type: 'bar',
|
||||||
data,
|
data,
|
||||||
|
|
||||||
barMaxWidth: 20,
|
barMaxWidth: 20,
|
||||||
|
@ -135,142 +112,120 @@ function HistogramChart({ bins, counts, zone }) {
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArrayStats {
|
||||||
|
statistics: {
|
||||||
|
count: number
|
||||||
|
mean: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
median: number
|
||||||
|
}
|
||||||
|
histogram: {
|
||||||
|
bins: number[]
|
||||||
|
counts: number[]
|
||||||
|
}
|
||||||
|
values: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoadDirectionInfo {
|
||||||
|
bearing: number
|
||||||
|
distanceOvertaker: ArrayStats
|
||||||
|
distanceStationary: ArrayStats
|
||||||
|
speed: ArrayStats
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoadInfoType {
|
||||||
|
road: {
|
||||||
|
way_id: number
|
||||||
|
zone: 'urban' | 'rural' | null
|
||||||
|
name: string
|
||||||
|
directionality: -1 | 0 | 1
|
||||||
|
oneway: boolean
|
||||||
|
geometry: Object
|
||||||
|
}
|
||||||
|
forwards: RoadDirectionInfo
|
||||||
|
backwards: RoadDirectionInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoadInfo({
|
export default function RoadInfo({
|
||||||
clickLocation,
|
roadInfo: info,
|
||||||
hasFilters,
|
hasFilters,
|
||||||
onClose,
|
onClose,
|
||||||
|
mapInfoPortal,
|
||||||
}: {
|
}: {
|
||||||
clickLocation: Location | null;
|
roadInfo: RoadInfoType
|
||||||
hasFilters: boolean;
|
hasFilters: boolean
|
||||||
onClose: () => void;
|
onClose: () => void
|
||||||
|
mapInfoPortal: HTMLElement
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const [direction, setDirection] = useState("forwards");
|
const [direction, setDirection] = useState('forwards')
|
||||||
|
|
||||||
const onClickDirection = useCallback(
|
const onClickDirection = useCallback(
|
||||||
(e, { name }) => {
|
(e, {name}) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
setDirection(name);
|
setDirection(name)
|
||||||
},
|
},
|
||||||
[setDirection]
|
[setDirection]
|
||||||
);
|
)
|
||||||
|
|
||||||
const info = useObservable(
|
// TODO: change based on left-hand/right-hand traffic
|
||||||
(_$, inputs$) =>
|
const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1
|
||||||
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;
|
<>
|
||||||
}
|
<div className={styles.closeHeader}>
|
||||||
|
<Header as="h3">{info?.road.name || t('MapPage.roadInfo.unnamedWay')}</Header>
|
||||||
|
<Button primary icon onClick={onClose}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
const loading = info == null;
|
{hasFilters && (
|
||||||
|
<Message info icon>
|
||||||
|
<Icon name="info circle" small />
|
||||||
|
<Message.Content>{t('MapPage.roadInfo.hintFiltersNotApplied')}</Message.Content>
|
||||||
|
</Message>
|
||||||
|
)}
|
||||||
|
|
||||||
const offsetDirection = info?.road?.oneway
|
{info?.road.zone && (
|
||||||
? 0
|
<Label size="small" color={ZONE_COLORS[info?.road.zone]}>
|
||||||
: direction === "forwards"
|
{t(`general.zone.${info.road.zone}`)}
|
||||||
? 1
|
</Label>
|
||||||
: -1; // TODO: change based on left-hand/right-hand traffic
|
)}
|
||||||
|
|
||||||
const content =
|
{info?.road.oneway && (
|
||||||
!loading && !info.road ? (
|
<Label size="small" color="blue">
|
||||||
"No road found."
|
<Icon name="long arrow alternate right" fitted /> {t('MapPage.roadInfo.oneway')}
|
||||||
) : (
|
</Label>
|
||||||
<>
|
)}
|
||||||
<Header as="h3">
|
|
||||||
{loading
|
|
||||||
? "..."
|
|
||||||
: info?.road.name || t("MapPage.roadInfo.unnamedWay")}
|
|
||||||
|
|
||||||
<Button
|
{info?.road.oneway ? null : (
|
||||||
style={{ float: "right" }}
|
<Menu size="tiny" pointing>
|
||||||
onClick={onClose}
|
<Menu.Item header>{t('MapPage.roadInfo.direction')}</Menu.Item>
|
||||||
title={t("MapPage.roadInfo.closeTooltip")}
|
<Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
|
||||||
size="small"
|
{getCardinalDirection(t, info?.forwards?.bearing)}
|
||||||
icon="close"
|
</Menu.Item>
|
||||||
basic
|
<Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
|
||||||
/>
|
{getCardinalDirection(t, info?.backwards?.bearing)}
|
||||||
</Header>
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasFilters && (
|
{info?.[direction] && <RoadStatsTable data={info[direction]} />}
|
||||||
<Message info icon>
|
|
||||||
<Icon name="info circle" small />
|
|
||||||
<Message.Content>
|
|
||||||
{t("MapPage.roadInfo.hintFiltersNotApplied")}
|
|
||||||
</Message.Content>
|
|
||||||
</Message>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info?.road.zone && (
|
{info?.[direction]?.distanceOvertaker?.histogram && (
|
||||||
<Label size="small" color={ZONE_COLORS[info?.road.zone]}>
|
<>
|
||||||
{t(`general.zone.${info.road.zone}`)}
|
<Header as="h5">{t('MapPage.roadInfo.overtakerDistanceDistribution')}</Header>
|
||||||
</Label>
|
<HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
{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>
|
|
||||||
<Menu.Item header>{t("MapPage.roadInfo.direction")}</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
name="forwards"
|
|
||||||
active={direction === "forwards"}
|
|
||||||
onClick={onClickDirection}
|
|
||||||
>
|
|
||||||
{getCardinalDirection(t, info?.forwards?.bearing)}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
name="backwards"
|
|
||||||
active={direction === "backwards"}
|
|
||||||
onClick={onClickDirection}
|
|
||||||
>
|
|
||||||
{getCardinalDirection(t, info?.backwards?.bearing)}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info?.[direction] && <RoadStatsTable data={info[direction]} />}
|
|
||||||
|
|
||||||
{info?.[direction]?.distanceOvertaker?.histogram && (
|
|
||||||
<>
|
|
||||||
<Header as="h5">
|
|
||||||
{t("MapPage.roadInfo.overtakerDistanceDistribution")}
|
|
||||||
</Header>
|
|
||||||
<HistogramChart
|
|
||||||
{...info[direction]?.distanceOvertaker?.histogram}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -280,22 +235,14 @@ export default function RoadInfo({
|
||||||
id="route"
|
id="route"
|
||||||
type="line"
|
type="line"
|
||||||
paint={{
|
paint={{
|
||||||
"line-width": [
|
'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12],
|
||||||
"interpolate",
|
'line-color': '#18FFFF',
|
||||||
["linear"],
|
'line-opacity': 0.5,
|
||||||
["zoom"],
|
|
||||||
14,
|
|
||||||
6,
|
|
||||||
17,
|
|
||||||
12,
|
|
||||||
],
|
|
||||||
"line-color": "#18FFFF",
|
|
||||||
"line-opacity": 0.5,
|
|
||||||
...{
|
...{
|
||||||
"line-offset": [
|
'line-offset': [
|
||||||
"interpolate",
|
'interpolate',
|
||||||
["exponential", 1.5],
|
['exponential', 1.5],
|
||||||
["zoom"],
|
['zoom'],
|
||||||
12,
|
12,
|
||||||
offsetDirection,
|
offsetDirection,
|
||||||
19,
|
19,
|
||||||
|
@ -307,11 +254,7 @@ export default function RoadInfo({
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{content && (
|
{content && mapInfoPortal && createPortal(<div className={styles.mapInfoBox}>{content}</div>, mapInfoPortal)}
|
||||||
<div className={styles.mapInfoBox}>
|
|
||||||
<Segment loading={loading}>{content}</Segment>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,241 +1,254 @@
|
||||||
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 {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 classNames from "classnames";
|
import classNames from 'classnames'
|
||||||
|
|
||||||
import type { Location } from "types";
|
import api from 'api'
|
||||||
import { Page, Map } from "components";
|
import type {Location} from 'types'
|
||||||
import { useConfig } from "config";
|
import {Page, Map} from 'components'
|
||||||
import {
|
import {useConfig} from 'config'
|
||||||
colorByDistance,
|
import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles'
|
||||||
colorByCount,
|
import {useMapConfig} from 'reducers/mapConfig'
|
||||||
borderByZone,
|
|
||||||
reds,
|
|
||||||
isValidAttribute,
|
|
||||||
} from "mapstyles";
|
|
||||||
import { useMapConfig } from "reducers/mapConfig";
|
|
||||||
|
|
||||||
import RoadInfo from "./RoadInfo";
|
import RoadInfo, {RoadInfoType} 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',
|
||||||
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
|
minzoom: 12,
|
||||||
|
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 getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
|
const getUntaggedRoadsLayer = (colorAttribute) =>
|
||||||
produce(untaggedRoadsLayer, (draft) => {
|
produce(untaggedRoadsLayer, (draft) => {
|
||||||
draft.filter = ["!", isValidAttribute(colorAttribute)];
|
draft.filter = ['!', isValidAttribute(colorAttribute)]
|
||||||
});
|
})
|
||||||
|
|
||||||
const getRoadsLayer = (colorAttribute, maxCount) =>
|
const getRoadsLayer = (colorAttribute, maxCount) =>
|
||||||
produce(untaggedRoadsLayer, (draft) => {
|
produce(untaggedRoadsLayer, (draft) => {
|
||||||
draft.id = "obs_roads_normal";
|
draft.id = 'obs_roads_normal'
|
||||||
draft.filter = isValidAttribute(colorAttribute);
|
draft.filter = isValidAttribute(colorAttribute)
|
||||||
draft.paint["line-width"][6] = 6; // scale bigger on zoom
|
draft.minzoom = 10
|
||||||
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
|
draft.paint['line-width'][6] = 6 // scale bigger on zoom
|
||||||
|
draft.paint['line-color'] = colorAttribute.startsWith('distance_')
|
||||||
? colorByDistance(colorAttribute)
|
? colorByDistance(colorAttribute)
|
||||||
: colorAttribute.endsWith("_count")
|
: colorAttribute.endsWith('_count')
|
||||||
? colorByCount(colorAttribute, maxCount)
|
? colorByCount(colorAttribute, maxCount)
|
||||||
: colorAttribute.endsWith("zone")
|
: colorAttribute.endsWith('zone')
|
||||||
? borderByZone()
|
? borderByZone()
|
||||||
: "#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],
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
function MapPage({ login }) {
|
interface RegionInfo {
|
||||||
const { obsMapSource, banner } = useConfig() || {};
|
properties: {
|
||||||
const [clickLocation, setClickLocation] = useState<Location | null>(null);
|
admin_level: number
|
||||||
|
name: string
|
||||||
|
overtaking_event_count: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mapConfig = useMapConfig();
|
type Details = {type: 'road'; road: RoadInfoType} | {type: 'region'; region: RegionInfo}
|
||||||
|
|
||||||
|
function MapPage({login}) {
|
||||||
|
const {obsMapSource, banner} = useConfig() || {}
|
||||||
|
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(
|
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 (
|
if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) {
|
||||||
[styles.mapInfoBox, styles.mapToolbar].some((className) =>
|
return
|
||||||
node?.classList?.contains(className)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
node = node.parentNode;
|
node = node.parentNode
|
||||||
}
|
}
|
||||||
|
|
||||||
setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] });
|
const {zoom} = viewportRef.current
|
||||||
},
|
|
||||||
[setClickLocation]
|
|
||||||
);
|
|
||||||
const onCloseRoadInfo = useCallback(() => {
|
|
||||||
setClickLocation(null);
|
|
||||||
}, [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 = []
|
||||||
|
|
||||||
const untaggedRoadsLayerCustom = useMemo(
|
const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
|
||||||
() => getUntaggedRoadsLayer(attribute),
|
|
||||||
[attribute]
|
|
||||||
);
|
|
||||||
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
|
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
|
||||||
layers.push(untaggedRoadsLayerCustom);
|
layers.push(untaggedRoadsLayerCustom)
|
||||||
}
|
}
|
||||||
|
|
||||||
const roadsLayer = useMemo(
|
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
|
||||||
() => getRoadsLayer(attribute, maxCount),
|
|
||||||
[attribute, maxCount]
|
|
||||||
);
|
|
||||||
if (mapConfig.obsRoads.show) {
|
if (mapConfig.obsRoads.show) {
|
||||||
layers.push(roadsLayer);
|
layers.push(roadsLayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsLayer = useMemo(() => getEventsLayer(), []);
|
const regionLayers = useMemo(() => getRegionLayers(), [])
|
||||||
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
|
if (mapConfig.obsRegions.show) {
|
||||||
|
layers.push(...regionLayers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsLayer = useMemo(() => getEventsLayer(), [])
|
||||||
|
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
|
||||||
|
|
||||||
if (mapConfig.obsEvents.show) {
|
if (mapConfig.obsEvents.show) {
|
||||||
layers.push(eventsLayer);
|
layers.push(eventsLayer)
|
||||||
layers.push(eventsTextLayer);
|
layers.push(eventsTextLayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onToggleLayerSidebarButtonClick = useCallback(
|
const onToggleLayerSidebarButtonClick = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
console.log("toggl;e");
|
console.log('toggl;e')
|
||||||
setLayerSidebar((v) => !v);
|
setLayerSidebar((v) => !v)
|
||||||
},
|
},
|
||||||
[setLayerSidebar]
|
[setLayerSidebar]
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!obsMapSource) {
|
if (!obsMapSource) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
|
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams()
|
||||||
if (login) {
|
if (login) {
|
||||||
if (mapConfig.filters.currentUser) {
|
if (mapConfig.filters.currentUser) {
|
||||||
query.append("user", login.id);
|
query.append('user', login.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapConfig.filters.dateMode === "range") {
|
if (mapConfig.filters.dateMode === 'range') {
|
||||||
if (mapConfig.filters.startDate) {
|
if (mapConfig.filters.startDate) {
|
||||||
query.append("start", mapConfig.filters.startDate);
|
query.append('start', mapConfig.filters.startDate)
|
||||||
}
|
}
|
||||||
if (mapConfig.filters.endDate) {
|
if (mapConfig.filters.endDate) {
|
||||||
query.append("end", mapConfig.filters.endDate);
|
query.append('end', mapConfig.filters.endDate)
|
||||||
}
|
}
|
||||||
} else if (mapConfig.filters.dateMode === "threshold") {
|
} else if (mapConfig.filters.dateMode === 'threshold') {
|
||||||
if (mapConfig.filters.startDate) {
|
if (mapConfig.filters.startDate) {
|
||||||
query.append(
|
query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
|
||||||
mapConfig.filters.thresholdAfter ? "start" : "end",
|
|
||||||
mapConfig.filters.startDate
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const queryString = String(query);
|
const queryString = String(query)
|
||||||
return tileUrl + (queryString ? "?" : "") + queryString;
|
return tileUrl + (queryString ? '?' : '') + queryString
|
||||||
});
|
})
|
||||||
|
|
||||||
const hasFilters: boolean =
|
const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none')
|
||||||
login &&
|
|
||||||
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page fullScreen title="Map">
|
<Page fullScreen title="Map">
|
||||||
<div
|
<div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}>
|
||||||
className={classNames(
|
|
||||||
styles.mapContainer,
|
|
||||||
banner ? styles.hasBanner : null
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{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} hasToolbar>
|
<Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}>
|
||||||
<div className={styles.mapToolbar}>
|
<div className={styles.mapToolbar}>
|
||||||
<Button
|
<Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} />
|
||||||
primary
|
|
||||||
icon="bars"
|
|
||||||
active={layerSidebar}
|
|
||||||
onClick={onToggleLayerSidebarButtonClick}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Source id="obs" {...obsMapSource} tiles={tiles}>
|
<Source id="obs" {...obsMapSource} tiles={tiles}>
|
||||||
{layers.map((layer) => (
|
{layers.map((layer) => (
|
||||||
|
@ -243,14 +256,23 @@ function MapPage({ login }) {
|
||||||
))}
|
))}
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
<RoadInfo
|
{details?.type === 'road' && details?.road?.road && (
|
||||||
{...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }}
|
<RoadInfo
|
||||||
/>
|
roadInfo={details.road}
|
||||||
|
mapInfoPortal={mapInfoPortal.current}
|
||||||
|
onClose={onCloseDetails}
|
||||||
|
{...{hasFilters}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{details?.type === 'region' && details?.region && (
|
||||||
|
<RegionInfo region={details.region} mapInfoPortal={mapInfoPortal.current} onClose={onCloseDetails} />
|
||||||
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state) => ({ login: state.login }))(MapPage);
|
export default connect((state) => ({login: state.login}))(MapPage)
|
||||||
|
|
|
@ -24,12 +24,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapInfoBox {
|
.mapInfoBox {
|
||||||
position: absolute;
|
|
||||||
right: 16px;
|
|
||||||
top: 32px;
|
|
||||||
max-height: 100%;
|
|
||||||
width: 36rem;
|
width: 36rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
border-left: 1px solid @borderColor;
|
||||||
|
background: white;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapToolbar {
|
.mapToolbar {
|
||||||
|
@ -37,3 +36,32 @@
|
||||||
left: 16px;
|
left: 16px;
|
||||||
top: 16px;
|
top: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,95 +1,92 @@
|
||||||
import { useMemo } from "react";
|
import {useMemo} from 'react'
|
||||||
import { useSelector } from "react-redux";
|
import {useSelector} from 'react-redux'
|
||||||
import produce from "immer";
|
import produce from 'immer'
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
|
|
||||||
type BaseMapStyle = "positron" | "bright";
|
type BaseMapStyle = 'positron' | 'bright'
|
||||||
|
|
||||||
type RoadAttribute =
|
type RoadAttribute =
|
||||||
| "distance_overtaker_mean"
|
| 'distance_overtaker_mean'
|
||||||
| "distance_overtaker_min"
|
| 'distance_overtaker_min'
|
||||||
| "distance_overtaker_max"
|
| 'distance_overtaker_max'
|
||||||
| "distance_overtaker_median"
|
| 'distance_overtaker_median'
|
||||||
| "overtaking_event_count"
|
| 'overtaking_event_count'
|
||||||
| "usage_count"
|
| 'usage_count'
|
||||||
| "zone";
|
| 'zone'
|
||||||
|
|
||||||
export type MapConfig = {
|
export type MapConfig = {
|
||||||
baseMap: {
|
baseMap: {
|
||||||
style: BaseMapStyle;
|
style: BaseMapStyle
|
||||||
};
|
}
|
||||||
obsRoads: {
|
obsRoads: {
|
||||||
show: boolean;
|
show: boolean
|
||||||
showUntagged: boolean;
|
showUntagged: boolean
|
||||||
attribute: RoadAttribute;
|
attribute: RoadAttribute
|
||||||
maxCount: number;
|
maxCount: number
|
||||||
};
|
}
|
||||||
obsEvents: {
|
obsEvents: {
|
||||||
show: boolean;
|
show: boolean
|
||||||
};
|
}
|
||||||
|
obsRegions: {
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
filters: {
|
filters: {
|
||||||
currentUser: boolean;
|
currentUser: boolean
|
||||||
dateMode: "none" | "range" | "threshold";
|
dateMode: 'none' | 'range' | 'threshold'
|
||||||
startDate?: null | string;
|
startDate?: null | string
|
||||||
endDate?: null | string;
|
endDate?: null | string
|
||||||
thresholdAfter?: null | boolean;
|
thresholdAfter?: null | boolean
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
export const initialState: MapConfig = {
|
export const initialState: MapConfig = {
|
||||||
baseMap: {
|
baseMap: {
|
||||||
style: "positron",
|
style: 'positron',
|
||||||
},
|
},
|
||||||
obsRoads: {
|
obsRoads: {
|
||||||
show: true,
|
show: true,
|
||||||
showUntagged: true,
|
showUntagged: true,
|
||||||
attribute: "distance_overtaker_median",
|
attribute: 'distance_overtaker_median',
|
||||||
maxCount: 20,
|
maxCount: 20,
|
||||||
},
|
},
|
||||||
obsEvents: {
|
obsEvents: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
|
obsRegions: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
filters: {
|
filters: {
|
||||||
currentUser: false,
|
currentUser: false,
|
||||||
dateMode: "none",
|
dateMode: 'none',
|
||||||
startDate: null,
|
startDate: null,
|
||||||
endDate: null,
|
endDate: null,
|
||||||
thresholdAfter: true,
|
thresholdAfter: true,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
type MapConfigAction = {
|
type MapConfigAction = {
|
||||||
type: "MAP_CONFIG.SET_FLAG";
|
type: 'MAP_CONFIG.SET_FLAG'
|
||||||
payload: { flag: string; value: any };
|
payload: {flag: string; value: any}
|
||||||
};
|
}
|
||||||
|
|
||||||
export function setMapConfigFlag(
|
export function setMapConfigFlag(flag: string, value: unknown): MapConfigAction {
|
||||||
flag: string,
|
return {type: 'MAP_CONFIG.SET_FLAG', payload: {flag, value}}
|
||||||
value: unknown
|
|
||||||
): MapConfigAction {
|
|
||||||
return { type: "MAP_CONFIG.SET_FLAG", payload: { flag, value } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMapConfig() {
|
export function useMapConfig() {
|
||||||
const mapConfig = useSelector((state) => state.mapConfig);
|
const mapConfig = useSelector((state) => state.mapConfig)
|
||||||
const result = useMemo(
|
const result = useMemo(() => _.merge({}, initialState, mapConfig), [mapConfig])
|
||||||
() => _.merge({}, initialState, mapConfig),
|
return result
|
||||||
[mapConfig]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function mapConfigReducer(
|
export default function mapConfigReducer(state: MapConfig = initialState, action: MapConfigAction) {
|
||||||
state: MapConfig = initialState,
|
|
||||||
action: MapConfigAction
|
|
||||||
) {
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "MAP_CONFIG.SET_FLAG":
|
case 'MAP_CONFIG.SET_FLAG':
|
||||||
return produce(state, (draft) => {
|
return produce(state, (draft) => {
|
||||||
_.set(draft, action.payload.flag, action.payload.value);
|
_.set(draft, action.payload.flag, action.payload.value)
|
||||||
});
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue