From e592c65a55c8523987c9aeec627d693be7487c82 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 22 Sep 2026 21:10:32 +0200 Subject: implement climb detection --- fietsboek/geo.py | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/fietsboek/geo.py b/fietsboek/geo.py index e6abb71..e7a58a9 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,15 @@ 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 = 5 +"""Window for when climbs are merged (in m).""" + @dataclass class Waypoint: @@ -120,6 +130,10 @@ 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.""" @@ -162,6 +176,93 @@ class Path: movement_data.average_speed = 0.0 return movement_data + 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. + """ + current_climb = None + for climb in self._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 _climbs(self): + # Detection of climbs: + # We use a single pass over the track with two pointers, the "leader" + # and the "chaser". The chaser always points behind the leader. In a + # loop, one of each is always advanced: + # + # If the distance between leader and chaser is less than the minimum + # climb length, we advance the leader to enlarge the window. This + # does not constitute a climb. + # + # If the current ascension is below the ascension threshold, we advance + # the chaser. This moves the window to advance the search. If the + # ascension was above the threshold before the last advancement, we emit + # the climb (which is now over). + # + # If the current ascension is above the threshold, we advance the + # leader. This enlarges the window while constituting a climb. + try: + front = enumerate(self.points) + li, lead = next(front) + back = enumerate(self.points) + ci, chaser = next(back) + except StopIteration: + # Happens if there are no points + return + + length = 0.0 + elevation_diff = 0.0 + climb_start = None + + try: + while True: + if length < CLIMB_MIN_LENGTH: + old_point = lead + li, lead = next(front) + length += old_point.distance(lead) + elevation_diff += lead.elevation - old_point.elevation + continue + + ascension = elevation_diff / length + if ascension < CLIMB_MIN_ASCENSION: + if climb_start is not None: + yield (climb_start, li) + climb_start = None + # Skip to the end of the just emitted climb + while ci != li: + old_point = chaser + ci, chaser = next(back) + length -= old_point.distance(chaser) + elevation_diff -= chaser.elevation - old_point.elevation + else: + old_point = chaser + ci, chaser = next(back) + length -= old_point.distance(chaser) + elevation_diff -= chaser.elevation - old_point.elevation + else: + if climb_start is None: + climb_start = ci + # See how much farther we can extend this climb + old_point = lead + li, lead = next(front) + length += old_point.distance(lead) + elevation_diff += lead.elevation - old_point.elevation + except StopIteration: + if climb_start is not None: + yield (climb_start, li) + def gpx_xml( title: str | None, -- cgit v1.2.3 From 9a0a4fbfbd005c64c2db2c1606cc7dccbff22053 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 24 Sep 2026 22:26:24 +0200 Subject: fix climb detection The old algorithm didn't work; it left large amounts of "non-climb" space before the climb. This is because we always kept the window at the minimum length, so even a large mountain at the end of the window could push the average above the ascension threshold and cause the whole window to be included. --- fietsboek/geo.py | 81 +++++++++++--------------------------------------------- 1 file changed, 16 insertions(+), 65 deletions(-) diff --git a/fietsboek/geo.py b/fietsboek/geo.py index e7a58a9..190454b 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -197,71 +197,22 @@ class Path: yield current_climb def _climbs(self): - # Detection of climbs: - # We use a single pass over the track with two pointers, the "leader" - # and the "chaser". The chaser always points behind the leader. In a - # loop, one of each is always advanced: - # - # If the distance between leader and chaser is less than the minimum - # climb length, we advance the leader to enlarge the window. This - # does not constitute a climb. - # - # If the current ascension is below the ascension threshold, we advance - # the chaser. This moves the window to advance the search. If the - # ascension was above the threshold before the last advancement, we emit - # the climb (which is now over). - # - # If the current ascension is above the threshold, we advance the - # leader. This enlarges the window while constituting a climb. - try: - front = enumerate(self.points) - li, lead = next(front) - back = enumerate(self.points) - ci, chaser = next(back) - except StopIteration: - # Happens if there are no points - return - - length = 0.0 - elevation_diff = 0.0 - climb_start = None - - try: - while True: - if length < CLIMB_MIN_LENGTH: - old_point = lead - li, lead = next(front) - length += old_point.distance(lead) - elevation_diff += lead.elevation - old_point.elevation - continue - - ascension = elevation_diff / length - if ascension < CLIMB_MIN_ASCENSION: - if climb_start is not None: - yield (climb_start, li) - climb_start = None - # Skip to the end of the just emitted climb - while ci != li: - old_point = chaser - ci, chaser = next(back) - length -= old_point.distance(chaser) - elevation_diff -= chaser.elevation - old_point.elevation - else: - old_point = chaser - ci, chaser = next(back) - length -= old_point.distance(chaser) - elevation_diff -= chaser.elevation - old_point.elevation - else: - if climb_start is None: - climb_start = ci - # See how much farther we can extend this climb - old_point = lead - li, lead = next(front) - length += old_point.distance(lead) - elevation_diff += lead.elevation - old_point.elevation - except StopIteration: - if climb_start is not None: - yield (climb_start, li) + i = 0 + + while i < len(self.points): + left = self.points[i] + 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 gpx_xml( -- cgit v1.2.3 From ff56efd935d3c68fb9db1921819bef43020b9212 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Fri, 25 Sep 2026 09:38:12 +0200 Subject: code for climb profile rendering --- fietsboek/trackmap.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/fietsboek/trackmap.py b/fietsboek/trackmap.py index 4cb13aa..cb88264 100644 --- a/fietsboek/trackmap.py +++ b/fietsboek/trackmap.py @@ -145,4 +145,54 @@ def render( return TrackMapRenderer(track, requester, size, layer).render() -__all__ = ["to_web_mercator", "TrackMapRenderer", "render"] +def _ascension_to_color(ascension: float) -> str: + 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: + 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( + '\n' + ) + + cur_x = 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'\n') + cur_x = right_x + + out.write("") + + result = out.getvalue() + out.close() + return result + return out + + +__all__ = ["to_web_mercator", "TrackMapRenderer", "render", "height_profile"] -- cgit v1.2.3 From 3a5187c118aba5eac93e85c9394346fc49fed604 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Fri, 25 Sep 2026 09:39:14 +0200 Subject: add climb model --- fietsboek/models/__init__.py | 1 + fietsboek/models/climb.py | 64 ++++++++++++++++++++++++++++++++++++++++++++ fietsboek/models/track.py | 8 ++++++ 3 files changed, 73 insertions(+) create mode 100644 fietsboek/models/climb.py diff --git a/fietsboek/models/__init__.py b/fietsboek/models/__init__.py index 004ed8e..4d4fcaf 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..3dd8d17 --- /dev/null +++ b/fietsboek/models/climb.py @@ -0,0 +1,64 @@ +"""The Climb model.""" + +from typing import TYPE_CHECKING + +from pyramid.httpexceptions import HTTPNotFound +from pyramid.request import Request +from sqlalchemy import ForeignKey, Integer, LargeBinary, Text, select +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .meta import Base +from ..geo import Path + +if TYPE_CHECKING: + from .track import Track + + +class Climb(Base): + + # pylint: disable=too-few-public-methods + __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) + + 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: + if self.given_name: + return self.given_name + return self.automatic_name + +__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" ) @@ -853,6 +856,11 @@ class TrackWithMetadata: """Badges of the underlying track.""" 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.""" -- cgit v1.2.3 From c48b6a287a5f107328558f53d0e08110df7ea522 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 27 Sep 2026 21:44:25 +0200 Subject: add climbs to track detail page --- asset-sources/theme.scss | 51 ++++++++++++++++++++++++++++++++++++++ fietsboek/static/theme.css | 51 ++++++++++++++++++++++++++++++++++++++ fietsboek/static/theme.css.map | 2 +- fietsboek/templates/details.jinja2 | 45 +++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss index 374f5fe..85062a9 100644 --- a/asset-sources/theme.scss +++ b/asset-sources/theme.scss @@ -176,6 +176,57 @@ strong { cursor: pointer; } +/* Climbs! */ +.climbs { + display: flex; + flex-wrap: wrap; + gap: 1em; +} + +.climb { + width: 300px; +} + +.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; +} +/* */ + .count-field { width: 50px; display: inline-block; diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css index 1005e75..e5c0f73 100644 --- a/fietsboek/static/theme.css +++ b/fietsboek/static/theme.css @@ -169,6 +169,57 @@ strong { cursor: pointer; } +/* Climbs! */ +.climbs { + display: flex; + flex-wrap: wrap; + gap: 1em; +} + +.climb { + width: 300px; +} + +.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; +} + +/* */ .count-field { width: 50px; display: inline-block; diff --git a/fietsboek/static/theme.css.map b/fietsboek/static/theme.css.map index 262e299..ff147a0 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;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..6eb7835 100644 --- a/fietsboek/templates/details.jinja2 +++ b/fietsboek/templates/details.jinja2 @@ -176,6 +176,51 @@ {% endfor %} {% endif %} + + {% if track.climbs %} +

