From ec53591ce00268a2b6d163befc4fc2c181f78737 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:39:23 +0100 Subject: [PATCH 1/8] Create Region table --- .../a049e5eb24dd_create_table_region.py | 35 +++++++++++++++++++ api/obs/api/db.py | 10 ++++++ 2 files changed, 45 insertions(+) create mode 100644 api/migrations/versions/a049e5eb24dd_create_table_region.py 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", From 7e51976c0648afee0595edafd11442b13b412fce Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:39:50 +0100 Subject: [PATCH 2/8] Import regions from administrative boundaries --- roads_import.lua | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) 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 From 78561d5929803ac3ffce0066fc457b4d95fff3b0 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:40:06 +0100 Subject: [PATCH 3/8] Add route to expose region stats --- api/obs/api/routes/stats.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) 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) From bea4174b3780c9785aaa79baf396ec6b6b137db1 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:40:57 +0100 Subject: [PATCH 4/8] Do not generate roads and events for tiles at low zoom levels --- tile-generator/layers/obs_events/layer.sql | 1 + tile-generator/layers/obs_roads/layer.sql | 1 + 2 files changed, 2 insertions(+) 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_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, From 3a97b07325e9538bbcbdd1cb1e2230da0645c7ee Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:41:09 +0100 Subject: [PATCH 5/8] Add tile layer for regions with event count --- tile-generator/layers/obs_regions/layer.sql | 26 +++++++++++++++++++ .../layers/obs_regions/obs_regions.yaml | 23 ++++++++++++++++ tile-generator/openbikesensor.yaml | 1 + 3 files changed, 50 insertions(+) create mode 100644 tile-generator/layers/obs_regions/layer.sql create mode 100644 tile-generator/layers/obs_regions/obs_regions.yaml 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/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: > From 382db5a11e5896ff09634fcc2b3fd51db7692675 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:41:41 +0100 Subject: [PATCH 6/8] Expose OBS map source for all zoom levels --- api/obs/api/routes/frontend.py | 2 +- frontend/config.example.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 } } From 7ae4ebebb6168a64cdadacd9f8a2fab5f565a90e Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:42:42 +0100 Subject: [PATCH 7/8] Show region stats on home page --- frontend/src/components/RegionStats/index.tsx | 83 +++++++++++++++++++ frontend/src/pages/HomePage.tsx | 5 +- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/RegionStats/index.tsx 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 ( + <> +
Top Regions
+ +
+ + + + + + 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/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() { + - + From 518bcd81ef7ca30d7f7fc715da105183cc8f2ef2 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:43:08 +0100 Subject: [PATCH 8/8] Show regions on map page, and move on-click info panel into a proper sidebar --- frontend/src/components/ColorMapLegend.tsx | 4 +- frontend/src/components/Map/index.tsx | 154 ++++---- frontend/src/components/index.js | 3 +- frontend/src/mapstyles/index.js | 54 +++ frontend/src/pages/MapPage/LayerSidebar.tsx | 259 ++++++------ frontend/src/pages/MapPage/RegionInfo.tsx | 33 ++ frontend/src/pages/MapPage/RoadInfo.tsx | 371 ++++++++---------- frontend/src/pages/MapPage/index.tsx | 334 ++++++++-------- frontend/src/pages/MapPage/styles.module.less | 36 +- frontend/src/reducers/mapConfig.ts | 105 +++-- 10 files changed, 692 insertions(+), 661 deletions(-) create mode 100644 frontend/src/pages/MapPage/RegionInfo.tsx 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/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/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')} ({ @@ -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)} /> - {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)} /> {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 && ( + + )} - const content = - !loading && !info.road ? ( - "No road found." - ) : ( - <> -
- {loading - ? "..." - : info?.road.name || t("MapPage.roadInfo.unnamedWay")} + {info?.road.oneway && ( + + )} -
+ {info?.road.oneway ? null : ( + + {t('MapPage.roadInfo.direction')} + + {getCardinalDirection(t, info?.forwards?.bearing)} + + + {getCardinalDirection(t, info?.backwards?.bearing)} + + + )} - {hasFilters && ( - - - - {t("MapPage.roadInfo.hintFiltersNotApplied")} - - - )} + {info?.[direction] && } - {info?.road.zone && ( - - )} - - {info?.road.oneway && ( - - )} - - {info?.road.oneway ? null : ( - - {t("MapPage.roadInfo.direction")} - - {getCardinalDirection(t, info?.forwards?.bearing)} - - - {getCardinalDirection(t, info?.backwards?.bearing)} - - - )} - - {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 && (
)}
- +
-
{layers.map((layer) => ( @@ -243,14 +256,23 @@ function MapPage({ login }) { ))} - + {details?.type === 'road' && details?.road?.road && ( + + )} + + {details?.type === 'region' && details?.region && ( + + )}
- ); + ) } -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 } }