Add details for roads when clicking them in the big map

This commit is contained in:
Paul Bienkowski 2021-11-30 19:50:25 +01:00
parent fe3aa7a8f6
commit 61efdeb673
6 changed files with 262 additions and 19 deletions

View file

@ -193,6 +193,7 @@ from .routes import (
tiles, tiles,
tracks, tracks,
users, users,
mapdetails,
) )
from .routes import frontend from .routes import frontend

View file

@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
import os import os
from os.path import join, dirname from os.path import join, dirname
from json import loads
import re import re
import math import math
import aiofiles import aiofiles
@ -93,7 +94,7 @@ class Geometry(UserDefinedType):
return func.ST_GeomFromGeoJSON(bindvalue, type_=self) return func.ST_GeomFromGeoJSON(bindvalue, type_=self)
def column_expression(self, col): 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): class OvertakingEvent(Base):
@ -130,6 +131,16 @@ class Road(Base):
directionality = Column(Integer) directionality = Column(Integer)
oneway = Column(Boolean) 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()") NOW = text("NOW()")

View file

@ -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),
}
)

View file

@ -27,6 +27,8 @@ MINUMUM_RECORDING_DATE = datetime(2010, 1, 1)
def round_to(value: float, multiples: float) -> float: def round_to(value: float, multiples: float) -> float:
if value is None:
return None
return round(value / multiples) * multiples return round(value / multiples) * multiples

View file

@ -5,3 +5,13 @@
background: red; background: red;
position: relative; position: relative;
} }
.mapInfoBox {
position: absolute;
right: 0;
top: 0;
max-height: 100%;
width: 36rem;
overflow: auto;
margin: 20px;
}

View file

@ -1,15 +1,21 @@
import React from 'react' 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 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 {of, from, concat} from 'rxjs'
import {useObservable} from 'rxjs-hooks'
import {switchMap, distinctUntilChanged} from 'rxjs/operators'
import {Page} from 'components' import {Page} from 'components'
import {useConfig, Config} from 'config' import {useConfig, Config} from 'config'
import {useHistory, useLocation} from 'react-router-dom'
import {roadsLayer, basemap} from '../mapstyles'
import styles from './MapPage.module.less' import styles from './MapPage.module.less'
import {roadsLayer, basemap} from '../mapstyles' const EMPTY_VIEWPORT = {longitude: 0, latitude: 0, zoom: 0}
function parseHash(v) { function parseHash(v) {
if (!v) return null if (!v) return null
@ -21,7 +27,6 @@ function parseHash(v) {
longitude: Number.parseFloat(m[3]), longitude: Number.parseFloat(m[3]),
} }
} }
const EMPTY_VIEWPORT = {longitude: 0, latitude: 0, zoom: 0}
function buildHash(v) { function buildHash(v) {
return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}` return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`
@ -46,6 +51,7 @@ export function CustomMap({
viewportFromUrl, viewportFromUrl,
children, children,
boundsFromJson, boundsFromJson,
...props
}: { }: {
viewportFromUrl?: boolean viewportFromUrl?: boolean
children: React.ReactNode children: React.ReactNode
@ -58,7 +64,7 @@ export function CustomMap({
const config = useConfig() const config = useConfig()
React.useEffect(() => { React.useEffect(() => {
if (config?.mapHome && viewport.zoom === 0 && !boundsFromJson) { if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) {
setViewport(config.mapHome) setViewport(config.mapHome)
} }
}, [config, boundsFromJson]) }, [config, boundsFromJson])
@ -81,7 +87,7 @@ export function CustomMap({
}, [boundsFromJson]) }, [boundsFromJson])
return ( return (
<ReactMapGl mapStyle={basemap} width="100%" height="100%" onViewportChange={setViewport} {...viewport}> <ReactMapGl mapStyle={basemap} width="100%" height="100%" onViewportChange={setViewport} {...viewport} {...props}>
<AttributionControl <AttributionControl
style={{right: 0, bottom: 0}} style={{right: 0, bottom: 0}}
customAttribution={[ customAttribution={[
@ -97,27 +103,132 @@ export function CustomMap({
) )
} }
export function RoadsMap() { function CurrentRoadInfo({clickLocation}) {
const info = useObservable(
(_$, inputs$) =>
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.'
) : (
<>
<Header as="h1">{loading ? '...' : info?.road.name || 'Unnamed way'}</Header>
<List>
<List.Item>
<List.Header>Zone</List.Header>
{info?.road.zone}
</List.Item>
<List.Item>
<List.Header>Tags</List.Header>
{info?.road.oneway && (
<Label size="small" color="blue">
<Icon name="long arrow alternate right" fitted /> oneway
</Label>
)}
</List.Item>
<List.Item>
<List.Header>Statistics</List.Header>
<Table size='small' compact>
<Table.Header>
<Table.Row><Table.HeaderCell></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.Row>
</Table.Header>
<Table.Body>
{['distanceOvertaker', 'distanceStationary', 'speed'].map(prop => <Table.Row key={prop}>
<Table.Cell>{prop}</Table.Cell>
{['count', 'min', 'median', 'max', 'mean'].map(stat => <Table.Cell key={stat}>{info?.[prop]?.statistics?.[stat]?.toFixed(3)}</Table.Cell>)}
</Table.Row>)}
</Table.Body>
</Table>
</List.Item>
</List>
</>
)
return (
<>
{info?.road && (
<Source id="highlight" type="geojson" data={info.road.geometry}>
<Layer
id="route"
type="line"
paint={{
'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12],
'line-color': '#18FFFF',
'line-opacity': 0.8,
}}
/>
</Source>
)}
{content && (
<div className={styles.mapInfoBox}>
<Segment loading={loading}>{content}</Segment>
</div>
)}
</>
)
}
export default function MapPage() {
const {obsMapSource} = useConfig() || {} 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) { if (!obsMapSource) {
return null return null
} }
return ( return (
<CustomMap viewportFromUrl> <Page fullScreen>
<div className={styles.mapContainer}>
<CustomMap viewportFromUrl onClick={onClick}>
<Source id="obs" {...obsMapSource}> <Source id="obs" {...obsMapSource}>
<Layer {...roadsLayer} /> <Layer {...roadsLayer} />
</Source> </Source>
</CustomMap>
)
}
export default function MapPage() { <CurrentRoadInfo {...{clickLocation}} />
return ( </CustomMap>
<Page fullScreen>
<div className={styles.mapContainer}>
<RoadsMap />
</div> </div>
</Page> </Page>
) )