Compare commits
6 commits
main
...
administra
Author | SHA1 | Date | |
---|---|---|---|
f11f55d0ef | |||
fac793e3a0 | |||
e38bc9bd76 | |||
908093dd6f | |||
7ad3896cdd | |||
f429ed32f3 |
35
api/migrations/versions/a049e5eb24dd_create_table_region.py
Normal file
35
api/migrations/versions/a049e5eb24dd_create_table_region.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
"""create table region
|
||||||
|
|
||||||
|
Revision ID: a049e5eb24dd
|
||||||
|
Revises: a9627f63fbed
|
||||||
|
Create Date: 2022-04-02 21:28:43.124521
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from migrations.utils import dbtype
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "a049e5eb24dd"
|
||||||
|
down_revision = "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")
|
|
@ -401,9 +401,22 @@ class Comment(Base):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Region(Base):
|
||||||
|
__tablename__ = "region"
|
||||||
|
|
||||||
|
relation_id = Column(BIGINT, primary_key=True, index=True)
|
||||||
|
name = Column(String)
|
||||||
|
geometry = Column(Geometry)
|
||||||
|
admin_level = Column(Integer)
|
||||||
|
tags = Column(HSTORE)
|
||||||
|
|
||||||
|
|
||||||
Comment.author = relationship("User", back_populates="authored_comments")
|
Comment.author = relationship("User", back_populates="authored_comments")
|
||||||
User.authored_comments = relationship(
|
User.authored_comments = relationship(
|
||||||
"Comment", order_by=Comment.created_at, back_populates="author", passive_deletes=True
|
"Comment",
|
||||||
|
order_by=Comment.created_at,
|
||||||
|
back_populates="author",
|
||||||
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
Track.author = relationship("User", back_populates="authored_tracks")
|
Track.author = relationship("User", back_populates="authored_tracks")
|
||||||
|
@ -418,7 +431,10 @@ Track.comments = relationship(
|
||||||
|
|
||||||
OvertakingEvent.track = relationship("Track", back_populates="overtaking_events")
|
OvertakingEvent.track = relationship("Track", back_populates="overtaking_events")
|
||||||
Track.overtaking_events = relationship(
|
Track.overtaking_events = relationship(
|
||||||
"OvertakingEvent", order_by=OvertakingEvent.time, back_populates="track", passive_deletes=True
|
"OvertakingEvent",
|
||||||
|
order_by=OvertakingEvent.time,
|
||||||
|
back_populates="track",
|
||||||
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ if app.config.FRONTEND_CONFIG:
|
||||||
.replace("111", "{x}")
|
.replace("111", "{x}")
|
||||||
.replace("222", "{y}")
|
.replace("222", "{y}")
|
||||||
],
|
],
|
||||||
"minzoom": 12,
|
"minzoom": 0,
|
||||||
"maxzoom": 14,
|
"maxzoom": 14,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
|
@ -4,12 +4,12 @@ from typing import Optional
|
||||||
from operator import and_
|
from operator import and_
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
from sanic.response import json
|
from sanic.response import json
|
||||||
|
|
||||||
from obs.api.app import api
|
from obs.api.app import api
|
||||||
from obs.api.db import Track, OvertakingEvent, User
|
from obs.api.db import Track, OvertakingEvent, User, Region
|
||||||
from obs.api.utils import round_to
|
from obs.api.utils import round_to
|
||||||
|
|
||||||
|
|
||||||
|
@ -167,3 +167,36 @@ async def stats(req):
|
||||||
# });
|
# });
|
||||||
# }),
|
# }),
|
||||||
# );
|
# );
|
||||||
|
|
||||||
|
|
||||||
|
@api.route("/stats/regions")
|
||||||
|
async def stats(req):
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
[
|
||||||
|
Region.relation_id.label("id"),
|
||||||
|
Region.name,
|
||||||
|
func.count(OvertakingEvent.id).label("overtaking_event_count"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.select_from(Region)
|
||||||
|
.join(
|
||||||
|
OvertakingEvent,
|
||||||
|
func.ST_Within(
|
||||||
|
func.ST_Transform(OvertakingEvent.geometry, 3857), Region.geometry
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(Region.admin_level == 6)
|
||||||
|
.group_by(
|
||||||
|
Region.relation_id,
|
||||||
|
Region.name,
|
||||||
|
Region.relation_id,
|
||||||
|
Region.admin_level,
|
||||||
|
Region.geometry,
|
||||||
|
)
|
||||||
|
.having(func.count(OvertakingEvent.id) > 0)
|
||||||
|
.order_by(desc("overtaking_event_count"))
|
||||||
|
)
|
||||||
|
|
||||||
|
regions = list(map(dict, (await req.ctx.db.execute(query)).all()))
|
||||||
|
return json(regions)
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
"obsMapSource": {
|
"obsMapSource": {
|
||||||
"type": "vector",
|
"type": "vector",
|
||||||
"tiles": ["https://portal.example.com/tiles/{z}/{x}/{y}.pbf"],
|
"tiles": ["https://portal.example.com/tiles/{z}/{x}/{y}.pbf"],
|
||||||
"minzoom": 12,
|
"minzoom": 0,
|
||||||
"maxzoom": 14
|
"maxzoom": 14
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap, twoTicks?: boolean}) {
|
export default function ColorMapLegend({map, twoTicks = false, digits=2}: {map: ColorMap, twoTicks?: boolean, digits?: number}) {
|
||||||
const min = map[0][0]
|
const min = map[0][0]
|
||||||
const max = map[map.length - 1][0]
|
const max = map[map.length - 1][0]
|
||||||
const normalizeValue = (v) => (v - min) / (max - min)
|
const normalizeValue = (v) => (v - min) / (max - min)
|
||||||
|
@ -81,7 +81,7 @@ export default function ColorMapLegend({map, twoTicks = false}: {map: ColorMap,
|
||||||
</svg>
|
</svg>
|
||||||
{tickValues.map(([value]) => (
|
{tickValues.map(([value]) => (
|
||||||
<span className={styles.tick} key={value} style={{left: normalizeValue(value) * 100 + '%'}}>
|
<span className={styles.tick} key={value} style={{left: normalizeValue(value) * 100 + '%'}}>
|
||||||
{value.toFixed(2)}
|
{value.toFixed(digits)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -56,17 +56,23 @@ function Map({
|
||||||
children,
|
children,
|
||||||
boundsFromJson,
|
boundsFromJson,
|
||||||
baseMapStyle,
|
baseMapStyle,
|
||||||
|
onViewportChange,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
viewportFromUrl?: boolean
|
viewportFromUrl?: boolean
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
boundsFromJson: GeoJSON.Geometry
|
boundsFromJson: GeoJSON.Geometry
|
||||||
baseMapStyle: string
|
baseMapStyle: string
|
||||||
|
onViewportChange: (viewport: Viewport) => void,
|
||||||
}) {
|
}) {
|
||||||
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
|
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
|
||||||
const [viewportUrl, setViewportUrl] = useViewportFromUrl()
|
const [viewportUrl, setViewportUrl] = useViewportFromUrl()
|
||||||
|
|
||||||
const [viewport, setViewport] = viewportFromUrl ? [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(() => {
|
useEffect(() => {
|
||||||
|
|
83
frontend/src/components/RegionStats/index.tsx
Normal file
83
frontend/src/components/RegionStats/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
export {default as Avatar} from './Avatar'
|
export {default as Avatar} from './Avatar'
|
||||||
|
export {default as Chart} from './Chart'
|
||||||
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend'
|
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend'
|
||||||
export {default as FileDrop} from './FileDrop'
|
export {default as FileDrop} from './FileDrop'
|
||||||
export {default as FileUploadField} from './FileUploadField'
|
export {default as FileUploadField} from './FileUploadField'
|
||||||
|
@ -6,6 +7,6 @@ export {default as FormattedDate} from './FormattedDate'
|
||||||
export {default as LoginButton} from './LoginButton'
|
export {default as LoginButton} from './LoginButton'
|
||||||
export {default as Map} from './Map'
|
export {default as Map} from './Map'
|
||||||
export {default as Page} from './Page'
|
export {default as Page} from './Page'
|
||||||
|
export {default as RegionStats} from './RegionStats'
|
||||||
export {default as Stats} from './Stats'
|
export {default as Stats} from './Stats'
|
||||||
export {default as StripMarkdown} from './StripMarkdown'
|
export {default as StripMarkdown} from './StripMarkdown'
|
||||||
export {default as Chart} from './Chart'
|
|
||||||
|
|
|
@ -77,6 +77,60 @@ export const trackLayer = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getRegionLayers = (adminLevel = 6, baseColor = "#00897B", maxValue = 5000) => [{
|
||||||
|
id: 'region',
|
||||||
|
"type": "fill",
|
||||||
|
"source": "obs",
|
||||||
|
"source-layer": "obs_regions",
|
||||||
|
"minzoom": 0,
|
||||||
|
"maxzoom": 10,
|
||||||
|
"filter": [
|
||||||
|
"all",
|
||||||
|
["==", "admin_level", adminLevel],
|
||||||
|
[">", "overtaking_event_count", 0],
|
||||||
|
],
|
||||||
|
"paint": {
|
||||||
|
"fill-color": baseColor,
|
||||||
|
"fill-antialias": true,
|
||||||
|
"fill-opacity": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
[
|
||||||
|
"log10",
|
||||||
|
[
|
||||||
|
"get",
|
||||||
|
"overtaking_event_count"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Math.log10(maxValue),
|
||||||
|
0.9
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'region-border',
|
||||||
|
"type": "line",
|
||||||
|
"source": "obs",
|
||||||
|
"source-layer": "obs_regions",
|
||||||
|
"minzoom": 0,
|
||||||
|
"maxzoom": 10,
|
||||||
|
"filter": [
|
||||||
|
"all",
|
||||||
|
["==", "admin_level", adminLevel],
|
||||||
|
[">", "overtaking_event_count", 0],
|
||||||
|
],
|
||||||
|
"paint": {
|
||||||
|
"line-width": 1,
|
||||||
|
"line-color": baseColor,
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"line-join": "round",
|
||||||
|
"line-cap": "round"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
|
||||||
export const trackLayerRaw = produce(trackLayer, draft => {
|
export const trackLayerRaw = produce(trackLayer, draft => {
|
||||||
// draft.paint['line-color'] = '#81D4FA'
|
// draft.paint['line-color'] = '#81D4FA'
|
||||||
draft.paint['line-width'][4] = 1
|
draft.paint['line-width'][4] = 1
|
||||||
|
|
|
@ -1,42 +1,44 @@
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
import {Link} from 'react-router-dom'
|
import { Link } from "react-router-dom";
|
||||||
import {Message, Grid, Loader, Header, Item} from 'semantic-ui-react'
|
import { Message, Grid, Loader, Header, Item } from "semantic-ui-react";
|
||||||
import {useObservable} from 'rxjs-hooks'
|
import { useObservable } from "rxjs-hooks";
|
||||||
import {of, from} from 'rxjs'
|
import { of, from } from "rxjs";
|
||||||
import {map, switchMap} from 'rxjs/operators'
|
import { map, switchMap } from "rxjs/operators";
|
||||||
|
|
||||||
import api from 'api'
|
import api from "api";
|
||||||
import {Stats, Page, Map} from 'components'
|
import { RegionStats, Stats, Page, Map } from "components";
|
||||||
|
|
||||||
import {TrackListItem} from './TracksPage'
|
import { TrackListItem } from "./TracksPage";
|
||||||
import styles from './HomePage.module.less'
|
import styles from "./HomePage.module.less";
|
||||||
|
|
||||||
function MostRecentTrack() {
|
function MostRecentTrack() {
|
||||||
const track: Track | null = useObservable(
|
const tracks: Track[] | null = useObservable(
|
||||||
() =>
|
() =>
|
||||||
of(null).pipe(
|
of(null).pipe(
|
||||||
switchMap(() => from(api.fetch('/tracks?limit=1'))),
|
switchMap(() => from(api.fetch("/tracks?limit=3"))),
|
||||||
map((response) => response?.tracks?.[0])
|
map((response) => response?.tracks)
|
||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
[]
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header as="h2">Most recent track</Header>
|
<Header as="h2">Most recent tracks</Header>
|
||||||
<Loader active={track === null} />
|
<Loader active={tracks === null} />
|
||||||
{track === undefined ? (
|
{tracks?.length === 0 ? (
|
||||||
<Message>
|
<Message>
|
||||||
No public tracks yet. <Link to="/upload">Upload the first!</Link>
|
No public tracks yet. <Link to="/upload">Upload the first!</Link>
|
||||||
</Message>
|
</Message>
|
||||||
) : track ? (
|
) : tracks ? (
|
||||||
<Item.Group>
|
<Item.Group>
|
||||||
<TrackListItem track={track} />
|
{tracks.map((track) => (
|
||||||
|
<TrackListItem key={track.id} track={track} />
|
||||||
|
))}
|
||||||
</Item.Group>
|
</Item.Group>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
@ -46,12 +48,13 @@ export default function HomePage() {
|
||||||
<Grid.Row>
|
<Grid.Row>
|
||||||
<Grid.Column width={8}>
|
<Grid.Column width={8}>
|
||||||
<Stats />
|
<Stats />
|
||||||
|
<MostRecentTrack />
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
<Grid.Column width={8}>
|
<Grid.Column width={8}>
|
||||||
<MostRecentTrack />
|
<RegionStats />
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
</Grid.Row>
|
</Grid.Row>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,7 @@ function LayerSidebar({
|
||||||
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},
|
||||||
} = mapConfig
|
} = mapConfig
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -50,6 +51,28 @@ function LayerSidebar({
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<List.Item>
|
||||||
|
<Checkbox
|
||||||
|
toggle
|
||||||
|
size="small"
|
||||||
|
id="obsRegions.show"
|
||||||
|
style={{float: 'right'}}
|
||||||
|
checked={showRegions}
|
||||||
|
onChange={() => setMapConfigFlag('obsRegions.show', !showRegions)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="obsRegions.show">
|
||||||
|
<Header as="h4">Regions</Header>
|
||||||
|
</label>
|
||||||
|
</List.Item>
|
||||||
|
{showRegions && (
|
||||||
|
<>
|
||||||
|
<List.Item>Color regions based on event count</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
<ColorMapLegend twoTicks map={[[0, "#00897B00"], [5000, "#00897BFF"]]} digits={0} />
|
||||||
|
</List.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Divider />
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
toggle
|
toggle
|
||||||
|
|
33
frontend/src/pages/MapPage/RegionInfo.tsx
Normal file
33
frontend/src/pages/MapPage/RegionInfo.tsx
Normal 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;
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import React, {useState, useCallback} from 'react'
|
import React, {useState, useCallback} from 'react'
|
||||||
|
import {createPortal} from 'react-dom'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import {Segment, Menu, Header, Label, Icon, Table} from 'semantic-ui-react'
|
import {Menu, Header, Label, Icon, Table, Button} from 'semantic-ui-react'
|
||||||
import {Layer, Source} from 'react-map-gl'
|
import {Layer, Source} from 'react-map-gl'
|
||||||
import {of, from, concat} from 'rxjs'
|
import {of, from, concat} from 'rxjs'
|
||||||
import {useObservable} from 'rxjs-hooks'
|
import {useObservable} from 'rxjs-hooks'
|
||||||
|
@ -110,7 +111,7 @@ function HistogramChart({bins, counts}) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoadInfo({clickLocation}) {
|
export default function RoadInfo({roadInfo: info, mapInfoPortal, onClose}) {
|
||||||
const [direction, setDirection] = useState('forwards')
|
const [direction, setDirection] = useState('forwards')
|
||||||
|
|
||||||
const onClickDirection = useCallback(
|
const onClickDirection = useCallback(
|
||||||
|
@ -122,72 +123,42 @@ export default function RoadInfo({clickLocation}) {
|
||||||
[setDirection]
|
[setDirection]
|
||||||
)
|
)
|
||||||
|
|
||||||
const info = useObservable(
|
const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1 // TODO: change based on left-hand/right-hand traffic
|
||||||
(_$, inputs$) =>
|
|
||||||
inputs$.pipe(
|
|
||||||
distinctUntilChanged(_.isEqual),
|
|
||||||
switchMap(([location]) =>
|
|
||||||
location
|
|
||||||
? concat(
|
|
||||||
of(null),
|
|
||||||
from(
|
|
||||||
api.get('/mapdetails/road', {
|
|
||||||
query: {
|
|
||||||
...location,
|
|
||||||
radius: 100,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: of(null)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
[clickLocation]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!clickLocation) {
|
const content = (
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = info == null
|
|
||||||
|
|
||||||
const offsetDirection = info?.road?.oneway ? 0 : direction === 'forwards' ? 1 : -1 // TODO: change based on left-hand/right-hand traffic
|
|
||||||
|
|
||||||
const content =
|
|
||||||
!loading && !info.road ? (
|
|
||||||
'No road found.'
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<Header as="h3">{loading ? '...' : info?.road.name || 'Unnamed way'}</Header>
|
<div className={styles.closeHeader}>
|
||||||
|
<Header as="h3">{info.road.name || 'Unnamed way'}</Header>
|
||||||
|
<Button primary icon onClick={onClose}><Icon name='close' /></Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{info?.road.zone && (
|
{info.road.zone && (
|
||||||
<Label size="small" color={ZONE_COLORS[info?.road.zone]}>
|
<Label size="small" color={ZONE_COLORS[info.road.zone]}>
|
||||||
{info?.road.zone}
|
{info.road.zone}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{info?.road.oneway && (
|
{info.road.oneway && (
|
||||||
<Label size="small" color="blue">
|
<Label size="small" color="blue">
|
||||||
<Icon name="long arrow alternate right" fitted /> oneway
|
<Icon name="long arrow alternate right" fitted /> oneway
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{info?.road.oneway ? null : (
|
{info.road.oneway ? null : (
|
||||||
<Menu size="tiny" fluid secondary>
|
<Menu size="tiny" pointing>
|
||||||
<Menu.Item header>Direction</Menu.Item>
|
<Menu.Item header>Direction</Menu.Item>
|
||||||
<Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
|
<Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
|
||||||
{getCardinalDirection(info?.forwards?.bearing)}
|
{getCardinalDirection(info.forwards?.bearing)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
|
<Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
|
||||||
{getCardinalDirection(info?.backwards?.bearing)}
|
{getCardinalDirection(info.backwards?.bearing)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{info?.[direction] && <RoadStatsTable data={info[direction]} />}
|
{info[direction] && <RoadStatsTable data={info[direction]} />}
|
||||||
|
|
||||||
{info?.[direction]?.distanceOvertaker?.histogram && (
|
{info[direction]?.distanceOvertaker?.histogram && (
|
||||||
<>
|
<>
|
||||||
<Header as="h5">Overtaker distance distribution</Header>
|
<Header as="h5">Overtaker distance distribution</Header>
|
||||||
<HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
|
<HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
|
||||||
|
@ -198,7 +169,7 @@ export default function RoadInfo({clickLocation}) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{info?.road && (
|
{info.road && (
|
||||||
<Source id="highlight" type="geojson" data={info.road.geometry}>
|
<Source id="highlight" type="geojson" data={info.road.geometry}>
|
||||||
<Layer
|
<Layer
|
||||||
id="route"
|
id="route"
|
||||||
|
@ -223,10 +194,11 @@ export default function RoadInfo({clickLocation}) {
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{content && (
|
{content && mapInfoPortal && (
|
||||||
|
createPortal(
|
||||||
<div className={styles.mapInfoBox}>
|
<div className={styles.mapInfoBox}>
|
||||||
<Segment loading={loading}>{content}</Segment>
|
{content}
|
||||||
</div>
|
</div>, mapInfoPortal))}
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,166 +1,257 @@
|
||||||
import React, {useState, useCallback, useMemo} from 'react'
|
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||||
import _ from 'lodash'
|
import _ from "lodash";
|
||||||
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 {Page, Map} from 'components'
|
import api from "api";
|
||||||
import {useConfig} from 'config'
|
import { Page, Map } from "components";
|
||||||
import {colorByDistance, colorByCount, reds} from 'mapstyles'
|
import { useConfig } from "config";
|
||||||
import {useMapConfig} from 'reducers/mapConfig'
|
import { colorByDistance, colorByCount, getRegionLayers } from "mapstyles";
|
||||||
|
import { useMapConfig } from "reducers/mapConfig";
|
||||||
|
|
||||||
import RoadInfo from './RoadInfo'
|
import RoadInfo from "./RoadInfo";
|
||||||
import LayerSidebar from './LayerSidebar'
|
import RegionInfo from "./RegionInfo";
|
||||||
import styles from './styles.module.less'
|
import LayerSidebar from "./LayerSidebar";
|
||||||
|
import styles from "./styles.module.less";
|
||||||
|
|
||||||
const untaggedRoadsLayer = {
|
const untaggedRoadsLayer = {
|
||||||
id: 'obs_roads_untagged',
|
id: "obs_roads_untagged",
|
||||||
type: 'line',
|
type: "line",
|
||||||
source: 'obs',
|
source: "obs",
|
||||||
'source-layer': 'obs_roads',
|
"source-layer": "obs_roads",
|
||||||
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
|
minzoom: 12,
|
||||||
|
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
|
||||||
layout: {
|
layout: {
|
||||||
'line-cap': 'round',
|
"line-cap": "round",
|
||||||
'line-join': 'round',
|
"line-join": "round",
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2],
|
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2],
|
||||||
'line-color': '#ABC',
|
"line-color": "#ABC",
|
||||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1],
|
// 'line-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1],
|
||||||
'line-offset': [
|
"line-offset": [
|
||||||
'interpolate',
|
"interpolate",
|
||||||
['exponential', 1.5],
|
["exponential", 1.5],
|
||||||
['zoom'],
|
["zoom"],
|
||||||
12,
|
12,
|
||||||
['get', 'offset_direction'],
|
["get", "offset_direction"],
|
||||||
19,
|
19,
|
||||||
['*', ['get', 'offset_direction'], 8],
|
["*", ["get", "offset_direction"], 8],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
minzoom: 12,
|
minzoom: 12,
|
||||||
}
|
};
|
||||||
|
|
||||||
const getRoadsLayer = (colorAttribute, maxCount) =>
|
const getRoadsLayer = (colorAttribute, maxCount) =>
|
||||||
produce(untaggedRoadsLayer, (draft) => {
|
produce(untaggedRoadsLayer, (draft) => {
|
||||||
draft.id = 'obs_roads_normal'
|
draft.id = "obs_roads_normal";
|
||||||
if (colorAttribute.endsWith('_count')) {
|
if (colorAttribute.endsWith("_count")) {
|
||||||
// delete draft.filter
|
// delete draft.filter
|
||||||
draft.filter = ['to-boolean', ['get', colorAttribute]]
|
draft.filter = ["to-boolean", ["get", colorAttribute]];
|
||||||
} else {
|
} else {
|
||||||
draft.filter = draft.filter[1] // remove '!'
|
draft.filter = draft.filter[1]; // remove '!'
|
||||||
}
|
}
|
||||||
draft.paint['line-width'][6] = 6 // scale bigger on zoom
|
draft.minzoom = 10;
|
||||||
draft.paint['line-color'] = colorAttribute.startsWith('distance_')
|
draft.paint["line-width"][6] = 6; // scale bigger on zoom
|
||||||
|
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
|
||||||
? colorByDistance(colorAttribute)
|
? colorByDistance(colorAttribute)
|
||||||
: colorAttribute.endsWith('_count')
|
: colorAttribute.endsWith("_count")
|
||||||
? colorByCount(colorAttribute, maxCount)
|
? colorByCount(colorAttribute, maxCount)
|
||||||
: '#DDD'
|
: "#DDD";
|
||||||
draft.paint['line-opacity'][3] = 12
|
// draft.paint['line-opacity'][3] = 12
|
||||||
draft.paint['line-opacity'][5] = 13
|
// draft.paint['line-opacity'][5] = 13
|
||||||
})
|
});
|
||||||
|
|
||||||
const getEventsLayer = () => ({
|
const getEventsLayer = () => ({
|
||||||
id: 'obs_events',
|
id: "obs_events",
|
||||||
type: 'circle',
|
type: "circle",
|
||||||
source: 'obs',
|
source: "obs",
|
||||||
'source-layer': 'obs_events',
|
"source-layer": "obs_events",
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
|
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8],
|
||||||
'circle-color': colorByDistance('distance_overtaker'),
|
"circle-color": colorByDistance("distance_overtaker"),
|
||||||
},
|
},
|
||||||
minzoom: 11,
|
minzoom: 11,
|
||||||
})
|
});
|
||||||
|
|
||||||
const getEventsTextLayer = () => ({
|
const getEventsTextLayer = () => ({
|
||||||
id: 'obs_events_text',
|
id: "obs_events_text",
|
||||||
type: 'symbol',
|
type: "symbol",
|
||||||
minzoom: 18,
|
minzoom: 18,
|
||||||
source: 'obs',
|
source: "obs",
|
||||||
'source-layer': 'obs_events',
|
"source-layer": "obs_events",
|
||||||
layout: {
|
layout: {
|
||||||
'text-field': [
|
"text-field": [
|
||||||
'number-format',
|
"number-format",
|
||||||
['get', 'distance_overtaker'],
|
["get", "distance_overtaker"],
|
||||||
{'min-fraction-digits': 2, 'max-fraction-digits': 2},
|
{ "min-fraction-digits": 2, "max-fraction-digits": 2 },
|
||||||
],
|
],
|
||||||
'text-allow-overlap': true,
|
"text-allow-overlap": true,
|
||||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
|
"text-font": ["Open Sans Bold", "Arial Unicode MS Regular"],
|
||||||
'text-size': 14,
|
"text-size": 14,
|
||||||
'text-keep-upright': false,
|
"text-keep-upright": false,
|
||||||
'text-anchor': 'left',
|
"text-anchor": "left",
|
||||||
'text-radial-offset': 1,
|
"text-radial-offset": 1,
|
||||||
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]],
|
"text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
|
||||||
'text-rotation-alignment': 'map',
|
"text-rotation-alignment": "map",
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'text-halo-color': 'rgba(255, 255, 255, 1)',
|
"text-halo-color": "rgba(255, 255, 255, 1)",
|
||||||
'text-halo-width': 1,
|
"text-halo-width": 1,
|
||||||
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
|
"text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
|
interface RegionInfo {
|
||||||
|
properties: {
|
||||||
|
admin_level: number;
|
||||||
|
name: string;
|
||||||
|
overtaking_event_count: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
interface ArrayStats {
|
||||||
|
statistics: {
|
||||||
|
count: number;
|
||||||
|
mean: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
median: number;
|
||||||
|
};
|
||||||
|
histogram: {
|
||||||
|
bins: number[];
|
||||||
|
counts: number[];
|
||||||
|
};
|
||||||
|
values: number[];
|
||||||
|
}
|
||||||
|
interface RoadDirectionInfo {
|
||||||
|
bearing: number;
|
||||||
|
distanceOvertaker: ArrayStats;
|
||||||
|
distanceStationary: ArrayStats;
|
||||||
|
speed: ArrayStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoadInfo {
|
||||||
|
road: {
|
||||||
|
way_id: number;
|
||||||
|
zone: "urban" | "rural" | null;
|
||||||
|
name: string;
|
||||||
|
directionality: -1 | 0 | 1;
|
||||||
|
oneway: boolean;
|
||||||
|
geometry: Object;
|
||||||
|
};
|
||||||
|
forwards: RoadDirectionInfo;
|
||||||
|
backwards: RoadDirectionInfo;
|
||||||
|
}
|
||||||
|
type Details =
|
||||||
|
| { type: "road"; road: Object }
|
||||||
|
| { type: "region"; region: RegionInfo };
|
||||||
|
|
||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
const {obsMapSource} = useConfig() || {}
|
const { obsMapSource } = useConfig() || {};
|
||||||
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
|
const [details, setDetails] = useState<null | Details>(null);
|
||||||
|
|
||||||
const mapConfig = useMapConfig()
|
const onCloseDetails = useCallback(() => setDetails(null), [setDetails]);
|
||||||
|
|
||||||
|
const mapConfig = useMapConfig();
|
||||||
|
|
||||||
|
const viewportRef = useRef();
|
||||||
|
const mapInfoPortal = useRef();
|
||||||
|
|
||||||
|
const onViewportChange = useCallback(
|
||||||
|
(viewport) => {
|
||||||
|
viewportRef.current = viewport;
|
||||||
|
},
|
||||||
|
[viewportRef]
|
||||||
|
);
|
||||||
|
|
||||||
const onClick = useCallback(
|
const onClick = useCallback(
|
||||||
(e) => {
|
async (e) => {
|
||||||
let node = e.target
|
// check if we clicked inside the mapInfoBox, if so, early exit
|
||||||
|
let node = e.target;
|
||||||
while (node) {
|
while (node) {
|
||||||
if (node?.classList?.contains(styles.mapInfoBox)) {
|
if (node?.classList?.contains(styles.mapInfoBox)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
node = node.parentNode
|
node = node.parentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]})
|
const { zoom } = viewportRef.current;
|
||||||
},
|
|
||||||
[setClickLocation]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [layerSidebar, setLayerSidebar] = useState(true)
|
if (zoom < 10) {
|
||||||
|
const clickedRegion = e.features?.find(
|
||||||
|
(f) => f.source === "obs" && f.sourceLayer === "obs_regions"
|
||||||
|
);
|
||||||
|
setDetails(
|
||||||
|
clickedRegion ? { type: "region", region: clickedRegion } : null
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const road = await api.get("/mapdetails/road", {
|
||||||
|
query: {
|
||||||
|
longitude: e.lngLat[0],
|
||||||
|
latitude: e.lngLat[1],
|
||||||
|
radius: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setDetails(road?.road ? { type: "road", road } : null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [layerSidebar, setLayerSidebar] = useState(true);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
obsRoads: {attribute, maxCount},
|
obsRoads: { attribute, maxCount },
|
||||||
} = mapConfig
|
} = mapConfig;
|
||||||
|
|
||||||
const layers = []
|
const layers = [];
|
||||||
|
|
||||||
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
|
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
|
||||||
layers.push(untaggedRoadsLayer)
|
layers.push(untaggedRoadsLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 eventsLayer = useMemo(() => getEventsLayer(), [])
|
const regionLayers = useMemo(() => getRegionLayers(), []);
|
||||||
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
|
if (mapConfig.obsRegions.show) {
|
||||||
|
layers.push(...regionLayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsLayer = useMemo(() => getEventsLayer(), []);
|
||||||
|
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
|
||||||
if (mapConfig.obsEvents.show) {
|
if (mapConfig.obsEvents.show) {
|
||||||
layers.push(eventsLayer)
|
layers.push(eventsLayer);
|
||||||
layers.push(eventsTextLayer)
|
layers.push(eventsTextLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!obsMapSource) {
|
if (!obsMapSource) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page fullScreen title="Map">
|
<Page fullScreen title="Map">
|
||||||
<div className={styles.mapContainer}>
|
<div className={styles.mapContainer} 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}>
|
<Map
|
||||||
|
viewportFromUrl
|
||||||
|
onClick={onClick}
|
||||||
|
onViewportChange={onViewportChange}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
left: 44,
|
left: 44,
|
||||||
top: 9,
|
top: 9,
|
||||||
}}
|
}}
|
||||||
|
@ -175,10 +266,24 @@ export default function MapPage() {
|
||||||
))}
|
))}
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
<RoadInfo {...{clickLocation}} />
|
{details?.type === "road" && details?.road?.road && (
|
||||||
|
<RoadInfo
|
||||||
|
roadInfo={details.road}
|
||||||
|
mapInfoPortal={mapInfoPortal.current}
|
||||||
|
onClose={onCloseDetails}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{details?.type === "region" && details?.region && (
|
||||||
|
<RegionInfo
|
||||||
|
region={details.region}
|
||||||
|
mapInfoPortal={mapInfoPortal.current}
|
||||||
|
onClose={onCloseDetails}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,11 +20,38 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapInfoBox {
|
.mapInfoBox {
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
max-height: 100%;
|
|
||||||
width: 36rem;
|
width: 36rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin: 20px;
|
border-left: 1px solid @borderColor;
|
||||||
|
background: white;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media @mobile {
|
||||||
|
.mapContainer {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - @menuHeight);
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapSidebar {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapInfoBox {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,9 @@ export type MapConfig = {
|
||||||
obsEvents: {
|
obsEvents: {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
};
|
};
|
||||||
|
obsRegions: {
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialState: MapConfig = {
|
export const initialState: MapConfig = {
|
||||||
|
@ -41,6 +44,9 @@ export const initialState: MapConfig = {
|
||||||
obsEvents: {
|
obsEvents: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
|
obsRegions: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type MapConfigAction = {
|
type MapConfigAction = {
|
||||||
|
|
|
@ -50,6 +50,9 @@ local MOTORWAY_TYPES = {
|
||||||
"motorway_link",
|
"motorway_link",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local ADMIN_LEVEL_MIN = 2
|
||||||
|
local ADMIN_LEVEL_MAX = 8
|
||||||
|
|
||||||
local ONEWAY_YES = {"yes", "true", "1"}
|
local ONEWAY_YES = {"yes", "true", "1"}
|
||||||
local ONEWAY_REVERSE = {"reverse", "-1"}
|
local ONEWAY_REVERSE = {"reverse", "-1"}
|
||||||
|
|
||||||
|
@ -61,6 +64,14 @@ local roads = osm2pgsql.define_way_table('road', {
|
||||||
{ column = 'oneway', type = 'bool' },
|
{ column = 'oneway', type = 'bool' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
local regions = osm2pgsql.define_relation_table('region', {
|
||||||
|
{ column = 'name', type = 'text' },
|
||||||
|
{ column = 'geometry', type = 'geometry' },
|
||||||
|
{ column = 'admin_level', type = 'int' },
|
||||||
|
{ column = 'tags', type = 'hstore' },
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
function osm2pgsql.process_way(object)
|
function osm2pgsql.process_way(object)
|
||||||
if object.tags.highway and contains(HIGHWAY_TYPES, object.tags.highway) then
|
if object.tags.highway and contains(HIGHWAY_TYPES, object.tags.highway) then
|
||||||
local tags = object.tags
|
local tags = object.tags
|
||||||
|
@ -112,3 +123,21 @@ function osm2pgsql.process_way(object)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function osm2pgsql.process_relation(object)
|
||||||
|
local admin_level = tonumber(object.tags.admin_level)
|
||||||
|
if object.tags.boundary == "administrative" and admin_level and admin_level >= ADMIN_LEVEL_MIN and admin_level <= ADMIN_LEVEL_MAX then
|
||||||
|
regions:add_row({
|
||||||
|
geometry = { create = 'area' },
|
||||||
|
name = object.tags.name,
|
||||||
|
admin_level = admin_level,
|
||||||
|
tags = object.tags,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function osm2pgsql.select_relation_members(relation)
|
||||||
|
if relation.tags.type == 'route' then
|
||||||
|
return { ways = osm2pgsql.way_member_ids(relation) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
|
@ -11,6 +11,8 @@ RETURNS TABLE(event_id bigint, geometry geometry, distance_overtaker float, dist
|
||||||
speed,
|
speed,
|
||||||
way_id::bigint as way_id
|
way_id::bigint as way_id
|
||||||
FROM overtaking_event
|
FROM overtaking_event
|
||||||
WHERE ST_Transform(overtaking_event.geometry, 3857) && bbox;
|
WHERE
|
||||||
|
zoom_level >= 10 AND
|
||||||
|
ST_Transform(overtaking_event.geometry, 3857) && bbox;
|
||||||
|
|
||||||
$$ LANGUAGE SQL IMMUTABLE;
|
$$ LANGUAGE SQL IMMUTABLE;
|
||||||
|
|
26
tile-generator/layers/obs_regions/layer.sql
Normal file
26
tile-generator/layers/obs_regions/layer.sql
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
DROP FUNCTION IF EXISTS layer_obs_regions(geometry, int);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION layer_obs_regions(bbox geometry, zoom_level int)
|
||||||
|
RETURNS TABLE(
|
||||||
|
region_id bigint,
|
||||||
|
geometry geometry,
|
||||||
|
name text,
|
||||||
|
admin_level int,
|
||||||
|
overtaking_event_count int
|
||||||
|
) AS $$
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
region.relation_id::bigint as region_id,
|
||||||
|
ST_SimplifyPreserveTopology(region.geometry, ZRes(zoom_level + 2)) as geometry,
|
||||||
|
region.name as name,
|
||||||
|
region.admin_level as admin_level,
|
||||||
|
count(overtaking_event.id)::int as overtaking_event_count
|
||||||
|
FROM region
|
||||||
|
LEFT JOIN overtaking_event on ST_Within(ST_Transform(overtaking_event.geometry, 3857), region.geometry)
|
||||||
|
WHERE
|
||||||
|
zoom_level >= 4 AND
|
||||||
|
zoom_level <= 12 AND
|
||||||
|
ST_Transform(region.geometry, 3857) && bbox
|
||||||
|
GROUP BY region.relation_id, region.name, region.geometry, region.admin_level
|
||||||
|
|
||||||
|
$$ LANGUAGE SQL IMMUTABLE;
|
23
tile-generator/layers/obs_regions/obs_regions.yaml
Normal file
23
tile-generator/layers/obs_regions/obs_regions.yaml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
layer:
|
||||||
|
id: "obs_regions"
|
||||||
|
description: |
|
||||||
|
Statistics on administrative boundary areas ("regions")
|
||||||
|
buffer_size: 4
|
||||||
|
fields:
|
||||||
|
overtaking_event_count: |
|
||||||
|
Number of overtaking events.
|
||||||
|
name: |
|
||||||
|
Name of the region
|
||||||
|
admin_level: |
|
||||||
|
Administrative level of the boundary, as tagged in OpenStreetMap
|
||||||
|
defaults:
|
||||||
|
srs: EPSG:3785
|
||||||
|
datasource:
|
||||||
|
srid: 3857
|
||||||
|
geometry_field: geometry
|
||||||
|
key_field: region_id
|
||||||
|
key_field_as_attribute: no
|
||||||
|
query: (SELECT region_id, geometry, name, admin_level, overtaking_event_count FROM layer_obs_regions(!bbox!, z(!scale_denominator!))) AS t
|
||||||
|
|
||||||
|
schema:
|
||||||
|
- ./layer.sql
|
|
@ -32,7 +32,9 @@ RETURNS TABLE(
|
||||||
LEFT JOIN (VALUES (-1, TRUE), (1, FALSE), (0, FALSE)) AS r(dir, rev) ON (abs(r.dir) != road.directionality)
|
LEFT JOIN (VALUES (-1, TRUE), (1, FALSE), (0, FALSE)) AS r(dir, rev) ON (abs(r.dir) != road.directionality)
|
||||||
FULL OUTER JOIN overtaking_event ON (road.way_id = overtaking_event.way_id and (road.directionality != 0 or overtaking_event.direction_reversed = r.rev))
|
FULL OUTER JOIN overtaking_event ON (road.way_id = overtaking_event.way_id and (road.directionality != 0 or overtaking_event.direction_reversed = r.rev))
|
||||||
-- WHERE road.name = 'Merzhauser Straße'
|
-- WHERE road.name = 'Merzhauser Straße'
|
||||||
WHERE road.geometry && bbox
|
WHERE
|
||||||
|
zoom_level >= 10 AND
|
||||||
|
road.geometry && bbox
|
||||||
GROUP BY road.name, road.way_id, road.geometry, road.directionality, r.dir, r.rev;
|
GROUP BY road.name, road.way_id, road.geometry, road.directionality, r.dir, r.rev;
|
||||||
|
|
||||||
$$ LANGUAGE SQL IMMUTABLE;
|
$$ LANGUAGE SQL IMMUTABLE;
|
||||||
|
|
|
@ -3,6 +3,7 @@ tileset:
|
||||||
layers:
|
layers:
|
||||||
- layers/obs_events/obs_events.yaml
|
- layers/obs_events/obs_events.yaml
|
||||||
- layers/obs_roads/obs_roads.yaml
|
- layers/obs_roads/obs_roads.yaml
|
||||||
|
- layers/obs_regions/obs_regions.yaml
|
||||||
version: 0.6.2
|
version: 0.6.2
|
||||||
id: openbikesensor
|
id: openbikesensor
|
||||||
description: >
|
description: >
|
||||||
|
|
Loading…
Reference in a new issue