diff --git a/api/obs/api/app.py b/api/obs/api/app.py index 2cf9ac2..05c9e9d 100644 --- a/api/obs/api/app.py +++ b/api/obs/api/app.py @@ -193,6 +193,7 @@ from .routes import ( tiles, tracks, users, + mapdetails, ) from .routes import frontend diff --git a/api/obs/api/db.py b/api/obs/api/db.py index 6fd33ea..00dffb2 100644 --- a/api/obs/api/db.py +++ b/api/obs/api/db.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager from datetime import datetime import os from os.path import join, dirname +from json import loads import re import math import aiofiles @@ -93,7 +94,7 @@ class Geometry(UserDefinedType): return func.ST_GeomFromGeoJSON(bindvalue, type_=self) def column_expression(self, col): - return func.ST_AsGeoJSON(col, type_=self) + return func.ST_AsGeoJSON(func.ST_Transform(col, 4326), type_=self) class OvertakingEvent(Base): @@ -130,6 +131,16 @@ class Road(Base): directionality = Column(Integer) oneway = Column(Boolean) + def to_dict(self): + return { + "way_id": self.way_id, + "zone": self.zone, + "name": self.name, + "directionality": self.directionality, + "oneway": self.oneway, + "geometry": loads(self.geometry), + } + NOW = text("NOW()") diff --git a/api/obs/api/routes/mapdetails.py b/api/obs/api/routes/mapdetails.py new file mode 100644 index 0000000..e18ea2e --- /dev/null +++ b/api/obs/api/routes/mapdetails.py @@ -0,0 +1,108 @@ +import json +from functools import partial +import numpy + +from sqlalchemy import select, func, column + +import sanic.response as response +from sanic.exceptions import InvalidUsage + +from obs.api.app import api +from obs.api.db import Road, OvertakingEvent, Track + + +from .stats import round_to + +round_distance = partial(round_to, multiples=0.001) +round_speed = partial(round_to, multiples=0.1) + +RAISE = object() + + +def get_single_arg(req, name, default=RAISE, convert=None): + try: + value = req.args[name][0] + except LookupError as e: + if default is not RAISE: + return default + raise InvalidUsage("missing `{name}`") from e + + if convert is not None: + try: + value = convert(value) + except (ValueError, TypeError) as e: + raise InvalidUsage("invalid `{name}`") from e + + return value + + +@api.route("/mapdetails/road", methods=["GET"]) +async def mapdetails_road(req): + longitude = get_single_arg(req, "longitude", convert=float) + latitude = get_single_arg(req, "latitude", convert=float) + radius = get_single_arg(req, "radius", default=100, convert=float) + + if not (1 <= radius <= 1000): + raise InvalidUsage("`radius` parameter must be between 1 and 1000") + + road_geometry = func.ST_Transform(Road.geometry, 3857) + point = func.ST_Transform( + func.ST_GeomFromGeoJSON( + json.dumps( + { + "type": "point", + "coordinates": [longitude, latitude], + } + ) + ), + 3857, + ) + + road = ( + await req.ctx.db.execute( + select(Road) + .where(func.ST_DWithin(road_geometry, point, radius)) + .order_by(func.ST_Distance(road_geometry, point)) + .limit(1) + ) + ).scalar() + + if road is None: + return response.json({}) + + arrays = ( + await req.ctx.db.execute( + select( + [ + OvertakingEvent.distance_overtaker, + OvertakingEvent.distance_stationary, + OvertakingEvent.speed, + ] + ).where(OvertakingEvent.way_id == road.way_id) + ) + ).all() + + arrays = numpy.array(arrays).T.astype(numpy.float64) + + def array_stats(arr, rounder): + arr = arr[~numpy.isnan(arr)] + n = len(arr) + return { + "statistics": { + "count": len(arr), + "mean": rounder(numpy.mean(arr)) if n else None, + "min": rounder(numpy.min(arr)) if n else None, + "max": rounder(numpy.max(arr)) if n else None, + "median": rounder(numpy.median(arr)) if n else None, + }, + "values": list(map(rounder, arr.tolist())), + } + + return response.json( + { + "road": road.to_dict(), + "distanceOvertaker": array_stats(arrays[0], round_distance), + "distanceStationary": array_stats(arrays[1], round_distance), + "speed": array_stats(arrays[2], round_speed), + } + ) diff --git a/api/obs/api/routes/stats.py b/api/obs/api/routes/stats.py index 19bc2c6..5292361 100644 --- a/api/obs/api/routes/stats.py +++ b/api/obs/api/routes/stats.py @@ -27,6 +27,8 @@ MINUMUM_RECORDING_DATE = datetime(2010, 1, 1) def round_to(value: float, multiples: float) -> float: + if value is None: + return None return round(value / multiples) * multiples diff --git a/frontend/src/pages/MapPage.module.less b/frontend/src/pages/MapPage.module.less index fc629fc..bf15b20 100644 --- a/frontend/src/pages/MapPage.module.less +++ b/frontend/src/pages/MapPage.module.less @@ -5,3 +5,13 @@ background: red; position: relative; } + +.mapInfoBox { + position: absolute; + right: 0; + top: 0; + max-height: 100%; + width: 36rem; + overflow: auto; + margin: 20px; +} diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index ee28e2f..e9e30bd 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -1,15 +1,21 @@ import React from 'react' - +import _ from 'lodash' +import {Segment, List, Header, Label, Icon, Table} from 'semantic-ui-react' import ReactMapGl, {WebMercatorViewport, AttributionControl, NavigationControl, Layer, Source} from 'react-map-gl' import turfBbox from '@turf/bbox' +import {useHistory, useLocation} from 'react-router-dom' +import {of, from, concat} from 'rxjs' +import {useObservable} from 'rxjs-hooks' +import {switchMap, distinctUntilChanged} from 'rxjs/operators' import {Page} from 'components' import {useConfig, Config} from 'config' -import {useHistory, useLocation} from 'react-router-dom' + +import {roadsLayer, basemap} from '../mapstyles' import styles from './MapPage.module.less' -import {roadsLayer, basemap} from '../mapstyles' +const EMPTY_VIEWPORT = {longitude: 0, latitude: 0, zoom: 0} function parseHash(v) { if (!v) return null @@ -21,7 +27,6 @@ function parseHash(v) { longitude: Number.parseFloat(m[3]), } } -const EMPTY_VIEWPORT = {longitude: 0, latitude: 0, zoom: 0} function buildHash(v) { return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}` @@ -46,6 +51,7 @@ export function CustomMap({ viewportFromUrl, children, boundsFromJson, + ...props }: { viewportFromUrl?: boolean children: React.ReactNode @@ -58,7 +64,7 @@ export function CustomMap({ const config = useConfig() React.useEffect(() => { - if (config?.mapHome && viewport.zoom === 0 && !boundsFromJson) { + if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) { setViewport(config.mapHome) } }, [config, boundsFromJson]) @@ -81,7 +87,7 @@ export function CustomMap({ }, [boundsFromJson]) return ( - + + inputs$.pipe( + distinctUntilChanged(_.isEqual), + switchMap(([location]) => + location + ? concat( + of(null), + from( + api.get('/mapdetails/road', { + query: { + ...location, + radius: 100, + }, + }) + ) + ) + : of(null) + ) + ), + null, + [clickLocation] + ) + + if (!clickLocation) { + return null + } + + const loading = info == null + + const content = + !loading && !info.road ? ( + 'No road found.' + ) : ( + <> +
{loading ? '...' : info?.road.name || 'Unnamed way'}
+ + + + Zone + {info?.road.zone} + + + Tags + {info?.road.oneway && ( + + )} + + + + Statistics + + + + n + min + q50 + max + mean + + + + {['distanceOvertaker', 'distanceStationary', 'speed'].map(prop => + {prop} + {['count', 'min', 'median', 'max', 'mean'].map(stat => {info?.[prop]?.statistics?.[stat]?.toFixed(3)})} + )} + +
+
+
+ + ) + + return ( + <> + {info?.road && ( + + + + )} + + {content && ( +
+ {content} +
+ )} + + ) +} + +export default function MapPage() { const {obsMapSource} = useConfig() || {} + const [clickLocation, setClickLocation] = React.useState<{longitude: number; latitude: number} | null>(null) + + const onClick = React.useCallback( + (e) => { + setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]}) + }, + [setClickLocation] + ) if (!obsMapSource) { return null } - return ( - - - - - - ) -} - -export default function MapPage() { return (
- + + + + + + +
)