Add details for roads when clicking them in the big map
This commit is contained in:
parent
fe3aa7a8f6
commit
61efdeb673
|
@ -193,6 +193,7 @@ from .routes import (
|
|||
tiles,
|
||||
tracks,
|
||||
users,
|
||||
mapdetails,
|
||||
)
|
||||
|
||||
from .routes import frontend
|
||||
|
|
|
@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
|
|||
from datetime import datetime
|
||||
import os
|
||||
from os.path import join, dirname
|
||||
from json import loads
|
||||
import re
|
||||
import math
|
||||
import aiofiles
|
||||
|
@ -93,7 +94,7 @@ class Geometry(UserDefinedType):
|
|||
return func.ST_GeomFromGeoJSON(bindvalue, type_=self)
|
||||
|
||||
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):
|
||||
|
@ -130,6 +131,16 @@ class Road(Base):
|
|||
directionality = Column(Integer)
|
||||
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()")
|
||||
|
||||
|
|
108
api/obs/api/routes/mapdetails.py
Normal file
108
api/obs/api/routes/mapdetails.py
Normal 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),
|
||||
}
|
||||
)
|
|
@ -27,6 +27,8 @@ MINUMUM_RECORDING_DATE = datetime(2010, 1, 1)
|
|||
|
||||
|
||||
def round_to(value: float, multiples: float) -> float:
|
||||
if value is None:
|
||||
return None
|
||||
return round(value / multiples) * multiples
|
||||
|
||||
|
||||
|
|
|
@ -5,3 +5,13 @@
|
|||
background: red;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mapInfoBox {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
max-height: 100%;
|
||||
width: 36rem;
|
||||
overflow: auto;
|
||||
margin: 20px;
|
||||
}
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
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 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 {useConfig, Config} from 'config'
|
||||
import {useHistory, useLocation} from 'react-router-dom'
|
||||
|
||||
import {roadsLayer, basemap} from '../mapstyles'
|
||||
|
||||
import styles from './MapPage.module.less'
|
||||
|
||||
import {roadsLayer, basemap} from '../mapstyles'
|
||||
const EMPTY_VIEWPORT = {longitude: 0, latitude: 0, zoom: 0}
|
||||
|
||||
function parseHash(v) {
|
||||
if (!v) return null
|
||||
|
@ -21,7 +27,6 @@ function parseHash(v) {
|
|||
longitude: Number.parseFloat(m[3]),
|
||||
}
|
||||
}
|
||||
const EMPTY_VIEWPORT = {longitude: 0, latitude: 0, zoom: 0}
|
||||
|
||||
function buildHash(v) {
|
||||
return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`
|
||||
|
@ -46,6 +51,7 @@ export function CustomMap({
|
|||
viewportFromUrl,
|
||||
children,
|
||||
boundsFromJson,
|
||||
...props
|
||||
}: {
|
||||
viewportFromUrl?: boolean
|
||||
children: React.ReactNode
|
||||
|
@ -58,7 +64,7 @@ export function CustomMap({
|
|||
|
||||
const config = useConfig()
|
||||
React.useEffect(() => {
|
||||
if (config?.mapHome && viewport.zoom === 0 && !boundsFromJson) {
|
||||
if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) {
|
||||
setViewport(config.mapHome)
|
||||
}
|
||||
}, [config, boundsFromJson])
|
||||
|
@ -81,7 +87,7 @@ export function CustomMap({
|
|||
}, [boundsFromJson])
|
||||
|
||||
return (
|
||||
<ReactMapGl mapStyle={basemap} width="100%" height="100%" onViewportChange={setViewport} {...viewport}>
|
||||
<ReactMapGl mapStyle={basemap} width="100%" height="100%" onViewportChange={setViewport} {...viewport} {...props}>
|
||||
<AttributionControl
|
||||
style={{right: 0, bottom: 0}}
|
||||
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 [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) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomMap viewportFromUrl>
|
||||
<Source id="obs" {...obsMapSource}>
|
||||
<Layer {...roadsLayer} />
|
||||
</Source>
|
||||
</CustomMap>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MapPage() {
|
||||
return (
|
||||
<Page fullScreen>
|
||||
<div className={styles.mapContainer}>
|
||||
<RoadsMap />
|
||||
<CustomMap viewportFromUrl onClick={onClick}>
|
||||
<Source id="obs" {...obsMapSource}>
|
||||
<Layer {...roadsLayer} />
|
||||
</Source>
|
||||
|
||||
<CurrentRoadInfo {...{clickLocation}} />
|
||||
</CustomMap>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue