Merge branch 'administrative-area-import' into next

This commit is contained in:
Paul Bienkowski 2023-03-12 12:44:09 +01:00
commit e0070fc794
23 changed files with 940 additions and 667 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

@ -432,6 +432,16 @@ 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", "Comment",

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

@ -1,75 +1,70 @@
import React, { useState, useCallback, useMemo, useEffect } from "react"; import React, {useState, useCallback, useMemo, useEffect} from 'react'
import classnames from "classnames"; import classnames from 'classnames'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import _ from "lodash"; import _ from 'lodash'
import ReactMapGl, { import ReactMapGl, {WebMercatorViewport, ScaleControl, NavigationControl, AttributionControl} from 'react-map-gl'
WebMercatorViewport, import turfBbox from '@turf/bbox'
ScaleControl, import {useHistory, useLocation} from 'react-router-dom'
NavigationControl,
AttributionControl,
} from "react-map-gl";
import turfBbox from "@turf/bbox";
import { useHistory, useLocation } from "react-router-dom";
import { useConfig } from "config"; import {useConfig} from 'config'
import { useCallbackRef } from "../../utils"; import {useCallbackRef} from '../../utils'
import { baseMapStyles } from "../../mapstyles"; import {baseMapStyles} from '../../mapstyles'
import styles from "./styles.module.less"; import styles from './styles.module.less'
interface Viewport { interface Viewport {
longitude: number; longitude: number
latitude: number; latitude: number
zoom: number; zoom: number
} }
const EMPTY_VIEWPORT: Viewport = { longitude: 0, latitude: 0, zoom: 0 }; const EMPTY_VIEWPORT: Viewport = {longitude: 0, latitude: 0, zoom: 0}
export const withBaseMapStyle = connect((state) => ({ export const withBaseMapStyle = connect((state) => ({
baseMapStyle: state.mapConfig?.baseMap?.style ?? "positron", baseMapStyle: state.mapConfig?.baseMap?.style ?? 'positron',
})); }))
function parseHash(v: string): Viewport | null { function parseHash(v: string): Viewport | null {
if (!v) return null; if (!v) return null
const m = v.match(/^#([0-9\.]+)\/([0-9\.\-]+)\/([0-9\.\-]+)$/); const m = v.match(/^#([0-9\.]+)\/([0-9\.\-]+)\/([0-9\.\-]+)$/)
if (!m) return null; if (!m) return null
return { return {
zoom: Number.parseFloat(m[1]), zoom: Number.parseFloat(m[1]),
latitude: Number.parseFloat(m[2]), latitude: Number.parseFloat(m[2]),
longitude: Number.parseFloat(m[3]), longitude: Number.parseFloat(m[3]),
}; }
} }
function buildHash(v: Viewport): string { function buildHash(v: Viewport): string {
return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`; return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`
} }
const setViewportToHash = _.debounce((history, viewport) => { const setViewportToHash = _.debounce((history, viewport) => {
history.replace({ history.replace({
hash: buildHash(viewport), hash: buildHash(viewport),
}); })
}, 200); }, 200)
function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] { function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] {
const history = useHistory(); const history = useHistory()
const location = useLocation(); const location = useLocation()
const [cachedValue, setCachedValue] = useState(parseHash(location.hash)); const [cachedValue, setCachedValue] = useState(parseHash(location.hash))
// when the location hash changes, set the new value to the cache // when the location hash changes, set the new value to the cache
useEffect(() => { useEffect(() => {
setCachedValue(parseHash(location.hash)); setCachedValue(parseHash(location.hash))
}, [location.hash]); }, [location.hash])
const setter = useCallback( const setter = useCallback(
(v) => { (v) => {
setCachedValue(v); setCachedValue(v)
setViewportToHash(history, v); setViewportToHash(history, v)
}, },
[history] [history]
); )
return [cachedValue || EMPTY_VIEWPORT, setter]; return [cachedValue || EMPTY_VIEWPORT, setter]
} }
function Map({ function Map({
@ -78,57 +73,54 @@ function Map({
boundsFromJson, boundsFromJson,
baseMapStyle, baseMapStyle,
hasToolbar, hasToolbar,
onViewportChange,
...props ...props
}: { }: {
viewportFromUrl?: boolean; viewportFromUrl?: boolean
children: React.ReactNode; children: React.ReactNode
boundsFromJson: GeoJSON.Geometry; boundsFromJson: GeoJSON.Geometry
baseMapStyle: string; baseMapStyle: string
hasToolbar?: boolean; hasToolbar?: boolean
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 const [viewport, setViewport_] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState]
? [viewportUrl, setViewportUrl] const setViewport = useCallback(
: [viewportState, setViewportState]; (viewport: Viewport) => {
setViewport_(viewport)
onViewportChange?.(viewport)
},
[setViewport_, onViewportChange]
)
const config = useConfig(); const config = useConfig()
useEffect(() => { useEffect(() => {
if ( if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) {
config?.mapHome && setViewport(config.mapHome)
viewport?.latitude === 0 &&
viewport?.longitude === 0 &&
!boundsFromJson
) {
setViewport(config.mapHome);
} }
}, [config, boundsFromJson]); }, [config, boundsFromJson])
const mapSourceHosts = useMemo( const mapSourceHosts = useMemo(
() => () => _.uniq(config?.obsMapSource?.tiles?.map((tileUrl: string) => new URL(tileUrl).host) ?? []),
_.uniq(
config?.obsMapSource?.tiles?.map(
(tileUrl: string) => new URL(tileUrl).host
) ?? []
),
[config?.obsMapSource] [config?.obsMapSource]
); )
const transformRequest = useCallbackRef((url, resourceType) => { const transformRequest = useCallbackRef((url, resourceType) => {
if (resourceType === "Tile" && mapSourceHosts.includes(new URL(url).host)) { if (resourceType === 'Tile' && mapSourceHosts.includes(new URL(url).host)) {
return { return {
url, url,
credentials: "include", credentials: 'include',
}; }
} }
}); })
useEffect(() => { useEffect(() => {
if (boundsFromJson) { if (boundsFromJson) {
const bbox = turfBbox(boundsFromJson); const bbox = turfBbox(boundsFromJson)
if (bbox.every((v) => Math.abs(v) !== Infinity)) { if (bbox.every((v) => Math.abs(v) !== Infinity)) {
const [minX, minY, maxX, maxY] = bbox; const [minX, minY, maxX, maxY] = bbox
const vp = new WebMercatorViewport({ const vp = new WebMercatorViewport({
width: 1000, width: 1000,
height: 800, height: 800,
@ -141,11 +133,11 @@ function Map({
padding: 20, padding: 20,
offset: [0, -100], offset: [0, -100],
} }
); )
setViewport(_.pick(vp, ["zoom", "latitude", "longitude"])); setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude']))
} }
} }
}, [boundsFromJson]); }, [boundsFromJson])
return ( return (
<ReactMapGl <ReactMapGl
@ -153,23 +145,19 @@ function Map({
width="100%" width="100%"
height="100%" height="100%"
onViewportChange={setViewport} onViewportChange={setViewport}
{...{ transformRequest }} {...{transformRequest}}
{...viewport} {...viewport}
{...props} {...props}
className={classnames(styles.map, props.className)} className={classnames(styles.map, props.className)}
attributionControl={false} attributionControl={false}
> >
<AttributionControl style={{ top: 0, right: 0 }} /> <AttributionControl style={{top: 0, right: 0}} />
<NavigationControl style={{ left: 16, top: hasToolbar ? 64 : 16 }} /> <NavigationControl style={{left: 16, top: hasToolbar ? 64 : 16}} />
<ScaleControl <ScaleControl maxWidth={200} unit="metric" style={{left: 16, bottom: 16}} />
maxWidth={200}
unit="metric"
style={{ left: 16, bottom: 16 }}
/>
{children} {children}
</ReactMapGl> </ReactMapGl>
); )
} }
export default withBaseMapStyle(Map); export default withBaseMapStyle(Map)

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,7 +7,7 @@ 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'
export {default as Visibility} from './Visibility' export {default as Visibility} from './Visibility'

View file

@ -124,6 +124,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

@ -6,7 +6,7 @@ import {map, switchMap} from 'rxjs/operators'
import {useTranslation} from 'react-i18next' import {useTranslation} from 'react-i18next'
import api from 'api' import api from 'api'
import {Stats, Page} from 'components' import {RegionStats, Stats, Page} from 'components'
import type {Track} from 'types' import type {Track} from 'types'
import {TrackListItem, NoPublicTracksMessage} from './TracksPage' import {TrackListItem, NoPublicTracksMessage} from './TracksPage'
@ -46,9 +46,10 @@ 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>

View file

@ -1,69 +1,56 @@
import React from "react"; import React from 'react'
import _ from "lodash"; import _ from 'lodash'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { import {List, Select, Input, Divider, Label, Checkbox, Header} from 'semantic-ui-react'
List, import {useTranslation} from 'react-i18next'
Select,
Input,
Divider,
Label,
Checkbox,
Header,
} from "semantic-ui-react";
import { useTranslation } from "react-i18next";
import { import {
MapConfig, MapConfig,
setMapConfigFlag as setMapConfigFlagAction, setMapConfigFlag as setMapConfigFlagAction,
initialState as defaultMapConfig, initialState as defaultMapConfig,
} from "reducers/mapConfig"; } from 'reducers/mapConfig'
import { colorByDistance, colorByCount, viridisSimpleHtml } from "mapstyles"; import {colorByDistance, colorByCount, viridisSimpleHtml} from 'mapstyles'
import { ColorMapLegend, DiscreteColorMapLegend } from "components"; import {ColorMapLegend, DiscreteColorMapLegend} from 'components'
const BASEMAP_STYLE_OPTIONS = ["positron", "bright"]; const BASEMAP_STYLE_OPTIONS = ['positron', 'bright']
const ROAD_ATTRIBUTE_OPTIONS = [ const ROAD_ATTRIBUTE_OPTIONS = [
"distance_overtaker_mean", 'distance_overtaker_mean',
"distance_overtaker_min", 'distance_overtaker_min',
"distance_overtaker_max", 'distance_overtaker_max',
"distance_overtaker_median", 'distance_overtaker_median',
"overtaking_event_count", 'overtaking_event_count',
"usage_count", 'usage_count',
"zone", 'zone',
]; ]
const DATE_FILTER_MODES = ["none", "range", "threshold"]; const DATE_FILTER_MODES = ['none', 'range', 'threshold']
type User = Object; type User = Object
function LayerSidebar({ function LayerSidebar({
mapConfig, mapConfig,
login, login,
setMapConfigFlag, setMapConfigFlag,
}: { }: {
login: User | null; login: User | null
mapConfig: MapConfig; mapConfig: MapConfig
setMapConfigFlag: (flag: string, value: unknown) => void; setMapConfigFlag: (flag: string, value: unknown) => void
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation()
const { const {
baseMap: { style }, baseMap: {style},
obsRoads: { show: showRoads, showUntagged, attribute, maxCount }, obsRoads: {show: showRoads, showUntagged, attribute, maxCount},
obsEvents: { show: showEvents }, obsEvents: {show: showEvents},
filters: { obsRegions: {show: showRegions},
currentUser: filtersCurrentUser, filters: {currentUser: filtersCurrentUser, dateMode, startDate, endDate, thresholdAfter},
dateMode, } = mapConfig
startDate,
endDate,
thresholdAfter,
},
} = mapConfig;
return ( return (
<div> <div>
<List relaxed> <List relaxed>
<List.Item> <List.Item>
<List.Header>{t("MapPage.sidebar.baseMap.style.label")}</List.Header> <List.Header>{t('MapPage.sidebar.baseMap.style.label')}</List.Header>
<Select <Select
options={BASEMAP_STYLE_OPTIONS.map((value) => ({ options={BASEMAP_STYLE_OPTIONS.map((value) => ({
value, value,
@ -71,23 +58,50 @@ function LayerSidebar({
text: t(`MapPage.sidebar.baseMap.style.${value}`), text: t(`MapPage.sidebar.baseMap.style.${value}`),
}))} }))}
value={style} value={style}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('baseMap.style', value)}
setMapConfigFlag("baseMap.style", value)
}
/> />
</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
size="small" size="small"
id="obsRoads.show" id="obsRoads.show"
style={{ float: "right" }} style={{float: 'right'}}
checked={showRoads} checked={showRoads}
onChange={() => setMapConfigFlag("obsRoads.show", !showRoads)} onChange={() => setMapConfigFlag('obsRoads.show', !showRoads)}
/> />
<label htmlFor="obsRoads.show"> <label htmlFor="obsRoads.show">
<Header as="h4">{t("MapPage.sidebar.obsRoads.title")}</Header> <Header as="h4">{t('MapPage.sidebar.obsRoads.title')}</Header>
</label> </label>
</List.Item> </List.Item>
{showRoads && ( {showRoads && (
@ -95,16 +109,12 @@ function LayerSidebar({
<List.Item> <List.Item>
<Checkbox <Checkbox
checked={showUntagged} checked={showUntagged}
onChange={() => onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
setMapConfigFlag("obsRoads.showUntagged", !showUntagged) label={t('MapPage.sidebar.obsRoads.showUntagged.label')}
}
label={t("MapPage.sidebar.obsRoads.showUntagged.label")}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header> <List.Header>{t('MapPage.sidebar.obsRoads.attribute.label')}</List.Header>
{t("MapPage.sidebar.obsRoads.attribute.label")}
</List.Header>
<Select <Select
fluid fluid
options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({ options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({
@ -113,74 +123,50 @@ function LayerSidebar({
text: t(`MapPage.sidebar.obsRoads.attribute.${value}`), text: t(`MapPage.sidebar.obsRoads.attribute.${value}`),
}))} }))}
value={attribute} value={attribute}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)}
setMapConfigFlag("obsRoads.attribute", value)
}
/> />
</List.Item> </List.Item>
{attribute.endsWith("_count") ? ( {attribute.endsWith('_count') ? (
<> <>
<List.Item> <List.Item>
<List.Header> <List.Header>{t('MapPage.sidebar.obsRoads.maxCount.label')}</List.Header>
{t("MapPage.sidebar.obsRoads.maxCount.label")}
</List.Header>
<Input <Input
fluid fluid
type="number" type="number"
value={maxCount} value={maxCount}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)}
setMapConfigFlag("obsRoads.maxCount", value)
}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<ColorMapLegend <ColorMapLegend
map={_.chunk( map={_.chunk(
colorByCount( colorByCount('obsRoads.maxCount', mapConfig.obsRoads.maxCount, viridisSimpleHtml).slice(3),
"obsRoads.maxCount",
mapConfig.obsRoads.maxCount,
viridisSimpleHtml
).slice(3),
2 2
)} )}
twoTicks twoTicks
/> />
</List.Item> </List.Item>
</> </>
) : attribute.endsWith("zone") ? ( ) : attribute.endsWith('zone') ? (
<> <>
<List.Item> <List.Item>
<Label <Label size="small" style={{background: 'blue', color: 'white'}}>
size="small" {t('general.zone.urban')} (1.5&nbsp;m)
style={{ background: "blue", color: "white" }}
>
{t("general.zone.urban")} (1.5&nbsp;m)
</Label> </Label>
<Label <Label size="small" style={{background: 'cyan', color: 'black'}}>
size="small" {t('general.zone.rural')}(2&nbsp;m)
style={{ background: "cyan", color: "black" }}
>
{t("general.zone.rural")}(2&nbsp;m)
</Label> </Label>
</List.Item> </List.Item>
</> </>
) : ( ) : (
<> <>
<List.Item> <List.Item>
<List.Header> <List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
{_.upperFirst(t("general.zone.urban"))} <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header> <List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
{_.upperFirst(t("general.zone.rural"))} <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item> </List.Item>
</> </>
)} )}
@ -192,40 +178,36 @@ function LayerSidebar({
toggle toggle
size="small" size="small"
id="obsEvents.show" id="obsEvents.show"
style={{ float: "right" }} style={{float: 'right'}}
checked={showEvents} checked={showEvents}
onChange={() => setMapConfigFlag("obsEvents.show", !showEvents)} onChange={() => setMapConfigFlag('obsEvents.show', !showEvents)}
/> />
<label htmlFor="obsEvents.show"> <label htmlFor="obsEvents.show">
<Header as="h4">{t("MapPage.sidebar.obsEvents.title")}</Header> <Header as="h4">{t('MapPage.sidebar.obsEvents.title')}</Header>
</label> </label>
</List.Item> </List.Item>
{showEvents && ( {showEvents && (
<> <>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header> <List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
<DiscreteColorMapLegend <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header> <List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
<DiscreteColorMapLegend <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item> </List.Item>
</> </>
)} )}
<Divider /> <Divider />
<List.Item> <List.Item>
<Header as="h4">{t("MapPage.sidebar.filters.title")}</Header> <Header as="h4">{t('MapPage.sidebar.filters.title')}</Header>
</List.Item> </List.Item>
{login && ( {login && (
<> <>
<List.Item> <List.Item>
<Header as="h5">{t("MapPage.sidebar.filters.userData")}</Header> <Header as="h5">{t('MapPage.sidebar.filters.userData')}</Header>
</List.Item> </List.Item>
<List.Item> <List.Item>
@ -234,15 +216,13 @@ function LayerSidebar({
size="small" size="small"
id="filters.currentUser" id="filters.currentUser"
checked={filtersCurrentUser} checked={filtersCurrentUser}
onChange={() => onChange={() => setMapConfigFlag('filters.currentUser', !filtersCurrentUser)}
setMapConfigFlag("filters.currentUser", !filtersCurrentUser) label={t('MapPage.sidebar.filters.currentUser')}
}
label={t("MapPage.sidebar.filters.currentUser")}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<Header as="h5">{t("MapPage.sidebar.filters.dateRange")}</Header> <Header as="h5">{t('MapPage.sidebar.filters.dateRange')}</Header>
</List.Item> </List.Item>
<List.Item> <List.Item>
@ -253,14 +233,12 @@ function LayerSidebar({
key: value, key: value,
text: t(`MapPage.sidebar.filters.dateMode.${value}`), text: t(`MapPage.sidebar.filters.dateMode.${value}`),
}))} }))}
value={dateMode ?? "none"} value={dateMode ?? 'none'}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.dateMode', value)}
setMapConfigFlag("filters.dateMode", value)
}
/> />
</List.Item> </List.Item>
{dateMode == "range" && ( {dateMode == 'range' && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -268,16 +246,14 @@ function LayerSidebar({
step="7" step="7"
size="small" size="small"
id="filters.startDate" id="filters.startDate"
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
setMapConfigFlag("filters.startDate", value)
}
value={startDate ?? null} value={startDate ?? null}
label={t("MapPage.sidebar.filters.start")} label={t('MapPage.sidebar.filters.start')}
/> />
</List.Item> </List.Item>
)} )}
{dateMode == "range" && ( {dateMode == 'range' && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -285,16 +261,14 @@ function LayerSidebar({
step="7" step="7"
size="small" size="small"
id="filters.endDate" id="filters.endDate"
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.endDate', value)}
setMapConfigFlag("filters.endDate", value)
}
value={endDate ?? null} value={endDate ?? null}
label={t("MapPage.sidebar.filters.end")} label={t('MapPage.sidebar.filters.end')}
/> />
</List.Item> </List.Item>
)} )}
{dateMode == "threshold" && ( {dateMode == 'threshold' && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -303,42 +277,33 @@ function LayerSidebar({
size="small" size="small"
id="filters.startDate" id="filters.startDate"
value={startDate ?? null} value={startDate ?? null}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
setMapConfigFlag("filters.startDate", value) label={t('MapPage.sidebar.filters.threshold')}
}
label={t("MapPage.sidebar.filters.threshold")}
/> />
</List.Item> </List.Item>
)} )}
{dateMode == "threshold" && ( {dateMode == 'threshold' && (
<List.Item> <List.Item>
<span> <span>
{t("MapPage.sidebar.filters.before")}{" "} {t('MapPage.sidebar.filters.before')}{' '}
<Checkbox <Checkbox
toggle toggle
size="small" size="small"
checked={thresholdAfter ?? false} checked={thresholdAfter ?? false}
onChange={() => onChange={() => setMapConfigFlag('filters.thresholdAfter', !thresholdAfter)}
setMapConfigFlag(
"filters.thresholdAfter",
!thresholdAfter
)
}
id="filters.thresholdAfter" id="filters.thresholdAfter"
/>{" "} />{' '}
{t("MapPage.sidebar.filters.after")} {t('MapPage.sidebar.filters.after')}
</span> </span>
</List.Item> </List.Item>
)} )}
</> </>
)} )}
{!login && ( {!login && <List.Item>{t('MapPage.sidebar.filters.needsLogin')}</List.Item>}
<List.Item>{t("MapPage.sidebar.filters.needsLogin")}</List.Item>
)}
</List> </List>
</div> </div>
); )
} }
export default connect( export default connect(
@ -351,6 +316,6 @@ export default connect(
), ),
login: state.login, login: state.login,
}), }),
{ setMapConfigFlag: setMapConfigFlagAction } {setMapConfigFlag: setMapConfigFlagAction}
// //
)(LayerSidebar); )(LayerSidebar)

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,74 +1,57 @@
import React, { useState, useCallback } from "react"; import React, {useState, useCallback} from 'react'
import _ from "lodash"; import {createPortal} from 'react-dom'
import { import _ from 'lodash'
Segment, import {Segment, Menu, Header, Label, Icon, Table, Message, Button} from 'semantic-ui-react'
Menu, import {Layer, Source} from 'react-map-gl'
Header, import {of, from, concat} from 'rxjs'
Label, import {useObservable} from 'rxjs-hooks'
Icon, import {switchMap, distinctUntilChanged} from 'rxjs/operators'
Table, import {Chart} from 'components'
Message, import {pairwise} from 'utils'
Button, import {useTranslation} from 'react-i18next'
} from "semantic-ui-react";
import { Layer, Source } from "react-map-gl";
import { of, from, concat } from "rxjs";
import { useObservable } from "rxjs-hooks";
import { switchMap, distinctUntilChanged } from "rxjs/operators";
import { Chart } from "components";
import { pairwise } from "utils";
import { useTranslation } from "react-i18next";
import type { Location } from "types"; import type {Location} from 'types'
import api from "api"; import api from 'api'
import { colorByDistance, borderByZone } from "mapstyles"; import {colorByDistance, borderByZone} from 'mapstyles'
import styles from "./styles.module.less"; import styles from './styles.module.less'
function selectFromColorMap(colormap, value) { function selectFromColorMap(colormap, value) {
let last = null; let last = null
for (let i = 0; i < colormap.length; i += 2) { for (let i = 0; i < colormap.length; i += 2) {
if (colormap[i + 1] > value) { if (colormap[i + 1] > value) {
return colormap[i]; return colormap[i]
} }
} }
return colormap[colormap.length - 1]; return colormap[colormap.length - 1]
} }
const UNITS = { const UNITS = {
distanceOvertaker: "m", distanceOvertaker: 'm',
distanceStationary: "m", distanceStationary: 'm',
speed: "km/h", speed: 'km/h',
}; }
const ZONE_COLORS = { urban: "blue", rural: "cyan", motorway: "purple" }; const ZONE_COLORS = {urban: 'blue', rural: 'cyan', motorway: 'purple'}
const CARDINAL_DIRECTIONS = [ const CARDINAL_DIRECTIONS = ['north', 'northEast', 'east', 'southEast', 'south', 'southWest', 'west', 'northWest']
"north",
"northEast",
"east",
"southEast",
"south",
"southWest",
"west",
"northWest",
];
const getCardinalDirection = (t, bearing) => { const getCardinalDirection = (t, bearing) => {
if (bearing == null) { if (bearing == null) {
return t("MapPage.roadInfo.cardinalDirections.unknown"); return t('MapPage.roadInfo.cardinalDirections.unknown')
} else { } else {
const n = CARDINAL_DIRECTIONS.length; const n = CARDINAL_DIRECTIONS.length
const i = Math.floor(((bearing / 360.0) * n + 0.5) % n); const i = Math.floor(((bearing / 360.0) * n + 0.5) % n)
const name = CARDINAL_DIRECTIONS[i]; const name = CARDINAL_DIRECTIONS[i]
return t(`MapPage.roadInfo.cardinalDirections.${name}`); return t(`MapPage.roadInfo.cardinalDirections.${name}`)
} }
}; }
function RoadStatsTable({ data }) { function RoadStatsTable({data}) {
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<Table size="small" compact> <Table size="small" compact>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell textAlign="right"></Table.HeaderCell> <Table.HeaderCell textAlign="right"></Table.HeaderCell>
{["distanceOvertaker", "distanceStationary", "speed"].map((prop) => ( {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
<Table.HeaderCell key={prop} textAlign="right"> <Table.HeaderCell key={prop} textAlign="right">
{t(`MapPage.roadInfo.${prop}`)} {t(`MapPage.roadInfo.${prop}`)}
</Table.HeaderCell> </Table.HeaderCell>
@ -76,58 +59,52 @@ function RoadStatsTable({ data }) {
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{["count", "min", "median", "max", "mean"].map((stat) => ( {['count', 'min', 'median', 'max', 'mean'].map((stat) => (
<Table.Row key={stat}> <Table.Row key={stat}>
<Table.Cell> {t(`MapPage.roadInfo.${stat}`)}</Table.Cell> <Table.Cell> {t(`MapPage.roadInfo.${stat}`)}</Table.Cell>
{["distanceOvertaker", "distanceStationary", "speed"].map( {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
(prop) => ( <Table.Cell key={prop} textAlign="right">
<Table.Cell key={prop} textAlign="right"> {(data[prop]?.statistics?.[stat] * (prop === `speed` && stat != 'count' ? 3.6 : 1)).toFixed(
{( stat === 'count' ? 0 : 2
data[prop]?.statistics?.[stat] * )}
(prop === `speed` && stat != "count" ? 3.6 : 1) {stat !== 'count' && ` ${UNITS[prop]}`}
).toFixed(stat === "count" ? 0 : 2)} </Table.Cell>
{stat !== "count" && ` ${UNITS[prop]}`} ))}
</Table.Cell>
)
)}
</Table.Row> </Table.Row>
))} ))}
</Table.Body> </Table.Body>
</Table> </Table>
); )
} }
function HistogramChart({ bins, counts, zone }) { function HistogramChart({bins, counts, zone}) {
const diff = bins[1] - bins[0]; const diff = bins[1] - bins[0]
const colortype = zone === "rural" ? 3 : 5; const colortype = zone === 'rural' ? 3 : 5
const data = _.zip( const data = _.zip(
bins.slice(0, bins.length - 1).map((v) => v + diff / 2), bins.slice(0, bins.length - 1).map((v) => v + diff / 2),
counts counts
).map((value) => ({ ).map((value) => ({
value, value,
itemStyle: { itemStyle: {
color: selectFromColorMap( color: selectFromColorMap(colorByDistance()[3][colortype].slice(2), value[0]),
colorByDistance()[3][colortype].slice(2),
value[0]
),
}, },
})); }))
return ( return (
<Chart <Chart
style={{ height: 240 }} style={{height: 240}}
option={{ option={{
grid: { top: 30, bottom: 30, right: 30, left: 30 }, grid: {top: 30, bottom: 30, right: 30, left: 30},
xAxis: { xAxis: {
type: "value", type: 'value',
axisLabel: { formatter: (v) => `${Math.round(v * 100)} cm` }, axisLabel: {formatter: (v) => `${Math.round(v * 100)} cm`},
min: 0, min: 0,
max: 2.5, max: 2.5,
}, },
yAxis: {}, yAxis: {},
series: [ series: [
{ {
type: "bar", type: 'bar',
data, data,
barMaxWidth: 20, barMaxWidth: 20,
@ -135,142 +112,120 @@ function HistogramChart({ bins, counts, zone }) {
], ],
}} }}
/> />
); )
}
interface ArrayStats {
statistics: {
count: number
mean: number
min: number
max: number
median: number
}
histogram: {
bins: number[]
counts: number[]
}
values: number[]
}
export interface RoadDirectionInfo {
bearing: number
distanceOvertaker: ArrayStats
distanceStationary: ArrayStats
speed: ArrayStats
}
export interface RoadInfoType {
road: {
way_id: number
zone: 'urban' | 'rural' | null
name: string
directionality: -1 | 0 | 1
oneway: boolean
geometry: Object
}
forwards: RoadDirectionInfo
backwards: RoadDirectionInfo
} }
export default function RoadInfo({ export default function RoadInfo({
clickLocation, roadInfo: info,
hasFilters, hasFilters,
onClose, onClose,
mapInfoPortal,
}: { }: {
clickLocation: Location | null; roadInfo: RoadInfoType
hasFilters: boolean; hasFilters: boolean
onClose: () => void; onClose: () => void
mapInfoPortal: HTMLElement
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation()
const [direction, setDirection] = useState("forwards"); const [direction, setDirection] = useState('forwards')
const onClickDirection = useCallback( const onClickDirection = useCallback(
(e, { name }) => { (e, {name}) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setDirection(name); setDirection(name)
}, },
[setDirection] [setDirection]
); )
const info = useObservable( // TODO: change based on left-hand/right-hand traffic
(_$, inputs$) => const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1
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; <>
} <div className={styles.closeHeader}>
<Header as="h3">{info?.road.name || t('MapPage.roadInfo.unnamedWay')}</Header>
<Button primary icon onClick={onClose}>
<Icon name="close" />
</Button>
</div>
const loading = info == null; {hasFilters && (
<Message info icon>
<Icon name="info circle" small />
<Message.Content>{t('MapPage.roadInfo.hintFiltersNotApplied')}</Message.Content>
</Message>
)}
const offsetDirection = info?.road?.oneway {info?.road.zone && (
? 0 <Label size="small" color={ZONE_COLORS[info?.road.zone]}>
: direction === "forwards" {t(`general.zone.${info.road.zone}`)}
? 1 </Label>
: -1; // TODO: change based on left-hand/right-hand traffic )}
const content = {info?.road.oneway && (
!loading && !info.road ? ( <Label size="small" color="blue">
"No road found." <Icon name="long arrow alternate right" fitted /> {t('MapPage.roadInfo.oneway')}
) : ( </Label>
<> )}
<Header as="h3">
{loading
? "..."
: info?.road.name || t("MapPage.roadInfo.unnamedWay")}
<Button {info?.road.oneway ? null : (
style={{ float: "right" }} <Menu size="tiny" pointing>
onClick={onClose} <Menu.Item header>{t('MapPage.roadInfo.direction')}</Menu.Item>
title={t("MapPage.roadInfo.closeTooltip")} <Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
size="small" {getCardinalDirection(t, info?.forwards?.bearing)}
icon="close" </Menu.Item>
basic <Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
/> {getCardinalDirection(t, info?.backwards?.bearing)}
</Header> </Menu.Item>
</Menu>
)}
{hasFilters && ( {info?.[direction] && <RoadStatsTable data={info[direction]} />}
<Message info icon>
<Icon name="info circle" small />
<Message.Content>
{t("MapPage.roadInfo.hintFiltersNotApplied")}
</Message.Content>
</Message>
)}
{info?.road.zone && ( {info?.[direction]?.distanceOvertaker?.histogram && (
<Label size="small" color={ZONE_COLORS[info?.road.zone]}> <>
{t(`general.zone.${info.road.zone}`)} <Header as="h5">{t('MapPage.roadInfo.overtakerDistanceDistribution')}</Header>
</Label> <HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
)} </>
)}
{info?.road.oneway && ( </>
<Label size="small" color="blue"> )
<Icon name="long arrow alternate right" fitted />{" "}
{t("MapPage.roadInfo.oneway")}
</Label>
)}
{info?.road.oneway ? null : (
<Menu size="tiny" fluid secondary>
<Menu.Item header>{t("MapPage.roadInfo.direction")}</Menu.Item>
<Menu.Item
name="forwards"
active={direction === "forwards"}
onClick={onClickDirection}
>
{getCardinalDirection(t, info?.forwards?.bearing)}
</Menu.Item>
<Menu.Item
name="backwards"
active={direction === "backwards"}
onClick={onClickDirection}
>
{getCardinalDirection(t, info?.backwards?.bearing)}
</Menu.Item>
</Menu>
)}
{info?.[direction] && <RoadStatsTable data={info[direction]} />}
{info?.[direction]?.distanceOvertaker?.histogram && (
<>
<Header as="h5">
{t("MapPage.roadInfo.overtakerDistanceDistribution")}
</Header>
<HistogramChart
{...info[direction]?.distanceOvertaker?.histogram}
/>
</>
)}
</>
);
return ( return (
<> <>
@ -280,22 +235,14 @@ export default function RoadInfo({
id="route" id="route"
type="line" type="line"
paint={{ paint={{
"line-width": [ 'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12],
"interpolate", 'line-color': '#18FFFF',
["linear"], 'line-opacity': 0.5,
["zoom"],
14,
6,
17,
12,
],
"line-color": "#18FFFF",
"line-opacity": 0.5,
...{ ...{
"line-offset": [ 'line-offset': [
"interpolate", 'interpolate',
["exponential", 1.5], ['exponential', 1.5],
["zoom"], ['zoom'],
12, 12,
offsetDirection, offsetDirection,
19, 19,
@ -307,11 +254,7 @@ export default function RoadInfo({
</Source> </Source>
)} )}
{content && ( {content && mapInfoPortal && createPortal(<div className={styles.mapInfoBox}>{content}</div>, mapInfoPortal)}
<div className={styles.mapInfoBox}>
<Segment loading={loading}>{content}</Segment>
</div>
)}
</> </>
); )
} }

View file

@ -1,241 +1,254 @@
import React, { useState, useCallback, useMemo } from "react"; import React, {useState, useCallback, useMemo, useRef} from 'react'
import _ from "lodash"; import _ from 'lodash'
import { connect } from "react-redux"; import {connect} from 'react-redux'
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 classNames from "classnames"; import classNames from 'classnames'
import type { Location } from "types"; import api from 'api'
import { Page, Map } from "components"; import type {Location} from 'types'
import { useConfig } from "config"; import {Page, Map} from 'components'
import { import {useConfig} from 'config'
colorByDistance, import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles'
colorByCount, import {useMapConfig} from 'reducers/mapConfig'
borderByZone,
reds,
isValidAttribute,
} from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo from "./RoadInfo"; import RoadInfo, {RoadInfoType} 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 getUntaggedRoadsLayer = (colorAttribute, maxCount) => const getUntaggedRoadsLayer = (colorAttribute) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.filter = ["!", isValidAttribute(colorAttribute)]; draft.filter = ['!', isValidAttribute(colorAttribute)]
}); })
const getRoadsLayer = (colorAttribute, maxCount) => const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.id = "obs_roads_normal"; draft.id = 'obs_roads_normal'
draft.filter = isValidAttribute(colorAttribute); draft.filter = isValidAttribute(colorAttribute)
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)
: colorAttribute.endsWith("zone") : colorAttribute.endsWith('zone')
? borderByZone() ? borderByZone()
: "#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],
}, },
}); })
function MapPage({ login }) { interface RegionInfo {
const { obsMapSource, banner } = useConfig() || {}; properties: {
const [clickLocation, setClickLocation] = useState<Location | null>(null); admin_level: number
name: string
overtaking_event_count: number
}
}
const mapConfig = useMapConfig(); type Details = {type: 'road'; road: RoadInfoType} | {type: 'region'; region: RegionInfo}
function MapPage({login}) {
const {obsMapSource, banner} = useConfig() || {}
const [details, setDetails] = useState<null | Details>(null)
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 ( if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) {
[styles.mapInfoBox, styles.mapToolbar].some((className) => return
node?.classList?.contains(className)
)
) {
return;
} }
node = node.parentNode; node = node.parentNode
} }
setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] }); const {zoom} = viewportRef.current
},
[setClickLocation]
);
const onCloseRoadInfo = useCallback(() => {
setClickLocation(null);
}, [setClickLocation]);
const [layerSidebar, setLayerSidebar] = useState(true); 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,
},
})
setDetails(road?.road ? {type: 'road', road} : null)
}
},
[setDetails]
)
const [layerSidebar, setLayerSidebar] = useState(true)
const { const {
obsRoads: { attribute, maxCount }, obsRoads: {attribute, maxCount},
} = mapConfig; } = mapConfig
const layers = []; const layers = []
const untaggedRoadsLayerCustom = useMemo( const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
() => getUntaggedRoadsLayer(attribute),
[attribute]
);
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) { if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayerCustom); layers.push(untaggedRoadsLayerCustom)
} }
const roadsLayer = useMemo( const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
() => 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)
} }
const onToggleLayerSidebarButtonClick = useCallback( const onToggleLayerSidebarButtonClick = useCallback(
(e) => { (e) => {
e.stopPropagation(); e.stopPropagation()
e.preventDefault(); e.preventDefault()
console.log("toggl;e"); console.log('toggl;e')
setLayerSidebar((v) => !v); setLayerSidebar((v) => !v)
}, },
[setLayerSidebar] [setLayerSidebar]
); )
if (!obsMapSource) { if (!obsMapSource) {
return null; return null
} }
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => { const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
const query = new URLSearchParams(); const query = new URLSearchParams()
if (login) { if (login) {
if (mapConfig.filters.currentUser) { if (mapConfig.filters.currentUser) {
query.append("user", login.id); query.append('user', login.id)
} }
if (mapConfig.filters.dateMode === "range") { if (mapConfig.filters.dateMode === 'range') {
if (mapConfig.filters.startDate) { if (mapConfig.filters.startDate) {
query.append("start", mapConfig.filters.startDate); query.append('start', mapConfig.filters.startDate)
} }
if (mapConfig.filters.endDate) { if (mapConfig.filters.endDate) {
query.append("end", mapConfig.filters.endDate); query.append('end', mapConfig.filters.endDate)
} }
} else if (mapConfig.filters.dateMode === "threshold") { } else if (mapConfig.filters.dateMode === 'threshold') {
if (mapConfig.filters.startDate) { if (mapConfig.filters.startDate) {
query.append( query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
mapConfig.filters.thresholdAfter ? "start" : "end",
mapConfig.filters.startDate
);
} }
} }
} }
const queryString = String(query); const queryString = String(query)
return tileUrl + (queryString ? "?" : "") + queryString; return tileUrl + (queryString ? '?' : '') + queryString
}); })
const hasFilters: boolean = const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none')
login &&
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
return ( return (
<Page fullScreen title="Map"> <Page fullScreen title="Map">
<div <div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}>
className={classNames(
styles.mapContainer,
banner ? styles.hasBanner : null
)}
>
{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} hasToolbar> <Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}>
<div className={styles.mapToolbar}> <div className={styles.mapToolbar}>
<Button <Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} />
primary
icon="bars"
active={layerSidebar}
onClick={onToggleLayerSidebarButtonClick}
/>
</div> </div>
<Source id="obs" {...obsMapSource} tiles={tiles}> <Source id="obs" {...obsMapSource} tiles={tiles}>
{layers.map((layer) => ( {layers.map((layer) => (
@ -243,14 +256,23 @@ function MapPage({ login }) {
))} ))}
</Source> </Source>
<RoadInfo {details?.type === 'road' && details?.road?.road && (
{...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }} <RoadInfo
/> roadInfo={details.road}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
{...{hasFilters}}
/>
)}
{details?.type === 'region' && details?.region && (
<RegionInfo region={details.region} mapInfoPortal={mapInfoPortal.current} onClose={onCloseDetails} />
)}
</Map> </Map>
</div> </div>
</div> </div>
</Page> </Page>
); )
} }
export default connect((state) => ({ login: state.login }))(MapPage); export default connect((state) => ({login: state.login}))(MapPage)

