aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Dockerfile1
-rw-r--r--asset-sources/theme.scss55
-rw-r--r--container/cron.py42
-rw-r--r--container/entrypoint3
-rw-r--r--doc/administration/configuration.rst29
-rw-r--r--fietsboek/__init__.py8
-rw-r--r--fietsboek/actions.py125
-rw-r--r--fietsboek/alembic/versions/20260930_b73ed8b45f67.py41
-rw-r--r--fietsboek/config.py13
-rw-r--r--fietsboek/geo.py207
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin20960 -> 21619 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po194
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin19742 -> 20345 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po194
-rw-r--r--fietsboek/locale/fietslog.pot200
-rw-r--r--fietsboek/models/__init__.py1
-rw-r--r--fietsboek/models/climb.py91
-rw-r--r--fietsboek/models/track.py8
-rw-r--r--fietsboek/osm.py397
-rw-r--r--fietsboek/routes.py14
-rw-r--r--fietsboek/scripts/fietscron.py32
-rw-r--r--fietsboek/static/theme.css55
-rw-r--r--fietsboek/static/theme.css.map2
-rw-r--r--fietsboek/templates/details.jinja2111
-rw-r--r--fietsboek/trackmap.py80
-rw-r--r--fietsboek/updater/script.py12
-rw-r--r--fietsboek/updater/scripts/upd_20260930_7fa8kflikvnlvuqe.py51
-rw-r--r--fietsboek/views/detail.py56
-rw-r--r--fietsboek/views/tileproxy.py9
-rw-r--r--fietsboek/views/upload.py9
-rw-r--r--tests/integration/test_upload.py11
31 files changed, 1803 insertions, 248 deletions
diff --git a/Dockerfile b/Dockerfile
index f485f2e..8a88bb6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,6 +8,7 @@ COPY ["pyproject.toml", "README.md", "LICENSE.txt", "CHANGELOG.rst", "production
RUN pip install .
COPY --chmod=755 ["container/entrypoint", "/bin/entrypoint"]
+COPY --chmod=755 ["container/cron.py", "/usr/bin/cron.py"]
COPY ["container/gunicorn.conf.py", "/fietsboek/gunicorn.conf.py"]
VOLUME /fietsboek/data /fietsboek/database /fietsboek/pages
diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss
index 374f5fe..1941f36 100644
--- a/asset-sources/theme.scss
+++ b/asset-sources/theme.scss
@@ -176,6 +176,61 @@ strong {
cursor: pointer;
}
+/* Climbs! */
+.climbs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1em;
+}
+
+.climb {
+ width: 300px;
+}
+
+.climb-preview {
+ cursor: zoom-in;
+}
+
+.climb-category {
+ float: right;
+ border: 3px solid;
+ border-radius: 5px;
+ min-width: 1.5em;
+ text-align: center;
+ font-size: 80%;
+}
+
+.climb-category-4 {
+ border-color: #99ae36;
+ background-color: #D4E09B;
+ color: #000;
+}
+
+.climb-category-3 {
+ border-color: #8a7b3e;
+ background-color: #C8BB84;
+ color: #000;
+}
+
+.climb-category-2 {
+ border-color: #7a5a38;
+ background-color: #BC956D;
+ color: #fff;
+}
+
+.climb-category-1 {
+ border-color: #6b4332;
+ background-color: #B07056;
+ color: #fff;
+}
+
+.climb-category-0 {
+ border-color: #622c26;
+ background-color: #A44A3F;
+ color: #fff;
+}
+/* </Climbs> */
+
.count-field {
width: 50px;
display: inline-block;
diff --git a/container/cron.py b/container/cron.py
new file mode 100644
index 0000000..47aeaa9
--- /dev/null
+++ b/container/cron.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python
+import os
+import sys
+import datetime
+import subprocess
+import time
+
+SLEEPY_TIME = datetime.timedelta(hours=1)
+SLEEPY_BATCH = datetime.timedelta(minutes=10)
+LOGFILE = "/fietsboek/fietscron.log"
+
+pid = os.fork()
+if pid > 0:
+ sys.exit(0)
+
+os.setsid()
+
+pid = os.fork()
+if pid > 0:
+ sys.exit(0)
+
+
+def run_fietscron():
+ with open(LOGFILE, "wb") as stdout:
+ subprocess.run(
+ ["/usr/local/bin/fietscron", "-c", "/fietsboek/fietsboek.ini"],
+ stdout=stdout,
+ stderr=subprocess.STDOUT,
+ )
+
+
+def wait(start):
+ wanted_time = start + SLEEPY_TIME
+ while (now := datetime.datetime.now(tz=datetime.UTC)) < wanted_time:
+ to_sleep = min(wanted_time - now, SLEEPY_BATCH)
+ time.sleep(to_sleep.total_seconds())
+
+
+while True:
+ start = datetime.datetime.now(tz=datetime.UTC)
+ run_fietscron()
+ wait(start)
diff --git a/container/entrypoint b/container/entrypoint
index c7f3b2d..dcfe1fa 100644
--- a/container/entrypoint
+++ b/container/entrypoint
@@ -59,4 +59,7 @@ fi
# the first time
(cd /package && fietsupdate update -fc "$CONFIG")
+# Start a cronjob for fietscron in the background
+/usr/bin/cron.py
+
exec "$@"
diff --git a/doc/administration/configuration.rst b/doc/administration/configuration.rst
index 98f3592..99ce4d1 100644
--- a/doc/administration/configuration.rst
+++ b/doc/administration/configuration.rst
@@ -218,6 +218,35 @@ true``. This will cause the tiles to be loaded directly by the client.
your users that third party content is included in your site, and that
their IP will be accessible to the third party.
+Overpass API
+------------
+
+Fietsboek uses Overpass_ to name climbs. You can set custom Overpass instances
+in the config:
+
+.. code:: ini
+
+ overpass.url = https://overpass.example.com/api/interpreter
+ # HTTP Basic auth, if needed:
+ overpass.basic_auth.username = fietsboek
+ overpass.basic_auth.password = super5ecre7
+
+By default, Fietsboek uses the API instance at
+https://maps.mail.ru/osm/tools/overpass/api/interpreter, however, it is often
+overloaded and will respond with timeouts.
+
+.. note::
+
+ The URL needs to include the whole path to the interpreter script, so
+ usually it ends with ``/api/interpreter``.
+
+.. note::
+
+ Overpass has no standard way to do authentication. Commonly, the API key is
+ included in the URL. Fietsboek also has support for HTTP Basic auth.
+
+.. _Overpass: https://wiki.openstreetmap.org/wiki/Overpass_API
+
Hittekaart Integration
----------------------
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py
index 1f21c5f..b2af484 100644
--- a/fietsboek/__init__.py
+++ b/fietsboek/__init__.py
@@ -158,8 +158,10 @@ def create_data_folders(data_dir: Path):
def main(global_config, **settings):
"""This function returns a Pyramid WSGI application."""
+ # pylint: disable=too-many-locals
# Avoid a circular import by not importing at the top level
# pylint: disable=import-outside-toplevel,cyclic-import
+ from .osm import OverpassApi
from .views.tileproxy import TileRequester
# In tests this isn't passed, so guard against it
@@ -180,6 +182,11 @@ def main(global_config, **settings):
def redis_(request):
return redis.from_url(request.config.redis_url)
+ overpass_api = OverpassApi.from_config(parsed_config)
+
+ def overpass_api_(_request):
+ return overpass_api
+
def config_(_request):
return parsed_config
@@ -215,6 +222,7 @@ def main(global_config, **settings):
config.add_request_method(pages, reify=True)
config.add_request_method(redis_, name="redis", reify=True)
config.add_request_method(config_, name="config", reify=True)
+ config.add_request_method(overpass_api_, name="overpass_api", reify=True)
config.registry.registerUtility(TileRequester(redis.from_url(parsed_config.redis_url)))
diff --git a/fietsboek/actions.py b/fietsboek/actions.py
index cdddaa2..a50884f 100644
--- a/fietsboek/actions.py
+++ b/fietsboek/actions.py
@@ -7,24 +7,29 @@ the test functions.
"""
import datetime
+import gzip
import io
import logging
import re
+from threading import Thread
from typing import Optional
+import sqlalchemy.exc
from pyramid.i18n import TranslationString as _
from pyramid.request import Request
from sqlalchemy import select
from sqlalchemy.orm.session import Session
-from . import convert, email, models, trackmap
+from . import convert, email, models, osm, trackmap
from . import transformers as mod_transformers
from . import util
from .config import TileLayerConfig
from .data import DataManager, TrackDataDir
+from .geo import Path
from .models.track import TrackType, Visibility
from .models.user import TokenType
-from .views.tileproxy import TileRequester
+from .osm import OverpassApi
+from .views.tileproxy import ITileRequester, TileRequester
LOGGER = logging.getLogger(__name__)
@@ -278,4 +283,118 @@ def send_verification_token(request: Request, user: models.User):
)
-__all__ = ["add_track", "edit_images", "execute_transformers", "send_verification_token"]
+def _categorize_climb(length: float, avg_grade: float) -> int | None:
+ # https://bicycles.stackexchange.com/questions/1210/how-are-the-categories-for-climbs-decided
+ score = length * avg_grade * 100
+ if score > 80000:
+ return 0
+ if score > 64000:
+ return 1
+ if score > 32000:
+ return 2
+ if score > 16000:
+ return 3
+ if score > 8000:
+ return 4
+ # You call this a climb?...
+ return None
+
+
+def extract_climbs(
+ dbsession: Session,
+ track: models.Track,
+ tile_requester: TileRequester,
+ layer: TileLayerConfig,
+ overpass_api: OverpassApi | None,
+):
+ """(Re-)extracts the climbs from the given track.
+
+ If the track already has climbs assigned, they will be deleted.
+
+ :param dbsession: The database session.
+ :param track: The track for which to extract climbs.
+ :param tile_requester: The tile requester to use to render preview images.
+ :param layer: The layer to use for preview images.
+ :param overpass_api: The overpass API to use for naming the climbs. If
+ ``None``, climbs will not be named.
+ :param find_names: Whether names should be searched or not.
+ """
+ # pylint: disable=too-many-locals,too-many-arguments
+ track.climbs = []
+ dbsession.add(track)
+ path = track.path()
+ climbs = path.climbs()
+ for climb_start, climb_end in climbs:
+ subpath = Path(path.points[climb_start : climb_end + 1])
+ movement_data = subpath.movement_data()
+
+ preview = trackmap.render(subpath, layer, tile_requester)
+ imageio = io.BytesIO()
+ preview.save(imageio, "png")
+ preview = imageio.getvalue()
+
+ profile = trackmap.height_profile(subpath, (300, 150))
+ profile_compressed = gzip.compress(profile.encode("ascii"))
+
+ avg_grade = 0.0
+ if movement_data.length:
+ avg_grade = movement_data.elevation_gain / movement_data.length
+ else:
+ LOGGER.debug("Warning: zero length for climb in track %s", track.id)
+
+ name = None
+ if overpass_api is not None:
+ name = osm.find_name(overpass_api, subpath)
+
+ climb = models.Climb(
+ track=track,
+ start_index=climb_start,
+ end_index=climb_end,
+ image=preview,
+ profile=profile_compressed,
+ avg_grade=avg_grade,
+ length=movement_data.length,
+ lowest_elevation=movement_data.min_elevation,
+ highest_elevation=movement_data.max_elevation,
+ category=_categorize_climb(movement_data.length, avg_grade),
+ automatic_name=name,
+ )
+ dbsession.add(climb)
+
+
+def extract_climbs_background(request: Request, track_id: int):
+ """Re-extracts the climbs of the given track in the background.
+
+ This spawns a new thread that calls :func:`extract_climbs` in a new thread.
+
+ :param request: The request.
+ :param track_id: The track's id.
+ """
+ loader: ITileRequester = request.registry.getUtility(ITileRequester)
+ layer = request.config.public_tile_layers()[0]
+
+ def _extract_climbs():
+ LOGGER.debug("Starting climb extraction for track %d", track_id)
+ request.redis.set(f"bg-tasks:track-{track_id}", 1)
+ session = request.registry["dbsession_factory"]()
+ track = session.get(models.Track, track_id)
+ with session:
+ extract_climbs(session, track, loader, layer, request.overpass_api)
+ try:
+ session.commit()
+ except sqlalchemy.exc.IntegrityError:
+ LOGGER.info("SQL IntegrityError -- did we lose the track while extracting climbs?")
+ request.redis.delete(f"bg-tasks:track-{track_id}")
+ LOGGER.debug("Climb extraction done")
+
+ Thread(name=f"climbs-{track_id}", target=_extract_climbs).start()
+
+
+__all__ = [
+ "add_track",
+ "edit_images",
+ "execute_transformers",
+ "send_verification_token",
+ "extract_climbs",
+ "extract_climbs_background",
+]
diff --git a/fietsboek/alembic/versions/20260930_b73ed8b45f67.py b/fietsboek/alembic/versions/20260930_b73ed8b45f67.py
new file mode 100644
index 0000000..e7bf276
--- /dev/null
+++ b/fietsboek/alembic/versions/20260930_b73ed8b45f67.py
@@ -0,0 +1,41 @@
+"""add climbs table
+
+Revision ID: b73ed8b45f67
+Revises: f9ca03541351
+Create Date: 2026-09-30 19:53:17.788093
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = 'b73ed8b45f67'
+down_revision = 'f9ca03541351'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('climbs',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('track_id', sa.Integer(), nullable=False),
+ sa.Column('automatic_name', sa.Text(), nullable=True),
+ sa.Column('given_name', sa.Text(), nullable=True),
+ sa.Column('image', sa.LargeBinary(), nullable=False),
+ sa.Column('profile', sa.LargeBinary(), nullable=False),
+ sa.Column('avg_grade', sa.Float(), nullable=False),
+ sa.Column('length', sa.Float(), nullable=False),
+ sa.Column('lowest_elevation', sa.Float(), nullable=False),
+ sa.Column('highest_elevation', sa.Float(), nullable=False),
+ sa.Column('category', sa.Integer(), nullable=True),
+ sa.Column('start_index', sa.Integer(), nullable=False),
+ sa.Column('end_index', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], name=op.f('fk_climbs_track_id_tracks')),
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_climbs'))
+ )
+ # ### end Alembic commands ###
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('climbs')
+ # ### end Alembic commands ###
diff --git a/fietsboek/config.py b/fietsboek/config.py
index bd2347a..6650f08 100644
--- a/fietsboek/config.py
+++ b/fietsboek/config.py
@@ -217,6 +217,19 @@ class Config(BaseModel):
Defaults to 0, which makes it use as many threads as there are CPU cores.
"""
+ overpass_url: str = Field(
+ "https://maps.mail.ru/osm/tools/overpass/api/interpreter",
+ alias="overpass.url",
+ )
+ """URL of the overpass instance to use (including the /interpreter suffix).
+ """
+
+ overpass_basic_username: str = Field("", alias="overpass.basic_auth.username")
+ """Username (if needed) for overpass authentication."""
+
+ overpass_basic_password: SecretStr = Field(SecretStr(""), alias="overpass.basic_auth.password")
+ """Password (if needed) for overpass authentication."""
+
@field_validator("session_key")
@classmethod
def _good_session_key(cls, value):
diff --git a/fietsboek/geo.py b/fietsboek/geo.py
index e6abb71..3a38ee1 100644
--- a/fietsboek/geo.py
+++ b/fietsboek/geo.py
@@ -5,6 +5,7 @@ import io
from dataclasses import dataclass
from itertools import islice
from math import cos, radians, sin, sqrt
+from typing import Iterator
from . import util
@@ -17,6 +18,17 @@ EARTH_RADIUS = 6378137.0
MOVING_THRESHOLD = 1.1
"""Speed which is considered to be the moving threshold, in m/s."""
+CLIMB_MIN_LENGTH = 500
+"""Minimum length (in m) for an ascent to be considered a climb."""
+
+CLIMB_MIN_ASCENSION = 0.03
+"""Minimum relative ascension for a segment to be considered a climb."""
+
+CLIMB_MERGE_THRESHOLD = 50
+"""Window for when climbs are merged (in m)."""
+
+NUM_SIMILARITY_SAMPLES = 10
+
@dataclass
class Waypoint:
@@ -59,6 +71,15 @@ class MovementData:
downhill: float = 0.0
"""Downhill elevation, in meters."""
+ elevation_gain: float = 0.0
+ """Elevation gain, in meters."""
+
+ min_elevation: float = 0.0
+ """Minimum elevation, in meters."""
+
+ max_elevation: float = 0.0
+ """Maximum elevation, in meters."""
+
@dataclass(slots=True)
class Point:
@@ -120,12 +141,14 @@ class Point:
return 0.0
return sqrt(radicand)
+ def latlon(self) -> tuple[float, float]:
+ """Returns the latitude and longitude of this point as a tuple."""
+ return (self.latitude, self.longitude)
+
class Path:
"""A GPS path, that is a series of GPS points."""
- # pylint: disable=too-few-public-methods
-
def __init__(self, points: list[Point]):
self.points = points
@@ -134,6 +157,7 @@ class Path:
def movement_data(self) -> MovementData:
"""Returns the movement data."""
+ # pylint: disable=too-many-branches
movement_data = MovementData()
for a, b in self._point_pairs():
distance = a.distance(b)
@@ -143,6 +167,25 @@ class Path:
else:
speed = 0.0
elevation = b.elevation - a.elevation
+ movement_data.elevation_gain += elevation
+
+ if movement_data.min_elevation == 0.0:
+ movement_data.min_elevation = min(a.elevation, b.elevation)
+ else:
+ movement_data.min_elevation = min(
+ a.elevation,
+ b.elevation,
+ movement_data.min_elevation,
+ )
+
+ if movement_data.max_elevation == 0.0:
+ movement_data.max_elevation = max(a.elevation, b.elevation)
+ else:
+ movement_data.max_elevation = max(
+ a.elevation,
+ b.elevation,
+ movement_data.max_elevation,
+ )
movement_data.length += distance
if speed >= MOVING_THRESHOLD:
@@ -162,6 +205,166 @@ class Path:
movement_data.average_speed = 0.0
return movement_data
+ def bounds(self) -> tuple[tuple[float, float], tuple[float, float]]:
+ """Returns the bounds of this path.
+
+ :return: Bounds in the form `(min_lat, min_lon), (max_lat, max_lon)`.
+ """
+ min_lat = float("+inf")
+ max_lat = float("-inf")
+ min_lon = float("+inf")
+ max_lon = float("-inf")
+
+ for point in self.points:
+ min_lat = min(point.latitude, min_lat)
+ max_lat = max(point.latitude, max_lat)
+ min_lon = min(point.longitude, min_lon)
+ max_lon = max(point.longitude, max_lon)
+
+ return ((min_lat, min_lon), (max_lat, max_lon))
+
+ def climbs(self) -> Iterator[tuple[int, int]]:
+ """Returns an iterator over all climbs of this path.
+
+ :return: An iterator over the climbs, represented by the starting index
+ and ending index of points included in the climb.
+ """
+ # After all the post-processing of climbs, it might happen that we get
+ # climbs shorter than the minimum amount again. Therefore, we do another
+ # filtering step here.
+ for i, j in self._trimmed_climbs():
+ path = Path(self.points[i : j + 1])
+ if path.movement_data().length >= CLIMB_MIN_LENGTH:
+ yield (i, j)
+
+ def _trimmed_climbs(self):
+ for i, j in self._merged_climbs():
+ # We don't want a climb to end in a "downhill" part, even if that
+ # technically is still above the grade threshold. Therefore, we
+ # shave off everything downhill at the end.
+ max_elevation = max(point.elevation for point in self.points[i:j])
+ while self.points[j].elevation < max_elevation:
+ j -= 1
+ yield (i, j)
+
+ def _merged_climbs(self):
+ current_climb = None
+ for climb in self._raw_climbs():
+ if current_climb is None:
+ current_climb = climb
+ else:
+ path_between = Path([self.points[i] for i in range(current_climb[1], climb[0])])
+ if path_between.movement_data().length > CLIMB_MERGE_THRESHOLD:
+ yield current_climb
+ current_climb = climb
+ else:
+ current_climb = (current_climb[0], climb[1])
+ if current_climb is not None:
+ yield current_climb
+
+ def _raw_climbs(self):
+ i = 0
+
+ while i < len(self.points):
+ j = i
+ length = 0.0
+ elevation_diff = 0.0
+ while j + 1 < len(self.points) and (
+ length == 0.0 or elevation_diff / length >= CLIMB_MIN_ASCENSION
+ ):
+ length += self.points[j].distance(self.points[j + 1])
+ elevation_diff += self.points[j + 1].elevation - self.points[j].elevation
+ j += 1
+ if length >= CLIMB_MIN_LENGTH:
+ yield (i, j)
+ i = j
+ else:
+ i += 1
+
+ def similarity(self, reference: "Path") -> float:
+ """Returns a similarity measure between this track and the given other track.
+
+ The higher the returned number is, the more *dissimilar* the given tracks are.
+
+ Note that currently, this measure is not symmetric, as ``other`` is cut
+ to match ``self`` in length (on a best-effort basis). Therefore,
+ ``longer.similarity(shorter)`` and ``shorter.similarity(longer)`` will
+ not be the same.
+
+ :param reference: The other track.
+ :return: The similarity measure.
+ """
+ start_idx = 0
+ start_dst = float("+inf")
+ end_idx = len(reference.points) - 1
+ end_dst = float("+inf")
+
+ for i, point in enumerate(reference.points):
+ if (dst := self.points[0].flat_distance(point)) < start_dst:
+ start_idx = i
+ start_dst = dst
+ if (dst := self.points[-1].flat_distance(point)) < end_dst:
+ end_idx = i
+ end_dst = dst
+
+ if start_idx < end_idx:
+ reference = Path(reference.points[start_idx : end_idx + 1])
+ else:
+ subpath = reference.points[end_idx : start_idx + 1]
+ subpath.reverse()
+ reference = Path(subpath)
+
+ similarity = 0.0
+ for sample in range(NUM_SIMILARITY_SAMPLES):
+ where = sample / (NUM_SIMILARITY_SAMPLES - 1)
+ here = self.interpolate(where)
+ there = reference.interpolate(where)
+ dst = here.flat_distance(there)
+ similarity += dst
+
+ return similarity / NUM_SIMILARITY_SAMPLES
+
+ def interpolate(self, where: float) -> Point:
+ """Returns a point in the middle of the path.
+
+ If ``where`` is 0, the beginning of the path is returned. If ``where``
+ is 1, the end is returned.
+
+ Unlike accessing ``self.points``, this function can return points
+ between two points in the path.
+
+ :param where: Float between 0 and 1 indicating the position of the
+ wanted point.
+ :return: The interpolated point.
+ """
+ if len(self.points) == 1:
+ return self.points[0]
+
+ total_length = self._flat_length()
+ target = where * total_length
+ for i, (a, b) in enumerate(self._point_pairs()):
+ dst = a.flat_distance(b)
+ if dst < target and i < len(self.points) - 2:
+ target -= dst
+ continue
+ if dst == 0.0:
+ partial = 0.0
+ else:
+ partial = min(target / dst, 1.0)
+ return Point(
+ longitude=a.longitude + (b.longitude - a.longitude) * partial,
+ latitude=a.latitude + (b.latitude - a.latitude) * partial,
+ elevation=a.elevation + (b.elevation - a.elevation) * partial,
+ time_offset=a.time_offset + (b.time_offset - a.time_offset) * partial,
+ )
+ raise ValueError(f"Invalid where: {where}")
+
+ def _flat_length(self):
+ length = 0.0
+ for a, b in self._point_pairs():
+ length += a.flat_distance(b)
+ return length
+
def gpx_xml(
title: str | None,
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo
index ec717d2..2cd5007 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po
index 3d6c4aa..552880f 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.po
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2026-08-13 17:40+0200\n"
+"POT-Creation-Date: 2026-10-01 13:36+0200\n"
"PO-Revision-Date: 2022-07-02 17:35+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
@@ -18,11 +18,11 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
-#: fietsboek/actions.py:266
+#: fietsboek/actions.py:269
msgid "email.verify_mail.subject"
msgstr "Fietsboek Konto Bestätigung"
-#: fietsboek/actions.py:269
+#: fietsboek/actions.py:272
msgid "email.verify.text"
msgstr ""
"Um Dein Fietsboek-Konto zu bestätigen, nutze diesen Link: {}\n"
@@ -61,7 +61,7 @@ msgstr "Maximalgeschwindigkeit"
msgid "pdf.table.avg_speed"
msgstr "Durchschnittsgeschwindigkeit"
-#: fietsboek/summaries.py:429
+#: fietsboek/summaries.py:434
msgid "page.profile.contribution_card.tooltip"
msgid_plural "page.profile.contribution_card.tooltips"
msgstr[0] "${count} Strecke am ${date}"
@@ -75,35 +75,35 @@ msgstr "Passwörter stimmen nicht überein"
msgid "password_constraint.length"
msgstr "Passwort zu kurz"
-#: fietsboek/models/track.py:774
+#: fietsboek/models/track.py:777
msgid "tooltip.table.length"
msgstr "Länge"
-#: fietsboek/models/track.py:775
+#: fietsboek/models/track.py:778
msgid "tooltip.table.people"
msgstr "# Personen"
-#: fietsboek/models/track.py:776
+#: fietsboek/models/track.py:779
msgid "tooltip.table.uphill"
msgstr "Bergauf"
-#: fietsboek/models/track.py:777
+#: fietsboek/models/track.py:780
msgid "tooltip.table.downhill"
msgstr "Bergab"
-#: fietsboek/models/track.py:778 fietsboek/templates/home.jinja2:7
+#: fietsboek/models/track.py:781 fietsboek/templates/home.jinja2:7
msgid "tooltip.table.moving_time"
msgstr "Fahrzeit"
-#: fietsboek/models/track.py:779 fietsboek/templates/home.jinja2:8
+#: fietsboek/models/track.py:782 fietsboek/templates/home.jinja2:8
msgid "tooltip.table.stopped_time"
msgstr "Haltezeit"
-#: fietsboek/models/track.py:781
+#: fietsboek/models/track.py:784
msgid "tooltip.table.max_speed"
msgstr "Maximalgeschwindigkeit"
-#: fietsboek/models/track.py:785
+#: fietsboek/models/track.py:788
msgid "tooltip.table.avg_speed"
msgstr "Durchschnittsgeschwindigkeit"
@@ -269,184 +269,184 @@ msgstr "Längenbeschränkungen"
msgid "page.browse.filter.length_maximum"
msgstr "Maximale Länge"
-#: fietsboek/templates/browse.jinja2:34
+#: fietsboek/templates/browse.jinja2:54
msgid "page.browse.filter.uphill_minimum"
msgstr "Minimaler Bergauf"
-#: fietsboek/templates/browse.jinja2:40
+#: fietsboek/templates/browse.jinja2:60
msgid "page.browse.filter.uphill_boundaries"
msgstr "Bergaufbeschränkungen"
-#: fietsboek/templates/browse.jinja2:46
+#: fietsboek/templates/browse.jinja2:66
msgid "page.browse.filter.uphill_maximum"
msgstr "Maximaler Bergauf"
-#: fietsboek/templates/browse.jinja2:59
+#: fietsboek/templates/browse.jinja2:79
msgid "page.browse.filter.date_boundaries"
msgstr "Datumsgrenzen"
-#: fietsboek/templates/browse.jinja2:73
+#: fietsboek/templates/browse.jinja2:93
msgid "page.browse.filter.type.organic"
msgstr "Aufnahme (natürlich)"
-#: fietsboek/templates/browse.jinja2:74
+#: fietsboek/templates/browse.jinja2:94
msgid "page.browse.filter.type.synthetic"
msgstr "Vorlage (synthetisch)"
-#: fietsboek/templates/browse.jinja2:82
+#: fietsboek/templates/browse.jinja2:102
msgid "page.browse.filter.favourite.all"
msgstr "Alle Strecken"
-#: fietsboek/templates/browse.jinja2:83
+#: fietsboek/templates/browse.jinja2:103
msgid "page.browse.filter.favourite.yes"
msgstr "Nur Favoriten"
-#: fietsboek/templates/browse.jinja2:84
+#: fietsboek/templates/browse.jinja2:104
msgid "page.browse.filter.favourite.no"
msgstr "Keine Favoriten"
-#: fietsboek/templates/browse.jinja2:97
+#: fietsboek/templates/browse.jinja2:117
msgid "page.browse.filter.my_tracks.only"
msgstr "Zeige nur eigene Strecken"
-#: fietsboek/templates/browse.jinja2:98
+#: fietsboek/templates/browse.jinja2:118
msgid "page.browse.filter.friends_tracks_only"
msgstr "Zeige nur Strecken von Freunden"
-#: fietsboek/templates/browse.jinja2:99
+#: fietsboek/templates/browse.jinja2:119
msgid "page.browse.filter.me_tagged_only"
msgstr "Zeige nur Strecken, in denen ich markiert bin"
-#: fietsboek/templates/browse.jinja2:109
+#: fietsboek/templates/browse.jinja2:129
msgid "page.browse.filters.apply"
msgstr "Filter anwenden"
-#: fietsboek/templates/browse.jinja2:113
+#: fietsboek/templates/browse.jinja2:133
msgid "page.browse.filters.clear_all"
msgstr "Filter zurücksetzen"
-#: fietsboek/templates/browse.jinja2:116
+#: fietsboek/templates/browse.jinja2:136
msgid "page.browse.filters.expand_advanced"
msgstr "Erweitert"
-#: fietsboek/templates/browse.jinja2:121 fietsboek/templates/browse.jinja2:122
+#: fietsboek/templates/browse.jinja2:141 fietsboek/templates/browse.jinja2:142
msgid "page.browse.sort.date"
msgstr "Nach Datum sortieren"
-#: fietsboek/templates/browse.jinja2:123 fietsboek/templates/browse.jinja2:124
+#: fietsboek/templates/browse.jinja2:143 fietsboek/templates/browse.jinja2:144
msgid "page.browse.sort.length"
msgstr "Nach Länge sortieren"
-#: fietsboek/templates/browse.jinja2:125 fietsboek/templates/browse.jinja2:126
+#: fietsboek/templates/browse.jinja2:145 fietsboek/templates/browse.jinja2:146
msgid "page.browse.sort.uphill"
msgstr "Nach Bergauf sortieren"
-#: fietsboek/templates/browse.jinja2:127 fietsboek/templates/browse.jinja2:128
+#: fietsboek/templates/browse.jinja2:147 fietsboek/templates/browse.jinja2:148
msgid "page.browse.sort.duration"
msgstr "Nach Dauer sortieren"
-#: fietsboek/templates/browse.jinja2:148
+#: fietsboek/templates/browse.jinja2:170
msgid "page.browse.organic_tooltip"
msgstr "Dies ist eine Aufnahme einer Strecke"
-#: fietsboek/templates/browse.jinja2:150
+#: fietsboek/templates/browse.jinja2:172
msgid "page.browse.synthetic_tooltip"
msgstr "Dies ist eine geplante Strecke"
-#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:127
+#: fietsboek/templates/browse.jinja2:184 fietsboek/templates/details.jinja2:140
#: fietsboek/templates/journey_details.jinja2:124
#: fietsboek/templates/profile_overview.jinja2:20
msgid "page.details.date"
msgstr "Datum"
-#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:141
+#: fietsboek/templates/browse.jinja2:186 fietsboek/templates/details.jinja2:154
#: fietsboek/templates/journey_details.jinja2:76
#: fietsboek/templates/journey_details.jinja2:126
#: fietsboek/templates/profile_overview.jinja2:22
msgid "page.details.length"
msgstr "Länge"
-#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:132
+#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:145
#: fietsboek/templates/journey_details.jinja2:131
#: fietsboek/templates/profile_overview.jinja2:26
msgid "page.details.start_time"
msgstr "Startzeit"
-#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:136
+#: fietsboek/templates/browse.jinja2:193 fietsboek/templates/details.jinja2:149
#: fietsboek/templates/journey_details.jinja2:133
#: fietsboek/templates/profile_overview.jinja2:28
msgid "page.details.end_time"
msgstr "Endzeit"
-#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:145
+#: fietsboek/templates/browse.jinja2:198 fietsboek/templates/details.jinja2:158
#: fietsboek/templates/journey_details.jinja2:80
#: fietsboek/templates/journey_details.jinja2:138
#: fietsboek/templates/profile_overview.jinja2:32
msgid "page.details.uphill"
msgstr "Bergauf"
-#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:149
+#: fietsboek/templates/browse.jinja2:200 fietsboek/templates/details.jinja2:162
#: fietsboek/templates/journey_details.jinja2:84
#: fietsboek/templates/journey_details.jinja2:140
#: fietsboek/templates/profile_overview.jinja2:34
msgid "page.details.downhill"
msgstr "Bergab"
-#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:154
+#: fietsboek/templates/browse.jinja2:205 fietsboek/templates/details.jinja2:167
#: fietsboek/templates/journey_details.jinja2:88
#: fietsboek/templates/journey_details.jinja2:145
#: fietsboek/templates/profile_overview.jinja2:38
msgid "page.details.moving_time"
msgstr "Fahrzeit"
-#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:158
+#: fietsboek/templates/browse.jinja2:207 fietsboek/templates/details.jinja2:171
#: fietsboek/templates/journey_details.jinja2:92
#: fietsboek/templates/journey_details.jinja2:147
#: fietsboek/templates/profile_overview.jinja2:40
msgid "page.details.stopped_time"
msgstr "Haltezeit"
-#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:162
+#: fietsboek/templates/browse.jinja2:211 fietsboek/templates/details.jinja2:175
#: fietsboek/templates/journey_details.jinja2:96
#: fietsboek/templates/journey_details.jinja2:151
#: fietsboek/templates/profile_overview.jinja2:44
msgid "page.details.max_speed"
msgstr "maximale Geschwindigkeit"
-#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:166
+#: fietsboek/templates/browse.jinja2:213 fietsboek/templates/details.jinja2:179
#: fietsboek/templates/journey_details.jinja2:100
#: fietsboek/templates/journey_details.jinja2:153
#: fietsboek/templates/profile_overview.jinja2:46
msgid "page.details.avg_speed"
msgstr "durchschnittliche Geschwindigkeit"
-#: fietsboek/templates/browse.jinja2:196
+#: fietsboek/templates/browse.jinja2:218
#: fietsboek/templates/journey_details.jinja2:158
msgid "page.browse.card.comments"
msgstr "Kommentare"
-#: fietsboek/templates/browse.jinja2:198
+#: fietsboek/templates/browse.jinja2:220
#: fietsboek/templates/journey_details.jinja2:160
msgid "page.browse.card.images"
msgstr "Bilder"
-#: fietsboek/templates/browse.jinja2:216
+#: fietsboek/templates/browse.jinja2:238
msgid "page.browse.download_multiple"
msgstr "ausgewählte Herunterladen"
-#: fietsboek/templates/browse.jinja2:222 fietsboek/templates/browse.jinja2:226
+#: fietsboek/templates/browse.jinja2:244 fietsboek/templates/browse.jinja2:248
msgid "pagination.previous"
msgstr "Vorherige"
-#: fietsboek/templates/browse.jinja2:231 fietsboek/templates/browse.jinja2:235
+#: fietsboek/templates/browse.jinja2:253 fietsboek/templates/browse.jinja2:257
msgid "pagination.next"
msgstr "Nächste"
-#: fietsboek/templates/browse.jinja2:242
+#: fietsboek/templates/browse.jinja2:264
msgid "page.browse.no_results"
msgstr "Es wurden keine Strecken gefunden, die den Filtern entsprechen."
-#: fietsboek/templates/browse.jinja2:244
+#: fietsboek/templates/browse.jinja2:266
msgid "page.browse.no_tracks"
msgstr ""
"Es wurden keine Strecken gefunden, auf die Du Zugriff hast. Versuche, "
@@ -505,46 +505,56 @@ msgid "page.details.share"
msgstr "Teilen"
#: fietsboek/templates/details.jinja2:39
+msgid "page.details.reclimb"
+msgstr "Steigen erneut bestimmen"
+
+#: fietsboek/templates/details.jinja2:40
msgid "page.details.delete"
msgstr "Löschen"
-#: fietsboek/templates/details.jinja2:45
+#: fietsboek/templates/details.jinja2:51
msgid "page.details.sharelink.title"
msgstr "Link zum Teilen"
-#: fietsboek/templates/details.jinja2:49
+#: fietsboek/templates/details.jinja2:55
msgid "page.details.sharelink.info"
msgstr "Jeder mit Zugang zu diesem Link kann die Strecke ansehen!"
-#: fietsboek/templates/details.jinja2:56
+#: fietsboek/templates/details.jinja2:62
msgid "page.details.sharelink.invalidate"
msgstr "Link invalidieren"
-#: fietsboek/templates/details.jinja2:58
+#: fietsboek/templates/details.jinja2:64
msgid "page.details.sharelink.close"
msgstr "Schließen"
-#: fietsboek/templates/details.jinja2:68
+#: fietsboek/templates/details.jinja2:74
msgid "page.details.delete.title"
msgstr "Strecke Löschen"
-#: fietsboek/templates/details.jinja2:72
+#: fietsboek/templates/details.jinja2:78
msgid "page.details.delete.info"
msgstr "Das Löschen der Strecke wird alle damit verbundenen Informationen löschen!"
-#: fietsboek/templates/details.jinja2:77
+#: fietsboek/templates/details.jinja2:83
msgid "page.details.delete.delete"
msgstr "Löschen"
-#: fietsboek/templates/details.jinja2:79
+#: fietsboek/templates/details.jinja2:85
msgid "page.details.delete.close"
msgstr "Abbrechen"
-#: fietsboek/templates/details.jinja2:98
+#: fietsboek/templates/details.jinja2:104
msgid "page.details.tags"
msgstr "Schlagwörter"
-#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10
+#: fietsboek/templates/details.jinja2:111
+msgid "page.details.background_tasks_in_progress"
+msgstr ""
+"Hintergrundaufgaben für diese Strecke sind noch nicht abgeschlossen. "
+"Die angezeigten Informationen sind noch unvollständig."
+
+#: fietsboek/templates/details.jinja2:121 fietsboek/templates/edit.jinja2:10
#: fietsboek/templates/finish_upload.jinja2:10
#: fietsboek/templates/journey_details.jinja2:66
msgid "page.noscript"
@@ -552,35 +562,63 @@ msgstr ""
"JavaScript ist deaktiviert, zum Nutzen aller Funktionen bitte JavaScript "
"aktivieren"
-#: fietsboek/templates/details.jinja2:115
+#: fietsboek/templates/details.jinja2:128
msgid "page.details.download"
msgstr "Herunterladen"
-#: fietsboek/templates/details.jinja2:120
+#: fietsboek/templates/details.jinja2:133
msgid "page.details.download_pdf"
msgstr "PDF Übersicht"
-#: fietsboek/templates/details.jinja2:211
+#: fietsboek/templates/details.jinja2:194
+msgid "page.details.climbs"
+msgstr "Steigen"
+
+#: fietsboek/templates/details.jinja2:197
+msgid "page.details.climbs.mark_on_map"
+msgstr "Steigen auf Karte anzeigen"
+
+#: fietsboek/templates/details.jinja2:203
+msgid "page.details.climbs.climb"
+msgstr "Steige"
+
+#: fietsboek/templates/details.jinja2:220
+msgid "page.details.climb.length"
+msgstr "Länge"
+
+#: fietsboek/templates/details.jinja2:224
+msgid "page.details.climb.average_grade"
+msgstr "Anstieg"
+
+#: fietsboek/templates/details.jinja2:228
+msgid "page.details.climb.lowest_elevation"
+msgstr "Niedrigster Punkt"
+
+#: fietsboek/templates/details.jinja2:232
+msgid "page.details.climb.highest_elevation"
+msgstr "Höchster Punkt"
+
+#: fietsboek/templates/details.jinja2:273
msgid "page.details.comments"
msgstr "Kommentare"
-#: fietsboek/templates/details.jinja2:215
+#: fietsboek/templates/details.jinja2:277
msgid "page.details.comments.author"
msgstr "Kommentar von {}"
-#: fietsboek/templates/details.jinja2:232
+#: fietsboek/templates/details.jinja2:294
msgid "page.details.comments.new.title"
msgstr "Kommentar erstellen"
-#: fietsboek/templates/details.jinja2:235
+#: fietsboek/templates/details.jinja2:297
msgid "page.details.comments.new.input_title"
msgstr "Titel"
-#: fietsboek/templates/details.jinja2:236
+#: fietsboek/templates/details.jinja2:298
msgid "page.details.comments.new.input_comment"
msgstr "Kommentar"
-#: fietsboek/templates/details.jinja2:239
+#: fietsboek/templates/details.jinja2:301
msgid "page.details.comments.new.submit"
msgstr "Absenden"
@@ -1034,35 +1072,35 @@ msgstr "Anzahl der Strecken"
msgid "page.profile.contribution_card.title"
msgstr "Streckenaktivität"
-#: fietsboek/templates/profile_overview.jinja2:147
+#: fietsboek/templates/profile_overview.jinja2:149
msgid "page.profile.contribution_card.legend.less"
msgstr "Weniger km"
-#: fietsboek/templates/profile_overview.jinja2:154
+#: fietsboek/templates/profile_overview.jinja2:156
msgid "page.profile.contribution_card.legend.more"
msgstr "Mehr km"
-#: fietsboek/templates/profile_overview.jinja2:161
+#: fietsboek/templates/profile_overview.jinja2:163
msgid "page.profile.longest_distance_track"
msgstr "Weiteste Strecke"
-#: fietsboek/templates/profile_overview.jinja2:166
+#: fietsboek/templates/profile_overview.jinja2:168
msgid "page.profile.shortest_distance_track"
msgstr "Kürzeste Strecke"
-#: fietsboek/templates/profile_overview.jinja2:171
+#: fietsboek/templates/profile_overview.jinja2:173
msgid "page.profile.longest_duration_track"
msgstr "Am Längsten Dauernde Strecke"
-#: fietsboek/templates/profile_overview.jinja2:176
+#: fietsboek/templates/profile_overview.jinja2:178
msgid "page.profile.shortest_duration_track"
msgstr "Am Kürzesten Dauernde Strecke"
-#: fietsboek/templates/profile_overview.jinja2:197
+#: fietsboek/templates/profile_overview.jinja2:199
msgid "page.profile.heatmap"
msgstr "Heatmap"
-#: fietsboek/templates/profile_overview.jinja2:205
+#: fietsboek/templates/profile_overview.jinja2:207
msgid "page.profile.tilehunt"
msgstr "Kacheljäger"
@@ -1280,7 +1318,7 @@ msgstr "E-Mail-Adresse bestätigt"
msgid "flash.password_updated"
msgstr "Passwort aktualisiert"
-#: fietsboek/views/detail.py:187
+#: fietsboek/views/detail.py:190
msgid "flash.track_deleted"
msgstr "Strecke gelöscht"
@@ -1300,7 +1338,7 @@ msgstr "Keine Datei ausgewählt"
msgid "flash.upload_success"
msgstr "Hochladen erfolgreich"
-#: fietsboek/views/upload.py:196
+#: fietsboek/views/upload.py:202
msgid "flash.upload_cancelled"
msgstr "Hochladen abgebrochen"
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo
index 95464f3..19b3c28 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po
index b94eb76..1feb09a 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.po
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2026-08-13 17:40+0200\n"
+"POT-Creation-Date: 2026-10-01 13:36+0200\n"
"PO-Revision-Date: 2023-04-03 20:42+0200\n"
"Last-Translator: \n"
"Language: en\n"
@@ -18,11 +18,11 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
-#: fietsboek/actions.py:266
+#: fietsboek/actions.py:269
msgid "email.verify_mail.subject"
msgstr "Fietsboek Account Verification"
-#: fietsboek/actions.py:269
+#: fietsboek/actions.py:272
msgid "email.verify.text"
msgstr ""
"To verify your Fietsboek account, please use this link: {}\n"
@@ -61,7 +61,7 @@ msgstr "Max Speed"
msgid "pdf.table.avg_speed"
msgstr "Average Speed"
-#: fietsboek/summaries.py:429
+#: fietsboek/summaries.py:434
msgid "page.profile.contribution_card.tooltip"
msgid_plural "page.profile.contribution_card.tooltips"
msgstr[0] "${count} track on ${date}"
@@ -75,35 +75,35 @@ msgstr "Passwords don't match"
msgid "password_constraint.length"
msgstr "Password not long enough"
-#: fietsboek/models/track.py:774
+#: fietsboek/models/track.py:777
msgid "tooltip.table.length"
msgstr "Length"
-#: fietsboek/models/track.py:775
+#: fietsboek/models/track.py:778
msgid "tooltip.table.people"
msgstr "# People"
-#: fietsboek/models/track.py:776
+#: fietsboek/models/track.py:779
msgid "tooltip.table.uphill"
msgstr "Uphill"
-#: fietsboek/models/track.py:777
+#: fietsboek/models/track.py:780
msgid "tooltip.table.downhill"
msgstr "Downhill"
-#: fietsboek/models/track.py:778 fietsboek/templates/home.jinja2:7
+#: fietsboek/models/track.py:781 fietsboek/templates/home.jinja2:7
msgid "tooltip.table.moving_time"
msgstr "Moving Time"
-#: fietsboek/models/track.py:779 fietsboek/templates/home.jinja2:8
+#: fietsboek/models/track.py:782 fietsboek/templates/home.jinja2:8
msgid "tooltip.table.stopped_time"
msgstr "Stopped Time"
-#: fietsboek/models/track.py:781
+#: fietsboek/models/track.py:784
msgid "tooltip.table.max_speed"
msgstr "Max Speed"
-#: fietsboek/models/track.py:785
+#: fietsboek/models/track.py:788
msgid "tooltip.table.avg_speed"
msgstr "Average Speed"
@@ -269,184 +269,184 @@ msgstr "Length limits"
msgid "page.browse.filter.length_maximum"
msgstr "Maximum length"
-#: fietsboek/templates/browse.jinja2:34
+#: fietsboek/templates/browse.jinja2:54
msgid "page.browse.filter.uphill_minimum"
msgstr "Minimum uphill"
-#: fietsboek/templates/browse.jinja2:40
+#: fietsboek/templates/browse.jinja2:60
msgid "page.browse.filter.uphill_boundaries"
msgstr "Uphill limits"
-#: fietsboek/templates/browse.jinja2:46
+#: fietsboek/templates/browse.jinja2:66
msgid "page.browse.filter.uphill_maximum"
msgstr "Maximum uphill"
-#: fietsboek/templates/browse.jinja2:59
+#: fietsboek/templates/browse.jinja2:79
msgid "page.browse.filter.date_boundaries"
msgstr "Date limits"
-#: fietsboek/templates/browse.jinja2:73
+#: fietsboek/templates/browse.jinja2:93
msgid "page.browse.filter.type.organic"
msgstr "Recording (organic)"
-#: fietsboek/templates/browse.jinja2:74
+#: fietsboek/templates/browse.jinja2:94
msgid "page.browse.filter.type.synthetic"
msgstr "Template (synthetic)"
-#: fietsboek/templates/browse.jinja2:82
+#: fietsboek/templates/browse.jinja2:102
msgid "page.browse.filter.favourite.all"
msgstr "All Tracks"
-#: fietsboek/templates/browse.jinja2:83
+#: fietsboek/templates/browse.jinja2:103
msgid "page.browse.filter.favourite.yes"
msgstr "Only Favourites"
-#: fietsboek/templates/browse.jinja2:84
+#: fietsboek/templates/browse.jinja2:104
msgid "page.browse.filter.favourite.no"
msgstr "No Favourites"
-#: fietsboek/templates/browse.jinja2:97
+#: fietsboek/templates/browse.jinja2:117
msgid "page.browse.filter.my_tracks.only"
msgstr "Show only my own tracks"
-#: fietsboek/templates/browse.jinja2:98
+#: fietsboek/templates/browse.jinja2:118
msgid "page.browse.filter.friends_tracks_only"
msgstr "Show only tracks of my friends"
-#: fietsboek/templates/browse.jinja2:99
+#: fietsboek/templates/browse.jinja2:119
msgid "page.browse.filter.me_tagged_only"
msgstr "Show only tracks in which I'm tagged"
-#: fietsboek/templates/browse.jinja2:109
+#: fietsboek/templates/browse.jinja2:129
msgid "page.browse.filters.apply"
msgstr "Apply filters"
-#: fietsboek/templates/browse.jinja2:113
+#: fietsboek/templates/browse.jinja2:133
msgid "page.browse.filters.clear_all"
msgstr "Remove filters"
-#: fietsboek/templates/browse.jinja2:116
+#: fietsboek/templates/browse.jinja2:136
msgid "page.browse.filters.expand_advanced"
msgstr "Advanced"
-#: fietsboek/templates/browse.jinja2:121 fietsboek/templates/browse.jinja2:122
+#: fietsboek/templates/browse.jinja2:141 fietsboek/templates/browse.jinja2:142
msgid "page.browse.sort.date"
msgstr "Sort by date"
-#: fietsboek/templates/browse.jinja2:123 fietsboek/templates/browse.jinja2:124
+#: fietsboek/templates/browse.jinja2:143 fietsboek/templates/browse.jinja2:144
msgid "page.browse.sort.length"
msgstr "Sort by length"
-#: fietsboek/templates/browse.jinja2:125 fietsboek/templates/browse.jinja2:126
+#: fietsboek/templates/browse.jinja2:145 fietsboek/templates/browse.jinja2:146
msgid "page.browse.sort.uphill"
msgstr "Sort by uphill"
-#: fietsboek/templates/browse.jinja2:127 fietsboek/templates/browse.jinja2:128
+#: fietsboek/templates/browse.jinja2:147 fietsboek/templates/browse.jinja2:148
msgid "page.browse.sort.duration"
msgstr "Sort by duration"
-#: fietsboek/templates/browse.jinja2:148
+#: fietsboek/templates/browse.jinja2:170
msgid "page.browse.organic_tooltip"
msgstr "This is a recording of a track"
-#: fietsboek/templates/browse.jinja2:150
+#: fietsboek/templates/browse.jinja2:172
msgid "page.browse.synthetic_tooltip"
msgstr "This is a pre-planned track"
-#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:127
+#: fietsboek/templates/browse.jinja2:184 fietsboek/templates/details.jinja2:140
#: fietsboek/templates/journey_details.jinja2:124
#: fietsboek/templates/profile_overview.jinja2:20
msgid "page.details.date"
msgstr "Date"
-#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:141
+#: fietsboek/templates/browse.jinja2:186 fietsboek/templates/details.jinja2:154
#: fietsboek/templates/journey_details.jinja2:76
#: fietsboek/templates/journey_details.jinja2:126
#: fietsboek/templates/profile_overview.jinja2:22
msgid "page.details.length"
msgstr "Length"
-#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:132
+#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:145
#: fietsboek/templates/journey_details.jinja2:131
#: fietsboek/templates/profile_overview.jinja2:26
msgid "page.details.start_time"
msgstr "Record Start"
-#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:136
+#: fietsboek/templates/browse.jinja2:193 fietsboek/templates/details.jinja2:149
#: fietsboek/templates/journey_details.jinja2:133
#: fietsboek/templates/profile_overview.jinja2:28
msgid "page.details.end_time"
msgstr "Record End"
-#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:145
+#: fietsboek/templates/browse.jinja2:198 fietsboek/templates/details.jinja2:158
#: fietsboek/templates/journey_details.jinja2:80
#: fietsboek/templates/journey_details.jinja2:138
#: fietsboek/templates/profile_overview.jinja2:32
msgid "page.details.uphill"
msgstr "Uphill"
-#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:149
+#: fietsboek/templates/browse.jinja2:200 fietsboek/templates/details.jinja2:162
#: fietsboek/templates/journey_details.jinja2:84
#: fietsboek/templates/journey_details.jinja2:140
#: fietsboek/templates/profile_overview.jinja2:34
msgid "page.details.downhill"
msgstr "Downhill"
-#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:154
+#: fietsboek/templates/browse.jinja2:205 fietsboek/templates/details.jinja2:167
#: fietsboek/templates/journey_details.jinja2:88
#: fietsboek/templates/journey_details.jinja2:145
#: fietsboek/templates/profile_overview.jinja2:38
msgid "page.details.moving_time"
msgstr "Moving Time"
-#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:158
+#: fietsboek/templates/browse.jinja2:207 fietsboek/templates/details.jinja2:171
#: fietsboek/templates/journey_details.jinja2:92
#: fietsboek/templates/journey_details.jinja2:147
#: fietsboek/templates/profile_overview.jinja2:40
msgid "page.details.stopped_time"
msgstr "Stopped Time"
-#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:162
+#: fietsboek/templates/browse.jinja2:211 fietsboek/templates/details.jinja2:175
#: fietsboek/templates/journey_details.jinja2:96
#: fietsboek/templates/journey_details.jinja2:151
#: fietsboek/templates/profile_overview.jinja2:44
msgid "page.details.max_speed"
msgstr "Max Speed"
-#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:166
+#: fietsboek/templates/browse.jinja2:213 fietsboek/templates/details.jinja2:179
#: fietsboek/templates/journey_details.jinja2:100
#: fietsboek/templates/journey_details.jinja2:153
#: fietsboek/templates/profile_overview.jinja2:46
msgid "page.details.avg_speed"
msgstr "Average Speed"
-#: fietsboek/templates/browse.jinja2:196
+#: fietsboek/templates/browse.jinja2:218
#: fietsboek/templates/journey_details.jinja2:158
msgid "page.browse.card.comments"
msgstr "Comments"
-#: fietsboek/templates/browse.jinja2:198
+#: fietsboek/templates/browse.jinja2:220
#: fietsboek/templates/journey_details.jinja2:160
msgid "page.browse.card.images"
msgstr "Images"
-#: fietsboek/templates/browse.jinja2:216
+#: fietsboek/templates/browse.jinja2:238
msgid "page.browse.download_multiple"
msgstr "Download selected"
-#: fietsboek/templates/browse.jinja2:222 fietsboek/templates/browse.jinja2:226
+#: fietsboek/templates/browse.jinja2:244 fietsboek/templates/browse.jinja2:248
msgid "pagination.previous"
msgstr "Previous"
-#: fietsboek/templates/browse.jinja2:231 fietsboek/templates/browse.jinja2:235
+#: fietsboek/templates/browse.jinja2:253 fietsboek/templates/browse.jinja2:257
msgid "pagination.next"
msgstr "Next"
-#: fietsboek/templates/browse.jinja2:242
+#: fietsboek/templates/browse.jinja2:264
msgid "page.browse.no_results"
msgstr "No results matching the filters were found."
-#: fietsboek/templates/browse.jinja2:244
+#: fietsboek/templates/browse.jinja2:266
msgid "page.browse.no_tracks"
msgstr "You currently do not have access to any tracks. Try logging in."
@@ -503,80 +503,118 @@ msgid "page.details.share"
msgstr "Share"
#: fietsboek/templates/details.jinja2:39
+msgid "page.details.reclimb"
+msgstr "Redetect climbs"
+
+#: fietsboek/templates/details.jinja2:40
msgid "page.details.delete"
msgstr "Delete"
-#: fietsboek/templates/details.jinja2:45
+#: fietsboek/templates/details.jinja2:51
msgid "page.details.sharelink.title"
msgstr "Share Link"
-#: fietsboek/templates/details.jinja2:49
+#: fietsboek/templates/details.jinja2:55
msgid "page.details.sharelink.info"
msgstr "Everyone with access to this link can view the track!"
-#: fietsboek/templates/details.jinja2:56
+#: fietsboek/templates/details.jinja2:62
msgid "page.details.sharelink.invalidate"
msgstr "Invalidate link"
-#: fietsboek/templates/details.jinja2:58
+#: fietsboek/templates/details.jinja2:64
msgid "page.details.sharelink.close"
msgstr "Close"
-#: fietsboek/templates/details.jinja2:68
+#: fietsboek/templates/details.jinja2:74
msgid "page.details.delete.title"
msgstr "Delete Track"
-#: fietsboek/templates/details.jinja2:72
+#: fietsboek/templates/details.jinja2:78
msgid "page.details.delete.info"
msgstr "Deleting this track will remove all associated information with it!"
-#: fietsboek/templates/details.jinja2:77
+#: fietsboek/templates/details.jinja2:83
msgid "page.details.delete.delete"
msgstr "Delete"
-#: fietsboek/templates/details.jinja2:79
+#: fietsboek/templates/details.jinja2:85
msgid "page.details.delete.close"
msgstr "Abort"
-#: fietsboek/templates/details.jinja2:98
+#: fietsboek/templates/details.jinja2:104
msgid "page.details.tags"
msgstr "Tagged as"
-#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10
+#: fietsboek/templates/details.jinja2:111
+msgid "page.details.background_tasks_in_progress"
+msgstr ""
+"Background tasks for this track are still running. "
+"The information displayed is still incomplete."
+
+#: fietsboek/templates/details.jinja2:121 fietsboek/templates/edit.jinja2:10
#: fietsboek/templates/finish_upload.jinja2:10
#: fietsboek/templates/journey_details.jinja2:66
msgid "page.noscript"
msgstr "JavaScript is disabled, please enable JavaScript"
-#: fietsboek/templates/details.jinja2:115
+#: fietsboek/templates/details.jinja2:128
msgid "page.details.download"
msgstr "Download Tour"
-#: fietsboek/templates/details.jinja2:120
+#: fietsboek/templates/details.jinja2:133
msgid "page.details.download_pdf"
msgstr "PDF overview"
-#: fietsboek/templates/details.jinja2:211
+#: fietsboek/templates/details.jinja2:194
+msgid "page.details.climbs"
+msgstr "Climbs"
+
+#: fietsboek/templates/details.jinja2:197
+msgid "page.details.climbs.mark_on_map"
+msgstr "Show climbs on map"
+
+#: fietsboek/templates/details.jinja2:203
+msgid "page.details.climbs.climb"
+msgstr "Climb"
+
+#: fietsboek/templates/details.jinja2:220
+msgid "page.details.climb.length"
+msgstr "Length"
+
+#: fietsboek/templates/details.jinja2:224
+msgid "page.details.climb.average_grade"
+msgstr "Grade"
+
+#: fietsboek/templates/details.jinja2:228
+msgid "page.details.climb.lowest_elevation"
+msgstr "Lowest point"
+
+#: fietsboek/templates/details.jinja2:232
+msgid "page.details.climb.highest_elevation"
+msgstr "Highest point"
+
+#: fietsboek/templates/details.jinja2:273
msgid "page.details.comments"
msgstr "Comments"
-#: fietsboek/templates/details.jinja2:215
+#: fietsboek/templates/details.jinja2:277
msgid "page.details.comments.author"
msgstr "Comment by {}"
-#: fietsboek/templates/details.jinja2:232
+#: fietsboek/templates/details.jinja2:294
msgid "page.details.comments.new.title"
msgstr "Create a new comment"
-#: fietsboek/templates/details.jinja2:235
+#: fietsboek/templates/details.jinja2:297
msgid "page.details.comments.new.input_title"
msgstr "Title"
-#: fietsboek/templates/details.jinja2:236
+#: fietsboek/templates/details.jinja2:298
msgid "page.details.comments.new.input_comment"
msgstr "Comment"
-#: fietsboek/templates/details.jinja2:239
+#: fietsboek/templates/details.jinja2:301
msgid "page.details.comments.new.submit"
msgstr "Submit"
@@ -1028,35 +1066,35 @@ msgstr "Number of tracks"
msgid "page.profile.contribution_card.title"
msgstr "Track Activity"
-#: fietsboek/templates/profile_overview.jinja2:147
+#: fietsboek/templates/profile_overview.jinja2:149
msgid "page.profile.contribution_card.legend.less"
msgstr "Fewer km"
-#: fietsboek/templates/profile_overview.jinja2:154
+#: fietsboek/templates/profile_overview.jinja2:156
msgid "page.profile.contribution_card.legend.more"
msgstr "More km"
-#: fietsboek/templates/profile_overview.jinja2:161
+#: fietsboek/templates/profile_overview.jinja2:163
msgid "page.profile.longest_distance_track"
msgstr "Longest Track"
-#: fietsboek/templates/profile_overview.jinja2:166
+#: fietsboek/templates/profile_overview.jinja2:168
msgid "page.profile.shortest_distance_track"
msgstr "Shortest Track"
-#: fietsboek/templates/profile_overview.jinja2:171
+#: fietsboek/templates/profile_overview.jinja2:173
msgid "page.profile.longest_duration_track"
msgstr "Most Time-Consuming Track"
-#: fietsboek/templates/profile_overview.jinja2:176
+#: fietsboek/templates/profile_overview.jinja2:178
msgid "page.profile.shortest_duration_track"
msgstr "Quickest Track"
-#: fietsboek/templates/profile_overview.jinja2:197
+#: fietsboek/templates/profile_overview.jinja2:199
msgid "page.profile.heatmap"
msgstr "Heat Map"
-#: fietsboek/templates/profile_overview.jinja2:205
+#: fietsboek/templates/profile_overview.jinja2:207
msgid "page.profile.tilehunt"
msgstr "Tilehunt"
@@ -1269,7 +1307,7 @@ msgstr "Your email address has been verified"
msgid "flash.password_updated"
msgstr "Password has been updated"
-#: fietsboek/views/detail.py:187
+#: fietsboek/views/detail.py:190
msgid "flash.track_deleted"
msgstr "Track has been deleted"
@@ -1289,7 +1327,7 @@ msgstr "No file selected"
msgid "flash.upload_success"
msgstr "Upload successful"
-#: fietsboek/views/upload.py:196
+#: fietsboek/views/upload.py:202
msgid "flash.upload_cancelled"
msgstr "Upload cancelled"
diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot
index 53f697f..4a9e203 100644
--- a/fietsboek/locale/fietslog.pot
+++ b/fietsboek/locale/fietslog.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2026-08-13 17:40+0200\n"
+"POT-Creation-Date: 2026-10-01 13:36+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,11 +17,11 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
-#: fietsboek/actions.py:266
+#: fietsboek/actions.py:269
msgid "email.verify_mail.subject"
msgstr ""
-#: fietsboek/actions.py:269
+#: fietsboek/actions.py:272
msgid "email.verify.text"
msgstr ""
@@ -57,7 +57,7 @@ msgstr ""
msgid "pdf.table.avg_speed"
msgstr ""
-#: fietsboek/summaries.py:429
+#: fietsboek/summaries.py:434
msgid "page.profile.contribution_card.tooltip"
msgid_plural "page.profile.contribution_card.tooltips"
msgstr[0] ""
@@ -71,35 +71,35 @@ msgstr ""
msgid "password_constraint.length"
msgstr ""
-#: fietsboek/models/track.py:774
+#: fietsboek/models/track.py:777
msgid "tooltip.table.length"
msgstr ""
-#: fietsboek/models/track.py:775
+#: fietsboek/models/track.py:778
msgid "tooltip.table.people"
msgstr ""
-#: fietsboek/models/track.py:776
+#: fietsboek/models/track.py:779
msgid "tooltip.table.uphill"
msgstr ""
-#: fietsboek/models/track.py:777
+#: fietsboek/models/track.py:780
msgid "tooltip.table.downhill"
msgstr ""
-#: fietsboek/models/track.py:778 fietsboek/templates/home.jinja2:7
+#: fietsboek/models/track.py:781 fietsboek/templates/home.jinja2:7
msgid "tooltip.table.moving_time"
msgstr ""
-#: fietsboek/models/track.py:779 fietsboek/templates/home.jinja2:8
+#: fietsboek/models/track.py:782 fietsboek/templates/home.jinja2:8
msgid "tooltip.table.stopped_time"
msgstr ""
-#: fietsboek/models/track.py:781
+#: fietsboek/models/track.py:784
msgid "tooltip.table.max_speed"
msgstr ""
-#: fietsboek/models/track.py:785
+#: fietsboek/models/track.py:788
msgid "tooltip.table.avg_speed"
msgstr ""
@@ -263,168 +263,184 @@ msgstr ""
msgid "page.browse.filter.length_maximum"
msgstr ""
-#: fietsboek/templates/browse.jinja2:59
+#: fietsboek/templates/browse.jinja2:54
+msgid "page.browse.filter.uphill_minimum"
+msgstr ""
+
+#: fietsboek/templates/browse.jinja2:60
+msgid "page.browse.filter.uphill_boundaries"
+msgstr ""
+
+#: fietsboek/templates/browse.jinja2:66
+msgid "page.browse.filter.uphill_maximum"
+msgstr ""
+
+#: fietsboek/templates/browse.jinja2:79
msgid "page.browse.filter.date_boundaries"
msgstr ""
-#: fietsboek/templates/browse.jinja2:73
+#: fietsboek/templates/browse.jinja2:93
msgid "page.browse.filter.type.organic"
msgstr ""
-#: fietsboek/templates/browse.jinja2:74
+#: fietsboek/templates/browse.jinja2:94
msgid "page.browse.filter.type.synthetic"
msgstr ""
-#: fietsboek/templates/browse.jinja2:82
+#: fietsboek/templates/browse.jinja2:102
msgid "page.browse.filter.favourite.all"
msgstr ""
-#: fietsboek/templates/browse.jinja2:83
+#: fietsboek/templates/browse.jinja2:103
msgid "page.browse.filter.favourite.yes"
msgstr ""
-#: fietsboek/templates/browse.jinja2:84
+#: fietsboek/templates/browse.jinja2:104
msgid "page.browse.filter.favourite.no"
msgstr ""
-#: fietsboek/templates/browse.jinja2:97
+#: fietsboek/templates/browse.jinja2:117
msgid "page.browse.filter.my_tracks.only"
msgstr ""
-#: fietsboek/templates/browse.jinja2:98
+#: fietsboek/templates/browse.jinja2:118
msgid "page.browse.filter.friends_tracks_only"
msgstr ""
-#: fietsboek/templates/browse.jinja2:99
+#: fietsboek/templates/browse.jinja2:119
msgid "page.browse.filter.me_tagged_only"
msgstr ""
-#: fietsboek/templates/browse.jinja2:109
+#: fietsboek/templates/browse.jinja2:129
msgid "page.browse.filters.apply"
msgstr ""
-#: fietsboek/templates/browse.jinja2:113
+#: fietsboek/templates/browse.jinja2:133
msgid "page.browse.filters.clear_all"
msgstr ""
-#: fietsboek/templates/browse.jinja2:116
+#: fietsboek/templates/browse.jinja2:136
msgid "page.browse.filters.expand_advanced"
msgstr ""
-#: fietsboek/templates/browse.jinja2:121 fietsboek/templates/browse.jinja2:122
+#: fietsboek/templates/browse.jinja2:141 fietsboek/templates/browse.jinja2:142
msgid "page.browse.sort.date"
msgstr ""
-#: fietsboek/templates/browse.jinja2:123 fietsboek/templates/browse.jinja2:124
+#: fietsboek/templates/browse.jinja2:143 fietsboek/templates/browse.jinja2:144
msgid "page.browse.sort.length"
msgstr ""
-#: fietsboek/templates/browse.jinja2:125 fietsboek/templates/browse.jinja2:126
+#: fietsboek/templates/browse.jinja2:145 fietsboek/templates/browse.jinja2:146
+msgid "page.browse.sort.uphill"
+msgstr ""
+
+#: fietsboek/templates/browse.jinja2:147 fietsboek/templates/browse.jinja2:148
msgid "page.browse.sort.duration"
msgstr ""
-#: fietsboek/templates/browse.jinja2:148
+#: fietsboek/templates/browse.jinja2:170
msgid "page.browse.organic_tooltip"
msgstr ""
-#: fietsboek/templates/browse.jinja2:150
+#: fietsboek/templates/browse.jinja2:172
msgid "page.browse.synthetic_tooltip"
msgstr ""
-#: fietsboek/templates/browse.jinja2:162 fietsboek/templates/details.jinja2:127
+#: fietsboek/templates/browse.jinja2:184 fietsboek/templates/details.jinja2:140
#: fietsboek/templates/journey_details.jinja2:124
#: fietsboek/templates/profile_overview.jinja2:20
msgid "page.details.date"
msgstr ""
-#: fietsboek/templates/browse.jinja2:164 fietsboek/templates/details.jinja2:141
+#: fietsboek/templates/browse.jinja2:186 fietsboek/templates/details.jinja2:154
#: fietsboek/templates/journey_details.jinja2:76
#: fietsboek/templates/journey_details.jinja2:126
#: fietsboek/templates/profile_overview.jinja2:22
msgid "page.details.length"
msgstr ""
-#: fietsboek/templates/browse.jinja2:169 fietsboek/templates/details.jinja2:132
+#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:145
#: fietsboek/templates/journey_details.jinja2:131
#: fietsboek/templates/profile_overview.jinja2:26
msgid "page.details.start_time"
msgstr ""
-#: fietsboek/templates/browse.jinja2:171 fietsboek/templates/details.jinja2:136
+#: fietsboek/templates/browse.jinja2:193 fietsboek/templates/details.jinja2:149
#: fietsboek/templates/journey_details.jinja2:133
#: fietsboek/templates/profile_overview.jinja2:28
msgid "page.details.end_time"
msgstr ""
-#: fietsboek/templates/browse.jinja2:176 fietsboek/templates/details.jinja2:145
+#: fietsboek/templates/browse.jinja2:198 fietsboek/templates/details.jinja2:158
#: fietsboek/templates/journey_details.jinja2:80
#: fietsboek/templates/journey_details.jinja2:138
#: fietsboek/templates/profile_overview.jinja2:32
msgid "page.details.uphill"
msgstr ""
-#: fietsboek/templates/browse.jinja2:178 fietsboek/templates/details.jinja2:149
+#: fietsboek/templates/browse.jinja2:200 fietsboek/templates/details.jinja2:162
#: fietsboek/templates/journey_details.jinja2:84
#: fietsboek/templates/journey_details.jinja2:140
#: fietsboek/templates/profile_overview.jinja2:34
msgid "page.details.downhill"
msgstr ""
-#: fietsboek/templates/browse.jinja2:183 fietsboek/templates/details.jinja2:154
+#: fietsboek/templates/browse.jinja2:205 fietsboek/templates/details.jinja2:167
#: fietsboek/templates/journey_details.jinja2:88
#: fietsboek/templates/journey_details.jinja2:145
#: fietsboek/templates/profile_overview.jinja2:38
msgid "page.details.moving_time"
msgstr ""
-#: fietsboek/templates/browse.jinja2:185 fietsboek/templates/details.jinja2:158
+#: fietsboek/templates/browse.jinja2:207 fietsboek/templates/details.jinja2:171
#: fietsboek/templates/journey_details.jinja2:92
#: fietsboek/templates/journey_details.jinja2:147
#: fietsboek/templates/profile_overview.jinja2:40
msgid "page.details.stopped_time"
msgstr ""
-#: fietsboek/templates/browse.jinja2:189 fietsboek/templates/details.jinja2:162
+#: fietsboek/templates/browse.jinja2:211 fietsboek/templates/details.jinja2:175
#: fietsboek/templates/journey_details.jinja2:96
#: fietsboek/templates/journey_details.jinja2:151
#: fietsboek/templates/profile_overview.jinja2:44
msgid "page.details.max_speed"
msgstr ""
-#: fietsboek/templates/browse.jinja2:191 fietsboek/templates/details.jinja2:166
+#: fietsboek/templates/browse.jinja2:213 fietsboek/templates/details.jinja2:179
#: fietsboek/templates/journey_details.jinja2:100
#: fietsboek/templates/journey_details.jinja2:153
#: fietsboek/templates/profile_overview.jinja2:46
msgid "page.details.avg_speed"
msgstr ""
-#: fietsboek/templates/browse.jinja2:196
+#: fietsboek/templates/browse.jinja2:218
#: fietsboek/templates/journey_details.jinja2:158
msgid "page.browse.card.comments"
msgstr ""
-#: fietsboek/templates/browse.jinja2:198
+#: fietsboek/templates/browse.jinja2:220
#: fietsboek/templates/journey_details.jinja2:160
msgid "page.browse.card.images"
msgstr ""
-#: fietsboek/templates/browse.jinja2:216
+#: fietsboek/templates/browse.jinja2:238
msgid "page.browse.download_multiple"
msgstr ""
-#: fietsboek/templates/browse.jinja2:222 fietsboek/templates/browse.jinja2:226
+#: fietsboek/templates/browse.jinja2:244 fietsboek/templates/browse.jinja2:248
msgid "pagination.previous"
msgstr ""
-#: fietsboek/templates/browse.jinja2:231 fietsboek/templates/browse.jinja2:235
+#: fietsboek/templates/browse.jinja2:253 fietsboek/templates/browse.jinja2:257
msgid "pagination.next"
msgstr ""
-#: fietsboek/templates/browse.jinja2:242
+#: fietsboek/templates/browse.jinja2:264
msgid "page.browse.no_results"
msgstr ""
-#: fietsboek/templates/browse.jinja2:244
+#: fietsboek/templates/browse.jinja2:266
msgid "page.browse.no_tracks"
msgstr ""
@@ -481,80 +497,116 @@ msgid "page.details.share"
msgstr ""
#: fietsboek/templates/details.jinja2:39
+msgid "page.details.reclimb"
+msgstr ""
+
+#: fietsboek/templates/details.jinja2:40
msgid "page.details.delete"
msgstr ""
-#: fietsboek/templates/details.jinja2:45
+#: fietsboek/templates/details.jinja2:51
msgid "page.details.sharelink.title"
msgstr ""
-#: fietsboek/templates/details.jinja2:49
+#: fietsboek/templates/details.jinja2:55
msgid "page.details.sharelink.info"
msgstr ""
-#: fietsboek/templates/details.jinja2:56
+#: fietsboek/templates/details.jinja2:62
msgid "page.details.sharelink.invalidate"
msgstr ""
-#: fietsboek/templates/details.jinja2:58
+#: fietsboek/templates/details.jinja2:64
msgid "page.details.sharelink.close"
msgstr ""
-#: fietsboek/templates/details.jinja2:68
+#: fietsboek/templates/details.jinja2:74
msgid "page.details.delete.title"
msgstr ""
-#: fietsboek/templates/details.jinja2:72
+#: fietsboek/templates/details.jinja2:78
msgid "page.details.delete.info"
msgstr ""
-#: fietsboek/templates/details.jinja2:77
+#: fietsboek/templates/details.jinja2:83
msgid "page.details.delete.delete"
msgstr ""
-#: fietsboek/templates/details.jinja2:79
+#: fietsboek/templates/details.jinja2:85
msgid "page.details.delete.close"
msgstr ""
-#: fietsboek/templates/details.jinja2:98
+#: fietsboek/templates/details.jinja2:104
msgid "page.details.tags"
msgstr ""
-#: fietsboek/templates/details.jinja2:108 fietsboek/templates/edit.jinja2:10
+#: fietsboek/templates/details.jinja2:111
+msgid "page.details.background_tasks_in_progress"
+msgstr ""
+
+#: fietsboek/templates/details.jinja2:121 fietsboek/templates/edit.jinja2:10
#: fietsboek/templates/finish_upload.jinja2:10
#: fietsboek/templates/journey_details.jinja2:66
msgid "page.noscript"
msgstr ""
-#: fietsboek/templates/details.jinja2:115
+#: fietsboek/templates/details.jinja2:128
msgid "page.details.download"
msgstr ""
-#: fietsboek/templates/details.jinja2:120
+#: fietsboek/templates/details.jinja2:133
msgid "page.details.download_pdf"
msgstr ""
-#: fietsboek/templates/details.jinja2:211
+#: fietsboek/templates/details.jinja2:194
+msgid "page.details.climbs"
+msgstr ""
+
+#: fietsboek/templates/details.jinja2:197
+msgid "page.details.climbs.mark_on_map"
+msgstr ""
+
+#: fietsboek/templates/details.jinja2:203
+msgid "page.details.climbs.climb"
+msgstr ""
+
+#: fietsboek/templates/details.jinja2:220
+msgid "page.details.climb.length"
+msgstr ""
+
+#: fietsboek/templates/details.jinja2:224
+msgid "page.details.climb.average_grade"
+msgstr ""
+
+#: fietsboek/templates/details.jinja2:228
+msgid "page.details.climb.lowest_elevation"
+msgstr ""
+
+#: fietsboek/templates/details.jinja2:232
+msgid "page.details.climb.highest_elevation"
+msgstr ""
+
+#: fietsboek/templates/details.jinja2:273
msgid "page.details.comments"
msgstr ""
-#: fietsboek/templates/details.jinja2:215
+#: fietsboek/templates/details.jinja2:277
msgid "page.details.comments.author"
msgstr ""
-#: fietsboek/templates/details.jinja2:232
+#: fietsboek/templates/details.jinja2:294
msgid "page.details.comments.new.title"
msgstr ""
-#: fietsboek/templates/details.jinja2:235
+#: fietsboek/templates/details.jinja2:297
msgid "page.details.comments.new.input_title"
msgstr ""
-#: fietsboek/templates/details.jinja2:236
+#: fietsboek/templates/details.jinja2:298
msgid "page.details.comments.new.input_comment"
msgstr ""
-#: fietsboek/templates/details.jinja2:239
+#: fietsboek/templates/details.jinja2:301
msgid "page.details.comments.new.submit"
msgstr ""
@@ -1004,35 +1056,35 @@ msgstr ""
msgid "page.profile.contribution_card.title"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:147
+#: fietsboek/templates/profile_overview.jinja2:149
msgid "page.profile.contribution_card.legend.less"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:154
+#: fietsboek/templates/profile_overview.jinja2:156
msgid "page.profile.contribution_card.legend.more"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:161
+#: fietsboek/templates/profile_overview.jinja2:163
msgid "page.profile.longest_distance_track"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:166
+#: fietsboek/templates/profile_overview.jinja2:168
msgid "page.profile.shortest_distance_track"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:171
+#: fietsboek/templates/profile_overview.jinja2:173
msgid "page.profile.longest_duration_track"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:176
+#: fietsboek/templates/profile_overview.jinja2:178
msgid "page.profile.shortest_duration_track"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:197
+#: fietsboek/templates/profile_overview.jinja2:199
msgid "page.profile.heatmap"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:205
+#: fietsboek/templates/profile_overview.jinja2:207
msgid "page.profile.tilehunt"
msgstr ""
@@ -1236,7 +1288,7 @@ msgstr ""
msgid "flash.password_updated"
msgstr ""
-#: fietsboek/views/detail.py:187
+#: fietsboek/views/detail.py:190
msgid "flash.track_deleted"
msgstr ""
@@ -1256,7 +1308,7 @@ msgstr ""
msgid "flash.upload_success"
msgstr ""
-#: fietsboek/views/upload.py:196
+#: fietsboek/views/upload.py:202
msgid "flash.upload_cancelled"
msgstr ""
diff --git a/fietsboek/models/__init__.py b/fietsboek/models/__init__.py
index b63d3ae..b6cd93f 100644
--- a/fietsboek/models/__init__.py
+++ b/fietsboek/models/__init__.py
@@ -9,6 +9,7 @@ from sqlalchemy import engine_from_config, event
from sqlalchemy.orm import configure_mappers, sessionmaker
from .badge import Badge # flake8: noqa
+from .climb import Climb # flake8: noqa
from .comment import Comment # flake8: noqa
from .image import ImageMetadata # flake8: noqa
from .journey import Journey
diff --git a/fietsboek/models/climb.py b/fietsboek/models/climb.py
new file mode 100644
index 0000000..baa7a66
--- /dev/null
+++ b/fietsboek/models/climb.py
@@ -0,0 +1,91 @@
+"""The Climb model."""
+
+from pyramid.httpexceptions import HTTPNotFound
+from pyramid.request import Request
+from sqlalchemy import Float, ForeignKey, Integer, LargeBinary, Text, select
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from ..geo import Path
+from .meta import Base
+from .track import Track
+
+
+class Climb(Base):
+ """A climb represents a subpath of a track that ascends at least 3% over 500m."""
+
+ __tablename__ = "climbs"
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ track_id: Mapped[int] = mapped_column(Integer, ForeignKey("tracks.id"), nullable=False)
+ automatic_name: Mapped[str | None] = mapped_column(Text)
+ given_name: Mapped[str | None] = mapped_column(Text)
+ image: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
+ profile: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
+
+ avg_grade: Mapped[float] = mapped_column(Float, nullable=False)
+ length: Mapped[float] = mapped_column(Float, nullable=False)
+ lowest_elevation: Mapped[float] = mapped_column(Float, nullable=False)
+ highest_elevation: Mapped[float] = mapped_column(Float, nullable=False)
+ category: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
+ start_index: Mapped[int] = mapped_column(Integer, nullable=False)
+ end_index: Mapped[int] = mapped_column(Integer, nullable=False)
+
+ track: Mapped[Track] = relationship("Track", back_populates="climbs")
+
+ @classmethod
+ def factory(cls, request: Request) -> "Climb":
+ """Factory method to pass to a route definition.
+
+ This factory retrieves the climb based on the ``climb_id`` matched
+ route parameter, and returns the climb. If the climb is not found,
+ ``HTTPNotFound`` is raised.
+
+ :raises pyramid.httpexception.NotFound: If the climb is not found.
+ :param request: The pyramid request.
+ :return: The climb.
+ """
+ query = select(cls).filter_by(id=request.matchdict["climb_id"])
+ climb_object = request.dbsession.execute(query).scalar_one_or_none()
+ if climb_object is None:
+ raise HTTPNotFound()
+ return climb_object
+
+ def __acl__(self):
+ # Delegate all access handling to the track this climb belongs to
+ return self.track.__acl__()
+
+ def path(self) -> Path:
+ """Returns the path of this climb."""
+ path = self.track.path()
+ return Path(path.points[self.start_index : self.end_index])
+
+ def display_name(self) -> str | None:
+ """Returns the display name.
+
+ If the given name (by the user) is set, returns this. Otherwise, return
+ the autmatic name.
+
+ :return: The name that should be displayed.
+ """
+ if self.given_name:
+ return self.given_name
+ return self.automatic_name
+
+ def bounds(self) -> tuple[tuple[float, float], tuple[float, float]]:
+ """Returns the bounds of this climb.
+
+ See :func:`Path.bounds`.
+ """
+ return self.path().bounds()
+
+ def latlon_points(self) -> list[tuple[float, float]]:
+ """Returns all points of this path as coordinates.
+
+ The returned points will be the latitude, longitude coordinates.
+
+ :return: The points as coordinates.
+ """
+ return [point.latlon() for point in self.path().points]
+
+
+__all__ = ["Climb"]
diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py
index 95e341f..361cc12 100644
--- a/fietsboek/models/track.py
+++ b/fietsboek/models/track.py
@@ -309,6 +309,9 @@ class Track(Base):
cache: Mapped[Optional["TrackCache"]] = relationship(
"TrackCache", back_populates="track", uselist=False, cascade="all, delete-orphan"
)
+ climbs: Mapped[list["models.Climb"]] = relationship(
+ "Climb", back_populates="track", cascade="all, delete-orphan"
+ )
tagged_people: Mapped[list["models.User"]] = relationship(
"User", secondary=track_people_assoc, back_populates="tagged_tracks"
)
@@ -854,6 +857,11 @@ class TrackWithMetadata:
return self.track.badges[:]
@property
+ def climbs(self) -> list["models.Climb"]:
+ """Climbs of the underlying track."""
+ return self.track.climbs[:]
+
+ @property
def tags(self) -> list["models.Tag"]:
"""Tags of the underlying track."""
return self.track.tags[:]
diff --git a/fietsboek/osm.py b/fietsboek/osm.py
new file mode 100644
index 0000000..9162cd7
--- /dev/null
+++ b/fietsboek/osm.py
@@ -0,0 +1,397 @@
+"""OSM related utilities for fietsboek."""
+
+import logging
+from xml.etree import ElementTree
+
+import requests
+import requests.auth
+
+from . import __VERSION__
+from .config import Config
+from .geo import Path, Point
+
+LOGGER = logging.getLogger(__name__)
+HEADERS = {
+ "user-agent": f"Fietsboek/{__VERSION__}",
+ "accept-encoding": "gzip;q=1.0, identity;q=0.5",
+}
+API_TIMEOUT = 20
+
+MAX_SIMILARITY_STREETNAME = 100
+MAX_DISTANCE_PEAK = 750
+MAX_DISTANCE_CITY = 750
+MAX_DISTANCE_WATER = 750
+
+
+class ApiError(Exception):
+ """Base error class for errors occuring in OSM/Overpass accesses."""
+
+
+class ApiTimeout(ApiError):
+ """API/Tile access timeout."""
+
+
+class ApiNotOk(ApiError):
+ """Server error response (non-200 status code)."""
+
+
+class OverpassApi:
+ """Helper class to access the overpass API.
+
+ This does not really provide a nice interface to use, rather, it takes care
+ of the low-level HTTP fuzz.
+ """
+
+ def __init__(self, interpreter_url: str):
+ self.session = requests.Session()
+ self.session.headers.update(HEADERS)
+ self.url = interpreter_url
+
+ def basic_auth(self, username: str, password: str):
+ """Sets the HTTP basic auth for this API.
+
+ :param username: The API username.
+ :param password: The API password.
+ """
+ self.session.auth = requests.auth.HTTPBasicAuth(username, password)
+
+ @classmethod
+ def from_config(cls, config: Config) -> "OverpassApi":
+ """Construct an OverpassApi from the given fietsboek config.
+
+ :param config: The config object.
+ :return: The API with correct URL/auth.
+ """
+ api = cls(config.overpass_url)
+ if config.overpass_basic_username and config.overpass_basic_password:
+ api.basic_auth(
+ config.overpass_basic_username,
+ config.overpass_basic_password.get_secret_value(),
+ )
+ return api
+
+ def run_script(self, script: str) -> requests.Response:
+ """Posts a overpass script to the API and returns the response.
+
+ Raises an error if the response is not 200.
+
+ :raises ApiTimeout: If the response takes too long.
+ :raises ApiNotOk: If the response code is not 200.
+ :param script: The overpass script.
+ :return: The response.
+ """
+ try:
+ with self.session.post(
+ self.url,
+ data={"data": script},
+ timeout=API_TIMEOUT,
+ ) as response:
+ if response.status_code != 200:
+ raise ApiNotOk(
+ f"Overpass error response: {response.status_code} {response.reason}"
+ )
+ return response
+ except (requests.ReadTimeout, requests.ConnectTimeout) as exc:
+ raise ApiTimeout(f"Timeout when trying to access overpass at {self.url}") from exc
+
+
+def find_name(api: OverpassApi, path: Path) -> str | None:
+ """Attempt to find a name for the given path.
+
+ :param api: The overpass API to use.
+ :param path: Path to find a name for.
+ :return: Name for the path.
+ """
+ LOGGER.debug("Starting naming process")
+
+ (min_lat, min_lon), (max_lat, max_lon) = path.bounds()
+
+ min_lat -= 0.001
+ min_lon -= 0.0015
+ max_lat += 0.001
+ max_lon += 0.0015
+
+ bbox = f"[bbox:{min_lat},{min_lon},{max_lat},{max_lon}];"
+ LOGGER.debug("Requesting %s", bbox)
+
+ overpass_script = f"""
+ {bbox}\n
+ (node;way;);
+ (._;>;);
+ out;
+ """
+ try:
+ response = api.run_script(overpass_script)
+ except ApiError as exc:
+ LOGGER.info("%s", exc)
+ return None
+ data = response.content
+
+ LOGGER.debug("Got %s bytes of OSM-XML data, parsing...", len(data))
+ tree = ElementTree.fromstring(data)
+ LOGGER.debug("Parsing complete!")
+
+ name = _name_from_street(tree, path)
+ if name is None:
+ name = _name_from_peak(tree, path)
+ if name is None:
+ name = _name_from_water(tree, path)
+ if name is None:
+ name = _name_from_city(tree, path)
+ if name is None:
+ name = _fallback_name(tree, path)
+ if name is None:
+ name = "Climb"
+
+ LOGGER.debug("Final name: %s", name)
+ return name
+
+
+def _name_from_street(tree: ElementTree.Element, path: Path) -> str | None:
+ # pylint: disable=too-many-locals,too-many-branches,too-many-statements
+ LOGGER.debug("Attempting to name by street...")
+ node_to_coords = {}
+ for node in tree.iterfind("./node"):
+ node_id = node.get("id")
+ lat = float(node.get("lat") or 0.0)
+ lon = float(node.get("lon") or 0.0)
+ node_to_coords[node_id] = (lat, lon)
+
+ named_streets: dict[str, list[list[str]]] = {}
+
+ # First, we find all named streets. Streets may contain of several
+ # "stretches" that have the same name
+ for way in tree.iterfind("./way"):
+ name_tag = way.find("./tag[@k='name']")
+ if name_tag is None:
+ continue
+ name = name_tag.get("v")
+ assert name is not None
+
+ # Generally, highway=* indicates roads
+ highway_elem = way.find("./tag[@k='highway']")
+ if highway_elem is None:
+ continue
+
+ nodes_refs = [nd.get("ref") for nd in way.iterfind("./nd")]
+ nodes = [ref for ref in nodes_refs if ref is not None]
+
+ stretches = named_streets.setdefault(name, [])
+ stretches.append(nodes)
+
+ best_name = None
+ best_score = None
+
+ # Next, we iterate over the streets we found to see how well they match
+ for name, stretches in named_streets.items():
+ merged_way = stretches[0]
+ remaining = stretches[1:]
+ # This combines the stretches into a single long street, by checking
+ # whether we can find a stretch that "glues" onto the end or beginning
+ # of the current merged street.
+ while remaining:
+ num_remaining = len(remaining)
+ anchor = merged_way[-1]
+ for i, stretch in enumerate(remaining):
+ if stretch[0] == anchor:
+ merged_way.extend(stretch)
+ del remaining[i]
+ break
+ if stretch[-1] == anchor:
+ stretch.reverse()
+ merged_way.extend(stretch)
+ del remaining[i]
+ break
+ anchor = merged_way[0]
+ for i, stretch in enumerate(remaining):
+ if stretch[-1] == anchor:
+ stretch.extend(merged_way)
+ merged_way = stretch
+ del remaining[i]
+ break
+ if stretch[0] == anchor:
+ stretch.reverse()
+ stretch.extend(merged_way)
+ merged_way = stretch
+ del remaining[i]
+ break
+
+ if num_remaining == len(remaining):
+ # Made no progress, abort
+ break
+
+ # Now that we have the merged street, we can check the similarity after
+ # converting the node references to coordinates
+ points = []
+ for node_id in merged_way:
+ coords = node_to_coords[node_id]
+ point = Point(longitude=coords[1], latitude=coords[0], elevation=0.0, time_offset=0.0)
+ points.append(point)
+ if not points:
+ continue
+
+ reference_path = Path(points)
+ similarity = path.similarity(reference_path)
+
+ if best_score is None or similarity < best_score:
+ best_score = similarity
+ best_name = name
+
+ accepted = best_score is not None and best_score < MAX_SIMILARITY_STREETNAME
+ LOGGER.debug("Best name: %s (score=%s) (accepted=%s)", best_name, best_score, accepted)
+
+ if accepted:
+ return best_name
+ return None
+
+
+def _name_from_peak(tree: ElementTree.Element, path: Path) -> str | None:
+ LOGGER.debug("Attempting to name by peak...")
+ best_name = None
+ best_distance = None
+ for peak in tree.iterfind("./node"):
+ if peak.find("./tag[@k='natural'][@v='peak']") is None:
+ continue
+
+ name_elem = peak.find("./tag[@k='name']")
+ if name_elem is None:
+ continue
+ name = name_elem.get("v")
+ lat = float(peak.get("lat") or 0.0)
+ lon = float(peak.get("lon") or 0.0)
+
+ peak_point = Point(longitude=lon, latitude=lat, elevation=0.0, time_offset=0.0)
+ distance = peak_point.flat_distance(path.points[-1])
+
+ if best_distance is None or distance < best_distance:
+ best_distance = distance
+ best_name = name
+
+ accepted = best_distance is not None and best_distance <= MAX_DISTANCE_PEAK
+ LOGGER.debug("Best name: %s (distance=%s) (accepted=%s)", best_name, best_distance, accepted)
+
+ if accepted:
+ return best_name
+ return None
+
+
+def _name_from_water(tree: ElementTree.Element, path: Path) -> str | None:
+ LOGGER.debug("Attempting to name by water...")
+ best_name = None
+ best_distance = None
+ node_to_coord = {}
+ for water in tree.iterfind("./node"):
+ node_id = water.get("id")
+ lat = float(water.get("lat") or 0.0)
+ lon = float(water.get("lon") or 0.0)
+ node_to_coord[node_id] = (lat, lon)
+
+ if water.find("./tag[@k='natural'][@v='water']") is None:
+ continue
+
+ name_elem = water.find("./tag[@k='name']")
+ if name_elem is None:
+ continue
+ name = name_elem.get("v")
+
+ water_point = Point(longitude=lon, latitude=lat, elevation=0.0, time_offset=0.0)
+ distance = water_point.flat_distance(path.points[-1])
+
+ if best_distance is None or distance < best_distance:
+ best_distance = distance
+ best_name = name
+
+ for water in tree.iterfind("./way"):
+ if water.find("./tag[@k='natural'][@v='water']") is None:
+ continue
+
+ name_elem = water.find("./tag[@k='name']")
+ if name_elem is None:
+ continue
+ name = name_elem.get("v")
+
+ for node in water.iterfind("nd"):
+ lat, lon = node_to_coord[node.get("ref")]
+ water_point = Point(longitude=lon, latitude=lat, elevation=0.0, time_offset=0.0)
+ distance = water_point.flat_distance(path.points[-1])
+
+ if best_distance is None or distance < best_distance:
+ best_distance = distance
+ best_name = name
+
+ accepted = best_distance is not None and best_distance <= MAX_DISTANCE_WATER
+ LOGGER.debug(
+ "Best name: %s (distance=%s) (accepted=%s)",
+ best_name,
+ best_distance,
+ accepted,
+ )
+
+ if accepted:
+ return best_name
+ return None
+
+
+def _name_from_city(tree: ElementTree.Element, path: Path) -> str | None:
+ LOGGER.debug("Attempting to name by city...")
+ best_name = None
+ best_distance = None
+ best_place_type = None
+ for city in tree.iterfind("./node"):
+ place_elem = city.find("./tag[@k='place']")
+ if place_elem is None:
+ continue
+ place = place_elem.get("v")
+ if place not in {"city", "town", "village", "suburb"}:
+ continue
+
+ name_elem = city.find("./tag[@k='name']")
+ if name_elem is None:
+ continue
+ name = name_elem.get("v")
+ lat = float(city.get("lat") or 0.0)
+ lon = float(city.get("lon") or 0.0)
+
+ city_point = Point(longitude=lon, latitude=lat, elevation=0.0, time_offset=0.0)
+ distance = city_point.flat_distance(path.points[-1])
+
+ if best_distance is None or distance < best_distance:
+ best_distance = distance
+ best_name = name
+ best_place_type = place
+
+ accepted = best_distance is not None and best_distance <= MAX_DISTANCE_CITY
+ LOGGER.debug(
+ "Best name: %s (place=%s) (distance=%s) (accepted=%s)",
+ best_name,
+ best_place_type,
+ best_distance,
+ accepted,
+ )
+
+ if accepted:
+ return best_name
+ return None
+
+
+def _fallback_name(tree: ElementTree.Element, path: Path) -> str | None:
+ LOGGER.debug("Attempting to find a fallback name...")
+ best_name = None
+ best_distance = None
+ for node in tree.iterfind("./node"):
+ name_elem = node.find("./tag[@k='name']")
+ if name_elem is None:
+ continue
+ name = name_elem.get("v")
+ lat = float(node.get("lat") or 0.0)
+ lon = float(node.get("lon") or 0.0)
+
+ point = Point(longitude=lon, latitude=lat, elevation=0.0, time_offset=0.0)
+ distance = point.flat_distance(path.points[-1])
+
+ if best_distance is None or distance < best_distance:
+ best_distance = distance
+ best_name = name
+
+ LOGGER.debug("Fallback name: %s (distance=%s)", best_name, best_distance)
+ return best_name
diff --git a/fietsboek/routes.py b/fietsboek/routes.py
index 7042415..2e1a171 100644
--- a/fietsboek/routes.py
+++ b/fietsboek/routes.py
@@ -41,6 +41,9 @@ def includeme(config):
"delete-track", "/track/{track_id}/delete", factory="fietsboek.models.Track.factory"
)
config.add_route(
+ "reclimb-track", "/track/{track_id}/reclimb", factory="fietsboek.models.Track.factory"
+ )
+ config.add_route(
"add-comment", "/track/{track_id}/comment", factory="fietsboek.models.Track.factory"
)
config.add_route(
@@ -57,6 +60,17 @@ def includeme(config):
factory="fietsboek.models.Track.factory",
)
+ config.add_route(
+ "climb-preview",
+ "/climb/{climb_id}/preview",
+ factory="fietsboek.models.Climb.factory",
+ )
+ config.add_route(
+ "climb-profile",
+ "/climb/{climb_id}/profile",
+ factory="fietsboek.models.Climb.factory",
+ )
+
config.add_route("journey-list", "/journey/")
config.add_route(
"journey-map",
diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py
index ea05f15..c9bb9dd 100644
--- a/fietsboek/scripts/fietscron.py
+++ b/fietsboek/scripts/fietscron.py
@@ -8,19 +8,22 @@ import click
import pyramid.paster
import redis as mod_redis
from redis import Redis
-from sqlalchemy import create_engine, delete, exists, not_, select
+from sqlalchemy import delete, exists, not_, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from .. import config as mod_config
-from .. import hittekaart, models, trackmap
+from .. import hittekaart, models, osm, trackmap
from ..config import Config
from ..data import DataManager
+from ..models import get_engine
from ..models.user import TOKEN_LIFETIME
+from ..osm import OverpassApi
from ..views.tileproxy import TileRequester
from . import config_option
LOGGER = logging.getLogger(__name__)
+MAX_CLIMB_NAMING_TIME = datetime.timedelta(minutes=10)
@click.command()
@@ -35,6 +38,7 @@ def cli(config):
* Rebuilds the cache for missing tracks.
* Builds preview images for tracks.
* (optional) Runs ``hittekaart`` to generate heatmaps
+ * Finds names for climbs not yet named
"""
logging.config.fileConfig(config)
settings = pyramid.paster.get_appsettings(config)
@@ -47,14 +51,16 @@ def cli(config):
LOGGER.info("Skipping cronjob tasks due to maintenance mode")
return
- engine = create_engine(config.sqlalchemy_url)
+ engine = get_engine(settings)
redis = mod_redis.from_url(config.redis_url)
+ overpass_api = OverpassApi.from_config(config)
LOGGER.debug("Starting maintenance tasks")
remove_old_uploads(engine)
remove_old_tokens(engine)
rebuild_cache(engine)
build_previews(engine, data_manager, redis, config)
+ name_climbs(engine, overpass_api)
redis = mod_redis.from_url(config.redis_url)
if config.hittekaart_autogenerate:
@@ -194,4 +200,24 @@ def refill_queue(session: Session, redis: Redis):
redis.sadd("hittekaart:queue:low", user.id)
+def name_climbs(engine: Engine, overpass_api: OverpassApi):
+ """Attempts to find names for climbs that have no name assigned yet."""
+ session = Session(engine)
+ LOGGER.debug("Naming climbs")
+ query = select(models.Climb).where(models.Climb.automatic_name.is_(None))
+ start = datetime.datetime.now(datetime.UTC)
+ for climb in session.execute(query).scalars():
+ LOGGER.debug("Attempting climb %s", climb.id)
+ path = climb.path()
+ name = osm.find_name(overpass_api, path)
+ if name is not None:
+ climb.automatic_name = name
+ session.add(climb)
+ session.commit()
+ elapsed = datetime.datetime.now(datetime.UTC) - start
+ if elapsed > MAX_CLIMB_NAMING_TIME:
+ LOGGER.debug("Exceeded time alotted for naming climbs, aborting for now...")
+ break
+
+
__all__ = ["cli"]
diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css
index 1005e75..0925d4e 100644
--- a/fietsboek/static/theme.css
+++ b/fietsboek/static/theme.css
@@ -169,6 +169,61 @@ strong {
cursor: pointer;
}
+/* Climbs! */
+.climbs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1em;
+}
+
+.climb {
+ width: 300px;
+}
+
+.climb-preview {
+ cursor: zoom-in;
+}
+
+.climb-category {
+ float: right;
+ border: 3px solid;
+ border-radius: 5px;
+ min-width: 1.5em;
+ text-align: center;
+ font-size: 80%;
+}
+
+.climb-category-4 {
+ border-color: #99ae36;
+ background-color: #D4E09B;
+ color: #000;
+}
+
+.climb-category-3 {
+ border-color: #8a7b3e;
+ background-color: #C8BB84;
+ color: #000;
+}
+
+.climb-category-2 {
+ border-color: #7a5a38;
+ background-color: #BC956D;
+ color: #fff;
+}
+
+.climb-category-1 {
+ border-color: #6b4332;
+ background-color: #B07056;
+ color: #fff;
+}
+
+.climb-category-0 {
+ border-color: #622c26;
+ background-color: #A44A3F;
+ color: #fff;
+}
+
+/* </Climbs> */
.count-field {
width: 50px;
display: inline-block;
diff --git a/fietsboek/static/theme.css.map b/fietsboek/static/theme.css.map
index 262e299..c7a2016 100644
--- a/fietsboek/static/theme.css.map
+++ b/fietsboek/static/theme.css.map
@@ -1 +1 @@
-{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAIA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;AAlCJ;EAqCE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACI;;;AAGJ;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAIJ;EACE;;AAEF;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;EACA;;AACA;EACE;;AAIJ;EACE;EACA;;;AAKF;EACE;EACA;EAEA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAGF;EACE;;;AAGF;AACA;EACE;;;AAGF;EACE;EACA;;AAEA;EAJF;IAKI,eACE;;;AAIJ;EACE;;AAGF;EACE;;AAEA;EAHF;IAII;;;AAGF;EACE;EACA;;AAIJ;EACE;EACA;;;AAIJ;EACE;;;AAGF;AACA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"} \ No newline at end of file
+{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAIA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;AAlCJ;EAqCE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACI;;;AAGJ;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;EACE;;;AAGF;EACE;;;AAGF;AACA;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAEF;AAEA;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAIJ;EACE;;AAEF;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;EACA;;AACA;EACE;;AAIJ;EACE;EACA;;;AAKF;EACE;EACA;EAEA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAGF;EACE;;;AAGF;AACA;EACE;;;AAGF;EACE;EACA;;AAEA;EAJF;IAKI,eACE;;;AAIJ;EACE;;AAGF;EACE;;AAEA;EAHF;IAII;;;AAGF;EACE;EACA;;AAIJ;EACE;EACA;;;AAIJ;EACE;;;AAGF;AACA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"} \ No newline at end of file
diff --git a/fietsboek/templates/details.jinja2 b/fietsboek/templates/details.jinja2
index f97f01f..748fc6f 100644
--- a/fietsboek/templates/details.jinja2
+++ b/fietsboek/templates/details.jinja2
@@ -36,8 +36,14 @@
<div class="btn-group" role="group">
<a class="btn btn-success ui-element" href="{{ request.route_path('edit', track_id=track.id) }}"><i class="bi-pencil-square"></i> {{ _("page.details.edit") }}</a>
<button type="button" class="btn btn-info ui-element" id="showShareLink" data-bs-toggle="modal" data-bs-target="#shareLinkModal"><i class="bi-share"></i> {{ _("page.details.share") }}</button>
+ <button type="submit" class="btn btn-info ui-element" form="reclimbForm"><i class="bi bi-graph-up-arrow"></i> {{ _("page.details.reclimb") }}</button>
<button type="button" class="btn btn-danger ui-element" id="deleteLink" data-bs-toggle="modal" data-bs-target="#deleteModal"><i class="bi bi-trash"></i> {{ _("page.details.delete") }}</button>
</div>
+
+ <form id="reclimbForm" method="POST" action="{{ request.route_path('reclimb-track', track_id=track.id) }}">
+ {{ util.hidden_csrf_input() }}
+ </form>
+
<div class="modal fade" id="shareLinkModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@@ -99,6 +105,13 @@
</p>
{% endif %}
+ {% if bgtasks_in_progress %}
+ <div class="alert alert-warning" role="alert">
+ <i class="bi bi-exclamation-diamond-fill"></i>
+ {{ _("page.details.background_tasks_in_progress") }}
+ </div>
+ {% endif %}
+
{% if 'secret' in request.GET %}
{% set gpx_url = request.route_path('gpx', track_id=track.id, _query=[('secret', request.GET['secret'])]) %}
{% else %}
@@ -176,6 +189,55 @@
{% endfor %}
</div>
{% endif %}
+
+ {% if track.climbs %}
+ <h2>{{ _("page.details.climbs") }}</h2>
+ <div class="form-check form-switch">
+ <input class="form-check-input" type="checkbox" role="switch" id="switchMarkClimbs">
+ <label class="form-check-label" for="switchMarkClimbs">{{ _("page.details.climbs.mark_on_map") }}</label>
+ </div>
+ <div class="climbs">
+ {% for climb in track.climbs %}
+ <div class="climb card">
+ <h5 class="card-header">
+ {{ climb.display_name() | default(_("page.details.climbs.climb"), true) }}
+ {% if climb.category is not none %}
+ <div class="climb-category climb-category-{{ climb.category }}">
+ {% if climb.category == 0 %}
+ HC
+ {% else %}
+ {{ climb.category }}
+ {% endif %}
+ </div>
+ {% endif %}
+ </h5>
+
+ <img class="mb-2 climb-preview" data-bounds="{{ climb.bounds() | tojson }}" src="{{ request.route_url('climb-preview', climb_id=climb.id) }}">
+ <img src="{{ request.route_url('climb-profile', climb_id=climb.id) }}">
+
+ <table>
+ <tr>
+ <th scope="row">{{ _("page.details.climb.length") }}</th>
+ <td>{{ (climb.length / 1000) | round(2) | format_decimal }} km</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.climb.average_grade") }}</th>
+ <td>{{ (climb.avg_grade * 100) | round(2) | format_decimal }} %</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.climb.lowest_elevation") }}</th>
+ <td>{{ climb.lowest_elevation | round(2) | format_decimal }} m</td>
+ </tr>
+ <tr>
+ <th scope="row">{{ _("page.details.climb.highest_elevation") }}</th>
+ <td>{{ climb.highest_elevation | round(2) | format_decimal }} m</td>
+ </tr>
+ </table>
+ </div>
+ {% endfor %}
+ </div>
+ {% endif %}
+
{% if description %}
<div class="track-description">
{{ description }}
@@ -244,3 +306,52 @@
{% endif %}
</div>
{% endblock %}
+
+{% block latescripts %}
+<script type="text/javascript">
+ (() => {
+ document.querySelectorAll(".climb-preview").forEach((img) => {
+ img.addEventListener("click", (e) => {
+ let mapDiv = document.getElementById("mainmap");
+ let bounds = JSON.parse(img.getAttribute("data-bounds"));
+ let map = mapDiv.makeMap.GetMap().map;
+ map.flyToBounds(bounds);
+ mapDiv.scrollIntoView();
+ });
+ });
+
+ let climbs = [
+ {% for climb in track.climbs %}
+ {{ climb.latlon_points() | tojson }}{% if not loop.last %},{% endif %}
+ {% endfor %}
+ ];
+
+ function showClimbs() {
+ let map = document.getElementById("mainmap").makeMap.GetMap().map;
+ window.fietsboekClimbs = climbs.map((c) => new L.Polyline(
+ c.map(([lat, lon]) => new L.LatLng(lat, lon)),
+ { color: "blue" },
+ ));
+ for (let poly of window.fietsboekClimbs) {
+ poly.addTo(map);
+ }
+ }
+
+ function hideClimbs() {
+ if (window.fietsboekClimbs) {
+ for (let poly of window.fietsboekClimbs) {
+ poly.remove();
+ }
+ }
+ }
+
+ document.querySelector("#switchMarkClimbs").addEventListener("change", (e) => {
+ if (e.target.checked) {
+ showClimbs();
+ } else {
+ hideClimbs()
+ };
+ });
+ })();
+</script>
+{% endblock %}
diff --git a/fietsboek/trackmap.py b/fietsboek/trackmap.py
index 4cb13aa..df21af1 100644
--- a/fietsboek/trackmap.py
+++ b/fietsboek/trackmap.py
@@ -1,14 +1,18 @@
"""Module to render tracks to static images on OSM tiles."""
import io
+import logging
import math
+import requests
from PIL import Image, ImageDraw
from . import geo
from .config import TileLayerConfig
from .views.tileproxy import TileRequester
+LOGGER = logging.getLogger(__name__)
+
TILE_SIZE = 256
# This is arbitrarily set to provide some image in case a render is requested
@@ -97,6 +101,7 @@ class TrackMapRenderer:
return DEFAULT_ZOOM, DEFAULT_BBOX
def _draw_base(self, image, zoom, bbox):
+ # pylint: disable=too-many-locals
min_x, max_x, min_y, max_y = bbox
# We center the track by centering its bounding box
start_x = min_x - (self.size[0] - (max_x - min_x)) // 2
@@ -107,8 +112,12 @@ class TrackMapRenderer:
for i in range(int(math.ceil(self.size[0] / TILE_SIZE)) + 1):
for j in range(int(math.ceil(self.size[1] / TILE_SIZE)) + 1):
- tile = self._load_tile(zoom, start_x // TILE_SIZE + i, start_y // TILE_SIZE + j)
- image.paste(tile, (i * TILE_SIZE + offset_x, j * TILE_SIZE + offset_y))
+ try:
+ tile = self._load_tile(zoom, start_x // TILE_SIZE + i, start_y // TILE_SIZE + j)
+ except requests.RequestException as exc:
+ LOGGER.error("Error downloading tiles: %s", exc)
+ else:
+ image.paste(tile, (i * TILE_SIZE + offset_x, j * TILE_SIZE + offset_y))
return start_x, start_y
@@ -145,4 +154,69 @@ def render(
return TrackMapRenderer(track, requester, size, layer).render()
-__all__ = ["to_web_mercator", "TrackMapRenderer", "render"]
+def _ascension_to_color(ascension: float) -> str:
+ # pylint: disable=too-many-return-statements
+ if ascension >= 0.20:
+ return "#99108a"
+ if ascension >= 0.14:
+ return "#ad1314"
+ if ascension >= 0.11:
+ return "#cb5126"
+ if ascension >= 0.08:
+ return "#c9775b"
+ if ascension >= 0.05:
+ return "#cbc410"
+ if ascension >= 0.02:
+ return "#109874"
+ return "#57bf93"
+
+
+def height_profile(track: geo.Path, size: tuple[int, int] = (300, 300)) -> str:
+ """Generates a height profile for the given track.
+
+ The output will be the image as a SVG source.
+
+ Note that SVG is a vector graphic, so the given size is only a hint
+ embedded in metadata. The image is freely scalable.
+
+ :param track: Path for which to generate a height profile.
+ :param size: Display size of the image (embedded as metadata).
+ :return: The height profile as SVG image.
+ """
+ # pylint: disable=too-many-locals
+ out = io.StringIO()
+
+ width, height = size
+
+ path_len = track.movement_data().length
+ max_elev = max(point.elevation for point in track.points)
+ min_elev = min(point.elevation for point in track.points)
+ delta_elev = max_elev - min_elev
+
+ out.write(
+ '<svg viewBox="0 -1000 1000 1000" '
+ f'width="{width}" height="{height}" preserveAspectRatio="none" '
+ 'xmlns="http://www.w3.org/2000/svg">\n'
+ )
+
+ cur_x = 0.0
+ for left, right in zip(track.points, track.points[1:]):
+ right_x = cur_x + (left.distance(right) / path_len) * 1000
+ left_y = (left.elevation - min_elev) / delta_elev * 1000
+ right_y = (right.elevation - min_elev) / delta_elev * 1000
+ ascension = (right.elevation - left.elevation) / max(left.distance(right), 0.001)
+ color = _ascension_to_color(ascension)
+ out.write(
+ f'<polygon points="{cur_x},0 {cur_x},{-left_y} {right_x},{-right_y} {right_x},0" '
+ f'fill="{color}" stroke="{color}" />\n'
+ )
+ cur_x = right_x
+
+ out.write("</svg>")
+
+ result = out.getvalue()
+ out.close()
+ return result
+
+
+__all__ = ["to_web_mercator", "TrackMapRenderer", "render", "height_profile"]
diff --git a/fietsboek/updater/script.py b/fietsboek/updater/script.py
index e54f435..0ac7874 100644
--- a/fietsboek/updater/script.py
+++ b/fietsboek/updater/script.py
@@ -2,6 +2,10 @@
# Placed in a separate file to avoid cyclic dependencies
+from sqlalchemy import Engine
+
+from ..models import get_engine
+
class UpdateScript:
"""Base class for update scripts.
@@ -10,6 +14,14 @@ class UpdateScript:
methods for user interaction.
"""
+ def dbengine(self, config: dict) -> Engine:
+ """Return a database engine.
+
+ :param config: The app configuration.
+ :return: The correctly configured database engine.
+ """
+ return get_engine(config)
+
def tell(self, text):
"""Output a message to the user.
diff --git a/fietsboek/updater/scripts/upd_20260930_7fa8kflikvnlvuqe.py b/fietsboek/updater/scripts/upd_20260930_7fa8kflikvnlvuqe.py
new file mode 100644
index 0000000..e708b8e
--- /dev/null
+++ b/fietsboek/updater/scripts/upd_20260930_7fa8kflikvnlvuqe.py
@@ -0,0 +1,51 @@
+"""Revision upgrade script 7fa8kflikvnlvuqe
+
+Add climbs table
+
+Date created: 2026-09-30 19:54:52.669074
+"""
+import redis
+from sqlalchemy import select
+from sqlalchemy.orm import sessionmaker
+
+from fietsboek import actions
+from fietsboek import config as mod_config
+from fietsboek.models import Track
+from fietsboek.updater.script import UpdateScript
+from fietsboek.views.tileproxy import TileRequester
+
+update_id = '7fa8kflikvnlvuqe'
+previous = [
+ 'v0.12.1',
+]
+alembic_revision = 'b73ed8b45f67'
+
+
+class Up(UpdateScript):
+ def pre_alembic(self, config):
+ pass
+
+ def post_alembic(self, config):
+ fietsconfig = mod_config.parse(config)
+ engine = self.dbengine(config)
+ Session = sessionmaker(engine)
+
+ redis_con = redis.from_url(fietsconfig.redis_url)
+
+ layer = fietsconfig.public_tile_layers()[0]
+ tile_requester = TileRequester(redis_con)
+
+ with Session() as session:
+ for track in session.execute(select(Track)).scalars():
+ self.tell(f"Extracting climbs for track {track.id}")
+ actions.extract_climbs(session, track, tile_requester, layer, None)
+ session.commit()
+ self.tell("Done extracting climbs")
+
+
+class Down(UpdateScript):
+ def pre_alembic(self, config):
+ pass
+
+ def post_alembic(self, config):
+ pass
diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py
index ca3a0af..91778fd 100644
--- a/fietsboek/views/detail.py
+++ b/fietsboek/views/detail.py
@@ -18,7 +18,7 @@ from pyramid.response import FileResponse, Response
from pyramid.view import view_config
from sqlalchemy import select
-from .. import models, pdf, trackmap, util
+from .. import actions, models, pdf, trackmap, util
from ..models.track import Track, TrackWithMetadata
from .tileproxy import ITileRequester
@@ -104,6 +104,8 @@ def details(request):
# Strip off the sort key again
images = [(image[1], image[2]) for image in images]
+ bgtasks_in_progress = request.redis.get(f"bg-tasks:track-{track.id}") is not None
+
with_meta = TrackWithMetadata(track)
return {
"track": with_meta,
@@ -114,6 +116,7 @@ def details(request):
"description": description,
"og_description": og_description,
"images": images,
+ "bgtasks_in_progress": bgtasks_in_progress,
}
@@ -192,6 +195,17 @@ def delete_track(request):
return HTTPFound(request.route_url("home"))
+@view_config(route_name="reclimb-track", request_method="POST", permission="track.edit")
+def reclimb_track(request: Request) -> Response:
+ """Endpoint to re-extract the track's climbs.
+
+ :param request: The Pyramid request.
+ :return: The HTTP response.
+ """
+ actions.extract_climbs_background(request, request.context.id)
+ return HTTPFound(request.route_url("details", track_id=request.context.id))
+
+
@view_config(route_name="badge", http_cache=3600)
def badge(request):
"""Returns the image data associated with a badge.
@@ -298,6 +312,44 @@ def track_pdf(request: Request):
return response
+@view_config(route_name="climb-preview", permission="track.view")
+def climb_preview(request: Request):
+ """Endpoint to return the climb's preview image.
+
+ :param request: The pyramid request.
+ :return: The HTTP response.
+ """
+ climb: models.Climb = request.context
+ response = Response(climb.image, content_type="image/png")
+ return response
+
+
+@view_config(route_name="climb-profile", permission="track.view")
+def climb_profile(request: Request):
+ """Endpoint to return the climb's profile image.
+
+ :param request: The pyramid request.
+ :return: The HTTP response.
+ """
+ climb: models.Climb = request.context
+ accepted = request.accept_encoding.acceptable_offers(["gzip", "identity"])
+ for encoding, _qvalue in accepted:
+ if encoding == "gzip":
+ response = Response(
+ climb.profile,
+ content_type="image/svg+xml",
+ content_encoding="gzip",
+ )
+ break
+ if encoding == "identity":
+ data = gzip.decompress(climb.profile)
+ response = Response(data, content_type="image/svg+xml")
+ break
+ else:
+ return HTTPNotAcceptable("No data with acceptable encoding found")
+ return response
+
+
__all__ = [
"details",
"gpx",
@@ -308,4 +360,6 @@ __all__ = [
"add_comment",
"track_map",
"track_pdf",
+ "climb_preview",
+ "climb_profile",
]
diff --git a/fietsboek/views/tileproxy.py b/fietsboek/views/tileproxy.py
index f472d6d..8717d6c 100644
--- a/fietsboek/views/tileproxy.py
+++ b/fietsboek/views/tileproxy.py
@@ -20,7 +20,7 @@ from pyramid.httpexceptions import HTTPBadRequest, HTTPGatewayTimeout
from pyramid.request import Request
from pyramid.response import Response
from pyramid.view import view_config
-from requests.exceptions import ReadTimeout
+from requests.exceptions import ConnectTimeout, ReadTimeout, RequestException
from zope.interface import Interface, implementer
from .. import __VERSION__
@@ -411,8 +411,9 @@ def tile_proxy(request):
try:
# We already tried the cache, so bypass it here
resp = loader.load_tile(tile_sources[provider], z, x, y, headers=headers, use_cache=False)
- except ReadTimeout:
+ except (ReadTimeout, ConnectTimeout) as exc:
LOGGER.debug("Proxy timeout when accessing z:%s/x:%s/y:%s from %s", z, x, y, provider)
+ LOGGER.error("Error accessing %s: %s", provider, exc)
request.redis.incr(timeout_tracker)
request.redis.expire(timeout_tracker, PUNISHMENT_TTL)
raise HTTPGatewayTimeout(f"No response in time from {provider}") from None
@@ -422,6 +423,10 @@ def tile_proxy(request):
if exc.response:
status_code = exc.response.status_code
return Response(f"Failed to get tile from {provider}", status_code=status_code)
+ except RequestException as exc:
+ LOGGER.error("Request error for %s: %s", provider, exc)
+ status_code = 500
+ return Response(f"Failed to get tile from {provider}", status_code=status_code)
else:
request.redis.set(cache_key, resp, ex=TTL)
return Response(resp, content_type=content_type)
diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py
index 7be5a42..d611723 100644
--- a/fietsboek/views/upload.py
+++ b/fietsboek/views/upload.py
@@ -179,7 +179,14 @@ def do_finish_upload(request):
if request.config.hittekaart_autogenerate:
request.redis.sadd("hittekaart:queue:high", request.identity.id)
- return HTTPFound(request.route_url("details", track_id=track.id))
+ track_id = track.id
+ if not request.tm.isDoomed():
+ request.tm.commit()
+ request.tm.begin()
+
+ actions.extract_climbs_background(request, track_id)
+
+ return HTTPFound(request.route_url("details", track_id=track_id))
@view_config(route_name="cancel-upload", permission="upload.finish", request_method="POST")
diff --git a/tests/integration/test_upload.py b/tests/integration/test_upload.py
index 8d51512..1b7516a 100644
--- a/tests/integration/test_upload.py
+++ b/tests/integration/test_upload.py
@@ -4,13 +4,20 @@ from sqlalchemy import select, func
from webtest import Upload
from testutils import load_gpx_asset
-from fietsboek import models
+from fietsboek import actions, models
def test_upload_forbidden(testapp, route_path):
testapp.get(route_path('upload'), status="4*")
-def test_upload(testapp, dbsession, route_path, logged_in):
+def test_upload(testapp, dbsession, route_path, logged_in, tm, monkeypatch):
+ # pylint: disable=too-many-positional-arguments
+ monkeypatch.setattr(
+ actions,
+ "extract_climbs_background",
+ lambda _request, _track_id: None,
+ )
+
# Step 1: Go to the upload form
upload_site = testapp.get(route_path('upload'))