Compare commits

...

111 commits

Author SHA1 Message Date
Benjamin Yule Bädorf 710a37dac3
ci: add docker build and push action
All checks were successful
Build docker image / build-image (push) Successful in 6m29s
2024-02-22 17:35:35 +01:00
Paul Bienkowski fbf4d739f5 improve sql formatting and parameter passing 2024-02-18 10:13:41 +01:00
gluap ec669fa077
remove accidental submit of build version 2024-01-31 14:14:04 +01:00
gluap f7c0d48c22
do not make inaccessible ways cycleable (often used for tram tracks) 2024-01-31 14:04:15 +01:00
gluap 7bffc3a2b3
do not make inaccessible ways cycleable (often used for tram tracks) 2024-01-31 14:02:47 +01:00
gluap 241a43c4ad
fix for older postgres version 2024-01-26 00:19:59 +01:00
gluap 4940679201
Release: 0.8.1 2024-01-25 22:24:21 +01:00
gluap 6d35001f8d
merge exporting zones from "exports" page 2024-01-25 22:11:11 +01:00
gluap c41aa3f6a0
fix gpstime 2024-01-18 21:26:36 +01:00
Paul Bienkowski 278bcfc603 Format all JS/TS files 2023-08-26 10:26:13 +02:00
Paul Bienkowski ba7de7582d Add openbikesensor-transform-osm command to PATH 2023-08-17 13:46:20 +02:00
Alexandre Fauquette 4fa1d31f33
fix setup (#352) 2023-07-24 18:49:36 +02:00
gluap be6c736148
Add index to fix very slow rendering speed on low zoom levels. 2023-07-16 13:44:11 +02:00
gluap 1faaa6e7b4
nginx config example to cache tiles up to level 12 for a day. 2023-07-16 13:09:26 +02:00
gluap 4f44cc0e56
fix per-user-statistics 2023-07-08 14:05:23 +02:00
gluap 74c7e6444e
remove the non-working buttons from the device edit field. 2023-07-08 13:55:23 +02:00
gluap 4ebffc529f
default to reverse chronological sorting in the table (so users see their newest tracks on top). Fixes #350 2023-07-08 13:17:48 +02:00
gluap b9c9a61ca1
Release: 0.8.0 2023-06-22 22:01:29 +02:00
gluap f23ecc37e4
keep OpenSans (but fallback to noto as suggested in #347) 2023-06-22 21:58:27 +02:00
yyxcv d29c68432d
Fix/default font family (#347)
* remove 'Open Sans' references

* set default font-family

(use the font-family list that was set in index.css)

* small style tweaks to compensate effect of new base font-family

---------

Co-authored-by: yyxcv <yxcv@github.com>
2023-06-22 21:40:24 +02:00
yyxcv 5b91449749
fix: remove compass from map (#346)
(as it can not be rotated anyway)

Co-authored-by: yyxcv <yxcv@github.com>
Co-authored-by: gluap <44007906+gluap@users.noreply.github.com>
2023-06-22 20:31:29 +02:00
yyxcv 31d8390bdc fix: add scrollbar to mapSidebar on overflow 2023-06-22 20:28:43 +02:00
gluap 14c7f6e88b
Merge pull request #321 from openbikesensor/next
together with @opatut: finally. 🚀
2023-06-22 20:27:48 +02:00
gluap c897412f99
Merge pull request #343 from openbikesensor/next-document-upgrade
Next document upgrade
2023-06-19 20:41:36 +02:00
gluap 43765092c3
fix issue when displaying mapdetails (newer numpy deprecates numpy.bool, it was always an alias for bool apparently) 2023-06-10 19:37:34 +02:00
gluap 1a1232f2a7
up openmaptiles version (7.0 has been running on adfc-hessen for ages), add changelog. 2023-06-10 19:33:14 +02:00
gluap 4a87489b3f
documentation update after dancing the dance on obs.adfc-hessen.de 2023-06-10 17:45:56 +02:00
gluap 7dd6b68da8
fix percentage logging 2023-06-10 13:00:45 +02:00
gluap 0233045959
import in chunks to avoid smaller systems chocking 2023-06-10 12:56:44 +02:00
gluap b1cfd30da9
Merge pull request #341 from openbikesensor/next-semaphore-with-road-usage
Add export of road usage after review (without temporal filtering)
2023-05-29 13:22:16 +02:00
gluap da82303042
implement reviewer comments
- rename road_usage -> segments (as we're actually dealing with segments not with roads)
- use "select as" to ensure defined behaviour
- cleanup
2023-05-29 13:20:34 +02:00
gluap 497e1b739a
Add a first shot at upgrade documentation (WIP) 2023-05-25 22:24:05 +02:00
gluap d8e8d9aec1
Merge pull request #337 from openbikesensor/next-semaphore
Semaphore for tile requests
2023-05-25 21:51:07 +02:00
gluap e1763e0d3c
chore: upgrade python in containers. 2023-05-19 18:21:45 +02:00
gluap de029fa3d2
Merge branch 'next-semaphore' into next-semaphore-with-road-usage 2023-05-19 11:31:35 +02:00
gluap 0766467412
Merge branch 'next' into next-semaphore 2023-05-19 11:30:49 +02:00
gluap edc3c37abb
fix openid logout (wasn't working with old keycloak anyhow, but this works at least with new keycloak) 2023-05-19 11:29:15 +02:00
gluap 41ce56ac09
restore .env which was lost (probably due to gitignore) 2023-05-19 11:02:06 +02:00
gluap 7ff88aba15
Add the possibility to export road usage (without temporal filtering) 2023-05-18 13:51:05 +02:00
Paul Bienkowski a6811d4ba2 Also add semaphore for exports 2023-05-13 20:52:45 +02:00
Paul Bienkowski d3fbb113f3 Fix export bounding box ESPG id 2023-05-13 20:52:04 +02:00
Paul Bienkowski c249b1638e Add semaphore to limit simultaneous requests to tile data 2023-05-13 20:42:22 +02:00
Paul Bienkowski dd2e995720 generate proper filenames for bulk download, and use that as base folder inside tar 2023-05-13 20:22:16 +02:00
gluap 612a443dde make the tar strip the common parts of the directory structure. 2023-05-13 20:22:16 +02:00
Paul Bienkowski 1c53230b4d chore: fix log import 2023-05-13 20:22:16 +02:00
gluap 7e44f6d31d implement comments from review; slow fade-in of events 2023-05-13 20:22:16 +02:00
gluap a946ea53c9 Add bulk downloading 2023-05-13 20:22:16 +02:00
gluap fb3e8bf701
log10(0) is not defined - also: make region borders less intrusive for depending on number of events. As it was, it was hard to see the underlying geography. 2023-04-10 13:10:03 +02:00
gluap 56c9d2e455
don't die if no argv is supplied. 2023-04-10 12:42:24 +02:00
gluap c359d945da
the current version of region does not have that column - furthermore it now has an index for its id column already during creation. 2023-04-10 12:38:37 +02:00
gluap a66d96568e
fix downgrade step of event geometry transform. 2023-04-10 12:15:25 +02:00
gluap dc89db5471
fix typo in column name... 2023-04-10 12:05:15 +02:00
gluap 10fd02804e
exclude newest websockets versions that kill sanic. 2023-04-10 12:01:41 +02:00
gluap 5108eb02ce
Update scripts to latest version to avoid version conflicts 2023-04-01 21:43:35 +02:00
Paul Bienkowski 251be4a699 Fix bearings on road info, and German words for those 2023-04-01 20:19:59 +02:00
Paul Bienkowski dd72ed791f Use ESPG 3857 for all geometry columns 2023-04-01 16:44:47 +02:00
Paul Bienkowski ce8054b7ae Use NUTS for region import, not OSM 2023-03-31 21:06:59 +02:00
Paul Bienkowski 0d9ddf4884 Use logging in import_osm 2023-03-30 14:56:21 +02:00
Paul Bienkowski 6fb5dfe6de Fix index names on geometry tables and add way_id/relation_id indexes 2023-03-30 14:18:44 +02:00
gluap 10f6b0c0c9 make sure we generate the right geometry column type and stay with the types we had from osm2psql (and thus compatible with older installations).
I believe retrofitting the migrations is OK as these were overwritten by osm2pgsql in the past anyhow. Now at least we create the schema everyone is using already.
2023-03-30 14:18:44 +02:00
gluap 8ce5816f53 enable importing and dumping also regions - in the same epsg geometry as we had them from osm2pgsql 2023-03-30 14:18:44 +02:00
gluap dd912bcd0d np.float was a deprecated alias for the builtin float. To avoid this error in existing code, use float by itself. Doing this will not modify any behavior and is safe. If you specifically wanted the numpy scalar type, use np.float64 here. 2023-03-30 14:18:44 +02:00
gluap 39d90b3606 Update 35e7f1768f9b_create_table_road.py
Weird. Normally alembic should not make spelling errors :D
2023-03-30 14:18:44 +02:00
gluap e13bc759d7 Update Dockerfile
Remove roads_import.lua reference (file is already deleted)
2023-03-30 14:18:44 +02:00
Paul Bienkowski 0a18cda691 Remove osm2pgsql 2023-03-30 14:18:44 +02:00
Paul Bienkowski 761908a987 Move deployment readme to docs/ 2023-03-30 14:18:44 +02:00
Paul Bienkowski c4cc4a9078 Docs for new pipeline 2023-03-30 14:18:44 +02:00
Paul Bienkowski ac90d50239 Remove lean mode 2023-03-30 14:18:44 +02:00
Paul Bienkowski 59f074cb28 New import pipeline with a PBF conversion step 2023-03-30 14:18:44 +02:00
Paul Bienkowski 4c1c95e4ff Add chunk utility 2023-03-30 14:18:44 +02:00
Paul Bienkowski 69d7f64ead Add import_group columns for OSM data tables 2023-03-30 14:18:44 +02:00
Paul Bienkowski 276a2ddc69 Remove HSTORE tags column from region table 2023-03-30 14:18:44 +02:00
Paul Bienkowski de8d371b65 Include device count in stats 2023-03-30 14:18:26 +02:00
gluap cf8358d14b
fix the road_usage issue dennis found. 2023-03-26 22:34:20 +02:00
Dennis Boldt eda3bf2688 Make menu stackable on mobile 2023-03-26 19:09:29 +02:00
gluap df0466c6f1
as discussed with paul: This makes the region lookup smooth. 2023-03-26 16:46:23 +02:00
gluap 9882b2041f
update what can be updated without breaking stuff. 2023-03-26 14:15:57 +02:00
gluap a7566fb6b3
restrict to admin_level 6
Only compute the regions layer at the required admin level 6 (kreise/kreisfreie Städte)- we're not using other levels yet and this speeds it up notably.
2023-03-25 17:43:32 +01:00
gluap b6cf59a09d
fix requirements.txt
remove reduntdant version that breaks pip
```
 => ERROR [stage-2  5/18] RUN pip install -r requirements.txt                                                                                           2.2s
------
 > [stage-2  5/18] RUN pip install -r requirements.txt:
#0 1.825 ERROR: Invalid requirement: 'sqlalchemy[asyncio]~=1.4.39 <2.0' (from line 10 of requirements.txt)
#0 2.055 WARNING: You are using pip version 21.2.4; however, version 23.0.1 is available.
```
2023-03-21 20:10:06 +01:00
Paul Bienkowski 2f8e40db08
disable dependabot 2023-03-19 22:13:32 +01:00
gluap fa29deb397
Fix that bug sonarcloud is moaning about. 2023-03-12 23:30:52 +01:00
Paul Bienkowski 665816cc98 Fix map size when sidebar is open 2023-03-12 13:45:50 +01:00
Paul Bienkowski 0d44560830 Merge branch 'device-identifiers' into next 2023-03-12 13:38:42 +01:00
Paul Bienkowski 61b74e90fd wip:Build devices page 2023-03-12 13:37:51 +01:00
Paul Bienkowski 2c27a2c549 Pin sqlalchemy better 2023-03-12 13:13:46 +01:00
Paul Bienkowski 141460c79f Split settings page 2023-03-12 13:13:46 +01:00
Paul Bienkowski 4fe7d45dec Bulk update operations on tracks 2023-03-12 13:13:46 +01:00
Paul Bienkowski cbab83e6e3 Build awesome "My Tracks" table with filters and sorting 2023-03-12 13:13:46 +01:00
Paul Bienkowski 5a78d7eb38 Parse device identifiers and create UserDevice entries in database 2023-03-12 13:13:46 +01:00
Paul Bienkowski 56905fdf75 Install stream-zip 2023-03-12 13:13:46 +01:00
Paul Bienkowski 6c458a43f6 Raise maximum on track page limit 2023-03-12 13:09:36 +01:00
Paul Bienkowski 84ab957aa0 fix cors by implementing it ourselves 2023-03-12 13:09:36 +01:00
Paul Bienkowski ed272b4e4a Use TTY in development docker to get line-buffered prints 2023-03-12 13:09:36 +01:00
Paul Bienkowski b9aaf23e0a Clean up sanic logging 2023-03-12 13:09:36 +01:00
Paul Bienkowski 78dca1477c Fix naming of AUTO_RELOAD/AUTO_RESTART 2023-03-12 13:09:36 +01:00
Paul Bienkowski 215801f2b0 Regions: Fix migration order 2023-03-12 13:09:36 +01:00
Paul Bienkowski 6d71b88010 Translate region frontend 2023-03-12 12:57:05 +01:00
Paul Bienkowski e0070fc794 Merge branch 'administrative-area-import' into next 2023-03-12 12:44:09 +01:00
Paul Bienkowski 518bcd81ef Show regions on map page, and move on-click info panel into a proper sidebar 2023-03-12 12:43:08 +01:00
Paul Bienkowski 7ae4ebebb6 Show region stats on home page 2023-03-12 12:42:42 +01:00
Paul Bienkowski 382db5a11e Expose OBS map source for all zoom levels 2023-03-12 12:41:41 +01:00
Paul Bienkowski 3a97b07325 Add tile layer for regions with event count 2023-03-12 12:41:09 +01:00
Paul Bienkowski bea4174b37 Do not generate roads and events for tiles at low zoom levels 2023-03-12 12:40:57 +01:00
Paul Bienkowski 78561d5929 Add route to expose region stats 2023-03-12 12:40:06 +01:00
Paul Bienkowski 7e51976c06 Import regions from administrative boundaries 2023-03-12 12:39:50 +01:00
Paul Bienkowski ec53591ce0 Create Region table 2023-03-12 12:39:23 +01:00
gluap 9e80113089
Add an optionally-displayable "Terms and Conditions" link. 2023-03-11 16:49:42 +01:00
gluap e7b02b170e
Merge pull request #303 from cbiteau/ADD-FR-TRANSLATION
Add French translation
2023-03-11 14:35:24 +01:00
gluap 94d23adcd2
Merge pull request #304 from cbiteau/fix_parse_hash_for_negative_values
Make parseHash function working with negative values
2023-01-08 13:05:10 +01:00
Charly BITEAU d889abc798 Make parseHash function working with negative values 2023-01-07 12:56:17 +01:00
Charly BITEAU 1d2218b2df Add FR translation 2023-01-07 12:23:14 +01:00
116 changed files with 5043 additions and 2726 deletions

View file

@ -0,0 +1,20 @@
name: Build docker image
on: [push]
jobs:
build-image:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Login to Forgejo docker registry
uses: docker/login-action@v3.0.0
with:
registry: git.pub.solar
username: hakkonaut
password: ${{ secrets.GIT_AUTH_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5.1.0
with:
push: true
tags: git.pub.solar/pub-solar/obs-portal:latest

View file

@ -1,19 +0,0 @@
# see also https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically
version: 2
updates:
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "daily"
# Disable submodules for the time being - they seem to not respect the branch of scripts we're using.
# - package-ecosystem: "gitsubmodule"
# directory: "/"
# schedule:
# interval: "daily"
- package-ecosystem: "pip"
directory: "/api"
schedule:
interval: "daily"

View file

@ -1,5 +1,38 @@
# Changelog # Changelog
## 0.8.1
### Improvements
* The zone (urban/rural) is now also exported with the events GeoJson export.
### Bug Fixes
* Update to a current version of gpstime (python dependency) fix portal startup.
## 0.8.0
### Features
* Bulk actions on users owned tracks (reprocess, download, make private, make public, delete) (#269, #38)
* Easy sorting by device for "multi-device users" (e.g. group lending out OBSes)
* Region display at higher zoom levels to easily find interesting areas (#112)
* Export of road statistics on top of the already-existing event statistics (#341)
### Improvements
* Refactored database access to hopefully combat portal crashes (#337)
* New infrastructure for map imports that makes import of larger maps possible on small VMs (#334)
* Reference current postgres and postgis versions in docker-compose.yaml files (#286)
* Configurable terms-and-conditions link (#320)
* French translation by @cbiteau (#303)
### Bug Fixes
* Logout not working (#285)
* Duplicate road usage hashes (#335, #253)
* cannot import name .... (#338)
## 0.7.0 ## 0.7.0
### Features ### Features

View file

@ -1,44 +1,5 @@
# This dockerfile is for the API + Frontend production image # This dockerfile is for the API + Frontend production image
#############################################
# Build osm2pgsql AS builder
#############################################
# This image should be the same as final one, because of the lib versions
FROM python:3.9.7-bullseye as osm2pgsql-builder
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Europe/Berlin
ENV OSM2PGSQL_VERSION=1.5.1
# Dependencies
RUN apt-get update &&\
apt-get install -y \
make \
cmake \
g++ \
libboost-dev \
libboost-system-dev \
libboost-filesystem-dev \
libexpat1-dev \
zlib1g-dev \
libbz2-dev \
libpq-dev \
libproj-dev \
lua5.3 \
liblua5.3-dev \
git &&\
rm -rf /var/lib/apt/lists/*
# Clone & Build
RUN git clone --branch $OSM2PGSQL_VERSION https://github.com/openstreetmap/osm2pgsql.git &&\
cd osm2pgsql/ &&\
mkdir build &&\
cd build &&\
cmake .. &&\
make -j4 &&\
make install
############################################# #############################################
# Build the frontend AS builder # Build the frontend AS builder
############################################# #############################################
@ -60,7 +21,7 @@ RUN npm run build
# Build the API and add the built frontend to it # Build the API and add the built frontend to it
############################################# #############################################
FROM python:3.9.7-bullseye FROM python:3.11.3-bullseye
RUN apt-get update &&\ RUN apt-get update &&\
apt-get install -y \ apt-get install -y \
@ -93,11 +54,7 @@ ADD api/obs /opt/obs/api/obs/
ADD api/tools /opt/obs/api/tools/ ADD api/tools /opt/obs/api/tools/
RUN pip install -e /opt/obs/api/ RUN pip install -e /opt/obs/api/
ADD roads_import.lua /opt/obs/api/tools
ADD osm2pgsql.sh /opt/obs/api/tools
COPY --from=frontend-builder /opt/obs/frontend/build /opt/obs/frontend/build COPY --from=frontend-builder /opt/obs/frontend/build /opt/obs/frontend/build
COPY --from=osm2pgsql-builder /usr/local/bin/osm2pgsql /usr/local/bin/osm2pgsql
EXPOSE 3000 EXPOSE 3000

View file

@ -36,10 +36,11 @@ git submodule update --init --recursive
## Production setup ## Production setup
There is a guide for a deployment based on docker in the There is a guide for a deployment based on docker at
[deployment](deployment) folder. Lots of non-docker deployment strategy are [docs/production-deployment.md](docs/production-deployment.md). Lots of
possible, but they are not "officially" supported, so please do not expect the non-docker deployment strategies are possible, but they are not "officially"
authors of the software to assist in troubleshooting. supported, so please do not expect the authors of the software to assist in
troubleshooting.
This is a rather complex application, and it is expected that you know the This is a rather complex application, and it is expected that you know the
basics of deploying a modern web application securely onto a production server. basics of deploying a modern web application securely onto a production server.
@ -52,7 +53,8 @@ Please note that you will always need to install your own reverse proxy that
terminates TLS for you and handles certificates. We do not support TLS directly terminates TLS for you and handles certificates. We do not support TLS directly
in the application, instead, please use this prefered method. in the application, instead, please use this prefered method.
Upgrading and migrating is descrube Upgrading and migrating is described in [UPGRADING.md](./UPGRADING.md) for each
version.
### Migrating (Production) ### Migrating (Production)
@ -75,18 +77,6 @@ docker-compose run --rm api alembic upgrade head
docker-compose run --rm api tools/prepare_sql_tiles docker-compose run --rm api tools/prepare_sql_tiles
``` ```
docker-compose run --rm api alembic upgrade head
### Upgrading from v0.2 to v0.3
After v0.2 we switched the underlying technology of the API and the database.
We now have no more MongoDB, instead, everything has moved to the PostgreSQL
installation. For development setups, it is advised to just reset the whole
state (remove the `local` folder) and start fresh. For production upgrades,
please follow the relevant section in [`UPGRADING.md`](./UPGRADING.md).
## Development setup ## Development setup
We've moved the whole development setup into Docker to make it easy for We've moved the whole development setup into Docker to make it easy for
@ -101,7 +91,6 @@ Then clone the repository as described above.
### Configure Keycloak ### Configure Keycloak
Login will not be possible until you configure the keycloak realm correctly. Boot your keycloak instance: Login will not be possible until you configure the keycloak realm correctly. Boot your keycloak instance:
```bash ```bash
@ -164,7 +153,7 @@ You will need to re-run this command after updates, to migrate the database and
(re-)create the functions in the SQL database that are used when generating (re-)create the functions in the SQL database that are used when generating
vector tiles. vector tiles.
You should also import OpenStreetMap data now, see below for instructions. You should also [import OpenStreetMap data](docs/osm-import.md) now.
### Boot the application ### Boot the application
@ -190,48 +179,6 @@ docker-compose run --rm api alembic upgrade head
``` ```
## Import OpenStreetMap data
**Hint:** This step may be skipped if you are using [Lean mode](./docs/lean-mode.md).
You need to import road information from OpenStreetMap for the portal to work.
This information is stored in your PostgreSQL database and used when processing
tracks (instead of querying the Overpass API), as well as for vector tile
generation. The process applies to both development and production setups. For
development, you should choose a small area for testing, such as your local
county or city, to keep the amount of data small. For production use you have
to import the whole region you are serving.
* Install `osm2pgsql`.
* Download the area(s) you would like to import from [GeoFabrik](https://download.geofabrik.de).
* Import each file like this:
```bash
osm2pgsql --create --hstore --style roads_import.lua -O flex \
-H localhost -d obs -U obs -W \
path/to/downloaded/myarea-latest.osm.pbf
```
You might need to adjust the host, database and username (`-H`, `-d`, `-U`) to
your setup, and also provide the correct password when queried. For the
development setup the password is `obs`. For production, you might need to
expose the containers port and/or create a TCP tunnel, for example with SSH,
such that you can run the import from your local host and write to the remote
database.
The import process should take a few seconds to minutes, depending on the area
size. A whole country might even take one or more hours. You should probably
not try to import `planet.osm.pbf`.
You can run the process multiple times, with the same or different area files,
to import or update the data. However, for this to work, the actual [command
line arguments](https://osm2pgsql.org/doc/manual.html#running-osm2pgsql) are a
bit different each time, including when first importing, and the disk space
required is much higher.
Refer to the documentation of `osm2pgsql` for assistance. We are using "flex
mode", the provided script `roads_import.lua` describes the transformations
and extractions to perform on the original data.
## Troubleshooting ## Troubleshooting

View file

@ -1,9 +1,43 @@
# Upgrading # Upgrading
This document describes the general steps to upgrade between major changes. This document describes the general steps to upgrade between major changes.
Simple migrations, e.g. for adding schema changes, are not documented Simple migrations, e.g. for adding schema changes, are not documented
explicitly. Their general usage is described in the [README](./README.md) (for explicitly. Their general usage is described in the [README](./README.md) (for
development) and [deployment/README.md](deployment/README.md) (for production). development) and [docs/production-deployment.md](docs/production-deployment.md) (for production).
## 0.8.1
- Get the release in your source folder (``git pull; git checkout 0.8.0`` and update submodules ``git submodule update --recursive``)
- Rebuild images ``docker-compose build``
- No database upgrade is required, but tile functions need an update:
```bash
docker-compose run --rm portal tools/prepare_sql_tiles.py
```
- Start your portal and worker services. ``docker-compose up -d worker portal``
## 0.8.0
Upgrade to `0.7.x` first. See below for details. Then follow these steps:
> **Warning** The update includes a reprocessing of tracks after import. Depending on the number of tracks this can take a few hours. The portal is reachable during that time but events disappear and incrementally reappear during reimport.
> **Info** With this version the import process for OpenStreetMap data has changed: the [new process](docs/osm-import.md) is easier on resources and finally permits to import a full country on a low-end VM.
- Do your [usual backup](docs/production-deployment.md)
- get the release in your source folder (``git pull; git checkout 0.8.0`` and update submodules ``git submodule update --recursive``)
- Rebuild images ``docker-compose build``
- Stop your portal and worker services ``docker-compose stop worker portal``
- run upgrade
```bash
docker-compose run --rm portal tools/upgrade.py
```
this automatically does the following
- Migration of database schema using alembic.
- Upgrade of SQL tile schema to new schema.
- Import the nuts-regions from the web into the database.
- Trigger a re-import of all tracks.
- Start your portal and worker services. ``docker-compose up -d worker portal``
## 0.7.0 ## 0.7.0
@ -57,7 +91,7 @@ You can, but do not have to, reimport all tracks. This will generate a GPX file
for each track and allow the users to download those. If a GPX file has not yet for each track and allow the users to download those. If a GPX file has not yet
been created, the download will fail. To reimport all tracks, log in to your been created, the download will fail. To reimport all tracks, log in to your
PostgreSQL database (instructions are in [README.md](./README.md) for PostgreSQL database (instructions are in [README.md](./README.md) for
development and [deployment/README.md](./deployment/README.md) for production) development and [docs/production-deployment.md](./docs/production-deployment.md) for production)
and run: and run:
```sql ```sql
@ -77,7 +111,7 @@ Make sure your worker is running to process the queue.
`POSTGRES_MAX_OVERFLOW`. Check the example config for sane default values. `POSTGRES_MAX_OVERFLOW`. Check the example config for sane default values.
* Re-run `tools/prepare_sql_tiles.py` again (see README) * Re-run `tools/prepare_sql_tiles.py` again (see README)
* It has been made easier to import OSM data, check * It has been made easier to import OSM data, check
[deployment/README.md](deployment/README.md) for the sections "Download [docs/production-deployment.md](./docs/production-deployment.md) for the sections "Download
OpenStreetMap maps" and "Import OpenStreetMap data". You can now download OpenStreetMap maps" and "Import OpenStreetMap data". You can now download
multiple .pbf files and then import them at once, using the docker image multiple .pbf files and then import them at once, using the docker image
built with the `Dockerfile`. Alternatively, you can choose to enable [lean built with the `Dockerfile`. Alternatively, you can choose to enable [lean
@ -132,5 +166,5 @@ Make sure your worker is running to process the queue.
`export/users.json` into your realm, it will re-add all the users from the `export/users.json` into your realm, it will re-add all the users from the
old installation. You should delete the file and `export/` folder afterwards. old installation. You should delete the file and `export/` folder afterwards.
* Start `portal`. * Start `portal`.
* Consider configuring a worker service. See [deployment/README.md](deployment/README.md). * Consider configuring a worker service. See [docs/production-deployment.md](./docs/production-deployment.md).

View file

@ -1,4 +1,4 @@
FROM python:3.9.7-bullseye FROM python:3.11.3-bullseye
WORKDIR /opt/obs/api WORKDIR /opt/obs/api

View file

@ -2,9 +2,8 @@ HOST = "0.0.0.0"
PORT = 3000 PORT = 3000
DEBUG = True DEBUG = True
VERBOSE = False VERBOSE = False
AUTO_RESTART = True AUTO_RELOAD = True
SECRET = "!!!!!!!!!!!!CHANGE ME!!!!!!!!!!!!" SECRET = "!!!!!!!!!!!!CHANGE ME!!!!!!!!!!!!"
LEAN_MODE = False
POSTGRES_URL = "postgresql+asyncpg://obs:obs@postgres/obs" POSTGRES_URL = "postgresql+asyncpg://obs:obs@postgres/obs"
POSTGRES_POOL_SIZE = 20 POSTGRES_POOL_SIZE = 20
POSTGRES_MAX_OVERFLOW = 2 * POSTGRES_POOL_SIZE POSTGRES_MAX_OVERFLOW = 2 * POSTGRES_POOL_SIZE
@ -19,6 +18,7 @@ FRONTEND_DIR = None
FRONTEND_CONFIG = { FRONTEND_CONFIG = {
"imprintUrl": "https://example.com/imprint", "imprintUrl": "https://example.com/imprint",
"privacyPolicyUrl": "https://example.com/privacy", "privacyPolicyUrl": "https://example.com/privacy",
# "termsUrl": "https://example.com/terms", # Link is only shown when set
"mapHome": {"zoom": 6, "longitude": 10.2, "latitude": 51.3}, "mapHome": {"zoom": 6, "longitude": 10.2, "latitude": 51.3},
# "banner": {"text": "This is a development installation.", "style": "info"}, # "banner": {"text": "This is a development installation.", "style": "info"},
} }
@ -29,5 +29,7 @@ ADDITIONAL_CORS_ORIGINS = [
"http://localhost:8880/", # for maputnik on 8880 "http://localhost:8880/", # for maputnik on 8880
"http://localhost:8888/", # for maputnik on 8888 "http://localhost:8888/", # for maputnik on 8888
] ]
TILE_SEMAPHORE_SIZE = 4
EXPORT_SEMAPHORE_SIZE = 4
# vim: set ft=python : # vim: set ft=python :

View file

@ -5,12 +5,7 @@ PORT = 3000
# Extended log output, but slower # Extended log output, but slower
DEBUG = False DEBUG = False
VERBOSE = DEBUG VERBOSE = DEBUG
AUTO_RESTART = DEBUG AUTO_RELOAD = DEBUG
# Turn on lean mode to simplify the setup. Lots of features will be
# unavailable, but you will not need to manage OpenStreetMap data. Please make
# sure to configure the OBS_FACE_CACHE_DIR correctly for lean mode.
LEAN_MODE = False
# Required to encrypt or sign sessions, cookies, tokens, etc. # Required to encrypt or sign sessions, cookies, tokens, etc.
SECRET = "!!!<<<CHANGEME>>>!!!" SECRET = "!!!<<<CHANGEME>>>!!!"
@ -44,6 +39,7 @@ FRONTEND_DIR = "../frontend/build/"
FRONTEND_CONFIG = { FRONTEND_CONFIG = {
"imprintUrl": "https://example.com/imprint", "imprintUrl": "https://example.com/imprint",
"privacyPolicyUrl": "https://example.com/privacy", "privacyPolicyUrl": "https://example.com/privacy",
# "termsUrl": "https://example.com/user_terms_and_conditions", # Link is only shown when set
"mapHome": {"zoom": 6, "longitude": 10.2, "latitude": 51.3}, "mapHome": {"zoom": 6, "longitude": 10.2, "latitude": 51.3},
"banner": {"text": "This is a test installation.", "style": "warning"}, "banner": {"text": "This is a test installation.", "style": "warning"},
} }
@ -65,4 +61,13 @@ TILES_FILE = None
# default. Python list, or whitespace separated string. # default. Python list, or whitespace separated string.
ADDITIONAL_CORS_ORIGINS = None ADDITIONAL_CORS_ORIGINS = None
# How many asynchronous requests may be sent to the database to generate tile
# information. Should be less than POSTGRES_POOL_SIZE to leave some connections
# to the other features of the API ;)
TILE_SEMAPHORE_SIZE = 4
# How many asynchronous requests may generate exported data simultaneously.
# Keep this small.
EXPORT_SEMAPHORE_SIZE = 1
# vim: set ft=python : # vim: set ft=python :

View file

@ -22,13 +22,16 @@ def upgrade():
op.create_table( op.create_table(
"road", "road",
sa.Column( sa.Column(
"way_id", sa.BIGINT, autoincrement=True, primary_key=True, index=True "way_id", sa.BIGINT, primary_key=True, index=True, autoincrement=False
), ),
sa.Column("zone", dbtype("zone_type")), sa.Column("zone", dbtype("zone_type")),
sa.Column("name", sa.String), sa.Column("name", sa.Text),
sa.Column("geometry", dbtype("GEOMETRY"), index=True), sa.Column("geometry", dbtype("geometry(LINESTRING,3857)")),
sa.Column("directionality", sa.Integer), sa.Column("directionality", sa.Integer),
sa.Column("oenway", sa.Boolean), sa.Column("oneway", sa.Boolean),
)
op.execute(
"CREATE INDEX road_geometry_idx ON road USING GIST (geometry) WITH (FILLFACTOR=100);"
) )

View file

@ -0,0 +1,30 @@
"""transform overtaking_event geometry to 3857
Revision ID: 587e69ecb466
Revises: f4b0f460254d
Create Date: 2023-04-01 14:30:49.927505
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "587e69ecb466"
down_revision = "f4b0f460254d"
branch_labels = None
depends_on = None
def upgrade():
op.execute("UPDATE overtaking_event SET geometry = ST_Transform(geometry, 3857);")
op.execute(
"ALTER TABLE overtaking_event ALTER COLUMN geometry TYPE geometry(POINT, 3857);"
)
def downgrade():
op.execute(
"ALTER TABLE overtaking_event ALTER COLUMN geometry TYPE geometry;"
)
op.execute("UPDATE overtaking_event SET geometry = ST_Transform(geometry, 4326);")

View file

@ -0,0 +1,26 @@
"""add_overtaking_event_index
Revision ID: 7868aed76122
Revises: 587e69ecb466
Create Date: 2023-07-16 13:37:17.694079
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7868aed76122'
down_revision = '587e69ecb466'
branch_labels = None
depends_on = None
def upgrade():
op.execute("CREATE INDEX IF NOT EXISTS ix_overtaking_event_geometry ON overtaking_event using GIST(geometry);")
def downgrade():
op.drop_index("ix_overtaking_event_geometry")

View file

@ -0,0 +1,35 @@
"""create table region
Revision ID: a049e5eb24dd
Revises: a9627f63fbed
Create Date: 2022-04-02 21:28:43.124521
"""
from alembic import op
import sqlalchemy as sa
from migrations.utils import dbtype
# revision identifiers, used by Alembic.
revision = "a049e5eb24dd"
down_revision = "99a3d2eb08f9"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"region",
sa.Column("id", sa.String(24), primary_key=True, index=True),
sa.Column("name", sa.Text),
sa.Column("geometry", dbtype("GEOMETRY(GEOMETRY,3857)"), index=False),
sa.Column("admin_level", sa.Integer, index=True),
)
op.execute(
"CREATE INDEX region_geometry_idx ON region USING GIST (geometry) WITH (FILLFACTOR=100);"
)
def downgrade():
op.drop_table("region")

View file

@ -0,0 +1,39 @@
"""add import groups
Revision ID: b8b0fbae50a4
Revises: f7b21148126a
Create Date: 2023-03-26 09:41:36.621203
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b8b0fbae50a4"
down_revision = "f7b21148126a"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"road",
sa.Column("import_group", sa.String(), nullable=True),
)
op.add_column(
"region",
sa.Column("import_group", sa.String(), nullable=True),
)
# Set existing to "osm2pgsql"
road = sa.table("road", sa.column("import_group", sa.String))
op.execute(road.update().values(import_group="osm2pgsql"))
region = sa.table("region", sa.column("import_group", sa.String))
op.execute(region.update().values(import_group="osm2pgsql"))
def downgrade():
op.drop_column("road", "import_group")
op.drop_column("region", "import_group")

View file

@ -0,0 +1,24 @@
"""add osm id indexes
Revision ID: f4b0f460254d
Revises: b8b0fbae50a4
Create Date: 2023-03-30 10:56:22.066768
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f4b0f460254d"
down_revision = "b8b0fbae50a4"
branch_labels = None
depends_on = None
def upgrade():
op.execute("CREATE INDEX IF NOT EXISTS ix_road_way_id ON road (way_id);")
def downgrade():
op.drop_index("ix_road_way_id")

View file

@ -0,0 +1,41 @@
"""add user_device
Revision ID: f7b21148126a
Revises: a9627f63fbed
Create Date: 2022-09-15 17:48:06.764342
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f7b21148126a"
down_revision = "a049e5eb24dd"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"user_device",
sa.Column("id", sa.Integer, autoincrement=True, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("user.id", ondelete="CASCADE")),
sa.Column("identifier", sa.String, nullable=False),
sa.Column("display_name", sa.String, nullable=True),
sa.Index("user_id_identifier", "user_id", "identifier", unique=True),
)
op.add_column(
"track",
sa.Column(
"user_device_id",
sa.Integer,
sa.ForeignKey("user_device.id", ondelete="RESTRICT"),
nullable=True,
),
)
def downgrade():
op.drop_column("track", "user_device_id")
op.drop_table("user_device")

View file

@ -1 +1 @@
__version__ = "0.7.0" __version__ = "0.8.1"

View file

@ -1,3 +1,4 @@
import asyncio
import logging import logging
import re import re
@ -21,16 +22,60 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from obs.api.db import User, make_session, connect_db from obs.api.db import User, make_session, connect_db
from obs.api.cors import setup_options, add_cors_headers
from obs.api.utils import get_single_arg from obs.api.utils import get_single_arg
from sqlalchemy.util import asyncio
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class SanicAccessMessageFilter(logging.Filter):
"""
A filter that modifies the log message of a sanic.access log entry to
include useful information.
"""
def filter(self, record):
record.msg = f"{record.request} -> {record.status}"
return True
def configure_sanic_logging():
for logger_name in ["sanic.root", "sanic.access", "sanic.error"]:
logger = logging.getLogger(logger_name)
for handler in logger.handlers:
logger.removeHandler(handler)
logger = logging.getLogger("sanic.access")
for filter_ in logger.filters:
logger.removeFilter(filter_)
logger.addFilter(SanicAccessMessageFilter())
logging.getLogger("sanic.root").setLevel(logging.WARNING)
app = Sanic( app = Sanic(
"openbikesensor-api", "openbikesensor-api",
env_prefix="OBS_", env_prefix="OBS_",
log_config={},
) )
configure_sanic_logging()
app.config.update(
dict(
DEBUG=False,
VERBOSE=False,
AUTO_RELOAD=False,
POSTGRES_POOL_SIZE=20,
POSTGRES_MAX_OVERFLOW=40,
DEDICATED_WORKER=True,
FRONTEND_URL=None,
FRONTEND_HTTPS=True,
TILES_FILE=None,
TILE_SEMAPHORE_SIZE=4,
EXPORT_SEMAPHORE_SIZE=1,
)
)
# overwrite from defaults again
app.config.load_environment_vars("OBS_")
if isfile("./config.py"): if isfile("./config.py"):
app.update_config("./config.py") app.update_config("./config.py")
@ -59,6 +104,39 @@ class NoConnectionLostFilter(logging.Filter):
logging.getLogger("sanic.error").addFilter(NoConnectionLostFilter) logging.getLogger("sanic.error").addFilter(NoConnectionLostFilter)
def setup_cors(app):
frontend_url = app.config.get("FRONTEND_URL")
additional_origins = app.config.get("ADDITIONAL_CORS_ORIGINS")
if not frontend_url and not additional_origins:
# No CORS configured
return
origins = []
if frontend_url:
u = urlparse(frontend_url)
origins.append(f"{u.scheme}://{u.netloc}")
if isinstance(additional_origins, str):
origins += re.split(r"\s+", additional_origins)
elif isinstance(additional_origins, list):
origins += additional_origins
elif additional_origins is not None:
raise ValueError(
"invalid option type for ADDITIONAL_CORS_ORIGINS, must be list or space separated str"
)
app.ctx.cors_origins = origins
# Add OPTIONS handlers to any route that is missing it
app.register_listener(setup_options, "before_server_start")
# Fill in CORS headers
app.register_middleware(add_cors_headers, "response")
setup_cors(app)
@app.exception(SanicException, BaseException) @app.exception(SanicException, BaseException)
async def _handle_sanic_errors(_request, exception): async def _handle_sanic_errors(_request, exception):
if isinstance(exception, asyncio.CancelledError): if isinstance(exception, asyncio.CancelledError):
@ -95,39 +173,6 @@ def configure_paths(c):
configure_paths(app.config) configure_paths(app.config)
def setup_cors(app):
frontend_url = app.config.get("FRONTEND_URL")
additional_origins = app.config.get("ADDITIONAL_CORS_ORIGINS")
if not frontend_url and not additional_origins:
# No CORS configured
return
origins = []
if frontend_url:
u = urlparse(frontend_url)
origins.append(f"{u.scheme}://{u.netloc}")
if isinstance(additional_origins, str):
origins += re.split(r"\s+", additional_origins)
elif isinstance(additional_origins, list):
origins += additional_origins
elif additional_origins is not None:
raise ValueError(
"invalid option type for ADDITIONAL_CORS_ORIGINS, must be list or space separated str"
)
from sanic_cors import CORS
CORS(
app,
origins=origins,
supports_credentials=True,
expose_headers={"Content-Disposition"},
)
setup_cors(app)
# TODO: use a different interface, maybe backed by the PostgreSQL, to allow # TODO: use a different interface, maybe backed by the PostgreSQL, to allow
# scaling the API # scaling the API
Session(app, interface=InMemorySessionInterface()) Session(app, interface=InMemorySessionInterface())
@ -142,6 +187,12 @@ async def app_connect_db(app, loop):
) )
app.ctx._db_engine = await app.ctx._db_engine_ctx.__aenter__() app.ctx._db_engine = await app.ctx._db_engine_ctx.__aenter__()
if app.config.TILE_SEMAPHORE_SIZE:
app.ctx.tile_semaphore = asyncio.Semaphore(app.config.TILE_SEMAPHORE_SIZE)
if app.config.EXPORT_SEMAPHORE_SIZE:
app.ctx.export_semaphore = asyncio.Semaphore(app.config.EXPORT_SEMAPHORE_SIZE)
@app.after_server_stop @app.after_server_stop
async def app_disconnect_db(app, loop): async def app_disconnect_db(app, loop):
@ -294,9 +345,7 @@ from .routes import (
exports, exports,
) )
if not app.config.LEAN_MODE: from .routes import tiles, mapdetails
from .routes import tiles, mapdetails
from .routes import frontend from .routes import frontend

68
api/obs/api/cors.py Normal file
View file

@ -0,0 +1,68 @@
from collections import defaultdict
from typing import Dict, FrozenSet, Iterable
from sanic import Sanic, response
from sanic_routing.router import Route
def _add_cors_headers(request, response, methods: Iterable[str]) -> None:
allow_methods = list(set(methods))
if "OPTIONS" not in allow_methods:
allow_methods.append("OPTIONS")
origin = request.headers.get("origin")
if origin in request.app.ctx.cors_origins:
headers = {
"Access-Control-Allow-Methods": ",".join(allow_methods),
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Headers": (
"origin, content-type, accept, "
"authorization, x-xsrf-token, x-request-id"
),
"Access-Control-Expose-Headers": "content-disposition",
}
response.headers.extend(headers)
def add_cors_headers(request, response):
if request.method != "OPTIONS":
methods = [method for method in request.route.methods]
_add_cors_headers(request, response, methods)
def _compile_routes_needing_options(routes: Dict[str, Route]) -> Dict[str, FrozenSet]:
needs_options = defaultdict(list)
# This is 21.12 and later. You will need to change this for older versions.
for route in routes.values():
if "OPTIONS" not in route.methods:
needs_options[route.uri].extend(route.methods)
return {uri: frozenset(methods) for uri, methods in dict(needs_options).items()}
def _options_wrapper(handler, methods):
def wrapped_handler(request, *args, **kwargs):
nonlocal methods
return handler(request, methods)
return wrapped_handler
async def options_handler(request, methods) -> response.HTTPResponse:
resp = response.empty()
_add_cors_headers(request, resp, methods)
return resp
def setup_options(app: Sanic, _):
app.router.reset()
needs_options = _compile_routes_needing_options(app.router.routes_all)
for uri, methods in needs_options.items():
app.add_route(
_options_wrapper(options_handler, methods),
uri,
methods=["OPTIONS"],
)
app.router.finalize()

View file

@ -34,8 +34,9 @@ from sqlalchemy import (
select, select,
text, text,
literal, literal,
Text,
) )
from sqlalchemy.dialects.postgresql import HSTORE, UUID from sqlalchemy.dialects.postgresql import UUID
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -107,6 +108,28 @@ class Geometry(UserDefinedType):
return func.ST_AsGeoJSON(func.ST_Transform(col, 4326), type_=self) return func.ST_AsGeoJSON(func.ST_Transform(col, 4326), type_=self)
class LineString(UserDefinedType):
def get_col_spec(self):
return "geometry(LineString, 3857)"
def bind_expression(self, bindvalue):
return func.ST_GeomFromGeoJSON(bindvalue, type_=self)
def column_expression(self, col):
return func.ST_AsGeoJSON(func.ST_Transform(col, 4326), type_=self)
class GeometryGeometry(UserDefinedType):
def get_col_spec(self):
return "geometry(GEOMETRY, 3857)"
def bind_expression(self, bindvalue):
return func.ST_GeomFromGeoJSON(bindvalue, type_=self)
def column_expression(self, col):
return func.ST_AsGeoJSON(func.ST_Transform(col, 4326), type_=self)
class OvertakingEvent(Base): class OvertakingEvent(Base):
__tablename__ = "overtaking_event" __tablename__ = "overtaking_event"
__table_args__ = (Index("road_segment", "way_id", "direction_reversed"),) __table_args__ = (Index("road_segment", "way_id", "direction_reversed"),)
@ -134,12 +157,23 @@ class OvertakingEvent(Base):
class Road(Base): class Road(Base):
__tablename__ = "road" __tablename__ = "road"
way_id = Column(BIGINT, primary_key=True, index=True) way_id = Column(BIGINT, primary_key=True, index=True, autoincrement=False)
zone = Column(ZoneType) zone = Column(ZoneType)
name = Column(String) name = Column(Text)
geometry = Column(Geometry) geometry = Column(LineString)
directionality = Column(Integer) directionality = Column(Integer)
oneway = Column(Boolean) oneway = Column(Boolean)
import_group = Column(String)
__table_args__ = (
# We keep the index name as osm2pgsql created it, way back when.
Index(
"road_geometry_idx",
"geometry",
postgresql_using="gist",
postgresql_with={"fillfactor": 100},
),
)
def to_dict(self): def to_dict(self):
return { return {
@ -166,6 +200,12 @@ class RoadUsage(Base):
def __repr__(self): def __repr__(self):
return f"<RoadUsage {self.id}>" return f"<RoadUsage {self.id}>"
def __hash__(self):
return int(self.hex_hash, 16)
def __eq__(self, other):
return self.hex_hash == other.hex_hash
NOW = text("NOW()") NOW = text("NOW()")
@ -221,6 +261,12 @@ class Track(Base):
Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
) )
user_device_id = Column(
Integer,
ForeignKey("user_device.id", ondelete="RESTRICT"),
nullable=True,
)
# Statistics... maybe we'll drop some of this if we can easily compute them from SQL # Statistics... maybe we'll drop some of this if we can easily compute them from SQL
recorded_at = Column(DateTime) recorded_at = Column(DateTime)
recorded_until = Column(DateTime) recorded_until = Column(DateTime)
@ -253,6 +299,7 @@ class Track(Base):
if for_user_id is not None and for_user_id == self.author_id: if for_user_id is not None and for_user_id == self.author_id:
result["uploadedByUserAgent"] = self.uploaded_by_user_agent result["uploadedByUserAgent"] = self.uploaded_by_user_agent
result["originalFileName"] = self.original_file_name result["originalFileName"] = self.original_file_name
result["userDeviceId"] = self.user_device_id
if self.author: if self.author:
result["author"] = self.author.to_dict(for_user_id=for_user_id) result["author"] = self.author.to_dict(for_user_id=for_user_id)
@ -362,7 +409,7 @@ class User(Base):
api_key = Column(String) api_key = Column(String)
# This user can be matched by the email address from the auth service # This user can be matched by the email address from the auth service
# instead of having to match by `sub`. If a matching user logs in, the # instead of having to match by `sub`. If a matching user logs in, the
# `sub` is updated to the new sub and this flag is disabled. This is for # `sub` is updated to the new sub and this flag is disabled. This is for
# migrating *to* the external authentication scheme. # migrating *to* the external authentication scheme.
match_by_username_email = Column(Boolean, server_default=false()) match_by_username_email = Column(Boolean, server_default=false())
@ -409,6 +456,28 @@ class User(Base):
self.username = new_name self.username = new_name
class UserDevice(Base):
__tablename__ = "user_device"
id = Column(Integer, autoincrement=True, primary_key=True)
user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
identifier = Column(String, nullable=False)
display_name = Column(String, nullable=True)
__table_args__ = (
Index("user_id_identifier", "user_id", "identifier", unique=True),
)
def to_dict(self, for_user_id=None):
if for_user_id != self.user_id:
return {}
return {
"id": self.id,
"identifier": self.identifier,
"displayName": self.display_name,
}
class Comment(Base): class Comment(Base):
__tablename__ = "comment" __tablename__ = "comment"
id = Column(Integer, autoincrement=True, primary_key=True) id = Column(Integer, autoincrement=True, primary_key=True)
@ -432,6 +501,26 @@ class Comment(Base):
} }
class Region(Base):
__tablename__ = "region"
id = Column(String(24), primary_key=True, index=True)
name = Column(Text)
geometry = Column(GeometryGeometry)
admin_level = Column(Integer, index=True)
import_group = Column(String)
__table_args__ = (
# We keep the index name as osm2pgsql created it, way back when.
Index(
"region_geometry_idx",
"geometry",
postgresql_using="gist",
postgresql_with={"fillfactor": 100},
),
)
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", "Comment",
@ -458,6 +547,14 @@ Track.overtaking_events = relationship(
passive_deletes=True, passive_deletes=True,
) )
Track.user_device = relationship("UserDevice", back_populates="tracks")
UserDevice.tracks = relationship(
"Track",
order_by=Track.created_at,
back_populates="user_device",
passive_deletes=False,
)
# 0..4 Night, 4..10 Morning, 10..14 Noon, 14..18 Afternoon, 18..22 Evening, 22..00 Night # 0..4 Night, 4..10 Morning, 10..14 Noon, 14..18 Afternoon, 18..22 Evening, 22..00 Night
# Two hour intervals # Two hour intervals

View file

@ -8,7 +8,7 @@ import pytz
from os.path import join from os.path import join
from datetime import datetime from datetime import datetime
from sqlalchemy import delete, select from sqlalchemy import delete, func, select, and_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from obs.face.importer import ImportMeasurementsCsv from obs.face.importer import ImportMeasurementsCsv
@ -25,9 +25,9 @@ from obs.face.filter import (
RequiredFieldsFilter, RequiredFieldsFilter,
) )
from obs.face.osm import DataSource, DatabaseTileSource, OverpassTileSource from obs.face.osm import DataSource, DatabaseTileSource
from obs.api.db import OvertakingEvent, RoadUsage, Track, make_session from obs.api.db import OvertakingEvent, RoadUsage, Track, UserDevice, make_session
from obs.api.app import app from obs.api.app import app
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -39,12 +39,7 @@ def get_data_source():
mode, the OverpassTileSource is used to fetch data on demand. In normal mode, the OverpassTileSource is used to fetch data on demand. In normal
mode, the roads database is used. mode, the roads database is used.
""" """
if app.config.LEAN_MODE: return DataSource(DatabaseTileSource())
tile_source = OverpassTileSource(cache_dir=app.config.OBS_FACE_CACHE_DIR)
else:
tile_source = DatabaseTileSource()
return DataSource(tile_source)
async def process_tracks_loop(delay): async def process_tracks_loop(delay):
@ -144,10 +139,11 @@ async def process_track(session, track, data_source):
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
log.info("Annotating and filtering CSV file") log.info("Annotating and filtering CSV file")
imported_data, statistics = ImportMeasurementsCsv().read( imported_data, statistics, track_metadata = ImportMeasurementsCsv().read(
original_file_path, original_file_path,
user_id="dummy", # TODO: user username or id or nothing? user_id="dummy", # TODO: user username or id or nothing?
dataset_id=Track.slug, # TODO: use track id or slug or nothing? dataset_id=Track.slug, # TODO: use track id or slug or nothing?
return_metadata=True,
) )
annotator = AnnotateMeasurements( annotator = AnnotateMeasurements(
@ -217,6 +213,36 @@ async def process_track(session, track, data_source):
await clear_track_data(session, track) await clear_track_data(session, track)
await session.commit() await session.commit()
device_identifier = track_metadata.get("DeviceId")
if device_identifier:
if isinstance(device_identifier, list):
device_identifier = device_identifier[0]
log.info("Finding or creating device %s", device_identifier)
user_device = (
await session.execute(
select(UserDevice).where(
and_(
UserDevice.user_id == track.author_id,
UserDevice.identifier == device_identifier,
)
)
)
).scalar()
log.debug("user_device is %s", user_device)
if not user_device:
user_device = UserDevice(
user_id=track.author_id, identifier=device_identifier
)
log.debug("Create new device for this user")
session.add(user_device)
track.user_device = user_device
else:
log.info("No DeviceId in track metadata.")
log.info("Import events into database...") log.info("Import events into database...")
await import_overtaking_events(session, track, overtaking_events) await import_overtaking_events(session, track, overtaking_events)
@ -280,11 +306,16 @@ async def import_overtaking_events(session, track, overtaking_events):
hex_hash=hex_hash, hex_hash=hex_hash,
way_id=m.get("OSM_way_id"), way_id=m.get("OSM_way_id"),
direction_reversed=m.get("OSM_way_orientation", 0) < 0, direction_reversed=m.get("OSM_way_orientation", 0) < 0,
geometry=json.dumps( geometry=func.ST_Transform(
{ func.ST_GeomFromGeoJSON(
"type": "Point", json.dumps(
"coordinates": [m["longitude"], m["latitude"]], {
} "type": "Point",
"coordinates": [m["longitude"], m["latitude"]],
}
)
),
3857,
), ),
latitude=m["latitude"], latitude=m["latitude"],
longitude=m["longitude"], longitude=m["longitude"],

View file

@ -3,15 +3,22 @@ from enum import Enum
from contextlib import contextmanager from contextlib import contextmanager
import zipfile import zipfile
import io import io
import re
import math
from sqlite3 import connect from sqlite3 import connect
import shapefile import shapefile
from obs.api.db import OvertakingEvent from obs.api.db import OvertakingEvent
from sqlalchemy import select, func from sqlalchemy import select, func, text
from sanic.response import raw from sanic.response import raw
from sanic.exceptions import InvalidUsage from sanic.exceptions import InvalidUsage
from obs.api.app import api, json as json_response from obs.api.app import api, json as json_response
from obs.api.utils import use_request_semaphore
import logging
log = logging.getLogger(__name__)
class ExportFormat(str, Enum): class ExportFormat(str, Enum):
@ -26,7 +33,7 @@ def parse_bounding_box(input_string):
func.ST_Point(left, bottom), func.ST_Point(left, bottom),
func.ST_Point(right, top), func.ST_Point(right, top),
), ),
3857, 4326,
) )
@ -38,11 +45,11 @@ PROJECTION_4326 = (
@contextmanager @contextmanager
def shapefile_zip(): def shapefile_zip(shape_type=shapefile.POINT, basename="events"):
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
shp, shx, dbf = (io.BytesIO() for _ in range(3)) shp, shx, dbf = (io.BytesIO() for _ in range(3))
writer = shapefile.Writer( writer = shapefile.Writer(
shp=shp, shx=shx, dbf=dbf, shapeType=shapefile.POINT, encoding="utf8" shp=shp, shx=shx, dbf=dbf, shapeType=shape_type, encoding="utf8"
) )
yield writer, zip_buffer yield writer, zip_buffer
@ -51,67 +58,204 @@ def shapefile_zip():
writer.close() writer.close()
zip_file = zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) zip_file = zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False)
zip_file.writestr("events.shp", shp.getbuffer()) zip_file.writestr(f"{basename}.shp", shp.getbuffer())
zip_file.writestr("events.shx", shx.getbuffer()) zip_file.writestr(f"{basename}.shx", shx.getbuffer())
zip_file.writestr("events.dbf", dbf.getbuffer()) zip_file.writestr(f"{basename}.dbf", dbf.getbuffer())
zip_file.writestr("events.prj", PROJECTION_4326) zip_file.writestr(f"{basename}.prj", PROJECTION_4326)
zip_file.close() zip_file.close()
@api.get(r"/export/events") @api.get(r"/export/events")
async def export_events(req): async def export_events(req):
bbox = req.ctx.get_single_arg( async with use_request_semaphore(req, "export_semaphore", timeout=30):
"bbox", default="-180,-90,180,90", convert=parse_bounding_box bbox = req.ctx.get_single_arg("bbox", default="-180,-90,180,90")
) assert re.match(r"(-?\d+\.?\d+,?){4}", bbox)
fmt = req.ctx.get_single_arg("fmt", convert=ExportFormat) bbox = list(map(float, bbox.split(",")))
events = await req.ctx.db.stream_scalars( fmt = req.ctx.get_single_arg("fmt", convert=ExportFormat)
select(OvertakingEvent).where(OvertakingEvent.geometry.bool_op("&&")(bbox))
)
if fmt == ExportFormat.SHAPEFILE: events = await req.ctx.db.stream(
with shapefile_zip() as (writer, zip_buffer): text(
writer.field("distance_overtaker", "N", decimal=4) """
writer.field("distance_stationary", "N", decimal=4) SELECT
writer.field("way_id", "N", decimal=0) ST_AsGeoJSON(ST_Transform(geometry, 4326)) AS geometry,
writer.field("direction", "N", decimal=0) distance_overtaker,
writer.field("course", "N", decimal=4) distance_stationary,
writer.field("speed", "N", decimal=4) way_id,
direction,
speed,
time_stamp,
course,
zone
FROM
layer_obs_events(
ST_Transform(ST_MakeEnvelope(:bbox0, :bbox1, :bbox2, :bbox3, 4326), 3857),
19,
NULL,
'1900-01-01'::timestamp,
'2100-01-01'::timestamp
)
"""
).bindparams(bbox0=bbox[0], bbox1=bbox[1], bbox2=bbox[2], bbox3=bbox[3])
)
if fmt == ExportFormat.SHAPEFILE:
with shapefile_zip(basename="events") as (writer, zip_buffer):
writer.field("distance_overtaker", "N", decimal=4)
writer.field("distance_stationary", "N", decimal=4)
writer.field("way_id", "N", decimal=0)
writer.field("direction", "N", decimal=0)
writer.field("course", "N", decimal=4)
writer.field("speed", "N", decimal=4)
writer.field("zone", "C")
async for event in events:
coords = json.loads(event.geometry)["coordinates"]
writer.point(*coords)
writer.record(
distance_overtaker=event.distance_overtaker,
distance_stationary=event.distance_stationary,
direction=event.direction,
way_id=event.way_id,
course=event.course,
speed=event.speed,
zone=event.zone
# "time"=event.time,
)
return raw(zip_buffer.getbuffer())
if fmt == ExportFormat.GEOJSON:
features = []
async for event in events: async for event in events:
writer.point(event.longitude, event.latitude) geom = json.loads(event.geometry)
writer.record( features.append(
distance_overtaker=event.distance_overtaker, {
distance_stationary=event.distance_stationary, "type": "Feature",
direction=-1 if event.direction_reversed else 1, "geometry": geom,
way_id=event.way_id, "properties": {
course=event.course, "distance_overtaker": event.distance_overtaker
speed=event.speed, if event.distance_overtaker is not None
# "time"=event.time, and not math.isnan(event.distance_overtaker)
else None,
"distance_stationary": event.distance_stationary
if event.distance_stationary is not None
and not math.isnan(event.distance_stationary)
else None,
"direction": event.direction
if event.direction is not None
and not math.isnan(event.direction)
else None,
"way_id": event.way_id,
"course": event.course
if event.course is not None and not math.isnan(event.course)
else None,
"speed": event.speed
if event.speed is not None and not math.isnan(event.speed)
else None,
"time": event.time_stamp,
"zone": event.zone,
},
}
) )
return raw(zip_buffer.getbuffer()) geojson = {"type": "FeatureCollection", "features": features}
return json_response(geojson)
if fmt == ExportFormat.GEOJSON: raise InvalidUsage("unknown export format")
features = []
async for event in events:
features.append(
{
"type": "Feature",
"geometry": json.loads(event.geometry),
"properties": {
"distance_overtaker": event.distance_overtaker,
"distance_stationary": event.distance_stationary,
"direction": -1 if event.direction_reversed else 1,
"way_id": event.way_id,
"course": event.course,
"speed": event.speed,
"time": event.time,
},
}
)
geojson = {"type": "FeatureCollection", "features": features}
return json_response(geojson)
raise InvalidUsage("unknown export format") @api.get(r"/export/segments")
async def export_segments(req):
async with use_request_semaphore(req, "export_semaphore", timeout=30):
bbox = req.ctx.get_single_arg("bbox", default="-180,-90,180,90")
assert re.match(r"(-?\d+\.?\d+,?){4}", bbox)
bbox = list(map(float, bbox.split(",")))
fmt = req.ctx.get_single_arg("fmt", convert=ExportFormat)
segments = await req.ctx.db.stream(
text(
"""
SELECT
ST_AsGeoJSON(ST_Transform(geometry, 4326)) AS geometry,
way_id,
distance_overtaker_mean,
distance_overtaker_min,
distance_overtaker_max,
distance_overtaker_median,
overtaking_event_count,
usage_count,
direction,
zone,
offset_direction,
distance_overtaker_array
FROM
layer_obs_roads(
ST_Transform(ST_MakeEnvelope(:bbox0, :bbox1, :bbox2, :bbox3, 4326), 3857),
11,
NULL,
'1900-01-01'::timestamp,
'2100-01-01'::timestamp
)
WHERE usage_count > 0
"""
).bindparams(bbox0=bbox[0], bbox1=bbox[1], bbox2=bbox[2], bbox3=bbox[3])
)
if fmt == ExportFormat.SHAPEFILE:
with shapefile_zip(shape_type=3, basename="segments") as (
writer,
zip_buffer,
):
writer.field("distance_overtaker_mean", "N", decimal=4)
writer.field("distance_overtaker_max", "N", decimal=4)
writer.field("distance_overtaker_min", "N", decimal=4)
writer.field("distance_overtaker_median", "N", decimal=4)
writer.field("overtaking_event_count", "N", decimal=4)
writer.field("usage_count", "N", decimal=4)
writer.field("way_id", "N", decimal=0)
writer.field("direction", "N", decimal=0)
writer.field("zone", "C")
async for segment in segments:
geom = json.loads(segment.st_asgeojson)
writer.line([geom["coordinates"]])
writer.record(
distance_overtaker_mean=segment.distance_overtaker_mean,
distance_overtaker_median=segment.distance_overtaker_median,
distance_overtaker_max=segment.distance_overtaker_max,
distance_overtaker_min=segment.distance_overtaker_min,
usage_count=segment.usage_count,
overtaking_event_count=segment.overtaking_event_count,
direction=segment.direction,
way_id=segment.way_id,
zone=segment.zone,
)
return raw(zip_buffer.getbuffer())
if fmt == ExportFormat.GEOJSON:
features = []
async for segment in segments:
features.append(
{
"type": "Feature",
"geometry": json.loads(segment.geometry),
"properties": {
"distance_overtaker_mean": segment.distance_overtaker_mean,
"distance_overtaker_max": segment.distance_overtaker_max,
"distance_overtaker_median": segment.distance_overtaker_median,
"overtaking_event_count": segment.overtaking_event_count,
"usage_count": segment.usage_count,
"distance_overtaker_array": segment.distance_overtaker_array,
"direction": segment.direction,
"way_id": segment.way_id,
"zone": segment.zone,
},
}
)
geojson = {"type": "FeatureCollection", "features": features}
return json_response(geojson)
raise InvalidUsage("unknown export format")

View file

@ -14,22 +14,18 @@ if app.config.FRONTEND_CONFIG:
**req.app.config.FRONTEND_CONFIG, **req.app.config.FRONTEND_CONFIG,
"apiUrl": f"{req.ctx.api_url}/api", "apiUrl": f"{req.ctx.api_url}/api",
"loginUrl": f"{req.ctx.api_url}/login", "loginUrl": f"{req.ctx.api_url}/login",
"obsMapSource": ( "obsMapSource": {
None "type": "vector",
if app.config.LEAN_MODE "tiles": [
else { req.ctx.api_url
"type": "vector", + req.app.url_for("tiles", zoom="000", x="111", y="222.pbf")
"tiles": [ .replace("000", "{z}")
req.ctx.api_url .replace("111", "{x}")
+ req.app.url_for("tiles", zoom="000", x="111", y="222.pbf") .replace("222", "{y}")
.replace("000", "{z}") ],
.replace("111", "{x}") "minzoom": 0,
.replace("222", "{y}") "maxzoom": 14,
], },
"minzoom": 12,
"maxzoom": 14,
}
),
} }
return response.json(result) return response.json(result)

View file

@ -170,4 +170,4 @@ async def logout(req):
auth_req = client.construct_EndSessionRequest(state=session["state"]) auth_req = client.construct_EndSessionRequest(state=session["state"])
logout_url = auth_req.request(client.end_session_endpoint) logout_url = auth_req.request(client.end_session_endpoint)
return redirect(logout_url + f"&redirect_uri={req.ctx.api_url}/logout") return redirect(logout_url + f"&post_logout_redirect_uri={req.ctx.api_url}/logout")

View file

@ -18,14 +18,16 @@ round_speed = partial(round_to, multiples=0.1)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def get_bearing(a, b):
def get_bearing(b, a):
# longitude, latitude # longitude, latitude
dL = b[0] - a[0] dL = b[0] - a[0]
X = numpy.cos(b[1]) * numpy.sin(dL) X = numpy.cos(b[1]) * numpy.sin(dL)
Y = numpy.cos(a[1]) * numpy.sin(b[1]) - numpy.sin(a[1]) * numpy.cos( Y = numpy.cos(a[1]) * numpy.sin(b[1]) - numpy.sin(a[1]) * numpy.cos(
b[1] b[1]
) * numpy.cos(dL) ) * numpy.cos(dL)
return numpy.arctan2(X, Y) return numpy.arctan2(Y, X) + 0.5 * math.pi
# Bins for histogram on overtaker distances. 0, 0.25, ... 2.25, infinity # Bins for histogram on overtaker distances. 0, 0.25, ... 2.25, infinity
DISTANCE_BINS = numpy.arange(0, 2.5, 0.25).tolist() + [float('inf')] DISTANCE_BINS = numpy.arange(0, 2.5, 0.25).tolist() + [float('inf')]
@ -82,11 +84,11 @@ async def mapdetails_road(req):
arrays = numpy.array(arrays).T arrays = numpy.array(arrays).T
if len(arrays) == 0: if len(arrays) == 0:
arrays = numpy.array([[], [], [], []], dtype=numpy.float) arrays = numpy.array([[], [], [], []], dtype=float)
data, mask = arrays[:-1], arrays[-1] data, mask = arrays[:-1], arrays[-1]
data = data.astype(numpy.float64) data = data.astype(numpy.float64)
mask = mask.astype(numpy.bool) mask = mask.astype(bool)
def partition(arr, cond): def partition(arr, cond):
return arr[:, cond], arr[:, ~cond] return arr[:, cond], arr[:, ~cond]

View file

@ -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 distinct, 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, UserDevice
from obs.api.utils import round_to from obs.api.utils import round_to
@ -45,7 +45,7 @@ async def stats(req):
# Only the user can look for their own stats, for now # Only the user can look for their own stats, for now
by_user = ( by_user = (
user is not None and req.ctx.user is not None and req.ctx.user.username == user user is not None and req.ctx.user is not None and req.ctx.user.id == int(user)
) )
if by_user: if by_user:
conditions.append(Track.author_id == req.ctx.user.id) conditions.append(Track.author_id == req.ctx.user.id)
@ -92,6 +92,14 @@ async def stats(req):
.where(track_condition) .where(track_condition)
) )
).scalar() ).scalar()
device_count = (
await req.ctx.db.execute(
select(func.count(distinct(UserDevice.id)))
.select_from(UserDevice)
.join(Track.user_device)
.where(track_condition)
)
).scalar()
result = { result = {
"numEvents": event_count, "numEvents": event_count,
@ -100,6 +108,7 @@ async def stats(req):
"trackDuration": round_to(track_duration or 0, TRACK_DURATION_ROUNDING), "trackDuration": round_to(track_duration or 0, TRACK_DURATION_ROUNDING),
"publicTrackCount": public_track_count, "publicTrackCount": public_track_count,
"trackCount": track_count, "trackCount": track_count,
"deviceCount": device_count,
} }
return json(result) return json(result)
@ -167,3 +176,31 @@ async def stats(req):
# }); # });
# }), # }),
# ); # );
@api.route("/stats/regions")
async def stats(req):
query = (
select(
[
Region.id,
Region.name,
func.count(OvertakingEvent.id).label("overtaking_event_count"),
]
)
.select_from(Region)
.join(
OvertakingEvent,
func.ST_Within(OvertakingEvent.geometry, Region.geometry),
)
.group_by(
Region.id,
Region.name,
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

@ -7,10 +7,10 @@ import dateutil.parser
from sanic.exceptions import Forbidden, InvalidUsage from sanic.exceptions import Forbidden, InvalidUsage
from sanic.response import raw from sanic.response import raw
from sqlalchemy import select, text from sqlalchemy import text
from sqlalchemy.sql.expression import table, column
from obs.api.app import app from obs.api.app import app
from obs.api.utils import use_request_semaphore
def get_tile(filename, zoom, x, y): def get_tile(filename, zoom, x, y):
@ -87,24 +87,25 @@ def get_filter_options(
@app.route(r"/tiles/<zoom:int>/<x:int>/<y:(\d+)\.pbf>") @app.route(r"/tiles/<zoom:int>/<x:int>/<y:(\d+)\.pbf>")
async def tiles(req, zoom: int, x: int, y: str): async def tiles(req, zoom: int, x: int, y: str):
if app.config.get("TILES_FILE"): async with use_request_semaphore(req, "tile_semaphore"):
tile = get_tile(req.app.config.TILES_FILE, int(zoom), int(x), int(y)) if app.config.get("TILES_FILE"):
tile = get_tile(req.app.config.TILES_FILE, int(zoom), int(x), int(y))
else: else:
user_id, start, end = get_filter_options(req) user_id, start, end = get_filter_options(req)
tile = await req.ctx.db.scalar( tile = await req.ctx.db.scalar(
text( text(
f"select data from getmvt(:zoom, :x, :y, :user_id, :min_time, :max_time) as b(data, key);" "select data from getmvt(:zoom, :x, :y, :user_id, :min_time, :max_time) as b(data, key);"
).bindparams( ).bindparams(
zoom=int(zoom), zoom=int(zoom),
x=int(x), x=int(x),
y=int(y), y=int(y),
user_id=user_id, user_id=user_id,
min_time=start, min_time=start,
max_time=end, max_time=end,
)
) )
)
gzip = "gzip" in req.headers["accept-encoding"] gzip = "gzip" in req.headers["accept-encoding"]

View file

@ -1,16 +1,18 @@
import logging import logging
import re import re
from datetime import date
from json import load as jsonload from json import load as jsonload
from os.path import join, exists, isfile from os.path import join, exists, isfile
from sqlalchemy import select, func from sanic.exceptions import InvalidUsage, NotFound, Forbidden
from sanic.response import file_stream, empty
from slugify import slugify
from sqlalchemy import select, func, and_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from obs.api.db import Track, User, Comment, DuplicateTrackFileError
from obs.api.app import api, require_auth, read_api_key, json from obs.api.app import api, require_auth, read_api_key, json
from obs.api.db import Track, Comment, DuplicateTrackFileError
from sanic.response import file_stream, empty from obs.api.utils import tar_of_tracks
from sanic.exceptions import InvalidUsage, NotFound, Forbidden
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -23,8 +25,8 @@ def normalize_user_agent(user_agent):
return m[0] if m else None return m[0] if m else None
async def _return_tracks(req, extend_query, limit, offset): async def _return_tracks(req, extend_query, limit, offset, order_by=None):
if limit <= 0 or limit > 100: if limit <= 0 or limit > 1000:
raise InvalidUsage("invalid limit") raise InvalidUsage("invalid limit")
if offset < 0: if offset < 0:
@ -39,7 +41,7 @@ async def _return_tracks(req, extend_query, limit, offset):
extend_query(select(Track).options(joinedload(Track.author))) extend_query(select(Track).options(joinedload(Track.author)))
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.order_by(Track.created_at.desc()) .order_by(order_by if order_by is not None else Track.created_at)
) )
tracks = (await req.ctx.db.execute(query)).scalars() tracks = (await req.ctx.db.execute(query)).scalars()
@ -76,16 +78,101 @@ async def get_tracks(req):
return await _return_tracks(req, extend_query, limit, offset) return await _return_tracks(req, extend_query, limit, offset)
def parse_boolean(s):
if s is None:
return None
s = s.lower()
if s in ("true", "1", "yes", "y", "t"):
return True
if s in ("false", "0", "no", "n", "f"):
return False
raise ValueError("invalid value for boolean")
@api.get("/tracks/feed") @api.get("/tracks/feed")
@require_auth @require_auth
async def get_feed(req): async def get_feed(req):
limit = req.ctx.get_single_arg("limit", default=20, convert=int) limit = req.ctx.get_single_arg("limit", default=20, convert=int)
offset = req.ctx.get_single_arg("offset", default=0, convert=int) offset = req.ctx.get_single_arg("offset", default=0, convert=int)
user_device_id = req.ctx.get_single_arg("user_device_id", default=None, convert=int)
order_by_columns = {
"recordedAt": Track.recorded_at,
"title": Track.title,
"visibility": Track.public,
"length": Track.length,
"duration": Track.duration,
"user_device_id": Track.user_device_id,
}
order_by = req.ctx.get_single_arg(
"order_by", default=None, convert=order_by_columns.get
)
reversed_ = req.ctx.get_single_arg("reversed", convert=parse_boolean, default=False)
if reversed_:
order_by = order_by.desc()
public = req.ctx.get_single_arg("public", convert=parse_boolean, default=None)
def extend_query(q): def extend_query(q):
return q.where(Track.author_id == req.ctx.user.id) q = q.where(Track.author_id == req.ctx.user.id)
return await _return_tracks(req, extend_query, limit, offset) if user_device_id is not None:
q = q.where(Track.user_device_id == user_device_id)
if public is not None:
q = q.where(Track.public == public)
return q
return await _return_tracks(req, extend_query, limit, offset, order_by)
@api.post("/tracks/bulk")
@require_auth
async def tracks_bulk_action(req):
body = req.json
action = body["action"]
track_slugs = body["tracks"]
if action not in ("delete", "makePublic", "makePrivate", "reprocess", "download"):
raise InvalidUsage("invalid action")
query = select(Track).where(
and_(Track.author_id == req.ctx.user.id, Track.slug.in_(track_slugs))
)
files = set()
for track in (await req.ctx.db.execute(query)).scalars():
if action == "delete":
await req.ctx.db.delete(track)
elif action == "makePublic":
if not track.public:
track.queue_processing()
track.public = True
elif action == "makePrivate":
if track.public:
track.queue_processing()
track.public = False
elif action == "reprocess":
track.queue_processing()
elif action == "download":
files.add(track.get_original_file_path(req.app.config))
await req.ctx.db.commit()
if action == "download":
username_slug = slugify(req.ctx.user.username, separator="-")
date_str = date.today().isoformat()
file_basename = f"tracks_{username_slug}_{date_str}"
await tar_of_tracks(req, files, file_basename)
return
return empty()
@api.post("/tracks") @api.post("/tracks")

View file

@ -1,9 +1,11 @@
import logging import logging
from sanic.response import json from sanic.response import json
from sanic.exceptions import InvalidUsage from sanic.exceptions import InvalidUsage, Forbidden, NotFound
from sqlalchemy import and_, select
from obs.api.app import api, require_auth from obs.api.app import api, require_auth
from obs.api.db import UserDevice
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -28,6 +30,48 @@ async def get_user(req):
return json(user_to_json(req.ctx.user) if req.ctx.user else None) return json(user_to_json(req.ctx.user) if req.ctx.user else None)
@api.get("/user/devices")
async def get_user_devices(req):
if not req.ctx.user:
raise Forbidden()
query = (
select(UserDevice)
.where(UserDevice.user_id == req.ctx.user.id)
.order_by(UserDevice.id)
)
devices = (await req.ctx.db.execute(query)).scalars()
return json([device.to_dict(req.ctx.user.id) for device in devices])
@api.put("/user/devices/<device_id:int>")
async def put_user_device(req, device_id):
if not req.ctx.user:
raise Forbidden()
body = req.json
query = (
select(UserDevice)
.where(and_(UserDevice.user_id == req.ctx.user.id, UserDevice.id == device_id))
.limit(1)
)
device = (await req.ctx.db.execute(query)).scalar()
if device is None:
raise NotFound()
new_name = body.get("displayName", "").strip()
if new_name and device.display_name != new_name:
device.display_name = new_name
await req.ctx.db.commit()
return json(device.to_dict())
@api.put("/user") @api.put("/user")
@require_auth @require_auth
async def put_user(req): async def put_user(req):

View file

@ -1,6 +1,15 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
import logging
from os.path import commonpath, join, relpath
import queue
import tarfile
import dateutil.parser import dateutil.parser
from sanic.exceptions import InvalidUsage from sanic.exceptions import InvalidUsage, ServiceUnavailable
log = logging.getLogger(__name__)
RAISE = object() RAISE = object()
@ -30,3 +39,124 @@ def round_to(value: float, multiples: float) -> float:
if value is None: if value is None:
return None return None
return round(value / multiples) * multiples return round(value / multiples) * multiples
def chunk_list(lst, n):
for s in range(0, len(lst), n):
yield lst[s : s + n]
class chunk:
def __init__(self, iterable, n):
self.iterable = iterable
self.n = n
def __iter__(self):
if isinstance(self.iterable, list):
yield from chunk_list(self.iterable, self.n)
return
it = iter(self.iterable)
while True:
current = []
try:
for _ in range(self.n):
current.append(next(it))
yield current
except StopIteration:
if current:
yield current
break
async def __aiter__(self):
if hasattr(self.iterable, "__iter__"):
for item in self:
yield item
return
it = self.iterable.__aiter__()
while True:
current = []
try:
for _ in range(self.n):
current.append(await it.__anext__())
yield current
except StopAsyncIteration:
if len(current):
yield current
break
async def tar_of_tracks(req, files, file_basename="tracks"):
response = await req.respond(
content_type="application/x-gtar",
headers={
"content-disposition": f'attachment; filename="{file_basename}.tar.bz2"'
},
)
helper = StreamerHelper(response)
tar = tarfile.open(name=None, fileobj=helper, mode="w|bz2", bufsize=256 * 512)
root = commonpath(list(files))
for fname in files:
log.info("Write file to tar: %s", fname)
with open(fname, "rb") as fobj:
tarinfo = tar.gettarinfo(fname)
tarinfo.name = join(file_basename, relpath(fname, root))
tar.addfile(tarinfo, fobj)
await helper.send_all()
tar.close()
await helper.send_all()
await response.eof()
class StreamerHelper:
def __init__(self, response):
self.response = response
self.towrite = queue.Queue()
def write(self, data):
self.towrite.put(data)
async def send_all(self):
while True:
try:
tosend = self.towrite.get(block=False)
await self.response.send(tosend)
except queue.Empty:
break
@asynccontextmanager
async def use_request_semaphore(req, semaphore_name, timeout=10):
"""
If configured, acquire a semaphore for the map tile request and release it
after the context has finished.
If the semaphore cannot be acquired within the timeout, issue a 503 Service
Unavailable error response that describes that the database is overloaded,
so users know what the problem is.
Operates as a noop when the tile semaphore is not enabled.
"""
semaphore = getattr(req.app.ctx, semaphore_name, None)
if semaphore is None:
yield
return
try:
await asyncio.wait_for(semaphore.acquire(), timeout)
try:
yield
finally:
semaphore.release()
except asyncio.TimeoutError:
raise ServiceUnavailable(
"Too many requests, database overloaded. Please retry later."
)

View file

@ -58,7 +58,7 @@ def main():
port=app.config.PORT, port=app.config.PORT,
debug=debug, debug=debug,
auto_reload=app.config.get("AUTO_RELOAD", debug), auto_reload=app.config.get("AUTO_RELOAD", debug),
# access_log=False, access_log=True,
) )

View file

@ -0,0 +1,191 @@
#!/usr/bin/env python3
import sys
import re
import msgpack
import osmium
import shapely.wkb as wkb
from shapely.ops import transform
HIGHWAY_TYPES = {
"trunk",
"primary",
"secondary",
"tertiary",
"unclassified",
"residential",
"trunk_link",
"primary_link",
"secondary_link",
"tertiary_link",
"living_street",
"service",
"track",
"road",
}
ZONE_TYPES = {
"urban",
"rural",
"motorway",
}
URBAN_TYPES = {
"residential",
"living_street",
"road",
}
MOTORWAY_TYPES = {
"motorway",
"motorway_link",
}
ADMIN_LEVEL_MIN = 2
ADMIN_LEVEL_MAX = 8
MINSPEED_RURAL = 60
ONEWAY_YES = {"yes", "true", "1"}
ONEWAY_REVERSE = {"reverse", "-1"}
def parse_number(tag):
if not tag:
return None
match = re.search(r"[0-9]+", tag)
if not match:
return None
digits = match.group(0)
try:
return int(digits)
except ValueError:
return None
def determine_zone(tags):
highway = tags.get("highway")
zone = tags.get("zone:traffic")
if zone is not None:
if "rural" in zone:
return "rural"
if "motorway" in zone:
return "motorway"
return "urban"
# From here on we are guessing based on other tags
if highway in URBAN_TYPES:
return "urban"
if highway in MOTORWAY_TYPES:
return "motorway"
maxspeed_source = tags.get("source:maxspeed")
if maxspeed_source and "rural" in maxspeed_source:
return "rural"
if maxspeed_source and "urban" in maxspeed_source:
return "urban"
for key in ["maxspeed", "maxspeed:forward", "maxspeed:backward"]:
maxspeed = parse_number(tags.get(key))
if maxspeed is not None and maxspeed > MINSPEED_RURAL:
return "rural"
# default to urban if we have no idea
return "urban"
def determine_direction(tags, zone):
if (
tags.get("oneway") in ONEWAY_YES
or tags.get("junction") == "roundabout"
or zone == "motorway"
):
return 1, True
if tags.get("oneway") in ONEWAY_REVERSE:
return -1, True
return 0, False
class StreamPacker:
def __init__(self, stream, *args, **kwargs):
self.stream = stream
self.packer = msgpack.Packer(*args, autoreset=False, **kwargs)
def _write_out(self):
if hasattr(self.packer, "getbuffer"):
chunk = self.packer.getbuffer()
else:
chunk = self.packer.bytes()
self.stream.write(chunk)
self.packer.reset()
def pack(self, *args, **kwargs):
self.packer.pack(*args, **kwargs)
self._write_out()
def pack_array_header(self, *args, **kwargs):
self.packer.pack_array_header(*args, **kwargs)
self._write_out()
def pack_map_header(self, *args, **kwargs):
self.packer.pack_map_header(*args, **kwargs)
self._write_out()
def pack_map_pairs(self, *args, **kwargs):
self.packer.pack_map_pairs(*args, **kwargs)
self._write_out()
# A global factory that creates WKB from a osmium geometry
wkbfab = osmium.geom.WKBFactory()
from pyproj import Transformer
project = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True).transform
class OSMHandler(osmium.SimpleHandler):
def __init__(self, packer):
self.packer = packer
super().__init__()
def way(self, way):
tags = way.tags
highway = tags.get("highway")
if not highway or highway not in HIGHWAY_TYPES:
return
access = tags.get("access", None)
bicycle = tags.get("bicycle", None)
if access == "no" and bicycle not in ["designated", "yes", "permissive", "destination"]:
return
zone = determine_zone(tags)
directionality, oneway = determine_direction(tags, zone)
name = tags.get("name")
geometry = wkb.loads(wkbfab.create_linestring(way), hex=True)
geometry = transform(project, geometry)
geometry = wkb.dumps(geometry)
self.packer.pack(
[b"\x01", way.id, name, zone, directionality, oneway, geometry]
)
def main():
with open(sys.argv[2], "wb") as fout:
packer = StreamPacker(fout)
osmhandler = OSMHandler(packer)
osmhandler.apply_file(sys.argv[1], locations=True)
if __name__ == "__main__":
main()

View file

@ -1,14 +1,22 @@
coloredlogs~=15.0.1 coloredlogs~=15.0.1
sanic~=22.6.0 sanic==22.6.2
oic~=1.3.0 oic~=1.5.0
sanic-session~=0.8.0 sanic-session~=0.8.0
sanic-cors~=2.0.1
python-slugify~=6.1.2 python-slugify~=6.1.2
motor~=3.0.0 motor~=3.1.1
pyyaml<6 pyyaml~=5.3.1
-e git+https://github.com/openmaptiles/openmaptiles-tools#egg=openmaptiles-tools -e git+https://github.com/openmaptiles/openmaptiles-tools#egg=openmaptiles-tools
sqlparse~=0.4.2 sqlparse~=0.4.3
sqlalchemy[asyncio]~=1.4.39 sqlalchemy[asyncio]~=1.4.46
asyncpg~=0.24.0 asyncpg~=0.27.0
pyshp~=2.3.1 pyshp~=2.3.1
alembic~=1.7.7 alembic~=1.9.4
stream-zip~=0.0.50
msgpack~=1.0.5
osmium~=3.6.0
psycopg~=3.1.8
shapely~=2.0.1
pyproj~=3.4.1
aiohttp~=3.8.1
# sanic requires websocets and chockes on >=10 in 2022.6.2
websockets<11

@ -1 +1 @@
Subproject commit 8e9395fd3cd0f1e83b4413546bc2d3cb0c726738 Subproject commit 664e4d606416417c0651ea1748d32dd36209be6a

View file

@ -11,23 +11,24 @@ setup(
package_data={}, package_data={},
install_requires=[ install_requires=[
"coloredlogs~=15.0.1", "coloredlogs~=15.0.1",
"sanic>=21.9.3,<22.7.0", "sanic==22.6.2",
"oic>=1.3.0, <2", "oic>=1.3.0, <2",
"sanic-session~=0.8.0", "sanic-session~=0.8.0",
"sanic-cors~=2.0.1",
"python-slugify>=5.0.2,<6.2.0", "python-slugify>=5.0.2,<6.2.0",
"motor>=2.5.1,<3.1.0", "motor>=2.5.1,<3.1.2",
"pyyaml<6", "pyyaml<6",
"sqlparse~=0.4.2", "sqlparse~=0.4.3",
"openmaptiles-tools", # install from git "openmaptiles-tools", # install from git
"pyshp>=2.2,<2.4", "pyshp>=2.2,<2.4",
"sqlalchemy[asyncio]~=1.4.25", "sqlalchemy[asyncio]~=1.4.46",
"asyncpg~=0.24.0", "asyncpg~=0.27.0",
"alembic~=1.7.7", "alembic~=1.9.4",
"stream-zip~=0.0.50",
], ],
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [
"openbikesensor-api=obs.bin.openbikesensor_api:main", "openbikesensor-api=obs.bin.openbikesensor_api:main",
"openbikesensor-transform-osm=obs.bin.openbikesensor_transform_osm:main",
] ]
}, },
) )

108
api/tools/import_osm.py Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
from dataclasses import dataclass
import asyncio
from os.path import basename, splitext
import sys
import logging
import msgpack
import psycopg
from obs.api.app import app
from obs.api.utils import chunk
log = logging.getLogger(__name__)
ROAD_BUFFER = 1000
AREA_BUFFER = 100
@dataclass
class Road:
way_id: int
name: str
zone: str
directionality: int
oneway: int
geometry: bytes
def read_file(filename):
"""
Reads a file iteratively, yielding
appear. Those may be mixed.
"""
with open(filename, "rb") as f:
unpacker = msgpack.Unpacker(f)
try:
while True:
type_id, *data = unpacker.unpack()
if type_id == b"\x01":
yield Road(*data)
except msgpack.OutOfData:
pass
async def import_osm(connection, filename, import_group=None):
if import_group is None:
import_group = splitext(basename(filename))[0]
# Pass 1: Find IDs only
road_ids = []
for item in read_file(filename):
road_ids.append(item.way_id)
async with connection.cursor() as cursor:
log.info("Pass 1: Delete previously imported data")
log.debug("Delete import group %s", import_group)
await cursor.execute(
"DELETE FROM road WHERE import_group = %s", (import_group,)
)
log.debug("Delete roads by way_id")
for ids in chunk(road_ids, 10000):
await cursor.execute("DELETE FROM road WHERE way_id = ANY(%s)", (ids,))
# Pass 2: Import
log.info("Pass 2: Import roads")
amount = 0
for items in chunk(read_file(filename), 10000):
amount += 10000
log.info(f"...{amount}/{len(road_ids)} ({100*amount/len(road_ids)}%)")
async with cursor.copy(
"COPY road (way_id, name, zone, directionality, oneway, geometry, import_group) FROM STDIN"
) as copy:
for item in items:
await copy.write_row(
(
item.way_id,
item.name,
item.zone,
item.directionality,
item.oneway,
bytes.hex(item.geometry),
import_group,
)
)
async def main():
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
url = app.config.POSTGRES_URL
url = url.replace("+asyncpg", "")
async with await psycopg.AsyncConnection.connect(url) as connection:
for filename in sys.argv[1:]:
log.debug("Loading file: %s", filename)
await import_osm(connection, filename)
if __name__ == "__main__":
asyncio.run(main())

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

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

30
api/tools/reimport_tracks.py Executable file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env python3
import logging
import asyncio
from sqlalchemy import text
from obs.api.app import app
from obs.api.db import connect_db, make_session
log = logging.getLogger(__name__)
async def main():
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
await reimport_tracks()
async def reimport_tracks():
async with connect_db(
app.config.POSTGRES_URL,
app.config.POSTGRES_POOL_SIZE,
app.config.POSTGRES_MAX_OVERFLOW,
):
async with make_session() as session:
await session.execute(text("UPDATE track SET processing_status = 'queued';"))
await session.commit()
if __name__ == "__main__":
asyncio.run(main())

6
api/tools/transform_osm.py Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env python3
from obs.bin.openbikesensor_transform_osm import main
if __name__ == "__main__":
main()

View file

@ -1,14 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging
import asyncio import asyncio
from alembic.config import Config import logging
from alembic import command
from os.path import join, dirname
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from prepare_sql_tiles import prepare_sql_tiles, _run from prepare_sql_tiles import prepare_sql_tiles, _run
from import_regions import main as import_nuts
from reimport_tracks import main as reimport_tracks
async def _migrate(): async def _migrate():
await _run("alembic upgrade head") await _run("alembic upgrade head")
@ -20,7 +21,11 @@ async def main():
await _migrate() await _migrate()
log.info("Preparing SQL tiles...") log.info("Preparing SQL tiles...")
await prepare_sql_tiles() await prepare_sql_tiles()
log.info("Upgraded") log.info("Importing nuts regions...")
await import_nuts()
log.info("Nuts regions imported, scheduling reimport of tracks")
await reimport_tracks()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,35 +1,30 @@
# Bind address of the server # Bind address of the server
#HOST = "127.0.0.1" # HOST = "127.0.0.1"
#PORT = 3000 # PORT = 3000
# Extended log output, but slower # Extended log output, but slower
DEBUG = False DEBUG = False
VERBOSE = DEBUG VERBOSE = DEBUG
AUTO_RESTART = DEBUG AUTO_RELOAD = DEBUG
# Turn on lean mode to simplify the setup. Lots of features will be
# unavailable, but you will not need to manage OpenStreetMap data. Please make
# sure to configure the OBS_FACE_CACHE_DIR correctly for lean mode.
LEAN_MODE = False
# Required to encrypt or sign sessions, cookies, tokens, etc. # Required to encrypt or sign sessions, cookies, tokens, etc.
#SECRET = "!!!<<<CHANGEME>>>!!!" # SECRET = "!!!<<<CHANGEME>>>!!!"
# Connection to the database # Connection to the database
#POSTGRES_URL = "postgresql+asyncpg://user:pass@host/dbname" # POSTGRES_URL = "postgresql+asyncpg://user:pass@host/dbname"
#POSTGRES_POOL_SIZE = 20 # POSTGRES_POOL_SIZE = 20
#POSTGRES_MAX_OVERFLOW = 2 * POSTGRES_POOL_SIZE # POSTGRES_MAX_OVERFLOW = 2 * POSTGRES_POOL_SIZE
# URL to the keycloak realm, as reachable by the API service. This is not # URL to the keycloak realm, as reachable by the API service. This is not
# necessarily its publicly reachable URL, keycloak advertises that iself. # necessarily its publicly reachable URL, keycloak advertises that iself.
#KEYCLOAK_URL = "http://localhost:1234/auth/realms/obs/" # KEYCLOAK_URL = "http://localhost:1234/auth/realms/obs/"
# Auth client credentials # Auth client credentials
#KEYCLOAK_CLIENT_ID = "portal" # KEYCLOAK_CLIENT_ID = "portal"
#KEYCLOAK_CLIENT_SECRET = "00000000-0000-0000-0000-000000000000" # KEYCLOAK_CLIENT_SECRET = "00000000-0000-0000-0000-000000000000"
# Whether the API should run the worker loop, or a dedicated worker is used # Whether the API should run the worker loop, or a dedicated worker is used
#DEDICATED_WORKER = True # DEDICATED_WORKER = True
# The root of the frontend. Needed for redirecting after login, and for CORS. # The root of the frontend. Needed for redirecting after login, and for CORS.
# Set to None if frontend is served by the API. # Set to None if frontend is served by the API.

View file

@ -0,0 +1,22 @@
events {}
http {
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=STATIC:10m
inactive=24h max_size=1g;
server {
location ~* ^/tiles/\d[012]?/[^?]+$ {
proxy_pass http://portal:3000;
proxy_set_header Host $host:3000;
proxy_buffering on;
proxy_cache_methods GET HEAD;
proxy_cache STATIC;
proxy_cache_valid 200 1d;
proxy_cache_use_stale error timeout invalid_header updating
http_500 http_502 http_503 http_504;
}
location / {
proxy_pass http://portal:3000;
proxy_set_header Host $host:3000;
}
}
}

View file

@ -14,7 +14,7 @@ services:
############################################################ ############################################################
postgres: postgres:
image: "openmaptiles/postgis:6.0" image: "openmaptiles/postgis:7.0"
environment: environment:
- POSTGRES_DB=${OBS_POSTGRES_DB} - POSTGRES_DB=${OBS_POSTGRES_DB}
- POSTGRES_USER=${OBS_POSTGRES_USER} - POSTGRES_USER=${OBS_POSTGRES_USER}
@ -136,7 +136,7 @@ services:
- "traefik.docker.network=gateway" - "traefik.docker.network=gateway"
postgres-keycloak: postgres-keycloak:
image: postgres:13.3 image: postgres:15
restart: always restart: always
networks: networks:
- backend - backend

View file

@ -8,7 +8,7 @@ version: '3'
services: services:
postgres: postgres:
image: "openmaptiles/postgis:6.0" image: "openmaptiles/postgis:7.0"
environment: environment:
POSTGRES_USER: obs POSTGRES_USER: obs
POSTGRES_PASSWORD: obs POSTGRES_PASSWORD: obs
@ -20,6 +20,7 @@ services:
api: api:
image: openbikesensor-api image: openbikesensor-api
tty: true
build: build:
context: ./api/ context: ./api/
dockerfile: Dockerfile dockerfile: Dockerfile
@ -35,6 +36,8 @@ services:
- ./tile-generator/data/:/tiles - ./tile-generator/data/:/tiles
- ./api/migrations:/opt/obs/api/migrations - ./api/migrations:/opt/obs/api/migrations
- ./api/alembic.ini:/opt/obs/api/alembic.ini - ./api/alembic.ini:/opt/obs/api/alembic.ini
- ./local/pbf:/pbf
- ./local/obsdata:/obsdata
depends_on: depends_on:
- postgres - postgres
- keycloak - keycloak
@ -46,6 +49,7 @@ services:
worker: worker:
image: openbikesensor-api image: openbikesensor-api
tty: true
build: build:
context: ./api/ context: ./api/
dockerfile: Dockerfile dockerfile: Dockerfile

View file

@ -1,57 +0,0 @@
# Lean mode
The application can be configured in "lean mode" through the `LEAN_MODE`
setting in `config.py`. A lean installation is easier to set up, as a few steps
can be skipped. However, the performance of the application will degrade in
lean mode, and lots of advanced features will not be available.
Lean mode is meant as an entrypoint to get started with collecting data,
without the hassle of importing and maintaining OpenStreetMap data.
## Disabled features in lean mode
* No map tiles are generated.
* The frontend will not show an overview map, only per-track maps.
* The `roads` database table is not used, neither for processing tracks, nor
for generating map tiles.
* The API will not generate auxiliary information for display on the
(nonexistent) map, such as per-road statistics.
## Switch to/from lean mode
To enable lean mode, set the following in your `config.py` (or in
`config.overrides.py`, especially in development setups):
```python
LEAN_MODE = True
```
To disable lean mode, set it to `False` instead.
For lean mode, it is important that the config variable `OBS_FACE_CACHE_DIR` is
properly set, or that you are happy with its default value of using
`$DATA_DIR/obs-face-cache`.
When turning off lean mode, make sure to fill your `roads` table properly, as
otherwise the track processing will not work. When turning on lean mode, you
may truncate the `roads` table to save space, but you don't need to, it simply
becomes unused.
## Benefits
* When using lean mode, you can skip the import of OpenStreetMap data during
setup, and you also do not need to keep it updated.
* People can already start uploading data and the data is also processed,
giving you as a maintainer more time to set up the full application, if you
want to.
## Drawbacks
* Lean mode is less performant when processing tracks.
* Lean mode track processing depends on the Overpass API data source, which may
be slow, unavailable, or rate limiting the requests, so processing may fail.
We use caching to prevent some issues, but as we depend on a third party
service here that is accessed for free and that generates a lot of server
load, we really can't ask for much. If you frequently run into issues, the
best bet is to manage OSM data yourself and turn off lean mode.
* Of course some features are missing.

103
docs/osm-import.md Normal file
View file

@ -0,0 +1,103 @@
# Importing OpenStreetMap data
The application requires a lot of data from the OpenStreetMap to work.
The required information is stored in the PostgreSQL database and used when
processing tracks, as well as for vector tile generation. The process applies
to both development and production setups. For development, you should choose a
small area for testing, such as your local county or city, to keep the amount
of data small. For production use you have to import the whole region you are
serving.
## General pipeline overview
1. Download OpenStreetMap data as one or more `.osm.pbf` files.
2. Transform this data to generate geometry data for all roads and regions, so
we don't need to look up nodes separately. This step requires a lot of CPU
and memory, so it can be done "offline" on a high power machine.
3. Import the transformed data into the PostgreSQL/PostGIS database.
## Community hosted transformed data
Since the first two steps are the same for everybody, the community will soon
provide a service where relatively up-to-date transformed data can be
downloaded for direct import. Stay tuned.
## Download data
[GeoFabrik](https://download.geofabrik.de) kindly hosts extracts of the
OpenStreetMap planet by region. Download all regions you're interested in from
there in `.osm.pbf` format, with the tool of your choice, e. g.:
```bash
wget -P local/pbf/ https://download.geofabrik.de/europe/germany/baden-wuerttemberg-latest.osm.pbf
```
## Transform data
To transform downloaded data, you can either use the docker image from a
development or production environment, or locally install the API into your
python environment. Then run the `api/tools/transform_osm.py` script on the data.
```bash
api/tools/transform_osm.py baden-wuerttemberg-latest.osm.pbf baden-wuerttemberg-latest.msgpack
```
In dockerized setups, make sure to mount your data somewhere in the container
and also mount a directory where the result can be written. The development
setup takes care of this, so you can use:
```bash
docker-compose run --rm api tools/transform_osm.py \
/pbf/baden-wuerttemberg-latest.osm.pbf /obsdata/baden-wuerttemberg-latest.msgpack
```
Repeat this command for every file you want to transform.
## Import transformed data
The command for importing looks like this:
```bash
api/tools/import_osm.py baden-wuerttemberg-latest.msgpack
```
This tool reads your application config from `config.py`, so set that up first
as if you were setting up your application.
In dockerized setups, make sure to mount your data somewhere in the container.
Again, the development setup takes care of this, so you can use:
```bash
docker-compose run --rm api tools/import_osm.py \
/obsdata/baden-wuerttemberg-latest.msgpack
```
The transform process should take a few seconds to minutes, depending on the area
size. You can run the process multiple times, with the same or different area
files, to import or update the data. You can update only one region and leave
the others as they are, or add more filenames to the command line to
bulk-import data.
## How this works
* The transformation is done with a python script that uses
[pyosmium](https://osmcode.org/pyosmium/) to read the `.osm.pbf` file. This
script then filters the data for only the required objects (such as road
segments and administrative areas), and extracts the interesting information
from those objects.
* The node geolocations are looked up to generate a geometry for each object.
This requires a lot of memory to run efficiently.
* The geometry is projected to [Web Mercator](https://epsg.io/3857) in this
step to avoid continous transformation when tiles are generated later. Most
operations will work fine in this projection. Projection is done with the
[pyproj](https://pypi.org/project/pyproj/) library.
* The output is written to a binary file in a very simple format using
[msgpack](https://github.com/msgpack/msgpack-python), which is way more
efficient that (Geo-)JSON for example. This format is stremable, so the
generated file is never fully written or read into memory.
* The import script reads the msgpack file and sends it to the database using
[psycopg](https://www.psycopg.org/). This is done because it supports
PostgreSQL's `COPY FROM` statement, which enables much faster writes to the
database that a traditionional `INSERT VALUES`. The file is streamed directly
to the database, so it is never read into memory.

View file

@ -55,12 +55,7 @@ git clone --recursive https://github.com/openbikesensor/portal source/
```bash ```bash
mkdir -p /opt/openbikesensor/config mkdir -p /opt/openbikesensor/config
cd /opt/openbikesensor/ cd /opt/openbikesensor/
cp -r source/deployment/config source/deployment/docker-compose.yaml source/deployment/.env .
cp source/deployment/examples/docker-compose.yaml docker-compose.yaml
cp source/deployment/examples/.env .env
cp source/deployment/examples/traefik.toml config/traefik.toml
cp source/deployment/examples/config.py config/config.py
``` ```
### Create a Docker network ### Create a Docker network
@ -224,18 +219,6 @@ docker-compose build portal
*Hint*: This may take up to 10 minutes. In the future, we will provide a prebuild image. *Hint*: This may take up to 10 minutes. In the future, we will provide a prebuild image.
#### Download OpenStreetMap maps
Download the area(s) you would like to import from
[GeoFabrik](https://download.geofabrik.de) into `data/pbf`, for example:
```bash
cd /opt/openbikesensor/
wget https://download.geofabrik.de/europe/germany/schleswig-holstein-latest.osm.pbf -P data/pbf
```
*Hint*: Start with a small region/city, since the import can take some hours for huge areas.
#### Prepare database #### Prepare database
Run the following scripts to prepare the database: Run the following scripts to prepare the database:
@ -248,13 +231,7 @@ For more details, see [README.md](../README.md) under "Prepare database".
#### Import OpenStreetMap data #### Import OpenStreetMap data
Run the following script, to import the OSM data: Follow [these instructions](./osm-import.md).
```
docker-compose run --rm portal tools/osm2pgsql.sh
```
For more details. see [README.md](../README.md) under "Import OpenStreetMap data".
#### Configure portal #### Configure portal
@ -320,7 +297,7 @@ You should see smth. like:
When you click on *My Tracks*, you should see it on a map. When you click on *My Tracks*, you should see it on a map.
#### Configre the map position #### Configure the map position
Open the tab *Map** an zoom to the desired position. The URL contains the corresponding GPS position, Open the tab *Map** an zoom to the desired position. The URL contains the corresponding GPS position,
for example: for example:
@ -341,10 +318,6 @@ docker-compose restart portal
The tab *Map* should be the selected map section now. The tab *Map* should be the selected map section now.
When you uploaded some tracks, you map should show a colors overlay on the streets. When you uploaded some tracks, you map should show a colors overlay on the streets.
#### Verify osm2pgsql
If you zoom in the tab *Map* at the imported region/city, you should see dark grey lines on the streets.
## Miscellaneous ## Miscellaneous
### Logs ### Logs

View file

@ -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
} }
} }

View file

@ -69,7 +69,6 @@
} }
.pageTitle a { .pageTitle a {
font-family: 'Open Sans Condensed';
font-weight: 600; font-weight: 600;
font-size: 18pt; font-size: 18pt;
@ -120,6 +119,15 @@
} }
} }
@media @mobile {
.menu.menu {
> :global(.ui.container) {
height: @menuHeightMobile;
align-items: stretch;
}
}
}
.banner { .banner {
padding: 8px; padding: 8px;
z-index: 100; z-index: 100;

View file

@ -6,7 +6,7 @@ import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
import {useObservable} from 'rxjs-hooks' import {useObservable} from 'rxjs-hooks'
import {from} from 'rxjs' import {from} from 'rxjs'
import {pluck} from 'rxjs/operators' import {pluck} from 'rxjs/operators'
import {Helmet} from "react-helmet"; import {Helmet} from 'react-helmet'
import {useTranslation} from 'react-i18next' import {useTranslation} from 'react-i18next'
import {useConfig} from 'config' import {useConfig} from 'config'
@ -14,6 +14,7 @@ import styles from './App.module.less'
import {AVAILABLE_LOCALES, setLocale} from 'i18n' import {AVAILABLE_LOCALES, setLocale} from 'i18n'
import { import {
AcknowledgementsPage,
ExportPage, ExportPage,
HomePage, HomePage,
LoginRedirectPage, LoginRedirectPage,
@ -25,6 +26,7 @@ import {
TrackPage, TrackPage,
TracksPage, TracksPage,
UploadPage, UploadPage,
MyTracksPage,
} from 'pages' } from 'pages'
import {Avatar, LoginButton} from 'components' import {Avatar, LoginButton} from 'components'
import api from 'api' import api from 'api'
@ -77,16 +79,16 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<title>OpenBikeSensor Portal</title> <title>OpenBikeSensor Portal</title>
</Helmet> </Helmet>
{config?.banner && <Banner {...config.banner} />} {config?.banner && <Banner {...config.banner} />}
<Menu className={styles.menu}> <Menu className={styles.menu} stackable>
<Container> <Container>
<Link to="/" component={MenuItemForLink} header className={styles.pageTitle}> <Link to="/" component={MenuItemForLink} header className={styles.pageTitle}>
OpenBikeSensor OpenBikeSensor
</Link> </Link>
{hasMap && ( {hasMap && (
<Link component={MenuItemForLink} to="/map" as="a"> <Link component={MenuItemForLink} to="/map" as="a">
{t('App.menu.map')} {t('App.menu.map')}
</Link> </Link>
)} )}
<Link component={MenuItemForLink} to="/tracks" as="a"> <Link component={MenuItemForLink} to="/tracks" as="a">
@ -105,8 +107,13 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
</Link> </Link>
<Dropdown item trigger={<Avatar user={login} className={styles.avatar} />}> <Dropdown item trigger={<Avatar user={login} className={styles.avatar} />}>
<Dropdown.Menu> <Dropdown.Menu>
<Link to="/upload" component={DropdownItemForLink} icon="cloud upload" text={t('App.menu.uploadTracks')} /> <Link
<Link to="/settings" component={DropdownItemForLink} icon="cog" text={t('App.menu.settings')}/> to="/upload"
component={DropdownItemForLink}
icon="cloud upload"
text={t('App.menu.uploadTracks')}
/>
<Link to="/settings" component={DropdownItemForLink} icon="cog" text={t('App.menu.settings')} />
<Dropdown.Divider /> <Dropdown.Divider />
<Link to="/logout" component={DropdownItemForLink} icon="sign-out" text={t('App.menu.logout')} /> <Link to="/logout" component={DropdownItemForLink} icon="sign-out" text={t('App.menu.logout')} />
</Dropdown.Menu> </Dropdown.Menu>
@ -125,14 +132,16 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Route path="/" exact> <Route path="/" exact>
<HomePage /> <HomePage />
</Route> </Route>
{hasMap && <Route path="/map" exact> {hasMap && (
<MapPage /> <Route path="/map" exact>
</Route>} <MapPage />
</Route>
)}
<Route path="/tracks" exact> <Route path="/tracks" exact>
<TracksPage /> <TracksPage />
</Route> </Route>
<Route path="/my/tracks" exact> <Route path="/my/tracks" exact>
<TracksPage privateTracks /> <MyTracksPage />
</Route> </Route>
<Route path={`/tracks/:slug`} exact> <Route path={`/tracks/:slug`} exact>
<TrackPage /> <TrackPage />
@ -143,6 +152,9 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Route path="/export" exact> <Route path="/export" exact>
<ExportPage /> <ExportPage />
</Route> </Route>
<Route path="/acknowledgements" exact>
<AcknowledgementsPage />
</Route>
<Route path="/redirect" exact> <Route path="/redirect" exact>
<LoginRedirectPage /> <LoginRedirectPage />
</Route> </Route>
@ -169,9 +181,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Grid columns={4} stackable> <Grid columns={4} stackable>
<Grid.Row> <Grid.Row>
<Grid.Column> <Grid.Column>
<Header as="h5"> <Header as="h5">{t('App.footer.aboutTheProject')}</Header>
{t('App.footer.aboutTheProject')}
</Header>
<List> <List>
<List.Item> <List.Item>
<a href="https://openbikesensor.org/" target="_blank" rel="noreferrer"> <a href="https://openbikesensor.org/" target="_blank" rel="noreferrer">
@ -182,9 +192,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
</Grid.Column> </Grid.Column>
<Grid.Column> <Grid.Column>
<Header as="h5"> <Header as="h5">{t('App.footer.getInvolved')}</Header>
{t('App.footer.getInvolved')}
</Header>
<List> <List>
<List.Item> <List.Item>
<a href="https://forum.openbikesensor.org/" target="_blank" rel="noreferrer"> <a href="https://forum.openbikesensor.org/" target="_blank" rel="noreferrer">
@ -205,9 +213,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
</Grid.Column> </Grid.Column>
<Grid.Column> <Grid.Column>
<Header as="h5"> <Header as="h5">{t('App.footer.thisInstallation')}</Header>
{t('App.footer.thisInstallation')}
</Header>
<List> <List>
<List.Item> <List.Item>
<a href={config?.privacyPolicyUrl} target="_blank" rel="noreferrer"> <a href={config?.privacyPolicyUrl} target="_blank" rel="noreferrer">
@ -219,6 +225,13 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
{t('App.footer.imprint')} {t('App.footer.imprint')}
</a> </a>
</List.Item> </List.Item>
{config?.termsUrl && (
<List.Item>
<a href={config?.termsUrl} target="_blank" rel="noreferrer">
{t('App.footer.terms')}
</a>
</List.Item>
)}
<List.Item> <List.Item>
<a <a
href={`https://github.com/openbikesensor/portal${ href={`https://github.com/openbikesensor/portal${
@ -236,7 +249,11 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Grid.Column> <Grid.Column>
<Header as="h5">{t('App.footer.changeLanguage')}</Header> <Header as="h5">{t('App.footer.changeLanguage')}</Header>
<List> <List>
{AVAILABLE_LOCALES.map(locale => <List.Item key={locale}><a onClick={() => setLocale(locale)}>{t(`locales.${locale}`)}</a></List.Item>)} {AVAILABLE_LOCALES.map((locale) => (
<List.Item key={locale}>
<a onClick={() => setLocale(locale)}>{t(`locales.${locale}`)}</a>
</List.Item>
))}
</List> </List>
</Grid.Column> </Grid.Column>
</Grid.Row> </Grid.Row>

View file

@ -1,42 +1,39 @@
import React from "react"; import React from 'react'
import { Comment } from "semantic-ui-react"; import {Comment} from 'semantic-ui-react'
import classnames from "classnames"; import classnames from 'classnames'
import "./styles.less"; import './styles.less'
function hashCode(s) { function hashCode(s) {
let hash = 0; let hash = 0
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {
hash = (hash << 5) - hash + s.charCodeAt(i); hash = (hash << 5) - hash + s.charCodeAt(i)
hash |= 0; hash |= 0
} }
return hash; return hash
} }
function getColor(s) { function getColor(s) {
const h = Math.floor(hashCode(s)) % 360; const h = Math.floor(hashCode(s)) % 360
return `hsl(${h}, 50%, 50%)`; return `hsl(${h}, 50%, 50%)`
} }
export default function Avatar({ user, className }) { export default function Avatar({user, className}) {
const { image, displayName } = user || {}; const {image, displayName} = user || {}
if (image) { if (image) {
return <Comment.Avatar src={image} className={className} />; return <Comment.Avatar src={image} className={className} />
} }
if (!displayName) { if (!displayName) {
return <div className={classnames(className, "avatar", "empty-avatar")} />; return <div className={classnames(className, 'avatar', 'empty-avatar')} />
} }
const color = getColor(displayName); const color = getColor(displayName)
return ( return (
<div <div className={classnames(className, 'avatar', 'text-avatar')} style={{background: color}}>
className={classnames(className, "avatar", "text-avatar")}
style={{ background: color }}
>
{displayName && <span>{displayName[0]}</span>} {displayName && <span>{displayName[0]}</span>}
</div> </div>
); )
} }

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react'
import ReactEChartsCore from 'echarts-for-react/lib/core'; import ReactEChartsCore from 'echarts-for-react/lib/core'
import * as echarts from 'echarts/core'; import * as echarts from 'echarts/core'
import { import {
// LineChart, // LineChart,
@ -26,7 +26,7 @@ import {
// ThemeRiverChart, // ThemeRiverChart,
// SunburstChart, // SunburstChart,
// CustomChart, // CustomChart,
} from 'echarts/charts'; } from 'echarts/charts'
// import components, all suffixed with Component // import components, all suffixed with Component
import { import {
@ -60,25 +60,18 @@ import {
// AriaComponent, // AriaComponent,
// TransformComponent, // TransformComponent,
DatasetComponent, DatasetComponent,
} from 'echarts/components'; } from 'echarts/components'
// Import renderer, note that introducing the CanvasRenderer or SVGRenderer is a required step // Import renderer, note that introducing the CanvasRenderer or SVGRenderer is a required step
import { import {
CanvasRenderer, CanvasRenderer,
// SVGRenderer, // SVGRenderer,
} from 'echarts/renderers'; } from 'echarts/renderers'
// Register the required components // Register the required components
echarts.use( echarts.use([TitleComponent, TooltipComponent, GridComponent, BarChart, CanvasRenderer])
[TitleComponent, TooltipComponent, GridComponent, BarChart, CanvasRenderer]
);
// The usage of ReactEChartsCore are same with above. // The usage of ReactEChartsCore are same with above.
export default function Chart(props) { export default function Chart(props) {
return <ReactEChartsCore return <ReactEChartsCore echarts={echarts} notMerge lazyUpdate {...props} />
echarts={echarts}
notMerge
lazyUpdate
{...props}
/>
} }

View file

@ -1,18 +1,18 @@
import React, {useMemo} from "react"; import React, {useMemo} from 'react'
type ColorMap = [number, string][]
import styles from './ColorMapLegend.module.less' import styles from './ColorMapLegend.module.less'
type ColorMap = [number, string][]
function* pairs(arr) { function* pairs(arr) {
for (let i = 1; i < arr.length; i++) { for (let i = 1; i < arr.length; i++) {
yield [arr[i - 1], arr[i]]; yield [arr[i - 1], arr[i]]
} }
} }
function* zip(...arrs) { function* zip(...arrs) {
const l = Math.min(...arrs.map(a => a.length)); const l = Math.min(...arrs.map((a) => a.length))
for (let i = 0; i < l; i++) { for (let i = 0; i < l; i++) {
yield arrs.map(a => a[i]); yield arrs.map((a) => a[i])
} }
} }
@ -25,10 +25,10 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
min -= buffer min -= buffer
max += buffer max += buffer
const normalizeValue = (v) => (v - min) / (max - min) const normalizeValue = (v) => (v - min) / (max - min)
const stopPairs = Array.from(pairs([min, ...stops, max])); const stopPairs = Array.from(pairs([min, ...stops, max]))
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, []); const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, [])
const gradientUrl = `url(#${gradientId})`; const gradientUrl = `url(#${gradientId})`
const parts = Array.from(zip(stopPairs, colors)) const parts = Array.from(zip(stopPairs, colors))
@ -38,11 +38,10 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
<defs> <defs>
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0"> <linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
{parts.map(([[left, right], color]) => ( {parts.map(([[left, right], color]) => (
<React.Fragment key={left}> <React.Fragment key={left}>
<stop offset={normalizeValue(left) * 100 + '%'} stopColor={color} /> <stop offset={normalizeValue(left) * 100 + '%'} stopColor={color} />
<stop offset={normalizeValue(right) * 100 + '%'} stopColor={color} /> <stop offset={normalizeValue(right) * 100 + '%'} stopColor={color} />
</React.Fragment> </React.Fragment>
))} ))}
</linearGradient> </linearGradient>
</defs> </defs>
@ -59,13 +58,21 @@ 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)
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, []); const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, [])
const gradientUrl = `url(#${gradientId})`; const gradientUrl = `url(#${gradientId})`
const tickValues = twoTicks ? [map[0], map[map.length-1]] : map const tickValues = twoTicks ? [map[0], map[map.length - 1]] : map
return ( return (
<div className={styles.colorMapLegend}> <div className={styles.colorMapLegend}>
<svg width="100%" height="20" version="1.1" xmlns="http://www.w3.org/2000/svg"> <svg width="100%" height="20" version="1.1" xmlns="http://www.w3.org/2000/svg">
@ -81,7 +88,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>

View file

@ -1,31 +1,31 @@
import React from "react"; import React from 'react'
import { Icon, Segment, Header, Button } from "semantic-ui-react"; import {Icon, Segment, Header, Button} from 'semantic-ui-react'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import { FileDrop } from "components"; import {FileDrop} from 'components'
export default function FileUploadField({ onSelect: onSelect_, multiple }) { export default function FileUploadField({onSelect: onSelect_, multiple}) {
const { t } = useTranslation(); const {t} = useTranslation()
const labelRef = React.useRef(); const labelRef = React.useRef()
const [labelRefState, setLabelRefState] = React.useState(); const [labelRefState, setLabelRefState] = React.useState()
const onSelect = multiple ? onSelect_ : (files) => onSelect_(files?.[0]); const onSelect = multiple ? onSelect_ : (files) => onSelect_(files?.[0])
React.useLayoutEffect( React.useLayoutEffect(
() => { () => {
setLabelRefState(labelRef.current); setLabelRefState(labelRef.current)
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[labelRef.current] [labelRef.current]
); )
function onChangeField(e) { function onChangeField(e) {
e.preventDefault?.(); e.preventDefault?.()
if (e.target.files && e.target.files.length) { if (e.target.files && e.target.files.length) {
onSelect(e.target.files); onSelect(e.target.files)
} }
e.target.value = ""; // reset the form field for uploading again e.target.value = '' // reset the form field for uploading again
} }
return ( return (
@ -36,7 +36,7 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
style={{ style={{
width: 0, width: 0,
height: 0, height: 0,
position: "fixed", position: 'fixed',
left: -1000, left: -1000,
top: -1000, top: -1000,
opacity: 0.001, opacity: 0.001,
@ -48,34 +48,22 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
<label htmlFor="upload-field" ref={labelRef}> <label htmlFor="upload-field" ref={labelRef}>
{labelRefState && ( {labelRefState && (
<FileDrop onDrop={onSelect} frame={labelRefState}> <FileDrop onDrop={onSelect} frame={labelRefState}>
{({ {({draggingOverFrame, draggingOverTarget, onDragOver, onDragLeave, onDrop, onClick}) => (
draggingOverFrame,
draggingOverTarget,
onDragOver,
onDragLeave,
onDrop,
onClick,
}) => (
<Segment <Segment
placeholder placeholder
{...{ onDragOver, onDragLeave, onDrop }} {...{onDragOver, onDragLeave, onDrop}}
style={{ style={{
background: background: draggingOverTarget || draggingOverFrame ? '#E0E0EE' : null,
draggingOverTarget || draggingOverFrame ? "#E0E0EE" : null, transition: 'background 0.2s',
transition: "background 0.2s",
}} }}
> >
<Header icon> <Header icon>
<Icon name="cloud upload" /> <Icon name="cloud upload" />
{multiple {multiple ? t('FileUploadField.dropOrClickMultiple') : t('FileUploadField.dropOrClick')}
? t("FileUploadField.dropOrClickMultiple")
: t("FileUploadField.dropOrClick")}
</Header> </Header>
<Button primary as="span"> <Button primary as="span">
{multiple {multiple ? t('FileUploadField.uploadFiles') : t('FileUploadField.uploadFile')}
? t("FileUploadField.uploadFiles")
: t("FileUploadField.uploadFile")}
</Button> </Button>
</Segment> </Segment>
)} )}
@ -83,5 +71,5 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
)} )}
</label> </label>
</> </>
); )
} }

View file

@ -21,5 +21,9 @@ export default function FormattedDate({date, relative = false}) {
} }
const iso = dateTime.toISO() const iso = dateTime.toISO()
return <time dateTime={iso} title={iso}>{str}</time> return (
<time dateTime={iso} title={iso}>
{str}
</time>
)
} }

View file

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

View file

@ -1,9 +1,9 @@
import React from "react"; import React from 'react'
import classnames from "classnames"; import classnames from 'classnames'
import { Container } from "semantic-ui-react"; import {Container} from 'semantic-ui-react'
import { Helmet } from "react-helmet"; import {Helmet} from 'react-helmet'
import styles from "./Page.module.less"; import styles from './Page.module.less'
export default function Page({ export default function Page({
small, small,
@ -12,11 +12,11 @@ export default function Page({
stage, stage,
title, title,
}: { }: {
small?: boolean; small?: boolean
children: ReactNode; children: ReactNode
fullScreen?: boolean; fullScreen?: boolean
stage?: ReactNode; stage?: ReactNode
title?: string; title?: string
}) { }) {
return ( return (
<> <>
@ -37,5 +37,5 @@ export default function Page({
{fullScreen ? children : <Container>{children}</Container>} {fullScreen ? children : <Container>{children}</Container>}
</main> </main>
</> </>
); )
} }

View file

@ -0,0 +1,73 @@
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'
import {useTranslation} from 'react-i18next'
function formatDuration(seconds) {
return (
Duration.fromMillis((seconds ?? 0) * 1000)
.as('hours')
.toFixed(1) + ' h'
)
}
export default function Stats() {
const {t} = useTranslation()
const [page, setPage] = useState(1)
const PER_PAGE = 10
const stats = useObservable(
() => of(null).pipe(switchMap(() => concat(of(null), from(api.get('/stats/regions'))))),
null
)
const pageCount = stats ? Math.ceil(stats.length / PER_PAGE) : 1
return (
<>
<Header as="h2">{t('RegionStats.title')}</Header>
<div>
<Loader active={stats == null} />
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell> {t('RegionStats.regionName')}</Table.HeaderCell>
<Table.HeaderCell>{t('RegionStats.eventCount')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{stats?.slice((page - 1) * PER_PAGE, page * PER_PAGE)?.map((area) => (
<Table.Row key={area.id}>
<Table.Cell>{area.name}</Table.Cell>
<Table.Cell>{area.overtaking_event_count}</Table.Cell>
</Table.Row>
))}
</Table.Body>
{pageCount > 1 && (
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan="2">
<Pagination
floated="right"
activePage={page}
totalPages={pageCount}
onPageChange={(e, data) => setPage(data.activePage as number)}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
)}
</Table>
</div>
</>
)
}

View file

@ -69,14 +69,14 @@ export default function Stats({user = null}: {user?: null | string}) {
return ( return (
<> <>
<Header as="h2">{user ? t('Stats.titleUser') : t('Stats.title')}</Header>
<div> <div>
<Segment attached="top"> <Segment attached="top">
<Loader active={stats == null} /> <Loader active={stats == null} />
<Statistic.Group widths={2} size="tiny"> <Statistic.Group widths={2} size="tiny">
<Statistic> <Statistic>
<Statistic.Value>{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : placeholder}</Statistic.Value> <Statistic.Value>
{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : placeholder}
</Statistic.Value>
<Statistic.Label>{t('Stats.totalTrackLength')}</Statistic.Label> <Statistic.Label>{t('Stats.totalTrackLength')}</Statistic.Label>
</Statistic> </Statistic>
<Statistic> <Statistic>
@ -87,16 +87,21 @@ export default function Stats({user = null}: {user?: null | string}) {
<Statistic.Value>{stats?.numEvents ?? placeholder}</Statistic.Value> <Statistic.Value>{stats?.numEvents ?? placeholder}</Statistic.Value>
<Statistic.Label>{t('Stats.eventsConfirmed')}</Statistic.Label> <Statistic.Label>{t('Stats.eventsConfirmed')}</Statistic.Label>
</Statistic> </Statistic>
{user ? ( <Statistic>
<Statistic> <Statistic.Value>{stats?.trackCount ?? placeholder}</Statistic.Value>
<Statistic.Value>{stats?.trackCount ?? placeholder}</Statistic.Value> <Statistic.Label>{t('Stats.tracksRecorded')}</Statistic.Label>
<Statistic.Label>{t('Stats.tracksRecorded')}</Statistic.Label> </Statistic>
</Statistic> {!user && (
) : ( <>
<Statistic> <Statistic>
<Statistic.Value>{stats?.userCount ?? placeholder}</Statistic.Value> <Statistic.Value>{stats?.userCount ?? placeholder}</Statistic.Value>
<Statistic.Label>{t('Stats.membersJoined')}</Statistic.Label> <Statistic.Label>{t('Stats.membersJoined')}</Statistic.Label>
</Statistic> </Statistic>
<Statistic>
<Statistic.Value>{stats?.deviceCount ?? placeholder}</Statistic.Value>
<Statistic.Label>{t('Stats.deviceCount')}</Statistic.Label>
</Statistic>
</>
)} )}
</Statistic.Group> </Statistic.Group>
</Segment> </Segment>

View file

@ -1,18 +1,14 @@
import React from "react"; import React from 'react'
import { Icon } from "semantic-ui-react"; import {Icon} from 'semantic-ui-react'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
export default function Visibility({ public: public_ }: { public: boolean }) { export default function Visibility({public: public_}: {public: boolean}) {
const { t } = useTranslation(); const {t} = useTranslation()
const icon = public_ ? ( const icon = public_ ? <Icon color="blue" name="eye" fitted /> : <Icon name="eye slash" fitted />
<Icon color="blue" name="eye" fitted /> const text = public_ ? t('general.public') : t('general.private')
) : (
<Icon name="eye slash" fitted />
);
const text = public_ ? t("general.public") : t("general.private");
return ( return (
<> <>
{icon} {text} {icon} {text}
</> </>
); )
} }

View file

@ -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,7 +7,7 @@ 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'
export {default as Visibility} from './Visibility' export {default as Visibility} from './Visibility'

View file

@ -1,45 +1,46 @@
import React from "react"; import React from 'react'
export type MapSource = { export type MapSource = {
type: "vector"; type: 'vector'
tiles: string[]; tiles: string[]
minzoom: number; minzoom: number
maxzoom: number; maxzoom: number
}; }
export interface Config { export interface Config {
apiUrl: string; apiUrl: string
mapHome: { mapHome: {
latitude: number; latitude: number
longitude: number; longitude: number
zoom: number; zoom: number
}; }
obsMapSource?: MapSource; obsMapSource?: MapSource
imprintUrl?: string; imprintUrl?: string
privacyPolicyUrl?: string; privacyPolicyUrl?: string
termsUrl?: string
banner?: { banner?: {
text: string; text: string
style?: "warning" | "info"; style?: 'warning' | 'info'
}; }
} }
async function loadConfig(): Promise<Config> { async function loadConfig(): Promise<Config> {
const response = await fetch(__webpack_public_path__ + "config.json"); const response = await fetch(__webpack_public_path__ + 'config.json')
const config = await response.json(); const config = await response.json()
return config; return config
} }
let _configPromise: Promise<Config> = loadConfig(); let _configPromise: Promise<Config> = loadConfig()
let _configCache: null | Config = null; let _configCache: null | Config = null
export function useConfig() { export function useConfig() {
const [config, setConfig] = React.useState<Config>(_configCache); const [config, setConfig] = React.useState<Config>(_configCache)
React.useEffect(() => { React.useEffect(() => {
if (!_configCache) { if (!_configCache) {
_configPromise.then(setConfig); _configPromise.then(setConfig)
} }
}, []); }, [])
return config; return config
} }
export default _configPromise; export default _configPromise

View file

@ -1,95 +1,87 @@
import { useState, useEffect, useMemo } from "react"; import {useState, useEffect, useMemo} from 'react'
import i18next, { TOptions } from "i18next"; import i18next, {TOptions} from 'i18next'
import { BehaviorSubject, combineLatest } from "rxjs"; import {BehaviorSubject, combineLatest} from 'rxjs'
import { map, distinctUntilChanged } from "rxjs/operators"; import {map, distinctUntilChanged} from 'rxjs/operators'
import HttpBackend, { import HttpBackend, {BackendOptions, RequestCallback} from 'i18next-http-backend'
BackendOptions, import {initReactI18next} from 'react-i18next'
RequestCallback, import LanguageDetector from 'i18next-browser-languagedetector'
} from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
export type AvailableLocales = "en" | "de"; export type AvailableLocales = 'en' | 'de' | 'fr'
async function request( async function request(_options: BackendOptions, url: string, _payload: any, callback: RequestCallback) {
_options: BackendOptions,
url: string,
_payload: any,
callback: RequestCallback
) {
try { try {
const [lng] = url.split("/"); const [lng] = url.split('/')
const locale = await import(`translations/${lng}.yaml`); const locale = await import(`translations/${lng}.yaml`)
callback(null, { status: 200, data: locale }); callback(null, {status: 200, data: locale})
} catch (e) { } catch (e) {
console.error(`Unable to load locale at ${url}\n`, e); console.error(`Unable to load locale at ${url}\n`, e)
callback(null, { status: 404, data: String(e) }); callback(null, {status: 404, data: String(e)})
} }
} }
export const AVAILABLE_LOCALES: AvailableLocales[] = ["en", "de"]; export const AVAILABLE_LOCALES: AvailableLocales[] = ['en', 'de', 'fr']
const i18n = i18next.createInstance(); const i18n = i18next.createInstance()
const options: TOptions = { const options: TOptions = {
fallbackLng: "en", fallbackLng: 'en',
ns: ["common"], ns: ['common'],
defaultNS: "common", defaultNS: 'common',
whitelist: AVAILABLE_LOCALES, whitelist: AVAILABLE_LOCALES,
// loading via webpack // loading via webpack
backend: { backend: {
loadPath: "{{lng}}/{{ns}}", loadPath: '{{lng}}/{{ns}}',
parse: (data: any) => data, parse: (data: any) => data,
request, request,
}, },
load: "languageOnly", load: 'languageOnly',
interpolation: { interpolation: {
escapeValue: false, // not needed for react as it escapes by default escapeValue: false, // not needed for react as it escapes by default
}, },
}; }
i18n i18n
.use(HttpBackend) .use(HttpBackend)
.use(initReactI18next) .use(initReactI18next)
.use(LanguageDetector) .use(LanguageDetector)
.init({ ...options }); .init({...options})
const locale$ = new BehaviorSubject<AvailableLocales>("en"); const locale$ = new BehaviorSubject<AvailableLocales>('en')
export const translate = i18n.t.bind(i18n); export const translate = i18n.t.bind(i18n)
export const translate$ = (stringAndData$: [string, any]) => export const translate$ = (stringAndData$: [string, any]) =>
combineLatest([stringAndData$, locale$.pipe(distinctUntilChanged())]).pipe( combineLatest([stringAndData$, locale$.pipe(distinctUntilChanged())]).pipe(
map(([stringAndData]) => { map(([stringAndData]) => {
if (typeof stringAndData === "string") { if (typeof stringAndData === 'string') {
return i18n.t(stringAndData); return i18n.t(stringAndData)
} else { } else {
const [string, data] = stringAndData; const [string, data] = stringAndData
return i18n.t(string, { data }); return i18n.t(string, {data})
} }
}) })
); )
export const setLocale = (locale: AvailableLocales) => { export const setLocale = (locale: AvailableLocales) => {
i18n.changeLanguage(locale); i18n.changeLanguage(locale)
locale$.next(locale); locale$.next(locale)
};
export function useLocale() {
const [, reload] = useState();
useEffect(() => {
i18n.on("languageChanged", reload);
return () => {
i18n.off("languageChanged", reload);
};
}, []);
return i18n.language;
} }
export default i18n; export function useLocale() {
const [, reload] = useState()
useEffect(() => {
i18n.on('languageChanged', reload)
return () => {
i18n.off('languageChanged', reload)
}
}, [])
return i18n.language
}
export default i18n

View file

@ -1,11 +0,0 @@
body {
margin: 0;
font-family: 'Noto Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Oxygen', 'Ubuntu', 'Cantarell',
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: 'Noto Sans Mono', source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}

View file

@ -3,7 +3,7 @@ import {Settings} from 'luxon'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import 'fomantic-ui-less/semantic.less' import 'fomantic-ui-less/semantic.less'
import './index.css' import './index.less'
import App from './App' import App from './App'
import 'maplibre-gl/dist/maplibre-gl.css' import 'maplibre-gl/dist/maplibre-gl.css'

8
frontend/src/index.less Normal file
View file

@ -0,0 +1,8 @@
@import 'styles.less';
body {
margin: 0;
font-family: @fontFamilyDefault;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View file

@ -23,7 +23,13 @@ function rgbArrayToColor(arr) {
} }
function rgbArrayToHtml(arr) { function rgbArrayToHtml(arr) {
return "#" + arr.map((v) => Math.round(v * 255).toString(16)).map(v => (v.length == 1 ? '0' : '') + v).join('') return (
'#' +
arr
.map((v) => Math.round(v * 255).toString(16))
.map((v) => (v.length == 1 ? '0' : '') + v)
.join('')
)
} }
export function colormapToScale(colormap, value, min, max) { export function colormapToScale(colormap, value, min, max) {
@ -38,17 +44,13 @@ export function colormapToScale(colormap, value, min, max) {
export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20) export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20)
export const viridisSimpleHtml = simplifyColormap(viridisBase.map(rgbArrayToHtml), 10) export const viridisSimpleHtml = simplifyColormap(viridisBase.map(rgbArrayToHtml), 10)
export const grayscale = ['#FFFFFF', '#000000'] export const grayscale = ['#FFFFFF', '#000000']
export const reds = [ export const reds = ['rgba( 255, 0, 0, 0)', 'rgba( 255, 0, 0, 255)']
'rgba( 255, 0, 0, 0)',
'rgba( 255, 0, 0, 255)',
]
export function colorByCount(attribute = 'event_count', maxCount, colormap = viridis) { export function colorByCount(attribute = 'event_count', maxCount, colormap = viridis) {
return colormapToScale(colormap, ['case', isValidAttribute(attribute), ['get', attribute], 0], 0, maxCount) return colormapToScale(colormap, ['case', isValidAttribute(attribute), ['get', attribute], 0], 0, maxCount)
} }
var steps = {'rural': [1.6,1.8,2.0,2.2], var steps = {rural: [1.6, 1.8, 2.0, 2.2], urban: [1.1, 1.3, 1.5, 1.7]}
'urban': [1.1,1.3,1.5,1.7]}
export function isValidAttribute(attribute) { export function isValidAttribute(attribute) {
if (attribute.endsWith('zone')) { if (attribute.endsWith('zone')) {
@ -58,60 +60,59 @@ export function isValidAttribute(attribute) {
} }
export function borderByZone() { export function borderByZone() {
return ["match", ['get', 'zone'], return ['match', ['get', 'zone'], 'rural', 'cyan', 'urban', 'blue', 'purple']
"rural", "cyan",
"urban", "blue",
"purple"
]
} }
export function colorByDistance(attribute = 'distance_overtaker_mean', fallback = '#ABC', zone='urban') { export function colorByDistance(attribute = 'distance_overtaker_mean', fallback = '#ABC', zone = 'urban') {
return [ return [
'case', 'case',
['!', isValidAttribute(attribute)], ['!', isValidAttribute(attribute)],
fallback, fallback,
["match", ['get', 'zone'], "rural",
[ [
'step', 'match',
['get', attribute], ['get', 'zone'],
'rgba(150, 0, 0, 1)', 'rural',
steps['rural'][0], [
'rgba(255, 0, 0, 1)', 'step',
steps['rural'][1], ['get', attribute],
'rgba(255, 220, 0, 1)', 'rgba(150, 0, 0, 1)',
steps['rural'][2], steps['rural'][0],
'rgba(67, 200, 0, 1)', 'rgba(255, 0, 0, 1)',
steps['rural'][3], steps['rural'][1],
'rgba(67, 150, 0, 1)', 'rgba(255, 220, 0, 1)',
], "urban", steps['rural'][2],
[ 'rgba(67, 200, 0, 1)',
'step', steps['rural'][3],
['get', attribute], 'rgba(67, 150, 0, 1)',
'rgba(150, 0, 0, 1)', ],
steps['urban'][0], 'urban',
'rgba(255, 0, 0, 1)', [
steps['urban'][1], 'step',
'rgba(255, 220, 0, 1)', ['get', attribute],
steps['urban'][2], 'rgba(150, 0, 0, 1)',
'rgba(67, 200, 0, 1)', steps['urban'][0],
steps['urban'][3], 'rgba(255, 0, 0, 1)',
'rgba(67, 150, 0, 1)', steps['urban'][1],
'rgba(255, 220, 0, 1)',
steps['urban'][2],
'rgba(67, 200, 0, 1)',
steps['urban'][3],
'rgba(67, 150, 0, 1)',
],
[
'step',
['get', attribute],
'rgba(150, 0, 0, 1)',
steps['urban'][0],
'rgba(255, 0, 0, 1)',
steps['urban'][1],
'rgba(255, 220, 0, 1)',
steps['urban'][2],
'rgba(67, 200, 0, 1)',
steps['urban'][3],
'rgba(67, 150, 0, 1)',
],
], ],
[
'step',
['get', attribute],
'rgba(150, 0, 0, 1)',
steps['urban'][0],
'rgba(255, 0, 0, 1)',
steps['urban'][1],
'rgba(255, 220, 0, 1)',
steps['urban'][2],
'rgba(67, 200, 0, 1)',
steps['urban'][3],
'rgba(67, 150, 0, 1)',
]
]
] ]
} }
@ -124,7 +125,57 @@ export const trackLayer = {
}, },
} }
export const trackLayerRaw = produce(trackLayer, draft => { export const getRegionLayers = (adminLevel = 6, baseColor = '#00897B', maxValue = 5000) => [
{
id: 'region',
type: 'fill',
source: 'obs',
'source-layer': 'obs_regions',
minzoom: 0,
maxzoom: 10,
// filter: [">", "overtaking_event_count", 0],
paint: {
'fill-color': baseColor,
'fill-antialias': true,
'fill-opacity': [
'interpolate',
['linear'],
['log10', ['max', ['get', 'overtaking_event_count'], 1]],
0,
0,
Math.log10(maxValue),
0.9,
],
},
},
{
id: 'region-border',
type: 'line',
source: 'obs',
'source-layer': 'obs_regions',
minzoom: 0,
maxzoom: 10,
// filter: [">", "overtaking_event_count", 0],
paint: {
'line-width': [
'interpolate',
['linear'],
['log10', ['max', ['get', 'overtaking_event_count'], 1]],
0,
0.2,
Math.log10(maxValue),
1.5,
],
'line-color': baseColor,
},
layout: {
'line-join': 'round',
'line-cap': 'round',
},
},
]
export const trackLayerRaw = produce(trackLayer, (draft) => {
// draft.paint['line-color'] = '#81D4FA' // draft.paint['line-color'] = '#81D4FA'
draft.paint['line-width'][4] = 1 draft.paint['line-width'][4] = 1
draft.paint['line-width'][6] = 2 draft.paint['line-width'][6] = 2

View file

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

View file

@ -1,135 +1,121 @@
import React, { useState, useCallback, useMemo } from "react"; import React, {useState, useCallback, useMemo} from 'react'
import { Source, Layer } from "react-map-gl"; import {Source, Layer} from 'react-map-gl'
import _ from "lodash"; import _ from 'lodash'
import { import {Button, Form, Dropdown, Header, Message, Icon} from 'semantic-ui-react'
Button, import {useTranslation, Trans as Translate} from 'react-i18next'
Form, import Markdown from 'react-markdown'
Dropdown,
Header,
Message,
Icon,
} from "semantic-ui-react";
import { useTranslation, Trans as Translate } from "react-i18next";
import Markdown from "react-markdown";
import { useConfig } from "config"; import {useConfig} from 'config'
import { Page, Map } from "components"; import {Page, Map} from 'components'
const BoundingBoxSelector = React.forwardRef( const BoundingBoxSelector = React.forwardRef(({value, name, onChange}, ref) => {
({ value, name, onChange }, ref) => { const {t} = useTranslation()
const { t } = useTranslation(); const [pointNum, setPointNum] = useState(0)
const [pointNum, setPointNum] = useState(0); const [point0, setPoint0] = useState(null)
const [point0, setPoint0] = useState(null); const [point1, setPoint1] = useState(null)
const [point1, setPoint1] = useState(null);
const onClick = (e) => { const onClick = (e) => {
if (pointNum == 0) { if (pointNum == 0) {
setPoint0(e.lngLat); setPoint0(e.lngLat)
} else { } else {
setPoint1(e.lngLat); setPoint1(e.lngLat)
} }
setPointNum(1 - pointNum); setPointNum(1 - pointNum)
};
React.useEffect(() => {
if (!point0 || !point1) return;
const bbox = `${point0[0]},${point0[1]},${point1[0]},${point1[1]}`;
if (bbox !== value) {
onChange(bbox);
}
}, [point0, point1]);
React.useEffect(() => {
if (!value) return;
const [p00, p01, p10, p11] = value
.split(",")
.map((v) => Number.parseFloat(v));
if (!point0 || point0[0] != p00 || point0[1] != p01)
setPoint0([p00, p01]);
if (!point1 || point1[0] != p10 || point1[1] != p11)
setPoint1([p10, p11]);
}, [value]);
return (
<div>
<Form.Input
label={t('ExportPage.boundingBox.label')}
{...{ name, value }}
onChange={(e) => onChange(e.target.value)}
/>
<div style={{ height: 400, position: "relative", marginBottom: 16 }}>
<Map onClick={onClick}>
<Source
id="bbox"
type="geojson"
data={
point0 && point1
? {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [
[
[point0[0], point0[1]],
[point1[0], point0[1]],
[point1[0], point1[1]],
[point0[0], point1[1]],
[point0[0], point0[1]],
],
],
},
},
],
}
: {}
}
>
<Layer
id="bbox"
type="line"
paint={{
"line-width": 4,
"line-color": "#F06292",
}}
/>
</Source>
</Map>
</div>
</div>
);
} }
);
const MODES = ["events"]; React.useEffect(() => {
const FORMATS = ["geojson", "shapefile"]; if (!point0 || !point1) return
const bbox = `${point0[0]},${point0[1]},${point1[0]},${point1[1]}`
if (bbox !== value) {
onChange(bbox)
}
}, [point0, point1])
React.useEffect(() => {
if (!value) return
const [p00, p01, p10, p11] = value.split(',').map((v) => Number.parseFloat(v))
if (!point0 || point0[0] != p00 || point0[1] != p01) setPoint0([p00, p01])
if (!point1 || point1[0] != p10 || point1[1] != p11) setPoint1([p10, p11])
}, [value])
return (
<div>
<Form.Input
label={t('ExportPage.boundingBox.label')}
{...{name, value}}
onChange={(e) => onChange(e.target.value)}
/>
<div style={{height: 400, position: 'relative', marginBottom: 16}}>
<Map onClick={onClick}>
<Source
id="bbox"
type="geojson"
data={
point0 && point1
? {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[point0[0], point0[1]],
[point1[0], point0[1]],
[point1[0], point1[1]],
[point0[0], point1[1]],
[point0[0], point0[1]],
],
],
},
},
],
}
: {}
}
>
<Layer
id="bbox"
type="line"
paint={{
'line-width': 4,
'line-color': '#F06292',
}}
/>
</Source>
</Map>
</div>
</div>
)
})
const MODES = ['events', 'segments']
const FORMATS = ['geojson', 'shapefile']
export default function ExportPage() { export default function ExportPage() {
const [mode, setMode] = useState("events"); const [mode, setMode] = useState('events')
const [bbox, setBbox] = useState("8.294678,49.651182,9.059601,50.108249"); const [bbox, setBbox] = useState('8.294678,49.651182,9.059601,50.108249')
const [fmt, setFmt] = useState("geojson"); const [fmt, setFmt] = useState('geojson')
const config = useConfig(); const config = useConfig()
const exportUrl = `${config?.apiUrl}/export/events?bbox=${bbox}&fmt=${fmt}`; const {t} = useTranslation()
const { t } = useTranslation();
return ( return (
<Page title="Export"> <Page title="Export">
<Header as="h2">{t("ExportPage.title")}</Header> <Header as="h2">{t('ExportPage.title')}</Header>
<Message icon info> <Message icon info>
<Icon name="info circle" /> <Icon name="info circle" />
<Message.Content> <Message.Content>
<Markdown>{t("ExportPage.information")}</Markdown> <Markdown>{t('ExportPage.information')}</Markdown>
</Message.Content> </Message.Content>
</Message> </Message>
<Form> <Form>
<Form.Field> <Form.Field>
<label>{t("ExportPage.mode.label")}</label> <label>{t('ExportPage.mode.label')}</label>
<Dropdown <Dropdown
placeholder={t("ExportPage.mode.placeholder")} placeholder={t('ExportPage.mode.placeholder')}
fluid fluid
selection selection
options={MODES.map((value) => ({ options={MODES.map((value) => ({
@ -138,14 +124,14 @@ export default function ExportPage() {
value, value,
}))} }))}
value={mode} value={mode}
onChange={(_e, { value }) => setMode(value)} onChange={(_e, {value}) => setMode(value)}
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<label>{t("ExportPage.format.label")}</label> <label>{t('ExportPage.format.label')}</label>
<Dropdown <Dropdown
placeholder={t("ExportPage.format.placeholder")} placeholder={t('ExportPage.format.placeholder')}
fluid fluid
selection selection
options={FORMATS.map((value) => ({ options={FORMATS.map((value) => ({
@ -154,7 +140,7 @@ export default function ExportPage() {
value, value,
}))} }))}
value={fmt} value={fmt}
onChange={(_e, { value }) => setFmt(value)} onChange={(_e, {value}) => setFmt(value)}
/> />
</Form.Field> </Form.Field>
@ -163,7 +149,7 @@ export default function ExportPage() {
<Button <Button
primary primary
as="a" as="a"
href={exportUrl} href={`${config?.apiUrl}/export/${mode}?bbox=${bbox}&fmt=${fmt}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -171,5 +157,5 @@ export default function ExportPage() {
</Button> </Button>
</Form> </Form>
</Page> </Page>
); )
} }

View file

@ -6,7 +6,7 @@ import {map, switchMap} from 'rxjs/operators'
import {useTranslation} from 'react-i18next' import {useTranslation} from 'react-i18next'
import api from 'api' import api from 'api'
import {Stats, Page} from 'components' import {RegionStats, Stats, Page} from 'components'
import type {Track} from 'types' import type {Track} from 'types'
import {TrackListItem, NoPublicTracksMessage} from './TracksPage' import {TrackListItem, NoPublicTracksMessage} from './TracksPage'
@ -46,9 +46,10 @@ 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>

View file

@ -1,69 +1,66 @@
import React from "react"; import React from 'react'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { Redirect, useLocation, useHistory } from "react-router-dom"; import {Redirect, useLocation, useHistory} from 'react-router-dom'
import { Icon, Message } from "semantic-ui-react"; import {Icon, Message} from 'semantic-ui-react'
import { useObservable } from "rxjs-hooks"; import {useObservable} from 'rxjs-hooks'
import { switchMap, pluck, distinctUntilChanged } from "rxjs/operators"; import {switchMap, pluck, distinctUntilChanged} from 'rxjs/operators'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import { Page } from "components"; import {Page} from 'components'
import api from "api"; import api from 'api'
const LoginRedirectPage = connect((state) => ({ const LoginRedirectPage = connect((state) => ({
loggedIn: Boolean(state.login), loggedIn: Boolean(state.login),
}))(function LoginRedirectPage({ loggedIn }) { }))(function LoginRedirectPage({loggedIn}) {
const location = useLocation(); const location = useLocation()
const history = useHistory(); const history = useHistory()
const { search } = location; const {search} = location
const { t } = useTranslation(); const {t} = useTranslation()
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
// Hook dependency arrays in this block are intentionally left blank, we want // Hook dependency arrays in this block are intentionally left blank, we want
// to keep the initial state, but reset the url once, ASAP, to not leak the // to keep the initial state, but reset the url once, ASAP, to not leak the
// query parameters. This is considered good practice by OAuth. // query parameters. This is considered good practice by OAuth.
const searchParams = React.useMemo( const searchParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(search).entries()), [])
() => Object.fromEntries(new URLSearchParams(search).entries()),
[]
);
React.useEffect(() => { React.useEffect(() => {
history.replace({ ...location, search: "" }); history.replace({...location, search: ''})
}, []); }, [])
/* eslint-enable react-hooks/exhaustive-deps */ /* eslint-enable react-hooks/exhaustive-deps */
if (loggedIn) { if (loggedIn) {
return <Redirect to="/" />; return <Redirect to="/" />
} }
const { error, error_description: errorDescription, code } = searchParams; const {error, error_description: errorDescription, code} = searchParams
if (error) { if (error) {
return ( return (
<Page small title={t("LoginRedirectPage.loginError")}> <Page small title={t('LoginRedirectPage.loginError')}>
<LoginError errorText={errorDescription || error} /> <LoginError errorText={errorDescription || error} />
</Page> </Page>
); )
} }
return <ExchangeAuthCode code={code} />; return <ExchangeAuthCode code={code} />
}); })
function LoginError({ errorText }: { errorText: string }) { function LoginError({errorText}: {errorText: string}) {
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<Message icon error> <Message icon error>
<Icon name="warning sign" /> <Icon name="warning sign" />
<Message.Content> <Message.Content>
<Message.Header>{t("LoginRedirectPage.loginError")}</Message.Header> <Message.Header>{t('LoginRedirectPage.loginError')}</Message.Header>
{t("LoginRedirectPage.loginErrorText", { error: errorText })} {t('LoginRedirectPage.loginErrorText', {error: errorText})}
</Message.Content> </Message.Content>
</Message> </Message>
); )
} }
function ExchangeAuthCode({ code }) { function ExchangeAuthCode({code}) {
const { t } = useTranslation(); const {t} = useTranslation()
const result = useObservable( const result = useObservable(
(_$, args$) => (_$, args$) =>
args$.pipe( args$.pipe(
@ -73,31 +70,31 @@ function ExchangeAuthCode({ code }) {
), ),
null, null,
[code] [code]
); )
let content; let content
if (result === null) { if (result === null) {
content = ( content = (
<Message icon info> <Message icon info>
<Icon name="circle notched" loading /> <Icon name="circle notched" loading />
<Message.Content> <Message.Content>
<Message.Header>{t("LoginRedirectPage.loggingIn")}</Message.Header> <Message.Header>{t('LoginRedirectPage.loggingIn')}</Message.Header>
{t("LoginRedirectPage.hangTight")} {t('LoginRedirectPage.hangTight')}
</Message.Content> </Message.Content>
</Message> </Message>
); )
} else if (result === true) { } else if (result === true) {
content = <Redirect to="/" />; content = <Redirect to="/" />
} else { } else {
const { error, error_description: errorDescription } = result; const {error, error_description: errorDescription} = result
content = <LoginError errorText={errorDescription || error} />; content = <LoginError errorText={errorDescription || error} />
} }
return ( return (
<Page small title="Login"> <Page small title="Login">
{content} {content}
</Page> </Page>
); )
} }
export default LoginRedirectPage; export default LoginRedirectPage

View file

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

View file

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

View file

@ -1,241 +1,253 @@
import React, { useState, useCallback, useMemo } from "react"; import React, {useState, useCallback, useMemo, useRef} from 'react'
import _ from "lodash"; import _ from 'lodash'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { Button } from "semantic-ui-react"; import {Button} from 'semantic-ui-react'
import { Layer, Source } from "react-map-gl"; import {Layer, Source} from 'react-map-gl'
import produce from "immer"; import produce from 'immer'
import classNames from "classnames"; import classNames from 'classnames'
import type { Location } from "types"; import api from 'api'
import { Page, Map } from "components"; import type {Location} from 'types'
import { useConfig } from "config"; import {Page, Map} from 'components'
import { import {useConfig} from 'config'
colorByDistance, import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles'
colorByCount, import {useMapConfig} from 'reducers/mapConfig'
borderByZone,
reds,
isValidAttribute,
} from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo from "./RoadInfo"; import RoadInfo, {RoadInfoType} 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, }
};
const getUntaggedRoadsLayer = (colorAttribute, maxCount) => const getUntaggedRoadsLayer = (colorAttribute) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.filter = ["!", isValidAttribute(colorAttribute)]; draft.filter = ['!', isValidAttribute(colorAttribute)]
}); })
const getRoadsLayer = (colorAttribute, maxCount) => const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.id = "obs_roads_normal"; draft.id = 'obs_roads_normal'
draft.filter = isValidAttribute(colorAttribute); draft.filter = isValidAttribute(colorAttribute)
draft.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)
: colorAttribute.endsWith("zone") : colorAttribute.endsWith('zone')
? borderByZone() ? borderByZone()
: "#DDD"; : '#DDD'
draft.paint["line-opacity"][3] = 12; // draft.paint["line-opacity"][3] = 12;
draft.paint["line-opacity"][5] = 13; // draft.paint["line-opacity"][5] = 13;
}); })
const getEventsLayer = () => ({ const getEventsLayer = () => ({
id: "obs_events", id: 'obs_events',
type: "circle", type: 'circle',
source: "obs", source: 'obs',
"source-layer": "obs_events", 'source-layer': 'obs_events',
paint: { paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8], 'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
"circle-color": colorByDistance("distance_overtaker"), 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.1, 9, 0.3, 10, 0.5, 11, 1],
'circle-color': colorByDistance('distance_overtaker'),
}, },
minzoom: 11, minzoom: 8,
}); })
const getEventsTextLayer = () => ({ const getEventsTextLayer = () => ({
id: "obs_events_text", id: 'obs_events_text',
type: "symbol", type: 'symbol',
minzoom: 18, minzoom: 18,
source: "obs", source: 'obs',
"source-layer": "obs_events", 'source-layer': 'obs_events',
layout: { layout: {
"text-field": [ 'text-field': [
"number-format", 'number-format',
["get", "distance_overtaker"], ['get', 'distance_overtaker'],
{ "min-fraction-digits": 2, "max-fraction-digits": 2 }, {'min-fraction-digits': 2, 'max-fraction-digits': 2},
], ],
"text-allow-overlap": true, 'text-allow-overlap': true,
"text-font": ["Open Sans Bold", "Arial Unicode MS Regular"], 'text-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],
}, },
}); })
function MapPage({ login }) { interface RegionInfo {
const { obsMapSource, banner } = useConfig() || {}; properties: {
const [clickLocation, setClickLocation] = useState<Location | null>(null); 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( 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 ( if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) {
[styles.mapInfoBox, styles.mapToolbar].some((className) => return
node?.classList?.contains(className)
)
) {
return;
} }
node = node.parentNode; node = node.parentNode
} }
setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] }); const {zoom} = viewportRef.current
},
[setClickLocation]
);
const onCloseRoadInfo = useCallback(() => {
setClickLocation(null);
}, [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 = []
const untaggedRoadsLayerCustom = useMemo( const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
() => getUntaggedRoadsLayer(attribute),
[attribute]
);
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) { if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayerCustom); layers.push(untaggedRoadsLayerCustom)
} }
const roadsLayer = useMemo( const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
() => 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)
} }
const onToggleLayerSidebarButtonClick = useCallback( const onToggleLayerSidebarButtonClick = useCallback(
(e) => { (e) => {
e.stopPropagation(); e.stopPropagation()
e.preventDefault(); e.preventDefault()
console.log("toggl;e"); console.log('toggl;e')
setLayerSidebar((v) => !v); setLayerSidebar((v) => !v)
}, },
[setLayerSidebar] [setLayerSidebar]
); )
if (!obsMapSource) { if (!obsMapSource) {
return null; return null
} }
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => { const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
const query = new URLSearchParams(); const query = new URLSearchParams()
if (login) { if (login) {
if (mapConfig.filters.currentUser) { if (mapConfig.filters.currentUser) {
query.append("user", login.id); query.append('user', login.id)
} }
if (mapConfig.filters.dateMode === "range") { if (mapConfig.filters.dateMode === 'range') {
if (mapConfig.filters.startDate) { if (mapConfig.filters.startDate) {
query.append("start", mapConfig.filters.startDate); query.append('start', mapConfig.filters.startDate)
} }
if (mapConfig.filters.endDate) { if (mapConfig.filters.endDate) {
query.append("end", mapConfig.filters.endDate); query.append('end', mapConfig.filters.endDate)
} }
} else if (mapConfig.filters.dateMode === "threshold") { } else if (mapConfig.filters.dateMode === 'threshold') {
if (mapConfig.filters.startDate) { if (mapConfig.filters.startDate) {
query.append( query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
mapConfig.filters.thresholdAfter ? "start" : "end",
mapConfig.filters.startDate
);
} }
} }
} }
const queryString = String(query); const queryString = String(query)
return tileUrl + (queryString ? "?" : "") + queryString; return tileUrl + (queryString ? '?' : '') + queryString
}); })
const hasFilters: boolean = const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none')
login &&
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
return ( return (
<Page fullScreen title="Map"> <Page fullScreen title="Map">
<div <div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}>
className={classNames(
styles.mapContainer,
banner ? styles.hasBanner : null
)}
>
{layerSidebar && ( {layerSidebar && (
<div className={styles.mapSidebar}> <div className={styles.mapSidebar}>
<LayerSidebar /> <LayerSidebar />
</div> </div>
)} )}
<div className={styles.map}> <div className={styles.map}>
<Map viewportFromUrl onClick={onClick} hasToolbar> <Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}>
<div className={styles.mapToolbar}> <div className={styles.mapToolbar}>
<Button <Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} />
primary
icon="bars"
active={layerSidebar}
onClick={onToggleLayerSidebarButtonClick}
/>
</div> </div>
<Source id="obs" {...obsMapSource} tiles={tiles}> <Source id="obs" {...obsMapSource} tiles={tiles}>
{layers.map((layer) => ( {layers.map((layer) => (
@ -243,14 +255,23 @@ function MapPage({ login }) {
))} ))}
</Source> </Source>
<RoadInfo {details?.type === 'road' && details?.road?.road && (
{...{ clickLocation, hasFilters, onClose: onCloseRoadInfo }} <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> </Map>
</div> </div>
</div> </div>
</Page> </Page>
); )
} }
export default connect((state) => ({ login: state.login }))(MapPage); export default connect((state) => ({login: state.login}))(MapPage)