{{ _("page.details.climbs") }}

+
+ {% for climb in track.climbs %} +
+
+ {{ climb.display_name() }} + {% if climb.category is not none %} +
+ {% if climb.category == 0 %} + HC + {% else %} + {{ climb.category }} + {% endif %} +
+ {% endif %} +
+ + + + + + + + + + + + + + + + + + + + + +
{{ _("page.details.climb.length") }}{{ (climb.length / 1000) | round(2) | format_decimal }} km
{{ _("page.details.climb.average_grade") }}{{ (climb.avg_grade * 100) | round(2) | format_decimal }} %
{{ _("page.details.climb.lowest_elevation") }}{{ climb.lowest_elevation | round(2) | format_decimal }} m
{{ _("page.details.climb.highest_elevation") }}{{ climb.highest_elevation | round(2) | format_decimal }} m
+
+ {% endfor %} +
+ {% endif %} + {% if description %}
{{ description }} -- cgit v1.2.3 From 77a12c168822ec62a1669103f9e94a8d5d30c0b3 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 27 Sep 2026 21:44:50 +0200 Subject: more climb metadata in database --- fietsboek/models/climb.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/fietsboek/models/climb.py b/fietsboek/models/climb.py index 3dd8d17..33ee0e2 100644 --- a/fietsboek/models/climb.py +++ b/fietsboek/models/climb.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from pyramid.httpexceptions import HTTPNotFound from pyramid.request import Request -from sqlalchemy import ForeignKey, Integer, LargeBinary, Text, select +from sqlalchemy import Float, ForeignKey, Integer, LargeBinary, Text, select from sqlalchemy.orm import Mapped, mapped_column, relationship from .meta import Base @@ -22,7 +22,14 @@ class Climb(Base): 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) + 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) -- cgit v1.2.3 From 2ac7a51cfb51e4a9597e10cf081e81cc19c3f642 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 27 Sep 2026 22:07:29 +0200 Subject: shave trailing downhill off climbs --- fietsboek/geo.py | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/fietsboek/geo.py b/fietsboek/geo.py index 190454b..e747f4d 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -24,7 +24,7 @@ CLIMB_MIN_LENGTH = 500 CLIMB_MIN_ASCENSION = 0.03 """Minimum relative ascension for a segment to be considered a climb.""" -CLIMB_MERGE_THRESHOLD = 5 +CLIMB_MERGE_THRESHOLD = 50 """Window for when climbs are merged (in m).""" @@ -69,6 +69,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: @@ -157,6 +166,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: @@ -182,8 +210,21 @@ class Path: :return: An iterator over the climbs, represented by the starting index and ending index of points included in the climb. """ + return self._trimmed_climbs() + + 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._climbs(): + for climb in self._raw_climbs(): if current_climb is None: current_climb = climb else: @@ -196,7 +237,7 @@ class Path: if current_climb is not None: yield current_climb - def _climbs(self): + def _raw_climbs(self): i = 0 while i < len(self.points): -- cgit v1.2.3 From 02e217dfd6f12b930034aef83c731dd7ef8be37a Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 27 Sep 2026 22:24:45 +0200 Subject: scroll climb into view when clicked --- fietsboek/geo.py | 14 ++++++++++++++ fietsboek/models/climb.py | 3 +++ fietsboek/templates/details.jinja2 | 18 +++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/fietsboek/geo.py b/fietsboek/geo.py index e747f4d..4daef06 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -204,6 +204,20 @@ class Path: movement_data.average_speed = 0.0 return movement_data + def bounds(self) -> tuple[tuple[float, float], tuple[float, float]]: + 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. diff --git a/fietsboek/models/climb.py b/fietsboek/models/climb.py index 33ee0e2..2bdda9f 100644 --- a/fietsboek/models/climb.py +++ b/fietsboek/models/climb.py @@ -68,4 +68,7 @@ class Climb(Base): return self.given_name return self.automatic_name + def bounds(self) -> tuple[tuple[float, float], tuple[float, float]]: + return self.path().bounds() + __all__ = ["Climb"] diff --git a/fietsboek/templates/details.jinja2 b/fietsboek/templates/details.jinja2 index 6eb7835..9d29a7f 100644 --- a/fietsboek/templates/details.jinja2 +++ b/fietsboek/templates/details.jinja2 @@ -195,7 +195,7 @@ {% endif %} - + @@ -289,3 +289,19 @@ {% endif %} {% endblock %} + +{% block latescripts %} + +{% endblock %} -- cgit v1.2.3 From 4b5f715a9b0bc85634ea4c3795f2d6262e14124f Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 27 Sep 2026 23:43:01 +0200 Subject: make climbs showable on map --- asset-sources/theme.scss | 4 ++++ fietsboek/models/climb.py | 3 +++ fietsboek/static/theme.css | 4 ++++ fietsboek/static/theme.css.map | 2 +- fietsboek/templates/details.jinja2 | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss index 85062a9..1941f36 100644 --- a/asset-sources/theme.scss +++ b/asset-sources/theme.scss @@ -187,6 +187,10 @@ strong { width: 300px; } +.climb-preview { + cursor: zoom-in; +} + .climb-category { float: right; border: 3px solid; diff --git a/fietsboek/models/climb.py b/fietsboek/models/climb.py index 2bdda9f..a2040f2 100644 --- a/fietsboek/models/climb.py +++ b/fietsboek/models/climb.py @@ -71,4 +71,7 @@ class Climb(Base): def bounds(self) -> tuple[tuple[float, float], tuple[float, float]]: return self.path().bounds() + def latlon_points(self) -> list[tuple[float, float]]: + return [point.latlon() for point in self.path().points] + __all__ = ["Climb"] diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css index e5c0f73..0925d4e 100644 --- a/fietsboek/static/theme.css +++ b/fietsboek/static/theme.css @@ -180,6 +180,10 @@ strong { width: 300px; } +.climb-preview { + cursor: zoom-in; +} + .climb-category { float: right; border: 3px solid; diff --git a/fietsboek/static/theme.css.map b/fietsboek/static/theme.css.map index ff147a0..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;AACA;EACE;EACA;EACA;;;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 +{"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 9d29a7f..fb2f9d8 100644 --- a/fietsboek/templates/details.jinja2 +++ b/fietsboek/templates/details.jinja2 @@ -179,6 +179,10 @@ {% if track.climbs %}

{{ _("page.details.climbs") }}

+
+ + +
{% for climb in track.climbs %}
@@ -302,6 +306,39 @@ 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() + }; + }); })(); {% endblock %} -- cgit v1.2.3 From 79b5a9a740eeaec74d3bf22f61aa636f84b93c18 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 27 Sep 2026 23:52:04 +0200 Subject: filter short climbs --- fietsboek/geo.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/fietsboek/geo.py b/fietsboek/geo.py index 4daef06..ee26424 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -224,7 +224,13 @@ class Path: :return: An iterator over the climbs, represented by the starting index and ending index of points included in the climb. """ - return self._trimmed_climbs() + # 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(): -- cgit v1.2.3 From 49c1309ebbbd059fec68bfaee74aba50f60ea34b Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 28 Sep 2026 23:06:31 +0200 Subject: implement automatic climb naming --- fietsboek/geo.py | 60 +++++++++++ fietsboek/osm.py | 302 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 fietsboek/osm.py diff --git a/fietsboek/geo.py b/fietsboek/geo.py index ee26424..91da445 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -27,6 +27,8 @@ CLIMB_MIN_ASCENSION = 0.03 CLIMB_MERGE_THRESHOLD = 50 """Window for when climbs are merged (in m).""" +NUM_SIMILARITY_SAMPLES = 10 + @dataclass class Waypoint: @@ -275,6 +277,64 @@ class Path: else: i += 1 + def similarity(self, reference: "Path") -> float: + 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: + 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, + ) + + 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/osm.py b/fietsboek/osm.py new file mode 100644 index 0000000..101bb89 --- /dev/null +++ b/fietsboek/osm.py @@ -0,0 +1,302 @@ +"""OSM related utilities for fietsboek.""" + +import logging +import pathlib +from urllib.parse import urljoin +from xml.etree import ElementTree + +import requests + +from . import __VERSION__ +from .geo import Path, Point + +LOGGER = logging.getLogger(__name__) +API_URL = "https://api.openstreetmap.org/" +HEADERS = { + "user-agent": f"Fietsboek/{__VERSION__}", +} + +MAX_SIMILARITY_STREETNAME = 100 +MAX_DISTANCE_PEAK = 750 +MAX_DISTANCE_CITY = 750 +MAX_DISTANCE_WATER = 750 + + +def find_name(path: Path) -> str: + """Attempt to find a name for the given path. + + :param path: Path to find a name for. + :return: Name for the path. + """ + LOGGER.debug("Starting naming process") + session = requests.Session() + + ((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 + + LOGGER.debug("bbox=%s,%s,%s,%s", min_lon, min_lat, max_lon, max_lat) + + urlpath = f"api/0.6/map?bbox={min_lon},{min_lat},{max_lon},{max_lat}" + with requests.get(urljoin(API_URL, urlpath), headers=HEADERS) as response: + data = response.content + + tree = ElementTree.fromstring(data) + + 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.ElementTree, path: Path) -> str | None: + 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")) + lon = float(node.get("lon")) + node_to_coords[node_id] = (lat, lon) + + named_streets = {} + + for way in tree.iterfind("./way"): + name_tag = way.find("./tag[@k='name']") + if name_tag is None: + continue + name = name_tag.get("v") + + # Generally, highway=* indicates roads + highway_elem = way.find("./tag[@k='highway']") + if highway_elem is None: + continue + + nodes = [nd.get("ref") for nd in way.iterfind("./nd")] + + stretches = named_streets.setdefault(name, []) + stretches.append(nodes) + + best_name = None + best_score = None + + for name, stretches in named_streets.items(): + merged_way = stretches[0] + remaining = stretches[1:] + 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 + + 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.ElementTree, 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")) + lon = float(peak.get("lon")) + + 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.ElementTree, 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")) + lon = float(water.get("lon")) + 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.ElementTree, 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")) + lon = float(city.get("lon")) + + 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.ElementTree, 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")) + lon = float(node.get("lon")) + + 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 -- cgit v1.2.3 From 6258b36bab2044f802cb89f244fca388bad27987 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Mon, 28 Sep 2026 23:09:01 +0200 Subject: implement climb extraction action --- fietsboek/actions.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/fietsboek/actions.py b/fietsboek/actions.py index cdddaa2..bcde0e4 100644 --- a/fietsboek/actions.py +++ b/fietsboek/actions.py @@ -7,6 +7,7 @@ the test functions. """ import datetime +import gzip import io import logging import re @@ -17,11 +18,12 @@ 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 @@ -278,4 +280,70 @@ 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, +): + """(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. + """ + 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]) + 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 = movement_data.elevation_gain / movement_data.length + + name = osm.find_name(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) + + +__all__ = ["add_track", "edit_images", "execute_transformers", "send_verification_token", "extract_climbs"] -- cgit v1.2.3 From bbfd8ff542dae773ef4affa914c1862f06aaf455 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Tue, 29 Sep 2026 09:16:41 +0200 Subject: request gzip-encoded osm xml --- fietsboek/osm.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fietsboek/osm.py b/fietsboek/osm.py index 101bb89..5513a67 100644 --- a/fietsboek/osm.py +++ b/fietsboek/osm.py @@ -1,7 +1,7 @@ """OSM related utilities for fietsboek.""" +import gzip import logging -import pathlib from urllib.parse import urljoin from xml.etree import ElementTree @@ -14,6 +14,7 @@ LOGGER = logging.getLogger(__name__) API_URL = "https://api.openstreetmap.org/" HEADERS = { "user-agent": f"Fietsboek/{__VERSION__}", + "accept-encoding": "gzip;q=1.0, identity;q=0.5", } MAX_SIMILARITY_STREETNAME = 100 @@ -30,6 +31,7 @@ def find_name(path: Path) -> str: """ LOGGER.debug("Starting naming process") session = requests.Session() + session.headers = HEADERS ((min_lat, min_lon), (max_lat, max_lon)) = path.bounds() @@ -41,7 +43,7 @@ def find_name(path: Path) -> str: LOGGER.debug("bbox=%s,%s,%s,%s", min_lon, min_lat, max_lon, max_lat) urlpath = f"api/0.6/map?bbox={min_lon},{min_lat},{max_lon},{max_lat}" - with requests.get(urljoin(API_URL, urlpath), headers=HEADERS) as response: + with session.get(urljoin(API_URL, urlpath)) as response: data = response.content tree = ElementTree.fromstring(data) -- cgit v1.2.3 From 68a5f39211ebafec3ea97ad1fc558ca35ed2d5a5 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 30 Sep 2026 00:25:28 +0200 Subject: detect climbs as part of upload --- fietsboek/views/upload.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index 7be5a42..278eee2 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -2,6 +2,7 @@ import datetime import logging +from threading import Thread from pyramid.httpexceptions import HTTPBadRequest, HTTPFound from pyramid.i18n import TranslationString as _ @@ -10,7 +11,7 @@ from pyramid.view import view_config from sqlalchemy import select from .. import actions, convert, models, transformers, util -from ..models.track import TrackType, Visibility +from ..models.track import Track, TrackType, Visibility from ..views.tileproxy import ITileRequester LOGGER = logging.getLogger(__name__) @@ -179,7 +180,23 @@ 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 + request.tm.commit() + request.tm.begin() + + 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) + session = request.registry["dbsession_factory"]() + track = session.get(Track, track_id) + with session: + actions.extract_climbs(session, track, loader, layer) + session.commit() + LOGGER.debug("Climb extraction done") + Thread(name=f"upload-{track_id}", target=_extract_climbs).start() + + return HTTPFound(request.route_url("details", track_id=track_id)) @view_config(route_name="cancel-upload", permission="upload.finish", request_method="POST") -- cgit v1.2.3 From ffa4b71706d6d855978cd833cc8bed13171127ce Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 30 Sep 2026 00:26:39 +0200 Subject: add routes for climb images --- fietsboek/routes.py | 11 +++++++++++ fietsboek/views/detail.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 7042415..bf8db1a 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -57,6 +57,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/views/detail.py b/fietsboek/views/detail.py index ca3a0af..8ce6718 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -298,6 +298,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 +346,6 @@ __all__ = [ "add_comment", "track_map", "track_pdf", + "climb_preview", + "climb_profile", ] -- cgit v1.2.3 From 4653773ae29b75c07cec5b53a655d2c29a2bc135 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 30 Sep 2026 17:10:32 +0200 Subject: display note if climbs are still in progress --- fietsboek/templates/details.jinja2 | 7 +++++++ fietsboek/views/detail.py | 3 +++ fietsboek/views/upload.py | 2 ++ 3 files changed, 12 insertions(+) diff --git a/fietsboek/templates/details.jinja2 b/fietsboek/templates/details.jinja2 index fb2f9d8..0bbcd44 100644 --- a/fietsboek/templates/details.jinja2 +++ b/fietsboek/templates/details.jinja2 @@ -99,6 +99,13 @@

