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,
|
tiles,
|
||||||
tracks,
|
tracks,
|
||||||
users,
|
users,
|
||||||
|
mapdetails,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .routes import frontend
|
from .routes import frontend
|
||||||
|
|
|
@ -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()")
|
||||||
|
|
||||||
|
|
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:
|
def round_to(value: float, multiples: float) -> float:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
return round(value / multiples) * multiples
|
return round(value / multiples) * multiples
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue