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>
306 lines
12 KiB
Markdown
306 lines
12 KiB
Markdown
# 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).
|
|
|
|
### `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 `<trkpt lat lng>` mit optionalem `<ele>` 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 `<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 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`)
|