diff --git a/api/migrations/versions/a049e5eb24dd_create_table_region.py b/api/migrations/versions/a049e5eb24dd_create_table_region.py
new file mode 100644
index 0000000..7e50667
--- /dev/null
+++ b/api/migrations/versions/a049e5eb24dd_create_table_region.py
@@ -0,0 +1,35 @@
+"""create table region
+
+Revision ID: a049e5eb24dd
+Revises: a9627f63fbed
+Create Date: 2022-04-02 21:28:43.124521
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+from migrations.utils import dbtype
+
+
+# revision identifiers, used by Alembic.
+revision = "a049e5eb24dd"
+down_revision = "a9627f63fbed"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "region",
+ sa.Column(
+ "relation_id", sa.BIGINT, autoincrement=True, primary_key=True, index=True
+ ),
+ sa.Column("name", sa.String),
+ sa.Column("geometry", dbtype("GEOMETRY"), index=True),
+ sa.Column("admin_level", sa.Integer, index=True),
+ sa.Column("tags", dbtype("HSTORE")),
+ )
+
+
+def downgrade():
+ op.drop_table("region")
diff --git a/api/obs/api/db.py b/api/obs/api/db.py
index 64d8e50..f7716a6 100644
--- a/api/obs/api/db.py
+++ b/api/obs/api/db.py
@@ -432,6 +432,16 @@ class Comment(Base):
}
+class Region(Base):
+ __tablename__ = "region"
+
+ relation_id = Column(BIGINT, primary_key=True, index=True)
+ name = Column(String)
+ geometry = Column(Geometry)
+ admin_level = Column(Integer)
+ tags = Column(HSTORE)
+
+
Comment.author = relationship("User", back_populates="authored_comments")
User.authored_comments = relationship(
"Comment",
diff --git a/api/obs/api/routes/frontend.py b/api/obs/api/routes/frontend.py
index fb681a0..7ccaa70 100644
--- a/api/obs/api/routes/frontend.py
+++ b/api/obs/api/routes/frontend.py
@@ -26,7 +26,7 @@ if app.config.FRONTEND_CONFIG:
.replace("111", "{x}")
.replace("222", "{y}")
],
- "minzoom": 12,
+ "minzoom": 0,
"maxzoom": 14,
}
),
diff --git a/api/obs/api/routes/stats.py b/api/obs/api/routes/stats.py
index 8f5603c..dfdbe7c 100644
--- a/api/obs/api/routes/stats.py
+++ b/api/obs/api/routes/stats.py
@@ -4,12 +4,12 @@ from typing import Optional
from operator import and_
from functools import reduce
-from sqlalchemy import select, func
+from sqlalchemy import select, func, desc
from sanic.response import json
from obs.api.app import api
-from obs.api.db import Track, OvertakingEvent, User
+from obs.api.db import Track, OvertakingEvent, User, Region
from obs.api.utils import round_to
@@ -167,3 +167,36 @@ async def stats(req):
# });
# }),
# );
+
+
+@api.route("/stats/regions")
+async def stats(req):
+ query = (
+ select(
+ [
+ Region.relation_id.label("id"),
+ Region.name,
+ func.count(OvertakingEvent.id).label("overtaking_event_count"),
+ ]
+ )
+ .select_from(Region)
+ .join(
+ OvertakingEvent,
+ func.ST_Within(
+ func.ST_Transform(OvertakingEvent.geometry, 3857), Region.geometry
+ ),
+ )
+ .where(Region.admin_level == 6)
+ .group_by(
+ Region.relation_id,
+ Region.name,
+ Region.relation_id,
+ Region.admin_level,
+ Region.geometry,
+ )
+ .having(func.count(OvertakingEvent.id) > 0)
+ .order_by(desc("overtaking_event_count"))
+ )
+
+ regions = list(map(dict, (await req.ctx.db.execute(query)).all()))
+ return json(regions)
diff --git a/frontend/config.example.json b/frontend/config.example.json
index 6566c6e..2918934 100644
--- a/frontend/config.example.json
+++ b/frontend/config.example.json
@@ -12,7 +12,7 @@
"obsMapSource": {
"type": "vector",
"tiles": ["https://portal.example.com/tiles/{z}/{x}/{y}.pbf"],
- "minzoom": 12,
+ "minzoom": 0,
"maxzoom": 14
}
}
diff --git a/frontend/src/components/ColorMapLegend.tsx b/frontend/src/components/ColorMapLegend.tsx
index f0c4d48..ca09860 100644
--- a/frontend/src/components/ColorMapLegend.tsx
+++ b/frontend/src/components/ColorMapLegend.tsx
@@ -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,
{tickValues.map(([value]) => (
- {value.toFixed(2)}
+ {value.toFixed(digits)}
))}
diff --git a/frontend/src/components/Map/index.tsx b/frontend/src/components/Map/index.tsx
index 149fb92..657b748 100644
--- a/frontend/src/components/Map/index.tsx
+++ b/frontend/src/components/Map/index.tsx
@@ -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 (
-
-
-
+
+
+
{children}
- );
+ )
}
-export default withBaseMapStyle(Map);
+export default withBaseMapStyle(Map)
diff --git a/frontend/src/components/RegionStats/index.tsx b/frontend/src/components/RegionStats/index.tsx
new file mode 100644
index 0000000..5dac6b8
--- /dev/null
+++ b/frontend/src/components/RegionStats/index.tsx
@@ -0,0 +1,83 @@
+import React, { useState, useCallback } from "react";
+import { pickBy } from "lodash";
+import {
+ Loader,
+ Statistic,
+ Pagination,
+ Segment,
+ Header,
+ Menu,
+ Table,
+ Icon,
+} from "semantic-ui-react";
+import { useObservable } from "rxjs-hooks";
+import { of, from, concat, combineLatest } from "rxjs";
+import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
+import { Duration, DateTime } from "luxon";
+
+import api from "api";
+
+function formatDuration(seconds) {
+ return (
+ Duration.fromMillis((seconds ?? 0) * 1000)
+ .as("hours")
+ .toFixed(1) + " h"
+ );
+}
+
+export default function Stats() {
+ const [page, setPage] = useState(1);
+ const PER_PAGE = 10;
+ const stats = useObservable(
+ () =>
+ of(null).pipe(
+ switchMap(() => concat(of(null), from(api.get("/stats/regions"))))
+ ),
+ null
+ );
+
+ const pageCount = stats ? Math.ceil(stats.length / PER_PAGE) : 1;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ Region name
+ Event count
+
+
+
+
+ {stats
+ ?.slice((page - 1) * PER_PAGE, page * PER_PAGE)
+ ?.map((area) => (
+
+ {area.name}
+ {area.overtaking_event_count}
+
+ ))}
+
+
+ {pageCount > 1 &&
+
+
+ setPage(data.activePage as number)}
+ />
+
+
+ }
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js
index e5e4c3f..8ea61ec 100644
--- a/frontend/src/components/index.js
+++ b/frontend/src/components/index.js
@@ -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'
diff --git a/frontend/src/mapstyles/index.js b/frontend/src/mapstyles/index.js
index 30067d0..4e75ad4 100644
--- a/frontend/src/mapstyles/index.js
+++ b/frontend/src/mapstyles/index.js
@@ -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
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index b8aede0..bc0ff84 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -6,7 +6,7 @@ import {map, switchMap} from 'rxjs/operators'
import {useTranslation} from 'react-i18next'
import api from 'api'
-import {Stats, Page} from 'components'
+import {RegionStats, Stats, Page} from 'components'
import type {Track} from 'types'
import {TrackListItem, NoPublicTracksMessage} from './TracksPage'
@@ -46,9 +46,10 @@ export default function HomePage() {
+
-
+
diff --git a/frontend/src/pages/MapPage/LayerSidebar.tsx b/frontend/src/pages/MapPage/LayerSidebar.tsx
index 7da55ce..0120bc3 100644
--- a/frontend/src/pages/MapPage/LayerSidebar.tsx
+++ b/frontend/src/pages/MapPage/LayerSidebar.tsx
@@ -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 (
- {t("MapPage.sidebar.baseMap.style.label")}
+ {t('MapPage.sidebar.baseMap.style.label')}
+
+ setMapConfigFlag('obsRegions.show', !showRegions)}
+ />
+
+
+ {showRegions && (
+ <>
+ Color regions based on event count
+
+
+
+ >
+ )}
+
setMapConfigFlag("obsRoads.show", !showRoads)}
+ onChange={() => setMapConfigFlag('obsRoads.show', !showRoads)}
/>
{showRoads && (
@@ -95,16 +109,12 @@ function LayerSidebar({
- setMapConfigFlag("obsRoads.showUntagged", !showUntagged)
- }
- label={t("MapPage.sidebar.obsRoads.showUntagged.label")}
+ onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
+ label={t('MapPage.sidebar.obsRoads.showUntagged.label')}
/>
-
- {t("MapPage.sidebar.obsRoads.attribute.label")}
-
+ {t('MapPage.sidebar.obsRoads.attribute.label')}
- {attribute.endsWith("_count") ? (
+ {attribute.endsWith('_count') ? (
<>
-
- {t("MapPage.sidebar.obsRoads.maxCount.label")}
-
+ {t('MapPage.sidebar.obsRoads.maxCount.label')}
- setMapConfigFlag("obsRoads.maxCount", value)
- }
+ onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)}
/>
>
- ) : attribute.endsWith("zone") ? (
+ ) : attribute.endsWith('zone') ? (
<>
-
>
) : (
<>
-
- {_.upperFirst(t("general.zone.urban"))}
-
-
+ {_.upperFirst(t('general.zone.urban'))}
+
-
- {_.upperFirst(t("general.zone.rural"))}
-
-
+ {_.upperFirst(t('general.zone.rural'))}
+
>
)}
@@ -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)}
/>
- {t("MapPage.sidebar.obsEvents.title")}
+ {t('MapPage.sidebar.obsEvents.title')}
{showEvents && (
<>
- {_.upperFirst(t("general.zone.urban"))}
-
+ {_.upperFirst(t('general.zone.urban'))}
+
- {_.upperFirst(t("general.zone.rural"))}
-
+ {_.upperFirst(t('general.zone.rural'))}
+
>
)}
- {t("MapPage.sidebar.filters.title")}
+ {t('MapPage.sidebar.filters.title')}
{login && (
<>
- {t("MapPage.sidebar.filters.userData")}
+ {t('MapPage.sidebar.filters.userData')}
@@ -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')}
/>
- {t("MapPage.sidebar.filters.dateRange")}
+ {t('MapPage.sidebar.filters.dateRange')}
@@ -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)}
/>
- {dateMode == "range" && (
+ {dateMode == 'range' && (
- 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')}
/>
)}
- {dateMode == "range" && (
+ {dateMode == 'range' && (
- 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')}
/>
)}
- {dateMode == "threshold" && (
+ {dateMode == 'threshold' && (
- setMapConfigFlag("filters.startDate", value)
- }
- label={t("MapPage.sidebar.filters.threshold")}
+ onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
+ label={t('MapPage.sidebar.filters.threshold')}
/>
)}
- {dateMode == "threshold" && (
+ {dateMode == 'threshold' && (
- {t("MapPage.sidebar.filters.before")}{" "}
+ {t('MapPage.sidebar.filters.before')}{' '}
- setMapConfigFlag(
- "filters.thresholdAfter",
- !thresholdAfter
- )
- }
+ onChange={() => setMapConfigFlag('filters.thresholdAfter', !thresholdAfter)}
id="filters.thresholdAfter"
- />{" "}
- {t("MapPage.sidebar.filters.after")}
+ />{' '}
+ {t('MapPage.sidebar.filters.after')}
)}
>
)}
- {!login && (
- {t("MapPage.sidebar.filters.needsLogin")}
- )}
+ {!login && {t('MapPage.sidebar.filters.needsLogin')}}
- );
+ )
}
export default connect(
@@ -351,6 +316,6 @@ export default connect(
),
login: state.login,
}),
- { setMapConfigFlag: setMapConfigFlagAction }
+ {setMapConfigFlag: setMapConfigFlagAction}
//
-)(LayerSidebar);
+)(LayerSidebar)
diff --git a/frontend/src/pages/MapPage/RegionInfo.tsx b/frontend/src/pages/MapPage/RegionInfo.tsx
new file mode 100644
index 0000000..cf29948
--- /dev/null
+++ b/frontend/src/pages/MapPage/RegionInfo.tsx
@@ -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 = (
+ <>
+
+ {region.properties.name || "Unnamed region"}
+
+
+
+
+
+ Number of events
+ {region.properties.overtaking_event_count ?? 0}
+
+
+ >
+ );
+
+ return content && mapInfoPortal
+ ? createPortal(
+ {content}
,
+ mapInfoPortal
+ )
+ : null;
+}
diff --git a/frontend/src/pages/MapPage/RoadInfo.tsx b/frontend/src/pages/MapPage/RoadInfo.tsx
index a4c988a..80116cc 100644
--- a/frontend/src/pages/MapPage/RoadInfo.tsx
+++ b/frontend/src/pages/MapPage/RoadInfo.tsx
@@ -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 (
- {["distanceOvertaker", "distanceStationary", "speed"].map((prop) => (
+ {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
{t(`MapPage.roadInfo.${prop}`)}
@@ -76,58 +59,52 @@ function RoadStatsTable({ data }) {
- {["count", "min", "median", "max", "mean"].map((stat) => (
+ {['count', 'min', 'median', 'max', 'mean'].map((stat) => (
{t(`MapPage.roadInfo.${stat}`)}
- {["distanceOvertaker", "distanceStationary", "speed"].map(
- (prop) => (
-
- {(
- data[prop]?.statistics?.[stat] *
- (prop === `speed` && stat != "count" ? 3.6 : 1)
- ).toFixed(stat === "count" ? 0 : 2)}
- {stat !== "count" && ` ${UNITS[prop]}`}
-
- )
- )}
+ {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
+
+ {(data[prop]?.statistics?.[stat] * (prop === `speed` && stat != 'count' ? 3.6 : 1)).toFixed(
+ stat === 'count' ? 0 : 2
+ )}
+ {stat !== 'count' && ` ${UNITS[prop]}`}
+
+ ))}
))}
- );
+ )
}
-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 (
`${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 = (
+ <>
+
+ {info?.road.name || t('MapPage.roadInfo.unnamedWay')}
+
+
- const loading = info == null;
+ {hasFilters && (
+
+
+ {t('MapPage.roadInfo.hintFiltersNotApplied')}
+
+ )}
- const offsetDirection = info?.road?.oneway
- ? 0
- : direction === "forwards"
- ? 1
- : -1; // TODO: change based on left-hand/right-hand traffic
+ {info?.road.zone && (
+
+ {t(`general.zone.${info.road.zone}`)}
+
+ )}
- const content =
- !loading && !info.road ? (
- "No road found."
- ) : (
- <>
-
- {loading
- ? "..."
- : info?.road.name || t("MapPage.roadInfo.unnamedWay")}
+ {info?.road.oneway && (
+
+ {t('MapPage.roadInfo.oneway')}
+
+ )}
-
-
+ {info?.road.oneway ? null : (
+
+ )}
- {hasFilters && (
-
-
-
- {t("MapPage.roadInfo.hintFiltersNotApplied")}
-
-
- )}
+ {info?.[direction] && }
- {info?.road.zone && (
-
- {t(`general.zone.${info.road.zone}`)}
-
- )}
-
- {info?.road.oneway && (
-
- {" "}
- {t("MapPage.roadInfo.oneway")}
-
- )}
-
- {info?.road.oneway ? null : (
-
- )}
-
- {info?.[direction] && }
-
- {info?.[direction]?.distanceOvertaker?.histogram && (
- <>
-
- {t("MapPage.roadInfo.overtakerDistanceDistribution")}
-
-
- >
- )}
- >
- );
+ {info?.[direction]?.distanceOvertaker?.histogram && (
+ <>
+ {t('MapPage.roadInfo.overtakerDistanceDistribution')}
+
+ >
+ )}
+ >
+ )
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({
)}
- {content && (
-
- {content}
-
- )}
+ {content && mapInfoPortal && createPortal({content}
, mapInfoPortal)}
>
- );
+ )
}
diff --git a/frontend/src/pages/MapPage/index.tsx b/frontend/src/pages/MapPage/index.tsx
index 921130c..a2afaaa 100644
--- a/frontend/src/pages/MapPage/index.tsx
+++ b/frontend/src/pages/MapPage/index.tsx
@@ -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(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)
+
+ 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 (
-
+
{layerSidebar && (
)}
-
- );
+ )
}
-export default connect((state) => ({ login: state.login }))(MapPage);
+export default connect((state) => ({login: state.login}))(MapPage)
diff --git a/frontend/src/pages/MapPage/styles.module.less b/frontend/src/pages/MapPage/styles.module.less
index 9af8b42..a88c095 100644
--- a/frontend/src/pages/MapPage/styles.module.less
+++ b/frontend/src/pages/MapPage/styles.module.less
@@ -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;
+ }
+}
diff --git a/frontend/src/reducers/mapConfig.ts b/frontend/src/reducers/mapConfig.ts
index 2ec5398..56151d3 100644
--- a/frontend/src/reducers/mapConfig.ts
+++ b/frontend/src/reducers/mapConfig.ts
@@ -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
}
}
diff --git a/roads_import.lua b/roads_import.lua
index 2c1c45b..10614f6 100644
--- a/roads_import.lua
+++ b/roads_import.lua
@@ -50,6 +50,9 @@ local MOTORWAY_TYPES = {
"motorway_link",
}
+local ADMIN_LEVEL_MIN = 2
+local ADMIN_LEVEL_MAX = 8
+
local ONEWAY_YES = {"yes", "true", "1"}
local ONEWAY_REVERSE = {"reverse", "-1"}
@@ -63,6 +66,13 @@ local roads = osm2pgsql.define_way_table('road', {
local minspeed_rural = 60
+local regions = osm2pgsql.define_relation_table('region', {
+ { column = 'name', type = 'text' },
+ { column = 'geometry', type = 'geometry' },
+ { column = 'admin_level', type = 'int' },
+ { column = 'tags', type = 'hstore' },
+})
+
function osm2pgsql.process_way(object)
if object.tags.highway and contains(HIGHWAY_TYPES, object.tags.highway) then
local tags = object.tags
@@ -131,3 +141,21 @@ function osm2pgsql.process_way(object)
})
end
end
+
+function osm2pgsql.process_relation(object)
+ local admin_level = tonumber(object.tags.admin_level)
+ if object.tags.boundary == "administrative" and admin_level and admin_level >= ADMIN_LEVEL_MIN and admin_level <= ADMIN_LEVEL_MAX then
+ regions:add_row({
+ geometry = { create = 'area' },
+ name = object.tags.name,
+ admin_level = admin_level,
+ tags = object.tags,
+ })
+ end
+end
+
+function osm2pgsql.select_relation_members(relation)
+ if relation.tags.type == 'route' then
+ return { ways = osm2pgsql.way_member_ids(relation) }
+ end
+end
diff --git a/tile-generator/layers/obs_events/layer.sql b/tile-generator/layers/obs_events/layer.sql
index 574d67c..ffac876 100644
--- a/tile-generator/layers/obs_events/layer.sql
+++ b/tile-generator/layers/obs_events/layer.sql
@@ -15,6 +15,7 @@ RETURNS TABLE(event_id bigint, geometry geometry, distance_overtaker float, dist
FULL OUTER JOIN road ON road.way_id = overtaking_event.way_id
JOIN track on track.id = overtaking_event.track_id
WHERE ST_Transform(overtaking_event.geometry, 3857) && bbox
+ AND zoom_level >= 10
AND (user_id is NULL OR user_id = track.author_id)
AND time BETWEEN COALESCE(min_time, '1900-01-01'::timestamp) AND COALESCE(max_time, '2100-01-01'::timestamp);
diff --git a/tile-generator/layers/obs_regions/layer.sql b/tile-generator/layers/obs_regions/layer.sql
new file mode 100644
index 0000000..fda2a44
--- /dev/null
+++ b/tile-generator/layers/obs_regions/layer.sql
@@ -0,0 +1,26 @@
+DROP FUNCTION IF EXISTS layer_obs_regions(geometry, int);
+
+CREATE OR REPLACE FUNCTION layer_obs_regions(bbox geometry, zoom_level int)
+RETURNS TABLE(
+ region_id bigint,
+ geometry geometry,
+ name text,
+ admin_level int,
+ overtaking_event_count int
+) AS $$
+
+ SELECT
+ region.relation_id::bigint as region_id,
+ ST_SimplifyPreserveTopology(region.geometry, ZRes(zoom_level + 2)) as geometry,
+ region.name as name,
+ region.admin_level as admin_level,
+ count(overtaking_event.id)::int as overtaking_event_count
+ FROM region
+ LEFT JOIN overtaking_event on ST_Within(ST_Transform(overtaking_event.geometry, 3857), region.geometry)
+ WHERE
+ zoom_level >= 4 AND
+ zoom_level <= 12 AND
+ ST_Transform(region.geometry, 3857) && bbox
+ GROUP BY region.relation_id, region.name, region.geometry, region.admin_level
+
+$$ LANGUAGE SQL IMMUTABLE;
diff --git a/tile-generator/layers/obs_regions/obs_regions.yaml b/tile-generator/layers/obs_regions/obs_regions.yaml
new file mode 100644
index 0000000..477bf45
--- /dev/null
+++ b/tile-generator/layers/obs_regions/obs_regions.yaml
@@ -0,0 +1,23 @@
+layer:
+ id: "obs_regions"
+ description: |
+ Statistics on administrative boundary areas ("regions")
+ buffer_size: 4
+ fields:
+ overtaking_event_count: |
+ Number of overtaking events.
+ name: |
+ Name of the region
+ admin_level: |
+ Administrative level of the boundary, as tagged in OpenStreetMap
+ defaults:
+ srs: EPSG:3785
+ datasource:
+ srid: 3857
+ geometry_field: geometry
+ key_field: region_id
+ key_field_as_attribute: no
+ query: (SELECT region_id, geometry, name, admin_level, overtaking_event_count FROM layer_obs_regions(!bbox!, z(!scale_denominator!))) AS t
+
+schema:
+ - ./layer.sql
diff --git a/tile-generator/layers/obs_roads/layer.sql b/tile-generator/layers/obs_roads/layer.sql
index 2073150..c381590 100644
--- a/tile-generator/layers/obs_roads/layer.sql
+++ b/tile-generator/layers/obs_roads/layer.sql
@@ -67,6 +67,7 @@ RETURNS TABLE(
) e on (e.way_id = road.way_id and (road.directionality != 0 or e.direction_reversed = r.rev))
WHERE road.geometry && bbox
+ AND zoom_level >= 10
GROUP BY
road.name,
road.way_id,
diff --git a/tile-generator/openbikesensor.yaml b/tile-generator/openbikesensor.yaml
index d3a7a65..3aedd7e 100644
--- a/tile-generator/openbikesensor.yaml
+++ b/tile-generator/openbikesensor.yaml
@@ -3,6 +3,7 @@ tileset:
layers:
- layers/obs_events/obs_events.yaml
- layers/obs_roads/obs_roads.yaml
+ - layers/obs_regions/obs_regions.yaml
version: 0.7.0
id: openbikesensor
description: >