From ec53591ce00268a2b6d163befc4fc2c181f78737 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:39:23 +0100 Subject: [PATCH 01/75] 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 02/75] 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 03/75] 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 04/75] 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 05/75] 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 06/75] 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 07/75] 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 08/75] 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 } } From 6d71b88010eab4b275e97810b681b2e15ab176b2 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 12:57:05 +0100 Subject: [PATCH 09/75] Translate region frontend --- frontend/src/components/RegionStats/index.tsx | 94 +++++++++---------- frontend/src/pages/MapPage/LayerSidebar.tsx | 4 +- frontend/src/pages/MapPage/RegionInfo.tsx | 26 +++-- frontend/src/translations/de.yaml | 13 +++ frontend/src/translations/en.yaml | 13 +++ frontend/src/translations/fr.yaml | 13 +++ 6 files changed, 95 insertions(+), 68 deletions(-) diff --git a/frontend/src/components/RegionStats/index.tsx b/frontend/src/components/RegionStats/index.tsx index 5dac6b8..6aafa1e 100644 --- a/frontend/src/components/RegionStats/index.tsx +++ b/frontend/src/components/RegionStats/index.tsx @@ -1,46 +1,36 @@ -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 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"; +import api from 'api' +import {useTranslation} from 'react-i18next' function formatDuration(seconds) { return ( Duration.fromMillis((seconds ?? 0) * 1000) - .as("hours") - .toFixed(1) + " h" - ); + .as('hours') + .toFixed(1) + ' h' + ) } export default function Stats() { - const [page, setPage] = useState(1); - const PER_PAGE = 10; + const {t} = useTranslation() + const [page, setPage] = useState(1) + const PER_PAGE = 10 const stats = useObservable( - () => - of(null).pipe( - switchMap(() => concat(of(null), from(api.get("/stats/regions")))) - ), + () => of(null).pipe(switchMap(() => concat(of(null), from(api.get('/stats/regions'))))), null - ); + ) - const pageCount = stats ? Math.ceil(stats.length / PER_PAGE) : 1; + const pageCount = stats ? Math.ceil(stats.length / PER_PAGE) : 1 return ( <> -
Top Regions
+
{t('RegionStats.title')}
@@ -48,36 +38,36 @@ export default function Stats() { - Region name - Event count + {t('RegionStats.regionName')} + {t('RegionStats.eventCount')} - {stats - ?.slice((page - 1) * PER_PAGE, page * PER_PAGE) - ?.map((area) => ( - - {area.name} - {area.overtaking_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)} - /> - - - } + {pageCount > 1 && ( + + + + setPage(data.activePage as number)} + /> + + + + )}
- ); + ) } diff --git a/frontend/src/pages/MapPage/LayerSidebar.tsx b/frontend/src/pages/MapPage/LayerSidebar.tsx index 0120bc3..7dcf698 100644 --- a/frontend/src/pages/MapPage/LayerSidebar.tsx +++ b/frontend/src/pages/MapPage/LayerSidebar.tsx @@ -72,12 +72,12 @@ function LayerSidebar({ onChange={() => setMapConfigFlag('obsRegions.show', !showRegions)} /> {showRegions && ( <> - Color regions based on event count + {t('MapPage.sidebar.obsRegions.colorByEventCount')}
-
{region.properties.name || "Unnamed region"}
+
{region.properties.name || t('MapPage.regionInfo.unnamedRegion')}
@@ -17,17 +18,14 @@ export default function RegionInfo({ region, mapInfoPortal, onClose }) { - Number of events + {t('MapPage.regionInfo.eventCount')} {region.properties.overtaking_event_count ?? 0} - ); + ) return content && mapInfoPortal - ? createPortal( -
{content}
, - mapInfoPortal - ) - : null; + ? createPortal(
{content}
, mapInfoPortal) + : null } diff --git a/frontend/src/translations/de.yaml b/frontend/src/translations/de.yaml index 0097b68..a0f42ff 100644 --- a/frontend/src/translations/de.yaml +++ b/frontend/src/translations/de.yaml @@ -158,6 +158,10 @@ MapPage: obsEvents: title: Überholvorgänge + obsRegions: + title: Regionen + colorByEventCount: Regionen eingefärbt nach Anzahl Überholungen + filters: title: Filter needsLogin: Filter sind ohne Login nicht verfügbar. @@ -203,6 +207,10 @@ MapPage: west: westwärts northWest: nordwestwärts + regionInfo: + unnamedRegion: Unbenannte Region + eventCount: Anzahl Überholungen + SettingsPage: title: Einstellungen @@ -343,3 +351,8 @@ TrackEditor: vorhanden bleiben. **Nutze diese Funktion mit Bedacht und auf dein eigenes Risiko.** + +RegionStats: + title: Top-Regionen + regionName: Region + eventCount: Anzahl Überholungen diff --git a/frontend/src/translations/en.yaml b/frontend/src/translations/en.yaml index 15c4aeb..14de675 100644 --- a/frontend/src/translations/en.yaml +++ b/frontend/src/translations/en.yaml @@ -164,6 +164,10 @@ MapPage: obsEvents: title: Event points + obsRegions: + title: Regions + colorByEventCount: Color regions are based on event count + filters: title: Filters needsLogin: No filters available without login. @@ -208,6 +212,10 @@ MapPage: west: west bound northWest: north-west bound + regionInfo: + unnamedRegion: Unnamed region + eventCount: Event count + SettingsPage: title: Settings @@ -342,3 +350,8 @@ TrackEditor: later. **Use at your own risk.** + +RegionStats: + title: Top regions + regionName: Region name + eventCount: Event count diff --git a/frontend/src/translations/fr.yaml b/frontend/src/translations/fr.yaml index 7248f70..6367382 100644 --- a/frontend/src/translations/fr.yaml +++ b/frontend/src/translations/fr.yaml @@ -164,6 +164,10 @@ MapPage: obsEvents: title: Points d'événement + obsRegions: + title: Régions + colorByEventCount: Couleurs des régions sont basées sur le nombre d'événements + filters: title: Filtres needsLogin: Aucun filtre disponible sans être connecté. @@ -208,6 +212,10 @@ MapPage: west: Ouest northWest: Nord-Ouest + regionInfo: + unnamedRegion: Région sans nom + eventCount: Nombre de dépassements + SettingsPage: title: Paramètres @@ -343,3 +351,8 @@ TrackEditor: ou les rendez anonymes plus tard. **Utilisation à vos risques et périls.** + +RegionStats: + title: Top régions + regionName: Nom de la région + eventCount: Nombre de dépassements From 215801f2b0074280d9d51354db2299fc150eb82e Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 12 Mar 2023 13:09:10 +0100 Subject: [PATCH 10/75] Regions: Fix migration order --- api/migrations/versions/a049e5eb24dd_create_table_region.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/migrations/versions/a049e5eb24dd_create_table_region.py b/api/migrations/versions/a049e5eb24dd_create_table_region.py index 7e50667..1970967 100644 --- a/api/migrations/versions/a049e5eb24dd_create_table_region.py +++ b/api/migrations/versions/a049e5eb24dd_create_table_region.py @@ -13,7 +13,7 @@ from migrations.utils import dbtype # revision identifiers, used by Alembic. revision = "a049e5eb24dd" -down_revision = "a9627f63fbed" +down_revision = "99a3d2eb08f9" branch_labels = None depends_on = None From 78dca1477c7cb61f9baf133cdd32d28f33cbdd7e Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Fri, 16 Sep 2022 10:14:56 +0200 Subject: [PATCH 11/75] Fix naming of AUTO_RELOAD/AUTO_RESTART --- api/config.dev.py | 2 +- api/config.py.example | 2 +- deployment/examples/config.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/config.dev.py b/api/config.dev.py index e146973..7889740 100644 --- a/api/config.dev.py +++ b/api/config.dev.py @@ -2,7 +2,7 @@ HOST = "0.0.0.0" PORT = 3000 DEBUG = True VERBOSE = False -AUTO_RESTART = True +AUTO_RELOAD = True SECRET = "!!!!!!!!!!!!CHANGE ME!!!!!!!!!!!!" LEAN_MODE = False POSTGRES_URL = "postgresql+asyncpg://obs:obs@postgres/obs" diff --git a/api/config.py.example b/api/config.py.example index 2310250..9e6096d 100644 --- a/api/config.py.example +++ b/api/config.py.example @@ -5,7 +5,7 @@ PORT = 3000 # Extended log output, but slower DEBUG = False VERBOSE = DEBUG -AUTO_RESTART = DEBUG +AUTO_RELOAD = DEBUG # Turn on lean mode to simplify the setup. Lots of features will be # unavailable, but you will not need to manage OpenStreetMap data. Please make diff --git a/deployment/examples/config.py b/deployment/examples/config.py index 5a65d36..1927434 100644 --- a/deployment/examples/config.py +++ b/deployment/examples/config.py @@ -5,7 +5,7 @@ # Extended log output, but slower DEBUG = False VERBOSE = DEBUG -AUTO_RESTART = DEBUG +AUTO_RELOAD = DEBUG # Turn on lean mode to simplify the setup. Lots of features will be # unavailable, but you will not need to manage OpenStreetMap data. Please make From b9aaf23e0aa19f63b0b5399de5489f9330382b6f Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Fri, 16 Sep 2022 11:47:15 +0200 Subject: [PATCH 12/75] Clean up sanic logging --- api/obs/api/app.py | 27 ++++++++++++++++++++++++++- api/obs/bin/openbikesensor_api.py | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/api/obs/api/app.py b/api/obs/api/app.py index d70dd83..49519e6 100644 --- a/api/obs/api/app.py +++ b/api/obs/api/app.py @@ -26,11 +26,36 @@ from sqlalchemy.util import asyncio log = logging.getLogger(__name__) + +class SanicAccessMessageFilter(logging.Filter): + """ + A filter that modifies the log message of a sanic.access log entry to + include useful information. + """ + + def filter(self, record): + record.msg = f"{record.request} -> {record.status}" + return True + + +def configure_sanic_logging(): + for logger_name in ["sanic.root", "sanic.access", "sanic.error"]: + logger = logging.getLogger(logger_name) + for handler in logger.handlers: + logger.removeHandler(handler) + + logger = logging.getLogger("sanic.access") + for filter_ in logger.filters: + logger.removeFilter(filter_) + logger.addFilter(SanicAccessMessageFilter()) + logging.getLogger("sanic.root").setLevel(logging.WARNING) + + app = Sanic( "openbikesensor-api", env_prefix="OBS_", - log_config={}, ) +configure_sanic_logging() if isfile("./config.py"): app.update_config("./config.py") diff --git a/api/obs/bin/openbikesensor_api.py b/api/obs/bin/openbikesensor_api.py index 19938aa..c43ae2a 100755 --- a/api/obs/bin/openbikesensor_api.py +++ b/api/obs/bin/openbikesensor_api.py @@ -58,7 +58,7 @@ def main(): port=app.config.PORT, debug=debug, auto_reload=app.config.get("AUTO_RELOAD", debug), - # access_log=False, + access_log=True, ) From ed272b4e4aad0e0fa77556e02a8e9ff6925d3e36 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Fri, 16 Sep 2022 11:47:33 +0200 Subject: [PATCH 13/75] Use TTY in development docker to get line-buffered prints --- docker-compose.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index ac68236..733a51a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,6 +20,7 @@ services: api: image: openbikesensor-api + tty: true build: context: ./api/ dockerfile: Dockerfile @@ -46,6 +47,7 @@ services: worker: image: openbikesensor-api + tty: true build: context: ./api/ dockerfile: Dockerfile From 84ab957aa0a66312305c676bd12cbe6c9c573382 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Fri, 16 Sep 2022 11:45:48 +0200 Subject: [PATCH 14/75] fix cors by implementing it ourselves --- api/obs/api/app.py | 67 ++++++++++++++++++++++---------------------- api/obs/api/cors.py | 67 ++++++++++++++++++++++++++++++++++++++++++++ api/requirements.txt | 3 +- api/setup.py | 3 +- 4 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 api/obs/api/cors.py diff --git a/api/obs/api/app.py b/api/obs/api/app.py index 49519e6..fa14e1a 100644 --- a/api/obs/api/app.py +++ b/api/obs/api/app.py @@ -21,6 +21,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from obs.api.db import User, make_session, connect_db +from obs.api.cors import setup_options, add_cors_headers from obs.api.utils import get_single_arg from sqlalchemy.util import asyncio @@ -84,6 +85,39 @@ class NoConnectionLostFilter(logging.Filter): logging.getLogger("sanic.error").addFilter(NoConnectionLostFilter) +def setup_cors(app): + frontend_url = app.config.get("FRONTEND_URL") + additional_origins = app.config.get("ADDITIONAL_CORS_ORIGINS") + if not frontend_url and not additional_origins: + # No CORS configured + return + + origins = [] + if frontend_url: + u = urlparse(frontend_url) + origins.append(f"{u.scheme}://{u.netloc}") + + if isinstance(additional_origins, str): + origins += re.split(r"\s+", additional_origins) + elif isinstance(additional_origins, list): + origins += additional_origins + elif additional_origins is not None: + raise ValueError( + "invalid option type for ADDITIONAL_CORS_ORIGINS, must be list or space separated str" + ) + + app.ctx.cors_origins = origins + + # Add OPTIONS handlers to any route that is missing it + app.register_listener(setup_options, "before_server_start") + + # Fill in CORS headers + app.register_middleware(add_cors_headers, "response") + + +setup_cors(app) + + @app.exception(SanicException, BaseException) async def _handle_sanic_errors(_request, exception): if isinstance(exception, asyncio.CancelledError): @@ -120,39 +154,6 @@ def configure_paths(c): configure_paths(app.config) -def setup_cors(app): - frontend_url = app.config.get("FRONTEND_URL") - additional_origins = app.config.get("ADDITIONAL_CORS_ORIGINS") - if not frontend_url and not additional_origins: - # No CORS configured - return - - origins = [] - if frontend_url: - u = urlparse(frontend_url) - origins.append(f"{u.scheme}://{u.netloc}") - - if isinstance(additional_origins, str): - origins += re.split(r"\s+", additional_origins) - elif isinstance(additional_origins, list): - origins += additional_origins - elif additional_origins is not None: - raise ValueError( - "invalid option type for ADDITIONAL_CORS_ORIGINS, must be list or space separated str" - ) - - from sanic_cors import CORS - - CORS( - app, - origins=origins, - supports_credentials=True, - expose_headers={"Content-Disposition"}, - ) - - -setup_cors(app) - # TODO: use a different interface, maybe backed by the PostgreSQL, to allow # scaling the API Session(app, interface=InMemorySessionInterface()) diff --git a/api/obs/api/cors.py b/api/obs/api/cors.py new file mode 100644 index 0000000..e46d6f1 --- /dev/null +++ b/api/obs/api/cors.py @@ -0,0 +1,67 @@ +from collections import defaultdict +from typing import Dict, FrozenSet, Iterable + +from sanic import Sanic, response +from sanic_routing.router import Route + + +def _add_cors_headers(request, response, methods: Iterable[str]) -> None: + allow_methods = list(set(methods)) + + if "OPTIONS" not in allow_methods: + allow_methods.append("OPTIONS") + + origin = request.headers.get("origin") + if origin in request.app.ctx.cors_origins: + headers = { + "Access-Control-Allow-Methods": ",".join(allow_methods), + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": ( + "origin, content-type, accept, " + "authorization, x-xsrf-token, x-request-id" + ), + } + response.headers.extend(headers) + + +def add_cors_headers(request, response): + if request.method != "OPTIONS": + methods = [method for method in request.route.methods] + _add_cors_headers(request, response, methods) + + +def _compile_routes_needing_options(routes: Dict[str, Route]) -> Dict[str, FrozenSet]: + needs_options = defaultdict(list) + # This is 21.12 and later. You will need to change this for older versions. + for route in routes.values(): + if "OPTIONS" not in route.methods: + needs_options[route.uri].extend(route.methods) + + return {uri: frozenset(methods) for uri, methods in dict(needs_options).items()} + + +def _options_wrapper(handler, methods): + def wrapped_handler(request, *args, **kwargs): + nonlocal methods + return handler(request, methods) + + return wrapped_handler + + +async def options_handler(request, methods) -> response.HTTPResponse: + resp = response.empty() + _add_cors_headers(request, resp, methods) + return resp + + +def setup_options(app: Sanic, _): + app.router.reset() + needs_options = _compile_routes_needing_options(app.router.routes_all) + for uri, methods in needs_options.items(): + app.add_route( + _options_wrapper(options_handler, methods), + uri, + methods=["OPTIONS"], + ) + app.router.finalize() diff --git a/api/requirements.txt b/api/requirements.txt index 837e553..ee242b4 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,8 +1,7 @@ coloredlogs~=15.0.1 -sanic~=22.6.0 +sanic==22.6.2 oic~=1.3.0 sanic-session~=0.8.0 -sanic-cors~=2.0.1 python-slugify~=6.1.2 motor~=3.0.0 pyyaml<6 diff --git a/api/setup.py b/api/setup.py index e76b57f..2b8ce1e 100644 --- a/api/setup.py +++ b/api/setup.py @@ -11,10 +11,9 @@ setup( package_data={}, install_requires=[ "coloredlogs~=15.0.1", - "sanic>=21.9.3,<22.7.0", + "sanic==22.6.2", "oic>=1.3.0, <2", "sanic-session~=0.8.0", - "sanic-cors~=2.0.1", "python-slugify>=5.0.2,<6.2.0", "motor>=2.5.1,<3.1.0", "pyyaml<6", From 6c458a43f666f4bdd546b85a77c63e2c3123e11b Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Fri, 16 Sep 2022 11:48:05 +0200 Subject: [PATCH 15/75] Raise maximum on track page limit --- api/obs/api/routes/tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/obs/api/routes/tracks.py b/api/obs/api/routes/tracks.py index 8ae1c6a..28f4097 100644 --- a/api/obs/api/routes/tracks.py +++ b/api/obs/api/routes/tracks.py @@ -24,7 +24,7 @@ def normalize_user_agent(user_agent): async def _return_tracks(req, extend_query, limit, offset): - if limit <= 0 or limit > 100: + if limit <= 0 or limit > 1000: raise InvalidUsage("invalid limit") if offset < 0: From 56905fdf751c7991612c963f275d99c7c924ab27 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Mon, 26 Sep 2022 11:45:49 +0200 Subject: [PATCH 16/75] Install stream-zip --- api/requirements.txt | 1 + api/setup.py | 1 + 2 files changed, 2 insertions(+) diff --git a/api/requirements.txt b/api/requirements.txt index 48277a6..2682253 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -11,3 +11,4 @@ sqlalchemy[asyncio]~=1.4.39 <2.0 asyncpg~=0.24.0 pyshp~=2.3.1 alembic~=1.7.7 +stream-zip~=0.0.50 diff --git a/api/setup.py b/api/setup.py index 2b8ce1e..3a5f09f 100644 --- a/api/setup.py +++ b/api/setup.py @@ -23,6 +23,7 @@ setup( "sqlalchemy[asyncio]~=1.4.25", "asyncpg~=0.24.0", "alembic~=1.7.7", + "stream-zip~=0.0.50", ], entry_points={ "console_scripts": [ From 5a78d7eb38437bf52ccad5235ddc0ad13dfcfcc6 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Fri, 16 Sep 2022 10:23:25 +0200 Subject: [PATCH 17/75] Parse device identifiers and create UserDevice entries in database --- .../versions/f7b21148126a_add_user_device.py | 41 +++++++++++++++++++ api/obs/api/db.py | 36 ++++++++++++++++ api/obs/api/process.py | 37 +++++++++++++++-- api/scripts | 2 +- 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 api/migrations/versions/f7b21148126a_add_user_device.py diff --git a/api/migrations/versions/f7b21148126a_add_user_device.py b/api/migrations/versions/f7b21148126a_add_user_device.py new file mode 100644 index 0000000..2e65451 --- /dev/null +++ b/api/migrations/versions/f7b21148126a_add_user_device.py @@ -0,0 +1,41 @@ +"""add user_device + +Revision ID: f7b21148126a +Revises: a9627f63fbed +Create Date: 2022-09-15 17:48:06.764342 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "f7b21148126a" +down_revision = "a049e5eb24dd" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "user_device", + sa.Column("id", sa.Integer, autoincrement=True, primary_key=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("user.id", ondelete="CASCADE")), + sa.Column("identifier", sa.String, nullable=False), + sa.Column("display_name", sa.String, nullable=True), + sa.Index("user_id_identifier", "user_id", "identifier", unique=True), + ) + op.add_column( + "track", + sa.Column( + "user_device_id", + sa.Integer, + sa.ForeignKey("user_device.id", ondelete="RESTRICT"), + nullable=True, + ), + ) + + +def downgrade(): + op.drop_column("track", "user_device_id") + op.drop_table("user_device") diff --git a/api/obs/api/db.py b/api/obs/api/db.py index f7716a6..74b5b20 100644 --- a/api/obs/api/db.py +++ b/api/obs/api/db.py @@ -221,6 +221,12 @@ class Track(Base): Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) + user_device_id = Column( + Integer, + ForeignKey("user_device.id", ondelete="RESTRICT"), + nullable=True, + ) + # Statistics... maybe we'll drop some of this if we can easily compute them from SQL recorded_at = Column(DateTime) recorded_until = Column(DateTime) @@ -409,6 +415,28 @@ class User(Base): self.username = new_name +class UserDevice(Base): + __tablename__ = "user_device" + id = Column(Integer, autoincrement=True, primary_key=True) + user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) + identifier = Column(String, nullable=False) + display_name = Column(String, nullable=True) + + __table_args__ = ( + Index("user_id_identifier", "user_id", "identifier", unique=True), + ) + + def to_dict(self, for_user_id=None): + if for_user_id != self.user_id: + return {} + + return { + "id": self.id, + "identifier": self.identifier, + "displayName": self.display_name, + } + + class Comment(Base): __tablename__ = "comment" id = Column(Integer, autoincrement=True, primary_key=True) @@ -468,6 +496,14 @@ Track.overtaking_events = relationship( passive_deletes=True, ) +Track.user_device = relationship("UserDevice", back_populates="tracks") +UserDevice.tracks = relationship( + "Track", + order_by=Track.created_at, + back_populates="user_device", + passive_deletes=False, +) + # 0..4 Night, 4..10 Morning, 10..14 Noon, 14..18 Afternoon, 18..22 Evening, 22..00 Night # Two hour intervals diff --git a/api/obs/api/process.py b/api/obs/api/process.py index 6fc2c5e..79a39d8 100644 --- a/api/obs/api/process.py +++ b/api/obs/api/process.py @@ -8,7 +8,7 @@ import pytz from os.path import join from datetime import datetime -from sqlalchemy import delete, select +from sqlalchemy import delete, select, and_ from sqlalchemy.orm import joinedload from obs.face.importer import ImportMeasurementsCsv @@ -27,7 +27,7 @@ from obs.face.filter import ( from obs.face.osm import DataSource, DatabaseTileSource, OverpassTileSource -from obs.api.db import OvertakingEvent, RoadUsage, Track, make_session +from obs.api.db import OvertakingEvent, RoadUsage, Track, UserDevice, make_session from obs.api.app import app log = logging.getLogger(__name__) @@ -144,10 +144,11 @@ async def process_track(session, track, data_source): os.makedirs(output_dir, exist_ok=True) log.info("Annotating and filtering CSV file") - imported_data, statistics = ImportMeasurementsCsv().read( + imported_data, statistics, track_metadata = ImportMeasurementsCsv().read( original_file_path, user_id="dummy", # TODO: user username or id or nothing? dataset_id=Track.slug, # TODO: use track id or slug or nothing? + return_metadata=True, ) annotator = AnnotateMeasurements( @@ -217,6 +218,36 @@ async def process_track(session, track, data_source): await clear_track_data(session, track) await session.commit() + device_identifier = track_metadata.get("DeviceId") + if device_identifier: + if isinstance(device_identifier, list): + device_identifier = device_identifier[0] + + log.info("Finding or creating device %s", device_identifier) + user_device = ( + await session.execute( + select(UserDevice).where( + and_( + UserDevice.user_id == track.author_id, + UserDevice.identifier == device_identifier, + ) + ) + ) + ).scalar() + + log.debug("user_device is %s", user_device) + + if not user_device: + user_device = UserDevice( + user_id=track.author_id, identifier=device_identifier + ) + log.debug("Create new device for this user") + session.add(user_device) + + track.user_device = user_device + else: + log.info("No DeviceId in track metadata.") + log.info("Import events into database...") await import_overtaking_events(session, track, overtaking_events) diff --git a/api/scripts b/api/scripts index 8e9395f..bbc6fec 160000 --- a/api/scripts +++ b/api/scripts @@ -1 +1 @@ -Subproject commit 8e9395fd3cd0f1e83b4413546bc2d3cb0c726738 +Subproject commit bbc6feca08aee9ea4f4263bb7c07e199d9c989ee From cbab83e6e3f5d6cc30b4ccbb2b9e6679f48fb550 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Thu, 22 Sep 2022 17:35:12 +0200 Subject: [PATCH 18/75] Build awesome "My Tracks" table with filters and sorting --- api/obs/api/db.py | 1 + api/obs/api/routes/tracks.py | 48 ++- api/obs/api/routes/users.py | 20 +- frontend/src/App.tsx | 219 ++++++++---- frontend/src/pages/MyTracksPage.tsx | 326 ++++++++++++++++++ frontend/src/pages/TrackPage/TrackDetails.tsx | 35 +- frontend/src/pages/index.js | 23 +- frontend/src/types.ts | 89 +++-- frontend/src/utils.js | 43 ++- 9 files changed, 645 insertions(+), 159 deletions(-) create mode 100644 frontend/src/pages/MyTracksPage.tsx diff --git a/api/obs/api/db.py b/api/obs/api/db.py index 74b5b20..4e870ca 100644 --- a/api/obs/api/db.py +++ b/api/obs/api/db.py @@ -259,6 +259,7 @@ class Track(Base): if for_user_id is not None and for_user_id == self.author_id: result["uploadedByUserAgent"] = self.uploaded_by_user_agent result["originalFileName"] = self.original_file_name + result["userDeviceId"] = self.user_device_id if self.author: result["author"] = self.author.to_dict(for_user_id=for_user_id) diff --git a/api/obs/api/routes/tracks.py b/api/obs/api/routes/tracks.py index 28f4097..e9df89c 100644 --- a/api/obs/api/routes/tracks.py +++ b/api/obs/api/routes/tracks.py @@ -23,7 +23,7 @@ def normalize_user_agent(user_agent): return m[0] if m else None -async def _return_tracks(req, extend_query, limit, offset): +async def _return_tracks(req, extend_query, limit, offset, order_by=None): if limit <= 0 or limit > 1000: raise InvalidUsage("invalid limit") @@ -39,7 +39,7 @@ async def _return_tracks(req, extend_query, limit, offset): extend_query(select(Track).options(joinedload(Track.author))) .limit(limit) .offset(offset) - .order_by(Track.created_at.desc()) + .order_by(order_by if order_by is not None else Track.created_at) ) tracks = (await req.ctx.db.execute(query)).scalars() @@ -76,16 +76,56 @@ async def get_tracks(req): return await _return_tracks(req, extend_query, limit, offset) +def parse_boolean(s): + if s is None: + return None + + s = s.lower() + if s in ("true", "1", "yes", "y", "t"): + return True + if s in ("false", "0", "no", "n", "f"): + return False + + raise ValueError("invalid value for boolean") + + @api.get("/tracks/feed") @require_auth async def get_feed(req): limit = req.ctx.get_single_arg("limit", default=20, convert=int) offset = req.ctx.get_single_arg("offset", default=0, convert=int) + user_device_id = req.ctx.get_single_arg("user_device_id", default=None, convert=int) + + order_by_columns = { + "recordedAt": Track.recorded_at, + "title": Track.title, + "visibility": Track.public, + "length": Track.length, + "duration": Track.duration, + "user_device_id": Track.user_device_id, + } + order_by = req.ctx.get_single_arg( + "order_by", default=None, convert=order_by_columns.get + ) + + reversed_ = req.ctx.get_single_arg("reversed", convert=parse_boolean, default=False) + if reversed_: + order_by = order_by.desc() + + public = req.ctx.get_single_arg("public", convert=parse_boolean, default=None) def extend_query(q): - return q.where(Track.author_id == req.ctx.user.id) + q = q.where(Track.author_id == req.ctx.user.id) - return await _return_tracks(req, extend_query, limit, offset) + if user_device_id is not None: + q = q.where(Track.user_device_id == user_device_id) + + if public is not None: + q = q.where(Track.public == public) + + return q + + return await _return_tracks(req, extend_query, limit, offset, order_by) @api.post("/tracks") diff --git a/api/obs/api/routes/users.py b/api/obs/api/routes/users.py index 60b0c19..38ffaf1 100644 --- a/api/obs/api/routes/users.py +++ b/api/obs/api/routes/users.py @@ -1,9 +1,11 @@ import logging from sanic.response import json -from sanic.exceptions import InvalidUsage +from sanic.exceptions import InvalidUsage, Forbidden +from sqlalchemy import select from obs.api.app import api, require_auth +from obs.api.db import UserDevice log = logging.getLogger(__name__) @@ -28,6 +30,22 @@ async def get_user(req): return json(user_to_json(req.ctx.user) if req.ctx.user else None) +@api.get("/user/devices") +async def get_user_devices(req): + if not req.ctx.user: + raise Forbidden() + + query = ( + select(UserDevice) + .where(UserDevice.user_id == req.ctx.user.id) + .order_by(UserDevice.id) + ) + + devices = (await req.ctx.db.execute(query)).scalars() + + return json([device.to_dict(req.ctx.user.id) for device in devices]) + + @api.put("/user") @require_auth async def put_user(req): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9c66e8e..c3d7cfd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,24 @@ -import React from 'react' -import classnames from 'classnames' -import {connect} from 'react-redux' -import {List, Grid, Container, Menu, Header, Dropdown} from 'semantic-ui-react' -import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom' -import {useObservable} from 'rxjs-hooks' -import {from} from 'rxjs' -import {pluck} from 'rxjs/operators' -import {Helmet} from "react-helmet"; -import {useTranslation} from 'react-i18next' +import React from "react"; +import classnames from "classnames"; +import { connect } from "react-redux"; +import { + List, + Grid, + Container, + Menu, + Header, + Dropdown, +} from "semantic-ui-react"; +import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom"; +import { useObservable } from "rxjs-hooks"; +import { from } from "rxjs"; +import { pluck } from "rxjs/operators"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; -import {useConfig} from 'config' -import styles from './App.module.less' -import {AVAILABLE_LOCALES, setLocale} from 'i18n' +import { useConfig } from "config"; +import styles from "./App.module.less"; +import { AVAILABLE_LOCALES, setLocale } from "i18n"; import { ExportPage, @@ -25,50 +32,61 @@ import { TrackPage, TracksPage, UploadPage, -} from 'pages' -import {Avatar, LoginButton} from 'components' -import api from 'api' + MyTracksPage, +} from "pages"; +import { Avatar, LoginButton } from "components"; +import api from "api"; // This component removes the "navigate" prop before rendering a Menu.Item, // which is a workaround for an annoying warning that is somehow caused by the // and combination. -function MenuItemForLink({navigate, ...props}) { +function MenuItemForLink({ navigate, ...props }) { return ( { - e.preventDefault() - navigate() + e.preventDefault(); + navigate(); }} /> - ) + ); } -function DropdownItemForLink({navigate, ...props}) { +function DropdownItemForLink({ navigate, ...props }) { return ( { - e.preventDefault() - navigate() + e.preventDefault(); + navigate(); }} /> - ) + ); } -function Banner({text, style = 'warning'}: {text: string; style: 'warning' | 'info'}) { - return
{text}
+function Banner({ + text, + style = "warning", +}: { + text: string; + style: "warning" | "info"; +}) { + return
{text}
; } -const App = connect((state) => ({login: state.login}))(function App({login}) { - const {t} = useTranslation() - const config = useConfig() - const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version'))) +const App = connect((state) => ({ login: state.login }))(function App({ + login, +}) { + const { t } = useTranslation(); + const config = useConfig(); + const apiVersion = useObservable(() => + from(api.get("/info")).pipe(pluck("version")) + ); - const hasMap = Boolean(config?.obsMapSource) + const hasMap = Boolean(config?.obsMapSource); React.useEffect(() => { - api.loadUser() - }, []) + api.loadUser(); + }, []); return config ? ( @@ -79,36 +97,59 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { {config?.banner && } - + OpenBikeSensor {hasMap && ( - - {t('App.menu.map')} - + + {t("App.menu.map")} + )} - {t('App.menu.tracks')} + {t("App.menu.tracks")} - {t('App.menu.export')} + {t("App.menu.export")} {login ? ( <> - {t('App.menu.myTracks')} + {t("App.menu.myTracks")} - }> + } + > - - + + - + @@ -125,14 +166,16 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { - {hasMap && - - } + {hasMap && ( + + + + )} - + @@ -169,12 +212,14 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { -
- {t('App.footer.aboutTheProject')} -
+
{t("App.footer.aboutTheProject")}
- + openbikesensor.org @@ -182,41 +227,57 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
-
- {t('App.footer.getInvolved')} -
+
{t("App.footer.getInvolved")}
- - {t('App.footer.getHelpInForum')} + + {t("App.footer.getHelpInForum")} - - {t('App.footer.reportAnIssue')} + + {t("App.footer.reportAnIssue")} - - {t('App.footer.development')} + + {t("App.footer.development")}
-
- {t('App.footer.thisInstallation')} -
+
{t("App.footer.thisInstallation")}
- - {t('App.footer.privacyPolicy')} + + {t("App.footer.privacyPolicy")} - - {t('App.footer.imprint')} + + {t("App.footer.imprint")} { config?.termsUrl && @@ -229,21 +290,29 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { - {apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')} + {apiVersion + ? t("App.footer.version", { apiVersion }) + : t("App.footer.versionLoading")}
-
{t('App.footer.changeLanguage')}
+
{t("App.footer.changeLanguage")}
- {AVAILABLE_LOCALES.map(locale => setLocale(locale)}>{t(`locales.${locale}`)})} + {AVAILABLE_LOCALES.map((locale) => ( + + setLocale(locale)}> + {t(`locales.${locale}`)} + + + ))}
@@ -251,7 +320,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
- ) : null -}) + ) : null; +}); -export default App +export default App; diff --git a/frontend/src/pages/MyTracksPage.tsx b/frontend/src/pages/MyTracksPage.tsx new file mode 100644 index 0000000..f8615f3 --- /dev/null +++ b/frontend/src/pages/MyTracksPage.tsx @@ -0,0 +1,326 @@ +import React, { useCallback, useState } from "react"; +import { connect } from "react-redux"; +import { + Accordion, + Button, + Header, + Icon, + Item, + List, + Loader, + Dropdown, + SemanticCOLORS, + SemanticICONS, + Table, +} from "semantic-ui-react"; +import { useObservable } from "rxjs-hooks"; +import { Link } from "react-router-dom"; +import { of, from, concat } from "rxjs"; +import { map, switchMap, distinctUntilChanged } from "rxjs/operators"; +import _ from "lodash"; +import { useTranslation } from "react-i18next"; + +import type { ProcessingStatus, Track, UserDevice } from "types"; +import { Page, FormattedDate, Visibility } from "components"; +import api from "api"; +import { formatDistance, formatDuration } from "utils"; + +const COLOR_BY_STATUS: Record = { + error: "red", + complete: "green", + created: "grey", + queued: "orange", + processing: "orange", +}; + +const ICON_BY_STATUS: Record = { + error: "warning sign", + complete: "check circle outline", + created: "bolt", + queued: "bolt", + processing: "bolt", +}; + +function ProcessingStatusLabel({ status }: { status: ProcessingStatus }) { + const { t } = useTranslation(); + return ( + + + + ); +} + +function SortableHeader({ + children, + setOrderBy, + orderBy, + reversed, + setReversed, + name, + ...props +}) { + const toggleSort = (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (orderBy === name) { + if (!reversed) { + setReversed(true); + } else { + setReversed(false); + setOrderBy(null); + } + } else { + setReversed(false); + setOrderBy(name); + } + }; + + let icon = + orderBy === name ? (reversed ? "sort descending" : "sort ascending") : null; + + return ( + +
+ {children} + +
+
+ ); +} + +type Filters = { + userDeviceId?: null | number; + visibility?: null | boolean; +}; + +function TrackFilters({ + filters, + setFilters, + deviceNames, +}: { + filters: Filters; + setFilters: (f: Filters) => void; + deviceNames: null | Record; +}) { + return ( + + + Device + ({ + value: Number(deviceId), + key: deviceId, + text: deviceName, + }) + ), + ]} + value={filters?.userDeviceId ?? 0} + onChange={(_e, { value }) => + setFilters({ ...filters, userDeviceId: (value as number) || null }) + } + /> + + + + Visibility + + setFilters({ + ...filters, + visibility: value === "none" ? null : (value as boolean), + }) + } + /> + + + ); +} + +function TracksTable() { + const [orderBy, setOrderBy] = useState("recordedAt"); + const [reversed, setReversed] = useState(false); + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({}); + + const query = _.pickBy( + { + limit: 1000, + offset: 0, + order_by: orderBy, + reversed: reversed ? "true" : "false", + user_device_id: filters?.userDeviceId, + public: filters?.visibility, + }, + (x) => x != null + ); + + const tracks: Track[] | null = useObservable( + (_$, inputs$) => + inputs$.pipe( + map(([query]) => query), + distinctUntilChanged(_.isEqual), + switchMap((query) => + concat( + of(null), + from(api.get("/tracks/feed", { query }).then((r) => r.tracks)) + ) + ) + ), + null, + [query] + ); + + const deviceNames: null | Record = useObservable(() => + from(api.get("/user/devices")).pipe( + map((response: UserDevice[]) => + Object.fromEntries( + response.map((device) => [ + device.id, + device.displayName || device.identifier, + ]) + ) + ) + ) + ); + + const { t } = useTranslation(); + + const p = { orderBy, setOrderBy, reversed, setReversed }; + + return ( +
+ + + + setShowFilters(!showFilters)} + > + + Filters + + + + + + + + + + + Title + + + Recorded at + + + Visibility + + + Length + + + Duration + + + Device + + + + + + {tracks?.map((track: Track) => ( + + + {track.processingStatus == null ? null : ( + + )} + + {track.title || t("general.unnamedTrack")} + + + + + + + + + {track.public == null ? null : ( + + )} + + + + {formatDistance(track.length)} + + + + {formatDuration(track.duration)} + + + + {track.userDeviceId + ? deviceNames?.[track.userDeviceId] ?? "..." + : null} + + + ))} + +
+
+ ); +} + +function UploadButton({ navigate, ...props }) { + const { t } = useTranslation(); + const onClick = useCallback( + (e) => { + e.preventDefault(); + navigate(); + }, + [navigate] + ); + return ( + + ); +} + +const MyTracksPage = connect((state) => ({ login: (state as any).login }))( + function MyTracksPage({ login }) { + const { t } = useTranslation(); + + const title = t("TracksPage.titleUser"); + + return ( + + +
{title}
+ +
+ ); + } +); + +export default MyTracksPage; diff --git a/frontend/src/pages/TrackPage/TrackDetails.tsx b/frontend/src/pages/TrackPage/TrackDetails.tsx index d5002b2..c94af57 100644 --- a/frontend/src/pages/TrackPage/TrackDetails.tsx +++ b/frontend/src/pages/TrackPage/TrackDetails.tsx @@ -5,10 +5,7 @@ import { Duration } from "luxon"; import { useTranslation } from "react-i18next"; import { FormattedDate, Visibility } from "components"; - -function formatDuration(seconds) { - return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'"); -} +import { formatDistance, formatDuration } from "utils"; export default function TrackDetails({ track, isAuthor }) { const { t } = useTranslation(); @@ -47,7 +44,7 @@ export default function TrackDetails({ track, isAuthor }) { track?.length != null && [ t("TrackPage.details.length"), - `${(track?.length / 1000).toFixed(2)} km`, + formatDistance(track?.length), ], track?.processingStatus != null && @@ -63,23 +60,23 @@ export default function TrackDetails({ track, isAuthor }) { ].filter(Boolean); const COLUMNS = 4; - const chunkSize = Math.ceil(items.length / COLUMNS) + const chunkSize = Math.ceil(items.length / COLUMNS); return ( - {_.chunk(items, chunkSize).map((chunkItems, idx) => ( - - - - {chunkItems.map(([title, value]) => ( - - {title} - {value} - ))} - - - ))} - + {_.chunk(items, chunkSize).map((chunkItems, idx) => ( + + + {chunkItems.map(([title, value]) => ( + + {title} + {value} + + ))} + + + ))} + ); } diff --git a/frontend/src/pages/index.js b/frontend/src/pages/index.js index 07233ed..6d0a958 100644 --- a/frontend/src/pages/index.js +++ b/frontend/src/pages/index.js @@ -1,11 +1,12 @@ -export {default as ExportPage} from './ExportPage' -export {default as HomePage} from './HomePage' -export {default as LoginRedirectPage} from './LoginRedirectPage' -export {default as LogoutPage} from './LogoutPage' -export {default as MapPage} from './MapPage' -export {default as NotFoundPage} from './NotFoundPage' -export {default as SettingsPage} from './SettingsPage' -export {default as TrackEditor} from './TrackEditor' -export {default as TrackPage} from './TrackPage' -export {default as TracksPage} from './TracksPage' -export {default as UploadPage} from './UploadPage' +export { default as ExportPage } from "./ExportPage"; +export { default as HomePage } from "./HomePage"; +export { default as LoginRedirectPage } from "./LoginRedirectPage"; +export { default as LogoutPage } from "./LogoutPage"; +export { default as MapPage } from "./MapPage"; +export { default as NotFoundPage } from "./NotFoundPage"; +export { default as SettingsPage } from "./SettingsPage"; +export { default as TrackEditor } from "./TrackEditor"; +export { default as TrackPage } from "./TrackPage"; +export { default as TracksPage } from "./TracksPage"; +export { default as MyTracksPage } from "./MyTracksPage"; +export { default as UploadPage } from "./UploadPage"; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e3f5c74..36f5cfd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,52 +1,67 @@ -import type {FeatureCollection, Feature, LineString, Point} from 'geojson' +import type { FeatureCollection, Feature, LineString, Point } from "geojson"; -export type UserProfile = { - id: number | string - displayName: string - image?: string | null - bio?: string | null +export interface UserProfile { + username: string; + displayName: string; + image?: string | null; + bio?: string | null; } -export type TrackData = { - track: Feature - measurements: FeatureCollection - overtakingEvents: FeatureCollection +export interface TrackData { + track: Feature; + measurements: FeatureCollection; + overtakingEvents: FeatureCollection; } -export type Track = { - slug: string - author: UserProfile - title: string - description?: string - createdAt: string - public?: boolean - recordedAt?: Date - recordedUntil?: Date - duration?: number - length?: number - segments?: number - numEvents?: number - numMeasurements?: number - numValid?: number +export type ProcessingStatus = + | "error" + | "complete" + | "created" + | "queued" + | "processing"; + +export interface Track { + slug: string; + author: UserProfile; + title: string; + description?: string; + createdAt: string; + processingStatus?: ProcessingStatus; + public?: boolean; + recordedAt?: Date; + recordedUntil?: Date; + duration?: number; + length?: number; + segments?: number; + numEvents?: number; + numMeasurements?: number; + numValid?: number; + userDeviceId?: number; } -export type TrackPoint = { - type: 'Feature' - geometry: Point +export interface TrackPoint { + type: "Feature"; + geometry: Point; properties: { - distanceOvertaker: null | number - distanceStationary: null | number - } + distanceOvertaker: null | number; + distanceStationary: null | number; + }; } -export type TrackComment = { - id: string - body: string - createdAt: string - author: UserProfile +export interface TrackComment { + id: string; + body: string; + createdAt: string; + author: UserProfile; } -export type Location { +export interface Location { longitude: number; latitude: number; } + +export interface UserDevice { + id: number; + identifier: string; + displayName?: string; +} diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 2fdb1a7..f4f3dc7 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -1,32 +1,51 @@ -import {useRef, useCallback} from 'react' +import { useRef, useCallback } from "react"; +import { Duration } from "luxon"; // Wraps the register callback from useForm into a new ref function, such that // any child of the provided element that is an input component will be // registered. export function findInput(register) { return (element) => { - const found = element ? element.querySelector('input, textarea, select, checkbox') : null - register(found) - } + const found = element + ? element.querySelector("input, textarea, select, checkbox") + : null; + register(found); + }; } // Generates pairs from the input iterable export function* pairwise(it) { - let lastValue - let firstRound = true + let lastValue; + let firstRound = true; for (const i of it) { if (firstRound) { - firstRound = false + firstRound = false; } else { - yield [lastValue, i] + yield [lastValue, i]; } - lastValue = i + lastValue = i; } } export function useCallbackRef(fn) { - const fnRef = useRef() - fnRef.current = fn - return useCallback(((...args) => fnRef.current(...args)), []) + const fnRef = useRef(); + fnRef.current = fn; + return useCallback((...args) => fnRef.current(...args), []); +} + +export function formatDuration(seconds) { + return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'"); +} + +export function formatDistance(meters) { + if (meters == null) return null; + + if (meters < 0) return "-" + formatDistance(meters); + + if (meters < 1000) { + return `${meters.toFixed(0)} m`; + } else { + return `${(meters / 1000).toFixed(2)} km`; + } } From 4fe7d45dec2ee28256d7d75bb789fbc3b7d9cdf0 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Thu, 22 Sep 2022 20:09:54 +0200 Subject: [PATCH 19/75] Bulk update operations on tracks --- api/obs/api/routes/tracks.py | 35 +++- frontend/src/pages/MyTracksPage.tsx | 271 ++++++++++++++++++---------- 2 files changed, 213 insertions(+), 93 deletions(-) diff --git a/api/obs/api/routes/tracks.py b/api/obs/api/routes/tracks.py index e9df89c..b004c3e 100644 --- a/api/obs/api/routes/tracks.py +++ b/api/obs/api/routes/tracks.py @@ -3,7 +3,7 @@ import re from json import load as jsonload from os.path import join, exists, isfile -from sqlalchemy import select, func +from sqlalchemy import select, func, and_ from sqlalchemy.orm import joinedload from obs.api.db import Track, User, Comment, DuplicateTrackFileError @@ -128,6 +128,39 @@ async def get_feed(req): return await _return_tracks(req, extend_query, limit, offset, order_by) +@api.post("/tracks/bulk") +@require_auth +async def tracks_bulk_action(req): + body = req.json + action = body["action"] + track_slugs = body["tracks"] + + if action not in ("delete", "makePublic", "makePrivate", "reprocess"): + raise InvalidUsage("invalid action") + + query = select(Track).where( + and_(Track.author_id == req.ctx.user.id, Track.slug.in_(track_slugs)) + ) + + for track in (await req.ctx.db.execute(query)).scalars(): + if action == "delete": + await req.ctx.db.delete(track) + elif action == "makePublic": + if not track.public: + track.queue_processing() + track.public = True + elif action == "makePrivate": + if track.public: + track.queue_processing() + track.public = False + elif action == "reprocess": + track.queue_processing() + + await req.ctx.db.commit() + + return empty() + + @api.post("/tracks") @read_api_key @require_auth diff --git a/frontend/src/pages/MyTracksPage.tsx b/frontend/src/pages/MyTracksPage.tsx index f8615f3..104abde 100644 --- a/frontend/src/pages/MyTracksPage.tsx +++ b/frontend/src/pages/MyTracksPage.tsx @@ -1,8 +1,10 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { connect } from "react-redux"; import { Accordion, Button, + Checkbox, + Confirm, Header, Icon, Item, @@ -15,7 +17,7 @@ import { } from "semantic-ui-react"; import { useObservable } from "rxjs-hooks"; import { Link } from "react-router-dom"; -import { of, from, concat } from "rxjs"; +import { of, from, concat, BehaviorSubject, combineLatest } from "rxjs"; import { map, switchMap, distinctUntilChanged } from "rxjs/operators"; import _ from "lodash"; import { useTranslation } from "react-i18next"; @@ -23,7 +25,7 @@ import { useTranslation } from "react-i18next"; import type { ProcessingStatus, Track, UserDevice } from "types"; import { Page, FormattedDate, Visibility } from "components"; import api from "api"; -import { formatDistance, formatDuration } from "utils"; +import { useCallbackRef, formatDistance, formatDuration } from "utils"; const COLOR_BY_STATUS: Record = { error: "red", @@ -150,11 +152,23 @@ function TrackFilters({ ); } -function TracksTable() { +function TracksTable({ title }) { const [orderBy, setOrderBy] = useState("recordedAt"); const [reversed, setReversed] = useState(false); const [showFilters, setShowFilters] = useState(false); const [filters, setFilters] = useState({}); + const [selectedTracks, setSelectedTracks] = useState>( + {} + ); + + const toggleTrackSelection = useCallbackRef( + (slug: string, selected?: boolean) => { + const newSelected = selected ?? !selectedTracks[slug]; + setSelectedTracks( + _.pickBy({ ...selectedTracks, [slug]: newSelected }, _.identity) + ); + } + ); const query = _.pickBy( { @@ -168,12 +182,17 @@ function TracksTable() { (x) => x != null ); + const forceUpdate$ = useMemo(() => new BehaviorSubject(null), []); const tracks: Track[] | null = useObservable( (_$, inputs$) => - inputs$.pipe( - map(([query]) => query), - distinctUntilChanged(_.isEqual), - switchMap((query) => + combineLatest([ + inputs$.pipe( + map(([query]) => query), + distinctUntilChanged(_.isEqual) + ), + forceUpdate$, + ]).pipe( + switchMap(([query]) => concat( of(null), from(api.get("/tracks/feed", { query }).then((r) => r.tracks)) @@ -201,88 +220,163 @@ function TracksTable() { const p = { orderBy, setOrderBy, reversed, setReversed }; + const selectedCount = Object.keys(selectedTracks).length; + const noneSelected = selectedCount === 0; + const allSelected = selectedCount === tracks?.length; + const selectAll = () => { + setSelectedTracks( + Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? []) + ); + }; + const selectNone = () => { + setSelectedTracks({}); + }; + + const bulkAction = async (action: string) => { + await api.post("/tracks/bulk", { + body: { + action, + tracks: Object.keys(selectedTracks), + }, + }); + + setShowBulkDelete(false); + setSelectedTracks({}); + forceUpdate$.next(null); + }; + const [showBulkDelete, setShowBulkDelete] = useState(false); + return ( -
- + <> +
+ + + + Selection of {selectedCount} tracks + + bulkAction("makePrivate")}> + Make private + + bulkAction("makePublic")}> + Make public + + bulkAction("reprocess")}> + Reprocess + + setShowBulkDelete(true)}> + Delete + + + + +
- - setShowFilters(!showFilters)} - > - - Filters - - - - - +
{title}
+
+ - - - - - Title - - - Recorded at - - - Visibility - - - Length - - - Duration - - - Device - - - + + setShowFilters(!showFilters)} + > + + Filters + + + + + - - {tracks?.map((track: Track) => ( - - - {track.processingStatus == null ? null : ( - - )} - - {track.title || t("general.unnamedTrack")} - - + setShowBulkDelete(false)} + onConfirm={() => bulkAction("delete")} + content={`Are you sure you want to delete ${selectedCount} tracks?`} + confirmButton={t("general.delete")} + cancelButton={t("general.cancel")} + /> - - - +
+ + + + (noneSelected ? selectAll() : selectNone())} + /> + - - {track.public == null ? null : ( - - )} - - - - {formatDistance(track.length)} - - - - {formatDuration(track.duration)} - - - - {track.userDeviceId - ? deviceNames?.[track.userDeviceId] ?? "..." - : null} - + + Title + + + Recorded at + + + Visibility + + + Length + + + Duration + + + Device + - ))} - -
-
+ + + + {tracks?.map((track: Track) => ( + + + toggleTrackSelection(track.slug)} + checked={selectedTracks[track.slug] ?? false} + /> + + + {track.processingStatus == null ? null : ( + + )} + + {track.title || t("general.unnamedTrack")} + + + + + + + + + {track.public == null ? null : ( + + )} + + + + {formatDistance(track.length)} + + + + {formatDuration(track.duration)} + + + + {track.userDeviceId + ? deviceNames?.[track.userDeviceId] ?? "..." + : null} + + + ))} + + +
+ ); } @@ -296,12 +390,7 @@ function UploadButton({ navigate, ...props }) { [navigate] ); return ( - ); @@ -315,9 +404,7 @@ const MyTracksPage = connect((state) => ({ login: (state as any).login }))( return ( - -
{title}
- +
); } From 141460c79fc570cfb3f610f17b1db519ed0e1205 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 27 Nov 2022 16:47:12 +0100 Subject: [PATCH 20/75] Split settings page --- frontend/src/components/Stats/index.tsx | 131 ++++++---- frontend/src/pages/MyTracksPage.tsx | 2 +- frontend/src/pages/SettingsPage.tsx | 227 ------------------ .../src/pages/SettingsPage/ApiKeySettings.tsx | 125 ++++++++++ .../pages/SettingsPage/UserSettingsForm.tsx | 89 +++++++ frontend/src/pages/SettingsPage/index.tsx | 64 +++++ frontend/src/pages/UploadPage.tsx | 6 +- frontend/src/translations/de.yaml | 13 +- frontend/src/translations/en.yaml | 12 +- 9 files changed, 377 insertions(+), 292 deletions(-) delete mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 frontend/src/pages/SettingsPage/ApiKeySettings.tsx create mode 100644 frontend/src/pages/SettingsPage/UserSettingsForm.tsx create mode 100644 frontend/src/pages/SettingsPage/index.tsx diff --git a/frontend/src/components/Stats/index.tsx b/frontend/src/components/Stats/index.tsx index 2c7abd3..ef0b3c2 100644 --- a/frontend/src/components/Stats/index.tsx +++ b/frontend/src/components/Stats/index.tsx @@ -1,118 +1,145 @@ -import React, {useState, useCallback} from 'react' -import {pickBy} from 'lodash' -import {Loader, Statistic, Segment, Header, Menu} 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 {useTranslation} from 'react-i18next' +import React, { useState, useCallback } from "react"; +import { pickBy } from "lodash"; +import { Loader, Statistic, Segment, Header, Menu } 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 { useTranslation } from "react-i18next"; -import api from 'api' +import api from "api"; function formatDuration(seconds) { return ( Duration.fromMillis((seconds ?? 0) * 1000) - .as('hours') - .toFixed(1) + ' h' - ) + .as("hours") + .toFixed(1) + " h" + ); } -export default function Stats({user = null}: {user?: null | string}) { - const {t} = useTranslation() - const [timeframe, setTimeframe] = useState('all_time') - const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe]) +export default function Stats({ user = null }: { user?: null | string }) { + const { t } = useTranslation(); + const [timeframe, setTimeframe] = useState("all_time"); + const onClick = useCallback( + (_e, { name }) => setTimeframe(name), + [setTimeframe] + ); const stats = useObservable( (_$, inputs$) => { const timeframe$ = inputs$.pipe( map((inputs) => inputs[0]), distinctUntilChanged() - ) + ); const user$ = inputs$.pipe( map((inputs) => inputs[1]), distinctUntilChanged() - ) + ); return combineLatest(timeframe$, user$).pipe( map(([timeframe_, user_]) => { - const now = DateTime.now() + const now = DateTime.now(); - let start, end + let start, end; switch (timeframe_) { - case 'this_month': - start = now.startOf('month') - end = now.endOf('month') - break + case "this_month": + start = now.startOf("month"); + end = now.endOf("month"); + break; - case 'this_year': - start = now.startOf('year') - end = now.endOf('year') - break + case "this_year": + start = now.startOf("year"); + end = now.endOf("year"); + break; } return pickBy({ start: start?.toISODate(), end: end?.toISODate(), user: user_, - }) + }); }), - switchMap((query) => concat(of(null), from(api.get('/stats', {query})))) - ) + switchMap((query) => + concat(of(null), from(api.get("/stats", { query }))) + ) + ); }, null, [timeframe, user] - ) + ); - const placeholder = t('Stats.placeholder') + const placeholder = t("Stats.placeholder"); return ( <> -
{user ? t('Stats.titleUser') : t('Stats.title')}
-
- {stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : placeholder} - {t('Stats.totalTrackLength')} + + {stats + ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` + : placeholder} + + {t("Stats.totalTrackLength")} - {stats ? formatDuration(stats?.trackDuration) : placeholder} - {t('Stats.timeRecorded')} + + {stats ? formatDuration(stats?.trackDuration) : placeholder} + + {t("Stats.timeRecorded")} - {stats?.numEvents ?? placeholder} - {t('Stats.eventsConfirmed')} + + {stats?.numEvents ?? placeholder} + + {t("Stats.eventsConfirmed")} {user ? ( - {stats?.trackCount ?? placeholder} - {t('Stats.tracksRecorded')} + + {stats?.trackCount ?? placeholder} + + {t("Stats.tracksRecorded")} ) : ( - {stats?.userCount ?? placeholder} - {t('Stats.membersJoined')} + + {stats?.userCount ?? placeholder} + + {t("Stats.membersJoined")} )} - - {t('Stats.thisMonth')} + + {t("Stats.thisMonth")} - - {t('Stats.thisYear')} + + {t("Stats.thisYear")} - - {t('Stats.allTime')} + + {t("Stats.allTime")}
- ) + ); } diff --git a/frontend/src/pages/MyTracksPage.tsx b/frontend/src/pages/MyTracksPage.tsx index 104abde..48b9f4e 100644 --- a/frontend/src/pages/MyTracksPage.tsx +++ b/frontend/src/pages/MyTracksPage.tsx @@ -271,7 +271,7 @@ function TracksTable({ title }) {
-
{title}
+
{title}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx deleted file mode 100644 index 11be6e5..0000000 --- a/frontend/src/pages/SettingsPage.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React from "react"; -import { connect } from "react-redux"; -import { - Message, - Icon, - Grid, - Form, - Button, - TextArea, - Ref, - Input, - Header, - Divider, - Popup, -} from "semantic-ui-react"; -import { useForm } from "react-hook-form"; -import Markdown from "react-markdown"; -import { useTranslation } from "react-i18next"; - -import { setLogin } from "reducers/login"; -import { Page, Stats } from "components"; -import api from "api"; -import { findInput } from "utils"; -import { useConfig } from "config"; - -const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })( - function SettingsPage({ login, setLogin }) { - const { t } = useTranslation(); - const { register, handleSubmit } = useForm(); - const [loading, setLoading] = React.useState(false); - const [errors, setErrors] = React.useState(null); - - const onSave = React.useCallback( - async (changes) => { - setLoading(true); - setErrors(null); - try { - const response = await api.put("/user", { body: changes }); - setLogin(response); - } catch (err) { - setErrors(err.errors); - } finally { - setLoading(false); - } - }, - [setLoading, setLogin, setErrors] - ); - - const onGenerateNewKey = React.useCallback(async () => { - setLoading(true); - setErrors(null); - try { - const response = await api.put("/user", { - body: { updateApiKey: true }, - }); - setLogin(response); - } catch (err) { - setErrors(err.errors); - } finally { - setLoading(false); - } - }, [setLoading, setLogin, setErrors]); - - return ( - - - - -
{t("SettingsPage.profile.title")}
- -
- - - - - - {t("SettingsPage.profile.username.hint")} - - - - {t("SettingsPage.profile.publicNotice")} - - - - - - - - - {t("SettingsPage.profile.displayName.fallbackNotice")} - - - - - - -