Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.tools
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
data/geofabrik/*.osm.pbf
|
||||||
|
data/geofabrik/*.osm.pbf.md5
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
PORT=5174
|
||||||
|
HOST=0.0.0.0
|
||||||
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
WATERMAPS_DEMO_DATA=true
|
||||||
|
|
||||||
|
# Serverseitiger EuRIS-Schleusenabgleich (`npm run sync:euris-locks`)
|
||||||
|
EURIS_COUNTRIES=DE
|
||||||
|
EURIS_API_TOKEN=
|
||||||
|
EURIS_DETAIL_LIMIT=0
|
||||||
|
EURIS_DRY_RUN=false
|
||||||
|
EURIS_REQUEST_TIMEOUT_MS=15000
|
||||||
|
|
||||||
|
# Vorsichtiger Offline-Abruf bereits verlinkter OSM-/EuRIS-Facility-Websites
|
||||||
|
MARINE_WEBSITE_DRY_RUN=true
|
||||||
|
MARINE_WEBSITE_LIMIT=25
|
||||||
|
MARINE_WEBSITE_CONCURRENCY=2
|
||||||
|
MARINE_WEBSITE_HOST_DELAY_MS=1000
|
||||||
|
MARINE_WEBSITE_TIMEOUT_MS=10000
|
||||||
|
|
||||||
|
# Suchmaschinen-Fallback für benannte Anlagen mit weiterhin fehlenden Daten.
|
||||||
|
# Standardmäßig Trockenlauf; DuckDuckGo HTML ist best effort und kann Bots blockieren.
|
||||||
|
MARINE_SEARCH_PROVIDER=duckduckgo
|
||||||
|
MARINE_SEARCH_DRY_RUN=true
|
||||||
|
MARINE_SEARCH_LIMIT=10
|
||||||
|
MARINE_SEARCH_CONCURRENCY=1
|
||||||
|
MARINE_SEARCH_DELAY_MS=5000
|
||||||
|
MARINE_SEARCH_HOST_DELAY_MS=1000
|
||||||
|
MARINE_SEARCH_TIMEOUT_MS=10000
|
||||||
|
MARINE_SEARCH_RESULTS=5
|
||||||
|
MARINE_SEARCH_PAGES=3
|
||||||
|
|
||||||
|
# Optional zuverlässigerer API-Provider: MARINE_SEARCH_PROVIDER=brave
|
||||||
|
BRAVE_SEARCH_API_KEY=
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
.tools/
|
||||||
|
data/geofabrik/*.osm.pbf
|
||||||
|
data/geofabrik/*.osm.pbf.md5
|
||||||
|
/gitlogin
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Watermaps
|
||||||
|
|
||||||
|
iPhone-taugliche Browser-PWA für Bootsfahrer: Karte, GPS, Kompass, Wetter/Wellen, BSH-Tiden, Fahrwasser-Routing mit Bootsmaßen sowie Schleusen- und Hafeninformationen.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- Routing auf See- und Binnenwasserstraßen statt Luftlinien
|
||||||
|
- explizit gestarteter Kursassistent mit dynamischem Sollkurs über Grund, Querabweichung, Reststrecke und Kurswechsel-Hinweisen
|
||||||
|
- zweistufige Ankerwache mit festem Ankerpunkt, GPS-Schwojkreis, Alarm, Tidenanstieg und nachvollziehbarer Ketten-/Leinenreserve
|
||||||
|
- bis zu drei ausreichend unterschiedliche Routenvorschläge, sofern der Wasserstraßengraph echte Alternativen enthält
|
||||||
|
- Prüfung von Tiefgang plus Sicherheitsreserve, Bootshöhe, Breite und bekannten Einbahnregeln
|
||||||
|
- Fahrwasser-Unterstützung für die Korridore Emden–Borkum und Emden–Hamm nach manueller Auswahl von Start und Ziel
|
||||||
|
- antippbare Info-Buttons ab Kartenzoom 12 an Schleusen und Häfen mit Telefon, Website, E-Mail, VHF, Öffnungszeiten, Betreiber und Adresse, soweit in den Quelldaten vorhanden
|
||||||
|
- Brückenhöhen, bekannte Tiefen, Wetter/Wellen, Tide, GPS und Kompass
|
||||||
|
- frei sortierbare Zwischenziele direkt von der Karte
|
||||||
|
- Abfahrtszeit-bezogene Wetter-, Wellen-, Strömungs- und Tidenplanung entlang der Route
|
||||||
|
- Live-Wasserstände aus der offiziellen PEGELONLINE-API der WSV mit Cache- und Veraltet-Status
|
||||||
|
- Tagesetappen mit Hafenwahl und Filtern für Strom, Wasser, Treibstoff, Entsorgung und Übernachtung
|
||||||
|
- Schleusenliste entlang der Route mit bekannten Betriebszeiten/Kontakten und einstellbarem Planungspuffer
|
||||||
|
- GPX-1.1-Export, lokal gespeicherte Offline-Routen und ein nur auf Nutzeraktion gestarteter Kursabweichungsalarm
|
||||||
|
- begrenztes Offline-Caching bereits besuchter Kartenressourcen ohne ungefragten Kacheldownload
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
Dieses Projekt benötigt Node `>=20.19`. In dieser Arbeitskopie liegt eine lokale Node-Version unter `.tools/`; nutze sie so:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="$PWD/.tools/bin:$PATH"
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Web: `http://localhost:5173`<br>
|
||||||
|
API: `http://localhost:5174`
|
||||||
|
|
||||||
|
Für GPS-Tests auf dem iPhone muss die App über HTTPS laufen. Starte dafür:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:https
|
||||||
|
```
|
||||||
|
|
||||||
|
Web HTTPS: `https://localhost:5173` bzw. die von Vite ausgegebene `https://192.168...:5173`-Adresse im gleichen WLAN. Beim lokalen Dev-Zertifikat muss Safari die Zertifikatswarnung einmal akzeptieren; falls iOS Geolocation danach weiterhin blockiert, nutze ein vertrauenswürdiges lokales Zertifikat oder einen HTTPS-Tunnel.
|
||||||
|
|
||||||
|
## Datenstatus
|
||||||
|
|
||||||
|
Die freie Datenstrategie ist bewusst als Fahr- und Planungshilfe umgesetzt. Die App zeigt Attribution und den Status `Nicht amtlich`, weil freie Karten-/Modelldaten keine amtlich zugelassene Seekarte ersetzen.
|
||||||
|
|
||||||
|
## Routingstatus
|
||||||
|
|
||||||
|
`POST /api/routes` nutzt zuerst lokale PostGIS-Fahrwasserdaten aus `marine_fairway_edges`. Wenn in der Datenbank kein passender Graph liegt, versucht die API live extrahierte OSM/OpenSeaMap-Fahrwasserdaten für die Bounding Box zwischen Start, Wegpunkten und Ziel. Lokale und Live-Fragmente werden topologisch zusammengeführt. Berücksichtigt werden unter anderem `navigation_line`, `recommended_track`, `fairway`, navigierbare Kanäle und explizit für Boote oder Schiffe freigegebene Flüsse. Gesperrte, private, stillgelegte oder im Bau befindliche Wege werden ausgeschlossen.
|
||||||
|
|
||||||
|
Wenn freie Laufzeitdaten fehlen, stehen zwei klar als nicht amtlich markierte Fallback-Korridore bereit:
|
||||||
|
|
||||||
|
- Emden Außenhafen → Borkum Reede
|
||||||
|
- Emden Außenhafen → Ems/Dortmund-Ems-Kanal → Datteln → Datteln-Hamm-Kanal → Wasserwanderrastplatz Hamm-Innenstadt (rund 153 sm)
|
||||||
|
|
||||||
|
Der Emden–Hamm-Fallback besitzt keine belastbaren Tiefen- oder Schleusenzeitdaten und ist bewusst auf Sportboote bis 2,5 m Tiefgang begrenzt. Vor der Fahrt sind aktuelle Sperrungen, Betriebszeiten, Wasserstände und amtliche Karten zu prüfen. Kostenfreie Inland-ENCs für den Dortmund-Ems- und Datteln-Hamm-Kanal stellt [ELWIS](https://www.elwis.de/DE/dynamisch/IENC/) bereit.
|
||||||
|
|
||||||
|
Wenn kein Graph passt, liefert die API bewusst `422 no_fairway_route`, damit keine irreführende Luftlinie als Bootsroute gezeichnet wird. `alternatives` im Ergebnis enthält bis zu zwei weitere, topologisch unterschiedliche Optionen.
|
||||||
|
|
||||||
|
## Kursassistent entlang einer Route
|
||||||
|
|
||||||
|
Nach erfolgreicher Routenplanung erscheint „Kursassistent starten“. Der Assistent verwendet den bereits vorhandenen hochgenauen GPS-Datenstrom und berechnet bei jedem neuen Fix einen geschwindigkeits- und genauigkeitsabhängigen Vorausschaupunkt auf der Route. Daraus entstehen Sollkurs über Grund, Kurskorrektur gegenüber dem GPS-Kurs über Grund (COG), Querabstand, Routenfortschritt, Reststrecke und der nächste deutliche Backbord-/Steuerbord-Kurswechsel. Vor engen Richtungswechseln endet die Vorausschau am Kurvenpunkt, damit nicht diagonal über das Fahrwasser abgekürzt wird.
|
||||||
|
|
||||||
|
Der Fortschritt wird gegen GPS-Sprünge und sich kreuzende Routenabschnitte stabilisiert. Bei einem mehr als 15 Sekunden alten oder zu ungenauen Fix pausiert die Steueranweisung. Der Gerätekompass bleibt als gekennzeichnete Orientierung sichtbar, wird aber nicht mit dem geografischen Sollkurs verrechnet; eine Kurskorrektur wird erst aus einem belastbaren GPS-COG gebildet. Laut [W3C-Geolocation-Spezifikation](https://www.w3.org/TR/geolocation/) kann der Browser Position, Genauigkeit, Geschwindigkeit und einen Kurs relativ zu geografisch Nord liefern, garantiert aber nicht die tatsächliche Position des Geräts.
|
||||||
|
|
||||||
|
Der Kursassistent steuert weder Ruder noch Maschine und gibt keine NMEA-Kommandos aus. Er ist eine nicht amtliche Navigationshilfe; Ausguck, sichere Geschwindigkeit, Tonnen, Ufer, Verkehr, Wasserstände und amtliche Unterlagen haben immer Vorrang. Browser und Betriebssystem können GPS-Aktualisierungen bei gesperrtem Display oder im Hintergrund anhalten.
|
||||||
|
|
||||||
|
## Ankerwache mit Tide und Leinenreserve
|
||||||
|
|
||||||
|
Die Ankerwache ist unabhängig von einer geplanten Route über das Ankersymbol rechts auf der Karte erreichbar. Sie startet nie automatisch:
|
||||||
|
|
||||||
|
1. Live-GPS starten und genau beim Erreichen des Grundes „Anker gefallen – Position jetzt setzen“ wählen. Der Fix muss jünger als zehn Sekunden und auf höchstens 30 m genau sein. Der gespeicherte Ankerpunkt bleibt danach fest.
|
||||||
|
2. Tiefe beim Setzen, Höhe der Bugrolle über Wasser, ausgesteckte Ketten-/Leinenlänge, gewünschtes Verhältnis, zusätzliche Wasserstandsreserve, Alarmradius und Tidenzeitraum prüfen.
|
||||||
|
3. Erst „Wache starten“ schaltet Positionsalarm, Warnton, Vibration, optionale Browser-Mitteilungen und – soweit unterstützt – eine Bildschirm-Wachhalteanforderung ein.
|
||||||
|
|
||||||
|
Kursassistent, separater Kursalarm und Ankerwache laufen nicht parallel. Beim Öffnen der Ankerfunktion wird eine laufende Kursführung beendet; die geplante Route selbst bleibt erhalten.
|
||||||
|
|
||||||
|
Die Leinenplanung verwendet:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(Tiefe beim Setzen + Bugrollenhöhe + maximaler weiterer Tidenanstieg + Wasserstandsreserve) × gewähltes Verhältnis
|
||||||
|
```
|
||||||
|
|
||||||
|
Die BSH-Pegelhöhe wird dabei ausdrücklich nicht als örtliche Wassertiefe verwendet. Aus der Kurve wird nur die relative Änderung gegenüber dem Zeitpunkt des Ankersetzens abgeleitet. Station und Entfernung bleiben sichtbar. Deckt die Prognose den gewählten Zeitraum nicht vollständig ab, zeigt Watermaps nur den tideunabhängigen Mindestbedarf und bestätigt weder Bedarf noch Reserve. Die laufende Positionswache bleibt trotzdem nutzbar. Die Differenz aus höchstem und niedrigstem Kurvenwert im Zeitraum wird als Tidenhub angezeigt; allgemein bezeichnet „tidal range“ die Höhendifferenz zwischen Hoch- und Niedrigwasser ([NOAA](https://oceanservice.noaa.gov/facts/tides.html)).
|
||||||
|
|
||||||
|
Das Verhältnis ist absichtlich einstellbar. Die RYA nennt als Orientierung viermal die maximale Wassertiefe bei Kette beziehungsweise sechsmal bei einer Kombination aus Kette und Leine, weist aber zugleich auf Grund und Schwojbereich hin ([RYA Anchoring with care](https://www.rya.org.uk/environment-and-sustainability/anchoring-with-care/)). Wind, Wellen, Strom, Schwell, Ankerbauart, Grund, Bootslänge und nahe Gefahren können mehr Länge oder einen enger gewählten Alarmbereich erfordern; die Rechnung entscheidet das nicht selbst.
|
||||||
|
|
||||||
|
Auf der Karte erscheinen der feste Ankerpunkt, ein geodätisch in Metern berechneter Alarmring und die Verbindung zum Boot. Ein Driftalarm wird erst ausgelöst, wenn der Abstand auch nach Abzug der gemeldeten GPS-Ungenauigkeit außerhalb des Radius liegt. Umgekehrt werden ein mehr als 20 Sekunden alter Fix, GPS-Ausfall oder eine Genauigkeit schlechter als 30 m als eigener Alarmzustand angezeigt. Das vermindert Fehlalarme, kann eine echte Drift aber auch später melden. Peilmarken, Ankerkontrolle und Ausguck bleiben deshalb erforderlich.
|
||||||
|
|
||||||
|
Web-Apps können keine ununterbrochene Hintergrundüberwachung garantieren. iOS, der Browser oder ein gesperrtes Display können GPS, JavaScript, Vibration, Ton und Mitteilungen anhalten. Für eine Nachtwache muss Watermaps sichtbar bleiben; die Funktion ersetzt keinen eigenständigen zugelassenen Ankeralarm.
|
||||||
|
|
||||||
|
## Reise-, Zeit- und Live-Datenplanung
|
||||||
|
|
||||||
|
Eine Route kann bis zu 25 Zwischenziele und eine ISO-Abfahrtszeit enthalten. Die Grund-ETA beginnt an dieser Abfahrtszeit. Für Start, Mitte und Ziel fragt die Web-App die Prognose zum geschätzten Passierzeitpunkt ab. Die Open-Meteo-Marine-Daten enthalten dabei Wellen und modellierte Strömung; die Strömungs-Komponente längs zum jeweiligen Routenkurs wird als klar gekennzeichnete Modellkorrektur der ETA dargestellt. Werte außerhalb des verfügbaren Vorhersagefensters werden nicht als aktuelle Prognose ausgegeben.
|
||||||
|
|
||||||
|
Für Start und Ziel werden passende BSH-Tidenstationen zur geplanten Zeit abgefragt. Die Stationsentfernung wird angezeigt, weil Bezugsnull und lokale Abweichungen für die Navigation entscheidend bleiben.
|
||||||
|
|
||||||
|
`GET /api/navigation/live` bindet aktuelle Wasserstände über die dokumentierte [PEGELONLINE REST-API v2](https://pegelonline.wsv.de/webservice/dokuRestapi) ein. Ergebnisse werden 60 Sekunden frisch gehalten; bei Ausfall kann höchstens sechs Stunden lang der letzte erfolgreiche Stand mit dem Status `stale` angezeigt werden. Für die Emden–Hamm-Route werden EMS, DEK und DHK abgefragt.
|
||||||
|
|
||||||
|
ELWIS veröffentlicht [Schleuseninformationen](https://www.elwis.de/DE/dynamisch/Schleuseninformationen/) und [Nachrichten für die Binnenschifffahrt](https://www.elwis.de/DE/dynamisch/Nfb/). Da dafür keine verlässlich dokumentierte öffentliche REST-/JSON-Schnittstelle vorliegt, wird kein vermeintlicher Live-Status aus HTML gescrapt. Das Backend besitzt stattdessen eine streng auf offizielle WSV-/ELWIS-HTTPS-Quellen begrenzte Adapter-Schnittstelle. Ohne konfigurierten amtlichen Feed zeigt die App den offiziellen Prüf-Link. Der einstellbare Schleusenpuffer ist ausdrücklich nur eine eigene Planannahme, keine gemessene Wartezeit.
|
||||||
|
|
||||||
|
Die Etappenplanung wählt nur Häfen innerhalb des Tageslimits und des erlaubten Abstechers. Ausstattungsmerkmale ohne bestätigten Wert gelten sicherheitshalber als unbekannt und erfüllen keinen aktivierten Versorgungsfilter.
|
||||||
|
|
||||||
|
Offline gespeichert werden Route, Wegpunkte, Bootsprofil und Abfahrtszeit auf dem jeweiligen Gerät. Live-GPS-Positionen werden weder gespeichert noch übertragen. Der Service Worker hält nur tatsächlich besuchte OpenFreeMap-/OpenSeaMap-Ressourcen zeitlich und mengenmäßig begrenzt vor; Watermaps lädt nicht automatisch ganze Routenkorridore herunter.
|
||||||
|
|
||||||
|
Für produktivere Daten kann `scripts/import-geofabrik.sh` aus einem Geofabrik-PBF routbare Fahrwasser in `marine_fairway_edges` importieren. Das ist der richtige Weg für "alle Fahrwasserdaten" im eigenen Backend; Rasterkacheln der Karte werden nicht zurückdigitalisiert.
|
||||||
|
|
||||||
|
Die Küsten-PBFs lassen sich reproduzierbar von Geofabrik laden und ohne lokal installierte GIS-Tools importieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/download-geofabrik.sh
|
||||||
|
docker compose up -d postgres redis martin
|
||||||
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \
|
||||||
|
./scripts/import-geofabrik-docker.sh \
|
||||||
|
data/geofabrik/germany-latest.osm.pbf \
|
||||||
|
data/geofabrik/netherlands-latest.osm.pbf
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne Argumente lädt `download-geofabrik.sh` die vollständigen Extrakte für Deutschland und die Niederlande. Das benötigt mehrere Gigabyte Speicher und Downloadvolumen. Für den Emden–Hamm-Korridor genügen gezielt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/download-geofabrik.sh niedersachsen nordrhein-westfalen
|
||||||
|
./scripts/import-geofabrik-docker.sh \
|
||||||
|
data/geofabrik/niedersachsen-latest.osm.pbf \
|
||||||
|
data/geofabrik/nordrhein-westfalen-latest.osm.pbf
|
||||||
|
```
|
||||||
|
|
||||||
|
Die vollständigen Niederlande lassen sich separat laden und importieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/download-geofabrik.sh netherlands
|
||||||
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \
|
||||||
|
./scripts/import-geofabrik-docker.sh data/geofabrik/netherlands-latest.osm.pbf
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Import erkennt auch Schleusen, Häfen, Marinas und Ports und aktualisiert geänderte OSM-Kontaktdaten per Upsert. Für Schleusen werden unter anderem `lock=yes`, `waterway=lock_gate`, `water=lock`, `obstacle=lock` und entsprechende Seamark-Gates berücksichtigt. Flächen aus überlappenden Extrakten werden über ihre kanonische OSM-ID zusammengeführt. Nach einem Update der Importlogik sollte der bestehende Datenbestand erneut importiert werden.
|
||||||
|
|
||||||
|
Deutsche Schleusen können zusätzlich aus dem offiziellen EuRIS-Datenangebot abgeglichen werden. Ein Trockenlauf prüft den Abruf, ohne die Datenbank zu verändern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \
|
||||||
|
EURIS_COUNTRIES=DE EURIS_DRY_RUN=true \
|
||||||
|
npm run sync:euris-locks
|
||||||
|
```
|
||||||
|
|
||||||
|
Zum Speichern `EURIS_DRY_RUN=false` setzen. `EURIS_DETAIL_LIMIT` ist optional und wegen der API-Belastung auf 20 Detailaufrufe pro Lauf begrenzt; die kompakten Schleusen- und RIS-Index-Daten werden unabhängig davon vollständig seitenweise gelesen. Ein optionales Zugriffstoken kann über `EURIS_API_TOKEN` gesetzt werden.
|
||||||
|
|
||||||
|
Fehlende Kontakte können zunächst aus den bereits in OSM oder EuRIS verlinkten Facility-Websites ergänzt werden. Dieser erste Adapter verwendet keine Suchmaschine, ist standardmäßig ein Trockenlauf und verarbeitet standardmäßig höchstens 25 Datensätze:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \
|
||||||
|
npm run enrich:marine-websites
|
||||||
|
```
|
||||||
|
|
||||||
|
Erst nach Prüfung der Zusammenfassung wird das Schreiben explizit aktiviert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \
|
||||||
|
MARINE_WEBSITE_DRY_RUN=false \
|
||||||
|
npm run enrich:marine-websites
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Ergänzungen werden mit derselben Geometrie separat als Quelle `facility-website` gespeichert. Abgerufen werden ausschließlich öffentliche HTTP(S)-Ziele; private und lokale IP-Bereiche sowie unsichere Redirects werden blockiert. Extrahiert werden strukturierte JSON-LD-Kontakte und explizite `tel:`-/`mailto:`-Links, keine frei im Seitentext vermuteten Telefonnummern.
|
||||||
|
|
||||||
|
Für benannte Anlagen, denen danach weiterhin Website- oder Kontaktdaten fehlen, gibt es einen konservativen Suchmaschinen-Fallback. Er ist ebenfalls standardmäßig ein Trockenlauf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \
|
||||||
|
MARINE_SEARCH_PROVIDER=duckduckgo \
|
||||||
|
npm run enrich:marine-search
|
||||||
|
```
|
||||||
|
|
||||||
|
Zum kontrollierten Speichern eines kleinen Batches:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \
|
||||||
|
MARINE_SEARCH_PROVIDER=duckduckgo \
|
||||||
|
MARINE_SEARCH_DRY_RUN=false \
|
||||||
|
MARINE_SEARCH_LIMIT=10 \
|
||||||
|
npm run enrich:marine-search
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Routine lehnt generische Namen wie `Hafen` oder `Schleuse` ab, bewertet Anlagenname, Ort, Wasserstraße, Typ und Domain und verlangt einen deutlichen Abstand zum zweitbesten Host. Ein Suchsnippet wird niemals als Kontaktquelle verwendet: Der Ziel-Link wird erneut durch die SSRF-, DNS-, Redirect-, TLS-, Größen- und Timeout-Prüfungen geschickt. Erst wenn auch die Zielseite eindeutig zur Anlage passt, werden dort vorhandene JSON-LD-Daten sowie `tel:`-/`mailto:`-Links übernommen. Eine verifizierte offizielle Website kann auch ohne weitere Kontaktfelder ergänzt werden; bestehende OSM-, EuRIS- und frühere Anreicherungswerte werden nicht überschrieben.
|
||||||
|
|
||||||
|
DuckDuckGo dokumentiert seine [HTML- und Lite-Seiten als Non-JavaScript-Suche](https://duckduckgo.com/duckduckgo-help-pages/features/non-javascript), jedoch nicht als stabile allgemeine Such-API. Deshalb läuft dieser Provider mit Parallelität 1, standardmäßig fünf Sekunden Abstand und einem Circuit Breaker: HTTP 202/403/429 oder eine Bot-Prüfung stoppen den Batch, statt fälschlich „keine Treffer“ zu speichern. Als zuverlässigere optionale API kann [Brave Web Search](https://api-dashboard.search.brave.com/app/documentation/web-search/get-started) verwendet werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass \
|
||||||
|
MARINE_SEARCH_PROVIDER=brave \
|
||||||
|
BRAVE_SEARCH_API_KEY=... \
|
||||||
|
MARINE_SEARCH_DRY_RUN=false \
|
||||||
|
npm run enrich:marine-search
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Schreibmodus führt `marine_enrichment_attempts` je Provider und Suchfingerabdruck einen Checkpoint. Erfolgreiche, mehrdeutige, kontaktlose und fehlgeschlagene Versuche werden mit unterschiedlichen Wiederholungsfristen gespeichert, sodass aufeinanderfolgende Batches fortschreiten, anstatt dieselben Anlagen sofort erneut abzufragen. Die eigentliche Kartenanreicherung bleibt für die bestehende Zusammenführung unter der Datenquelle `facility-website`; Properties wie `enrichmentSource=facility-search`, Provider, Suchabfrage, Treffer-URL, Scores und feldweise Provenienz machen den Ursprung auditierbar.
|
||||||
|
|
||||||
|
Die Karten-API vereinigt nahe OSM-, EuRIS- und Website-Objekte anhand offizieller Kennungen, normalisierter Anlagennamen, Entfernung und Objektrolle zu einer kanonischen Anlage. Rohdaten bleiben für spätere Neuberechnungen erhalten. Schleusen- und Hafenobjekte werden erst ab Zoom 12 geladen und in einer geclusterten MapLibre-GeoJSON-Ebene GPU-beschleunigt gezeichnet; beim Verschieben wird nur nach `moveend` neu geladen und die vorherige Anfrage abgebrochen. Erst beim Heranzoomen erscheinen einzelne Info-Symbole. Die streckenbezogene Etappen- und Schleusenplanung bleibt davon unabhängig verfügbar.
|
||||||
|
|
||||||
|
Um die Kartenantwort klein zu halten, liefert die API bei Brücken nur Einträge mit bekannter Durchfahrtshöhe oder einem beweglichen Brückentyp aus.
|
||||||
|
|
||||||
|
Der lokale PostGIS-Container nutzt standardmäßig Host-Port `55432`, damit er nicht mit bestehenden lokalen Postgres-Installationen auf `5432` kollidiert. Setze deshalb für API und Import `DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass`.
|
||||||
|
|
||||||
|
## Lokale Infrastruktur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres redis martin
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `.env` aus `.env.example` ableiten und für echte PostGIS-Features `WATERMAPS_DEMO_DATA=false` setzen. Die frühere Variable `SEA_COMPASS_DEMO_DATA` wird übergangsweise weiterhin akzeptiert.
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
- `GET /api/config`
|
||||||
|
- `GET /api/weather/marine?lat=54.18&lon=12.09`
|
||||||
|
- `GET /api/weather/marine?lat=54.18&lon=12.09&at=2026-07-20T08:00:00.000Z`
|
||||||
|
- `GET /api/tides/nearest?lat=54.18&lon=12.09&at=2026-07-20T08:00:00.000Z`
|
||||||
|
- `GET /api/navigation/live?waterways=EMS,DEK,DHK`
|
||||||
|
- `GET /api/features?bbox=12,54,13,55&layers=seamarks,bridges,locks,harbours`
|
||||||
|
- `POST /api/routes`
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@watermaps/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.0.1",
|
||||||
|
"@watermaps/shared": "0.1.0",
|
||||||
|
"fastify": "^5.4.0",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.14",
|
||||||
|
"@types/pg": "^8.15.4",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import cors from "@fastify/cors";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
buildFairwayRoutes,
|
||||||
|
EMDEN_HAMM_GRAPH,
|
||||||
|
type FairwayGraph,
|
||||||
|
type RouteOption,
|
||||||
|
type RouteRequest,
|
||||||
|
type RouteResult
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
import { loadEnv, type ApiEnv } from "./env.js";
|
||||||
|
import { createCache, type Cache } from "./services/cache.js";
|
||||||
|
import { appConfig } from "./services/config.js";
|
||||||
|
import { FairwayService } from "./services/fairways.js";
|
||||||
|
import { FeatureService } from "./services/features.js";
|
||||||
|
import { getNearestTideSummary } from "./services/tides.js";
|
||||||
|
import { getMarineForecast } from "./services/weather.js";
|
||||||
|
import type { FetchLike } from "./services/http.js";
|
||||||
|
import {
|
||||||
|
getNavigationData,
|
||||||
|
type NavigationDataAdapters
|
||||||
|
} from "./services/navigation-data.js";
|
||||||
|
|
||||||
|
export type AppDeps = {
|
||||||
|
env?: ApiEnv;
|
||||||
|
cache?: Cache;
|
||||||
|
fetcher?: FetchLike;
|
||||||
|
featureService?: FeatureService;
|
||||||
|
fairwayService?: Pick<FairwayService, "getGraphsForRoute" | "close">;
|
||||||
|
navigationAdapters?: NavigationDataAdapters;
|
||||||
|
};
|
||||||
|
|
||||||
|
const coordinateSchema = z.object({
|
||||||
|
lat: z.number().min(-90).max(90),
|
||||||
|
lon: z.number().min(-180).max(180)
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeRequestSchema = z.object({
|
||||||
|
start: coordinateSchema,
|
||||||
|
destination: coordinateSchema,
|
||||||
|
waypoints: z.array(coordinateSchema).max(25).optional(),
|
||||||
|
departureTime: z
|
||||||
|
.string()
|
||||||
|
.refine((value) => Number.isFinite(Date.parse(value)), "departureTime must be an ISO timestamp")
|
||||||
|
.optional(),
|
||||||
|
vesselProfile: z.object({
|
||||||
|
draughtM: z.number().positive().max(15),
|
||||||
|
safetyReserveM: z.number().min(0).max(10),
|
||||||
|
airDraftM: z.number().positive().max(80).optional(),
|
||||||
|
beamM: z.number().positive().max(80).optional(),
|
||||||
|
cruiseSpeedKn: z.number().positive().max(80).optional()
|
||||||
|
}),
|
||||||
|
depthSamples: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
coordinate: coordinateSchema,
|
||||||
|
depthM: z.number().nullable()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(500)
|
||||||
|
.optional()
|
||||||
|
}) satisfies z.ZodType<RouteRequest>;
|
||||||
|
|
||||||
|
const coordinateQuerySchema = z.object({
|
||||||
|
lat: z.coerce.number().min(-90).max(90),
|
||||||
|
lon: z.coerce.number().min(-180).max(180),
|
||||||
|
at: z
|
||||||
|
.string()
|
||||||
|
.refine((value) => Number.isFinite(Date.parse(value)), "at must be an ISO timestamp")
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const featuresQuerySchema = z.object({
|
||||||
|
bbox: z
|
||||||
|
.string()
|
||||||
|
.transform((value, ctx) => {
|
||||||
|
const parts = value.split(",").map(Number);
|
||||||
|
if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "bbox must be minLon,minLat,maxLon,maxLat" });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return parts as [number, number, number, number];
|
||||||
|
}),
|
||||||
|
layers: z
|
||||||
|
.string()
|
||||||
|
.default("seamarks,bridges,locks,harbours")
|
||||||
|
.transform((value) =>
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((layer) => layer.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigationQuerySchema = z.object({
|
||||||
|
waterways: commaSeparatedQuery(12).optional(),
|
||||||
|
stationIds: commaSeparatedQuery(30).optional(),
|
||||||
|
lockIds: commaSeparatedQuery(30).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance> {
|
||||||
|
const env = deps.env ?? loadEnv();
|
||||||
|
const cache = deps.cache ?? createCache(env.redisUrl);
|
||||||
|
const fetcher = deps.fetcher ?? fetch;
|
||||||
|
const featureService = deps.featureService ?? new FeatureService(env);
|
||||||
|
const fairwayService =
|
||||||
|
deps.fairwayService ??
|
||||||
|
new FairwayService({
|
||||||
|
cache,
|
||||||
|
fetcher,
|
||||||
|
liveEnabled: env.liveOsmFairways,
|
||||||
|
databaseUrl: env.databaseUrl
|
||||||
|
});
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: process.env.LOG_LEVEL ?? "info"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: true
|
||||||
|
});
|
||||||
|
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
await cache.close();
|
||||||
|
await featureService.close();
|
||||||
|
await fairwayService.close?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/health", async () => ({ ok: true, service: "watermaps-api" }));
|
||||||
|
|
||||||
|
app.get("/api/config", async () => appConfig);
|
||||||
|
|
||||||
|
app.get("/api/weather/marine", async (request, reply) => {
|
||||||
|
const parsed = coordinateQuerySchema.safeParse(request.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMarineForecast(parsed.data, { cache, fetcher });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/tides/nearest", async (request, reply) => {
|
||||||
|
const parsed = coordinateQuerySchema.safeParse(request.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await getNearestTideSummary(parsed.data, { cache, fetcher });
|
||||||
|
if (!summary) {
|
||||||
|
return reply.code(404).send({ error: "no_tide_station_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/navigation/live", async (request, reply) => {
|
||||||
|
const parsed = navigationQuerySchema.safeParse(request.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return getNavigationData(parsed.data, {
|
||||||
|
cache,
|
||||||
|
fetcher,
|
||||||
|
adapters: deps.navigationAdapters
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/features", async (request, reply) => {
|
||||||
|
const parsed = featuresQuerySchema.safeParse(request.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return featureService.getFeatures(parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/routes", async (request, reply) => {
|
||||||
|
const parsed = routeRequestSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "invalid_route", details: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
|
||||||
|
app.log.warn({ error }, "fairway extraction failed");
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const route = buildRouteFromGraphs(parsed.data, dynamicGraphs);
|
||||||
|
if (!route) {
|
||||||
|
return reply.code(422).send({
|
||||||
|
error: "no_fairway_route",
|
||||||
|
message:
|
||||||
|
"Keine Fahrwasserroute für Start und Ziel gefunden. Setze Punkte näher an ein bekanntes Fahrwasser oder importiere weitere Fahrwasserdaten."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route;
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commaSeparatedQuery(maxItems: number) {
|
||||||
|
return z.string().transform((value, ctx) => {
|
||||||
|
const items = [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
||||||
|
if (items.length > maxItems) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `too many values (maximum ${maxItems})`
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouteFromGraphs(request: RouteRequest, graphs: FairwayGraph[]) {
|
||||||
|
for (const graph of graphs) {
|
||||||
|
const routes = buildFairwayRoutes(request, graph);
|
||||||
|
if (routes.length > 0) {
|
||||||
|
return routeResultWithAlternatives(routes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const graph of [undefined, EMDEN_HAMM_GRAPH] as const) {
|
||||||
|
const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request);
|
||||||
|
if (routes.length > 0) {
|
||||||
|
return routeResultWithAlternatives(routes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeResultWithAlternatives(routes: RouteOption[]): RouteResult {
|
||||||
|
const [primary, ...alternatives] = routes;
|
||||||
|
if (!primary) {
|
||||||
|
throw new Error("routeResultWithAlternatives requires at least one route");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...primary,
|
||||||
|
alternatives
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type ApiEnv = {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
databaseUrl?: string;
|
||||||
|
redisUrl?: string;
|
||||||
|
demoData: boolean;
|
||||||
|
liveOsmFairways: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
|
||||||
|
return {
|
||||||
|
port: Number(env.PORT ?? 5174),
|
||||||
|
host: env.HOST ?? "0.0.0.0",
|
||||||
|
databaseUrl: env.DATABASE_URL,
|
||||||
|
redisUrl: env.REDIS_URL,
|
||||||
|
demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) !== "false",
|
||||||
|
liveOsmFairways:
|
||||||
|
(env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" &&
|
||||||
|
env.NODE_ENV !== "test"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { buildServer } from "./app.js";
|
||||||
|
import { loadEnv } from "./env.js";
|
||||||
|
|
||||||
|
for (const envFile of [resolve(process.cwd(), ".env"), resolve(process.cwd(), "../..", ".env")]) {
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
process.loadEnvFile(envFile);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = loadEnv();
|
||||||
|
const app = await buildServer({ env });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port: env.port, host: env.host });
|
||||||
|
} catch (error) {
|
||||||
|
app.log.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Redis } from "ioredis";
|
||||||
|
|
||||||
|
export interface Cache {
|
||||||
|
get<T>(key: string): Promise<T | null>;
|
||||||
|
set<T>(key: string, value: T, ttlMs: number): Promise<void>;
|
||||||
|
getOrSet<T>(key: string, ttlMs: number, loader: () => Promise<T>): Promise<T>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCache(redisUrl?: string): Cache {
|
||||||
|
if (!redisUrl) {
|
||||||
|
return new MemoryCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
const redis = new Redis(redisUrl, {
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
enableOfflineQueue: false
|
||||||
|
});
|
||||||
|
let redisReady = false;
|
||||||
|
const memoryFallback = new MemoryCache();
|
||||||
|
|
||||||
|
redis.on("ready", () => {
|
||||||
|
redisReady = true;
|
||||||
|
});
|
||||||
|
redis.on("error", () => {
|
||||||
|
redisReady = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
void redis.connect().catch(() => {
|
||||||
|
redisReady = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
if (!redisReady) {
|
||||||
|
return memoryFallback.get<T>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await redis.get(key);
|
||||||
|
return raw ? (JSON.parse(raw) as T) : null;
|
||||||
|
},
|
||||||
|
async set<T>(key: string, value: T, ttlMs: number): Promise<void> {
|
||||||
|
if (!redisReady) {
|
||||||
|
return memoryFallback.set(key, value, ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.set(key, JSON.stringify(value), "PX", ttlMs);
|
||||||
|
},
|
||||||
|
async getOrSet<T>(key: string, ttlMs: number, loader: () => Promise<T>): Promise<T> {
|
||||||
|
const cached = await this.get<T>(key);
|
||||||
|
if (cached !== null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await loader();
|
||||||
|
await this.set(key, value, ttlMs);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await memoryFallback.close();
|
||||||
|
redis.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryCache implements Cache {
|
||||||
|
private readonly entries = new Map<string, { expiresAt: number; value: unknown }>();
|
||||||
|
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.expiresAt < Date.now()) {
|
||||||
|
this.entries.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<T>(key: string, value: T, ttlMs: number): Promise<void> {
|
||||||
|
this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrSet<T>(key: string, ttlMs: number, loader: () => Promise<T>): Promise<T> {
|
||||||
|
const cached = await this.get<T>(key);
|
||||||
|
if (cached !== null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await loader();
|
||||||
|
await this.set(key, value, ttlMs);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.entries.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { AppConfig } from "@watermaps/shared";
|
||||||
|
|
||||||
|
export const appConfig: AppConfig = {
|
||||||
|
appName: "Watermaps",
|
||||||
|
region: "Deutschland/EU",
|
||||||
|
disclaimer:
|
||||||
|
"Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.",
|
||||||
|
featureFlags: {
|
||||||
|
gpsTracking: true,
|
||||||
|
compass: true,
|
||||||
|
marineWeather: true,
|
||||||
|
tides: true,
|
||||||
|
manualRouting: true,
|
||||||
|
liveFairwayExtraction: true,
|
||||||
|
postgisFeatures: true,
|
||||||
|
bridgeAndDepthOverlays: true,
|
||||||
|
inlandWaterwayRouting: true,
|
||||||
|
routeAlternatives: true,
|
||||||
|
lockAndHarbourContacts: true,
|
||||||
|
routeWaypoints: true,
|
||||||
|
departureTimeForecasts: true,
|
||||||
|
liveWaterLevels: true,
|
||||||
|
voyageStages: true,
|
||||||
|
gpxExport: true,
|
||||||
|
offlineRoutes: true,
|
||||||
|
offlineVisitedMapResources: true,
|
||||||
|
routeDeviationAlarm: true,
|
||||||
|
offlineTiles: true
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "openfreemap",
|
||||||
|
name: "Basiskarte",
|
||||||
|
kind: "style",
|
||||||
|
url: "https://tiles.openfreemap.org/styles/bright",
|
||||||
|
attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap",
|
||||||
|
defaultVisible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "openseamap-seamarks",
|
||||||
|
name: "Seezeichen",
|
||||||
|
kind: "raster-tile",
|
||||||
|
tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
|
||||||
|
attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors",
|
||||||
|
defaultVisible: true,
|
||||||
|
opacity: 0.95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "emodnet-bathymetry",
|
||||||
|
name: "Bathymetrie",
|
||||||
|
kind: "wms",
|
||||||
|
tileUrl:
|
||||||
|
"https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256",
|
||||||
|
attribution: "Bathymetry © EMODnet Bathymetry",
|
||||||
|
defaultVisible: false,
|
||||||
|
opacity: 0.55
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attribution: [
|
||||||
|
"© OpenStreetMap contributors",
|
||||||
|
"© OpenSeaMap contributors",
|
||||||
|
"© EMODnet Bathymetry",
|
||||||
|
"© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0",
|
||||||
|
"Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
|
||||||
|
"Weather, waves and current forecast © Open-Meteo"
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
import type { Coordinate, FairwayEdge, FairwayGraph, FairwayNode, RouteRequest } from "@watermaps/shared";
|
||||||
|
import type { Cache } from "./cache.js";
|
||||||
|
import type { FetchLike } from "./http.js";
|
||||||
|
|
||||||
|
type OverpassElement = {
|
||||||
|
type: "way";
|
||||||
|
id: number;
|
||||||
|
geometry?: Coordinate[];
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OverpassResponse = {
|
||||||
|
elements?: OverpassElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FairwayDeps = {
|
||||||
|
cache: Cache;
|
||||||
|
fetcher: FetchLike;
|
||||||
|
liveEnabled: boolean;
|
||||||
|
databaseUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FairwayRow = {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
source_id: string | null;
|
||||||
|
name: string | null;
|
||||||
|
min_depth_m: string | number | null;
|
||||||
|
properties?: Record<string, unknown> | null;
|
||||||
|
geometry: {
|
||||||
|
type: "LineString";
|
||||||
|
coordinates: [number, number][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const OVERPASS_URL = "https://overpass-api.de/api/interpreter";
|
||||||
|
const CACHE_TTL_MS = 1000 * 60 * 60 * 12;
|
||||||
|
const MAX_BBOX_SPAN_DEG = 3;
|
||||||
|
const MAX_POSTGIS_BBOX_SPAN_DEG = 6;
|
||||||
|
const BBOX_MARGIN_DEG = 0.15;
|
||||||
|
const ENDPOINT_SNAP_DEG = 0.0001;
|
||||||
|
const CONNECTOR_DISTANCE_NM = 0.08;
|
||||||
|
const CONNECTOR_GRID_DEG = 0.003;
|
||||||
|
|
||||||
|
export class FairwayService {
|
||||||
|
private readonly cache: Cache;
|
||||||
|
private readonly fetcher: FetchLike;
|
||||||
|
private readonly liveEnabled: boolean;
|
||||||
|
private readonly pool: pg.Pool | null;
|
||||||
|
|
||||||
|
constructor(deps: FairwayDeps) {
|
||||||
|
this.cache = deps.cache;
|
||||||
|
this.fetcher = deps.fetcher;
|
||||||
|
this.liveEnabled = deps.liveEnabled;
|
||||||
|
this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraph[]> {
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
this.getPostgisGraphForRoute(request),
|
||||||
|
this.getLiveGraphForRoute(request)
|
||||||
|
]);
|
||||||
|
const graphs = results.flatMap((result) =>
|
||||||
|
result.status === "fulfilled" && result.value ? [result.value] : []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (graphs.length === 0) {
|
||||||
|
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw new AggregateError(failures.map((failure) => failure.reason), "No fairway source produced a graph");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphs.length < 2) {
|
||||||
|
return graphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = mergeConnectedFairwayGraphs(graphs);
|
||||||
|
return combined ? [combined, ...graphs] : graphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.pool?.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPostgisGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
|
||||||
|
if (!this.pool) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bbox = routeBbox([request.start, ...(request.waypoints ?? []), request.destination], MAX_POSTGIS_BBOX_SPAN_DEG);
|
||||||
|
if (!bbox) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `fairways:postgis:${bbox.map((value) => value.toFixed(3)).join(",")}`;
|
||||||
|
return this.cache.getOrSet(cacheKey, CACHE_TTL_MS, async () => {
|
||||||
|
const [minLon, minLat, maxLon, maxLat] = bbox;
|
||||||
|
const result = await this.pool!.query<FairwayRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id::text,
|
||||||
|
source,
|
||||||
|
source_id,
|
||||||
|
name,
|
||||||
|
min_depth_m,
|
||||||
|
properties,
|
||||||
|
ST_AsGeoJSON(geom)::json AS geometry
|
||||||
|
FROM marine_fairway_edges
|
||||||
|
WHERE geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)
|
||||||
|
LIMIT 25000
|
||||||
|
`,
|
||||||
|
[minLon, minLat, maxLon, maxLat]
|
||||||
|
);
|
||||||
|
|
||||||
|
return fairwayRowsToGraph(result.rows, bbox);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
|
||||||
|
if (!this.liveEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bbox = routeBbox([request.start, ...(request.waypoints ?? []), request.destination]);
|
||||||
|
if (!bbox) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `fairways:overpass:${bbox.map((value) => value.toFixed(3)).join(",")}`;
|
||||||
|
return this.cache.getOrSet(cacheKey, CACHE_TTL_MS, async () => {
|
||||||
|
const response = await this.fetchOverpass(bbox);
|
||||||
|
return overpassToGraph(response, bbox);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchOverpass(bbox: [number, number, number, number]): Promise<OverpassResponse> {
|
||||||
|
const [minLon, minLat, maxLon, maxLat] = bbox;
|
||||||
|
const query = `
|
||||||
|
[out:json][timeout:25];
|
||||||
|
(
|
||||||
|
way["seamark:type"~"^(navigation_line|recommended_track)$"](${minLat},${minLon},${maxLat},${maxLon});
|
||||||
|
way["seamark:type"="fairway"](${minLat},${minLon},${maxLat},${maxLon});
|
||||||
|
way["waterway"="fairway"](${minLat},${minLon},${maxLat},${maxLon});
|
||||||
|
way["waterway"="canal"]["access"!="no"]["access"!="private"]["boat"!="no"]["ship"!="no"]["motorboat"!="no"]["disused"!="yes"](${minLat},${minLon},${maxLat},${maxLon});
|
||||||
|
way["waterway"="river"]["boat"~"^(yes|designated|permissive)$"]["access"!="no"]["access"!="private"]["disused"!="yes"](${minLat},${minLon},${maxLat},${maxLon});
|
||||||
|
way["waterway"="river"]["ship"~"^(yes|designated|permissive)$"]["access"!="no"]["access"!="private"]["disused"!="yes"](${minLat},${minLon},${maxLat},${maxLon});
|
||||||
|
way["route"="ferry"](${minLat},${minLon},${maxLat},${maxLon});
|
||||||
|
);
|
||||||
|
out tags geom;
|
||||||
|
`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 12_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetcher(OVERPASS_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"user-agent": "Watermaps/0.1 fairway-extractor"
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ data: query }).toString(),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Overpass request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as OverpassResponse;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeBbox(points: Coordinate[], maxSpanDeg = MAX_BBOX_SPAN_DEG): [number, number, number, number] | null {
|
||||||
|
const lons = points.map((point) => point.lon);
|
||||||
|
const lats = points.map((point) => point.lat);
|
||||||
|
const minLon = Math.max(-180, Math.min(...lons) - BBOX_MARGIN_DEG);
|
||||||
|
const maxLon = Math.min(180, Math.max(...lons) + BBOX_MARGIN_DEG);
|
||||||
|
const minLat = Math.max(-90, Math.min(...lats) - BBOX_MARGIN_DEG);
|
||||||
|
const maxLat = Math.min(90, Math.max(...lats) + BBOX_MARGIN_DEG);
|
||||||
|
|
||||||
|
if (maxLon - minLon > maxSpanDeg || maxLat - minLat > maxSpanDeg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [minLon, minLat, maxLon, maxLat];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fairwayRowsToGraph(rows: FairwayRow[], bbox: [number, number, number, number]): FairwayGraph | null {
|
||||||
|
return waysToGraph(
|
||||||
|
rows
|
||||||
|
.map((row) => {
|
||||||
|
const tags = stringTags(row.properties);
|
||||||
|
return {
|
||||||
|
id: `postgis-edge-${row.id}`,
|
||||||
|
name: row.name ?? row.source_id ?? `PostGIS ${row.id}`,
|
||||||
|
coordinates: row.geometry.coordinates.map(([lon, lat]) => ({ lon, lat })),
|
||||||
|
minDepthM: parseNumeric(row.min_depth_m),
|
||||||
|
source: `postgis-${row.source}`,
|
||||||
|
...edgeRestrictions(tags)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((way) => way.coordinates.length >= 2),
|
||||||
|
`postgis-${bbox.map((value) => value.toFixed(3)).join("-")}`,
|
||||||
|
"PostGIS Fahrwasser"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overpassToGraph(response: OverpassResponse, bbox: [number, number, number, number]): FairwayGraph | null {
|
||||||
|
const ways = (response.elements ?? [])
|
||||||
|
.map((element) => {
|
||||||
|
const tags = element.tags ?? {};
|
||||||
|
const coordinates = (element.geometry ?? []).filter(isValidCoordinate);
|
||||||
|
if (coordinates.length < 2 || !isRoutableWay(tags, coordinates)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `osm-way-${element.id}`,
|
||||||
|
name: tags.name ?? tags.ref ?? `OSM ${element.id}`,
|
||||||
|
coordinates,
|
||||||
|
minDepthM: parseDepth(tags),
|
||||||
|
source: sourceFor(tags),
|
||||||
|
...edgeRestrictions(tags)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((way): way is NonNullable<typeof way> => way !== null);
|
||||||
|
|
||||||
|
return waysToGraph(
|
||||||
|
ways,
|
||||||
|
`osm-overpass-${bbox.map((value) => value.toFixed(3)).join("-")}`,
|
||||||
|
"OSM/OpenSeaMap Fahrwasser"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeConnectedFairwayGraphs(graphs: FairwayGraph[]): FairwayGraph | null {
|
||||||
|
const nodes = new Map<string, FairwayNode>();
|
||||||
|
const edges = new Map<string, FairwayEdge>();
|
||||||
|
|
||||||
|
for (const graph of graphs) {
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
const coordinates = edge.coordinates.filter(isValidCoordinate);
|
||||||
|
for (let index = 0; index < coordinates.length - 1; index += 1) {
|
||||||
|
const start = coordinates[index]!;
|
||||||
|
const end = coordinates[index + 1]!;
|
||||||
|
const from = nodeIdFor(nodes, start);
|
||||||
|
const to = nodeIdFor(nodes, end);
|
||||||
|
if (from === to) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeId = `${graph.id}:${edge.id}:${index}`;
|
||||||
|
edges.set(edgeId, {
|
||||||
|
...edge,
|
||||||
|
id: edgeId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
coordinates: [start, end]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addEndpointConnectors(nodes, edges);
|
||||||
|
if (edges.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `combined-${graphs.map((graph) => graph.id).join("+")}`,
|
||||||
|
name: graphs.map((graph) => graph.name).join(" + "),
|
||||||
|
maxSnapDistanceNm: Math.min(...graphs.map((graph) => graph.maxSnapDistanceNm)),
|
||||||
|
nodes: [...nodes.values()],
|
||||||
|
edges: [...edges.values()]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function waysToGraph(
|
||||||
|
ways: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coordinates: Coordinate[];
|
||||||
|
minDepthM: number | null;
|
||||||
|
source: string;
|
||||||
|
maxAirDraftM?: number | null;
|
||||||
|
maxBeamM?: number | null;
|
||||||
|
maxDraughtM?: number | null;
|
||||||
|
oneway?: boolean | "forward" | "backward";
|
||||||
|
}>,
|
||||||
|
id: string,
|
||||||
|
name: string
|
||||||
|
): FairwayGraph | null {
|
||||||
|
const nodes = new Map<string, FairwayNode>();
|
||||||
|
const edges = new Map<string, FairwayEdge>();
|
||||||
|
|
||||||
|
for (const way of ways) {
|
||||||
|
const coordinates = way.coordinates.filter(isValidCoordinate);
|
||||||
|
for (let index = 0; index < coordinates.length - 1; index += 1) {
|
||||||
|
const start = coordinates[index]!;
|
||||||
|
const end = coordinates[index + 1]!;
|
||||||
|
const from = nodeIdFor(nodes, start);
|
||||||
|
const to = nodeIdFor(nodes, end);
|
||||||
|
if (from === to) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
edges.set(`${way.id}-segment-${index}`, {
|
||||||
|
id: `${way.id}-segment-${index}`,
|
||||||
|
name: way.name,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
coordinates: [start, end],
|
||||||
|
minDepthM: way.minDepthM,
|
||||||
|
source: way.source,
|
||||||
|
maxAirDraftM: way.maxAirDraftM,
|
||||||
|
maxBeamM: way.maxBeamM,
|
||||||
|
maxDraughtM: way.maxDraughtM,
|
||||||
|
oneway: way.oneway
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addEndpointConnectors(nodes, edges);
|
||||||
|
|
||||||
|
if (edges.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
maxSnapDistanceNm: 2,
|
||||||
|
nodes: [...nodes.values()],
|
||||||
|
edges: [...edges.values()]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidCoordinate(coordinate: Coordinate) {
|
||||||
|
return (
|
||||||
|
Number.isFinite(coordinate.lat) &&
|
||||||
|
Number.isFinite(coordinate.lon) &&
|
||||||
|
coordinate.lat >= -90 &&
|
||||||
|
coordinate.lat <= 90 &&
|
||||||
|
coordinate.lon >= -180 &&
|
||||||
|
coordinate.lon <= 180
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRoutableWay(tags: Record<string, string>, coordinates: Coordinate[]) {
|
||||||
|
if (
|
||||||
|
["no", "private"].includes(tags.access ?? "") ||
|
||||||
|
tags.boat === "no" ||
|
||||||
|
tags.ship === "no" ||
|
||||||
|
tags.motorboat === "no" ||
|
||||||
|
tags.disused === "yes" ||
|
||||||
|
tags.construction ||
|
||||||
|
tags.proposed
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seamarkType = tags["seamark:type"];
|
||||||
|
if (seamarkType === "navigation_line" || seamarkType === "recommended_track") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.waterway === "fairway") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.waterway === "canal") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tags.waterway === "river" &&
|
||||||
|
[tags.boat, tags.ship, tags.motorboat].some((value) =>
|
||||||
|
["yes", "designated", "permissive"].includes(value ?? "")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.route === "ferry" && tags.ship !== "no" && tags.motor_vehicle !== "no") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return seamarkType === "fairway" && !isClosedWay(coordinates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClosedWay(coordinates: Coordinate[]) {
|
||||||
|
const first = coordinates[0]!;
|
||||||
|
const last = coordinates.at(-1)!;
|
||||||
|
return Math.abs(first.lat - last.lat) < 0.00001 && Math.abs(first.lon - last.lon) < 0.00001;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeIdFor(nodes: Map<string, FairwayNode>, coordinate: Coordinate) {
|
||||||
|
const id = `${Math.round(coordinate.lat / ENDPOINT_SNAP_DEG)}:${Math.round(coordinate.lon / ENDPOINT_SNAP_DEG)}`;
|
||||||
|
if (!nodes.has(id)) {
|
||||||
|
nodes.set(id, {
|
||||||
|
id,
|
||||||
|
coordinate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEndpointConnectors(nodes: Map<string, FairwayNode>, edges: Map<string, FairwayEdge>) {
|
||||||
|
const degree = new Map<string, number>();
|
||||||
|
for (const edge of edges.values()) {
|
||||||
|
degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
|
||||||
|
degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const endpoints = [...nodes.values()].filter((node) => (degree.get(node.id) ?? 0) <= 1);
|
||||||
|
const buckets = new Map<string, FairwayNode[]>();
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const [x, y] = connectorCell(endpoint.coordinate);
|
||||||
|
const key = `${x}:${y}`;
|
||||||
|
buckets.set(key, [...(buckets.get(key) ?? []), endpoint]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectorIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const first of endpoints) {
|
||||||
|
const [cellX, cellY] = connectorCell(first.coordinate);
|
||||||
|
for (let dx = -1; dx <= 1; dx += 1) {
|
||||||
|
for (let dy = -1; dy <= 1; dy += 1) {
|
||||||
|
for (const second of buckets.get(`${cellX + dx}:${cellY + dy}`) ?? []) {
|
||||||
|
if (first.id >= second.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceNm = distanceApproxNm(first.coordinate, second.coordinate);
|
||||||
|
if (distanceNm === 0 || distanceNm > CONNECTOR_DISTANCE_NM) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectorId = `connector-${first.id}-${second.id}`;
|
||||||
|
if (connectorIds.has(connectorId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectorIds.add(connectorId);
|
||||||
|
edges.set(connectorId, {
|
||||||
|
id: connectorId,
|
||||||
|
name: "Fahrwasser-Verbindung",
|
||||||
|
from: first.id,
|
||||||
|
to: second.id,
|
||||||
|
coordinates: [first.coordinate, second.coordinate],
|
||||||
|
minDepthM: null,
|
||||||
|
source: "fairway-graph-connectors"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectorCell(coordinate: Coordinate) {
|
||||||
|
return [
|
||||||
|
Math.floor(coordinate.lon / CONNECTOR_GRID_DEG),
|
||||||
|
Math.floor(coordinate.lat / CONNECTOR_GRID_DEG)
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDepth(tags: Record<string, string>) {
|
||||||
|
const candidates = [
|
||||||
|
tags["seamark:fairway:minimum_depth"],
|
||||||
|
tags["seamark:recommended_track:minimum_depth"],
|
||||||
|
tags["seamark:navigation_line:minimum_depth"],
|
||||||
|
tags["depth"],
|
||||||
|
tags["min_depth"]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseNumeric(candidate);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeRestrictions(tags: Record<string, string>) {
|
||||||
|
return {
|
||||||
|
maxAirDraftM: firstNumericTag(tags, [
|
||||||
|
"seamark:bridge:clearance_height_safe",
|
||||||
|
"seamark:bridge:clearance_height",
|
||||||
|
"maxheight:physical",
|
||||||
|
"maxheight"
|
||||||
|
]),
|
||||||
|
maxBeamM: firstNumericTag(tags, ["maxwidth:physical", "maxwidth", "seamark:lock:chamber_width"]),
|
||||||
|
maxDraughtM: firstNumericTag(tags, ["maxdraft", "maxdraught", "seamark:restriction:max_draught"]),
|
||||||
|
oneway: parseOneway(tags.oneway)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNumericTag(tags: Record<string, string>, keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = parseNumeric(tags[key]);
|
||||||
|
if (value !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOneway(value: string | undefined): boolean | "backward" | undefined {
|
||||||
|
const normalized = value?.trim().toLowerCase();
|
||||||
|
if (["yes", "true", "1"].includes(normalized ?? "")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === "-1") {
|
||||||
|
return "backward";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringTags(properties: Record<string, unknown> | null | undefined) {
|
||||||
|
const tags: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(properties ?? {})) {
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
tags[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumeric(value: string | number | null | undefined) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value.replace(",", ".").match(/[0-9]+(?:\.[0-9]+)?/)?.[0]);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFor(tags: Record<string, string>) {
|
||||||
|
if (tags.route === "ferry") {
|
||||||
|
return "osm-overpass-ferry-routes";
|
||||||
|
}
|
||||||
|
if (tags.waterway === "fairway") {
|
||||||
|
return "osm-overpass-waterway-fairway";
|
||||||
|
}
|
||||||
|
if (tags.waterway === "canal") {
|
||||||
|
return "osm-overpass-waterway-canal";
|
||||||
|
}
|
||||||
|
if (tags.waterway === "river") {
|
||||||
|
return "osm-overpass-waterway-river";
|
||||||
|
}
|
||||||
|
if (tags["seamark:type"]) {
|
||||||
|
return "osm-overpass-seamarks";
|
||||||
|
}
|
||||||
|
return "osm-overpass";
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceApproxNm(a: Coordinate, b: Coordinate) {
|
||||||
|
const meanLatRad = ((a.lat + b.lat) / 2) * (Math.PI / 180);
|
||||||
|
const x = (a.lon - b.lon) * 60 * Math.cos(meanLatRad);
|
||||||
|
const y = (a.lat - b.lat) * 60;
|
||||||
|
return Math.sqrt(x * x + y * y);
|
||||||
|
}
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
import {
|
||||||
|
canonicalizeMarinePois,
|
||||||
|
type MarinePoiCandidate,
|
||||||
|
type MarinePoiLayer
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
import type { ApiEnv } from "../env.js";
|
||||||
|
|
||||||
|
export type FeatureQuery = {
|
||||||
|
bbox: [number, number, number, number];
|
||||||
|
layers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeatureCollection = {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Array<{
|
||||||
|
type: "Feature";
|
||||||
|
id?: string;
|
||||||
|
geometry: unknown;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
metadata: {
|
||||||
|
source: "postgis" | "demo";
|
||||||
|
warning?: string;
|
||||||
|
deduplication?: {
|
||||||
|
inputPoiCount: number;
|
||||||
|
outputPoiCount: number;
|
||||||
|
mergedObjectCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const demoFeatures = [
|
||||||
|
{
|
||||||
|
type: "Feature" as const,
|
||||||
|
id: "demo-seamark-warnemuende",
|
||||||
|
geometry: { type: "Point", coordinates: [12.0886, 54.1798] },
|
||||||
|
properties: {
|
||||||
|
layer: "seamarks",
|
||||||
|
name: "Warnemünde Mole",
|
||||||
|
seamark_type: "light_minor",
|
||||||
|
source: "demo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature" as const,
|
||||||
|
id: "demo-lock-kiel",
|
||||||
|
geometry: { type: "Point", coordinates: [10.1424, 54.3665] },
|
||||||
|
properties: {
|
||||||
|
layer: "locks",
|
||||||
|
name: "Schleuse Kiel-Holtenau",
|
||||||
|
source: "demo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature" as const,
|
||||||
|
id: "demo-bridge-hamburg",
|
||||||
|
geometry: { type: "Point", coordinates: [9.966, 53.541] },
|
||||||
|
properties: {
|
||||||
|
layer: "bridges",
|
||||||
|
name: "Hamburg Brücke Demo",
|
||||||
|
source: "demo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export class FeatureService {
|
||||||
|
private pool: pg.Pool | null;
|
||||||
|
private demoData: boolean;
|
||||||
|
|
||||||
|
constructor(env: Pick<ApiEnv, "databaseUrl" | "demoData">) {
|
||||||
|
this.pool = env.databaseUrl ? new pg.Pool({ connectionString: env.databaseUrl }) : null;
|
||||||
|
this.demoData = env.demoData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFeatures(query: FeatureQuery): Promise<FeatureCollection> {
|
||||||
|
if (this.pool && !this.demoData) {
|
||||||
|
return this.getPostgisFeatures(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [minLon, minLat, maxLon, maxLat] = query.bbox;
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: demoFeatures.filter((feature) => {
|
||||||
|
const [lon, lat] = feature.geometry.coordinates;
|
||||||
|
if (typeof lon !== "number" || typeof lat !== "number") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
query.layers.includes(String(feature.properties.layer)) &&
|
||||||
|
lon >= minLon &&
|
||||||
|
lon <= maxLon &&
|
||||||
|
lat >= minLat &&
|
||||||
|
lat <= maxLat
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
metadata: {
|
||||||
|
source: "demo",
|
||||||
|
warning:
|
||||||
|
"Demo-Features aktiv. Für Produktionsdaten DATABASE_URL setzen und WATERMAPS_DEMO_DATA=false verwenden."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.pool?.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPostgisFeatures(query: FeatureQuery): Promise<FeatureCollection> {
|
||||||
|
const [minLon, minLat, maxLon, maxLat] = query.bbox;
|
||||||
|
const requestedMarineLayers = query.layers.filter((layer) => layer !== "depths");
|
||||||
|
const features: FeatureCollection["features"] = [];
|
||||||
|
let deduplication: FeatureCollection["metadata"]["deduplication"];
|
||||||
|
|
||||||
|
if (requestedMarineLayers.length > 0) {
|
||||||
|
const result = await this.pool!.query<{
|
||||||
|
id: string;
|
||||||
|
layer: string;
|
||||||
|
name: string | null;
|
||||||
|
source: string;
|
||||||
|
source_id: string | null;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
updated_at: Date | string;
|
||||||
|
geometry: unknown;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
WITH ranked_features AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
layer,
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
source_id,
|
||||||
|
properties,
|
||||||
|
updated_at,
|
||||||
|
geom,
|
||||||
|
row_number() OVER (PARTITION BY layer ORDER BY id) AS layer_rank
|
||||||
|
FROM marine_features
|
||||||
|
WHERE layer = ANY($1::text[])
|
||||||
|
AND geom && ST_MakeEnvelope($2, $3, $4, $5, 4326)
|
||||||
|
AND (
|
||||||
|
layer <> 'bridges'
|
||||||
|
OR properties ? 'seamark:bridge:clearance_height'
|
||||||
|
OR properties ? 'seamark:bridge:clearance_height_safe'
|
||||||
|
OR properties ? 'maxheight'
|
||||||
|
OR properties ? 'maxheight:physical'
|
||||||
|
OR lower(COALESCE(properties->>'bridge', '')) = ANY(
|
||||||
|
ARRAY['movable', 'bascule', 'lift', 'swing', 'drawbridge', 'retractable', 'submersible', 'opening']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id::text,
|
||||||
|
layer,
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
source_id,
|
||||||
|
properties,
|
||||||
|
updated_at,
|
||||||
|
ST_AsGeoJSON(
|
||||||
|
CASE
|
||||||
|
WHEN layer IN ('locks', 'harbours') AND GeometryType(geom) = 'LINESTRING'
|
||||||
|
THEN ST_LineInterpolatePoint(geom, 0.5)
|
||||||
|
WHEN layer IN ('locks', 'harbours') THEN ST_PointOnSurface(geom)
|
||||||
|
ELSE geom
|
||||||
|
END
|
||||||
|
)::json AS geometry
|
||||||
|
FROM ranked_features
|
||||||
|
WHERE layer_rank <= 1000
|
||||||
|
`,
|
||||||
|
[requestedMarineLayers, minLon, minLat, maxLon, maxLat]
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedFeatures = result.rows.map((row) => ({
|
||||||
|
type: "Feature" as const,
|
||||||
|
id: row.id,
|
||||||
|
geometry: row.geometry,
|
||||||
|
properties: normalizeMarineFeatureProperties({
|
||||||
|
layer: row.layer,
|
||||||
|
name: row.name,
|
||||||
|
source: row.source,
|
||||||
|
sourceId: row.source_id,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
properties: row.properties
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
const canonicalized = deduplicateMarineContactFeatures(normalizedFeatures);
|
||||||
|
features.push(...canonicalized.features);
|
||||||
|
deduplication = canonicalized.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.layers.includes("depths")) {
|
||||||
|
const result = await this.pool!.query<{
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
source_id: string | null;
|
||||||
|
name: string | null;
|
||||||
|
min_depth_m: string | number | null;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
geometry: unknown;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id::text,
|
||||||
|
source,
|
||||||
|
source_id,
|
||||||
|
name,
|
||||||
|
min_depth_m,
|
||||||
|
properties,
|
||||||
|
ST_AsGeoJSON(geom)::json AS geometry
|
||||||
|
FROM marine_fairway_edges
|
||||||
|
WHERE min_depth_m IS NOT NULL
|
||||||
|
AND geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)
|
||||||
|
ORDER BY ST_Length(geom::geography) DESC
|
||||||
|
LIMIT 1000
|
||||||
|
`,
|
||||||
|
[minLon, minLat, maxLon, maxLat]
|
||||||
|
);
|
||||||
|
|
||||||
|
features.push(
|
||||||
|
...result.rows.map((row) => ({
|
||||||
|
type: "Feature" as const,
|
||||||
|
id: `depth-${row.id}`,
|
||||||
|
geometry: row.geometry,
|
||||||
|
properties: normalizeDepthFeatureProperties({
|
||||||
|
name: row.name,
|
||||||
|
source: row.source,
|
||||||
|
sourceId: row.source_id,
|
||||||
|
minDepthM: row.min_depth_m,
|
||||||
|
properties: row.properties
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features,
|
||||||
|
metadata: { source: "postgis", ...(deduplication ? { deduplication } : {}) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deduplicateMarineContactFeatures(features: FeatureCollection["features"]): {
|
||||||
|
features: FeatureCollection["features"];
|
||||||
|
metadata: NonNullable<FeatureCollection["metadata"]["deduplication"]>;
|
||||||
|
} {
|
||||||
|
const candidates: MarinePoiCandidate[] = [];
|
||||||
|
const passthrough: FeatureCollection["features"] = [];
|
||||||
|
for (const feature of features) {
|
||||||
|
const layer = feature.properties.layer;
|
||||||
|
const coordinate = pointCoordinate(feature.geometry);
|
||||||
|
if ((layer !== "locks" && layer !== "harbours") || !coordinate) {
|
||||||
|
passthrough.push(feature);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const source = stringProperty(feature.properties, "source");
|
||||||
|
if (!source) {
|
||||||
|
passthrough.push(feature);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push({
|
||||||
|
id: String(feature.id ?? `${source}:${coordinate.lon}:${coordinate.lat}`),
|
||||||
|
layer: layer as MarinePoiLayer,
|
||||||
|
source,
|
||||||
|
sourceId: firstStringProperty(feature.properties, ["sourceId", "source_id"]),
|
||||||
|
name: stringProperty(feature.properties, "name"),
|
||||||
|
coordinate,
|
||||||
|
properties: feature.properties
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalPois = canonicalizeMarinePois(candidates);
|
||||||
|
const canonicalFeatures = canonicalPois.map((poi) => ({
|
||||||
|
type: "Feature" as const,
|
||||||
|
id: poi.entityId,
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [poi.coordinate.lon, poi.coordinate.lat]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
...poi.properties,
|
||||||
|
layer: poi.layer,
|
||||||
|
name: poi.name,
|
||||||
|
source_id: poi.canonicalSourceId,
|
||||||
|
sourceId: poi.canonicalSourceId,
|
||||||
|
dedupeMemberCount: poi.memberCount,
|
||||||
|
dedupeMemberIds: poi.memberIds
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
features: [...passthrough, ...canonicalFeatures],
|
||||||
|
metadata: {
|
||||||
|
inputPoiCount: candidates.length,
|
||||||
|
outputPoiCount: canonicalFeatures.length,
|
||||||
|
mergedObjectCount: Math.max(0, candidates.length - canonicalFeatures.length)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointCoordinate(geometry: unknown) {
|
||||||
|
if (!geometry || typeof geometry !== "object") return null;
|
||||||
|
const candidate = geometry as { type?: unknown; coordinates?: unknown };
|
||||||
|
if (candidate.type !== "Point" || !Array.isArray(candidate.coordinates)) return null;
|
||||||
|
const [lon, lat] = candidate.coordinates;
|
||||||
|
if (
|
||||||
|
typeof lon !== "number" ||
|
||||||
|
typeof lat !== "number" ||
|
||||||
|
!Number.isFinite(lon) ||
|
||||||
|
!Number.isFinite(lat) ||
|
||||||
|
lon < -180 ||
|
||||||
|
lon > 180 ||
|
||||||
|
lat < -90 ||
|
||||||
|
lat > 90
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { lon, lat };
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarineFeaturePropertiesInput = {
|
||||||
|
layer: string;
|
||||||
|
name: string | null;
|
||||||
|
source: string;
|
||||||
|
sourceId: string | null;
|
||||||
|
updatedAt?: Date | string | null;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DepthFeaturePropertiesInput = {
|
||||||
|
name: string | null;
|
||||||
|
source: string;
|
||||||
|
sourceId: string | null;
|
||||||
|
minDepthM: string | number | null;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeMarineFeatureProperties(input: MarineFeaturePropertiesInput): Record<string, unknown> {
|
||||||
|
const sourceProperties = input.properties;
|
||||||
|
const properties = selectedMarineProperties(sourceProperties);
|
||||||
|
const clearanceM = input.layer === "bridges" ? bridgeClearanceM(sourceProperties) : null;
|
||||||
|
const name = input.name ?? stringProperty(sourceProperties, "name");
|
||||||
|
const label = bridgeLabel(name, clearanceM);
|
||||||
|
const phone = firstStringProperty(sourceProperties, ["phone", "contact:phone"]);
|
||||||
|
const website = firstStringProperty(sourceProperties, ["website", "contact:website", "url"]);
|
||||||
|
const email = firstStringProperty(sourceProperties, ["email", "contact:email"]);
|
||||||
|
const vhf = firstStringProperty(sourceProperties, [
|
||||||
|
"vhf",
|
||||||
|
"contact:vhf",
|
||||||
|
"seamark:harbour:communication_channel",
|
||||||
|
"seamark:lock_basin:communication_channel",
|
||||||
|
"seamark:radio_station:channel"
|
||||||
|
]);
|
||||||
|
const openingHours = firstStringProperty(sourceProperties, ["opening_hours", "service_times"]);
|
||||||
|
const operator = firstStringProperty(sourceProperties, ["operator"]);
|
||||||
|
const address = normalizedAddress(sourceProperties);
|
||||||
|
const sourceUrl = firstStringProperty(sourceProperties, [
|
||||||
|
"sourceUrl",
|
||||||
|
"source_url",
|
||||||
|
"enrichmentSourceUrl"
|
||||||
|
]);
|
||||||
|
const updatedAt = normalizedTimestamp(
|
||||||
|
input.updatedAt ?? firstStringProperty(sourceProperties, ["updatedAt", "updated_at", "@timestamp", "timestamp"])
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...properties,
|
||||||
|
layer: input.layer,
|
||||||
|
source: input.source,
|
||||||
|
source_id: input.sourceId,
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
updatedAt,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
website,
|
||||||
|
email,
|
||||||
|
vhf,
|
||||||
|
openingHours,
|
||||||
|
operator,
|
||||||
|
address,
|
||||||
|
sourceUrl,
|
||||||
|
clearance_m: clearanceM,
|
||||||
|
clearance_label: clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null,
|
||||||
|
label
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSM objects can carry hundreds of tags. Only values consumed by the map,
|
||||||
|
// voyage planner and facility resolver belong in the viewport GeoJSON.
|
||||||
|
const MARINE_PROPERTY_KEYS = new Set([
|
||||||
|
"@id",
|
||||||
|
"@type",
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"lock_name",
|
||||||
|
"official_name",
|
||||||
|
"loc_name",
|
||||||
|
"operator",
|
||||||
|
"operator:name",
|
||||||
|
"owner",
|
||||||
|
"phone",
|
||||||
|
"contact:phone",
|
||||||
|
"website",
|
||||||
|
"contact:website",
|
||||||
|
"url",
|
||||||
|
"email",
|
||||||
|
"contact:email",
|
||||||
|
"vhf",
|
||||||
|
"contact:vhf",
|
||||||
|
"vhf_channel",
|
||||||
|
"radio_channel",
|
||||||
|
"opening_hours",
|
||||||
|
"lock:opening_hours",
|
||||||
|
"service_times",
|
||||||
|
"address",
|
||||||
|
"addr:full",
|
||||||
|
"contact:address",
|
||||||
|
"addr:street",
|
||||||
|
"addr:housenumber",
|
||||||
|
"addr:postcode",
|
||||||
|
"addr:city",
|
||||||
|
"addr:place",
|
||||||
|
"addr:country",
|
||||||
|
"country",
|
||||||
|
"seamark:type",
|
||||||
|
"seamark:name",
|
||||||
|
"seamark:gate:category",
|
||||||
|
"seamark:harbour:communication_channel",
|
||||||
|
"seamark:harbour:radio_channel",
|
||||||
|
"seamark:lock_basin:communication_channel",
|
||||||
|
"seamark:radio_station:channel",
|
||||||
|
"seamark:small_craft_facility:category",
|
||||||
|
"leisure",
|
||||||
|
"harbour",
|
||||||
|
"industrial",
|
||||||
|
"landuse",
|
||||||
|
"waterway",
|
||||||
|
"water",
|
||||||
|
"lock",
|
||||||
|
"obstacle",
|
||||||
|
"bridge",
|
||||||
|
"maxheight",
|
||||||
|
"maxheight:physical",
|
||||||
|
"height",
|
||||||
|
"seamark:bridge:clearance_height",
|
||||||
|
"seamark:bridge:clearance_height_safe",
|
||||||
|
"electricity",
|
||||||
|
"power_supply",
|
||||||
|
"shore_power",
|
||||||
|
"service:electricity",
|
||||||
|
"drinking_water",
|
||||||
|
"water_point",
|
||||||
|
"service:water",
|
||||||
|
"fuel",
|
||||||
|
"fuel:diesel",
|
||||||
|
"service:fuel",
|
||||||
|
"waste_disposal",
|
||||||
|
"sanitary_dump_station",
|
||||||
|
"pump_out",
|
||||||
|
"service:waste",
|
||||||
|
"overnight",
|
||||||
|
"guest_berths",
|
||||||
|
"visitor_berths",
|
||||||
|
"guest_moorings",
|
||||||
|
"ref",
|
||||||
|
"ref:EU:RIS",
|
||||||
|
"isrs",
|
||||||
|
"wikidata",
|
||||||
|
"waterwayName",
|
||||||
|
"waterway_name",
|
||||||
|
"hectom",
|
||||||
|
"phones",
|
||||||
|
"phone_raw",
|
||||||
|
"upstreamSource",
|
||||||
|
"upstream_source",
|
||||||
|
"data_source",
|
||||||
|
"sourceUrl",
|
||||||
|
"source_url",
|
||||||
|
"compact_source_url",
|
||||||
|
"ris_source_url",
|
||||||
|
"enrichmentSource",
|
||||||
|
"enrichmentSourceUrl",
|
||||||
|
"fetchedAt",
|
||||||
|
"fetched_at",
|
||||||
|
"updatedAt",
|
||||||
|
"updated_at",
|
||||||
|
"@timestamp",
|
||||||
|
"timestamp"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function selectedMarineProperties(properties: Record<string, unknown>) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(properties).filter(([key, value]) => MARINE_PROPERTY_KEYS.has(key) && value !== undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDepthFeatureProperties(input: DepthFeaturePropertiesInput): Record<string, unknown> {
|
||||||
|
const depthM = parseNumber(input.minDepthM);
|
||||||
|
const depthLabel = depthM !== null ? formatMeters(depthM) : null;
|
||||||
|
const name = input.name ?? stringProperty(input.properties, "name");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...input.properties,
|
||||||
|
layer: "depths",
|
||||||
|
source: input.source,
|
||||||
|
source_id: input.sourceId,
|
||||||
|
name,
|
||||||
|
depth_m: depthM,
|
||||||
|
depth_label: depthLabel,
|
||||||
|
label: name && depthLabel ? `${name} ${depthLabel}` : (depthLabel ?? name ?? "Tiefe")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeClearanceM(properties: Record<string, unknown>) {
|
||||||
|
const candidates = [
|
||||||
|
"seamark:bridge:clearance_height",
|
||||||
|
"seamark:bridge:clearance_height_safe",
|
||||||
|
"maxheight",
|
||||||
|
"maxheight:physical",
|
||||||
|
"height"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of candidates) {
|
||||||
|
const value = parseNumber(properties[key]);
|
||||||
|
if (value !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeLabel(name: string | null | undefined, clearanceM: number | null) {
|
||||||
|
const clearance = clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null;
|
||||||
|
if (name && clearance) {
|
||||||
|
return `${name} ${clearance}`;
|
||||||
|
}
|
||||||
|
return name ?? clearance ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringProperty(properties: Record<string, unknown>, key: string) {
|
||||||
|
const value = properties[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstStringProperty(properties: Record<string, unknown>, keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = stringProperty(properties, key);
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedAddress(properties: Record<string, unknown>) {
|
||||||
|
const fullAddress = firstStringProperty(properties, ["address", "addr:full", "contact:address"]);
|
||||||
|
if (fullAddress) {
|
||||||
|
return fullAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
const street = firstStringProperty(properties, ["addr:street"]);
|
||||||
|
const houseNumber = firstStringProperty(properties, ["addr:housenumber"]);
|
||||||
|
const postcode = firstStringProperty(properties, ["addr:postcode"]);
|
||||||
|
const locality = firstStringProperty(properties, ["addr:city", "addr:place"]);
|
||||||
|
const country = firstStringProperty(properties, ["addr:country"]);
|
||||||
|
const streetLine = [street, houseNumber].filter(Boolean).join(" ");
|
||||||
|
const localityLine = [postcode, locality].filter(Boolean).join(" ");
|
||||||
|
const address = [streetLine, localityLine, country].filter(Boolean).join(", ");
|
||||||
|
return address || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedTimestamp(value: unknown) {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date(value);
|
||||||
|
return Number.isNaN(timestamp.getTime()) ? value.trim() : timestamp.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: unknown) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!value.trim() || value.trim().toLowerCase() === "default") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.replace(",", ".").match(/\d+(?:\.\d+)?/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(match[0]);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMeters(value: number) {
|
||||||
|
return Number.isInteger(value) ? `${value} m` : `${value.toFixed(1)} m`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
export async function fetchJson<T>(
|
||||||
|
fetcher: FetchLike,
|
||||||
|
url: string,
|
||||||
|
timeoutMs = 8000
|
||||||
|
): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetcher(url, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with ${response.status} for ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import type { Cache } from "./cache.js";
|
||||||
|
import type { FetchLike } from "./http.js";
|
||||||
|
import type {
|
||||||
|
LockOperationInfo,
|
||||||
|
NavigationDataSnapshot,
|
||||||
|
NavigationNotice,
|
||||||
|
NavigationSourceKind,
|
||||||
|
NavigationSourceStatus,
|
||||||
|
WaterLevel,
|
||||||
|
WaterLevelState
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
LockOperationInfo,
|
||||||
|
NavigationDataSnapshot,
|
||||||
|
NavigationNotice,
|
||||||
|
NavigationSourceKind,
|
||||||
|
NavigationSourceStatus,
|
||||||
|
WaterLevel,
|
||||||
|
WaterLevelState
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Official reference pages. Only PEGELONLINE currently documents a public,
|
||||||
|
* unauthenticated machine-readable API. ELWIS lock data and Notices to
|
||||||
|
* Skippers are therefore exposed through optional adapters instead of being
|
||||||
|
* scraped from unstable HTML.
|
||||||
|
*/
|
||||||
|
export const OFFICIAL_NAVIGATION_SOURCES = {
|
||||||
|
pegelOnlineApi: "https://pegelonline.wsv.de/webservices/rest-api/v2",
|
||||||
|
pegelOnlineDocumentation: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||||
|
elwisLockInformation: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/",
|
||||||
|
elwisNotices: "https://www.elwis.de/DE/dynamisch/Nfb/"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NavigationSourceState =
|
||||||
|
| "live"
|
||||||
|
| "cached"
|
||||||
|
| "stale"
|
||||||
|
| "unavailable"
|
||||||
|
| "not-configured";
|
||||||
|
|
||||||
|
export type NavigationDataQuery = {
|
||||||
|
/** Exact PEGELONLINE water names, for example EMS or RHEIN. */
|
||||||
|
waterways?: string[];
|
||||||
|
/** Stable PEGELONLINE station UUIDs. Prefer these when they are known. */
|
||||||
|
stationIds?: string[];
|
||||||
|
/** Provider-specific stable lock IDs made available to optional adapters. */
|
||||||
|
lockIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationAdapterContext = {
|
||||||
|
fetcher: FetchLike;
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NavigationDataAdapter<T> {
|
||||||
|
kind: NavigationSourceKind;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** Human-readable official documentation or source page. */
|
||||||
|
sourceUrl: string;
|
||||||
|
freshTtlMs?: number;
|
||||||
|
staleTtlMs?: number;
|
||||||
|
load(query: NavigationDataQuery, context: NavigationAdapterContext): Promise<readonly T[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavigationDataAdapters = {
|
||||||
|
/** `undefined` selects the built-in PEGELONLINE adapter; `null` disables it. */
|
||||||
|
waterLevels?: NavigationDataAdapter<WaterLevel> | null;
|
||||||
|
/** No public machine-readable ELWIS endpoint is assumed; configure explicitly. */
|
||||||
|
lockOperations?: NavigationDataAdapter<LockOperationInfo> | null;
|
||||||
|
/** No public machine-readable ELWIS endpoint is assumed; configure explicitly. */
|
||||||
|
notices?: NavigationDataAdapter<NavigationNotice> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationDataDependencies = {
|
||||||
|
cache: Cache;
|
||||||
|
fetcher: FetchLike;
|
||||||
|
adapters?: NavigationDataAdapters;
|
||||||
|
timeoutMs?: number;
|
||||||
|
now?: () => Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SourceCacheEntry<T> = {
|
||||||
|
cachedAt: string;
|
||||||
|
items: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoadedSource<T> = {
|
||||||
|
items: T[];
|
||||||
|
status: NavigationSourceStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 8_000;
|
||||||
|
const DEFAULT_FRESH_TTL_MS = 60_000;
|
||||||
|
const DEFAULT_STALE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function getNavigationData(
|
||||||
|
query: NavigationDataQuery,
|
||||||
|
deps: NavigationDataDependencies
|
||||||
|
): Promise<NavigationDataSnapshot> {
|
||||||
|
const now = deps.now ?? (() => new Date());
|
||||||
|
const waterLevelAdapter =
|
||||||
|
deps.adapters?.waterLevels === undefined
|
||||||
|
? createPegelOnlineWaterLevelAdapter()
|
||||||
|
: deps.adapters.waterLevels;
|
||||||
|
const lockAdapter = deps.adapters?.lockOperations ?? null;
|
||||||
|
const noticeAdapter = deps.adapters?.notices ?? null;
|
||||||
|
|
||||||
|
const [waterLevels, lockOperations, notices] = await Promise.all([
|
||||||
|
waterLevelAdapter
|
||||||
|
? loadSource(waterLevelAdapter, query, deps, now)
|
||||||
|
: notConfiguredSource<WaterLevel>(
|
||||||
|
"water-levels",
|
||||||
|
"pegelonline-wsv",
|
||||||
|
"PEGELONLINE der WSV",
|
||||||
|
OFFICIAL_NAVIGATION_SOURCES.pegelOnlineDocumentation,
|
||||||
|
now,
|
||||||
|
"Wasserstandsdaten wurden deaktiviert."
|
||||||
|
),
|
||||||
|
lockAdapter
|
||||||
|
? loadSource(lockAdapter, query, deps, now)
|
||||||
|
: notConfiguredSource<LockOperationInfo>(
|
||||||
|
"lock-operations",
|
||||||
|
"elwis-lock-information",
|
||||||
|
"ELWIS Schleuseninformationen",
|
||||||
|
OFFICIAL_NAVIGATION_SOURCES.elwisLockInformation,
|
||||||
|
now,
|
||||||
|
"Keine dokumentierte öffentliche Maschinenschnittstelle konfiguriert; offizielle ELWIS-Seite verwenden."
|
||||||
|
),
|
||||||
|
noticeAdapter
|
||||||
|
? loadSource(noticeAdapter, query, deps, now)
|
||||||
|
: notConfiguredSource<NavigationNotice>(
|
||||||
|
"notices",
|
||||||
|
"elwis-notices-to-skippers",
|
||||||
|
"ELWIS Nachrichten für die Binnenschifffahrt",
|
||||||
|
OFFICIAL_NAVIGATION_SOURCES.elwisNotices,
|
||||||
|
now,
|
||||||
|
"Keine dokumentierte öffentliche Maschinenschnittstelle konfiguriert; NfB in ELWIS prüfen."
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
waterLevels: waterLevels.items,
|
||||||
|
lockOperations: lockOperations.items,
|
||||||
|
notices: notices.items,
|
||||||
|
sources: [waterLevels.status, lockOperations.status, notices.status],
|
||||||
|
generatedAt: now().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPegelOnlineWaterLevelAdapter(
|
||||||
|
baseUrl: string = OFFICIAL_NAVIGATION_SOURCES.pegelOnlineApi
|
||||||
|
): NavigationDataAdapter<WaterLevel> {
|
||||||
|
assertOfficialNavigationUrl(baseUrl);
|
||||||
|
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "water-levels",
|
||||||
|
id: "pegelonline-wsv-v2",
|
||||||
|
label: "PEGELONLINE REST-API v2",
|
||||||
|
sourceUrl: OFFICIAL_NAVIGATION_SOURCES.pegelOnlineDocumentation,
|
||||||
|
freshTtlMs: 60_000,
|
||||||
|
staleTtlMs: 6 * 60 * 60 * 1000,
|
||||||
|
async load(query, { fetcher, signal }) {
|
||||||
|
const url = buildPegelOnlineUrl(query, normalizedBaseUrl);
|
||||||
|
if (!url) {
|
||||||
|
throw new AdapterNotConfiguredError(
|
||||||
|
"Für PEGELONLINE werden mindestens eine Stations-UUID oder ein exakter Gewässername benötigt."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await fetchOfficialJson(fetcher, url, signal);
|
||||||
|
return normalizePegelOnlineStations(payload, normalizedBaseUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OfficialJsonAdapterOptions<T> = {
|
||||||
|
kind: NavigationSourceKind;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
buildUrl: (query: NavigationDataQuery) => string;
|
||||||
|
parse: (payload: unknown, query: NavigationDataQuery) => readonly T[];
|
||||||
|
freshTtlMs?: number;
|
||||||
|
staleTtlMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter boundary for a future documented ELWIS/WSV JSON feed. Both the
|
||||||
|
* reference page and every generated endpoint are restricted to official
|
||||||
|
* HTTPS ELWIS/WSV hosts.
|
||||||
|
*/
|
||||||
|
export function createOfficialJsonAdapter<T>(
|
||||||
|
options: OfficialJsonAdapterOptions<T>
|
||||||
|
): NavigationDataAdapter<T> {
|
||||||
|
assertOfficialNavigationUrl(options.sourceUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: options.kind,
|
||||||
|
id: options.id,
|
||||||
|
label: options.label,
|
||||||
|
sourceUrl: options.sourceUrl,
|
||||||
|
freshTtlMs: options.freshTtlMs,
|
||||||
|
staleTtlMs: options.staleTtlMs,
|
||||||
|
async load(query, { fetcher, signal }) {
|
||||||
|
const endpoint = options.buildUrl(query);
|
||||||
|
assertOfficialNavigationUrl(endpoint);
|
||||||
|
const payload = await fetchOfficialJson(fetcher, endpoint, signal);
|
||||||
|
return [...options.parse(payload, query)];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPegelOnlineUrl(
|
||||||
|
query: NavigationDataQuery,
|
||||||
|
baseUrl: string = OFFICIAL_NAVIGATION_SOURCES.pegelOnlineApi
|
||||||
|
): string | null {
|
||||||
|
const stationIds = normalizeQueryValues(query.stationIds);
|
||||||
|
const waterways = normalizeQueryValues(query.waterways);
|
||||||
|
if (stationIds.length === 0 && waterways.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertOfficialNavigationUrl(baseUrl);
|
||||||
|
const url = new URL(`${baseUrl.replace(/\/$/, "")}/stations.json`);
|
||||||
|
if (stationIds.length > 0) {
|
||||||
|
url.searchParams.set("ids", stationIds.join(","));
|
||||||
|
}
|
||||||
|
if (waterways.length > 0) {
|
||||||
|
url.searchParams.set("waters", waterways.join(","));
|
||||||
|
}
|
||||||
|
url.searchParams.set("timeseries", "W");
|
||||||
|
url.searchParams.set("includeTimeseries", "true");
|
||||||
|
url.searchParams.set("includeCurrentMeasurement", "true");
|
||||||
|
url.searchParams.set("prettyprint", "false");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePegelOnlineStations(payload: unknown, baseUrl: string): WaterLevel[] {
|
||||||
|
if (!Array.isArray(payload)) {
|
||||||
|
throw new Error("PEGELONLINE-Antwort ist keine Stationsliste.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const levels: WaterLevel[] = [];
|
||||||
|
for (const candidate of payload) {
|
||||||
|
if (!isRecord(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const stationId = stringValue(candidate.uuid);
|
||||||
|
const stationName = stringValue(candidate.shortname) ?? stringValue(candidate.longname);
|
||||||
|
const water = isRecord(candidate.water) ? candidate.water : null;
|
||||||
|
const waterway = water
|
||||||
|
? stringValue(water.shortname) ?? stringValue(water.longname)
|
||||||
|
: null;
|
||||||
|
const timeseries = Array.isArray(candidate.timeseries) ? candidate.timeseries : [];
|
||||||
|
const waterSeries = timeseries.find(
|
||||||
|
(entry) => isRecord(entry) && stringValue(entry.shortname)?.toUpperCase() === "W"
|
||||||
|
);
|
||||||
|
if (!stationId || !stationName || !waterway || !isRecord(waterSeries)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurement = isRecord(waterSeries.currentMeasurement)
|
||||||
|
? waterSeries.currentMeasurement
|
||||||
|
: null;
|
||||||
|
const value = measurement ? numberValue(measurement.value) : null;
|
||||||
|
const measuredAt = measurement ? stringValue(measurement.timestamp) : null;
|
||||||
|
const unit = stringValue(waterSeries.unit);
|
||||||
|
if (value === null || !measuredAt || !unit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
levels.push({
|
||||||
|
stationId,
|
||||||
|
stationNumber: stringValue(candidate.number),
|
||||||
|
stationName,
|
||||||
|
waterway,
|
||||||
|
waterwayKm: numberValue(candidate.km),
|
||||||
|
latitude: numberValue(candidate.latitude),
|
||||||
|
longitude: numberValue(candidate.longitude),
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
measuredAt,
|
||||||
|
stateMnwMhw: waterLevelState(measurement?.stateMnwMhw),
|
||||||
|
stateNswHsw: waterLevelState(measurement?.stateNswHsw),
|
||||||
|
agency: stringValue(candidate.agency),
|
||||||
|
sourceUrl: `${baseUrl.replace(/\/$/, "")}/stations/${encodeURIComponent(stationId)}.json`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertOfficialNavigationUrl(rawUrl: string): void {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Navigationsdatenquelle muss eine gültige URL sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = url.hostname.toLowerCase();
|
||||||
|
const officialHost =
|
||||||
|
host === "elwis.de" ||
|
||||||
|
host.endsWith(".elwis.de") ||
|
||||||
|
host === "wsv.de" ||
|
||||||
|
host.endsWith(".wsv.de") ||
|
||||||
|
host === "wsv.bund.de" ||
|
||||||
|
host.endsWith(".wsv.bund.de");
|
||||||
|
if (url.protocol !== "https:" || !officialHost) {
|
||||||
|
throw new Error("Navigationsdatenadapter akzeptieren nur offizielle HTTPS-Quellen von ELWIS/WSV.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSource<T>(
|
||||||
|
adapter: NavigationDataAdapter<T>,
|
||||||
|
query: NavigationDataQuery,
|
||||||
|
deps: NavigationDataDependencies,
|
||||||
|
now: () => Date
|
||||||
|
): Promise<LoadedSource<T>> {
|
||||||
|
assertOfficialNavigationUrl(adapter.sourceUrl);
|
||||||
|
const queryHash = hashQuery(query);
|
||||||
|
const prefix = `navigation-data:v1:${adapter.id}:${queryHash}`;
|
||||||
|
const freshKey = `${prefix}:fresh`;
|
||||||
|
const staleKey = `${prefix}:last-good`;
|
||||||
|
const cached = await safeCacheGet<T>(deps.cache, freshKey);
|
||||||
|
if (cached) {
|
||||||
|
return sourceResult(adapter, cached.items, "cached", now, cached.cachedAt, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await runWithTimeout(
|
||||||
|
(signal) => adapter.load(query, { fetcher: officialOnlyFetcher(deps.fetcher), signal }),
|
||||||
|
deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||||
|
adapter.label
|
||||||
|
);
|
||||||
|
const entry: SourceCacheEntry<T> = {
|
||||||
|
cachedAt: now().toISOString(),
|
||||||
|
items: [...items]
|
||||||
|
};
|
||||||
|
await Promise.all([
|
||||||
|
safeCacheSet(deps.cache, freshKey, entry, adapter.freshTtlMs ?? DEFAULT_FRESH_TTL_MS),
|
||||||
|
safeCacheSet(deps.cache, staleKey, entry, adapter.staleTtlMs ?? DEFAULT_STALE_TTL_MS)
|
||||||
|
]);
|
||||||
|
return sourceResult(adapter, entry.items, "live", now, entry.cachedAt, null);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AdapterNotConfiguredError) {
|
||||||
|
return sourceResult(adapter, [], "not-configured", now, null, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale = await safeCacheGet<T>(deps.cache, staleKey);
|
||||||
|
const message = toErrorMessage(error);
|
||||||
|
if (stale) {
|
||||||
|
return sourceResult(
|
||||||
|
adapter,
|
||||||
|
stale.items,
|
||||||
|
"stale",
|
||||||
|
now,
|
||||||
|
stale.cachedAt,
|
||||||
|
`Live-Quelle nicht erreichbar; letzter erfolgreicher Stand wird verwendet. ${message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sourceResult(adapter, [], "unavailable", now, null, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceResult<T>(
|
||||||
|
adapter: NavigationDataAdapter<T>,
|
||||||
|
items: T[],
|
||||||
|
state: NavigationSourceState,
|
||||||
|
now: () => Date,
|
||||||
|
dataTimestamp: string | null,
|
||||||
|
warning: string | null
|
||||||
|
): LoadedSource<T> {
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
status: {
|
||||||
|
kind: adapter.kind,
|
||||||
|
id: adapter.id,
|
||||||
|
label: adapter.label,
|
||||||
|
sourceUrl: adapter.sourceUrl,
|
||||||
|
state,
|
||||||
|
checkedAt: now().toISOString(),
|
||||||
|
dataTimestamp,
|
||||||
|
warning
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function notConfiguredSource<T>(
|
||||||
|
kind: NavigationSourceKind,
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
sourceUrl: string,
|
||||||
|
now: () => Date,
|
||||||
|
warning: string
|
||||||
|
): LoadedSource<T> {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
status: {
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
sourceUrl,
|
||||||
|
state: "not-configured",
|
||||||
|
checkedAt: now().toISOString(),
|
||||||
|
dataTimestamp: null,
|
||||||
|
warning
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOfficialJson(fetcher: FetchLike, url: string, signal: AbortSignal): Promise<unknown> {
|
||||||
|
assertOfficialNavigationUrl(url);
|
||||||
|
const response = await fetcher(url, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Offizielle Navigationsdatenquelle antwortet mit HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function officialOnlyFetcher(fetcher: FetchLike): FetchLike {
|
||||||
|
return ((input: Parameters<FetchLike>[0], init?: Parameters<FetchLike>[1]) => {
|
||||||
|
const url = input instanceof Request ? input.url : String(input);
|
||||||
|
assertOfficialNavigationUrl(url);
|
||||||
|
return fetcher(input, init);
|
||||||
|
}) as FetchLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithTimeout<T>(
|
||||||
|
task: (signal: AbortSignal) => Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
label: string
|
||||||
|
): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
reject(new Error(`${label} hat das Zeitlimit von ${timeoutMs} ms überschritten.`));
|
||||||
|
}, Math.max(1, timeoutMs));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([task(controller.signal), timeoutPromise]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeCacheGet<T>(cache: Cache, key: string): Promise<SourceCacheEntry<T> | null> {
|
||||||
|
try {
|
||||||
|
const entry = await cache.get<SourceCacheEntry<T>>(key);
|
||||||
|
return entry && Array.isArray(entry.items) && typeof entry.cachedAt === "string" ? entry : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeCacheSet<T>(
|
||||||
|
cache: Cache,
|
||||||
|
key: string,
|
||||||
|
entry: SourceCacheEntry<T>,
|
||||||
|
ttlMs: number
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await cache.set(key, entry, ttlMs);
|
||||||
|
} catch {
|
||||||
|
// A cache outage must never hide otherwise usable navigation data.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQueryValues(values: string[] | undefined): string[] {
|
||||||
|
return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))].sort((a, b) =>
|
||||||
|
a.localeCompare(b, "de")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashQuery(query: NavigationDataQuery): string {
|
||||||
|
const normalized = JSON.stringify({
|
||||||
|
waterways: normalizeQueryValues(query.waterways),
|
||||||
|
stationIds: normalizeQueryValues(query.stationIds),
|
||||||
|
lockIds: normalizeQueryValues(query.lockIds)
|
||||||
|
});
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < normalized.length; index += 1) {
|
||||||
|
hash ^= normalized.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waterLevelState(value: unknown): WaterLevelState {
|
||||||
|
return value === "low" ||
|
||||||
|
value === "normal" ||
|
||||||
|
value === "high" ||
|
||||||
|
value === "commented" ||
|
||||||
|
value === "out-dated"
|
||||||
|
? value
|
||||||
|
: "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "Offizielle Navigationsdatenquelle ist derzeit nicht erreichbar.";
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdapterNotConfiguredError extends Error {}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { haversineDistanceM, type TideCurvePoint, type TideEvent, type TideSummary } from "@watermaps/shared";
|
||||||
|
import type { Cache } from "./cache.js";
|
||||||
|
import { fetchJson, type FetchLike } from "./http.js";
|
||||||
|
|
||||||
|
type BshFeatureCollection = {
|
||||||
|
features?: BshFeature[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BshFeature = {
|
||||||
|
geometry?: {
|
||||||
|
type: "Point";
|
||||||
|
coordinates: [number, number];
|
||||||
|
};
|
||||||
|
properties?: {
|
||||||
|
gauge_label?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
forecast_timestamp?: string;
|
||||||
|
automated_curveforecast_timestamp?: string;
|
||||||
|
high_water_low_water?: BshTideEvent[];
|
||||||
|
curve?: BshCurvePoint[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type BshTideEvent = {
|
||||||
|
event_timestamp?: string;
|
||||||
|
event?: "HW" | "NW" | string;
|
||||||
|
forecast_value?: number;
|
||||||
|
tidal_prediction_value?: string;
|
||||||
|
forecast_deviation?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BshCurvePoint = {
|
||||||
|
timestamp?: string;
|
||||||
|
tidal_prediction?: string;
|
||||||
|
measurement?: string;
|
||||||
|
forecast?: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 15 * 60 * 1000;
|
||||||
|
const BSH_URL =
|
||||||
|
"https://gdi.bsh.de/ldproxy/rest/services/WaterLevelForecast/collections/waterlevelforecastdata/items?f=json&limit=500";
|
||||||
|
|
||||||
|
export async function getNearestTideSummary(
|
||||||
|
params: { lat: number; lon: number; at?: string },
|
||||||
|
deps: { cache: Cache; fetcher: FetchLike }
|
||||||
|
): Promise<TideSummary | null> {
|
||||||
|
const data = await deps.cache.getOrSet("bsh:water-level-forecast:all", CACHE_TTL_MS, () =>
|
||||||
|
fetchJson<BshFeatureCollection>(deps.fetcher, BSH_URL, 12_000)
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestedTime = params.at ? new Date(params.at) : undefined;
|
||||||
|
return normalizeNearestTideSummary(
|
||||||
|
data,
|
||||||
|
params,
|
||||||
|
requestedTime && Number.isFinite(requestedTime.getTime()) ? requestedTime : new Date()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNearestTideSummary(
|
||||||
|
data: BshFeatureCollection,
|
||||||
|
params: { lat: number; lon: number },
|
||||||
|
now = new Date()
|
||||||
|
): TideSummary | null {
|
||||||
|
const features = data.features ?? [];
|
||||||
|
const nearest = features
|
||||||
|
.map((feature) => {
|
||||||
|
const coordinate = getFeatureCoordinate(feature);
|
||||||
|
if (!coordinate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
feature,
|
||||||
|
distanceKm: haversineDistanceM(params, coordinate) / 1000
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is { feature: BshFeature; distanceKm: number } => item !== null)
|
||||||
|
.sort((a, b) => a.distanceKm - b.distanceKm)[0];
|
||||||
|
|
||||||
|
if (!nearest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = nearest.feature.properties ?? {};
|
||||||
|
const events = (props.high_water_low_water ?? [])
|
||||||
|
.map(normalizeBshEvent)
|
||||||
|
.filter((event): event is TideEvent => event !== null)
|
||||||
|
.filter((event) => new Date(event.time).getTime() >= now.getTime());
|
||||||
|
|
||||||
|
return {
|
||||||
|
station: props.gauge_label ?? "Unbekannte BSH-Station",
|
||||||
|
distanceKm: round(nearest.distanceKm, 1),
|
||||||
|
nextHigh: events.find((event) => event.type === "high") ?? null,
|
||||||
|
nextLow: events.find((event) => event.type === "low") ?? null,
|
||||||
|
waterLevelCurve: normalizeCurve(props.curve ?? []),
|
||||||
|
source: "BSH WaterLevelForecast API, CC BY 4.0",
|
||||||
|
updatedAt: toIso(props.automated_curveforecast_timestamp ?? props.forecast_timestamp) ?? new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFeatureCoordinate(feature: BshFeature) {
|
||||||
|
if (feature.geometry?.coordinates) {
|
||||||
|
return { lon: feature.geometry.coordinates[0], lat: feature.geometry.coordinates[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = feature.properties?.latitude;
|
||||||
|
const lon = feature.properties?.longitude;
|
||||||
|
return typeof lat === "number" && typeof lon === "number" ? { lat, lon } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBshEvent(event: BshTideEvent): TideEvent | null {
|
||||||
|
const time = toIso(event.event_timestamp);
|
||||||
|
if (!time || (event.event !== "HW" && event.event !== "NW")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forecastM = cmToM(event.forecast_value);
|
||||||
|
const predictedM = cmStringToM(event.tidal_prediction_value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: event.event === "HW" ? "high" : "low",
|
||||||
|
time,
|
||||||
|
heightM: forecastM ?? predictedM,
|
||||||
|
deviationM: parseDeviationM(event.forecast_deviation)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCurve(curve: BshCurvePoint[]): TideCurvePoint[] {
|
||||||
|
const stride = Math.max(1, Math.ceil(curve.length / 96));
|
||||||
|
|
||||||
|
return curve
|
||||||
|
.filter((_, index) => index % stride === 0)
|
||||||
|
.map((point) => ({
|
||||||
|
time: toIso(point.timestamp) ?? new Date().toISOString(),
|
||||||
|
predictedM: cmStringToM(point.tidal_prediction),
|
||||||
|
measuredM: cmStringToM(point.measurement),
|
||||||
|
forecastM:
|
||||||
|
typeof point.forecast === "number" ? cmToM(point.forecast) : cmStringToM(point.forecast)
|
||||||
|
}))
|
||||||
|
.filter((point) => point.predictedM !== null || point.measuredM !== null || point.forecastM !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(value?: string): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.replace(" ", "T");
|
||||||
|
const timestamp = new Date(normalized).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmToM(value?: number): number | null {
|
||||||
|
return typeof value === "number" ? round(value / 100, 2) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmStringToM(value?: string | number): number | null {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return cmToM(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number.parseFloat(value.replace(",", "."));
|
||||||
|
return Number.isFinite(numeric) ? round(numeric / 100, 2) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeviationM(value?: string): number | null {
|
||||||
|
if (!value || value.includes("+/-")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number.parseFloat(value.replace(",", ".").replace("m", "").trim());
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value: number, digits: number): number {
|
||||||
|
const factor = 10 ** digits;
|
||||||
|
return Math.round(value * factor) / factor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import type { MarineForecast } from "@watermaps/shared";
|
||||||
|
import type { Cache } from "./cache.js";
|
||||||
|
import { fetchJson, type FetchLike } from "./http.js";
|
||||||
|
|
||||||
|
type OpenMeteoMarineResponse = {
|
||||||
|
current?: {
|
||||||
|
time?: string;
|
||||||
|
wave_height?: number;
|
||||||
|
wave_direction?: number;
|
||||||
|
wave_period?: number;
|
||||||
|
ocean_current_velocity?: number;
|
||||||
|
ocean_current_direction?: number;
|
||||||
|
sea_level_height_msl?: number;
|
||||||
|
};
|
||||||
|
hourly?: {
|
||||||
|
time?: string[];
|
||||||
|
wave_height?: Array<number | null>;
|
||||||
|
wave_direction?: Array<number | null>;
|
||||||
|
wave_period?: Array<number | null>;
|
||||||
|
ocean_current_velocity?: Array<number | null>;
|
||||||
|
ocean_current_direction?: Array<number | null>;
|
||||||
|
sea_level_height_msl?: Array<number | null>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenMeteoWeatherResponse = {
|
||||||
|
current?: {
|
||||||
|
time?: string;
|
||||||
|
wind_speed_10m?: number;
|
||||||
|
wind_direction_10m?: number;
|
||||||
|
weather_code?: number;
|
||||||
|
temperature_2m?: number;
|
||||||
|
};
|
||||||
|
hourly?: {
|
||||||
|
time?: string[];
|
||||||
|
wind_speed_10m?: Array<number | null>;
|
||||||
|
wind_direction_10m?: Array<number | null>;
|
||||||
|
weather_code?: Array<number | null>;
|
||||||
|
temperature_2m?: Array<number | null>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function getMarineForecast(
|
||||||
|
params: { lat: number; lon: number; at?: string },
|
||||||
|
deps: { cache: Cache; fetcher: FetchLike }
|
||||||
|
): Promise<MarineForecast> {
|
||||||
|
const requestedTimestamp = params.at ? Date.parse(params.at) : Number.NaN;
|
||||||
|
const requestedHour = Number.isFinite(requestedTimestamp)
|
||||||
|
? new Date(requestedTimestamp).toISOString().slice(0, 13)
|
||||||
|
: "current";
|
||||||
|
const cacheKey = `marine:${params.lat.toFixed(3)}:${params.lon.toFixed(3)}:${requestedHour}`;
|
||||||
|
|
||||||
|
return deps.cache.getOrSet(cacheKey, CACHE_TTL_MS, async () => {
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
latitude: String(params.lat),
|
||||||
|
longitude: String(params.lon),
|
||||||
|
current:
|
||||||
|
"wave_height,wave_direction,wave_period,ocean_current_velocity,ocean_current_direction,sea_level_height_msl",
|
||||||
|
hourly:
|
||||||
|
"wave_height,wave_direction,wave_period,ocean_current_velocity,ocean_current_direction,sea_level_height_msl",
|
||||||
|
forecast_days: "8",
|
||||||
|
timezone: "GMT",
|
||||||
|
wind_speed_unit: "kn",
|
||||||
|
cell_selection: "sea"
|
||||||
|
});
|
||||||
|
const weatherSearch = new URLSearchParams({
|
||||||
|
latitude: String(params.lat),
|
||||||
|
longitude: String(params.lon),
|
||||||
|
current: "wind_speed_10m,wind_direction_10m,weather_code,temperature_2m",
|
||||||
|
hourly: "wind_speed_10m,wind_direction_10m,weather_code,temperature_2m",
|
||||||
|
forecast_days: "8",
|
||||||
|
wind_speed_unit: "kn",
|
||||||
|
timezone: "GMT"
|
||||||
|
});
|
||||||
|
|
||||||
|
const [marineResult, weatherResult] = await Promise.allSettled([
|
||||||
|
fetchJson<OpenMeteoMarineResponse>(
|
||||||
|
deps.fetcher,
|
||||||
|
`https://marine-api.open-meteo.com/v1/marine?${search.toString()}`,
|
||||||
|
12_000
|
||||||
|
),
|
||||||
|
fetchJson<OpenMeteoWeatherResponse>(
|
||||||
|
deps.fetcher,
|
||||||
|
`https://api.open-meteo.com/v1/forecast?${weatherSearch.toString()}`,
|
||||||
|
12_000
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return normalizeMarineForecast(
|
||||||
|
marineResult.status === "fulfilled" ? marineResult.value : {},
|
||||||
|
weatherResult.status === "fulfilled" ? weatherResult.value : {},
|
||||||
|
{
|
||||||
|
marineAvailable: marineResult.status === "fulfilled",
|
||||||
|
weatherAvailable: weatherResult.status === "fulfilled"
|
||||||
|
},
|
||||||
|
Number.isFinite(requestedTimestamp) ? new Date(requestedTimestamp).toISOString() : undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMarineForecast(
|
||||||
|
marine: OpenMeteoMarineResponse,
|
||||||
|
weather: OpenMeteoWeatherResponse,
|
||||||
|
availability = { marineAvailable: true, weatherAvailable: true },
|
||||||
|
requestedTime?: string
|
||||||
|
): MarineForecast {
|
||||||
|
const first = <T>(values: Array<T | null | undefined> | undefined): T | null => {
|
||||||
|
const value = values?.find((candidate) => candidate !== null && candidate !== undefined);
|
||||||
|
return value ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nearestMarineIndex = requestedTime ? nearestTimeIndex(marine.hourly?.time, requestedTime) : -1;
|
||||||
|
const nearestWeatherIndex = requestedTime ? nearestTimeIndex(weather.hourly?.time, requestedTime) : -1;
|
||||||
|
const marineIndex = forecastIndexWithinWindow(marine.hourly?.time, nearestMarineIndex, requestedTime);
|
||||||
|
const weatherIndex = forecastIndexWithinWindow(weather.hourly?.time, nearestWeatherIndex, requestedTime);
|
||||||
|
const marineAt = <T>(values?: Array<T | null>) => valueAt(values, marineIndex);
|
||||||
|
const weatherAt = <T>(values?: Array<T | null>) => valueAt(values, weatherIndex);
|
||||||
|
const forecastTime =
|
||||||
|
(marineIndex >= 0 ? marine.hourly?.time?.[marineIndex] : undefined) ??
|
||||||
|
(weatherIndex >= 0 ? weather.hourly?.time?.[weatherIndex] : undefined) ??
|
||||||
|
requestedTime ??
|
||||||
|
new Date().toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
waveHeightM: requestedTime ? marineAt(marine.hourly?.wave_height) : marine.current?.wave_height ?? first(marine.hourly?.wave_height),
|
||||||
|
waveDirectionDeg: requestedTime
|
||||||
|
? marineAt(marine.hourly?.wave_direction)
|
||||||
|
: marine.current?.wave_direction ?? first(marine.hourly?.wave_direction),
|
||||||
|
wavePeriodS: requestedTime ? marineAt(marine.hourly?.wave_period) : marine.current?.wave_period ?? first(marine.hourly?.wave_period),
|
||||||
|
windSpeed: requestedTime ? weatherAt(weather.hourly?.wind_speed_10m) : weather.current?.wind_speed_10m ?? null,
|
||||||
|
windDirectionDeg: requestedTime
|
||||||
|
? weatherAt(weather.hourly?.wind_direction_10m)
|
||||||
|
: weather.current?.wind_direction_10m ?? null,
|
||||||
|
weatherCode: requestedTime ? weatherAt(weather.hourly?.weather_code) : weather.current?.weather_code ?? null,
|
||||||
|
temperatureC: requestedTime ? weatherAt(weather.hourly?.temperature_2m) : weather.current?.temperature_2m ?? null,
|
||||||
|
oceanCurrentSpeedKn: requestedTime
|
||||||
|
? marineAt(marine.hourly?.ocean_current_velocity)
|
||||||
|
: marine.current?.ocean_current_velocity ?? null,
|
||||||
|
oceanCurrentDirectionDeg: requestedTime
|
||||||
|
? marineAt(marine.hourly?.ocean_current_direction)
|
||||||
|
: marine.current?.ocean_current_direction ?? null,
|
||||||
|
seaLevelHeightMslM: requestedTime
|
||||||
|
? marineAt(marine.hourly?.sea_level_height_msl)
|
||||||
|
: marine.current?.sea_level_height_msl ?? null,
|
||||||
|
forecastTime: normalizeForecastTime(forecastTime),
|
||||||
|
source: sourceLabel(availability),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestTimeIndex(values: string[] | undefined, requestedTime: string): number {
|
||||||
|
const requestedTimestamp = Date.parse(requestedTime);
|
||||||
|
if (!values?.length || !Number.isFinite(requestedTimestamp)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearestIndex = -1;
|
||||||
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
const timestamp = parseForecastTimestamp(value);
|
||||||
|
const distance = Math.abs(timestamp - requestedTimestamp);
|
||||||
|
if (Number.isFinite(distance) && distance < nearestDistance) {
|
||||||
|
nearestIndex = index;
|
||||||
|
nearestDistance = distance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nearestIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueAt<T>(values: Array<T | null> | undefined, index: number): T | null {
|
||||||
|
if (index < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return values?.[index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forecastIndexWithinWindow(values: string[] | undefined, index: number, requestedTime?: string): number {
|
||||||
|
if (!requestedTime || index < 0 || !values?.[index]) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const forecastTimestamp = parseForecastTimestamp(values[index]);
|
||||||
|
const requestedTimestamp = Date.parse(requestedTime);
|
||||||
|
return Number.isFinite(forecastTimestamp) &&
|
||||||
|
Number.isFinite(requestedTimestamp) &&
|
||||||
|
Math.abs(forecastTimestamp - requestedTimestamp) <= 2 * 60 * 60 * 1000
|
||||||
|
? index
|
||||||
|
: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForecastTime(value: string): string {
|
||||||
|
const timestamp = parseForecastTimestamp(value);
|
||||||
|
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseForecastTimestamp(value: string): number {
|
||||||
|
const hasExplicitZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
|
||||||
|
return Date.parse(hasExplicitZone ? value : `${value}Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(availability: { marineAvailable: boolean; weatherAvailable: boolean }) {
|
||||||
|
if (availability.marineAvailable && availability.weatherAvailable) {
|
||||||
|
return "Open-Meteo Marine + Forecast";
|
||||||
|
}
|
||||||
|
if (availability.marineAvailable) {
|
||||||
|
return "Open-Meteo Marine";
|
||||||
|
}
|
||||||
|
if (availability.weatherAvailable) {
|
||||||
|
return "Open-Meteo Forecast";
|
||||||
|
}
|
||||||
|
return "Open-Meteo nicht erreichbar";
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { buildServer } from "../src/app.js";
|
||||||
|
import { createCache } from "../src/services/cache.js";
|
||||||
|
import type {
|
||||||
|
LockOperationInfo,
|
||||||
|
NavigationDataAdapter
|
||||||
|
} from "../src/services/navigation-data.js";
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" }
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Watermaps API", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns app config with map layers", async () => {
|
||||||
|
const app = await buildServer({ cache: createCache() });
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/config" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().layers).toHaveLength(3);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes marine weather responses", async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
current: {
|
||||||
|
wave_height: 0.8,
|
||||||
|
wave_direction: 280,
|
||||||
|
wave_period: 4.2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
current: {
|
||||||
|
wind_speed_10m: 11,
|
||||||
|
wind_direction_10m: 245,
|
||||||
|
weather_code: 3,
|
||||||
|
temperature_2m: 19
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const app = await buildServer({ cache: createCache(), fetcher: fetcher as unknown as typeof fetch });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/weather/marine?lat=54.18&lon=12.08"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
waveHeightM: 0.8,
|
||||||
|
windSpeed: 11
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns partial marine weather when one provider request fails", async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
current: {
|
||||||
|
wave_height: 1.1,
|
||||||
|
wave_direction: 290,
|
||||||
|
wave_period: 5.3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockRejectedValueOnce(new Error("timeout"));
|
||||||
|
const app = await buildServer({ cache: createCache(), fetcher: fetcher as unknown as typeof fetch });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/weather/marine?lat=53.5&lon=7.1"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
waveHeightM: 1.1,
|
||||||
|
windSpeed: null,
|
||||||
|
source: "Open-Meteo Marine"
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves injected live navigation adapters and forwards bounded route filters", async () => {
|
||||||
|
const lock: LockOperationInfo = {
|
||||||
|
id: "lock-1",
|
||||||
|
name: "Schleuse Hamm",
|
||||||
|
waterway: "DHK",
|
||||||
|
regularHours: "06:00-22:00",
|
||||||
|
operatingState: "restricted",
|
||||||
|
validFrom: "2026-07-20T04:00:00.000Z",
|
||||||
|
validTo: "2026-07-20T20:00:00.000Z",
|
||||||
|
phone: "+49 2381 1234",
|
||||||
|
vhf: "Kanal 20",
|
||||||
|
note: "Anmeldung erforderlich",
|
||||||
|
updatedAt: "2026-07-19T12:00:00.000Z",
|
||||||
|
sourceUrl: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/"
|
||||||
|
};
|
||||||
|
const load = vi.fn(async () => [lock]);
|
||||||
|
const lockAdapter: NavigationDataAdapter<LockOperationInfo> = {
|
||||||
|
kind: "lock-operations",
|
||||||
|
id: "test-elwis-locks",
|
||||||
|
label: "Test ELWIS locks",
|
||||||
|
sourceUrl: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/",
|
||||||
|
load
|
||||||
|
};
|
||||||
|
const app = await buildServer({
|
||||||
|
cache: createCache(),
|
||||||
|
navigationAdapters: {
|
||||||
|
waterLevels: null,
|
||||||
|
lockOperations: lockAdapter,
|
||||||
|
notices: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/navigation/live?waterways=EMS,DHK,EMS&lockIds=lock-1,lock-2"
|
||||||
|
});
|
||||||
|
const body = response.json();
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(load).toHaveBeenCalledWith(
|
||||||
|
{ waterways: ["EMS", "DHK"], lockIds: ["lock-1", "lock-2"] },
|
||||||
|
expect.objectContaining({ signal: expect.any(AbortSignal), fetcher: expect.any(Function) })
|
||||||
|
);
|
||||||
|
expect(body.lockOperations).toEqual([lock]);
|
||||||
|
expect(body.sources).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "lock-operations", id: "test-elwis-locks", state: "live" }),
|
||||||
|
expect.objectContaining({ kind: "water-levels", state: "not-configured" }),
|
||||||
|
expect.objectContaining({ kind: "notices", state: "not-configured" })
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(body.generatedAt).toEqual(expect.any(String));
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns critical route warnings for shallow samples", async () => {
|
||||||
|
const app = await buildServer({ cache: createCache() });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start: { lat: 53.344167, lon: 7.186111 },
|
||||||
|
destination: { lat: 53.563776, lon: 6.750562 },
|
||||||
|
vesselProfile: { draughtM: 1.5, safetyReserveM: 0.4 },
|
||||||
|
depthSamples: [{ coordinate: { lat: 53.442996, lon: 6.833146 }, depthM: 1.6 }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().warnings.some((warning: { severity: string }) => warning.severity === "critical")).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects routes without a known fairway instead of returning a straight line", async () => {
|
||||||
|
const app = await buildServer({ cache: createCache() });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start: { lat: 54.1749, lon: 12.0731 },
|
||||||
|
destination: { lat: 54.1833, lon: 12.0928 },
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const body = response.json();
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(422);
|
||||||
|
expect(body.error).toBe("no_fairway_route");
|
||||||
|
expect(body.message).toContain("Keine Fahrwasserroute");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
|
||||||
|
const app = await buildServer({ cache: createCache() });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start: { lat: 53.344167, lon: 7.186111 },
|
||||||
|
destination: { lat: 53.563776, lon: 6.750562 },
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const body = response.json();
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(body.routingMode).toBe("fairway");
|
||||||
|
expect(body.dataSources).toContain("fairway-graph:ems-borkum-seed");
|
||||||
|
expect(body.geometry.coordinates.length).toBeGreaterThan(20);
|
||||||
|
expect(body.warnings.some((warning: { code: string }) => warning.code === "FAIRWAY_ROUTE")).toBe(true);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the inland fallback route from Emden to Hamm", async () => {
|
||||||
|
const app = await buildServer({ cache: createCache() });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start: { lat: 53.344167, lon: 7.186111 },
|
||||||
|
destination: { lat: 51.6814536, lon: 7.8042615 },
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const body = response.json();
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(body.distanceNm).toBeGreaterThan(145);
|
||||||
|
expect(body.distanceNm).toBeLessThan(165);
|
||||||
|
expect(body.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
|
||||||
|
expect(body.geometry.coordinates.at(-1)).toEqual([7.8042615, 51.6814536]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an extracted fairway graph before the seed graph", async () => {
|
||||||
|
const app = await buildServer({
|
||||||
|
cache: createCache(),
|
||||||
|
fairwayService: {
|
||||||
|
async getGraphsForRoute() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "test-extracted",
|
||||||
|
name: "Test Extracted Fairways",
|
||||||
|
maxSnapDistanceNm: 1,
|
||||||
|
nodes: [
|
||||||
|
{ id: "a", coordinate: { lat: 54, lon: 10 } },
|
||||||
|
{ id: "b", coordinate: { lat: 54.02, lon: 10.05 } },
|
||||||
|
{ id: "c", coordinate: { lat: 54.04, lon: 10.1 } }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: "ab",
|
||||||
|
name: "AB",
|
||||||
|
from: "a",
|
||||||
|
to: "b",
|
||||||
|
minDepthM: 4,
|
||||||
|
source: "test-overpass",
|
||||||
|
coordinates: [
|
||||||
|
{ lat: 54, lon: 10 },
|
||||||
|
{ lat: 54.02, lon: 10.05 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bc",
|
||||||
|
name: "BC",
|
||||||
|
from: "b",
|
||||||
|
to: "c",
|
||||||
|
minDepthM: 4,
|
||||||
|
source: "test-overpass",
|
||||||
|
coordinates: [
|
||||||
|
{ lat: 54.02, lon: 10.05 },
|
||||||
|
{ lat: 54.04, lon: 10.1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start: { lat: 54, lon: 10 },
|
||||||
|
destination: { lat: 54.04, lon: 10.1 },
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const body = response.json();
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(body.routingMode).toBe("fairway");
|
||||||
|
expect(body.dataSources).toContain("fairway-graph:test-extracted");
|
||||||
|
expect(body.dataSources).toContain("test-overpass");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns distinct route alternatives when the waterway graph contains them", async () => {
|
||||||
|
const coordinate = (lat: number, lon: number) => ({ lat, lon });
|
||||||
|
const edge = (id: string, from: string, to: string, coordinates: Array<{ lat: number; lon: number }>) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
coordinates,
|
||||||
|
minDepthM: 4,
|
||||||
|
source: "test-alternatives"
|
||||||
|
});
|
||||||
|
const app = await buildServer({
|
||||||
|
cache: createCache(),
|
||||||
|
fairwayService: {
|
||||||
|
async getGraphsForRoute() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "api-alternatives",
|
||||||
|
name: "API Alternativen",
|
||||||
|
maxSnapDistanceNm: 0.2,
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", coordinate: coordinate(52, 7) },
|
||||||
|
{ id: "branch-in", coordinate: coordinate(52, 7.01) },
|
||||||
|
{ id: "upper", coordinate: coordinate(52.012, 7.03) },
|
||||||
|
{ id: "lower", coordinate: coordinate(51.988, 7.03) },
|
||||||
|
{ id: "branch-out", coordinate: coordinate(52, 7.05) },
|
||||||
|
{ id: "destination", coordinate: coordinate(52, 7.06) }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge("start-access", "start", "branch-in", [coordinate(52, 7), coordinate(52, 7.01)]),
|
||||||
|
edge("main", "branch-in", "branch-out", [coordinate(52, 7.01), coordinate(52, 7.05)]),
|
||||||
|
edge("upper-in", "branch-in", "upper", [coordinate(52, 7.01), coordinate(52.012, 7.03)]),
|
||||||
|
edge("upper-out", "upper", "branch-out", [coordinate(52.012, 7.03), coordinate(52, 7.05)]),
|
||||||
|
edge("lower-in", "branch-in", "lower", [coordinate(52, 7.01), coordinate(51.988, 7.03)]),
|
||||||
|
edge("lower-out", "lower", "branch-out", [coordinate(51.988, 7.03), coordinate(52, 7.05)]),
|
||||||
|
edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start: coordinate(52, 7),
|
||||||
|
destination: coordinate(52, 7.06),
|
||||||
|
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, cruiseSpeedKn: 6 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const body = response.json();
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(body.name).toBe("Hauptroute");
|
||||||
|
expect(body.alternatives).toHaveLength(2);
|
||||||
|
expect(body.alternatives.map((route: { name: string }) => route.name)).toEqual(["Alternative 1", "Alternative 2"]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
MAX_DETAIL_LIMIT,
|
||||||
|
buildLockRecord,
|
||||||
|
collectEurisLocks,
|
||||||
|
compactLocksFilter,
|
||||||
|
normalizePhones,
|
||||||
|
parseCountries,
|
||||||
|
parseDetailLimit,
|
||||||
|
requestJson,
|
||||||
|
risIndexFilter,
|
||||||
|
runEurisSync,
|
||||||
|
} from "../../../scripts/sync-euris-locks.mjs";
|
||||||
|
|
||||||
|
function jsonResponse(payload, init = {}) {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginatedFixtureFetch({ recordRequests = [] } = {}) {
|
||||||
|
const compact = [
|
||||||
|
{
|
||||||
|
locode: "DELOCK001",
|
||||||
|
objectName: "Testschleuse Nord",
|
||||||
|
waterwayName: "Testkanal",
|
||||||
|
contactPhone: "0049 201 12345",
|
||||||
|
comcha: " 18 ",
|
||||||
|
},
|
||||||
|
{ locode: "DELOCK002", objectName: "Testschleuse Süd" },
|
||||||
|
];
|
||||||
|
const ris = [
|
||||||
|
{
|
||||||
|
isrs: "DELOCK001",
|
||||||
|
objectName: "Testschleuse Nord",
|
||||||
|
countryCode: "DE",
|
||||||
|
lon: 7.1,
|
||||||
|
lat: 51.5,
|
||||||
|
source: "WSV, Wadaba",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isrs: "DELOCK002",
|
||||||
|
objectName: "Testschleuse Süd",
|
||||||
|
countryCode: "DE",
|
||||||
|
lon: 7.2,
|
||||||
|
lat: 51.6,
|
||||||
|
source: "WSV, Wadaba",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isrs: "DELOCK003",
|
||||||
|
objectName: "Nur im RIS-Index",
|
||||||
|
countryCode: "DE",
|
||||||
|
lon: 7.3,
|
||||||
|
lat: 51.7,
|
||||||
|
source: "WSV",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return async (input, init) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
recordRequests.push({ url, init });
|
||||||
|
const skip = Number(url.searchParams.get("$skip"));
|
||||||
|
const top = Number(url.searchParams.get("$top"));
|
||||||
|
|
||||||
|
if (url.pathname.endsWith("/GetCompactLocks")) {
|
||||||
|
return jsonResponse({ count: compact.length, items: compact.slice(skip, skip + top) });
|
||||||
|
}
|
||||||
|
if (url.pathname.endsWith("/GetRISIndexObjects")) {
|
||||||
|
return jsonResponse({ count: ris.length, items: ris.slice(skip, skip + top) });
|
||||||
|
}
|
||||||
|
throw new Error(`Unerwartete Test-URL: ${url}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EuRIS lock synchronization", () => {
|
||||||
|
it("validates country filters and caps optional detail requests", () => {
|
||||||
|
expect(parseCountries(" de,NL de ")).toEqual(["DE", "NL"]);
|
||||||
|
expect(() => parseCountries("DEU")).toThrow(/ISO-Ländercode/u);
|
||||||
|
expect(parseDetailLimit("999")).toBe(MAX_DETAIL_LIMIT);
|
||||||
|
expect(compactLocksFilter(["DE", "NL"])).toContain("startswith(locode,'DE')");
|
||||||
|
expect(risIndexFilter(["DE", "NL"])).toBe(
|
||||||
|
"(countryCode eq 'DE' or countryCode eq 'NL') and function eq 'lokare'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes EuRIS contact data and always uses the RIS coordinate", () => {
|
||||||
|
expect(normalizePhones("0049 201 12345; +49 201 67890")).toEqual([
|
||||||
|
"+49 201 12345",
|
||||||
|
"+49 201 67890",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const record = buildLockRecord({
|
||||||
|
compact: {
|
||||||
|
locode: "DELOCK001",
|
||||||
|
objectName: "Lock",
|
||||||
|
waterwayName: "Canal",
|
||||||
|
contactPhone: "0049 201 12345",
|
||||||
|
comcha: " 18 ",
|
||||||
|
},
|
||||||
|
ris: {
|
||||||
|
isrs: "DELOCK001",
|
||||||
|
lon: "7.123",
|
||||||
|
lat: "51.456",
|
||||||
|
source: "WSV, Wadaba",
|
||||||
|
countryCode: "DE",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
facility: {
|
||||||
|
street: "Uferstraße 1",
|
||||||
|
postCode: "12345",
|
||||||
|
city: "Teststadt",
|
||||||
|
country: "DE",
|
||||||
|
contacts: [
|
||||||
|
{
|
||||||
|
company: "Wasserstraßenverwaltung",
|
||||||
|
emails: ["lock@example.test"],
|
||||||
|
phones: ["+49 201 999"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetchedAt: "2026-07-20T10:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
sourceId: "DELOCK001",
|
||||||
|
longitude: 7.123,
|
||||||
|
latitude: 51.456,
|
||||||
|
properties: {
|
||||||
|
phone: "+49 201 12345",
|
||||||
|
vhf: "18",
|
||||||
|
waterway_name: "Canal",
|
||||||
|
operator: "Wasserstraßenverwaltung",
|
||||||
|
email: "lock@example.test",
|
||||||
|
address: "Uferstraße 1, 12345, Teststadt, DE",
|
||||||
|
upstream_source: "WSV, Wadaba",
|
||||||
|
fetched_at: "2026-07-20T10:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(record.properties.source_url).toContain("isrs=DELOCK001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors Retry-After for throttled requests and sends an optional bearer token", async () => {
|
||||||
|
const waits = [];
|
||||||
|
const headers = [];
|
||||||
|
let calls = 0;
|
||||||
|
const fetchImpl = async (_url, init) => {
|
||||||
|
calls += 1;
|
||||||
|
headers.push(new Headers(init.headers));
|
||||||
|
if (calls === 1) {
|
||||||
|
return new Response("rate limited", {
|
||||||
|
status: 429,
|
||||||
|
headers: { "retry-after": "2" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestJson("https://example.test/euris", {
|
||||||
|
fetchImpl,
|
||||||
|
token: "secret-token",
|
||||||
|
sleepImpl: async (milliseconds) => waits.push(milliseconds),
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
expect(waits).toEqual([2_000]);
|
||||||
|
expect(headers.every((entry) => entry.get("authorization") === "Bearer secret-token")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paginates compact and RIS data stably, joins by ISRS and keeps RIS-only locks", async () => {
|
||||||
|
const requests = [];
|
||||||
|
const result = await collectEurisLocks({
|
||||||
|
countries: ["DE"],
|
||||||
|
detailLimit: 0,
|
||||||
|
pageSize: 1,
|
||||||
|
fetchImpl: paginatedFixtureFetch({ recordRequests: requests }),
|
||||||
|
token: "token",
|
||||||
|
fetchedAt: "2026-07-20T10:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.records.map((record) => record.sourceId)).toEqual([
|
||||||
|
"DELOCK001",
|
||||||
|
"DELOCK002",
|
||||||
|
"DELOCK003",
|
||||||
|
]);
|
||||||
|
expect(result.stats).toMatchObject({
|
||||||
|
compactLocks: 2,
|
||||||
|
risLocks: 3,
|
||||||
|
joinedLocks: 2,
|
||||||
|
risOnlyLocks: 1,
|
||||||
|
storedLocks: 3,
|
||||||
|
});
|
||||||
|
expect(requests.every(({ url }) => Number(url.searchParams.get("$top")) <= 100)).toBe(true);
|
||||||
|
expect(requests.every(({ url }) => url.searchParams.has("$orderby"))).toBe(true);
|
||||||
|
expect(
|
||||||
|
requests.every(({ init }) => new Headers(init.headers).get("authorization") === "Bearer token"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invoke the database writer in dry-run mode", async () => {
|
||||||
|
const writer = vi.fn();
|
||||||
|
const result = await runEurisSync({
|
||||||
|
dryRun: true,
|
||||||
|
writer,
|
||||||
|
collectorOptions: {
|
||||||
|
countries: ["DE"],
|
||||||
|
pageSize: 100,
|
||||||
|
fetchImpl: paginatedFixtureFetch(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.records).toHaveLength(3);
|
||||||
|
expect(writer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildRoute } from "@watermaps/shared";
|
||||||
|
import {
|
||||||
|
fairwayRowsToGraph,
|
||||||
|
mergeConnectedFairwayGraphs,
|
||||||
|
overpassToGraph
|
||||||
|
} from "../src/services/fairways.js";
|
||||||
|
|
||||||
|
describe("fairway graph extraction", () => {
|
||||||
|
it("builds a routable graph from PostGIS fairway rows", () => {
|
||||||
|
const graph = fairwayRowsToGraph(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
source: "osm",
|
||||||
|
source_id: "way-1",
|
||||||
|
name: "Harbour Reach",
|
||||||
|
min_depth_m: "4.2",
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[10, 54],
|
||||||
|
[10.04, 54.02]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
source: "osm",
|
||||||
|
source_id: "way-2",
|
||||||
|
name: "Outer Reach",
|
||||||
|
min_depth_m: 4.2,
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[10.04, 54.02],
|
||||||
|
[10.1, 54.04]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[9.9, 53.9, 10.2, 54.1]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(graph).not.toBeNull();
|
||||||
|
const route = buildRoute(
|
||||||
|
{
|
||||||
|
start: { lat: 54, lon: 10 },
|
||||||
|
destination: { lat: 54.04, lon: 10.1 },
|
||||||
|
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.4 }
|
||||||
|
},
|
||||||
|
graph ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route?.routingMode).toBe("fairway");
|
||||||
|
expect(route?.dataSources).toContain("fairway-graph:postgis-9.900-53.900-10.200-54.100");
|
||||||
|
expect(route?.dataSources).toContain("postgis-osm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes the iPhone Emden coordinates through intermediate fairway vertices", () => {
|
||||||
|
const start = { lat: 53.3306, lon: 7.1752 };
|
||||||
|
const destination = { lat: 53.6741, lon: 7.1474 };
|
||||||
|
const graph = fairwayRowsToGraph(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "emden-main-reach",
|
||||||
|
source: "osm",
|
||||||
|
source_id: "way-main",
|
||||||
|
name: "Ems Fahrwasser",
|
||||||
|
min_depth_m: null,
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[7.1751368, 53.3331995],
|
||||||
|
[7.16, 53.42],
|
||||||
|
[7.1474, 53.55],
|
||||||
|
[7.18, 53.61]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "busetief-branch",
|
||||||
|
source: "osm",
|
||||||
|
source_id: "way-branch",
|
||||||
|
name: "Busetief",
|
||||||
|
min_depth_m: null,
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[7.1474, 53.55],
|
||||||
|
[7.1414995, 53.6668156]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[6.9974, 53.1806, 7.3252, 53.8241]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(graph).not.toBeNull();
|
||||||
|
const route = buildRoute(
|
||||||
|
{
|
||||||
|
start,
|
||||||
|
destination,
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||||
|
},
|
||||||
|
graph ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route).not.toBeNull();
|
||||||
|
expect(route?.routingMode).toBe("fairway");
|
||||||
|
expect(route?.dataSources).toContain("postgis-osm");
|
||||||
|
expect(route?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
|
||||||
|
expect(route?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]);
|
||||||
|
expect(route?.geometry.coordinates.some(([lon, lat]) => lon === 7.1474 && lat === 53.55)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a graph from OSM/OpenSeaMap Overpass ways", () => {
|
||||||
|
const graph = overpassToGraph(
|
||||||
|
{
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "way",
|
||||||
|
id: 123,
|
||||||
|
tags: {
|
||||||
|
"seamark:type": "navigation_line",
|
||||||
|
"seamark:navigation_line:minimum_depth": "3.5"
|
||||||
|
},
|
||||||
|
geometry: [
|
||||||
|
{ lat: 54, lon: 10 },
|
||||||
|
{ lat: 54.02, lon: 10.05 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[9.9, 53.9, 10.1, 54.1]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(graph).not.toBeNull();
|
||||||
|
expect(graph?.edges[0]?.source).toBe("osm-overpass-seamarks");
|
||||||
|
expect(graph?.edges[0]?.minDepthM).toBe(3.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts navigable canals but rejects explicitly closed waterways", () => {
|
||||||
|
const graph = overpassToGraph(
|
||||||
|
{
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "way",
|
||||||
|
id: 201,
|
||||||
|
tags: { waterway: "canal", boat: "yes", name: "Datteln-Hamm-Kanal" },
|
||||||
|
geometry: [
|
||||||
|
{ lat: 51.65, lon: 7.35 },
|
||||||
|
{ lat: 51.66, lon: 7.4 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "way",
|
||||||
|
id: 202,
|
||||||
|
tags: { waterway: "canal", boat: "no", name: "Gesperrter Kanal" },
|
||||||
|
geometry: [
|
||||||
|
{ lat: 51.66, lon: 7.4 },
|
||||||
|
{ lat: 51.67, lon: 7.45 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[7.3, 51.6, 7.5, 51.7]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(graph).not.toBeNull();
|
||||||
|
expect(graph?.edges).toHaveLength(1);
|
||||||
|
expect(graph?.edges[0]?.name).toBe("Datteln-Hamm-Kanal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("topologically joins graph fragments from PostGIS and live data", () => {
|
||||||
|
const first = fairwayRowsToGraph(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "north",
|
||||||
|
source: "osm",
|
||||||
|
source_id: "north",
|
||||||
|
name: "Dortmund-Ems-Kanal",
|
||||||
|
min_depth_m: null,
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[7.3, 52.1],
|
||||||
|
[7.35, 52]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[7.2, 51.8, 7.6, 52.2]
|
||||||
|
);
|
||||||
|
const second = overpassToGraph(
|
||||||
|
{
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "way",
|
||||||
|
id: 301,
|
||||||
|
tags: { waterway: "canal", boat: "yes", name: "Datteln-Hamm-Kanal" },
|
||||||
|
geometry: [
|
||||||
|
{ lat: 52, lon: 7.35 },
|
||||||
|
{ lat: 51.9, lon: 7.5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[7.2, 51.8, 7.6, 52.2]
|
||||||
|
);
|
||||||
|
const graph = mergeConnectedFairwayGraphs([first!, second!]);
|
||||||
|
const route = buildRoute(
|
||||||
|
{
|
||||||
|
start: { lat: 52.1, lon: 7.3 },
|
||||||
|
destination: { lat: 51.9, lon: 7.5 },
|
||||||
|
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3 }
|
||||||
|
},
|
||||||
|
graph ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route).not.toBeNull();
|
||||||
|
expect(route?.dataSources).toContain("postgis-osm");
|
||||||
|
expect(route?.dataSources).toContain("osm-overpass-waterway-canal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries OSM vessel restrictions into the routing graph", () => {
|
||||||
|
const graph = fairwayRowsToGraph(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "restricted",
|
||||||
|
source: "osm",
|
||||||
|
source_id: "way-restricted",
|
||||||
|
name: "Niedrige Durchfahrt",
|
||||||
|
min_depth_m: "3.0",
|
||||||
|
properties: { maxheight: "2.4 m", maxwidth: "3.2", maxdraft: "1.8", oneway: "yes" },
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[7, 52],
|
||||||
|
[7.04, 52]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[6.9, 51.9, 7.1, 52.1]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(graph?.edges[0]).toMatchObject({
|
||||||
|
maxAirDraftM: 2.4,
|
||||||
|
maxBeamM: 3.2,
|
||||||
|
maxDraughtM: 1.8,
|
||||||
|
oneway: true
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
buildRoute(
|
||||||
|
{
|
||||||
|
start: { lat: 52, lon: 7 },
|
||||||
|
destination: { lat: 52, lon: 7.04 },
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.3, airDraftM: 2.5, beamM: 3 }
|
||||||
|
},
|
||||||
|
graph ?? undefined
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
deduplicateMarineContactFeatures,
|
||||||
|
normalizeDepthFeatureProperties,
|
||||||
|
normalizeMarineFeatureProperties
|
||||||
|
} from "../src/services/features.js";
|
||||||
|
|
||||||
|
describe("marine feature normalization", () => {
|
||||||
|
it("formats bridge clearance labels from known OSM height tags", () => {
|
||||||
|
const properties = normalizeMarineFeatureProperties({
|
||||||
|
layer: "bridges",
|
||||||
|
name: "Kaiser-Wilhelm-Brücke",
|
||||||
|
source: "osm",
|
||||||
|
sourceId: "w123",
|
||||||
|
properties: {
|
||||||
|
bridge: "movable",
|
||||||
|
maxheight: "3"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(properties.clearance_m).toBe(3);
|
||||||
|
expect(properties.clearance_label).toBe("H 3 m");
|
||||||
|
expect(properties.label).toBe("Kaiser-Wilhelm-Brücke H 3 m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-numeric default bridge heights", () => {
|
||||||
|
const properties = normalizeMarineFeatureProperties({
|
||||||
|
layer: "bridges",
|
||||||
|
name: null,
|
||||||
|
source: "osm",
|
||||||
|
sourceId: "w124",
|
||||||
|
properties: {
|
||||||
|
bridge: "yes",
|
||||||
|
maxheight: "default"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(properties.clearance_m).toBeNull();
|
||||||
|
expect(properties.label).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes contact aliases, address and database timestamps", () => {
|
||||||
|
const properties = normalizeMarineFeatureProperties({
|
||||||
|
layer: "locks",
|
||||||
|
name: "Schleuse Hamm",
|
||||||
|
source: "osm",
|
||||||
|
sourceId: "w166568834",
|
||||||
|
updatedAt: new Date("2026-07-19T08:30:00.000Z"),
|
||||||
|
properties: {
|
||||||
|
"contact:phone": "+49 2381 9019280",
|
||||||
|
"contact:website": "https://example.test/schleuse-hamm",
|
||||||
|
"contact:email": "schleuse@example.test",
|
||||||
|
"seamark:lock_basin:communication_channel": "18",
|
||||||
|
opening_hours: "24/7",
|
||||||
|
operator: "WSV",
|
||||||
|
"addr:street": "Fährstraße",
|
||||||
|
"addr:housenumber": "1",
|
||||||
|
"addr:postcode": "59071",
|
||||||
|
"addr:city": "Hamm",
|
||||||
|
"addr:country": "DE"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(properties.phone).toBe("+49 2381 9019280");
|
||||||
|
expect(properties.website).toBe("https://example.test/schleuse-hamm");
|
||||||
|
expect(properties.email).toBe("schleuse@example.test");
|
||||||
|
expect(properties.vhf).toBe("18");
|
||||||
|
expect(properties.openingHours).toBe("24/7");
|
||||||
|
expect(properties.operator).toBe("WSV");
|
||||||
|
expect(properties.address).toBe("Fährstraße 1, 59071 Hamm, DE");
|
||||||
|
expect(properties.source).toBe("osm");
|
||||||
|
expect(properties.sourceId).toBe("w166568834");
|
||||||
|
expect(properties.updatedAt).toBe("2026-07-19T08:30:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers direct contact fields and returns stable null values when details are absent", () => {
|
||||||
|
const properties = normalizeMarineFeatureProperties({
|
||||||
|
layer: "harbours",
|
||||||
|
name: "Marina Emden",
|
||||||
|
source: "osm",
|
||||||
|
sourceId: "n1",
|
||||||
|
properties: {
|
||||||
|
phone: "+49 4921 123",
|
||||||
|
"contact:phone": "+49 4921 999"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(properties.phone).toBe("+49 4921 123");
|
||||||
|
expect(properties.website).toBeNull();
|
||||||
|
expect(properties.email).toBeNull();
|
||||||
|
expect(properties.vhf).toBeNull();
|
||||||
|
expect(properties.openingHours).toBeNull();
|
||||||
|
expect(properties.operator).toBeNull();
|
||||||
|
expect(properties.address).toBeNull();
|
||||||
|
expect(properties.updatedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps navigation details but removes unrelated bulk OSM tags from viewport features", () => {
|
||||||
|
const properties = normalizeMarineFeatureProperties({
|
||||||
|
layer: "harbours",
|
||||||
|
name: "Testhafen",
|
||||||
|
source: "osm",
|
||||||
|
sourceId: "w42",
|
||||||
|
properties: {
|
||||||
|
leisure: "marina",
|
||||||
|
electricity: "yes",
|
||||||
|
"contact:phone": "+49 40 123",
|
||||||
|
"source:geometry": "survey",
|
||||||
|
note: "A very large unrelated note that is not consumed by the client"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(properties.leisure).toBe("marina");
|
||||||
|
expect(properties.electricity).toBe("yes");
|
||||||
|
expect(properties.phone).toBe("+49 40 123");
|
||||||
|
expect(properties).not.toHaveProperty("source:geometry");
|
||||||
|
expect(properties).not.toHaveProperty("note");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats fairway depth labels", () => {
|
||||||
|
const properties = normalizeDepthFeatureProperties({
|
||||||
|
name: "Nord-Ostsee-Kanal",
|
||||||
|
source: "osm",
|
||||||
|
sourceId: "w456",
|
||||||
|
minDepthM: "14",
|
||||||
|
properties: {
|
||||||
|
depth: "14"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(properties.depth_m).toBe(14);
|
||||||
|
expect(properties.depth_label).toBe("14 m");
|
||||||
|
expect(properties.label).toBe("Nord-Ostsee-Kanal 14 m");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("marine contact feature deduplication", () => {
|
||||||
|
it("returns one stable facility feature and reports how many raw objects were merged", () => {
|
||||||
|
const result = deduplicateMarineContactFeatures([
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "12",
|
||||||
|
geometry: { type: "Point", coordinates: [7.867, 51.695] },
|
||||||
|
properties: {
|
||||||
|
layer: "locks",
|
||||||
|
source: "osm",
|
||||||
|
sourceId: "w12",
|
||||||
|
name: "Schleuse Werries",
|
||||||
|
website: "https://example.test/werries"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "99",
|
||||||
|
geometry: { type: "Point", coordinates: [7.86708, 51.69508] },
|
||||||
|
properties: {
|
||||||
|
layer: "locks",
|
||||||
|
source: "euris",
|
||||||
|
sourceId: "DEHMM00301LOCKS00404",
|
||||||
|
name: "Werries",
|
||||||
|
phone: "+49 2381 9019-290",
|
||||||
|
vhf: "22",
|
||||||
|
"ref:EU:RIS": "DEHMM00301LOCKS00404"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "bridge-1",
|
||||||
|
geometry: { type: "LineString", coordinates: [[7.8, 51.6], [7.9, 51.7]] },
|
||||||
|
properties: { layer: "bridges", source: "osm", name: "Testbrücke" }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.metadata).toEqual({ inputPoiCount: 2, outputPoiCount: 1, mergedObjectCount: 1 });
|
||||||
|
expect(result.features).toHaveLength(2);
|
||||||
|
expect(result.features.find((feature) => feature.properties.layer === "locks")).toMatchObject({
|
||||||
|
id: "marine-poi:locks:euris:DEHMM00301LOCKS00404",
|
||||||
|
properties: {
|
||||||
|
name: "Werries",
|
||||||
|
phone: "+49 2381 9019-290",
|
||||||
|
website: "https://example.test/werries",
|
||||||
|
dedupeMemberCount: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
buildFacilitySearchQuery,
|
||||||
|
buildSearchEnrichmentRecord,
|
||||||
|
enrichSearchCandidates,
|
||||||
|
evaluateFacilityPageMatch,
|
||||||
|
parseDuckDuckGoResults,
|
||||||
|
runSearchEnrichment,
|
||||||
|
searchBrave,
|
||||||
|
searchDuckDuckGo,
|
||||||
|
scoreSearchResult,
|
||||||
|
unwrapDuckDuckGoUrl,
|
||||||
|
} from "../../../scripts/enrich-marine-search.mjs";
|
||||||
|
|
||||||
|
const MATCH_THRESHOLD = 70;
|
||||||
|
const FETCHED_AT = "2026-07-23T09:30:00.000Z";
|
||||||
|
|
||||||
|
function searchCandidate(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "42",
|
||||||
|
layer: "locks",
|
||||||
|
source: "osm",
|
||||||
|
sourceId: "w123",
|
||||||
|
name: "Schleuse Werries",
|
||||||
|
properties: {
|
||||||
|
"addr:city": "Hamm",
|
||||||
|
"addr:country": "DE",
|
||||||
|
waterway_name: "Datteln-Hamm-Kanal",
|
||||||
|
},
|
||||||
|
enrichmentProperties: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingResult = {
|
||||||
|
url: "https://www.wsa.example/schleuse-werries",
|
||||||
|
title: "Schleuse Werries | WSA Westdeutsche Kanäle",
|
||||||
|
snippet: "Offizielle Informationen und Kontakt zur Schleuse Werries in Hamm am Datteln-Hamm-Kanal.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchingPageHtml = `
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<title>Schleuse Werries | WSA Westdeutsche Kanäle</title>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "GovernmentOrganization",
|
||||||
|
"name": "WSA Westdeutsche Kanäle",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressLocality": "Hamm",
|
||||||
|
"addressCountry": "DE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Schleuse Werries</h1>
|
||||||
|
<p>Datteln-Hamm-Kanal in Hamm</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const identityOnlyPageHtml = `
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head><title>Schleuse Werries | WSA Westdeutsche Kanäle</title></head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Schleuse Werries</h1>
|
||||||
|
<p>Datteln-Hamm-Kanal in Hamm</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("marine facility search discovery", () => {
|
||||||
|
it("builds a stable, specific query and refuses generic facility names", () => {
|
||||||
|
const query = buildFacilitySearchQuery(searchCandidate());
|
||||||
|
|
||||||
|
expect(query).toContain('"Schleuse Werries"');
|
||||||
|
expect(query).toContain("Hamm");
|
||||||
|
expect(query).toContain("Datteln-Hamm-Kanal");
|
||||||
|
expect(query).toMatch(/Kontakt/iu);
|
||||||
|
expect(query).not.toMatch(/\b(?:undefined|null)\b/iu);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildFacilitySearchQuery(
|
||||||
|
searchCandidate({
|
||||||
|
name: "Hafen",
|
||||||
|
layer: "harbours",
|
||||||
|
properties: { "addr:city": "Hamm" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(buildFacilitySearchQuery(searchCandidate({ name: "Schleuse", properties: {} }))).toBeNull();
|
||||||
|
expect(buildFacilitySearchQuery(searchCandidate({ name: null, properties: {} }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unwraps DuckDuckGo targets but rejects internal and unsafe links", () => {
|
||||||
|
const target = "https://www.wsa.example/schleuse-werries?view=contact";
|
||||||
|
const wrapped =
|
||||||
|
`//duckduckgo.com/l/?uddg=${encodeURIComponent(target)}` +
|
||||||
|
"&rut=0123456789";
|
||||||
|
|
||||||
|
expect(unwrapDuckDuckGoUrl(wrapped)).toBe(target);
|
||||||
|
expect(unwrapDuckDuckGoUrl(target)).toBe(target);
|
||||||
|
expect(unwrapDuckDuckGoUrl("/html/?q=schleuse+werries")).toBeNull();
|
||||||
|
expect(unwrapDuckDuckGoUrl("javascript:alert(1)")).toBeNull();
|
||||||
|
expect(unwrapDuckDuckGoUrl("mailto:test@example.test")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses organic DuckDuckGo results, decodes text and removes ads and duplicates", () => {
|
||||||
|
const wrappedTarget =
|
||||||
|
"//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.wsa.example%2Fschleuse-werries%23kontakt" +
|
||||||
|
"&rut=abc";
|
||||||
|
const html = `
|
||||||
|
<div class="result results_links results_links_deep web-result">
|
||||||
|
<h2 class="result__title">
|
||||||
|
<a rel="nofollow" class="result__a" href="${wrappedTarget}">
|
||||||
|
Schleuse <b>Werries</b> & Kontakt
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<a class="result__snippet">
|
||||||
|
Offizielle Informationen für Hamm. Telefon & E-Mail.
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="result result--ad">
|
||||||
|
<h2>
|
||||||
|
<a class="result__a" href="https://advertising.example/werries">
|
||||||
|
Anzeige für Werries
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<span class="result__badge">Ad</span>
|
||||||
|
</div>
|
||||||
|
<div class="result">
|
||||||
|
<a class="result__a" href="https://www.wsa.example/schleuse-werries#anfahrt">
|
||||||
|
Derselbe Treffer ein zweites Mal
|
||||||
|
</a>
|
||||||
|
<a class="result__snippet">Duplikat</a>
|
||||||
|
</div>
|
||||||
|
<div class="result">
|
||||||
|
<a class="result__a" href="https://hafen.example/kontakt?lang=de">
|
||||||
|
Hafenservice Hamm
|
||||||
|
</a>
|
||||||
|
<a class="result__snippet">Ein zweiter organischer Treffer.</a>
|
||||||
|
</div>
|
||||||
|
<a href="javascript:alert(1)" class="result__a">Unsicher</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(parseDuckDuckGoResults(html, { limit: 10 })).toEqual([
|
||||||
|
{
|
||||||
|
url: "https://www.wsa.example/schleuse-werries",
|
||||||
|
title: "Schleuse Werries & Kontakt",
|
||||||
|
snippet: "Offizielle Informationen für Hamm. Telefon & E-Mail.",
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://hafen.example/kontakt?lang=de",
|
||||||
|
title: "Hafenservice Hamm",
|
||||||
|
snippet: "Ein zweiter organischer Treffer.",
|
||||||
|
rank: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(parseDuckDuckGoResults(html, { limit: 1 })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a DuckDuckGo browser challenge as a provider block, not as no results", async () => {
|
||||||
|
await expect(
|
||||||
|
searchDuckDuckGo("Schleuse Werries Hamm", {
|
||||||
|
fetchPageImpl: async () => ({
|
||||||
|
status: 202,
|
||||||
|
finalUrl: "https://html.duckduckgo.com/html/",
|
||||||
|
html: '<form id="challenge-form"><script src="/anomaly.js"></script></form>',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "PROVIDER_BLOCKED", status: 202 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports the authenticated Brave API through the same normalized result shape", async () => {
|
||||||
|
const fetchImpl = vi.fn(async (_url, init) => {
|
||||||
|
expect(init.headers["X-Subscription-Token"]).toBe("test-token");
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
web: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Schleuse Werries",
|
||||||
|
url: "https://www.wsa.example/schleuse-werries",
|
||||||
|
description: "Kontakt in Hamm",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
searchBrave("Schleuse Werries Hamm", {
|
||||||
|
apiKey: "test-token",
|
||||||
|
fetchImpl,
|
||||||
|
limit: 3,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual([
|
||||||
|
{
|
||||||
|
title: "Schleuse Werries",
|
||||||
|
url: "https://www.wsa.example/schleuse-werries",
|
||||||
|
snippet: "Kontakt in Hamm",
|
||||||
|
rank: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a distinctive name plus matching place evidence before accepting a result", () => {
|
||||||
|
const accepted = scoreSearchResult(searchCandidate(), matchingResult);
|
||||||
|
const wrongPlace = scoreSearchResult(searchCandidate(), {
|
||||||
|
...matchingResult,
|
||||||
|
url: "https://tourismus.example/amsterdam/werries",
|
||||||
|
title: "Schleuse Werries in Amsterdam",
|
||||||
|
snippet: "Besuchen Sie die historische Schleuse in Amsterdam, Noord-Holland.",
|
||||||
|
});
|
||||||
|
const missingName = scoreSearchResult(searchCandidate(), {
|
||||||
|
url: "https://www.hamm.example/schleusen",
|
||||||
|
title: "Wasserstraßen und Schleusen in Hamm",
|
||||||
|
snippet: "Kontakt für den Datteln-Hamm-Kanal.",
|
||||||
|
});
|
||||||
|
const genericName = scoreSearchResult(
|
||||||
|
searchCandidate({ name: "Schleuse", properties: { "addr:city": "Hamm" } }),
|
||||||
|
matchingResult,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(accepted.accepted).toBe(true);
|
||||||
|
expect(accepted.score).toBeGreaterThanOrEqual(MATCH_THRESHOLD);
|
||||||
|
expect(accepted.evidence.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(wrongPlace.accepted).toBe(false);
|
||||||
|
expect(wrongPlace.score).toBeLessThan(MATCH_THRESHOLD);
|
||||||
|
expect(missingName.accepted).toBe(false);
|
||||||
|
expect(genericName).toMatchObject({ accepted: false, score: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates the fetched page itself and rejects a misleading redirect or generic homepage", () => {
|
||||||
|
const accepted = evaluateFacilityPageMatch(searchCandidate(), {
|
||||||
|
html: matchingPageHtml,
|
||||||
|
finalUrl: "https://www.wsa.example/schleuse-werries",
|
||||||
|
});
|
||||||
|
const genericHomepage = evaluateFacilityPageMatch(searchCandidate(), {
|
||||||
|
html: `
|
||||||
|
<html>
|
||||||
|
<head><title>WSA Westdeutsche Kanäle</title></head>
|
||||||
|
<body><h1>Willkommen</h1><p>Allgemeine Informationen zur Wasserstraßenverwaltung.</p></body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
finalUrl: "https://www.wsa.example/",
|
||||||
|
});
|
||||||
|
const wrongFacility = evaluateFacilityPageMatch(searchCandidate(), {
|
||||||
|
html: `
|
||||||
|
<html>
|
||||||
|
<head><title>Schleuse Werries Amsterdam</title></head>
|
||||||
|
<body><h1>Schleuse Werries</h1><address>Amsterdam, NL</address></body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
finalUrl: "https://tourismus.example/amsterdam/werries",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(accepted.accepted).toBe(true);
|
||||||
|
expect(accepted.score).toBeGreaterThanOrEqual(MATCH_THRESHOLD);
|
||||||
|
expect(accepted.evidence.length).toBeGreaterThan(0);
|
||||||
|
expect(genericHomepage.accepted).toBe(false);
|
||||||
|
expect(wrongFacility.accepted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the verified website with auditable provenance and never overwrites existing contacts", () => {
|
||||||
|
const candidate = searchCandidate({
|
||||||
|
properties: {
|
||||||
|
"addr:city": "Hamm",
|
||||||
|
phone: "+49 2381 100",
|
||||||
|
},
|
||||||
|
enrichmentProperties: {
|
||||||
|
operator: "Vorhandener Betreiber",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const query = buildFacilitySearchQuery(candidate);
|
||||||
|
const page = {
|
||||||
|
html: matchingPageHtml,
|
||||||
|
finalUrl: "https://www.wsa.example/anlagen/schleuse-werries",
|
||||||
|
};
|
||||||
|
const match = evaluateFacilityPageMatch(candidate, page);
|
||||||
|
const record = buildSearchEnrichmentRecord({
|
||||||
|
candidate,
|
||||||
|
query,
|
||||||
|
providerId: "duckduckgo",
|
||||||
|
searchResult: matchingResult,
|
||||||
|
page,
|
||||||
|
match,
|
||||||
|
extracted: {
|
||||||
|
phone: "+49 2381 999",
|
||||||
|
email: "schleuse-werries@example.test",
|
||||||
|
operator: "Anderer Betreiber",
|
||||||
|
address: null,
|
||||||
|
},
|
||||||
|
fetchedAt: FETCHED_AT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
originalId: "42",
|
||||||
|
sourceId: "osm:w123",
|
||||||
|
properties: {
|
||||||
|
website: "https://www.wsa.example/anlagen/schleuse-werries",
|
||||||
|
phone: "+49 2381 100",
|
||||||
|
email: "schleuse-werries@example.test",
|
||||||
|
operator: "Vorhandener Betreiber",
|
||||||
|
original_source: "osm",
|
||||||
|
original_source_id: "w123",
|
||||||
|
enrichmentSource: "facility-search",
|
||||||
|
enrichmentProvider: "duckduckgo",
|
||||||
|
searchQuery: query,
|
||||||
|
searchResultUrl: matchingResult.url,
|
||||||
|
source_url: page.finalUrl,
|
||||||
|
fetched_at: FETCHED_AT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(record.properties.searchScore).toBeGreaterThanOrEqual(MATCH_THRESHOLD);
|
||||||
|
expect(record.properties.enriched_fields).toEqual(
|
||||||
|
expect.arrayContaining(["website", "email"]),
|
||||||
|
);
|
||||||
|
expect(record.properties.enriched_fields).not.toContain("phone");
|
||||||
|
expect(record.properties.enriched_fields).not.toContain("operator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists a verified discovered website even when the page exposes no contact fields", async () => {
|
||||||
|
const search = vi.fn(async () => [matchingResult]);
|
||||||
|
const fetchPageImpl = vi.fn(async () => ({
|
||||||
|
html: identityOnlyPageHtml,
|
||||||
|
finalUrl: matchingResult.url,
|
||||||
|
redirects: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await enrichSearchCandidates([searchCandidate()], {
|
||||||
|
searchProvider: { id: "duckduckgo", search },
|
||||||
|
fetchPageImpl,
|
||||||
|
concurrency: 1,
|
||||||
|
hostDelayMs: 0,
|
||||||
|
maxResults: 5,
|
||||||
|
maxPages: 2,
|
||||||
|
fetchedAt: FETCHED_AT,
|
||||||
|
logger: { warn: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(search).toHaveBeenCalledTimes(1);
|
||||||
|
expect(search.mock.calls[0][0]).toContain("Schleuse Werries");
|
||||||
|
expect(fetchPageImpl).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.records).toHaveLength(1);
|
||||||
|
expect(result.records[0]).toMatchObject({
|
||||||
|
originalId: "42",
|
||||||
|
sourceId: "osm:w123",
|
||||||
|
properties: {
|
||||||
|
website: matchingResult.url,
|
||||||
|
enrichmentProvider: "duckduckgo",
|
||||||
|
fetched_at: FETCHED_AT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.records[0].properties.enriched_fields).toContain("website");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the provider circuit after a block response and does not continue querying", async () => {
|
||||||
|
const blockedError = Object.assign(new Error("DuckDuckGo hat weitere Anfragen blockiert."), {
|
||||||
|
code: "SEARCH_PROVIDER_BLOCKED",
|
||||||
|
status: 429,
|
||||||
|
});
|
||||||
|
const search = vi.fn(async () => {
|
||||||
|
throw blockedError;
|
||||||
|
});
|
||||||
|
const fetchPageImpl = vi.fn();
|
||||||
|
const logger = { warn: vi.fn() };
|
||||||
|
|
||||||
|
const result = await enrichSearchCandidates(
|
||||||
|
[
|
||||||
|
searchCandidate(),
|
||||||
|
searchCandidate({
|
||||||
|
id: "43",
|
||||||
|
sourceId: "w124",
|
||||||
|
name: "Schleuse Uentrop",
|
||||||
|
properties: { "addr:city": "Hamm" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
searchProvider: { id: "duckduckgo", search },
|
||||||
|
fetchPageImpl,
|
||||||
|
concurrency: 1,
|
||||||
|
hostDelayMs: 0,
|
||||||
|
fetchedAt: FETCHED_AT,
|
||||||
|
logger,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.records).toEqual([]);
|
||||||
|
expect(search).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchPageImpl).not.toHaveBeenCalled();
|
||||||
|
expect(logger.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the database untouched in the default dry-run", async () => {
|
||||||
|
const writer = vi.fn();
|
||||||
|
const search = vi.fn(async () => [matchingResult]);
|
||||||
|
const fetchPageImpl = vi.fn(async () => ({
|
||||||
|
html: identityOnlyPageHtml,
|
||||||
|
finalUrl: matchingResult.url,
|
||||||
|
redirects: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await runSearchEnrichment({
|
||||||
|
candidates: [searchCandidate()],
|
||||||
|
writer,
|
||||||
|
enrichmentOptions: {
|
||||||
|
searchProvider: { id: "duckduckgo", search },
|
||||||
|
fetchPageImpl,
|
||||||
|
concurrency: 1,
|
||||||
|
hostDelayMs: 0,
|
||||||
|
fetchedAt: FETCHED_AT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.dryRun).toBe(true);
|
||||||
|
expect(result.records).toHaveLength(1);
|
||||||
|
expect(writer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes validated records and checkpoint attempts to the writer only when enabled", async () => {
|
||||||
|
const writer = vi.fn(async () => ({ enrichments: 1, attempts: 1 }));
|
||||||
|
const result = await runSearchEnrichment({
|
||||||
|
dryRun: false,
|
||||||
|
candidates: [searchCandidate()],
|
||||||
|
writer,
|
||||||
|
enrichmentOptions: {
|
||||||
|
searchProvider: { id: "duckduckgo", search: async () => [matchingResult] },
|
||||||
|
fetchPageImpl: async () => ({
|
||||||
|
html: identityOnlyPageHtml,
|
||||||
|
finalUrl: matchingResult.url,
|
||||||
|
redirects: 0,
|
||||||
|
}),
|
||||||
|
concurrency: 1,
|
||||||
|
hostDelayMs: 0,
|
||||||
|
fetchedAt: FETCHED_AT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.records).toHaveLength(1);
|
||||||
|
expect(result.attempts).toHaveLength(1);
|
||||||
|
expect(result.attempts[0]).toMatchObject({
|
||||||
|
status: "success",
|
||||||
|
provider: "duckduckgo",
|
||||||
|
originalSource: "osm",
|
||||||
|
originalSourceId: "w123",
|
||||||
|
});
|
||||||
|
expect(writer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(writer.mock.calls[0][0]).toMatchObject({
|
||||||
|
records: [expect.objectContaining({ sourceId: "osm:w123" })],
|
||||||
|
attempts: [expect.objectContaining({ status: "success" })],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_LIMIT,
|
||||||
|
MAX_HTML_BYTES,
|
||||||
|
assertPublicHttpUrl,
|
||||||
|
buildEnrichmentRecord,
|
||||||
|
createHostLimiter,
|
||||||
|
createPinnedLookup,
|
||||||
|
extractContactsFromHtml,
|
||||||
|
fetchHtmlPage,
|
||||||
|
parseBooleanDefault,
|
||||||
|
runWebsiteEnrichment,
|
||||||
|
} from "../../../scripts/enrich-marine-websites.mjs";
|
||||||
|
|
||||||
|
const publicDns = async () => [{ address: "93.184.216.34", family: 4 }];
|
||||||
|
|
||||||
|
function htmlResponse(html, init = {}) {
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html; charset=utf-8" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidate(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "42",
|
||||||
|
layer: "harbours",
|
||||||
|
source: "osm",
|
||||||
|
sourceId: "w123",
|
||||||
|
name: "Testhafen",
|
||||||
|
properties: { website: "https://marina.example/contact" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("marine facility website enrichment", () => {
|
||||||
|
it("defaults to dry-run and validates explicit boolean values", () => {
|
||||||
|
expect(DEFAULT_LIMIT).toBe(25);
|
||||||
|
expect(parseBooleanDefault(undefined)).toBe(true);
|
||||||
|
expect(parseBooleanDefault("false")).toBe(false);
|
||||||
|
expect(() => parseBooleanDefault("maybe")).toThrow(/true oder false/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"http://127.0.0.1/admin",
|
||||||
|
"http://[::1]/admin",
|
||||||
|
"http://localhost/admin",
|
||||||
|
"http://service.local/admin",
|
||||||
|
])("blocks local URL %s before fetching", async (url) => {
|
||||||
|
await expect(assertPublicHttpUrl(url, { lookupImpl: publicDns })).rejects.toMatchObject({
|
||||||
|
name: "WebsiteEnrichmentError",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a public hostname if any DNS result is private", async () => {
|
||||||
|
const lookupImpl = vi.fn(async () => [
|
||||||
|
{ address: "93.184.216.34", family: 4 },
|
||||||
|
{ address: "10.0.0.8", family: 4 },
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
assertPublicHttpUrl("https://marina.example", { lookupImpl }),
|
||||||
|
).rejects.toMatchObject({ code: "SSRF_BLOCKED_DNS" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins the socket lookup to the already validated DNS addresses", async () => {
|
||||||
|
const lookup = createPinnedLookup([{ address: "93.184.216.34", family: 4 }]);
|
||||||
|
const addresses = await new Promise((resolve, reject) => {
|
||||||
|
lookup("a-second-dns-name.example", { all: true }, (error, result) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(addresses).toEqual([{ address: "93.184.216.34", family: 4 }]);
|
||||||
|
expect(() => createPinnedLookup([{ address: "127.0.0.1", family: 4 }])).toThrow(
|
||||||
|
/gepinnt/u,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks a redirect target again and never requests a private redirect", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () =>
|
||||||
|
new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: "http://169.254.169.254/latest/meta-data" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchHtmlPage("https://marina.example", {
|
||||||
|
fetchImpl,
|
||||||
|
lookupImpl: publicDns,
|
||||||
|
beforeRequest: async () => {},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "SSRF_BLOCKED_IP" });
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchImpl.mock.calls[0][1]).toMatchObject({ redirect: "manual" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires HTML and stops streamed responses above 512 KiB", async () => {
|
||||||
|
await expect(
|
||||||
|
fetchHtmlPage("https://marina.example/file.pdf", {
|
||||||
|
lookupImpl: publicDns,
|
||||||
|
beforeRequest: async () => {},
|
||||||
|
fetchImpl: async () =>
|
||||||
|
new Response("pdf", { status: 200, headers: { "content-type": "application/pdf" } }),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "UNSUPPORTED_CONTENT_TYPE" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchHtmlPage("https://marina.example/huge", {
|
||||||
|
lookupImpl: publicDns,
|
||||||
|
beforeRequest: async () => {},
|
||||||
|
fetchImpl: async () => htmlResponse("x".repeat(MAX_HTML_BYTES + 1)),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "BODY_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts a hanging HTTP request at the configured timeout", async () => {
|
||||||
|
const fetchImpl = async (_url, init) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchHtmlPage("https://marina.example/hangs", {
|
||||||
|
fetchImpl,
|
||||||
|
lookupImpl: publicDns,
|
||||||
|
beforeRequest: async () => {},
|
||||||
|
timeoutMs: 5,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "FETCH_FAILED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also bounds a hanging DNS lookup", async () => {
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
await expect(
|
||||||
|
fetchHtmlPage("https://marina.example/hangs", {
|
||||||
|
fetchImpl,
|
||||||
|
lookupImpl: async () => new Promise(() => {}),
|
||||||
|
beforeRequest: async () => {},
|
||||||
|
timeoutMs: 5,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "DNS_TIMEOUT" });
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers JSON-LD and otherwise accepts only tel/mailto links", () => {
|
||||||
|
const html = `
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Marina",
|
||||||
|
"name": "Hafenbetrieb Musterstadt",
|
||||||
|
"telephone": "+49 201 11111",
|
||||||
|
"email": "hafen@example.test",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "Ufer 1",
|
||||||
|
"postalCode": "12345",
|
||||||
|
"addressLocality": "Musterstadt",
|
||||||
|
"addressCountry": "DE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<a href="tel:+49-201-99999">Alternative</a>
|
||||||
|
<a href="mailto:other@example.test">Alternative</a>
|
||||||
|
<p>Telefon 01234 567890</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(extractContactsFromHtml(html)).toEqual({
|
||||||
|
phone: "+49 201 11111",
|
||||||
|
email: "hafen@example.test",
|
||||||
|
operator: "Hafenbetrieb Musterstadt",
|
||||||
|
address: "Ufer 1, 12345 Musterstadt, DE",
|
||||||
|
});
|
||||||
|
expect(extractContactsFromHtml("<p>Telefon 01234 567890</p>")).toEqual({
|
||||||
|
phone: null,
|
||||||
|
email: null,
|
||||||
|
operator: null,
|
||||||
|
address: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
extractContactsFromHtml(
|
||||||
|
'<a href="tel:0049%20201%20777">Anrufen</a><a href="mailto:lock%40example.test">Mail</a>',
|
||||||
|
),
|
||||||
|
).toMatchObject({ phone: "+49 201 777", email: "lock@example.test" });
|
||||||
|
expect(
|
||||||
|
extractContactsFromHtml('<a title="some href=\'tel:+49999\'">Kein Kontaktlink</a>'),
|
||||||
|
).toMatchObject({ phone: null });
|
||||||
|
expect(extractContactsFromHtml('<a href="tel:+49201�">X</a>')).toMatchObject({
|
||||||
|
phone: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
extractContactsFromHtml(
|
||||||
|
'<script type="application/ld+json">{"@type":"Marina","name":"Hafen\\u0000","email":"bad@example.test\\u0000"}</script>',
|
||||||
|
),
|
||||||
|
).toMatchObject({ email: null, operator: null });
|
||||||
|
expect(
|
||||||
|
extractContactsFromHtml(
|
||||||
|
'<script type="application/ld+json">{"@type":"Organization","name":"Schleusenbetrieb Nord"}</script><a href="tel:+49-201-555">Telefon</a>',
|
||||||
|
),
|
||||||
|
).toMatchObject({ phone: "+49-201-555", operator: "Schleusenbetrieb Nord" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps existing contact values and only fills missing fields", () => {
|
||||||
|
const record = buildEnrichmentRecord({
|
||||||
|
candidate: candidate({
|
||||||
|
properties: {
|
||||||
|
website: "https://marina.example/contact",
|
||||||
|
phone: "+49 201 100",
|
||||||
|
},
|
||||||
|
enrichmentProperties: { operator: "Vorhandener Hafenbetreiber" },
|
||||||
|
}),
|
||||||
|
website: {
|
||||||
|
key: "website",
|
||||||
|
original: "https://marina.example/contact",
|
||||||
|
url: "https://marina.example/contact",
|
||||||
|
},
|
||||||
|
page: { finalUrl: "https://marina.example/kontakt" },
|
||||||
|
extracted: {
|
||||||
|
phone: "+49 201 999",
|
||||||
|
email: "hafen@example.test",
|
||||||
|
},
|
||||||
|
fetchedAt: "2026-07-20T11:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
sourceId: "osm:w123",
|
||||||
|
properties: {
|
||||||
|
website: "https://marina.example/contact",
|
||||||
|
phone: "+49 201 100",
|
||||||
|
email: "hafen@example.test",
|
||||||
|
operator: "Vorhandener Hafenbetreiber",
|
||||||
|
source_url: "https://marina.example/kontakt",
|
||||||
|
fetchedAt: "2026-07-20T11:00:00.000Z",
|
||||||
|
enriched_fields: ["email"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spaces starts to the same host while allowing a deterministic injected clock", async () => {
|
||||||
|
let currentTime = 1_000;
|
||||||
|
const waits = [];
|
||||||
|
const limiter = createHostLimiter({
|
||||||
|
delayMs: 500,
|
||||||
|
now: () => currentTime,
|
||||||
|
sleepImpl: async (milliseconds) => {
|
||||||
|
waits.push(milliseconds);
|
||||||
|
currentTime += milliseconds;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await limiter(new URL("https://marina.example/one"));
|
||||||
|
await limiter(new URL("https://marina.example/two"));
|
||||||
|
await limiter(new URL("https://other.example/one"));
|
||||||
|
expect(waits).toEqual([500]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invoke the database writer during the default dry-run", async () => {
|
||||||
|
const writer = vi.fn();
|
||||||
|
const fetchImpl = vi.fn(async (_url, init) => {
|
||||||
|
expect(init.headers["User-Agent"]).toContain("Watermaps");
|
||||||
|
return htmlResponse('<a href="tel:+49-201-12345">Schleuse anrufen</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runWebsiteEnrichment({
|
||||||
|
candidates: [
|
||||||
|
candidate(),
|
||||||
|
candidate({ id: "43", sourceId: "w124", properties: { website: "https://other.example" } }),
|
||||||
|
],
|
||||||
|
limit: 1,
|
||||||
|
writer,
|
||||||
|
enrichmentOptions: {
|
||||||
|
fetchImpl,
|
||||||
|
lookupImpl: publicDns,
|
||||||
|
hostDelayMs: 0,
|
||||||
|
fetchedAt: "2026-07-20T11:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.dryRun).toBe(true);
|
||||||
|
expect(result.records).toHaveLength(1);
|
||||||
|
expect(result.records[0]).toMatchObject({
|
||||||
|
originalId: "42",
|
||||||
|
sourceId: "osm:w123",
|
||||||
|
properties: { phone: "+49-201-12345" },
|
||||||
|
});
|
||||||
|
expect(writer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createCache, type Cache } from "../src/services/cache.js";
|
||||||
|
import type { FetchLike } from "../src/services/http.js";
|
||||||
|
import {
|
||||||
|
assertOfficialNavigationUrl,
|
||||||
|
buildPegelOnlineUrl,
|
||||||
|
createOfficialJsonAdapter,
|
||||||
|
getNavigationData,
|
||||||
|
type NavigationDataAdapter,
|
||||||
|
type WaterLevel
|
||||||
|
} from "../src/services/navigation-data.js";
|
||||||
|
|
||||||
|
const openCaches: Cache[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(openCaches.splice(0).map((cache) => cache.close()));
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("official navigation data", () => {
|
||||||
|
it("normalizes current PEGELONLINE water levels and caches the result", async () => {
|
||||||
|
const cache = memoryCache();
|
||||||
|
const fetcher = vi.fn(async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
uuid: "edfdf747-be92-462f-87ed-53d228a33172",
|
||||||
|
number: "3970010",
|
||||||
|
shortname: "EMDEN NEUE SEESCHLEUSE",
|
||||||
|
agency: "STANDORT EMDEN",
|
||||||
|
longitude: 7.186348,
|
||||||
|
latitude: 53.336781,
|
||||||
|
km: 40.45,
|
||||||
|
water: { shortname: "EMS", longname: "EMS" },
|
||||||
|
timeseries: [
|
||||||
|
{
|
||||||
|
shortname: "W",
|
||||||
|
unit: "cm",
|
||||||
|
currentMeasurement: {
|
||||||
|
timestamp: "2026-07-19T13:18:00+02:00",
|
||||||
|
value: 564,
|
||||||
|
stateMnwMhw: "normal",
|
||||||
|
stateNswHsw: "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } }
|
||||||
|
)) as unknown as FetchLike;
|
||||||
|
const now = () => new Date("2026-07-19T11:20:00.000Z");
|
||||||
|
|
||||||
|
const first = await getNavigationData(
|
||||||
|
{ waterways: [" EMS ", "EMS"] },
|
||||||
|
{ cache, fetcher, now }
|
||||||
|
);
|
||||||
|
const second = await getNavigationData(
|
||||||
|
{ waterways: ["EMS"] },
|
||||||
|
{ cache, fetcher, now }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(vi.mocked(fetcher).mock.calls[0]?.[0])).toContain("waters=EMS");
|
||||||
|
expect(first.waterLevels).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
stationId: "edfdf747-be92-462f-87ed-53d228a33172",
|
||||||
|
stationName: "EMDEN NEUE SEESCHLEUSE",
|
||||||
|
waterway: "EMS",
|
||||||
|
value: 564,
|
||||||
|
unit: "cm",
|
||||||
|
stateMnwMhw: "normal"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(first.sources.map((source) => source.state)).toEqual([
|
||||||
|
"live",
|
||||||
|
"not-configured",
|
||||||
|
"not-configured"
|
||||||
|
]);
|
||||||
|
expect(second.sources[0]?.state).toBe("cached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses last-good data when a live source fails", async () => {
|
||||||
|
const cache = memoryCache();
|
||||||
|
let sourceAvailable = true;
|
||||||
|
const adapter: NavigationDataAdapter<WaterLevel> = {
|
||||||
|
kind: "water-levels",
|
||||||
|
id: "test-wsv-levels",
|
||||||
|
label: "Test WSV levels",
|
||||||
|
sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||||
|
freshTtlMs: 1,
|
||||||
|
staleTtlMs: 60_000,
|
||||||
|
async load() {
|
||||||
|
if (!sourceAvailable) {
|
||||||
|
throw new Error("WSV test outage");
|
||||||
|
}
|
||||||
|
return [waterLevelFixture()];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const live = await getNavigationData(
|
||||||
|
{ waterways: ["EMS"] },
|
||||||
|
{ cache, fetcher: vi.fn() as unknown as FetchLike, adapters: { waterLevels: adapter } }
|
||||||
|
);
|
||||||
|
sourceAvailable = false;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
const fallback = await getNavigationData(
|
||||||
|
{ waterways: ["EMS"] },
|
||||||
|
{ cache, fetcher: vi.fn() as unknown as FetchLike, adapters: { waterLevels: adapter } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(live.sources[0]?.state).toBe("live");
|
||||||
|
expect(fallback.waterLevels).toEqual([waterLevelFixture()]);
|
||||||
|
expect(fallback.sources[0]).toEqual(
|
||||||
|
expect.objectContaining({ state: "stale", warning: expect.stringContaining("letzter erfolgreicher Stand") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out an adapter and returns an explicit unavailable state", async () => {
|
||||||
|
const cache = memoryCache();
|
||||||
|
const adapter: NavigationDataAdapter<WaterLevel> = {
|
||||||
|
kind: "water-levels",
|
||||||
|
id: "slow-wsv-source",
|
||||||
|
label: "Slow official source",
|
||||||
|
sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||||
|
load: () => new Promise(() => undefined)
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getNavigationData(
|
||||||
|
{ waterways: ["EMS"] },
|
||||||
|
{
|
||||||
|
cache,
|
||||||
|
fetcher: vi.fn() as unknown as FetchLike,
|
||||||
|
adapters: { waterLevels: adapter },
|
||||||
|
timeoutMs: 5
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.waterLevels).toEqual([]);
|
||||||
|
expect(result.sources[0]).toEqual(
|
||||||
|
expect.objectContaining({ state: "unavailable", warning: expect.stringContaining("Zeitlimit") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not download the nationwide station list without a route filter", async () => {
|
||||||
|
const cache = memoryCache();
|
||||||
|
const fetcher = vi.fn() as unknown as FetchLike;
|
||||||
|
|
||||||
|
const result = await getNavigationData({}, { cache, fetcher });
|
||||||
|
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
expect(result.sources[0]).toEqual(
|
||||||
|
expect.objectContaining({ state: "not-configured", warning: expect.stringContaining("Stations-UUID") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports explicitly configured adapters for documented official JSON endpoints", async () => {
|
||||||
|
const cache = memoryCache();
|
||||||
|
const level = waterLevelFixture();
|
||||||
|
const adapter = createOfficialJsonAdapter<WaterLevel>({
|
||||||
|
kind: "water-levels",
|
||||||
|
id: "configured-pegelonline-feed",
|
||||||
|
label: "Configured PEGELONLINE feed",
|
||||||
|
sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||||
|
buildUrl: () =>
|
||||||
|
"https://pegelonline.wsv.de/webservices/rest-api/v2/stations.json?waters=EMS",
|
||||||
|
parse: (payload) => (payload as { levels: WaterLevel[] }).levels
|
||||||
|
});
|
||||||
|
const fetcher = vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ levels: [level] }), { status: 200 })) as unknown as FetchLike;
|
||||||
|
|
||||||
|
const result = await getNavigationData(
|
||||||
|
{ waterways: ["EMS"] },
|
||||||
|
{ cache, fetcher, adapters: { waterLevels: adapter } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.waterLevels).toEqual([level]);
|
||||||
|
expect(result.sources.find((source) => source.kind === "water-levels")?.state).toBe("live");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unofficial or insecure configured endpoints", () => {
|
||||||
|
expect(() => assertOfficialNavigationUrl("http://www.elwis.de/feed.json")).toThrow(
|
||||||
|
/offizielle HTTPS-Quellen/
|
||||||
|
);
|
||||||
|
expect(() => assertOfficialNavigationUrl("https://elwis.de.example.org/feed.json")).toThrow(
|
||||||
|
/offizielle HTTPS-Quellen/
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
createOfficialJsonAdapter({
|
||||||
|
kind: "notices",
|
||||||
|
id: "unofficial",
|
||||||
|
label: "Unofficial",
|
||||||
|
sourceUrl: "https://example.org/feed",
|
||||||
|
buildUrl: () => "https://example.org/feed",
|
||||||
|
parse: () => []
|
||||||
|
})
|
||||||
|
).toThrow(/offizielle HTTPS-Quellen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks unofficial network requests made by a custom adapter", async () => {
|
||||||
|
const cache = memoryCache();
|
||||||
|
const networkFetcher = vi.fn() as unknown as FetchLike;
|
||||||
|
const adapter: NavigationDataAdapter<WaterLevel> = {
|
||||||
|
kind: "water-levels",
|
||||||
|
id: "misconfigured-custom-adapter",
|
||||||
|
label: "Misconfigured adapter",
|
||||||
|
sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||||
|
async load(_query, { fetcher }) {
|
||||||
|
await fetcher("https://example.org/not-official.json");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getNavigationData(
|
||||||
|
{ waterways: ["EMS"] },
|
||||||
|
{ cache, fetcher: networkFetcher, adapters: { waterLevels: adapter } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(networkFetcher).not.toHaveBeenCalled();
|
||||||
|
expect(result.sources[0]).toEqual(
|
||||||
|
expect.objectContaining({ state: "unavailable", warning: expect.stringContaining("offizielle HTTPS-Quellen") })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a stable, bounded PEGELONLINE query", () => {
|
||||||
|
const url = new URL(
|
||||||
|
buildPegelOnlineUrl({ stationIds: ["b", "a", "a"], waterways: ["RHEIN", "EMS"] }) ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(url.origin).toBe("https://pegelonline.wsv.de");
|
||||||
|
expect(url.searchParams.get("ids")).toBe("a,b");
|
||||||
|
expect(url.searchParams.get("waters")).toBe("EMS,RHEIN");
|
||||||
|
expect(url.searchParams.get("timeseries")).toBe("W");
|
||||||
|
expect(url.searchParams.get("includeCurrentMeasurement")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function memoryCache(): Cache {
|
||||||
|
const cache = createCache();
|
||||||
|
openCaches.push(cache);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waterLevelFixture(): WaterLevel {
|
||||||
|
return {
|
||||||
|
stationId: "station-1",
|
||||||
|
stationNumber: "3970010",
|
||||||
|
stationName: "EMDEN NEUE SEESCHLEUSE",
|
||||||
|
waterway: "EMS",
|
||||||
|
waterwayKm: 40.45,
|
||||||
|
latitude: 53.336781,
|
||||||
|
longitude: 7.186348,
|
||||||
|
value: 564,
|
||||||
|
unit: "cm",
|
||||||
|
measuredAt: "2026-07-19T13:18:00+02:00",
|
||||||
|
stateMnwMhw: "normal",
|
||||||
|
stateNswHsw: "unknown",
|
||||||
|
agency: "STANDORT EMDEN",
|
||||||
|
sourceUrl: "https://pegelonline.wsv.de/webservices/rest-api/v2/stations/station-1.json"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
featureName,
|
||||||
|
isLockFeature,
|
||||||
|
sourceId
|
||||||
|
} from "../../../scripts/osm-marine-classification.mjs";
|
||||||
|
|
||||||
|
describe("OSM marine import classification", () => {
|
||||||
|
it.each([
|
||||||
|
{ lock: "yes" },
|
||||||
|
{ waterway: "lock_gate" },
|
||||||
|
{ waterway: "lock" },
|
||||||
|
{ natural: "water", water: "lock" },
|
||||||
|
{ obstacle: "lock" },
|
||||||
|
{ "seamark:type": "lock_basin" },
|
||||||
|
{ "seamark:type": "gate", "seamark:gate:category": "lock" }
|
||||||
|
])("recognizes a lock encoded as %o", (properties) => {
|
||||||
|
expect(isLockFeature(properties)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not classify an unrelated gate as a lock", () => {
|
||||||
|
expect(isLockFeature({ "seamark:type": "gate", "seamark:gate:category": "flood_barrage" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the canonical OSM id for converted area features", () => {
|
||||||
|
expect(
|
||||||
|
sourceId(
|
||||||
|
{ id: "a69307610", properties: { "@type": "way", "@id": 34653805 } },
|
||||||
|
1,
|
||||||
|
"nordrhein-westfalen-latest"
|
||||||
|
)
|
||||||
|
).toBe("w34653805");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the lock name and falls back to the seamark name", () => {
|
||||||
|
expect(featureName({ lock_name: "Schleuse Test" })).toBe("Schleuse Test");
|
||||||
|
expect(featureName({ name: "Testkanal", lock_name: "Schleuse Test" })).toBe("Schleuse Test");
|
||||||
|
expect(featureName({ "seamark:name": "Test Lock" })).toBe("Test Lock");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createCache } from "../src/services/cache.js";
|
||||||
|
import type { FetchLike } from "../src/services/http.js";
|
||||||
|
import { getNearestTideSummary, normalizeNearestTideSummary } from "../src/services/tides.js";
|
||||||
|
|
||||||
|
describe("BSH tide normalization", () => {
|
||||||
|
it("selects the nearest station and upcoming events", () => {
|
||||||
|
const summary = normalizeNearestTideSummary(
|
||||||
|
{
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
geometry: { type: "Point", coordinates: [12.1, 54.2] },
|
||||||
|
properties: {
|
||||||
|
gauge_label: "Demo Pegel",
|
||||||
|
forecast_timestamp: "2026-07-09 09:00:00+02:00",
|
||||||
|
high_water_low_water: [
|
||||||
|
{
|
||||||
|
event_timestamp: "2026-07-09 10:00:00+02:00",
|
||||||
|
event: "HW",
|
||||||
|
forecast_value: 620,
|
||||||
|
forecast_deviation: "+0,2 m"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_timestamp: "2026-07-09 15:30:00+02:00",
|
||||||
|
event: "NW",
|
||||||
|
tidal_prediction_value: "370"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
curve: [
|
||||||
|
{
|
||||||
|
timestamp: "2026-07-09 10:00:00+02:00",
|
||||||
|
tidal_prediction: "620",
|
||||||
|
measurement: "618"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ lat: 54.2, lon: 12.1 },
|
||||||
|
new Date("2026-07-09T08:00:00+02:00")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary?.station).toBe("Demo Pegel");
|
||||||
|
expect(summary?.nextHigh?.heightM).toBe(6.2);
|
||||||
|
expect(summary?.nextLow?.heightM).toBe(3.7);
|
||||||
|
expect(summary?.waterLevelCurve[0]?.predictedM).toBe(6.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses params.at instead of wall-clock time when filtering upcoming tide events", async () => {
|
||||||
|
const cache = createCache();
|
||||||
|
const fetcher = vi.fn(async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
geometry: { type: "Point", coordinates: [7.18, 53.34] },
|
||||||
|
properties: {
|
||||||
|
gauge_label: "Emden",
|
||||||
|
forecast_timestamp: "2030-01-01T09:00:00.000Z",
|
||||||
|
high_water_low_water: [
|
||||||
|
{
|
||||||
|
event_timestamp: "2030-01-01T10:00:00.000Z",
|
||||||
|
event: "HW",
|
||||||
|
forecast_value: 610
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_timestamp: "2030-01-01T13:00:00.000Z",
|
||||||
|
event: "NW",
|
||||||
|
forecast_value: 350
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_timestamp: "2030-01-01T16:00:00.000Z",
|
||||||
|
event: "HW",
|
||||||
|
forecast_value: 625
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } }
|
||||||
|
)) as unknown as FetchLike;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await getNearestTideSummary(
|
||||||
|
{ lat: 53.34, lon: 7.18, at: "2030-01-01T12:00:00.000Z" },
|
||||||
|
{ cache, fetcher }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
expect(summary?.nextLow).toEqual(
|
||||||
|
expect.objectContaining({ time: "2030-01-01T13:00:00.000Z", heightM: 3.5 })
|
||||||
|
);
|
||||||
|
expect(summary?.nextHigh).toEqual(
|
||||||
|
expect.objectContaining({ time: "2030-01-01T16:00:00.000Z", heightM: 6.25 })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cache.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeMarineForecast } from "../src/services/weather.js";
|
||||||
|
|
||||||
|
describe("departure-time marine forecast", () => {
|
||||||
|
it("selects waves, wind and ocean current nearest the requested passage time", () => {
|
||||||
|
const result = normalizeMarineForecast(
|
||||||
|
{
|
||||||
|
hourly: {
|
||||||
|
time: ["2026-07-20T08:00", "2026-07-20T09:00"],
|
||||||
|
wave_height: [0.6, 0.9],
|
||||||
|
wave_direction: [280, 290],
|
||||||
|
wave_period: [4, 5],
|
||||||
|
ocean_current_velocity: [0.4, 1.1],
|
||||||
|
ocean_current_direction: [90, 100],
|
||||||
|
sea_level_height_msl: [0.2, 0.4]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hourly: {
|
||||||
|
time: ["2026-07-20T08:00", "2026-07-20T09:00"],
|
||||||
|
wind_speed_10m: [8, 12],
|
||||||
|
wind_direction_10m: [240, 250],
|
||||||
|
weather_code: [2, 3],
|
||||||
|
temperature_2m: [18, 19]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ marineAvailable: true, weatherAvailable: true },
|
||||||
|
"2026-07-20T08:40:00.000Z"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
waveHeightM: 0.9,
|
||||||
|
windSpeed: 12,
|
||||||
|
oceanCurrentSpeedKn: 1.1,
|
||||||
|
oceanCurrentDirectionDeg: 100,
|
||||||
|
seaLevelHeightMslM: 0.4,
|
||||||
|
forecastTime: "2026-07-20T09:00:00.000Z"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reuse the edge of the forecast as if it covered a much later departure", () => {
|
||||||
|
const result = normalizeMarineForecast(
|
||||||
|
{
|
||||||
|
hourly: {
|
||||||
|
time: ["2026-07-20T08:00"],
|
||||||
|
wave_height: [0.6],
|
||||||
|
wave_direction: [280],
|
||||||
|
wave_period: [4],
|
||||||
|
ocean_current_velocity: [0.4],
|
||||||
|
ocean_current_direction: [90],
|
||||||
|
sea_level_height_msl: [0.2]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hourly: {
|
||||||
|
time: ["2026-07-20T08:00"],
|
||||||
|
wind_speed_10m: [8],
|
||||||
|
wind_direction_10m: [240],
|
||||||
|
weather_code: [2],
|
||||||
|
temperature_2m: [18]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ marineAvailable: true, weatherAvailable: true },
|
||||||
|
"2026-07-24T08:00:00.000Z"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
waveHeightM: null,
|
||||||
|
windSpeed: null,
|
||||||
|
oceanCurrentSpeedKn: null,
|
||||||
|
oceanCurrentDirectionDeg: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"noEmit": false,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f4c5c" />
|
||||||
|
<title>Watermaps</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@watermaps/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
|
"dev:https": "WATERMAPS_HTTPS=true vite --host 0.0.0.0 --port 5173",
|
||||||
|
"build": "tsc -b && vite build && node scripts/check-chunks.mjs",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"typecheck": "tsc -b --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@watermaps/shared": "0.1.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"maplibre-gl": "^5.6.1",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.54.1",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-pwa": "^1.0.1",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { devices, defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
timeout: 30_000,
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:5173",
|
||||||
|
trace: "on-first-retry"
|
||||||
|
},
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: "WATERMAPS_LIVE_FAIRWAYS=false npm run dev --workspace @watermaps/api",
|
||||||
|
url: "http://127.0.0.1:5174/health",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 30_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "npm run dev --workspace @watermaps/web",
|
||||||
|
url: "http://127.0.0.1:5173",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 30_000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "iphone",
|
||||||
|
testIgnore: /desktop-layout\.spec\.ts/,
|
||||||
|
use: { ...devices["iPhone 15"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "desktop",
|
||||||
|
testMatch: /desktop-layout\.spec\.ts/,
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Safari"],
|
||||||
|
viewport: { width: 1440, height: 900 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#0f4c5c"/>
|
||||||
|
<circle cx="32" cy="32" r="22" fill="#f6f9f7"/>
|
||||||
|
<path d="M32 10l8 25-8 19-8-19 8-25z" fill="#cc3333"/>
|
||||||
|
<path d="M32 10l8 25-8-5V10z" fill="#0f4c5c"/>
|
||||||
|
<circle cx="32" cy="32" r="4" fill="#10242b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
@@ -0,0 +1,141 @@
|
|||||||
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const distRoot = path.join(webRoot, "dist");
|
||||||
|
const manifestPath = path.join(distRoot, ".vite", "manifest.json");
|
||||||
|
|
||||||
|
assert(existsSync(manifestPath), "Vite-Manifest fehlt. Zuerst den Produktions-Build ausführen.");
|
||||||
|
|
||||||
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||||
|
const entry = manifest["index.html"];
|
||||||
|
assert(entry?.isEntry, "Der Web-Einstieg fehlt im Vite-Manifest.");
|
||||||
|
|
||||||
|
const expectedDynamicEntries = [
|
||||||
|
"src/components/MapView.tsx",
|
||||||
|
"src/components/AnchorWatchPanel.tsx",
|
||||||
|
"src/components/CourseAssistantPanel.tsx",
|
||||||
|
"src/components/VoyageNavigationTools.tsx",
|
||||||
|
"src/routeWeatherReport.ts"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of expectedDynamicEntries) {
|
||||||
|
const chunk = manifest[key];
|
||||||
|
assert(chunk?.isDynamicEntry, `${key} ist kein dynamischer Einstieg mehr.`);
|
||||||
|
assertFile(chunk.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapEngineEntry = Object.entries(manifest).find(([, chunk]) => chunk.name === "map-engine");
|
||||||
|
assert(mapEngineEntry, "Der isolierte MapLibre-Chunk fehlt.");
|
||||||
|
const [mapEngineKey, mapEngine] = mapEngineEntry;
|
||||||
|
assertFile(mapEngine.file);
|
||||||
|
assert(
|
||||||
|
!entry.imports?.includes(mapEngineKey),
|
||||||
|
"Der MapLibre-Chunk wird wieder statisch vom App-Einstieg geladen."
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
manifest["src/components/MapView.tsx"].imports?.includes(mapEngineKey),
|
||||||
|
"MapView verweist nicht auf den isolierten MapLibre-Chunk."
|
||||||
|
);
|
||||||
|
const mapViewChunk = manifest["src/components/MapView.tsx"];
|
||||||
|
assert(mapViewChunk.css?.length, "Das MapLibre-Stylesheet ist nicht mehr an MapView gekoppelt.");
|
||||||
|
assert(
|
||||||
|
mapViewChunk.css.every((file) => !entry.css?.includes(file)),
|
||||||
|
"Das MapLibre-Stylesheet wird wieder vom App-Einstieg geladen."
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialChunkKeys = collectStaticImports("index.html");
|
||||||
|
const initialBytes = [...initialChunkKeys].reduce(
|
||||||
|
(total, key) => total + fileSize(manifest[key].file),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
initialBytes <= 350_000,
|
||||||
|
`Initiales JavaScript ist mit ${formatKb(initialBytes)} größer als das Budget von 350 kB.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapEngineBytes = fileSize(mapEngine.file);
|
||||||
|
assert(
|
||||||
|
mapEngineBytes <= 1_100_000,
|
||||||
|
`Der MapLibre-Chunk ist mit ${formatKb(mapEngineBytes)} unerwartet gewachsen.`
|
||||||
|
);
|
||||||
|
for (const chunk of Object.values(manifest)) {
|
||||||
|
if (chunk.file?.endsWith(".js") && chunk.file !== mapEngine.file) {
|
||||||
|
const bytes = fileSize(chunk.file);
|
||||||
|
assert(
|
||||||
|
bytes <= 350_000,
|
||||||
|
`${chunk.file} ist mit ${formatKb(bytes)} zu groß und sollte weiter aufgeteilt werden.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const initialCssBytes = (entry.css ?? []).reduce(
|
||||||
|
(total, file) => total + fileSize(file),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
initialCssBytes <= 40_000,
|
||||||
|
`Initiales CSS ist mit ${formatKb(initialCssBytes)} größer als das Budget von 40 kB.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = readFileSync(path.join(distRoot, "index.html"), "utf8");
|
||||||
|
assert(
|
||||||
|
!html.includes(path.basename(mapEngine.file)),
|
||||||
|
"index.html lädt den dynamischen MapLibre-Chunk per modulepreload."
|
||||||
|
);
|
||||||
|
const serviceWorkerPath = path.join(distRoot, "sw.js");
|
||||||
|
assert(existsSync(serviceWorkerPath), "Der PWA-Service-Worker fehlt.");
|
||||||
|
const serviceWorker = readFileSync(serviceWorkerPath, "utf8");
|
||||||
|
const offlineFiles = [
|
||||||
|
mapEngine.file,
|
||||||
|
...mapViewChunk.css,
|
||||||
|
...expectedDynamicEntries.map((key) => manifest[key].file)
|
||||||
|
];
|
||||||
|
for (const file of offlineFiles) {
|
||||||
|
assert(
|
||||||
|
serviceWorker.includes(file),
|
||||||
|
`${file} fehlt im PWA-Precache und wäre offline nicht zuverlässig verfügbar.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Chunk-Prüfung erfolgreich: initial ${formatKb(initialBytes)} JS + ${formatKb(initialCssBytes)} CSS, Karte ${formatKb(mapEngineBytes)}, ${expectedDynamicEntries.length} dynamische Funktionsmodule.`
|
||||||
|
);
|
||||||
|
|
||||||
|
function collectStaticImports(rootKey) {
|
||||||
|
const collected = new Set();
|
||||||
|
const visit = (key) => {
|
||||||
|
if (collected.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunk = manifest[key];
|
||||||
|
assert(chunk, `Manifest-Verweis ${key} fehlt.`);
|
||||||
|
collected.add(key);
|
||||||
|
for (const dependency of chunk.imports ?? []) {
|
||||||
|
visit(dependency);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(rootKey);
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFile(relativePath) {
|
||||||
|
assert(
|
||||||
|
typeof relativePath === "string" && existsSync(path.join(distRoot, relativePath)),
|
||||||
|
`Chunk-Datei ${relativePath ?? "(unbekannt)"} fehlt.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSize(relativePath) {
|
||||||
|
return statSync(path.join(distRoot, relativePath)).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKb(bytes) {
|
||||||
|
return `${(bytes / 1_000).toFixed(1)} kB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
|||||||
|
import type {
|
||||||
|
AppConfig,
|
||||||
|
Coordinate,
|
||||||
|
MarineForecast,
|
||||||
|
NavigationDataSnapshot,
|
||||||
|
RouteRequest,
|
||||||
|
RouteResult,
|
||||||
|
TideSummary
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
import type { FeatureCollection } from "geojson";
|
||||||
|
|
||||||
|
async function getJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await responseErrorMessage(response));
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseErrorMessage(response: Response) {
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { message?: string; error?: string };
|
||||||
|
return body.message ?? body.error ?? `${response.status} ${response.statusText}`;
|
||||||
|
} catch {
|
||||||
|
return `${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig(): Promise<AppConfig> {
|
||||||
|
return getJson<AppConfig>("/api/config");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMarineForecast(position: Coordinate, at?: string): Promise<MarineForecast> {
|
||||||
|
const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) });
|
||||||
|
if (at) {
|
||||||
|
search.set("at", at);
|
||||||
|
}
|
||||||
|
return getJson<MarineForecast>(`/api/weather/marine?${search.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNearestTide(position: Coordinate, at?: string): Promise<TideSummary> {
|
||||||
|
const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) });
|
||||||
|
if (at) {
|
||||||
|
search.set("at", at);
|
||||||
|
}
|
||||||
|
return getJson<TideSummary>(`/api/tides/nearest?${search.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNavigationData(params: {
|
||||||
|
waterways?: string[];
|
||||||
|
stationIds?: string[];
|
||||||
|
lockIds?: string[];
|
||||||
|
}): Promise<NavigationDataSnapshot> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.waterways?.length) {
|
||||||
|
search.set("waterways", params.waterways.join(","));
|
||||||
|
}
|
||||||
|
if (params.stationIds?.length) {
|
||||||
|
search.set("stationIds", params.stationIds.join(","));
|
||||||
|
}
|
||||||
|
if (params.lockIds?.length) {
|
||||||
|
search.set("lockIds", params.lockIds.join(","));
|
||||||
|
}
|
||||||
|
return getJson<NavigationDataSnapshot>(`/api/navigation/live?${search.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRoute(request: RouteRequest): Promise<RouteResult> {
|
||||||
|
return postJson<RouteResult>("/api/routes", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMapFeatures(params: {
|
||||||
|
bbox: [number, number, number, number];
|
||||||
|
layers: string[];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<FeatureCollection> {
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
bbox: params.bbox.join(","),
|
||||||
|
layers: params.layers.join(",")
|
||||||
|
});
|
||||||
|
return getJson<FeatureCollection>(`/api/features?${search.toString()}`, { signal: params.signal });
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
.anchor-watch-panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: calc(86px + env(safe-area-inset-bottom));
|
||||||
|
width: min(520px, calc(100vw - 20px));
|
||||||
|
max-height: min(74vh, calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 118px));
|
||||||
|
max-height: min(74dvh, calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 118px));
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
border: 2px solid rgba(15, 76, 92, 0.42);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px;
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
background: rgba(246, 249, 247, 0.98);
|
||||||
|
color: #10242b;
|
||||||
|
box-shadow: 0 14px 38px rgba(7, 25, 29, 0.28);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-watch-panel[data-alert="true"] {
|
||||||
|
border-color: #c44a30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-watch-header,
|
||||||
|
.anchor-watch-header > span,
|
||||||
|
.anchor-point-summary,
|
||||||
|
.anchor-gps-readiness,
|
||||||
|
.anchor-inline-warning,
|
||||||
|
.anchor-inline-alert,
|
||||||
|
.anchor-tide-card,
|
||||||
|
.anchor-rode-card,
|
||||||
|
.anchor-primary-action,
|
||||||
|
.anchor-secondary-action,
|
||||||
|
.anchor-acknowledge-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-watch-header {
|
||||||
|
min-height: 38px;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-watch-header > span {
|
||||||
|
gap: 7px;
|
||||||
|
color: #0f4c5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-watch-header button,
|
||||||
|
.anchor-point-summary button {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #e2ece9;
|
||||||
|
color: #23434c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-capture,
|
||||||
|
.anchor-watch-setup,
|
||||||
|
.anchor-watch-active {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-capture > p {
|
||||||
|
margin: 0;
|
||||||
|
color: #334e56;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-gps-readiness,
|
||||||
|
.anchor-point-summary {
|
||||||
|
min-height: 52px;
|
||||||
|
gap: 9px;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #edf3f1;
|
||||||
|
color: #526a72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-gps-readiness[data-ready="true"] {
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-gps-readiness > span,
|
||||||
|
.anchor-point-summary > span {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-gps-readiness strong,
|
||||||
|
.anchor-point-summary strong {
|
||||||
|
color: #16323a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-gps-readiness small,
|
||||||
|
.anchor-point-summary small {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-primary-action,
|
||||||
|
.anchor-secondary-action,
|
||||||
|
.anchor-acknowledge-action,
|
||||||
|
.anchor-stop-action {
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 9px;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-primary-action {
|
||||||
|
background: #0f4c5c;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-secondary-action {
|
||||||
|
background: #e2ece9;
|
||||||
|
color: #23434c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-acknowledge-action {
|
||||||
|
width: 100%;
|
||||||
|
background: #c44a30;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-stop-action {
|
||||||
|
min-width: 150px;
|
||||||
|
background: #f1ded9;
|
||||||
|
color: #8b3024;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-safety-note {
|
||||||
|
display: block;
|
||||||
|
color: #607278;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-settings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-settings label {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 58px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
background: #edf3f1;
|
||||||
|
color: #526a72;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-settings label > span:last-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #16323a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-settings input,
|
||||||
|
.anchor-settings select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #bdcfca;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0 7px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #10242b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-settings[data-compact="true"] {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-planning-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-tide-card,
|
||||||
|
.anchor-rode-card {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 80px;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 7px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-tide-card[data-incomplete="true"],
|
||||||
|
.anchor-tide-card[data-far="true"],
|
||||||
|
.anchor-rode-card[data-state="unknown"] {
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-rode-card[data-state="short"] {
|
||||||
|
background: #ffe1dc;
|
||||||
|
color: #9d2c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-card-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-tide-card > div,
|
||||||
|
.anchor-rode-card > div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-tide-card small,
|
||||||
|
.anchor-rode-card small,
|
||||||
|
.anchor-live-metrics small {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-tide-card strong,
|
||||||
|
.anchor-rode-card strong {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: currentColor;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-tide-card span,
|
||||||
|
.anchor-rode-card span,
|
||||||
|
.anchor-tide-card em {
|
||||||
|
color: currentColor;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-tide-card em {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-spinner {
|
||||||
|
animation: anchor-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes anchor-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-inline-warning,
|
||||||
|
.anchor-inline-alert {
|
||||||
|
margin: 0;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-inline-warning {
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-inline-alert {
|
||||||
|
background: #ffe1dc;
|
||||||
|
color: #9d2c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-setup-actions,
|
||||||
|
.anchor-stop-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.25fr);
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-distance-hero {
|
||||||
|
min-height: 78px;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #0f4c5c;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-distance-hero[data-tone="warning"] {
|
||||||
|
background: #805900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-distance-hero[data-tone="alarm"] {
|
||||||
|
background: #8b3024;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-distance-hero > span {
|
||||||
|
color: #ffce66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-distance-hero > div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-distance-hero small {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-distance-hero strong {
|
||||||
|
font-size: clamp(30px, 10vw, 44px);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-distance-hero em {
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-watch-status {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-watch-status[data-tone="warning"] {
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-watch-status[data-tone="alarm"] {
|
||||||
|
background: #ffe1dc;
|
||||||
|
color: #9d2c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-live-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-live-metrics > span {
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
background: #edf3f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-live-metrics small {
|
||||||
|
color: #607278;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-live-metrics strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: #16323a;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-acknowledged {
|
||||||
|
margin: 0;
|
||||||
|
color: #9d2c22;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-active-details {
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #edf3f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-active-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #23434c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-anchor-watch-active="true"] .data-badge,
|
||||||
|
.app-shell[data-anchor-panel-open="true"] .data-badge {
|
||||||
|
top: calc(env(safe-area-inset-top) + 266px);
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.anchor-watch-panel {
|
||||||
|
left: auto;
|
||||||
|
right: 12px;
|
||||||
|
width: 430px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 390px) {
|
||||||
|
.anchor-planning-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-settings {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchor-live-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.anchor-spinner {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Anchor,
|
||||||
|
BellRing,
|
||||||
|
CheckCircle2,
|
||||||
|
Crosshair,
|
||||||
|
LoaderCircle,
|
||||||
|
MapPin,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Waves,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { GpsState } from "../hooks/useGeolocation";
|
||||||
|
import { type AnchorWatchSettings, useAnchorWatch } from "../hooks/useAnchorWatch";
|
||||||
|
import "./AnchorWatchPanel.css";
|
||||||
|
|
||||||
|
type AnchorWatchModel = ReturnType<typeof useAnchorWatch>;
|
||||||
|
|
||||||
|
export type AnchorWatchPanelProps = {
|
||||||
|
watch: AnchorWatchModel;
|
||||||
|
gps: Pick<GpsState, "status" | "accuracyM" | "timestampMs">;
|
||||||
|
onStartGps: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatchPanelProps) {
|
||||||
|
const [confirmStop, setConfirmStop] = useState(false);
|
||||||
|
const armed = watch.phase === "armed";
|
||||||
|
const hasAlarm = armed && (watch.positionAlarm || watch.rodeShortfall);
|
||||||
|
const distanceM = watch.watchResult?.distanceFromAnchorM ?? null;
|
||||||
|
const nearLimit = Boolean(
|
||||||
|
armed &&
|
||||||
|
!watch.positionAlarm &&
|
||||||
|
distanceM !== null &&
|
||||||
|
distanceM >= watch.settings.alarmRadiusM * 0.8
|
||||||
|
);
|
||||||
|
const status = anchorStatus(watch, gps.status, nearLimit);
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
watch.reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (!confirmStop) {
|
||||||
|
setConfirmStop(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="anchor-watch-panel"
|
||||||
|
aria-label="Ankerwache"
|
||||||
|
data-phase={watch.phase}
|
||||||
|
data-alert={hasAlarm}
|
||||||
|
data-near-limit={nearLimit}
|
||||||
|
>
|
||||||
|
<header className="anchor-watch-header">
|
||||||
|
<span>
|
||||||
|
<Anchor size={19} aria-hidden="true" />
|
||||||
|
<strong>{armed ? "Ankerwache aktiv" : watch.phase === "set" ? "Ankerwache einrichten" : "Ankerwache"}</strong>
|
||||||
|
</span>
|
||||||
|
{!armed && (
|
||||||
|
<button type="button" onClick={cancel} aria-label="Ankerwache schließen">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{watch.phase === "idle" ? (
|
||||||
|
<AnchorCapture watch={watch} gps={gps} onStartGps={onStartGps} />
|
||||||
|
) : armed ? (
|
||||||
|
<AnchorWatchActive
|
||||||
|
watch={watch}
|
||||||
|
status={status}
|
||||||
|
nearLimit={nearLimit}
|
||||||
|
confirmStop={confirmStop}
|
||||||
|
onCancelStop={() => setConfirmStop(false)}
|
||||||
|
onStop={stop}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AnchorWatchSetup watch={watch} onCancel={cancel} />
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnchorCapture({
|
||||||
|
watch,
|
||||||
|
gps,
|
||||||
|
onStartGps
|
||||||
|
}: {
|
||||||
|
watch: AnchorWatchModel;
|
||||||
|
gps: AnchorWatchPanelProps["gps"];
|
||||||
|
onStartGps: () => void;
|
||||||
|
}) {
|
||||||
|
const gpsReady = gps.status === "tracking" && gps.accuracyM !== null;
|
||||||
|
return (
|
||||||
|
<div className="anchor-capture">
|
||||||
|
<p>
|
||||||
|
Setze den Punkt genau dann, wenn der Anker den Grund erreicht. Watermaps verschiebt ihn danach nicht mit dem Boot.
|
||||||
|
</p>
|
||||||
|
<div className="anchor-gps-readiness" data-ready={gpsReady}>
|
||||||
|
<Crosshair size={18} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{gpsReady ? `GPS ±${Math.round(gps.accuracyM ?? 0)} m` : gpsLabel(gps.status)}</strong>
|
||||||
|
<small>Erforderlich: frischer Fix mit höchstens ±{watch.maxCaptureAccuracyM} m</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{gps.status !== "tracking" && (
|
||||||
|
<button className="anchor-secondary-action" type="button" onClick={onStartGps}>
|
||||||
|
<Crosshair size={17} aria-hidden="true" />
|
||||||
|
GPS starten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="anchor-primary-action" type="button" onClick={watch.captureAnchor}>
|
||||||
|
<Anchor size={18} aria-hidden="true" />
|
||||||
|
Anker gefallen – Position jetzt setzen
|
||||||
|
</button>
|
||||||
|
{watch.operationError && <p className="anchor-inline-alert" role="alert">{watch.operationError}</p>}
|
||||||
|
<small className="anchor-safety-note">
|
||||||
|
Keine automatische Ankererkennung: Ein Browser-GPS kann das Fallenlassen nicht zuverlässig erkennen.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCancel: () => void }) {
|
||||||
|
const suggestedReachM = watch.rodePlan
|
||||||
|
? Math.ceil(Math.max(
|
||||||
|
watch.rodePlan.horizontalReachAtSetM,
|
||||||
|
watch.rodePlan.horizontalReachM ?? 0
|
||||||
|
) + 5)
|
||||||
|
: null;
|
||||||
|
const radiusTooSmall = suggestedReachM !== null && watch.settings.alarmRadiusM < suggestedReachM;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="anchor-watch-setup">
|
||||||
|
<div className="anchor-point-summary">
|
||||||
|
<MapPin size={17} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>Ankerpunkt gespeichert</strong>
|
||||||
|
<small>
|
||||||
|
{watch.anchorPoint ? formatCoordinate(watch.anchorPoint) : "–"}
|
||||||
|
{watch.anchorCaptureAccuracyM !== null && ` · GPS ±${Math.round(watch.anchorCaptureAccuracyM)} m`}
|
||||||
|
{watch.anchorSetAtMs !== null && ` · ${formatClock(watch.anchorSetAtMs)}`}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={watch.captureAnchor} aria-label="Ankerpunkt an aktueller Position neu setzen">
|
||||||
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnchorSettingsForm watch={watch} />
|
||||||
|
<TideAndRodeSummary watch={watch} />
|
||||||
|
|
||||||
|
{radiusTooSmall && (
|
||||||
|
<p className="anchor-inline-warning">
|
||||||
|
<AlertTriangle size={15} aria-hidden="true" />
|
||||||
|
Der Radius liegt unter der rechnerischen horizontalen Reichweite von etwa {suggestedReachM} m. Normales Schwojen kann bereits alarmieren; Bootslänge und GPS-Unsicherheit kommen noch hinzu.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{watch.settingsError && <p className="anchor-inline-alert" role="alert">{watch.settingsError}</p>}
|
||||||
|
{watch.operationError && <p className="anchor-inline-alert" role="alert">{watch.operationError}</p>}
|
||||||
|
|
||||||
|
<div className="anchor-setup-actions">
|
||||||
|
<button className="anchor-secondary-action" type="button" onClick={onCancel}>Abbrechen</button>
|
||||||
|
<button className="anchor-primary-action" type="button" onClick={() => void watch.arm()}>
|
||||||
|
<BellRing size={17} aria-hidden="true" />
|
||||||
|
Wache starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small className="anchor-safety-note">
|
||||||
|
Tidendaten und Scope-Rechnung sind Planungshilfen. Grund, Anker, Wind, Wellen, Strom, Schwell und Abstand zu Gefahren müssen vor Ort beurteilt werden.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchModel; compact?: boolean }) {
|
||||||
|
const fields: Array<{
|
||||||
|
key: keyof AnchorWatchSettings;
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
}> = [
|
||||||
|
{ key: "depthAtSetM", label: "Tiefe beim Setzen", unit: "m", min: 0.1, max: 200, step: 0.1 },
|
||||||
|
{ key: "bowRollerHeightM", label: "Bugrolle über Wasser", unit: "m", min: 0, max: 20, step: 0.1 },
|
||||||
|
{ key: "deployedRodeLengthM", label: "Kette / Leine draußen", unit: "m", min: 1, max: 2_000, step: 1 },
|
||||||
|
{ key: "safetyAllowanceM", label: "Wasserstandsreserve", unit: "m", min: 0, max: 10, step: 0.1 },
|
||||||
|
{ key: "alarmRadiusM", label: "Alarmradius ab Anker", unit: "m", min: 10, max: 2_000, step: 5 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="anchor-settings" data-compact={compact}>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<label key={field.key} htmlFor={`anchor-${field.key}`}>
|
||||||
|
<span>{field.label}</span>
|
||||||
|
<span>
|
||||||
|
<input
|
||||||
|
id={`anchor-${field.key}`}
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step}
|
||||||
|
value={watch.settings[field.key]}
|
||||||
|
onChange={(event) => watch.updateSettings({ [field.key]: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
{field.unit}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<label htmlFor="anchor-scopeRatio">
|
||||||
|
<span>Gewähltes Verhältnis</span>
|
||||||
|
<select
|
||||||
|
id="anchor-scopeRatio"
|
||||||
|
value={watch.settings.scopeRatio}
|
||||||
|
onChange={(event) => watch.updateSettings({ scopeRatio: Number(event.target.value) })}
|
||||||
|
>
|
||||||
|
{[3, 4, 5, 6, 7, 8, 10].map((value) => <option key={value} value={value}>{value}:1</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label htmlFor="anchor-horizonHours">
|
||||||
|
<span>Tidenzeitraum</span>
|
||||||
|
<select
|
||||||
|
id="anchor-horizonHours"
|
||||||
|
value={watch.settings.horizonHours}
|
||||||
|
onChange={(event) => watch.updateSettings({ horizonHours: Number(event.target.value) })}
|
||||||
|
>
|
||||||
|
<option value={12}>12 Stunden</option>
|
||||||
|
<option value={24}>24 Stunden</option>
|
||||||
|
<option value={48}>48 Stunden</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TideAndRodeSummary({ watch, active = false }: { watch: AnchorWatchModel; active?: boolean }) {
|
||||||
|
const tideWindow = active ? watch.remainingTideWindow : watch.tideWindow;
|
||||||
|
const plan = watch.rodePlan;
|
||||||
|
const complete = tideWindow?.coverage === "complete" && plan?.calculationComplete;
|
||||||
|
const stationFar = Boolean(watch.tide && watch.tide.distanceKm > 30);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="anchor-planning-summary">
|
||||||
|
<section className="anchor-tide-card" data-incomplete={!complete} data-far={stationFar}>
|
||||||
|
<span className="anchor-card-icon"><Waves size={18} aria-hidden="true" /></span>
|
||||||
|
<div>
|
||||||
|
<small>{active ? "TIDE AB JETZT" : "TIDE AB ANKERSETZEN"}</small>
|
||||||
|
{watch.tideLoading && !watch.tide ? (
|
||||||
|
<strong><LoaderCircle className="anchor-spinner" size={15} aria-hidden="true" /> Wird geladen …</strong>
|
||||||
|
) : tideWindow?.maximumRiseM !== null && tideWindow?.maximumRiseM !== undefined ? (
|
||||||
|
<strong>max. +{tideWindow.maximumRiseM.toFixed(2)} m · Hub {formatNullable(tideWindow.tidalRangeM)} m</strong>
|
||||||
|
) : (
|
||||||
|
<strong>Nicht berechenbar</strong>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{watch.tide
|
||||||
|
? `${watch.tide.station} · ${watch.tide.distanceKm.toFixed(1)} km entfernt · Stand ${formatUpdatedAt(watch.tide.updatedAt)}`
|
||||||
|
: watch.tideError
|
||||||
|
? "Stationsprognose nicht erreichbar – nicht als 0 m angesetzt"
|
||||||
|
: "Warte auf Stationsprognose"}
|
||||||
|
</span>
|
||||||
|
{tideWindow?.coverage === "partial" && <em>Prognose deckt den gewählten Zeitraum nur teilweise ab.</em>}
|
||||||
|
{stationFar && <em>Entfernter Pegel: lokale Tide kann deutlich abweichen.</em>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="anchor-rode-card"
|
||||||
|
data-state={!plan?.calculationComplete ? "unknown" : plan.hasSufficientRode ? "safe" : "short"}
|
||||||
|
>
|
||||||
|
<span className="anchor-card-icon">
|
||||||
|
{plan?.calculationComplete && plan.hasSufficientRode
|
||||||
|
? <ShieldCheck size={18} aria-hidden="true" />
|
||||||
|
: <AlertTriangle size={18} aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<small>ANKERLEINEN-RESERVE</small>
|
||||||
|
{plan?.calculationComplete && plan.requiredRodeLengthM !== null && plan.rodeReserveM !== null ? (
|
||||||
|
<>
|
||||||
|
<strong>{plan.rodeReserveM >= 0 ? "+" : ""}{plan.rodeReserveM.toFixed(1)} m Reserve</strong>
|
||||||
|
<span>Rechnerisch {plan.requiredRodeLengthM.toFixed(1)} m bei {watch.settings.scopeRatio}:1 erforderlich</span>
|
||||||
|
</>
|
||||||
|
) : plan ? (
|
||||||
|
<>
|
||||||
|
<strong>Nicht bestätigt</strong>
|
||||||
|
<span>Ohne vollständige Tide mindestens {plan.minimumRequiredRodeLengthM.toFixed(1)} m; Zukunftsbedarf offen</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<strong>Eingaben prüfen</strong>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnchorWatchActive({
|
||||||
|
watch,
|
||||||
|
status,
|
||||||
|
nearLimit,
|
||||||
|
confirmStop,
|
||||||
|
onCancelStop,
|
||||||
|
onStop
|
||||||
|
}: {
|
||||||
|
watch: AnchorWatchModel;
|
||||||
|
status: { tone: string; title: string; detail: string };
|
||||||
|
nearLimit: boolean;
|
||||||
|
confirmStop: boolean;
|
||||||
|
onCancelStop: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
}) {
|
||||||
|
const distance = watch.watchResult?.distanceFromAnchorM;
|
||||||
|
const elapsedMs = watch.anchorSetAtMs === null ? 0 : Math.max(0, Date.now() - watch.anchorSetAtMs);
|
||||||
|
const remainingWindow = watch.remainingTideWindow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="anchor-watch-active">
|
||||||
|
<section className="anchor-distance-hero" data-tone={status.tone} aria-live="polite">
|
||||||
|
<span>
|
||||||
|
{status.tone === "safe" ? <CheckCircle2 size={26} aria-hidden="true" /> : <AlertTriangle size={26} aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<small>ABSTAND / ALARMRADIUS</small>
|
||||||
|
<strong>{distance === null || distance === undefined ? "---" : Math.round(distance)} <em>/ {Math.round(watch.settings.alarmRadiusM)} m</em></strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="anchor-watch-status" data-tone={status.tone} role={watch.positionAlarm ? "alert" : "status"}>
|
||||||
|
<strong>{status.title}</strong>
|
||||||
|
<span>{status.detail}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="anchor-live-metrics">
|
||||||
|
<span><small>GPS</small><strong>{watch.watchResult?.accuracyM === null || watch.watchResult?.accuracyM === undefined ? "--" : `±${Math.round(watch.watchResult.accuracyM)} m`}</strong></span>
|
||||||
|
<span><small>SEIT</small><strong>{formatDuration(elapsedMs)}</strong></span>
|
||||||
|
<span><small>TIDE NOCH</small><strong>{remainingWindow?.coverage === "complete" && remainingWindow.maximumRiseM !== null ? `+${remainingWindow.maximumRiseM.toFixed(2)} m` : "offen"}</strong></span>
|
||||||
|
<span><small>LEINE</small><strong>{watch.rodePlan?.rodeReserveM === null || watch.rodePlan?.rodeReserveM === undefined ? "offen" : `${watch.rodePlan.rodeReserveM >= 0 ? "+" : ""}${watch.rodePlan.rodeReserveM.toFixed(1)} m`}</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{watch.positionAlarm && !watch.alarmAcknowledged && (
|
||||||
|
<button className="anchor-acknowledge-action" type="button" onClick={watch.acknowledgeAlarm}>
|
||||||
|
<BellRing size={17} aria-hidden="true" />
|
||||||
|
Alarm quittieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{watch.positionAlarm && watch.alarmAcknowledged && (
|
||||||
|
<p className="anchor-acknowledged">Alarmton quittiert · rote Warnanzeige bleibt aktiv</p>
|
||||||
|
)}
|
||||||
|
{watch.rodeShortfall && (
|
||||||
|
<p className="anchor-inline-alert" role="alert">
|
||||||
|
Nach der aktuellen Stationsprognose ist die eingegebene Kette/Leine rechnerisch zu kurz.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{nearLimit && <p className="anchor-inline-warning">80 % des Alarmradius erreicht.</p>}
|
||||||
|
|
||||||
|
<details className="anchor-active-details">
|
||||||
|
<summary>Radius, Tide und Leinenrechnung</summary>
|
||||||
|
<AnchorSettingsForm watch={watch} compact />
|
||||||
|
<TideAndRodeSummary watch={watch} active />
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="anchor-stop-actions">
|
||||||
|
{confirmStop && <button className="anchor-secondary-action" type="button" onClick={onCancelStop}>Weiter überwachen</button>}
|
||||||
|
<button className="anchor-stop-action" type="button" onClick={onStop}>
|
||||||
|
{confirmStop ? "Wirklich beenden" : "Ankerwache beenden"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small className="anchor-safety-note">
|
||||||
|
App sichtbar und Display an lassen. Browser und Betriebssystem können GPS, Ton und Mitteilungen im Hintergrund anhalten. Watermaps ersetzt keine Ankerpeilung und keinen Ausguck.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchorStatus(watch: AnchorWatchModel, gpsStatus: GpsState["status"], nearLimit: boolean) {
|
||||||
|
if (watch.fixStale) {
|
||||||
|
return { tone: "alarm", title: "GPS-Fix veraltet", detail: "Die Ankerposition wird gerade nicht sicher überwacht." };
|
||||||
|
}
|
||||||
|
if (gpsStatus !== "tracking") {
|
||||||
|
return { tone: "alarm", title: "GPS ausgefallen", detail: "Position prüfen und GPS-Berechtigung wiederherstellen." };
|
||||||
|
}
|
||||||
|
if (watch.gpsUnreliable) {
|
||||||
|
return { tone: "alarm", title: "GPS zu ungenau", detail: "Keine sichere Aussage zum Schwojradius möglich." };
|
||||||
|
}
|
||||||
|
if (watch.watchResult?.alarmTriggered) {
|
||||||
|
return {
|
||||||
|
tone: "alarm",
|
||||||
|
title: "Außerhalb des Alarmradius",
|
||||||
|
detail: `Auch nach Abzug der GPS-Ungenauigkeit noch ${Math.round(watch.watchResult.conservativeDistanceFromAnchorM ?? 0)} m vom Ankerpunkt.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (nearLimit) {
|
||||||
|
return { tone: "warning", title: "Nahe am Alarmradius", detail: "Position und Peilmarken aufmerksam beobachten." };
|
||||||
|
}
|
||||||
|
return { tone: "safe", title: "Im überwachten Schwojkreis", detail: "Abstand wird mit jedem neuen GPS-Fix geprüft." };
|
||||||
|
}
|
||||||
|
|
||||||
|
function gpsLabel(status: GpsState["status"]) {
|
||||||
|
if (status === "requesting") return "GPS-Freigabe wird angefragt";
|
||||||
|
if (status === "denied") return "GPS-Freigabe abgelehnt";
|
||||||
|
if (status === "unavailable") return "GPS nicht verfügbar";
|
||||||
|
if (status === "error") return "GPS-Fehler";
|
||||||
|
return "GPS noch nicht gestartet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(coordinate: { lat: number; lon: number }) {
|
||||||
|
return `${coordinate.lat.toFixed(5)}, ${coordinate.lon.toFixed(5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(timestampMs: number) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(timestampMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpdatedAt(value: string) {
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
return Number.isFinite(timestamp) ? formatClock(timestamp) : "unbekannt";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(milliseconds: number) {
|
||||||
|
const totalMinutes = Math.floor(milliseconds / 60_000);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNullable(value: number | null) {
|
||||||
|
return value === null ? "–" : value.toFixed(2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Compass } from "lucide-react";
|
||||||
|
|
||||||
|
type CompassDialProps = {
|
||||||
|
headingDeg: number | null;
|
||||||
|
source: string;
|
||||||
|
status: string;
|
||||||
|
targetHeadingDeg?: number | null;
|
||||||
|
onRequest: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompassDial({ headingDeg, source, status, targetHeadingDeg = null, onRequest }: CompassDialProps) {
|
||||||
|
const displayHeading = headingDeg ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="compass-dial"
|
||||||
|
type="button"
|
||||||
|
title="Kompass aktivieren"
|
||||||
|
aria-label={
|
||||||
|
targetHeadingDeg === null
|
||||||
|
? "Kompass aktivieren"
|
||||||
|
: `Kompass aktivieren, Sollkurs ${Math.round(targetHeadingDeg)} Grad`
|
||||||
|
}
|
||||||
|
onClick={onRequest}
|
||||||
|
data-status={status}
|
||||||
|
>
|
||||||
|
<span className="compass-ring">
|
||||||
|
{targetHeadingDeg !== null && (
|
||||||
|
<span
|
||||||
|
className="compass-course-marker"
|
||||||
|
style={{ transform: `rotate(${targetHeadingDeg}deg)` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="compass-needle" style={{ transform: `rotate(${displayHeading}deg)` }} />
|
||||||
|
<Compass size={18} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="compass-value">{headingDeg === null ? "--" : Math.round(headingDeg)}</span>
|
||||||
|
<span className="compass-source">{source}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
/* Weather and tide --------------------------------------------------------- */
|
||||||
|
|
||||||
|
.conditions-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-header > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-header h2 {
|
||||||
|
margin: 3px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-kicker,
|
||||||
|
.conditions-position-source,
|
||||||
|
.conditions-section-heading,
|
||||||
|
.conditions-station,
|
||||||
|
.conditions-panel-state,
|
||||||
|
.conditions-data-provenance,
|
||||||
|
.upcoming-events-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-kicker {
|
||||||
|
gap: 6px;
|
||||||
|
color: #0f4c5c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-position-source {
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 28px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 9px;
|
||||||
|
gap: 5px;
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-position-source[data-source="fallback"],
|
||||||
|
.conditions-position-source[data-source="unknown"] {
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-close {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #e2ece9;
|
||||||
|
color: #23434c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-state {
|
||||||
|
min-height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 7px;
|
||||||
|
background: #edf3f1;
|
||||||
|
color: #526a72;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-state[data-state="warning"] {
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-state[data-state="empty"] {
|
||||||
|
border: 1px dashed #b9cbc7;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-section {
|
||||||
|
border: 1px solid rgba(15, 76, 92, 0.12);
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
background: rgba(237, 243, 241, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-section-heading {
|
||||||
|
margin: 0;
|
||||||
|
gap: 7px;
|
||||||
|
color: #17343c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-metric-grid,
|
||||||
|
.conditions-route-sample > dl {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-metric {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 58px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-metric dt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #607278;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-metric dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #16323a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-data-provenance {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0;
|
||||||
|
gap: 3px 10px;
|
||||||
|
color: #607278;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-station {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0;
|
||||||
|
gap: 5px;
|
||||||
|
color: #526a72;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-station strong {
|
||||||
|
color: #17343c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-tide-events {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-tide-event {
|
||||||
|
min-height: 62px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-tide-event dt {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-tide-event dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
color: #526a72;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-tide-event time {
|
||||||
|
color: #17343c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-assessment {
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-assessment[data-severity="caution"] {
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-assessment[data-severity="critical"] {
|
||||||
|
background: #ffe1dc;
|
||||||
|
color: #9d2c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-assessment > p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-assessment .conditions-data-provenance {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-samples {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-sample {
|
||||||
|
border: 1px solid rgba(15, 76, 92, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-sample > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-sample h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #17343c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-sample > header time {
|
||||||
|
color: #607278;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-sample .conditions-metric {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-sample .conditions-metric:nth-child(3) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-tide {
|
||||||
|
border-top: 1px solid rgba(15, 76, 92, 0.1);
|
||||||
|
padding-top: 7px;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
color: #526a72;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-route-tide strong {
|
||||||
|
color: #17343c;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-inline-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: #607278;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-panel-disclaimer {
|
||||||
|
margin: 0;
|
||||||
|
color: #607278;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CloudSun,
|
||||||
|
MapPin,
|
||||||
|
Navigation2,
|
||||||
|
Thermometer,
|
||||||
|
Waves,
|
||||||
|
Wind,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useId, type ReactNode } from "react";
|
||||||
|
import type { MarineForecast, TideEvent, TideSummary } from "@watermaps/shared";
|
||||||
|
import "./ConditionsPanel.css";
|
||||||
|
|
||||||
|
export type ConditionsPositionSource = {
|
||||||
|
kind: "gps" | "fallback" | "unknown";
|
||||||
|
label?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConditionsRouteWeatherSample = {
|
||||||
|
label: "Start" | "Mitte" | "Ziel";
|
||||||
|
forecast: MarineForecast;
|
||||||
|
plannedTime?: string;
|
||||||
|
currentAlongRouteKn?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately narrower than RouteWeatherReport. The complete report remains
|
||||||
|
* assignable while this panel stays independent of the report generator.
|
||||||
|
*/
|
||||||
|
export type ConditionsRouteWeatherReport = {
|
||||||
|
samples: ReadonlyArray<ConditionsRouteWeatherSample>;
|
||||||
|
severity?: "ok" | "caution" | "critical";
|
||||||
|
summary?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
unavailableSamples?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConditionsRouteTides = {
|
||||||
|
start: TideSummary | null;
|
||||||
|
middle?: TideSummary | null;
|
||||||
|
destination: TideSummary | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConditionsPanelProps = {
|
||||||
|
forecast: MarineForecast | null;
|
||||||
|
tide: TideSummary | null;
|
||||||
|
positionSource?: ConditionsPositionSource;
|
||||||
|
currentLoading?: boolean;
|
||||||
|
currentError?: string | null;
|
||||||
|
routeWeatherReport?: ConditionsRouteWeatherReport | null;
|
||||||
|
routeTides?: ConditionsRouteTides | null;
|
||||||
|
routeLoading?: boolean;
|
||||||
|
routeError?: string | null;
|
||||||
|
onClose?: () => void;
|
||||||
|
className?: string;
|
||||||
|
/** Optional clock for deterministic consumers and tests. */
|
||||||
|
now?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROUTE_SAMPLE_LABELS = ["Start", "Mitte", "Ziel"] as const;
|
||||||
|
|
||||||
|
export function ConditionsPanel({
|
||||||
|
forecast,
|
||||||
|
tide,
|
||||||
|
positionSource = { kind: "unknown" },
|
||||||
|
currentLoading = false,
|
||||||
|
currentError = null,
|
||||||
|
routeWeatherReport = null,
|
||||||
|
routeTides = null,
|
||||||
|
routeLoading = false,
|
||||||
|
routeError = null,
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
now = Date.now()
|
||||||
|
}: ConditionsPanelProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
const currentTitleId = useId();
|
||||||
|
const tideTitleId = useId();
|
||||||
|
const routeTitleId = useId();
|
||||||
|
const hasCurrentData = Boolean(forecast || tide);
|
||||||
|
const hasRouteData = Boolean(routeWeatherReport || routeTides);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={joinClassNames("conditions-panel", className)}
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-busy={currentLoading || routeLoading}
|
||||||
|
data-position-source={positionSource.kind}
|
||||||
|
>
|
||||||
|
<header className="conditions-panel-header">
|
||||||
|
<div>
|
||||||
|
<span className="conditions-panel-kicker">
|
||||||
|
<CloudSun size={16} aria-hidden="true" />
|
||||||
|
Bedingungen
|
||||||
|
</span>
|
||||||
|
<h2 id={titleId}>Wetter & Tide</h2>
|
||||||
|
<PositionSource source={positionSource} />
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
className="conditions-panel-close"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Wetter und Tide schließen"
|
||||||
|
title="Schließen"
|
||||||
|
>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{currentLoading && (
|
||||||
|
<p className="conditions-panel-state" role="status" aria-live="polite">
|
||||||
|
Bedingungen für die aktuelle Position werden geladen …
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{currentError && (
|
||||||
|
<p className="conditions-panel-state" data-state="warning" role="alert">
|
||||||
|
<AlertTriangle size={15} aria-hidden="true" />
|
||||||
|
{currentError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!hasCurrentData && !currentLoading && !currentError && (
|
||||||
|
<p className="conditions-panel-state" data-state="empty">
|
||||||
|
Für diese Position liegen noch keine Wetter- oder Tidendaten vor.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(forecast || (hasCurrentData && !currentLoading)) && (
|
||||||
|
<section className="conditions-section" aria-labelledby={currentTitleId}>
|
||||||
|
<SectionHeading id={currentTitleId} icon={<Wind size={16} aria-hidden="true" />}>
|
||||||
|
Aktuelle Bedingungen
|
||||||
|
</SectionHeading>
|
||||||
|
|
||||||
|
{forecast ? (
|
||||||
|
<>
|
||||||
|
<dl className="conditions-metric-grid">
|
||||||
|
<Metric
|
||||||
|
icon={<Wind size={15} aria-hidden="true" />}
|
||||||
|
label="Wind"
|
||||||
|
value={formatWind(forecast)}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={<Waves size={15} aria-hidden="true" />}
|
||||||
|
label="Welle"
|
||||||
|
value={formatWave(forecast)}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={<Navigation2 size={15} aria-hidden="true" />}
|
||||||
|
label="Strömung"
|
||||||
|
value={formatCurrent(forecast)}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
icon={<Thermometer size={15} aria-hidden="true" />}
|
||||||
|
label="Temperatur"
|
||||||
|
value={formatTemperature(forecast.temperatureC)}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
<DataProvenance
|
||||||
|
source={forecast.source}
|
||||||
|
updatedAt={forecast.updatedAt}
|
||||||
|
validAt={forecast.forecastTime}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="conditions-inline-empty">Aktuelle Wetter- und Strömungsdaten fehlen.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(tide || (hasCurrentData && !currentLoading)) && (
|
||||||
|
<section className="conditions-section" aria-labelledby={tideTitleId}>
|
||||||
|
<SectionHeading id={tideTitleId} icon={<Waves size={16} aria-hidden="true" />}>
|
||||||
|
Tide an der Position
|
||||||
|
</SectionHeading>
|
||||||
|
|
||||||
|
{tide ? (
|
||||||
|
<>
|
||||||
|
<p className="conditions-station">
|
||||||
|
<MapPin size={14} aria-hidden="true" />
|
||||||
|
<strong>{safeText(tide.station, "Station unbekannt")}</strong>
|
||||||
|
<span>{formatDistanceKm(tide.distanceKm)} entfernt</span>
|
||||||
|
</p>
|
||||||
|
<dl className="conditions-tide-events">
|
||||||
|
<TideEventRow label="Nächstes Hochwasser" shortLabel="HW" event={tide.nextHigh} />
|
||||||
|
<TideEventRow label="Nächstes Niedrigwasser" shortLabel="NW" event={tide.nextLow} />
|
||||||
|
</dl>
|
||||||
|
<DataProvenance source={tide.source} updatedAt={tide.updatedAt} now={now} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="conditions-inline-empty">Für diese Position fehlt eine passende Tidenstation.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="conditions-section conditions-route" aria-labelledby={routeTitleId}>
|
||||||
|
<SectionHeading id={routeTitleId} icon={<Navigation2 size={16} aria-hidden="true" />}>
|
||||||
|
Bedingungen auf der Strecke
|
||||||
|
</SectionHeading>
|
||||||
|
|
||||||
|
{routeLoading && (
|
||||||
|
<p className="conditions-panel-state" role="status" aria-live="polite">
|
||||||
|
Streckenprognose wird geladen …
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{routeError && (
|
||||||
|
<p className="conditions-panel-state" data-state="warning" role="alert">
|
||||||
|
<AlertTriangle size={15} aria-hidden="true" />
|
||||||
|
{routeError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{routeWeatherReport && (
|
||||||
|
<RouteAssessment report={routeWeatherReport} now={now} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasRouteData ? (
|
||||||
|
<div className="conditions-route-samples">
|
||||||
|
{ROUTE_SAMPLE_LABELS.map((label) => (
|
||||||
|
<RouteSampleCard
|
||||||
|
key={label}
|
||||||
|
label={label}
|
||||||
|
sample={routeWeatherReport?.samples.find((candidate) => candidate.label === label) ?? null}
|
||||||
|
tide={
|
||||||
|
label === "Start"
|
||||||
|
? routeTides?.start ?? null
|
||||||
|
: label === "Mitte"
|
||||||
|
? routeTides?.middle ?? null
|
||||||
|
: label === "Ziel"
|
||||||
|
? routeTides?.destination ?? null
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
routeTidesAvailable={Boolean(routeTides)}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!routeLoading && (
|
||||||
|
<p className="conditions-panel-state" data-state="empty">
|
||||||
|
Nach der Routenberechnung erscheinen hier Start, Mitte und Ziel.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="conditions-panel-disclaimer">
|
||||||
|
Prognosen und entfernte Tidenstationen können lokal abweichen. Amtliche Warnungen, Pegel und
|
||||||
|
Befahrensregeln zusätzlich prüfen.
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionSource({ source }: { source: ConditionsPositionSource }) {
|
||||||
|
const label =
|
||||||
|
source.kind === "gps"
|
||||||
|
? source.label
|
||||||
|
? `GPS · ${source.label}`
|
||||||
|
: "Aktuelle GPS-Position"
|
||||||
|
: source.kind === "fallback"
|
||||||
|
? source.label
|
||||||
|
? `Fallback · ${source.label}`
|
||||||
|
: "Fallback-Position"
|
||||||
|
: source.label || "Positionsquelle noch offen";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="conditions-position-source" data-source={source.kind}>
|
||||||
|
{source.kind === "gps" ? (
|
||||||
|
<Navigation2 size={14} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<MapPin size={14} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>{label}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeading({
|
||||||
|
id,
|
||||||
|
icon,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<h3 id={id} className="conditions-section-heading">
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="conditions-metric">
|
||||||
|
<dt>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd>{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TideEventRow({
|
||||||
|
label,
|
||||||
|
shortLabel,
|
||||||
|
event
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
shortLabel: "HW" | "NW";
|
||||||
|
event: TideEvent | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="conditions-tide-event">
|
||||||
|
<dt>
|
||||||
|
<abbr title={label}>{shortLabel}</abbr>
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{event ? (
|
||||||
|
<>
|
||||||
|
{isValidDate(event.time) ? (
|
||||||
|
<time dateTime={event.time}>{formatDateTime(event.time)}</time>
|
||||||
|
) : (
|
||||||
|
<span>Zeit offen</span>
|
||||||
|
)}
|
||||||
|
<span>{formatTideHeight(event.heightM)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>Zeit und Höhe offen</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataProvenance({
|
||||||
|
source,
|
||||||
|
updatedAt,
|
||||||
|
validAt,
|
||||||
|
now
|
||||||
|
}: {
|
||||||
|
source?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
validAt?: string | null;
|
||||||
|
now: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p className="conditions-data-provenance">
|
||||||
|
<span>Quelle: {safeText(source, "nicht angegeben")}</span>
|
||||||
|
<span>
|
||||||
|
Stand:{" "}
|
||||||
|
{updatedAt && isValidDate(updatedAt) ? (
|
||||||
|
<time dateTime={updatedAt} title={formatDateTime(updatedAt)}>
|
||||||
|
{formatAge(updatedAt, now)}
|
||||||
|
</time>
|
||||||
|
) : (
|
||||||
|
"Zeit unbekannt"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{validAt && isValidDate(validAt) && (
|
||||||
|
<span>
|
||||||
|
Gültig: <time dateTime={validAt}>{formatDateTime(validAt)}</time>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteAssessment({
|
||||||
|
report,
|
||||||
|
now
|
||||||
|
}: {
|
||||||
|
report: ConditionsRouteWeatherReport;
|
||||||
|
now: number;
|
||||||
|
}) {
|
||||||
|
const unavailableSamples = finiteNumber(report.unavailableSamples);
|
||||||
|
const hasUnavailableSamples = unavailableSamples !== null && unavailableSamples > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="conditions-route-assessment" data-severity={report.severity ?? "unknown"}>
|
||||||
|
{(report.severity || report.summary) && (
|
||||||
|
<p>
|
||||||
|
{report.severity && <strong>{severityLabel(report.severity)}: </strong>}
|
||||||
|
{report.summary || "Streckenbedingungen teilweise verfügbar."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hasUnavailableSamples && (
|
||||||
|
<p className="conditions-panel-state" data-state="warning">
|
||||||
|
<AlertTriangle size={14} aria-hidden="true" />
|
||||||
|
{unavailableSamples === 1
|
||||||
|
? "Für einen Streckenpunkt fehlt die Prognose."
|
||||||
|
: `Für ${unavailableSamples} Streckenpunkte fehlt die Prognose.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(report.source || report.updatedAt) && (
|
||||||
|
<DataProvenance source={report.source} updatedAt={report.updatedAt} now={now} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteSampleCard({
|
||||||
|
label,
|
||||||
|
sample,
|
||||||
|
tide,
|
||||||
|
routeTidesAvailable,
|
||||||
|
now
|
||||||
|
}: {
|
||||||
|
label: (typeof ROUTE_SAMPLE_LABELS)[number];
|
||||||
|
sample: ConditionsRouteWeatherSample | null;
|
||||||
|
tide: TideSummary | null;
|
||||||
|
routeTidesAvailable: boolean;
|
||||||
|
now: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className="conditions-route-sample" data-sample={label.toLowerCase()}>
|
||||||
|
<header>
|
||||||
|
<h4>{label}</h4>
|
||||||
|
{sample?.plannedTime && isValidDate(sample.plannedTime) && (
|
||||||
|
<time dateTime={sample.plannedTime}>{formatDateTime(sample.plannedTime)}</time>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{sample ? (
|
||||||
|
<>
|
||||||
|
<dl>
|
||||||
|
<Metric label="Wind" value={formatWind(sample.forecast)} icon={<Wind size={14} aria-hidden="true" />} />
|
||||||
|
<Metric label="Welle" value={formatWave(sample.forecast)} icon={<Waves size={14} aria-hidden="true" />} />
|
||||||
|
<Metric
|
||||||
|
label="Strom"
|
||||||
|
value={formatRouteCurrent(sample)}
|
||||||
|
icon={<Navigation2 size={14} aria-hidden="true" />}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
<DataProvenance
|
||||||
|
source={sample.forecast.source}
|
||||||
|
updatedAt={sample.forecast.updatedAt}
|
||||||
|
validAt={sample.forecast.forecastTime}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="conditions-inline-empty">Keine Wetterprognose für diesen Streckenpunkt.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tide ? (
|
||||||
|
<div className="conditions-route-tide">
|
||||||
|
<strong>
|
||||||
|
{safeText(tide.station, "Tidenstation")} · {formatDistanceKm(tide.distanceKm)}
|
||||||
|
</strong>
|
||||||
|
<span>{formatCompactTideEvent("HW", tide.nextHigh)}</span>
|
||||||
|
<span>{formatCompactTideEvent("NW", tide.nextLow)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
routeTidesAvailable && (
|
||||||
|
<p className="conditions-inline-empty">Keine passende Tide für {label.toLowerCase()}.</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWind(forecast: MarineForecast) {
|
||||||
|
return joinMeasurements(
|
||||||
|
formatMeasurement(forecast.windSpeed, "kn", 0),
|
||||||
|
formatDirection(forecast.windDirectionDeg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWave(forecast: MarineForecast) {
|
||||||
|
return joinMeasurements(
|
||||||
|
formatMeasurement(forecast.waveHeightM, "m", 1),
|
||||||
|
formatMeasurement(forecast.wavePeriodS, "s", 0),
|
||||||
|
formatDirection(forecast.waveDirectionDeg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrent(forecast: MarineForecast) {
|
||||||
|
return joinMeasurements(
|
||||||
|
formatMeasurement(forecast.oceanCurrentSpeedKn, "kn", 1),
|
||||||
|
formatDirection(forecast.oceanCurrentDirectionDeg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRouteCurrent(sample: ConditionsRouteWeatherSample) {
|
||||||
|
const alongRoute = finiteNumber(sample.currentAlongRouteKn);
|
||||||
|
if (alongRoute !== null) {
|
||||||
|
const sign = alongRoute > 0 ? "+" : "";
|
||||||
|
return `${sign}${alongRoute.toFixed(1)} kn entlang Route`;
|
||||||
|
}
|
||||||
|
return formatCurrent(sample.forecast);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTemperature(value: number | null | undefined) {
|
||||||
|
const normalized = finiteNumber(value);
|
||||||
|
return normalized === null ? "Keine Daten" : `${normalized.toFixed(1)} °C`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMeasurement(value: number | null | undefined, unit: string, digits: number) {
|
||||||
|
const normalized = finiteNumber(value);
|
||||||
|
return normalized === null ? null : `${normalized.toFixed(digits)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDirection(value: number | null | undefined) {
|
||||||
|
const normalized = finiteNumber(value);
|
||||||
|
if (normalized === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const heading = ((normalized % 360) + 360) % 360;
|
||||||
|
const cardinal = ["N", "NO", "O", "SO", "S", "SW", "W", "NW"][
|
||||||
|
Math.round(heading / 45) % 8
|
||||||
|
];
|
||||||
|
return `${String(Math.round(heading)).padStart(3, "0")}° ${cardinal}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistanceKm(value: number) {
|
||||||
|
const normalized = finiteNumber(value);
|
||||||
|
return normalized === null
|
||||||
|
? "Entfernung unbekannt"
|
||||||
|
: `${normalized.toLocaleString("de-DE", { maximumFractionDigits: 1 })} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTideHeight(value: number | null | undefined) {
|
||||||
|
const normalized = finiteNumber(value);
|
||||||
|
return normalized === null ? "Höhe offen" : `${normalized.toFixed(2)} m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactTideEvent(label: "HW" | "NW", event: TideEvent | null) {
|
||||||
|
if (!event) {
|
||||||
|
return `${label} offen`;
|
||||||
|
}
|
||||||
|
return `${label} ${formatDateTime(event.time)} · ${formatTideHeight(event.heightM)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isFinite(date.getTime())
|
||||||
|
? date.toLocaleString("de-DE", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
})
|
||||||
|
: "Zeit offen";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(value: string, now: number) {
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (!Number.isFinite(timestamp) || !Number.isFinite(now)) {
|
||||||
|
return "Zeit unbekannt";
|
||||||
|
}
|
||||||
|
if (timestamp - now > 60_000) {
|
||||||
|
return formatDateTime(value);
|
||||||
|
}
|
||||||
|
const ageMs = Math.max(0, now - timestamp);
|
||||||
|
const minutes = Math.floor(ageMs / 60_000);
|
||||||
|
if (minutes < 1) {
|
||||||
|
return "gerade aktualisiert";
|
||||||
|
}
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `vor ${minutes} Min.`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 48) {
|
||||||
|
return `vor ${hours} Std.`;
|
||||||
|
}
|
||||||
|
return `vor ${Math.floor(hours / 24)} Tagen`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityLabel(severity: NonNullable<ConditionsRouteWeatherReport["severity"]>) {
|
||||||
|
switch (severity) {
|
||||||
|
case "critical":
|
||||||
|
return "Kritisch";
|
||||||
|
case "caution":
|
||||||
|
return "Achtung";
|
||||||
|
case "ok":
|
||||||
|
return "Unauffällig";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinMeasurements(...parts: Array<string | null>) {
|
||||||
|
const available = parts.filter((part): part is string => Boolean(part));
|
||||||
|
return available.length > 0 ? available.join(" · ") : "Keine Daten";
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: number | null | undefined) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDate(value: string) {
|
||||||
|
return Number.isFinite(Date.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeText(value: string | null | undefined, fallback: string) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinClassNames(...values: Array<string | null | undefined | false>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
.course-assistant-panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 8;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: calc(86px + env(safe-area-inset-bottom));
|
||||||
|
width: min(430px, calc(100vw - 20px));
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 2px solid rgba(15, 76, 92, 0.42);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
background: rgba(246, 249, 247, 0.97);
|
||||||
|
color: #10242b;
|
||||||
|
box-shadow: 0 14px 38px rgba(7, 25, 29, 0.28);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-panel[data-alert="true"] {
|
||||||
|
border-color: #c44a30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-header,
|
||||||
|
.course-assistant-header > span,
|
||||||
|
.course-assistant-header button,
|
||||||
|
.course-assistant-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-header {
|
||||||
|
min-height: 36px;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-header > span {
|
||||||
|
gap: 7px;
|
||||||
|
color: #0f4c5c;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-header button {
|
||||||
|
min-width: 72px;
|
||||||
|
min-height: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #e2ece9;
|
||||||
|
color: #23434c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-main {
|
||||||
|
min-height: 72px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 54px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: #0f4c5c;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-panel[data-alert="true"] .course-assistant-main {
|
||||||
|
background: #7c3327;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-arrow {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
color: #ffce66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-arrow[data-muted="true"] {
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-arrow svg {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-course {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: baseline;
|
||||||
|
column-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-course > span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-course strong {
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
grid-column: 2;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: clamp(29px, 9vw, 42px);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-course small {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #d9edeb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-status,
|
||||||
|
.course-assistant-turn,
|
||||||
|
.course-assistant-safety {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-status {
|
||||||
|
min-height: 30px;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-panel[data-alert="true"] .course-assistant-status {
|
||||||
|
background: #ffe1dc;
|
||||||
|
color: #9d2c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-metrics > span {
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
background: #edf3f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-metrics small {
|
||||||
|
color: #607278;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-metrics strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: #16323a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-progress {
|
||||||
|
height: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #d5e1dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-progress > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #d89c28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-turn {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-safety {
|
||||||
|
color: #607278;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-guidance-active="true"] .route-panel-toggle {
|
||||||
|
top: calc(env(safe-area-inset-top) + 62px);
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-guidance-active="true"] .data-badge {
|
||||||
|
top: calc(env(safe-area-inset-top) + 266px);
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.course-assistant-panel {
|
||||||
|
left: auto;
|
||||||
|
right: 12px;
|
||||||
|
width: 390px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 680px) {
|
||||||
|
.course-assistant-safety {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-assistant-panel {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.course-assistant-arrow {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { AlertTriangle, Navigation, Square } from "lucide-react";
|
||||||
|
import type { RouteGuidanceResult } from "@watermaps/shared";
|
||||||
|
import "./CourseAssistantPanel.css";
|
||||||
|
|
||||||
|
export type CourseAssistantPanelProps = {
|
||||||
|
guidance: RouteGuidanceResult | null;
|
||||||
|
gpsStatus: string;
|
||||||
|
headingDeg: number | null;
|
||||||
|
headingSource: "COG" | "HDG" | "--";
|
||||||
|
accuracyM: number | null;
|
||||||
|
fixStale: boolean;
|
||||||
|
onStop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CourseAssistantPanel({
|
||||||
|
guidance,
|
||||||
|
gpsStatus,
|
||||||
|
headingDeg,
|
||||||
|
headingSource,
|
||||||
|
accuracyM,
|
||||||
|
fixStale,
|
||||||
|
onStop
|
||||||
|
}: CourseAssistantPanelProps) {
|
||||||
|
const waitingForGps = !guidance && !fixStale;
|
||||||
|
const suppressSteering =
|
||||||
|
!guidance || fixStale || guidance.status === "gps-unreliable" || guidance.status === "arrived";
|
||||||
|
const alertState = Boolean(fixStale || guidance?.status === "off-route" || guidance?.status === "gps-unreliable");
|
||||||
|
const correction = guidance?.courseCorrectionDeg ?? null;
|
||||||
|
const progress = guidance ? Math.round(guidance.progressRatio * 100) : 0;
|
||||||
|
const statusText = guidanceStatusText(guidance, fixStale, gpsStatus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="course-assistant-panel"
|
||||||
|
aria-label="Kursassistent"
|
||||||
|
data-alert={alertState}
|
||||||
|
data-status={fixStale ? "stale-fix" : guidance?.status ?? "waiting-gps"}
|
||||||
|
>
|
||||||
|
<header className="course-assistant-header">
|
||||||
|
<span>
|
||||||
|
<Navigation size={17} aria-hidden="true" />
|
||||||
|
<strong>Kursassistent</strong>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onStop} aria-label="Kursassistent stoppen">
|
||||||
|
<Square size={14} aria-hidden="true" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="course-assistant-main">
|
||||||
|
<span
|
||||||
|
className="course-assistant-arrow"
|
||||||
|
data-muted={suppressSteering}
|
||||||
|
style={{ transform: `rotate(${suppressSteering || correction === null ? 0 : correction}deg)` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Navigation size={32} />
|
||||||
|
</span>
|
||||||
|
<div className="course-assistant-course">
|
||||||
|
<span>SOLL ÜBER GRUND</span>
|
||||||
|
<strong>{suppressSteering ? "---" : formatCourse(guidance.desiredCourseDeg)}</strong>
|
||||||
|
<small>{suppressSteering ? "Keine verlässliche Steueranweisung" : correctionText(correction)}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="course-assistant-status" aria-live="polite">
|
||||||
|
{alertState && <AlertTriangle size={14} aria-hidden="true" />}
|
||||||
|
<span>{waitingForGps ? "GPS-Fix wird ermittelt …" : statusText}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{guidance && (
|
||||||
|
<>
|
||||||
|
<div className="course-assistant-metrics">
|
||||||
|
<span>
|
||||||
|
<small>IST {headingSource}</small>
|
||||||
|
<strong>{headingDeg === null ? "---" : formatActualHeading(headingDeg, headingSource)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<small>QUERABSTAND</small>
|
||||||
|
<strong>{formatCrossTrack(guidance)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<small>REST</small>
|
||||||
|
<strong>{formatNauticalMiles(guidance.remainingRouteDistanceM)}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="course-assistant-progress"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Routenfortschritt"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={progress}
|
||||||
|
aria-valuetext={`${progress} Prozent`}
|
||||||
|
>
|
||||||
|
<span style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{guidance.nextTurn && guidance.status !== "arrived" && (
|
||||||
|
<p className="course-assistant-turn">
|
||||||
|
{turnLabel(guidance.nextTurn.direction)} in {formatDistance(guidance.nextTurn.distanceM)}
|
||||||
|
{` · danach ${formatCourse(guidance.nextTurn.outgoingCourseDeg)}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<small className="course-assistant-safety">
|
||||||
|
Steuert das Boot nicht. Sollkurs über Grund; Ufer, Tonnen, Verkehr und amtliche Unterlagen haben Vorrang.
|
||||||
|
{accuracyM !== null ? ` GPS ±${Math.round(accuracyM)} m.` : ""}
|
||||||
|
</small>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function guidanceStatusText(guidance: RouteGuidanceResult | null, fixStale: boolean, gpsStatus: string) {
|
||||||
|
if (fixStale) return "GPS-Fix ist veraltet – Kursanweisung pausiert.";
|
||||||
|
if (!guidance) {
|
||||||
|
if (gpsStatus === "denied") return "GPS-Freigabe wurde abgelehnt.";
|
||||||
|
if (gpsStatus === "unavailable") return "GPS ist auf diesem Gerät nicht verfügbar.";
|
||||||
|
if (gpsStatus === "error") return "GPS-Position konnte nicht gelesen werden.";
|
||||||
|
return "GPS-Fix wird ermittelt …";
|
||||||
|
}
|
||||||
|
switch (guidance.status) {
|
||||||
|
case "arrived":
|
||||||
|
return "Ziel erreicht.";
|
||||||
|
case "gps-unreliable":
|
||||||
|
return "GPS zu ungenau – Kursanweisung pausiert.";
|
||||||
|
case "off-route":
|
||||||
|
return `Route um ${Math.round(guidance.distanceToRouteM)} m verlassen – nur im freien Fahrwasser zurückkehren.`;
|
||||||
|
case "approaching-turn":
|
||||||
|
return guidance.nextTurn
|
||||||
|
? `${turnLabel(guidance.nextTurn.direction)} in ${formatDistance(guidance.nextTurn.distanceM)}.`
|
||||||
|
: "Kursänderung voraus.";
|
||||||
|
default:
|
||||||
|
return "Auf Route – Sollkurs wird mit jedem GPS-Fix angepasst.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function correctionText(value: number | null) {
|
||||||
|
if (value === null || !Number.isFinite(value)) return "COG noch nicht verfügbar";
|
||||||
|
const rounded = Math.round(Math.abs(value));
|
||||||
|
if (rounded <= 4) return "Kurs halten";
|
||||||
|
return `${rounded}° nach ${value > 0 ? "Steuerbord" : "Backbord"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCrossTrack(guidance: RouteGuidanceResult) {
|
||||||
|
const distance = Math.round(guidance.distanceToRouteM);
|
||||||
|
if (distance <= 3 || guidance.crossTrackSide === "on-route") return "auf Linie";
|
||||||
|
return `${distance} m ${guidance.crossTrackSide === "port" ? "Backbord" : "Steuerbord"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function turnLabel(direction: NonNullable<RouteGuidanceResult["nextTurn"]>["direction"]) {
|
||||||
|
if (direction === "port") return "Backbord-Kursänderung";
|
||||||
|
if (direction === "starboard") return "Steuerbord-Kursänderung";
|
||||||
|
return "Wenden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCourse(value: number) {
|
||||||
|
const normalized = Math.round(((value % 360) + 360) % 360);
|
||||||
|
return `${String(normalized === 360 ? 0 : normalized).padStart(3, "0")}°T`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActualHeading(value: number, source: CourseAssistantPanelProps["headingSource"]) {
|
||||||
|
const formatted = formatCourse(value);
|
||||||
|
return source === "COG" ? formatted : formatted.replace("°T", "°");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNauticalMiles(meters: number) {
|
||||||
|
const remainingM = Math.max(0, meters);
|
||||||
|
if (remainingM < 185) return `${Math.round(remainingM)} m`;
|
||||||
|
return `${(remainingM / 1852).toFixed(remainingM < 18_520 ? 1 : 0)} sm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistance(meters: number) {
|
||||||
|
return meters < 1000 ? `${Math.max(0, Math.round(meters))} m` : `${(meters / 1000).toFixed(1)} km`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Component, Suspense, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type LazyContentProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
pending: ReactNode;
|
||||||
|
failed: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LazyLoadErrorBoundaryProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LazyLoadErrorBoundaryState = {
|
||||||
|
failed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LazyContent({ children, pending, failed }: LazyContentProps) {
|
||||||
|
return (
|
||||||
|
<LazyLoadErrorBoundary fallback={failed}>
|
||||||
|
<Suspense fallback={pending}>{children}</Suspense>
|
||||||
|
</LazyLoadErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LazyLoadErrorBoundary extends Component<
|
||||||
|
LazyLoadErrorBoundaryProps,
|
||||||
|
LazyLoadErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state: LazyLoadErrorBoundaryState = { failed: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): LazyLoadErrorBoundaryState {
|
||||||
|
return { failed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(_error: unknown, _errorInfo: ErrorInfo) {
|
||||||
|
// The local fallback keeps the rest of the navigation UI usable. A reload
|
||||||
|
// can then pick up a newer PWA chunk after a deployment.
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.failed ? this.props.fallback : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Globe2, Mail, Phone, X } from "lucide-react";
|
||||||
|
import type { Coordinate } from "@watermaps/shared";
|
||||||
|
|
||||||
|
export type MarineFeatureDetails = {
|
||||||
|
id: string;
|
||||||
|
layer: "locks" | "harbours";
|
||||||
|
name: string;
|
||||||
|
typeLabel: "Schleuse" | "Hafen";
|
||||||
|
coordinate: Coordinate;
|
||||||
|
phone: string | null;
|
||||||
|
website: string | null;
|
||||||
|
email: string | null;
|
||||||
|
vhf: string | null;
|
||||||
|
openingHours: string | null;
|
||||||
|
operator: string | null;
|
||||||
|
address: string | null;
|
||||||
|
source: string | null;
|
||||||
|
sourceUrl?: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
memberCount?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarineFeatureInfoProps = {
|
||||||
|
feature: MarineFeatureDetails;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MarineFeatureInfo({ feature, onClose }: MarineFeatureInfoProps) {
|
||||||
|
const dialogRef = useRef<HTMLElement | null>(null);
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
|
||||||
|
const phone = presentValue(feature.phone);
|
||||||
|
const website = presentValue(feature.website);
|
||||||
|
const email = presentValue(feature.email);
|
||||||
|
const vhf = presentValue(feature.vhf);
|
||||||
|
const openingHours = presentValue(feature.openingHours);
|
||||||
|
const operator = presentValue(feature.operator);
|
||||||
|
const address = presentValue(feature.address);
|
||||||
|
const source = presentValue(feature.source);
|
||||||
|
const updatedAt = presentValue(feature.updatedAt);
|
||||||
|
const phoneHref = phone ? telephoneHref(phone) : null;
|
||||||
|
const websiteHref = website ? websiteUrl(website) : null;
|
||||||
|
const sourceHref = feature.sourceUrl ? websiteUrl(feature.sourceUrl) : null;
|
||||||
|
const hasContactActions = Boolean(phoneHref || websiteHref || email);
|
||||||
|
const hasOperatingDetails = Boolean(vhf || openingHours || operator || address);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previouslyFocusedElementRef.current =
|
||||||
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
closeButtonRef.current?.focus();
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "Tab" || !dialogRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusableElements = getFocusableElements(dialogRef.current);
|
||||||
|
if (focusableElements.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
dialogRef.current.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstElement = focusableElements[0];
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1];
|
||||||
|
if (!firstElement || !lastElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
const focusIsOutsideDialog =
|
||||||
|
!(activeElement instanceof Node) || !dialogRef.current.contains(activeElement);
|
||||||
|
|
||||||
|
if (event.shiftKey && (activeElement === firstElement || focusIsOutsideDialog)) {
|
||||||
|
event.preventDefault();
|
||||||
|
lastElement.focus();
|
||||||
|
} else if (!event.shiftKey && (activeElement === lastElement || focusIsOutsideDialog)) {
|
||||||
|
event.preventDefault();
|
||||||
|
firstElement.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
const previouslyFocusedElement = previouslyFocusedElementRef.current;
|
||||||
|
if (previouslyFocusedElement?.isConnected) {
|
||||||
|
previouslyFocusedElement.focus();
|
||||||
|
}
|
||||||
|
previouslyFocusedElementRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="marine-feature-info-layer"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onPointerUp={(event) => event.stopPropagation()}
|
||||||
|
onTouchStart={(event) => event.stopPropagation()}
|
||||||
|
onTouchEnd={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="marine-feature-info-backdrop"
|
||||||
|
type="button"
|
||||||
|
aria-label="Detailansicht schließen"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<section
|
||||||
|
ref={dialogRef}
|
||||||
|
className="marine-feature-info"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="marine-feature-info-title"
|
||||||
|
aria-describedby="marine-feature-info-contact-note"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<header className="marine-feature-info-header">
|
||||||
|
<div>
|
||||||
|
<span className="marine-feature-type">{feature.typeLabel}</span>
|
||||||
|
<h2 id="marine-feature-info-title">{feature.name}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
ref={closeButtonRef}
|
||||||
|
className="marine-feature-info-close"
|
||||||
|
type="button"
|
||||||
|
aria-label="Informationen schließen"
|
||||||
|
title="Informationen schließen"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={20} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{hasContactActions ? (
|
||||||
|
<div className="marine-feature-actions" role="group" aria-label="Kontaktmöglichkeiten">
|
||||||
|
{phoneHref && phone && (
|
||||||
|
<a
|
||||||
|
className="marine-feature-action"
|
||||||
|
data-action="phone"
|
||||||
|
href={phoneHref}
|
||||||
|
aria-label={`${feature.name} anrufen: ${phone}`}
|
||||||
|
>
|
||||||
|
<Phone size={20} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>Anrufen</strong>
|
||||||
|
<small>{phone}</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{websiteHref && (
|
||||||
|
<a
|
||||||
|
className="marine-feature-action"
|
||||||
|
data-action="website"
|
||||||
|
href={websiteHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={`Website von ${feature.name} in einem neuen Tab öffnen`}
|
||||||
|
>
|
||||||
|
<Globe2 size={20} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>Website</strong>
|
||||||
|
<small>Öffnen</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{email && (
|
||||||
|
<a
|
||||||
|
className="marine-feature-action"
|
||||||
|
data-action="email"
|
||||||
|
href={`mailto:${email}`}
|
||||||
|
aria-label={`E-Mail an ${feature.name}: ${email}`}
|
||||||
|
>
|
||||||
|
<Mail size={20} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>E-Mail</strong>
|
||||||
|
<small>{email}</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="marine-feature-contact-empty">Keine direkten Kontaktdaten hinterlegt.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasOperatingDetails && (
|
||||||
|
<dl className="marine-feature-details marine-feature-operating-details">
|
||||||
|
{vhf && <DetailRow label="UKW / VHF">{vhf}</DetailRow>}
|
||||||
|
{openingHours && <DetailRow label="Öffnungszeiten">{openingHours}</DetailRow>}
|
||||||
|
{operator && <DetailRow label="Betreiber">{operator}</DetailRow>}
|
||||||
|
{address && <DetailRow label="Adresse">{address}</DetailRow>}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details className="marine-feature-metadata">
|
||||||
|
<summary>Daten & Quelle</summary>
|
||||||
|
<dl className="marine-feature-details">
|
||||||
|
<DetailRow label="Koordinaten">{formatCoordinate(feature.coordinate)}</DetailRow>
|
||||||
|
{(source || sourceHref) && (
|
||||||
|
<DetailRow label="Quelle">
|
||||||
|
{sourceHref ? (
|
||||||
|
<a href={sourceHref} target="_blank" rel="noreferrer">
|
||||||
|
{source ?? "Quelldatensatz öffnen"}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
source
|
||||||
|
)}
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
{updatedAt && <DetailRow label="Datenstand">{formatTimestamp(updatedAt)}</DetailRow>}
|
||||||
|
{(feature.memberCount ?? 1) > 1 && (
|
||||||
|
<DetailRow label="Zusammengeführt">{feature.memberCount} Kartenobjekte</DetailRow>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<p id="marine-feature-info-contact-note" className="marine-feature-contact-note">
|
||||||
|
Kontaktdaten können unvollständig oder veraltet sein. Vor der Fahrt bei der zuständigen Stelle prüfen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(coordinate: Coordinate) {
|
||||||
|
return `${coordinate.lat.toFixed(5)}°, ${coordinate.lon.toFixed(5)}°`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentValue(value: string | null | undefined) {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFocusableElements(container: HTMLElement) {
|
||||||
|
const selector = [
|
||||||
|
"a[href]",
|
||||||
|
"button:not([disabled])",
|
||||||
|
"input:not([disabled])",
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
"summary",
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter((element) => {
|
||||||
|
if (element.getAttribute("aria-hidden") === "true" || element.closest("[hidden]")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closedDetails = element.closest("details:not([open])");
|
||||||
|
return !closedDetails || element.tagName === "SUMMARY";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function telephoneHref(value: string) {
|
||||||
|
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
|
||||||
|
return compact ? `tel:${compact}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function websiteUrl(value: string) {
|
||||||
|
const trimmed = value.trim().split(/[;,]/)[0]?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isFinite(date.getTime())
|
||||||
|
? date.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })
|
||||||
|
: value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { AlertTriangle, ExternalLink, Gauge, Radio, ShipWheel } from "lucide-react";
|
||||||
|
import type { NavigationDataSnapshot, NavigationSourceStatus, WaterLevel } from "@watermaps/shared";
|
||||||
|
|
||||||
|
type NavigationDataPanelProps = {
|
||||||
|
snapshot: NavigationDataSnapshot | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NavigationDataPanel({ snapshot, loading, error }: NavigationDataPanelProps) {
|
||||||
|
if (!loading && !snapshot && !error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="navigation-data-panel" aria-label="Live-Fahrtdaten">
|
||||||
|
<header>
|
||||||
|
<Radio size={15} aria-hidden="true" />
|
||||||
|
<strong>Live-Fahrtdaten</strong>
|
||||||
|
{snapshot && <span>{formatClock(snapshot.generatedAt)}</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <p>WSV-Daten werden geladen</p>}
|
||||||
|
{error && (
|
||||||
|
<p className="navigation-data-warning">
|
||||||
|
<AlertTriangle size={14} aria-hidden="true" /> {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot && (
|
||||||
|
<>
|
||||||
|
{snapshot.waterLevels.length > 0 ? (
|
||||||
|
<div className="water-level-list">
|
||||||
|
{snapshot.waterLevels.slice(0, 8).map((level) => (
|
||||||
|
<WaterLevelRow key={level.stationId} level={level} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>Keine passenden PEGELONLINE-Messstellen gefunden.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot.lockOperations.map((lock) => (
|
||||||
|
<div className="navigation-operation" key={lock.id} data-state={lock.operatingState}>
|
||||||
|
<ShipWheel size={14} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{lock.name}</strong>
|
||||||
|
{lock.regularHours ?? lock.note ?? "Betriebsinformation ohne Zeitangabe"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{snapshot.notices.map((notice) => (
|
||||||
|
<a className="navigation-notice" key={notice.id} href={notice.sourceUrl} target="_blank" rel="noreferrer">
|
||||||
|
<AlertTriangle size={14} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{notice.title}</strong>
|
||||||
|
{notice.location ?? notice.waterway ?? "ELWIS-Nachricht"}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="navigation-source-list">
|
||||||
|
{snapshot.sources.map((source) => (
|
||||||
|
<SourceLink key={`${source.kind}:${source.id}`} source={source} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="navigation-data-disclaimer">
|
||||||
|
Live-Daten können verzögert oder unvollständig sein. Schleusenabweichungen und Sperrungen vor Abfahrt
|
||||||
|
zusätzlich in ELWIS prüfen.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaterLevelRow({ level }: { level: WaterLevel }) {
|
||||||
|
return (
|
||||||
|
<a href={level.sourceUrl} target="_blank" rel="noreferrer" data-state={level.stateMnwMhw}>
|
||||||
|
<Gauge size={14} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{level.stationName}</strong>
|
||||||
|
{level.waterway} {level.waterwayKm !== null ? `km ${formatNumber(level.waterwayKm)}` : ""}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>
|
||||||
|
{formatNumber(level.value)} {level.unit}
|
||||||
|
</strong>
|
||||||
|
{waterLevelLabel(level.stateMnwMhw)} · {formatDateTime(level.measuredAt)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceLink({ source }: { source: NavigationSourceStatus }) {
|
||||||
|
return (
|
||||||
|
<a href={source.sourceUrl} target="_blank" rel="noreferrer" data-state={source.state}>
|
||||||
|
<span>{source.label}</span>
|
||||||
|
<span>{sourceStateLabel(source.state)}</span>
|
||||||
|
<ExternalLink size={12} aria-hidden="true" />
|
||||||
|
{source.warning && <small>{source.warning}</small>}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceStateLabel(state: NavigationSourceStatus["state"]) {
|
||||||
|
switch (state) {
|
||||||
|
case "live":
|
||||||
|
return "Live";
|
||||||
|
case "cached":
|
||||||
|
return "Cache";
|
||||||
|
case "stale":
|
||||||
|
return "Veraltet";
|
||||||
|
case "unavailable":
|
||||||
|
return "Nicht erreichbar";
|
||||||
|
default:
|
||||||
|
return "Offiziell prüfen";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waterLevelLabel(state: WaterLevel["stateMnwMhw"]) {
|
||||||
|
switch (state) {
|
||||||
|
case "low":
|
||||||
|
return "niedrig";
|
||||||
|
case "normal":
|
||||||
|
return "normal";
|
||||||
|
case "high":
|
||||||
|
return "hoch";
|
||||||
|
case "out-dated":
|
||||||
|
return "veraltet";
|
||||||
|
case "commented":
|
||||||
|
return "kommentiert";
|
||||||
|
default:
|
||||||
|
return "Status offen";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number) {
|
||||||
|
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isFinite(date.getTime())
|
||||||
|
? date.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
|
||||||
|
: "Zeit offen";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isFinite(date.getTime())
|
||||||
|
? date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
|
||||||
|
: "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { Anchor, Bell, CloudSun, Route as RouteIcon, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export const NAVIGATION_TOOL_ORDER = [
|
||||||
|
"anchor",
|
||||||
|
"conditions",
|
||||||
|
"upcoming",
|
||||||
|
"route"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NavigationToolId = (typeof NAVIGATION_TOOL_ORDER)[number];
|
||||||
|
export type ActiveTool = NavigationToolId | null;
|
||||||
|
export type NavigationToolStatus = "idle" | "active" | "caution" | "alarm" | "stale";
|
||||||
|
|
||||||
|
type NavigationToolDefinition = {
|
||||||
|
id: NavigationToolId;
|
||||||
|
label: string;
|
||||||
|
Icon: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAVIGATION_TOOLS: readonly NavigationToolDefinition[] = [
|
||||||
|
{ id: "anchor", label: "Ankerwache", Icon: Anchor },
|
||||||
|
{ id: "conditions", label: "Wetter und Tide", Icon: CloudSun },
|
||||||
|
{ id: "upcoming", label: "Als Nächstes", Icon: Bell },
|
||||||
|
{ id: "route", label: "Route", Icon: RouteIcon }
|
||||||
|
];
|
||||||
|
|
||||||
|
export type NavigationToolRailProps = {
|
||||||
|
activeTool: ActiveTool;
|
||||||
|
onSelect: (tool: NavigationToolId) => void;
|
||||||
|
statuses?: Partial<Record<NavigationToolId, NavigationToolStatus>>;
|
||||||
|
badges?: Partial<Record<NavigationToolId, number | string | null>>;
|
||||||
|
disabledTools?: Partial<Record<NavigationToolId, boolean>>;
|
||||||
|
workspaceId?: string;
|
||||||
|
className?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NavigationToolRail({
|
||||||
|
activeTool,
|
||||||
|
onSelect,
|
||||||
|
statuses = {},
|
||||||
|
badges = {},
|
||||||
|
disabledTools = {},
|
||||||
|
workspaceId = "navigation-workspace",
|
||||||
|
className,
|
||||||
|
ariaLabel = "Kartenwerkzeuge"
|
||||||
|
}: NavigationToolRailProps) {
|
||||||
|
return (
|
||||||
|
<nav className={classNames("navigation-tool-rail", className)} aria-label={ariaLabel}>
|
||||||
|
{NAVIGATION_TOOLS.map(({ id, label, Icon }) => {
|
||||||
|
const active = activeTool === id;
|
||||||
|
const status = statuses[id] ?? "idle";
|
||||||
|
const badge = formatBadge(badges[id]);
|
||||||
|
const accessibleLabel = toolAriaLabel(label, status, badge, active);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
className="navigation-tool-rail-button"
|
||||||
|
type="button"
|
||||||
|
disabled={Boolean(disabledTools[id])}
|
||||||
|
data-tool={id}
|
||||||
|
data-status={status}
|
||||||
|
data-active={active}
|
||||||
|
aria-controls={workspaceId}
|
||||||
|
aria-expanded={active}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={accessibleLabel}
|
||||||
|
title={accessibleLabel}
|
||||||
|
onClick={() => onSelect(id)}
|
||||||
|
>
|
||||||
|
<Icon size={21} aria-hidden="true" />
|
||||||
|
<span className="navigation-tool-rail-label" aria-hidden="true">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{badge && (
|
||||||
|
<span className="navigation-tool-rail-badge" aria-hidden="true">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigationToolLabel(tool: NavigationToolId) {
|
||||||
|
return NAVIGATION_TOOLS.find((definition) => definition.id === tool)?.label ?? tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolAriaLabel(
|
||||||
|
label: string,
|
||||||
|
status: NavigationToolStatus,
|
||||||
|
badge: string | null,
|
||||||
|
active: boolean
|
||||||
|
) {
|
||||||
|
const parts = [label, statusLabel(status)];
|
||||||
|
if (badge) {
|
||||||
|
parts.push(`${badge} Hinweise`);
|
||||||
|
}
|
||||||
|
parts.push(active ? "geöffnet" : "öffnen");
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: NavigationToolStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "aktiv";
|
||||||
|
case "caution":
|
||||||
|
return "Warnung";
|
||||||
|
case "alarm":
|
||||||
|
return "Alarm";
|
||||||
|
case "stale":
|
||||||
|
return "Daten veraltet";
|
||||||
|
default:
|
||||||
|
return "bereit";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBadge(value: number | string | null | undefined) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value > 99 ? "99+" : String(Math.floor(value));
|
||||||
|
}
|
||||||
|
const normalized = value?.trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...values: Array<string | null | undefined | false>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowUp,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
navigationToolLabel,
|
||||||
|
type ActiveTool,
|
||||||
|
type NavigationToolStatus
|
||||||
|
} from "./NavigationToolRail";
|
||||||
|
|
||||||
|
export type NavigationSheetState = "compact" | "half" | "full";
|
||||||
|
export type NavigationWorkspacePresentation =
|
||||||
|
| "responsive"
|
||||||
|
| "bottom-sheet"
|
||||||
|
| "overlay-drawer"
|
||||||
|
| "docked-drawer";
|
||||||
|
|
||||||
|
export type NavigationWorkspaceProps = {
|
||||||
|
activeTool: ActiveTool;
|
||||||
|
children: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
summary?: ReactNode;
|
||||||
|
leading?: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
status?: NavigationToolStatus;
|
||||||
|
sheetState?: NavigationSheetState;
|
||||||
|
onSheetStateChange?: (state: NavigationSheetState) => void;
|
||||||
|
presentation?: NavigationWorkspacePresentation;
|
||||||
|
onBack?: () => void;
|
||||||
|
backLabel?: string;
|
||||||
|
closeLabel?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NavigationWorkspace({
|
||||||
|
activeTool,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
id = "navigation-workspace",
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
leading,
|
||||||
|
footer,
|
||||||
|
status = "idle",
|
||||||
|
sheetState = "half",
|
||||||
|
onSheetStateChange,
|
||||||
|
presentation = "responsive",
|
||||||
|
onBack,
|
||||||
|
backLabel = "Zurück",
|
||||||
|
closeLabel,
|
||||||
|
busy = false,
|
||||||
|
className
|
||||||
|
}: NavigationWorkspaceProps) {
|
||||||
|
if (!activeTool) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolLabel = navigationToolLabel(activeTool);
|
||||||
|
const heading = title ?? toolLabel;
|
||||||
|
const headingId = `${id}-title`;
|
||||||
|
const bodyId = `${id}-body`;
|
||||||
|
const sizeAction = workspaceSizeAction(sheetState);
|
||||||
|
const compact = sheetState === "compact";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
id={id}
|
||||||
|
className={classNames(
|
||||||
|
"navigation-workspace",
|
||||||
|
`navigation-workspace--${presentation}`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
aria-busy={busy}
|
||||||
|
data-tool={activeTool}
|
||||||
|
data-status={status}
|
||||||
|
data-presentation={presentation}
|
||||||
|
data-sheet-state={sheetState}
|
||||||
|
>
|
||||||
|
<span className="navigation-workspace-grip" aria-hidden="true">
|
||||||
|
━
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<header className="navigation-workspace-header">
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
className="navigation-workspace-back"
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
aria-label={backLabel}
|
||||||
|
title={backLabel}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={19} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{leading && (
|
||||||
|
<span className="navigation-workspace-leading" aria-hidden="true">
|
||||||
|
{leading}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="navigation-workspace-heading">
|
||||||
|
<strong id={headingId}>{heading}</strong>
|
||||||
|
{summary && <div className="navigation-workspace-summary">{summary}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onSheetStateChange && (
|
||||||
|
<button
|
||||||
|
className="navigation-workspace-size-action"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSheetStateChange(sizeAction.nextState)}
|
||||||
|
aria-controls={bodyId}
|
||||||
|
aria-label={sizeAction.label}
|
||||||
|
title={sizeAction.label}
|
||||||
|
>
|
||||||
|
{sizeAction.direction === "up" ? (
|
||||||
|
<ArrowUp size={19} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={19} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="navigation-workspace-close"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={closeLabel ?? `${toolLabel} schließen`}
|
||||||
|
title={closeLabel ?? `${toolLabel} schließen`}
|
||||||
|
>
|
||||||
|
<X size={19} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id={bodyId}
|
||||||
|
className="navigation-workspace-body"
|
||||||
|
hidden={compact}
|
||||||
|
data-collapsed={compact}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<footer className="navigation-workspace-footer" hidden={compact}>
|
||||||
|
{footer}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceSizeAction(state: NavigationSheetState): {
|
||||||
|
nextState: NavigationSheetState;
|
||||||
|
label: string;
|
||||||
|
direction: "up" | "down";
|
||||||
|
} {
|
||||||
|
if (state === "compact") {
|
||||||
|
return {
|
||||||
|
nextState: "half",
|
||||||
|
label: "Arbeitsbereich auf halbe Höhe vergrößern",
|
||||||
|
direction: "up"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state === "half") {
|
||||||
|
return {
|
||||||
|
nextState: "full",
|
||||||
|
label: "Arbeitsbereich auf volle Höhe vergrößern",
|
||||||
|
direction: "up"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nextState: "compact",
|
||||||
|
label: "Arbeitsbereich auf kompakte Höhe verkleinern",
|
||||||
|
direction: "down"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...values: Array<string | null | undefined | false>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
|||||||
|
import { AlertTriangle, Waves } from "lucide-react";
|
||||||
|
import type { TideEvent, TideSummary } from "@watermaps/shared";
|
||||||
|
|
||||||
|
export type RouteTidePlan = {
|
||||||
|
start: TideSummary | null;
|
||||||
|
middle?: TideSummary | null;
|
||||||
|
destination: TideSummary | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteTidePanelProps = {
|
||||||
|
plan: RouteTidePlan | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RouteTidePanel({ plan, loading, error }: RouteTidePanelProps) {
|
||||||
|
if (!loading && !plan && !error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="route-tide-panel" aria-label="Tidenplanung">
|
||||||
|
<header>
|
||||||
|
<Waves size={15} aria-hidden="true" />
|
||||||
|
<strong>Tide zur Fahrtzeit</strong>
|
||||||
|
</header>
|
||||||
|
{loading && <p>Tidenfenster werden geladen.</p>}
|
||||||
|
{error && (
|
||||||
|
<p className="route-tide-warning">
|
||||||
|
<AlertTriangle size={14} aria-hidden="true" /> {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{plan && (
|
||||||
|
<div className="route-tide-locations">
|
||||||
|
<TideLocation label="Start" summary={plan.start} />
|
||||||
|
<TideLocation label="Mitte" summary={plan.middle ?? null} />
|
||||||
|
<TideLocation label="Ziel" summary={plan.destination} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<small>Stationsabstand und Bezugsnull beachten; Wasserstände ersetzen keine amtliche Tiefenprüfung.</small>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TideLocation({ label, summary }: { label: string; summary: TideSummary | null }) {
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
{summary ? (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{summary.station} · {summary.distanceKm.toLocaleString("de-DE", { maximumFractionDigits: 1 })} km
|
||||||
|
</span>
|
||||||
|
<span>{formatEvent("HW", summary.nextHigh)}</span>
|
||||||
|
<span>{formatEvent("NW", summary.nextLow)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>Keine passende Vorhersage</span>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEvent(label: string, event: TideEvent | null) {
|
||||||
|
if (!event) {
|
||||||
|
return `${label} offen`;
|
||||||
|
}
|
||||||
|
const date = new Date(event.time);
|
||||||
|
const time = Number.isFinite(date.getTime())
|
||||||
|
? date.toLocaleString("de-DE", { weekday: "short", hour: "2-digit", minute: "2-digit" })
|
||||||
|
: "Zeit offen";
|
||||||
|
const height = event.heightM !== null ? ` · ${event.heightM.toFixed(2)} m` : "";
|
||||||
|
return `${label} ${time}${height}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Activity, AlertTriangle, Anchor, Navigation2, Waves, Wind } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { MarineForecast, RouteGuidanceResult, TideSummary } from "@watermaps/shared";
|
||||||
|
import type { GpsState } from "../hooks/useGeolocation";
|
||||||
|
|
||||||
|
type StatusBarProps = {
|
||||||
|
gps: GpsState;
|
||||||
|
forecast: MarineForecast | null;
|
||||||
|
tide: TideSummary | null;
|
||||||
|
routeWarningCount: number;
|
||||||
|
mode?: "planning" | "route" | "guidance" | "anchor";
|
||||||
|
guidance?: RouteGuidanceResult | null;
|
||||||
|
anchor?: {
|
||||||
|
distanceFromAnchorM: number | null;
|
||||||
|
alarmRadiusM: number;
|
||||||
|
alarm: boolean;
|
||||||
|
maximumTideRiseM: number | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBar({
|
||||||
|
gps,
|
||||||
|
forecast,
|
||||||
|
tide,
|
||||||
|
routeWarningCount,
|
||||||
|
mode,
|
||||||
|
guidance = null,
|
||||||
|
anchor = null
|
||||||
|
}: StatusBarProps) {
|
||||||
|
const resolvedMode =
|
||||||
|
mode ??
|
||||||
|
(routeWarningCount > 0
|
||||||
|
? "route"
|
||||||
|
: gps.status === "requesting" || gps.status === "tracking"
|
||||||
|
? "guidance"
|
||||||
|
: "planning");
|
||||||
|
const gpsValue = gps.position
|
||||||
|
? gps.accuracyM === null
|
||||||
|
? "Position aktiv"
|
||||||
|
: `±${gps.accuracyM} m`
|
||||||
|
: gpsStatusLabel(gps.status);
|
||||||
|
const speedValue = gps.speedKn === null ? "-- kn" : `${gps.speedKn.toFixed(1)} kn`;
|
||||||
|
const tideValue = tide?.nextHigh
|
||||||
|
? `HW ${new Date(tide.nextHigh.time).toLocaleTimeString("de-DE", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
})}`
|
||||||
|
: "Keine Daten";
|
||||||
|
const conditionsValue = formatConditions(forecast);
|
||||||
|
|
||||||
|
const items =
|
||||||
|
resolvedMode === "anchor"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: anchor?.alarm
|
||||||
|
? <AlertTriangle size={15} aria-hidden="true" />
|
||||||
|
: <Anchor size={15} aria-hidden="true" />,
|
||||||
|
label: "Anker",
|
||||||
|
value: anchor?.alarm
|
||||||
|
? "Alarm"
|
||||||
|
: anchor?.distanceFromAnchorM === null || anchor?.distanceFromAnchorM === undefined
|
||||||
|
? `Radius ${Math.round(anchor?.alarmRadiusM ?? 0)} m`
|
||||||
|
: `${Math.round(anchor.distanceFromAnchorM)} / ${Math.round(anchor.alarmRadiusM)} m`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Navigation2 size={15} aria-hidden="true" />,
|
||||||
|
label: "GPS",
|
||||||
|
value: gpsValue
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Waves size={15} aria-hidden="true" />,
|
||||||
|
label: "Tidenanstieg",
|
||||||
|
value:
|
||||||
|
anchor?.maximumTideRiseM === null || anchor?.maximumTideRiseM === undefined
|
||||||
|
? "Keine Daten"
|
||||||
|
: `+${anchor.maximumTideRiseM.toFixed(1)} m`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: resolvedMode === "guidance"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: <Navigation2 size={15} aria-hidden="true" />,
|
||||||
|
label: "Sollkurs",
|
||||||
|
value: guidance ? formatCourse(guidance.desiredCourseDeg) : "Warte auf GPS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Activity size={15} aria-hidden="true" />,
|
||||||
|
label: "Abweichung",
|
||||||
|
value: guidance ? formatCrossTrack(guidance) : "--"
|
||||||
|
},
|
||||||
|
routeWarningCount > 0
|
||||||
|
? {
|
||||||
|
icon: <AlertTriangle size={15} aria-hidden="true" />,
|
||||||
|
label: "Warnungen",
|
||||||
|
value: `${routeWarningCount} offen`
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
icon: <Activity size={15} aria-hidden="true" />,
|
||||||
|
label: "SOG",
|
||||||
|
value: speedValue
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: resolvedMode === "route"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon:
|
||||||
|
routeWarningCount > 0
|
||||||
|
? <AlertTriangle size={15} aria-hidden="true" />
|
||||||
|
: <Navigation2 size={15} aria-hidden="true" />,
|
||||||
|
label: "Warnungen",
|
||||||
|
value: routeWarningCount > 0 ? `${routeWarningCount} offen` : "Keine offenen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Navigation2 size={15} aria-hidden="true" />,
|
||||||
|
label: "GPS",
|
||||||
|
value: gpsValue
|
||||||
|
},
|
||||||
|
gps.position
|
||||||
|
? {
|
||||||
|
icon: <Activity size={15} aria-hidden="true" />,
|
||||||
|
label: "SOG",
|
||||||
|
value: speedValue
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
icon: <Waves size={15} aria-hidden="true" />,
|
||||||
|
label: "Tide",
|
||||||
|
value: tideValue
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
icon: <Navigation2 size={15} aria-hidden="true" />,
|
||||||
|
label: "GPS",
|
||||||
|
value: gpsValue
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Wind size={15} aria-hidden="true" />,
|
||||||
|
label: "Wetter",
|
||||||
|
value: conditionsValue
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Waves size={15} aria-hidden="true" />,
|
||||||
|
label: "Tide",
|
||||||
|
value: tideValue
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className="status-bar"
|
||||||
|
aria-label="Navigationsstatus"
|
||||||
|
style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<StatusItem key={item.label} icon={item.icon} label={item.label} value={item.value} />
|
||||||
|
))}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusItem({ icon, label, value }: { icon?: ReactNode; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<span className="status-item">
|
||||||
|
{icon}
|
||||||
|
<span className="status-label">{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gpsStatusLabel(status: GpsState["status"]): string {
|
||||||
|
switch (status) {
|
||||||
|
case "idle":
|
||||||
|
return "Aus";
|
||||||
|
case "requesting":
|
||||||
|
return "Position wird gesucht";
|
||||||
|
case "tracking":
|
||||||
|
return "Position aktiv";
|
||||||
|
case "denied":
|
||||||
|
return "Zugriff verweigert";
|
||||||
|
case "unavailable":
|
||||||
|
return "Nicht verfügbar";
|
||||||
|
case "error":
|
||||||
|
return "Fehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConditions(forecast: MarineForecast | null): string {
|
||||||
|
if (!forecast || (forecast.windSpeed === null && forecast.waveHeightM === null)) {
|
||||||
|
return "Keine Daten";
|
||||||
|
}
|
||||||
|
|
||||||
|
const wind = forecast.windSpeed === null ? null : `${Math.round(forecast.windSpeed)} kn`;
|
||||||
|
const wave = forecast.waveHeightM === null ? null : `${forecast.waveHeightM.toFixed(1)} m`;
|
||||||
|
return [wind, wave].filter((value): value is string => value !== null).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCourse(value: number) {
|
||||||
|
const normalized = Math.round(((value % 360) + 360) % 360) % 360;
|
||||||
|
return `${String(normalized).padStart(3, "0")}°T`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCrossTrack(guidance: RouteGuidanceResult) {
|
||||||
|
const distanceM = Math.round(guidance.distanceToRouteM);
|
||||||
|
if (distanceM <= 3 || guidance.crossTrackSide === "on-route") {
|
||||||
|
return "Auf Linie";
|
||||||
|
}
|
||||||
|
return `${distanceM} m ${guidance.crossTrackSide === "port" ? "Bb" : "Stb"}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
/* Upcoming route events ---------------------------------------------------- */
|
||||||
|
|
||||||
|
.upcoming-events-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-header {
|
||||||
|
min-height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-header > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: #0f4c5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-header small {
|
||||||
|
color: #607278;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-message {
|
||||||
|
min-height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
gap: 7px;
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-filters button {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid rgba(15, 76, 92, 0.12);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: #edf3f1;
|
||||||
|
color: #526a72;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-filters button[data-active="true"] {
|
||||||
|
border-color: #0f4c5c;
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-filters button small {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(15, 76, 92, 0.1);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero {
|
||||||
|
border: 1px solid rgba(15, 76, 92, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
background: #dceee6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero[data-status="caution"] {
|
||||||
|
border-color: #d89c28;
|
||||||
|
background: #fff1cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero[data-status="alarm"] {
|
||||||
|
border-color: #c44a30;
|
||||||
|
background: #ffe1dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero > small {
|
||||||
|
color: #526a72;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero-main {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 74px;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: #17343c;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero-main > svg {
|
||||||
|
grid-row: 1 / span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero-main > span:nth-of-type(1) {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero-main > span:nth-of-type(1) strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero-main > span:nth-of-type(1) span,
|
||||||
|
.upcoming-event-hero-eta,
|
||||||
|
.upcoming-event-hero-fact {
|
||||||
|
color: #607278;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero-distance {
|
||||||
|
color: #0f4c5c;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 900;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero-eta {
|
||||||
|
grid-column: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-hero-fact {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-list-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-list-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #526a72;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-row {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60px;
|
||||||
|
border: 1px solid rgba(15, 76, 92, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17343c;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-row[data-status="caution"] {
|
||||||
|
border-color: #d89c28;
|
||||||
|
background: #fff9e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-row[data-status="alarm"] {
|
||||||
|
border-color: #c44a30;
|
||||||
|
background: #fff0ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-row-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-row-content,
|
||||||
|
.upcoming-event-row-progress {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-row-content strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-row-content small,
|
||||||
|
.upcoming-event-row-progress small {
|
||||||
|
color: #607278;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-row-progress {
|
||||||
|
justify-items: end;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-row-progress strong {
|
||||||
|
color: #0f4c5c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-contact-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-contact-actions a,
|
||||||
|
.upcoming-event-map-action {
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
background: #0f4c5c;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-contact-actions[data-compact="true"] a {
|
||||||
|
background: rgba(15, 76, 92, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-header {
|
||||||
|
min-height: 50px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-header > button {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #e2ece9;
|
||||||
|
color: #23434c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-header > span,
|
||||||
|
.upcoming-event-detail-header > span > span {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-header > span > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-header small {
|
||||||
|
color: #607278;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-header strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 15px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-hero {
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
background: #dceee6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail[data-status="caution"] .upcoming-event-detail-hero {
|
||||||
|
background: #fff1cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail[data-status="alarm"] .upcoming-event-detail-hero {
|
||||||
|
background: #ffe1dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-hero > span {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px 6px;
|
||||||
|
color: #526a72;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-hero > span > svg {
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-hero strong {
|
||||||
|
color: #17343c;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-facts {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid rgba(15, 76, 92, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-facts > div {
|
||||||
|
min-height: 44px;
|
||||||
|
border-bottom: 1px solid rgba(15, 76, 92, 0.08);
|
||||||
|
padding: 7px 9px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, 0.75fr) minmax(0, 1.25fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-facts > div:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-facts dt {
|
||||||
|
color: #607278;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-detail-facts dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #17343c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-radio-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-event-map-action {
|
||||||
|
width: 100%;
|
||||||
|
background: #e2ece9;
|
||||||
|
color: #23434c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-state {
|
||||||
|
min-height: 210px;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
color: #526a72;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-state > svg {
|
||||||
|
color: #0f4c5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-state > strong {
|
||||||
|
color: #17343c;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-state > p,
|
||||||
|
.upcoming-events-empty-filter {
|
||||||
|
max-width: 32rem;
|
||||||
|
margin: 0;
|
||||||
|
color: #607278;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-events-empty-filter {
|
||||||
|
min-height: 96px;
|
||||||
|
border: 1px dashed #b9cbc7;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeft,
|
||||||
|
CalendarClock,
|
||||||
|
Clock3,
|
||||||
|
Globe2,
|
||||||
|
Landmark,
|
||||||
|
LoaderCircle,
|
||||||
|
LockKeyhole,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
Radio,
|
||||||
|
Route as RouteIcon,
|
||||||
|
Ship,
|
||||||
|
type LucideIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
type RouteEventKind,
|
||||||
|
type UpcomingRouteEvent
|
||||||
|
} from "../routeEvents";
|
||||||
|
import "./UpcomingEventsPanel.css";
|
||||||
|
|
||||||
|
type EventFilter = "all" | RouteEventKind;
|
||||||
|
|
||||||
|
type EventFilterDefinition = {
|
||||||
|
id: EventFilter;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_FILTERS: readonly EventFilterDefinition[] = [
|
||||||
|
{ id: "all", label: "Alle" },
|
||||||
|
{ id: "harbour", label: "Häfen" },
|
||||||
|
{ id: "lock", label: "Schleusen" },
|
||||||
|
{ id: "bridge", label: "Brücken" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const EVENT_KIND_LABELS: Record<RouteEventKind, string> = {
|
||||||
|
harbour: "Hafen",
|
||||||
|
lock: "Schleuse",
|
||||||
|
bridge: "Brücke"
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_KIND_ICONS: Record<RouteEventKind, LucideIcon> = {
|
||||||
|
harbour: Ship,
|
||||||
|
lock: LockKeyhole,
|
||||||
|
bridge: Landmark
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpcomingEventsPanelProps = {
|
||||||
|
events: readonly UpcomingRouteEvent[];
|
||||||
|
hasRoute: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onShowOnMap?: (event: UpcomingRouteEvent) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpcomingEventsPanel({
|
||||||
|
events,
|
||||||
|
hasRoute,
|
||||||
|
loading = false,
|
||||||
|
error = null,
|
||||||
|
onShowOnMap,
|
||||||
|
className
|
||||||
|
}: UpcomingEventsPanelProps) {
|
||||||
|
const [filter, setFilter] = useState<EventFilter>("all");
|
||||||
|
const [selectedEventKey, setSelectedEventKey] = useState<string | null>(null);
|
||||||
|
const orderedEvents = useMemo(
|
||||||
|
() =>
|
||||||
|
events
|
||||||
|
.map((event, index) => ({ event, index }))
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
finiteDistance(left.event.remainingNm) - finiteDistance(right.event.remainingNm) ||
|
||||||
|
left.index - right.index
|
||||||
|
)
|
||||||
|
.map(({ event }) => event),
|
||||||
|
[events]
|
||||||
|
);
|
||||||
|
const selectedEvent =
|
||||||
|
orderedEvents.find((event) => routeEventKey(event) === selectedEventKey) ?? null;
|
||||||
|
const filteredEvents = useMemo(
|
||||||
|
() =>
|
||||||
|
filter === "all"
|
||||||
|
? orderedEvents
|
||||||
|
: orderedEvents.filter((event) => event.kind === filter),
|
||||||
|
[filter, orderedEvents]
|
||||||
|
);
|
||||||
|
const counts = useMemo(
|
||||||
|
() => ({
|
||||||
|
all: orderedEvents.length,
|
||||||
|
harbour: orderedEvents.filter((event) => event.kind === "harbour").length,
|
||||||
|
lock: orderedEvents.filter((event) => event.kind === "lock").length,
|
||||||
|
bridge: orderedEvents.filter((event) => event.kind === "bridge").length
|
||||||
|
}),
|
||||||
|
[orderedEvents]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasRoute || (selectedEventKey && !selectedEvent)) {
|
||||||
|
setSelectedEventKey(null);
|
||||||
|
}
|
||||||
|
}, [hasRoute, selectedEvent, selectedEventKey]);
|
||||||
|
|
||||||
|
if (!hasRoute) {
|
||||||
|
return (
|
||||||
|
<PanelState
|
||||||
|
className={className}
|
||||||
|
icon={RouteIcon}
|
||||||
|
title="Noch keine Route"
|
||||||
|
message="Plane zuerst eine Route. Danach erscheinen Häfen, Schleusen und Brücken in Fahrtreihenfolge."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && orderedEvents.length === 0) {
|
||||||
|
return (
|
||||||
|
<PanelState
|
||||||
|
className={className}
|
||||||
|
icon={LoaderCircle}
|
||||||
|
title="Ereignisse werden geladen"
|
||||||
|
message="Die nächsten Häfen, Schleusen und Brücken entlang der Route werden ermittelt."
|
||||||
|
live
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && orderedEvents.length === 0) {
|
||||||
|
return (
|
||||||
|
<PanelState
|
||||||
|
className={className}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
title="Ereignisse nicht erreichbar"
|
||||||
|
message={error}
|
||||||
|
alert
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderedEvents.length === 0) {
|
||||||
|
return (
|
||||||
|
<PanelState
|
||||||
|
className={className}
|
||||||
|
icon={CalendarClock}
|
||||||
|
title="Keine bevorstehenden Ereignisse"
|
||||||
|
message="Auf dem verbleibenden Routenabschnitt wurden keine Häfen, Schleusen oder Brücken gefunden."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEvent) {
|
||||||
|
return (
|
||||||
|
<EventDrilldown
|
||||||
|
className={className}
|
||||||
|
event={selectedEvent}
|
||||||
|
onBack={() => setSelectedEventKey(null)}
|
||||||
|
onShowOnMap={onShowOnMap}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEvent = filteredEvents[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={classNames("upcoming-events-panel", className)}
|
||||||
|
aria-label="Bevorstehende Ereignisse"
|
||||||
|
aria-busy={loading}
|
||||||
|
>
|
||||||
|
<header className="upcoming-events-header">
|
||||||
|
<span>
|
||||||
|
<CalendarClock size={18} aria-hidden="true" />
|
||||||
|
<strong>Bevorstehend</strong>
|
||||||
|
</span>
|
||||||
|
<small>{orderedEvents.length} auf der Route</small>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<p className="upcoming-events-message" role="status" aria-live="polite">
|
||||||
|
<LoaderCircle size={16} aria-hidden="true" />
|
||||||
|
Ereignisse werden aktualisiert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="upcoming-events-message" role="alert">
|
||||||
|
<AlertTriangle size={16} aria-hidden="true" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="upcoming-events-filters" role="group" aria-label="Ereignisse filtern">
|
||||||
|
{EVENT_FILTERS.map(({ id, label }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
data-active={filter === id}
|
||||||
|
aria-pressed={filter === id}
|
||||||
|
onClick={() => setFilter(id)}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<small aria-label={`${counts[id]} Ereignisse`}>{counts[id]}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nextEvent ? (
|
||||||
|
<>
|
||||||
|
<NextEventHero
|
||||||
|
event={nextEvent}
|
||||||
|
onOpen={() => setSelectedEventKey(routeEventKey(nextEvent))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="upcoming-events-list-section" aria-labelledby="upcoming-events-list-title">
|
||||||
|
<h3 id="upcoming-events-list-title">
|
||||||
|
{filter === "all"
|
||||||
|
? "Alle Ereignisse in Fahrtreihenfolge"
|
||||||
|
: `${filterLabel(filter)} in Fahrtreihenfolge`}
|
||||||
|
</h3>
|
||||||
|
<ol className="upcoming-events-list">
|
||||||
|
{filteredEvents.map((event) => (
|
||||||
|
<li key={routeEventKey(event)}>
|
||||||
|
<EventRow
|
||||||
|
event={event}
|
||||||
|
onOpen={() => setSelectedEventKey(routeEventKey(event))}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="upcoming-events-empty-filter" role="status">
|
||||||
|
Keine {filterLabel(filter)} auf dem verbleibenden Routenabschnitt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NextEventHero({
|
||||||
|
event,
|
||||||
|
onOpen
|
||||||
|
}: {
|
||||||
|
event: UpcomingRouteEvent;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = EVENT_KIND_ICONS[event.kind];
|
||||||
|
const contact = eventContact(event);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="upcoming-event-hero"
|
||||||
|
data-kind={event.kind}
|
||||||
|
data-status={eventStatus(event)}
|
||||||
|
aria-labelledby={`next-event-${safeDomId(routeEventKey(event))}`}
|
||||||
|
>
|
||||||
|
<small>Nächstes Ereignis</small>
|
||||||
|
<button className="upcoming-event-hero-main" type="button" onClick={onOpen}>
|
||||||
|
<Icon size={24} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong id={`next-event-${safeDomId(routeEventKey(event))}`}>{event.name}</strong>
|
||||||
|
<span>{EVENT_KIND_LABELS[event.kind]}</span>
|
||||||
|
</span>
|
||||||
|
<span className="upcoming-event-hero-distance">{formatDistance(event.remainingNm)}</span>
|
||||||
|
<span className="upcoming-event-hero-eta">{formatEta(event)}</span>
|
||||||
|
<span className="upcoming-event-hero-fact">{importantFact(event)}</span>
|
||||||
|
</button>
|
||||||
|
<ContactActions
|
||||||
|
name={event.name}
|
||||||
|
phone={contact.phone}
|
||||||
|
website={contact.website}
|
||||||
|
email={contact.email}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventRow({
|
||||||
|
event,
|
||||||
|
onOpen
|
||||||
|
}: {
|
||||||
|
event: UpcomingRouteEvent;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = EVENT_KIND_ICONS[event.kind];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="upcoming-event-row"
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
data-kind={event.kind}
|
||||||
|
data-status={eventStatus(event)}
|
||||||
|
aria-label={`${EVENT_KIND_LABELS[event.kind]} ${event.name}, ${formatDistance(
|
||||||
|
event.remainingNm
|
||||||
|
)}, ${formatEta(event)}, ${importantFact(event)}. Details öffnen`}
|
||||||
|
>
|
||||||
|
<span className="upcoming-event-row-icon">
|
||||||
|
<Icon size={19} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="upcoming-event-row-content">
|
||||||
|
<strong>{event.name}</strong>
|
||||||
|
<small>{importantFact(event)}</small>
|
||||||
|
</span>
|
||||||
|
<span className="upcoming-event-row-progress">
|
||||||
|
<strong>{formatDistance(event.remainingNm)}</strong>
|
||||||
|
<small>{formatEta(event)}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventDrilldown({
|
||||||
|
event,
|
||||||
|
onBack,
|
||||||
|
onShowOnMap,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
event: UpcomingRouteEvent;
|
||||||
|
onBack: () => void;
|
||||||
|
onShowOnMap?: (event: UpcomingRouteEvent) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const Icon = EVENT_KIND_ICONS[event.kind];
|
||||||
|
const contact = eventContact(event);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={classNames("upcoming-events-panel", "upcoming-event-detail", className)}
|
||||||
|
aria-labelledby="upcoming-event-detail-title"
|
||||||
|
data-kind={event.kind}
|
||||||
|
data-status={eventStatus(event)}
|
||||||
|
>
|
||||||
|
<header className="upcoming-event-detail-header">
|
||||||
|
<button type="button" onClick={onBack} aria-label="Zurück zur Ereignisliste" title="Zurück">
|
||||||
|
<ArrowLeft size={19} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
<Icon size={20} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<small>{EVENT_KIND_LABELS[event.kind]}</small>
|
||||||
|
<strong id="upcoming-event-detail-title">{event.name}</strong>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="upcoming-event-detail-hero">
|
||||||
|
<span>
|
||||||
|
<RouteIcon size={17} aria-hidden="true" />
|
||||||
|
<strong>{formatDistance(event.remainingNm)}</strong>
|
||||||
|
verbleibend
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Clock3 size={17} aria-hidden="true" />
|
||||||
|
<strong>{formatEta(event)}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="upcoming-event-detail-facts">
|
||||||
|
<DetailRow label="Wichtigster Hinweis">{importantFact(event)}</DetailRow>
|
||||||
|
<DetailRow label="Abstand von der Route">
|
||||||
|
{formatDistance(event.distanceFromRouteNm)}
|
||||||
|
</DetailRow>
|
||||||
|
{event.eta && (
|
||||||
|
<DetailRow label="ETA-Grundlage">{etaBasisLabel(event)}</DetailRow>
|
||||||
|
)}
|
||||||
|
<KindSpecificDetails event={event} />
|
||||||
|
<EventDataDetails event={event} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<ContactActions
|
||||||
|
name={event.name}
|
||||||
|
phone={contact.phone}
|
||||||
|
website={contact.website}
|
||||||
|
email={contact.email}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{onShowOnMap && (
|
||||||
|
<button
|
||||||
|
className="upcoming-event-map-action"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onShowOnMap(event)}
|
||||||
|
>
|
||||||
|
<MapPin size={18} aria-hidden="true" />
|
||||||
|
Auf Karte zeigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindSpecificDetails({ event }: { event: UpcomingRouteEvent }) {
|
||||||
|
if (event.kind === "harbour") {
|
||||||
|
const amenities = availableAmenities(event);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailRow label="Typ">{event.feature.kind === "marina" ? "Marina" : "Hafen"}</DetailRow>
|
||||||
|
<DetailRow label="Öffnungszeiten">
|
||||||
|
{event.feature.openingHours ?? "Nicht hinterlegt"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="UKW / VHF">
|
||||||
|
{event.feature.vhf ?? "Nicht hinterlegt"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Betreiber">
|
||||||
|
{event.feature.operator ?? "Nicht hinterlegt"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Adresse">
|
||||||
|
{event.feature.address ?? "Nicht hinterlegt"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Ausstattung">
|
||||||
|
{amenities.length > 0 ? amenities.join(" · ") : "Nicht hinterlegt"}
|
||||||
|
</DetailRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === "lock") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailRow label="Öffnungszeiten">
|
||||||
|
{event.feature.openingHours ?? "Nicht hinterlegt"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="UKW / VHF">
|
||||||
|
{event.feature.vhf ? (
|
||||||
|
<span className="upcoming-event-radio-value">
|
||||||
|
<Radio size={15} aria-hidden="true" />
|
||||||
|
{event.feature.vhf}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Nicht hinterlegt"
|
||||||
|
)}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Betreiber">
|
||||||
|
{event.feature.operator ?? "Nicht hinterlegt"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Adresse">
|
||||||
|
{event.feature.address ?? "Nicht hinterlegt"}
|
||||||
|
</DetailRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailRow label="Durchfahrtshöhe">
|
||||||
|
{event.feature.clearanceLabel ??
|
||||||
|
formatMeters(event.feature.clearanceM) ??
|
||||||
|
"Nicht bekannt"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Benötigte Höhe">
|
||||||
|
{formatMeters(event.feature.requiredAirDraftM) ?? "Nicht bekannt"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Reserve">
|
||||||
|
{bridgeMargin(event.feature.marginM)}
|
||||||
|
</DetailRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventDataDetails({ event }: { event: UpcomingRouteEvent }) {
|
||||||
|
const source =
|
||||||
|
event.kind === "bridge" ? event.feature.source : event.feature.source ?? null;
|
||||||
|
const updatedAt =
|
||||||
|
event.kind === "bridge" ? null : event.feature.updatedAt ?? null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailRow label="Koordinaten">
|
||||||
|
{event.coordinate.lat.toFixed(5)}, {event.coordinate.lon.toFixed(5)}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Quelle">{source || "Nicht angegeben"}</DetailRow>
|
||||||
|
{updatedAt && (
|
||||||
|
<DetailRow label="Datenstand">{formatTimestamp(updatedAt)}</DetailRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactActions({
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
website,
|
||||||
|
email,
|
||||||
|
compact = false
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
phone: string | null;
|
||||||
|
website: string | null;
|
||||||
|
email: string | null;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const phoneHref = phone ? telephoneHref(phone) : null;
|
||||||
|
const safeWebsite = website ? websiteHref(website) : null;
|
||||||
|
const safeEmail = email?.trim() || null;
|
||||||
|
if (!phoneHref && !safeWebsite && !safeEmail) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="upcoming-event-contact-actions" data-compact={compact}>
|
||||||
|
{phoneHref && (
|
||||||
|
<a href={phoneHref} aria-label={`${name} anrufen`}>
|
||||||
|
<Phone size={17} aria-hidden="true" />
|
||||||
|
Anrufen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{safeWebsite && (
|
||||||
|
<a
|
||||||
|
href={safeWebsite}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={`Website von ${name} öffnen`}
|
||||||
|
>
|
||||||
|
<Globe2 size={17} aria-hidden="true" />
|
||||||
|
Website
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{safeEmail && (
|
||||||
|
<a href={`mailto:${safeEmail}`} aria-label={`E-Mail an ${name} schreiben`}>
|
||||||
|
<Mail size={17} aria-hidden="true" />
|
||||||
|
E-Mail
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
live = false,
|
||||||
|
alert = false,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
live?: boolean;
|
||||||
|
alert?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={classNames("upcoming-events-panel", "upcoming-events-state", className)}
|
||||||
|
aria-label="Bevorstehende Ereignisse"
|
||||||
|
role={alert ? "alert" : live ? "status" : undefined}
|
||||||
|
aria-live={live ? "polite" : undefined}
|
||||||
|
>
|
||||||
|
<Icon size={26} aria-hidden="true" />
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<p>{message}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventContact(event: UpcomingRouteEvent) {
|
||||||
|
if (event.kind === "harbour" || event.kind === "lock") {
|
||||||
|
return {
|
||||||
|
phone: event.feature.phone ?? null,
|
||||||
|
website: event.feature.website ?? null,
|
||||||
|
email: event.feature.email ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { phone: null, website: null, email: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function importantFact(event: UpcomingRouteEvent) {
|
||||||
|
if (event.kind === "harbour") {
|
||||||
|
const amenities = availableAmenities(event);
|
||||||
|
if (amenities.length > 0) {
|
||||||
|
return amenities.slice(0, 3).join(" · ");
|
||||||
|
}
|
||||||
|
if (event.feature.phone || event.feature.website) {
|
||||||
|
return "Kontaktdaten vorhanden";
|
||||||
|
}
|
||||||
|
return "Ausstattung nicht hinterlegt";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === "lock") {
|
||||||
|
if (event.feature.openingHours) {
|
||||||
|
return `Öffnung: ${event.feature.openingHours}`;
|
||||||
|
}
|
||||||
|
if (event.feature.vhf) {
|
||||||
|
return `UKW / VHF ${event.feature.vhf}`;
|
||||||
|
}
|
||||||
|
return "Betriebszeiten nicht hinterlegt";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.feature.marginM !== null) {
|
||||||
|
return event.feature.marginM < 0
|
||||||
|
? `${Math.abs(event.feature.marginM).toFixed(1)} m zu niedrig`
|
||||||
|
: `${event.feature.marginM.toFixed(1)} m Reserve`;
|
||||||
|
}
|
||||||
|
if (event.feature.clearanceLabel) {
|
||||||
|
return `Durchfahrt ${event.feature.clearanceLabel}`;
|
||||||
|
}
|
||||||
|
if (event.feature.clearanceM !== null) {
|
||||||
|
return `Durchfahrt ${event.feature.clearanceM.toFixed(1)} m`;
|
||||||
|
}
|
||||||
|
return "Durchfahrtshöhe unbekannt";
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventStatus(event: UpcomingRouteEvent) {
|
||||||
|
if (event.kind !== "bridge") {
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
switch (event.feature.status) {
|
||||||
|
case "too_low":
|
||||||
|
return "alarm";
|
||||||
|
case "tight":
|
||||||
|
return "caution";
|
||||||
|
case "unknown":
|
||||||
|
return "stale";
|
||||||
|
default:
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableAmenities(event: Extract<UpcomingRouteEvent, { kind: "harbour" }>) {
|
||||||
|
const amenities = event.feature.amenities ?? {};
|
||||||
|
return [
|
||||||
|
["fuel", "Kraftstoff"],
|
||||||
|
["water", "Wasser"],
|
||||||
|
["electricity", "Strom"],
|
||||||
|
["overnight", "Übernachtung"],
|
||||||
|
["waste", "Entsorgung"]
|
||||||
|
].flatMap(([id, label]) => {
|
||||||
|
const availability = amenities[id as keyof typeof amenities];
|
||||||
|
return availability === true || availability === "available" ? [label] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistance(value: number) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return "-- sm";
|
||||||
|
}
|
||||||
|
if (value > 0 && value < 0.1) {
|
||||||
|
return "< 0,1 sm";
|
||||||
|
}
|
||||||
|
return `${value.toLocaleString("de-DE", {
|
||||||
|
minimumFractionDigits: value < 10 ? 1 : 0,
|
||||||
|
maximumFractionDigits: 1
|
||||||
|
})} sm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEta(event: UpcomingRouteEvent) {
|
||||||
|
if (!event.eta) {
|
||||||
|
return "ETA offen";
|
||||||
|
}
|
||||||
|
const date = new Date(event.eta.estimatedAt);
|
||||||
|
if (!Number.isFinite(date.getTime())) {
|
||||||
|
return "ETA offen";
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
const sameDay =
|
||||||
|
date.getFullYear() === today.getFullYear() &&
|
||||||
|
date.getMonth() === today.getMonth() &&
|
||||||
|
date.getDate() === today.getDate();
|
||||||
|
return `ETA ${date.toLocaleString("de-DE", {
|
||||||
|
weekday: sameDay ? undefined : "short",
|
||||||
|
day: sameDay ? undefined : "2-digit",
|
||||||
|
month: sameDay ? undefined : "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function etaBasisLabel(event: UpcomingRouteEvent) {
|
||||||
|
if (!event.eta) {
|
||||||
|
return "Nicht verfügbar";
|
||||||
|
}
|
||||||
|
const speed =
|
||||||
|
event.eta.speedSource === "gps-sog"
|
||||||
|
? "GPS-Fahrt über Grund"
|
||||||
|
: "geplante Bootsgeschwindigkeit";
|
||||||
|
const reference =
|
||||||
|
event.eta.referenceSource === "current-time"
|
||||||
|
? "ab jetzt"
|
||||||
|
: "ab geplanter Abfahrt";
|
||||||
|
return `${event.eta.speedKn.toFixed(1)} kn ${speed}, ${reference}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeMargin(value: number | null) {
|
||||||
|
if (value === null || !Number.isFinite(value)) {
|
||||||
|
return "Nicht bekannt";
|
||||||
|
}
|
||||||
|
return value < 0
|
||||||
|
? `${Math.abs(value).toFixed(1)} m zu niedrig`
|
||||||
|
: `${value.toFixed(1)} m Reserve`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMeters(value: number | null) {
|
||||||
|
return value !== null && Number.isFinite(value) ? `${value.toFixed(1)} m` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isFinite(date.getTime())
|
||||||
|
? date.toLocaleString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
})
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterLabel(filter: EventFilter) {
|
||||||
|
return EVENT_FILTERS.find((definition) => definition.id === filter)?.label ?? "Ereignisse";
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteDistance(value: number) {
|
||||||
|
return Number.isFinite(value) ? Math.max(0, value) : Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeEventKey(event: UpcomingRouteEvent) {
|
||||||
|
return `${event.kind}:${event.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDomId(value: string) {
|
||||||
|
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function telephoneHref(value: string) {
|
||||||
|
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
|
||||||
|
return compact ? `tel:${compact}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function websiteHref(value: string) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(
|
||||||
|
/^[a-z][a-z\d+.-]*:/i.test(normalized) ? normalized : `https://${normalized}`
|
||||||
|
);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...values: Array<string | null | undefined | false>) {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
.voyage-navigation-tools {
|
||||||
|
border-top: 1px solid rgba(18, 46, 55, 0.12);
|
||||||
|
padding-top: 9px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-tools-heading {
|
||||||
|
min-height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
color: #10242b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-tools-heading span {
|
||||||
|
color: #4c6269;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-tools-actions,
|
||||||
|
.deviation-alarm-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-tools-actions button,
|
||||||
|
.offline-voyage-picker button,
|
||||||
|
.deviation-alarm-button {
|
||||||
|
min-height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 9px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #e6f1ed;
|
||||||
|
color: #0f4c5c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-voyage-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto 38px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-voyage-picker label {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #4c6269;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-voyage-picker select {
|
||||||
|
min-width: 0;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid rgba(18, 46, 55, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #10242b;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-voyage-picker .danger-action {
|
||||||
|
width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
background: #ffe1dc;
|
||||||
|
color: #9d2c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviation-alarm-controls label {
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
background: #eef3f0;
|
||||||
|
color: #4c6269;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviation-alarm-controls label span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviation-alarm-controls input {
|
||||||
|
width: 61px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid rgba(18, 46, 55, 0.18);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #10242b;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviation-alarm-button {
|
||||||
|
min-height: 42px;
|
||||||
|
background: #0f4c5c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviation-alarm-button[data-active="true"] {
|
||||||
|
background: #196f5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviation-alarm-status,
|
||||||
|
.offline-voyage-status {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deviation-alarm-status[data-off-route="true"] {
|
||||||
|
background: #ffe1dc;
|
||||||
|
color: #9d2c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-voyage-status {
|
||||||
|
background: #eef3f0;
|
||||||
|
color: #4c6269;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-privacy-note {
|
||||||
|
color: #607278;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 390px) {
|
||||||
|
.voyage-tools-heading span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { Bell, BellOff, Download, FolderOpen, Save, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { RouteResult } from "@watermaps/shared";
|
||||||
|
import { useRouteDeviationAlarm } from "../hooks/useRouteDeviationAlarm";
|
||||||
|
import { downloadRouteGpx } from "../lib/gpx";
|
||||||
|
import {
|
||||||
|
deleteOfflineVoyage,
|
||||||
|
listOfflineVoyages,
|
||||||
|
loadOfflineVoyage,
|
||||||
|
requestPersistentOfflineStorage,
|
||||||
|
saveOfflineVoyage,
|
||||||
|
type OfflineVoyage,
|
||||||
|
type OfflineVoyagePlanInput
|
||||||
|
} from "../lib/offline-route";
|
||||||
|
import "./VoyageNavigationTools.css";
|
||||||
|
|
||||||
|
export type VoyageNavigationToolsProps = {
|
||||||
|
route: RouteResult | null;
|
||||||
|
plan?: OfflineVoyagePlanInput;
|
||||||
|
defaultDeviationThresholdM?: number;
|
||||||
|
courseAssistantActive?: boolean;
|
||||||
|
onLoadOfflineVoyage?: (voyage: OfflineVoyage) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VoyageNavigationTools({
|
||||||
|
route,
|
||||||
|
plan,
|
||||||
|
defaultDeviationThresholdM = 100,
|
||||||
|
courseAssistantActive = false,
|
||||||
|
onLoadOfflineVoyage
|
||||||
|
}: VoyageNavigationToolsProps) {
|
||||||
|
const [savedVoyages, setSavedVoyages] = useState<OfflineVoyage[]>([]);
|
||||||
|
const [selectedVoyageId, setSelectedVoyageId] = useState("");
|
||||||
|
const [storageMessage, setStorageMessage] = useState<string | null>(null);
|
||||||
|
const [thresholdM, setThresholdM] = useState(() => clampThreshold(defaultDeviationThresholdM));
|
||||||
|
const alarm = useRouteDeviationAlarm(route, thresholdM);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (courseAssistantActive && alarm.active) {
|
||||||
|
alarm.stop();
|
||||||
|
}
|
||||||
|
}, [alarm.active, alarm.stop, courseAssistantActive]);
|
||||||
|
|
||||||
|
const refreshSavedVoyages = () => {
|
||||||
|
try {
|
||||||
|
const next = listOfflineVoyages();
|
||||||
|
setSavedVoyages(next);
|
||||||
|
setSelectedVoyageId((current) => next.some((voyage) => voyage.id === current) ? current : next[0]?.id ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
setStorageMessage(errorMessage(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(refreshSavedVoyages, []);
|
||||||
|
|
||||||
|
const selectedVoyage = useMemo(
|
||||||
|
() => savedVoyages.find((voyage) => voyage.id === selectedVoyageId) ?? null,
|
||||||
|
[savedVoyages, selectedVoyageId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportGpx = () => {
|
||||||
|
if (!route) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
downloadRouteGpx(route);
|
||||||
|
setStorageMessage("GPX-Datei wurde zum Download bereitgestellt.");
|
||||||
|
} catch (error) {
|
||||||
|
setStorageMessage(errorMessage(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRoute = async () => {
|
||||||
|
if (!route) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const voyage = saveOfflineVoyage({ route, plan });
|
||||||
|
void requestPersistentOfflineStorage();
|
||||||
|
refreshSavedVoyages();
|
||||||
|
setSelectedVoyageId(voyage.id);
|
||||||
|
setStorageMessage(`„${voyage.name}“ ist auf diesem Gerät offline verfügbar.`);
|
||||||
|
} catch (error) {
|
||||||
|
setStorageMessage(errorMessage(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRoute = () => {
|
||||||
|
if (!selectedVoyage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const voyage = loadOfflineVoyage(selectedVoyage.id);
|
||||||
|
if (!voyage) {
|
||||||
|
setStorageMessage("Die gespeicherte Route ist nicht mehr verfügbar.");
|
||||||
|
refreshSavedVoyages();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onLoadOfflineVoyage?.(voyage);
|
||||||
|
setStorageMessage(`„${voyage.name}“ wurde offline geladen.`);
|
||||||
|
} catch (error) {
|
||||||
|
setStorageMessage(errorMessage(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRoute = () => {
|
||||||
|
if (!selectedVoyage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
deleteOfflineVoyage(selectedVoyage.id);
|
||||||
|
setStorageMessage(`„${selectedVoyage.name}“ wurde vom Gerät gelöscht.`);
|
||||||
|
refreshSavedVoyages();
|
||||||
|
} catch (error) {
|
||||||
|
setStorageMessage(errorMessage(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="voyage-navigation-tools" aria-label="Navigation und Offline-Route">
|
||||||
|
<div className="voyage-tools-heading">
|
||||||
|
<strong>Unterwegs</strong>
|
||||||
|
<span>GPX · Offline · Kursalarm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="voyage-tools-actions">
|
||||||
|
<button type="button" onClick={exportGpx} disabled={!route} aria-label="Route als GPX exportieren">
|
||||||
|
<Download size={15} aria-hidden="true" />
|
||||||
|
GPX
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={saveRoute} disabled={!route} aria-label="Route offline speichern">
|
||||||
|
<Save size={15} aria-hidden="true" />
|
||||||
|
Offline speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{savedVoyages.length > 0 && (
|
||||||
|
<div className="offline-voyage-picker">
|
||||||
|
<label htmlFor="offline-voyage-select">Gespeicherte Route</label>
|
||||||
|
<select
|
||||||
|
id="offline-voyage-select"
|
||||||
|
value={selectedVoyageId}
|
||||||
|
onChange={(event) => setSelectedVoyageId(event.target.value)}
|
||||||
|
>
|
||||||
|
{savedVoyages.map((voyage) => (
|
||||||
|
<option key={voyage.id} value={voyage.id}>
|
||||||
|
{voyage.name} · {new Date(voyage.savedAt).toLocaleDateString("de-DE")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={loadRoute} aria-label="Offline-Route laden">
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
|
Laden
|
||||||
|
</button>
|
||||||
|
<button className="danger-action" type="button" onClick={removeRoute} aria-label="Offline-Route löschen">
|
||||||
|
<Trash2 size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="deviation-alarm-controls">
|
||||||
|
<label htmlFor="route-deviation-threshold">
|
||||||
|
Warnen ab
|
||||||
|
<span>
|
||||||
|
<input
|
||||||
|
id="route-deviation-threshold"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={25}
|
||||||
|
max={2_000}
|
||||||
|
step={25}
|
||||||
|
value={thresholdM}
|
||||||
|
disabled={alarm.active || courseAssistantActive}
|
||||||
|
onChange={(event) => setThresholdM(clampThreshold(Number(event.target.value)))}
|
||||||
|
/>
|
||||||
|
m
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="deviation-alarm-button"
|
||||||
|
data-active={alarm.active}
|
||||||
|
disabled={courseAssistantActive || (!route && !alarm.active)}
|
||||||
|
onClick={alarm.active ? alarm.stop : alarm.start}
|
||||||
|
aria-label={
|
||||||
|
courseAssistantActive
|
||||||
|
? "Kursalarm ist im Kursassistenten enthalten"
|
||||||
|
: alarm.active
|
||||||
|
? "Abweichungsalarm stoppen"
|
||||||
|
: "Abweichungsalarm starten"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{alarm.active ? <BellOff size={16} aria-hidden="true" /> : <Bell size={16} aria-hidden="true" />}
|
||||||
|
{courseAssistantActive ? "Im Assistenten aktiv" : alarm.active ? "Alarm stoppen" : "Kursalarm starten"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courseAssistantActive && (
|
||||||
|
<p className="deviation-alarm-status" role="status">
|
||||||
|
Querabweichung und einmalige Warnsignale werden vom Kursassistenten übernommen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{alarm.message && (
|
||||||
|
<p className="deviation-alarm-status" data-off-route={alarm.isOffRoute} role={alarm.isOffRoute ? "alert" : "status"}>
|
||||||
|
{alarm.message}
|
||||||
|
{alarm.accuracyM !== null && alarm.reliable && ` · GPS ±${Math.round(alarm.accuracyM)} m`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{storageMessage && <p className="offline-voyage-status" role="status">{storageMessage}</p>}
|
||||||
|
|
||||||
|
<small className="voyage-privacy-note">
|
||||||
|
GPS wird erst nach „Kursalarm starten“ für diesen separaten Alarm angefragt; der Kursassistent nutzt den ebenfalls bewusst
|
||||||
|
gestarteten Live-GPS-Datenstrom der Karte. Positionen bleiben im Browser und werden weder gespeichert noch übertragen.
|
||||||
|
Bereits aufgerufene Kartenausschnitte kann die installierte App zeitlich begrenzt zwischenspeichern.
|
||||||
|
</small>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampThreshold(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return Math.min(2_000, Math.max(25, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : "Offline-Funktion nicht verfügbar.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
.voyage-plan {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
border-top: 1px solid rgba(18, 46, 55, 0.12);
|
||||||
|
padding-top: 10px;
|
||||||
|
color: #10242b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-header h2 {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-summary,
|
||||||
|
.voyage-plan-requirements,
|
||||||
|
.voyage-plan-leg-route,
|
||||||
|
.voyage-plan-waypoints,
|
||||||
|
.voyage-plan-unconfirmed-stop,
|
||||||
|
.voyage-plan-harbour-contact {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-summary {
|
||||||
|
color: #314b54;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-requirements {
|
||||||
|
color: #4c6269;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-warnings,
|
||||||
|
.voyage-plan-legs,
|
||||||
|
.voyage-plan-amenities {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-warnings {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-warnings li {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
background: #e8f0f1;
|
||||||
|
color: #314b54;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-warnings li[data-severity="caution"] {
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-warnings li[data-severity="critical"] {
|
||||||
|
background: #ffe1dc;
|
||||||
|
color: #9d2c22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-legs {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
counter-reset: voyage-day;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-leg {
|
||||||
|
border: 1px solid rgba(18, 46, 55, 0.12);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 9px;
|
||||||
|
background: #f8faf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-leg article,
|
||||||
|
.voyage-plan-harbour {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-leg article > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-leg article > header span,
|
||||||
|
.voyage-plan-waypoints {
|
||||||
|
color: #60737a;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-leg-route {
|
||||||
|
color: #10242b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-amenities {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-amenities li {
|
||||||
|
min-height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
background: #eef3f0;
|
||||||
|
color: #60737a;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-amenities li[data-availability="available"] {
|
||||||
|
background: #dceee6;
|
||||||
|
color: #196f5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-amenities li[data-availability="unavailable"] {
|
||||||
|
background: #f0e8e4;
|
||||||
|
color: #74483d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-unconfirmed-stop {
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #fff1cc;
|
||||||
|
color: #805900;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-harbour-contact {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voyage-plan-harbour-contact a {
|
||||||
|
color: #075f78;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 460px) {
|
||||||
|
.voyage-plan-header {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
VOYAGE_AMENITIES,
|
||||||
|
voyageAmenityLabel,
|
||||||
|
type ProjectedVoyageHarbour,
|
||||||
|
type VoyageAmenity,
|
||||||
|
type VoyageAmenityAvailability,
|
||||||
|
type VoyagePlan as VoyagePlanResult
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
import "./VoyagePlan.css";
|
||||||
|
|
||||||
|
type VoyagePlanProps = {
|
||||||
|
plan: VoyagePlanResult | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VoyagePlan({ plan }: VoyagePlanProps) {
|
||||||
|
if (!plan) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="voyage-plan" aria-labelledby="voyage-plan-title">
|
||||||
|
<header className="voyage-plan-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Reiseplanung</span>
|
||||||
|
<h2 id="voyage-plan-title">Etappenplan</h2>
|
||||||
|
</div>
|
||||||
|
<p className="voyage-plan-summary">
|
||||||
|
{plan.legs.length} {plan.legs.length === 1 ? "Tag" : "Tage"} · {formatNm(plan.totalDistanceNm)} ·{" "}
|
||||||
|
{formatDuration(plan.totalDurationHours)}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{plan.requiredAmenities.length > 0 ? (
|
||||||
|
<p className="voyage-plan-requirements">
|
||||||
|
Benötigte Versorgung: {plan.requiredAmenities.map(voyageAmenityLabel).join(", ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{plan.warnings.length > 0 ? (
|
||||||
|
<ul className="voyage-plan-warnings" aria-label="Hinweise zum Etappenplan">
|
||||||
|
{plan.warnings.map((warning, index) => (
|
||||||
|
<li key={`${warning.code}-${warning.day ?? 0}-${index}`} data-severity={warning.severity}>
|
||||||
|
{warning.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ol className="voyage-plan-legs">
|
||||||
|
{plan.legs.map((leg) => (
|
||||||
|
<li key={leg.day} className="voyage-plan-leg">
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<strong>Tag {leg.day}</strong>
|
||||||
|
<span>
|
||||||
|
{formatNm(leg.distanceNm)} · {formatDuration(leg.durationHours)}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p className="voyage-plan-leg-route">
|
||||||
|
{leg.start.name} → {leg.end.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{leg.waypoints.length > 0 ? (
|
||||||
|
<p className="voyage-plan-waypoints">
|
||||||
|
Via: {leg.waypoints.map((waypoint) => waypoint.name).join(" → ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{leg.end.harbour ? <HarbourSupply harbour={leg.end.harbour} /> : null}
|
||||||
|
{leg.end.type === "route" ? (
|
||||||
|
<p className="voyage-plan-unconfirmed-stop">Kein bestätigter Liegeplatz</p>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HarbourSupply({ harbour }: { harbour: ProjectedVoyageHarbour }) {
|
||||||
|
return (
|
||||||
|
<div className="voyage-plan-harbour">
|
||||||
|
<ul className="voyage-plan-amenities" aria-label={`Versorgung in ${harbour.name}`}>
|
||||||
|
{VOYAGE_AMENITIES.map((amenity) => (
|
||||||
|
<Amenity key={amenity} amenity={amenity} availability={harbour.amenities[amenity]} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{harbour.phone || harbour.website ? (
|
||||||
|
<p className="voyage-plan-harbour-contact">
|
||||||
|
{harbour.phone ? <a href={telephoneHref(harbour.phone)}>Hafen anrufen</a> : null}
|
||||||
|
{harbour.phone && harbour.website ? " · " : null}
|
||||||
|
{harbour.website ? (
|
||||||
|
<a href={websiteHref(harbour.website)} target="_blank" rel="noreferrer">
|
||||||
|
Website
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Amenity({
|
||||||
|
amenity,
|
||||||
|
availability
|
||||||
|
}: {
|
||||||
|
amenity: VoyageAmenity;
|
||||||
|
availability: VoyageAmenityAvailability;
|
||||||
|
}) {
|
||||||
|
const status =
|
||||||
|
availability === "available"
|
||||||
|
? "verfügbar"
|
||||||
|
: availability === "unavailable"
|
||||||
|
? "nicht verfügbar"
|
||||||
|
: "unbekannt";
|
||||||
|
const symbol = availability === "available" ? "✓" : availability === "unavailable" ? "–" : "?";
|
||||||
|
return (
|
||||||
|
<li data-availability={availability} aria-label={`${voyageAmenityLabel(amenity)}: ${status}`}>
|
||||||
|
<span aria-hidden="true">{symbol}</span> {voyageAmenityLabel(amenity)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNm(value: number) {
|
||||||
|
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 1 })} sm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(hours: number) {
|
||||||
|
const totalMinutes = Math.max(0, Math.round(hours * 60));
|
||||||
|
const fullHours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return `${fullHours} h ${String(minutes).padStart(2, "0")} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function telephoneHref(value: string) {
|
||||||
|
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
|
||||||
|
return `tel:${compact}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function websiteHref(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
analyzeTideWindow,
|
||||||
|
calculateAnchorRodePlan,
|
||||||
|
evaluateAnchorWatch,
|
||||||
|
type Coordinate,
|
||||||
|
type TideSummary
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
import { getNearestTide } from "../api";
|
||||||
|
import type { GpsState } from "./useGeolocation";
|
||||||
|
|
||||||
|
export type AnchorWatchPhase = "idle" | "set" | "armed";
|
||||||
|
|
||||||
|
export type AnchorWatchSettings = {
|
||||||
|
depthAtSetM: number;
|
||||||
|
bowRollerHeightM: number;
|
||||||
|
deployedRodeLengthM: number;
|
||||||
|
scopeRatio: number;
|
||||||
|
safetyAllowanceM: number;
|
||||||
|
alarmRadiusM: number;
|
||||||
|
horizonHours: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_ANCHOR_WATCH_SETTINGS: AnchorWatchSettings = {
|
||||||
|
depthAtSetM: 3,
|
||||||
|
bowRollerHeightM: 1,
|
||||||
|
deployedRodeLengthM: 30,
|
||||||
|
scopeRatio: 6,
|
||||||
|
safetyAllowanceM: 0.5,
|
||||||
|
alarmRadiusM: 35,
|
||||||
|
horizonHours: 24
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_CAPTURE_ACCURACY_M = 30;
|
||||||
|
const MAX_FIX_AGE_MS = 10_000;
|
||||||
|
const STALE_FIX_AFTER_MS = 20_000;
|
||||||
|
const TIDE_REFRESH_MS = 15 * 60 * 1_000;
|
||||||
|
const REPEAT_ALARM_MS = 60_000;
|
||||||
|
|
||||||
|
type WakeLockSentinelLike = {
|
||||||
|
released?: boolean;
|
||||||
|
release: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavigatorWithWakeLock = Navigator & {
|
||||||
|
wakeLock?: {
|
||||||
|
request: (type: "screen") => Promise<WakeLockSentinelLike>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAnchorWatch(gps: Pick<
|
||||||
|
GpsState,
|
||||||
|
"status" | "position" | "accuracyM" | "timestampMs"
|
||||||
|
>) {
|
||||||
|
const [phase, setPhase] = useState<AnchorWatchPhase>("idle");
|
||||||
|
const [anchorPoint, setAnchorPoint] = useState<Coordinate | null>(null);
|
||||||
|
const [anchorSetAtMs, setAnchorSetAtMs] = useState<number | null>(null);
|
||||||
|
const [anchorCaptureAccuracyM, setAnchorCaptureAccuracyM] = useState<number | null>(null);
|
||||||
|
const [settings, setSettings] = useState<AnchorWatchSettings>(DEFAULT_ANCHOR_WATCH_SETTINGS);
|
||||||
|
const [operationError, setOperationError] = useState<string | null>(null);
|
||||||
|
const [tide, setTide] = useState<TideSummary | null>(null);
|
||||||
|
const [tideLoading, setTideLoading] = useState(false);
|
||||||
|
const [tideError, setTideError] = useState<string | null>(null);
|
||||||
|
const [clockMs, setClockMs] = useState(() => Date.now());
|
||||||
|
const [alarmAcknowledged, setAlarmAcknowledged] = useState(false);
|
||||||
|
const tideRequestIdRef = useRef(0);
|
||||||
|
const previousAlarmRef = useRef(false);
|
||||||
|
const previousRodeShortfallRef = useRef(false);
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
|
||||||
|
const updateSettings = useCallback((next: Partial<AnchorWatchSettings>) => {
|
||||||
|
setSettings((current) => ({ ...current, ...next }));
|
||||||
|
setOperationError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const captureAnchor = useCallback(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (gps.status !== "tracking" || !gps.position || gps.timestampMs === null) {
|
||||||
|
setOperationError("Für den Ankerpunkt wird zuerst ein aktueller GPS-Fix benötigt.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (now - gps.timestampMs > MAX_FIX_AGE_MS) {
|
||||||
|
setOperationError("Der GPS-Fix ist älter als 10 Sekunden. Bitte auf einen neuen Fix warten.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (gps.accuracyM === null || gps.accuracyM > MAX_CAPTURE_ACCURACY_M) {
|
||||||
|
setOperationError(
|
||||||
|
`GPS noch zu ungenau${gps.accuracyM === null ? "" : ` (±${Math.round(gps.accuracyM)} m)`}. Ankerpunkt erst bei höchstens ±${MAX_CAPTURE_ACCURACY_M} m setzen.`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tideRequestIdRef.current += 1;
|
||||||
|
setAnchorPoint({ ...gps.position });
|
||||||
|
setAnchorSetAtMs(gps.timestampMs);
|
||||||
|
setAnchorCaptureAccuracyM(gps.accuracyM);
|
||||||
|
setPhase("set");
|
||||||
|
setOperationError(null);
|
||||||
|
setTide(null);
|
||||||
|
setTideError(null);
|
||||||
|
setAlarmAcknowledged(false);
|
||||||
|
previousAlarmRef.current = false;
|
||||||
|
previousRodeShortfallRef.current = false;
|
||||||
|
return true;
|
||||||
|
}, [gps.accuracyM, gps.position, gps.status, gps.timestampMs]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
tideRequestIdRef.current += 1;
|
||||||
|
setPhase("idle");
|
||||||
|
setAnchorPoint(null);
|
||||||
|
setAnchorSetAtMs(null);
|
||||||
|
setAnchorCaptureAccuracyM(null);
|
||||||
|
setOperationError(null);
|
||||||
|
setTide(null);
|
||||||
|
setTideLoading(false);
|
||||||
|
setTideError(null);
|
||||||
|
setAlarmAcknowledged(false);
|
||||||
|
previousAlarmRef.current = false;
|
||||||
|
previousRodeShortfallRef.current = false;
|
||||||
|
const audioContext = audioContextRef.current;
|
||||||
|
audioContextRef.current = null;
|
||||||
|
void audioContext?.close().catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const settingsError = useMemo(() => validateSettings(settings), [settings]);
|
||||||
|
|
||||||
|
const arm = useCallback(async () => {
|
||||||
|
if (!anchorPoint || anchorSetAtMs === null) {
|
||||||
|
setOperationError("Zuerst „Anker gefallen“ wählen und den Ankerpunkt setzen.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const validationError = validateSettings(settings);
|
||||||
|
if (validationError) {
|
||||||
|
setOperationError(validationError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOperationError(null);
|
||||||
|
setClockMs(Date.now());
|
||||||
|
setAlarmAcknowledged(false);
|
||||||
|
previousAlarmRef.current = false;
|
||||||
|
setPhase("armed");
|
||||||
|
audioContextRef.current = createAlarmAudioContext();
|
||||||
|
void audioContextRef.current?.resume().catch(() => undefined);
|
||||||
|
|
||||||
|
if (typeof Notification !== "undefined" && Notification.permission === "default") {
|
||||||
|
try {
|
||||||
|
await Notification.requestPermission();
|
||||||
|
} catch {
|
||||||
|
// The persistent in-app warning remains available when notifications
|
||||||
|
// are unsupported or denied.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [anchorPoint, anchorSetAtMs, settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anchorPoint || anchorSetAtMs === null || phase === "idle") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
const refresh = async () => {
|
||||||
|
const requestId = tideRequestIdRef.current + 1;
|
||||||
|
tideRequestIdRef.current = requestId;
|
||||||
|
setTideLoading(true);
|
||||||
|
try {
|
||||||
|
const summary = await getNearestTide(anchorPoint, new Date(anchorSetAtMs).toISOString());
|
||||||
|
if (active && tideRequestIdRef.current === requestId) {
|
||||||
|
setTide(summary);
|
||||||
|
setTideError(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (active && tideRequestIdRef.current === requestId) {
|
||||||
|
setTide(null);
|
||||||
|
setTideError(error instanceof Error ? error.message : "Tidenprognose nicht erreichbar");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active && tideRequestIdRef.current === requestId) {
|
||||||
|
setTideLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
const intervalId = window.setInterval(refresh, TIDE_REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [anchorPoint, anchorSetAtMs, phase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === "idle") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClockMs(Date.now());
|
||||||
|
const intervalId = window.setInterval(() => setClockMs(Date.now()), 5_000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [phase, gps.timestampMs]);
|
||||||
|
|
||||||
|
const tideWindow = useMemo(
|
||||||
|
() => tide && anchorSetAtMs !== null
|
||||||
|
? analyzeTideWindow(tide, anchorSetAtMs, settings.horizonHours)
|
||||||
|
: null,
|
||||||
|
[anchorSetAtMs, settings.horizonHours, tide]
|
||||||
|
);
|
||||||
|
const remainingTideWindow = useMemo(
|
||||||
|
() => tide ? analyzeTideWindow(tide, clockMs, settings.horizonHours) : null,
|
||||||
|
[clockMs, settings.horizonHours, tide]
|
||||||
|
);
|
||||||
|
const rodePlan = useMemo(
|
||||||
|
() => calculateAnchorRodePlan({
|
||||||
|
depthAtSetM: settings.depthAtSetM,
|
||||||
|
bowRollerHeightM: settings.bowRollerHeightM,
|
||||||
|
deployedRodeLengthM: settings.deployedRodeLengthM,
|
||||||
|
scopeRatio: settings.scopeRatio,
|
||||||
|
safetyAllowanceM: settings.safetyAllowanceM,
|
||||||
|
tideWindow
|
||||||
|
}),
|
||||||
|
[settings, tideWindow]
|
||||||
|
);
|
||||||
|
const watchResult = useMemo(
|
||||||
|
() => anchorPoint && gps.position
|
||||||
|
? evaluateAnchorWatch({
|
||||||
|
anchorPoint,
|
||||||
|
position: gps.position,
|
||||||
|
alarmRadiusM: settings.alarmRadiusM,
|
||||||
|
accuracyM: gps.accuracyM,
|
||||||
|
maxReliableAccuracyM: MAX_CAPTURE_ACCURACY_M
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
[anchorPoint, gps.accuracyM, gps.position, settings.alarmRadiusM]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixStale = phase === "armed" && (
|
||||||
|
gps.timestampMs === null || Math.max(0, clockMs - gps.timestampMs) > STALE_FIX_AFTER_MS
|
||||||
|
);
|
||||||
|
const gpsUnavailable = phase === "armed" && gps.status !== "tracking";
|
||||||
|
const gpsUnreliable = phase === "armed" && Boolean(watchResult && !watchResult.positionReliable);
|
||||||
|
const positionAlarm = phase === "armed" && (
|
||||||
|
fixStale || gpsUnavailable || gpsUnreliable || !watchResult || watchResult.alarmTriggered
|
||||||
|
);
|
||||||
|
const rodeShortfall = Boolean(
|
||||||
|
phase === "armed" && rodePlan?.calculationComplete && (rodePlan.rodeReserveM ?? 0) < 0
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "armed") {
|
||||||
|
previousAlarmRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!positionAlarm) {
|
||||||
|
previousAlarmRef.current = false;
|
||||||
|
setAlarmAcknowledged(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!previousAlarmRef.current) {
|
||||||
|
setAlarmAcknowledged(false);
|
||||||
|
emitAnchorAlert(
|
||||||
|
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
|
||||||
|
audioContextRef.current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
previousAlarmRef.current = true;
|
||||||
|
}, [fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "armed" || !positionAlarm || alarmAcknowledged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
emitAnchorAlert(
|
||||||
|
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
|
||||||
|
audioContextRef.current
|
||||||
|
);
|
||||||
|
}, REPEAT_ALARM_MS);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [alarmAcknowledged, fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === "armed" && rodeShortfall && !previousRodeShortfallRef.current) {
|
||||||
|
const shortfallM = Math.abs(rodePlan?.rodeReserveM ?? 0);
|
||||||
|
emitAnchorAlert(
|
||||||
|
`Nach Stationsprognose fehlen rechnerisch etwa ${shortfallM.toFixed(1)} m Ankerleine oder Kette.`,
|
||||||
|
audioContextRef.current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
previousRodeShortfallRef.current = rodeShortfall;
|
||||||
|
}, [phase, rodePlan?.rodeReserveM, rodeShortfall]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "armed" || typeof navigator === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
let sentinel: WakeLockSentinelLike | null = null;
|
||||||
|
const acquire = async () => {
|
||||||
|
const wakeLock = (navigator as NavigatorWithWakeLock).wakeLock;
|
||||||
|
if (!wakeLock || document.visibilityState !== "visible") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sentinel = await wakeLock.request("screen");
|
||||||
|
if (!active) {
|
||||||
|
await sentinel.release();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sentinel = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible" && (!sentinel || sentinel.released)) {
|
||||||
|
void acquire();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void acquire();
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
void sentinel?.release().catch(() => undefined);
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
const audioContext = audioContextRef.current;
|
||||||
|
audioContextRef.current = null;
|
||||||
|
void audioContext?.close().catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const acknowledgeAlarm = useCallback(() => setAlarmAcknowledged(true), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
anchorPoint,
|
||||||
|
anchorSetAtMs,
|
||||||
|
anchorCaptureAccuracyM,
|
||||||
|
settings,
|
||||||
|
settingsError,
|
||||||
|
tide,
|
||||||
|
tideLoading,
|
||||||
|
tideError,
|
||||||
|
tideWindow,
|
||||||
|
remainingTideWindow,
|
||||||
|
rodePlan,
|
||||||
|
watchResult,
|
||||||
|
fixStale,
|
||||||
|
gpsUnreliable,
|
||||||
|
positionAlarm,
|
||||||
|
rodeShortfall,
|
||||||
|
alarmAcknowledged,
|
||||||
|
operationError,
|
||||||
|
maxCaptureAccuracyM: MAX_CAPTURE_ACCURACY_M,
|
||||||
|
captureAnchor,
|
||||||
|
updateSettings,
|
||||||
|
arm,
|
||||||
|
acknowledgeAlarm,
|
||||||
|
reset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSettings(settings: AnchorWatchSettings): string | null {
|
||||||
|
if (!positive(settings.depthAtSetM)) return "Die Tiefe beim Setzen muss größer als 0 m sein.";
|
||||||
|
if (!nonNegative(settings.bowRollerHeightM)) return "Die Höhe der Bugrolle darf nicht negativ sein.";
|
||||||
|
if (!positive(settings.deployedRodeLengthM)) return "Die ausgesteckte Länge muss größer als 0 m sein.";
|
||||||
|
if (!Number.isFinite(settings.scopeRatio) || settings.scopeRatio < 2 || settings.scopeRatio > 15) {
|
||||||
|
return "Das gewählte Verhältnis muss zwischen 2:1 und 15:1 liegen.";
|
||||||
|
}
|
||||||
|
if (!nonNegative(settings.safetyAllowanceM)) return "Die Wasserstandsreserve darf nicht negativ sein.";
|
||||||
|
if (!Number.isFinite(settings.alarmRadiusM) || settings.alarmRadiusM < 10 || settings.alarmRadiusM > 2_000) {
|
||||||
|
return "Der Alarmradius muss zwischen 10 m und 2.000 m liegen.";
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(settings.horizonHours) || settings.horizonHours < 6 || settings.horizonHours > 72) {
|
||||||
|
return "Der Tidenzeitraum muss zwischen 6 und 72 Stunden liegen.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positive(value: number) {
|
||||||
|
return Number.isFinite(value) && value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegative(value: number) {
|
||||||
|
return Number.isFinite(value) && value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchorAlertMessage({
|
||||||
|
fixStale,
|
||||||
|
gpsUnavailable,
|
||||||
|
gpsUnreliable,
|
||||||
|
watchResult
|
||||||
|
}: {
|
||||||
|
fixStale: boolean;
|
||||||
|
gpsUnavailable: boolean;
|
||||||
|
gpsUnreliable: boolean;
|
||||||
|
watchResult: ReturnType<typeof evaluateAnchorWatch>;
|
||||||
|
}) {
|
||||||
|
if (fixStale) return "Kein aktueller GPS-Fix – Ankerposition kann nicht sicher überwacht werden.";
|
||||||
|
if (gpsUnavailable) return "GPS ist ausgefallen – Ankerposition kann nicht überwacht werden.";
|
||||||
|
if (gpsUnreliable) return "GPS ist zu ungenau – Ankerposition kann nicht sicher überwacht werden.";
|
||||||
|
if (watchResult?.alarmTriggered) {
|
||||||
|
return `Ankeralarm: ${Math.round(watchResult.distanceFromAnchorM)} m vom gesetzten Ankerpunkt entfernt.`;
|
||||||
|
}
|
||||||
|
return "Ankerwache hat keine auswertbare Position.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitAnchorAlert(message: string, audioContext: AudioContext | null) {
|
||||||
|
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
|
||||||
|
navigator.vibrate([300, 120, 300, 120, 500]);
|
||||||
|
}
|
||||||
|
playAlarmTone(audioContext);
|
||||||
|
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
|
||||||
|
try {
|
||||||
|
new Notification("Watermaps Ankerwache", {
|
||||||
|
body: message,
|
||||||
|
tag: "watermaps-anchor-watch",
|
||||||
|
requireInteraction: true
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The live panel remains the primary warning surface.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAlarmAudioContext(): AudioContext | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const AudioContextConstructor = window.AudioContext ?? (
|
||||||
|
window as typeof window & { webkitAudioContext?: typeof AudioContext }
|
||||||
|
).webkitAudioContext;
|
||||||
|
if (!AudioContextConstructor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new AudioContextConstructor();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAlarmTone(audioContext: AudioContext | null) {
|
||||||
|
if (!audioContext || audioContext.state === "closed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void audioContext.resume().then(() => {
|
||||||
|
const startAt = audioContext.currentTime;
|
||||||
|
for (const offset of [0, 0.32, 0.64]) {
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gain = audioContext.createGain();
|
||||||
|
oscillator.type = "square";
|
||||||
|
oscillator.frequency.value = 880;
|
||||||
|
gain.gain.setValueAtTime(0.0001, startAt + offset);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.18, startAt + offset + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + offset + 0.2);
|
||||||
|
oscillator.connect(gain);
|
||||||
|
gain.connect(audioContext.destination);
|
||||||
|
oscillator.start(startAt + offset);
|
||||||
|
oscillator.stop(startAt + offset + 0.21);
|
||||||
|
}
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { normalizeHeadingDeg } from "@watermaps/shared";
|
||||||
|
|
||||||
|
type DeviceOrientationWithWebkit = DeviceOrientationEvent & {
|
||||||
|
webkitCompassHeading?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeviceOrientationConstructor = typeof DeviceOrientationEvent & {
|
||||||
|
requestPermission?: (absolute?: boolean) => Promise<"granted" | "denied">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompassState = {
|
||||||
|
status: "idle" | "requesting" | "active" | "denied" | "unavailable" | "error";
|
||||||
|
headingDeg: number | null;
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompass(fallbackCourseDeg: number | null) {
|
||||||
|
const [state, setState] = useState<CompassState>({
|
||||||
|
status: "idle",
|
||||||
|
headingDeg: null,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOrientation = useCallback((event: DeviceOrientationEvent) => {
|
||||||
|
const orientation = event as DeviceOrientationWithWebkit;
|
||||||
|
const heading =
|
||||||
|
typeof orientation.webkitCompassHeading === "number"
|
||||||
|
? orientation.webkitCompassHeading
|
||||||
|
: typeof event.alpha === "number"
|
||||||
|
? 360 - event.alpha
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (heading !== null) {
|
||||||
|
setState({
|
||||||
|
status: "active",
|
||||||
|
headingDeg: Math.round(normalizeHeadingDeg(heading)),
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeOrientationListeners = useCallback(() => {
|
||||||
|
window.removeEventListener("deviceorientationabsolute", handleOrientation);
|
||||||
|
window.removeEventListener("deviceorientation", handleOrientation);
|
||||||
|
}, [handleOrientation]);
|
||||||
|
|
||||||
|
const request = useCallback(async () => {
|
||||||
|
if (typeof window === "undefined" || !("DeviceOrientationEvent" in window)) {
|
||||||
|
setState((current) => ({ ...current, status: "unavailable", message: "Kompass nicht verfügbar" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState((current) => ({ ...current, status: "requesting", message: null }));
|
||||||
|
const ctor = DeviceOrientationEvent as DeviceOrientationConstructor;
|
||||||
|
if (typeof ctor.requestPermission === "function") {
|
||||||
|
const permission = await ctor.requestPermission(true);
|
||||||
|
if (permission !== "granted") {
|
||||||
|
setState((current) => ({ ...current, status: "denied", message: "Kompass gesperrt" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOrientationListeners();
|
||||||
|
window.addEventListener("deviceorientationabsolute", handleOrientation);
|
||||||
|
window.addEventListener("deviceorientation", handleOrientation);
|
||||||
|
setState((current) => ({ ...current, status: "active" }));
|
||||||
|
} catch (error) {
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
status: "error",
|
||||||
|
message: error instanceof Error ? error.message : "Kompassfehler"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [handleOrientation, removeOrientationListeners]);
|
||||||
|
|
||||||
|
useEffect(() => removeOrientationListeners, [removeOrientationListeners]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
headingDeg: state.headingDeg ?? fallbackCourseDeg,
|
||||||
|
source: state.headingDeg !== null ? "HDG" : fallbackCourseDeg !== null ? "COG" : "N",
|
||||||
|
request
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
calculateRouteGuidance,
|
||||||
|
type Coordinate,
|
||||||
|
type RouteGuidanceResult,
|
||||||
|
type RouteResult
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
|
||||||
|
export type CourseAssistantInput = {
|
||||||
|
route: RouteResult | null;
|
||||||
|
position: Coordinate | null;
|
||||||
|
accuracyM: number | null;
|
||||||
|
speedKn: number | null;
|
||||||
|
headingDeg: number | null;
|
||||||
|
fixTimestampMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STALE_FIX_AFTER_MS = 15_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps route-following state in memory while reusing the app's single GPS
|
||||||
|
* stream. It never starts a sensor or transmits a position by itself.
|
||||||
|
*/
|
||||||
|
export function useCourseAssistant({
|
||||||
|
route,
|
||||||
|
position,
|
||||||
|
accuracyM,
|
||||||
|
speedKn,
|
||||||
|
headingDeg,
|
||||||
|
fixTimestampMs
|
||||||
|
}: CourseAssistantInput) {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [clockMs, setClockMs] = useState(() => Date.now());
|
||||||
|
const activeRouteRef = useRef<RouteResult | null>(null);
|
||||||
|
const progressMRef = useRef<number | null>(null);
|
||||||
|
const previousSignalRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
activeRouteRef.current = null;
|
||||||
|
progressMRef.current = null;
|
||||||
|
previousSignalRef.current = null;
|
||||||
|
setActive(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
if (!route) return;
|
||||||
|
activeRouteRef.current = route;
|
||||||
|
progressMRef.current = null;
|
||||||
|
previousSignalRef.current = null;
|
||||||
|
setClockMs(Date.now());
|
||||||
|
setActive(true);
|
||||||
|
}, [route]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeRouteRef.current && activeRouteRef.current !== route) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}, [route, stop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setClockMs(Date.now());
|
||||||
|
const intervalId = window.setInterval(() => setClockMs(Date.now()), 5_000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [active, fixTimestampMs]);
|
||||||
|
|
||||||
|
const fixStale = Boolean(
|
||||||
|
active &&
|
||||||
|
position &&
|
||||||
|
fixTimestampMs !== null &&
|
||||||
|
Math.max(0, clockMs - fixTimestampMs) > STALE_FIX_AFTER_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
const guidance = useMemo<RouteGuidanceResult | null>(() => {
|
||||||
|
if (!active || activeRouteRef.current !== route || !route || !position || fixStale) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return calculateRouteGuidance({
|
||||||
|
route,
|
||||||
|
position,
|
||||||
|
accuracyM,
|
||||||
|
speedKn,
|
||||||
|
headingDeg,
|
||||||
|
previousProgressM: progressMRef.current
|
||||||
|
});
|
||||||
|
}, [accuracyM, active, fixStale, headingDeg, position, route, speedKn]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (guidance) {
|
||||||
|
progressMRef.current = guidance.progressM;
|
||||||
|
}
|
||||||
|
}, [guidance]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !guidance) return;
|
||||||
|
const signal = guidanceSignal(guidance);
|
||||||
|
if (signal !== previousSignalRef.current && typeof navigator.vibrate === "function") {
|
||||||
|
if (guidance.status === "off-route") navigator.vibrate([200, 100, 200]);
|
||||||
|
if (guidance.status === "arrived") navigator.vibrate([300, 150, 300]);
|
||||||
|
if (guidance.status === "approaching-turn") navigator.vibrate(120);
|
||||||
|
}
|
||||||
|
previousSignalRef.current = signal;
|
||||||
|
}, [active, guidance]);
|
||||||
|
|
||||||
|
return { active, guidance, fixStale, start, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function guidanceSignal(guidance: RouteGuidanceResult) {
|
||||||
|
if (guidance.status === "approaching-turn" && guidance.nextTurn) {
|
||||||
|
const turn = guidance.nextTurn.coordinate;
|
||||||
|
return `turn:${turn.lat.toFixed(5)}:${turn.lon.toFixed(5)}`;
|
||||||
|
}
|
||||||
|
return guidance.status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { haversineDistanceM, initialBearingDeg, type Coordinate } from "@watermaps/shared";
|
||||||
|
|
||||||
|
export type GpsState = {
|
||||||
|
status: "idle" | "requesting" | "tracking" | "denied" | "unavailable" | "error";
|
||||||
|
position: Coordinate | null;
|
||||||
|
accuracyM: number | null;
|
||||||
|
speedKn: number | null;
|
||||||
|
courseDeg: number | null;
|
||||||
|
timestampMs: number | null;
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MS_TO_KN = 1.94384449;
|
||||||
|
|
||||||
|
export function useGeolocation() {
|
||||||
|
const watchId = useRef<number | null>(null);
|
||||||
|
const courseAnchor = useRef<{ coord: Coordinate; accuracyM: number } | null>(null);
|
||||||
|
const [state, setState] = useState<GpsState>({
|
||||||
|
status: "idle",
|
||||||
|
position: null,
|
||||||
|
accuracyM: null,
|
||||||
|
speedKn: null,
|
||||||
|
courseDeg: null,
|
||||||
|
timestampMs: null,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearActiveWatch = useCallback(() => {
|
||||||
|
if (watchId.current !== null) {
|
||||||
|
navigator.geolocation.clearWatch(watchId.current);
|
||||||
|
watchId.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
clearActiveWatch();
|
||||||
|
courseAnchor.current = null;
|
||||||
|
setState({
|
||||||
|
status: "idle",
|
||||||
|
position: null,
|
||||||
|
accuracyM: null,
|
||||||
|
speedKn: null,
|
||||||
|
courseDeg: null,
|
||||||
|
timestampMs: null,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
}, [clearActiveWatch]);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
if (typeof window !== "undefined" && window.isSecureContext === false) {
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
status: "unavailable",
|
||||||
|
message: "GPS benötigt HTTPS oder localhost. Öffne die App auf dem iPhone über einen HTTPS-Link."
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("geolocation" in navigator)) {
|
||||||
|
setState((current) => ({ ...current, status: "unavailable", message: "GPS nicht verfügbar" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearActiveWatch();
|
||||||
|
courseAnchor.current = null;
|
||||||
|
setState((current) => ({ ...current, status: "requesting", message: null }));
|
||||||
|
|
||||||
|
watchId.current = navigator.geolocation.watchPosition(
|
||||||
|
(position) => {
|
||||||
|
// Freshness is based on when this watch callback reached the app. Some
|
||||||
|
// embedded/WebKit implementations expose a non-epoch timestamp even
|
||||||
|
// though the DOM type is an EpochTimeStamp. maximumAge below already
|
||||||
|
// limits how old the accepted sensor fix may be.
|
||||||
|
const receivedAtMs = Date.now();
|
||||||
|
const coord = {
|
||||||
|
lat: position.coords.latitude,
|
||||||
|
lon: position.coords.longitude
|
||||||
|
};
|
||||||
|
const nativeCourse =
|
||||||
|
typeof position.coords.heading === "number" && Number.isFinite(position.coords.heading)
|
||||||
|
? position.coords.heading
|
||||||
|
: null;
|
||||||
|
const speedKn =
|
||||||
|
typeof position.coords.speed === "number" && Number.isFinite(position.coords.speed)
|
||||||
|
? position.coords.speed * MS_TO_KN
|
||||||
|
: null;
|
||||||
|
const accuracyM = Math.max(0, position.coords.accuracy);
|
||||||
|
const anchor = courseAnchor.current;
|
||||||
|
let derivedCourse: number | null = nativeCourse;
|
||||||
|
if (nativeCourse !== null) {
|
||||||
|
courseAnchor.current = { coord, accuracyM };
|
||||||
|
} else if (!anchor) {
|
||||||
|
courseAnchor.current = { coord, accuracyM };
|
||||||
|
} else {
|
||||||
|
const movementM = haversineDistanceM(anchor.coord, coord);
|
||||||
|
const minimumMovementM = Math.max(3, Math.min(12, Math.max(anchor.accuracyM, accuracyM) * 0.5));
|
||||||
|
if (movementM >= minimumMovementM) {
|
||||||
|
derivedCourse = initialBearingDeg(anchor.coord, coord);
|
||||||
|
courseAnchor.current = { coord, accuracyM };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((current) => ({
|
||||||
|
status: "tracking",
|
||||||
|
position: coord,
|
||||||
|
accuracyM: Math.round(accuracyM),
|
||||||
|
speedKn,
|
||||||
|
courseDeg: derivedCourse ?? current.courseDeg,
|
||||||
|
timestampMs: receivedAtMs,
|
||||||
|
message: null
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
const status = error.code === error.PERMISSION_DENIED ? "denied" : "error";
|
||||||
|
setState((current) => ({ ...current, status, message: error.message }));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 12_000,
|
||||||
|
maximumAge: 2_000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [clearActiveWatch]);
|
||||||
|
|
||||||
|
useEffect(() => clearActiveWatch, [clearActiveWatch]);
|
||||||
|
|
||||||
|
return { ...state, start, stop };
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { Coordinate, MarineForecast, TideSummary } from "@watermaps/shared";
|
||||||
|
import { getMarineForecast, getNearestTide } from "../api";
|
||||||
|
|
||||||
|
export type MarineDataState = {
|
||||||
|
forecast: MarineForecast | null;
|
||||||
|
tide: TideSummary | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
forecastError: string | null;
|
||||||
|
tideError: string | null;
|
||||||
|
queryPosition: Coordinate | null;
|
||||||
|
refreshedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
export function useMarineData(position: Coordinate | null): MarineDataState {
|
||||||
|
const key = useMemo(() => {
|
||||||
|
if (!position) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${position.lat.toFixed(2)}:${position.lon.toFixed(2)}`;
|
||||||
|
}, [position]);
|
||||||
|
const queryPosition = useMemo<Coordinate | null>(() => {
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [lat, lon] = key.split(":").map(Number);
|
||||||
|
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat: lat!, lon: lon! } : null;
|
||||||
|
}, [key]);
|
||||||
|
const [state, setState] = useState<MarineDataState>({
|
||||||
|
forecast: null,
|
||||||
|
tide: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
forecastError: null,
|
||||||
|
tideError: null,
|
||||||
|
queryPosition: null,
|
||||||
|
refreshedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queryPosition || !key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const refresh = () => {
|
||||||
|
setState((current) => {
|
||||||
|
const sameQuery =
|
||||||
|
current.queryPosition?.lat === queryPosition.lat &&
|
||||||
|
current.queryPosition?.lon === queryPosition.lon;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
forecast: sameQuery ? current.forecast : null,
|
||||||
|
tide: sameQuery ? current.tide : null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
forecastError: null,
|
||||||
|
tideError: null,
|
||||||
|
queryPosition
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
void Promise.allSettled([
|
||||||
|
getMarineForecast(queryPosition),
|
||||||
|
getNearestTide(queryPosition)
|
||||||
|
]).then(([forecastResult, tideResult]) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState((current) => {
|
||||||
|
const forecastError =
|
||||||
|
forecastResult.status === "rejected" ? "Wetterdaten nicht erreichbar" : null;
|
||||||
|
const tideError =
|
||||||
|
tideResult.status === "rejected" ? "Tidendaten nicht erreichbar" : null;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
forecast:
|
||||||
|
forecastResult.status === "fulfilled"
|
||||||
|
? forecastResult.value
|
||||||
|
: current.forecast,
|
||||||
|
tide:
|
||||||
|
tideResult.status === "fulfilled"
|
||||||
|
? tideResult.value
|
||||||
|
: current.tide,
|
||||||
|
loading: false,
|
||||||
|
error:
|
||||||
|
forecastError && tideError ? "Metocean-Daten nicht erreichbar" : null,
|
||||||
|
forecastError,
|
||||||
|
tideError,
|
||||||
|
queryPosition,
|
||||||
|
refreshedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
const intervalId = window.setInterval(refresh, REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [key, queryPosition]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { Coordinate, RouteResult } from "@watermaps/shared";
|
||||||
|
import { evaluateRouteDeviation } from "../lib/route-deviation";
|
||||||
|
|
||||||
|
export type RouteDeviationAlarmStatus =
|
||||||
|
| "idle"
|
||||||
|
| "requesting"
|
||||||
|
| "tracking"
|
||||||
|
| "denied"
|
||||||
|
| "unavailable"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export type RouteDeviationAlarmState = {
|
||||||
|
status: RouteDeviationAlarmStatus;
|
||||||
|
position: Coordinate | null;
|
||||||
|
accuracyM: number | null;
|
||||||
|
distanceM: number | null;
|
||||||
|
reliable: boolean;
|
||||||
|
isOffRoute: boolean;
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_STATE: RouteDeviationAlarmState = {
|
||||||
|
status: "idle",
|
||||||
|
position: null,
|
||||||
|
accuracyM: null,
|
||||||
|
distanceM: null,
|
||||||
|
reliable: true,
|
||||||
|
isOffRoute: false,
|
||||||
|
message: null
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches the device position only after start() is called from a user action.
|
||||||
|
* Coordinates are evaluated in memory and are never persisted or transmitted.
|
||||||
|
*/
|
||||||
|
export function useRouteDeviationAlarm(route: RouteResult | null, thresholdM: number) {
|
||||||
|
const [state, setState] = useState<RouteDeviationAlarmState>(INITIAL_STATE);
|
||||||
|
const watchId = useRef<number | null>(null);
|
||||||
|
const activeRoute = useRef<RouteResult | null>(route);
|
||||||
|
const threshold = useRef(thresholdM);
|
||||||
|
const offRoute = useRef(false);
|
||||||
|
const monitoringGeneration = useRef(0);
|
||||||
|
|
||||||
|
activeRoute.current = route;
|
||||||
|
threshold.current = thresholdM;
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
monitoringGeneration.current += 1;
|
||||||
|
if (watchId.current !== null && typeof navigator !== "undefined" && "geolocation" in navigator) {
|
||||||
|
navigator.geolocation.clearWatch(watchId.current);
|
||||||
|
watchId.current = null;
|
||||||
|
}
|
||||||
|
offRoute.current = false;
|
||||||
|
setState(INITIAL_STATE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
if (!activeRoute.current) {
|
||||||
|
setState({ ...INITIAL_STATE, status: "error", message: "Zuerst eine Route berechnen oder offline laden." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined" && window.isSecureContext === false) {
|
||||||
|
setState({ ...INITIAL_STATE, status: "unavailable", message: "Der Abweichungsalarm benötigt HTTPS oder localhost." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
|
||||||
|
setState({ ...INITIAL_STATE, status: "unavailable", message: "GPS ist auf diesem Gerät nicht verfügbar." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watchId.current !== null) {
|
||||||
|
navigator.geolocation.clearWatch(watchId.current);
|
||||||
|
}
|
||||||
|
monitoringGeneration.current += 1;
|
||||||
|
const generation = monitoringGeneration.current;
|
||||||
|
offRoute.current = false;
|
||||||
|
setState({ ...INITIAL_STATE, status: "requesting", message: "GPS-Freigabe wird angefragt …" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
watchId.current = navigator.geolocation.watchPosition(
|
||||||
|
(position) => {
|
||||||
|
if (monitoringGeneration.current !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coordinate = { lat: position.coords.latitude, lon: position.coords.longitude };
|
||||||
|
const result = activeRoute.current
|
||||||
|
? evaluateRouteDeviation(coordinate, activeRoute.current, {
|
||||||
|
thresholdM: threshold.current,
|
||||||
|
accuracyM: position.coords.accuracy
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
setState({
|
||||||
|
...INITIAL_STATE,
|
||||||
|
status: "error",
|
||||||
|
message: "Der Abstand zu dieser Route konnte nicht bestimmt werden."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.isOffRoute && !offRoute.current && typeof navigator.vibrate === "function") {
|
||||||
|
navigator.vibrate([200, 100, 200]);
|
||||||
|
}
|
||||||
|
offRoute.current = result.isOffRoute;
|
||||||
|
setState({
|
||||||
|
status: "tracking",
|
||||||
|
position: coordinate,
|
||||||
|
accuracyM: result.accuracyM,
|
||||||
|
distanceM: result.distanceM,
|
||||||
|
reliable: result.reliable,
|
||||||
|
isOffRoute: result.isOffRoute,
|
||||||
|
message: result.reliable
|
||||||
|
? result.isOffRoute
|
||||||
|
? `Achtung: ${Math.round(result.distanceM)} m von der Route entfernt.`
|
||||||
|
: `Auf Kurs · ${Math.round(result.distanceM)} m zur Route.`
|
||||||
|
: `GPS noch zu ungenau (±${Math.round(result.accuracyM ?? 0)} m) – kein Alarm.`
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (monitoringGeneration.current !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
watchId.current = null;
|
||||||
|
const denied = error.code === error.PERMISSION_DENIED;
|
||||||
|
setState({
|
||||||
|
...INITIAL_STATE,
|
||||||
|
status: denied ? "denied" : "error",
|
||||||
|
message: denied ? "GPS-Freigabe wurde abgelehnt." : error.message || "GPS-Position nicht verfügbar."
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
maximumAge: 3_000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
watchId.current = null;
|
||||||
|
setState({ ...INITIAL_STATE, status: "unavailable", message: "GPS konnte nicht gestartet werden." });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => stop, [stop]);
|
||||||
|
|
||||||
|
// A newly selected route requires a deliberate restart, so the monitor can
|
||||||
|
// never silently continue against a different voyage.
|
||||||
|
const previousRoute = useRef(route);
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousRoute.current !== route) {
|
||||||
|
previousRoute.current = route;
|
||||||
|
if (watchId.current !== null) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [route, stop]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
active: watchId.current !== null,
|
||||||
|
start,
|
||||||
|
stop
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { RouteResult } from "@watermaps/shared";
|
||||||
|
|
||||||
|
const DEFAULT_CREATOR = "Watermaps";
|
||||||
|
|
||||||
|
export type GpxExportOptions = {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
creator?: string;
|
||||||
|
createdAt?: Date | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a standards-compliant GPX 1.1 document containing both the planned
|
||||||
|
* route and a track. Keeping both makes the export useful in navigation apps
|
||||||
|
* which support only one of the two GPX representations.
|
||||||
|
*/
|
||||||
|
export function createRouteGpx(route: RouteResult, options: GpxExportOptions = {}): string {
|
||||||
|
const coordinates = validRouteCoordinates(route);
|
||||||
|
const name = cleanText(options.name ?? route.name ?? "Watermaps Bootsroute", 120);
|
||||||
|
const description = cleanText(
|
||||||
|
options.description ?? `${route.distanceNm.toFixed(1)} sm · ${route.routingMode === "fairway" ? "Fahrwasserroute" : "Bootsroute"}`,
|
||||||
|
500
|
||||||
|
);
|
||||||
|
const creator = cleanText(options.creator ?? DEFAULT_CREATOR, 120);
|
||||||
|
const createdAt = normalizeDate(options.createdAt);
|
||||||
|
const source = cleanText(route.dataSources.join(", ") || DEFAULT_CREATOR, 500);
|
||||||
|
const warningSummary = cleanText(
|
||||||
|
route.warnings.map((warning) => warning.message).join(" · ") || "Nicht amtliche Routenplanung",
|
||||||
|
1_000
|
||||||
|
);
|
||||||
|
const bounds = routeBounds(coordinates);
|
||||||
|
const routePoints = coordinates.map(([lon, lat]) => ` <rtept lat="${formatCoordinate(lat)}" lon="${formatCoordinate(lon)}"/>`).join("\n");
|
||||||
|
const trackPoints = coordinates.map(([lon, lat]) => ` <trkpt lat="${formatCoordinate(lat)}" lon="${formatCoordinate(lon)}"/>`).join("\n");
|
||||||
|
|
||||||
|
return [
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
||||||
|
`<gpx version="1.1" creator="${escapeXml(creator)}" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">`,
|
||||||
|
" <metadata>",
|
||||||
|
` <name>${escapeXml(name)}</name>`,
|
||||||
|
` <desc>${escapeXml(description)}</desc>`,
|
||||||
|
" <author>",
|
||||||
|
` <name>${escapeXml(creator)}</name>`,
|
||||||
|
" </author>",
|
||||||
|
` <time>${createdAt}</time>`,
|
||||||
|
" <keywords>Watermaps, Bootsroute, Navigation</keywords>",
|
||||||
|
` <bounds minlat="${formatCoordinate(bounds.minLat)}" minlon="${formatCoordinate(bounds.minLon)}" maxlat="${formatCoordinate(bounds.maxLat)}" maxlon="${formatCoordinate(bounds.maxLon)}"/>`,
|
||||||
|
" </metadata>",
|
||||||
|
" <rte>",
|
||||||
|
` <name>${escapeXml(name)}</name>`,
|
||||||
|
` <cmt>${escapeXml(warningSummary)}</cmt>`,
|
||||||
|
` <desc>${escapeXml(description)}</desc>`,
|
||||||
|
` <src>${escapeXml(source)}</src>`,
|
||||||
|
" <number>1</number>",
|
||||||
|
" <type>Motorboating</type>",
|
||||||
|
routePoints,
|
||||||
|
" </rte>",
|
||||||
|
" <trk>",
|
||||||
|
` <name>${escapeXml(name)}</name>`,
|
||||||
|
` <cmt>${escapeXml(warningSummary)}</cmt>`,
|
||||||
|
` <desc>${escapeXml(description)}</desc>`,
|
||||||
|
` <src>${escapeXml(source)}</src>`,
|
||||||
|
" <number>1</number>",
|
||||||
|
" <type>Motorboating</type>",
|
||||||
|
" <trkseg>",
|
||||||
|
trackPoints,
|
||||||
|
" </trkseg>",
|
||||||
|
" </trk>",
|
||||||
|
"</gpx>",
|
||||||
|
""
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadRouteGpx(route: RouteResult, options: GpxExportOptions = {}): void {
|
||||||
|
if (typeof document === "undefined" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
|
||||||
|
throw new Error("GPX-Download wird von diesem Browser nicht unterstützt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = cleanText(options.name ?? route.name ?? "Watermaps-Route", 120);
|
||||||
|
const filename = `${safeFilename(name)}.gpx`;
|
||||||
|
const blobUrl = URL.createObjectURL(new Blob([createRouteGpx(route, options)], { type: "application/gpx+xml;charset=utf-8" }));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
link.rel = "noopener";
|
||||||
|
link.hidden = true;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRouteCoordinates(route: RouteResult): [number, number][] {
|
||||||
|
if (route.geometry.type !== "LineString" || route.geometry.coordinates.length < 2) {
|
||||||
|
throw new Error("Die Route enthält nicht genügend Punkte für einen GPX-Export.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = route.geometry.coordinates.map((coordinate) => {
|
||||||
|
const [lon, lat] = coordinate;
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||||||
|
throw new Error("Die Route enthält ungültige Koordinaten.");
|
||||||
|
}
|
||||||
|
return [lon, lat] as [number, number];
|
||||||
|
});
|
||||||
|
|
||||||
|
return coordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeBounds(coordinates: [number, number][]) {
|
||||||
|
let minLat = 90;
|
||||||
|
let maxLat = -90;
|
||||||
|
let minLon = 180;
|
||||||
|
let maxLon = -180;
|
||||||
|
for (const [lon, lat] of coordinates) {
|
||||||
|
minLat = Math.min(minLat, lat);
|
||||||
|
maxLat = Math.max(maxLat, lat);
|
||||||
|
minLon = Math.min(minLon, lon);
|
||||||
|
maxLon = Math.max(maxLon, lon);
|
||||||
|
}
|
||||||
|
return { minLat, maxLat, minLon, maxLon };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(value: Date | string | undefined): string {
|
||||||
|
const date = value instanceof Date ? value : value ? new Date(value) : new Date();
|
||||||
|
if (!Number.isFinite(date.getTime())) {
|
||||||
|
throw new Error("Ungültiger Zeitstempel für den GPX-Export.");
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number): string {
|
||||||
|
return value.toFixed(7).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value: string, maxLength: number): string {
|
||||||
|
const cleaned = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ").trim();
|
||||||
|
return cleaned.slice(0, maxLength) || DEFAULT_CREATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFilename(value: string): string {
|
||||||
|
const normalized = value
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "-")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^[-.]+|[-.]+$/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
return normalized || "Watermaps-Route";
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import type { Coordinate, RouteResult, RouteWarning, VesselProfile } from "@watermaps/shared";
|
||||||
|
|
||||||
|
export const OFFLINE_VOYAGES_STORAGE_KEY = "watermaps.offline-voyages.v1";
|
||||||
|
export const LEGACY_OFFLINE_VOYAGES_STORAGE_KEY = "seacompass.offline-voyages.v1";
|
||||||
|
export const MAX_OFFLINE_VOYAGES = 20;
|
||||||
|
const MAX_SERIALIZED_BYTES = 4_000_000;
|
||||||
|
const MAX_ROUTE_POINTS = 25_000;
|
||||||
|
|
||||||
|
export type OfflineVoyagePlan = {
|
||||||
|
start: Coordinate;
|
||||||
|
destination: Coordinate;
|
||||||
|
waypoints: Coordinate[];
|
||||||
|
vesselProfile: VesselProfile | null;
|
||||||
|
departureAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OfflineVoyagePlanInput = Partial<OfflineVoyagePlan>;
|
||||||
|
|
||||||
|
export type OfflineVoyage = {
|
||||||
|
schemaVersion: 1;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
savedAt: string;
|
||||||
|
plan: OfflineVoyagePlan;
|
||||||
|
route: RouteResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveOfflineVoyageInput = {
|
||||||
|
route: RouteResult;
|
||||||
|
name?: string;
|
||||||
|
plan?: OfflineVoyagePlanInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OfflineVoyageRecordOptions = {
|
||||||
|
id?: string;
|
||||||
|
savedAt?: Date | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OfflineVoyageStorageError extends Error {
|
||||||
|
override name = "OfflineVoyageStorageError";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves one validated snapshot atomically. Only route-planning fields are
|
||||||
|
* whitelisted; live GPS positions are deliberately never persisted.
|
||||||
|
*/
|
||||||
|
export function saveOfflineVoyage(
|
||||||
|
input: SaveOfflineVoyageInput,
|
||||||
|
storage: Storage = browserStorage(),
|
||||||
|
options: OfflineVoyageRecordOptions = {}
|
||||||
|
): OfflineVoyage {
|
||||||
|
const record = createOfflineVoyageRecord(input, options);
|
||||||
|
const records = listOfflineVoyages(storage).filter((item) => item.id !== record.id);
|
||||||
|
const next = [record, ...records].slice(0, MAX_OFFLINE_VOYAGES);
|
||||||
|
const serialized = JSON.stringify(next);
|
||||||
|
if (serialized.length > MAX_SERIALIZED_BYTES) {
|
||||||
|
throw new OfflineVoyageStorageError("Die Route ist zu groß für den Offline-Speicher.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, serialized);
|
||||||
|
} catch {
|
||||||
|
throw new OfflineVoyageStorageError("Die Route konnte auf diesem Gerät nicht gespeichert werden.");
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listOfflineVoyages(storage: Storage = browserStorage()): OfflineVoyage[] {
|
||||||
|
let serialized: string | null;
|
||||||
|
try {
|
||||||
|
serialized =
|
||||||
|
storage.getItem(OFFLINE_VOYAGES_STORAGE_KEY) ??
|
||||||
|
storage.getItem(LEGACY_OFFLINE_VOYAGES_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
throw new OfflineVoyageStorageError("Der Offline-Speicher ist nicht verfügbar.");
|
||||||
|
}
|
||||||
|
if (!serialized || serialized.length > MAX_SERIALIZED_BYTES) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(serialized);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
.slice(0, MAX_OFFLINE_VOYAGES)
|
||||||
|
.map(parseOfflineVoyage)
|
||||||
|
.filter((item): item is OfflineVoyage => item !== null)
|
||||||
|
.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadOfflineVoyage(id: string, storage: Storage = browserStorage()): OfflineVoyage | null {
|
||||||
|
const safeId = limitedString(id, 160);
|
||||||
|
if (!safeId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return listOfflineVoyages(storage).find((record) => record.id === safeId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOfflineVoyage(id: string, storage: Storage = browserStorage()): boolean {
|
||||||
|
const records = listOfflineVoyages(storage);
|
||||||
|
const next = records.filter((record) => record.id !== id);
|
||||||
|
if (next.length === records.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
storage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
throw new OfflineVoyageStorageError("Die Offline-Route konnte nicht gelöscht werden.");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort protection against automatic browser eviction after a user saves a route. */
|
||||||
|
export async function requestPersistentOfflineStorage(): Promise<boolean | null> {
|
||||||
|
if (typeof navigator === "undefined" || !navigator.storage?.persist) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (await navigator.storage.persisted()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return await navigator.storage.persist();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOfflineVoyageRecord(
|
||||||
|
input: SaveOfflineVoyageInput,
|
||||||
|
options: OfflineVoyageRecordOptions = {}
|
||||||
|
): OfflineVoyage {
|
||||||
|
const route = normalizeRoute(input.route);
|
||||||
|
const first = route.geometry.coordinates[0]!;
|
||||||
|
const last = route.geometry.coordinates.at(-1)!;
|
||||||
|
const savedAt = normalizeIsoDate(options.savedAt ?? new Date());
|
||||||
|
const name = limitedString(input.name ?? route.name ?? "Offline-Bootsroute", 120) || "Offline-Bootsroute";
|
||||||
|
const id = limitedString(options.id ?? createId(savedAt), 160);
|
||||||
|
if (!id) {
|
||||||
|
throw new OfflineVoyageStorageError("Ungültige Kennung für die Offline-Route.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStart = { lat: first[1], lon: first[0] };
|
||||||
|
const defaultDestination = { lat: last[1], lon: last[0] };
|
||||||
|
const planInput = input.plan ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
savedAt,
|
||||||
|
plan: {
|
||||||
|
start: normalizeCoordinate(planInput.start ?? defaultStart),
|
||||||
|
destination: normalizeCoordinate(planInput.destination ?? defaultDestination),
|
||||||
|
waypoints: normalizeCoordinateList(planInput.waypoints ?? []),
|
||||||
|
vesselProfile: planInput.vesselProfile ? normalizeVesselProfile(planInput.vesselProfile) : null,
|
||||||
|
departureAt: planInput.departureAt ? normalizeIsoDate(planInput.departureAt) : null,
|
||||||
|
notes: planInput.notes ? limitedString(planInput.notes, 2_000) : null
|
||||||
|
},
|
||||||
|
route
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOfflineVoyage(value: unknown): OfflineVoyage | null {
|
||||||
|
if (!isRecord(value) || value.schemaVersion !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const id = limitedString(value.id, 160);
|
||||||
|
const name = limitedString(value.name, 120);
|
||||||
|
if (!id || !name || !isRecord(value.plan)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const plan = value.plan;
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
savedAt: normalizeIsoDate(value.savedAt),
|
||||||
|
plan: {
|
||||||
|
start: normalizeCoordinate(plan.start),
|
||||||
|
destination: normalizeCoordinate(plan.destination),
|
||||||
|
waypoints: normalizeCoordinateList(plan.waypoints),
|
||||||
|
vesselProfile: plan.vesselProfile === null ? null : normalizeVesselProfile(plan.vesselProfile),
|
||||||
|
departureAt: plan.departureAt === null ? null : normalizeIsoDate(plan.departureAt),
|
||||||
|
notes: plan.notes === null ? null : limitedString(plan.notes, 2_000)
|
||||||
|
},
|
||||||
|
route: normalizeRoute(value.route)
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoute(value: unknown): RouteResult {
|
||||||
|
if (!isRecord(value) || !isRecord(value.geometry) || value.geometry.type !== "LineString") {
|
||||||
|
throw new OfflineVoyageStorageError("Die Offline-Route hat ein ungültiges Format.");
|
||||||
|
}
|
||||||
|
const coordinates = normalizeLineCoordinates(value.geometry.coordinates);
|
||||||
|
const distanceNm = finiteNumber(value.distanceNm, 0, 100_000);
|
||||||
|
const eta = value.eta === null ? null : limitedString(value.eta, 100);
|
||||||
|
const minKnownDepthM = value.minKnownDepthM === null ? null : finiteNumber(value.minKnownDepthM, 0, 20_000);
|
||||||
|
const unknownDepthRatio = finiteNumber(value.unknownDepthRatio, 0, 1);
|
||||||
|
const warnings = normalizeWarnings(value.warnings);
|
||||||
|
const dataSources = normalizeStrings(value.dataSources, 100, 500);
|
||||||
|
const id = value.id === undefined ? undefined : limitedString(value.id, 160);
|
||||||
|
const name = value.name === undefined ? undefined : limitedString(value.name, 120);
|
||||||
|
const routingMode = value.routingMode === "manual" || value.routingMode === "fairway" ? value.routingMode : undefined;
|
||||||
|
const departureTime =
|
||||||
|
typeof value.departureTime === "string" ? normalizeIsoDate(value.departureTime) : undefined;
|
||||||
|
const durationMinutes =
|
||||||
|
value.durationMinutes === undefined ? undefined : finiteNumber(value.durationMinutes, 0, 10_000_000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
geometry: { type: "LineString", coordinates },
|
||||||
|
distanceNm,
|
||||||
|
eta,
|
||||||
|
warnings,
|
||||||
|
minKnownDepthM,
|
||||||
|
unknownDepthRatio,
|
||||||
|
dataSources,
|
||||||
|
...(departureTime ? { departureTime } : {}),
|
||||||
|
...(durationMinutes !== undefined ? { durationMinutes } : {}),
|
||||||
|
...(routingMode ? { routingMode } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLineCoordinates(value: unknown): [number, number][] {
|
||||||
|
if (!Array.isArray(value) || value.length < 2 || value.length > MAX_ROUTE_POINTS) {
|
||||||
|
throw new OfflineVoyageStorageError("Die Offline-Route enthält keine gültige Liniengeometrie.");
|
||||||
|
}
|
||||||
|
return value.map((item) => {
|
||||||
|
if (!Array.isArray(item) || item.length < 2) {
|
||||||
|
throw new OfflineVoyageStorageError("Die Offline-Route enthält ungültige Koordinaten.");
|
||||||
|
}
|
||||||
|
const lon = finiteNumber(item[0], -180, 180);
|
||||||
|
const lat = finiteNumber(item[1], -90, 90);
|
||||||
|
return [lon, lat];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCoordinateList(value: unknown): Coordinate[] {
|
||||||
|
if (!Array.isArray(value) || value.length > 1_000) {
|
||||||
|
throw new OfflineVoyageStorageError("Die Wegpunktliste ist ungültig.");
|
||||||
|
}
|
||||||
|
return value.map(normalizeCoordinate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCoordinate(value: unknown): Coordinate {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new OfflineVoyageStorageError("Eine Plankoordinate ist ungültig.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lat: finiteNumber(value.lat, -90, 90),
|
||||||
|
lon: finiteNumber(value.lon, -180, 180)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVesselProfile(value: unknown): VesselProfile {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new OfflineVoyageStorageError("Das Bootsprofil ist ungültig.");
|
||||||
|
}
|
||||||
|
const profile: VesselProfile = {
|
||||||
|
draughtM: finiteNumber(value.draughtM, 0, 100),
|
||||||
|
safetyReserveM: finiteNumber(value.safetyReserveM, 0, 100)
|
||||||
|
};
|
||||||
|
if (value.airDraftM !== undefined) {
|
||||||
|
profile.airDraftM = finiteNumber(value.airDraftM, 0, 200);
|
||||||
|
}
|
||||||
|
if (value.beamM !== undefined) {
|
||||||
|
profile.beamM = finiteNumber(value.beamM, 0, 200);
|
||||||
|
}
|
||||||
|
if (value.cruiseSpeedKn !== undefined) {
|
||||||
|
profile.cruiseSpeedKn = finiteNumber(value.cruiseSpeedKn, 0.1, 200);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWarnings(value: unknown): RouteWarning[] {
|
||||||
|
if (!Array.isArray(value) || value.length > 500) {
|
||||||
|
throw new OfflineVoyageStorageError("Die Routenwarnungen sind ungültig.");
|
||||||
|
}
|
||||||
|
return value.map((warning) => {
|
||||||
|
if (!isRecord(warning)) {
|
||||||
|
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
|
||||||
|
}
|
||||||
|
const code = limitedString(warning.code, 100);
|
||||||
|
const message = limitedString(warning.message, 1_000);
|
||||||
|
const severity = warning.severity;
|
||||||
|
if (!code || !message || (severity !== "info" && severity !== "caution" && severity !== "critical")) {
|
||||||
|
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
severity,
|
||||||
|
...(warning.coordinate === undefined ? {} : { coordinate: normalizeCoordinate(warning.coordinate) })
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStrings(value: unknown, maxItems: number, maxLength: number): string[] {
|
||||||
|
if (!Array.isArray(value) || value.length > maxItems) {
|
||||||
|
throw new OfflineVoyageStorageError("Die Quellenangaben der Route sind ungültig.");
|
||||||
|
}
|
||||||
|
return value.map((item) => limitedString(item, maxLength)).filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown, min: number, max: number): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
||||||
|
throw new OfflineVoyageStorageError("Die Offline-Route enthält einen ungültigen Zahlenwert.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitedString(value: unknown, maxLength: number): string {
|
||||||
|
return typeof value === "string" ? value.replace(/[\u0000-\u001F\u007F]/g, " ").trim().slice(0, maxLength) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIsoDate(value: unknown): string {
|
||||||
|
const date = value instanceof Date ? value : typeof value === "string" ? new Date(value) : new Date(Number.NaN);
|
||||||
|
if (!Number.isFinite(date.getTime())) {
|
||||||
|
throw new OfflineVoyageStorageError("Der Zeitstempel der Offline-Route ist ungültig.");
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createId(savedAt: string): string {
|
||||||
|
const randomId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: Math.random().toString(36).slice(2, 14);
|
||||||
|
return `voyage-${savedAt.replace(/\D/g, "").slice(0, 14)}-${randomId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserStorage(): Storage {
|
||||||
|
if (typeof window === "undefined" || !window.localStorage) {
|
||||||
|
throw new OfflineVoyageStorageError("Der Offline-Speicher wird von diesem Browser nicht unterstützt.");
|
||||||
|
}
|
||||||
|
return window.localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { Coordinate, GeoJsonLineString, RouteResult } from "@watermaps/shared";
|
||||||
|
|
||||||
|
const EARTH_RADIUS_M = 6_371_008.8;
|
||||||
|
const MIN_SEGMENT_ANGLE = 1e-12;
|
||||||
|
|
||||||
|
export type RouteDeviationOptions = {
|
||||||
|
thresholdM?: number;
|
||||||
|
accuracyM?: number | null;
|
||||||
|
maxAccuracyM?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteDeviationResult = {
|
||||||
|
distanceM: number;
|
||||||
|
conservativeDistanceM: number;
|
||||||
|
thresholdM: number;
|
||||||
|
accuracyM: number | null;
|
||||||
|
reliable: boolean;
|
||||||
|
isOffRoute: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns the shortest geodesic distance from a point to the route segments. */
|
||||||
|
export function distanceToRouteM(
|
||||||
|
position: Coordinate,
|
||||||
|
route: RouteResult | GeoJsonLineString | readonly [number, number][]
|
||||||
|
): number | null {
|
||||||
|
if (!isCoordinate(position)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const coordinates = routeCoordinates(route);
|
||||||
|
if (coordinates.length === 0 || coordinates.some((coordinate) => !isGeoJsonCoordinate(coordinate))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (coordinates.length === 1) {
|
||||||
|
return angularDistance(position, toCoordinate(coordinates[0]!)) * EARTH_RADIUS_M;
|
||||||
|
}
|
||||||
|
|
||||||
|
let closestM = Number.POSITIVE_INFINITY;
|
||||||
|
for (let index = 1; index < coordinates.length; index += 1) {
|
||||||
|
const start = toCoordinate(coordinates[index - 1]!);
|
||||||
|
const end = toCoordinate(coordinates[index]!);
|
||||||
|
closestM = Math.min(closestM, distanceToGreatCircleSegmentM(position, start, end));
|
||||||
|
}
|
||||||
|
return Number.isFinite(closestM) ? closestM : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies GPS accuracy conservatively: an alarm is emitted only when even the
|
||||||
|
* nearest edge of the reported accuracy circle is outside the corridor.
|
||||||
|
*/
|
||||||
|
export function evaluateRouteDeviation(
|
||||||
|
position: Coordinate,
|
||||||
|
route: RouteResult | GeoJsonLineString | readonly [number, number][],
|
||||||
|
options: RouteDeviationOptions = {}
|
||||||
|
): RouteDeviationResult | null {
|
||||||
|
const thresholdM = finiteRange(options.thresholdM ?? 100, 10, 10_000);
|
||||||
|
const distanceM = distanceToRouteM(position, route);
|
||||||
|
if (distanceM === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const accuracyM = typeof options.accuracyM === "number" && Number.isFinite(options.accuracyM) && options.accuracyM >= 0
|
||||||
|
? options.accuracyM
|
||||||
|
: null;
|
||||||
|
const maxAccuracyM = finiteRange(options.maxAccuracyM ?? Math.max(100, thresholdM * 2), 10, 20_000);
|
||||||
|
const reliable = accuracyM === null || accuracyM <= maxAccuracyM;
|
||||||
|
const conservativeDistanceM = Math.max(0, distanceM - (accuracyM ?? 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
distanceM,
|
||||||
|
conservativeDistanceM,
|
||||||
|
thresholdM,
|
||||||
|
accuracyM,
|
||||||
|
reliable,
|
||||||
|
isOffRoute: reliable && conservativeDistanceM > thresholdM
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToGreatCircleSegmentM(point: Coordinate, start: Coordinate, end: Coordinate): number {
|
||||||
|
const segmentAngle = angularDistance(start, end);
|
||||||
|
if (segmentAngle < MIN_SEGMENT_ANGLE) {
|
||||||
|
return angularDistance(point, start) * EARTH_RADIUS_M;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointAngle = angularDistance(start, point);
|
||||||
|
if (pointAngle < MIN_SEGMENT_ANGLE) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentBearing = initialBearingRad(start, end);
|
||||||
|
const pointBearing = initialBearingRad(start, point);
|
||||||
|
const bearingDelta = pointBearing - segmentBearing;
|
||||||
|
const crossTrackAngle = Math.asin(clamp(Math.sin(pointAngle) * Math.sin(bearingDelta), -1, 1));
|
||||||
|
const alongTrackAngle = Math.atan2(
|
||||||
|
Math.sin(pointAngle) * Math.cos(bearingDelta),
|
||||||
|
Math.cos(pointAngle)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (alongTrackAngle <= 0) {
|
||||||
|
return pointAngle * EARTH_RADIUS_M;
|
||||||
|
}
|
||||||
|
if (alongTrackAngle >= segmentAngle) {
|
||||||
|
return angularDistance(point, end) * EARTH_RADIUS_M;
|
||||||
|
}
|
||||||
|
return Math.abs(crossTrackAngle) * EARTH_RADIUS_M;
|
||||||
|
}
|
||||||
|
|
||||||
|
function angularDistance(a: Coordinate, b: Coordinate): number {
|
||||||
|
const lat1 = toRadians(a.lat);
|
||||||
|
const lat2 = toRadians(b.lat);
|
||||||
|
const deltaLat = lat2 - lat1;
|
||||||
|
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
|
||||||
|
const haversine = Math.sin(deltaLat / 2) ** 2
|
||||||
|
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
|
||||||
|
return 2 * Math.asin(Math.sqrt(clamp(haversine, 0, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialBearingRad(a: Coordinate, b: Coordinate): number {
|
||||||
|
const lat1 = toRadians(a.lat);
|
||||||
|
const lat2 = toRadians(b.lat);
|
||||||
|
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
|
||||||
|
return Math.atan2(
|
||||||
|
Math.sin(deltaLon) * Math.cos(lat2),
|
||||||
|
Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeCoordinates(route: RouteResult | GeoJsonLineString | readonly [number, number][]): readonly [number, number][] {
|
||||||
|
if (Array.isArray(route)) {
|
||||||
|
return route as readonly [number, number][];
|
||||||
|
}
|
||||||
|
if ("geometry" in route) {
|
||||||
|
return route.geometry.coordinates;
|
||||||
|
}
|
||||||
|
return (route as GeoJsonLineString).coordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGeoJsonCoordinate(value: unknown): value is [number, number] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
&& value.length >= 2
|
||||||
|
&& typeof value[0] === "number"
|
||||||
|
&& Number.isFinite(value[0])
|
||||||
|
&& value[0] >= -180
|
||||||
|
&& value[0] <= 180
|
||||||
|
&& typeof value[1] === "number"
|
||||||
|
&& Number.isFinite(value[1])
|
||||||
|
&& value[1] >= -90
|
||||||
|
&& value[1] <= 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCoordinate(value: Coordinate): boolean {
|
||||||
|
return Number.isFinite(value.lat)
|
||||||
|
&& value.lat >= -90
|
||||||
|
&& value.lat <= 90
|
||||||
|
&& Number.isFinite(value.lon)
|
||||||
|
&& value.lon >= -180
|
||||||
|
&& value.lon <= 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCoordinate(value: [number, number]): Coordinate {
|
||||||
|
return { lon: value[0], lat: value[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRadians(value: number): number {
|
||||||
|
return value * Math.PI / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRadians(value: number): number {
|
||||||
|
return ((value + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteRange(value: number, min: number, max: number): number {
|
||||||
|
return Number.isFinite(value) ? clamp(value, min, max) : min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import "./styles/app.css";
|
||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import {
|
||||||
|
orderWaypointsAlongRoute,
|
||||||
|
type Coordinate,
|
||||||
|
type RouteResult,
|
||||||
|
type VoyageHarbour
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
import type { RouteBridgeAssessment } from "./routeWeatherReport";
|
||||||
|
import type { RouteLock } from "./voyageHarbours";
|
||||||
|
|
||||||
|
export type RouteEventKind = "harbour" | "lock" | "bridge";
|
||||||
|
|
||||||
|
export type RouteEventCorridors = Record<RouteEventKind, number>;
|
||||||
|
|
||||||
|
export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly<RouteEventCorridors> = Object.freeze({
|
||||||
|
harbour: 1.5,
|
||||||
|
lock: 0.25,
|
||||||
|
bridge: 0.08
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed";
|
||||||
|
export type RouteEventEtaReferenceSource = "current-time" | "route-departure";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ETA assumptions are deliberately supplied by the caller. This prevents a
|
||||||
|
* stale GPS speed or a planned cruise speed from being presented without its
|
||||||
|
* provenance.
|
||||||
|
*/
|
||||||
|
export type RouteEventEtaBasis = {
|
||||||
|
speedKn: number;
|
||||||
|
speedSource: RouteEventEtaSpeedSource;
|
||||||
|
referenceTime: string | number | Date;
|
||||||
|
referenceSource: RouteEventEtaReferenceSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteEventEta = {
|
||||||
|
estimatedAt: string;
|
||||||
|
minutesFromProgress: number;
|
||||||
|
speedKn: number;
|
||||||
|
speedSource: RouteEventEtaSpeedSource;
|
||||||
|
referenceTime: string;
|
||||||
|
referenceSource: RouteEventEtaReferenceSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteEventBase = {
|
||||||
|
kind: RouteEventKind;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coordinate: Coordinate;
|
||||||
|
/** Position of the projected feature along the routed geometry. */
|
||||||
|
routeDistanceNm: number;
|
||||||
|
/** Shortest lateral distance between the feature and the route. */
|
||||||
|
distanceFromRouteNm: number;
|
||||||
|
remainingNm: number;
|
||||||
|
eta: RouteEventEta | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HarbourRouteEvent = RouteEventBase & {
|
||||||
|
kind: "harbour";
|
||||||
|
feature: VoyageHarbour;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LockRouteEvent = RouteEventBase & {
|
||||||
|
kind: "lock";
|
||||||
|
feature: RouteLock;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BridgeRouteEvent = RouteEventBase & {
|
||||||
|
kind: "bridge";
|
||||||
|
feature: RouteBridgeAssessment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpcomingRouteEvent =
|
||||||
|
| HarbourRouteEvent
|
||||||
|
| LockRouteEvent
|
||||||
|
| BridgeRouteEvent;
|
||||||
|
|
||||||
|
export type NextRouteEventsByKind = {
|
||||||
|
harbour: HarbourRouteEvent | null;
|
||||||
|
lock: LockRouteEvent | null;
|
||||||
|
bridge: BridgeRouteEvent | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpcomingRouteEventsInput = {
|
||||||
|
route: Pick<RouteResult, "geometry" | "distanceNm">;
|
||||||
|
harbours?: readonly VoyageHarbour[];
|
||||||
|
locks?: readonly RouteLock[];
|
||||||
|
bridges?: readonly RouteBridgeAssessment[];
|
||||||
|
/** Progress along the route. Invalid or negative values resolve to zero. */
|
||||||
|
progressNm?: number | null;
|
||||||
|
corridorsNm?: Partial<RouteEventCorridors>;
|
||||||
|
etaBasis?: RouteEventEtaBasis | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteEventCandidate =
|
||||||
|
| {
|
||||||
|
projectionId: string;
|
||||||
|
kind: "harbour";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coordinate: Coordinate;
|
||||||
|
feature: VoyageHarbour;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
projectionId: string;
|
||||||
|
kind: "lock";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coordinate: Coordinate;
|
||||||
|
feature: RouteLock;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
projectionId: string;
|
||||||
|
kind: "bridge";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coordinate: Coordinate;
|
||||||
|
feature: RouteBridgeAssessment;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects all supplied facilities onto the route, applies a corridor per
|
||||||
|
* facility type and returns only the current or upcoming facilities in route
|
||||||
|
* order.
|
||||||
|
*
|
||||||
|
* Every feature is projected again. In particular,
|
||||||
|
* RouteBridgeAssessment.distanceNm is intentionally ignored because it is the
|
||||||
|
* bridge's lateral distance to the route, not its distance along the route.
|
||||||
|
*/
|
||||||
|
export function upcomingRouteEvents(
|
||||||
|
input: UpcomingRouteEventsInput
|
||||||
|
): UpcomingRouteEvent[] {
|
||||||
|
const progressNm = normalizeProgress(input.progressNm);
|
||||||
|
const corridors = resolveCorridors(input.corridorsNm);
|
||||||
|
const candidates = routeEventCandidates(input);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidatesByProjectionId = new Map(
|
||||||
|
candidates.map((candidate) => [candidate.projectionId, candidate])
|
||||||
|
);
|
||||||
|
const projected = orderWaypointsAlongRoute(
|
||||||
|
candidates.map((candidate) => ({
|
||||||
|
id: candidate.projectionId,
|
||||||
|
name: candidate.name,
|
||||||
|
coordinate: candidate.coordinate
|
||||||
|
})),
|
||||||
|
input.route
|
||||||
|
);
|
||||||
|
|
||||||
|
return projected.flatMap<UpcomingRouteEvent>((projection) => {
|
||||||
|
const candidate = candidatesByProjectionId.get(projection.id);
|
||||||
|
if (
|
||||||
|
!candidate ||
|
||||||
|
projection.distanceFromRouteNm > corridors[candidate.kind] ||
|
||||||
|
projection.routeDistanceNm < progressNm
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingNm = Math.max(0, projection.routeDistanceNm - progressNm);
|
||||||
|
const common = {
|
||||||
|
kind: candidate.kind,
|
||||||
|
id: candidate.id,
|
||||||
|
name: candidate.name,
|
||||||
|
coordinate: candidate.coordinate,
|
||||||
|
routeDistanceNm: projection.routeDistanceNm,
|
||||||
|
distanceFromRouteNm: projection.distanceFromRouteNm,
|
||||||
|
remainingNm,
|
||||||
|
eta: estimateRouteEventEta(remainingNm, input.etaBasis)
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (candidate.kind) {
|
||||||
|
case "harbour":
|
||||||
|
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||||
|
case "lock":
|
||||||
|
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||||
|
case "bridge":
|
||||||
|
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextRouteEventsByKind(
|
||||||
|
events: readonly UpcomingRouteEvent[]
|
||||||
|
): NextRouteEventsByKind {
|
||||||
|
const next: NextRouteEventsByKind = {
|
||||||
|
harbour: null,
|
||||||
|
lock: null,
|
||||||
|
bridge: null
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
switch (event.kind) {
|
||||||
|
case "harbour":
|
||||||
|
next.harbour ??= event;
|
||||||
|
break;
|
||||||
|
case "lock":
|
||||||
|
next.lock ??= event;
|
||||||
|
break;
|
||||||
|
case "bridge":
|
||||||
|
next.bridge ??= event;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeEventCandidates(input: UpcomingRouteEventsInput): RouteEventCandidate[] {
|
||||||
|
const candidates: RouteEventCandidate[] = [];
|
||||||
|
|
||||||
|
input.harbours?.forEach((feature, index) => {
|
||||||
|
candidates.push({
|
||||||
|
projectionId: projectionId("harbour", index, feature.id),
|
||||||
|
kind: "harbour",
|
||||||
|
id: feature.id,
|
||||||
|
name: feature.name,
|
||||||
|
coordinate: feature.coordinate,
|
||||||
|
feature
|
||||||
|
});
|
||||||
|
});
|
||||||
|
input.locks?.forEach((feature, index) => {
|
||||||
|
candidates.push({
|
||||||
|
projectionId: projectionId("lock", index, feature.id),
|
||||||
|
kind: "lock",
|
||||||
|
id: feature.id,
|
||||||
|
name: feature.name,
|
||||||
|
coordinate: feature.coordinate,
|
||||||
|
feature
|
||||||
|
});
|
||||||
|
});
|
||||||
|
input.bridges?.forEach((feature, index) => {
|
||||||
|
candidates.push({
|
||||||
|
projectionId: projectionId("bridge", index, feature.id),
|
||||||
|
kind: "bridge",
|
||||||
|
id: feature.id,
|
||||||
|
name: feature.name ?? feature.label ?? "Brücke",
|
||||||
|
coordinate: feature.coordinate,
|
||||||
|
feature
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectionId(kind: RouteEventKind, index: number, featureId: string) {
|
||||||
|
return `${kind}:${index}:${featureId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCorridors(
|
||||||
|
overrides: Partial<RouteEventCorridors> | undefined
|
||||||
|
): RouteEventCorridors {
|
||||||
|
return {
|
||||||
|
harbour: nonNegativeOrDefault(
|
||||||
|
overrides?.harbour,
|
||||||
|
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour
|
||||||
|
),
|
||||||
|
lock: nonNegativeOrDefault(
|
||||||
|
overrides?.lock,
|
||||||
|
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.lock
|
||||||
|
),
|
||||||
|
bridge: nonNegativeOrDefault(
|
||||||
|
overrides?.bridge,
|
||||||
|
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.bridge
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProgress(value: number | null | undefined) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value)
|
||||||
|
? Math.max(0, value)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeOrDefault(value: number | undefined, fallback: number) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||||
|
? value
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateRouteEventEta(
|
||||||
|
remainingNm: number,
|
||||||
|
basis: RouteEventEtaBasis | null | undefined
|
||||||
|
): RouteEventEta | null {
|
||||||
|
if (
|
||||||
|
!basis ||
|
||||||
|
!Number.isFinite(basis.speedKn) ||
|
||||||
|
basis.speedKn <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const referenceTimestamp = timestampValue(basis.referenceTime);
|
||||||
|
if (referenceTimestamp === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutesFromProgress = (remainingNm / basis.speedKn) * 60;
|
||||||
|
const estimatedTimestamp =
|
||||||
|
referenceTimestamp + minutesFromProgress * 60_000;
|
||||||
|
if (!Number.isFinite(estimatedTimestamp)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
estimatedAt: new Date(estimatedTimestamp).toISOString(),
|
||||||
|
minutesFromProgress,
|
||||||
|
speedKn: basis.speedKn,
|
||||||
|
speedSource: basis.speedSource,
|
||||||
|
referenceTime: new Date(referenceTimestamp).toISOString(),
|
||||||
|
referenceSource: basis.referenceSource
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampValue(value: string | number | Date): number | null {
|
||||||
|
const timestamp =
|
||||||
|
value instanceof Date
|
||||||
|
? value.getTime()
|
||||||
|
: typeof value === "number"
|
||||||
|
? value
|
||||||
|
: Date.parse(value);
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,792 @@
|
|||||||
|
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||||
|
import {
|
||||||
|
haversineDistanceNm,
|
||||||
|
initialBearingDeg,
|
||||||
|
type Coordinate,
|
||||||
|
type MarineForecast,
|
||||||
|
type RouteResult,
|
||||||
|
type VesselProfile
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
|
||||||
|
export type RouteWeatherSample = {
|
||||||
|
label: "Start" | "Mitte" | "Ziel";
|
||||||
|
coordinate: Coordinate;
|
||||||
|
forecast: MarineForecast;
|
||||||
|
plannedTime?: string;
|
||||||
|
routeBearingDeg?: number | null;
|
||||||
|
currentAlongRouteKn?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteWeatherReport = {
|
||||||
|
samples: RouteWeatherSample[];
|
||||||
|
maxWaveHeightM: number | null;
|
||||||
|
maxWindSpeedKn: number | null;
|
||||||
|
maxWavePeriodS: number | null;
|
||||||
|
strongestWindDirectionDeg: number | null;
|
||||||
|
highestWaveDirectionDeg: number | null;
|
||||||
|
severity: "ok" | "caution" | "critical";
|
||||||
|
summary: string;
|
||||||
|
source: string;
|
||||||
|
updatedAt: string;
|
||||||
|
unavailableSamples: number;
|
||||||
|
bridgeReport: RouteBridgeReport | null;
|
||||||
|
departureTime: string;
|
||||||
|
adjustedEta: string | null;
|
||||||
|
currentAdjustmentMinutes: number | null;
|
||||||
|
averageAlongRouteCurrentKn: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchForecast = (coordinate: Coordinate, at?: string) => Promise<MarineForecast>;
|
||||||
|
type FetchFeatures = (params: { bbox: [number, number, number, number]; layers: string[] }) => Promise<FeatureCollection>;
|
||||||
|
type LonLat = [number, number];
|
||||||
|
type ProjectedPoint = { x: number; y: number };
|
||||||
|
|
||||||
|
export type RouteBridgeStatus = "passable" | "tight" | "too_low" | "unknown";
|
||||||
|
|
||||||
|
export type RouteBridgeAssessment = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
label: string;
|
||||||
|
coordinate: Coordinate;
|
||||||
|
distanceNm: number;
|
||||||
|
clearanceM: number | null;
|
||||||
|
clearanceLabel: string | null;
|
||||||
|
requiredAirDraftM: number | null;
|
||||||
|
marginM: number | null;
|
||||||
|
status: RouteBridgeStatus;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteBridgeReport = {
|
||||||
|
bridges: RouteBridgeAssessment[];
|
||||||
|
requiredAirDraftM: number | null;
|
||||||
|
checkedCount: number;
|
||||||
|
unknownCount: number;
|
||||||
|
tooLowCount: number;
|
||||||
|
tightCount: number;
|
||||||
|
minClearanceM: number | null;
|
||||||
|
severity: "ok" | "caution" | "critical";
|
||||||
|
summary: string;
|
||||||
|
source: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAMPLE_TARGETS: Array<{ label: RouteWeatherSample["label"]; ratio: number }> = [
|
||||||
|
{ label: "Start", ratio: 0 },
|
||||||
|
{ label: "Mitte", ratio: 0.5 },
|
||||||
|
{ label: "Ziel", ratio: 1 }
|
||||||
|
];
|
||||||
|
const ROUTE_BRIDGE_BBOX_MARGIN_DEG = 0.02;
|
||||||
|
const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08;
|
||||||
|
const BRIDGE_TIGHT_MARGIN_M = 0.5;
|
||||||
|
|
||||||
|
export async function createRouteWeatherReport(
|
||||||
|
route: RouteResult,
|
||||||
|
fetchForecast: FetchForecast
|
||||||
|
): Promise<RouteWeatherReport>;
|
||||||
|
export async function createRouteWeatherReport(
|
||||||
|
route: RouteResult,
|
||||||
|
vesselProfile: VesselProfile,
|
||||||
|
fetchForecast: FetchForecast,
|
||||||
|
fetchFeatures?: FetchFeatures,
|
||||||
|
departureTime?: string
|
||||||
|
): Promise<RouteWeatherReport>;
|
||||||
|
export async function createRouteWeatherReport(
|
||||||
|
route: RouteResult,
|
||||||
|
vesselProfileOrFetchForecast: VesselProfile | FetchForecast,
|
||||||
|
maybeFetchForecast?: FetchForecast,
|
||||||
|
fetchFeatures?: FetchFeatures,
|
||||||
|
departureTime?: string
|
||||||
|
): Promise<RouteWeatherReport> {
|
||||||
|
const vesselProfile: VesselProfile =
|
||||||
|
typeof vesselProfileOrFetchForecast === "function"
|
||||||
|
? { draughtM: 0, safetyReserveM: 0 }
|
||||||
|
: vesselProfileOrFetchForecast;
|
||||||
|
const fetchForecast =
|
||||||
|
typeof vesselProfileOrFetchForecast === "function" ? vesselProfileOrFetchForecast : maybeFetchForecast;
|
||||||
|
if (!fetchForecast) {
|
||||||
|
throw new Error("Wetterbericht nicht erreichbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannedDeparture = validIso(departureTime ?? route.departureTime) ?? new Date().toISOString();
|
||||||
|
const cruiseSpeedKn = normalizeSpeed(vesselProfile.cruiseSpeedKn);
|
||||||
|
const samplePoints = sampleRoute(route).map((sample) => ({
|
||||||
|
...sample,
|
||||||
|
plannedTime: new Date(
|
||||||
|
Date.parse(plannedDeparture) + (route.distanceNm * sample.ratio / cruiseSpeedKn) * 60 * 60 * 1000
|
||||||
|
).toISOString()
|
||||||
|
}));
|
||||||
|
const [results, bridgeReport] = await Promise.all([
|
||||||
|
Promise.allSettled(
|
||||||
|
samplePoints.map(async (sample) => ({
|
||||||
|
...sample,
|
||||||
|
forecast: await fetchForecast(sample.coordinate, sample.plannedTime)
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
fetchFeatures
|
||||||
|
? createRouteBridgeReport(route, vesselProfile, fetchFeatures).catch(() => unavailableBridgeReport(vesselProfile))
|
||||||
|
: Promise.resolve(null)
|
||||||
|
]);
|
||||||
|
const samples = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : []));
|
||||||
|
|
||||||
|
if (samples.length === 0) {
|
||||||
|
throw new Error("Wetterbericht nicht erreichbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
const samplesWithCurrent = samples.map((sample) => ({
|
||||||
|
...sample,
|
||||||
|
currentAlongRouteKn: alongRouteCurrentKn(sample.forecast, sample.routeBearingDeg)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return summarizeRouteWeather(samplesWithCurrent, results.length - samples.length, bridgeReport, {
|
||||||
|
departureTime: plannedDeparture,
|
||||||
|
distanceNm: route.distanceNm,
|
||||||
|
cruiseSpeedKn
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRouteBridgeReport(
|
||||||
|
route: RouteResult,
|
||||||
|
vesselProfile: Pick<VesselProfile, "airDraftM">,
|
||||||
|
fetchFeatures: FetchFeatures
|
||||||
|
): Promise<RouteBridgeReport> {
|
||||||
|
const routeLine = routeLonLatLine(route);
|
||||||
|
if (routeLine.length === 0) {
|
||||||
|
return summarizeBridgeReport([], normalizeMeters(vesselProfile.airDraftM));
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = await fetchFeatures({
|
||||||
|
bbox: bboxForLine(routeLine, ROUTE_BRIDGE_BBOX_MARGIN_DEG),
|
||||||
|
layers: ["bridges"]
|
||||||
|
});
|
||||||
|
const requiredAirDraftM = normalizeMeters(vesselProfile.airDraftM);
|
||||||
|
const bridges = features.features
|
||||||
|
.map((feature) => bridgeAssessmentFromFeature(feature, routeLine, requiredAirDraftM))
|
||||||
|
.filter((bridge): bridge is RouteBridgeAssessment => Boolean(bridge))
|
||||||
|
.filter((bridge) => bridge.distanceNm <= ROUTE_BRIDGE_MAX_DISTANCE_NM);
|
||||||
|
const deduped = dedupeBridges(bridges);
|
||||||
|
|
||||||
|
return summarizeBridgeReport(
|
||||||
|
deduped.sort((left, right) => left.distanceNm - right.distanceNm),
|
||||||
|
requiredAirDraftM
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRouteWeather(
|
||||||
|
samples: RouteWeatherSample[],
|
||||||
|
unavailableSamples = 0,
|
||||||
|
bridgeReport: RouteBridgeReport | null = null,
|
||||||
|
planning?: { departureTime: string; distanceNm: number; cruiseSpeedKn: number }
|
||||||
|
): RouteWeatherReport {
|
||||||
|
const maxWaveHeightM = maxValue(samples.map((sample) => sample.forecast.waveHeightM));
|
||||||
|
const maxWindSpeedKn = maxValue(samples.map((sample) => sample.forecast.windSpeed));
|
||||||
|
const maxWavePeriodS = maxValue(samples.map((sample) => sample.forecast.wavePeriodS));
|
||||||
|
const strongestWind = maxBy(samples, (sample) => sample.forecast.windSpeed);
|
||||||
|
const highestWave = maxBy(samples, (sample) => sample.forecast.waveHeightM);
|
||||||
|
const severity = weatherSeverity(maxWindSpeedKn, maxWaveHeightM);
|
||||||
|
const currentComponents = samples
|
||||||
|
.map((sample) => sample.currentAlongRouteKn)
|
||||||
|
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||||
|
const averageAlongRouteCurrentKn =
|
||||||
|
currentComponents.length > 0
|
||||||
|
? currentComponents.reduce((sum, value) => sum + value, 0) / currentComponents.length
|
||||||
|
: null;
|
||||||
|
const currentTiming = currentAdjustedTiming(planning, averageAlongRouteCurrentKn);
|
||||||
|
|
||||||
|
return {
|
||||||
|
samples,
|
||||||
|
maxWaveHeightM,
|
||||||
|
maxWindSpeedKn,
|
||||||
|
maxWavePeriodS,
|
||||||
|
strongestWindDirectionDeg: strongestWind?.forecast.windDirectionDeg ?? null,
|
||||||
|
highestWaveDirectionDeg: highestWave?.forecast.waveDirectionDeg ?? null,
|
||||||
|
severity,
|
||||||
|
summary: weatherSummary(severity, maxWindSpeedKn, maxWaveHeightM),
|
||||||
|
source: unique(samples.map((sample) => sample.forecast.source)).join(", "),
|
||||||
|
updatedAt: latestIso(samples.map((sample) => sample.forecast.updatedAt)) ?? new Date().toISOString(),
|
||||||
|
unavailableSamples,
|
||||||
|
bridgeReport,
|
||||||
|
departureTime: planning?.departureTime ?? new Date().toISOString(),
|
||||||
|
adjustedEta: currentTiming.adjustedEta,
|
||||||
|
currentAdjustmentMinutes: currentTiming.adjustmentMinutes,
|
||||||
|
averageAlongRouteCurrentKn
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleRoute(route: RouteResult) {
|
||||||
|
const points = route.geometry.coordinates.map(([lon, lat]) => ({ lat, lon }));
|
||||||
|
const uniqueSamples = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label: RouteWeatherSample["label"];
|
||||||
|
coordinate: Coordinate;
|
||||||
|
ratio: number;
|
||||||
|
routeBearingDeg: number | null;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const target of SAMPLE_TARGETS) {
|
||||||
|
const coordinate = coordinateAtProgress(points, target.ratio);
|
||||||
|
const key = `${coordinate.lat.toFixed(3)}:${coordinate.lon.toFixed(3)}`;
|
||||||
|
uniqueSamples.set(key, {
|
||||||
|
label: target.label,
|
||||||
|
coordinate,
|
||||||
|
ratio: target.ratio,
|
||||||
|
routeBearingDeg: routeBearingAtProgress(points, target.ratio)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...uniqueSamples.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeBearingAtProgress(points: Coordinate[], ratio: number): number | null {
|
||||||
|
if (points.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const before = coordinateAtProgress(points, Math.max(0, ratio - 0.01));
|
||||||
|
const after = coordinateAtProgress(points, Math.min(1, ratio + 0.01));
|
||||||
|
if (haversineDistanceNm(before, after) < 0.001) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return initialBearingDeg(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alongRouteCurrentKn(forecast: MarineForecast, routeBearingDeg?: number | null): number | null {
|
||||||
|
const speedKn = forecast.oceanCurrentSpeedKn;
|
||||||
|
const directionDeg = forecast.oceanCurrentDirectionDeg;
|
||||||
|
if (
|
||||||
|
typeof speedKn !== "number" ||
|
||||||
|
!Number.isFinite(speedKn) ||
|
||||||
|
typeof directionDeg !== "number" ||
|
||||||
|
!Number.isFinite(directionDeg) ||
|
||||||
|
typeof routeBearingDeg !== "number"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const angleRad = (((directionDeg - routeBearingDeg + 540) % 360) - 180) * (Math.PI / 180);
|
||||||
|
return Math.round(speedKn * Math.cos(angleRad) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentAdjustedTiming(
|
||||||
|
planning: { departureTime: string; distanceNm: number; cruiseSpeedKn: number } | undefined,
|
||||||
|
averageCurrentKn: number | null
|
||||||
|
): { adjustedEta: string | null; adjustmentMinutes: number | null } {
|
||||||
|
if (!planning || averageCurrentKn === null) {
|
||||||
|
return { adjustedEta: null, adjustmentMinutes: null };
|
||||||
|
}
|
||||||
|
const effectiveSpeedKn = Math.max(0.5, planning.cruiseSpeedKn + averageCurrentKn);
|
||||||
|
const baseMinutes = (planning.distanceNm / planning.cruiseSpeedKn) * 60;
|
||||||
|
const adjustedMinutes = (planning.distanceNm / effectiveSpeedKn) * 60;
|
||||||
|
return {
|
||||||
|
adjustedEta: new Date(Date.parse(planning.departureTime) + adjustedMinutes * 60 * 1000).toISOString(),
|
||||||
|
adjustmentMinutes: Math.round(adjustedMinutes - baseMinutes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSpeed(value: number | undefined): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validIso(value: string | undefined): string | null {
|
||||||
|
const timestamp = value ? Date.parse(value) : Number.NaN;
|
||||||
|
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coordinateAtProgress(points: Coordinate[], ratio: number): Coordinate {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return { lat: 0, lon: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratio <= 0 || points.length === 1) {
|
||||||
|
return points[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratio >= 1) {
|
||||||
|
return points.at(-1)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentLengths = points.slice(1).map((point, index) => haversineDistanceNm(points[index]!, point));
|
||||||
|
const totalDistanceNm = segmentLengths.reduce((sum, length) => sum + length, 0);
|
||||||
|
const targetDistanceNm = totalDistanceNm * ratio;
|
||||||
|
let traveledNm = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < segmentLengths.length; index += 1) {
|
||||||
|
const segmentLengthNm = segmentLengths[index]!;
|
||||||
|
if (traveledNm + segmentLengthNm >= targetDistanceNm) {
|
||||||
|
const start = points[index]!;
|
||||||
|
const end = points[index + 1]!;
|
||||||
|
const segmentRatio = segmentLengthNm === 0 ? 0 : (targetDistanceNm - traveledNm) / segmentLengthNm;
|
||||||
|
return {
|
||||||
|
lat: start.lat + (end.lat - start.lat) * segmentRatio,
|
||||||
|
lon: start.lon + (end.lon - start.lon) * segmentRatio
|
||||||
|
};
|
||||||
|
}
|
||||||
|
traveledNm += segmentLengthNm;
|
||||||
|
}
|
||||||
|
|
||||||
|
return points.at(-1)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxValue(values: Array<number | null | undefined>) {
|
||||||
|
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||||
|
return valid.length > 0 ? Math.max(...valid) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minValue(values: Array<number | null | undefined>) {
|
||||||
|
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||||
|
return valid.length > 0 ? Math.min(...valid) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxBy<T>(values: T[], selector: (value: T) => number | null | undefined) {
|
||||||
|
return values.reduce<T | null>((best, value) => {
|
||||||
|
const candidate = selector(value);
|
||||||
|
if (candidate === null || candidate === undefined || !Number.isFinite(candidate)) {
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestValue = best ? selector(best) : null;
|
||||||
|
return bestValue === null || bestValue === undefined || candidate > bestValue ? value : best;
|
||||||
|
}, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherSeverity(windSpeedKn: number | null, waveHeightM: number | null) {
|
||||||
|
if (windSpeedKn === null && waveHeightM === null) {
|
||||||
|
return "caution";
|
||||||
|
}
|
||||||
|
if ((windSpeedKn !== null && windSpeedKn >= 27) || (waveHeightM !== null && waveHeightM >= 2)) {
|
||||||
|
return "critical";
|
||||||
|
}
|
||||||
|
if ((windSpeedKn !== null && windSpeedKn >= 16) || (waveHeightM !== null && waveHeightM >= 1)) {
|
||||||
|
return "caution";
|
||||||
|
}
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherSummary(
|
||||||
|
severity: RouteWeatherReport["severity"],
|
||||||
|
windSpeedKn: number | null,
|
||||||
|
waveHeightM: number | null
|
||||||
|
) {
|
||||||
|
if (windSpeedKn === null && waveHeightM === null) {
|
||||||
|
return "Keine belastbare Wetter- oder Wellenprognose für die gewählte Abfahrtszeit.";
|
||||||
|
}
|
||||||
|
const wind = windSpeedKn !== null ? `${Math.round(windSpeedKn)} kn Wind` : "Wind unbekannt";
|
||||||
|
const wave = waveHeightM !== null ? `${waveHeightM.toFixed(1)} m Welle` : "Welle unbekannt";
|
||||||
|
|
||||||
|
if (severity === "critical") {
|
||||||
|
return `Kritische Bedingungen: bis ${wind}, ${wave}.`;
|
||||||
|
}
|
||||||
|
if (severity === "caution") {
|
||||||
|
return `Aufmerksam fahren: bis ${wind}, ${wave}.`;
|
||||||
|
}
|
||||||
|
return `Ruhige Bedingungen: bis ${wind}, ${wave}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestIso(values: string[]) {
|
||||||
|
const timestamps = values.map((value) => Date.parse(value)).filter((value) => Number.isFinite(value));
|
||||||
|
return timestamps.length > 0 ? new Date(Math.max(...timestamps)).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(values: string[]) {
|
||||||
|
return [...new Set(values.filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeLonLatLine(route: RouteResult): LonLat[] {
|
||||||
|
return route.geometry.coordinates.map(([lon, lat]) => [lon, lat]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bboxForLine(line: LonLat[], marginDeg: number): [number, number, number, number] {
|
||||||
|
const lons = line.map(([lon]) => lon);
|
||||||
|
const lats = line.map(([, lat]) => lat);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Math.min(...lons) - marginDeg,
|
||||||
|
Math.min(...lats) - marginDeg,
|
||||||
|
Math.max(...lons) + marginDeg,
|
||||||
|
Math.max(...lats) + marginDeg
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeAssessmentFromFeature(
|
||||||
|
feature: Feature,
|
||||||
|
routeLine: LonLat[],
|
||||||
|
requiredAirDraftM: number | null
|
||||||
|
): RouteBridgeAssessment | null {
|
||||||
|
if (!feature.geometry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridgeLines = geometryLineStrings(feature.geometry);
|
||||||
|
if (bridgeLines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceNm = minDistanceBetweenLinesNm(bridgeLines, routeLine);
|
||||||
|
if (!Number.isFinite(distanceNm)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinate = centroid(bridgeLines.flat());
|
||||||
|
if (!coordinate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = (feature.properties ?? {}) as Record<string, unknown>;
|
||||||
|
const clearanceM = normalizeMeters(properties.clearance_m);
|
||||||
|
const name = stringProperty(properties.name);
|
||||||
|
const clearanceLabel = stringProperty(properties.clearance_label) ?? (clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null);
|
||||||
|
const label = stringProperty(properties.label) ?? name ?? clearanceLabel ?? "Brücke";
|
||||||
|
const status = bridgeStatus(clearanceM, requiredAirDraftM);
|
||||||
|
const marginM = clearanceM !== null && requiredAirDraftM !== null ? clearanceM - requiredAirDraftM : null;
|
||||||
|
const idCandidate = feature.id ?? properties.source_id ?? `${coordinate.lat.toFixed(5)}:${coordinate.lon.toFixed(5)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(idCandidate),
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
coordinate,
|
||||||
|
distanceNm,
|
||||||
|
clearanceM,
|
||||||
|
clearanceLabel,
|
||||||
|
requiredAirDraftM,
|
||||||
|
marginM,
|
||||||
|
status,
|
||||||
|
source: stringProperty(properties.source) ?? "OSM/Geofabrik"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeBridgeReport(
|
||||||
|
bridges: RouteBridgeAssessment[],
|
||||||
|
requiredAirDraftM: number | null
|
||||||
|
): RouteBridgeReport {
|
||||||
|
const knownClearanceBridges = bridges.filter((bridge) => bridge.clearanceM !== null);
|
||||||
|
const unknownCount = bridges.length - knownClearanceBridges.length;
|
||||||
|
const checkedCount = requiredAirDraftM === null ? 0 : knownClearanceBridges.length;
|
||||||
|
const tooLowCount = bridges.filter((bridge) => bridge.status === "too_low").length;
|
||||||
|
const tightCount = bridges.filter((bridge) => bridge.status === "tight").length;
|
||||||
|
const minKnownClearanceM = minValue(knownClearanceBridges.map((bridge) => bridge.clearanceM));
|
||||||
|
const severity = bridgeSeverity(bridges);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bridges,
|
||||||
|
requiredAirDraftM,
|
||||||
|
checkedCount,
|
||||||
|
unknownCount,
|
||||||
|
tooLowCount,
|
||||||
|
tightCount,
|
||||||
|
minClearanceM: minKnownClearanceM,
|
||||||
|
severity,
|
||||||
|
summary: bridgeSummary({
|
||||||
|
bridgeCount: bridges.length,
|
||||||
|
unknownCount,
|
||||||
|
tooLowCount,
|
||||||
|
tightCount,
|
||||||
|
minClearanceM: minKnownClearanceM,
|
||||||
|
requiredAirDraftM
|
||||||
|
}),
|
||||||
|
source: unique(bridges.map((bridge) => bridge.source)).join(", ") || "OSM/Geofabrik",
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailableBridgeReport(vesselProfile: Pick<VesselProfile, "airDraftM">): RouteBridgeReport {
|
||||||
|
return {
|
||||||
|
bridges: [],
|
||||||
|
requiredAirDraftM: normalizeMeters(vesselProfile.airDraftM),
|
||||||
|
checkedCount: 0,
|
||||||
|
unknownCount: 0,
|
||||||
|
tooLowCount: 0,
|
||||||
|
tightCount: 0,
|
||||||
|
minClearanceM: null,
|
||||||
|
severity: "caution",
|
||||||
|
summary: "Brückenprüfung nicht erreichbar. Durchfahrtshöhen vor Abfahrt extern prüfen.",
|
||||||
|
source: "OSM/Geofabrik",
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeStatus(clearanceM: number | null, requiredAirDraftM: number | null): RouteBridgeStatus {
|
||||||
|
if (clearanceM === null || requiredAirDraftM === null) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
const marginM = clearanceM - requiredAirDraftM;
|
||||||
|
if (marginM < 0) {
|
||||||
|
return "too_low";
|
||||||
|
}
|
||||||
|
if (marginM < BRIDGE_TIGHT_MARGIN_M) {
|
||||||
|
return "tight";
|
||||||
|
}
|
||||||
|
return "passable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeSeverity(bridges: RouteBridgeAssessment[]): RouteBridgeReport["severity"] {
|
||||||
|
if (bridges.some((bridge) => bridge.status === "too_low")) {
|
||||||
|
return "critical";
|
||||||
|
}
|
||||||
|
if (bridges.some((bridge) => bridge.status === "tight" || bridge.status === "unknown")) {
|
||||||
|
return "caution";
|
||||||
|
}
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeSummary({
|
||||||
|
bridgeCount,
|
||||||
|
unknownCount,
|
||||||
|
tooLowCount,
|
||||||
|
tightCount,
|
||||||
|
minClearanceM,
|
||||||
|
requiredAirDraftM
|
||||||
|
}: {
|
||||||
|
bridgeCount: number;
|
||||||
|
unknownCount: number;
|
||||||
|
tooLowCount: number;
|
||||||
|
tightCount: number;
|
||||||
|
minClearanceM: number | null;
|
||||||
|
requiredAirDraftM: number | null;
|
||||||
|
}) {
|
||||||
|
if (bridgeCount === 0) {
|
||||||
|
return "Keine Brücken im Routenkorridor erkannt.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredAirDraftM === null) {
|
||||||
|
return `${bridgeCount} Brücken erkannt. Bootshöhe fehlt, Durchfahrt nicht bewertbar.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tooLowCount > 0) {
|
||||||
|
return `Nicht passierbar: ${tooLowCount} Brücke(n) niedriger als ${formatMeters(requiredAirDraftM)} Bootshöhe.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tightCount > 0) {
|
||||||
|
return `Knapp: ${tightCount} Brücke(n) mit weniger als ${formatMeters(BRIDGE_TIGHT_MARGIN_M)} Reserve.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unknownCount > 0) {
|
||||||
|
return `${unknownCount} Brücke(n) ohne Höhenangabe. Durchfahrt vor Abfahrt prüfen.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Brücken passierbar: ${bridgeCount} Brücke(n), min. ${formatMeters(minClearanceM ?? requiredAirDraftM)} Durchfahrt.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeBridges(bridges: RouteBridgeAssessment[]) {
|
||||||
|
const byId = new Map<string, RouteBridgeAssessment>();
|
||||||
|
for (const bridge of bridges) {
|
||||||
|
const existing = byId.get(bridge.id);
|
||||||
|
if (!existing || bridge.distanceNm < existing.distanceNm) {
|
||||||
|
byId.set(bridge.id, bridge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometryLineStrings(geometry: Geometry): LonLat[][] {
|
||||||
|
switch (geometry.type) {
|
||||||
|
case "Point":
|
||||||
|
return [singlePositionLine(geometry.coordinates)].filter((line) => line.length > 0);
|
||||||
|
case "MultiPoint":
|
||||||
|
return geometry.coordinates.map(singlePositionLine).filter((line) => line.length > 0);
|
||||||
|
case "LineString":
|
||||||
|
return [positionsToLine(geometry.coordinates)].filter((line) => line.length > 0);
|
||||||
|
case "MultiLineString":
|
||||||
|
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
|
||||||
|
case "Polygon":
|
||||||
|
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
|
||||||
|
case "MultiPolygon":
|
||||||
|
return geometry.coordinates.flat().map(positionsToLine).filter((line) => line.length > 0);
|
||||||
|
case "GeometryCollection":
|
||||||
|
return geometry.geometries.flatMap(geometryLineStrings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function singlePositionLine(position: Position): LonLat[] {
|
||||||
|
const coordinate = positionToLonLat(position);
|
||||||
|
return coordinate ? [coordinate] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionsToLine(positions: Position[]): LonLat[] {
|
||||||
|
return positions.map(positionToLonLat).filter((coordinate): coordinate is LonLat => Boolean(coordinate));
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionToLonLat(position: Position): LonLat | null {
|
||||||
|
const [lon, lat] = position;
|
||||||
|
return typeof lon === "number" && typeof lat === "number" && Number.isFinite(lon) && Number.isFinite(lat)
|
||||||
|
? [lon, lat]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minDistanceBetweenLinesNm(featureLines: LonLat[][], routeLine: LonLat[]) {
|
||||||
|
if (routeLine.length === 0) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = routeLine[0]!;
|
||||||
|
let minDistanceNm = Number.POSITIVE_INFINITY;
|
||||||
|
for (const featureLine of featureLines) {
|
||||||
|
if (featureLine.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (featureLine.length === 1) {
|
||||||
|
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(featureLine[0]!, routeLine, origin));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeLine.length === 1) {
|
||||||
|
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(routeLine[0]!, featureLine, origin));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let featureIndex = 1; featureIndex < featureLine.length; featureIndex += 1) {
|
||||||
|
const featureStart = featureLine[featureIndex - 1]!;
|
||||||
|
const featureEnd = featureLine[featureIndex]!;
|
||||||
|
for (let routeIndex = 1; routeIndex < routeLine.length; routeIndex += 1) {
|
||||||
|
minDistanceNm = Math.min(
|
||||||
|
minDistanceNm,
|
||||||
|
segmentDistanceNm(featureStart, featureEnd, routeLine[routeIndex - 1]!, routeLine[routeIndex]!, origin)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return minDistanceNm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minPointToLineDistanceNm(point: LonLat, line: LonLat[], origin: LonLat) {
|
||||||
|
if (line.length === 0) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
if (line.length === 1) {
|
||||||
|
return distanceBetweenProjected(project(point, origin), project(line[0]!, origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
let minDistanceNm = Number.POSITIVE_INFINITY;
|
||||||
|
for (let index = 1; index < line.length; index += 1) {
|
||||||
|
minDistanceNm = Math.min(
|
||||||
|
minDistanceNm,
|
||||||
|
pointToSegmentDistance(project(point, origin), project(line[index - 1]!, origin), project(line[index]!, origin))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return minDistanceNm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentDistanceNm(startA: LonLat, endA: LonLat, startB: LonLat, endB: LonLat, origin: LonLat) {
|
||||||
|
const a = project(startA, origin);
|
||||||
|
const b = project(endA, origin);
|
||||||
|
const c = project(startB, origin);
|
||||||
|
const d = project(endB, origin);
|
||||||
|
|
||||||
|
if (segmentsIntersect(a, b, c, d)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
pointToSegmentDistance(a, c, d),
|
||||||
|
pointToSegmentDistance(b, c, d),
|
||||||
|
pointToSegmentDistance(c, a, b),
|
||||||
|
pointToSegmentDistance(d, a, b)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function project([lon, lat]: LonLat, [originLon, originLat]: LonLat): ProjectedPoint {
|
||||||
|
const averageLatRad = ((lat + originLat) / 2) * (Math.PI / 180);
|
||||||
|
return {
|
||||||
|
x: (lon - originLon) * 60 * Math.cos(averageLatRad),
|
||||||
|
y: (lat - originLat) * 60
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentsIntersect(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint, d: ProjectedPoint) {
|
||||||
|
const o1 = orientation(a, b, c);
|
||||||
|
const o2 = orientation(a, b, d);
|
||||||
|
const o3 = orientation(c, d, a);
|
||||||
|
const o4 = orientation(c, d, b);
|
||||||
|
|
||||||
|
if (o1 !== o2 && o3 !== o4) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(o1 === 0 && onSegment(a, c, b)) ||
|
||||||
|
(o2 === 0 && onSegment(a, d, b)) ||
|
||||||
|
(o3 === 0 && onSegment(c, a, d)) ||
|
||||||
|
(o4 === 0 && onSegment(c, b, d))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function orientation(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
|
||||||
|
const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
|
||||||
|
if (Math.abs(value) < 1e-9) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return value > 0 ? 1 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSegment(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
|
||||||
|
return (
|
||||||
|
b.x <= Math.max(a.x, c.x) + 1e-9 &&
|
||||||
|
b.x >= Math.min(a.x, c.x) - 1e-9 &&
|
||||||
|
b.y <= Math.max(a.y, c.y) + 1e-9 &&
|
||||||
|
b.y >= Math.min(a.y, c.y) - 1e-9
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointToSegmentDistance(point: ProjectedPoint, start: ProjectedPoint, end: ProjectedPoint) {
|
||||||
|
const dx = end.x - start.x;
|
||||||
|
const dy = end.y - start.y;
|
||||||
|
if (dx === 0 && dy === 0) {
|
||||||
|
return distanceBetweenProjected(point, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
|
||||||
|
return distanceBetweenProjected(point, {
|
||||||
|
x: start.x + ratio * dx,
|
||||||
|
y: start.y + ratio * dy
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceBetweenProjected(left: ProjectedPoint, right: ProjectedPoint) {
|
||||||
|
return Math.hypot(left.x - right.x, left.y - right.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centroid(coordinates: LonLat[]): Coordinate | null {
|
||||||
|
if (coordinates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = coordinates.reduce(
|
||||||
|
(total, [lon, lat]) => ({
|
||||||
|
lon: total.lon + lon,
|
||||||
|
lat: total.lat + lat
|
||||||
|
}),
|
||||||
|
{ lon: 0, lat: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lon: sum.lon / coordinates.length,
|
||||||
|
lat: sum.lat / coordinates.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringProperty(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMeters(value: unknown) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.replace(",", ".").match(/\d+(?:\.\d+)?/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(match[0]);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMeters(value: number) {
|
||||||
|
return Number.isInteger(value) ? `${value} m` : `${value.toFixed(1)} m`;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
harbourAmenitiesFromProperties,
|
||||||
|
orderWaypointsAlongRoute,
|
||||||
|
type RouteResult,
|
||||||
|
type VoyageHarbour
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
import type { FeatureCollection, GeoJsonProperties, Geometry } from "geojson";
|
||||||
|
|
||||||
|
export type RouteLock = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coordinate: { lat: number; lon: number };
|
||||||
|
routeDistanceNm: number;
|
||||||
|
distanceFromRouteNm: number;
|
||||||
|
openingHours: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
vhf: string | null;
|
||||||
|
website: string | null;
|
||||||
|
operator?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
sourceUrl?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Converts the normalized harbour layer returned by /api/features for planning. */
|
||||||
|
export function voyageHarboursFromFeatures(
|
||||||
|
collection: FeatureCollection<Geometry, GeoJsonProperties>
|
||||||
|
): VoyageHarbour[] {
|
||||||
|
const harbours = new Map<string, VoyageHarbour>();
|
||||||
|
|
||||||
|
for (const feature of collection.features) {
|
||||||
|
if (feature.geometry?.type !== "Point" || feature.properties?.layer !== "harbours") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [lon, lat] = feature.geometry.coordinates;
|
||||||
|
if (typeof lon !== "number" || typeof lat !== "number" || !Number.isFinite(lon) || !Number.isFinite(lat)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const properties = feature.properties;
|
||||||
|
const id = String(feature.id ?? properties.sourceId ?? properties.source_id ?? `${lat}:${lon}`);
|
||||||
|
const seamarkType = stringValue(properties["seamark:type"]);
|
||||||
|
const kind =
|
||||||
|
stringValue(properties.leisure) === "marina" || seamarkType === "marina"
|
||||||
|
? "marina"
|
||||||
|
: "harbour";
|
||||||
|
|
||||||
|
harbours.set(id, {
|
||||||
|
id,
|
||||||
|
name: displayString(properties.name) ?? (kind === "marina" ? "Marina" : "Hafen"),
|
||||||
|
coordinate: { lat, lon },
|
||||||
|
kind,
|
||||||
|
amenities: harbourAmenitiesFromProperties(properties),
|
||||||
|
phone: firstString(properties, ["contact:phone", "phone", "telephone", "contact_phone"]),
|
||||||
|
website: firstString(properties, ["contact:website", "website", "url", "contact_website"]),
|
||||||
|
email: firstString(properties, ["contact:email", "email", "contact_email"]),
|
||||||
|
vhf: firstString(properties, [
|
||||||
|
"vhf",
|
||||||
|
"vhf_channel",
|
||||||
|
"radio_channel",
|
||||||
|
"contact:vhf",
|
||||||
|
"seamark:harbour:radio_channel"
|
||||||
|
]),
|
||||||
|
openingHours: firstString(properties, ["openingHours", "opening_hours", "service_times"]),
|
||||||
|
operator: firstString(properties, ["operator", "operator:name", "owner"]),
|
||||||
|
address: featureAddress(properties),
|
||||||
|
source: firstString(properties, ["source", "data_source", "attribution"]),
|
||||||
|
sourceUrl: firstString(properties, ["sourceUrl", "source_url", "enrichmentSourceUrl"]),
|
||||||
|
updatedAt: firstString(properties, [
|
||||||
|
"updatedAt",
|
||||||
|
"updated_at",
|
||||||
|
"fetchedAt",
|
||||||
|
"fetched_at",
|
||||||
|
"timestamp",
|
||||||
|
"@timestamp"
|
||||||
|
])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...harbours.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns lock points that are close enough to plausibly lie on the routed waterway. */
|
||||||
|
export function routeLocksFromFeatures(
|
||||||
|
collection: FeatureCollection<Geometry, GeoJsonProperties>,
|
||||||
|
route: Pick<RouteResult, "geometry" | "distanceNm">,
|
||||||
|
maxDistanceFromRouteNm = 0.25
|
||||||
|
): RouteLock[] {
|
||||||
|
const details = new Map<
|
||||||
|
string,
|
||||||
|
Omit<RouteLock, "routeDistanceNm" | "distanceFromRouteNm">
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const feature of collection.features) {
|
||||||
|
if (feature.geometry?.type !== "Point" || feature.properties?.layer !== "locks") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [lon, lat] = feature.geometry.coordinates;
|
||||||
|
if (typeof lon !== "number" || typeof lat !== "number" || !Number.isFinite(lon) || !Number.isFinite(lat)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const properties = feature.properties;
|
||||||
|
const id = String(feature.id ?? properties.sourceId ?? properties.source_id ?? `${lat}:${lon}`);
|
||||||
|
details.set(id, {
|
||||||
|
id,
|
||||||
|
name: displayString(properties.name) ?? "Schleuse",
|
||||||
|
coordinate: { lat, lon },
|
||||||
|
openingHours: displayString(properties.openingHours) ?? displayString(properties.opening_hours),
|
||||||
|
phone: firstString(properties, ["contact:phone", "phone", "telephone", "contact_phone"]),
|
||||||
|
email: firstString(properties, ["contact:email", "email", "contact_email"]),
|
||||||
|
vhf: firstString(properties, [
|
||||||
|
"vhf",
|
||||||
|
"vhf_channel",
|
||||||
|
"radio_channel",
|
||||||
|
"contact:vhf",
|
||||||
|
"seamark:radio_station:channel"
|
||||||
|
]),
|
||||||
|
website: firstString(properties, ["contact:website", "website", "url", "contact_website"]),
|
||||||
|
operator: firstString(properties, ["operator", "operator:name", "owner"]),
|
||||||
|
address: featureAddress(properties),
|
||||||
|
source: firstString(properties, ["source", "data_source", "attribution"]),
|
||||||
|
sourceUrl: firstString(properties, ["sourceUrl", "source_url", "enrichmentSourceUrl"]),
|
||||||
|
updatedAt: firstString(properties, [
|
||||||
|
"updatedAt",
|
||||||
|
"updated_at",
|
||||||
|
"fetchedAt",
|
||||||
|
"fetched_at",
|
||||||
|
"timestamp",
|
||||||
|
"@timestamp"
|
||||||
|
])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = orderWaypointsAlongRoute(
|
||||||
|
[...details.values()].map(({ id, name, coordinate }) => ({ id, name, coordinate })),
|
||||||
|
route
|
||||||
|
);
|
||||||
|
return ordered
|
||||||
|
.filter((lock) => lock.distanceFromRouteNm <= maxDistanceFromRouteNm)
|
||||||
|
.map((lock) => ({ ...details.get(lock.id)!, routeDistanceNm: lock.routeDistanceNm, distanceFromRouteNm: lock.distanceFromRouteNm }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a padded request box that covers every point of a routed line. */
|
||||||
|
export function routeFeatureBounds(
|
||||||
|
route: Pick<RouteResult, "geometry">,
|
||||||
|
paddingNm = 2
|
||||||
|
): [number, number, number, number] {
|
||||||
|
if (!Number.isFinite(paddingNm) || paddingNm < 0) {
|
||||||
|
throw new RangeError("paddingNm must be a non-negative finite number");
|
||||||
|
}
|
||||||
|
const coordinates = route.geometry.coordinates;
|
||||||
|
if (coordinates.length === 0) {
|
||||||
|
throw new RangeError("route.geometry must contain coordinates");
|
||||||
|
}
|
||||||
|
|
||||||
|
let minLon = Infinity;
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLon = -Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
for (const [lon, lat] of coordinates) {
|
||||||
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
|
||||||
|
throw new RangeError("route.geometry must contain finite coordinates");
|
||||||
|
}
|
||||||
|
minLon = Math.min(minLon, lon);
|
||||||
|
minLat = Math.min(minLat, lat);
|
||||||
|
maxLon = Math.max(maxLon, lon);
|
||||||
|
maxLat = Math.max(maxLat, lat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latitudePadding = paddingNm / 60;
|
||||||
|
const highestAbsoluteLatitude = Math.min(89, Math.max(Math.abs(minLat), Math.abs(maxLat)));
|
||||||
|
const longitudePadding = paddingNm / (60 * Math.cos(highestAbsoluteLatitude * (Math.PI / 180)));
|
||||||
|
return [
|
||||||
|
Math.max(-180, minLon - longitudePadding),
|
||||||
|
Math.max(-90, minLat - latitudePadding),
|
||||||
|
Math.min(180, maxLon + longitudePadding),
|
||||||
|
Math.min(90, maxLat + latitudePadding)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayString(value: unknown) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return displayString(value)?.toLowerCase() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(properties: Record<string, unknown>, keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = displayString(properties[key]);
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function featureAddress(properties: Record<string, unknown>) {
|
||||||
|
const fullAddress = firstString(properties, ["contact:address", "addr:full", "address"]);
|
||||||
|
if (fullAddress) {
|
||||||
|
return fullAddress;
|
||||||
|
}
|
||||||
|
const streetLine = [
|
||||||
|
firstString(properties, ["addr:street"]),
|
||||||
|
firstString(properties, ["addr:housenumber"])
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
const cityLine = [
|
||||||
|
firstString(properties, ["addr:postcode"]),
|
||||||
|
firstString(properties, ["addr:city", "addr:place"])
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
return [streetLine, cityLine].filter(Boolean).join(", ") || null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { TideSummary } from "@watermaps/shared";
|
||||||
|
import type { GpsState } from "../src/hooks/useGeolocation";
|
||||||
|
|
||||||
|
const apiMocks = vi.hoisted(() => ({ getNearestTide: vi.fn() }));
|
||||||
|
vi.mock("../src/api", () => apiMocks);
|
||||||
|
|
||||||
|
import { useAnchorWatch } from "../src/hooks/useAnchorWatch";
|
||||||
|
|
||||||
|
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
apiMocks.getNearestTide.mockReset();
|
||||||
|
apiMocks.getNearestTide.mockResolvedValue(tideFixture());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
|
||||||
|
else Reflect.deleteProperty(navigator, "vibrate");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAnchorWatch", () => {
|
||||||
|
it("captures an immutable anchor point only after an explicit action", async () => {
|
||||||
|
const gps = gpsFix({ lat: 53.2, lon: 7.1 });
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => useAnchorWatch(value),
|
||||||
|
{ initialProps: { value: gps } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.phase).toBe("idle");
|
||||||
|
expect(result.current.anchorPoint).toBeNull();
|
||||||
|
|
||||||
|
act(() => expect(result.current.captureAnchor()).toBe(true));
|
||||||
|
expect(result.current.phase).toBe("set");
|
||||||
|
expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 });
|
||||||
|
|
||||||
|
rerender({ value: gpsFix({ lat: 53.21, lon: 7.11 }) });
|
||||||
|
expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 });
|
||||||
|
await waitFor(() => expect(result.current.tide?.station).toBe("Testpegel"));
|
||||||
|
expect(apiMocks.getNearestTide).toHaveBeenCalledWith(
|
||||||
|
{ lat: 53.2, lon: 7.1 },
|
||||||
|
expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale and inaccurate fixes instead of silently setting a point", () => {
|
||||||
|
const stale = gpsFix({ lat: 53.2, lon: 7.1 }, { timestampMs: Date.now() - 20_000 });
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => useAnchorWatch(value),
|
||||||
|
{ initialProps: { value: stale } }
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => expect(result.current.captureAnchor()).toBe(false));
|
||||||
|
expect(result.current.operationError).toMatch(/älter als 10 Sekunden/);
|
||||||
|
expect(result.current.anchorPoint).toBeNull();
|
||||||
|
|
||||||
|
rerender({ value: gpsFix({ lat: 53.2, lon: 7.1 }, { accuracyM: 55 }) });
|
||||||
|
act(() => expect(result.current.captureAnchor()).toBe(false));
|
||||||
|
expect(result.current.operationError).toMatch(/GPS noch zu ungenau/);
|
||||||
|
expect(result.current.anchorPoint).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns and vibrates once a conservative GPS distance exceeds the radius", async () => {
|
||||||
|
const vibrate = vi.fn();
|
||||||
|
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
|
||||||
|
const initial = gpsFix({ lat: 0, lon: 0 });
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ value }) => useAnchorWatch(value),
|
||||||
|
{ initialProps: { value: initial } }
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.captureAnchor();
|
||||||
|
result.current.updateSettings({ alarmRadiusM: 100, deployedRodeLengthM: 100 });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
expect(await result.current.arm()).toBe(true);
|
||||||
|
});
|
||||||
|
expect(result.current.positionAlarm).toBe(false);
|
||||||
|
|
||||||
|
rerender({
|
||||||
|
value: gpsFix(
|
||||||
|
{ lat: 150 / 111_195.08, lon: 0 },
|
||||||
|
{ accuracyM: 10, timestampMs: Date.now() }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.positionAlarm).toBe(true));
|
||||||
|
expect(result.current.watchResult?.alarmTriggered).toBe(true);
|
||||||
|
expect(result.current.watchResult?.conservativeDistanceFromAnchorM).toBeGreaterThan(100);
|
||||||
|
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender({
|
||||||
|
value: gpsFix(
|
||||||
|
{ lat: 155 / 111_195.08, lon: 0 },
|
||||||
|
{ accuracyM: 10, timestampMs: Date.now() }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the positional watch usable but never confirms rode sufficiency from partial tide data", async () => {
|
||||||
|
apiMocks.getNearestTide.mockResolvedValue(tideFixture(6));
|
||||||
|
const { result } = renderHook(() => useAnchorWatch(gpsFix({ lat: 53.2, lon: 7.1 })));
|
||||||
|
|
||||||
|
act(() => result.current.captureAnchor());
|
||||||
|
await waitFor(() => expect(result.current.tideWindow?.coverage).toBe("partial"));
|
||||||
|
expect(result.current.rodePlan?.calculationComplete).toBe(false);
|
||||||
|
expect(result.current.rodePlan?.rodeReserveM).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
expect(await result.current.arm()).toBe(true);
|
||||||
|
});
|
||||||
|
expect(result.current.phase).toBe("armed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function gpsFix(
|
||||||
|
position: { lat: number; lon: number },
|
||||||
|
overrides: Partial<Pick<GpsState, "accuracyM" | "timestampMs" | "status">> = {}
|
||||||
|
): Pick<GpsState, "status" | "position" | "accuracyM" | "timestampMs"> {
|
||||||
|
return {
|
||||||
|
status: overrides.status ?? "tracking",
|
||||||
|
position,
|
||||||
|
accuracyM: overrides.accuracyM ?? 5,
|
||||||
|
timestampMs: overrides.timestampMs ?? Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tideFixture(hours = 30): TideSummary {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
station: "Testpegel",
|
||||||
|
distanceKm: 3.2,
|
||||||
|
nextHigh: null,
|
||||||
|
nextLow: null,
|
||||||
|
waterLevelCurve: [
|
||||||
|
{ time: new Date(now - 60 * 60_000).toISOString(), predictedM: 1 },
|
||||||
|
{ time: new Date(now + 6 * 60 * 60_000).toISOString(), predictedM: 2 },
|
||||||
|
{ time: new Date(now + hours * 60 * 60_000).toISOString(), predictedM: 0.5 }
|
||||||
|
],
|
||||||
|
source: "Test",
|
||||||
|
updatedAt: new Date(now).toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { MarineForecast, TideSummary } from "@watermaps/shared";
|
||||||
|
import {
|
||||||
|
ConditionsPanel,
|
||||||
|
type ConditionsRouteWeatherReport,
|
||||||
|
type ConditionsRouteWeatherSample
|
||||||
|
} from "../src/components/ConditionsPanel";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const NOW = Date.parse("2026-07-23T10:00:00.000Z");
|
||||||
|
|
||||||
|
describe("ConditionsPanel", () => {
|
||||||
|
it("labels GPS as the source and shows the current marine weather metrics", () => {
|
||||||
|
render(
|
||||||
|
<ConditionsPanel
|
||||||
|
forecast={forecast()}
|
||||||
|
tide={null}
|
||||||
|
positionSource={{ kind: "gps" }}
|
||||||
|
now={NOW}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("complementary", { name: "Wetter & Tide" })).toHaveAttribute(
|
||||||
|
"data-position-source",
|
||||||
|
"gps"
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Aktuelle GPS-Position")).toBeVisible();
|
||||||
|
|
||||||
|
const currentConditions = screen
|
||||||
|
.getByRole("heading", { name: "Aktuelle Bedingungen" })
|
||||||
|
.closest("section");
|
||||||
|
expect(currentConditions).not.toBeNull();
|
||||||
|
expect(within(currentConditions!).getByText("12 kn · 270° W")).toBeVisible();
|
||||||
|
expect(within(currentConditions!).getByText("0.8 m · 6 s · 225° SW")).toBeVisible();
|
||||||
|
expect(within(currentConditions!).getByText("0.7 kn · 090° O")).toBeVisible();
|
||||||
|
expect(within(currentConditions!).getByText("14.2 °C")).toBeVisible();
|
||||||
|
expect(within(currentConditions!).getByText("vor 15 Min.")).toBeVisible();
|
||||||
|
expect(within(currentConditions!).getByText("Quelle: Test-Meteo")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the nearest tide station and both high- and low-water events", () => {
|
||||||
|
render(
|
||||||
|
<ConditionsPanel
|
||||||
|
forecast={null}
|
||||||
|
tide={tideSummary("Pegel Emden", 4.2, 1.72, 0.31)}
|
||||||
|
positionSource={{ kind: "gps", label: "Außenhafen" }}
|
||||||
|
now={NOW}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("GPS · Außenhafen")).toBeVisible();
|
||||||
|
expect(screen.getByText("Pegel Emden")).toBeVisible();
|
||||||
|
expect(screen.getByText("4,2 km entfernt")).toBeVisible();
|
||||||
|
|
||||||
|
const tideEvents = document.querySelector(".conditions-tide-events");
|
||||||
|
expect(tideEvents).not.toBeNull();
|
||||||
|
expect(tideEvents).toHaveTextContent(/HW.*1\.72 m/);
|
||||||
|
expect(tideEvents).toHaveTextContent(/NW.*0\.31 m/);
|
||||||
|
expect(within(tideEvents!).getByTitle("Nächstes Hochwasser")).toHaveTextContent("HW");
|
||||||
|
expect(within(tideEvents!).getByTitle("Nächstes Niedrigwasser")).toHaveTextContent("NW");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Start, Mitte and Ziel with weather and the middle tide", () => {
|
||||||
|
const routeWeatherReport: ConditionsRouteWeatherReport = {
|
||||||
|
severity: "caution",
|
||||||
|
summary: "Wind nimmt zur Mitte der Strecke zu.",
|
||||||
|
source: "Streckenprognose",
|
||||||
|
updatedAt: "2026-07-23T09:40:00.000Z",
|
||||||
|
unavailableSamples: 0,
|
||||||
|
samples: [
|
||||||
|
routeSample("Start", { windSpeed: 8, waveHeightM: 0.3 }),
|
||||||
|
routeSample("Mitte", { windSpeed: 17, waveHeightM: 0.9 }, -0.2),
|
||||||
|
routeSample("Ziel", { windSpeed: 11, waveHeightM: 0.5 })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConditionsPanel
|
||||||
|
forecast={null}
|
||||||
|
tide={null}
|
||||||
|
positionSource={{ kind: "fallback", label: "Routenstart" }}
|
||||||
|
routeWeatherReport={routeWeatherReport}
|
||||||
|
routeTides={{
|
||||||
|
start: tideSummary("Startpegel", 2.1, 1.1, 0.2),
|
||||||
|
middle: tideSummary("Mittelplate", 7.5, 1.48, 0.18),
|
||||||
|
destination: tideSummary("Zielpegel", 3.4, 1.25, 0.14)
|
||||||
|
}}
|
||||||
|
now={NOW}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Fallback · Routenstart")).toBeVisible();
|
||||||
|
expect(screen.getByText("Wind nimmt zur Mitte der Strecke zu.")).toBeVisible();
|
||||||
|
|
||||||
|
const start = routeCard("Start");
|
||||||
|
const middle = routeCard("Mitte");
|
||||||
|
const destination = routeCard("Ziel");
|
||||||
|
|
||||||
|
expect(start).toHaveTextContent("Startpegel · 2,1 km");
|
||||||
|
expect(middle).toHaveTextContent("17 kn");
|
||||||
|
expect(middle).toHaveTextContent("-0.2 kn entlang Route");
|
||||||
|
expect(middle).toHaveTextContent("Mittelplate · 7,5 km");
|
||||||
|
expect(middle).toHaveTextContent(/HW.*1\.48 m/);
|
||||||
|
expect(middle).toHaveTextContent(/NW.*0\.18 m/);
|
||||||
|
expect(destination).toHaveTextContent("Zielpegel · 3,4 km");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps partial data visible while reporting current and route failures", () => {
|
||||||
|
const partialReport: ConditionsRouteWeatherReport = {
|
||||||
|
severity: "caution",
|
||||||
|
summary: "Streckenprognose ist unvollständig.",
|
||||||
|
unavailableSamples: 2,
|
||||||
|
samples: [routeSample("Start", { windSpeed: 9, waveHeightM: 0.4 })]
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConditionsPanel
|
||||||
|
forecast={forecast({ windSpeed: 9 })}
|
||||||
|
tide={null}
|
||||||
|
positionSource={{ kind: "fallback", label: "Letzte GPS-Position" }}
|
||||||
|
currentError="Tidendaten nicht erreichbar."
|
||||||
|
routeWeatherReport={partialReport}
|
||||||
|
routeTides={{ start: null, middle: null, destination: null }}
|
||||||
|
routeError="Streckentiden nur teilweise erreichbar."
|
||||||
|
now={NOW}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("alert")).toHaveLength(2);
|
||||||
|
expect(screen.getByText("Tidendaten nicht erreichbar.")).toBeVisible();
|
||||||
|
expect(screen.getByText("Streckentiden nur teilweise erreichbar.")).toBeVisible();
|
||||||
|
const currentConditions = screen
|
||||||
|
.getByRole("heading", { name: "Aktuelle Bedingungen" })
|
||||||
|
.closest("section");
|
||||||
|
expect(currentConditions).not.toBeNull();
|
||||||
|
expect(within(currentConditions!).getByText("9 kn · 270° W")).toBeVisible();
|
||||||
|
expect(screen.getByText("Für diese Position fehlt eine passende Tidenstation.")).toBeVisible();
|
||||||
|
expect(screen.getByText("Für 2 Streckenpunkte fehlt die Prognose.")).toBeVisible();
|
||||||
|
|
||||||
|
expect(routeCard("Start")).toHaveTextContent("9 kn");
|
||||||
|
expect(routeCard("Mitte")).toHaveTextContent("Keine Wetterprognose für diesen Streckenpunkt.");
|
||||||
|
expect(routeCard("Ziel")).toHaveTextContent("Keine Wetterprognose für diesen Streckenpunkt.");
|
||||||
|
expect(screen.getAllByText(/Keine passende Tide für/)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows useful empty states when neither position nor route conditions exist", () => {
|
||||||
|
render(
|
||||||
|
<ConditionsPanel
|
||||||
|
forecast={null}
|
||||||
|
tide={null}
|
||||||
|
positionSource={{ kind: "unknown" }}
|
||||||
|
now={NOW}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Positionsquelle noch offen")).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Für diese Position liegen noch keine Wetter- oder Tidendaten vor.")
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Nach der Routenberechnung erscheinen hier Start, Mitte und Ziel.")
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function routeCard(label: "Start" | "Mitte" | "Ziel") {
|
||||||
|
const card = screen.getByRole("heading", { name: label, level: 4 }).closest("article");
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
return card!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeSample(
|
||||||
|
label: ConditionsRouteWeatherSample["label"],
|
||||||
|
overrides: Partial<MarineForecast>,
|
||||||
|
currentAlongRouteKn: number | null = 0.3
|
||||||
|
): ConditionsRouteWeatherSample {
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
plannedTime: `2026-07-23T${label === "Start" ? "10" : label === "Mitte" ? "12" : "14"}:00:00.000Z`,
|
||||||
|
currentAlongRouteKn,
|
||||||
|
forecast: forecast(overrides)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function forecast(overrides: Partial<MarineForecast> = {}): MarineForecast {
|
||||||
|
return {
|
||||||
|
waveHeightM: 0.8,
|
||||||
|
waveDirectionDeg: 225,
|
||||||
|
wavePeriodS: 5.6,
|
||||||
|
windSpeed: 12.4,
|
||||||
|
windDirectionDeg: 270,
|
||||||
|
temperatureC: 14.2,
|
||||||
|
oceanCurrentSpeedKn: 0.7,
|
||||||
|
oceanCurrentDirectionDeg: 90,
|
||||||
|
forecastTime: "2026-07-23T10:00:00.000Z",
|
||||||
|
source: "Test-Meteo",
|
||||||
|
updatedAt: "2026-07-23T09:45:00.000Z",
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tideSummary(
|
||||||
|
station: string,
|
||||||
|
distanceKm: number,
|
||||||
|
highWaterM: number,
|
||||||
|
lowWaterM: number
|
||||||
|
): TideSummary {
|
||||||
|
return {
|
||||||
|
station,
|
||||||
|
distanceKm,
|
||||||
|
nextHigh: {
|
||||||
|
type: "high",
|
||||||
|
time: "2026-07-23T12:30:00.000Z",
|
||||||
|
heightM: highWaterM
|
||||||
|
},
|
||||||
|
nextLow: {
|
||||||
|
type: "low",
|
||||||
|
time: "2026-07-23T18:45:00.000Z",
|
||||||
|
heightM: lowWaterM
|
||||||
|
},
|
||||||
|
waterLevelCurve: [],
|
||||||
|
source: "Test-Tide",
|
||||||
|
updatedAt: "2026-07-23T09:50:00.000Z"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { RouteResult } from "@watermaps/shared";
|
||||||
|
import { useCourseAssistant, type CourseAssistantInput } from "../src/hooks/useCourseAssistant";
|
||||||
|
|
||||||
|
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
|
||||||
|
else Reflect.deleteProperty(navigator, "vibrate");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCourseAssistant", () => {
|
||||||
|
it("never starts automatically and calculates only after an explicit start", () => {
|
||||||
|
const { result } = renderHook(() => useCourseAssistant(input()));
|
||||||
|
|
||||||
|
expect(result.current.active).toBe(false);
|
||||||
|
expect(result.current.guidance).toBeNull();
|
||||||
|
|
||||||
|
act(() => result.current.start());
|
||||||
|
|
||||||
|
expect(result.current.active).toBe(true);
|
||||||
|
expect(result.current.guidance?.status).toBe("on-route");
|
||||||
|
expect(result.current.guidance?.desiredCourseDeg).toBeCloseTo(90, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops deliberately when a different route is selected", () => {
|
||||||
|
const first = route("first", [[0, 0], [0.02, 0]]);
|
||||||
|
const second = route("second", [[0, 0], [0, 0.02]]);
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ currentRoute }) => useCourseAssistant(input(currentRoute)),
|
||||||
|
{ initialProps: { currentRoute: first } }
|
||||||
|
);
|
||||||
|
act(() => result.current.start());
|
||||||
|
expect(result.current.active).toBe(true);
|
||||||
|
|
||||||
|
rerender({ currentRoute: second });
|
||||||
|
|
||||||
|
expect(result.current.active).toBe(false);
|
||||||
|
expect(result.current.guidance).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses guidance when no fresh GPS fix arrives", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-20T10:00:00.000Z"));
|
||||||
|
const fixTimestampMs = Date.now();
|
||||||
|
const { result } = renderHook(() => useCourseAssistant(input(routeFixture, fixTimestampMs)));
|
||||||
|
act(() => result.current.start());
|
||||||
|
expect(result.current.guidance).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => vi.advanceTimersByTime(20_000));
|
||||||
|
|
||||||
|
expect(result.current.fixStale).toBe(true);
|
||||||
|
expect(result.current.guidance).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vibrates once when crossing into the off-route state", () => {
|
||||||
|
const vibrate = vi.fn();
|
||||||
|
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ lat }) => useCourseAssistant(input(routeFixture, Date.now(), lat)),
|
||||||
|
{ initialProps: { lat: 0 } }
|
||||||
|
);
|
||||||
|
act(() => result.current.start());
|
||||||
|
expect(vibrate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender({ lat: 0.003 });
|
||||||
|
expect(result.current.guidance?.status).toBe("off-route");
|
||||||
|
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender({ lat: 0.0031 });
|
||||||
|
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function input(
|
||||||
|
currentRoute: RouteResult = routeFixture,
|
||||||
|
fixTimestampMs = Date.now(),
|
||||||
|
lat = 0
|
||||||
|
): CourseAssistantInput {
|
||||||
|
return {
|
||||||
|
route: currentRoute,
|
||||||
|
position: { lon: 0.001, lat },
|
||||||
|
accuracyM: 5,
|
||||||
|
speedKn: 6,
|
||||||
|
headingDeg: 90,
|
||||||
|
fixTimestampMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function route(id: string, coordinates: [number, number][]): RouteResult {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
geometry: { type: "LineString", coordinates },
|
||||||
|
distanceNm: 1,
|
||||||
|
eta: null,
|
||||||
|
warnings: [],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: ["Test"],
|
||||||
|
routingMode: "fairway"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeFixture = route("test-route", [[0, 0], [0.02, 0]]);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { calculateRouteGuidance } from "@watermaps/shared";
|
||||||
|
import { CourseAssistantPanel } from "../src/components/CourseAssistantPanel";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("CourseAssistantPanel", () => {
|
||||||
|
it("shows the continuously calculated target course and correction", () => {
|
||||||
|
const guidance = calculateRouteGuidance({
|
||||||
|
route: [[0, 0], [0.02, 0]],
|
||||||
|
position: { lon: 0.001, lat: 0 },
|
||||||
|
headingDeg: 80,
|
||||||
|
speedKn: 6,
|
||||||
|
accuracyM: 5
|
||||||
|
});
|
||||||
|
const onStop = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CourseAssistantPanel
|
||||||
|
guidance={guidance}
|
||||||
|
gpsStatus="tracking"
|
||||||
|
headingDeg={80}
|
||||||
|
headingSource="COG"
|
||||||
|
accuracyM={5}
|
||||||
|
fixStale={false}
|
||||||
|
onStop={onStop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("complementary", { name: "Kursassistent" })).toHaveTextContent("090°T");
|
||||||
|
expect(screen.getByText("10° nach Steuerbord")).toBeVisible();
|
||||||
|
expect(screen.getByText("Auf Route – Sollkurs wird mit jedem GPS-Fix angepasst.")).toBeVisible();
|
||||||
|
expect(screen.getByText("IST COG").parentElement).toHaveTextContent("080°T");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Kursassistent stoppen" }));
|
||||||
|
expect(onStop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses steering advice while GPS accuracy is insufficient", () => {
|
||||||
|
const guidance = calculateRouteGuidance({
|
||||||
|
route: [[0, 0], [0.02, 0]],
|
||||||
|
position: { lon: 0.001, lat: 0 },
|
||||||
|
headingDeg: 80,
|
||||||
|
speedKn: 6,
|
||||||
|
accuracyM: 250
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CourseAssistantPanel
|
||||||
|
guidance={guidance}
|
||||||
|
gpsStatus="tracking"
|
||||||
|
headingDeg={80}
|
||||||
|
headingSource="COG"
|
||||||
|
accuracyM={250}
|
||||||
|
fixStale={false}
|
||||||
|
onStop={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(guidance?.status).toBe("gps-unreliable");
|
||||||
|
expect(screen.getByText("Keine verlässliche Steueranweisung")).toBeVisible();
|
||||||
|
expect(screen.getByText(/GPS zu ungenau/)).toBeVisible();
|
||||||
|
expect(screen.getByRole("complementary", { name: "Kursassistent" })).toHaveAttribute("data-alert", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses when the latest GPS fix is stale", () => {
|
||||||
|
render(
|
||||||
|
<CourseAssistantPanel
|
||||||
|
guidance={null}
|
||||||
|
gpsStatus="tracking"
|
||||||
|
headingDeg={null}
|
||||||
|
headingSource="--"
|
||||||
|
accuracyM={null}
|
||||||
|
fixStale
|
||||||
|
onStop={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/GPS-Fix ist veraltet/)).toBeVisible();
|
||||||
|
expect(screen.getByText("---")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("renders the iPhone PWA shell without overlapping core controls", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["geolocation"]);
|
||||||
|
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "GPS starten" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Watermaps")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("map-container")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
|
||||||
|
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
|
||||||
|
await expect(page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Abfahrt")).toBeVisible();
|
||||||
|
await page.getByText("Unterwegs: GPX · Offline · Kursalarm").click();
|
||||||
|
await expect(page.getByRole("region", { name: "Navigation und Offline-Route" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Layer" }).click();
|
||||||
|
await expect(page.getByLabel("Layer Auswahl").getByText("Brücken")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Layer Auswahl").getByText("Tiefen")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Layer Auswahl").getByText("Schleusen")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Layer Auswahl").getByText("Häfen")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets start and destination only after explicit map picking actions", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["geolocation"]);
|
||||||
|
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByText("Start und Ziel setzen")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
|
||||||
|
|
||||||
|
const mapBox = await page.getByTestId("map-container").boundingBox();
|
||||||
|
if (!mapBox) {
|
||||||
|
throw new Error("Map container not visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapTapY = mapBox.y + Math.min(132, mapBox.height * 0.2);
|
||||||
|
|
||||||
|
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
|
||||||
|
await expect(page.getByText("Start und Ziel setzen")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Start auf Karte setzen" }).click();
|
||||||
|
await expect(page.getByText("Startpunkt auf der Karte anklicken")).toBeVisible();
|
||||||
|
await page.touchscreen.tap(mapBox.x + mapBox.width / 3, mapTapY);
|
||||||
|
await expect(page.getByText(/^Start \d/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Ziel auf Karte setzen" }).click();
|
||||||
|
await expect(page.getByText("Ziel auf der Karte anklicken")).toBeVisible();
|
||||||
|
await page.touchscreen.tap(mapBox.x + (mapBox.width * 2) / 3, mapTapY);
|
||||||
|
|
||||||
|
await expect(page.getByText(/^Ziel \d/)).toBeVisible();
|
||||||
|
await expect(page.getByText("Route bereit zur Prüfung")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
|
||||||
|
await page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" }).click();
|
||||||
|
await expect(page.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
|
||||||
|
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
|
||||||
|
await expect(page.getByText(/^Z1 /)).toBeVisible();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not expose Borkum or Hamm as direct route shortcuts", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: /Borkum/i })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: /Hamm/i })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can hide and restore the route planner for a map-only view", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Routenfenster ausblenden" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
|
||||||
|
await expect(page.getByRole("button", { name: "Routenfenster einblenden" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Routenfenster einblenden" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cycles the mobile route sheet through half, full, and compact heights", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
|
||||||
|
await expect(routePanel).toHaveAttribute("data-sheet-state", "half");
|
||||||
|
const halfBox = await routePanel.boundingBox();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Routenfenster auf volle Höhe vergrößern" })
|
||||||
|
.click();
|
||||||
|
await expect(routePanel).toHaveAttribute("data-sheet-state", "full");
|
||||||
|
const fullBox = await routePanel.boundingBox();
|
||||||
|
expect(fullBox && halfBox && fullBox.height > halfBox.height + 80).toBe(true);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Routenfenster auf kompakte Höhe verkleinern" })
|
||||||
|
.click();
|
||||||
|
await expect(routePanel).toHaveAttribute("data-sheet-state", "compact");
|
||||||
|
const compactBox = await routePanel.boundingBox();
|
||||||
|
expect(compactBox && halfBox && compactBox.height < halfBox.height - 80).toBe(true);
|
||||||
|
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeHidden();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 900, height: 844 });
|
||||||
|
await expect(routePanel).toHaveAttribute("data-sheet-state", "half");
|
||||||
|
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("starts a visible dynamic course assistant only after route planning", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["geolocation"]);
|
||||||
|
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
|
||||||
|
await page.route("**/api/routes", async (request) => {
|
||||||
|
await request.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: "guidance-e2e",
|
||||||
|
name: "Testkurs Ost",
|
||||||
|
geometry: { type: "LineString", coordinates: [[12.09, 54.18], [12.11, 54.18]] },
|
||||||
|
distanceNm: 0.7,
|
||||||
|
eta: null,
|
||||||
|
warnings: [],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: ["E2E"],
|
||||||
|
routingMode: "fairway"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
|
||||||
|
|
||||||
|
const mapBox = await page.getByTestId("map-container").boundingBox();
|
||||||
|
if (!mapBox) throw new Error("Map container not visible");
|
||||||
|
const tapY = mapBox.y + Math.min(132, mapBox.height * 0.2);
|
||||||
|
await page.getByRole("button", { name: "Start auf Karte setzen" }).click();
|
||||||
|
await page.touchscreen.tap(mapBox.x + mapBox.width / 3, tapY);
|
||||||
|
await page.getByRole("button", { name: "Ziel auf Karte setzen" }).click();
|
||||||
|
await page.touchscreen.tap(mapBox.x + (mapBox.width * 2) / 3, tapY);
|
||||||
|
await page.getByRole("button", { name: "Route berechnen" }).click();
|
||||||
|
|
||||||
|
const startAssistant = page.getByRole("button", { name: "Navigation starten" });
|
||||||
|
await expect(startAssistant).toBeVisible();
|
||||||
|
await startAssistant.click();
|
||||||
|
|
||||||
|
const assistant = page.getByRole("complementary", { name: "Kursassistent" });
|
||||||
|
await expect(assistant).toBeVisible();
|
||||||
|
await expect(assistant.getByText("SOLL ÜBER GRUND")).toBeVisible();
|
||||||
|
await expect(assistant.getByRole("button", { name: "Kursassistent stoppen" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
|
||||||
|
const assistantBox = await assistant.boundingBox();
|
||||||
|
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
|
||||||
|
expect(assistantBox && statusBox && assistantBox.y + assistantBox.height <= statusBox.y + 1).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets and arms the anchor watch only through the explicit two-step flow", async ({ page, context }) => {
|
||||||
|
const now = Date.now();
|
||||||
|
await context.grantPermissions(["geolocation"]);
|
||||||
|
await context.setGeolocation({ latitude: 53.2159, longitude: 6.5766 });
|
||||||
|
await page.route("**/api/tides/nearest?*", async (request) => {
|
||||||
|
await request.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
station: "Testpegel Emden",
|
||||||
|
distanceKm: 4.2,
|
||||||
|
nextHigh: null,
|
||||||
|
nextLow: null,
|
||||||
|
waterLevelCurve: [
|
||||||
|
{ time: new Date(now - 60 * 60_000).toISOString(), predictedM: 1 },
|
||||||
|
{ time: new Date(now + 6 * 60 * 60_000).toISOString(), predictedM: 2 },
|
||||||
|
{ time: new Date(now + 30 * 60 * 60_000).toISOString(), predictedM: 0.5 }
|
||||||
|
],
|
||||||
|
source: "E2E",
|
||||||
|
updatedAt: new Date(now).toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByRole("complementary", { name: "Ankerwache" })).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "GPS starten" }).click();
|
||||||
|
await page.getByRole("button", { name: "Ankerwache öffnen" }).click();
|
||||||
|
|
||||||
|
const panel = page.getByRole("complementary", { name: "Ankerwache" });
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await expect(panel.getByText(/GPS ±\d+ m/)).toBeVisible();
|
||||||
|
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
|
||||||
|
await panel.getByRole("button", { name: "Anker gefallen – Position jetzt setzen" }).click();
|
||||||
|
|
||||||
|
await expect(panel.getByText("Ankerpunkt gespeichert")).toBeVisible();
|
||||||
|
await panel.getByLabel("Tiefe beim Setzen").fill("4");
|
||||||
|
await panel.getByLabel("Bugrolle über Wasser").fill("1");
|
||||||
|
await panel.getByLabel("Kette / Leine draußen").fill("40");
|
||||||
|
await panel.getByLabel("Gewähltes Verhältnis").selectOption("5");
|
||||||
|
await expect(panel.getByText(/Testpegel Emden · 4\.2 km entfernt/)).toBeVisible();
|
||||||
|
await expect(panel.getByText(/m Reserve/)).toBeVisible();
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "Wache starten" }).click();
|
||||||
|
await expect(panel.getByText("Ankerwache aktiv")).toBeVisible();
|
||||||
|
await expect(panel.getByText("Im überwachten Schwojkreis")).toBeVisible();
|
||||||
|
const panelBox = await panel.boundingBox();
|
||||||
|
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
|
||||||
|
expect(panelBox && statusBox && panelBox.y + panelBox.height <= statusBox.y + 1).toBe(true);
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "Ankerwache beenden" }).click();
|
||||||
|
await expect(panel.getByRole("button", { name: "Wirklich beenden" })).toBeVisible();
|
||||||
|
await panel.getByRole("button", { name: "Wirklich beenden" }).click();
|
||||||
|
await expect(panel).toBeHidden();
|
||||||
|
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("docks the route planner beside the map and releases the full map when collapsed", async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
|
||||||
|
const map = page.getByTestId("map-container");
|
||||||
|
const status = page.getByLabel("Navigationsstatus");
|
||||||
|
|
||||||
|
await expect(routePanel).toBeVisible();
|
||||||
|
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
|
||||||
|
|
||||||
|
const dockedPanelBox = await routePanel.boundingBox();
|
||||||
|
const dockedMapBox = await map.boundingBox();
|
||||||
|
const statusBox = await status.boundingBox();
|
||||||
|
expect(dockedPanelBox).not.toBeNull();
|
||||||
|
expect(dockedMapBox).not.toBeNull();
|
||||||
|
expect(statusBox).not.toBeNull();
|
||||||
|
expect(dockedPanelBox!.x).toBe(0);
|
||||||
|
expect(dockedPanelBox!.width).toBe(400);
|
||||||
|
expect(dockedPanelBox!.x + dockedPanelBox!.width).toBeLessThanOrEqual(dockedMapBox!.x);
|
||||||
|
expect(dockedPanelBox!.y + dockedPanelBox!.height).toBeLessThanOrEqual(statusBox!.y + 1);
|
||||||
|
|
||||||
|
await routePanel.getByRole("button", { name: "Routenfenster ausblenden" }).click();
|
||||||
|
|
||||||
|
await expect(routePanel).toBeHidden();
|
||||||
|
await expect.poll(async () => (await map.boundingBox())?.x).toBe(0);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
return page.locator(".maplibregl-canvas").evaluate((node) => {
|
||||||
|
const canvas = node as HTMLCanvasElement;
|
||||||
|
return Math.abs(canvas.width / window.devicePixelRatio - canvas.clientWidth);
|
||||||
|
});
|
||||||
|
}).toBeLessThan(2);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Routenfenster einblenden" }).click();
|
||||||
|
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switches from floating tablet panel to docked desktop sidebar at 1100 pixels", async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
|
||||||
|
const map = page.getByTestId("map-container");
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1099, height: 900 });
|
||||||
|
await expect.poll(async () => (await map.boundingBox())?.x).toBe(0);
|
||||||
|
const floatingPanelBox = await routePanel.boundingBox();
|
||||||
|
expect(floatingPanelBox?.x).toBe(10);
|
||||||
|
expect(floatingPanelBox?.width).toBe(360);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1100, height: 900 });
|
||||||
|
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
|
||||||
|
const dockedPanelBox = await routePanel.boundingBox();
|
||||||
|
expect(dockedPanelBox?.x).toBe(0);
|
||||||
|
expect(dockedPanelBox?.width).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows marine contact details as a right-hand desktop drawer", async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(["geolocation"]);
|
||||||
|
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
|
||||||
|
const requestedFeatureLayers: string[][] = [];
|
||||||
|
await page.route("**/api/features**", async (route) => {
|
||||||
|
const requestedLayers =
|
||||||
|
new URL(route.request().url()).searchParams.get("layers")?.split(",") ?? [];
|
||||||
|
requestedFeatureLayers.push(requestedLayers);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: requestedLayers.includes("harbours")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "e2e-test-harbour",
|
||||||
|
geometry: { type: "Point", coordinates: [12.09, 54.18] },
|
||||||
|
properties: {
|
||||||
|
layer: "harbours",
|
||||||
|
name: "Testhafen Rostock",
|
||||||
|
phone: "+49 381 123456",
|
||||||
|
website: "https://example.com",
|
||||||
|
vhf: "Kanal 12",
|
||||||
|
source: "E2E"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".data-badge")).toHaveAttribute("data-ready", "true");
|
||||||
|
await page.getByRole("button", { name: "GPS starten" }).click();
|
||||||
|
await page.getByRole("button", { name: "Position zentrieren" }).click();
|
||||||
|
await expect.poll(() => requestedFeatureLayers.flat().sort().join(",")).toContain("harbours");
|
||||||
|
|
||||||
|
const featureButton = page.getByRole("button", {
|
||||||
|
name: "Informationen zu Hafen Testhafen Rostock",
|
||||||
|
includeHidden: true
|
||||||
|
});
|
||||||
|
await expect(featureButton).toBeAttached({ timeout: 10_000 });
|
||||||
|
await featureButton.focus();
|
||||||
|
await featureButton.press("Enter");
|
||||||
|
|
||||||
|
const drawer = page.getByRole("dialog", { name: "Testhafen Rostock" });
|
||||||
|
await expect(drawer).toBeVisible();
|
||||||
|
await expect(drawer.getByRole("link", { name: /Testhafen Rostock anrufen/ })).toBeVisible();
|
||||||
|
|
||||||
|
const drawerBox = await drawer.boundingBox();
|
||||||
|
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
|
||||||
|
expect(drawerBox).not.toBeNull();
|
||||||
|
expect(statusBox).not.toBeNull();
|
||||||
|
expect(drawerBox!.x + drawerBox!.width).toBeGreaterThanOrEqual(1426);
|
||||||
|
expect(drawerBox!.y).toBeGreaterThanOrEqual(62);
|
||||||
|
expect(drawerBox!.y + drawerBox!.height).toBeLessThanOrEqual(statusBox!.y);
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(drawer).toBeHidden();
|
||||||
|
await expect(featureButton).toBeFocused();
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useGeolocation } from "../src/hooks/useGeolocation";
|
||||||
|
|
||||||
|
const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, "geolocation");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
if (originalGeolocation) Object.defineProperty(navigator, "geolocation", originalGeolocation);
|
||||||
|
else Reflect.deleteProperty(navigator, "geolocation");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useGeolocation", () => {
|
||||||
|
it("derives COG only after movement exceeds GPS noise and clears the watch on stop", () => {
|
||||||
|
let success: PositionCallback | undefined;
|
||||||
|
const watchPosition = vi.fn((callback: PositionCallback) => {
|
||||||
|
success = callback;
|
||||||
|
return 23;
|
||||||
|
});
|
||||||
|
const clearWatch = vi.fn();
|
||||||
|
Object.defineProperty(navigator, "geolocation", {
|
||||||
|
configurable: true,
|
||||||
|
value: { watchPosition, clearWatch, getCurrentPosition: vi.fn() }
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useGeolocation());
|
||||||
|
|
||||||
|
act(() => result.current.start());
|
||||||
|
act(() => success?.(positionAt(53, 7, 5, 1_000)));
|
||||||
|
expect(result.current.courseDeg).toBeNull();
|
||||||
|
|
||||||
|
act(() => success?.(positionAt(53, 7.00001, 5, 2_000)));
|
||||||
|
expect(result.current.courseDeg).toBeNull();
|
||||||
|
|
||||||
|
const receivedAtMs = Date.now();
|
||||||
|
act(() => success?.(positionAt(53, 7.0001, 5, 3_000)));
|
||||||
|
expect(result.current.courseDeg).toBeCloseTo(90, 0);
|
||||||
|
expect(result.current.timestampMs).toBeGreaterThanOrEqual(receivedAtMs);
|
||||||
|
|
||||||
|
act(() => result.current.stop());
|
||||||
|
expect(clearWatch).toHaveBeenCalledWith(23);
|
||||||
|
expect(result.current.status).toBe("idle");
|
||||||
|
expect(result.current.position).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function positionAt(lat: number, lon: number, accuracy: number, timestamp: number): GeolocationPosition {
|
||||||
|
return {
|
||||||
|
coords: {
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
accuracy,
|
||||||
|
altitude: null,
|
||||||
|
altitudeAccuracy: null,
|
||||||
|
heading: null,
|
||||||
|
speed: 3,
|
||||||
|
toJSON: () => ({})
|
||||||
|
},
|
||||||
|
timestamp,
|
||||||
|
toJSON: () => ({})
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { RouteResult } from "@watermaps/shared";
|
||||||
|
import { createRouteGpx } from "../src/lib/gpx";
|
||||||
|
|
||||||
|
describe("GPX export", () => {
|
||||||
|
it("creates parseable GPX 1.1 route and track metadata", () => {
|
||||||
|
const gpx = createRouteGpx(routeFixture, {
|
||||||
|
name: "Emden & Hamm <Test>",
|
||||||
|
createdAt: "2026-07-19T12:00:00.000Z"
|
||||||
|
});
|
||||||
|
const document = new DOMParser().parseFromString(gpx, "application/xml");
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
expect(document.querySelector("parsererror")).toBeNull();
|
||||||
|
expect(root.localName).toBe("gpx");
|
||||||
|
expect(root.getAttribute("version")).toBe("1.1");
|
||||||
|
expect(root.namespaceURI).toBe("http://www.topografix.com/GPX/1/1");
|
||||||
|
expect(document.getElementsByTagNameNS(root.namespaceURI, "metadata")).toHaveLength(1);
|
||||||
|
expect(document.getElementsByTagNameNS(root.namespaceURI, "rte")).toHaveLength(1);
|
||||||
|
expect(document.getElementsByTagNameNS(root.namespaceURI, "trk")).toHaveLength(1);
|
||||||
|
expect(document.getElementsByTagNameNS(root.namespaceURI, "rtept")).toHaveLength(3);
|
||||||
|
expect(document.getElementsByTagNameNS(root.namespaceURI, "trkpt")).toHaveLength(3);
|
||||||
|
expect(document.getElementsByTagNameNS(root.namespaceURI, "time")[0]?.textContent).toBe("2026-07-19T12:00:00.000Z");
|
||||||
|
expect(gpx).toContain("Emden & Hamm <Test>");
|
||||||
|
expect(gpx).toContain("minlat=\"51.6814536\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid or incomplete geometry", () => {
|
||||||
|
expect(() => createRouteGpx({
|
||||||
|
...routeFixture,
|
||||||
|
geometry: { type: "LineString", coordinates: [[7, 53]] }
|
||||||
|
})).toThrow(/nicht genügend Punkte/);
|
||||||
|
expect(() => createRouteGpx({
|
||||||
|
...routeFixture,
|
||||||
|
geometry: { type: "LineString", coordinates: [[7, 53], [181, 52]] }
|
||||||
|
})).toThrow(/ungültige Koordinaten/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeFixture: RouteResult = {
|
||||||
|
id: "emden-hamm",
|
||||||
|
name: "Emden – Hamm",
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]]
|
||||||
|
},
|
||||||
|
distanceNm: 153.69,
|
||||||
|
eta: null,
|
||||||
|
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich & vor Ort prüfen" }],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: ["OpenStreetMap <curated>"],
|
||||||
|
routingMode: "fairway"
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { lazy } from "react";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { LazyContent } from "../src/components/LazyContent";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LazyContent", () => {
|
||||||
|
it("keeps the surrounding app shell visible while a feature chunk loads", async () => {
|
||||||
|
let resolveModule: ((module: { default: () => React.JSX.Element }) => void) | undefined;
|
||||||
|
const Feature = lazy(
|
||||||
|
() =>
|
||||||
|
new Promise<{ default: () => React.JSX.Element }>((resolve) => {
|
||||||
|
resolveModule = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<main>
|
||||||
|
<h1>Watermaps</h1>
|
||||||
|
<LazyContent
|
||||||
|
pending={<p role="status">Modul wird geladen …</p>}
|
||||||
|
failed={<p role="alert">Modul fehlt.</p>}
|
||||||
|
>
|
||||||
|
<Feature />
|
||||||
|
</LazyContent>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { name: "Watermaps" })).toBeVisible();
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Modul wird geladen");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveModule?.({ default: () => <section>Funktion bereit</section> });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Funktion bereit")).toBeVisible();
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains a failed lazy import inside its local fallback", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
const BrokenFeature = lazy(() => Promise.reject(new Error("Chunk fehlt")));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<main>
|
||||||
|
<h1>Watermaps</h1>
|
||||||
|
<LazyContent
|
||||||
|
pending={<p role="status">Modul wird geladen …</p>}
|
||||||
|
failed={<p role="alert">Modul konnte nicht geladen werden.</p>}
|
||||||
|
>
|
||||||
|
<BrokenFeature />
|
||||||
|
</LazyContent>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("nicht geladen");
|
||||||
|
expect(screen.getByRole("heading", { name: "Watermaps" })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,866 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AppConfig, RouteResult } from "@watermaps/shared";
|
||||||
|
import { MarineFeatureInfo, type MarineFeatureDetails } from "../src/components/MarineFeatureInfo";
|
||||||
|
|
||||||
|
const maplibreState = vi.hoisted(() => ({ current: null as any, markerConstructions: 0 }));
|
||||||
|
const apiMocks = vi.hoisted(() => ({ getMapFeatures: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("../src/api", () => apiMocks);
|
||||||
|
|
||||||
|
vi.mock("maplibre-gl", () => {
|
||||||
|
class MockLngLatBounds {
|
||||||
|
extend = vi.fn(() => this);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockMap {
|
||||||
|
handlers = new globalThis.Map<string, Set<(event?: any) => void>>();
|
||||||
|
sources = new globalThis.Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
setData: ReturnType<typeof vi.fn>;
|
||||||
|
getClusterExpansionZoom: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
sourceDefinitions = new globalThis.Map<string, any>();
|
||||||
|
layers = new Set<string>();
|
||||||
|
layerDefinitions = new globalThis.Map<string, any>();
|
||||||
|
renderedFeatures: any[] = [];
|
||||||
|
zoom = 13;
|
||||||
|
container: HTMLElement;
|
||||||
|
fitBounds = vi.fn(() => this);
|
||||||
|
easeTo = vi.fn(() => this);
|
||||||
|
flyTo = vi.fn(() => this);
|
||||||
|
setLayoutProperty = vi.fn();
|
||||||
|
moveLayer = vi.fn();
|
||||||
|
remove = vi.fn();
|
||||||
|
|
||||||
|
constructor(options: { container: HTMLElement }) {
|
||||||
|
this.container = options.container;
|
||||||
|
this.container.addEventListener("click", () => {
|
||||||
|
this.emit("click", { lngLat: { lng: 7.8, lat: 51.7 }, point: { x: 10, y: 10 } });
|
||||||
|
});
|
||||||
|
maplibreState.current = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addControl() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
on(eventName: string, handler: (event?: any) => void) {
|
||||||
|
this.handlers.set(eventName, new Set([...(this.handlers.get(eventName) ?? []), handler]));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
off(eventName: string, handler: (event?: any) => void) {
|
||||||
|
this.handlers.get(eventName)?.delete(handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(eventName: string, event?: any) {
|
||||||
|
for (const handler of this.handlers.get(eventName) ?? []) {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSource(id: string, definition: any) {
|
||||||
|
this.sourceDefinitions.set(id, definition);
|
||||||
|
this.sources.set(id, {
|
||||||
|
setData: vi.fn(),
|
||||||
|
getClusterExpansionZoom: vi.fn(async () => 15)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSource(id: string) {
|
||||||
|
return this.sources.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLayer(layer: { id: string }) {
|
||||||
|
this.layers.add(layer.id);
|
||||||
|
this.layerDefinitions.set(layer.id, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayer(id: string) {
|
||||||
|
return this.layers.has(id) ? { id } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCanvas() {
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCanvasContainer() {
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContainer() {
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
|
||||||
|
unproject() {
|
||||||
|
return { lng: 7.8, lat: 51.7 };
|
||||||
|
}
|
||||||
|
|
||||||
|
project() {
|
||||||
|
return { x: 10, y: 10 };
|
||||||
|
}
|
||||||
|
|
||||||
|
queryRenderedFeatures() {
|
||||||
|
return this.renderedFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
isStyleLoaded() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getZoom() {
|
||||||
|
return this.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBounds() {
|
||||||
|
return {
|
||||||
|
getWest: () => 7,
|
||||||
|
getSouth: () => 51,
|
||||||
|
getEast: () => 9,
|
||||||
|
getNorth: () => 54
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockMarker {
|
||||||
|
element: HTMLElement;
|
||||||
|
|
||||||
|
constructor(options: { element: HTMLElement }) {
|
||||||
|
maplibreState.markerConstructions += 1;
|
||||||
|
this.element = options.element;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLngLat() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addTo(map: MockMap) {
|
||||||
|
map.container.append(this.element);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
this.element.remove();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockControl {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
Map: MockMap,
|
||||||
|
Marker: MockMarker,
|
||||||
|
LngLatBounds: MockLngLatBounds,
|
||||||
|
AttributionControl: MockControl,
|
||||||
|
ScaleControl: MockControl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { MapView } from "../src/components/MapView";
|
||||||
|
|
||||||
|
const config: AppConfig = {
|
||||||
|
appName: "Watermaps",
|
||||||
|
region: "Test",
|
||||||
|
disclaimer: "Test",
|
||||||
|
featureFlags: {},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "base",
|
||||||
|
name: "Basiskarte",
|
||||||
|
kind: "style",
|
||||||
|
url: "https://example.test/style.json",
|
||||||
|
attribution: "Test",
|
||||||
|
defaultVisible: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attribution: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const marineFeatures = [
|
||||||
|
{
|
||||||
|
type: "Feature" as const,
|
||||||
|
id: "lock-1",
|
||||||
|
geometry: { type: "Point" as const, coordinates: [7.61, 52.01] },
|
||||||
|
properties: {
|
||||||
|
layer: "locks",
|
||||||
|
name: "Schleuse Nord",
|
||||||
|
"contact:phone": "+49 123 456",
|
||||||
|
website: "https://schleuse.example",
|
||||||
|
email: "schleuse@example.test",
|
||||||
|
vhf_channel: "Kanal 20",
|
||||||
|
opening_hours: "Mo-Su 06:00-22:00",
|
||||||
|
operator: "WSV",
|
||||||
|
"addr:street": "Am Kanal",
|
||||||
|
"addr:housenumber": "1",
|
||||||
|
"addr:postcode": "12345",
|
||||||
|
"addr:city": "Hafenstadt",
|
||||||
|
source: "OSM/Geofabrik"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature" as const,
|
||||||
|
id: "harbour-1",
|
||||||
|
geometry: { type: "Point" as const, coordinates: [7.72, 51.82] },
|
||||||
|
properties: {
|
||||||
|
layer: "harbours",
|
||||||
|
name: "Stadthafen",
|
||||||
|
source: "Hafenbetreiber"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
maplibreState.current = null;
|
||||||
|
maplibreState.markerConstructions = 0;
|
||||||
|
apiMocks.getMapFeatures.mockReset();
|
||||||
|
apiMocks.getMapFeatures.mockImplementation(async ({ layers }: { layers: string[] }) => ({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: marineFeatures.filter((feature) => layers.includes(feature.properties.layer))
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MapView marine feature information", () => {
|
||||||
|
it("loads clustered lock and harbour info layers only at a close zoom level", async () => {
|
||||||
|
render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
maplibreState.current.zoom = 9;
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument();
|
||||||
|
expect(apiMocks.getMapFeatures).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ layers: expect.not.arrayContaining(["locks", "harbours"]) })
|
||||||
|
);
|
||||||
|
expect(maplibreState.current.sourceDefinitions.get("marine-contact-pois")).toEqual(
|
||||||
|
expect.objectContaining({ type: "geojson", cluster: true, clusterMaxZoom: 14 })
|
||||||
|
);
|
||||||
|
expect(maplibreState.current.layerDefinitions.get("lock-info-circles")).toEqual(
|
||||||
|
expect.objectContaining({ type: "circle", minzoom: 12, source: "marine-contact-pois" })
|
||||||
|
);
|
||||||
|
expect(maplibreState.current.layerDefinitions.get("contact-cluster-count")).toEqual(
|
||||||
|
expect.objectContaining({ type: "symbol", minzoom: 12, source: "marine-contact-pois" })
|
||||||
|
);
|
||||||
|
|
||||||
|
apiMocks.getMapFeatures.mockClear();
|
||||||
|
maplibreState.current.zoom = 12;
|
||||||
|
await act(async () => maplibreState.current.emit("zoomend"));
|
||||||
|
await act(async () => maplibreState.current.emit("moveend"));
|
||||||
|
expect(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument();
|
||||||
|
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1);
|
||||||
|
expect(maplibreState.markerConstructions).toBe(0);
|
||||||
|
expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0);
|
||||||
|
|
||||||
|
maplibreState.current.zoom = 11;
|
||||||
|
await act(async () => maplibreState.current.emit("zoom"));
|
||||||
|
expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a rendered POI before route-coordinate picking and keeps static accessible buttons", async () => {
|
||||||
|
const onPickCoordinate = vi.fn();
|
||||||
|
render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode="destination"
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={onPickCoordinate}
|
||||||
|
onMapReady={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" });
|
||||||
|
expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument();
|
||||||
|
expect(apiMocks.getMapFeatures).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ layers: expect.arrayContaining(["locks", "harbours"]) })
|
||||||
|
);
|
||||||
|
|
||||||
|
maplibreState.current.renderedFeatures = [marineFeatures[0]];
|
||||||
|
await act(async () =>
|
||||||
|
maplibreState.current.emit("click", {
|
||||||
|
point: { x: 12, y: 18 },
|
||||||
|
lngLat: { lng: 7.61, lat: 52.01 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onPickCoordinate).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible();
|
||||||
|
expect(screen.getByRole("link", { name: /Schleuse Nord anrufen/i })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"tel:+49123456"
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("link", { name: /Website von Schleuse Nord/i })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://schleuse.example/"
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Kanal 20")).toBeVisible();
|
||||||
|
expect(screen.getByText("Am Kanal 1, 12345 Hafenstadt")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a contact cluster instead of treating it as a picked route coordinate", async () => {
|
||||||
|
const onPickCoordinate = vi.fn();
|
||||||
|
render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode="destination"
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={onPickCoordinate}
|
||||||
|
onMapReady={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
maplibreState.current.renderedFeatures = [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "Point", coordinates: [7.7, 52] },
|
||||||
|
properties: { cluster: true, cluster_id: 42, point_count: 12 }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
await act(async () =>
|
||||||
|
maplibreState.current.emit("click", {
|
||||||
|
point: { x: 20, y: 20 },
|
||||||
|
lngLat: { lng: 7.7, lat: 52 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactSource = maplibreState.current.getSource("marine-contact-pois");
|
||||||
|
expect(contactSource.getClusterExpansionZoom).toHaveBeenCalledWith(42);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(maplibreState.current.easeTo).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ center: [7.7, 52], zoom: 15, essential: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(onPickCoordinate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes accessible POIs and closes their panel when a feature layer is disabled", async () => {
|
||||||
|
render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Hafen Stadthafen" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: "Stadthafen" })).toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Layer" }));
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Häfen ab Zoom 12" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("button", { name: "Informationen zu Hafen Stadthafen" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Stadthafen" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces stale static POI controls after the visible map area is refreshed", async () => {
|
||||||
|
render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible();
|
||||||
|
|
||||||
|
apiMocks.getMapFeatures.mockResolvedValue({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "lock-2",
|
||||||
|
geometry: { type: "Point", coordinates: [8.1, 52.2] },
|
||||||
|
properties: { layer: "locks", name: "Schleuse Süd", source: "Test" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await act(async () => maplibreState.current.emit("moveend"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Schleuse Nord" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Süd" })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps map movement handlers constant for a dense contact response", async () => {
|
||||||
|
apiMocks.getMapFeatures.mockResolvedValue({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: Array.from({ length: 250 }, (_, index) => ({
|
||||||
|
type: "Feature",
|
||||||
|
id: `lock-${index}`,
|
||||||
|
geometry: { type: "Point", coordinates: [7 + index / 10_000, 52] },
|
||||||
|
properties: { layer: "locks", name: `Schleuse ${index}` }
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(maplibreState.current.getSource("marine-contact-pois").setData).toHaveBeenCalled());
|
||||||
|
expect(maplibreState.markerConstructions).toBe(0);
|
||||||
|
expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0);
|
||||||
|
expect(maplibreState.current.handlers.get("moveend")?.size ?? 0).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts a superseded feature request", async () => {
|
||||||
|
render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" });
|
||||||
|
|
||||||
|
let firstSignal: AbortSignal | undefined;
|
||||||
|
apiMocks.getMapFeatures.mockImplementationOnce(
|
||||||
|
({ signal }: { signal?: AbortSignal }) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
firstSignal = signal;
|
||||||
|
signal?.addEventListener("abort", () => reject(new DOMException("Abgebrochen", "AbortError")), {
|
||||||
|
once: true
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await act(async () => maplibreState.current.emit("moveend"));
|
||||||
|
await waitFor(() => expect(firstSignal).toBeDefined());
|
||||||
|
|
||||||
|
apiMocks.getMapFeatures.mockResolvedValueOnce({ type: "FeatureCollection", features: marineFeatures });
|
||||||
|
await act(async () => maplibreState.current.emit("moveend"));
|
||||||
|
|
||||||
|
expect(firstSignal?.aborted).toBe(true);
|
||||||
|
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fits the map to a newly calculated route", async () => {
|
||||||
|
const stableOnMapReady = vi.fn();
|
||||||
|
const view = render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={stableOnMapReady}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
const route: RouteResult = {
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[7.18, 53.34],
|
||||||
|
[7.81, 51.68]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
distanceNm: 120,
|
||||||
|
eta: null,
|
||||||
|
warnings: [],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: [],
|
||||||
|
routingMode: "fairway"
|
||||||
|
};
|
||||||
|
view.rerender(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={route}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={stableOnMapReady}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(maplibreState.current.fitBounds).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ maxZoom: 14, essential: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses a route event requested by the shared navigation workspace", async () => {
|
||||||
|
const stableOnMapReady = vi.fn();
|
||||||
|
const view = render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
focusRequest={null}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={stableOnMapReady}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
focusRequest={{
|
||||||
|
key: "lock:test:1",
|
||||||
|
coordinate: { lat: 52.1, lon: 7.4 },
|
||||||
|
zoom: 15
|
||||||
|
}}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={stableOnMapReady}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(maplibreState.current.flyTo).toHaveBeenCalledWith({
|
||||||
|
center: [7.4, 52.1],
|
||||||
|
zoom: 15,
|
||||||
|
essential: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies an existing route-event focus after the map style has loaded", async () => {
|
||||||
|
render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
focusRequest={{
|
||||||
|
key: "harbour:early:1",
|
||||||
|
coordinate: { lat: 53.2, lon: 6.9 },
|
||||||
|
zoom: 13
|
||||||
|
}}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(maplibreState.current.flyTo).not.toHaveBeenCalled();
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
expect(maplibreState.current.flyTo).toHaveBeenCalledWith({
|
||||||
|
center: [6.9, 53.2],
|
||||||
|
zoom: 13,
|
||||||
|
essential: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws the live guidance vector and lookahead target without DOM markers", async () => {
|
||||||
|
render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={{ lat: 52, lon: 7 }}
|
||||||
|
accuracyM={6}
|
||||||
|
startPoint={{ lat: 52, lon: 7 }}
|
||||||
|
destination={{ lat: 52, lon: 7.1 }}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
guidanceActive
|
||||||
|
guidanceTarget={{ lat: 52.001, lon: 7.01 }}
|
||||||
|
onPickCoordinate={vi.fn()}
|
||||||
|
onMapReady={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
const definition = maplibreState.current.sourceDefinitions.get("route-guidance");
|
||||||
|
expect(definition).toEqual(expect.objectContaining({ type: "geojson" }));
|
||||||
|
expect(definition.data.features).toHaveLength(2);
|
||||||
|
expect(definition.data.features[0].geometry).toEqual({
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [[7, 52], [7.01, 52.001]]
|
||||||
|
});
|
||||||
|
expect(maplibreState.current.layerDefinitions.get("route-guidance-line")).toEqual(
|
||||||
|
expect.objectContaining({ type: "line", source: "route-guidance" })
|
||||||
|
);
|
||||||
|
expect(maplibreState.markerConstructions).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MapView anchor watch", () => {
|
||||||
|
it("renders one geodesic metre-based alarm ring and updates it without markers", async () => {
|
||||||
|
const onPickCoordinate = vi.fn();
|
||||||
|
const onMapReady = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={{ lat: 53.2001, lon: 7.1001 }}
|
||||||
|
accuracyM={5}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
anchorPoint={{ lat: 53.2, lon: 7.1 }}
|
||||||
|
anchorAlarmRadiusM={80}
|
||||||
|
anchorWatchActive
|
||||||
|
anchorAlarm={false}
|
||||||
|
onPickCoordinate={onPickCoordinate}
|
||||||
|
onMapReady={onMapReady}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
const initial = maplibreState.current.sourceDefinitions.get("anchor-watch").data;
|
||||||
|
const radius = initial.features.find((feature: any) => feature.properties.kind === "radius");
|
||||||
|
expect(radius.geometry.type).toBe("Polygon");
|
||||||
|
expect(radius.geometry.coordinates[0]).toHaveLength(65);
|
||||||
|
expect(initial.features.filter((feature: any) => feature.properties.kind === "anchor")).toHaveLength(1);
|
||||||
|
expect(initial.features.filter((feature: any) => feature.properties.kind === "distance")).toHaveLength(1);
|
||||||
|
expect(maplibreState.current.layerDefinitions.get("anchor-watch-radius-fill")).toEqual(
|
||||||
|
expect.objectContaining({ type: "fill", source: "anchor-watch" })
|
||||||
|
);
|
||||||
|
expect(maplibreState.current.easeTo).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ center: [7.1, 53.2], zoom: 15 })
|
||||||
|
);
|
||||||
|
expect(maplibreState.markerConstructions).toBe(0);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={{ lat: 53.2015, lon: 7.1 }}
|
||||||
|
accuracyM={5}
|
||||||
|
startPoint={null}
|
||||||
|
destination={null}
|
||||||
|
pickMode={null}
|
||||||
|
route={null}
|
||||||
|
anchorPoint={{ lat: 53.2, lon: 7.1 }}
|
||||||
|
anchorAlarmRadiusM={100}
|
||||||
|
anchorWatchActive
|
||||||
|
anchorAlarm
|
||||||
|
onPickCoordinate={onPickCoordinate}
|
||||||
|
onMapReady={onMapReady}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const source = maplibreState.current.sources.get("anchor-watch");
|
||||||
|
await waitFor(() => expect(source.setData).toHaveBeenCalled());
|
||||||
|
const updated = source.setData.mock.calls.at(-1)?.[0];
|
||||||
|
expect(updated.features.every((feature: any) => feature.properties.alarm === true)).toBe(true);
|
||||||
|
expect(updated.features.find((feature: any) => feature.properties.kind === "radius").geometry.coordinates[0]).toHaveLength(65);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MapView waypoints", () => {
|
||||||
|
it("creates a labelled waypoint source, updates it, and shows the waypoint picking hint", async () => {
|
||||||
|
const stableOnPickCoordinate = vi.fn();
|
||||||
|
const stableOnMapReady = vi.fn();
|
||||||
|
const view = render(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={{ lat: 53.34, lon: 7.18 }}
|
||||||
|
destination={{ lat: 51.68, lon: 7.8 }}
|
||||||
|
waypoints={[
|
||||||
|
{ lat: 52.8, lon: 7.25 },
|
||||||
|
{ lat: 52.1, lon: 7.5 }
|
||||||
|
]}
|
||||||
|
pickMode="waypoint"
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={stableOnPickCoordinate}
|
||||||
|
onMapReady={stableOnMapReady}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
|
||||||
|
await act(async () => maplibreState.current.emit("load"));
|
||||||
|
|
||||||
|
expect(maplibreState.current.sourceDefinitions.get("waypoints")).toEqual({
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: { label: "Z1" },
|
||||||
|
geometry: { type: "Point", coordinates: [7.25, 52.8] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: { label: "Z2" },
|
||||||
|
geometry: { type: "Point", coordinates: [7.5, 52.1] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(maplibreState.current.layerDefinitions.get("waypoint-label")).toEqual(
|
||||||
|
expect.objectContaining({ source: "waypoints" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const waypointSource = maplibreState.current.getSource("waypoints");
|
||||||
|
view.rerender(
|
||||||
|
<MapView
|
||||||
|
config={config}
|
||||||
|
position={null}
|
||||||
|
accuracyM={null}
|
||||||
|
startPoint={{ lat: 53.34, lon: 7.18 }}
|
||||||
|
destination={{ lat: 51.68, lon: 7.8 }}
|
||||||
|
waypoints={[{ lat: 52.4, lon: 7.65 }]}
|
||||||
|
pickMode="waypoint"
|
||||||
|
route={null}
|
||||||
|
onPickCoordinate={stableOnPickCoordinate}
|
||||||
|
onMapReady={stableOnMapReady}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(waypointSource?.setData).toHaveBeenLastCalledWith({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: { label: "Z1" },
|
||||||
|
geometry: { type: "Point", coordinates: [7.65, 52.4] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MarineFeatureInfo", () => {
|
||||||
|
it("summarizes missing contact information without rendering empty data rows and closes with Escape", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const feature: MarineFeatureDetails = {
|
||||||
|
id: "harbour-missing",
|
||||||
|
layer: "harbours",
|
||||||
|
name: "Unbemannter Hafen",
|
||||||
|
typeLabel: "Hafen",
|
||||||
|
coordinate: { lat: 51.7, lon: 7.8 },
|
||||||
|
phone: null,
|
||||||
|
website: null,
|
||||||
|
email: null,
|
||||||
|
vhf: null,
|
||||||
|
openingHours: null,
|
||||||
|
operator: null,
|
||||||
|
address: null,
|
||||||
|
source: null,
|
||||||
|
updatedAt: null
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<MarineFeatureInfo feature={feature} onClose={onClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Keine direkten Kontaktdaten hinterlegt.")).toBeVisible();
|
||||||
|
expect(screen.queryByText("Nicht hinterlegt")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Daten & Quelle")).toBeVisible();
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links the enrichment provenance and explains merged map objects", () => {
|
||||||
|
const feature: MarineFeatureDetails = {
|
||||||
|
id: "lock-enriched",
|
||||||
|
layer: "locks",
|
||||||
|
name: "Schleuse Werries",
|
||||||
|
typeLabel: "Schleuse",
|
||||||
|
coordinate: { lat: 51.69508, lon: 7.86708 },
|
||||||
|
phone: "+49 2381 9019-290",
|
||||||
|
website: null,
|
||||||
|
email: null,
|
||||||
|
vhf: "22",
|
||||||
|
openingHours: null,
|
||||||
|
operator: "WSV",
|
||||||
|
address: null,
|
||||||
|
source: "EuRIS + OpenStreetMap",
|
||||||
|
sourceUrl: "https://www.eurisportal.eu/visuris/api/Locks_v2/GetLock?isrs=DEHMM00301LOCKS00404",
|
||||||
|
updatedAt: "2026-07-20T10:00:00.000Z",
|
||||||
|
memberCount: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<MarineFeatureInfo feature={feature} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("link", { name: /Schleuse Werries anrufen/i })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"tel:+4923819019290"
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("Daten & Quelle"));
|
||||||
|
expect(screen.getByRole("link", { name: "EuRIS + OpenStreetMap" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
expect.stringContaining("DEHMM00301LOCKS00404")
|
||||||
|
);
|
||||||
|
expect(screen.getByText("4 Kartenobjekte")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const apiMocks = vi.hoisted(() => ({
|
||||||
|
getMarineForecast: vi.fn(),
|
||||||
|
getNearestTide: vi.fn()
|
||||||
|
}));
|
||||||
|
vi.mock("../src/api", () => apiMocks);
|
||||||
|
|
||||||
|
import { useMarineData } from "../src/hooks/useMarineData";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
apiMocks.getMarineForecast.mockResolvedValue({ source: "test", updatedAt: new Date().toISOString() });
|
||||||
|
apiMocks.getNearestTide.mockResolvedValue({
|
||||||
|
station: "test",
|
||||||
|
distanceKm: 1,
|
||||||
|
nextHigh: null,
|
||||||
|
nextLow: null,
|
||||||
|
waterLevelCurve: [],
|
||||||
|
source: "test",
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useMarineData", () => {
|
||||||
|
it("does not refetch weather and tide for every GPS jitter inside the same rounded cell", async () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ position }) => useMarineData(position),
|
||||||
|
{ initialProps: { position: { lat: 53.201, lon: 7.101 } } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender({ position: { lat: 53.203, lon: 7.103 } });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender({ position: { lat: 53.216, lon: 7.116 } });
|
||||||
|
await waitFor(() => expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(2));
|
||||||
|
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps partial data visible and exposes the failed source separately", async () => {
|
||||||
|
apiMocks.getMarineForecast.mockRejectedValueOnce(new Error("forecast offline"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useMarineData({ lat: 53.2159, lon: 6.5766 })
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.forecast).toBeNull();
|
||||||
|
expect(result.current.tide?.station).toBe("test");
|
||||||
|
expect(result.current.forecastError).toBe("Wetterdaten nicht erreichbar");
|
||||||
|
expect(result.current.tideError).toBeNull();
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(result.current.queryPosition).toEqual({ lat: 53.22, lon: 6.58 });
|
||||||
|
expect(result.current.refreshedAt).toEqual(expect.any(String));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
NavigationToolRail,
|
||||||
|
type NavigationToolId
|
||||||
|
} from "../src/components/NavigationToolRail";
|
||||||
|
import { NavigationWorkspace } from "../src/components/NavigationWorkspace";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("NavigationToolRail", () => {
|
||||||
|
it("renders the fixed tool order with accessible state and badge information", () => {
|
||||||
|
const onSelect = vi.fn<(tool: NavigationToolId) => void>();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NavigationToolRail
|
||||||
|
activeTool="conditions"
|
||||||
|
onSelect={onSelect}
|
||||||
|
workspaceId="test-workspace"
|
||||||
|
statuses={{
|
||||||
|
anchor: "alarm",
|
||||||
|
conditions: "active",
|
||||||
|
upcoming: "caution",
|
||||||
|
route: "stale"
|
||||||
|
}}
|
||||||
|
badges={{ upcoming: 123 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rail = screen.getByRole("navigation", { name: "Kartenwerkzeuge" });
|
||||||
|
const buttons = within(rail).getAllByRole("button");
|
||||||
|
expect(buttons.map((button) => button.dataset.tool)).toEqual([
|
||||||
|
"anchor",
|
||||||
|
"conditions",
|
||||||
|
"upcoming",
|
||||||
|
"route"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const anchor = screen.getByRole("button", { name: /Ankerwache, Alarm, öffnen/ });
|
||||||
|
expect(anchor).toHaveAttribute("data-status", "alarm");
|
||||||
|
expect(anchor).toHaveAttribute("aria-controls", "test-workspace");
|
||||||
|
expect(anchor).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(anchor).toHaveAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
|
const conditions = screen.getByRole("button", {
|
||||||
|
name: /Wetter und Tide, aktiv, geöffnet/
|
||||||
|
});
|
||||||
|
expect(conditions).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(conditions).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
|
const upcoming = screen.getByRole("button", {
|
||||||
|
name: /Als Nächstes, Warnung, 99\+ Hinweise, öffnen/
|
||||||
|
});
|
||||||
|
expect(upcoming).toHaveAttribute("data-status", "caution");
|
||||||
|
expect(within(upcoming).getByText("99+")).toBeVisible();
|
||||||
|
|
||||||
|
const route = screen.getByRole("button", { name: /Route, Daten veraltet, öffnen/ });
|
||||||
|
fireEvent.click(route);
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("route");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NavigationWorkspace", () => {
|
||||||
|
it("keeps compact content inaccessible and exposes controlled size and close actions", () => {
|
||||||
|
const onSheetStateChange = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onBack = vi.fn();
|
||||||
|
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<NavigationWorkspace
|
||||||
|
id="test-workspace"
|
||||||
|
activeTool="route"
|
||||||
|
title="Routenübersicht"
|
||||||
|
summary="12 sm bis zum Ziel"
|
||||||
|
status="active"
|
||||||
|
sheetState="compact"
|
||||||
|
presentation="bottom-sheet"
|
||||||
|
onSheetStateChange={onSheetStateChange}
|
||||||
|
onBack={onBack}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={<button type="button">Navigation starten</button>}
|
||||||
|
>
|
||||||
|
<button type="button">Route bearbeiten</button>
|
||||||
|
</NavigationWorkspace>
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspace = screen.getByRole("complementary", { name: "Routenübersicht" });
|
||||||
|
expect(workspace).toHaveAttribute("data-tool", "route");
|
||||||
|
expect(workspace).toHaveAttribute("data-status", "active");
|
||||||
|
expect(workspace).toHaveAttribute("data-presentation", "bottom-sheet");
|
||||||
|
expect(workspace).toHaveAttribute("data-sheet-state", "compact");
|
||||||
|
expect(container.querySelector(".navigation-workspace-body")).toHaveAttribute("hidden");
|
||||||
|
expect(container.querySelector(".navigation-workspace-footer")).toHaveAttribute("hidden");
|
||||||
|
expect(screen.queryByRole("button", { name: "Route bearbeiten" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Arbeitsbereich auf halbe Höhe vergrößern" })
|
||||||
|
);
|
||||||
|
expect(onSheetStateChange).toHaveBeenCalledWith("half");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Zurück" }));
|
||||||
|
expect(onBack).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Route schließen" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<NavigationWorkspace
|
||||||
|
id="test-workspace"
|
||||||
|
activeTool="route"
|
||||||
|
title="Routenübersicht"
|
||||||
|
sheetState="half"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<button type="button">Route bearbeiten</button>
|
||||||
|
</NavigationWorkspace>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Route bearbeiten" })).toBeVisible();
|
||||||
|
expect(container.querySelector(".navigation-workspace-body")).not.toHaveAttribute("hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { RouteResult } from "@watermaps/shared";
|
||||||
|
import {
|
||||||
|
OFFLINE_VOYAGES_STORAGE_KEY,
|
||||||
|
createOfflineVoyageRecord,
|
||||||
|
deleteOfflineVoyage,
|
||||||
|
listOfflineVoyages,
|
||||||
|
loadOfflineVoyage,
|
||||||
|
saveOfflineVoyage
|
||||||
|
} from "../src/lib/offline-route";
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
describe("offline voyage storage", () => {
|
||||||
|
it("stores and restores a validated route and voyage plan", () => {
|
||||||
|
const saved = saveOfflineVoyage(
|
||||||
|
{
|
||||||
|
route: routeFixture,
|
||||||
|
name: "Törn Emden – Hamm",
|
||||||
|
plan: {
|
||||||
|
vesselProfile: {
|
||||||
|
draughtM: 1.4,
|
||||||
|
safetyReserveM: 0.5,
|
||||||
|
airDraftM: 2.8,
|
||||||
|
beamM: 3.2,
|
||||||
|
cruiseSpeedKn: 6
|
||||||
|
},
|
||||||
|
waypoints: [{ lat: 51.65, lon: 7.34 }],
|
||||||
|
departureAt: "2026-07-20T06:00:00.000Z",
|
||||||
|
notes: "Schleusen vor Abfahrt prüfen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
localStorage,
|
||||||
|
{ id: "voyage-test", savedAt: "2026-07-19T12:00:00.000Z" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saved.plan.start).toEqual({ lat: 53.344167, lon: 7.186111 });
|
||||||
|
expect(saved.plan.destination).toEqual({ lat: 51.6814536, lon: 7.8042615 });
|
||||||
|
expect(saved.route.departureTime).toBe("2026-07-20T06:00:00.000Z");
|
||||||
|
expect(saved.route.durationMinutes).toBe(120);
|
||||||
|
expect(saved.route.alternatives).toBeUndefined();
|
||||||
|
expect(listOfflineVoyages(localStorage)).toHaveLength(1);
|
||||||
|
const restored = loadOfflineVoyage("voyage-test", localStorage);
|
||||||
|
expect(restored).toEqual(saved);
|
||||||
|
expect(restored?.route).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
departureTime: "2026-07-20T06:00:00.000Z",
|
||||||
|
durationMinutes: 120
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(deleteOfflineVoyage("voyage-test", localStorage)).toBe(true);
|
||||||
|
expect(loadOfflineVoyage("voyage-test", localStorage)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores corrupted or untrusted persisted values", () => {
|
||||||
|
localStorage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, "not-json");
|
||||||
|
expect(listOfflineVoyages(localStorage)).toEqual([]);
|
||||||
|
|
||||||
|
localStorage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, JSON.stringify([{
|
||||||
|
schemaVersion: 1,
|
||||||
|
id: "bad",
|
||||||
|
name: "Bad",
|
||||||
|
savedAt: "no date",
|
||||||
|
plan: {},
|
||||||
|
route: { geometry: { type: "LineString", coordinates: [[999, 0], [0, 0]] } }
|
||||||
|
}]));
|
||||||
|
expect(listOfflineVoyages(localStorage)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe route coordinates before writing", () => {
|
||||||
|
expect(() => createOfflineVoyageRecord({
|
||||||
|
route: {
|
||||||
|
...routeFixture,
|
||||||
|
geometry: { type: "LineString", coordinates: [[7, 53], [Number.NaN, 52]] }
|
||||||
|
}
|
||||||
|
})).toThrow(/ungültige Koordinaten|Zahlenwert/);
|
||||||
|
expect(localStorage.getItem(OFFLINE_VOYAGES_STORAGE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeFixture: RouteResult = {
|
||||||
|
id: "emden-hamm",
|
||||||
|
name: "Emden – Hamm",
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]]
|
||||||
|
},
|
||||||
|
distanceNm: 153.69,
|
||||||
|
departureTime: "2026-07-20T06:00:00.000Z",
|
||||||
|
durationMinutes: 120,
|
||||||
|
eta: "2026-07-20T08:00:00.000Z",
|
||||||
|
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich" }],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: ["OSM"],
|
||||||
|
routingMode: "fairway",
|
||||||
|
alternatives: [{
|
||||||
|
id: "unused",
|
||||||
|
name: "Alternative",
|
||||||
|
geometry: { type: "LineString", coordinates: [[7, 53], [7.1, 52]] },
|
||||||
|
distanceNm: 160,
|
||||||
|
eta: null,
|
||||||
|
warnings: [],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: ["OSM"]
|
||||||
|
}]
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { distanceToRouteM, evaluateRouteDeviation } from "../src/lib/route-deviation";
|
||||||
|
|
||||||
|
describe("route deviation", () => {
|
||||||
|
it("calculates the cross-track distance to a route segment", () => {
|
||||||
|
const distance = distanceToRouteM(
|
||||||
|
{ lat: 0.001, lon: 0.005 },
|
||||||
|
[[0, 0], [0.01, 0]]
|
||||||
|
);
|
||||||
|
expect(distance).toBeGreaterThan(110);
|
||||||
|
expect(distance).toBeLessThan(112.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the endpoint when the nearest point lies beyond the segment", () => {
|
||||||
|
const distance = distanceToRouteM(
|
||||||
|
{ lat: 0, lon: 0.02 },
|
||||||
|
[[0, 0], [0.01, 0]]
|
||||||
|
);
|
||||||
|
expect(distance).toBeGreaterThan(1_110);
|
||||||
|
expect(distance).toBeLessThan(1_113);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a short segment crossing the antimeridian", () => {
|
||||||
|
const distance = distanceToRouteM(
|
||||||
|
{ lat: 0.001, lon: 180 },
|
||||||
|
[[179.9, 0], [-179.9, 0]]
|
||||||
|
);
|
||||||
|
expect(distance).toBeGreaterThan(110);
|
||||||
|
expect(distance).toBeLessThan(113);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accounts conservatively for GPS accuracy and suppresses unreliable alarms", () => {
|
||||||
|
const route = [[0, 0], [0.01, 0]] as const;
|
||||||
|
const near = evaluateRouteDeviation({ lat: 0.001, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 30 });
|
||||||
|
const away = evaluateRouteDeviation({ lat: 0.002, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 30 });
|
||||||
|
const inaccurate = evaluateRouteDeviation({ lat: 0.01, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 500 });
|
||||||
|
|
||||||
|
expect(near?.isOffRoute).toBe(false);
|
||||||
|
expect(near?.conservativeDistanceM).toBeLessThan(100);
|
||||||
|
expect(away?.isOffRoute).toBe(true);
|
||||||
|
expect(inaccurate?.reliable).toBe(false);
|
||||||
|
expect(inaccurate?.isOffRoute).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null instead of alarming for invalid or missing route data", () => {
|
||||||
|
expect(distanceToRouteM({ lat: Number.NaN, lon: 7 }, [[7, 53], [7.1, 53.1]])).toBeNull();
|
||||||
|
expect(distanceToRouteM({ lat: 53, lon: 7 }, [])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type {
|
||||||
|
RouteResult,
|
||||||
|
VoyageHarbour
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
import {
|
||||||
|
nextRouteEventsByKind,
|
||||||
|
upcomingRouteEvents
|
||||||
|
} from "../src/routeEvents";
|
||||||
|
import type { RouteBridgeAssessment } from "../src/routeWeatherReport";
|
||||||
|
import type { RouteLock } from "../src/voyageHarbours";
|
||||||
|
|
||||||
|
const route: RouteResult = {
|
||||||
|
id: "eastbound",
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[0, 0],
|
||||||
|
[1, 0]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
distanceNm: 60,
|
||||||
|
eta: null,
|
||||||
|
warnings: [],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: ["test"]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("upcomingRouteEvents", () => {
|
||||||
|
it("projects mixed facilities, filters passed and off-corridor entries, and orders them along the route", () => {
|
||||||
|
const events = upcomingRouteEvents({
|
||||||
|
route,
|
||||||
|
progressNm: 12,
|
||||||
|
harbours: [
|
||||||
|
harbour("passed-harbour", 0.1, 0),
|
||||||
|
harbour("next-harbour", 0.6, 0),
|
||||||
|
harbour("off-route-harbour", 0.5, 0.05)
|
||||||
|
],
|
||||||
|
locks: [
|
||||||
|
lock("next-lock", 0.4, 0, 999, 0),
|
||||||
|
lock("off-route-lock", 0.5, 0.01, 1, 0)
|
||||||
|
],
|
||||||
|
bridges: [
|
||||||
|
bridge("next-bridge", 0.3, 0.0005, 99),
|
||||||
|
bridge("off-route-bridge", 0.35, 0.01, 0.001)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events.map((event) => `${event.kind}:${event.id}`)).toEqual([
|
||||||
|
"bridge:next-bridge",
|
||||||
|
"lock:next-lock",
|
||||||
|
"harbour:next-harbour"
|
||||||
|
]);
|
||||||
|
expect(events.every((event) => event.routeDistanceNm >= 12)).toBe(true);
|
||||||
|
expect(events.every((event) => event.remainingNm >= 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reprojects locks and bridges instead of trusting their existing distance fields", () => {
|
||||||
|
const [projectedLock, projectedBridge] = upcomingRouteEvents({
|
||||||
|
route,
|
||||||
|
locks: [lock("lock", 0.25, 0, 999, 777)],
|
||||||
|
bridges: [bridge("bridge", 0.75, 0, 999)]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projectedLock?.kind).toBe("lock");
|
||||||
|
expect(projectedLock?.routeDistanceNm).toBeCloseTo(15, 0);
|
||||||
|
expect(projectedLock?.distanceFromRouteNm).toBeCloseTo(0, 6);
|
||||||
|
expect(projectedBridge?.kind).toBe("bridge");
|
||||||
|
expect(projectedBridge?.routeDistanceNm).toBeCloseTo(45, 0);
|
||||||
|
expect(projectedBridge?.distanceFromRouteNm).toBeCloseTo(0, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a corridor override without changing the defaults for other kinds", () => {
|
||||||
|
const events = upcomingRouteEvents({
|
||||||
|
route,
|
||||||
|
corridorsNm: { harbour: 4 },
|
||||||
|
harbours: [harbour("detour", 0.5, 0.05)],
|
||||||
|
locks: [lock("off-route-lock", 0.5, 0.01, 0, 0)]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events.map((event) => event.id)).toEqual(["detour"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates ETA and retains both speed and reference-time provenance", () => {
|
||||||
|
const [event] = upcomingRouteEvents({
|
||||||
|
route,
|
||||||
|
progressNm: 6,
|
||||||
|
locks: [lock("lock", 0.5, 0, 0, 0)],
|
||||||
|
etaBasis: {
|
||||||
|
speedKn: 6,
|
||||||
|
speedSource: "vessel-cruise-speed",
|
||||||
|
referenceTime: "2026-07-23T08:00:00.000Z",
|
||||||
|
referenceSource: "route-departure"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event).toBeDefined();
|
||||||
|
expect(event!.remainingNm).toBeCloseTo(24, 0);
|
||||||
|
expect(event!.eta).toMatchObject({
|
||||||
|
speedKn: 6,
|
||||||
|
speedSource: "vessel-cruise-speed",
|
||||||
|
referenceTime: "2026-07-23T08:00:00.000Z",
|
||||||
|
referenceSource: "route-departure"
|
||||||
|
});
|
||||||
|
expect(event!.eta!.minutesFromProgress).toBeCloseTo(
|
||||||
|
(event!.remainingNm / 6) * 60,
|
||||||
|
8
|
||||||
|
);
|
||||||
|
expect(Date.parse(event!.eta!.estimatedAt)).toBeCloseTo(
|
||||||
|
Date.parse("2026-07-23T08:00:00.000Z") +
|
||||||
|
event!.eta!.minutesFromProgress * 60_000,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits ETA for an invalid assumption rather than inventing a speed", () => {
|
||||||
|
const [event] = upcomingRouteEvents({
|
||||||
|
route,
|
||||||
|
harbours: [harbour("harbour", 0.5, 0)],
|
||||||
|
etaBasis: {
|
||||||
|
speedKn: 0,
|
||||||
|
speedSource: "gps-sog",
|
||||||
|
referenceTime: "not-a-time",
|
||||||
|
referenceSource: "current-time"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event?.eta).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the first upcoming event of each kind from an ordered list", () => {
|
||||||
|
const events = upcomingRouteEvents({
|
||||||
|
route,
|
||||||
|
harbours: [
|
||||||
|
harbour("harbour-2", 0.8, 0),
|
||||||
|
harbour("harbour-1", 0.2, 0)
|
||||||
|
],
|
||||||
|
locks: [lock("lock-1", 0.3, 0, 0, 0)]
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = nextRouteEventsByKind(events);
|
||||||
|
|
||||||
|
expect(next.harbour?.id).toBe("harbour-1");
|
||||||
|
expect(next.lock?.id).toBe("lock-1");
|
||||||
|
expect(next.bridge).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function harbour(
|
||||||
|
id: string,
|
||||||
|
lon: number,
|
||||||
|
lat: number
|
||||||
|
): VoyageHarbour {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
kind: "harbour",
|
||||||
|
coordinate: { lon, lat }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lock(
|
||||||
|
id: string,
|
||||||
|
lon: number,
|
||||||
|
lat: number,
|
||||||
|
routeDistanceNm: number,
|
||||||
|
distanceFromRouteNm: number
|
||||||
|
): RouteLock {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
coordinate: { lon, lat },
|
||||||
|
routeDistanceNm,
|
||||||
|
distanceFromRouteNm,
|
||||||
|
openingHours: null,
|
||||||
|
phone: null,
|
||||||
|
vhf: null,
|
||||||
|
website: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridge(
|
||||||
|
id: string,
|
||||||
|
lon: number,
|
||||||
|
lat: number,
|
||||||
|
distanceNm: number
|
||||||
|
): RouteBridgeAssessment {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
label: id,
|
||||||
|
coordinate: { lon, lat },
|
||||||
|
distanceNm,
|
||||||
|
clearanceM: null,
|
||||||
|
clearanceLabel: null,
|
||||||
|
requiredAirDraftM: null,
|
||||||
|
marginM: null,
|
||||||
|
status: "unknown",
|
||||||
|
source: "test"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RoutePlanner } from "../src/components/RoutePlanner";
|
||||||
|
import type { RouteWeatherReport } from "../src/routeWeatherReport";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe("RoutePlanner", () => {
|
||||||
|
it("submits vessel profile when start and destination exist", () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={{ lat: 54.18, lon: 12.08 }}
|
||||||
|
gpsPosition={{ lat: 54.18, lon: 12.08 }}
|
||||||
|
destination={{ lat: 54.2, lon: 12.1 }}
|
||||||
|
result={null}
|
||||||
|
routeOptions={[]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Route berechnen" }));
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
start: { lat: 54.18, lon: 12.08 },
|
||||||
|
destination: { lat: 54.2, lon: 12.1 },
|
||||||
|
waypoints: [],
|
||||||
|
departureTime: expect.any(String),
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits ordered waypoints together with the selected departure time", () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const waypoints = [
|
||||||
|
{ lat: 52.4, lon: 7.1 },
|
||||||
|
{ lat: 51.9, lon: 7.45 }
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={{ lat: 53.344167, lon: 7.186111 }}
|
||||||
|
gpsPosition={null}
|
||||||
|
destination={{ lat: 51.6814536, lon: 7.8042615 }}
|
||||||
|
waypoints={waypoints}
|
||||||
|
result={null}
|
||||||
|
routeOptions={[]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onPickWaypoint={vi.fn()}
|
||||||
|
onRemoveWaypoint={vi.fn()}
|
||||||
|
onMoveWaypoint={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const localDeparture = "2026-07-20T08:30";
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Abfahrt"), { target: { value: localDeparture } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Route berechnen" }));
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
start: { lat: 53.344167, lon: 7.186111 },
|
||||||
|
destination: { lat: 51.6814536, lon: 7.8042615 },
|
||||||
|
waypoints,
|
||||||
|
departureTime: new Date(localDeparture).toISOString(),
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers an explicit start picking action", () => {
|
||||||
|
const onPickStart = vi.fn();
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={null}
|
||||||
|
gpsPosition={{ lat: 54.18, lon: 12.08 }}
|
||||||
|
destination={null}
|
||||||
|
result={null}
|
||||||
|
routeOptions={[]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={onPickStart}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Start auf Karte setzen" }));
|
||||||
|
|
||||||
|
expect(onPickStart).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers an explicit destination picking action", () => {
|
||||||
|
const onPickDestination = vi.fn();
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={null}
|
||||||
|
gpsPosition={{ lat: 54.18, lon: 12.08 }}
|
||||||
|
destination={null}
|
||||||
|
result={null}
|
||||||
|
routeOptions={[]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={onPickDestination}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Ziel auf Karte setzen" }));
|
||||||
|
|
||||||
|
expect(onPickDestination).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer Borkum or Hamm as direct destination shortcuts", () => {
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={null}
|
||||||
|
gpsPosition={null}
|
||||||
|
destination={null}
|
||||||
|
result={null}
|
||||||
|
routeOptions={[]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Borkum/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /Hamm/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the skipper switch between returned route alternatives", () => {
|
||||||
|
const onSelectRoute = vi.fn();
|
||||||
|
const primary = routeOption("primary", "Hauptroute", 12);
|
||||||
|
const alternative = routeOption("alternative", "Alternative 1", 14.5);
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={{ lat: 52, lon: 7 }}
|
||||||
|
gpsPosition={null}
|
||||||
|
destination={{ lat: 52, lon: 7.1 }}
|
||||||
|
result={primary}
|
||||||
|
routeOptions={[primary, alternative]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={onSelectRoute}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Alternative 1/ }));
|
||||||
|
|
||||||
|
expect(onSelectRoute).toHaveBeenCalledWith("alternative");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can collapse the route planner", () => {
|
||||||
|
const onCollapse = vi.fn();
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={null}
|
||||||
|
gpsPosition={null}
|
||||||
|
destination={null}
|
||||||
|
result={null}
|
||||||
|
routeOptions={[]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={onCollapse}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Routenfenster ausblenden" }));
|
||||||
|
|
||||||
|
expect(onCollapse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("controls mobile sheet heights and leaves compact mode when the desktop layout starts", () => {
|
||||||
|
let desktopLayout = false;
|
||||||
|
let changeListener: (() => void) | null = null;
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
const mediaQuery = {
|
||||||
|
get matches() {
|
||||||
|
return desktopLayout;
|
||||||
|
},
|
||||||
|
media: "(min-width: 720px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn((_type: string, listener: () => void) => {
|
||||||
|
changeListener = listener;
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn()
|
||||||
|
} as unknown as MediaQueryList;
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(() => mediaQuery)
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, unmount } = render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={null}
|
||||||
|
gpsPosition={null}
|
||||||
|
destination={null}
|
||||||
|
result={null}
|
||||||
|
routeOptions={[]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const panel = container.querySelector("aside.route-panel");
|
||||||
|
const body = container.querySelector(".route-panel-body");
|
||||||
|
|
||||||
|
expect(panel).toHaveAttribute("data-sheet-state", "half");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Routenfenster auf volle Höhe vergrößern" }));
|
||||||
|
expect(panel).toHaveAttribute("data-sheet-state", "full");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Routenfenster auf kompakte Höhe verkleinern" }));
|
||||||
|
expect(panel).toHaveAttribute("data-sheet-state", "compact");
|
||||||
|
expect(body).toHaveAttribute("hidden");
|
||||||
|
|
||||||
|
desktopLayout = true;
|
||||||
|
act(() => changeListener?.());
|
||||||
|
expect(panel).toHaveAttribute("data-sheet-state", "half");
|
||||||
|
expect(body).not.toHaveAttribute("hidden");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalMatchMedia
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the course assistant only after a route was planned", () => {
|
||||||
|
const onStartGuidance = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={{ lat: 52, lon: 7 }}
|
||||||
|
gpsPosition={null}
|
||||||
|
destination={{ lat: 52, lon: 7.1 }}
|
||||||
|
result={null}
|
||||||
|
routeOptions={[]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onStartGuidance={onStartGuidance}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Navigation starten" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={{ lat: 52, lon: 7 }}
|
||||||
|
gpsPosition={null}
|
||||||
|
destination={{ lat: 52, lon: 7.1 }}
|
||||||
|
result={routeOption("primary", "Hauptroute", 4)}
|
||||||
|
routeOptions={[routeOption("primary", "Hauptroute", 4)]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onStartGuidance={onStartGuidance}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Navigation starten" }));
|
||||||
|
expect(onStartGuidance).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the compact result view and keeps critical warnings above secondary route details", () => {
|
||||||
|
const result = {
|
||||||
|
...routeOption("primary", "Hauptroute", 4),
|
||||||
|
warnings: [
|
||||||
|
{ code: "weather", severity: "caution" as const, message: "Wind aufmerksam beobachten." },
|
||||||
|
{ code: "bridge", severity: "critical" as const, message: "Brücke ist zu niedrig." }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={{ lat: 52, lon: 7 }}
|
||||||
|
gpsPosition={null}
|
||||||
|
destination={{ lat: 52, lon: 7.1 }}
|
||||||
|
result={result}
|
||||||
|
routeOptions={[result]}
|
||||||
|
weatherReport={null}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Routenergebnis")).toBeVisible();
|
||||||
|
expect(screen.getByText("Brücke ist zu niedrig.")).toBeVisible();
|
||||||
|
expect(screen.getByText("Wind aufmerksam beobachten.")).toBeVisible();
|
||||||
|
expect(screen.queryByRole("button", { name: "Start auf Karte setzen" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Plan ändern" }));
|
||||||
|
expect(screen.getByText("Route planen")).toBeVisible();
|
||||||
|
expect(screen.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
|
||||||
|
expect(screen.getByRole("button", { name: "Zur Route" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the route weather report after a route was calculated", () => {
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={{ lat: 54.18, lon: 12.08 }}
|
||||||
|
gpsPosition={{ lat: 54.18, lon: 12.08 }}
|
||||||
|
destination={{ lat: 54.2, lon: 12.1 }}
|
||||||
|
result={{
|
||||||
|
geometry: { type: "LineString", coordinates: [[12.08, 54.18], [12.1, 54.2]] },
|
||||||
|
distanceNm: 4.2,
|
||||||
|
eta: null,
|
||||||
|
warnings: [],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: [],
|
||||||
|
routingMode: "fairway"
|
||||||
|
}}
|
||||||
|
routeOptions={[]}
|
||||||
|
weatherReport={weatherReportFixture}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("region", { name: "Fahrtbericht" })).toBeVisible();
|
||||||
|
expect(screen.getByText(/Aufmerksam fahren/)).toBeVisible();
|
||||||
|
expect(screen.getByText("18 kn 270°")).toBeVisible();
|
||||||
|
expect(screen.getByText("1.2 m 290°")).toBeVisible();
|
||||||
|
expect(screen.getByText(/Nicht passierbar/)).toBeVisible();
|
||||||
|
expect(screen.getByText("Niedrige Brücke")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps lock-delay planning and the current-adjusted ETA in the embedded route tool", () => {
|
||||||
|
const adjustedReport: RouteWeatherReport = {
|
||||||
|
...weatherReportFixture,
|
||||||
|
adjustedEta: "2026-07-13T14:45:00.000Z",
|
||||||
|
currentAdjustmentMinutes: 45,
|
||||||
|
averageAlongRouteCurrentKn: -0.6
|
||||||
|
};
|
||||||
|
const result = routeOption("primary", "Hauptroute", 18);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePlanner
|
||||||
|
startPoint={{ lat: 53.3, lon: 7.2 }}
|
||||||
|
gpsPosition={null}
|
||||||
|
destination={{ lat: 52.9, lon: 7.4 }}
|
||||||
|
result={result}
|
||||||
|
routeOptions={[result]}
|
||||||
|
weatherReport={adjustedReport}
|
||||||
|
weatherLoading={false}
|
||||||
|
weatherError={null}
|
||||||
|
routeLocks={[
|
||||||
|
{
|
||||||
|
id: "lock-1",
|
||||||
|
name: "Testschleuse",
|
||||||
|
coordinate: { lat: 53.1, lon: 7.3 },
|
||||||
|
routeDistanceNm: 8,
|
||||||
|
distanceFromRouteNm: 0.02,
|
||||||
|
openingHours: "06:00-22:00",
|
||||||
|
phone: "+49 123 456",
|
||||||
|
vhf: "20",
|
||||||
|
website: null
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
pickMode={null}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onPickStart={vi.fn()}
|
||||||
|
onPickDestination={vi.fn()}
|
||||||
|
onClearStart={vi.fn()}
|
||||||
|
onClearDestination={vi.fn()}
|
||||||
|
onUseGpsAsStart={vi.fn()}
|
||||||
|
onSelectRoute={vi.fn()}
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
operationalPanelsVisible={false}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Strömungs-ETA/)).toBeVisible();
|
||||||
|
fireEvent.click(screen.getByText("Schleusenplanung · 1 auf der Route"));
|
||||||
|
expect(screen.getByRole("spinbutton", { name: /Pauschale/ })).toHaveValue(20);
|
||||||
|
expect(screen.getByText(/Plan-ETA inkl. Schleusenpuffer/)).toBeVisible();
|
||||||
|
expect(screen.queryByRole("region", { name: "Fahrtbericht" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const weatherReportFixture: RouteWeatherReport = {
|
||||||
|
samples: [
|
||||||
|
{
|
||||||
|
label: "Start",
|
||||||
|
coordinate: { lat: 54.18, lon: 12.08 },
|
||||||
|
forecast: {
|
||||||
|
waveHeightM: 0.8,
|
||||||
|
waveDirectionDeg: 280,
|
||||||
|
wavePeriodS: 4,
|
||||||
|
windSpeed: 12,
|
||||||
|
windDirectionDeg: 260,
|
||||||
|
weatherCode: 2,
|
||||||
|
temperatureC: 18,
|
||||||
|
source: "Test",
|
||||||
|
updatedAt: "2026-07-13T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Ziel",
|
||||||
|
coordinate: { lat: 54.2, lon: 12.1 },
|
||||||
|
forecast: {
|
||||||
|
waveHeightM: 1.2,
|
||||||
|
waveDirectionDeg: 290,
|
||||||
|
wavePeriodS: 5,
|
||||||
|
windSpeed: 18,
|
||||||
|
windDirectionDeg: 270,
|
||||||
|
weatherCode: 3,
|
||||||
|
temperatureC: 18,
|
||||||
|
source: "Test",
|
||||||
|
updatedAt: "2026-07-13T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
maxWaveHeightM: 1.2,
|
||||||
|
maxWindSpeedKn: 18,
|
||||||
|
maxWavePeriodS: 5,
|
||||||
|
strongestWindDirectionDeg: 270,
|
||||||
|
highestWaveDirectionDeg: 290,
|
||||||
|
severity: "caution",
|
||||||
|
summary: "Aufmerksam fahren: bis 18 kn Wind, 1.2 m Welle.",
|
||||||
|
source: "Test",
|
||||||
|
updatedAt: "2026-07-13T12:00:00.000Z",
|
||||||
|
unavailableSamples: 0,
|
||||||
|
departureTime: "2026-07-13T12:00:00.000Z",
|
||||||
|
adjustedEta: null,
|
||||||
|
currentAdjustmentMinutes: null,
|
||||||
|
averageAlongRouteCurrentKn: null,
|
||||||
|
bridgeReport: {
|
||||||
|
bridges: [
|
||||||
|
{
|
||||||
|
id: "bridge-low",
|
||||||
|
name: "Niedrige Brücke",
|
||||||
|
label: "Niedrige Brücke H 2.2 m",
|
||||||
|
coordinate: { lat: 54.19, lon: 12.09 },
|
||||||
|
distanceNm: 0,
|
||||||
|
clearanceM: 2.2,
|
||||||
|
clearanceLabel: "H 2.2 m",
|
||||||
|
requiredAirDraftM: 2.5,
|
||||||
|
marginM: -0.3,
|
||||||
|
status: "too_low",
|
||||||
|
source: "OSM/Geofabrik"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
requiredAirDraftM: 2.5,
|
||||||
|
checkedCount: 1,
|
||||||
|
unknownCount: 0,
|
||||||
|
tooLowCount: 1,
|
||||||
|
tightCount: 0,
|
||||||
|
minClearanceM: 2.2,
|
||||||
|
severity: "critical",
|
||||||
|
summary: "Nicht passierbar: 1 Brücke(n) niedriger als 2.5 m Bootshöhe.",
|
||||||
|
source: "OSM/Geofabrik",
|
||||||
|
updatedAt: "2026-07-13T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function routeOption(id: string, name: string, distanceNm: number) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
geometry: { type: "LineString" as const, coordinates: [[7, 52], [7.1, 52]] as [number, number][] },
|
||||||
|
distanceNm,
|
||||||
|
eta: "2026-07-13T14:00:00.000Z",
|
||||||
|
warnings: [],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: [],
|
||||||
|
routingMode: "fairway" as const
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { MarineForecast, RouteResult } from "@watermaps/shared";
|
||||||
|
import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport";
|
||||||
|
|
||||||
|
describe("route weather report", () => {
|
||||||
|
it("samples start, middle and destination forecasts", async () => {
|
||||||
|
const requested: Array<{ lat: number; lon: number }> = [];
|
||||||
|
const report = await createRouteWeatherReport(routeFixture, async (coordinate) => {
|
||||||
|
requested.push(coordinate);
|
||||||
|
return forecast({
|
||||||
|
windSpeed: coordinate.lon > 7.1 ? 18 : 10,
|
||||||
|
waveHeightM: coordinate.lon > 7.1 ? 1.2 : 0.4
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requested).toHaveLength(3);
|
||||||
|
expect(report.maxWindSpeedKn).toBe(18);
|
||||||
|
expect(report.maxWaveHeightM).toBe(1.2);
|
||||||
|
expect(report.severity).toBe("caution");
|
||||||
|
expect(report.summary).toContain("Aufmerksam fahren");
|
||||||
|
expect(report.bridgeReport).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects forecasts along the route timeline and adjusts ETA for along-route current", async () => {
|
||||||
|
const requestedTimes: Array<string | undefined> = [];
|
||||||
|
const departureTime = "2026-07-20T06:00:00.000Z";
|
||||||
|
const timedRoute: RouteResult = {
|
||||||
|
...routeFixture,
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [[7, 52], [7.2, 52], [7.4, 52]]
|
||||||
|
},
|
||||||
|
distanceNm: 20,
|
||||||
|
departureTime,
|
||||||
|
durationMinutes: 240,
|
||||||
|
eta: "2026-07-20T10:00:00.000Z"
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = await createRouteWeatherReport(
|
||||||
|
timedRoute,
|
||||||
|
{ draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 5 },
|
||||||
|
async (_coordinate, at) => {
|
||||||
|
requestedTimes.push(at);
|
||||||
|
return forecast({
|
||||||
|
oceanCurrentSpeedKn: 1,
|
||||||
|
oceanCurrentDirectionDeg: 90,
|
||||||
|
forecastTime: at
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requestedTimes).toEqual([
|
||||||
|
"2026-07-20T06:00:00.000Z",
|
||||||
|
"2026-07-20T08:00:00.000Z",
|
||||||
|
"2026-07-20T10:00:00.000Z"
|
||||||
|
]);
|
||||||
|
expect(report.samples.map((sample) => sample.plannedTime)).toEqual(requestedTimes);
|
||||||
|
expect(report.samples.map((sample) => sample.currentAlongRouteKn)).toEqual([1, 1, 1]);
|
||||||
|
expect(report.departureTime).toBe(departureTime);
|
||||||
|
expect(report.averageAlongRouteCurrentKn).toBe(1);
|
||||||
|
expect(report.currentAdjustmentMinutes).toBe(-40);
|
||||||
|
expect(report.adjustedEta).toBe("2026-07-20T09:20:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a bridge report and marks a route as blocked by a low bridge", async () => {
|
||||||
|
const report = await createRouteWeatherReport(
|
||||||
|
routeFixture,
|
||||||
|
{ draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 3 },
|
||||||
|
async () => forecast({}),
|
||||||
|
async (params) => {
|
||||||
|
expect(params.layers).toEqual(["bridges"]);
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "bridge-low",
|
||||||
|
properties: {
|
||||||
|
layer: "bridges",
|
||||||
|
name: "Niedrige Brücke",
|
||||||
|
clearance_m: 2.7,
|
||||||
|
clearance_label: "H 2.7 m",
|
||||||
|
label: "Niedrige Brücke H 2.7 m",
|
||||||
|
source: "OSM/Geofabrik"
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[6.99, 53.5],
|
||||||
|
[7.21, 53.5]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "bridge-high",
|
||||||
|
properties: {
|
||||||
|
layer: "bridges",
|
||||||
|
name: "Hohe Brücke",
|
||||||
|
clearance_m: 4.2,
|
||||||
|
clearance_label: "H 4.2 m",
|
||||||
|
label: "Hohe Brücke H 4.2 m",
|
||||||
|
source: "OSM/Geofabrik"
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[7.18, 53.68],
|
||||||
|
[7.22, 53.68]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.bridgeReport?.severity).toBe("critical");
|
||||||
|
expect(report.bridgeReport?.tooLowCount).toBe(1);
|
||||||
|
expect(report.bridgeReport?.checkedCount).toBe(2);
|
||||||
|
expect(report.bridgeReport?.summary).toContain("Nicht passierbar");
|
||||||
|
expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks critical weather when wind or wave thresholds are exceeded", () => {
|
||||||
|
const report = summarizeRouteWeather([
|
||||||
|
{
|
||||||
|
label: "Mitte",
|
||||||
|
coordinate: { lat: 53.5, lon: 7.1 },
|
||||||
|
forecast: forecast({ windSpeed: 28, waveHeightM: 1.1 })
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(report.severity).toBe("critical");
|
||||||
|
expect(report.summary).toContain("Kritische Bedingungen");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeFixture: RouteResult = {
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [
|
||||||
|
[7, 53.3],
|
||||||
|
[7.1, 53.5],
|
||||||
|
[7.2, 53.7]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
distanceNm: 25,
|
||||||
|
eta: null,
|
||||||
|
warnings: [],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: [],
|
||||||
|
routingMode: "fairway"
|
||||||
|
};
|
||||||
|
|
||||||
|
function forecast(overrides: Partial<MarineForecast>): MarineForecast {
|
||||||
|
return {
|
||||||
|
waveHeightM: 0.4,
|
||||||
|
waveDirectionDeg: 280,
|
||||||
|
wavePeriodS: 4,
|
||||||
|
windSpeed: 10,
|
||||||
|
windDirectionDeg: 260,
|
||||||
|
weatherCode: 2,
|
||||||
|
temperatureC: 18,
|
||||||
|
source: "Test",
|
||||||
|
updatedAt: "2026-07-13T12:00:00.000Z",
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { MarineForecast, RouteGuidanceResult } from "@watermaps/shared";
|
||||||
|
import { StatusBar } from "../src/components/StatusBar";
|
||||||
|
import type { GpsState } from "../src/hooks/useGeolocation";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const idleGps: GpsState = {
|
||||||
|
status: "idle",
|
||||||
|
position: null,
|
||||||
|
accuracyM: null,
|
||||||
|
speedKn: null,
|
||||||
|
courseDeg: null,
|
||||||
|
timestampMs: null,
|
||||||
|
message: null
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("StatusBar", () => {
|
||||||
|
it("shows only three planning values without claiming that a route is safe", () => {
|
||||||
|
const forecast = {
|
||||||
|
windSpeed: 12,
|
||||||
|
waveHeightM: 0.8
|
||||||
|
} as MarineForecast;
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<StatusBar
|
||||||
|
gps={idleGps}
|
||||||
|
forecast={forecast}
|
||||||
|
tide={null}
|
||||||
|
routeWarningCount={0}
|
||||||
|
mode="planning"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".status-item")).toHaveLength(3);
|
||||||
|
expect(screen.getByText("12 kn · 0.8 m")).toBeVisible();
|
||||||
|
expect(screen.queryByText("Route OK")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes course, cross-track error and open warnings during guidance", () => {
|
||||||
|
const guidance = {
|
||||||
|
desiredCourseDeg: 87,
|
||||||
|
distanceToRouteM: 24,
|
||||||
|
crossTrackSide: "starboard"
|
||||||
|
} as RouteGuidanceResult;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<StatusBar
|
||||||
|
gps={{ ...idleGps, status: "tracking", speedKn: 6.2 }}
|
||||||
|
forecast={null}
|
||||||
|
tide={null}
|
||||||
|
routeWarningCount={2}
|
||||||
|
mode="guidance"
|
||||||
|
guidance={guidance}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("087°T")).toBeVisible();
|
||||||
|
expect(screen.getByText("24 m Stb")).toBeVisible();
|
||||||
|
expect(screen.getByText("2 offen")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows anchor drift, GPS and the expected tide rise in anchor mode", () => {
|
||||||
|
render(
|
||||||
|
<StatusBar
|
||||||
|
gps={{ ...idleGps, status: "tracking", position: { lat: 53.2, lon: 7.1 }, accuracyM: 5 }}
|
||||||
|
forecast={null}
|
||||||
|
tide={null}
|
||||||
|
routeWarningCount={0}
|
||||||
|
mode="anchor"
|
||||||
|
anchor={{
|
||||||
|
distanceFromAnchorM: 11.5,
|
||||||
|
alarmRadiusM: 40,
|
||||||
|
alarm: false,
|
||||||
|
maximumTideRiseM: 1.3
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("12 / 40 m")).toBeVisible();
|
||||||
|
expect(screen.getByText("±5 m")).toBeVisible();
|
||||||
|
expect(screen.getByText("+1.3 m")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { UpcomingRouteEvent } from "../src/routeEvents";
|
||||||
|
import { UpcomingEventsPanel } from "../src/components/UpcomingEventsPanel";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("UpcomingEventsPanel", () => {
|
||||||
|
it("shows the nearest event first, preserves chronological list order and filters by kind", () => {
|
||||||
|
render(<UpcomingEventsPanel events={events} hasRoute />);
|
||||||
|
|
||||||
|
const hero = screen.getByText("Nächstes Ereignis").closest("article");
|
||||||
|
expect(hero).not.toBeNull();
|
||||||
|
expect(within(hero!).getByText("Schleuse Nah")).toBeVisible();
|
||||||
|
expect(within(hero!).getByText("3,0 sm")).toBeVisible();
|
||||||
|
expect(within(hero!).getByText(/ETA/)).toBeVisible();
|
||||||
|
|
||||||
|
const list = screen.getByRole("list");
|
||||||
|
expect(
|
||||||
|
within(list)
|
||||||
|
.getAllByRole("button")
|
||||||
|
.map((button) => button.textContent)
|
||||||
|
).toEqual([
|
||||||
|
expect.stringContaining("Schleuse Nah"),
|
||||||
|
expect.stringContaining("Brücke Mitte"),
|
||||||
|
expect.stringContaining("Hafen Weit")
|
||||||
|
]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
|
||||||
|
expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("Brücke Mitte")).toHaveLength(2);
|
||||||
|
expect(screen.queryByText("Schleuse Nah")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Hafen Weit")).not.toBeInTheDocument();
|
||||||
|
expect(within(screen.getByRole("list")).getAllByRole("button")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a lock drilldown with direct contact, VHF, hours and map actions", () => {
|
||||||
|
const onShowOnMap = vi.fn();
|
||||||
|
render(
|
||||||
|
<UpcomingEventsPanel
|
||||||
|
events={events}
|
||||||
|
hasRoute
|
||||||
|
onShowOnMap={onShowOnMap}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const hero = screen.getByText("Nächstes Ereignis").closest("article");
|
||||||
|
fireEvent.click(within(hero!).getByRole("button", { name: /Schleuse Nah/ }));
|
||||||
|
|
||||||
|
const detail = screen.getByRole("region", { name: "Schleuse Nah" });
|
||||||
|
expect(within(detail).getByText("Mo–Fr 08:00–18:00")).toBeVisible();
|
||||||
|
expect(within(detail).getByText("Kanal 12")).toBeVisible();
|
||||||
|
expect(within(detail).getByRole("link", { name: "Schleuse Nah anrufen" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"tel:+4949123456"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(detail).getByRole("link", { name: "Website von Schleuse Nah öffnen" })
|
||||||
|
).toHaveAttribute("href", "https://lock.example/");
|
||||||
|
|
||||||
|
fireEvent.click(within(detail).getByRole("button", { name: "Auf Karte zeigen" }));
|
||||||
|
expect(onShowOnMap).toHaveBeenCalledWith(events[0]);
|
||||||
|
|
||||||
|
fireEvent.click(within(detail).getByRole("button", { name: "Zurück zur Ereignisliste" }));
|
||||||
|
expect(screen.getByText("Nächstes Ereignis")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows bridge clearance, required height and reserve in its drilldown", () => {
|
||||||
|
render(<UpcomingEventsPanel events={events} hasRoute />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
|
||||||
|
const hero = screen.getByText("Nächstes Ereignis").closest("article");
|
||||||
|
fireEvent.click(within(hero!).getByRole("button", { name: /Brücke Mitte/ }));
|
||||||
|
|
||||||
|
const detail = screen.getByRole("region", { name: "Brücke Mitte" });
|
||||||
|
expect(within(detail).getByText("H 4,2 m")).toBeVisible();
|
||||||
|
expect(within(detail).getByText("3.8 m")).toBeVisible();
|
||||||
|
const reserve = within(detail).getByText("Reserve").closest("div");
|
||||||
|
expect(reserve).toHaveTextContent("0.4 m Reserve");
|
||||||
|
expect(within(detail).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
props: { hasRoute: false, events: [] as UpcomingRouteEvent[] },
|
||||||
|
text: "Noch keine Route",
|
||||||
|
role: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
props: { hasRoute: true, events: [] as UpcomingRouteEvent[], loading: true },
|
||||||
|
text: "Ereignisse werden geladen",
|
||||||
|
role: "status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
props: {
|
||||||
|
hasRoute: true,
|
||||||
|
events: [] as UpcomingRouteEvent[],
|
||||||
|
error: "Datenquelle antwortet nicht"
|
||||||
|
},
|
||||||
|
text: "Ereignisse nicht erreichbar",
|
||||||
|
role: "alert"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
props: { hasRoute: true, events: [] as UpcomingRouteEvent[] },
|
||||||
|
text: "Keine bevorstehenden Ereignisse",
|
||||||
|
role: undefined
|
||||||
|
}
|
||||||
|
])("renders the state '$text'", ({ props, text, role }) => {
|
||||||
|
render(<UpcomingEventsPanel {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(text)).toBeVisible();
|
||||||
|
if (role) {
|
||||||
|
expect(screen.getByRole(role)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const events: UpcomingRouteEvent[] = [
|
||||||
|
{
|
||||||
|
kind: "lock",
|
||||||
|
id: "lock-near",
|
||||||
|
name: "Schleuse Nah",
|
||||||
|
coordinate: { lat: 53.1, lon: 7.1 },
|
||||||
|
routeDistanceNm: 8,
|
||||||
|
distanceFromRouteNm: 0.02,
|
||||||
|
remainingNm: 3,
|
||||||
|
eta: eta("2026-07-23T12:30:00.000Z", 30),
|
||||||
|
feature: {
|
||||||
|
id: "lock-near",
|
||||||
|
name: "Schleuse Nah",
|
||||||
|
coordinate: { lat: 53.1, lon: 7.1 },
|
||||||
|
routeDistanceNm: 8,
|
||||||
|
distanceFromRouteNm: 0.02,
|
||||||
|
openingHours: "Mo–Fr 08:00–18:00",
|
||||||
|
phone: "+49 49 123456",
|
||||||
|
vhf: "Kanal 12",
|
||||||
|
website: "lock.example"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "harbour",
|
||||||
|
id: "harbour-far",
|
||||||
|
name: "Hafen Weit",
|
||||||
|
coordinate: { lat: 53.3, lon: 7.3 },
|
||||||
|
routeDistanceNm: 17,
|
||||||
|
distanceFromRouteNm: 0.4,
|
||||||
|
remainingNm: 12,
|
||||||
|
eta: eta("2026-07-23T14:00:00.000Z", 120),
|
||||||
|
feature: {
|
||||||
|
id: "harbour-far",
|
||||||
|
name: "Hafen Weit",
|
||||||
|
coordinate: { lat: 53.3, lon: 7.3 },
|
||||||
|
kind: "marina",
|
||||||
|
amenities: { water: "available", electricity: true },
|
||||||
|
phone: null,
|
||||||
|
website: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "bridge",
|
||||||
|
id: "bridge-middle",
|
||||||
|
name: "Brücke Mitte",
|
||||||
|
coordinate: { lat: 53.2, lon: 7.2 },
|
||||||
|
routeDistanceNm: 11,
|
||||||
|
distanceFromRouteNm: 0.01,
|
||||||
|
remainingNm: 6,
|
||||||
|
eta: eta("2026-07-23T13:00:00.000Z", 60),
|
||||||
|
feature: {
|
||||||
|
id: "bridge-middle",
|
||||||
|
name: "Brücke Mitte",
|
||||||
|
label: "Brücke Mitte H 4,2 m",
|
||||||
|
coordinate: { lat: 53.2, lon: 7.2 },
|
||||||
|
distanceNm: 0.01,
|
||||||
|
clearanceM: 4.2,
|
||||||
|
clearanceLabel: "H 4,2 m",
|
||||||
|
requiredAirDraftM: 3.8,
|
||||||
|
marginM: 0.4,
|
||||||
|
status: "tight",
|
||||||
|
source: "Test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function eta(estimatedAt: string, minutesFromProgress: number) {
|
||||||
|
return {
|
||||||
|
estimatedAt,
|
||||||
|
minutesFromProgress,
|
||||||
|
speedKn: 6,
|
||||||
|
speedSource: "vessel-cruise-speed" as const,
|
||||||
|
referenceTime: "2026-07-23T12:00:00.000Z",
|
||||||
|
referenceSource: "current-time" as const
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { FeatureCollection, Geometry } from "geojson";
|
||||||
|
import {
|
||||||
|
routeFeatureBounds,
|
||||||
|
routeLocksFromFeatures,
|
||||||
|
voyageHarboursFromFeatures
|
||||||
|
} from "../src/voyageHarbours";
|
||||||
|
|
||||||
|
describe("voyage harbour feature adapter", () => {
|
||||||
|
it("turns normalized point features into amenity-aware harbour candidates", () => {
|
||||||
|
const collection: FeatureCollection<Geometry> = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "hamm-marina",
|
||||||
|
geometry: { type: "Point", coordinates: [7.8, 51.68] },
|
||||||
|
properties: {
|
||||||
|
layer: "harbours",
|
||||||
|
name: "Marina Hamm",
|
||||||
|
leisure: "marina",
|
||||||
|
phone: "+49 2381 123",
|
||||||
|
website: "hamm.example",
|
||||||
|
email: "hafen@hamm.example",
|
||||||
|
vhf: "12",
|
||||||
|
opening_hours: "täglich 08:00-20:00",
|
||||||
|
operator: "Hafen Hamm",
|
||||||
|
"addr:street": "Uferweg",
|
||||||
|
"addr:housenumber": "4",
|
||||||
|
"addr:postcode": "59000",
|
||||||
|
"addr:city": "Hamm",
|
||||||
|
power_supply: "yes",
|
||||||
|
drinking_water: "yes",
|
||||||
|
guest_berths: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "LineString", coordinates: [[7, 52], [8, 52]] },
|
||||||
|
properties: { layer: "harbours", name: "Keine Punktgeometrie" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "Point", coordinates: [7.7, 51.7] },
|
||||||
|
properties: { layer: "locks", name: "Keine Marina" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(voyageHarboursFromFeatures(collection)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "hamm-marina",
|
||||||
|
name: "Marina Hamm",
|
||||||
|
kind: "marina",
|
||||||
|
coordinate: { lat: 51.68, lon: 7.8 },
|
||||||
|
email: "hafen@hamm.example",
|
||||||
|
vhf: "12",
|
||||||
|
openingHours: "täglich 08:00-20:00",
|
||||||
|
operator: "Hafen Hamm",
|
||||||
|
address: "Uferweg 4, 59000 Hamm",
|
||||||
|
amenities: expect.objectContaining({
|
||||||
|
electricity: "available",
|
||||||
|
water: "available",
|
||||||
|
overnight: "available"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates a nautical-mile padded feature request box", () => {
|
||||||
|
const bounds = routeFeatureBounds(
|
||||||
|
{
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [[7, 52], [8, 53]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
6
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bounds[0]).toBeLessThan(6.84);
|
||||||
|
expect(bounds[1]).toBeCloseTo(51.9, 5);
|
||||||
|
expect(bounds[2]).toBeGreaterThan(8.16);
|
||||||
|
expect(bounds[3]).toBeCloseTo(53.1, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters and orders route locks while preserving opening hours and phone", () => {
|
||||||
|
const collection: FeatureCollection<Geometry> = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "lock-late",
|
||||||
|
geometry: { type: "Point", coordinates: [7.8, 52] },
|
||||||
|
properties: {
|
||||||
|
layer: "locks",
|
||||||
|
name: "Schleuse Ost",
|
||||||
|
openingHours: "Mo-Su 06:00-22:00",
|
||||||
|
phone: "+49 2381 200"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "lock-off-route",
|
||||||
|
geometry: { type: "Point", coordinates: [7.5, 52.02] },
|
||||||
|
properties: { layer: "locks", name: "Entfernte Schleuse" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "lock-early",
|
||||||
|
geometry: { type: "Point", coordinates: [7.2, 52] },
|
||||||
|
properties: {
|
||||||
|
layer: "locks",
|
||||||
|
name: "Schleuse West",
|
||||||
|
opening_hours: "nach Anmeldung",
|
||||||
|
phone: "+49 2381 100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
id: "harbour-on-route",
|
||||||
|
geometry: { type: "Point", coordinates: [7.4, 52] },
|
||||||
|
properties: { layer: "harbours", name: "Kein Schleusenpunkt" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const locks = routeLocksFromFeatures(collection, {
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [[7, 52], [8, 52]]
|
||||||
|
},
|
||||||
|
distanceNm: 41
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(locks.map((lock) => lock.id)).toEqual(["lock-early", "lock-late"]);
|
||||||
|
expect(locks[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Schleuse West",
|
||||||
|
openingHours: "nach Anmeldung",
|
||||||
|
phone: "+49 2381 100",
|
||||||
|
distanceFromRouteNm: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(locks[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Schleuse Ost",
|
||||||
|
openingHours: "Mo-Su 06:00-22:00",
|
||||||
|
phone: "+49 2381 200",
|
||||||
|
distanceFromRouteNm: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(locks[0]!.routeDistanceNm).toBeLessThan(locks[1]!.routeDistanceNm);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { RouteResult } from "@watermaps/shared";
|
||||||
|
import { VoyageNavigationTools } from "../src/components/VoyageNavigationTools";
|
||||||
|
|
||||||
|
const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, "geolocation");
|
||||||
|
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
restoreNavigatorProperty("geolocation", originalGeolocation);
|
||||||
|
restoreNavigatorProperty("vibrate", originalVibrate);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VoyageNavigationTools", () => {
|
||||||
|
it("does not request geolocation before the skipper starts the alarm", () => {
|
||||||
|
const watchPosition = vi.fn(() => 17);
|
||||||
|
installGeolocation(watchPosition, vi.fn());
|
||||||
|
|
||||||
|
render(<VoyageNavigationTools route={routeFixture} />);
|
||||||
|
|
||||||
|
expect(watchPosition).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(/GPS wird erst nach/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open a second GPS watch while the course assistant is active", () => {
|
||||||
|
const watchPosition = vi.fn(() => 17);
|
||||||
|
installGeolocation(watchPosition, vi.fn());
|
||||||
|
|
||||||
|
render(<VoyageNavigationTools route={routeFixture} courseAssistantActive />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Kursalarm ist im Kursassistenten enthalten" })).toBeDisabled();
|
||||||
|
expect(screen.getByText(/Querabweichung.*Kursassistenten/)).toBeVisible();
|
||||||
|
expect(watchPosition).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts explicitly and warns when the vessel leaves the route", () => {
|
||||||
|
let success: PositionCallback | undefined;
|
||||||
|
const watchPosition = vi.fn((next: PositionCallback) => {
|
||||||
|
success = next;
|
||||||
|
return 42;
|
||||||
|
});
|
||||||
|
const clearWatch = vi.fn();
|
||||||
|
const vibrate = vi.fn();
|
||||||
|
installGeolocation(watchPosition, clearWatch);
|
||||||
|
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
|
||||||
|
|
||||||
|
render(<VoyageNavigationTools route={routeFixture} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm starten" }));
|
||||||
|
|
||||||
|
expect(watchPosition).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByRole("button", { name: "Abweichungsalarm stoppen" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => success?.(positionAt(53.01, 7.005, 5)));
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent(/von der Route entfernt/);
|
||||||
|
expect(vibrate).toHaveBeenCalledWith([200, 100, 200]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm stoppen" }));
|
||||||
|
expect(clearWatch).toHaveBeenCalledWith(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves and restores the route and plan on this device", () => {
|
||||||
|
const onLoad = vi.fn();
|
||||||
|
render(
|
||||||
|
<VoyageNavigationTools
|
||||||
|
route={routeFixture}
|
||||||
|
plan={{
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 },
|
||||||
|
departureAt: "2026-07-20T06:00:00.000Z"
|
||||||
|
}}
|
||||||
|
onLoadOfflineVoyage={onLoad}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Route offline speichern" }));
|
||||||
|
expect(screen.getByText(/ist auf diesem Gerät offline verfügbar/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Offline-Route laden" }));
|
||||||
|
|
||||||
|
expect(onLoad).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLoad.mock.calls[0]?.[0].route.geometry).toEqual(routeFixture.geometry);
|
||||||
|
expect(onLoad.mock.calls[0]?.[0].plan.vesselProfile.cruiseSpeedKn).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function installGeolocation(watchPosition: typeof navigator.geolocation.watchPosition, clearWatch: typeof navigator.geolocation.clearWatch) {
|
||||||
|
Object.defineProperty(navigator, "geolocation", {
|
||||||
|
configurable: true,
|
||||||
|
value: { watchPosition, clearWatch, getCurrentPosition: vi.fn() }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreNavigatorProperty(name: "geolocation" | "vibrate", descriptor: PropertyDescriptor | undefined) {
|
||||||
|
if (descriptor) {
|
||||||
|
Object.defineProperty(navigator, name, descriptor);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(navigator, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionAt(lat: number, lon: number, accuracy: number): GeolocationPosition {
|
||||||
|
return {
|
||||||
|
coords: {
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
accuracy,
|
||||||
|
altitude: null,
|
||||||
|
altitudeAccuracy: null,
|
||||||
|
heading: null,
|
||||||
|
speed: null,
|
||||||
|
toJSON: () => ({})
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toJSON: () => ({})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeFixture: RouteResult = {
|
||||||
|
id: "test-route",
|
||||||
|
name: "Testfahrt",
|
||||||
|
geometry: { type: "LineString", coordinates: [[7, 53], [7.01, 53]] },
|
||||||
|
distanceNm: 0.4,
|
||||||
|
eta: null,
|
||||||
|
warnings: [],
|
||||||
|
minKnownDepthM: null,
|
||||||
|
unknownDepthRatio: 1,
|
||||||
|
dataSources: ["Test"],
|
||||||
|
routingMode: "fairway"
|
||||||
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { VoyagePlan as VoyagePlanResult } from "@watermaps/shared";
|
||||||
|
import { VoyagePlan } from "../src/components/VoyagePlan";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe("VoyagePlan", () => {
|
||||||
|
it("shows daily legs, waypoints, harbour supply and contacts", () => {
|
||||||
|
render(<VoyagePlan plan={plan()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { name: "Etappenplan" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/2 Tage/)).toHaveTextContent("2 Tage · 72,5 sm · 12 h 05 min");
|
||||||
|
expect(screen.getByText("Start → Marina Mitte")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Via: Schleuse Eins")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const supply = screen.getByRole("list", { name: "Versorgung in Marina Mitte" });
|
||||||
|
expect(within(supply).getByLabelText("Strom: verfügbar")).toBeInTheDocument();
|
||||||
|
expect(within(supply).getByLabelText("Treibstoff: nicht verfügbar")).toBeInTheDocument();
|
||||||
|
expect(within(supply).getByLabelText("Entsorgung: unbekannt")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: "Hafen anrufen" })).toHaveAttribute("href", "tel:+4923811234");
|
||||||
|
expect(screen.getByRole("link", { name: "Website" })).toHaveAttribute("href", "https://hafen.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders unconfirmed stops and planning warnings clearly", () => {
|
||||||
|
const result = plan();
|
||||||
|
result.legs[0] = {
|
||||||
|
...result.legs[0]!,
|
||||||
|
end: {
|
||||||
|
type: "route",
|
||||||
|
name: "Tagesziel auf der Route",
|
||||||
|
coordinate: { lat: 52, lon: 7.5 },
|
||||||
|
routeDistanceNm: 40,
|
||||||
|
distanceFromRouteNm: 0,
|
||||||
|
harbour: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
result.warnings = [
|
||||||
|
{
|
||||||
|
code: "NO_SUITABLE_HARBOUR",
|
||||||
|
severity: "caution",
|
||||||
|
message: "Kein geeigneter Hafen gefunden.",
|
||||||
|
day: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<VoyagePlan plan={result} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Kein bestätigter Liegeplatz")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("list", { name: "Hinweise zum Etappenplan" })).toHaveTextContent(
|
||||||
|
"Kein geeigneter Hafen gefunden."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing until a plan exists", () => {
|
||||||
|
const { container } = render(<VoyagePlan plan={null} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function plan(): VoyagePlanResult {
|
||||||
|
const start = {
|
||||||
|
type: "start" as const,
|
||||||
|
name: "Start",
|
||||||
|
coordinate: { lat: 52, lon: 7 },
|
||||||
|
routeDistanceNm: 0,
|
||||||
|
distanceFromRouteNm: 0,
|
||||||
|
harbour: null
|
||||||
|
};
|
||||||
|
const harbour = {
|
||||||
|
id: "marina-mitte",
|
||||||
|
name: "Marina Mitte",
|
||||||
|
kind: "marina" as const,
|
||||||
|
coordinate: { lat: 52, lon: 7.5 },
|
||||||
|
phone: "+49 (2381) 1234",
|
||||||
|
website: "hafen.example",
|
||||||
|
amenities: {
|
||||||
|
electricity: "available" as const,
|
||||||
|
water: "available" as const,
|
||||||
|
fuel: "unavailable" as const,
|
||||||
|
waste: "unknown" as const,
|
||||||
|
overnight: "available" as const
|
||||||
|
},
|
||||||
|
routeDistanceNm: 40,
|
||||||
|
distanceFromRouteNm: 0.25
|
||||||
|
};
|
||||||
|
const middle = {
|
||||||
|
type: "harbour" as const,
|
||||||
|
name: harbour.name,
|
||||||
|
coordinate: harbour.coordinate,
|
||||||
|
routeDistanceNm: harbour.routeDistanceNm,
|
||||||
|
distanceFromRouteNm: harbour.distanceFromRouteNm,
|
||||||
|
harbour
|
||||||
|
};
|
||||||
|
const destination = {
|
||||||
|
type: "destination" as const,
|
||||||
|
name: "Ziel",
|
||||||
|
coordinate: { lat: 52, lon: 8 },
|
||||||
|
routeDistanceNm: 72,
|
||||||
|
distanceFromRouteNm: 0,
|
||||||
|
harbour: null
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
legs: [
|
||||||
|
{
|
||||||
|
day: 1,
|
||||||
|
start,
|
||||||
|
end: middle,
|
||||||
|
routeDistanceNm: 40,
|
||||||
|
distanceNm: 40.25,
|
||||||
|
durationHours: 6.708,
|
||||||
|
waypoints: [
|
||||||
|
{
|
||||||
|
id: "lock-one",
|
||||||
|
name: "Schleuse Eins",
|
||||||
|
coordinate: { lat: 52, lon: 7.2 },
|
||||||
|
sequence: 1,
|
||||||
|
routeDistanceNm: 12,
|
||||||
|
distanceFromRouteNm: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: 2,
|
||||||
|
start: middle,
|
||||||
|
end: destination,
|
||||||
|
routeDistanceNm: 32,
|
||||||
|
distanceNm: 32.25,
|
||||||
|
durationHours: 5.375,
|
||||||
|
waypoints: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
orderedWaypoints: [],
|
||||||
|
warnings: [],
|
||||||
|
requiredAmenities: ["water", "overnight"],
|
||||||
|
totalRouteDistanceNm: 72,
|
||||||
|
totalDistanceNm: 72.5,
|
||||||
|
totalDurationHours: 12.0833,
|
||||||
|
maxCruisingDistancePerDayNm: 42
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
const useHttps =
|
||||||
|
process.env.WATERMAPS_HTTPS === "true" ||
|
||||||
|
process.env.SEACOMPASS_HTTPS === "true" ||
|
||||||
|
process.env.HTTPS === "true";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
...(useHttps ? [basicSsl()] : []),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["favicon.svg"],
|
||||||
|
manifest: {
|
||||||
|
name: "Watermaps",
|
||||||
|
short_name: "Watermaps",
|
||||||
|
description: "Bootsrouten, Etappen, Live-Wasserstände, Offline-Navigation, GPS, Wetter und Tide.",
|
||||||
|
theme_color: "#0f4c5c",
|
||||||
|
background_color: "#f5f7f4",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait",
|
||||||
|
scope: "/",
|
||||||
|
start_url: "/",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "/favicon.svg",
|
||||||
|
sizes: "any",
|
||||||
|
type: "image/svg+xml",
|
||||||
|
purpose: "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: "/",
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/tiles\.openseamap\.org\/.*/i,
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "openseamap-tiles",
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
},
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 500,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i,
|
||||||
|
handler: "StaleWhileRevalidate",
|
||||||
|
options: {
|
||||||
|
// Runtime caching sees only resources requested during normal map
|
||||||
|
// use. It deliberately does not prefetch whole route corridors.
|
||||||
|
cacheName: "openfreemap-visited-resources",
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
},
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 1_200,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
manifest: true,
|
||||||
|
// MapLibre is delivered upstream as one pre-bundled module. Its isolated
|
||||||
|
// chunk is intentionally large and checked separately after each build.
|
||||||
|
chunkSizeWarningLimit: 1_100,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
onlyExplicitManualChunks: true,
|
||||||
|
manualChunks(id) {
|
||||||
|
const normalizedId = id.replaceAll("\\", "/");
|
||||||
|
if (
|
||||||
|
normalizedId.includes("/node_modules/maplibre-gl/") &&
|
||||||
|
!normalizedId.endsWith(".css")
|
||||||
|
) {
|
||||||
|
return "map-engine";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalizedId.includes("/node_modules/react/") ||
|
||||||
|
normalizedId.includes("/node_modules/react-dom/") ||
|
||||||
|
normalizedId.includes("/node_modules/scheduler/")
|
||||||
|
) {
|
||||||
|
return "react-vendor";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:5174",
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
target: "http://localhost:5174",
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
exclude: ["tests/e2e/**", "node_modules/**", "dist/**"]
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
listen_addresses: "0.0.0.0:3000"
|
||||||
|
cache_size_mb: 256
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
connection_string: "postgres://seacompass:seacompass@postgres:5432/seacompass"
|
||||||
|
auto_publish: false
|
||||||
|
tables:
|
||||||
|
marine_features_mvt:
|
||||||
|
schema: public
|
||||||
|
table: marine_features_mvt
|
||||||
|
srid: 4326
|
||||||
|
geometry_column: geom
|
||||||
|
geometry_type: GEOMETRY
|
||||||
|
properties:
|
||||||
|
id: int8
|
||||||
|
layer: text
|
||||||
|
source: text
|
||||||
|
name: text
|
||||||
|
properties: jsonb
|
||||||
|
marine_fairway_edges:
|
||||||
|
schema: public
|
||||||
|
table: marine_fairway_edges
|
||||||
|
srid: 4326
|
||||||
|
geometry_column: geom
|
||||||
|
geometry_type: LINESTRING
|
||||||
|
properties:
|
||||||
|
id: int8
|
||||||
|
source: text
|
||||||
|
source_id: text
|
||||||
|
name: text
|
||||||
|
min_depth_m: numeric
|
||||||
|
properties: jsonb
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS marine_features (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
layer text NOT NULL CHECK (layer IN ('seamarks', 'bridges', 'locks', 'harbours', 'waterways', 'fairways')),
|
||||||
|
source text NOT NULL DEFAULT 'manual',
|
||||||
|
source_id text,
|
||||||
|
name text,
|
||||||
|
properties jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
geom geometry(Geometry, 4326) NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_features_geom_idx ON marine_features USING gist (geom);
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_features_layer_idx ON marine_features (layer);
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_features_properties_idx ON marine_features USING gin (properties);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS marine_features_source_layer_uidx
|
||||||
|
ON marine_features (source, source_id, layer)
|
||||||
|
WHERE source_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS marine_enrichment_attempts (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
layer text NOT NULL CHECK (layer IN ('locks', 'harbours')),
|
||||||
|
original_source text NOT NULL,
|
||||||
|
original_source_id text NOT NULL,
|
||||||
|
provider text NOT NULL,
|
||||||
|
query_fingerprint text NOT NULL,
|
||||||
|
query text,
|
||||||
|
status text NOT NULL CHECK (
|
||||||
|
status IN (
|
||||||
|
'success',
|
||||||
|
'no_match',
|
||||||
|
'ambiguous',
|
||||||
|
'no_contacts',
|
||||||
|
'fetch_failed',
|
||||||
|
'provider_blocked',
|
||||||
|
'invalid_candidate'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
result_url text,
|
||||||
|
details jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
attempted_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
retry_after timestamptz,
|
||||||
|
UNIQUE (layer, original_source, original_source_id, provider, query_fingerprint)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_enrichment_attempts_retry_idx
|
||||||
|
ON marine_enrichment_attempts (provider, retry_after);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS marine_fairway_edges (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source text NOT NULL DEFAULT 'osm',
|
||||||
|
source_id text,
|
||||||
|
name text,
|
||||||
|
min_depth_m numeric,
|
||||||
|
properties jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
geom geometry(LineString, 4326) NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_fairway_edges_geom_idx ON marine_fairway_edges USING gist (geom);
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_fairway_edges_source_idx ON marine_fairway_edges (source, source_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS marine_fairway_edges_source_uidx
|
||||||
|
ON marine_fairway_edges (source, source_id)
|
||||||
|
WHERE source_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW marine_features_mvt AS
|
||||||
|
SELECT id, layer, source, name, properties, geom
|
||||||
|
FROM marine_features;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
version: "2.4"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: seacompass
|
||||||
|
POSTGRES_USER: seacompass
|
||||||
|
POSTGRES_PASSWORD: seacompass
|
||||||
|
ports:
|
||||||
|
- "${WATERMAPS_POSTGRES_PORT:-${SEA_COMPASS_POSTGRES_PORT:-55432}}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
- ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U seacompass -d seacompass"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
martin:
|
||||||
|
image: ghcr.io/maplibre/martin:latest
|
||||||
|
command: ["--config", "/config/martin.yaml"]
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./database/martin.yaml:/config/martin.yaml:ro
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
nodejs \
|
||||||
|
osmium-tool \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user