# 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 (siehe `src/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 über `npm 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:** 1. Datei über `DOMParser` als XML lesen 2. Alle `` mit optionalem `` einsammeln 3. Streckenlänge über die Haversine-Formel aufsummieren 4. Höhenmeter aufwärts summieren, dabei Schwankungen unter 3 m ignorieren (GPS-Rauschen würde sonst absurde Werte erzeugen) 5. Bounding-Box bestimmen 6. Punktfolge für die Anzeige vereinfachen (Douglas-Peucker, Toleranz so gewählt, dass höchstens ~2000 Punkte übrig bleiben) 7. Original-Datei und abgeleitete Werte gemeinsam an PocketBase schicken **Fehlerbehandlung:** Enthält die Datei keine ``-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 `` statt ``. 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 samt `create`, `edit`, `remove` - `trailVersions.svelte.ts` — Versionen eines Trails, `upload`, `activate` - `trailFlags.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: 1. **Zirkelbezug:** `trails.current` zeigt auf `trail_versions`, das seinerseits auf `trails` zeigt. Die Migration legt deshalb zuerst `trails` ohne das Feld `current` an, dann `trail_versions`, und ergänzt `current` in einem zweiten Schritt. 2. **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 1. Migration im lokalen Container: alle fünf Collections vorhanden, Felder und Rules wie beschrieben 2. Eine echte GPX-Datei hochladen: Länge und Höhenmeter plausibel gegenüber dem Wert, den ein anderes Werkzeug (z. B. gpx.studio) nennt 3. Track erscheint auf der Karte, Kartenausschnitt passt zur Bounding-Box 4. Höhenprofil zeigt einen Verlauf, Hover markiert die Stelle auf der Karte 5. Marker setzen, Flag zuweisen, als erledigt markieren 6. Kommentar schreiben, erscheint bei einem zweiten Browser per Realtime 7. Zweite GPX-Version hochladen: Version wird angelegt, Marker und Kommentare bleiben erhalten 8. Rechte: Ein Team-Mitglied ohne Patenschaft kann Marker und Kommentare anlegen, aber den Trail nicht umbenennen 9. `npm run check` ohne 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`)