Compare commits

...

6 commits

Author SHA1 Message Date
Paul Bienkowski f11f55d0ef Make sidebar toggle for Regions actually work 2022-07-25 17:56:34 +02:00
Paul Bienkowski fac793e3a0 Add regional stats to homepage 2022-07-25 17:47:47 +02:00
Paul Bienkowski e38bc9bd76 fix region DB schema 2022-07-25 17:46:58 +02:00
Paul Bienkowski 908093dd6f Show only borders of areas with events 2022-07-25 17:03:22 +02:00
Paul Bienkowski 7ad3896cdd wip 2022-07-25 16:57:40 +02:00
Paul Bienkowski f429ed32f3 Add administrative boundary import and display regional event count 2022-07-25 16:57:40 +02:00
23 changed files with 666 additions and 186 deletions

View file

@ -0,0 +1,35 @@
"""create table region
Revision ID: a049e5eb24dd
Revises: a9627f63fbed
Create Date: 2022-04-02 21:28:43.124521
"""
from alembic import op
import sqlalchemy as sa
from migrations.utils import dbtype
# revision identifiers, used by Alembic.
revision = "a049e5eb24dd"
down_revision = "a9627f63fbed"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"region",
sa.Column(
"relation_id", sa.BIGINT, autoincrement=True, primary_key=True, index=True
),
sa.Column("name", sa.String),
sa.Column("geometry", dbtype("GEOMETRY"), index=True),
sa.Column("admin_level", sa.Integer, index=True),
sa.Column("tags", dbtype("HSTORE")),
)
def downgrade():
op.drop_table("region")

View file

@ -401,9 +401,22 @@ class Comment(Base):
} }
class Region(Base):
__tablename__ = "region"
relation_id = Column(BIGINT, primary_key=True, index=True)
name = Column(String)
geometry = Column(Geometry)
admin_level = Column(Integer)
tags = Column(HSTORE)
Comment.author = relationship("User", back_populates="authored_comments") Comment.author = relationship("User", back_populates="authored_comments")
User.authored_comments = relationship( User.authored_comments = relationship(
"Comment", order_by=Comment.created_at, back_populates="author", passive_deletes=True "Comment",
order_by=Comment.created_at,
back_populates="author",
passive_deletes=True,
) )
Track.author = relationship("User", back_populates="authored_tracks") Track.author = relationship("User", back_populates="authored_tracks")
@ -418,7 +431,10 @@ Track.comments = relationship(
OvertakingEvent.track = relationship("Track", back_populates="overtaking_events") OvertakingEvent.track = relationship("Track", back_populates="overtaking_events")
Track.overtaking_events = relationship( Track.overtaking_events = relationship(
"OvertakingEvent", order_by=OvertakingEvent.time, back_populates="track", passive_deletes=True "OvertakingEvent",
order_by=OvertakingEvent.time,
back_populates="track",
passive_deletes=True,
) )

View file

@ -26,7 +26,7 @@ if app.config.FRONTEND_CONFIG:
.replace("111", "{x}") .replace("111", "{x}")
.replace("222", "{y}") .replace("222", "{y}")
], ],
"minzoom": 12, "minzoom": 0,
"maxzoom": 14, "maxzoom": 14,
} }
), ),

View file

@ -4,12 +4,12 @@ from typing import Optional
from operator import and_ from operator import and_
from functools import reduce from functools import reduce
from sqlalchemy import select, func from sqlalchemy import select, func, desc
from sanic.response import json from sanic.response import json
from obs.api.app import api from obs.api.app import api
from obs.api.db import Track, OvertakingEvent, User from obs.api.db import Track, OvertakingEvent, User, Region
from obs.api.utils import round_to from obs.api.utils import round_to
@ -167,3 +167,36 @@ async def stats(req):
# }); # });
# }), # }),
# ); # );
@api.route("/stats/regions")
async def stats(req):
query = (
select(
[
Region.relation_id.label("id"),
Region.name,
func.count(OvertakingEvent.id).label("overtaking_event_count"),
]
)
.select_from(Region)
.join(
OvertakingEvent,
func.ST_Within(
func.ST_Transform(OvertakingEvent.geometry, 3857), Region.geometry
),
)
.where(Region.admin_level == 6)
.group_by(
Region.relation_id,
Region.name,
Region.relation_id,
Region.admin_level,
Region.geometry,
)
.having(func.count(OvertakingEvent.id) > 0)
.order_by(desc("overtaking_event_count"))
)
regions = list(map(dict, (await req.ctx.db.execute(query)).all()))
return json(regions)

View file

