feat: Split road statistics by direction (fixes #117)

This commit is contained in:
Paul Bienkowski 2021-12-03 19:28:07 +01:00
parent 776275c52b
commit 4bf23143e0
2 changed files with 149 additions and 44 deletions

View file

@ -1,6 +1,7 @@
import json import json
from functools import partial from functools import partial
import numpy import numpy
import math
from sqlalchemy import select, func, column from sqlalchemy import select, func, column
@ -36,6 +37,16 @@ def get_single_arg(req, name, default=RAISE, convert=None):
return value 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"]) @api.route("/mapdetails/road", methods=["GET"])
async def mapdetails_road(req): async def mapdetails_road(req):
longitude = get_single_arg(req, "longitude", convert=float) longitude = get_single_arg(req, "longitude", convert=float)
@ -77,22 +88,40 @@ async def mapdetails_road(req):
OvertakingEvent.distance_overtaker, OvertakingEvent.distance_overtaker,
OvertakingEvent.distance_stationary, OvertakingEvent.distance_stationary,
OvertakingEvent.speed, 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) ).where(OvertakingEvent.way_id == road.way_id)
) )
).all() ).all()
arrays = numpy.array(arrays).T.astype(numpy.float64) arrays = numpy.array(arrays).T
if len(arrays) == 0: 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): def array_stats(arr, rounder):
if len(arr):
print("ARR DTYPE", arr.dtype)
print("ARR", arr)
arr = arr[~numpy.isnan(arr)] arr = arr[~numpy.isnan(arr)]
n = len(arr) n = len(arr)
return { return {
"statistics": { "statistics": {
"count": len(arr), "count": n,
"mean": rounder(numpy.mean(arr)) if n else None, "mean": rounder(numpy.mean(arr)) if n else None,
"min": rounder(numpy.min(arr)) if n else None, "min": rounder(numpy.min(arr)) if n else None,
"max": rounder(numpy.max(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())), "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( return response.json(
{ {
"road": road.to_dict(), "road": road.to_dict(),
"distanceOvertaker": array_stats(arrays[0], round_distance), "forwards": get_direction_stats(forwards),
"distanceStationary": array_stats(arrays[1], round_distance), "backwards": get_direction_stats(backwards, True),
"speed": array_stats(arrays[2], round_speed),
} }
) )

View file

@ -1,6 +1,6 @@
import React from 'react' import React, {useState, useCallback, useMemo, useEffect} from 'react'
import _ from 'lodash' 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 ReactMapGl, {WebMercatorViewport, AttributionControl, NavigationControl, Layer, Source} from 'react-map-gl'
import turfBbox from '@turf/bbox' import turfBbox from '@turf/bbox'
import {useHistory, useLocation} from 'react-router-dom' import {useHistory, useLocation} from 'react-router-dom'
@ -9,7 +9,7 @@ import {useObservable} from 'rxjs-hooks'
import {switchMap, distinctUntilChanged} from 'rxjs/operators' import {switchMap, distinctUntilChanged} from 'rxjs/operators'
import {Page} from 'components' import {Page} from 'components'
import {useConfig, Config} from 'config' import {useConfig} from 'config'
import api from 'api' import api from 'api'
import {roadsLayer, basemap} from '../mapstyles' import {roadsLayer, basemap} from '../mapstyles'
@ -36,8 +36,8 @@ function buildHash(v) {
function useViewportFromUrl() { function useViewportFromUrl() {
const history = useHistory() const history = useHistory()
const location = useLocation() const location = useLocation()
const value = React.useMemo(() => parseHash(location.hash), [location.hash]) const value = useMemo(() => parseHash(location.hash), [location.hash])
const setter = React.useCallback( const setter = useCallback(
(v) => { (v) => {
history.replace({ history.replace({
hash: buildHash(v), hash: buildHash(v),
@ -58,19 +58,19 @@ export function CustomMap({
children: React.ReactNode children: React.ReactNode
boundsFromJson: GeoJSON.Geometry boundsFromJson: GeoJSON.Geometry
}) { }) {
const [viewportState, setViewportState] = React.useState(EMPTY_VIEWPORT) const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
const [viewportUrl, setViewportUrl] = useViewportFromUrl() const [viewportUrl, setViewportUrl] = useViewportFromUrl()
const [viewport, setViewport] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState] const [viewport, setViewport] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState]
const config = useConfig() const config = useConfig()
React.useEffect(() => { useEffect(() => {
if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) { if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) {
setViewport(config.mapHome) setViewport(config.mapHome)
} }
}, [config, boundsFromJson]) }, [config, boundsFromJson])
React.useEffect(() => { useEffect(() => {
if (boundsFromJson) { if (boundsFromJson) {
const [minX, minY, maxX, maxY] = turfBbox(boundsFromJson) const [minX, minY, maxX, maxY] = turfBbox(boundsFromJson)
const vp = new WebMercatorViewport({width: 1000, height: 800}).fitBounds( 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 UNITS = {distanceOvertaker: 'm', distanceStationary: 'm', speed: 'm/s'}
const LABELS = {distanceOvertaker: 'Overtaker', distanceStationary: 'Stationary', speed: 'Speed'} const LABELS = {distanceOvertaker: 'Overtaker', distanceStationary: 'Stationary', speed: 'Speed'}
const ZONE_COLORS = {urban: 'olive', rural: 'brown', motorway: 'purple'} 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 (
<Table size="small" compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Property</Table.HeaderCell>
<Table.HeaderCell>n</Table.HeaderCell>
<Table.HeaderCell>min</Table.HeaderCell>
<Table.HeaderCell>q50</Table.HeaderCell>
<Table.HeaderCell>max</Table.HeaderCell>
<Table.HeaderCell>mean</Table.HeaderCell>
<Table.HeaderCell>unit</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
<Table.Row key={prop}>
<Table.Cell>{LABELS[prop]}</Table.Cell>
{['count', 'min', 'median', 'max', 'mean'].map((stat) => (
<Table.Cell key={stat}>{data[prop]?.statistics?.[stat]?.toFixed(stat === 'count' ? 0 : 3)}</Table.Cell>
))}
<Table.Cell>{UNITS[prop]}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
)
}
function CurrentRoadInfo({clickLocation}) { function CurrentRoadInfo({clickLocation}) {
const [direction, setDirection] = useState('forwards')
const onClickDirection = useCallback(
(e, {name}) => {
e.preventDefault()
e.stopPropagation()
setDirection(name)
},
[setDirection]
)
const info = useObservable( const info = useObservable(
(_$, inputs$) => (_$, inputs$) =>
inputs$.pipe( inputs$.pipe(
@ -139,6 +186,8 @@ function CurrentRoadInfo({clickLocation}) {
const loading = info == null 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 = const content =
!loading && !info.road ? ( !loading && !info.road ? (
'No road found.' 'No road found.'
@ -158,32 +207,19 @@ function CurrentRoadInfo({clickLocation}) {
</Label> </Label>
)} )}
<Table size="small" compact> {info?.road.oneway ? null : (
<Table.Header> <Menu size="tiny" fluid secondary>
<Table.Row> <Menu.Item header>Direction</Menu.Item>
<Table.HeaderCell>Property</Table.HeaderCell> <Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
<Table.HeaderCell>n</Table.HeaderCell> {getCardinalDirection(info?.forwards?.bearing)}
<Table.HeaderCell>min</Table.HeaderCell> </Menu.Item>
<Table.HeaderCell>q50</Table.HeaderCell> <Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
<Table.HeaderCell>max</Table.HeaderCell> {getCardinalDirection(info?.backwards?.bearing)}
<Table.HeaderCell>mean</Table.HeaderCell> </Menu.Item>
<Table.HeaderCell>unit</Table.HeaderCell> </Menu>
</Table.Row> )}
</Table.Header>
<Table.Body> {info?.[direction] && <RoadStatsTable data={info[direction]} />}
{['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
<Table.Row key={prop}>
<Table.Cell>{LABELS[prop]}</Table.Cell>
{['count', 'min', 'median', 'max', 'mean'].map((stat) => (
<Table.Cell key={stat}>
{info?.[prop]?.statistics?.[stat]?.toFixed(stat === 'count' ? 0 : 3)}
</Table.Cell>
))}
<Table.Cell>{UNITS[prop]}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</> </>
) )
@ -197,7 +233,19 @@ function CurrentRoadInfo({clickLocation}) {
paint={{ paint={{
'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12], 'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12],
'line-color': '#18FFFF', 'line-color': '#18FFFF',
'line-opacity': 0.8, 'line-opacity': 0.5,
...({
'line-offset': [
'interpolate',
['exponential', 1.5],
['zoom'],
12,
offsetDirection,
19,
offsetDirection * 8,
],
})
}} }}
/> />
</Source> </Source>
@ -214,10 +262,18 @@ function CurrentRoadInfo({clickLocation}) {
export default function MapPage() { export default function MapPage() {
const {obsMapSource} = useConfig() || {} 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) => { (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({longitude: e.lngLat[0], latitude: e.lngLat[1]})
}, },
[setClickLocation] [setClickLocation]