View file

@ -17,19 +17,27 @@
background: white; background: white;
border-right: 1px solid @borderColor; border-right: 1px solid @borderColor;
padding: 1rem; padding: 1rem;
overflow-y: auto;
} }
.map { .map {
flex: 1 1 0; flex: 1 1 0;
overflow: hidden;
} }
.mapInfoBox { .mapInfoBox {
position: absolute;
right: 16px;
top: 32px;
max-height: 100%;
width: 36rem; width: 36rem;
overflow: auto; overflow: auto;
border-left: 1px solid @borderColor;
background: white;
padding: 16px;
}
.copyright {
color: #888;
font-size: 0.8em;
line-height: 1.4;
margin-block-start: 1em;
} }
.mapToolbar { .mapToolbar {
@ -37,3 +45,35 @@
left: 16px; left: 16px;
top: 16px; top: 16px;
} }
.closeHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
}
@media @mobile {
.mapContainer {
height: auto;
min-height: calc(100vh - @menuHeightMobile);
&.hasBanner {
height: calc(100vh - @menuHeightMobile - 50px);
}
flex-direction: column;
}
.map {
height: 60vh;
}
.mapSidebar {
width: auto;
height: auto;
}
.mapInfoBox {
width: auto;
height: auto;
}
}

View file

@ -0,0 +1,360 @@
import React, {useCallback, useMemo, useState} from 'react'
import {connect} from 'react-redux'
import {
Accordion,
Button,
Checkbox,
Confirm,
Header,
Icon,
Item,
List,
Loader,
Dropdown,
SemanticCOLORS,
SemanticICONS,
Table,
} from 'semantic-ui-react'
import {useObservable} from 'rxjs-hooks'
import {Link} from 'react-router-dom'
import {of, from, concat, BehaviorSubject, combineLatest} from 'rxjs'
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
import _ from 'lodash'
import {useTranslation} from 'react-i18next'
import type {ProcessingStatus, Track, UserDevice} from 'types'
import {Page, FormattedDate, Visibility} from 'components'
import api from 'api'
import {useCallbackRef, formatDistance, formatDuration} from 'utils'
import download from 'downloadjs'
const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = {
error: 'red',
complete: 'green',
created: 'grey',
queued: 'orange',
processing: 'orange',
}
const ICON_BY_STATUS: Record<ProcessingStatus, SemanticICONS> = {
error: 'warning sign',
complete: 'check circle outline',
created: 'bolt',
queued: 'bolt',
processing: 'bolt',
}
function ProcessingStatusLabel({status}: {status: ProcessingStatus}) {
const {t} = useTranslation()
return (
<span title={t(`TracksPage.processing.${status}`)}>
<Icon color={COLOR_BY_STATUS[status]} name={ICON_BY_STATUS[status]} />
</span>
)
}
function SortableHeader({children, setOrderBy, orderBy, reversed, setReversed, name, ...props}) {
const toggleSort = (e) => {
e.preventDefault()
e.stopPropagation()
if (orderBy === name) {
if (!reversed) {
setReversed(true)
} else {
setReversed(false)
setOrderBy(null)
}
} else {
setReversed(false)
setOrderBy(name)
}
}
let icon = orderBy === name ? (reversed ? 'sort descending' : 'sort ascending') : null
return (
<Table.HeaderCell {...props}>
<div onClick={toggleSort}>
{children}
<Icon name={icon} />
</div>
</Table.HeaderCell>
)
}
type Filters = {
userDeviceId?: null | number
visibility?: null | boolean
}
function TrackFilters({
filters,
setFilters,
deviceNames,
}: {
filters: Filters
setFilters: (f: Filters) => void
deviceNames: null | Record<number, string>
}) {
return (
<List horizontal>
<List.Item>
<List.Header>Device</List.Header>
<Dropdown
selection
clearable
options={[
{value: 0, key: '__none__', text: 'All my devices'},
..._.sortBy(Object.entries(deviceNames ?? {}), 1).map(([deviceId, deviceName]: [string, string]) => ({
value: Number(deviceId),
key: deviceId,
text: deviceName,
})),
]}
value={filters?.userDeviceId ?? 0}
onChange={(_e, {value}) => setFilters({...filters, userDeviceId: (value as number) || null})}
/>
</List.Item>
<List.Item>
<List.Header>Visibility</List.Header>
<Dropdown
selection
clearable
options={[
{value: 'none', key: 'any', text: 'Any'},
{value: true, key: 'public', text: 'Public'},
{value: false, key: 'private', text: 'Private'},
]}
value={filters?.visibility ?? 'none'}
onChange={(_e, {value}) =>
setFilters({
...filters,
visibility: value === 'none' ? null : (value as boolean),
})
}
/>
</List.Item>
</List>
)
}
function TracksTable({title}) {
const [orderBy, setOrderBy] = useState('recordedAt')
const [reversed, setReversed] = useState(true)
const [showFilters, setShowFilters] = useState(false)
const [filters, setFilters] = useState<Filters>({})
const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>({})
const toggleTrackSelection = useCallbackRef((slug: string, selected?: boolean) => {
const newSelected = selected ?? !selectedTracks[slug]
setSelectedTracks(_.pickBy({...selectedTracks, [slug]: newSelected}, _.identity))
})
const query = _.pickBy(
{
limit: 1000,
offset: 0,
order_by: orderBy,
reversed: reversed ? 'true' : 'false',
user_device_id: filters?.userDeviceId,
public: filters?.visibility,
},
(x) => x != null
)
const forceUpdate$ = useMemo(() => new BehaviorSubject(null), [])
const tracks: Track[] | null = useObservable(
(_$, inputs$) =>
combineLatest([
inputs$.pipe(
map(([query]) => query),
distinctUntilChanged(_.isEqual)
),
forceUpdate$,
]).pipe(switchMap(([query]) => concat(of(null), from(api.get('/tracks/feed', {query}).then((r) => r.tracks))))),
null,
[query]
)
const deviceNames: null | Record<number, string> = useObservable(() =>
from(api.get('/user/devices')).pipe(
map((response: UserDevice[]) =>
Object.fromEntries(response.map((device) => [device.id, device.displayName || device.identifier]))
)
)
)
const {t} = useTranslation()
const p = {orderBy, setOrderBy, reversed, setReversed}
const selectedCount = Object.keys(selectedTracks).length
const noneSelected = selectedCount === 0
const allSelected = selectedCount === tracks?.length
const selectAll = () => {
setSelectedTracks(Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? []))
}
const selectNone = () => {
setSelectedTracks({})
}
const bulkAction = async (action: string) => {
const response = await api.post('/tracks/bulk', {
body: {
action,
tracks: Object.keys(selectedTracks),
},
returnResponse: true,
})
if (action === 'download') {
const contentType = response.headers.get('content-type') ?? 'application/x-gtar'
const filename = response.headers.get('content-disposition')?.match(/filename="([^"]+)"/)?.[1] ?? 'tracks.tar.bz2'
download(await response.blob(), filename, contentType)
}
setShowBulkDelete(false)
setSelectedTracks({})
forceUpdate$.next(null)
}
const [showBulkDelete, setShowBulkDelete] = useState(false)
return (
<>
<div style={{float: 'right'}}>
<Dropdown disabled={noneSelected} text="Bulk actions" floating button>
<Dropdown.Menu>
<Dropdown.Header>Selection of {selectedCount} tracks</Dropdown.Header>
<Dropdown.Item onClick={() => bulkAction('makePrivate')}>Make private</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction('makePublic')}>Make public</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction('reprocess')}>Reprocess</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction('download')}>Download</Dropdown.Item>
<Dropdown.Item onClick={() => setShowBulkDelete(true)}>Delete</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Link component={UploadButton} to="/upload" />
</div>
<Header as="h1">{title}</Header>
<div style={{clear: 'both'}}>
<Loader content={t('general.loading')} active={tracks == null} />
<Accordion>
<Accordion.Title active={showFilters} index={0} onClick={() => setShowFilters(!showFilters)}>
<Icon name="dropdown" />
Filters
</Accordion.Title>
<Accordion.Content active={showFilters}>
<TrackFilters {...{filters, setFilters, deviceNames}} />
</Accordion.Content>
</Accordion>
<Confirm
open={showBulkDelete}
onCancel={() => setShowBulkDelete(false)}
onConfirm={() => bulkAction('delete')}
content={`Are you sure you want to delete ${selectedCount} tracks?`}
confirmButton={t('general.delete')}
cancelButton={t('general.cancel')}
/>
<Table compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell>
<Checkbox
checked={allSelected}
indeterminate={!allSelected && !noneSelected}
onClick={() => (noneSelected ? selectAll() : selectNone())}
/>
</Table.HeaderCell>
<SortableHeader {...p} name="title">
Title
</SortableHeader>
<SortableHeader {...p} name="recordedAt">
Recorded at
</SortableHeader>
<SortableHeader {...p} name="visibility">
Visibility
</SortableHeader>
<SortableHeader {...p} name="length" textAlign="right">
Length
</SortableHeader>
<SortableHeader {...p} name="duration" textAlign="right">
Duration
</SortableHeader>
<SortableHeader {...p} name="user_device_id">
Device
</SortableHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{tracks?.map((track: Track) => (
<Table.Row key={track.slug}>
<Table.Cell>
<Checkbox
onClick={(e) => toggleTrackSelection(track.slug)}
checked={selectedTracks[track.slug] ?? false}
/>
</Table.Cell>
<Table.Cell>
{track.processingStatus == null ? null : <ProcessingStatusLabel status={track.processingStatus} />}
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title || t('general.unnamedTrack')}
</Item.Header>
</Table.Cell>
<Table.Cell>
<FormattedDate date={track.recordedAt} />
</Table.Cell>
<Table.Cell>{track.public == null ? null : <Visibility public={track.public} />}</Table.Cell>
<Table.Cell textAlign="right">{formatDistance(track.length)}</Table.Cell>
<Table.Cell textAlign="right">{formatDuration(track.duration)}</Table.Cell>
<Table.Cell>{track.userDeviceId ? deviceNames?.[track.userDeviceId] ?? '...' : null}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</div>
</>
)
}
function UploadButton({navigate, ...props}) {
const {t} = useTranslation()
const onClick = useCallback(
(e) => {
e.preventDefault()
navigate()
},
[navigate]
)
return (
<Button onClick={onClick} {...props} color="green">
{t('TracksPage.upload')}
</Button>
)
}
const MyTracksPage = connect((state) => ({login: (state as any).login}))(function MyTracksPage({login}) {
const {t} = useTranslation()
const title = t('TracksPage.titleUser')
return (
<Page title={title}>
<TracksTable {...{title}} />
</Page>
)
})
export default MyTracksPage

