Die Alternative (bool active auf trail_versions) käme ohne Zirkelbezug aus, verlagert die Wahrheit aber in N Datensätze: zwei Schreibvorgänge beim Umschalten, ohne Transaktion kurzzeitig zwei aktive Versionen möglich. trails.current ändert genau ein Feld und kennt keinen ungültigen Zustand. 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).
Warum nicht ein active-Flag auf trail_versions? Das wäre ohne
Zirkelbezug ausgekommen, verlagert die Wahrheit aber in N Datensätze statt
einen: Beim Umschalten müssten zwei Versionen geschrieben werden, und ohne
Transaktion kann es kurzzeitig zwei aktive oder gar keine geben. Mit
trails.current ändert sich genau ein Feld, und ein ungültiger Zustand ist
nicht möglich. Der Zirkelbezug kostet dafür einen zusätzlichen
Migrationsschritt — einmalig, gegenüber einer Fehlerquelle im Betrieb.
Dass current theoretisch auf eine Version eines anderen Trails zeigen
könnte, fängt das Frontend ab: activate(versionId) prüft vorher, dass die
Version zum Trail gehört.
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)