@ -12,7 +12,7 @@
"obsMapSource": { "obsMapSource": {
"type": "vector", "type": "vector",
"tiles": ["https://portal.example.com/tiles/{z}/{x}/{y}.pbf"], "tiles": ["https://portal.example.com/tiles/{z}/{x}/{y}.pbf"],
"minzoom": 12, "minzoom": 0,
"maxzoom": 14 "maxzoom": 14
} }
} }

View file

@ -59,7 +59,7 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
) )
} }
export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap, twoTicks?: boolean}) { export default function ColorMapLegend({map, twoTicks = false, digits=2}: {map: ColorMap, twoTicks?: boolean, digits?: number}) {
const min = map[0][0] const min = map[0][0]
const max = map[map.length - 1][0] const max = map[map.length - 1][0]
const normalizeValue = (v) => (v - min) / (max - min) const normalizeValue = (v) => (v - min) / (max - min)
@ -81,7 +81,7 @@ export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap,
</svg> </svg>
{tickValues.map(([value]) => ( {tickValues.map(([value]) => (
<span className={styles.tick} key={value} style={{left: normalizeValue(value) * 100 + '%'}}> <span className={styles.tick} key={value} style={{left: normalizeValue(value) * 100 + '%'}}>
{value.toFixed(2)} {value.toFixed(digits)}
</span> </span>
))} ))}
</div> </div>

View file

@ -56,17 +56,23 @@ function Map({
children, children,
boundsFromJson, boundsFromJson,
baseMapStyle, baseMapStyle,
onViewportChange,
...props ...props
}: { }: {
viewportFromUrl?: boolean viewportFromUrl?: boolean
children: React.ReactNode children: React.ReactNode
boundsFromJson: GeoJSON.Geometry boundsFromJson: GeoJSON.Geometry
baseMapStyle: string baseMapStyle: string
onViewportChange: (viewport: Viewport) => void,
}) { }) {
const [viewportState, setViewportState] = 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 setViewport = useCallback((viewport: Viewport) => {
setViewport_(viewport);
onViewportChange?.(viewport);
}, [setViewport_, onViewportChange])
const config = useConfig() const config = useConfig()
useEffect(() => { useEffect(() => {

View file

@ -0,0 +1,83 @@
import React, { useState, useCallback } from "react";
import { pickBy } from "lodash";
import {
Loader,
Statistic,
Pagination,
Segment,
Header,
Menu,
Table,
Icon,
} from "semantic-ui-react";
import { useObservable } from "rxjs-hooks";
import { of, from, concat, combineLatest } from "rxjs";
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
import { Duration, DateTime } from "luxon";
import api from "api";
function formatDuration(seconds) {
return (
Duration.fromMillis((seconds ?? 0) * 1000)
.as("hours")
.toFixed(1) + " h"
);
}
export default function Stats() {
const [page, setPage] = useState(1);
const PER_PAGE = 10;
const stats = useObservable(
() =>
of(null).pipe(
switchMap(() => concat(of(null), from(api.get("/stats/regions"))))
),
null
);
const pageCount = stats ? Math.ceil(stats.length / PER_PAGE) : 1;
return (
<>
<Header as="h2">Top Regions</Header>
<div>
<Loader active={stats == null} />
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Region name</Table.HeaderCell>
<Table.HeaderCell>Event count</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{stats
?.slice((page - 1) * PER_PAGE, page * PER_PAGE)
?.map((area) => (
<Table.Row key={area.id}>
<Table.Cell>{area.name}</Table.Cell>
<Table.Cell>{area.overtaking_event_count}</Table.Cell>
</Table.Row>
))}
</Table.Body>
{pageCount > 1 && <Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan="2">
<Pagination
floated="right"
activePage={page}
totalPages={pageCount}
onPageChange={(e, data) => setPage(data.activePage as number)}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>}
</Table>
</div>
</>
);
}

View file

@ -1,4 +1,5 @@
export {default as Avatar} from './Avatar' export {default as Avatar} from './Avatar'
export {default as Chart} from './Chart'
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend' export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend'
export {default as FileDrop} from './FileDrop' export {default as FileDrop} from './FileDrop'
export {default as FileUploadField} from './FileUploadField' export {default as FileUploadField} from './FileUploadField'
@ -6,6 +7,6 @@ export {default as FormattedDate} from './FormattedDate'
export {default as LoginButton} from './LoginButton' export {default as LoginButton} from './LoginButton'
export {default as Map} from './Map' export {default as Map} from './Map'
export {default as Page} from './Page' export {default as Page} from './Page'
export {default as RegionStats} from './RegionStats'
export {default as Stats} from './Stats' export {default as Stats} from './Stats'
export {default as StripMarkdown} from './StripMarkdown' export {default as StripMarkdown} from './StripMarkdown'
export {default as Chart} from './Chart'

View file

@ -77,6 +77,60 @@ export const trackLayer = {
}, },
} }
export const getRegionLayers = (adminLevel = 6, baseColor = "#00897B", maxValue = 5000) => [{
id: 'region',
"type": "fill",
"source": "obs",
"source-layer": "obs_regions",
"minzoom": 0,
"maxzoom": 10,
"filter": [
"all",
["==", "admin_level", adminLevel],
[">", "overtaking_event_count", 0],
],
"paint": {
"fill-color": baseColor,
"fill-antialias": true,
"fill-opacity": [
"interpolate",
["linear"],
[
"log10",
[
"get",
"overtaking_event_count"
]
],
0,
0,
Math.log10(maxValue),
0.9
]
},
},
{
id: 'region-border',
"type": "line",
"source": "obs",
"source-layer": "obs_regions",
"minzoom": 0,
"maxzoom": 10,
"filter": [
"all",
["==", "admin_level", adminLevel],
[">", "overtaking_event_count", 0],
],
"paint": {
"line-width": 1,
"line-color": baseColor,
},
"layout": {
"line-join": "round",
"line-cap": "round"
}
}]
export const trackLayerRaw = produce(trackLayer, draft => { export const trackLayerRaw = produce(trackLayer, draft => {
// draft.paint['line-color'] = '#81D4FA' // draft.paint['line-color'] = '#81D4FA'
draft.paint['line-width'][4] = 1 draft.paint['line-width'][4] = 1

View file

@ -1,42 +1,44 @@
import React from 'react' import React from "react";
import {Link} from 'react-router-dom' import { Link } from "react-router-dom";
import {Message, Grid, Loader, Header, Item} from 'semantic-ui-react' import { Message, Grid, Loader, Header, Item } from "semantic-ui-react";
import {useObservable} from 'rxjs-hooks' import { useObservable } from "rxjs-hooks";
import {of, from} from 'rxjs' import { of, from } from "rxjs";
import {map, switchMap} from 'rxjs/operators' import { map, switchMap } from "rxjs/operators";
import api from 'api' import api from "api";
import {Stats, Page, Map} from 'components' import { RegionStats, Stats, Page, Map } from "components";
import {TrackListItem} from './TracksPage' import { TrackListItem } from "./TracksPage";
import styles from './HomePage.module.less' import styles from "./HomePage.module.less";
function MostRecentTrack() { function MostRecentTrack() {
const track: Track | null = useObservable( const tracks: Track[] | null = useObservable(
() => () =>
of(null).pipe( of(null).pipe(
switchMap(() => from(api.fetch('/tracks?limit=1'))), switchMap(() => from(api.fetch("/tracks?limit=3"))),
map((response) => response?.tracks?.[0]) map((response) => response?.tracks)
), ),
null, null,
[] []
) );
return ( return (
<> <>
<Header as="h2">Most recent track</Header> <Header as="h2">Most recent tracks</Header>
<Loader active={track === null} /> <Loader active={tracks === null} />
{track === undefined ? ( {tracks?.length === 0 ? (
<Message> <Message>
No public tracks yet. <Link to="/upload">Upload the first!</Link> No public tracks yet. <Link to="/upload">Upload the first!</Link>
</Message> </Message>
) : track ? ( ) : tracks ? (
<Item.Group> <Item.Group>
<TrackListItem track={track} /> {tracks.map((track) => (
<TrackListItem key={track.id} track={track} />
))}
</Item.Group> </Item.Group>
) : null} ) : null}
</> </>
) );
} }
export default function HomePage() { export default function HomePage() {
@ -46,12 +48,13 @@ export default function HomePage() {
<Grid.Row> <Grid.Row>
<Grid.Column width={8}> <Grid.Column width={8}>
<Stats /> <Stats />
<MostRecentTrack />
</Grid.Column> </Grid.Column>
<Grid.Column width={8}> <Grid.Column width={8}>
<MostRecentTrack /> <RegionStats />
</Grid.Column> </Grid.Column>
</Grid.Row> </Grid.Row>
</Grid> </Grid>
</Page> </Page>
) );
} }

View file

@ -36,6 +36,7 @@ function LayerSidebar({
baseMap: {style}, baseMap: {style},
obsRoads: {show: showRoads, showUntagged, attribute, maxCount}, obsRoads: {show: showRoads, showUntagged, attribute, maxCount},
obsEvents: {show: showEvents}, obsEvents: {show: showEvents},
obsRegions: {show: showRegions},
} = mapConfig } = mapConfig
return ( return (
@ -50,6 +51,28 @@ function LayerSidebar({
/> />
</List.Item> </List.Item>
<Divider /> <Divider />
<List.Item>
<Checkbox
toggle
size="small"
id="obsRegions.show"
style={{float: 'right'}}
checked={showRegions}
onChange={() => setMapConfigFlag('obsRegions.show', !showRegions)}
/>
<label htmlFor="obsRegions.show">
<Header as="h4">Regions</Header>
</label>
</List.Item>
{showRegions && (
<>
<List.Item>Color regions based on event count</List.Item>
<List.Item>
<ColorMapLegend twoTicks map={[[0, "#00897B00"], [5000, "#00897BFF"]]} digits={0} />
</List.Item>
</>
)}
<Divider />
<List.Item> <List.Item>
<Checkbox <Checkbox
toggle toggle

View file

@ -0,0 +1,33 @@
import React, { useState, useCallback } from "react";
import { createPortal } from "react-dom";
import _ from "lodash";
import { List, Header, Icon, Button } from "semantic-ui-react";
import styles from "./styles.module.less";
export default function RegionInfo({ region, mapInfoPortal, onClose }) {
const content = (
<>
<div className={styles.closeHeader}>
<Header as="h3">{region.properties.name || "Unnamed region"}</Header>
<Button primary icon onClick={onClose}>
<Icon name="close" />
</Button>
</div>
<List>
<List.Item>
<List.Header>Number of events</List.Header>
<List.Content>{region.properties.overtaking_event_count ?? 0}</List.Content>
</List.Item>
</List>
</>
);
return content && mapInfoPortal
? createPortal(
<div className={styles.mapInfoBox}>{content}</div>,
mapInfoPortal
)
: null;
}

View file

@ -1,6 +1,7 @@
import React, {useState, useCallback} from 'react' import React, {useState, useCallback} from 'react'
import {createPortal} from 'react-dom'
import _ from 'lodash' import _ from 'lodash'
import {Segment, Menu, Header, Label, Icon, Table} from 'semantic-ui-react' import {Menu, Header, Label, Icon, Table, Button} from 'semantic-ui-react'
import {Layer, Source} from 'react-map-gl' import {Layer, Source} from 'react-map-gl'
import {of, from, concat} from 'rxjs' import {of, from, concat} from 'rxjs'
import {useObservable} from 'rxjs-hooks' import {useObservable} from 'rxjs-hooks'
@ -110,7 +111,7 @@ function HistogramChart({bins, counts}) {
) )
} }
export default function RoadInfo({clickLocation}) { export default function RoadInfo({roadInfo: info, mapInfoPortal, onClose}) {
const [direction, setDirection] = useState('forwards') const [direction, setDirection] = useState('forwards')
const onClickDirection = useCallback( const onClickDirection = useCallback(
@ -122,72 +123,42 @@ export default function RoadInfo({clickLocation}) {
[setDirection] [setDirection]
) )
const info = useObservable( const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1 // TODO: change based on left-hand/right-hand traffic
(_$, 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) { const content = (
return 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 =
!loading && !info.road ? (
'No road found.'
) : (
<> <>
<Header as="h3">{loading ? '...' : info?.road.name || 'Unnamed way'}</Header> <div className={styles.closeHeader}>
<Header as="h3">{info.road.name || 'Unnamed way'}</Header>
<Button primary icon onClick={onClose}><Icon name='close' /></Button>
</div>
{info?.road.zone && ( {info.road.zone && (
<Label size="small" color={ZONE_COLORS[info?.road.zone]}> <Label size="small" color={ZONE_COLORS[info.road.zone]}>
{info?.road.zone} {info.road.zone}
</Label> </Label>
)} )}
{info?.road.oneway && ( {info.road.oneway && (
<Label size="small" color="blue"> <Label size="small" color="blue">
<Icon name="long arrow alternate right" fitted /> oneway <Icon name="long arrow alternate right" fitted /> oneway
</Label> </Label>
)} )}
{info?.road.oneway ? null : ( {info.road.oneway ? null : (
<Menu size="tiny" fluid secondary> <Menu size="tiny" pointing>
<Menu.Item header>Direction</Menu.Item> <Menu.Item header>Direction</Menu.Item>
<Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}> <Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
{getCardinalDirection(info?.forwards?.bearing)} {getCardinalDirection(info.forwards?.bearing)}
</Menu.Item> </Menu.Item>
<Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}> <Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
{getCardinalDirection(info?.backwards?.bearing)} {getCardinalDirection(info.backwards?.bearing)}
</Menu.Item> </Menu.Item>
</Menu> </Menu>
)} )}
{info?.[direction] && <RoadStatsTable data={info[direction]} />} {info[direction] && <RoadStatsTable data={info[direction]} />}
{info?.[direction]?.distanceOvertaker?.histogram && ( {info[direction]?.distanceOvertaker?.histogram && (
<> <>
<Header as="h5">Overtaker distance distribution</Header> <Header as="h5">Overtaker distance distribution</Header>
<HistogramChart {...info[direction]?.distanceOvertaker?.histogram} /> <HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
@ -198,7 +169,7 @@ export default function RoadInfo({clickLocation}) {
return ( return (
<> <>
{info?.road && ( {info.road && (
<Source id="highlight" type="geojson" data={info.road.geometry}> <Source id="highlight" type="geojson" data={info.road.geometry}>
<Layer <Layer
id="route" id="route"
@ -223,10 +194,11 @@ export default function RoadInfo({clickLocation}) {
</Source> </Source>
)} )}
{content && ( {content && mapInfoPortal && (
createPortal(
<div className={styles.mapInfoBox}> <div className={styles.mapInfoBox}>
<Segment loading={loading}>{content}</Segment> {content}
</div> </div>, mapInfoPortal))}
)} )}
</> </>
) )

View file

@ -1,166 +1,257 @@
import React, {useState, useCallback, useMemo} from 'react' import React, { useState, useCallback, useMemo, useRef } from "react";
import _ from 'lodash' import _ from "lodash";
import {Button} from 'semantic-ui-react' import { Button } from "semantic-ui-react";
import {Layer, Source} from 'react-map-gl' import { Layer, Source } from "react-map-gl";
import produce from 'immer' import produce from "immer";
import {Page, Map} from 'components' import api from "api";
import {useConfig} from 'config' import { Page, Map } from "components";
import {colorByDistance, colorByCount, reds} from 'mapstyles' import { useConfig } from "config";
import {useMapConfig} from 'reducers/mapConfig' import { colorByDistance, colorByCount, getRegionLayers } from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo from './RoadInfo' import RoadInfo from "./RoadInfo";
import LayerSidebar from './LayerSidebar' import RegionInfo from "./RegionInfo";
import styles from './styles.module.less' import LayerSidebar from "./LayerSidebar";
import styles from "./styles.module.less";
const untaggedRoadsLayer = { const untaggedRoadsLayer = {
id: 'obs_roads_untagged', id: "obs_roads_untagged",
type: 'line', type: "line",
source: 'obs', source: "obs",
'source-layer': 'obs_roads', "source-layer": "obs_roads",
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], minzoom: 12,
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
layout: { layout: {
'line-cap': 'round', "line-cap": "round",
'line-join': 'round', "line-join": "round",
}, },
paint: { paint: {
'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2], "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2],
'line-color': '#ABC', "line-color": "#ABC",
'line-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1], // 'line-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1],
'line-offset': [ "line-offset": [
'interpolate', "interpolate",
['exponential', 1.5], ["exponential", 1.5],
['zoom'], ["zoom"],
12, 12,
['get', 'offset_direction'], ["get", "offset_direction"],
19, 19,
['*', ['get', 'offset_direction'], 8], ["*", ["get", "offset_direction"], 8],
], ],
}, },
minzoom: 12, minzoom: 12,
} };
const getRoadsLayer = (colorAttribute, maxCount) => const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.id = 'obs_roads_normal' draft.id = "obs_roads_normal";
if (colorAttribute.endsWith('_count')) { if (colorAttribute.endsWith("_count")) {
// delete draft.filter // delete draft.filter
draft.filter = ['to-boolean', ['get', colorAttribute]] draft.filter = ["to-boolean", ["get", colorAttribute]];
} else { } else {
draft.filter = draft.filter[1] // remove '!' draft.filter = draft.filter[1]; // remove '!'
} }
draft.paint['line-width'][6] = 6 // scale bigger on zoom draft.minzoom = 10;
draft.paint['line-color'] = colorAttribute.startsWith('distance_') draft.paint["line-width"][6] = 6; // scale bigger on zoom
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
? colorByDistance(colorAttribute) ? colorByDistance(colorAttribute)
: colorAttribute.endsWith('_count') : colorAttribute.endsWith("_count")
? colorByCount(colorAttribute, maxCount) ? colorByCount(colorAttribute, maxCount)
: '#DDD' : "#DDD";
draft.paint['line-opacity'][3] = 12 // draft.paint['line-opacity'][3] = 12
draft.paint['line-opacity'][5] = 13 // draft.paint['line-opacity'][5] = 13
}) });
const getEventsLayer = () => ({ const getEventsLayer = () => ({
id: 'obs_events', id: "obs_events",
type: 'circle', type: "circle",
source: 'obs', source: "obs",
'source-layer': 'obs_events', "source-layer": "obs_events",
paint: { paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8], "circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8],
'circle-color': colorByDistance('distance_overtaker'), "circle-color": colorByDistance("distance_overtaker"),
}, },
minzoom: 11, minzoom: 11,
}) });
const getEventsTextLayer = () => ({ const getEventsTextLayer = () => ({
id: 'obs_events_text', id: "obs_events_text",
type: 'symbol', type: "symbol",
minzoom: 18, minzoom: 18,
source: 'obs', source: "obs",
'source-layer': 'obs_events', "source-layer": "obs_events",
layout: { layout: {
'text-field': [ "text-field": [
'number-format', "number-format",
['get', 'distance_overtaker'], ["get", "distance_overtaker"],
{'min-fraction-digits': 2, 'max-fraction-digits': 2}, { "min-fraction-digits": 2, "max-fraction-digits": 2 },
], ],
'text-allow-overlap': true, "text-allow-overlap": true,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'], "text-font": ["Open Sans Bold", "Arial Unicode MS Regular"],
'text-size': 14, "text-size": 14,
'text-keep-upright': false, "text-keep-upright": false,
'text-anchor': 'left', "text-anchor": "left",
'text-radial-offset': 1, "text-radial-offset": 1,
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]], "text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
'text-rotation-alignment': 'map', "text-rotation-alignment": "map",
}, },
paint: { paint: {
'text-halo-color': 'rgba(255, 255, 255, 1)', "text-halo-color": "rgba(255, 255, 255, 1)",
'text-halo-width': 1, "text-halo-width": 1,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1], "text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
}, },
}) });
interface RegionInfo {
properties: {
admin_level: number;
name: string;
overtaking_event_count: number;
};
}
interface ArrayStats {
statistics: {
count: number;
mean: number;
min: number;
max: number;
median: number;
};
histogram: {
bins: number[];
counts: number[];
};
values: number[];
}
interface RoadDirectionInfo {
bearing: number;
distanceOvertaker: ArrayStats;
distanceStationary: ArrayStats;
speed: ArrayStats;
}
interface RoadInfo {
road: {
way_id: number;
zone: "urban" | "rural" | null;
name: string;
directionality: -1 | 0 | 1;
oneway: boolean;
geometry: Object;
};
forwards: RoadDirectionInfo;
backwards: RoadDirectionInfo;
}
type Details =
| { type: "road"; road: Object }
| { type: "region"; region: RegionInfo };
export default function MapPage() { export default function MapPage() {
const {obsMapSource} = useConfig() || {} const { obsMapSource } = useConfig() || {};
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null) const [details, setDetails] = useState<null | Details>(null);
const mapConfig = useMapConfig() const onCloseDetails = useCallback(() => setDetails(null), [setDetails]);
const mapConfig = useMapConfig();
const viewportRef = useRef();
const mapInfoPortal = useRef();
const onViewportChange = useCallback(
(viewport) => {
viewportRef.current = viewport;
},
[viewportRef]
);
const onClick = useCallback( const onClick = useCallback(
(e) => { async (e) => {
let node = e.target // check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target;
while (node) { while (node) {
if (node?.classList?.contains(styles.mapInfoBox)) { if (node?.classList?.contains(styles.mapInfoBox)) {
return return;
} }
node = node.parentNode node = node.parentNode;
} }
setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]}) const { zoom } = viewportRef.current;
if (zoom < 10) {
const clickedRegion = e.features?.find(
(f) => f.source === "obs" && f.sourceLayer === "obs_regions"
);
setDetails(
clickedRegion ? { type: "region", region: clickedRegion } : null
);
} else {
const road = await api.get("/mapdetails/road", {
query: {
longitude: e.lngLat[0],
latitude: e.lngLat[1],
radius: 100,
}, },
[setClickLocation] });
) setDetails(road?.road ? { type: "road", road } : null);
}
},
[setDetails]
);
const [layerSidebar, setLayerSidebar] = useState(true) const [layerSidebar, setLayerSidebar] = useState(true);
const { const {
obsRoads: { attribute, maxCount }, obsRoads: { attribute, maxCount },
} = mapConfig } = mapConfig;
const layers = [] const layers = [];
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) { if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayer) layers.push(untaggedRoadsLayer);
} }
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount]) const roadsLayer = useMemo(
() => getRoadsLayer(attribute, maxCount),
[attribute, maxCount]
);
if (mapConfig.obsRoads.show) { if (mapConfig.obsRoads.show) {
layers.push(roadsLayer) layers.push(roadsLayer);
} }
const eventsLayer = useMemo(() => getEventsLayer(), []) const regionLayers = useMemo(() => getRegionLayers(), []);
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []) if (mapConfig.obsRegions.show) {
layers.push(...regionLayers);
}
const eventsLayer = useMemo(() => getEventsLayer(), []);
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
if (mapConfig.obsEvents.show) { if (mapConfig.obsEvents.show) {
layers.push(eventsLayer) layers.push(eventsLayer);
layers.push(eventsTextLayer) layers.push(eventsTextLayer);
} }
if (!obsMapSource) { if (!obsMapSource) {
return null return null;
} }
return ( return (
<Page fullScreen title="Map"> <Page fullScreen title="Map">
<div className={styles.mapContainer}> <div className={styles.mapContainer} ref={mapInfoPortal}>
{layerSidebar && ( {layerSidebar && (
<div className={styles.mapSidebar}> <div className={styles.mapSidebar}>
<LayerSidebar /> <LayerSidebar />
</div> </div>
)} )}
<div className={styles.map}> <div className={styles.map}>
<Map viewportFromUrl onClick={onClick}> <Map
viewportFromUrl
onClick={onClick}
onViewportChange={onViewportChange}
>
<Button <Button
style={{ style={{
position: 'absolute', position: "absolute",
left: 44, left: 44,
top: 9, top: 9,
}} }}
@ -175,10 +266,24 @@ export default function MapPage() {
))} ))}
</Source> </Source>
<RoadInfo {...{clickLocation}} /> {details?.type === "road" && details?.road?.road && (
<RoadInfo
roadInfo={details.road}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
/>
)}
{details?.type === "region" && details?.region && (
<RegionInfo
region={details.region}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
/>
)}
</Map> </Map>
</div> </div>
</div> </div>
</Page> </Page>
) );
} }

