feat: Split road statistics by direction (fixes #117)
This commit is contained in:
parent
776275c52b
commit
4bf23143e0
|
@ -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):
|
||||||
arr = arr[~numpy.isnan(arr)]
|
if len(arr):
|
||||||
|
print("ARR DTYPE", arr.dtype)
|
||||||
|
print("ARR", 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),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -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]
|
||||||
|
|
Loading…
Reference in a new issue