{% endif %} + {% if bgtasks_in_progress %} + + {% endif %} + {% if 'secret' in request.GET %} {% set gpx_url = request.route_path('gpx', track_id=track.id, _query=[('secret', request.GET['secret'])]) %} {% else %} diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index 8ce6718..9454b94 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -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, } diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index 278eee2..610847c 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -188,11 +188,13 @@ def do_finish_upload(request): 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(Track, track_id) with session: actions.extract_climbs(session, track, loader, layer) session.commit() + request.redis.delete(f"bg-tasks:track-{track_id}") LOGGER.debug("Climb extraction done") Thread(name=f"upload-{track_id}", target=_extract_climbs).start() -- cgit v1.2.3 From 731cc2aeaed97dde06091f2600f6ffeacbf3eed3 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 30 Sep 2026 17:31:40 +0200 Subject: add a button to re-extract climbs --- fietsboek/routes.py | 3 +++ fietsboek/templates/details.jinja2 | 6 ++++++ fietsboek/views/detail.py | 24 +++++++++++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/fietsboek/routes.py b/fietsboek/routes.py index bf8db1a..2e1a171 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -40,6 +40,9 @@ def includeme(config): config.add_route( "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" ) diff --git a/fietsboek/templates/details.jinja2 b/fietsboek/templates/details.jinja2 index 0bbcd44..a800998 100644 --- a/fietsboek/templates/details.jinja2 +++ b/fietsboek/templates/details.jinja2 @@ -36,8 +36,14 @@
{{ _("page.details.edit") }} +
+ +
+ {{ util.hidden_csrf_input() }} + +