View file

@ -1,12 +1,12 @@
import React from 'react' import React from 'react'
import {Button, Header} from 'semantic-ui-react' import {Button, Header} from 'semantic-ui-react'
import {useHistory} from 'react-router-dom' import {useHistory} from 'react-router-dom'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import {Page} from '../components' import {Page} from '../components'
export default function NotFoundPage() { export default function NotFoundPage() {
const { t } = useTranslation(); const {t} = useTranslation()
const history = useHistory() const history = useHistory()
return ( return (
<Page title={t('NotFoundPage.title')}> <Page title={t('NotFoundPage.title')}>

View file

@ -1,227 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import {
Message,
Icon,
Grid,
Form,
Button,
TextArea,
Ref,
Input,
Header,
Divider,
Popup,
} from "semantic-ui-react";
import { useForm } from "react-hook-form";
import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
import { setLogin } from "reducers/login";
import { Page, Stats } from "components";
import api from "api";
import { findInput } from "utils";
import { useConfig } from "config";
const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
function SettingsPage({ login, setLogin }) {
const { t } = useTranslation();
const { register, handleSubmit } = useForm();
const [loading, setLoading] = React.useState(false);
const [errors, setErrors] = React.useState(null);
const onSave = React.useCallback(
async (changes) => {
setLoading(true);
setErrors(null);
try {
const response = await api.put("/user", { body: changes });
setLogin(response);
} catch (err) {
setErrors(err.errors);
} finally {
setLoading(false);
}
},
[setLoading, setLogin, setErrors]
);
const onGenerateNewKey = React.useCallback(async () => {
setLoading(true);
setErrors(null);
try {
const response = await api.put("/user", {
body: { updateApiKey: true },
});
setLogin(response);
} catch (err) {
setErrors(err.errors);
} finally {
setLoading(false);
}
}, [setLoading, setLogin, setErrors]);
return (
<Page title={t("SettingsPage.title")}>
<Grid centered relaxed divided stackable>
<Grid.Row>
<Grid.Column width={8}>
<Header as="h2">{t("SettingsPage.profile.title")}</Header>
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
<Form.Field error={errors?.username}>
<label>{t("SettingsPage.profile.username.label")}</label>
<Ref innerRef={findInput(register)}>
<Input
name="username"
defaultValue={login.username}
disabled
/>
</Ref>
<small>{t("SettingsPage.profile.username.hint")}</small>
</Form.Field>
<Message info visible>
{t("SettingsPage.profile.publicNotice")}
</Message>
<Form.Field error={errors?.displayName}>
<label>{t("SettingsPage.profile.displayName.label")}</label>
<Ref innerRef={findInput(register)}>
<Input
name="displayName"
defaultValue={login.displayName}
placeholder={login.username}
/>
</Ref>
<small>
{t("SettingsPage.profile.displayName.fallbackNotice")}
</small>
</Form.Field>
<Form.Field error={errors?.bio}>
<label>{t("SettingsPage.profile.bio.label")}</label>
<Ref innerRef={register}>
<TextArea name="bio" rows={4} defaultValue={login.bio} />
</Ref>
</Form.Field>
<Form.Field error={errors?.image}>
<label>{t("SettingsPage.profile.avatarUrl.label")}</label>
<Ref innerRef={findInput(register)}>
<Input name="image" defaultValue={login.image} />
</Ref>
</Form.Field>
<Button type="submit" primary>
{t("general.save")}
</Button>
</Form>
</Grid.Column>
<Grid.Column width={6}>
<ApiKeyDialog {...{ login, onGenerateNewKey }} />
<Divider />
<Stats user={login.id} />
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
);
}
);
function CopyInput({ value, ...props }) {
const { t } = useTranslation();
const [success, setSuccess] = React.useState(null);
const onClick = async () => {
try {
await window.navigator?.clipboard?.writeText(value);
setSuccess(true);
} catch (err) {
setSuccess(false);
} finally {
setTimeout(() => {
setSuccess(null);
}, 2000);
}
};
return (
<Popup
trigger={
<Input
{...props}
value={value}
fluid
action={{ icon: "copy", onClick }}
/>
}
position="top right"
open={success != null}
content={success ? t("general.copied") : t("general.copyError")}
/>
);
}
const selectField = findInput((ref) => ref?.select());
function ApiKeyDialog({ login, onGenerateNewKey }) {
const { t } = useTranslation();
const config = useConfig();
const [show, setShow] = React.useState(false);
const onClick = React.useCallback(
(e) => {
e.preventDefault();
setShow(true);
},
[setShow]
);
const onGenerateNewKeyInner = React.useCallback(
(e) => {
e.preventDefault();
onGenerateNewKey();
},
[onGenerateNewKey]
);
return (
<>
<Header as="h2">{t("SettingsPage.apiKey.title")}</Header>
<Markdown>{t("SettingsPage.apiKey.description")}</Markdown>
<div style={{ minHeight: 40, marginBottom: 16 }}>
{show ? (
login.apiKey ? (
<Ref innerRef={selectField}>
<CopyInput
label={t("SettingsPage.apiKey.key.label")}
value={login.apiKey}
/>
</Ref>
) : (
<Message warning content={t("SettingsPage.apiKey.key.empty")} />
)
) : (
<Button onClick={onClick}>
<Icon name="lock" /> {t("SettingsPage.apiKey.key.show")}
</Button>
)}
</div>
<Markdown>{t("SettingsPage.apiKey.urlDescription")}</Markdown>
<div style={{ marginBottom: 16 }}>
<CopyInput
label={t("SettingsPage.apiKey.url.label")}
value={config?.apiUrl?.replace(/\/api$/, "") ?? "..."}
/>
</div>
<Markdown>{t("SettingsPage.apiKey.generateDescription")}</Markdown>
<p></p>
<Button onClick={onGenerateNewKeyInner}>
{t("SettingsPage.apiKey.generate")}
</Button>
</>
);
}
export default SettingsPage;

View file

@ -0,0 +1,102 @@
import React from 'react'
import {connect} from 'react-redux'
import {Message, Icon, Button, Ref, Input, Segment, Popup} from 'semantic-ui-react'
import Markdown from 'react-markdown'
import {useTranslation} from 'react-i18next'
import {setLogin} from 'reducers/login'
import api from 'api'
import {findInput} from 'utils'
import {useConfig} from 'config'
function CopyInput({value, ...props}) {
const {t} = useTranslation()
const [success, setSuccess] = React.useState(null)
const onClick = async () => {
try {
await window.navigator?.clipboard?.writeText(value)
setSuccess(true)
} catch (err) {
setSuccess(false)
} finally {
setTimeout(() => {
setSuccess(null)
}, 2000)
}
}
return (
<Popup
trigger={<Input {...props} value={value} fluid action={{icon: 'copy', onClick}} />}
position="top right"
open={success != null}
content={success ? t('general.copied') : t('general.copyError')}
/>
)
}
const selectField = findInput((ref) => ref?.select())
const ApiKeySettings = connect((state) => ({login: state.login}), {
setLogin,
})(function ApiKeySettings({login, setLogin, setErrors}) {
const {t} = useTranslation()
const [loading, setLoading] = React.useState(false)
const config = useConfig()
const [show, setShow] = React.useState(false)
const onClick = React.useCallback(
(e) => {
e.preventDefault()
setShow(true)
},
[setShow]
)
const onGenerateNewKey = React.useCallback(
async (e) => {
e.preventDefault()
setLoading(true)
try {
const response = await api.put('/user', {
body: {updateApiKey: true},
})
setLogin(response)
} catch (err) {
setErrors(err.errors)
} finally {
setLoading(false)
}
},
[setLoading, setLogin, setErrors]
)
return (
<Segment style={{maxWidth: 600, margin: '24px auto'}}>
<Markdown>{t('SettingsPage.apiKey.description')}</Markdown>
<div style={{minHeight: 40, marginBottom: 16}}>
{show ? (
login.apiKey ? (
<Ref innerRef={selectField}>
<CopyInput label={t('SettingsPage.apiKey.key.label')} value={login.apiKey} />
</Ref>
) : (
<Message warning content={t('SettingsPage.apiKey.key.empty')} />
)
) : (
<Button onClick={onClick}>
<Icon name="lock" /> {t('SettingsPage.apiKey.key.show')}
</Button>
)}
</div>
<Markdown>{t('SettingsPage.apiKey.urlDescription')}</Markdown>
<div style={{marginBottom: 16}}>
<CopyInput label={t('SettingsPage.apiKey.url.label')} value={config?.apiUrl?.replace(/\/api$/, '') ?? '...'} />
</div>
<Markdown>{t('SettingsPage.apiKey.generateDescription')}</Markdown>
<p></p>
<Button onClick={onGenerateNewKey}>{t('SettingsPage.apiKey.generate')}</Button>
</Segment>
)
})
export default ApiKeySettings

View file

@ -0,0 +1,125 @@
import React, {useCallback, useMemo, useRef} from 'react'
import {useObservable} from 'rxjs-hooks'
import {concat, from, of, Subject} from 'rxjs'
import {Table, Button, Input} from 'semantic-ui-react'
import {useTranslation} from 'react-i18next'
import api from 'api'
import {UserDevice} from 'types'
import {startWith, switchMap} from 'rxjs/operators'
function EditField({value, onEdit}) {
const [editing, setEditing] = React.useState(false)
const [tempValue, setTempValue] = React.useState(value)
const timeoutRef = useRef<null | number>(null)
const cancelTimeout = useCallback(() => {
if (timeoutRef.current != null) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [timeoutRef])
const abort = useCallback(() => {
cancelTimeout()
setEditing(false)
setTempValue(value)
}, [setEditing, setTempValue, value, cancelTimeout])
const confirm = useCallback(() => {
console.log('confirmed')
cancelTimeout()
setEditing(false)
onEdit(tempValue)
}, [setEditing, onEdit, tempValue, cancelTimeout])
React.useEffect(() => {
if (value !== tempValue) {
setTempValue(value)
}
}, [value])
if (editing) {
return (
<>
<Input
value={tempValue}
onChange={(e) => setTempValue(e.target.value)}
onBlur={(e) => {
timeoutRef.current = setTimeout(abort, 20)
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
confirm()
} else if (e.key === 'Escape') {
abort()
}
}}
style={{marginRight: 8}}
/>
</>
)
} else {
return (
<>
{value && <span style={{marginRight: 8}}>{value}</span>}
<Button icon="edit" size="tiny" onClick={() => setEditing(true)} />
</>
)
}
}
export default function DeviceList() {
const {t} = useTranslation()
const [loading_, setLoading] = React.useState(false)
const trigger$ = useMemo(() => new Subject(), [])
const devices: null | UserDevice[] = useObservable(() =>
trigger$.pipe(
startWith(null),
switchMap(() => concat(of(null), from(api.get('/user/devices'))))
)
)
const setDeviceDisplayName = useCallback(
async (deviceId: number, displayName: string) => {
setLoading(true)
try {
await api.put(`/user/devices/${deviceId}`, {body: {displayName}})
} finally {
setLoading(false)
trigger$.next(null)
}
},
[trigger$, setLoading]
)
const loading = devices == null || loading_
return (
<>
<Table compact {...{loading}}>
<Table.Header>
<Table.Row>
<Table.HeaderCell width={4}>{t('SettingsPage.devices.identifier')}</Table.HeaderCell>
<Table.HeaderCell>{t('SettingsPage.devices.alias')}</Table.HeaderCell>
<Table.HeaderCell />
</Table.Row>
</Table.Header>
<Table.Body>
{devices?.map((device: UserDevice) => (
<Table.Row key={device.id}>
<Table.Cell> {device.identifier}</Table.Cell>
<Table.Cell>
<EditField
value={device.displayName}
onEdit={(displayName: string) => setDeviceDisplayName(device.id, displayName)}
/>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</>
)
}

View file

@ -0,0 +1,77 @@
import React from 'react'
import {connect} from 'react-redux'
import {Segment, Message, Form, Button, TextArea, Ref, Input} from 'semantic-ui-react'
import {useForm} from 'react-hook-form'
import {useTranslation} from 'react-i18next'
import {setLogin} from 'reducers/login'
import api from 'api'
import {findInput} from 'utils'
const UserSettingsForm = connect((state) => ({login: state.login}), {
setLogin,
})(function UserSettingsForm({login, setLogin, errors, setErrors}) {
const {t} = useTranslation()
const {register, handleSubmit} = useForm()
const [loading, setLoading] = React.useState(false)
const onSave = React.useCallback(
async (changes) => {
setLoading(true)
setErrors(null)
try {
const response = await api.put('/user', {body: changes})
setLogin(response)
} catch (err) {
setErrors(err.errors)
} finally {
setLoading(false)
}
},
[setLoading, setLogin, setErrors]
)
return (
<Segment style={{maxWidth: 600}}>
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
<Form.Field error={errors?.username}>
<label>{t('SettingsPage.profile.username.label')}</label>
<Ref innerRef={findInput(register)}>
<Input name="username" defaultValue={login.username} disabled />
</Ref>
<small>{t('SettingsPage.profile.username.hint')}</small>
</Form.Field>
<Message info visible>
{t('SettingsPage.profile.publicNotice')}
</Message>
<Form.Field error={errors?.displayName}>
<label>{t('SettingsPage.profile.displayName.label')}</label>
<Ref innerRef={findInput(register)}>
<Input name="displayName" defaultValue={login.displayName} placeholder={login.username} />
</Ref>
<small>{t('SettingsPage.profile.displayName.fallbackNotice')}</small>
</Form.Field>
<Form.Field error={errors?.bio}>
<label>{t('SettingsPage.profile.bio.label')}</label>
<Ref innerRef={register}>
<TextArea name="bio" rows={4} defaultValue={login.bio} />
</Ref>
</Form.Field>
<Form.Field error={errors?.image}>
<label>{t('SettingsPage.profile.avatarUrl.label')}</label>
<Ref innerRef={findInput(register)}>
<Input name="image" defaultValue={login.image} />
</Ref>
</Form.Field>
<Button type="submit" primary>
{t('general.save')}
</Button>
</Form>
</Segment>
)
})
export default UserSettingsForm

View file

@ -0,0 +1,68 @@
import React from 'react'
import {connect} from 'react-redux'
import {Header, Tab} from 'semantic-ui-react'
import {useForm} from 'react-hook-form'
import {useTranslation} from 'react-i18next'
import {setLogin} from 'reducers/login'
import {Page, Stats} from 'components'
import api from 'api'
import ApiKeySettings from './ApiKeySettings'
import UserSettingsForm from './UserSettingsForm'
import DeviceList from './DeviceList'
const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) {
const {t} = useTranslation()
const {register, handleSubmit} = useForm()
const [loading, setLoading] = React.useState(false)
const [errors, setErrors] = React.useState(null)
const onGenerateNewKey = React.useCallback(async () => {
setLoading(true)
setErrors(null)
try {
const response = await api.put('/user', {
body: {updateApiKey: true},
})
setLogin(response)
} catch (err) {
setErrors(err.errors)
} finally {
setLoading(false)
}
}, [setLoading, setLogin, setErrors])
return (
<Page title={t('SettingsPage.title')}>
<Header as="h1">{t('SettingsPage.title')}</Header>
<Tab
menu={{secondary: true, pointing: true}}
panes={[
{
menuItem: t('SettingsPage.profile.title'),
render: () => <UserSettingsForm {...{errors, setErrors}} />,
},
{
menuItem: t('SettingsPage.apiKey.title'),
render: () => <ApiKeySettings {...{errors, setErrors}} />,
},
{
menuItem: t('SettingsPage.stats.title'),
render: () => <Stats user={login.id} />,
},
{
menuItem: t('SettingsPage.devices.title'),
render: () => <DeviceList />,
},
]}
/>
</Page>
)
})
export default SettingsPage

View file

@ -1,6 +1,6 @@
import React from "react"; import React from 'react'
import _ from "lodash"; import _ from 'lodash'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { import {
Divider, Divider,
Message, Message,
@ -14,31 +14,31 @@ import {
TextArea, TextArea,
Checkbox, Checkbox,
Header, Header,
} from "semantic-ui-react"; } from 'semantic-ui-react'
import { useHistory, useParams, Link } from "react-router-dom"; import {useHistory, useParams, Link} from 'react-router-dom'
import { concat, of, from } from "rxjs"; import {concat, of, from} from 'rxjs'
import { pluck, distinctUntilChanged, map, switchMap } from "rxjs/operators"; import {pluck, distinctUntilChanged, map, switchMap} from 'rxjs/operators'
import { useObservable } from "rxjs-hooks"; import {useObservable} from 'rxjs-hooks'
import { findInput } from "utils"; import {findInput} from 'utils'
import { useForm, Controller } from "react-hook-form"; import {useForm, Controller} from 'react-hook-form'
import { useTranslation, Trans as Translate } from "react-i18next"; import {useTranslation, Trans as Translate} from 'react-i18next'
import Markdown from "react-markdown"; import Markdown from 'react-markdown'
import api from "api"; import api from 'api'
import { Page, FileUploadField } from "components"; import {Page, FileUploadField} from 'components'
import type { Track } from "types"; import type {Track} from 'types'
import { FileUploadStatus } from "pages/UploadPage"; import {FileUploadStatus} from 'pages/UploadPage'
function ReplaceTrackData({ slug }) { function ReplaceTrackData({slug}) {
const { t } = useTranslation(); const {t} = useTranslation()
const [file, setFile] = React.useState(null); const [file, setFile] = React.useState(null)
const [result, setResult] = React.useState(null); const [result, setResult] = React.useState(null)
const onComplete = React.useCallback((_id, r) => setResult(r), [setResult]); const onComplete = React.useCallback((_id, r) => setResult(r), [setResult])
return ( return (
<> <>
<Header as="h2">{t("TrackEditor.replaceTrackData")}</Header> <Header as="h2">{t('TrackEditor.replaceTrackData')}</Header>
{!file ? ( {!file ? (
<FileUploadField onSelect={setFile} /> <FileUploadField onSelect={setFile} />
) : result ? ( ) : result ? (
@ -48,167 +48,146 @@ function ReplaceTrackData({ slug }) {
</Translate> </Translate>
</Message> </Message>
) : ( ) : (
<FileUploadStatus {...{ file, onComplete, slug }} /> <FileUploadStatus {...{file, onComplete, slug}} />
)} )}
</> </>
); )
} }
const TrackEditor = connect((state) => ({ login: state.login }))( const TrackEditor = connect((state) => ({login: state.login}))(function TrackEditor({login}) {
function TrackEditor({ login }) { const {t} = useTranslation()
const { t } = useTranslation(); const [busy, setBusy] = React.useState(false)
const [busy, setBusy] = React.useState(false); const {register, control, handleSubmit} = useForm()
const { register, control, handleSubmit } = useForm(); const {slug} = useParams()
const { slug } = useParams(); const history = useHistory()
const history = useHistory();
const track: null | Track = useObservable( const track: null | Track = useObservable(
(_$, args$) => { (_$, args$) => {
const slug$ = args$.pipe(pluck(0), distinctUntilChanged()); const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
return slug$.pipe( return slug$.pipe(
map((slug) => `/tracks/${slug}`), map((slug) => `/tracks/${slug}`),
switchMap((url) => concat(of(null), from(api.get(url)))), switchMap((url) => concat(of(null), from(api.get(url)))),
pluck("track") pluck('track')
); )
}, },
null, null,
[slug] [slug]
); )
const loading = busy || track == null; const loading = busy || track == null
const isAuthor = login?.id === track?.author?.id; const isAuthor = login?.id === track?.author?.id
// Navigate to track detials if we are not the author // Navigate to track detials if we are not the author
React.useEffect(() => { React.useEffect(() => {
if (!login || (track && !isAuthor)) { if (!login || (track && !isAuthor)) {
history.replace(`/tracks/${slug}`); history.replace(`/tracks/${slug}`)
} }
}, [slug, login, track, isAuthor, history]); }, [slug, login, track, isAuthor, history])
const onSubmit = React.useMemo( const onSubmit = React.useMemo(
() => () =>
handleSubmit(async (values) => { handleSubmit(async (values) => {
setBusy(true); setBusy(true)
try { try {
await api.put(`/tracks/${slug}`, { await api.put(`/tracks/${slug}`, {
body: { body: {
track: _.pickBy(values, (v) => typeof v !== "undefined"), track: _.pickBy(values, (v) => typeof v !== 'undefined'),
}, },
}); })
history.push(`/tracks/${slug}`); history.push(`/tracks/${slug}`)
} finally { } finally {
setBusy(false); setBusy(false)
} }
}), }),
[slug, handleSubmit, history] [slug, handleSubmit, history]
); )
const [confirmDelete, setConfirmDelete] = React.useState(false); const [confirmDelete, setConfirmDelete] = React.useState(false)
const onDelete = React.useCallback(async () => { const onDelete = React.useCallback(async () => {
setBusy(true); setBusy(true)
try { try {
await api.delete(`/tracks/${slug}`); await api.delete(`/tracks/${slug}`)
history.push("/tracks"); history.push('/tracks')
} finally { } finally {
setConfirmDelete(false); setConfirmDelete(false)
setBusy(false); setBusy(false)
} }
}, [setBusy, setConfirmDelete, slug, history]); }, [setBusy, setConfirmDelete, slug, history])
const trackTitle: string = track?.title || t("general.unnamedTrack"); const trackTitle: string = track?.title || t('general.unnamedTrack')
const title = t("TrackEditor.title", { trackTitle }); const title = t('TrackEditor.title', {trackTitle})
return ( return (
<Page title={title}> <Page title={title}>
<Grid centered relaxed divided stackable> <Grid centered relaxed divided stackable>
<Grid.Row> <Grid.Row>
<Grid.Column width={10}> <Grid.Column width={10}>
<Header as="h2">{title}</Header> <Header as="h2">{title}</Header>
<Form loading={loading} key={track?.slug} onSubmit={onSubmit}> <Form loading={loading} key={track?.slug} onSubmit={onSubmit}>
<Ref innerRef={findInput(register)}> <Ref innerRef={findInput(register)}>
<Form.Input <Form.Input label="Title" name="title" defaultValue={track?.title} style={{fontSize: '120%'}} />
label="Title" </Ref>
name="title"
defaultValue={track?.title} <Form.Field>
style={{ fontSize: "120%" }} <label>{t('TrackEditor.description.label')}</label>
/> <Ref innerRef={register}>
<TextArea name="description" rows={4} defaultValue={track?.description} />
</Ref> </Ref>
</Form.Field>
<Form.Field> <Form.Field>
<label>{t("TrackEditor.description.label")}</label> <label>
<Ref innerRef={register}> {t('TrackEditor.visibility.label')}
<TextArea <Popup
name="description" wide="very"
rows={4} content={<Markdown>{t('TrackEditor.visibility.description')}</Markdown>}
defaultValue={track?.description} trigger={<Icon name="warning sign" style={{marginLeft: 8}} color="orange" />}
/>
</Ref>
</Form.Field>
<Form.Field>
<label>
{t("TrackEditor.visibility.label")}
<Popup
wide="very"
content={
<Markdown>
{t("TrackEditor.visibility.description")}
</Markdown>
}
trigger={
<Icon
name="warning sign"
style={{ marginLeft: 8 }}
color="orange"
/>
}
/>
</label>
<Controller
name="public"
control={control}
defaultValue={track?.public}
render={(props) => (
<Checkbox
name="public"
label={t("TrackEditor.visibility.checkboxLabel")}
checked={props.value}
onChange={(_, { checked }) => props.onChange(checked)}
/>
)}
/> />
</Form.Field> </label>
<Button type="submit">{t("general.save")}</Button>
</Form>
</Grid.Column>
<Grid.Column width={6}>
<ReplaceTrackData slug={slug} />
<Divider /> <Controller
name="public"
control={control}
defaultValue={track?.public}
render={(props) => (
<Checkbox
name="public"
label={t('TrackEditor.visibility.checkboxLabel')}
checked={props.value}
onChange={(_, {checked}) => props.onChange(checked)}
/>
)}
/>
</Form.Field>
<Button type="submit">{t('general.save')}</Button>
</Form>
</Grid.Column>
<Grid.Column width={6}>
<ReplaceTrackData slug={slug} />
<Header as="h2">{t("TrackEditor.dangerZone.title")}</Header> <Divider />
<Markdown>{t("TrackEditor.dangerZone.description")}</Markdown>
<Button color="red" onClick={() => setConfirmDelete(true)}> <Header as="h2">{t('TrackEditor.dangerZone.title')}</Header>
{t("general.delete")} <Markdown>{t('TrackEditor.dangerZone.description')}</Markdown>
</Button>
<Confirm
open={confirmDelete}
onCancel={() => setConfirmDelete(false)}
onConfirm={onDelete}
content={t("TrackEditor.dangerZone.confirmDelete")}
confirmButton={t("general.delete")}
cancelButton={t("general.cancel")}
/>
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
);
}
);
export default TrackEditor; <Button color="red" onClick={() => setConfirmDelete(true)}>
{t('general.delete')}
</Button>
<Confirm
open={confirmDelete}
onCancel={() => setConfirmDelete(false)}
onConfirm={onDelete}
content={t('TrackEditor.dangerZone.confirmDelete')}
confirmButton={t('general.delete')}
cancelButton={t('general.cancel')}
/>
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
)
})
export default TrackEditor

View file

@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import {Link} from 'react-router-dom' import {Link} from 'react-router-dom'
import {Icon, Popup, Button, Dropdown} from 'semantic-ui-react' import {Icon, Popup, Button, Dropdown} from 'semantic-ui-react'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
export default function TrackActions({slug, isAuthor, onDownload}) { export default function TrackActions({slug, isAuthor, onDownload}) {
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<> <>
@ -25,7 +25,11 @@ export default function TrackActions({slug, isAuthor, onDownload}) {
<Dropdown text={t('TrackPage.actions.download')} button> <Dropdown text={t('TrackPage.actions.download')} button>
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Item text={t('TrackPage.actions.original')}onClick={() => onDownload('original.csv')} disabled={!isAuthor} /> <Dropdown.Item
text={t('TrackPage.actions.original')}
onClick={() => onDownload('original.csv')}
disabled={!isAuthor}
/>
<Dropdown.Item text={t('TrackPage.actions.gpx')} onClick={() => onDownload('track.gpx')} /> <Dropdown.Item text={t('TrackPage.actions.gpx')} onClick={() => onDownload('track.gpx')} />
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>

View file

@ -1,57 +1,34 @@
import React from "react"; import React from 'react'
import { import {Message, Segment, Form, Button, Loader, Header, Comment} from 'semantic-ui-react'
Message, import Markdown from 'react-markdown'
Segment, import {useTranslation} from 'react-i18next'
Form,
Button,
Loader,
Header,
Comment,
} from "semantic-ui-react";
import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
import { Avatar, FormattedDate } from "components"; import {Avatar, FormattedDate} from 'components'
function CommentForm({ onSubmit }) { function CommentForm({onSubmit}) {
const { t } = useTranslation(); const {t} = useTranslation()
const [body, setBody] = React.useState(""); const [body, setBody] = React.useState('')
const onSubmitComment = React.useCallback(() => { const onSubmitComment = React.useCallback(() => {
onSubmit({ body }); onSubmit({body})
setBody(""); setBody('')
}, [onSubmit, body]); }, [onSubmit, body])
return ( return (
<Form reply onSubmit={onSubmitComment}> <Form reply onSubmit={onSubmitComment}>
<Form.TextArea <Form.TextArea rows={4} value={body} onChange={(e) => setBody(e.target.value)} />
rows={4} <Button content={t('TrackPage.comments.post')} labelPosition="left" icon="edit" primary />
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<Button
content={t("TrackPage.comments.post")}
labelPosition="left"
icon="edit"
primary
/>
</Form> </Form>
); )
} }
export default function TrackComments({ export default function TrackComments({comments, onSubmit, onDelete, login, hideLoader}) {
comments, const {t} = useTranslation()
onSubmit,
onDelete,
login,
hideLoader,
}) {
const { t } = useTranslation();
return ( return (
<> <>
<Comment.Group> <Comment.Group>
<Header as="h2" dividing> <Header as="h2" dividing>
{t("TrackPage.comments.title")} {t('TrackPage.comments.title')}
</Header> </Header>
<Loader active={!hideLoader && comments == null} inline /> <Loader active={!hideLoader && comments == null} inline />
@ -60,9 +37,7 @@ export default function TrackComments({
<Comment key={comment.id}> <Comment key={comment.id}>
<Avatar user={comment.author} /> <Avatar user={comment.author} />
<Comment.Content> <Comment.Content>
<Comment.Author as="a"> <Comment.Author as="a">{comment.author.displayName}</Comment.Author>
{comment.author.displayName}
</Comment.Author>
<Comment.Metadata> <Comment.Metadata>
<div> <div>
<FormattedDate date={comment.createdAt} relative /> <FormattedDate date={comment.createdAt} relative />
@ -75,11 +50,11 @@ export default function TrackComments({
<Comment.Actions> <Comment.Actions>
<Comment.Action <Comment.Action
onClick={(e) => { onClick={(e) => {
onDelete(comment.id); onDelete(comment.id)
e.preventDefault(); e.preventDefault()
}} }}
> >
{t("general.delete")} {t('general.delete')}
</Comment.Action> </Comment.Action>
</Comment.Actions> </Comment.Actions>
)} )}
@ -87,12 +62,10 @@ export default function TrackComments({
</Comment> </Comment>
))} ))}
{comments != null && !comments.length && ( {comments != null && !comments.length && <Message>{t('TrackPage.comments.empty')}</Message>}
<Message>{t("TrackPage.comments.empty")}</Message>
)}
{login && comments != null && <CommentForm onSubmit={onSubmit} />} {login && comments != null && <CommentForm onSubmit={onSubmit} />}
</Comment.Group> </Comment.Group>
</> </>
); )
} }

View file

@ -1,85 +1,54 @@
import React from "react"; import React from 'react'
import _ from "lodash"; import _ from 'lodash'
import { List, Header, Grid } from "semantic-ui-react"; import {List, Header, Grid} from 'semantic-ui-react'
import { Duration } from "luxon"; import {Duration} from 'luxon'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import { FormattedDate, Visibility } from "components"; import {FormattedDate, Visibility} from 'components'
import {formatDistance, formatDuration} from 'utils'
function formatDuration(seconds) { export default function TrackDetails({track, isAuthor}) {
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'"); const {t} = useTranslation()
}
export default function TrackDetails({ track, isAuthor }) {
const { t } = useTranslation();
const items = [ const items = [
track.public != null && track.public != null && isAuthor && [t('TrackPage.details.visibility'), <Visibility public={track.public} />],
isAuthor && [
t("TrackPage.details.visibility"),
<Visibility public={track.public} />,
],
track.uploadedByUserAgent != null && [ track.uploadedByUserAgent != null && [t('TrackPage.details.uploadedWith'), track.uploadedByUserAgent],
t("TrackPage.details.uploadedWith"),
track.uploadedByUserAgent,
],
track.duration != null && [ track.duration != null && [t('TrackPage.details.duration'), formatDuration(track.duration)],
t("TrackPage.details.duration"),
formatDuration(track.duration),
],
track.createdAt != null && [ track.createdAt != null && [t('TrackPage.details.uploadedDate'), <FormattedDate date={track.createdAt} />],
t("TrackPage.details.uploadedDate"),
<FormattedDate date={track.createdAt} />,
],
track?.recordedAt != null && [ track?.recordedAt != null && [t('TrackPage.details.recordedDate'), <FormattedDate date={track?.recordedAt} />],
t("TrackPage.details.recordedDate"),
<FormattedDate date={track?.recordedAt} />,
],
track?.numEvents != null && [ track?.numEvents != null && [t('TrackPage.details.numEvents'), track?.numEvents],
t("TrackPage.details.numEvents"),
track?.numEvents,
],
track?.length != null && [ track?.length != null && [t('TrackPage.details.length'), formatDistance(track?.length)],
t("TrackPage.details.length"),
`${(track?.length / 1000).toFixed(2)} km`,
],
track?.processingStatus != null && track?.processingStatus != null &&
track?.processingStatus != "error" && [ track?.processingStatus != 'error' && [t('TrackPage.details.processingStatus'), track.processingStatus],
t("TrackPage.details.processingStatus"),
track.processingStatus,
],
track.originalFileName != null && [ track.originalFileName != null && [t('TrackPage.details.originalFileName'), <code>{track.originalFileName}</code>],
t("TrackPage.details.originalFileName"), ].filter(Boolean)
<code>{track.originalFileName}</code>,
],
].filter(Boolean);
const COLUMNS = 4; const COLUMNS = 4
const chunkSize = Math.ceil(items.length / COLUMNS) const chunkSize = Math.ceil(items.length / COLUMNS)
return ( return (
<Grid> <Grid>
<Grid.Row columns={COLUMNS}> <Grid.Row columns={COLUMNS}>
{_.chunk(items, chunkSize).map((chunkItems, idx) => ( {_.chunk(items, chunkSize).map((chunkItems, idx) => (
<Grid.Column key={idx}> <Grid.Column key={idx}>
<List>
<List> {chunkItems.map(([title, value]) => (
{chunkItems.map(([title, value]) => ( <List.Item key={title}>
<List.Item key={title}> <List.Header>{title}</List.Header>
<List.Header>{title}</List.Header> <List.Description>{value}</List.Description>
<List.Description>{value}</List.Description> </List.Item>
</List.Item>))} ))}
</List> </List>
</Grid.Column> </Grid.Column>
))} ))}
</Grid.Row> </Grid.Row>
</Grid> </Grid>
); )
} }

View file

@ -61,7 +61,6 @@ export default function TrackMap({
layout: { layout: {
'text-field': ['number-format', ['get', p], {'min-fraction-digits': 2, 'max-fraction-digits': 2}], 'text-field': ['number-format', ['get', p], {'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-size': 14, 'text-size': 14,
'text-keep-upright': false, 'text-keep-upright': false,
'text-anchor': a, 'text-anchor': a,

View file

@ -1,5 +1,5 @@
import React from "react"; import React from 'react'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { import {
List, List,
Dropdown, Dropdown,
@ -12,354 +12,323 @@ import {
Message, Message,
Confirm, Confirm,
Container, Container,
} from "semantic-ui-react"; } from 'semantic-ui-react'
import { useParams, useHistory } from "react-router-dom"; import {useParams, useHistory} from 'react-router-dom'
import { concat, combineLatest, of, from, Subject } from "rxjs"; import {concat, combineLatest, of, from, Subject} from 'rxjs'
import { import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators'
pluck, import {useObservable} from 'rxjs-hooks'
distinctUntilChanged, import Markdown from 'react-markdown'
map, import {useTranslation} from 'react-i18next'
switchMap,
startWith,
catchError,
} from "rxjs/operators";
import { useObservable } from "rxjs-hooks";
import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
import api from "api"; import api from 'api'
import { Page } from "components"; import {Page} from 'components'
import type { Track, TrackData, TrackComment } from "types"; import type {Track, TrackData, TrackComment} from 'types'
import { trackLayer, trackLayerRaw } from "../../mapstyles"; import {trackLayer, trackLayerRaw} from '../../mapstyles'
import TrackActions from "./TrackActions"; import TrackActions from './TrackActions'
import TrackComments from "./TrackComments"; import TrackComments from './TrackComments'
import TrackDetails from "./TrackDetails"; import TrackDetails from './TrackDetails'
import TrackMap from "./TrackMap"; import TrackMap from './TrackMap'
import styles from "./TrackPage.module.less"; import styles from './TrackPage.module.less'
function useTriggerSubject() { function useTriggerSubject() {
const subject$ = React.useMemo(() => new Subject(), []); const subject$ = React.useMemo(() => new Subject(), [])
const trigger = React.useCallback(() => subject$.next(null), [subject$]); const trigger = React.useCallback(() => subject$.next(null), [subject$])
return [trigger, subject$]; return [trigger, subject$]
} }
function TrackMapSettings({ function TrackMapSettings({showTrack, setShowTrack, pointsMode, setPointsMode, side, setSide}) {
showTrack, const {t} = useTranslation()
setShowTrack,
pointsMode,
setPointsMode,
side,
setSide,
}) {
const { t } = useTranslation();
return ( return (
<> <>
<Header as="h4">{t("TrackPage.mapSettings.title")}</Header> <Header as="h4">{t('TrackPage.mapSettings.title')}</Header>
<List> <List>
<List.Item> <List.Item>
<Checkbox <Checkbox checked={showTrack} onChange={(e, d) => setShowTrack(d.checked)} />{' '}
checked={showTrack} {t('TrackPage.mapSettings.showTrack')}
onChange={(e, d) => setShowTrack(d.checked)} <div style={{marginTop: 8}}>
/>{" "}
{t("TrackPage.mapSettings.showTrack")}
<div style={{ marginTop: 8 }}>
<span <span
style={{ style={{
borderTop: "3px dashed " + trackLayerRaw.paint["line-color"], borderTop: '3px dashed ' + trackLayerRaw.paint['line-color'],
height: 0, height: 0,
width: 24, width: 24,
display: "inline-block", display: 'inline-block',
verticalAlign: "middle", verticalAlign: 'middle',
marginRight: 4, marginRight: 4,
}} }}
/> />
{t("TrackPage.mapSettings.gpsTrack")} {t('TrackPage.mapSettings.gpsTrack')}
</div> </div>
<div> <div>
<span <span
style={{ style={{
borderTop: "6px solid " + trackLayerRaw.paint["line-color"], borderTop: '6px solid ' + trackLayerRaw.paint['line-color'],
height: 6, height: 6,
width: 24, width: 24,
display: "inline-block", display: 'inline-block',
verticalAlign: "middle", verticalAlign: 'middle',
marginRight: 4, marginRight: 4,
}} }}
/> />
{t("TrackPage.mapSettings.snappedTrack")} {t('TrackPage.mapSettings.snappedTrack')}
</div> </div>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header> {t("TrackPage.mapSettings.points")} </List.Header> <List.Header> {t('TrackPage.mapSettings.points')} </List.Header>
<Dropdown <Dropdown
selection selection
value={pointsMode} value={pointsMode}
onChange={(e, d) => setPointsMode(d.value)} onChange={(e, d) => setPointsMode(d.value)}
options={[ options={[
{ key: "none", value: "none", text: "None" }, {key: 'none', value: 'none', text: 'None'},
{ {
key: "overtakingEvents", key: 'overtakingEvents',
value: "overtakingEvents", value: 'overtakingEvents',
text: t("TrackPage.mapSettings.confirmedPoints"), text: t('TrackPage.mapSettings.confirmedPoints'),
}, },
{ {
key: "measurements", key: 'measurements',
value: "measurements", value: 'measurements',
text: t("TrackPage.mapSettings.allPoints"), text: t('TrackPage.mapSettings.allPoints'),
}, },
]} ]}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>{t("TrackPage.mapSettings.side")}</List.Header> <List.Header>{t('TrackPage.mapSettings.side')}</List.Header>
<Dropdown <Dropdown
selection selection
value={side} value={side}
onChange={(e, d) => setSide(d.value)} onChange={(e, d) => setSide(d.value)}
options={[ options={[
{ {
key: "overtaker", key: 'overtaker',
value: "overtaker", value: 'overtaker',
text: t("TrackPage.mapSettings.overtakerSide"), text: t('TrackPage.mapSettings.overtakerSide'),
}, },
{ {
key: "stationary", key: 'stationary',
value: "stationary", value: 'stationary',
text: t("TrackPage.mapSettings.stationarySide"), text: t('TrackPage.mapSettings.stationarySide'),
}, },
]} ]}
/> />
</List.Item> </List.Item>
</List> </List>
</> </>
); )
} }
const TrackPage = connect((state) => ({ login: state.login }))( const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) {
function TrackPage({ login }) { const {slug} = useParams()
const { slug } = useParams(); const {t} = useTranslation()
const { t } = useTranslation();
const [reloadComments, reloadComments$] = useTriggerSubject(); const [reloadComments, reloadComments$] = useTriggerSubject()
const history = useHistory(); const history = useHistory()
const data: { const data: {
track: null | Track; track: null | Track
trackData: null | TrackData; trackData: null | TrackData
comments: null | TrackComment[]; comments: null | TrackComment[]
} | null = useObservable( } | null = useObservable(
(_$, args$) => { (_$, args$) => {
const slug$ = args$.pipe(pluck(0), distinctUntilChanged()); const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
const track$ = slug$.pipe( const track$ = slug$.pipe(
map((slug) => `/tracks/${slug}`), map((slug) => `/tracks/${slug}`),
switchMap((url) => switchMap((url) =>
concat( concat(
of(null), of(null),
from(api.get(url)).pipe(
catchError(() => {
history.replace("/tracks");
})
)
)
),
pluck("track")
);
const trackData$ = slug$.pipe(
map((slug) => `/tracks/${slug}/data`),
switchMap((url) =>
concat(
of(undefined),
from(api.get(url)).pipe(
catchError(() => {
return of(null);
})
)
)
),
startWith(undefined) // show track infos before track data is loaded
);
const comments$ = concat(of(null), reloadComments$).pipe(
switchMap(() => slug$),
map((slug) => `/tracks/${slug}/comments`),
switchMap((url) =>
from(api.get(url)).pipe( from(api.get(url)).pipe(
catchError(() => { catchError(() => {
return of(null); history.replace('/tracks')
}) })
) )
), )
pluck("comments"), ),
startWith(null) // show track infos before comments are loaded pluck('track')
); )
return combineLatest([track$, trackData$, comments$]).pipe( const trackData$ = slug$.pipe(
map(([track, trackData, comments]) => ({ map((slug) => `/tracks/${slug}/data`),
track, switchMap((url) =>
trackData, concat(
comments, of(undefined),
})) from(api.get(url)).pipe(
); catchError(() => {
}, return of(null)
null, })
[slug] )
); )
),
startWith(undefined) // show track infos before track data is loaded
)
const onSubmitComment = React.useCallback( const comments$ = concat(of(null), reloadComments$).pipe(
async ({ body }) => { switchMap(() => slug$),
await api.post(`/tracks/${slug}/comments`, { map((slug) => `/tracks/${slug}/comments`),
body: { comment: { body } }, switchMap((url) =>
}); from(api.get(url)).pipe(
reloadComments(); catchError(() => {
}, return of(null)
[slug, reloadComments] })
); )
),
pluck('comments'),
startWith(null) // show track infos before comments are loaded
)
const onDeleteComment = React.useCallback( return combineLatest([track$, trackData$, comments$]).pipe(
async (id) => { map(([track, trackData, comments]) => ({
await api.delete(`/tracks/${slug}/comments/${id}`); track,
reloadComments(); trackData,
}, comments,
[slug, reloadComments] }))
); )
},
null,
[slug]
)
const [downloadError, setDownloadError] = React.useState(null); const onSubmitComment = React.useCallback(
const hideDownloadError = React.useCallback( async ({body}) => {
() => setDownloadError(null), await api.post(`/tracks/${slug}/comments`, {
[setDownloadError] body: {comment: {body}},
); })
const onDownload = React.useCallback( reloadComments()
async (filename) => { },
try { [slug, reloadComments]
await api.downloadFile(`/tracks/${slug}/download/${filename}`); )
} catch (err) {
if (/Failed to fetch/.test(String(err))) { const onDeleteComment = React.useCallback(
setDownloadError(t("TrackPage.downloadError")); async (id) => {
} else { await api.delete(`/tracks/${slug}/comments/${id}`)
setDownloadError(String(err)); reloadComments()
} },
[slug, reloadComments]
)
const [downloadError, setDownloadError] = React.useState(null)
const hideDownloadError = React.useCallback(() => setDownloadError(null), [setDownloadError])
const onDownload = React.useCallback(
async (filename) => {
try {
await api.downloadFile(`/tracks/${slug}/download/${filename}`)
} catch (err) {
if (/Failed to fetch/.test(String(err))) {
setDownloadError(t('TrackPage.downloadError'))
} else {
setDownloadError(String(err))
} }
}, }
[slug] },
); [slug]
)
const isAuthor = login?.id === data?.track?.author?.id; const isAuthor = login?.id === data?.track?.author?.id
const { track, trackData, comments } = data || {}; const {track, trackData, comments} = data || {}
const loading = track == null || trackData === undefined; const loading = track == null || trackData === undefined
const processing = ["processing", "queued", "created"].includes( const processing = ['processing', 'queued', 'created'].includes(track?.processingStatus)
track?.processingStatus const error = track?.processingStatus === 'error'
);
const error = track?.processingStatus === "error";
const [showTrack, setShowTrack] = React.useState(true); const [showTrack, setShowTrack] = React.useState(true)
const [pointsMode, setPointsMode] = React.useState("overtakingEvents"); // none|overtakingEvents|measurements const [pointsMode, setPointsMode] = React.useState('overtakingEvents') // none|overtakingEvents|measurements
const [side, setSide] = React.useState("overtaker"); // overtaker|stationary const [side, setSide] = React.useState('overtaker') // overtaker|stationary
const title = track ? track.title || t("general.unnamedTrack") : null; const title = track ? track.title || t('general.unnamedTrack') : null
return ( return (
<Page <Page
title={title} title={title}
stage={ stage={
<> <>
<Container> <Container>
{track && ( {track && (
<Segment basic> <Segment basic>
<div <div
style={{ style={{
display: "flex", display: 'flex',
alignItems: "baseline", alignItems: 'baseline',
marginBlockStart: 32, marginBlockStart: 32,
marginBlockEnd: 16, marginBlockEnd: 16,
}} }}
> >
<Header as="h1">{title}</Header> <Header as="h1">{title}</Header>
<div style={{ marginLeft: "auto" }}> <div style={{marginLeft: 'auto'}}>
<TrackActions {...{ isAuthor, onDownload, slug }} /> <TrackActions {...{isAuthor, onDownload, slug}} />
</div>
</div> </div>
</div>
<div style={{ marginBlockEnd: 16 }}> <div style={{marginBlockEnd: 16}}>
<TrackDetails {...{ track, isAuthor }} /> <TrackDetails {...{track, isAuthor}} />
</div> </div>
</Segment> </Segment>
)} )}
</Container> </Container>
<div className={styles.stage}> <div className={styles.stage}>
<Loader active={loading} /> <Loader active={loading} />
<Dimmer.Dimmable blurring dimmed={loading}> <Dimmer.Dimmable blurring dimmed={loading}>
<TrackMap <TrackMap {...{track, trackData, pointsMode, side, showTrack}} style={{height: '80vh'}} />
{...{ track, trackData, pointsMode, side, showTrack }} </Dimmer.Dimmable>
style={{ height: "80vh" }}
<div className={styles.details}>
<Segment>
<TrackMapSettings
{...{
showTrack,
setShowTrack,
pointsMode,
setPointsMode,
side,
setSide,
}}
/> />
</Dimmer.Dimmable> </Segment>
<div className={styles.details}> {processing && (
<Segment> <Message warning>
<TrackMapSettings <Message.Content>{t('TrackPage.processing')}</Message.Content>
{...{ </Message>
showTrack,
setShowTrack,
pointsMode,
setPointsMode,
side,
setSide,
}}
/>
</Segment>
{processing && (
<Message warning>
<Message.Content>
{t("TrackPage.processing")}
</Message.Content>
</Message>
)}
{error && (
<Message error>
<Message.Content>
{t("TrackPage.processingError")}
</Message.Content>
</Message>
)}
</div>
</div>
<Container>
{track?.description && (
<>
<Header as="h2" dividing>
{t("TrackPage.description")}
</Header>
<Markdown>{track.description}</Markdown>
</>
)} )}
<TrackComments {error && (
{...{ hideLoader: loading, comments, login }} <Message error>
onSubmit={onSubmitComment} <Message.Content>{t('TrackPage.processingError')}</Message.Content>
onDelete={onDeleteComment} </Message>
/> )}
</Container> </div>
</> </div>
}
>
<Confirm
open={downloadError != null}
cancelButton={false}
onConfirm={hideDownloadError}
header={t("TrackPage.downloadFailed")}
content={String(downloadError)}
confirmButton={t("general.ok")}
/>
</Page>
);
}
);
export default TrackPage; <Container>
{track?.description && (
<>
<Header as="h2" dividing>
{t('TrackPage.description')}
</Header>
<Markdown>{track.description}</Markdown>
</>
)}
<TrackComments
{...{hideLoader: loading, comments, login}}
onSubmit={onSubmitComment}
onDelete={onDeleteComment}
/>
</Container>
</>
}
>
<Confirm
open={downloadError != null}
cancelButton={false}
onConfirm={hideDownloadError}
header={t('TrackPage.downloadFailed')}
content={String(downloadError)}
confirmButton={t('general.ok')}
/>
</Page>
)
})
export default TrackPage

View file

@ -1,65 +1,49 @@
import React, { useCallback } from "react"; import React, {useCallback} from 'react'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { import {Button, Message, Item, Header, Loader, Pagination, Icon} from 'semantic-ui-react'
Button, import {useObservable} from 'rxjs-hooks'
Message, import {Link} from 'react-router-dom'
Item, import {of, from, concat} from 'rxjs'
Header, import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
Loader, import _ from 'lodash'
Pagination, import {useTranslation, Trans as Translate} from 'react-i18next'
Icon,
} from "semantic-ui-react";
import { useObservable } from "rxjs-hooks";
import { Link } from "react-router-dom";
import { of, from, concat } from "rxjs";
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
import _ from "lodash";
import { useTranslation, Trans as Translate } from "react-i18next";
import type { Track } from "types"; import type {Track} from 'types'
import { import {Avatar, Page, StripMarkdown, FormattedDate, Visibility} from 'components'
Avatar, import api from 'api'
Page, import {useQueryParam} from 'query'
StripMarkdown,
FormattedDate,
Visibility,
} from "components";
import api from "api";
import { useQueryParam } from "query";
function TrackList({ privateTracks }: { privateTracks: boolean }) { function TrackList({privateTracks}: {privateTracks: boolean}) {
const [page, setPage] = useQueryParam<number>("page", 1, Number); const [page, setPage] = useQueryParam<number>('page', 1, Number)
const pageSize = 10; const pageSize = 10
const data: { const data: {
tracks: Track[]; tracks: Track[]
trackCount: number; trackCount: number
} | null = useObservable( } | null = useObservable(
(_$, inputs$) => (_$, inputs$) =>
inputs$.pipe( inputs$.pipe(
map(([page, privateTracks]) => { map(([page, privateTracks]) => {
const url = "/tracks" + (privateTracks ? "/feed" : ""); const url = '/tracks' + (privateTracks ? '/feed' : '')
const query = { limit: pageSize, offset: pageSize * (page - 1) }; const query = {limit: pageSize, offset: pageSize * (page - 1)}
return { url, query }; return {url, query}
}), }),
distinctUntilChanged(_.isEqual), distinctUntilChanged(_.isEqual),
switchMap((request) => switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query}))))
concat(of(null), from(api.get(request.url, { query: request.query })))
)
), ),
null, null,
[page, privateTracks] [page, privateTracks]
); )
const { tracks, trackCount } = data || { tracks: [], trackCount: 0 }; const {tracks, trackCount} = data || {tracks: [], trackCount: 0}
const loading = !data; const loading = !data
const totalPages = Math.ceil(trackCount / pageSize); const totalPages = Math.ceil(trackCount / pageSize)
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<div> <div>
<Loader content={t("general.loading")} active={loading} /> <Loader content={t('general.loading')} active={loading} />
{!loading && totalPages > 1 && ( {!loading && totalPages > 1 && (
<Pagination <Pagination
activePage={page} activePage={page}
@ -71,14 +55,14 @@ function TrackList({ privateTracks }: { privateTracks: boolean }) {
{tracks && tracks.length ? ( {tracks && tracks.length ? (
<Item.Group divided> <Item.Group divided>
{tracks.map((track: Track) => ( {tracks.map((track: Track) => (
<TrackListItem key={track.slug} {...{ track, privateTracks }} /> <TrackListItem key={track.slug} {...{track, privateTracks}} />
))} ))}
</Item.Group> </Item.Group>
) : ( ) : (
<NoPublicTracksMessage /> <NoPublicTracksMessage />
)} )}
</div> </div>
); )
} }
export function NoPublicTracksMessage() { export function NoPublicTracksMessage() {
@ -88,27 +72,27 @@ export function NoPublicTracksMessage() {
No public tracks yet. <Link to="/upload">Upload the first!</Link> No public tracks yet. <Link to="/upload">Upload the first!</Link>
</Translate> </Translate>
</Message> </Message>
); )
} }
function maxLength(t: string | null, max: number): string | null { function maxLength(t: string | null, max: number): string | null {
if (t && t.length > max) { if (t && t.length > max) {
return t.substring(0, max) + " ..."; return t.substring(0, max) + ' ...'
} else { } else {
return t; return t
} }
} }
const COLOR_BY_STATUS = { const COLOR_BY_STATUS = {
error: "red", error: 'red',
complete: "green", complete: 'green',
created: "gray", created: 'gray',
queued: "orange", queued: 'orange',
processing: "orange", processing: 'orange',
}; }
export function TrackListItem({ track, privateTracks = false }) { export function TrackListItem({track, privateTracks = false}) {
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<Item key={track.slug}> <Item key={track.slug}>
@ -117,14 +101,10 @@ export function TrackListItem({ track, privateTracks = false }) {
</Item.Image> </Item.Image>
<Item.Content> <Item.Content>
<Item.Header as={Link} to={`/tracks/${track.slug}`}> <Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title || t("general.unnamedTrack")} {track.title || t('general.unnamedTrack')}
</Item.Header> </Item.Header>
<Item.Meta> <Item.Meta>
{privateTracks ? null : ( {privateTracks ? null : <span>{t('TracksPage.createdBy', {author: track.author.displayName})}</span>}
<span>
{t("TracksPage.createdBy", { author: track.author.displayName })}
</span>
)}
<span> <span>
<FormattedDate date={track.createdAt} /> <FormattedDate date={track.createdAt} />
</span> </span>
@ -136,57 +116,44 @@ export function TrackListItem({ track, privateTracks = false }) {
<Item.Extra> <Item.Extra>
<Visibility public={track.public} /> <Visibility public={track.public} />
<span style={{ marginLeft: "1em" }}> <span style={{marginLeft: '1em'}}>
<Icon <Icon color={COLOR_BY_STATUS[track.processingStatus]} name="bolt" fitted />{' '}
color={COLOR_BY_STATUS[track.processingStatus]}
name="bolt"
fitted
/>{" "}
{t(`TracksPage.processing.${track.processingStatus}`)} {t(`TracksPage.processing.${track.processingStatus}`)}
</span> </span>
</Item.Extra> </Item.Extra>
)} )}
</Item.Content> </Item.Content>
</Item> </Item>
); )
} }
function UploadButton({ navigate, ...props }) { function UploadButton({navigate, ...props}) {
const { t } = useTranslation(); const {t} = useTranslation()
const onClick = useCallback( const onClick = useCallback(
(e) => { (e) => {
e.preventDefault(); e.preventDefault()
navigate(); navigate()
}, },
[navigate] [navigate]
); )
return ( return (
<Button <Button onClick={onClick} {...props} color="green" style={{float: 'right'}}>
onClick={onClick} {t('TracksPage.upload')}
{...props}
color="green"
style={{ float: "right" }}
>
{t("TracksPage.upload")}
</Button> </Button>
); )
} }
const TracksPage = connect((state) => ({ login: (state as any).login }))( const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateTracks}) {
function TracksPage({ login, privateTracks }) { const {t} = useTranslation()
const { t } = useTranslation(); const title = privateTracks ? t('TracksPage.titleUser') : t('TracksPage.titlePublic')
const title = privateTracks
? t("TracksPage.titleUser")
: t("TracksPage.titlePublic");
return ( return (
<Page title={title}> <Page title={title}>
<Header as="h2">{title}</Header> <Header as="h2">{title}</Header>
{privateTracks && <Link component={UploadButton} to="/upload" />} {privateTracks && <Link component={UploadButton} to="/upload" />}
<TrackList {...{ privateTracks }} /> <TrackList {...{privateTracks}} />
</Page> </Page>
); )
} })
);
export default TracksPage; export default TracksPage

