Trail-Katalog pro Team: GPX hochladen, auf Karte anzeigen, verortete Meldungen setzen, kommentieren. Neue Uploads legen Versionen an statt zu überschreiben; Marker und Kommentare hängen am Trail und überleben damit einen Track-Austausch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
Trails: GPX-Tracks, Marker, Flags und Kommentare
Datum: 2026-08-06 Status: Entwurf, vom Nutzer freigegeben
Ziel
Teams sollen GPX-Tracks hochladen, beschriften und auf einer Karte ansehen können. Zu jedem Trail lassen sich verortete Meldungen setzen (Baum quer, Erosion, gesperrt) und Kommentare schreiben. Neue GPX-Uploads ersetzen den Track nicht, sondern legen eine Version an — Korrekturen bleiben nachvollziehbar.
Ausgangslage
- SvelteKit 5 mit Svelte-5-Runes, PocketBase als Backend (Monorepo:
frontend/,backend/) - Bestehende Collections:
users,teams,events,runs,riders,times - Alles ist teamgebunden: Die API-Rules lauten durchweg
team.users.id ?= @request.auth.id - Store-Muster: Klasse mit
$state, Context über Symbol, Realtime-Subscription,scoped-Getter für das aktive Team (siehesrc/lib/stores/events.svelte.ts) - Keine Kartenbibliothek vorhanden — MapLibre GL kommt neu dazu
- Das Schema ist als Migration in
backend/pb_migrations/versioniert; Typen entstehen daraus übernpm run generate-pocketbase-types
Entscheidungen
| Frage | Entscheidung |
|---|---|
| Zweck | Eigenständiger Trail-Katalog, optional einem Event zugeordnet |
| Sichtbarkeit | Teamspezifisch, keine öffentlichen Trails |
| Bearbeiten | Trail-Paten (stewards) und Team-Admins |
| Flags setzen | Jedes Team-Mitglied — schnelle Information hat Vorrang |
| Flag-Typen | Eigene Collection, im UI pflegbar |
| Flag-Bezug | Trail-Gesamtstatus und verortete Marker |
| GPX-Speicherung | Originaldatei und extrahierte Geometrie |
| Parsing | Im Browser beim Upload — kein pb_hooks nötig |
| Versionierung | Trail = Identität, Versionen als eigene Collection |
| Marker/Kommentare | Hängen am Trail, nicht an der Version |
| Karte | MapLibre GL mit OSM-Raster |
| Höhenprofil | Ja, verknüpft mit der Karte |
Datenmodell
Fünf neue Collections. Alle tragen team und die Standardfelder created
und updated als autodate.
trails
Die dauerhafte Identität eines Trails.
| Feld | Typ | Anmerkung |
|---|---|---|
team |
relation → teams | Pflicht, single |
name |
text | Pflicht |
description |
editor | Rich-Text |
status |
select | offen, gesperrt, eingeschraenkt |
stewards |
relation → users | mehrfach, die Trail-Paten |
event |
relation → events | optional, single |
current |
relation → trail_versions | die aktive Version, single |
created_by |
relation → users | single |
current erzeugt einen Zirkelbezug zu trail_versions.trail. PocketBase
verträgt das, die Migration muss die Collections aber in zwei Schritten
anlegen (siehe „Migration" unten).
trail_versions
Jeder GPX-Upload erzeugt einen Datensatz. Sie werden nie überschrieben.
| Feld | Typ | Anmerkung |
|---|---|---|
trail |
relation → trails | Pflicht, single |
gpx |
file | das Original, herunterladbar, max. 10 MB |
geojson |
json | LineString für die Karte, vereinfacht |
distance_m |
number | Streckenlänge in Metern |
ascent_m |
number | Höhenmeter aufwärts |
elevation |
json | Array [{d, ele}] für das Höhenprofil |
bounds |
json | [[minLng, minLat], [maxLng, maxLat]] |
note |
text | was an dieser Version anders ist |
uploaded_by |
relation → users | single |
trail_flags
Die Flag-Typen, pro Team pflegbar.
| Feld | Typ | Anmerkung |
|---|---|---|
team |
relation → teams | Pflicht |
label |
text | Pflicht, z. B. „Baum quer" |
icon |
text | Name eines Lucide-Icons |
color |
text | Hex-Farbe, z. B. #dc2626 |
severity |
select | info, warnung, kritisch |
Beim Anlegen eines Teams gibt es keine automatischen Standard-Flags; die Flag-Verwaltung bietet stattdessen einen Knopf „Standardsatz anlegen", der sechs übliche Typen erzeugt (Baum quer, Erosion, Verblockt, Sperrung, Bauarbeiten, Hinweis). Das hält die Migration frei von Seed-Daten.
trail_markers
Verortete Meldungen. Sie hängen am Trail, nicht an einer Version — ein umgestürzter Baum liegt im Gelände, nicht in einer Datei.
| Feld | Typ | Anmerkung |
|---|---|---|
trail |
relation → trails | Pflicht |
team |
relation → teams | Pflicht, für die API-Rules |
flag |
relation → trail_flags | Pflicht |
lat |
number | Pflicht |
lng |
number | Pflicht |
note |
text | freie Beschreibung |
resolved |
bool | erledigt statt gelöscht |
created_by |
relation → users | single |
trail_comments
| Feld | Typ | Anmerkung |
|---|---|---|
trail |
relation → trails | Pflicht |
team |
relation → teams | Pflicht, für die API-Rules |
text |
text | Pflicht |
created_by |
relation → users | single |
team wird auf markers und comments mitgeführt, obwohl es über
trail.team erreichbar wäre. Grund: Die API-Rule bleibt damit eine direkte
Prüfung statt einer Relation über zwei Ebenen — das ist schneller und
robuster gegen Tippfehler in der Regel.
API-Rules
trails
listRule team.users.id ?= @request.auth.id
viewRule team.users.id ?= @request.auth.id
createRule team.users.id ?= @request.auth.id
updateRule stewards.id ?= @request.auth.id
|| team.owner.id ?= @request.auth.id
|| team.admins.id ?= @request.auth.id
deleteRule team.owner.id ?= @request.auth.id
|| team.admins.id ?= @request.auth.id
Anlegen darf jedes Team-Mitglied; wer anlegt, wird im Frontend automatisch als erster Pate eingetragen. Löschen ist Admins vorbehalten — ein Trail trägt Kommentare und Marker anderer Leute.
trail_versions
listRule/viewRule trail.team.users.id ?= @request.auth.id
createRule trail.stewards.id ?= @request.auth.id
|| trail.team.owner.id ?= @request.auth.id
|| trail.team.admins.id ?= @request.auth.id
updateRule null (Versionen sind unveränderlich)
deleteRule trail.team.owner.id ?= @request.auth.id
|| trail.team.admins.id ?= @request.auth.id
trail_flags
listRule/viewRule team.users.id ?= @request.auth.id
createRule/updateRule/deleteRule team.owner.id ?= @request.auth.id
|| team.admins.id ?= @request.auth.id
trail_markers und trail_comments
listRule/viewRule team.users.id ?= @request.auth.id
createRule team.users.id ?= @request.auth.id
updateRule created_by.id ?= @request.auth.id
|| trail.stewards.id ?= @request.auth.id
|| team.owner.id ?= @request.auth.id
|| team.admins.id ?= @request.auth.id
deleteRule (wie updateRule)
GPX-Verarbeitung
Das Parsen läuft im Browser (src/lib/gpx.ts), das Backend bleibt reines
PocketBase ohne Hooks.
Ablauf beim Upload:
- Datei über
DOMParserals XML lesen - Alle
<trkpt lat lng>mit optionalem<ele>einsammeln - Streckenlänge über die Haversine-Formel aufsummieren
- Höhenmeter aufwärts summieren, dabei Schwankungen unter 3 m ignorieren (GPS-Rauschen würde sonst absurde Werte erzeugen)
- Bounding-Box bestimmen
- Punktfolge für die Anzeige vereinfachen (Douglas-Peucker, Toleranz so gewählt, dass höchstens ~2000 Punkte übrig bleiben)
- Original-Datei und abgeleitete Werte gemeinsam an PocketBase schicken
Fehlerbehandlung: Enthält die Datei keine <trkpt>-Elemente oder ist sie
kein gültiges XML, wird der Upload mit einer verständlichen Meldung
abgelehnt. Dateien über 10 MB werden vorab abgewiesen.
Routen statt Tracks: Manche GPX-Dateien enthalten <rtept> statt
<trkpt>. Fehlen Trackpunkte, greift der Parser auf Routenpunkte zurück,
bevor er aufgibt.
Karte
MapLibre GL JS mit OSM-Raster-Kacheln (tile.openstreetmap.org), kein
API-Schlüssel nötig. Die Kachel-URL steht in einer Konstanten, damit sie
später gegen eine Outdoor-Karte getauscht werden kann.
Die Karte lebt in src/lib/components/TrailMap.svelte mit den Props:
geojson, markers, status, interactive. Sie kennt weder Stores noch
PocketBase — Daten kommen ausschließlich über Props, Ereignisse gehen über
Callback-Props zurück. Damit ist sie in Liste, Detail und Marker-Setzmodus
gleichermaßen verwendbar.
Die Trail-Linie wird nach status eingefärbt: offen grün, eingeschränkt
gelb, gesperrt rot. Marker tragen Icon und Farbe ihres Flag-Typs; erledigte
Marker (resolved) erscheinen blass und lassen sich ausblenden.
Marker setzen: Ein Umschalter versetzt die Karte in den Setzmodus; der nächste Klick öffnet einen Dialog mit Flag-Auswahl und Notiz.
Höhenprofil
src/lib/components/ElevationProfile.svelte zeichnet die Werte aus
trail_versions.elevation als SVG-Fläche — keine Diagramm-Bibliothek, die
Daten sind eine einfache Zahlenreihe.
Beim Überfahren wird die entsprechende Stelle auf der Karte hervorgehoben.
Die Verknüpfung läuft über einen Index in der Punktfolge, den beide
Komponenten teilen; die Elternkomponente hält ihn als $state.
Stores
Vier neue Stores nach dem Muster von events.svelte.ts — Klasse mit
$state, Context über Symbol, scoped-Getter, Realtime-Subscription:
trails.svelte.ts— Trails samtcreate,edit,removetrailVersions.svelte.ts— Versionen eines Trails,upload,activatetrailFlags.svelte.ts— Flag-Typen,seedDefaults()trailMarkers.svelte.ts— Marker,toggleResolved
Kommentare bekommen keinen eigenen Store, sondern werden in der Detailseite direkt geladen — sie werden nur an einer Stelle gebraucht.
Routen
| Route | Inhalt |
|---|---|
/dashboard/trails |
Liste mit Übersichtskarte aller Trails des Teams |
/dashboard/trails/[id] |
Karte, Höhenprofil, Marker, Kommentare, Versionen |
/dashboard/settings/flags |
Flag-Typen verwalten |
Die Trail-Detailseite gliedert sich in Karte (mit Höhenprofil darunter) und einen Bereich mit Reitern für Marker, Kommentare und Versionen.
Migration
Eine neue Datei in backend/pb_migrations/. Zwei Besonderheiten:
- Zirkelbezug:
trails.currentzeigt auftrail_versions, das seinerseits auftrailszeigt. Die Migration legt deshalb zuersttrailsohne das Feldcurrentan, danntrail_versions, und ergänztcurrentin einem zweiten Schritt. - Keine Seed-Daten. Standard-Flags entstehen auf Knopfdruck im UI, nicht in der Migration — sonst kämen sie bei jedem Containerstart zurück.
Nach der Migration ist npm run generate-pocketbase-types auszuführen und
frontend/src/lib/types.d.ts mitzucommitten.
Neue Abhängigkeiten
| Paket | Zweck |
|---|---|
maplibre-gl |
Kartendarstellung |
Mehr nicht: GPX-Parsing, Douglas-Peucker und das Höhenprofil sind wenige Dutzend Zeilen und kommen ohne Bibliothek aus.
Verifikation
- Migration im lokalen Container: alle fünf Collections vorhanden, Felder und Rules wie beschrieben
- Eine echte GPX-Datei hochladen: Länge und Höhenmeter plausibel gegenüber dem Wert, den ein anderes Werkzeug (z. B. gpx.studio) nennt
- Track erscheint auf der Karte, Kartenausschnitt passt zur Bounding-Box
- Höhenprofil zeigt einen Verlauf, Hover markiert die Stelle auf der Karte
- Marker setzen, Flag zuweisen, als erledigt markieren
- Kommentar schreiben, erscheint bei einem zweiten Browser per Realtime
- Zweite GPX-Version hochladen: Version wird angelegt, Marker und Kommentare bleiben erhalten
- Rechte: Ein Team-Mitglied ohne Patenschaft kann Marker und Kommentare anlegen, aber den Trail nicht umbenennen
npm run checkohne neue Fehler
Bewusst nicht Teil dieser Umsetzung
- Öffentliche, nicht angemeldete Trail-Ansicht
- Bestätigungs-Workflow für Meldungen (jeder im Team meldet direkt)
- Automatisches Aufräumen alter Versionen
- Ein Trail in mehreren Events gleichzeitig
- Offline-Karten, Track-Aufzeichnung im Browser
- Verknüpfung der Trails mit der Zeitnahme (
runs,times)