Compare commits
25 commits
main
...
administra
Author | SHA1 | Date | |
---|---|---|---|
78ac5e68c8 | |||
30fb29e437 | |||
c7970d9382 | |||
2f3a66f2ee | |||
5774a52773 | |||
7021af8e14 | |||
f423cdabfe | |||
115e871ee9 | |||
c4b9cbb607 | |||
88af9a28fd | |||
0b3dde4172 | |||
846a4ba990 | |||
0eab614a48 | |||
0186183a7b | |||
8b6205ff31 | |||
ea4d715f5d | |||
7ce6b66b75 | |||
cec3db5feb | |||
a6725cdc1a | |||
3a7929cf8a | |||
b12cb1a695 | |||
68c37be383 | |||
19a8112905 | |||
2ecd94baba | |||
9e9a80a0be |
35
api/migrations/versions/a049e5eb24dd_create_table_region.py
Normal file
35
api/migrations/versions/a049e5eb24dd_create_table_region.py
Normal 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")
|
|
@ -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",
|
||||
|
|
|
@ -26,7 +26,7 @@ if app.config.FRONTEND_CONFIG:
|
|||
.replace("111", "{x}")
|
||||
.replace("222", "{y}")
|
||||
],
|
||||
"minzoom": 12,
|
||||
"minzoom": 0,
|
||||
"maxzoom": 14,
|
||||
}
|
||||
),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
"obsMapSource": {
|
||||
"type": "vector",
|
||||
"tiles": ["https://portal.example.com/tiles/{z}/{x}/{y}.pbf"],
|
||||
"minzoom": 12,
|
||||
"minzoom": 0,
|
||||
"maxzoom": 14
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
86
frontend/src/components/RegionStats/index.tsx
Normal file
86
frontend/src/components/RegionStats/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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
|
||||
|
|
35
frontend/src/pages/MapPage/RegionInfo.tsx
Normal file
35
frontend/src/pages/MapPage/RegionInfo.tsx
Normal 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;
|
||||
}
|
|
@ -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 [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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
26
tile-generator/layers/obs_regions/layer.sql
Normal file
26
tile-generator/layers/obs_regions/layer.sql
Normal 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;
|
23
tile-generator/layers/obs_regions/obs_regions.yaml
Normal file
23
tile-generator/layers/obs_regions/obs_regions.yaml
Normal 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
|
|
@ -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,
|
||||
|
|
|
@ -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: >
|
||||
|
|
Loading…
Reference in a new issue