diff --git a/api/obs/api/routes/mapdetails.py b/api/obs/api/routes/mapdetails.py index d34c9b1..2c9ce59 100644 --- a/api/obs/api/routes/mapdetails.py +++ b/api/obs/api/routes/mapdetails.py @@ -1,6 +1,7 @@ import json from functools import partial import numpy +import math from sqlalchemy import select, func, column @@ -36,6 +37,16 @@ def get_single_arg(req, name, default=RAISE, convert=None): return value +def get_bearing(a, b): + # longitude, latitude + dL = b[0] - a[0] + X = numpy.cos(b[1]) * numpy.sin(dL) + Y = numpy.cos(a[1]) * numpy.sin(b[1]) - numpy.sin(a[1]) * numpy.cos( + b[1] + ) * numpy.cos(dL) + return numpy.arctan2(X, Y) + + @api.route("/mapdetails/road", methods=["GET"]) async def mapdetails_road(req): longitude = get_single_arg(req, "longitude", convert=float) @@ -77,22 +88,40 @@ async def mapdetails_road(req): OvertakingEvent.distance_overtaker, OvertakingEvent.distance_stationary, OvertakingEvent.speed, + # Keep this as the last entry always for numpy.partition + # below to work. + OvertakingEvent.direction_reversed, ] ).where(OvertakingEvent.way_id == road.way_id) ) ).all() - arrays = numpy.array(arrays).T.astype(numpy.float64) + arrays = numpy.array(arrays).T if len(arrays) == 0: - arrays = numpy.array([[], [], []]) + arrays = numpy.array([[], [], [], []], dtype=numpy.float) + + data, mask = arrays[:-1], arrays[-1] + data = data.astype(numpy.float64) + mask = mask.astype(numpy.bool) + + def partition(arr, cond): + return arr[:, cond], arr[:, ~cond] + + forwards, backwards = partition(data, mask) + print("for", forwards.dtype, "back", backwards.dtype) def array_stats(arr, rounder): - arr = arr[~numpy.isnan(arr)] + if len(arr): + print("ARR DTYPE", arr.dtype) + print("ARR", arr) + arr = arr[~numpy.isnan(arr)] + n = len(arr) + return { "statistics": { - "count": len(arr), + "count": n, "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, @@ -101,11 +130,31 @@ async def mapdetails_road(req): "values": list(map(rounder, arr.tolist())), } + bearing = None + + geom = json.loads(road.geometry) + if geom["type"] == "LineString": + coordinates = geom["coordinates"] + bearing = get_bearing(coordinates[0], coordinates[-1]) + # convert to degrees, as this is more natural to understand for consumers + bearing = round_to((bearing / math.pi * 180 + 360) % 360, 1) + + print(road.geometry) + + def get_direction_stats(direction_arrays, backwards=False): + return { + "bearing": ((bearing + 180) % 360 if backwards else bearing) + if bearing is not None + else None, + "distanceOvertaker": array_stats(direction_arrays[0], round_distance), + "distanceStationary": array_stats(direction_arrays[1], round_distance), + "speed": array_stats(direction_arrays[2], round_speed), + } + 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), + "forwards": get_direction_stats(forwards), + "backwards": get_direction_stats(backwards, True), } ) diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index bb8ea0d..9341f3d 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -1,6 +1,6 @@ -import React from 'react' +import React, {useState, useCallback, useMemo, useEffect} from 'react' import _ from 'lodash' -import {Segment, List, Header, Label, Icon, Table} from 'semantic-ui-react' +import {Segment, Menu, 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' @@ -9,7 +9,7 @@ import {useObservable} from 'rxjs-hooks' import {switchMap, distinctUntilChanged} from 'rxjs/operators' import {Page} from 'components' -import {useConfig, Config} from 'config' +import {useConfig} from 'config' import api from 'api' import {roadsLayer, basemap} from '../mapstyles' @@ -36,8 +36,8 @@ function buildHash(v) { function useViewportFromUrl() { const history = useHistory() const location = useLocation() - const value = React.useMemo(() => parseHash(location.hash), [location.hash]) - const setter = React.useCallback( + const value = useMemo(() => parseHash(location.hash), [location.hash]) + const setter = useCallback( (v) => { history.replace({ hash: buildHash(v), @@ -58,19 +58,19 @@ export function CustomMap({ children: React.ReactNode boundsFromJson: GeoJSON.Geometry }) { - const [viewportState, setViewportState] = React.useState(EMPTY_VIEWPORT) + const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT) const [viewportUrl, setViewportUrl] = useViewportFromUrl() const [viewport, setViewport] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState] const config = useConfig() - React.useEffect(() => { + useEffect(() => { if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) { setViewport(config.mapHome) } }, [config, boundsFromJson]) - React.useEffect(() => { + useEffect(() => { if (boundsFromJson) { const [minX, minY, maxX, maxY] = turfBbox(boundsFromJson) const vp = new WebMercatorViewport({width: 1000, height: 800}).fitBounds( @@ -107,8 +107,55 @@ export function CustomMap({ const UNITS = {distanceOvertaker: 'm', distanceStationary: 'm', speed: 'm/s'} const LABELS = {distanceOvertaker: 'Overtaker', distanceStationary: 'Stationary', speed: 'Speed'} const ZONE_COLORS = {urban: 'olive', rural: 'brown', motorway: 'purple'} +const CARDINAL_DIRECTIONS = ['north', 'north-east', 'east', 'south-east', 'south', 'south-west', 'west', 'north-west'] +const getCardinalDirection = (bearing) => + bearing == null + ? 'unknown' + : CARDINAL_DIRECTIONS[ + Math.floor(((bearing / 360.0) * CARDINAL_DIRECTIONS.length + 0.5) % CARDINAL_DIRECTIONS.length) + ] + ' bound' + +function RoadStatsTable({data}) { + return ( + + + + Property + n + min + q50 + max + mean + unit + + + + {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => ( + + {LABELS[prop]} + {['count', 'min', 'median', 'max', 'mean'].map((stat) => ( + {data[prop]?.statistics?.[stat]?.toFixed(stat === 'count' ? 0 : 3)} + ))} + {UNITS[prop]} + + ))} + +
+ ) +} function CurrentRoadInfo({clickLocation}) { + const [direction, setDirection] = useState('forwards') + + const onClickDirection = useCallback( + (e, {name}) => { + e.preventDefault() + e.stopPropagation() + setDirection(name) + }, + [setDirection] + ) + const info = useObservable( (_$, inputs$) => inputs$.pipe( @@ -139,6 +186,8 @@ function CurrentRoadInfo({clickLocation}) { const loading = info == null + const offsetDirection = info?.road.oneway ? 0 : direction === 'forwards' ? 1 : -1; // TODO: change based on left-hand/right-hand traffic + const content = !loading && !info.road ? ( 'No road found.' @@ -158,32 +207,19 @@ function CurrentRoadInfo({clickLocation}) { )} - - - - Property - n - min - q50 - max - mean - unit - - - - {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => ( - - {LABELS[prop]} - {['count', 'min', 'median', 'max', 'mean'].map((stat) => ( - - {info?.[prop]?.statistics?.[stat]?.toFixed(stat === 'count' ? 0 : 3)} - - ))} - {UNITS[prop]} - - ))} - -
+ {info?.road.oneway ? null : ( + + Direction + + {getCardinalDirection(info?.forwards?.bearing)} + + + {getCardinalDirection(info?.backwards?.bearing)} + + + )} + + {info?.[direction] && } ) @@ -197,7 +233,19 @@ function CurrentRoadInfo({clickLocation}) { paint={{ 'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12], 'line-color': '#18FFFF', - 'line-opacity': 0.8, + 'line-opacity': 0.5, + ...({ + 'line-offset': [ + 'interpolate', + ['exponential', 1.5], + ['zoom'], + 12, + offsetDirection, + 19, + offsetDirection * 8, + ], + }) + }} /> @@ -214,10 +262,18 @@ function CurrentRoadInfo({clickLocation}) { export default function MapPage() { const {obsMapSource} = useConfig() || {} - const [clickLocation, setClickLocation] = React.useState<{longitude: number; latitude: number} | null>(null) + const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null) - const onClick = React.useCallback( + const onClick = useCallback( (e) => { + let node = e.target + while (node) { + if (node?.classList?.contains(styles.mapInfoBox)) { + return + } + node = node.parentNode + } + setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]}) }, [setClickLocation]