Compare commits

...

25 commits

Author SHA1 Message Date
gluap 78ac5e68c8
fix build errrors detected by sonarcloud 2022-10-28 12:04:46 +02:00
gluap 30fb29e437
Make it work (fix rebase issues; add translations) 2022-10-28 11:56:37 +02:00
Paul Bienkowski c7970d9382
Rename users when they log in with a new preferred_username 2022-10-27 22:25:51 +02:00
Paul Bienkowski 2f3a66f2ee
Do not click on road when toggling sidebar (fixes #274) 2022-10-27 22:24:32 +02:00
Paul Bienkowski 5774a52773
Improve placement of map controls and popovers (fixes #272) 2022-10-27 22:23:21 +02:00
Paul Bienkowski 7021af8e14
Decrease map page height if banner is enabled (for #272) 2022-10-27 22:21:45 +02:00
Paul Bienkowski f423cdabfe
Hint about filters not being applied in road info popover 2022-10-27 22:19:00 +02:00
Paul Bienkowski 115e871ee9
Add date-range filters to map 2022-10-27 22:17:38 +02:00
Paul Bienkowski c4b9cbb607
Add filter toggle for user-owned data to map UI 2022-10-27 22:14:41 +02:00
Paul Bienkowski 88af9a28fd
fix dynamic tile arguments and implement in both layers 2022-10-27 22:10:38 +02:00
Paul Bienkowski 0b3dde4172
add dynamic tile arguments 2022-10-27 22:09:44 +02:00
Paul Bienkowski 846a4ba990
Translate TrackPage 2022-10-27 22:06:32 +02:00
Paul Bienkowski 0eab614a48
Translate MapPage 2022-10-27 22:05:55 +02:00
Paul Bienkowski 0186183a7b
Translate TracksPage 2022-10-27 22:03:35 +02:00
Paul Bienkowski 8b6205ff31
Remove unneeded style file 2022-10-27 22:01:08 +02:00
Paul Bienkowski ea4d715f5d
Translate HomePage, Stats 2022-10-27 22:01:08 +02:00
Paul Bienkowski 7ce6b66b75
Add react-i18next integration 2022-10-27 22:00:33 +02:00
Paul Bienkowski cec3db5feb
derive logic for "include roads without data" from selected attribute 2022-10-27 21:59:23 +02:00
gluap a6725cdc1a
save current state. 2022-10-27 21:58:27 +02:00
Paul Bienkowski 3a7929cf8a
Make sidebar toggle for Regions actually work 2022-10-27 21:53:18 +02:00
Paul Bienkowski b12cb1a695
Add regional stats to homepage 2022-10-27 21:53:18 +02:00
Paul Bienkowski 68c37be383
fix region DB schema 2022-10-27 21:52:36 +02:00
Paul Bienkowski 19a8112905
Show only borders of areas with events 2022-10-27 21:52:36 +02:00
Paul Bienkowski 2ecd94baba
wip 2022-10-27 21:52:36 +02:00
Paul Bienkowski 9e9a80a0be
Add administrative boundary import and display regional event count 2022-10-27 21:43:55 +02:00
26 changed files with 606 additions and 67 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 = "99a3d2eb08f9"
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")
User.authored_comments = relationship(
"Comment",

View file

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

View file

@ -4,12 +4,12 @@ from typing import Optional
from operator import and_
from functools import reduce
from sqlalchemy import select, func
from sqlalchemy import select, func, desc
from sanic.response import json
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
@ -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

@ -32,6 +32,32 @@ EXTRA_ARGS = [
]
class CustomMvtGenerator(MvtGenerator):
def generate_sqltomvt_func(self, fname, extra_args: List[Tuple[str, str]]) -> str:
"""
Creates a SQL function that returns a single bytea value or null. This
method is overridden to allow for custom arguments in the created function
"""
extra_args_types = "".join([f", {a[1]}" for a in extra_args])
extra_args_definitions = "".join(
[f", {a[0]} {a[1]} DEFAULT {a[2]}" for a in extra_args]
)
return f"""\
DROP FUNCTION IF EXISTS {fname}(integer, integer, integer{extra_args_types});
CREATE FUNCTION {fname}(zoom integer, x integer, y integer{extra_args_definitions})
RETURNS {'TABLE(mvt bytea, key text)' if self.key_column else 'bytea'} AS $$
{self.generate_sql()};
$$ LANGUAGE SQL STABLE CALLED ON NULL INPUT;"""
EXTRA_ARGS = [
# name, type, default
("user_id", "integer", "NULL"),
("min_time", "timestamp", "NULL"),
("max_time", "timestamp", "NULL"),
]
class CustomMvtGenerator(MvtGenerator):
def generate_sqltomvt_func(self, fname, extra_args: List[Tuple[str, str]]) -> str:
"""

View file

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

View file

@ -227,7 +227,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
target="_blank"
rel="noreferrer"
>
{apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')}
Version {apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')}
</a>
</List.Item>
</List>

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

View file

@ -1,15 +1,10 @@
import React, { useState, useCallback, useMemo, useEffect } from "react";
import classnames from "classnames";
import { connect } from "react-redux";
import _ from "lodash";
import ReactMapGl, {
WebMercatorViewport,
ScaleControl,
NavigationControl,
AttributionControl,
} from "react-map-gl";
import turfBbox from "@turf/bbox";
import { useHistory, useLocation } from "react-router-dom";
import React, {useState, useCallback, useMemo, useEffect} from 'react'
import classnames from 'classnames'
import {connect} from 'react-redux'
import _ from 'lodash'
import ReactMapGl, {WebMercatorViewport, ScaleControl, NavigationControl, AttributionControl} from 'react-map-gl'
import turfBbox from '@turf/bbox'
import {useHistory, useLocation} from 'react-router-dom'
import { useConfig } from "config";
@ -78,20 +73,24 @@ function Map({
boundsFromJson,
baseMapStyle,
hasToolbar,
onViewportChange,
...props
}: {
viewportFromUrl?: boolean;
children: React.ReactNode;
boundsFromJson: GeoJSON.Geometry;
baseMapStyle: string;
viewportFromUrl?: boolean
children: React.ReactNode
boundsFromJson: GeoJSON.Geometry
baseMapStyle: string
hasToolbar?: boolean;
onViewportChange: (viewport: Viewport) => void,
}) {
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT);
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();
useEffect(() => {

View file

@ -0,0 +1,86 @@
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 {useTranslation} from 'react-i18next'
import api from "api";
function formatDuration(seconds) {
return (
Duration.fromMillis((seconds ?? 0) * 1000)
.as("hours")
.toFixed(1) + " h"
);
}
export default function Stats() {
const {t} = useTranslation()
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">{t(`Stats.topRegions`)}</Header>
<div>
<Loader active={stats == null} />
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{t(`Stats.regionName`)}</Table.HeaderCell>
<Table.HeaderCell>{t(`Stats.eventCount`)}</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 Chart} from './Chart'
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend'
export {default as FileDrop} from './FileDrop'
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 Map} from './Map'
export {default as Page} from './Page'
export {default as RegionStats} from './RegionStats'
export {default as Stats} from './Stats'
export {default as StripMarkdown} from './StripMarkdown'
export {default as Chart} from './Chart'
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": 11,
"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": 11,
"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 => {
// draft.paint['line-color'] = '#81D4FA'
draft.paint['line-width'][4] = 1

View file

@ -6,7 +6,7 @@ import {map, switchMap} from 'rxjs/operators'
import {useTranslation} from 'react-i18next'
import api from 'api'
import {Stats, Page} from 'components'
import {RegionStats, Stats, Page} from 'components'
import type {Track} from 'types'
import {TrackListItem, NoPublicTracksMessage} from './TracksPage'
@ -46,6 +46,7 @@ export default function HomePage() {
<Grid.Row>
<Grid.Column width={8}>
<Stats />
<RegionStats />
</Grid.Column>
<Grid.Column width={8}>
<MostRecentTrack />

View file

@ -12,6 +12,7 @@ import {
} from "semantic-ui-react";
import { useTranslation } from "react-i18next";
import {
MapConfig,
setMapConfigFlag as setMapConfigFlagAction,
@ -50,6 +51,7 @@ function LayerSidebar({
baseMap: { style },
obsRoads: { show: showRoads, showUntagged, attribute, maxCount },
obsEvents: { show: showEvents },
obsRegions: {show: showRegions},
filters: {
currentUser: filtersCurrentUser,
dateMode,
@ -57,7 +59,7 @@ function LayerSidebar({
endDate,
thresholdAfter,
},
} = mapConfig;
} = mapConfig
return (
<div>
@ -77,6 +79,28 @@ function LayerSidebar({
/>
</List.Item>
<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>
<Checkbox
toggle

View file

@ -0,0 +1,35 @@
import React, { useState, useCallback } from "react";
import { createPortal } from "react-dom";
import _ from "lodash";
import { List, Header, Icon, Button } from "semantic-ui-react";
import { useTranslation } from "react-i18next";
import styles from "./styles.module.less";
export default function RegionInfo({ region, mapInfoPortal, onClose }) {
const { t } = useTranslation();
const content = (
<>
<div className={styles.closeHeader}>
<Header as="h3">{region.properties.name || t(`MapPage.regionInfo.unnamedRegion`)}</Header>
<Button primary icon onClick={onClose}>
<Icon name="close" />
</Button>
</div>
<List>
<List.Item>
<List.Header>{t(`MapPage.regionInfo.eventNumber`)}</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,4 +1,4 @@
import React, { useState, useCallback, useMemo } from "react";
import React, { useState, useCallback, useMemo, useRef } from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Button } from "semantic-ui-react";
@ -6,6 +6,7 @@ import { Layer, Source } from "react-map-gl";
import produce from "immer";
import classNames from "classnames";
import api from "api";
import type { Location } from "types";
import { Page, Map } from "components";
import { useConfig } from "config";
@ -15,10 +16,12 @@ import {
borderByZone,
reds,
isValidAttribute,
getRegionLayers
} from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo from "./RoadInfo";
import RegionInfo from "./RegionInfo";
import LayerSidebar from "./LayerSidebar";
import styles from "./styles.module.less";
@ -27,6 +30,7 @@ const untaggedRoadsLayer = {
type: "line",
source: "obs",
"source-layer": "obs_roads",
minzoom: 12,
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
layout: {
"line-cap": "round",
@ -46,7 +50,6 @@ const untaggedRoadsLayer = {
["*", ["get", "offset_direction"], 8],
],
},
minzoom: 12,
};
const getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
@ -57,7 +60,8 @@ const getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => {
draft.id = "obs_roads_normal";
draft.filter = isValidAttribute(colorAttribute);
draft.filter = isValidAttribute(colorAttribute)
draft.minzoom = 10
draft.paint["line-width"][6] = 6; // scale bigger on zoom
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
? colorByDistance(colorAttribute)
@ -66,8 +70,8 @@ const getRoadsLayer = (colorAttribute, maxCount) =>
: colorAttribute.endsWith("zone")
? borderByZone()
: "#DDD";
draft.paint["line-opacity"][3] = 12;
draft.paint["line-opacity"][5] = 13;
draft.paint["line-opacity"][3] = 10;
draft.paint["line-opacity"][5] = 11;
});
const getEventsLayer = () => ({
@ -110,14 +114,74 @@ const getEventsTextLayer = () => ({
},
});
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 };
function MapPage({ login }) {
const { obsMapSource, banner } = useConfig() || {};
const [clickLocation, setClickLocation] = useState<Location | null>(null);
const { obsMapSource , banner } = useConfig() || {};
const [details, setDetails] = useState<null | Details>(null);
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(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(
(e) => {
async (e) => {
// check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target;
while (node) {
if (
@ -130,7 +194,18 @@ function MapPage({ login }) {
node = node.parentNode;
}
setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] });
const { zoom } = viewportRef.current;
if (zoom < 11) {
const clickedRegion = e.features?.find(
(f) => f.source === "obs" && f.sourceLayer === "obs_regions"
);
setDetails(
clickedRegion ? { type: "region", region: clickedRegion } : null
);
} else {
setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]})
}
},
[setClickLocation]
);
@ -151,7 +226,7 @@ function MapPage({ login }) {
[attribute]
);
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayerCustom);
layers.push(untaggedRoadsLayerCustom)
}
const roadsLayer = useMemo(
@ -162,8 +237,13 @@ function MapPage({ login }) {
layers.push(roadsLayer);
}
const eventsLayer = useMemo(() => getEventsLayer(), []);
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
const regionLayers = useMemo(() => getRegionLayers(), []);
if (mapConfig.obsRegions.show) {
layers.push(...regionLayers);
}
const eventsLayer = useMemo(() => getEventsLayer(), [])
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
if (mapConfig.obsEvents.show) {
layers.push(eventsLayer);
layers.push(eventsTextLayer);
@ -183,32 +263,31 @@ function MapPage({ login }) {
return null;
}
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
const query = new URLSearchParams();
const tiles = obsMapSource?.tiles?.map(
(tileUrl: string) => {
const query = new URLSearchParams()
if (login) {
if (mapConfig.filters.currentUser) {
query.append("user", login.id);
query.append('user', login.username)
}
if (mapConfig.filters.dateMode === "range") {
if (mapConfig.filters.startDate) {
query.append("start", mapConfig.filters.startDate);
query.append('start', mapConfig.filters.startDate)
}
if (mapConfig.filters.endDate) {
query.append("end", mapConfig.filters.endDate);
query.append('end', mapConfig.filters.endDate)
}
} else if (mapConfig.filters.dateMode === "threshold") {
if (mapConfig.filters.startDate) {
query.append(
mapConfig.filters.thresholdAfter ? "start" : "end",
mapConfig.filters.startDate
query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
}
}
}
const queryString = String(query)
return tileUrl + (queryString ? '?' : '') + queryString
}
);
}
}
}
const queryString = String(query);
return tileUrl + (queryString ? "?" : "") + queryString;
});
const hasFilters: boolean =
login &&
@ -221,6 +300,7 @@ function MapPage({ login }) {
styles.mapContainer,
banner ? styles.hasBanner : null
)}
ref={mapInfoPortal}
>
{layerSidebar && (
<div className={styles.mapSidebar}>
@ -228,9 +308,14 @@ function MapPage({ login }) {
</div>
)}
<div className={styles.map}>
<Map viewportFromUrl onClick={onClick} hasToolbar>
<Map viewportFromUrl onClick={onClick} onViewportChange={onViewportChange} hasToolbar>
<div className={styles.mapToolbar}>
<Button
style={{
position: "absolute",
left: 16,
top: 16,
}}
primary
icon="bars"
active={layerSidebar}
@ -243,9 +328,19 @@ function MapPage({ login }) {
))}
</Source>
<RoadInfo
{...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }}
/>
{details?.type === "region" && details?.region && (
<RegionInfo
region={details.region}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
/>
)}
</Map>
</div>
</div>

View file

@ -36,4 +36,36 @@
position: absolute;
left: 16px;
top: 16px;
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

@ -34,6 +34,16 @@ export type MapConfig = {
endDate?: null | string;
thresholdAfter?: null | boolean;
};
obsRegions: {
show: boolean;
};
filters: {
currentUser: boolean;
dateMode: "none" | "range" | "threshold";
startDate?: null | string;
endDate?: null | string;
thresholdAfter?: null | boolean;
};
};
export const initialState: MapConfig = {
@ -56,6 +66,9 @@ export const initialState: MapConfig = {
endDate: null,
thresholdAfter: true,
},
obsRegions: {
show: true,
},
};
type MapConfigAction = {

View file

@ -62,6 +62,9 @@ Stats:
thisMonth: Dieser Monat
thisYear: Dieses Jahr
allTime: Immer
topRegions: Regionen mit den meisten Messwerten
eventCount: Überholevents
regionName: Region
TracksPage:
titlePublic: Öffentliche Fahrten
@ -201,6 +204,9 @@ MapPage:
southWest: südwestwärts
west: westwärts
northWest: nordwestwärts
regionInfo:
unnamedRegion: Unbenannte Region
eventNumber: Anzahl Überholevents
SettingsPage:
title: Einstellungen

View file

@ -53,6 +53,7 @@ LoginButton:
HomePage:
mostRecentTrack: Most recent track
noPublicTracks: No public tracks yet. <1>Upload the first!</1>
Stats:
title: Statistics
@ -66,6 +67,9 @@ Stats:
thisMonth: This month
thisYear: This year
allTime: All time
topRegions: Region Leaderboard
eventCount: Overtaking events
regionName: Region
TracksPage:
titlePublic: Public tracks
@ -205,6 +209,10 @@ MapPage:
southWest: south-west bound
west: west bound
northWest: north-west bound
regionInfo:
unnamedRegion: "unnamed region"
eventNumber: Number of Overtaking events
SettingsPage:
title: Settings

View file

@ -50,6 +50,9 @@ local MOTORWAY_TYPES = {
"motorway_link",
}
local ADMIN_LEVEL_MIN = 2
local ADMIN_LEVEL_MAX = 8
local ONEWAY_YES = {"yes", "true", "1"}
local ONEWAY_REVERSE = {"reverse", "-1"}
@ -61,6 +64,13 @@ local roads = osm2pgsql.define_way_table('road', {
{ 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' },
})
local minspeed_rural = 60
function osm2pgsql.process_way(object)
@ -131,3 +141,21 @@ function osm2pgsql.process_way(object)
})
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
JOIN track on track.id = overtaking_event.track_id
WHERE ST_Transform(overtaking_event.geometry, 3857) && bbox
AND zoom_level >= 12
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);

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

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

View file

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