View file

@ -20,11 +20,38 @@
} }
.mapInfoBox { .mapInfoBox {
position: absolute;
right: 0;
top: 0;
max-height: 100%;
width: 36rem; width: 36rem;
overflow: auto; overflow: auto;
margin: 20px; border-left: 1px solid @borderColor;
background: white;
padding: 16px;
}
.closeHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
}
@media @mobile {
.mapContainer {
height: auto;
min-height: calc(100vh - @menuHeight);
flex-direction: column;
}
.map {
height: 60vh;
}
.mapSidebar {
width: auto;
height: auto;
}
.mapInfoBox {
width: auto;
height: auto;
}
} }

View file

@ -26,6 +26,9 @@ export type MapConfig = {
obsEvents: { obsEvents: {
show: boolean; show: boolean;
}; };
obsRegions: {
show: boolean;
};
}; };
export const initialState: MapConfig = { export const initialState: MapConfig = {
@ -41,6 +44,9 @@ export const initialState: MapConfig = {
obsEvents: { obsEvents: {
show: false, show: false,
}, },
obsRegions: {
show: true,
},
}; };
type MapConfigAction = { type MapConfigAction = {

View file

@ -50,6 +50,9 @@ local MOTORWAY_TYPES = {
"motorway_link", "motorway_link",
} }
local ADMIN_LEVEL_MIN = 2
local ADMIN_LEVEL_MAX = 8
local ONEWAY_YES = {"yes", "true", "1"} local ONEWAY_YES = {"yes", "true", "1"}
local ONEWAY_REVERSE = {"reverse", "-1"} local ONEWAY_REVERSE = {"reverse", "-1"}
@ -61,6 +64,14 @@ local roads = osm2pgsql.define_way_table('road', {
{ column = 'oneway', type = 'bool' }, { column = 'oneway', type = 'bool' },
}) })
local regions = osm2pgsql.define_relation_table('region', {
{ column = 'name', type = 'text' },
{ column = 'geometry', type = 'geometry' },
{ column = 'admin_level', type = 'int' },
{ column = 'tags', type = 'hstore' },
})
function osm2pgsql.process_way(object) function osm2pgsql.process_way(object)
if object.tags.highway and contains(HIGHWAY_TYPES, object.tags.highway) then if object.tags.highway and contains(HIGHWAY_TYPES, object.tags.highway) then
local tags = object.tags local tags = object.tags
@ -112,3 +123,21 @@ function osm2pgsql.process_way(object)
}) })
end end
end end
function osm2pgsql.process_relation(object)
local admin_level = tonumber(object.tags.admin_level)
if object.tags.boundary == "administrative" and admin_level and admin_level >= ADMIN_LEVEL_MIN and admin_level <= ADMIN_LEVEL_MAX then
regions:add_row({
geometry = { create = 'area' },
name = object.tags.name,
admin_level = admin_level,
tags = object.tags,
})
end
end
function osm2pgsql.select_relation_members(relation)
if relation.tags.type == 'route' then
return { ways = osm2pgsql.way_member_ids(relation) }
end
end

