Show regions on map page, and move on-click info panel into a proper sidebar

This commit is contained in:
Paul Bienkowski 2023-03-12 12:43:08 +01:00
parent 7ae4ebebb6
commit 518bcd81ef
10 changed files with 692 additions and 661 deletions

View file

@ -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 max = map[map.length - 1][0]
const normalizeValue = (v) => (v - min) / (max - min)
@ -81,7 +81,7 @@ export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap,
</svg>
{tickValues.map(([value]) => (
<span className={styles.tick} key={value} style={{left: normalizeValue(value) * 100 + '%'}}>
{value.toFixed(2)}
{value.toFixed(digits)}
</span>
))}
</div>

View file

@ -1,75 +1,70 @@
import React, { useState, useCallback, useMemo, useEffect } from "react";
import classnames from "classnames";
import { connect } from "react-redux";
import _ from "lodash";
import ReactMapGl, {
WebMercatorViewport,
ScaleControl,
NavigationControl,
AttributionControl,
} from "react-map-gl";
import turfBbox from "@turf/bbox";
import { useHistory, useLocation } from "react-router-dom";
import React, {useState, useCallback, useMemo, useEffect} from 'react'
import classnames from 'classnames'
import {connect} from 'react-redux'
import _ from 'lodash'
import ReactMapGl, {WebMercatorViewport, ScaleControl, 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 { baseMapStyles } from "../../mapstyles";
import {useCallbackRef} from '../../utils'
import {baseMapStyles} from '../../mapstyles'
import styles from "./styles.module.less";
import styles from './styles.module.less'
interface Viewport {
longitude: number;
latitude: number;
zoom: number;
longitude: number
latitude: 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) => ({
baseMapStyle: state.mapConfig?.baseMap?.style ?? "positron",
}));
baseMapStyle: state.mapConfig?.baseMap?.style ?? 'positron',
}))
function parseHash(v: string): Viewport | null {
if (!v) return null;
const m = v.match(/^#([0-9\.]+)\/([0-9\.\-]+)\/([0-9\.\-]+)$/);
if (!m) return null;
if (!v) return null
const m = v.match(/^#([0-9\.]+)\/([0-9\.\-]+)\/([0-9\.\-]+)$/)
if (!m) return null
return {
zoom: Number.parseFloat(m[1]),
latitude: Number.parseFloat(m[2]),
longitude: Number.parseFloat(m[3]),
};
}
}
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) => {
history.replace({
hash: buildHash(viewport),
});
}, 200);
})
}, 200)
function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] {
const history = useHistory();
const location = useLocation();
const history = useHistory()
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
useEffect(() => {
setCachedValue(parseHash(location.hash));
}, [location.hash]);
setCachedValue(parseHash(location.hash))
}, [location.hash])
const setter = useCallback(
(v) => {
setCachedValue(v);
setViewportToHash(history, v);
setCachedValue(v)
setViewportToHash(history, v)
},
[history]
);
)
return [cachedValue || EMPTY_VIEWPORT, setter];
return [cachedValue || EMPTY_VIEWPORT, setter]
}
function Map({
@ -78,57 +73,54 @@ function Map({
boundsFromJson,
baseMapStyle,
hasToolbar,
onViewportChange,
...props
}: {
viewportFromUrl?: boolean;
children: React.ReactNode;
boundsFromJson: GeoJSON.Geometry;
baseMapStyle: string;
hasToolbar?: boolean;
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 [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();
const config = useConfig()
useEffect(() => {
if (
config?.mapHome &&
viewport?.latitude === 0 &&
viewport?.longitude === 0 &&
!boundsFromJson
) {
setViewport(config.mapHome);
if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) {
setViewport(config.mapHome)
}
}, [config, boundsFromJson]);
}, [config, boundsFromJson])
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]
);
)
const transformRequest = useCallbackRef((url, resourceType) => {
if (resourceType === "Tile" && mapSourceHosts.includes(new URL(url).host)) {
if (resourceType === 'Tile' && mapSourceHosts.includes(new URL(url).host)) {
return {
url,
credentials: "include",
};
credentials: 'include',
}
}
});
})
useEffect(() => {
if (boundsFromJson) {
const bbox = turfBbox(boundsFromJson);
const bbox = turfBbox(boundsFromJson)
if (bbox.every((v) => Math.abs(v) !== Infinity)) {
const [minX, minY, maxX, maxY] = bbox;
const [minX, minY, maxX, maxY] = bbox
const vp = new WebMercatorViewport({
width: 1000,
height: 800,
@ -141,11 +133,11 @@ function Map({
padding: 20,
offset: [0, -100],
}
);
setViewport(_.pick(vp, ["zoom", "latitude", "longitude"]));
)
setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude']))
}
}
}, [boundsFromJson]);
}, [boundsFromJson])
return (
<ReactMapGl
@ -153,23 +145,19 @@ function Map({
width="100%"
height="100%"
onViewportChange={setViewport}
{...{ transformRequest }}
{...{transformRequest}}
{...viewport}
{...props}
className={classnames(styles.map, props.className)}
attributionControl={false}
>
<AttributionControl style={{ top: 0, right: 0 }} />
<NavigationControl style={{ left: 16, top: hasToolbar ? 64 : 16 }} />
<ScaleControl
maxWidth={200}
unit="metric"
style={{ left: 16, bottom: 16 }}
/>
<AttributionControl style={{top: 0, right: 0}} />
<NavigationControl style={{left: 16, top: hasToolbar ? 64 : 16}} />
<ScaleControl maxWidth={200} unit="metric" style={{left: 16, bottom: 16}} />
{children}
</ReactMapGl>
);
)
}
export default withBaseMapStyle(Map);
export default withBaseMapStyle(Map)

View file

@ -1,4 +1,5 @@
export {default as Avatar} from './Avatar'
export {default as Chart} from './Chart'
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend'
export {default as FileDrop} from './FileDrop'
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 Map} from './Map'
export {default as Page} from './Page'
export {default as RegionStats} from './RegionStats'
export {default as Stats} from './Stats'
export {default as StripMarkdown} from './StripMarkdown'
export {default as Chart} from './Chart'
export {default as Visibility} from './Visibility'

View file

@ -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 => {
// draft.paint['line-color'] = '#81D4FA'
draft.paint['line-width'][4] = 1

View file

@ -1,69 +1,56 @@
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import {
List,
Select,
Input,
Divider,
Label,
Checkbox,
Header,
} from "semantic-ui-react";
import { useTranslation } from "react-i18next";
import React from 'react'
import _ from 'lodash'
import {connect} from 'react-redux'
import {List, Select, Input, Divider, Label, Checkbox, Header} from 'semantic-ui-react'
import {useTranslation} from 'react-i18next'
import {
MapConfig,
setMapConfigFlag as setMapConfigFlagAction,
initialState as defaultMapConfig,
} from "reducers/mapConfig";
import { colorByDistance, colorByCount, viridisSimpleHtml } from "mapstyles";
import { ColorMapLegend, DiscreteColorMapLegend } from "components";
} from 'reducers/mapConfig'
import {colorByDistance, colorByCount, viridisSimpleHtml} from 'mapstyles'
import {ColorMapLegend, DiscreteColorMapLegend} from 'components'
const BASEMAP_STYLE_OPTIONS = ["positron", "bright"];
const BASEMAP_STYLE_OPTIONS = ['positron', 'bright']
const ROAD_ATTRIBUTE_OPTIONS = [
"distance_overtaker_mean",
"distance_overtaker_min",
"distance_overtaker_max",
"distance_overtaker_median",
"overtaking_event_count",
"usage_count",
"zone",
];
'distance_overtaker_mean',
'distance_overtaker_min',
'distance_overtaker_max',
'distance_overtaker_median',
'overtaking_event_count',
'usage_count',
'zone',
]
const DATE_FILTER_MODES = ["none", "range", "threshold"];
const DATE_FILTER_MODES = ['none', 'range', 'threshold']
type User = Object;
type User = Object
function LayerSidebar({
mapConfig,
login,
setMapConfigFlag,
}: {
login: User | null;
mapConfig: MapConfig;
setMapConfigFlag: (flag: string, value: unknown) => void;
login: User | null
mapConfig: MapConfig
setMapConfigFlag: (flag: string, value: unknown) => void
}) {
const { t } = useTranslation();
const {t} = useTranslation()
const {
baseMap: { style },
obsRoads: { show: showRoads, showUntagged, attribute, maxCount },
obsEvents: { show: showEvents },
filters: {
currentUser: filtersCurrentUser,
dateMode,
startDate,
endDate,
thresholdAfter,
},
} = mapConfig;
baseMap: {style},
obsRoads: {show: showRoads, showUntagged, attribute, maxCount},
obsEvents: {show: showEvents},
obsRegions: {show: showRegions},
filters: {currentUser: filtersCurrentUser, dateMode, startDate, endDate, thresholdAfter},
} = mapConfig
return (
<div>
<List relaxed>
<List.Item>
<List.Header>{t("MapPage.sidebar.baseMap.style.label")}</List.Header>
<List.Header>{t('MapPage.sidebar.baseMap.style.label')}</List.Header>
<Select
options={BASEMAP_STYLE_OPTIONS.map((value) => ({
value,
@ -71,23 +58,50 @@ function LayerSidebar({
text: t(`MapPage.sidebar.baseMap.style.${value}`),
}))}
value={style}
onChange={(_e, { value }) =>
setMapConfigFlag("baseMap.style", value)
}
onChange={(_e, {value}) => setMapConfigFlag('baseMap.style', value)}
/>
</List.Item>
<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>
<Checkbox
toggle
size="small"
id="obsRoads.show"
style={{ float: "right" }}
style={{float: 'right'}}
checked={showRoads}
onChange={() => setMapConfigFlag("obsRoads.show", !showRoads)}
onChange={() => setMapConfigFlag('obsRoads.show', !showRoads)}
/>
<label htmlFor="obsRoads.show">
<Header as="h4">{t("MapPage.sidebar.obsRoads.title")}</Header>
<Header as="h4">{t('MapPage.sidebar.obsRoads.title')}</Header>
</label>
</List.Item>
{showRoads && (
@ -95,16 +109,12 @@ function LayerSidebar({
<List.Item>
<Checkbox
checked={showUntagged}
onChange={() =>
setMapConfigFlag("obsRoads.showUntagged", !showUntagged)
}
label={t("MapPage.sidebar.obsRoads.showUntagged.label")}
onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
label={t('MapPage.sidebar.obsRoads.showUntagged.label')}
/>
</List.Item>
<List.Item>
<List.Header>
{t("MapPage.sidebar.obsRoads.attribute.label")}
</List.Header>
<List.Header>{t('MapPage.sidebar.obsRoads.attribute.label')}</List.Header>
<Select
fluid
options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({
@ -113,74 +123,50 @@ function LayerSidebar({
text: t(`MapPage.sidebar.obsRoads.attribute.${value}`),
}))}
value={attribute}
onChange={(_e, { value }) =>
setMapConfigFlag("obsRoads.attribute", value)
}
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)}
/>
</List.Item>
{attribute.endsWith("_count") ? (
{attribute.endsWith('_count') ? (
<>
<List.Item>
<List.Header>
{t("MapPage.sidebar.obsRoads.maxCount.label")}
</List.Header>
<List.Header>{t('MapPage.sidebar.obsRoads.maxCount.label')}</List.Header>
<Input
fluid
type="number"
value={maxCount}
onChange={(_e, { value }) =>
setMapConfigFlag("obsRoads.maxCount", value)
}
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)}
/>
</List.Item>
<List.Item>
<ColorMapLegend
map={_.chunk(
colorByCount(
"obsRoads.maxCount",
mapConfig.obsRoads.maxCount,
viridisSimpleHtml
).slice(3),
colorByCount('obsRoads.maxCount', mapConfig.obsRoads.maxCount, viridisSimpleHtml).slice(3),
2
)}
twoTicks
/>
</List.Item>
</>
) : attribute.endsWith("zone") ? (
) : attribute.endsWith('zone') ? (
<>
<List.Item>
<Label
size="small"
style={{ background: "blue", color: "white" }}
>
{t("general.zone.urban")} (1.5&nbsp;m)
<Label size="small" style={{background: 'blue', color: 'white'}}>
{t('general.zone.urban')} (1.5&nbsp;m)
</Label>
<Label
size="small"
style={{ background: "cyan", color: "black" }}
>
{t("general.zone.rural")}(2&nbsp;m)
<Label size="small" style={{background: 'cyan', color: 'black'}}>
{t('general.zone.rural')}(2&nbsp;m)
</Label>
</List.Item>
</>
) : (
<>
<List.Item>
<List.Header>
{_.upperFirst(t("general.zone.urban"))}
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
</List.Item>
<List.Item>
<List.Header>
{_.upperFirst(t("general.zone.rural"))}
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
<List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
</List.Item>
</>
)}
@ -192,40 +178,36 @@ function LayerSidebar({
toggle
size="small"
id="obsEvents.show"
style={{ float: "right" }}
style={{float: 'right'}}
checked={showEvents}
onChange={() => setMapConfigFlag("obsEvents.show", !showEvents)}
onChange={() => setMapConfigFlag('obsEvents.show', !showEvents)}
/>
<label htmlFor="obsEvents.show">
<Header as="h4">{t("MapPage.sidebar.obsEvents.title")}</Header>
<Header as="h4">{t('MapPage.sidebar.obsEvents.title')}</Header>
</label>
</List.Item>
{showEvents && (
<>
<List.Item>
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
</List.Item>
<List.Item>
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
<List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
</List.Item>
</>
)}
<Divider />
<List.Item>
<Header as="h4">{t("MapPage.sidebar.filters.title")}</Header>
<Header as="h4">{t('MapPage.sidebar.filters.title')}</Header>
</List.Item>
{login && (
<>
<List.Item>
<Header as="h5">{t("MapPage.sidebar.filters.userData")}</Header>
<Header as="h5">{t('MapPage.sidebar.filters.userData')}</Header>
</List.Item>
<List.Item>
@ -234,15 +216,13 @@ function LayerSidebar({
size="small"
id="filters.currentUser"
checked={filtersCurrentUser}
onChange={() =>
setMapConfigFlag("filters.currentUser", !filtersCurrentUser)
}
label={t("MapPage.sidebar.filters.currentUser")}
onChange={() => setMapConfigFlag('filters.currentUser', !filtersCurrentUser)}
label={t('MapPage.sidebar.filters.currentUser')}
/>
</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>
@ -253,14 +233,12 @@ function LayerSidebar({
key: value,
text: t(`MapPage.sidebar.filters.dateMode.${value}`),
}))}
value={dateMode ?? "none"}
onChange={(_e, { value }) =>
setMapConfigFlag("filters.dateMode", value)
}
value={dateMode ?? 'none'}
onChange={(_e, {value}) => setMapConfigFlag('filters.dateMode', value)}
/>
</List.Item>
{dateMode == "range" && (
{dateMode == 'range' && (
<List.Item>
<Input
type="date"
@ -268,16 +246,14 @@ function LayerSidebar({
step="7"
size="small"
id="filters.startDate"
onChange={(_e, { value }) =>
setMapConfigFlag("filters.startDate", value)
}
onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
value={startDate ?? null}
label={t("MapPage.sidebar.filters.start")}
label={t('MapPage.sidebar.filters.start')}
/>
</List.Item>
)}
{dateMode == "range" && (
{dateMode == 'range' && (
<List.Item>
<Input
type="date"
@ -285,16 +261,14 @@ function LayerSidebar({
step="7"
size="small"
id="filters.endDate"
onChange={(_e, { value }) =>
setMapConfigFlag("filters.endDate", value)
}
onChange={(_e, {value}) => setMapConfigFlag('filters.endDate', value)}
value={endDate ?? null}
label={t("MapPage.sidebar.filters.end")}
label={t('MapPage.sidebar.filters.end')}
/>
</List.Item>
)}
{dateMode == "threshold" && (
{dateMode == 'threshold' && (
<List.Item>
<Input
type="date"
@ -303,42 +277,33 @@ function LayerSidebar({
size="small"
id="filters.startDate"
value={startDate ?? null}
onChange={(_e, { value }) =>
setMapConfigFlag("filters.startDate", value)
}
label={t("MapPage.sidebar.filters.threshold")}
onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
label={t('MapPage.sidebar.filters.threshold')}
/>
</List.Item>
)}
{dateMode == "threshold" && (
{dateMode == 'threshold' && (
<List.Item>
<span>
{t("MapPage.sidebar.filters.before")}{" "}
{t('MapPage.sidebar.filters.before')}{' '}
<Checkbox
toggle
size="small"
checked={thresholdAfter ?? false}
onChange={() =>
setMapConfigFlag(
"filters.thresholdAfter",
!thresholdAfter
)
}
onChange={() => setMapConfigFlag('filters.thresholdAfter', !thresholdAfter)}
id="filters.thresholdAfter"
/>{" "}
{t("MapPage.sidebar.filters.after")}
/>{' '}
{t('MapPage.sidebar.filters.after')}
</span>
</List.Item>
)}
</>
)}
{!login && (
<List.Item>{t("MapPage.sidebar.filters.needsLogin")}</List.Item>
)}
{!login && <List.Item>{t('MapPage.sidebar.filters.needsLogin')}</List.Item>}
</List>
</div>
);
)
}
export default connect(
@ -351,6 +316,6 @@ export default connect(
),
login: state.login,
}),
{ setMapConfigFlag: setMapConfigFlagAction }
{setMapConfigFlag: setMapConfigFlagAction}
//
)(LayerSidebar);
)(LayerSidebar)

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,74 +1,57 @@
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 {createPortal} from 'react-dom'
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 type { Location } from "types";
import api from "api";
import { colorByDistance, borderByZone } from "mapstyles";
import type {Location} from 'types'
import api from 'api'
import {colorByDistance, borderByZone} from 'mapstyles'
import styles from "./styles.module.less";
import styles from './styles.module.less'
function selectFromColorMap(colormap, value) {
let last = null;
let last = null
for (let i = 0; i < colormap.length; i += 2) {
if (colormap[i + 1] > value) {
return colormap[i];
return colormap[i]
}
}
return colormap[colormap.length - 1];
return colormap[colormap.length - 1]
}
const UNITS = {
distanceOvertaker: "m",
distanceStationary: "m",
speed: "km/h",
};
const ZONE_COLORS = { urban: "blue", rural: "cyan", motorway: "purple" };
const CARDINAL_DIRECTIONS = [
"north",
"northEast",
"east",
"southEast",
"south",
"southWest",
"west",
"northWest",
];
distanceOvertaker: 'm',
distanceStationary: 'm',
speed: 'km/h',
}
const ZONE_COLORS = {urban: 'blue', rural: 'cyan', motorway: 'purple'}
const CARDINAL_DIRECTIONS = ['north', 'northEast', 'east', 'southEast', 'south', 'southWest', 'west', 'northWest']
const getCardinalDirection = (t, bearing) => {
if (bearing == null) {
return t("MapPage.roadInfo.cardinalDirections.unknown");
return t('MapPage.roadInfo.cardinalDirections.unknown')
} else {
const n = CARDINAL_DIRECTIONS.length;
const i = Math.floor(((bearing / 360.0) * n + 0.5) % n);
const name = CARDINAL_DIRECTIONS[i];
return t(`MapPage.roadInfo.cardinalDirections.${name}`);
const n = CARDINAL_DIRECTIONS.length
const i = Math.floor(((bearing / 360.0) * n + 0.5) % n)
const name = CARDINAL_DIRECTIONS[i]
return t(`MapPage.roadInfo.cardinalDirections.${name}`)
}
};
}
function RoadStatsTable({ data }) {
const { t } = useTranslation();
function RoadStatsTable({data}) {
const {t} = useTranslation()
return (
<Table size="small" compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell textAlign="right"></Table.HeaderCell>
{["distanceOvertaker", "distanceStationary", "speed"].map((prop) => (
{['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
<Table.HeaderCell key={prop} textAlign="right">
{t(`MapPage.roadInfo.${prop}`)}
</Table.HeaderCell>
@ -76,58 +59,52 @@ function RoadStatsTable({ data }) {
</Table.Row>
</Table.Header>
<Table.Body>
{["count", "min", "median", "max", "mean"].map((stat) => (
{['count', 'min', 'median', 'max', 'mean'].map((stat) => (
<Table.Row key={stat}>
<Table.Cell> {t(`MapPage.roadInfo.${stat}`)}</Table.Cell>
{["distanceOvertaker", "distanceStationary", "speed"].map(
(prop) => (
<Table.Cell key={prop} textAlign="right">
{(
data[prop]?.statistics?.[stat] *
(prop === `speed` && stat != "count" ? 3.6 : 1)
).toFixed(stat === "count" ? 0 : 2)}
{stat !== "count" && ` ${UNITS[prop]}`}
</Table.Cell>
)
)}
{['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
<Table.Cell key={prop} textAlign="right">
{(data[prop]?.statistics?.[stat] * (prop === `speed` && stat != 'count' ? 3.6 : 1)).toFixed(
stat === 'count' ? 0 : 2
)}
{stat !== 'count' && ` ${UNITS[prop]}`}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
);
)
}
function HistogramChart({ bins, counts, zone }) {
const diff = bins[1] - bins[0];
const colortype = zone === "rural" ? 3 : 5;
function HistogramChart({bins, counts, zone}) {
const diff = bins[1] - bins[0]
const colortype = zone === 'rural' ? 3 : 5
const data = _.zip(
bins.slice(0, bins.length - 1).map((v) => v + diff / 2),
counts
).map((value) => ({
value,
itemStyle: {
color: selectFromColorMap(
colorByDistance()[3][colortype].slice(2),
value[0]
),
color: selectFromColorMap(colorByDistance()[3][colortype].slice(2), value[0]),
},
}));
}))
return (
<Chart
style={{ height: 240 }}
style={{height: 240}}
option={{
grid: { top: 30, bottom: 30, right: 30, left: 30 },
grid: {top: 30, bottom: 30, right: 30, left: 30},
xAxis: {
type: "value",
axisLabel: { formatter: (v) => `${Math.round(v * 100)} cm` },
type: 'value',
axisLabel: {formatter: (v) => `${Math.round(v * 100)} cm`},
min: 0,
max: 2.5,
},
yAxis: {},
series: [
{
type: "bar",
type: 'bar',
data,
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({
clickLocation,
roadInfo: info,
hasFilters,
onClose,
mapInfoPortal,
}: {
clickLocation: Location | null;
hasFilters: boolean;
onClose: () => void;
roadInfo: RoadInfoType
hasFilters: boolean
onClose: () => void
mapInfoPortal: HTMLElement
}) {
const { t } = useTranslation();
const [direction, setDirection] = useState("forwards");
const {t} = useTranslation()
const [direction, setDirection] = useState('forwards')
const onClickDirection = useCallback(
(e, { name }) => {
e.preventDefault();
e.stopPropagation();
setDirection(name);
(e, {name}) => {
e.preventDefault()
e.stopPropagation()
setDirection(name)
},
[setDirection]
);
)
const info = useObservable(
(_$, inputs$) =>
inputs$.pipe(
distinctUntilChanged(_.isEqual),
switchMap(([location]) =>
location
? concat(
of(null),
from(
api.get("/mapdetails/road", {
query: {
...location,
radius: 100,
},
})
)
)
: of(null)
)
),
null,
[clickLocation]
);
// TODO: change based on left-hand/right-hand traffic
const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1
if (!clickLocation) {
return null;
}
const content = (
<>
<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
? 0
: direction === "forwards"
? 1
: -1; // TODO: change based on left-hand/right-hand traffic
{info?.road.zone && (
<Label size="small" color={ZONE_COLORS[info?.road.zone]}>
{t(`general.zone.${info.road.zone}`)}
</Label>
)}
const content =
!loading && !info.road ? (
"No road found."
) : (
<>
<Header as="h3">
{loading
? "..."
: info?.road.name || t("MapPage.roadInfo.unnamedWay")}
{info?.road.oneway && (
<Label size="small" color="blue">
<Icon name="long arrow alternate right" fitted /> {t('MapPage.roadInfo.oneway')}
</Label>
)}
<Button
style={{ float: "right" }}
onClick={onClose}
title={t("MapPage.roadInfo.closeTooltip")}
size="small"
icon="close"
basic
/>
</Header>
{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)}
</Menu.Item>
<Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
{getCardinalDirection(t, info?.backwards?.bearing)}
</Menu.Item>
</Menu>
)}
{hasFilters && (
<Message info icon>
<Icon name="info circle" small />
<Message.Content>
{t("MapPage.roadInfo.hintFiltersNotApplied")}
</Message.Content>
</Message>
)}
{info?.[direction] && <RoadStatsTable data={info[direction]} />}
{info?.road.zone && (
<Label size="small" color={ZONE_COLORS[info?.road.zone]}>
{t(`general.zone.${info.road.zone}`)}
</Label>
)}
{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}
/>
</>
)}
</>
);
{info?.[direction]?.distanceOvertaker?.histogram && (
<>
<Header as="h5">{t('MapPage.roadInfo.overtakerDistanceDistribution')}</Header>
<HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
</>
)}
</>
)
return (
<>
@ -280,22 +235,14 @@ export default function RoadInfo({
id="route"
type="line"
paint={{
"line-width": [
"interpolate",
["linear"],
["zoom"],
14,
6,
17,
12,
],
"line-color": "#18FFFF",
"line-opacity": 0.5,
'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12],
'line-color': '#18FFFF',
'line-opacity': 0.5,
...{
"line-offset": [
"interpolate",
["exponential", 1.5],
["zoom"],
'line-offset': [
'interpolate',
['exponential', 1.5],
['zoom'],
12,
offsetDirection,
19,
@ -307,11 +254,7 @@ export default function RoadInfo({
</Source>
)}
{content && (
<div className={styles.mapInfoBox}>
<Segment loading={loading}>{content}</Segment>
</div>
)}
{content && mapInfoPortal && createPortal(<div className={styles.mapInfoBox}>{content}</div>, mapInfoPortal)}
</>
);
)
}

View file

@ -1,241 +1,254 @@
import React, { useState, useCallback, useMemo } 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 classNames from "classnames";
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 classNames from 'classnames'
import type { Location } from "types";
import { Page, Map } from "components";
import { useConfig } from "config";
import {
colorByDistance,
colorByCount,
borderByZone,
reds,
isValidAttribute,
} from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import api from 'api'
import type {Location} from 'types'
import {Page, Map} from 'components'
import {useConfig} from 'config'
import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles'
import {useMapConfig} from 'reducers/mapConfig'
import RoadInfo from "./RoadInfo";
import LayerSidebar from "./LayerSidebar";
import styles from "./styles.module.less";
import RoadInfo, {RoadInfoType} from './RoadInfo'
import RegionInfo from './RegionInfo'
import LayerSidebar from './LayerSidebar'
import styles from './styles.module.less'
const untaggedRoadsLayer = {
id: "obs_roads_untagged",
type: "line",
source: "obs",
"source-layer": "obs_roads",
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
id: 'obs_roads_untagged',
type: 'line',
source: 'obs',
'source-layer': 'obs_roads',
minzoom: 12,
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
layout: {
"line-cap": "round",
"line-join": "round",
'line-cap': 'round',
'line-join': 'round',
},
paint: {
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2],
"line-color": "#ABC",
"line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
"line-offset": [
"interpolate",
["exponential", 1.5],
["zoom"],
'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'],
12,
["get", "offset_direction"],
['get', 'offset_direction'],
19,
["*", ["get", "offset_direction"], 8],
['*', ['get', 'offset_direction'], 8],
],
},
minzoom: 12,
};
}
const getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
const getUntaggedRoadsLayer = (colorAttribute) =>
produce(untaggedRoadsLayer, (draft) => {
draft.filter = ["!", isValidAttribute(colorAttribute)];
});
draft.filter = ['!', isValidAttribute(colorAttribute)]
})
const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => {
draft.id = "obs_roads_normal";
draft.filter = isValidAttribute(colorAttribute);
draft.paint["line-width"][6] = 6; // scale bigger on zoom
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
draft.id = 'obs_roads_normal'
draft.filter = isValidAttribute(colorAttribute)
draft.minzoom = 10
draft.paint['line-width'][6] = 6 // scale bigger on zoom
draft.paint['line-color'] = colorAttribute.startsWith('distance_')
? colorByDistance(colorAttribute)
: colorAttribute.endsWith("_count")
: colorAttribute.endsWith('_count')
? colorByCount(colorAttribute, maxCount)
: colorAttribute.endsWith("zone")
: colorAttribute.endsWith('zone')
? borderByZone()
: "#DDD";
draft.paint["line-opacity"][3] = 12;
draft.paint["line-opacity"][5] = 13;
});
: '#DDD'
// draft.paint["line-opacity"][3] = 12;
// draft.paint["line-opacity"][5] = 13;
})
const getEventsLayer = () => ({
id: "obs_events",
type: "circle",
source: "obs",
"source-layer": "obs_events",
id: 'obs_events',
type: 'circle',
source: 'obs',
'source-layer': 'obs_events',
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8],
"circle-color": colorByDistance("distance_overtaker"),
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
'circle-color': colorByDistance('distance_overtaker'),
},
minzoom: 11,
});
})
const getEventsTextLayer = () => ({
id: "obs_events_text",
type: "symbol",
id: 'obs_events_text',
type: 'symbol',
minzoom: 18,
source: "obs",
"source-layer": "obs_events",
source: 'obs',
'source-layer': 'obs_events',
layout: {
"text-field": [
"number-format",
["get", "distance_overtaker"],
{ "min-fraction-digits": 2, "max-fraction-digits": 2 },
'text-field': [
'number-format',
['get', 'distance_overtaker'],
{'min-fraction-digits': 2, 'max-fraction-digits': 2},
],
"text-allow-overlap": true,
"text-font": ["Open Sans Bold", "Arial Unicode MS Regular"],
"text-size": 14,
"text-keep-upright": false,
"text-anchor": "left",
"text-radial-offset": 1,
"text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
"text-rotation-alignment": "map",
'text-allow-overlap': true,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
'text-size': 14,
'text-keep-upright': false,
'text-anchor': 'left',
'text-radial-offset': 1,
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]],
'text-rotation-alignment': 'map',
},
paint: {
"text-halo-color": "rgba(255, 255, 255, 1)",
"text-halo-width": 1,
"text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
'text-halo-color': 'rgba(255, 255, 255, 1)',
'text-halo-width': 1,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
},
});
})
function MapPage({ login }) {
const { obsMapSource, banner } = useConfig() || {};
const [clickLocation, setClickLocation] = useState<Location | null>(null);
interface RegionInfo {
properties: {
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(
(e) => {
let node = e.target;
async (e) => {
// check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target
while (node) {
if (
[styles.mapInfoBox, styles.mapToolbar].some((className) =>
node?.classList?.contains(className)
)
) {
return;
if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) {
return
}
node = node.parentNode;
node = node.parentNode
}
setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] });
},
[setClickLocation]
);
const onCloseRoadInfo = useCallback(() => {
setClickLocation(null);
}, [setClickLocation]);
const {zoom} = viewportRef.current
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 {
obsRoads: { attribute, maxCount },
} = mapConfig;
obsRoads: {attribute, maxCount},
} = mapConfig
const layers = [];
const layers = []
const untaggedRoadsLayerCustom = useMemo(
() => getUntaggedRoadsLayer(attribute),
[attribute]
);
const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayerCustom);
layers.push(untaggedRoadsLayerCustom)
}
const roadsLayer = useMemo(
() => getRoadsLayer(attribute, maxCount),
[attribute, maxCount]
);
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
if (mapConfig.obsRoads.show) {
layers.push(roadsLayer);
layers.push(roadsLayer)
}
const eventsLayer = useMemo(() => getEventsLayer(), []);
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
const regionLayers = useMemo(() => getRegionLayers(), [])
if (mapConfig.obsRegions.show) {
layers.push(...regionLayers)
}
const eventsLayer = useMemo(() => getEventsLayer(), [])
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
if (mapConfig.obsEvents.show) {
layers.push(eventsLayer);
layers.push(eventsTextLayer);
layers.push(eventsLayer)
layers.push(eventsTextLayer)
}
const onToggleLayerSidebarButtonClick = useCallback(
(e) => {
e.stopPropagation();
e.preventDefault();
console.log("toggl;e");
setLayerSidebar((v) => !v);
e.stopPropagation()
e.preventDefault()
console.log('toggl;e')
setLayerSidebar((v) => !v)
},
[setLayerSidebar]
);
)
if (!obsMapSource) {
return null;
return null
}
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
const query = new URLSearchParams();
const query = new URLSearchParams()
if (login) {
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) {
query.append("start", mapConfig.filters.startDate);
query.append('start', mapConfig.filters.startDate)
}
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) {
query.append(
mapConfig.filters.thresholdAfter ? "start" : "end",
mapConfig.filters.startDate
);
query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
}
}
}
const queryString = String(query);
return tileUrl + (queryString ? "?" : "") + queryString;
});
const queryString = String(query)
return tileUrl + (queryString ? '?' : '') + queryString
})
const hasFilters: boolean =
login &&
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none')
return (
<Page fullScreen title="Map">
<div
className={classNames(
styles.mapContainer,
banner ? styles.hasBanner : null
)}
>
<div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}>
{layerSidebar && (
<div className={styles.mapSidebar}>
<LayerSidebar />
</div>
)}
<div className={styles.map}>
<Map viewportFromUrl onClick={onClick} hasToolbar>
<Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}>
<div className={styles.mapToolbar}>
<Button
primary
icon="bars"
active={layerSidebar}
onClick={onToggleLayerSidebarButtonClick}
/>
<Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} />
</div>
<Source id="obs" {...obsMapSource} tiles={tiles}>
{layers.map((layer) => (
@ -243,14 +256,23 @@ function MapPage({ login }) {
))}
</Source>
<RoadInfo
{...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }}
/>
{details?.type === 'road' && details?.road?.road && (
<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>
</div>
</div>
</Page>
);
)
}
export default connect((state) => ({ login: state.login }))(MapPage);
export default connect((state) => ({login: state.login}))(MapPage)

View file

@ -24,12 +24,11 @@
}
.mapInfoBox {
position: absolute;
right: 16px;
top: 32px;
max-height: 100%;
width: 36rem;
overflow: auto;
border-left: 1px solid @borderColor;
background: white;
padding: 16px;
}
.mapToolbar {
@ -37,3 +36,32 @@
left: 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;
}
}

View file

@ -1,95 +1,92 @@
import { useMemo } from "react";
import { useSelector } from "react-redux";
import produce from "immer";
import _ from "lodash";
import {useMemo} from 'react'
import {useSelector} from 'react-redux'
import produce from 'immer'
import _ from 'lodash'
type BaseMapStyle = "positron" | "bright";
type BaseMapStyle = 'positron' | 'bright'
type RoadAttribute =
| "distance_overtaker_mean"
| "distance_overtaker_min"
| "distance_overtaker_max"
| "distance_overtaker_median"
| "overtaking_event_count"
| "usage_count"
| "zone";
| 'distance_overtaker_mean'
| 'distance_overtaker_min'
| 'distance_overtaker_max'
| 'distance_overtaker_median'
| 'overtaking_event_count'
| 'usage_count'
| 'zone'
export type MapConfig = {
baseMap: {
style: BaseMapStyle;
};
style: BaseMapStyle
}
obsRoads: {
show: boolean;
showUntagged: boolean;
attribute: RoadAttribute;
maxCount: number;
};
show: boolean
showUntagged: boolean
attribute: RoadAttribute
maxCount: number
}
obsEvents: {
show: boolean;
};
show: boolean
}
obsRegions: {
show: boolean
}
filters: {
currentUser: boolean;
dateMode: "none" | "range" | "threshold";
startDate?: null | string;
endDate?: null | string;
thresholdAfter?: null | boolean;
};
};
currentUser: boolean
dateMode: 'none' | 'range' | 'threshold'
startDate?: null | string
endDate?: null | string
thresholdAfter?: null | boolean
}
}
export const initialState: MapConfig = {
baseMap: {
style: "positron",
style: 'positron',
},
obsRoads: {
show: true,
showUntagged: true,
attribute: "distance_overtaker_median",
attribute: 'distance_overtaker_median',
maxCount: 20,
},
obsEvents: {
show: false,
},
obsRegions: {
show: true,
},
filters: {
currentUser: false,
dateMode: "none",
dateMode: 'none',
startDate: null,
endDate: null,
thresholdAfter: true,
},
};
}
type MapConfigAction = {
type: "MAP_CONFIG.SET_FLAG";
payload: { flag: string; value: any };
};
type: 'MAP_CONFIG.SET_FLAG'
payload: {flag: string; value: any}
}
export function setMapConfigFlag(
flag: string,
value: unknown
): MapConfigAction {
return { type: "MAP_CONFIG.SET_FLAG", payload: { flag, value } };
export function setMapConfigFlag(flag: string, value: unknown): MapConfigAction {
return {type: 'MAP_CONFIG.SET_FLAG', payload: {flag, value}}
}
export function useMapConfig() {
const mapConfig = useSelector((state) => state.mapConfig);
const result = useMemo(
() => _.merge({}, initialState, mapConfig),
[mapConfig]
);
return result;
const mapConfig = useSelector((state) => state.mapConfig)
const result = useMemo(() => _.merge({}, initialState, mapConfig), [mapConfig])
return result
}
export default function mapConfigReducer(
state: MapConfig = initialState,
action: MapConfigAction
) {
export default function mapConfigReducer(state: MapConfig = initialState, action: MapConfigAction) {
switch (action.type) {
case "MAP_CONFIG.SET_FLAG":
case 'MAP_CONFIG.SET_FLAG':
return produce(state, (draft) => {
_.set(draft, action.payload.flag, action.payload.value);
});
_.set(draft, action.payload.flag, action.payload.value)
})
default:
return state;
return state
}
}