View file

@ -24,12 +24,11 @@
} }
.mapInfoBox { .mapInfoBox {
position: absolute;
right: 16px;
top: 32px;
max-height: 100%;
width: 36rem; width: 36rem;
overflow: auto; overflow: auto;
border-left: 1px solid @borderColor;
background: white;
padding: 16px;
} }
.mapToolbar { .mapToolbar {
@ -37,3 +36,32 @@
left: 16px; left: 16px;
top: 16px; top: 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

@ -1,95 +1,92 @@
import { useMemo } from "react"; import {useMemo} from 'react'
import { useSelector } from "react-redux"; import {useSelector} from 'react-redux'
import produce from "immer"; import produce from 'immer'
import _ from "lodash"; import _ from 'lodash'
type BaseMapStyle = "positron" | "bright"; type BaseMapStyle = 'positron' | 'bright'
type RoadAttribute = type RoadAttribute =
| "distance_overtaker_mean" | 'distance_overtaker_mean'
| "distance_overtaker_min" | 'distance_overtaker_min'
| "distance_overtaker_max" | 'distance_overtaker_max'
| "distance_overtaker_median" | 'distance_overtaker_median'
| "overtaking_event_count" | 'overtaking_event_count'
| "usage_count" | 'usage_count'
| "zone"; | 'zone'
export type MapConfig = { export type MapConfig = {
baseMap: { baseMap: {
style: BaseMapStyle; style: BaseMapStyle
}; }
obsRoads: { obsRoads: {
show: boolean; show: boolean
showUntagged: boolean; showUntagged: boolean
attribute: RoadAttribute; attribute: RoadAttribute
maxCount: number; maxCount: number
}; }
obsEvents: { obsEvents: {
show: boolean; show: boolean
}; }
obsRegions: {
show: boolean
}
filters: { filters: {
currentUser: boolean; currentUser: boolean
dateMode: "none" | "range" | "threshold"; dateMode: 'none' | 'range' | 'threshold'
startDate?: null | string; startDate?: null | string
endDate?: null | string; endDate?: null | string
thresholdAfter?: null | boolean; thresholdAfter?: null | boolean
}; }
}; }
export const initialState: MapConfig = { export const initialState: MapConfig = {
baseMap: { baseMap: {
style: "positron", style: 'positron',
}, },
obsRoads: { obsRoads: {
show: true, show: true,
showUntagged: true, showUntagged: true,
attribute: "distance_overtaker_median", attribute: 'distance_overtaker_median',
maxCount: 20, maxCount: 20,
}, },
obsEvents: { obsEvents: {
show: false, show: false,
}, },
obsRegions: {
show: true,
},
filters: { filters: {
currentUser: false, currentUser: false,
dateMode: "none", dateMode: 'none',
startDate: null, startDate: null,
endDate: null, endDate: null,
thresholdAfter: true, thresholdAfter: true,
}, },
}; }
type MapConfigAction = { type MapConfigAction = {
type: "MAP_CONFIG.SET_FLAG"; type: 'MAP_CONFIG.SET_FLAG'
payload: { flag: string; value: any }; payload: {flag: string; value: any}
}; }
export function setMapConfigFlag( export function setMapConfigFlag(flag: string, value: unknown): MapConfigAction {
flag: string, return {type: 'MAP_CONFIG.SET_FLAG', payload: {flag, value}}
value: unknown
): MapConfigAction {
return { type: "MAP_CONFIG.SET_FLAG", payload: { flag, value } };
} }
export function useMapConfig() { export function useMapConfig() {
const mapConfig = useSelector((state) => state.mapConfig); const mapConfig = useSelector((state) => state.mapConfig)
const result = useMemo( const result = useMemo(() => _.merge({}, initialState, mapConfig), [mapConfig])
() => _.merge({}, initialState, mapConfig), return result
[mapConfig]
);
return result;
} }
export default function mapConfigReducer( export default function mapConfigReducer(state: MapConfig = initialState, action: MapConfigAction) {
state: MapConfig = initialState,
action: MapConfigAction
) {
switch (action.type) { switch (action.type) {
case "MAP_CONFIG.SET_FLAG": case 'MAP_CONFIG.SET_FLAG':
return produce(state, (draft) => { return produce(state, (draft) => {
_.set(draft, action.payload.flag, action.payload.value); _.set(draft, action.payload.flag, action.payload.value)
}); })
default: default:
return state; return state
} }
} }

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"}
@ -63,6 +66,13 @@ local roads = osm2pgsql.define_way_table('road', {
local minspeed_rural = 60 local minspeed_rural = 60
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
@ -131,3 +141,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

@ -15,6 +15,7 @@ RETURNS TABLE(event_id bigint, geometry geometry, distance_overtaker float, dist
FULL OUTER JOIN road ON road.way_id = overtaking_event.way_id FULL OUTER JOIN road ON road.way_id = overtaking_event.way_id
JOIN track on track.id = overtaking_event.track_id JOIN track on track.id = overtaking_event.track_id
WHERE ST_Transform(overtaking_event.geometry, 3857) && bbox WHERE ST_Transform(overtaking_event.geometry, 3857) && bbox
AND zoom_level >= 10
AND (user_id is NULL OR user_id = track.author_id) AND (user_id is NULL OR user_id = track.author_id)
AND time BETWEEN COALESCE(min_time, '1900-01-01'::timestamp) AND COALESCE(max_time, '2100-01-01'::timestamp); AND time BETWEEN COALESCE(min_time, '1900-01-01'::timestamp) AND COALESCE(max_time, '2100-01-01'::timestamp);

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

@ -67,6 +67,7 @@ RETURNS TABLE(
) e on (e.way_id = road.way_id and (road.directionality != 0 or e.direction_reversed = r.rev)) ) e on (e.way_id = road.way_id and (road.directionality != 0 or e.direction_reversed = r.rev))
WHERE road.geometry && bbox WHERE road.geometry && bbox
AND zoom_level >= 10
GROUP BY GROUP BY
road.name, road.name,
road.way_id, road.way_id,

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.7.0 version: 0.7.0
id: openbikesensor id: openbikesensor
description: > description: >