Use NUTS for region import, not OSM

This commit is contained in:
Paul Bienkowski 2023-03-31 21:06:59 +02:00
parent 0d9ddf4884
commit ce8054b7ae
23 changed files with 799 additions and 505 deletions

View file

@ -1,28 +0,0 @@
"""remove region tags
Revision ID: 5c7755ead95d
Revises: f7b21148126a
Create Date: 2023-03-26 09:36:46.808239
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import HSTORE
# revision identifiers, used by Alembic.
revision = "5c7755ead95d"
down_revision = "f7b21148126a"
branch_labels = None
depends_on = None
def upgrade():
op.drop_column("region", "tags")
def downgrade():
op.add_column(
"region",
sa.Column("tags", HSTORE, nullable=True),
)

View file

@ -21,13 +21,10 @@ depends_on = None
def upgrade(): def upgrade():
op.create_table( op.create_table(
"region", "region",
sa.Column( sa.Column("id", sa.String(24), primary_key=True, index=True),
"relation_id", sa.BIGINT, primary_key=True, index=True, autoincrement=False
),
sa.Column("name", sa.Text), sa.Column("name", sa.Text),
sa.Column("geometry", dbtype("GEOMETRY(GEOMETRY,3857)"), index=False), sa.Column("geometry", dbtype("GEOMETRY(GEOMETRY,3857)"), index=False),
sa.Column("admin_level", sa.Integer, index=True), sa.Column("admin_level", sa.Integer, index=True),
sa.Column("tags", dbtype("HSTORE")),
) )
op.execute( op.execute(
"CREATE INDEX region_geometry_idx ON region USING GIST (geometry) WITH (FILLFACTOR=100);" "CREATE INDEX region_geometry_idx ON region USING GIST (geometry) WITH (FILLFACTOR=100);"

View file

@ -1,7 +1,7 @@
"""add import groups """add import groups
Revision ID: b8b0fbae50a4 Revision ID: b8b0fbae50a4
Revises: 5c7755ead95d Revises: f7b21148126a
Create Date: 2023-03-26 09:41:36.621203 Create Date: 2023-03-26 09:41:36.621203
""" """
@ -11,7 +11,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = "b8b0fbae50a4" revision = "b8b0fbae50a4"
down_revision = "5c7755ead95d" down_revision = "f7b21148126a"
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View file

@ -18,9 +18,7 @@ depends_on = None
def upgrade(): def upgrade():
op.execute("CREATE INDEX IF NOT EXISTS ix_road_way_id ON road (way_id);") op.execute("CREATE INDEX IF NOT EXISTS ix_road_way_id ON road (way_id);")
op.execute( op.execute("CREATE INDEX IF NOT EXISTS ix_region_id ON region (relation_id);")
"CREATE INDEX IF NOT EXISTS ix_region_relation_id ON region (relation_id);"
)
def downgrade(): def downgrade():

View file

@ -504,7 +504,7 @@ class Comment(Base):
class Region(Base): class Region(Base):
__tablename__ = "region" __tablename__ = "region"
relation_id = Column(BIGINT, primary_key=True, index=True, autoincrement=False) id = Column(String(24), primary_key=True, index=True)
name = Column(Text) name = Column(Text)
geometry = Column(GeometryGeometry) geometry = Column(GeometryGeometry)
admin_level = Column(Integer, index=True) admin_level = Column(Integer, index=True)

View file

@ -183,7 +183,7 @@ async def stats(req):
query = ( query = (
select( select(
[ [
Region.relation_id.label("id"), Region.id,
Region.name, Region.name,
func.count(OvertakingEvent.id).label("overtaking_event_count"), func.count(OvertakingEvent.id).label("overtaking_event_count"),
] ]
@ -195,12 +195,9 @@ async def stats(req):
func.ST_Transform(OvertakingEvent.geometry, 3857), Region.geometry func.ST_Transform(OvertakingEvent.geometry, 3857), Region.geometry
), ),
) )
.where(Region.admin_level == 6)
.group_by( .group_by(
Region.relation_id, Region.id,
Region.name, Region.name,
Region.relation_id,
Region.admin_level,
Region.geometry, Region.geometry,
) )
.having(func.count(OvertakingEvent.id) > 0) .having(func.count(OvertakingEvent.id) > 0)

View file

@ -17,3 +17,4 @@ osmium~=3.6.0
psycopg~=3.1.8 psycopg~=3.1.8
shapely~=2.0.1 shapely~=2.0.1
pyproj~=3.4.1 pyproj~=3.4.1
aiohttp~=3.8.1

View file

@ -18,17 +18,6 @@ log = logging.getLogger(__name__)
ROAD_BUFFER = 1000 ROAD_BUFFER = 1000
AREA_BUFFER = 100 AREA_BUFFER = 100
ROAD_TYPE = b"\x01"
REGION_TYPE = b"\x02"
@dataclass
class Region:
relation_id: int
name: str
admin_level: int
geometry: bytes
@dataclass @dataclass
class Road: class Road:
@ -40,12 +29,9 @@ class Road:
geometry: bytes geometry: bytes
data_types = {ROAD_TYPE: Road, REGION_TYPE: Region} def read_file(filename):
def read_file(filename, only_type: bytes):
""" """
Reads a file iteratively, yielding Road and Region objects as they Reads a file iteratively, yielding
appear. Those may be mixed. appear. Those may be mixed.
""" """
@ -53,11 +39,10 @@ def read_file(filename, only_type: bytes):
unpacker = msgpack.Unpacker(f) unpacker = msgpack.Unpacker(f)
try: try:
while True: while True:
type_id = unpacker.unpack() type_id, *data = unpacker.unpack()
data = unpacker.unpack()
if type_id == only_type: if type_id == b"\x01":
yield data_types[only_type](*data) yield Road(*data)
except msgpack.OutOfData: except msgpack.OutOfData:
pass pass
@ -69,11 +54,8 @@ async def import_osm(connection, filename, import_group=None):
# Pass 1: Find IDs only # Pass 1: Find IDs only
road_ids = [] road_ids = []
region_ids = [] for item in read_file(filename):
for item in read_file(filename, only_type=ROAD_TYPE):
road_ids.append(item.way_id) road_ids.append(item.way_id)
for item in read_file(filename, only_type=REGION_TYPE):
region_ids.append(item.relation_id)
async with connection.cursor() as cursor: async with connection.cursor() as cursor:
log.info("Pass 1: Delete previously imported data") log.info("Pass 1: Delete previously imported data")
@ -82,25 +64,17 @@ async def import_osm(connection, filename, import_group=None):
await cursor.execute( await cursor.execute(
"DELETE FROM road WHERE import_group = %s", (import_group,) "DELETE FROM road WHERE import_group = %s", (import_group,)
) )
await cursor.execute(
"DELETE FROM region WHERE import_group = %s", (import_group,)
)
log.debug("Delete roads by way_id") log.debug("Delete roads by way_id")
for ids in chunk(road_ids, 10000): for ids in chunk(road_ids, 10000):
await cursor.execute("DELETE FROM road WHERE way_id = ANY(%s)", (ids,)) await cursor.execute("DELETE FROM road WHERE way_id = ANY(%s)", (ids,))
log.debug("Delete regions by region_id")
for ids in chunk(region_ids, 10000):
await cursor.execute(
"DELETE FROM region WHERE relation_id = ANY(%s)", (ids,)
)
# Pass 2: Import # Pass 2: Import
log.info("Pass 2: Import roads") log.info("Pass 2: Import roads")
async with cursor.copy( async with cursor.copy(
"COPY road (way_id, name, zone, directionality, oneway, geometry, import_group) FROM STDIN" "COPY road (way_id, name, zone, directionality, oneway, geometry, import_group) FROM STDIN"
) as copy: ) as copy:
for item in read_file(filename, ROAD_TYPE): for item in read_file(filename):
await copy.write_row( await copy.write_row(
( (
item.way_id, item.way_id,
@ -113,21 +87,6 @@ async def import_osm(connection, filename, import_group=None):
) )
) )
log.info(f"Pass 2: Import regions")
async with cursor.copy(
"COPY region (relation_id, name, geometry, admin_level, import_group) FROM STDIN"
) as copy:
for item in read_file(filename, REGION_TYPE):
await copy.write_row(
(
item.relation_id,
item.name,
bytes.hex(item.geometry),
item.admin_level,
import_group,
)
)
async def main(): async def main():
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")

93
api/tools/import_regions.py Executable file
View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
This script downloads and/or imports regions for statistical analysis into the
PostGIS database. The regions are sourced from:
* EU countries are covered by
[NUTS](https://ec.europa.eu/eurostat/web/gisco/geodata/reference-data/administrative-units-statistical-units/nuts).
"""
import tempfile
from dataclasses import dataclass
import json
import asyncio
from os.path import basename, splitext
import sys
import logging
from typing import Optional
import aiohttp
import psycopg
from obs.api.app import app
from obs.api.utils import chunk
log = logging.getLogger(__name__)
NUTS_URL = "https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/NUTS_RG_01M_2021_3857.geojson"
from pyproj import Transformer
project = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True).transform
from shapely.ops import transform
from shapely.geometry import shape
import shapely.wkb as wkb
async def import_nuts(
connection, filename=None, level: int = 3, import_group: Optional[str] = None
):
if import_group is None:
import_group = f"nuts{level}"
if filename:
log.info("Load NUTS from file")
with open(filename) as f:
data = json.load(f)
else:
log.info("Download NUTS regions from europa.eu")
async with aiohttp.ClientSession() as session:
async with session.get(NUTS_URL) as resp:
data = await resp.json(content_type=None)
async with connection.cursor() as cursor:
log.info(
"Delete previously imported regions with import group %s", import_group
)
await cursor.execute(
"DELETE FROM region WHERE import_group = %s", (import_group,)
)
log.info("Import regions")
async with cursor.copy(
"COPY region (id, name, geometry, import_group) FROM STDIN"
) as copy:
for feature in data["features"]:
if feature["properties"]["LEVL_CODE"] == level:
geometry = shape(feature["geometry"])
# geometry = transform(project, geometry)
geometry = wkb.dumps(geometry)
geometry = bytes.hex(geometry)
await copy.write_row(
(
feature["properties"]["NUTS_ID"],
feature["properties"]["NUTS_NAME"],
geometry,
import_group,
)
)
async def main():
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
url = app.config.POSTGRES_URL
url = url.replace("+asyncpg", "")
async with await psycopg.AsyncConnection.connect(url) as connection:
await import_nuts(connection, sys.argv[1])
if __name__ == "__main__":
asyncio.run(main())