View file

@ -11,6 +11,8 @@ RETURNS TABLE(event_id bigint, geometry geometry, distance_overtaker float, dist
speed, speed,
way_id::bigint as way_id way_id::bigint as way_id
FROM overtaking_event FROM overtaking_event
WHERE ST_Transform(overtaking_event.geometry, 3857) && bbox; WHERE
zoom_level >= 10 AND
ST_Transform(overtaking_event.geometry, 3857) && bbox;
$$ LANGUAGE SQL IMMUTABLE; $$ LANGUAGE SQL IMMUTABLE;

View file

@ -0,0 +1,26 @@
DROP FUNCTION IF EXISTS layer_obs_regions(geometry, int);
CREATE OR REPLACE FUNCTION layer_obs_regions(bbox geometry, zoom_level int)
RETURNS TABLE(
region_id bigint,
geometry geometry,
name text,
admin_level int,
overtaking_event_count int
) AS $$
SELECT
region.relation_id::bigint as region_id,
ST_SimplifyPreserveTopology(region.geometry, ZRes(zoom_level + 2)) as geometry,
region.name as name,
region.admin_level as admin_level,
count(overtaking_event.id)::int as overtaking_event_count
FROM region
LEFT JOIN overtaking_event on ST_Within(ST_Transform(overtaking_event.geometry, 3857), region.geometry)
WHERE
zoom_level >= 4 AND
zoom_level <= 12 AND
ST_Transform(region.geometry, 3857) && bbox
GROUP BY region.relation_id, region.name, region.geometry, region.admin_level
$$ LANGUAGE SQL IMMUTABLE;

View file

@ -0,0 +1,23 @@
layer:
id: "obs_regions"
description: |
Statistics on administrative boundary areas ("regions")
buffer_size: 4
fields:
overtaking_event_count: |
Number of overtaking events.
name: |
Name of the region
admin_level: |
Administrative level of the boundary, as tagged in OpenStreetMap
defaults:
srs: EPSG:3785
datasource:
srid: 3857
geometry_field: geometry
key_field: region_id
key_field_as_attribute: no
query: (SELECT region_id, geometry, name, admin_level, overtaking_event_count FROM layer_obs_regions(!bbox!, z(!scale_denominator!))) AS t
schema:
- ./layer.sql

View file

@ -32,7 +32,9 @@ RETURNS TABLE(
LEFT JOIN (VALUES (-1, TRUE), (1, FALSE), (0, FALSE)) AS r(dir, rev) ON (abs(r.dir) != road.directionality) LEFT JOIN (VALUES (-1, TRUE), (1, FALSE), (0, FALSE)) AS r(dir, rev) ON (abs(r.dir) != road.directionality)
FULL OUTER JOIN overtaking_event ON (road.way_id = overtaking_event.way_id and (road.directionality != 0 or overtaking_event.direction_reversed = r.rev)) FULL OUTER JOIN overtaking_event ON (road.way_id = overtaking_event.way_id and (road.directionality != 0 or overtaking_event.direction_reversed = r.rev))
-- WHERE road.name = 'Merzhauser Straße' -- WHERE road.name = 'Merzhauser Straße'
WHERE road.geometry && bbox WHERE
zoom_level >= 10 AND
road.geometry && bbox
GROUP BY road.name, road.way_id, road.geometry, road.directionality, r.dir, r.rev; GROUP BY road.name, road.way_id, road.geometry, road.directionality, r.dir, r.rev;
$$ LANGUAGE SQL IMMUTABLE; $$ LANGUAGE SQL IMMUTABLE;

View file

@ -3,6 +3,7 @@ tileset:
layers: layers:
- layers/obs_events/obs_events.yaml - layers/obs_events/obs_events.yaml
- layers/obs_roads/obs_roads.yaml - layers/obs_roads/obs_roads.yaml
- layers/obs_regions/obs_regions.yaml
version: 0.6.2 version: 0.6.2
id: openbikesensor id: openbikesensor
description: > description: >