From 2c9c566fea75bb25964dbf82f1cca22efa25023f Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Sun, 27 Sep 2026 12:05:42 +0200 Subject: wip --- fietsboek/actions.py | 45 +++++++++++++++++++++++++++++++++++++- fietsboek/geo.py | 34 ++++++++++++++++++++++++++++ fietsboek/models/climb.py | 11 ++++++++-- fietsboek/routes.py | 12 ++++++++++ fietsboek/templates/details.jinja2 | 32 +++++++++++++++++++++++++++ fietsboek/views/detail.py | 43 ++++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 3 deletions(-) diff --git a/fietsboek/actions.py b/fietsboek/actions.py index cdddaa2..1290726 100644 --- a/fietsboek/actions.py +++ b/fietsboek/actions.py @@ -22,6 +22,7 @@ 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 +279,46 @@ def send_verification_token(request: Request, user: models.User): ) -__all__ = ["add_track", "edit_images", "execute_transformers", "send_verification_token"] +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)) + + climb = models.Climb( + track=track, + start_index=climb_start, + end_index=climb_end, + image=preview, + profile=profile.encode("ascii"), + avg_grade=movement_data.elevation_gain / movement_data.length, + length=movement_data.length, + lowest_elevation=movement_data.min_elevation, + highest_elevation=movement_data.max_elevation, + ) + dbsession.add(climb) + + +__all__ = ["add_track", "edit_images", "execute_transformers", "send_verification_token", "extract_climbs"] diff --git a/fietsboek/geo.py b/fietsboek/geo.py index 190454b..6f9a3c6 100644 --- a/fietsboek/geo.py +++ b/fietsboek/geo.py @@ -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: @@ -208,6 +236,12 @@ class Path: length += self.points[j].distance(self.points[j + 1]) elevation_diff += self.points[j + 1].elevation - self.points[j].elevation j += 1 + backlen = 0.0 + back_elevation = 0.0 + while j > i + 1 and (backlen == 0.0 or back_elevation / backlen < 0.0): + backlen += self.points[j - 1].distance(self.points[j]) + back_elevation += self.points[j].elevation - self.points[j - 1].elevation + j -= 1 if length >= CLIMB_MIN_LENGTH: yield (i, j) i = j 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) diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 7042415..442c1c3 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -56,6 +56,18 @@ def includeme(config): "/track/{track_id}/index.pdf", factory="fietsboek.models.Track.factory", ) + config.add_route("climbs", "/track/{track_id}/climbs.json", 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( diff --git a/fietsboek/templates/details.jinja2 b/fietsboek/templates/details.jinja2 index f97f01f..f07aa77 100644 --- a/fietsboek/templates/details.jinja2 +++ b/fietsboek/templates/details.jinja2 @@ -110,6 +110,23 @@
+ + +
{{ _("page.details.download") }} @@ -176,6 +193,21 @@ {% endfor %}
{% endif %} + + {% if track.climbs %} +

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

+
+ {% for climb in track.climbs %} +
+ {{ climb.display_name() }} + + + +
+ {% endfor %} +
+ {% endif %} + {% if description %}
{{ description }} diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index ca3a0af..eb41211 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -76,6 +76,12 @@ def details(request): :rtype: pyramid.response.Response """ track = request.context + + loader: ITileRequester = request.registry.getUtility(ITileRequester) + layer = request.config.public_tile_layers()[0] + from fietsboek import actions + actions.extract_climbs(request.dbsession, track, loader, layer) + description = util.safe_markdown(track.description) og_description = _og_description(description) show_edit_link = track.owner == request.identity @@ -117,6 +123,18 @@ def details(request): } +@view_config( + route_name="climbs", renderer="json", permission="track.view" +) +def climbs(request): + track = request.context + path = track.path() + return [ + [path.points[i].latlon() for i in range(climb_start, climb_end + 1)] + for climb_start, climb_end in path.climbs() + ] + + @view_config(route_name="gpx", http_cache=3600, permission="track.view") def gpx(request): """Returns the actual GPX data from the stored track. @@ -298,6 +316,30 @@ 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 + response = Response(climb.profile, content_type="image/svg+xml") + return response + + __all__ = [ "details", "gpx", @@ -308,4 +350,5 @@ __all__ = [ "add_comment", "track_map", "track_pdf", + "climb_preview", ] -- cgit v1.2.3