Merge branch 'administrative-area-import' into next

This commit is contained in:
Paul Bienkowski 2023-03-12 12:44:09 +01:00
commit e0070fc794
23 changed files with 940 additions and 667 deletions

View file

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

View file

@ -432,6 +432,16 @@ class Comment(Base):
}
class Region(Base):
__tablename__ = "region"
relation_id = Column(BIGINT, primary_key=True, index=True)
name = Column(String)
geometry = Column(Geometry)
admin_level = Column(Integer)
tags = Column(HSTORE)
Comment.author = relationship("User", back_populates="authored_comments")
User.authored_comments = relationship(
"Comment",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,241 +1,254 @@
import React, { useState, useCallback, useMemo } from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Button } from "semantic-ui-react";
import { Layer, Source } from "react-map-gl";
import produce from "immer";
import classNames from "classnames";
import React, {useState, useCallback, useMemo, useRef} from 'react'
import _ from 'lodash'
import {connect} from 'react-redux'
import {Button} from 'semantic-ui-react'
import {Layer, Source} from 'react-map-gl'
import produce from 'immer'
import classNames from 'classnames'
import type { Location } from "types";
import { Page, Map } from "components";
import { useConfig } from "config";
import {
colorByDistance,
colorByCount,
borderByZone,
reds,
isValidAttribute,
} from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import api from 'api'
import type {Location} from 'types'
import {Page, Map} from 'components'
import {useConfig} from 'config'
import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles'
import {useMapConfig} from 'reducers/mapConfig'
import RoadInfo from "./RoadInfo";
import LayerSidebar from "./LayerSidebar";
import styles from "./styles.module.less";
import RoadInfo, {RoadInfoType} from './RoadInfo'
import RegionInfo from './RegionInfo'
import LayerSidebar from './LayerSidebar'
import styles from './styles.module.less'
const untaggedRoadsLayer = {
id: "obs_roads_untagged",
type: "line",
source: "obs",
"source-layer": "obs_roads",
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
id: 'obs_roads_untagged',
type: 'line',
source: 'obs',
'source-layer': 'obs_roads',
minzoom: 12,
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
layout: {
"line-cap": "round",
"line-join": "round",
'line-cap': 'round',
'line-join': 'round',
},
paint: {
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2],
"line-color": "#ABC",
"line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
"line-offset": [
"interpolate",
["exponential", 1.5],
["zoom"],
'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2],
'line-color': '#ABC',
// "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
'line-offset': [
'interpolate',
['exponential', 1.5],
['zoom'],
12,
["get", "offset_direction"],
['get', 'offset_direction'],
19,
["*", ["get", "offset_direction"], 8],
['*', ['get', 'offset_direction'], 8],
],
},
minzoom: 12,
};
}
const getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
const getUntaggedRoadsLayer = (colorAttribute) =>
produce(untaggedRoadsLayer, (draft) => {
draft.filter = ["!", isValidAttribute(colorAttribute)];
});
draft.filter = ['!', isValidAttribute(colorAttribute)]
})
const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => {
draft.id = "obs_roads_normal";
draft.filter = isValidAttribute(colorAttribute);
draft.paint["line-width"][6] = 6; // scale bigger on zoom
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
draft.id = 'obs_roads_normal'
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)
: colorAttribute.endsWith("_count")
: colorAttribute.endsWith('_count')
? colorByCount(colorAttribute, maxCount)
: colorAttribute.endsWith("zone")
: colorAttribute.endsWith('zone')
? borderByZone()
: "#DDD";
draft.paint["line-opacity"][3] = 12;
draft.paint["line-opacity"][5] = 13;
});
: '#DDD'
// draft.paint["line-opacity"][3] = 12;
// draft.paint["line-opacity"][5] = 13;
})
const getEventsLayer = () => ({
id: "obs_events",
type: "circle",
source: "obs",
"source-layer": "obs_events",
id: 'obs_events',
type: 'circle',
source: 'obs',
'source-layer': 'obs_events',
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8],
"circle-color": colorByDistance("distance_overtaker"),
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
'circle-color': colorByDistance('distance_overtaker'),
},
minzoom: 11,
});
})
const getEventsTextLayer = () => ({
id: "obs_events_text",
type: "symbol",
id: 'obs_events_text',
type: 'symbol',
minzoom: 18,
source: "obs",
"source-layer": "obs_events",
source: 'obs',
'source-layer': 'obs_events',
layout: {
"text-field": [
"number-format",
["get", "distance_overtaker"],
{ "min-fraction-digits": 2, "max-fraction-digits": 2 },
'text-field': [
'number-format',
['get', 'distance_overtaker'],
{'min-fraction-digits': 2, 'max-fraction-digits': 2},
],
"text-allow-overlap": true,
"text-font": ["Open Sans Bold", "Arial Unicode MS Regular"],
"text-size": 14,
"text-keep-upright": false,
"text-anchor": "left",
"text-radial-offset": 1,
"text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
"text-rotation-alignment": "map",
'text-allow-overlap': true,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
'text-size': 14,
'text-keep-upright': false,
'text-anchor': 'left',
'text-radial-offset': 1,
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]],
'text-rotation-alignment': 'map',
},
paint: {
"text-halo-color": "rgba(255, 255, 255, 1)",
"text-halo-width": 1,
"text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
'text-halo-color': 'rgba(255, 255, 255, 1)',
'text-halo-width': 1,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
},
});
})
function MapPage({ login }) {
const { obsMapSource, banner } = useConfig() || {};
const [clickLocation, setClickLocation] = useState<Location | null>(null);
interface RegionInfo {
properties: {
admin_level: number
name: string
overtaking_event_count: number
}
}
const mapConfig = useMapConfig();
type Details = {type: 'road'; road: RoadInfoType} | {type: 'region'; region: RegionInfo}
function MapPage({login}) {
const {obsMapSource, banner} = useConfig() || {}
const [details, setDetails] = useState<null | Details>(null)
const onCloseDetails = useCallback(() => setDetails(null), [setDetails])
const mapConfig = useMapConfig()
const viewportRef = useRef()
const mapInfoPortal = useRef()
const onViewportChange = useCallback(
(viewport) => {
viewportRef.current = viewport
},
[viewportRef]
)
const onClick = useCallback(
(e) => {
let node = e.target;
async (e) => {
// check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target
while (node) {
if (
[styles.mapInfoBox, styles.mapToolbar].some((className) =>
node?.classList?.contains(className)
)
) {
return;
if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) {
return
}
node = node.parentNode;
node = node.parentNode
}
setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] });
},
[setClickLocation]
);
const onCloseRoadInfo = useCallback(() => {
setClickLocation(null);
}, [setClickLocation]);
const {zoom} = viewportRef.current
const [layerSidebar, setLayerSidebar] = useState(true);
if (zoom < 10) {
const clickedRegion = e.features?.find((f) => f.source === 'obs' && f.sourceLayer === 'obs_regions')
setDetails(clickedRegion ? {type: 'region', region: clickedRegion} : null)
} else {
const road = await api.get('/mapdetails/road', {
query: {
longitude: e.lngLat[0],
latitude: e.lngLat[1],
radius: 100,
},
})
setDetails(road?.road ? {type: 'road', road} : null)
}
},
[setDetails]
)
const [layerSidebar, setLayerSidebar] = useState(true)
const {
obsRoads: { attribute, maxCount },
} = mapConfig;
obsRoads: {attribute, maxCount},
} = mapConfig
const layers = [];
const layers = []
const untaggedRoadsLayerCustom = useMemo(
() => getUntaggedRoadsLayer(attribute),
[attribute]
);
const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
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) {
layers.push(roadsLayer);
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);
layers.push(eventsLayer)
layers.push(eventsTextLayer)
}
const onToggleLayerSidebarButtonClick = useCallback(
(e) => {
e.stopPropagation();
e.preventDefault();
console.log("toggl;e");
setLayerSidebar((v) => !v);
e.stopPropagation()
e.preventDefault()
console.log('toggl;e')
setLayerSidebar((v) => !v)
},
[setLayerSidebar]
);
)
if (!obsMapSource) {
return null;
return null
}
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
const query = new URLSearchParams();
const query = new URLSearchParams()
if (login) {
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) {
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") {
} 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 &&
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none')
return (
<Page fullScreen title="Map">
<div
className={classNames(
styles.mapContainer,
banner ? styles.hasBanner : null
)}
>
<div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}>
{layerSidebar && (
<div className={styles.mapSidebar}>
<LayerSidebar />
</div>
)}
<div className={styles.map}>
<Map viewportFromUrl onClick={onClick} hasToolbar>
<Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}>
<div className={styles.mapToolbar}>
<Button
primary
icon="bars"
active={layerSidebar}
onClick={onToggleLayerSidebarButtonClick}
/>
<Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} />
</div>
<Source id="obs" {...obsMapSource} tiles={tiles}>
{layers.map((layer) => (
@ -243,14 +256,23 @@ function MapPage({ login }) {
))}
</Source>
<RoadInfo
{...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }}
/>
{details?.type === 'road' && details?.road?.road && (
<RoadInfo
roadInfo={details.road}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
{...{hasFilters}}
/>
)}
{details?.type === 'region' && details?.region && (
<RegionInfo region={details.region} mapInfoPortal={mapInfoPortal.current} onClose={onCloseDetails} />
)}
</Map>
</div>
</div>
</Page>
);
)
}
export default connect((state) => ({ login: state.login }))(MapPage);
export default connect((state) => ({login: state.login}))(MapPage)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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