View file

@ -170,33 +170,8 @@ class OSMHandler(osmium.SimpleHandler):
geometry = wkb.loads(wkbfab.create_linestring(way), hex=True) geometry = wkb.loads(wkbfab.create_linestring(way), hex=True)
geometry = transform(project, geometry) geometry = transform(project, geometry)
geometry = wkb.dumps(geometry) geometry = wkb.dumps(geometry)
self.packer.pack(b"\x01")
self.packer.pack([way.id, name, zone, directionality, oneway, geometry])
def area(self, area):
tags = area.tags
if tags.get("boundary") != "administrative":
return
admin_level = parse_number(tags.get("admin_level"))
if not admin_level:
return
if admin_level < ADMIN_LEVEL_MIN or admin_level > ADMIN_LEVEL_MAX:
return
name = tags.get("name")
geometry = wkb.loads(wkbfab.create_multipolygon(area), hex=True)
geometry = transform(project, geometry)
geometry = wkb.dumps(geometry)
self.packer.pack(b"\x02")
self.packer.pack( self.packer.pack(
[ [b"\x01", way.id, name, zone, directionality, oneway, geometry]
area.id,
name,
admin_level,
geometry,
]
) )

View file

@ -21,6 +21,7 @@ import styles from "./App.module.less";
import { AVAILABLE_LOCALES, setLocale } from "i18n"; import { AVAILABLE_LOCALES, setLocale } from "i18n";
import { import {
AcknowledgementsPage,
ExportPage, ExportPage,
HomePage, HomePage,
LoginRedirectPage, LoginRedirectPage,
@ -186,6 +187,9 @@ const App = connect((state) => ({ login: state.login }))(function App({
<Route path="/export" exact> <Route path="/export" exact>
<ExportPage /> <ExportPage />
</Route> </Route>
<Route path="/acknowledgements" exact>
<AcknowledgementsPage />
</Route>
<Route path="/redirect" exact> <Route path="/redirect" exact>
<LoginRedirectPage /> <LoginRedirectPage />
</Route> </Route>
@ -280,13 +284,17 @@ const App = connect((state) => ({ login: state.login }))(function App({
{t("App.footer.imprint")} {t("App.footer.imprint")}
</a> </a>
</List.Item> </List.Item>
{ config?.termsUrl && {config?.termsUrl && (
<List.Item> <List.Item>
<a href={config?.termsUrl} target="_blank" rel="noreferrer"> <a
{t('App.footer.terms')} href={config?.termsUrl}
</a> target="_blank"
</List.Item> rel="noreferrer"
} >
{t("App.footer.terms")}
</a>
</List.Item>
)}
<List.Item> <List.Item>
<a <a
href={`https://github.com/openbikesensor/portal${ href={`https://github.com/openbikesensor/portal${

View file

@ -1,189 +1,201 @@
import _ from 'lodash' import _ from "lodash";
import produce from 'immer' import produce from "immer";
import bright from './bright.json' import bright from "./bright.json";
import positron from './positron.json' import positron from "./positron.json";
import viridisBase from 'colormap/res/res/viridis' import viridisBase from "colormap/res/res/viridis";
export {bright, positron} export { bright, positron };
export const baseMapStyles = {bright, positron} export const baseMapStyles = { bright, positron };
function simplifyColormap(colormap, maxCount = 16) { function simplifyColormap(colormap, maxCount = 16) {
const result = [] const result = [];
const step = Math.ceil(colormap.length / maxCount) const step = Math.ceil(colormap.length / maxCount);
for (let i = 0; i < colormap.length; i += step) { for (let i = 0; i < colormap.length; i += step) {
result.push(colormap[i]) result.push(colormap[i]);
} }
return result return result;
} }
function rgbArrayToColor(arr) { function rgbArrayToColor(arr) {
return ['rgb', ...arr.map((v) => Math.round(v * 255))] return ["rgb", ...arr.map((v) => Math.round(v * 255))];
} }
function rgbArrayToHtml(arr) { function rgbArrayToHtml(arr) {
return "#" + arr.map((v) => Math.round(v * 255).toString(16)).map(v => (v.length == 1 ? '0' : '') + v).join('') return (
"#" +
arr
.map((v) => Math.round(v * 255).toString(16))
.map((v) => (v.length == 1 ? "0" : "") + v)
.join("")
);
} }
export function colormapToScale(colormap, value, min, max) { export function colormapToScale(colormap, value, min, max) {
return [ return [
'interpolate-hcl', "interpolate-hcl",
['linear'], ["linear"],
value, value,
...colormap.flatMap((v, i, a) => [(i / (a.length - 1)) * (max - min) + min, v]), ...colormap.flatMap((v, i, a) => [
] (i / (a.length - 1)) * (max - min) + min,
v,
]),
];
} }
export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20) export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20);
export const viridisSimpleHtml = simplifyColormap(viridisBase.map(rgbArrayToHtml), 10) export const viridisSimpleHtml = simplifyColormap(
export const grayscale = ['#FFFFFF', '#000000'] viridisBase.map(rgbArrayToHtml),
export const reds = [ 10
'rgba( 255, 0, 0, 0)', );
'rgba( 255, 0, 0, 255)', export const grayscale = ["#FFFFFF", "#000000"];
] export const reds = ["rgba( 255, 0, 0, 0)", "rgba( 255, 0, 0, 255)"];
export function colorByCount(attribute = 'event_count', maxCount, colormap = viridis) { export function colorByCount(
return colormapToScale(colormap, ['case', isValidAttribute(attribute), ['get', attribute], 0], 0, maxCount) attribute = "event_count",
maxCount,
colormap = viridis
) {
return colormapToScale(
colormap,
["case", isValidAttribute(attribute), ["get", attribute], 0],
0,
maxCount
);
} }
var steps = {'rural': [1.6,1.8,2.0,2.2], var steps = { rural: [1.6, 1.8, 2.0, 2.2], urban: [1.1, 1.3, 1.5, 1.7] };
'urban': [1.1,1.3,1.5,1.7]}
export function isValidAttribute(attribute) { export function isValidAttribute(attribute) {
if (attribute.endsWith('zone')) { if (attribute.endsWith("zone")) {
return ['in', ['get', attribute], ['literal', ['rural', 'urban']]] return ["in", ["get", attribute], ["literal", ["rural", "urban"]]];
} }
return ['to-boolean', ['get', attribute]] return ["to-boolean", ["get", attribute]];
} }
export function borderByZone() { export function borderByZone() {
return ["match", ['get', 'zone'], return ["match", ["get", "zone"], "rural", "cyan", "urban", "blue", "purple"];
"rural", "cyan",
"urban", "blue",
"purple"
]
} }
export function colorByDistance(attribute = 'distance_overtaker_mean', fallback = '#ABC', zone='urban') { export function colorByDistance(
attribute = "distance_overtaker_mean",
fallback = "#ABC",
zone = "urban"
) {
return [ return [
'case', "case",
['!', isValidAttribute(attribute)], ["!", isValidAttribute(attribute)],
fallback, fallback,
["match", ['get', 'zone'], "rural",
[ [
'step', "match",
['get', attribute], ["get", "zone"],
'rgba(150, 0, 0, 1)', "rural",
steps['rural'][0], [
'rgba(255, 0, 0, 1)', "step",
steps['rural'][1], ["get", attribute],
'rgba(255, 220, 0, 1)', "rgba(150, 0, 0, 1)",
steps['rural'][2], steps["rural"][0],
'rgba(67, 200, 0, 1)', "rgba(255, 0, 0, 1)",
steps['rural'][3], steps["rural"][1],
'rgba(67, 150, 0, 1)', "rgba(255, 220, 0, 1)",
], "urban", steps["rural"][2],
[ "rgba(67, 200, 0, 1)",
'step', steps["rural"][3],
['get', attribute], "rgba(67, 150, 0, 1)",
'rgba(150, 0, 0, 1)', ],
steps['urban'][0], "urban",
'rgba(255, 0, 0, 1)', [
steps['urban'][1], "step",
'rgba(255, 220, 0, 1)', ["get", attribute],
steps['urban'][2], "rgba(150, 0, 0, 1)",
'rgba(67, 200, 0, 1)', steps["urban"][0],
steps['urban'][3], "rgba(255, 0, 0, 1)",
'rgba(67, 150, 0, 1)', steps["urban"][1],
"rgba(255, 220, 0, 1)",
steps["urban"][2],
"rgba(67, 200, 0, 1)",
steps["urban"][3],
"rgba(67, 150, 0, 1)",
],
[
"step",
["get", attribute],
"rgba(150, 0, 0, 1)",
steps["urban"][0],
"rgba(255, 0, 0, 1)",
steps["urban"][1],
"rgba(255, 220, 0, 1)",
steps["urban"][2],
"rgba(67, 200, 0, 1)",
steps["urban"][3],
"rgba(67, 150, 0, 1)",
],
], ],
[ ];
'step',
['get', attribute],
'rgba(150, 0, 0, 1)',
steps['urban'][0],
'rgba(255, 0, 0, 1)',
steps['urban'][1],
'rgba(255, 220, 0, 1)',
steps['urban'][2],
'rgba(67, 200, 0, 1)',
steps['urban'][3],
'rgba(67, 150, 0, 1)',
]
]
]
} }
export const trackLayer = { export const trackLayer = {
type: 'line', type: "line",
paint: { paint: {
'line-width': ['interpolate', ['linear'], ['zoom'], 14, 2, 17, 5], "line-width": ["interpolate", ["linear"], ["zoom"], 14, 2, 17, 5],
'line-color': '#F06292', "line-color": "#F06292",
'line-opacity': 0.6, "line-opacity": 0.6,
}, },
} };
export const getRegionLayers = (adminLevel = 6, baseColor = "#00897B", maxValue = 5000) => [{ export const getRegionLayers = (
id: 'region', adminLevel = 6,
"type": "fill", baseColor = "#00897B",
"source": "obs", maxValue = 5000
"source-layer": "obs_regions", ) => [
"minzoom": 0, {
"maxzoom": 10, id: "region",
"filter": [ type: "fill",
"all", source: "obs",
["==", "admin_level", adminLevel], "source-layer": "obs_regions",
[">", "overtaking_event_count", 0], minzoom: 0,
], maxzoom: 10,
"paint": { // filter: [">", "overtaking_event_count", 0],
"fill-color": baseColor, paint: {
"fill-antialias": true, "fill-color": baseColor,
"fill-opacity": [ "fill-antialias": true,
"interpolate", "fill-opacity": [
["linear"], "interpolate",
[ ["linear"],
"log10", ["log10", ["get", "overtaking_event_count"]],
[ 0,
"get", 0,
"overtaking_event_count" Math.log10(maxValue),
] 0.9,
], ],
0, },
0,
Math.log10(maxValue),
0.9
]
}, },
}, {
{ id: "region-border",
id: 'region-border', type: "line",
"type": "line", source: "obs",
"source": "obs", "source-layer": "obs_regions",
"source-layer": "obs_regions", minzoom: 0,
"minzoom": 0, maxzoom: 10,
"maxzoom": 10, // filter: [">", "overtaking_event_count", 0],
"filter": [ paint: {
"all", "line-width": 1,
["==", "admin_level", adminLevel], "line-color": baseColor,
[">", "overtaking_event_count", 0], },
], layout: {
"paint": { "line-join": "round",
"line-width": 1, "line-cap": "round",
"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;
draft.paint['line-width'][6] = 2 draft.paint["line-width"][6] = 2;
draft.paint['line-dasharray'] = [3, 3] draft.paint["line-dasharray"] = [3, 3];
delete draft.paint['line-opacity'] delete draft.paint["line-opacity"];
}) });
export const basemap = positron export const basemap = positron;

View file

@ -0,0 +1,18 @@
import React from "react";
import { Header } from "semantic-ui-react";
import { useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import { Page } from "components";
export default function AcknowledgementsPage() {
const { t } = useTranslation();
const title = t("AcknowledgementsPage.title");
return (
<Page title={title}>
<Header as="h2">{title}</Header>
<Markdown>{t("AcknowledgementsPage.information")}</Markdown>
</Page>
);
}

View file

@ -1,56 +1,81 @@
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 {List, Select, Input, Divider, Label, Checkbox, Header} from 'semantic-ui-react' import { Link } from "react-router-dom";
import {useTranslation} from 'react-i18next' import {
List,
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";
import styles from "./styles.module.less";
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 },
obsRegions: {show: showRegions}, obsRegions: { show: showRegions },
filters: {currentUser: filtersCurrentUser, dateMode, startDate, endDate, thresholdAfter}, filters: {
} = mapConfig currentUser: filtersCurrentUser,
dateMode,
startDate,
endDate,
thresholdAfter,
},
} = mapConfig;
const openStreetMapCopyright = (
<List.Item className={styles.copyright}>
{t("MapPage.sidebar.copyright.openStreetMap")}{" "}
<Link to="/acknowledgements">
{t("MapPage.sidebar.copyright.learnMore")}
</Link>
</List.Item>
);
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,
@ -58,36 +83,47 @@ function LayerSidebar({
text: t(`MapPage.sidebar.baseMap.style.${value}`), text: t(`MapPage.sidebar.baseMap.style.${value}`),
}))} }))}
value={style} value={style}
onChange={(_e, {value}) => setMapConfigFlag('baseMap.style', value)} onChange={(_e, { value }) =>
setMapConfigFlag("baseMap.style", value)
}
/> />
</List.Item> </List.Item>
{openStreetMapCopyright}
<Divider /> <Divider />
<List.Item> <List.Item>
<Checkbox <Checkbox
toggle toggle
size="small" size="small"
id="obsRegions.show" id="obsRegions.show"
style={{float: 'right'}} style={{ float: "right" }}
checked={showRegions} checked={showRegions}
onChange={() => setMapConfigFlag('obsRegions.show', !showRegions)} onChange={() => setMapConfigFlag("obsRegions.show", !showRegions)}
/> />
<label htmlFor="obsRegions.show"> <label htmlFor="obsRegions.show">
<Header as="h4">{t('MapPage.sidebar.obsRegions.title')}</Header> <Header as="h4">{t("MapPage.sidebar.obsRegions.title")}</Header>
</label> </label>
</List.Item> </List.Item>
{showRegions && ( {showRegions && (
<> <>
<List.Item>{t('MapPage.sidebar.obsRegions.colorByEventCount')}</List.Item> <List.Item>
{t("MapPage.sidebar.obsRegions.colorByEventCount")}
</List.Item>
<List.Item> <List.Item>
<ColorMapLegend <ColorMapLegend
twoTicks twoTicks
map={[ map={[
[0, '#00897B00'], [0, "#00897B00"],
[5000, '#00897BFF'], [5000, "#00897BFF"],
]} ]}
digits={0} digits={0}
/> />
</List.Item> </List.Item>
<List.Item className={styles.copyright}>
{t("MapPage.sidebar.copyright.boundaries")}{" "}
<Link to="/acknowledgements">
{t("MapPage.sidebar.copyright.learnMore")}
</Link>
</List.Item>
</> </>
)} )}
<Divider /> <Divider />
@ -96,12 +132,12 @@ function LayerSidebar({
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 && (
@ -109,12 +145,16 @@ function LayerSidebar({
<List.Item> <List.Item>
<Checkbox <Checkbox
checked={showUntagged} checked={showUntagged}
onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)} onChange={() =>
label={t('MapPage.sidebar.obsRoads.showUntagged.label')} setMapConfigFlag("obsRoads.showUntagged", !showUntagged)
}
label={t("MapPage.sidebar.obsRoads.showUntagged.label")}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>{t('MapPage.sidebar.obsRoads.attribute.label')}</List.Header> <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) => ({
@ -123,53 +163,78 @@ function LayerSidebar({
text: t(`MapPage.sidebar.obsRoads.attribute.${value}`), text: t(`MapPage.sidebar.obsRoads.attribute.${value}`),
}))} }))}
value={attribute} value={attribute}
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)} onChange={(_e, { value }) =>
setMapConfigFlag("obsRoads.attribute", value)
}
/> />
</List.Item> </List.Item>
{attribute.endsWith('_count') ? ( {attribute.endsWith("_count") ? (
<> <>
<List.Item> <List.Item>
<List.Header>{t('MapPage.sidebar.obsRoads.maxCount.label')}</List.Header> <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}) => setMapConfigFlag('obsRoads.maxCount', value)} onChange={(_e, { value }) =>
setMapConfigFlag("obsRoads.maxCount", value)
}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<ColorMapLegend <ColorMapLegend
map={_.chunk( map={_.chunk(
colorByCount('obsRoads.maxCount', mapConfig.obsRoads.maxCount, viridisSimpleHtml).slice(3), colorByCount(
"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 size="small" style={{background: 'blue', color: 'white'}}> <Label
{t('general.zone.urban')} (1.5&nbsp;m) size="small"
style={{ background: "blue", color: "white" }}
>
{t("general.zone.urban")} (1.5&nbsp;m)
</Label> </Label>
<Label size="small" style={{background: 'cyan', color: 'black'}}> <Label
{t('general.zone.rural')}(2&nbsp;m) size="small"
style={{ background: "cyan", color: "black" }}
>
{t("general.zone.rural")}(2&nbsp;m)
</Label> </Label>
</List.Item> </List.Item>
</> </>
) : ( ) : (
<> <>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header> <List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} /> {_.upperFirst(t("general.zone.urban"))}
</List.Header>
<DiscreteColorMapLegend
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>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} /> {_.upperFirst(t("general.zone.rural"))}
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item> </List.Item>
</> </>
)} )}
{openStreetMapCopyright}
</> </>
)} )}
<Divider /> <Divider />
@ -178,36 +243,40 @@ 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 map={colorByDistance('distance_overtaker')[3][5].slice(2)} /> <DiscreteColorMapLegend
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 map={colorByDistance('distance_overtaker')[3][3].slice(2)} /> <DiscreteColorMapLegend
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>
@ -216,13 +285,15 @@ function LayerSidebar({
size="small" size="small"
id="filters.currentUser" id="filters.currentUser"
checked={filtersCurrentUser} checked={filtersCurrentUser}
onChange={() => setMapConfigFlag('filters.currentUser', !filtersCurrentUser)} onChange={() =>
label={t('MapPage.sidebar.filters.currentUser')} setMapConfigFlag("filters.currentUser", !filtersCurrentUser)
}
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>
@ -233,12 +304,14 @@ 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}) => setMapConfigFlag('filters.dateMode', value)} onChange={(_e, { value }) =>
setMapConfigFlag("filters.dateMode", value)
}
/> />
</List.Item> </List.Item>
{dateMode == 'range' && ( {dateMode == "range" && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -246,14 +319,16 @@ function LayerSidebar({
step="7" step="7"
size="small" size="small"
id="filters.startDate" id="filters.startDate"
onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)} onChange={(_e, { 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"
@ -261,14 +336,16 @@ function LayerSidebar({
step="7" step="7"
size="small" size="small"
id="filters.endDate" id="filters.endDate"
onChange={(_e, {value}) => setMapConfigFlag('filters.endDate', value)} onChange={(_e, { 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"
@ -277,33 +354,42 @@ function LayerSidebar({
size="small" size="small"
id="filters.startDate" id="filters.startDate"
value={startDate ?? null} value={startDate ?? null}
onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)} onChange={(_e, { value }) =>
label={t('MapPage.sidebar.filters.threshold')} setMapConfigFlag("filters.startDate", value)
}
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={() => setMapConfigFlag('filters.thresholdAfter', !thresholdAfter)} onChange={() =>
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 && <List.Item>{t('MapPage.sidebar.filters.needsLogin')}</List.Item>} {!login && (
<List.Item>{t("MapPage.sidebar.filters.needsLogin")}</List.Item>
)}
</List> </List>
</div> </div>
) );
} }
export default connect( export default connect(
@ -316,6 +402,6 @@ export default connect(
), ),
login: state.login, login: state.login,
}), }),
{setMapConfigFlag: setMapConfigFlagAction} { setMapConfigFlag: setMapConfigFlagAction }
// //
)(LayerSidebar) )(LayerSidebar);

View file

@ -1,253 +1,296 @@
import React, {useState, useCallback, useMemo, useRef} 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 api from 'api' import api from "api";
import type {Location} from 'types' import type { Location } from "types";
import {Page, Map} from 'components' import { Page, Map } from "components";
import {useConfig} from 'config' import { useConfig } from "config";
import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles' import {
import {useMapConfig} from 'reducers/mapConfig' colorByDistance,
colorByCount,
getRegionLayers,
borderByZone,
isValidAttribute,
} from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo, {RoadInfoType} from './RoadInfo' import RoadInfo, { RoadInfoType } from "./RoadInfo";
import RegionInfo from './RegionInfo' import RegionInfo from "./RegionInfo";
import LayerSidebar from './LayerSidebar' import LayerSidebar from "./LayerSidebar";
import styles from './styles.module.less' 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",
minzoom: 12, minzoom: 12,
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], 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],
], ],
}, },
} };
const getUntaggedRoadsLayer = (colorAttribute) => 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.minzoom = 10 draft.minzoom = 10;
draft.paint['line-width'][6] = 6 // scale bigger on zoom draft.paint["line-width"][6] = 6; // scale bigger on zoom
draft.paint['line-color'] = colorAttribute.startsWith('distance_') 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: 8,
}) });
const getEventsTextLayer = () => ({ const getEventsTextLayer = () => ({
id: 'obs_events_text', id: "obs_events_text",
type: 'symbol', type: "symbol",
minzoom: 18, minzoom: 18,
source: 'obs', source: "obs",
'source-layer': 'obs_events', "source-layer": "obs_events",
layout: { layout: {
'text-field': [ "text-field": [
'number-format', "number-format",
['get', 'distance_overtaker'], ["get", "distance_overtaker"],
{'min-fraction-digits': 2, 'max-fraction-digits': 2}, { "min-fraction-digits": 2, "max-fraction-digits": 2 },
], ],
'text-allow-overlap': true, "text-allow-overlap": true,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'], "text-font": ["Open Sans Bold", "Arial Unicode MS Regular"],
'text-size': 14, "text-size": 14,
'text-keep-upright': false, "text-keep-upright": false,
'text-anchor': 'left', "text-anchor": "left",
'text-radial-offset': 1, "text-radial-offset": 1,
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]], "text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
'text-rotation-alignment': 'map', "text-rotation-alignment": "map",
}, },
paint: { paint: {
'text-halo-color': 'rgba(255, 255, 255, 1)', "text-halo-color": "rgba(255, 255, 255, 1)",
'text-halo-width': 1, "text-halo-width": 1,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1], "text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
}, },
}) });
interface RegionInfo { interface RegionInfo {
properties: { properties: {
admin_level: number admin_level: number;
name: string name: string;
overtaking_event_count: number overtaking_event_count: number;
} };
} }
type Details = {type: 'road'; road: RoadInfoType} | {type: 'region'; region: RegionInfo} type Details =
| { type: "road"; road: RoadInfoType }
| { type: "region"; region: RegionInfo };
function MapPage({login}) { function MapPage({ login }) {
const {obsMapSource, banner} = useConfig() || {} const { obsMapSource, banner } = useConfig() || {};
const [details, setDetails] = useState<null | Details>(null) const [details, setDetails] = useState<null | Details>(null);
const onCloseDetails = useCallback(() => setDetails(null), [setDetails]) const onCloseDetails = useCallback(() => setDetails(null), [setDetails]);
const mapConfig = useMapConfig() const mapConfig = useMapConfig();
const viewportRef = useRef() const viewportRef = useRef();
const mapInfoPortal = useRef() const mapInfoPortal = useRef();
const onViewportChange = useCallback( const onViewportChange = useCallback(
(viewport) => { (viewport) => {
viewportRef.current = viewport viewportRef.current = viewport;
}, },
[viewportRef] [viewportRef]
) );
const onClick = useCallback( const onClick = useCallback(
async (e) => { async (e) => {
// check if we clicked inside the mapInfoBox, if so, early exit // check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target let node = e.target;
while (node) { while (node) {
if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) { if (
return [styles.mapInfoBox, styles.mapToolbar].some((className) =>
node?.classList?.contains(className)
)
) {
return;
} }
node = node.parentNode node = node.parentNode;
} }
const {zoom} = viewportRef.current const { zoom } = viewportRef.current;
if (zoom < 10) { if (zoom < 10) {
const clickedRegion = e.features?.find((f) => f.source === 'obs' && f.sourceLayer === 'obs_regions') const clickedRegion = e.features?.find(
setDetails(clickedRegion ? {type: 'region', region: clickedRegion} : null) (f) => f.source === "obs" && f.sourceLayer === "obs_regions"
);
setDetails(
clickedRegion ? { type: "region", region: clickedRegion } : null
);
} else { } else {
const road = await api.get('/mapdetails/road', { const road = await api.get("/mapdetails/road", {
query: { query: {
longitude: e.lngLat[0], longitude: e.lngLat[0],
latitude: e.lngLat[1], latitude: e.lngLat[1],
radius: 100, radius: 100,
}, },
}) });
setDetails(road?.road ? {type: 'road', road} : null) setDetails(road?.road ? { type: "road", road } : null);
} }
}, },
[setDetails] [setDetails]
) );
const [layerSidebar, setLayerSidebar] = useState(true) const [layerSidebar, setLayerSidebar] = useState(true);
const { const {
obsRoads: {attribute, maxCount}, obsRoads: { attribute, maxCount },
} = mapConfig } = mapConfig;
const layers = [] const layers = [];
const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute]) const untaggedRoadsLayerCustom = useMemo(
() => 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(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount]) const roadsLayer = useMemo(
() => getRoadsLayer(attribute, maxCount),
[attribute, maxCount]
);
if (mapConfig.obsRoads.show) { if (mapConfig.obsRoads.show) {
layers.push(roadsLayer) layers.push(roadsLayer);
} }
const regionLayers = useMemo(() => getRegionLayers(), []) const regionLayers = useMemo(() => getRegionLayers(), []);
if (mapConfig.obsRegions.show) { if (mapConfig.obsRegions.show) {
layers.push(...regionLayers) layers.push(...regionLayers);
} }
const eventsLayer = useMemo(() => getEventsLayer(), []) const eventsLayer = useMemo(() => getEventsLayer(), []);
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []) 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(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate) query.append(
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 = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none') const hasFilters: boolean =
login &&
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
return ( return (
<Page fullScreen title="Map"> <Page fullScreen title="Map">
<div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}> <div
className={classNames(
styles.mapContainer,
banner ? styles.hasBanner : null
)}
ref={mapInfoPortal}
>
{layerSidebar && ( {layerSidebar && (
<div className={styles.mapSidebar}> <div className={styles.mapSidebar}>
<LayerSidebar /> <LayerSidebar />
</div> </div>
)} )}
<div className={styles.map}> <div className={styles.map}>
<Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}> <Map
viewportFromUrl
onClick={onClick}
hasToolbar
onViewportChange={onViewportChange}
>
<div className={styles.mapToolbar}> <div className={styles.mapToolbar}>
<Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} /> <Button
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) => (
@ -255,23 +298,27 @@ function MapPage({login}) {
))} ))}
</Source> </Source>
{details?.type === 'road' && details?.road?.road && ( {details?.type === "road" && details?.road?.road && (
<RoadInfo <RoadInfo
roadInfo={details.road} roadInfo={details.road}
mapInfoPortal={mapInfoPortal.current} mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails} onClose={onCloseDetails}
{...{hasFilters}} {...{ hasFilters }}
/> />
)} )}
{details?.type === 'region' && details?.region && ( {details?.type === "region" && details?.region && (
<RegionInfo region={details.region} mapInfoPortal={mapInfoPortal.current} onClose={onCloseDetails} /> <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

@ -32,6 +32,13 @@
padding: 16px; padding: 16px;
} }
.copyright {
color: #888;
font-size: 0.8em;
line-height: 1.4;
margin-block-start: 1em;
}
.mapToolbar { .mapToolbar {
position: absolute; position: absolute;
left: 16px; left: 16px;

View file

@ -1,3 +1,4 @@
export { default as AcknowledgementsPage } from "./AcknowledgementsPage";
export { default as ExportPage } from "./ExportPage"; export { default as ExportPage } from "./ExportPage";
export { default as HomePage } from "./HomePage"; export { default as HomePage } from "./HomePage";
export { default as LoginRedirectPage } from "./LoginRedirectPage"; export { default as LoginRedirectPage } from "./LoginRedirectPage";

View file

@ -134,6 +134,11 @@ NotFoundPage:
MapPage: MapPage:
sidebar: sidebar:
copyright:
learnMore: Mehr erfahren
openStreetMap: © OpenStreetMap
boundaries: © EuroGeographics bezüglich der Verwaltungsgrenzen
baseMap: baseMap:
style: style:
label: Stil der Basiskarte label: Stil der Basiskarte
@ -365,3 +370,42 @@ RegionStats:
title: Top-Regionen title: Top-Regionen
regionName: Region regionName: Region
eventCount: Anzahl Überholungen eventCount: Anzahl Überholungen
AcknowledgementsPage:
title: Danksagungen
information: |
Diese Software kann nur funktionieren Dank der Arbeit vieler anderer
Menschen. Auf dieser Seite möchten wir die genutzten Datenbanken und
Bibliotheken hervorheben.
Wenn du die von dieser Software angezeigten Daten nutzen möchtest, zum
Beispiel Exporte, Downloads, Screenshots oder andere Extrakte, kann es sein
dass du einen oder mehrere dieser Datensätze weiterverwendest. Das
entsprechende Urheberrechts findet dann Anwendung, prüfe also bitte
sorgfältig die Lizenzbedigungen und ob sie für dich relevant sind. Denke
daran, die Autor:innen der Quellen angemessen zu attribuieren, auf deren
Arbeit dein Werk direkt oder indirekt durch Verwendung der Daten aufbaut.
## Basiskarte
Die Basiskarte wird durch Daten aus der
[OpenStreetMap](openstreetmap.org/copyright) erzeugt und verwendet das
Schema und die Stile von [OpenMapTiles](https://openmaptiles.org/).
## Straßenzüge
Informationen über Straßenzüge werden für die Verarbeitung hochgeladener
Fahrten und für die Anzeige der Straßensegmente auf der Karte verwendet.
Diese Informationen stammen aus der
[OpenStreetMap](openstreetmap.org/copyright).
## Verwaltungsgrenzen
Verwaltungsgrenzen werden für statistische Auswertung der Regionen und
Anzeige auf der Karte verwendet und werden von
[NUTS](https://ec.europa.eu/eurostat/web/gisco/geodata/reference-data/administrative-units-statistical-units)
importiert. Es gelten gesonderte Bedingungen für die Nutzung dieser Daten,
bitte folge dem obigen Link um mehr zu erfahren, wenn du diese Daten in
eigenen Werken nutzen möchtest.
© EuroGeographics bezüglich der Verwaltungsgrenzen

View file

@ -140,6 +140,11 @@ NotFoundPage:
MapPage: MapPage:
sidebar: sidebar:
copyright:
learnMore: Learn more
openStreetMap: © OpenStreetMap
boundaries: © EuroGeographics for the administrative boundaries
baseMap: baseMap:
style: style:
label: Basemap Style label: Basemap Style
@ -363,3 +368,38 @@ RegionStats:
title: Top regions title: Top regions
regionName: Region name regionName: Region name
eventCount: Event count eventCount: Event count
AcknowledgementsPage:
title: Acknowledgements
information: |
This software is only able to function thanks to the work of other people.
On this page we'd like to acknowledge the work we depend on and the
databases and libraries we use.
If you use any data provided by this software, including exports,
downloads, screenshots or other extracted information, you might be making
use of these datasets, and their copyright provisision might apply to you.
Please take care to review this on a case by case basis and attribute the
origins of the data you are using in your derivate work, whether that is
directly or indirectly through this software.
## Basemap
Basemap data is generally generated from
[OpenStreetMap](openstreetmap.org/copyright) data and is using the
[OpenMapTiles](https://openmaptiles.org/) schema and styles.
## Roadway information
Roadway information is used to process uploaded tracks and to display road
segment statistics. This data is extracted from the
[OpenStreetMap](openstreetmap.org/copyright).
## Region boundaries
Region boundaries for statistical analysis and map display are imported from
[NUTS](https://ec.europa.eu/eurostat/web/gisco/geodata/reference-data/administrative-units-statistical-units).
Provisions apply to the use of region boundary data, please follow above
link to learn more if you want to use this information in your derivative work.
© EuroGeographics for the administrative boundaries

View file

@ -140,6 +140,11 @@ NotFoundPage:
MapPage: MapPage:
sidebar: sidebar:
copyright:
learnMore: En savoir plus
openStreetMap: © OpenStreetMap
boundaries: © EuroGeographics pour les limites administratives
baseMap: baseMap:
style: style:
label: Style de fond de carte label: Style de fond de carte
@ -361,3 +366,40 @@ RegionStats:
title: Top régions title: Top régions
regionName: Nom de la région regionName: Nom de la région
eventCount: Nombre de dépassements eventCount: Nombre de dépassements
AcknowledgementsPage:
title: Remerciements
information: |
Ce logiciel ne peut fonctionner que grâce au travail d'autres personnes.
Sur cette page, nous aimerions reconnaître le travail dont nous dépendons et le
bases de données et bibliothèques que nous utilisons.
Si vous utilisez des données fournies par ce logiciel, y compris les exportations,
téléchargements, captures d'écran ou autres informations extraites, vous pourriez faire
l'utilisation de ces ensembles de données, et leur disposition sur le droit d'auteur peut s'appliquer à vous.
Veuillez prendre soin d'examiner cela au cas par cas et d'attribuer le
origines des données que vous utilisez dans votre travail dérivé, que ce soit
directement ou indirectement via ce logiciel.
## Fond de carte
Les données de fond de carte sont généralement générées à partir de
[OpenStreetMap](openstreetmap.org/copyright) données et utilise le
[OpenMapTiles](https://openmaptiles.org/) schéma et styles.
## Informations routières
Les informations routières sont utilisées pour traiter les pistes
téléchargées et pour afficher la route statistiques de segments. Ces
données sont extraites du [OpenStreetMap](openstreetmap.org/copyright).
## Limites administratives
Les limites administratives pour l'analyse statistique et l'affichage de la
carte sont importées de
[NUTS](https://ec.europa.eu/eurostat/web/gisco/geodata/reference-data/administrative-units-statistical-units).
Des dispositions s'appliquent à l'utilisation des données sur les limites
de la région, veuillez suivre ci-dessus lien pour en savoir plus si vous
souhaitez utiliser ces informations dans votre travail dérivé.
© EuroGeographics pour les limites administratives

View file

@ -15,7 +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 zoom_level >= 8
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

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

View file

@ -8,8 +8,6 @@ layer:
Number of overtaking events. Number of overtaking events.
name: | name: |
Name of the region Name of the region
admin_level: |
Administrative level of the boundary, as tagged in OpenStreetMap
defaults: defaults:
srs: EPSG:3785 srs: EPSG:3785
datasource: datasource:
@ -17,7 +15,7 @@ layer:
geometry_field: geometry geometry_field: geometry
key_field: region_id key_field: region_id
key_field_as_attribute: no 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 query: (SELECT region_id, geometry, name, overtaking_event_count FROM layer_obs_regions(!bbox!, z(!scale_denominator!))) AS t
schema: schema:
- ./layer.sql - ./layer.sql