feat: Split road statistics by direction (fixes #117)
This commit is contained in:
parent
776275c52b
commit
4bf23143e0
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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 (
|
||||
<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}) {
|
||||
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}) {
|
|||
</Label>
|
||||
)}
|
||||
|
||||
<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}>
|
||||
{info?.[prop]?.statistics?.[stat]?.toFixed(stat === 'count' ? 0 : 3)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
<Table.Cell>{UNITS[prop]}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{info?.road.oneway ? null : (
|
||||
<Menu size="tiny" fluid secondary>
|
||||
<Menu.Item header>Direction</Menu.Item>
|
||||
<Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
|
||||
{getCardinalDirection(info?.forwards?.bearing)}
|
||||
</Menu.Item>
|
||||
<Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
|
||||
{getCardinalDirection(info?.backwards?.bearing)}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{info?.[direction] && <RoadStatsTable data={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,
|
||||
],
|
||||
})
|
||||
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
@ -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]
|
||||
|
|
Loading…
Reference in a new issue