From 57f7b4dedbca75113dfb384b42deca22490a3f1a Mon Sep 17 00:00:00 2001 From: BuTzZ Date: Fri, 24 Jul 2026 11:24:31 +0200 Subject: [PATCH] Initial Watermaps import --- .dockerignore | 12 + .env.example | 34 + .gitignore | 15 + README.md | 222 + apps/api/package.json | 28 + apps/api/src/app.ts | 247 + apps/api/src/env.ts | 21 + apps/api/src/server.ts | 21 + apps/api/src/services/cache.ts | 102 + apps/api/src/services/config.ts | 67 + apps/api/src/services/fairways.ts | 576 + apps/api/src/services/features.ts | 608 ++ apps/api/src/services/http.ts | 25 + apps/api/src/services/navigation-data.ts | 537 + apps/api/src/services/tides.ts | 183 + apps/api/src/services/weather.ts | 213 + apps/api/tests/api.test.ts | 352 + apps/api/tests/euris-lock-sync.test.mjs | 217 + apps/api/tests/fairways.test.ts | 265 + apps/api/tests/features.test.ts | 186 + .../tests/marine-search-enrichment.test.mjs | 467 + .../tests/marine-website-enrichment.test.mjs | 290 + apps/api/tests/navigation-data.test.ts | 260 + .../tests/osm-marine-classification.test.mjs | 40 + apps/api/tests/tides.test.ts | 102 + apps/api/tests/weather.test.ts | 74 + apps/api/tsconfig.json | 12 + apps/web/index.html | 13 + apps/web/package.json | 36 + apps/web/playwright.config.ts | 39 + apps/web/public/favicon.svg | 7 + apps/web/scripts/check-chunks.mjs | 141 + apps/web/src/App.tsx | 1287 +++ apps/web/src/api.ts | 93 + apps/web/src/components/AnchorWatchPanel.css | 462 + apps/web/src/components/AnchorWatchPanel.tsx | 438 + apps/web/src/components/CompassDial.tsx | 42 + apps/web/src/components/ConditionsPanel.css | 317 + apps/web/src/components/ConditionsPanel.tsx | 616 ++ .../src/components/CourseAssistantPanel.css | 238 + .../src/components/CourseAssistantPanel.tsx | 178 + apps/web/src/components/LazyContent.tsx | 44 + apps/web/src/components/MapView.tsx | 1591 +++ apps/web/src/components/MarineFeatureInfo.tsx | 305 + .../src/components/NavigationDataPanel.tsx | 154 + .../web/src/components/NavigationToolRail.tsx | 134 + .../src/components/NavigationWorkspace.tsx | 188 + apps/web/src/components/RoutePlanner.tsx | 1106 ++ apps/web/src/components/RouteTidePanel.tsx | 74 + apps/web/src/components/StatusBar.tsx | 209 + .../src/components/UpcomingEventsPanel.css | 435 + .../src/components/UpcomingEventsPanel.tsx | 759 ++ .../src/components/VoyageNavigationTools.css | 154 + .../src/components/VoyageNavigationTools.tsx | 229 + apps/web/src/components/VoyagePlan.css | 167 + apps/web/src/components/VoyagePlan.tsx | 144 + apps/web/src/hooks/useAnchorWatch.ts | 463 + apps/web/src/hooks/useCompass.ts | 86 + apps/web/src/hooks/useCourseAssistant.ts | 114 + apps/web/src/hooks/useGeolocation.ts | 129 + apps/web/src/hooks/useMarineData.ts | 110 + apps/web/src/hooks/useRouteDeviationAlarm.ts | 165 + apps/web/src/lib/gpx.ts | 157 + apps/web/src/lib/offline-route.ts | 351 + apps/web/src/lib/route-deviation.ts | 176 + apps/web/src/main.tsx | 10 + apps/web/src/routeEvents.ts | 322 + apps/web/src/routeWeatherReport.ts | 792 ++ apps/web/src/styles/app.css | 2220 ++++ apps/web/src/vite-env.d.ts | 1 + apps/web/src/voyageHarbours.ts | 218 + apps/web/tests/anchor-watch-hook.test.tsx | 148 + apps/web/tests/conditions-panel.test.tsx | 229 + apps/web/tests/course-assistant-hook.test.tsx | 108 + .../web/tests/course-assistant-panel.test.tsx | 83 + apps/web/tests/e2e/app.spec.ts | 219 + apps/web/tests/e2e/desktop-layout.spec.ts | 123 + apps/web/tests/geolocation.test.tsx | 61 + apps/web/tests/gpx.test.ts | 54 + apps/web/tests/lazy-content.test.tsx | 64 + apps/web/tests/map-view.test.tsx | 866 ++ apps/web/tests/marine-data-hook.test.tsx | 68 + apps/web/tests/navigation-workspace.test.tsx | 120 + apps/web/tests/offline-route.test.ts | 108 + apps/web/tests/route-deviation.test.ts | 49 + apps/web/tests/route-events.test.ts | 202 + apps/web/tests/route-planner.test.tsx | 605 ++ apps/web/tests/route-weather-report.test.ts | 170 + apps/web/tests/status-bar.test.tsx | 86 + apps/web/tests/upcoming-events-panel.test.tsx | 197 + apps/web/tests/voyage-harbours.test.ts | 155 + .../tests/voyage-navigation-tools.test.tsx | 133 + apps/web/tests/voyage-plan.test.tsx | 142 + apps/web/tsconfig.json | 12 + apps/web/vite.config.ts | 113 + apps/web/vitest.config.ts | 8 + database/martin.yaml | 32 + database/schema.sql | 69 + docker-compose.yml | 43 + docker/geofabrik-import/Dockerfile | 11 + package-lock.json | 9397 +++++++++++++++++ package.json | 29 + packages/shared/package.json | 21 + packages/shared/src/anchor-watch.ts | 445 + packages/shared/src/fairway-routing.ts | 894 ++ packages/shared/src/geo.ts | 50 + packages/shared/src/index.ts | 9 + packages/shared/src/inland-seed.ts | 519 + packages/shared/src/marine-poi-clustering.ts | 678 ++ packages/shared/src/route-guidance.ts | 622 ++ packages/shared/src/route.ts | 129 + packages/shared/src/types.ts | 200 + packages/shared/src/voyage-planning.ts | 641 ++ packages/shared/tests/anchor-watch.test.ts | 288 + packages/shared/tests/geo.test.ts | 20 + .../tests/marine-poi-clustering.test.ts | 148 + packages/shared/tests/route-guidance.test.ts | 291 + packages/shared/tests/route.test.ts | 299 + packages/shared/tests/voyage-planning.test.ts | 177 + packages/shared/tsconfig.json | 13 + scripts/download-geofabrik.sh | 51 + scripts/enrich-marine-search.mjs | 1623 +++ scripts/enrich-marine-websites.mjs | 1123 ++ scripts/import-geofabrik-docker.sh | 32 + scripts/import-geofabrik.sh | 67 + scripts/load-osm-fairways.mjs | 383 + scripts/osm-marine-classification.mjs | 54 + scripts/sync-euris-locks.mjs | 547 + tsconfig.base.json | 18 + 129 files changed, 43136 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/env.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/src/services/cache.ts create mode 100644 apps/api/src/services/config.ts create mode 100644 apps/api/src/services/fairways.ts create mode 100644 apps/api/src/services/features.ts create mode 100644 apps/api/src/services/http.ts create mode 100644 apps/api/src/services/navigation-data.ts create mode 100644 apps/api/src/services/tides.ts create mode 100644 apps/api/src/services/weather.ts create mode 100644 apps/api/tests/api.test.ts create mode 100644 apps/api/tests/euris-lock-sync.test.mjs create mode 100644 apps/api/tests/fairways.test.ts create mode 100644 apps/api/tests/features.test.ts create mode 100644 apps/api/tests/marine-search-enrichment.test.mjs create mode 100644 apps/api/tests/marine-website-enrichment.test.mjs create mode 100644 apps/api/tests/navigation-data.test.ts create mode 100644 apps/api/tests/osm-marine-classification.test.mjs create mode 100644 apps/api/tests/tides.test.ts create mode 100644 apps/api/tests/weather.test.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/scripts/check-chunks.mjs create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/api.ts create mode 100644 apps/web/src/components/AnchorWatchPanel.css create mode 100644 apps/web/src/components/AnchorWatchPanel.tsx create mode 100644 apps/web/src/components/CompassDial.tsx create mode 100644 apps/web/src/components/ConditionsPanel.css create mode 100644 apps/web/src/components/ConditionsPanel.tsx create mode 100644 apps/web/src/components/CourseAssistantPanel.css create mode 100644 apps/web/src/components/CourseAssistantPanel.tsx create mode 100644 apps/web/src/components/LazyContent.tsx create mode 100644 apps/web/src/components/MapView.tsx create mode 100644 apps/web/src/components/MarineFeatureInfo.tsx create mode 100644 apps/web/src/components/NavigationDataPanel.tsx create mode 100644 apps/web/src/components/NavigationToolRail.tsx create mode 100644 apps/web/src/components/NavigationWorkspace.tsx create mode 100644 apps/web/src/components/RoutePlanner.tsx create mode 100644 apps/web/src/components/RouteTidePanel.tsx create mode 100644 apps/web/src/components/StatusBar.tsx create mode 100644 apps/web/src/components/UpcomingEventsPanel.css create mode 100644 apps/web/src/components/UpcomingEventsPanel.tsx create mode 100644 apps/web/src/components/VoyageNavigationTools.css create mode 100644 apps/web/src/components/VoyageNavigationTools.tsx create mode 100644 apps/web/src/components/VoyagePlan.css create mode 100644 apps/web/src/components/VoyagePlan.tsx create mode 100644 apps/web/src/hooks/useAnchorWatch.ts create mode 100644 apps/web/src/hooks/useCompass.ts create mode 100644 apps/web/src/hooks/useCourseAssistant.ts create mode 100644 apps/web/src/hooks/useGeolocation.ts create mode 100644 apps/web/src/hooks/useMarineData.ts create mode 100644 apps/web/src/hooks/useRouteDeviationAlarm.ts create mode 100644 apps/web/src/lib/gpx.ts create mode 100644 apps/web/src/lib/offline-route.ts create mode 100644 apps/web/src/lib/route-deviation.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/routeEvents.ts create mode 100644 apps/web/src/routeWeatherReport.ts create mode 100644 apps/web/src/styles/app.css create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/src/voyageHarbours.ts create mode 100644 apps/web/tests/anchor-watch-hook.test.tsx create mode 100644 apps/web/tests/conditions-panel.test.tsx create mode 100644 apps/web/tests/course-assistant-hook.test.tsx create mode 100644 apps/web/tests/course-assistant-panel.test.tsx create mode 100644 apps/web/tests/e2e/app.spec.ts create mode 100644 apps/web/tests/e2e/desktop-layout.spec.ts create mode 100644 apps/web/tests/geolocation.test.tsx create mode 100644 apps/web/tests/gpx.test.ts create mode 100644 apps/web/tests/lazy-content.test.tsx create mode 100644 apps/web/tests/map-view.test.tsx create mode 100644 apps/web/tests/marine-data-hook.test.tsx create mode 100644 apps/web/tests/navigation-workspace.test.tsx create mode 100644 apps/web/tests/offline-route.test.ts create mode 100644 apps/web/tests/route-deviation.test.ts create mode 100644 apps/web/tests/route-events.test.ts create mode 100644 apps/web/tests/route-planner.test.tsx create mode 100644 apps/web/tests/route-weather-report.test.ts create mode 100644 apps/web/tests/status-bar.test.tsx create mode 100644 apps/web/tests/upcoming-events-panel.test.tsx create mode 100644 apps/web/tests/voyage-harbours.test.ts create mode 100644 apps/web/tests/voyage-navigation-tools.test.tsx create mode 100644 apps/web/tests/voyage-plan.test.tsx create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 database/martin.yaml create mode 100644 database/schema.sql create mode 100644 docker-compose.yml create mode 100644 docker/geofabrik-import/Dockerfile create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/anchor-watch.ts create mode 100644 packages/shared/src/fairway-routing.ts create mode 100644 packages/shared/src/geo.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/inland-seed.ts create mode 100644 packages/shared/src/marine-poi-clustering.ts create mode 100644 packages/shared/src/route-guidance.ts create mode 100644 packages/shared/src/route.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/src/voyage-planning.ts create mode 100644 packages/shared/tests/anchor-watch.test.ts create mode 100644 packages/shared/tests/geo.test.ts create mode 100644 packages/shared/tests/marine-poi-clustering.test.ts create mode 100644 packages/shared/tests/route-guidance.test.ts create mode 100644 packages/shared/tests/route.test.ts create mode 100644 packages/shared/tests/voyage-planning.test.ts create mode 100644 packages/shared/tsconfig.json create mode 100755 scripts/download-geofabrik.sh create mode 100644 scripts/enrich-marine-search.mjs create mode 100644 scripts/enrich-marine-websites.mjs create mode 100755 scripts/import-geofabrik-docker.sh create mode 100755 scripts/import-geofabrik.sh create mode 100755 scripts/load-osm-fairways.mjs create mode 100644 scripts/osm-marine-classification.mjs create mode 100644 scripts/sync-euris-locks.mjs create mode 100644 tsconfig.base.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c773b80 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b6c7e64 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cccdc5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7160ea4 --- /dev/null +++ b/README.md @@ -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`
+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` diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..a67503d --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..03fb6da --- /dev/null +++ b/apps/api/src/app.ts @@ -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; + 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; + +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 { + 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 + }; +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..f6e5b6f --- /dev/null +++ b/apps/api/src/env.ts @@ -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" + }; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..1ab2ffa --- /dev/null +++ b/apps/api/src/server.ts @@ -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); +} diff --git a/apps/api/src/services/cache.ts b/apps/api/src/services/cache.ts new file mode 100644 index 0000000..a72202a --- /dev/null +++ b/apps/api/src/services/cache.ts @@ -0,0 +1,102 @@ +import { Redis } from "ioredis"; + +export interface Cache { + get(key: string): Promise; + set(key: string, value: T, ttlMs: number): Promise; + getOrSet(key: string, ttlMs: number, loader: () => Promise): Promise; + close(): Promise; +} + +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(key: string): Promise { + if (!redisReady) { + return memoryFallback.get(key); + } + + const raw = await redis.get(key); + return raw ? (JSON.parse(raw) as T) : null; + }, + async set(key: string, value: T, ttlMs: number): Promise { + if (!redisReady) { + return memoryFallback.set(key, value, ttlMs); + } + + await redis.set(key, JSON.stringify(value), "PX", ttlMs); + }, + async getOrSet(key: string, ttlMs: number, loader: () => Promise): Promise { + const cached = await this.get(key); + if (cached !== null) { + return cached; + } + + const value = await loader(); + await this.set(key, value, ttlMs); + return value; + }, + async close(): Promise { + await memoryFallback.close(); + redis.disconnect(); + } + }; +} + +class MemoryCache implements Cache { + private readonly entries = new Map(); + + async get(key: string): Promise { + 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(key: string, value: T, ttlMs: number): Promise { + this.entries.set(key, { value, expiresAt: Date.now() + ttlMs }); + } + + async getOrSet(key: string, ttlMs: number, loader: () => Promise): Promise { + const cached = await this.get(key); + if (cached !== null) { + return cached; + } + + const value = await loader(); + await this.set(key, value, ttlMs); + return value; + } + + async close(): Promise { + this.entries.clear(); + } +} diff --git a/apps/api/src/services/config.ts b/apps/api/src/services/config.ts new file mode 100644 index 0000000..ee62e10 --- /dev/null +++ b/apps/api/src/services/config.ts @@ -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" + ] +}; diff --git a/apps/api/src/services/fairways.ts b/apps/api/src/services/fairways.ts new file mode 100644 index 0000000..e7aee27 --- /dev/null +++ b/apps/api/src/services/fairways.ts @@ -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; +}; + +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 | 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 { + 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 { + await this.pool?.end(); + } + + private async getPostgisGraphForRoute(request: RouteRequest): Promise { + 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( + ` + 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 { + 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 { + 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 => 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(); + const edges = new Map(); + + 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(); + const edges = new Map(); + + 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, 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, 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, edges: Map) { + const degree = new Map(); + 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(); + + 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(); + + 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) { + 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) { + 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, 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 | null | undefined) { + const tags: Record = {}; + 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) { + 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); +} diff --git a/apps/api/src/services/features.ts b/apps/api/src/services/features.ts new file mode 100644 index 0000000..e7d6550 --- /dev/null +++ b/apps/api/src/services/features.ts @@ -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; + }>; + 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) { + this.pool = env.databaseUrl ? new pg.Pool({ connectionString: env.databaseUrl }) : null; + this.demoData = env.demoData; + } + + async getFeatures(query: FeatureQuery): Promise { + 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 { + await this.pool?.end(); + } + + private async getPostgisFeatures(query: FeatureQuery): Promise { + 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; + 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; + 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; +} { + 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; +}; + +type DepthFeaturePropertiesInput = { + name: string | null; + source: string; + sourceId: string | null; + minDepthM: string | number | null; + properties: Record; +}; + +export function normalizeMarineFeatureProperties(input: MarineFeaturePropertiesInput): Record { + 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) { + return Object.fromEntries( + Object.entries(properties).filter(([key, value]) => MARINE_PROPERTY_KEYS.has(key) && value !== undefined) + ); +} + +export function normalizeDepthFeatureProperties(input: DepthFeaturePropertiesInput): Record { + 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) { + 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, key: string) { + const value = properties[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function firstStringProperty(properties: Record, keys: string[]) { + for (const key of keys) { + const value = stringProperty(properties, key); + if (value) { + return value; + } + } + return null; +} + +function normalizedAddress(properties: Record) { + 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`; +} diff --git a/apps/api/src/services/http.ts b/apps/api/src/services/http.ts new file mode 100644 index 0000000..bd514e5 --- /dev/null +++ b/apps/api/src/services/http.ts @@ -0,0 +1,25 @@ +export type FetchLike = typeof fetch; + +export async function fetchJson( + fetcher: FetchLike, + url: string, + timeoutMs = 8000 +): Promise { + 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); + } +} diff --git a/apps/api/src/services/navigation-data.ts b/apps/api/src/services/navigation-data.ts new file mode 100644 index 0000000..1602087 --- /dev/null +++ b/apps/api/src/services/navigation-data.ts @@ -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 { + 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; +} + +export type NavigationDataAdapters = { + /** `undefined` selects the built-in PEGELONLINE adapter; `null` disables it. */ + waterLevels?: NavigationDataAdapter | null; + /** No public machine-readable ELWIS endpoint is assumed; configure explicitly. */ + lockOperations?: NavigationDataAdapter | null; + /** No public machine-readable ELWIS endpoint is assumed; configure explicitly. */ + notices?: NavigationDataAdapter | null; +}; + +export type NavigationDataDependencies = { + cache: Cache; + fetcher: FetchLike; + adapters?: NavigationDataAdapters; + timeoutMs?: number; + now?: () => Date; +}; + +type SourceCacheEntry = { + cachedAt: string; + items: T[]; +}; + +type LoadedSource = { + 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 { + 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( + "water-levels", + "pegelonline-wsv", + "PEGELONLINE der WSV", + OFFICIAL_NAVIGATION_SOURCES.pegelOnlineDocumentation, + now, + "Wasserstandsdaten wurden deaktiviert." + ), + lockAdapter + ? loadSource(lockAdapter, query, deps, now) + : notConfiguredSource( + "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( + "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 { + 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 = { + 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( + options: OfficialJsonAdapterOptions +): NavigationDataAdapter { + 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( + adapter: NavigationDataAdapter, + query: NavigationDataQuery, + deps: NavigationDataDependencies, + now: () => Date +): Promise> { + 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(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 = { + 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(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( + adapter: NavigationDataAdapter, + items: T[], + state: NavigationSourceState, + now: () => Date, + dataTimestamp: string | null, + warning: string | null +): LoadedSource { + return { + items, + status: { + kind: adapter.kind, + id: adapter.id, + label: adapter.label, + sourceUrl: adapter.sourceUrl, + state, + checkedAt: now().toISOString(), + dataTimestamp, + warning + } + }; +} + +function notConfiguredSource( + kind: NavigationSourceKind, + id: string, + label: string, + sourceUrl: string, + now: () => Date, + warning: string +): LoadedSource { + 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 { + 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[0], init?: Parameters[1]) => { + const url = input instanceof Request ? input.url : String(input); + assertOfficialNavigationUrl(url); + return fetcher(input, init); + }) as FetchLike; +} + +async function runWithTimeout( + task: (signal: AbortSignal) => Promise, + timeoutMs: number, + label: string +): Promise { + const controller = new AbortController(); + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_, 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(cache: Cache, key: string): Promise | null> { + try { + const entry = await cache.get>(key); + return entry && Array.isArray(entry.items) && typeof entry.cachedAt === "string" ? entry : null; + } catch { + return null; + } +} + +async function safeCacheSet( + cache: Cache, + key: string, + entry: SourceCacheEntry, + ttlMs: number +): Promise { + 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 { + 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 {} diff --git a/apps/api/src/services/tides.ts b/apps/api/src/services/tides.ts new file mode 100644 index 0000000..fdc0b19 --- /dev/null +++ b/apps/api/src/services/tides.ts @@ -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 { + const data = await deps.cache.getOrSet("bsh:water-level-forecast:all", CACHE_TTL_MS, () => + fetchJson(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; +} diff --git a/apps/api/src/services/weather.ts b/apps/api/src/services/weather.ts new file mode 100644 index 0000000..29e2a77 --- /dev/null +++ b/apps/api/src/services/weather.ts @@ -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; + wave_direction?: Array; + wave_period?: Array; + ocean_current_velocity?: Array; + ocean_current_direction?: Array; + sea_level_height_msl?: Array; + }; +}; + +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; + wind_direction_10m?: Array; + weather_code?: Array; + temperature_2m?: Array; + }; +}; + +const CACHE_TTL_MS = 10 * 60 * 1000; + +export async function getMarineForecast( + params: { lat: number; lon: number; at?: string }, + deps: { cache: Cache; fetcher: FetchLike } +): Promise { + 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( + deps.fetcher, + `https://marine-api.open-meteo.com/v1/marine?${search.toString()}`, + 12_000 + ), + fetchJson( + 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 = (values: Array | 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 = (values?: Array) => valueAt(values, marineIndex); + const weatherAt = (values?: Array) => 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(values: Array | 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"; +} diff --git a/apps/api/tests/api.test.ts b/apps/api/tests/api.test.ts new file mode 100644 index 0000000..e68963d --- /dev/null +++ b/apps/api/tests/api.test.ts @@ -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 = { + 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(); + }); +}); diff --git a/apps/api/tests/euris-lock-sync.test.mjs b/apps/api/tests/euris-lock-sync.test.mjs new file mode 100644 index 0000000..c9b8858 --- /dev/null +++ b/apps/api/tests/euris-lock-sync.test.mjs @@ -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(); + }); +}); diff --git a/apps/api/tests/fairways.test.ts b/apps/api/tests/fairways.test.ts new file mode 100644 index 0000000..f4dcfdb --- /dev/null +++ b/apps/api/tests/fairways.test.ts @@ -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(); + }); +}); diff --git a/apps/api/tests/features.test.ts b/apps/api/tests/features.test.ts new file mode 100644 index 0000000..a9fba5a --- /dev/null +++ b/apps/api/tests/features.test.ts @@ -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 + } + }); + }); +}); diff --git a/apps/api/tests/marine-search-enrichment.test.mjs b/apps/api/tests/marine-search-enrichment.test.mjs new file mode 100644 index 0000000..6e7e3e2 --- /dev/null +++ b/apps/api/tests/marine-search-enrichment.test.mjs @@ -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 = ` + + + + Schleuse Werries | WSA Westdeutsche Kanäle + + + +
+

Schleuse Werries

+

Datteln-Hamm-Kanal in Hamm

+
+ + +`; + +const identityOnlyPageHtml = ` + + + Schleuse Werries | WSA Westdeutsche Kanäle + +
+

Schleuse Werries

+

Datteln-Hamm-Kanal in Hamm

+
+ + +`; + +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 = ` + + + + + Unsicher + `; + + 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: '
', + }), + }), + ).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: ` + + WSA Westdeutsche Kanäle +

Willkommen

Allgemeine Informationen zur Wasserstraßenverwaltung.

+ + `, + finalUrl: "https://www.wsa.example/", + }); + const wrongFacility = evaluateFacilityPageMatch(searchCandidate(), { + html: ` + + Schleuse Werries Amsterdam +

Schleuse Werries

Amsterdam, NL
+ + `, + 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" })], + }); + }); +}); diff --git a/apps/api/tests/marine-website-enrichment.test.mjs b/apps/api/tests/marine-website-enrichment.test.mjs new file mode 100644 index 0000000..beceb75 --- /dev/null +++ b/apps/api/tests/marine-website-enrichment.test.mjs @@ -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 = ` + + Alternative + Alternative +

Telefon 01234 567890

+ `; + + expect(extractContactsFromHtml(html)).toEqual({ + phone: "+49 201 11111", + email: "hafen@example.test", + operator: "Hafenbetrieb Musterstadt", + address: "Ufer 1, 12345 Musterstadt, DE", + }); + expect(extractContactsFromHtml("

Telefon 01234 567890

")).toEqual({ + phone: null, + email: null, + operator: null, + address: null, + }); + expect( + extractContactsFromHtml( + 'AnrufenMail', + ), + ).toMatchObject({ phone: "+49 201 777", email: "lock@example.test" }); + expect( + extractContactsFromHtml('Kein Kontaktlink'), + ).toMatchObject({ phone: null }); + expect(extractContactsFromHtml('X')).toMatchObject({ + phone: null, + }); + expect( + extractContactsFromHtml( + '', + ), + ).toMatchObject({ email: null, operator: null }); + expect( + extractContactsFromHtml( + 'Telefon', + ), + ).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('Schleuse anrufen'); + }); + + 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(); + }); +}); diff --git a/apps/api/tests/navigation-data.test.ts b/apps/api/tests/navigation-data.test.ts new file mode 100644 index 0000000..50ee6f9 --- /dev/null +++ b/apps/api/tests/navigation-data.test.ts @@ -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 = { + 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 = { + 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({ + 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 = { + 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" + }; +} diff --git a/apps/api/tests/osm-marine-classification.test.mjs b/apps/api/tests/osm-marine-classification.test.mjs new file mode 100644 index 0000000..a0b2c3b --- /dev/null +++ b/apps/api/tests/osm-marine-classification.test.mjs @@ -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"); + }); +}); diff --git a/apps/api/tests/tides.test.ts b/apps/api/tests/tides.test.ts new file mode 100644 index 0000000..81e1265 --- /dev/null +++ b/apps/api/tests/tides.test.ts @@ -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(); + } + }); +}); diff --git a/apps/api/tests/weather.test.ts b/apps/api/tests/weather.test.ts new file mode 100644 index 0000000..4de97f9 --- /dev/null +++ b/apps/api/tests/weather.test.ts @@ -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 + }); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..8269b50 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..0260562 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Watermaps + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..ae1a16b --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..271b3df --- /dev/null +++ b/apps/web/playwright.config.ts @@ -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 } + } + } + ] +}); diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..b7d29aa --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/web/scripts/check-chunks.mjs b/apps/web/scripts/check-chunks.mjs new file mode 100644 index 0000000..1b209dc --- /dev/null +++ b/apps/web/scripts/check-chunks.mjs @@ -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); + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..a693e63 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,1287 @@ +import { AlertTriangle, Compass, Navigation, ShieldAlert } from "lucide-react"; +import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + calculateRouteGuidance, + haversineDistanceNm, + type VoyageHarbour, + type AppConfig, + type Coordinate, + type MarineForecast, + type NavigationDataSnapshot, + type RouteGuidanceResult, + type RouteResult, + type TideSummary, + type VesselProfile +} from "@watermaps/shared"; +import { + createRoute, + getConfig, + getMapFeatures, + getMarineForecast, + getNavigationData, + getNearestTide +} from "./api"; +import { CompassDial } from "./components/CompassDial"; +import { LazyContent } from "./components/LazyContent"; +import { + NavigationToolRail, + type ActiveTool, + type NavigationToolId, + type NavigationToolStatus +} from "./components/NavigationToolRail"; +import { + NavigationWorkspace, + type NavigationSheetState +} from "./components/NavigationWorkspace"; +import type { RouteTidePlan } from "./components/RouteTidePanel"; +import { StatusBar } from "./components/StatusBar"; +import { useCompass } from "./hooks/useCompass"; +import { useAnchorWatch } from "./hooks/useAnchorWatch"; +import { useCourseAssistant } from "./hooks/useCourseAssistant"; +import { useGeolocation } from "./hooks/useGeolocation"; +import { useMarineData } from "./hooks/useMarineData"; +import type { OfflineVoyage } from "./lib/offline-route"; +import { + upcomingRouteEvents, + type RouteEventEtaBasis, + type UpcomingRouteEvent +} from "./routeEvents"; +import type { RouteWeatherReport } from "./routeWeatherReport"; +import { + routeFeatureBounds, + routeLocksFromFeatures, + voyageHarboursFromFeatures, + type RouteLock +} from "./voyageHarbours"; + +type PickMode = "start" | "destination" | "waypoint" | null; + +const LazyAnchorWatchPanel = lazy(() => + import("./components/AnchorWatchPanel").then((module) => ({ + default: module.AnchorWatchPanel + })) +); +const LazyCourseAssistantPanel = lazy(() => + import("./components/CourseAssistantPanel").then((module) => ({ + default: module.CourseAssistantPanel + })) +); +const LazyConditionsPanel = lazy(() => + import("./components/ConditionsPanel").then((module) => ({ + default: module.ConditionsPanel + })) +); +const LazyRoutePlanner = lazy(() => + import("./components/RoutePlanner").then((module) => ({ + default: module.RoutePlanner + })) +); +const LazyUpcomingEventsPanel = lazy(() => + import("./components/UpcomingEventsPanel").then((module) => ({ + default: module.UpcomingEventsPanel + })) +); +const loadMapViewModule = () => + import("./components/MapView").then((module) => ({ + default: module.MapView + })); +const LazyMapView = lazy(loadMapViewModule); + +export function App() { + const [config, setConfig] = useState(null); + const [configError, setConfigError] = useState(null); + const gps = useGeolocation(); + const compass = useCompass(gps.courseDeg); + const marineData = useMarineData(gps.position); + const anchorWatch = useAnchorWatch(gps); + const [activeTool, setActiveTool] = useState("route"); + const [workspaceSheetState, setWorkspaceSheetState] = + useState("half"); + const [routeStart, setRouteStart] = useState(null); + const [destination, setDestination] = useState(null); + const [waypoints, setWaypoints] = useState([]); + const [route, setRoute] = useState(null); + const guidanceUsesCog = Boolean( + gps.courseDeg !== null && (gps.speedKn === null || gps.speedKn >= 0.8) + ); + // Route bearings and browser COG are true-north based. A device-orientation + // heading can be magnetic or depend on how the phone is held, so it is shown + // for orientation but never used to invent a steering correction. + const guidanceCourseDeg = guidanceUsesCog ? gps.courseDeg : null; + const displayedHeadingDeg = guidanceUsesCog ? gps.courseDeg : compass.headingDeg; + const guidanceHeadingSource: "COG" | "HDG" | "--" = guidanceUsesCog + ? "COG" + : compass.headingDeg !== null + ? "HDG" + : "--"; + const courseAssistant = useCourseAssistant({ + route, + position: gps.position, + accuracyM: gps.accuracyM, + speedKn: gps.speedKn, + headingDeg: guidanceCourseDeg, + fixTimestampMs: gps.timestampMs + }); + const [routeOptions, setRouteOptions] = useState([]); + const [activeVesselProfile, setActiveVesselProfile] = useState(null); + const [routeWeatherReport, setRouteWeatherReport] = useState(null); + const [routeWeatherLoading, setRouteWeatherLoading] = useState(false); + const [routeWeatherError, setRouteWeatherError] = useState(null); + const [navigationData, setNavigationData] = useState(null); + const [navigationDataLoading, setNavigationDataLoading] = useState(false); + const [navigationDataError, setNavigationDataError] = useState(null); + const [routeHarbours, setRouteHarbours] = useState([]); + const [routeLocks, setRouteLocks] = useState([]); + const [routeHarboursLoading, setRouteHarboursLoading] = useState(false); + const [routeHarboursError, setRouteHarboursError] = useState(null); + const [routeTides, setRouteTides] = useState(null); + const [routeTidesLoading, setRouteTidesLoading] = useState(false); + const [routeTidesError, setRouteTidesError] = useState(null); + const [routeLoading, setRouteLoading] = useState(false); + const [routeError, setRouteError] = useState(null); + const [mapReady, setMapReady] = useState(false); + const [mapFocusRequest, setMapFocusRequest] = useState<{ + key: string; + coordinate: Coordinate; + zoom: number; + } | null>(null); + const [pickMode, setPickMode] = useState(null); + const [mapToolsSuppressed, setMapToolsSuppressed] = useState(false); + const routeRequestId = useRef(0); + const routeWeatherRequestId = useRef(0); + const mapToolsResumeTimer = useRef(null); + + useEffect(() => { + // Start the large map chunk and the small runtime configuration in + // parallel after the first app-shell paint. + void loadMapViewModule().catch(() => undefined); + getConfig() + .then(setConfig) + .catch((error) => setConfigError(error instanceof Error ? error.message : "Konfiguration nicht erreichbar")); + }, []); + + useEffect(() => { + if (!route) { + setNavigationData(null); + setNavigationDataLoading(false); + setNavigationDataError(null); + return; + } + + let active = true; + const refresh = async () => { + setNavigationDataLoading(true); + try { + const snapshot = await getNavigationData({ waterways: navigationWaterwaysForRoute(route) }); + if (active) { + setNavigationData(snapshot); + setNavigationDataError(null); + } + } catch (error) { + if (active) { + setNavigationDataError(error instanceof Error ? error.message : "WSV-Fahrtdaten nicht erreichbar"); + } + } finally { + if (active) { + setNavigationDataLoading(false); + } + } + }; + + void refresh(); + const intervalId = window.setInterval(refresh, 60_000); + return () => { + active = false; + window.clearInterval(intervalId); + }; + }, [route]); + + useEffect(() => { + if (!route) { + setRouteTides(null); + setRouteTidesLoading(false); + setRouteTidesError(null); + return; + } + + const first = route.geometry.coordinates[0]; + const last = route.geometry.coordinates.at(-1); + const middle = coordinateAlongRoute(route, 0.5); + if (!first || !last || !middle) { + setRouteTides(null); + setRouteTidesError("Route enthält keine Tiden-Messpunkte."); + return; + } + + let active = true; + setRouteTidesLoading(true); + Promise.allSettled([ + getNearestTide({ lon: first[0], lat: first[1] }, route.departureTime), + getNearestTide(middle, midpointIso(route.departureTime, route.eta)), + getNearestTide({ lon: last[0], lat: last[1] }, route.eta ?? undefined) + ]) + .then(([startResult, middleResult, destinationResult]) => { + if (!active) { + return; + } + const start = fulfilledValue(startResult); + const middleTide = fulfilledValue(middleResult); + const destinationTide = fulfilledValue(destinationResult); + setRouteTides({ start, middle: middleTide, destination: destinationTide }); + setRouteTidesError( + !start && !middleTide && !destinationTide + ? "Tidenprognose für Start, Mitte und Ziel nicht erreichbar." + : null + ); + }) + .finally(() => { + if (active) { + setRouteTidesLoading(false); + } + }); + + return () => { + active = false; + }; + }, [route]); + + useEffect(() => { + if (!route) { + setRouteHarbours([]); + setRouteLocks([]); + setRouteHarboursLoading(false); + setRouteHarboursError(null); + return; + } + + let active = true; + setRouteHarboursLoading(true); + getMapFeatures({ bbox: routeFeatureBounds(route, 2.5), layers: ["harbours", "locks"] }) + .then((collection) => { + if (!active) { + return; + } + setRouteHarbours(voyageHarboursFromFeatures(collection)); + setRouteLocks(routeLocksFromFeatures(collection, route)); + setRouteHarboursError(null); + }) + .catch((error) => { + if (active) { + setRouteHarbours([]); + setRouteLocks([]); + setRouteHarboursError(error instanceof Error ? error.message : "Häfen entlang der Route nicht erreichbar"); + } + }) + .finally(() => { + if (active) { + setRouteHarboursLoading(false); + } + }); + + return () => { + active = false; + }; + }, [route]); + + const routeWarningCount = useMemo( + () => route?.warnings.filter((warning) => warning.severity !== "info").length ?? 0, + [route] + ); + const handleMapReady = useCallback(() => setMapReady(true), []); + const startCourseAssistant = useCallback(() => { + if (!route || anchorWatch.phase !== "idle") return; + courseAssistant.start(); + gps.start(); + void compass.request(); + setActiveTool("route"); + setWorkspaceSheetState("half"); + }, [anchorWatch.phase, compass, courseAssistant, gps, route]); + const anchorPanelVisible = activeTool === "anchor"; + const closeWorkspace = useCallback(() => setActiveTool(null), []); + const selectTool = useCallback((tool: NavigationToolId) => { + setActiveTool((current) => current === tool ? null : tool); + if (!pickMode) { + setWorkspaceSheetState("half"); + } + }, [pickMode]); + const toggleMapPick = useCallback((mode: Exclude) => { + setRouteError(null); + setPickMode((current) => { + const next = current === mode ? null : mode; + setWorkspaceSheetState(next ? "compact" : "half"); + return next; + }); + }, []); + const showRouteEventOnMap = useCallback((event: UpcomingRouteEvent) => { + setMapFocusRequest({ + key: `${event.kind}:${event.id}:${Date.now()}`, + coordinate: event.coordinate, + zoom: event.kind === "harbour" ? 13 : 15 + }); + setActiveTool(null); + }, []); + const activeGuidance = courseAssistant.guidance && + courseAssistant.guidance.status !== "gps-unreliable" && + courseAssistant.guidance.status !== "arrived" + ? courseAssistant.guidance + : null; + const routeProgress = useMemo(() => { + if (!route) { + return { distanceNm: 0, source: "route-start" as const, reliable: false }; + } + if ( + courseAssistant.guidance && + courseAssistant.guidance.positionReliable && + !courseAssistant.fixStale + ) { + return { + distanceNm: courseAssistant.guidance.progressM / 1_852, + source: "guidance" as const, + reliable: true + }; + } + if (gps.position) { + const projection = calculateRouteGuidance({ + route, + position: gps.position, + accuracyM: gps.accuracyM, + speedKn: gps.speedKn + }); + if ( + projection && + projection.positionReliable && + projection.distanceToRouteM <= Math.max(1_000, (gps.accuracyM ?? 0) * 3) + ) { + return { + distanceNm: projection.progressM / 1_852, + source: "gps" as const, + reliable: true + }; + } + } + return { distanceNm: 0, source: "route-start" as const, reliable: false }; + }, [ + courseAssistant.fixStale, + courseAssistant.guidance, + gps.accuracyM, + gps.position, + gps.speedKn, + route + ]); + const routeEventEtaBasis = useMemo(() => { + if ( + routeProgress.source !== "route-start" && + typeof gps.speedKn === "number" && + Number.isFinite(gps.speedKn) && + gps.speedKn >= 0.8 + ) { + return { + speedKn: gps.speedKn, + speedSource: "gps-sog", + referenceTime: Date.now(), + referenceSource: "current-time" + }; + } + const cruiseSpeedKn = activeVesselProfile?.cruiseSpeedKn; + if ( + typeof cruiseSpeedKn !== "number" || + !Number.isFinite(cruiseSpeedKn) || + cruiseSpeedKn <= 0 + ) { + return null; + } + return { + speedKn: cruiseSpeedKn, + speedSource: "vessel-cruise-speed", + referenceTime: + routeProgress.source === "route-start" && route?.departureTime + ? route.departureTime + : Date.now(), + referenceSource: + routeProgress.source === "route-start" && route?.departureTime + ? "route-departure" + : "current-time" + }; + }, [ + activeVesselProfile?.cruiseSpeedKn, + gps.speedKn, + route?.departureTime, + routeProgress.distanceNm, + routeProgress.source + ]); + const routeEvents = useMemo( + () => + route + ? upcomingRouteEvents({ + route, + harbours: routeHarbours, + locks: routeLocks, + bridges: routeWeatherReport?.bridgeReport?.bridges ?? [], + progressNm: routeProgress.distanceNm, + etaBasis: routeEventEtaBasis + }) + : [], + [ + route, + routeEventEtaBasis, + routeHarbours, + routeLocks, + routeProgress.distanceNm, + routeWeatherReport?.bridgeReport?.bridges + ] + ); + const eventWarningCount = useMemo( + () => + routeEvents.filter( + (event) => + event.kind === "bridge" && + (event.feature.status === "too_low" || event.feature.status === "tight") + ).length, + [routeEvents] + ); + const anchorAlarm = anchorWatch.positionAlarm || anchorWatch.rodeShortfall; + const guidanceAlarm = Boolean( + courseAssistant.active && + ( + courseAssistant.fixStale || + courseAssistant.guidance?.status === "off-route" || + courseAssistant.guidance?.status === "gps-unreliable" + ) + ); + const toolStatuses = useMemo>>( + () => ({ + anchor: anchorAlarm + ? "alarm" + : anchorWatch.phase === "idle" + ? "idle" + : "active", + conditions: conditionsToolStatus( + marineData.forecast?.updatedAt, + marineData.tide?.updatedAt, + routeWeatherReport?.severity, + marineData.loading + ), + upcoming: eventWarningCount > 0 + ? routeEvents.some( + (event) => event.kind === "bridge" && event.feature.status === "too_low" + ) + ? "alarm" + : "caution" + : route + ? "active" + : "idle", + route: guidanceAlarm || route?.warnings.some((warning) => warning.severity === "critical") + ? "alarm" + : route?.warnings.some((warning) => warning.severity === "caution") + ? "caution" + : courseAssistant.active || route + ? "active" + : "idle" + }), + [ + anchorAlarm, + anchorWatch.phase, + courseAssistant.active, + guidanceAlarm, + eventWarningCount, + marineData.forecast?.updatedAt, + marineData.loading, + marineData.tide?.updatedAt, + route, + routeEvents, + routeWeatherReport?.severity + ] + ); + const activeToolStatus = activeTool ? toolStatuses[activeTool] ?? "idle" : "idle"; + + const loadRouteWeather = useCallback((result: RouteResult, vesselProfile: VesselProfile) => { + const reportRequestId = routeWeatherRequestId.current + 1; + routeWeatherRequestId.current = reportRequestId; + setRouteWeatherReport(null); + setRouteWeatherError(null); + setRouteWeatherLoading(true); + + import("./routeWeatherReport") + .then(({ createRouteWeatherReport }) => + createRouteWeatherReport(result, vesselProfile, getMarineForecast, getMapFeatures) + ) + .then((report) => { + if (routeWeatherRequestId.current === reportRequestId) { + setRouteWeatherReport(report); + } + }) + .catch((error) => { + if (routeWeatherRequestId.current === reportRequestId) { + setRouteWeatherError(error instanceof Error ? error.message : "Wetterbericht nicht erreichbar"); + } + }) + .finally(() => { + if (routeWeatherRequestId.current === reportRequestId) { + setRouteWeatherLoading(false); + } + }); + }, []); + + const handleRoute = useCallback( + async ({ + start, + destination: requestedDestination, + waypoints: requestedWaypoints, + departureTime, + vesselProfile + }: { + start: Coordinate; + destination: Coordinate; + waypoints: Coordinate[]; + departureTime: string; + vesselProfile: VesselProfile; + }) => { + if (!start) { + setRouteError("Startpunkt fehlt"); + return; + } + + setRouteLoading(true); + setRouteError(null); + setRoute(null); + setRouteOptions([]); + setActiveVesselProfile(null); + setRouteWeatherReport(null); + setRouteWeatherError(null); + setRouteWeatherLoading(false); + routeWeatherRequestId.current += 1; + const requestId = routeRequestId.current + 1; + routeRequestId.current = requestId; + + try { + const result = await createRoute({ + start, + destination: requestedDestination, + waypoints: requestedWaypoints, + departureTime, + vesselProfile + }); + if (routeRequestId.current !== requestId) { + return; + } + const options: RouteResult[] = [result, ...(result.alternatives ?? [])]; + setRoute(result); + setRouteOptions(options); + setActiveVesselProfile(vesselProfile); + loadRouteWeather(result, vesselProfile); + } catch (error) { + if (routeRequestId.current !== requestId) { + return; + } + setRouteError(error instanceof Error ? error.message : "Route nicht berechenbar"); + routeWeatherRequestId.current += 1; + } finally { + if (routeRequestId.current === requestId) { + setRouteLoading(false); + } + } + }, + [loadRouteWeather] + ); + + const clearRouteOutcome = useCallback(() => { + routeRequestId.current += 1; + routeWeatherRequestId.current += 1; + setRoute(null); + setRouteOptions([]); + setActiveVesselProfile(null); + setRouteError(null); + setRouteWeatherReport(null); + setRouteWeatherError(null); + setRouteWeatherLoading(false); + setRouteLoading(false); + }, []); + + const setRouteStartPoint = useCallback((coordinate: Coordinate) => { + setRouteStart(coordinate); + clearRouteOutcome(); + }, [clearRouteOutcome]); + + const setRouteDestinationPoint = useCallback((coordinate: Coordinate) => { + setDestination(coordinate); + clearRouteOutcome(); + }, [clearRouteOutcome]); + + const suppressMapToolsAfterPick = useCallback(() => { + setMapToolsSuppressed(true); + if (mapToolsResumeTimer.current !== null) { + window.clearTimeout(mapToolsResumeTimer.current); + } + mapToolsResumeTimer.current = window.setTimeout(() => { + setMapToolsSuppressed(false); + mapToolsResumeTimer.current = null; + }, 400); + }, []); + + useEffect(() => () => { + if (mapToolsResumeTimer.current !== null) { + window.clearTimeout(mapToolsResumeTimer.current); + } + }, []); + + const handleMapPick = useCallback( + (coordinate: Coordinate) => { + if (pickMode === "start") { + suppressMapToolsAfterPick(); + setRouteStartPoint(coordinate); + setPickMode(null); + setWorkspaceSheetState("half"); + return; + } + + if (pickMode === "destination") { + suppressMapToolsAfterPick(); + setRouteDestinationPoint(coordinate); + setPickMode(null); + setWorkspaceSheetState("half"); + return; + } + + if (pickMode === "waypoint") { + suppressMapToolsAfterPick(); + setWaypoints((current) => [...current, coordinate]); + clearRouteOutcome(); + setPickMode(null); + setWorkspaceSheetState("half"); + } + }, + [ + clearRouteOutcome, + pickMode, + setRouteDestinationPoint, + setRouteStartPoint, + suppressMapToolsAfterPick + ] + ); + + const useGpsAsStart = useCallback(() => { + if (!gps.position) { + setRouteError("GPS-Position fehlt. Starte Navigation oder setze den Startpunkt manuell."); + return; + } + + setRouteStartPoint(gps.position); + setPickMode(null); + setWorkspaceSheetState("half"); + }, [gps.position, setRouteStartPoint]); + + const selectRouteOption = useCallback((routeId: string) => { + const selected = routeOptions.find((option) => option.id === routeId); + if (!selected) { + return; + } + + setRoute(selected); + if (activeVesselProfile) { + loadRouteWeather(selected, activeVesselProfile); + } + }, [activeVesselProfile, loadRouteWeather, routeOptions]); + + const clearStart = useCallback(() => { + setRouteStart(null); + clearRouteOutcome(); + setPickMode((current) => (current === "start" ? null : current)); + }, [clearRouteOutcome]); + + const clearDestination = useCallback(() => { + setDestination(null); + clearRouteOutcome(); + setPickMode((current) => (current === "destination" ? null : current)); + }, [clearRouteOutcome]); + + const removeWaypoint = useCallback((index: number) => { + setWaypoints((current) => current.filter((_, waypointIndex) => waypointIndex !== index)); + clearRouteOutcome(); + }, [clearRouteOutcome]); + + const moveWaypoint = useCallback((index: number, direction: -1 | 1) => { + setWaypoints((current) => { + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= current.length) { + return current; + } + const next = [...current]; + const [waypoint] = next.splice(index, 1); + if (!waypoint) { + return current; + } + next.splice(targetIndex, 0, waypoint); + return next; + }); + clearRouteOutcome(); + }, [clearRouteOutcome]); + + const loadOfflineVoyage = useCallback((voyage: OfflineVoyage) => { + routeRequestId.current += 1; + routeWeatherRequestId.current += 1; + setRouteStart(voyage.plan.start); + setDestination(voyage.plan.destination); + setWaypoints(voyage.plan.waypoints); + const offlineRoute = voyage.plan.departureAt && !voyage.route.departureTime + ? { ...voyage.route, departureTime: voyage.plan.departureAt } + : voyage.route; + setRoute(offlineRoute); + setRouteOptions([offlineRoute]); + setActiveVesselProfile(voyage.plan.vesselProfile); + setRouteError(null); + setRouteLoading(false); + setRouteWeatherReport(null); + setRouteWeatherError(null); + setRouteWeatherLoading(false); + setPickMode(null); + + if (voyage.plan.vesselProfile && navigator.onLine) { + loadRouteWeather(offlineRoute, voyage.plan.vesselProfile); + } + }, [loadRouteWeather]); + + const uiMode = anchorWatch.phase !== "idle" + ? "anchor" + : courseAssistant.active + ? "guidance" + : route + ? "route" + : "planning"; + const systemMessages = Array.from( + new Set( + [configError, gps.message, marineData.error].filter( + (message): message is string => Boolean(message) + ) + ) + ); + const gpsButtonLabel = + gps.status === "tracking" + ? "GPS aktiv" + : gps.status === "requesting" + ? "GPS wird gestartet" + : "GPS starten"; + const workspaceSummary = + activeTool === "anchor" + ? anchorWorkspaceSummary(anchorWatch) + : activeTool === "conditions" + ? conditionsWorkspaceSummary(marineData.forecast, marineData.tide) + : activeTool === "upcoming" + ? upcomingWorkspaceSummary(routeEvents, routeProgress.source, Boolean(route)) + : activeTool === "route" + ? routeWorkspaceSummary(route, courseAssistant.active, activeGuidance) + : null; + const workspaceBusy = + activeTool === "conditions" + ? marineData.loading || routeWeatherLoading || routeTidesLoading + : activeTool === "upcoming" + ? routeHarboursLoading || routeWeatherLoading + : activeTool === "route" + ? routeLoading + : false; + + return ( +
0} + data-pick-mode={pickMode ?? "none"} + data-map-tools-suppressed={mapToolsSuppressed} + > + {config ? ( + } + failed={ + + } + > + + + ) : ( + + )} + +
+
+
+ +
+ + + + + + + {activeTool === "anchor" && ( + } + failed={} + > + + + )} + + {activeTool === "conditions" && ( + } + failed={} + > + Boolean(message)) + .join(" · ") || marineData.error + } + routeWeatherReport={routeWeatherReport} + routeTides={routeTides} + routeLoading={routeWeatherLoading || routeTidesLoading} + routeError={routeWeatherError ?? routeTidesError} + /> + + )} + + {activeTool === "upcoming" && ( + } + failed={} + > + + + )} + + {activeTool === "route" && ( + courseAssistant.active ? ( + } + failed={} + > + + + ) : ( + } + failed={} + > + toggleMapPick("start")} + onPickDestination={() => toggleMapPick("destination")} + onPickWaypoint={() => toggleMapPick("waypoint")} + onRemoveWaypoint={removeWaypoint} + onMoveWaypoint={moveWaypoint} + onClearStart={clearStart} + onClearDestination={clearDestination} + onUseGpsAsStart={useGpsAsStart} + onSelectRoute={selectRouteOption} + guidanceActive={courseAssistant.active} + onStartGuidance={startCourseAssistant} + onCollapse={closeWorkspace} + operationalPanelsVisible={false} + embedded + /> + + ) + )} + + + {(anchorAlarm || guidanceAlarm) && ( +
+
+ )} + + {systemMessages.length > 0 && ( +
+
+ )} + +
+
+ + +
+ ); +} + +function MapLoadState({ + pickMode, + message, + failed = false +}: { + pickMode: PickMode; + message: string; + failed?: boolean; +}) { + return ( +
+
+
+ {message} + {failed && ( + + )} +
+
+ ); +} + +function WorkspaceLoadState({ label, failed = false }: { label: string; failed?: boolean }) { + return ( +
+ {label} + + {failed ? "Modul konnte nicht geladen werden." : "Modul wird geladen …"} + + {failed && ( + + )} +
+ ); +} + +function navigationWaterwaysForRoute(route: RouteResult): string[] { + const sources = route.dataSources.join(" ").toLowerCase(); + if (sources.includes("emden-hamm")) { + return ["EMS", "DEK", "DHK"]; + } + if (sources.includes("ems-borkum") || sources.includes("borkum")) { + return ["EMS", "NORDSEE"]; + } + return []; +} + +function fulfilledValue(result: PromiseSettledResult): T | null { + return result.status === "fulfilled" ? result.value : null; +} + +function conditionsToolStatus( + forecastUpdatedAt: string | null | undefined, + tideUpdatedAt: string | null | undefined, + routeSeverity: RouteWeatherReport["severity"] | undefined, + loading: boolean +): NavigationToolStatus { + if (routeSeverity === "critical") { + return "alarm"; + } + if (routeSeverity === "caution") { + return "caution"; + } + const timestamps = [forecastUpdatedAt, tideUpdatedAt] + .map((value) => value ? Date.parse(value) : Number.NaN); + if (timestamps.some((timestamp) => !Number.isFinite(timestamp))) { + return loading ? "active" : "stale"; + } + const oldestTimestamp = Math.min(...timestamps); + return Date.now() - oldestTimestamp > 90 * 60_000 ? "stale" : "active"; +} + +function anchorWorkspaceSummary(watch: ReturnType) { + const distanceM = watch.watchResult?.distanceFromAnchorM; + if (watch.phase === "armed") { + return `${typeof distanceM === "number" ? `${Math.round(distanceM)} m` : "Position offen"} / ${Math.round(watch.settings.alarmRadiusM)} m · Wache aktiv`; + } + if (watch.phase === "set") { + return "Ankerpunkt gesetzt · Wache einrichten"; + } + return "Ankerpunkt setzen und Schwojkreis überwachen"; +} + +function conditionsWorkspaceSummary( + forecast: MarineForecast | null, + tide: TideSummary | null +) { + const parts: string[] = []; + if (typeof forecast?.windSpeed === "number") { + parts.push(`Wind ${Math.round(forecast.windSpeed)} kn`); + } + if (typeof forecast?.waveHeightM === "number") { + parts.push(`Welle ${forecast.waveHeightM.toFixed(1)} m`); + } + const tideEvent = [tide?.nextHigh, tide?.nextLow] + .filter((event): event is NonNullable => Boolean(event)) + .sort((left, right) => Date.parse(left.time) - Date.parse(right.time))[0]; + if (tideEvent) { + parts.push( + `${tideEvent.type === "high" ? "HW" : "NW"} ${formatClockTime(tideEvent.time)}` + ); + } + return parts.length > 0 ? parts.join(" · ") : "GPS-Wetter und Streckenbedingungen"; +} + +function upcomingWorkspaceSummary( + events: readonly UpcomingRouteEvent[], + progressSource: "guidance" | "gps" | "route-start", + hasRoute: boolean +) { + if (!hasRoute) { + return "Route planen, um Ereignisse zu sehen"; + } + const next = events[0]; + if (!next) { + return "Keine weiteren Ereignisse im Routenkorridor"; + } + const sourceHint = progressSource === "route-start" ? " · ab Routenstart" : ""; + return `${routeEventKindLabel(next.kind)} ${next.name} · ${formatEventDistance(next.remainingNm)}${sourceHint}`; +} + +function routeWorkspaceSummary( + route: RouteResult | null, + guidanceActive: boolean, + guidance: RouteGuidanceResult | null +) { + if (!route) { + return "Start, Ziel und Bootsprofil festlegen"; + } + if (guidanceActive) { + const remainingNm = guidance ? guidance.remainingRouteDistanceM / 1_852 : null; + return remainingNm === null + ? "Navigation aktiv · GPS-Fix wird ermittelt" + : `Navigation aktiv · ${formatEventDistance(remainingNm)} verbleibend`; + } + return `${route.name ?? "Geplante Route"} · ${route.distanceNm.toFixed(1)} sm`; +} + +function routeEventKindLabel(kind: UpcomingRouteEvent["kind"]) { + if (kind === "harbour") return "Hafen"; + if (kind === "lock") return "Schleuse"; + return "Brücke"; +} + +function formatEventDistance(distanceNm: number) { + if (distanceNm < 0.1) { + return `${Math.round(distanceNm * 1_852)} m`; + } + return `${distanceNm.toFixed(distanceNm < 10 ? 1 : 0)} sm`; +} + +function formatClockTime(value: string) { + const date = new Date(value); + return Number.isFinite(date.getTime()) + ? date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + : "--:--"; +} + +function coordinateAlongRoute( + route: Pick, + ratio: number +): Coordinate | null { + const points = route.geometry.coordinates.map(([lon, lat]) => ({ lon, lat })); + const first = points[0]; + if (!first) { + return null; + } + if (points.length === 1) { + return first; + } + const segments = points.slice(1).map((point, index) => ({ + from: points[index]!, + to: point, + lengthNm: haversineDistanceNm(points[index]!, point) + })); + const totalNm = segments.reduce((sum, segment) => sum + segment.lengthNm, 0); + if (totalNm <= 0) { + return first; + } + const targetNm = Math.max(0, Math.min(1, ratio)) * totalNm; + let travelledNm = 0; + for (const segment of segments) { + if (travelledNm + segment.lengthNm >= targetNm) { + const fraction = + segment.lengthNm <= 0 ? 0 : (targetNm - travelledNm) / segment.lengthNm; + return { + lat: segment.from.lat + (segment.to.lat - segment.from.lat) * fraction, + lon: segment.from.lon + (segment.to.lon - segment.from.lon) * fraction + }; + } + travelledNm += segment.lengthNm; + } + return points.at(-1) ?? first; +} + +function midpointIso(start: string | undefined, end: string | null) { + const startTimestamp = start ? Date.parse(start) : Number.NaN; + const endTimestamp = end ? Date.parse(end) : Number.NaN; + return Number.isFinite(startTimestamp) && Number.isFinite(endTimestamp) + ? new Date(startTimestamp + (endTimestamp - startTimestamp) / 2).toISOString() + : undefined; +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts new file mode 100644 index 0000000..2088f2c --- /dev/null +++ b/apps/web/src/api.ts @@ -0,0 +1,93 @@ +import type { + AppConfig, + Coordinate, + MarineForecast, + NavigationDataSnapshot, + RouteRequest, + RouteResult, + TideSummary +} from "@watermaps/shared"; +import type { FeatureCollection } from "geojson"; + +async function getJson(url: string, init?: RequestInit): Promise { + 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(url: string, body: unknown): Promise { + 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 { + return getJson("/api/config"); +} + +export function getMarineForecast(position: Coordinate, at?: string): Promise { + const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) }); + if (at) { + search.set("at", at); + } + return getJson(`/api/weather/marine?${search.toString()}`); +} + +export function getNearestTide(position: Coordinate, at?: string): Promise { + const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) }); + if (at) { + search.set("at", at); + } + return getJson(`/api/tides/nearest?${search.toString()}`); +} + +export function getNavigationData(params: { + waterways?: string[]; + stationIds?: string[]; + lockIds?: string[]; +}): Promise { + 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(`/api/navigation/live?${search.toString()}`); +} + +export function createRoute(request: RouteRequest): Promise { + return postJson("/api/routes", request); +} + +export function getMapFeatures(params: { + bbox: [number, number, number, number]; + layers: string[]; + signal?: AbortSignal; +}): Promise { + const search = new URLSearchParams({ + bbox: params.bbox.join(","), + layers: params.layers.join(",") + }); + return getJson(`/api/features?${search.toString()}`, { signal: params.signal }); +} diff --git a/apps/web/src/components/AnchorWatchPanel.css b/apps/web/src/components/AnchorWatchPanel.css new file mode 100644 index 0000000..01bebf6 --- /dev/null +++ b/apps/web/src/components/AnchorWatchPanel.css @@ -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; + } +} diff --git a/apps/web/src/components/AnchorWatchPanel.tsx b/apps/web/src/components/AnchorWatchPanel.tsx new file mode 100644 index 0000000..05d6f19 --- /dev/null +++ b/apps/web/src/components/AnchorWatchPanel.tsx @@ -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; + +export type AnchorWatchPanelProps = { + watch: AnchorWatchModel; + gps: Pick; + 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 ( + + ); +} + +function AnchorCapture({ + watch, + gps, + onStartGps +}: { + watch: AnchorWatchModel; + gps: AnchorWatchPanelProps["gps"]; + onStartGps: () => void; +}) { + const gpsReady = gps.status === "tracking" && gps.accuracyM !== null; + return ( +
+

+ Setze den Punkt genau dann, wenn der Anker den Grund erreicht. Watermaps verschiebt ihn danach nicht mit dem Boot. +

+
+
+ {gps.status !== "tracking" && ( + + )} + + {watch.operationError &&

{watch.operationError}

} + + Keine automatische Ankererkennung: Ein Browser-GPS kann das Fallenlassen nicht zuverlässig erkennen. + +
+ ); +} + +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 ( +
+
+
+ + + + + {radiusTooSmall && ( +

+

+ )} + {watch.settingsError &&

{watch.settingsError}

} + {watch.operationError &&

{watch.operationError}

} + +
+ + +
+ + Tidendaten und Scope-Rechnung sind Planungshilfen. Grund, Anker, Wind, Wellen, Strom, Schwell und Abstand zu Gefahren müssen vor Ort beurteilt werden. + +
+ ); +} + +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 ( +
+ {fields.map((field) => ( + + ))} + + +
+ ); +} + +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 ( +
+
+ +
+ {active ? "TIDE AB JETZT" : "TIDE AB ANKERSETZEN"} + {watch.tideLoading && !watch.tide ? ( + + ) : tideWindow?.maximumRiseM !== null && tideWindow?.maximumRiseM !== undefined ? ( + max. +{tideWindow.maximumRiseM.toFixed(2)} m · Hub {formatNullable(tideWindow.tidalRangeM)} m + ) : ( + Nicht berechenbar + )} + + {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"} + + {tideWindow?.coverage === "partial" && Prognose deckt den gewählten Zeitraum nur teilweise ab.} + {stationFar && Entfernter Pegel: lokale Tide kann deutlich abweichen.} +
+
+ +
+ + {plan?.calculationComplete && plan.hasSufficientRode + ? +
+ ANKERLEINEN-RESERVE + {plan?.calculationComplete && plan.requiredRodeLengthM !== null && plan.rodeReserveM !== null ? ( + <> + {plan.rodeReserveM >= 0 ? "+" : ""}{plan.rodeReserveM.toFixed(1)} m Reserve + Rechnerisch {plan.requiredRodeLengthM.toFixed(1)} m bei {watch.settings.scopeRatio}:1 erforderlich + + ) : plan ? ( + <> + Nicht bestätigt + Ohne vollständige Tide mindestens {plan.minimumRequiredRodeLengthM.toFixed(1)} m; Zukunftsbedarf offen + + ) : ( + Eingaben prüfen + )} +
+
+
+ ); +} + +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 ( +
+
+ + {status.tone === "safe" ? +
+ ABSTAND / ALARMRADIUS + {distance === null || distance === undefined ? "---" : Math.round(distance)} / {Math.round(watch.settings.alarmRadiusM)} m +
+
+ +

+ {status.title} + {status.detail} +

+ +
+ GPS{watch.watchResult?.accuracyM === null || watch.watchResult?.accuracyM === undefined ? "--" : `±${Math.round(watch.watchResult.accuracyM)} m`} + SEIT{formatDuration(elapsedMs)} + TIDE NOCH{remainingWindow?.coverage === "complete" && remainingWindow.maximumRiseM !== null ? `+${remainingWindow.maximumRiseM.toFixed(2)} m` : "offen"} + LEINE{watch.rodePlan?.rodeReserveM === null || watch.rodePlan?.rodeReserveM === undefined ? "offen" : `${watch.rodePlan.rodeReserveM >= 0 ? "+" : ""}${watch.rodePlan.rodeReserveM.toFixed(1)} m`} +
+ + {watch.positionAlarm && !watch.alarmAcknowledged && ( + + )} + {watch.positionAlarm && watch.alarmAcknowledged && ( +

Alarmton quittiert · rote Warnanzeige bleibt aktiv

+ )} + {watch.rodeShortfall && ( +

+ Nach der aktuellen Stationsprognose ist die eingegebene Kette/Leine rechnerisch zu kurz. +

+ )} + {nearLimit &&

80 % des Alarmradius erreicht.

} + +
+ Radius, Tide und Leinenrechnung + + +
+ +
+ {confirmStop && } + +
+ + 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. + +
+ ); +} + +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); +} diff --git a/apps/web/src/components/CompassDial.tsx b/apps/web/src/components/CompassDial.tsx new file mode 100644 index 0000000..5db2ee6 --- /dev/null +++ b/apps/web/src/components/CompassDial.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/ConditionsPanel.css b/apps/web/src/components/ConditionsPanel.css new file mode 100644 index 0000000..7b30a77 --- /dev/null +++ b/apps/web/src/components/ConditionsPanel.css @@ -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; +} diff --git a/apps/web/src/components/ConditionsPanel.tsx b/apps/web/src/components/ConditionsPanel.tsx new file mode 100644 index 0000000..366f400 --- /dev/null +++ b/apps/web/src/components/ConditionsPanel.tsx @@ -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; + 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 ( + + ); +} + +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 ( +

+ {source.kind === "gps" ? ( +

+ ); +} + +function SectionHeading({ + id, + icon, + children +}: { + id: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( +

+ {icon} + {children} +

+ ); +} + +function Metric({ + icon, + label, + value +}: { + icon: ReactNode; + label: string; + value: string; +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} + +function TideEventRow({ + label, + shortLabel, + event +}: { + label: string; + shortLabel: "HW" | "NW"; + event: TideEvent | null; +}) { + return ( +
+
+ {shortLabel} +
+
+ {event ? ( + <> + {isValidDate(event.time) ? ( + + ) : ( + Zeit offen + )} + {formatTideHeight(event.heightM)} + + ) : ( + Zeit und Höhe offen + )} +
+
+ ); +} + +function DataProvenance({ + source, + updatedAt, + validAt, + now +}: { + source?: string | null; + updatedAt?: string | null; + validAt?: string | null; + now: number; +}) { + return ( +

+ Quelle: {safeText(source, "nicht angegeben")} + + Stand:{" "} + {updatedAt && isValidDate(updatedAt) ? ( + + ) : ( + "Zeit unbekannt" + )} + + {validAt && isValidDate(validAt) && ( + + Gültig: + + )} +

+ ); +} + +function RouteAssessment({ + report, + now +}: { + report: ConditionsRouteWeatherReport; + now: number; +}) { + const unavailableSamples = finiteNumber(report.unavailableSamples); + const hasUnavailableSamples = unavailableSamples !== null && unavailableSamples > 0; + + return ( +
+ {(report.severity || report.summary) && ( +

+ {report.severity && {severityLabel(report.severity)}: } + {report.summary || "Streckenbedingungen teilweise verfügbar."} +

+ )} + {hasUnavailableSamples && ( +

+

+ )} + {(report.source || report.updatedAt) && ( + + )} +
+ ); +} + +function RouteSampleCard({ + label, + sample, + tide, + routeTidesAvailable, + now +}: { + label: (typeof ROUTE_SAMPLE_LABELS)[number]; + sample: ConditionsRouteWeatherSample | null; + tide: TideSummary | null; + routeTidesAvailable: boolean; + now: number; +}) { + return ( +
+
+

{label}

+ {sample?.plannedTime && isValidDate(sample.plannedTime) && ( + + )} +
+ + {sample ? ( + <> +
+
+ + + ) : ( +

Keine Wetterprognose für diesen Streckenpunkt.

+ )} + + {tide ? ( +
+ + {safeText(tide.station, "Tidenstation")} · {formatDistanceKm(tide.distanceKm)} + + {formatCompactTideEvent("HW", tide.nextHigh)} + {formatCompactTideEvent("NW", tide.nextLow)} +
+ ) : ( + routeTidesAvailable && ( +

Keine passende Tide für {label.toLowerCase()}.

+ ) + )} +
+ ); +} + +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) { + switch (severity) { + case "critical": + return "Kritisch"; + case "caution": + return "Achtung"; + case "ok": + return "Unauffällig"; + } +} + +function joinMeasurements(...parts: Array) { + 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) { + return values.filter(Boolean).join(" "); +} diff --git a/apps/web/src/components/CourseAssistantPanel.css b/apps/web/src/components/CourseAssistantPanel.css new file mode 100644 index 0000000..4981f81 --- /dev/null +++ b/apps/web/src/components/CourseAssistantPanel.css @@ -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; + } +} diff --git a/apps/web/src/components/CourseAssistantPanel.tsx b/apps/web/src/components/CourseAssistantPanel.tsx new file mode 100644 index 0000000..560610b --- /dev/null +++ b/apps/web/src/components/CourseAssistantPanel.tsx @@ -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 ( + + ); +} + +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["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`; +} diff --git a/apps/web/src/components/LazyContent.tsx b/apps/web/src/components/LazyContent.tsx new file mode 100644 index 0000000..67f21ca --- /dev/null +++ b/apps/web/src/components/LazyContent.tsx @@ -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 ( + + {children} + + ); +} + +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; + } +} diff --git a/apps/web/src/components/MapView.tsx b/apps/web/src/components/MapView.tsx new file mode 100644 index 0000000..abe84ac --- /dev/null +++ b/apps/web/src/components/MapView.tsx @@ -0,0 +1,1591 @@ +import maplibregl, { type GeoJSONSource, type Map as MapLibreMap } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import type { Feature, FeatureCollection, Geometry, Position } from "geojson"; +import { Layers, LocateFixed } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { AppConfig, Coordinate, RouteResult } from "@watermaps/shared"; +import { getMapFeatures } from "../api"; +import { MarineFeatureInfo, type MarineFeatureDetails } from "./MarineFeatureInfo"; + +type MapViewProps = { + config: AppConfig | null; + position: Coordinate | null; + accuracyM: number | null; + startPoint: Coordinate | null; + destination: Coordinate | null; + waypoints?: Coordinate[]; + pickMode: "start" | "destination" | "waypoint" | null; + route: RouteResult | null; + guidanceActive?: boolean; + guidanceTarget?: Coordinate | null; + anchorPoint?: Coordinate | null; + anchorAlarmRadiusM?: number | null; + anchorWatchActive?: boolean; + anchorAlarm?: boolean; + focusRequest?: { + key: string; + coordinate: Coordinate; + zoom?: number; + } | null; + onPickCoordinate: (coordinate: Coordinate) => void; + onMapReady: () => void; +}; + +const MARINE_OVERLAYS = [ + { id: "bridges", name: "Brücken", defaultVisible: true }, + { id: "depths", name: "Tiefen", defaultVisible: true }, + { id: "locks", name: "Schleusen", defaultVisible: true }, + { id: "harbours", name: "Häfen", defaultVisible: true } +] as const; + +const MARINE_OVERLAY_LAYER_IDS: Record = { + bridges: ["bridge-lines", "bridge-labels"], + depths: ["depth-lines", "depth-labels"], + locks: ["lock-info-circles", "lock-info-symbols"], + harbours: ["harbour-info-circles", "harbour-info-symbols"] +}; +const CONTACT_SOURCE_ID = "marine-contact-pois"; +const CONTACT_CLUSTER_LAYER_IDS = ["contact-clusters", "contact-cluster-count"] as const; +const CONTACT_INTERACTIVE_LAYER_IDS = ["contact-clusters", "lock-info-circles", "harbour-info-circles"] as const; +const MARINE_LABEL_LAYER_IDS = [ + "depth-labels", + "bridge-labels", + ...CONTACT_CLUSTER_LAYER_IDS, + "lock-info-circles", + "harbour-info-circles", + "lock-info-symbols", + "harbour-info-symbols" +] as const; + +const MIN_FEATURE_ZOOM = 8; +const MIN_BRIDGE_ZOOM = 10; +const MIN_CONTACT_POI_ZOOM = 12; +const CONTACT_CLUSTER_MAX_ZOOM = 14; + +export function MapView({ + config, + position, + accuracyM, + startPoint, + destination, + waypoints = [], + pickMode, + route, + guidanceActive = false, + guidanceTarget = null, + anchorPoint = null, + anchorAlarmRadiusM = null, + anchorWatchActive = false, + anchorAlarm = false, + focusRequest = null, + onPickCoordinate, + onMapReady +}: MapViewProps) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const hasCenteredOnFirstFixRef = useRef(false); + const marineFeatureRequestIdRef = useRef(0); + const marineFeatureAbortControllerRef = useRef(null); + const contactFeaturesRef = useRef([]); + const contactZoomActiveRef = useRef(false); + const pickModeRef = useRef(pickMode); + const onPickCoordinateRef = useRef(onPickCoordinate); + const startPointRef = useRef(startPoint); + const destinationRef = useRef(destination); + const waypointsRef = useRef(waypoints); + const routeRef = useRef(route); + const positionRef = useRef(position); + const accuracyMRef = useRef(accuracyM); + const guidanceActiveRef = useRef(guidanceActive); + const guidanceTargetRef = useRef(guidanceTarget); + const anchorPointRef = useRef(anchorPoint); + const anchorAlarmRadiusMRef = useRef(anchorAlarmRadiusM); + const anchorWatchActiveRef = useRef(anchorWatchActive); + const anchorAlarmRef = useRef(anchorAlarm); + const focusRequestRef = useRef(focusRequest); + const lastAnchorFocusKeyRef = useRef(null); + const [layersOpen, setLayersOpen] = useState(false); + const [visibleLayers, setVisibleLayers] = useState>({}); + const [accessibleMarineFeatures, setAccessibleMarineFeatures] = useState([]); + const [selectedMarineFeature, setSelectedMarineFeature] = useState(null); + + useEffect(() => { + focusRequestRef.current = focusRequest; + applyMapFocusRequest(mapRef.current, focusRequest); + }, [focusRequest]); + + const clearContactFeatures = useCallback((map: MapLibreMap) => { + const hadContactFeatures = contactFeaturesRef.current.length > 0; + contactFeaturesRef.current = []; + if (hadContactFeatures) { + const source = map.getSource(CONTACT_SOURCE_ID) as GeoJSONSource | undefined; + source?.setData(emptyFeatureCollection()); + setAccessibleMarineFeatures([]); + } + setSelectedMarineFeature((current) => (current ? null : current)); + }, []); + + useEffect(() => { + pickModeRef.current = pickMode; + onPickCoordinateRef.current = onPickCoordinate; + startPointRef.current = startPoint; + destinationRef.current = destination; + waypointsRef.current = waypoints; + routeRef.current = route; + positionRef.current = position; + accuracyMRef.current = accuracyM; + guidanceActiveRef.current = guidanceActive; + guidanceTargetRef.current = guidanceTarget; + anchorPointRef.current = anchorPoint; + anchorAlarmRadiusMRef.current = anchorAlarmRadiusM; + anchorWatchActiveRef.current = anchorWatchActive; + anchorAlarmRef.current = anchorAlarm; + }, [ + anchorAlarm, + anchorAlarmRadiusM, + anchorPoint, + anchorWatchActive, + accuracyM, + destination, + guidanceActive, + guidanceTarget, + onPickCoordinate, + pickMode, + position, + route, + startPoint, + waypoints + ]); + + useEffect(() => { + if (!containerRef.current || !config || mapRef.current) { + return; + } + + const styleLayer = config.layers.find((layer) => layer.kind === "style"); + const map = new maplibregl.Map({ + container: containerRef.current, + style: styleLayer?.kind === "style" ? styleLayer.url : "https://tiles.openfreemap.org/styles/bright", + center: [12.09, 54.18], + zoom: 9, + pitch: 0, + attributionControl: false + }); + + map.addControl(new maplibregl.AttributionControl({ compact: true }), "bottom-right"); + map.addControl(new maplibregl.ScaleControl({ unit: "nautical" }), "bottom-left"); + mapRef.current = map; + + map.on("load", () => { + const initialVisibility: Record = {}; + for (const layer of config.layers) { + initialVisibility[layer.id] = layer.defaultVisible; + if (layer.kind === "raster-tile" || layer.kind === "wms") { + map.addSource(layer.id, { + type: "raster", + tiles: [layer.tileUrl], + tileSize: 256, + attribution: layer.attribution + }); + map.addLayer({ + id: layer.id, + type: "raster", + source: layer.id, + layout: { + visibility: layer.defaultVisible ? "visible" : "none" + }, + paint: { + "raster-opacity": layer.opacity + } + }); + } + } + for (const layer of MARINE_OVERLAYS) { + initialVisibility[layer.id] = layer.defaultVisible; + } + + map.addSource("marine-features", { + type: "geojson", + data: emptyFeatureCollection() + }); + map.addSource(CONTACT_SOURCE_ID, { + type: "geojson", + data: emptyFeatureCollection(), + cluster: true, + clusterRadius: 48, + clusterMaxZoom: CONTACT_CLUSTER_MAX_ZOOM + }); + map.addLayer({ + id: "depth-lines", + type: "line", + source: "marine-features", + minzoom: MIN_FEATURE_ZOOM, + filter: ["==", ["get", "layer"], "depths"], + layout: { + visibility: initialVisibility.depths ? "visible" : "none", + "line-cap": "round", + "line-join": "round" + }, + paint: { + "line-color": "#18708a", + "line-width": ["interpolate", ["linear"], ["zoom"], 8, 1.5, 14, 4], + "line-opacity": 0.78 + } + }); + map.addLayer({ + id: "depth-labels", + type: "symbol", + source: "marine-features", + minzoom: 9, + filter: ["==", ["get", "layer"], "depths"], + layout: { + visibility: initialVisibility.depths ? "visible" : "none", + "symbol-placement": "line", + "symbol-spacing": 180, + "text-field": ["coalesce", ["get", "depth_label"], ["get", "label"]], + "text-size": ["interpolate", ["linear"], ["zoom"], 9, 11, 14, 14], + "text-allow-overlap": false, + "text-ignore-placement": false + }, + paint: { + "text-color": "#0b5168", + "text-halo-color": "#ffffff", + "text-halo-width": 1.3 + } + }); + map.addLayer({ + id: "bridge-lines", + type: "line", + source: "marine-features", + minzoom: 10, + filter: ["==", ["get", "layer"], "bridges"], + layout: { + visibility: initialVisibility.bridges ? "visible" : "none", + "line-cap": "round", + "line-join": "round" + }, + paint: { + "line-color": "#7b3f2a", + "line-width": ["interpolate", ["linear"], ["zoom"], 10, 2, 15, 6], + "line-opacity": 0.85 + } + }); + map.addLayer({ + id: "bridge-labels", + type: "symbol", + source: "marine-features", + minzoom: 11, + filter: ["all", ["==", ["get", "layer"], "bridges"], ["!=", ["get", "label"], null]], + layout: { + visibility: initialVisibility.bridges ? "visible" : "none", + "symbol-placement": "line", + "symbol-spacing": 220, + "text-field": ["get", "label"], + "text-size": ["interpolate", ["linear"], ["zoom"], 11, 11, 15, 14], + "text-offset": [0, -0.8], + "text-allow-overlap": true, + "text-ignore-placement": false + }, + paint: { + "text-color": "#5a2616", + "text-halo-color": "#fff8ee", + "text-halo-width": 2.2 + } + }); + map.addLayer({ + id: "contact-clusters", + type: "circle", + source: CONTACT_SOURCE_ID, + minzoom: MIN_CONTACT_POI_ZOOM, + filter: ["has", "point_count"], + layout: { + visibility: initialVisibility.locks || initialVisibility.harbours ? "visible" : "none" + }, + paint: { + "circle-color": "#173f4a", + "circle-radius": ["step", ["get", "point_count"], 20, 25, 24, 100, 29], + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 3, + "circle-opacity": 0.94 + } + }); + map.addLayer({ + id: "contact-cluster-count", + type: "symbol", + source: CONTACT_SOURCE_ID, + minzoom: MIN_CONTACT_POI_ZOOM, + filter: ["has", "point_count"], + layout: { + visibility: initialVisibility.locks || initialVisibility.harbours ? "visible" : "none", + "text-field": ["get", "point_count_abbreviated"], + "text-size": 13, + "text-allow-overlap": true, + "text-ignore-placement": true + }, + paint: { + "text-color": "#ffffff" + } + }); + map.addLayer({ + id: "lock-info-circles", + type: "circle", + source: CONTACT_SOURCE_ID, + minzoom: MIN_CONTACT_POI_ZOOM, + filter: ["all", ["!", ["has", "point_count"]], ["==", ["get", "layer"], "locks"]], + layout: { + visibility: initialVisibility.locks ? "visible" : "none" + }, + paint: { + "circle-color": "#8a561d", + "circle-radius": 20, + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 3, + "circle-opacity": 0.96 + } + }); + map.addLayer({ + id: "harbour-info-circles", + type: "circle", + source: CONTACT_SOURCE_ID, + minzoom: MIN_CONTACT_POI_ZOOM, + filter: ["all", ["!", ["has", "point_count"]], ["==", ["get", "layer"], "harbours"]], + layout: { + visibility: initialVisibility.harbours ? "visible" : "none" + }, + paint: { + "circle-color": "#0b6177", + "circle-radius": 20, + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 3, + "circle-opacity": 0.96 + } + }); + for (const [id, layer, visible] of [ + ["lock-info-symbols", "locks", initialVisibility.locks], + ["harbour-info-symbols", "harbours", initialVisibility.harbours] + ] as const) { + map.addLayer({ + id, + type: "symbol", + source: CONTACT_SOURCE_ID, + minzoom: MIN_CONTACT_POI_ZOOM, + filter: ["all", ["!", ["has", "point_count"]], ["==", ["get", "layer"], layer]], + layout: { + visibility: visible ? "visible" : "none", + "text-field": "i", + "text-size": 18, + "text-allow-overlap": true, + "text-ignore-placement": true + }, + paint: { + "text-color": "#ffffff" + } + }); + } + + map.addSource("user-position", { + type: "geojson", + data: emptyPoint() + }); + map.addLayer({ + id: "user-accuracy", + type: "circle", + source: "user-position", + paint: { + "circle-radius": ["interpolate", ["linear"], ["zoom"], 7, 8, 14, ["get", "radius"]], + "circle-color": "#5bb3ff", + "circle-opacity": 0.18, + "circle-stroke-color": "#1378ad", + "circle-stroke-width": 1 + } + }); + map.addLayer({ + id: "user-dot", + type: "circle", + source: "user-position", + paint: { + "circle-radius": 7, + "circle-color": "#0f4c5c", + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 2 + } + }); + map.addSource("route", { + type: "geojson", + data: routeRef.current ? routeFeature(routeRef.current) : emptyLine() + }); + map.addSource("route-guidance", { + type: "geojson", + data: guidanceFeatures( + guidanceActiveRef.current ? positionRef.current : null, + guidanceActiveRef.current ? guidanceTargetRef.current : null + ) + }); + map.addSource("anchor-watch", { + type: "geojson", + data: anchorWatchFeatures( + anchorPointRef.current, + anchorWatchActiveRef.current ? positionRef.current : null, + anchorAlarmRadiusMRef.current, + anchorWatchActiveRef.current, + anchorAlarmRef.current + ) + }); + map.addSource("start-point", { + type: "geojson", + data: startPointRef.current ? pointFeature(startPointRef.current) : emptyPoint() + }); + map.addSource("destination", { + type: "geojson", + data: destinationRef.current ? pointFeature(destinationRef.current) : emptyPoint() + }); + map.addSource("waypoints", { + type: "geojson", + data: waypointFeatures(waypointsRef.current) + }); + map.addLayer({ + id: "anchor-watch-radius-fill", + type: "fill", + source: "anchor-watch", + filter: ["==", ["get", "kind"], "radius"], + paint: { + "fill-color": ["case", ["==", ["get", "alarm"], true], "#c44a30", "#0f6f88"], + "fill-opacity": 0.12 + } + }); + map.addLayer({ + id: "route-line", + type: "line", + source: "route", + paint: { + "line-width": 5, + "line-color": "#d89c28", + "line-opacity": 0.95 + } + }); + map.addLayer({ + id: "route-guidance-line", + type: "line", + source: "route-guidance", + filter: ["==", ["get", "kind"], "line"], + layout: { + "line-cap": "round", + "line-join": "round" + }, + paint: { + "line-width": 4, + "line-color": "#0f6f88", + "line-dasharray": [1.5, 1.5], + "line-opacity": 0.92 + } + }); + map.addLayer({ + id: "route-guidance-target-halo", + type: "circle", + source: "route-guidance", + filter: ["==", ["get", "kind"], "target"], + paint: { + "circle-radius": 13, + "circle-color": "#ffffff", + "circle-opacity": 0.9, + "circle-stroke-color": "#0f6f88", + "circle-stroke-width": 3 + } + }); + map.addLayer({ + id: "route-guidance-target", + type: "circle", + source: "route-guidance", + filter: ["==", ["get", "kind"], "target"], + paint: { + "circle-radius": 5, + "circle-color": "#0f6f88", + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 1 + } + }); + map.addLayer({ + id: "anchor-watch-radius-line", + type: "line", + source: "anchor-watch", + filter: ["==", ["get", "kind"], "radius"], + layout: { + "line-cap": "round", + "line-join": "round" + }, + paint: { + "line-width": 3, + "line-color": ["case", ["==", ["get", "alarm"], true], "#c44a30", "#0f6f88"], + "line-dasharray": [2, 1.5], + "line-opacity": 0.95 + } + }); + map.addLayer({ + id: "anchor-watch-distance-line", + type: "line", + source: "anchor-watch", + filter: ["==", ["get", "kind"], "distance"], + layout: { + "line-cap": "round", + "line-join": "round" + }, + paint: { + "line-width": 3, + "line-color": ["case", ["==", ["get", "alarm"], true], "#c44a30", "#d89c28"], + "line-opacity": 0.9 + } + }); + map.addLayer({ + id: "anchor-watch-halo", + type: "circle", + source: "anchor-watch", + filter: ["==", ["get", "kind"], "anchor"], + paint: { + "circle-radius": 14, + "circle-color": "#ffffff", + "circle-opacity": 0.94, + "circle-stroke-color": ["case", ["==", ["get", "alarm"], true], "#c44a30", "#d89c28"], + "circle-stroke-width": 3 + } + }); + map.addLayer({ + id: "anchor-watch-point", + type: "circle", + source: "anchor-watch", + filter: ["==", ["get", "kind"], "anchor"], + paint: { + "circle-radius": 6, + "circle-color": ["case", ["==", ["get", "alarm"], true], "#c44a30", "#d89c28"], + "circle-stroke-color": "#10242b", + "circle-stroke-width": 1 + } + }); + map.addLayer({ + id: "start-halo", + type: "circle", + source: "start-point", + paint: { + "circle-radius": 15, + "circle-color": "#ffffff", + "circle-opacity": 0.9, + "circle-stroke-color": "#196f5c", + "circle-stroke-width": 3 + } + }); + map.addLayer({ + id: "start-point", + type: "circle", + source: "start-point", + paint: { + "circle-radius": 6, + "circle-color": "#196f5c", + "circle-stroke-color": "#10242b", + "circle-stroke-width": 1 + } + }); + map.addLayer({ + id: "destination-halo", + type: "circle", + source: "destination", + paint: { + "circle-radius": 15, + "circle-color": "#ffffff", + "circle-opacity": 0.9, + "circle-stroke-color": "#d89c28", + "circle-stroke-width": 3 + } + }); + map.addLayer({ + id: "destination-point", + type: "circle", + source: "destination", + paint: { + "circle-radius": 6, + "circle-color": "#d89c28", + "circle-stroke-color": "#10242b", + "circle-stroke-width": 1 + } + }); + map.addLayer({ + id: "waypoint-halo", + type: "circle", + source: "waypoints", + paint: { + "circle-radius": 13, + "circle-color": "#ffffff", + "circle-opacity": 0.92, + "circle-stroke-color": "#246b85", + "circle-stroke-width": 3 + } + }); + map.addLayer({ + id: "waypoint-point", + type: "circle", + source: "waypoints", + paint: { + "circle-radius": 6, + "circle-color": "#246b85", + "circle-stroke-color": "#10242b", + "circle-stroke-width": 1 + } + }); + map.addLayer({ + id: "waypoint-label", + type: "symbol", + source: "waypoints", + layout: { + "text-field": ["get", "label"], + "text-size": 12, + "text-offset": [0, -1.7], + "text-allow-overlap": true + }, + paint: { + "text-color": "#123f50", + "text-halo-color": "#ffffff", + "text-halo-width": 2 + } + }); + raiseMarineLabels(map); + if (positionRef.current) { + const userSource = map.getSource("user-position") as GeoJSONSource | undefined; + userSource?.setData(userPositionFeatures(positionRef.current, accuracyMRef.current)); + } + if (anchorPointRef.current) { + lastAnchorFocusKeyRef.current = coordinateKey(anchorPointRef.current); + focusAnchorOnMap(map, anchorPointRef.current); + } else if (positionRef.current && !routeRef.current && !hasCenteredOnFirstFixRef.current) { + hasCenteredOnFirstFixRef.current = true; + map.easeTo({ + center: [positionRef.current.lon, positionRef.current.lat], + zoom: Math.max(map.getZoom(), 12), + duration: 650, + essential: true + }); + } + fitRouteOnMap(map, routeRef.current); + applyMapFocusRequest(map, focusRequestRef.current); + + setVisibleLayers(initialVisibility); + window.requestAnimationFrame(() => { + const attribution = containerRef.current?.querySelector( + ".maplibregl-ctrl-attrib.maplibregl-compact" + ); + if (attribution?.classList.contains("maplibregl-compact-show")) { + attribution.querySelector(".maplibregl-ctrl-attrib-button")?.click(); + } + }); + onMapReady(); + }); + + let lastTouchPickAt = 0; + let touchStartPoint: { x: number; y: number } | null = null; + const pickCoordinate = (coordinate: Coordinate) => { + if (!pickModeRef.current) { + return; + } + + onPickCoordinateRef.current(coordinate); + }; + const activateContactFeatureAtPoint = (point: ReturnType) => { + const interactiveLayers = CONTACT_INTERACTIVE_LAYER_IDS.filter((layerId) => map.getLayer(layerId)); + if (interactiveLayers.length === 0) { + return false; + } + + const feature = map.queryRenderedFeatures(point, { layers: [...interactiveLayers] })[0] as Feature | undefined; + if (!feature) { + return false; + } + + const properties = (feature.properties ?? {}) as Record; + const rawClusterId = properties.cluster_id; + const clusterId = + typeof rawClusterId === "number" + ? rawClusterId + : typeof rawClusterId === "string" && rawClusterId.trim() + ? Number(rawClusterId) + : null; + if (properties.cluster === true || (clusterId !== null && Number.isFinite(clusterId))) { + const coordinate = representativeCoordinate(feature.geometry); + const source = map.getSource(CONTACT_SOURCE_ID) as GeoJSONSource | undefined; + if (coordinate && source && clusterId !== null && Number.isFinite(clusterId)) { + setSelectedMarineFeature(null); + void source + .getClusterExpansionZoom(clusterId) + .then((zoom) => { + if (mapRef.current === map) { + map.easeTo({ + center: [coordinate.lon, coordinate.lat], + zoom, + duration: 450, + essential: true + }); + } + }) + .catch(() => undefined); + } + return true; + } + + const details = marineFeatureDetails(feature); + if (details) { + setSelectedMarineFeature(details); + } + return Boolean(details); + }; + map.on("click", (event) => { + if (Date.now() - lastTouchPickAt < 350) { + return; + } + if (activateContactFeatureAtPoint(event.point)) { + return; + } + + pickCoordinate({ lon: event.lngLat.lng, lat: event.lngLat.lat }); + }); + const canvas = map.getCanvas(); + const canvasContainer = map.getCanvasContainer(); + const pickCanvasPoint = (clientX: number, clientY: number) => { + if (Date.now() - lastTouchPickAt < 100) { + return; + } + + const rect = canvas.getBoundingClientRect(); + const lngLat = map.unproject([clientX - rect.left, clientY - rect.top]); + if (activateContactFeatureAtPoint(map.project(lngLat))) { + lastTouchPickAt = Date.now(); + return; + } + if (!pickModeRef.current) { + return; + } + lastTouchPickAt = Date.now(); + pickCoordinate({ lon: lngLat.lng, lat: lngLat.lat }); + }; + const handlePointerDown = (event: PointerEvent) => { + if (event.pointerType === "mouse") { + return; + } + + touchStartPoint = { x: event.clientX, y: event.clientY }; + }; + const handlePointerUp = (event: PointerEvent) => { + if (event.pointerType === "mouse" || !touchStartPoint) { + return; + } + + const distancePx = Math.hypot(event.clientX - touchStartPoint.x, event.clientY - touchStartPoint.y); + touchStartPoint = null; + if (distancePx <= 8) { + pickCanvasPoint(event.clientX, event.clientY); + } + }; + const handleTouchStart = (event: TouchEvent) => { + const touch = event.touches[0]; + if (!touch) { + return; + } + + touchStartPoint = { x: touch.clientX, y: touch.clientY }; + }; + const handleTouchEnd = (event: TouchEvent) => { + const touch = event.changedTouches[0]; + if (!touch || !touchStartPoint) { + return; + } + + const distancePx = Math.hypot(touch.clientX - touchStartPoint.x, touch.clientY - touchStartPoint.y); + touchStartPoint = null; + if (distancePx <= 8) { + pickCanvasPoint(touch.clientX, touch.clientY); + } + }; + canvasContainer.addEventListener("pointerdown", handlePointerDown, { passive: true }); + canvasContainer.addEventListener("pointerup", handlePointerUp, { passive: true }); + canvasContainer.addEventListener("touchstart", handleTouchStart, { passive: true }); + canvasContainer.addEventListener("touchend", handleTouchEnd, { passive: true }); + + return () => { + marineFeatureRequestIdRef.current += 1; + marineFeatureAbortControllerRef.current?.abort(); + marineFeatureAbortControllerRef.current = null; + canvasContainer.removeEventListener("pointerdown", handlePointerDown); + canvasContainer.removeEventListener("pointerup", handlePointerUp); + canvasContainer.removeEventListener("touchstart", handleTouchStart); + canvasContainer.removeEventListener("touchend", handleTouchEnd); + map.remove(); + mapRef.current = null; + }; + }, [config, onMapReady]); + + const refreshMarineFeatures = useCallback(async () => { + const map = mapRef.current; + if (!map) { + return; + } + + marineFeatureAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + marineFeatureAbortControllerRef.current = abortController; + const requestId = marineFeatureRequestIdRef.current + 1; + marineFeatureRequestIdRef.current = requestId; + + const source = map.getSource("marine-features") as GeoJSONSource | undefined; + const contactSource = map.getSource(CONTACT_SOURCE_ID) as GeoJSONSource | undefined; + if (!source || !contactSource) { + return; + } + + const zoom = map.getZoom(); + contactZoomActiveRef.current = zoom >= MIN_CONTACT_POI_ZOOM; + if (zoom < MIN_CONTACT_POI_ZOOM) { + clearContactFeatures(map); + } + const activeLayers = MARINE_OVERLAYS + .filter( + (layer) => + visibleLayers[layer.id] && + zoom >= marineLayerMinimumZoom(layer.id) + ) + .map((layer) => layer.id); + if (activeLayers.length === 0) { + source.setData(emptyFeatureCollection()); + clearContactFeatures(map); + return; + } + + const bounds = map.getBounds(); + try { + const features = await getMapFeatures({ + bbox: [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()], + layers: activeLayers, + signal: abortController.signal + }); + if (requestId !== marineFeatureRequestIdRef.current || mapRef.current !== map) { + return; + } + const contactFeatures = features.features.filter(isContactFeature); + const otherFeatures = features.features.filter((feature) => !isContactFeature(feature)); + source.setData(featureCollectionWithFeatures(features, otherFeatures)); + contactSource.setData(featureCollectionWithFeatures(features, contactFeatures)); + contactFeaturesRef.current = contactFeatures; + + const detailsById = new Map(); + for (const feature of contactFeatures) { + const details = marineFeatureDetails(feature); + if (details) { + detailsById.set(details.id, details); + } + } + setAccessibleMarineFeatures([...detailsById.values()]); + setSelectedMarineFeature((current) => (current ? (detailsById.get(current.id) ?? null) : null)); + } catch { + if (abortController.signal.aborted) { + return; + } + if (requestId !== marineFeatureRequestIdRef.current || mapRef.current !== map) { + return; + } + source.setData(emptyFeatureCollection()); + clearContactFeatures(map); + } finally { + if (marineFeatureAbortControllerRef.current === abortController) { + marineFeatureAbortControllerRef.current = null; + } + } + }, [clearContactFeatures, visibleLayers]); + + useEffect(() => { + const map = mapRef.current; + if (!map) { + return; + } + + const refresh = () => { + void refreshMarineFeatures(); + }; + const updateContactZoomState = () => { + const contactsActive = map.getZoom() >= MIN_CONTACT_POI_ZOOM; + if (contactsActive === contactZoomActiveRef.current) { + return; + } + contactZoomActiveRef.current = contactsActive; + if (!contactsActive) { + marineFeatureRequestIdRef.current += 1; + marineFeatureAbortControllerRef.current?.abort(); + marineFeatureAbortControllerRef.current = null; + clearContactFeatures(map); + } + }; + contactZoomActiveRef.current = map.getZoom() >= MIN_CONTACT_POI_ZOOM; + map.on("moveend", refresh); + map.on("zoom", updateContactZoomState); + refresh(); + + return () => { + map.off("moveend", refresh); + map.off("zoom", updateContactZoomState); + }; + }, [clearContactFeatures, refreshMarineFeatures]); + + useEffect(() => { + const map = mapRef.current; + if (!map?.isStyleLoaded() || !position) { + return; + } + + const source = map.getSource("user-position") as GeoJSONSource | undefined; + source?.setData(userPositionFeatures(position, accuracyM)); + + if (!hasCenteredOnFirstFixRef.current) { + hasCenteredOnFirstFixRef.current = true; + map.easeTo({ + center: [position.lon, position.lat], + zoom: Math.max(map.getZoom(), 12), + duration: 650, + essential: true + }); + } + }, [accuracyM, position]); + + useEffect(() => { + const map = mapRef.current; + if (!map?.isStyleLoaded()) { + return; + } + + const source = map.getSource("route-guidance") as GeoJSONSource | undefined; + source?.setData(guidanceFeatures(guidanceActive ? position : null, guidanceActive ? guidanceTarget : null)); + }, [guidanceActive, guidanceTarget, position]); + + useEffect(() => { + const map = mapRef.current; + if (!map?.isStyleLoaded()) { + return; + } + + const source = map.getSource("anchor-watch") as GeoJSONSource | undefined; + source?.setData( + anchorWatchFeatures( + anchorPoint, + anchorWatchActive ? position : null, + anchorAlarmRadiusM, + anchorWatchActive, + anchorAlarm + ) + ); + if (anchorPoint) { + const focusKey = coordinateKey(anchorPoint); + if (lastAnchorFocusKeyRef.current !== focusKey) { + lastAnchorFocusKeyRef.current = focusKey; + focusAnchorOnMap(map, anchorPoint); + } + } else { + lastAnchorFocusKeyRef.current = null; + } + }, [anchorAlarm, anchorAlarmRadiusM, anchorPoint, anchorWatchActive, position]); + + useEffect(() => { + const map = mapRef.current; + if (!map?.isStyleLoaded()) { + return; + } + + const source = map.getSource("start-point") as GeoJSONSource | undefined; + source?.setData(startPoint ? pointFeature(startPoint) : emptyPoint()); + }, [startPoint]); + + useEffect(() => { + const map = mapRef.current; + if (!map?.isStyleLoaded()) { + return; + } + + const source = map.getSource("destination") as GeoJSONSource | undefined; + source?.setData(destination ? pointFeature(destination) : emptyPoint()); + }, [destination]); + + useEffect(() => { + const map = mapRef.current; + if (!map?.isStyleLoaded()) { + return; + } + + const source = map.getSource("waypoints") as GeoJSONSource | undefined; + source?.setData(waypointFeatures(waypoints)); + }, [waypoints]); + + useEffect(() => { + const map = mapRef.current; + if (!map?.isStyleLoaded()) { + return; + } + + const source = map.getSource("route") as GeoJSONSource | undefined; + source?.setData(route ? routeFeature(route) : emptyLine()); + fitRouteOnMap(map, route); + }, [route]); + + const toggleLayer = (id: string) => { + const map = mapRef.current; + const next = !visibleLayers[id]; + const nextVisibleLayers = { ...visibleLayers, [id]: next }; + const targetLayerIds = MARINE_OVERLAY_LAYER_IDS[id] ?? [id]; + for (const layerId of targetLayerIds) { + if (map?.getLayer(layerId)) { + map.setLayoutProperty(layerId, "visibility", next ? "visible" : "none"); + } + } + if (id === "locks" || id === "harbours") { + const clustersVisible = Boolean(nextVisibleLayers.locks || nextVisibleLayers.harbours); + for (const layerId of CONTACT_CLUSTER_LAYER_IDS) { + if (map?.getLayer(layerId)) { + map.setLayoutProperty(layerId, "visibility", clustersVisible ? "visible" : "none"); + } + } + const visibleContactFeatures = contactFeaturesRef.current.filter((feature) => { + const layer = stringValue(feature.properties?.layer); + return (layer === "locks" || layer === "harbours") && Boolean(nextVisibleLayers[layer]); + }); + const contactSource = map?.getSource(CONTACT_SOURCE_ID) as GeoJSONSource | undefined; + contactSource?.setData({ type: "FeatureCollection", features: visibleContactFeatures }); + const details = visibleContactFeatures + .map(marineFeatureDetails) + .filter((feature): feature is MarineFeatureDetails => feature !== null); + setAccessibleMarineFeatures(details); + if (!next) { + setSelectedMarineFeature((current) => (current?.layer === id ? null : current)); + } + } + setVisibleLayers(nextVisibleLayers); + }; + + return ( +
+
+ {pickMode && ( +
+ {pickMode === "start" + ? "Startpunkt auf der Karte anklicken" + : pickMode === "destination" + ? "Ziel auf der Karte anklicken" + : "Zwischenziel auf der Karte anklicken"} +
+ )} + + {position && ( + + )} + {layersOpen && config && ( +
+ {config.layers + .filter((layer) => layer.kind !== "style") + .map((layer) => ( + + ))} + {MARINE_OVERLAYS.map((layer) => ( + + ))} +
+ )} + {accessibleMarineFeatures.length > 0 && ( + + )} + {selectedMarineFeature && ( + setSelectedMarineFeature(null)} + /> + )} +
+ ); +} + +function emptyFeatureCollection(): FeatureCollection { + return { type: "FeatureCollection", features: [] }; +} + +function featureCollectionWithFeatures(_collection: FeatureCollection, features: Feature[]): FeatureCollection { + return { type: "FeatureCollection", features }; +} + +function isContactFeature(feature: Feature) { + const layer = stringValue(feature.properties?.layer); + return layer === "locks" || layer === "harbours"; +} + +function emptyPoint(): FeatureCollection { + return { type: "FeatureCollection", features: [] }; +} + +function pointFeature(coordinate: Coordinate): FeatureCollection { + return { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: {}, + geometry: { + type: "Point", + coordinates: [coordinate.lon, coordinate.lat] + } + } + ] + }; +} + +function userPositionFeatures(position: Coordinate, accuracyM: number | null): FeatureCollection { + return { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { + radius: Math.max(10, Math.min(80, accuracyM ?? 20)) + }, + geometry: { + type: "Point", + coordinates: [position.lon, position.lat] + } + } + ] + }; +} + +function waypointFeatures(coordinates: Coordinate[]): FeatureCollection { + return { + type: "FeatureCollection", + features: coordinates.map((coordinate, index) => ({ + type: "Feature", + properties: { label: `Z${index + 1}` }, + geometry: { + type: "Point", + coordinates: [coordinate.lon, coordinate.lat] + } + })) + }; +} + +function guidanceFeatures(position: Coordinate | null, target: Coordinate | null): FeatureCollection { + if (!position || !target) { + return emptyFeatureCollection(); + } + return { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { kind: "line" }, + geometry: { + type: "LineString", + coordinates: [ + [position.lon, position.lat], + [target.lon, target.lat] + ] + } + }, + { + type: "Feature", + properties: { kind: "target" }, + geometry: { + type: "Point", + coordinates: [target.lon, target.lat] + } + } + ] + }; +} + +function anchorWatchFeatures( + anchor: Coordinate | null, + position: Coordinate | null, + radiusM: number | null, + active: boolean, + alarm: boolean +): FeatureCollection { + if (!anchor) { + return emptyFeatureCollection(); + } + + const features: Feature[] = []; + if (typeof radiusM === "number" && Number.isFinite(radiusM) && radiusM > 0) { + features.push({ + type: "Feature", + properties: { kind: "radius", alarm }, + geometry: { + type: "Polygon", + coordinates: [geodesicCircle(anchor, radiusM)] + } + }); + } + if (active && position) { + features.push({ + type: "Feature", + properties: { kind: "distance", alarm }, + geometry: { + type: "LineString", + coordinates: [ + [anchor.lon, anchor.lat], + [position.lon, position.lat] + ] + } + }); + } + features.push({ + type: "Feature", + properties: { kind: "anchor", alarm }, + geometry: { + type: "Point", + coordinates: [anchor.lon, anchor.lat] + } + }); + return { type: "FeatureCollection", features }; +} + +function geodesicCircle(center: Coordinate, radiusM: number): Position[] { + const earthRadiusM = 6_371_008.8; + const angularDistance = radiusM / earthRadiusM; + const latitude = (center.lat * Math.PI) / 180; + const longitude = (center.lon * Math.PI) / 180; + const points: Position[] = []; + + for (let index = 0; index <= 64; index += 1) { + const bearing = (index / 64) * Math.PI * 2; + const destinationLatitude = Math.asin( + Math.sin(latitude) * Math.cos(angularDistance) + + Math.cos(latitude) * Math.sin(angularDistance) * Math.cos(bearing) + ); + const destinationLongitude = + longitude + + Math.atan2( + Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitude), + Math.cos(angularDistance) - Math.sin(latitude) * Math.sin(destinationLatitude) + ); + points.push([ + ((destinationLongitude * 180) / Math.PI + 540) % 360 - 180, + (destinationLatitude * 180) / Math.PI + ]); + } + return points; +} + +function coordinateKey(coordinate: Coordinate) { + return `${coordinate.lat.toFixed(7)}:${coordinate.lon.toFixed(7)}`; +} + +function focusAnchorOnMap(map: MapLibreMap, anchor: Coordinate) { + const height = map.getContainer().clientHeight; + map.easeTo({ + center: [anchor.lon, anchor.lat], + zoom: Math.max(map.getZoom(), 15), + offset: [0, -Math.min(180, height * 0.24)], + duration: 650, + essential: true + }); +} + +function emptyLine(): Feature { + return { + type: "Feature", + properties: {}, + geometry: { + type: "LineString", + coordinates: [] + } + }; +} + +function routeFeature(route: RouteResult): Feature { + return { + type: "Feature", + properties: {}, + geometry: route.geometry + }; +} + +function raiseMarineLabels(map: MapLibreMap) { + for (const layerId of MARINE_LABEL_LAYER_IDS) { + if (map.getLayer(layerId)) { + map.moveLayer(layerId); + } + } +} + +function fitRouteOnMap(map: MapLibreMap, route: RouteResult | null) { + const coordinates = (route?.geometry.coordinates ?? []).filter(isLngLatPosition); + if (coordinates.length === 0) { + return; + } + if (coordinates.length === 1) { + map.easeTo({ center: coordinates[0], zoom: Math.max(map.getZoom(), 13), duration: 650, essential: true }); + return; + } + + const bounds = coordinates.slice(1).reduce( + (current, coordinate) => current.extend(coordinate), + new maplibregl.LngLatBounds(coordinates[0], coordinates[0]) + ); + const container = map.getContainer(); + const viewportWidth = container.ownerDocument.defaultView?.innerWidth ?? container.clientWidth; + const shell = container.closest(".app-shell"); + const workspace = shell?.querySelector(".navigation-workspace") ?? null; + const workspaceVisible = shell?.dataset.workspaceOpen === "true" && Boolean(workspace); + const containerRect = container.getBoundingClientRect(); + const workspaceRect = workspace?.getBoundingClientRect() ?? null; + const desktopWorkspaceInset = + workspaceVisible && workspaceRect && viewportWidth >= 720 + ? Math.max(0, containerRect.right - workspaceRect.left) + : 0; + const mobileWorkspaceInset = + workspaceVisible && workspaceRect && viewportWidth < 720 + ? Math.max(0, containerRect.bottom - workspaceRect.top) + : 0; + map.fitBounds(bounds, { + padding: + viewportWidth >= 720 + ? { + top: 96, + right: Math.max(64, Math.ceil(desktopWorkspaceInset) + 24), + bottom: 104, + left: 64 + } + : { + top: 96, + right: 24, + bottom: Math.max(104, Math.ceil(mobileWorkspaceInset) + 24), + left: 24 + }, + maxZoom: 14, + duration: 700, + essential: true + }); +} + +function applyMapFocusRequest( + map: MapLibreMap | null, + request: MapViewProps["focusRequest"] +) { + if (!map || !request) { + return; + } + map.flyTo({ + center: [request.coordinate.lon, request.coordinate.lat], + zoom: request.zoom ?? 14, + essential: true + }); +} + +function marineFeatureDetails(feature: Feature): MarineFeatureDetails | null { + const properties = (feature.properties ?? {}) as Record; + const layer = stringValue(properties.layer); + if (layer !== "locks" && layer !== "harbours") { + return null; + } + + const coordinate = representativeCoordinate(feature.geometry); + if (!coordinate) { + return null; + } + + const typeLabel = layer === "locks" ? "Schleuse" : "Hafen"; + const name = firstString(properties, ["name", "label", "loc_name", "official_name"]) ?? `Unbenannte ${typeLabel}`; + const id = String( + feature.id ?? + firstString(properties, ["source_id", "id", "@id"]) ?? + `${layer}:${coordinate.lat.toFixed(6)}:${coordinate.lon.toFixed(6)}:${name}` + ); + + return { + id, + layer, + name, + typeLabel, + coordinate, + 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:radio_station:channel", + "seamark:harbour:radio_channel" + ]), + openingHours: firstString(properties, ["opening_hours", "lock: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" + ]), + memberCount: finiteNumber(properties.dedupeMemberCount ?? properties.memberCount) + }; +} + +function marineLayerMinimumZoom(layer: (typeof MARINE_OVERLAYS)[number]["id"]) { + if (layer === "locks" || layer === "harbours") { + return MIN_CONTACT_POI_ZOOM; + } + return layer === "bridges" ? MIN_BRIDGE_ZOOM : MIN_FEATURE_ZOOM; +} + +function representativeCoordinate(geometry: Geometry | null): Coordinate | null { + if (!geometry) { + return null; + } + + const positions = geometryPositions(geometry).filter(isLngLatPosition); + if (positions.length === 0) { + return null; + } + + const bounds = positions.reduce( + (current, [lon, lat]) => ({ + minLon: Math.min(current.minLon, lon), + maxLon: Math.max(current.maxLon, lon), + minLat: Math.min(current.minLat, lat), + maxLat: Math.max(current.maxLat, lat) + }), + { + minLon: positions[0]![0], + maxLon: positions[0]![0], + minLat: positions[0]![1], + maxLat: positions[0]![1] + } + ); + return { + lon: (bounds.minLon + bounds.maxLon) / 2, + lat: (bounds.minLat + bounds.maxLat) / 2 + }; +} + +function geometryPositions(geometry: Geometry): Position[] { + if (geometry.type === "GeometryCollection") { + return geometry.geometries.flatMap(geometryPositions); + } + return flattenPositions(geometry.coordinates); +} + +function flattenPositions(value: unknown): Position[] { + if (!Array.isArray(value)) { + return []; + } + if (value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") { + return [value as Position]; + } + return value.flatMap(flattenPositions); +} + +function isLngLatPosition(value: Position): value is [number, number] { + return ( + typeof value[0] === "number" && + Number.isFinite(value[0]) && + typeof value[1] === "number" && + Number.isFinite(value[1]) && + value[0] >= -180 && + value[0] <= 180 && + value[1] >= -90 && + value[1] <= 90 + ); +} + +function firstString(properties: Record, keys: string[]) { + for (const key of keys) { + const value = stringValue(properties[key]); + if (value) { + return value; + } + } + return null; +} + +function stringValue(value: unknown) { + if (typeof value === "string") { + return value.trim() || null; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return null; +} + +function finiteNumber(value: unknown) { + const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN; + return Number.isFinite(number) ? number : null; +} + +function featureAddress(properties: Record) { + const fullAddress = firstString(properties, ["contact:address", "addr:full", "address"]); + if (fullAddress) { + return fullAddress; + } + + const street = firstString(properties, ["addr:street"]); + const houseNumber = firstString(properties, ["addr:housenumber"]); + const postcode = firstString(properties, ["addr:postcode"]); + const city = firstString(properties, ["addr:city", "addr:place"]); + const streetLine = [street, houseNumber].filter(Boolean).join(" "); + const cityLine = [postcode, city].filter(Boolean).join(" "); + return [streetLine, cityLine].filter(Boolean).join(", ") || null; +} diff --git a/apps/web/src/components/MarineFeatureInfo.tsx b/apps/web/src/components/MarineFeatureInfo.tsx new file mode 100644 index 0000000..39768b6 --- /dev/null +++ b/apps/web/src/components/MarineFeatureInfo.tsx @@ -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(null); + const closeButtonRef = useRef(null); + const previouslyFocusedElementRef = useRef(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 ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + onTouchStart={(event) => event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + > + + + + {hasContactActions ? ( +
+ {phoneHref && phone && ( + + + )} + {websiteHref && ( + + + )} + {email && ( + + + )} +
+ ) : ( +

Keine direkten Kontaktdaten hinterlegt.

+ )} + + {hasOperatingDetails && ( +
+ {vhf && {vhf}} + {openingHours && {openingHours}} + {operator && {operator}} + {address && {address}} +
+ )} + +
+ Daten & Quelle +
+ {formatCoordinate(feature.coordinate)} + {(source || sourceHref) && ( + + {sourceHref ? ( + + {source ?? "Quelldatensatz öffnen"} + + ) : ( + source + )} + + )} + {updatedAt && {formatTimestamp(updatedAt)}} + {(feature.memberCount ?? 1) > 1 && ( + {feature.memberCount} Kartenobjekte + )} +
+
+ +

+ Kontaktdaten können unvollständig oder veraltet sein. Vor der Fahrt bei der zuständigen Stelle prüfen. +

+ +
+ ); +} + +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +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(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; +} diff --git a/apps/web/src/components/NavigationDataPanel.tsx b/apps/web/src/components/NavigationDataPanel.tsx new file mode 100644 index 0000000..d62071c --- /dev/null +++ b/apps/web/src/components/NavigationDataPanel.tsx @@ -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 ( +
+
+
+ + {loading &&

WSV-Daten werden geladen

} + {error && ( +

+

+ )} + + {snapshot && ( + <> + {snapshot.waterLevels.length > 0 ? ( +
+ {snapshot.waterLevels.slice(0, 8).map((level) => ( + + ))} +
+ ) : ( +

Keine passenden PEGELONLINE-Messstellen gefunden.

+ )} + + {snapshot.lockOperations.map((lock) => ( +
+
+ ))} + + {snapshot.notices.map((notice) => ( + + + ))} + +
+ {snapshot.sources.map((source) => ( + + ))} +
+

+ Live-Daten können verzögert oder unvollständig sein. Schleusenabweichungen und Sperrungen vor Abfahrt + zusätzlich in ELWIS prüfen. +

+ + )} +
+ ); +} + +function WaterLevelRow({ level }: { level: WaterLevel }) { + return ( + + + ); +} + +function SourceLink({ source }: { source: NavigationSourceStatus }) { + return ( + + {source.label} + {sourceStateLabel(source.state)} + + ); +} + +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" }) + : ""; +} diff --git a/apps/web/src/components/NavigationToolRail.tsx b/apps/web/src/components/NavigationToolRail.tsx new file mode 100644 index 0000000..609fea6 --- /dev/null +++ b/apps/web/src/components/NavigationToolRail.tsx @@ -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>; + badges?: Partial>; + disabledTools?: Partial>; + workspaceId?: string; + className?: string; + ariaLabel?: string; +}; + +export function NavigationToolRail({ + activeTool, + onSelect, + statuses = {}, + badges = {}, + disabledTools = {}, + workspaceId = "navigation-workspace", + className, + ariaLabel = "Kartenwerkzeuge" +}: NavigationToolRailProps) { + return ( + + ); +} + +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) { + return values.filter(Boolean).join(" "); +} diff --git a/apps/web/src/components/NavigationWorkspace.tsx b/apps/web/src/components/NavigationWorkspace.tsx new file mode 100644 index 0000000..d8a43a4 --- /dev/null +++ b/apps/web/src/components/NavigationWorkspace.tsx @@ -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 ( + + ); +} + +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) { + return values.filter(Boolean).join(" "); +} diff --git a/apps/web/src/components/RoutePlanner.tsx b/apps/web/src/components/RoutePlanner.tsx new file mode 100644 index 0000000..ac94a5e --- /dev/null +++ b/apps/web/src/components/RoutePlanner.tsx @@ -0,0 +1,1106 @@ +import { + AlertTriangle, + ArrowDown, + ArrowUp, + ChevronDown, + Clock3, + CloudSun, + Landmark, + LocateFixed, + MapPin, + Plus, + Navigation, + Route, + ShieldAlert, + ShipWheel, + Waves, + Wind, + X, + Trash2 +} from "lucide-react"; +import { lazy, useEffect, useMemo, useRef, useState } from "react"; +import { + buildVoyagePlan, + VOYAGE_AMENITIES, + voyageAmenityLabel, + type Coordinate, + type NavigationDataSnapshot, + type RouteResult, + type VesselProfile, + type VoyageAmenity, + type VoyageHarbour +} from "@watermaps/shared"; +import type { RouteWeatherReport } from "../routeWeatherReport"; +import type { OfflineVoyage } from "../lib/offline-route"; +import type { RouteLock } from "../voyageHarbours"; +import { LazyContent } from "./LazyContent"; +import { NavigationDataPanel } from "./NavigationDataPanel"; +import { RouteTidePanel, type RouteTidePlan } from "./RouteTidePanel"; +import { VoyagePlan } from "./VoyagePlan"; + +type RoutePlannerProps = { + startPoint: Coordinate | null; + gpsPosition: Coordinate | null; + destination: Coordinate | null; + waypoints?: Coordinate[]; + result: RouteResult | null; + routeOptions: RouteResult[]; + weatherReport: RouteWeatherReport | null; + weatherLoading: boolean; + weatherError: string | null; + navigationData?: NavigationDataSnapshot | null; + navigationDataLoading?: boolean; + navigationDataError?: string | null; + routeHarbours?: VoyageHarbour[]; + routeHarboursLoading?: boolean; + routeHarboursError?: string | null; + routeLocks?: RouteLock[]; + routeTides?: RouteTidePlan | null; + routeTidesLoading?: boolean; + routeTidesError?: string | null; + loading: boolean; + error: string | null; + pickMode: "start" | "destination" | "waypoint" | null; + onSubmit: (request: { + start: Coordinate; + destination: Coordinate; + waypoints: Coordinate[]; + departureTime: string; + vesselProfile: VesselProfile; + }) => void; + onPickStart: () => void; + onPickDestination: () => void; + onPickWaypoint?: () => void; + onRemoveWaypoint?: (index: number) => void; + onMoveWaypoint?: (index: number, direction: -1 | 1) => void; + onLoadOfflineVoyage?: (voyage: OfflineVoyage) => void; + onClearStart: () => void; + onClearDestination: () => void; + onUseGpsAsStart: () => void; + onSelectRoute: (routeId: string) => void; + guidanceActive?: boolean; + onStartGuidance?: () => void; + operationalPanelsVisible?: boolean; + embedded?: boolean; + onCollapse: () => void; +}; + +type RouteSheetState = "compact" | "half" | "full"; + +const LazyVoyageNavigationTools = lazy(() => + import("./VoyageNavigationTools").then((module) => ({ + default: module.VoyageNavigationTools + })) +); + +export function RoutePlanner({ + startPoint, + gpsPosition, + destination, + waypoints = [], + result, + routeOptions, + weatherReport, + weatherLoading, + weatherError, + navigationData = null, + navigationDataLoading = false, + navigationDataError = null, + routeHarbours = [], + routeHarboursLoading = false, + routeHarboursError = null, + routeLocks = [], + routeTides = null, + routeTidesLoading = false, + routeTidesError = null, + loading, + error, + pickMode, + onSubmit, + onPickStart, + onPickDestination, + onPickWaypoint = () => undefined, + onRemoveWaypoint = () => undefined, + onMoveWaypoint = () => undefined, + onLoadOfflineVoyage, + onClearStart, + onClearDestination, + onUseGpsAsStart, + onSelectRoute, + guidanceActive = false, + onStartGuidance = () => undefined, + operationalPanelsVisible = true, + embedded = false, + onCollapse +}: RoutePlannerProps) { + const [draughtM, setDraughtM] = useState(1.4); + const [reserveM, setReserveM] = useState(0.5); + const [airDraftM, setAirDraftM] = useState(2.5); + const [beamM, setBeamM] = useState(3.2); + const [speedKn, setSpeedKn] = useState(6); + const [departureTime, setDepartureTime] = useState(() => localDateTimeValue(new Date())); + const [maxCruisingHoursPerDay, setMaxCruisingHoursPerDay] = useState(8); + const [requiredAmenities, setRequiredAmenities] = useState(["overnight"]); + const [lockDelayMinutes, setLockDelayMinutes] = useState(20); + const [plannerView, setPlannerView] = useState<"input" | "result">(() => (result ? "result" : "input")); + const [routeOptionsOpen, setRouteOptionsOpen] = useState( + () => isRouteOptionsInitiallyOpen(pickMode, waypoints) + ); + const [voyageToolsRequested, setVoyageToolsRequested] = useState(false); + const [sheetState, setSheetState] = useState("half"); + const previousResult = useRef(result); + const canRoute = Boolean(startPoint && destination); + const isPickingStart = pickMode === "start"; + const isPickingDestination = pickMode === "destination"; + const isPickingWaypoint = pickMode === "waypoint"; + const severity = useMemo(() => { + if (!result) { + return "idle"; + } + if (result.warnings.some((warning) => warning.severity === "critical")) { + return "critical"; + } + if (result.warnings.some((warning) => warning.severity === "caution")) { + return "caution"; + } + return "ok"; + }, [result]); + const voyagePlan = useMemo(() => { + if (!result) { + return null; + } + return buildVoyagePlan({ + route: result, + cruiseSpeedKn: speedKn, + maxCruisingHoursPerDay, + harbours: routeHarbours, + waypoints: waypoints.map((coordinate, index) => ({ + id: `waypoint-${index + 1}`, + name: `Zwischenziel ${index + 1}`, + coordinate + })), + requiredAmenities, + maxHarbourDetourNm: 2.5 + }); + }, [maxCruisingHoursPerDay, requiredAmenities, result, routeHarbours, speedKn, waypoints]); + const operationalEta = useMemo(() => { + const baseEta = weatherReport?.adjustedEta ?? result?.eta; + const timestamp = baseEta ? Date.parse(baseEta) : Number.NaN; + if (!Number.isFinite(timestamp) || routeLocks.length === 0) { + return null; + } + return new Date(timestamp + routeLocks.length * lockDelayMinutes * 60_000).toISOString(); + }, [lockDelayMinutes, result?.eta, routeLocks.length, weatherReport?.adjustedEta]); + const criticalWarnings = result?.warnings.filter((warning) => warning.severity === "critical") ?? []; + const otherWarnings = result?.warnings.filter((warning) => warning.severity !== "critical") ?? []; + + useEffect(() => { + if (!result) { + setPlannerView("input"); + } else if (result !== previousResult.current) { + setPlannerView("result"); + setSheetState("half"); + } + previousResult.current = result; + }, [result]); + + useEffect(() => { + if (isPickingWaypoint) { + setRouteOptionsOpen(true); + } + }, [isPickingWaypoint]); + + useEffect(() => { + if (typeof window.matchMedia !== "function") { + return; + } + const desktopLayout = window.matchMedia("(min-width: 720px)"); + const resetMobileSheetState = () => { + if (desktopLayout.matches) { + setSheetState("half"); + } + }; + resetMobileSheetState(); + if (typeof desktopLayout.addEventListener === "function") { + desktopLayout.addEventListener("change", resetMobileSheetState); + return () => desktopLayout.removeEventListener("change", resetMobileSheetState); + } + desktopLayout.addListener(resetMobileSheetState); + return () => desktopLayout.removeListener(resetMobileSheetState); + }, []); + + const sheetSizeAction = routeSheetSizeAction(sheetState); + + return ( + + ); +} + +function formatCoordinate(coordinate: Coordinate) { + return `${coordinate.lat.toFixed(4)}, ${coordinate.lon.toFixed(4)}`; +} + +function isRouteOptionsInitiallyOpen( + pickMode: RoutePlannerProps["pickMode"], + waypoints: Coordinate[] +) { + return pickMode === "waypoint" || waypoints.length > 0; +} + +function routeSheetSizeAction(state: RouteSheetState): { + nextState: RouteSheetState; + label: string; + direction: "up" | "down"; +} { + if (state === "compact") { + return { + nextState: "half", + label: "Routenfenster auf halbe Höhe vergrößern", + direction: "up" + }; + } + if (state === "half") { + return { + nextState: "full", + label: "Routenfenster auf volle Höhe vergrößern", + direction: "up" + }; + } + return { + nextState: "compact", + label: "Routenfenster auf kompakte Höhe verkleinern", + direction: "down" + }; +} + +function formatKn(value: number | null | undefined) { + return typeof value === "number" ? `${Math.round(value)} kn` : "-- kn"; +} + +function formatMeters(value: number | null | undefined) { + return typeof value === "number" ? `${value.toFixed(1)} m` : "-- m"; +} + +function formatBridgeMargin(value: number | null | undefined) { + if (typeof value !== "number") { + return "offen"; + } + if (value < 0) { + return `${Math.abs(value).toFixed(1)} m zu niedrig`; + } + return `+${value.toFixed(1)} m`; +} + +function formatSeconds(value: number | null | undefined) { + return typeof value === "number" ? `${Math.round(value)} s Periode` : "-- s Periode"; +} + +function formatDirection(value: number | null | undefined) { + return typeof value === "number" ? ` ${Math.round(value)}°` : ""; +} + +function formatUpdatedAt(value: string) { + const date = new Date(value); + return Number.isFinite(date.getTime()) + ? date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + : ""; +} + +function formatEta(value: string) { + const date = new Date(value); + return Number.isFinite(date.getTime()) + ? `ETA ${date.toLocaleString("de-DE", { + weekday: "short", + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit" + })}` + : ""; +} + +function formatDateTime(value: string) { + const date = new Date(value); + return Number.isFinite(date.getTime()) + ? date.toLocaleString("de-DE", { weekday: "short", hour: "2-digit", minute: "2-digit" }) + : "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" }) + : ""; +} + +function formatSignedMinutes(value: number) { + if (value === 0) { + return "±0 min"; + } + return `${value > 0 ? "+" : ""}${value} min`; +} + +function formatTravelTime(distanceNm: number, speedKn: number) { + if (!Number.isFinite(speedKn) || speedKn <= 0) { + return "Dauer offen"; + } + const minutes = Math.round((distanceNm / speedKn) * 60); + const hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + return hours > 0 ? `${hours} h ${remainder.toString().padStart(2, "0")} min` : `${remainder} min`; +} + +function weatherText(code: number | null | undefined) { + if (code === null || code === undefined) { + return "Wetter offen"; + } + if (code <= 1) { + return "klar"; + } + if (code <= 3) { + return "bewölkt"; + } + if (code < 60) { + return "Sicht prüfen"; + } + if (code < 80) { + return "Regen"; + } + if (code < 95) { + return "Schauer"; + } + return "Gewitter"; +} + +function localDateTimeValue(date: Date) { + const offsetMs = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16); +} + +function toIsoOrNow(value: string) { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : new Date().toISOString(); +} + +function clampDailyHours(value: number) { + if (!Number.isFinite(value)) { + return 8; + } + return Math.min(24, Math.max(1, Math.round(value * 2) / 2)); +} + +function toggleAmenity(current: VoyageAmenity[], amenity: VoyageAmenity) { + return current.includes(amenity) + ? current.filter((candidate) => candidate !== amenity) + : [...current, amenity]; +} + +function clampLockDelay(value: number) { + if (!Number.isFinite(value)) { + return 20; + } + return Math.min(240, Math.max(0, Math.round(value / 5) * 5)); +} + +function telephoneHref(value: string) { + const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? ""; + return `tel:${compact}`; +} diff --git a/apps/web/src/components/RouteTidePanel.tsx b/apps/web/src/components/RouteTidePanel.tsx new file mode 100644 index 0000000..a801fc8 --- /dev/null +++ b/apps/web/src/components/RouteTidePanel.tsx @@ -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 ( +
+
+
+ {loading &&

Tidenfenster werden geladen.

} + {error && ( +

+

+ )} + {plan && ( +
+ + + +
+ )} + Stationsabstand und Bezugsnull beachten; Wasserstände ersetzen keine amtliche Tiefenprüfung. +
+ ); +} + +function TideLocation({ label, summary }: { label: string; summary: TideSummary | null }) { + return ( +
+ {label} + {summary ? ( + <> + + {summary.station} · {summary.distanceKm.toLocaleString("de-DE", { maximumFractionDigits: 1 })} km + + {formatEvent("HW", summary.nextHigh)} + {formatEvent("NW", summary.nextLow)} + + ) : ( + Keine passende Vorhersage + )} +
+ ); +} + +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}`; +} diff --git a/apps/web/src/components/StatusBar.tsx b/apps/web/src/components/StatusBar.tsx new file mode 100644 index 0000000..b4563fe --- /dev/null +++ b/apps/web/src/components/StatusBar.tsx @@ -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 + ?