View file

@ -1,46 +1,46 @@
import _ from "lodash"; import _ from 'lodash'
import React from "react"; import React from 'react'
import { List, Loader, Table, Icon } from "semantic-ui-react"; import {Header, List, Loader, Table, Icon} from 'semantic-ui-react'
import { Link } from "react-router-dom"; import {Link} from 'react-router-dom'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import { FileUploadField, Page } from "components"; import {FileUploadField, Page} from 'components'
import type { Track } from "types"; import type {Track} from 'types'
import api from "api"; import api from 'api'
import configPromise from "config"; import configPromise from 'config'
function isSameFile(a: File, b: File) { function isSameFile(a: File, b: File) {
return a.name === b.name && a.size === b.size; return a.name === b.name && a.size === b.size
} }
function formatFileSize(bytes: number) { function formatFileSize(bytes: number) {
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes} bytes`; return `${bytes} bytes`
} }
bytes /= 1024; bytes /= 1024
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes.toFixed(1)} KiB`; return `${bytes.toFixed(1)} KiB`
} }
bytes /= 1024; bytes /= 1024
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes.toFixed(1)} MiB`; return `${bytes.toFixed(1)} MiB`
} }
bytes /= 1024; bytes /= 1024
return `${bytes.toFixed(1)} GiB`; return `${bytes.toFixed(1)} GiB`
} }
type FileUploadResult = type FileUploadResult =
| { | {
track: Track; track: Track
} }
| { | {
errors: Record<string, string>; errors: Record<string, string>
}; }
export function FileUploadStatus({ export function FileUploadStatus({
id, id,
@ -48,127 +48,117 @@ export function FileUploadStatus({
onComplete, onComplete,
slug, slug,
}: { }: {
id: string; id: string
file: File; file: File
onComplete: (id: string, result: FileUploadResult) => void; onComplete: (id: string, result: FileUploadResult) => void
slug?: string; slug?: string
}) { }) {
const [progress, setProgress] = React.useState(0); const [progress, setProgress] = React.useState(0)
React.useEffect( React.useEffect(
() => { () => {
let xhr; let xhr
async function _work() { async function _work() {
const formData = new FormData(); const formData = new FormData()
formData.append("body", file); formData.append('body', file)
xhr = new XMLHttpRequest(); xhr = new XMLHttpRequest()
xhr.withCredentials = true; xhr.withCredentials = true
const onProgress = (e) => { const onProgress = (e) => {
const progress = (e.loaded || 0) / (e.total || 1); const progress = (e.loaded || 0) / (e.total || 1)
setProgress(progress); setProgress(progress)
}; }
const onLoad = (e) => { const onLoad = (e) => {
onComplete(id, xhr.response); onComplete(id, xhr.response)
}; }
xhr.responseType = "json"; xhr.responseType = 'json'
xhr.onload = onLoad; xhr.onload = onLoad
xhr.upload.onprogress = onProgress; xhr.upload.onprogress = onProgress
const config = await configPromise; const config = await configPromise
if (slug) { if (slug) {
xhr.open("PUT", `${config.apiUrl}/tracks/${slug}`); xhr.open('PUT', `${config.apiUrl}/tracks/${slug}`)
} else { } else {
xhr.open("POST", `${config.apiUrl}/tracks`); xhr.open('POST', `${config.apiUrl}/tracks`)
} }
// const accessToken = await api.getValidAccessToken() // const accessToken = await api.getValidAccessToken()
// xhr.setRequestHeader('Authorization', accessToken) // xhr.setRequestHeader('Authorization', accessToken)
xhr.send(formData); xhr.send(formData)
} }
_work(); _work()
return () => xhr.abort(); return () => xhr.abort()
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[file] [file]
); )
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<span> <span>
<Loader inline size="mini" active />{" "} <Loader inline size="mini" active />{' '}
{progress < 1 {progress < 1
? t("UploadPage.uploadProgress", { ? t('UploadPage.uploadProgress', {
progress: (progress * 100).toFixed(0), progress: (progress * 100).toFixed(0),
}) })
: t("UploadPage.processing")} : t('UploadPage.processing')}
</span> </span>
); )
} }
type FileEntry = { type FileEntry = {
id: string; id: string
file?: File | null; file?: File | null
size: number; size: number
name: string; name: string
result?: FileUploadResult; result?: FileUploadResult
}; }
export default function UploadPage() { export default function UploadPage() {
const [files, setFiles] = React.useState<FileEntry[]>([]); const [files, setFiles] = React.useState<FileEntry[]>([])
const onCompleteFileUpload = React.useCallback( const onCompleteFileUpload = React.useCallback(
(id, result) => { (id, result) => {
setFiles((files) => setFiles((files) => files.map((file) => (file.id === id ? {...file, result, file: null} : file)))
files.map((file) =>
file.id === id ? { ...file, result, file: null } : file
)
);
}, },
[setFiles] [setFiles]
); )
function onSelectFiles(fileList) { function onSelectFiles(fileList) {
const newFiles = Array.from(fileList).map((file) => ({ const newFiles = Array.from(fileList).map((file) => ({
id: "file-" + String(Math.floor(Math.random() * 1000000)), id: 'file-' + String(Math.floor(Math.random() * 1000000)),
file, file,
name: file.name, name: file.name,
size: file.size, size: file.size,
})); }))
setFiles( setFiles(files.filter((a) => !newFiles.some((b) => isSameFile(a, b))).concat(newFiles))
files
.filter((a) => !newFiles.some((b) => isSameFile(a, b)))
.concat(newFiles)
);
} }
const { t } = useTranslation(); const {t} = useTranslation()
const title = t('UploadPage.title')
return ( return (
<Page title="Upload"> <Page title={title}>
<Header as="h1">{title}</Header>
{files.length ? ( {files.length ? (
<Table> <Table>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell> <Table.HeaderCell>{t('UploadPage.table.filename')}</Table.HeaderCell>
{t("UploadPage.table.filename")} <Table.HeaderCell>{t('UploadPage.table.size')}</Table.HeaderCell>
</Table.HeaderCell> <Table.HeaderCell>{t('UploadPage.table.statusTitle')}</Table.HeaderCell>
<Table.HeaderCell>{t("UploadPage.table.size")}</Table.HeaderCell>
<Table.HeaderCell>
{t("UploadPage.table.statusTitle")}
</Table.HeaderCell>
<Table.HeaderCell colSpan={2}></Table.HeaderCell> <Table.HeaderCell colSpan={2}></Table.HeaderCell>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{files.map(({ id, name, size, file, result }) => ( {files.map(({id, name, size, file, result}) => (
<Table.Row key={id}> <Table.Row key={id}>
<Table.Cell> <Table.Cell>
<Icon name="file" /> <Icon name="file" />
@ -179,9 +169,7 @@ export default function UploadPage() {
{result?.errors ? ( {result?.errors ? (
<List> <List>
{_.sortBy(Object.entries(result.errors)) {_.sortBy(Object.entries(result.errors))
.filter( .filter(([field, message]) => typeof message === 'string')
([field, message]) => typeof message === "string"
)
.map(([field, message]) => ( .map(([field, message]) => (
<List.Item key={field}> <List.Item key={field}>
<List.Icon name="warning sign" color="red" /> <List.Icon name="warning sign" color="red" />
@ -191,29 +179,17 @@ export default function UploadPage() {
</List> </List>
) : result ? ( ) : result ? (
<> <>
<Icon name="check" />{" "} <Icon name="check" /> {result.track?.title || t('general.unnamedTrack')}
{result.track?.title || t("general.unnamedTrack")}
</> </>
) : ( ) : (
<FileUploadStatus <FileUploadStatus {...{id, file}} onComplete={onCompleteFileUpload} />
{...{ id, file }}
onComplete={onCompleteFileUpload}
/>
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{result?.track ? ( {result?.track ? <Link to={`/tracks/${result.track.slug}`}>{t('general.show')}</Link> : null}
<Link to={`/tracks/${result.track.slug}`}>
{t("general.show")}
</Link>
) : null}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{result?.track ? ( {result?.track ? <Link to={`/tracks/${result.track.slug}/edit`}>{t('general.edit')}</Link> : null}
<Link to={`/tracks/${result.track.slug}/edit`}>
{t("general.edit")}
</Link>
) : null}
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
))} ))}
@ -223,5 +199,5 @@ export default function UploadPage() {
<FileUploadField onSelect={onSelectFiles} multiple /> <FileUploadField onSelect={onSelectFiles} multiple />
</Page> </Page>
); )
} }

View file

@ -1,3 +1,4 @@
export {default as AcknowledgementsPage} from './AcknowledgementsPage'
export {default as ExportPage} from './ExportPage' export {default as ExportPage} from './ExportPage'
export {default as HomePage} from './HomePage' export {default as HomePage} from './HomePage'
export {default as LoginRedirectPage} from './LoginRedirectPage' export {default as LoginRedirectPage} from './LoginRedirectPage'
@ -8,4 +9,5 @@ export {default as SettingsPage} from './SettingsPage'
export {default as TrackEditor} from './TrackEditor' export {default as TrackEditor} from './TrackEditor'
export {default as TrackPage} from './TrackPage' export {default as TrackPage} from './TrackPage'
export {default as TracksPage} from './TracksPage' export {default as TracksPage} from './TracksPage'
export {default as MyTracksPage} from './MyTracksPage'
export {default as UploadPage} from './UploadPage' export {default as UploadPage} from './UploadPage'

View file

@ -53,7 +53,7 @@ export function useQueryParam<T extends QueryValue>(
): [T, (newValue: T) => void] { ): [T, (newValue: T) => void] {
const history = useHistory() const history = useHistory()
useLocation() // to trigger a reload when the url changes useLocation() // to trigger a reload when the url changes
const {[name]: value = defaultValue} = (parseQuery(history.location.search) as unknown) as { const {[name]: value = defaultValue} = parseQuery(history.location.search) as unknown as {
[name: string]: T [name: string]: T
} }
const setter = useMemo( const setter = useMemo(

View file

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

View file

@ -3,22 +3,19 @@
*******************************/ *******************************/
h1.ui.header { h1.ui.header {
font-family: "Open Sans Condensed";
line-height: 35pt; line-height: 35pt;
font-size: 30pt; font-size: 30pt;
color: @obsColorB4; color: @obsColorB4;
} }
h2.ui.header { h2.ui.header {
font-family: "Open Sans"; font-weight: 400;
font-weight: 300;
line-height: 25pt; line-height: 25pt;
font-size: 20pt; font-size: 20pt;
color: @obsColorG1; color: @obsColorG1;
} }
h3.ui.header { h3.ui.header {
font-family: "Open Sans";
font-weight: normal; font-weight: normal;
line-height: 18pt; line-height: 18pt;
font-size: 15pt; font-size: 15pt;
@ -26,7 +23,6 @@ h3.ui.header {
} }
h4.ui.header { h4.ui.header {
font-family: "Open Sans";
font-weight: bold; font-weight: bold;
line-height: 15pt; line-height: 15pt;
font-size: 15pt; font-size: 15pt;

View file

@ -1,3 +1,12 @@
/******************************* /*******************************
User Variable Overrides User Variable Overrides
*******************************/ *******************************/
.ui.list{
.item{
.header{
margin-bottom: 5px;
}
}
}

View file

@ -3,8 +3,8 @@
*******************************/ *******************************/
@importGoogleFonts : false; @importGoogleFonts : false;
@fontName : 'Open Sans'; @fontName : 'Open Sans', 'Noto Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Oxygen', 'Ubuntu', 'Cantarell',
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@obsColorB4: #114594; @obsColorB4: #114594;
@obsColorG1: #76520E; @obsColorG1: #76520E;
@obsColorW: #FFFFFF; @obsColorW: #FFFFFF;

View file

@ -1,3 +1,7 @@
/******************************* /*******************************
User Variable Overrides User Variable Overrides
*******************************/ *******************************/
.ui.statistic .label{
font-size: 13px;
}

Some files were not shown because too many files have changed in this diff Show more