Initial Watermaps import

This commit is contained in:
BuTzZ
2026-07-24 11:29:24 +02:00
commit 57f7b4dedb
129 changed files with 43136 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f4c5c" />
<title>Watermaps</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -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"
}
}
+39
View File
@@ -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 }
}
}
]
});
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#0f4c5c"/>
<circle cx="32" cy="32" r="22" fill="#f6f9f7"/>
<path d="M32 10l8 25-8 19-8-19 8-25z" fill="#cc3333"/>
<path d="M32 10l8 25-8-5V10z" fill="#0f4c5c"/>
<circle cx="32" cy="32" r="4" fill="#10242b"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

+141
View File
@@ -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);
}
}
+1287
View File
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
import type {
AppConfig,
Coordinate,
MarineForecast,
NavigationDataSnapshot,
RouteRequest,
RouteResult,
TideSummary
} from "@watermaps/shared";
import type { FeatureCollection } from "geojson";
async function getJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
}
async function postJson<T>(url: string, body: unknown): Promise<T> {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response));
}
return (await response.json()) as T;
}
async function responseErrorMessage(response: Response) {
try {
const body = (await response.json()) as { message?: string; error?: string };
return body.message ?? body.error ?? `${response.status} ${response.statusText}`;
} catch {
return `${response.status} ${response.statusText}`;
}
}
export function getConfig(): Promise<AppConfig> {
return getJson<AppConfig>("/api/config");
}
export function getMarineForecast(position: Coordinate, at?: string): Promise<MarineForecast> {
const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) });
if (at) {
search.set("at", at);
}
return getJson<MarineForecast>(`/api/weather/marine?${search.toString()}`);
}
export function getNearestTide(position: Coordinate, at?: string): Promise<TideSummary> {
const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) });
if (at) {
search.set("at", at);
}
return getJson<TideSummary>(`/api/tides/nearest?${search.toString()}`);
}
export function getNavigationData(params: {
waterways?: string[];
stationIds?: string[];
lockIds?: string[];
}): Promise<NavigationDataSnapshot> {
const search = new URLSearchParams();
if (params.waterways?.length) {
search.set("waterways", params.waterways.join(","));
}
if (params.stationIds?.length) {
search.set("stationIds", params.stationIds.join(","));
}
if (params.lockIds?.length) {
search.set("lockIds", params.lockIds.join(","));
}
return getJson<NavigationDataSnapshot>(`/api/navigation/live?${search.toString()}`);
}
export function createRoute(request: RouteRequest): Promise<RouteResult> {
return postJson<RouteResult>("/api/routes", request);
}
export function getMapFeatures(params: {
bbox: [number, number, number, number];
layers: string[];
signal?: AbortSignal;
}): Promise<FeatureCollection> {
const search = new URLSearchParams({
bbox: params.bbox.join(","),
layers: params.layers.join(",")
});
return getJson<FeatureCollection>(`/api/features?${search.toString()}`, { signal: params.signal });
}
@@ -0,0 +1,462 @@
.anchor-watch-panel {
position: absolute;
z-index: 9;
left: 10px;
right: 10px;
bottom: calc(86px + env(safe-area-inset-bottom));
width: min(520px, calc(100vw - 20px));
max-height: min(74vh, calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 118px));
max-height: min(74dvh, calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 118px));
margin: 0 auto;
overflow-y: auto;
overscroll-behavior: contain;
border: 2px solid rgba(15, 76, 92, 0.42);
border-radius: 14px;
padding: 11px;
display: grid;
gap: 9px;
background: rgba(246, 249, 247, 0.98);
color: #10242b;
box-shadow: 0 14px 38px rgba(7, 25, 29, 0.28);
backdrop-filter: blur(18px);
}
.anchor-watch-panel[data-alert="true"] {
border-color: #c44a30;
}
.anchor-watch-header,
.anchor-watch-header > span,
.anchor-point-summary,
.anchor-gps-readiness,
.anchor-inline-warning,
.anchor-inline-alert,
.anchor-tide-card,
.anchor-rode-card,
.anchor-primary-action,
.anchor-secondary-action,
.anchor-acknowledge-action {
display: flex;
align-items: center;
}
.anchor-watch-header {
min-height: 38px;
justify-content: space-between;
gap: 8px;
}
.anchor-watch-header > span {
gap: 7px;
color: #0f4c5c;
}
.anchor-watch-header button,
.anchor-point-summary button {
width: 44px;
height: 44px;
flex: 0 0 auto;
border-radius: 9px;
display: grid;
place-items: center;
background: #e2ece9;
color: #23434c;
}
.anchor-capture,
.anchor-watch-setup,
.anchor-watch-active {
display: grid;
gap: 9px;
}
.anchor-capture > p {
margin: 0;
color: #334e56;
font-size: 13px;
font-weight: 700;
line-height: 1.4;
}
.anchor-gps-readiness,
.anchor-point-summary {
min-height: 52px;
gap: 9px;
border-radius: 10px;
padding: 8px 10px;
background: #edf3f1;
color: #526a72;
}
.anchor-gps-readiness[data-ready="true"] {
background: #dceee6;
color: #196f5c;
}
.anchor-gps-readiness > span,
.anchor-point-summary > span {
min-width: 0;
flex: 1;
display: grid;
gap: 2px;
}
.anchor-gps-readiness strong,
.anchor-point-summary strong {
color: #16323a;
font-size: 13px;
}
.anchor-gps-readiness small,
.anchor-point-summary small {
overflow-wrap: anywhere;
font-size: 10px;
line-height: 1.35;
}
.anchor-primary-action,
.anchor-secondary-action,
.anchor-acknowledge-action,
.anchor-stop-action {
min-height: 44px;
border-radius: 9px;
justify-content: center;
gap: 7px;
font-size: 13px;
font-weight: 850;
}
.anchor-primary-action {
background: #0f4c5c;
color: #ffffff;
}
.anchor-secondary-action {
background: #e2ece9;
color: #23434c;
}
.anchor-acknowledge-action {
width: 100%;
background: #c44a30;
color: #ffffff;
}
.anchor-stop-action {
min-width: 150px;
background: #f1ded9;
color: #8b3024;
}
.anchor-safety-note {
display: block;
color: #607278;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.anchor-settings {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.anchor-settings label {
min-width: 0;
min-height: 58px;
border-radius: 9px;
padding: 6px 8px;
display: grid;
gap: 4px;
background: #edf3f1;
color: #526a72;
font-size: 12px;
font-weight: 850;
}
.anchor-settings label > span:last-child {
display: flex;
align-items: center;
gap: 5px;
color: #16323a;
}
.anchor-settings input,
.anchor-settings select {
width: 100%;
min-width: 0;
height: 44px;
border: 1px solid #bdcfca;
border-radius: 7px;
padding: 0 7px;
background: #ffffff;
color: #10242b;
font-size: 13px;
font-weight: 800;
}
.anchor-settings[data-compact="true"] {
margin-top: 8px;
}
.anchor-planning-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.anchor-tide-card,
.anchor-rode-card {
min-width: 0;
min-height: 80px;
align-items: flex-start;
gap: 7px;
border-radius: 9px;
padding: 8px;
background: #dceee6;
color: #196f5c;
}
.anchor-tide-card[data-incomplete="true"],
.anchor-tide-card[data-far="true"],
.anchor-rode-card[data-state="unknown"] {
background: #fff1cc;
color: #805900;
}
.anchor-rode-card[data-state="short"] {
background: #ffe1dc;
color: #9d2c22;
}
.anchor-card-icon {
flex: 0 0 auto;
padding-top: 2px;
}
.anchor-tide-card > div,
.anchor-rode-card > div {
min-width: 0;
display: grid;
gap: 3px;
}
.anchor-tide-card small,
.anchor-rode-card small,
.anchor-live-metrics small {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.04em;
}
.anchor-tide-card strong,
.anchor-rode-card strong {
display: flex;
align-items: center;
gap: 4px;
color: currentColor;
font-size: 12px;
line-height: 1.25;
}
.anchor-tide-card span,
.anchor-rode-card span,
.anchor-tide-card em {
color: currentColor;
font-size: 11px;
font-weight: 750;
line-height: 1.3;
}
.anchor-tide-card em {
font-style: normal;
}
.anchor-spinner {
animation: anchor-spin 1s linear infinite;
}
@keyframes anchor-spin {
to { transform: rotate(360deg); }
}
.anchor-inline-warning,
.anchor-inline-alert {
margin: 0;
gap: 6px;
border-radius: 9px;
padding: 8px 9px;
font-size: 11px;
font-weight: 800;
line-height: 1.35;
}
.anchor-inline-warning {
background: #fff1cc;
color: #805900;
}
.anchor-inline-alert {
background: #ffe1dc;
color: #9d2c22;
}
.anchor-setup-actions,
.anchor-stop-actions {
display: grid;
grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.25fr);
gap: 7px;
}
.anchor-distance-hero {
min-height: 78px;
border-radius: 11px;
padding: 9px 12px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 8px;
background: #0f4c5c;
color: #ffffff;
}
.anchor-distance-hero[data-tone="warning"] {
background: #805900;
}
.anchor-distance-hero[data-tone="alarm"] {
background: #8b3024;
}
.anchor-distance-hero > span {
color: #ffce66;
}
.anchor-distance-hero > div {
min-width: 0;
display: grid;
}
.anchor-distance-hero small {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.06em;
}
.anchor-distance-hero strong {
font-size: clamp(30px, 10vw, 44px);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.anchor-distance-hero em {
font-size: 16px;
font-style: normal;
white-space: nowrap;
}
.anchor-watch-status {
margin: 0;
border-radius: 9px;
padding: 7px 9px;
display: grid;
gap: 2px;
background: #dceee6;
color: #196f5c;
font-size: 11px;
line-height: 1.3;
}
.anchor-watch-status[data-tone="warning"] {
background: #fff1cc;
color: #805900;
}
.anchor-watch-status[data-tone="alarm"] {
background: #ffe1dc;
color: #9d2c22;
}
.anchor-live-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
}
.anchor-live-metrics > span {
min-width: 0;
border-radius: 8px;
padding: 6px;
display: grid;
gap: 2px;
background: #edf3f1;
}
.anchor-live-metrics small {
color: #607278;
}
.anchor-live-metrics strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: #16323a;
font-size: 11px;
white-space: nowrap;
}
.anchor-acknowledged {
margin: 0;
color: #9d2c22;
font-size: 10px;
font-weight: 800;
}
.anchor-active-details {
border-radius: 9px;
padding: 8px;
background: #edf3f1;
}
.anchor-active-details summary {
cursor: pointer;
color: #23434c;
font-size: 11px;
font-weight: 850;
}
.app-shell[data-anchor-watch-active="true"] .data-badge,
.app-shell[data-anchor-panel-open="true"] .data-badge {
top: calc(env(safe-area-inset-top) + 266px);
bottom: auto;
}
@media (min-width: 720px) {
.anchor-watch-panel {
left: auto;
right: 12px;
width: 430px;
margin: 0;
}
}
@media (max-width: 390px) {
.anchor-planning-summary {
grid-template-columns: 1fr;
}
.anchor-settings {
gap: 5px;
}
.anchor-live-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.anchor-spinner {
animation: none;
}
}
@@ -0,0 +1,438 @@
import {
AlertTriangle,
Anchor,
BellRing,
CheckCircle2,
Crosshair,
LoaderCircle,
MapPin,
RefreshCw,
ShieldCheck,
Waves,
X
} from "lucide-react";
import { useState } from "react";
import type { GpsState } from "../hooks/useGeolocation";
import { type AnchorWatchSettings, useAnchorWatch } from "../hooks/useAnchorWatch";
import "./AnchorWatchPanel.css";
type AnchorWatchModel = ReturnType<typeof useAnchorWatch>;
export type AnchorWatchPanelProps = {
watch: AnchorWatchModel;
gps: Pick<GpsState, "status" | "accuracyM" | "timestampMs">;
onStartGps: () => void;
onClose: () => void;
};
export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatchPanelProps) {
const [confirmStop, setConfirmStop] = useState(false);
const armed = watch.phase === "armed";
const hasAlarm = armed && (watch.positionAlarm || watch.rodeShortfall);
const distanceM = watch.watchResult?.distanceFromAnchorM ?? null;
const nearLimit = Boolean(
armed &&
!watch.positionAlarm &&
distanceM !== null &&
distanceM >= watch.settings.alarmRadiusM * 0.8
);
const status = anchorStatus(watch, gps.status, nearLimit);
const cancel = () => {
watch.reset();
onClose();
};
const stop = () => {
if (!confirmStop) {
setConfirmStop(true);
return;
}
cancel();
};
return (
<aside
className="anchor-watch-panel"
aria-label="Ankerwache"
data-phase={watch.phase}
data-alert={hasAlarm}
data-near-limit={nearLimit}
>
<header className="anchor-watch-header">
<span>
<Anchor size={19} aria-hidden="true" />
<strong>{armed ? "Ankerwache aktiv" : watch.phase === "set" ? "Ankerwache einrichten" : "Ankerwache"}</strong>
</span>
{!armed && (
<button type="button" onClick={cancel} aria-label="Ankerwache schließen">
<X size={18} aria-hidden="true" />
</button>
)}
</header>
{watch.phase === "idle" ? (
<AnchorCapture watch={watch} gps={gps} onStartGps={onStartGps} />
) : armed ? (
<AnchorWatchActive
watch={watch}
status={status}
nearLimit={nearLimit}
confirmStop={confirmStop}
onCancelStop={() => setConfirmStop(false)}
onStop={stop}
/>
) : (
<AnchorWatchSetup watch={watch} onCancel={cancel} />
)}
</aside>
);
}
function AnchorCapture({
watch,
gps,
onStartGps
}: {
watch: AnchorWatchModel;
gps: AnchorWatchPanelProps["gps"];
onStartGps: () => void;
}) {
const gpsReady = gps.status === "tracking" && gps.accuracyM !== null;
return (
<div className="anchor-capture">
<p>
Setze den Punkt genau dann, wenn der Anker den Grund erreicht. Watermaps verschiebt ihn danach nicht mit dem Boot.
</p>
<div className="anchor-gps-readiness" data-ready={gpsReady}>
<Crosshair size={18} aria-hidden="true" />
<span>
<strong>{gpsReady ? `GPS ±${Math.round(gps.accuracyM ?? 0)} m` : gpsLabel(gps.status)}</strong>
<small>Erforderlich: frischer Fix mit höchstens ±{watch.maxCaptureAccuracyM} m</small>
</span>
</div>
{gps.status !== "tracking" && (
<button className="anchor-secondary-action" type="button" onClick={onStartGps}>
<Crosshair size={17} aria-hidden="true" />
GPS starten
</button>
)}
<button className="anchor-primary-action" type="button" onClick={watch.captureAnchor}>
<Anchor size={18} aria-hidden="true" />
Anker gefallen Position jetzt setzen
</button>
{watch.operationError && <p className="anchor-inline-alert" role="alert">{watch.operationError}</p>}
<small className="anchor-safety-note">
Keine automatische Ankererkennung: Ein Browser-GPS kann das Fallenlassen nicht zuverlässig erkennen.
</small>
</div>
);
}
function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCancel: () => void }) {
const suggestedReachM = watch.rodePlan
? Math.ceil(Math.max(
watch.rodePlan.horizontalReachAtSetM,
watch.rodePlan.horizontalReachM ?? 0
) + 5)
: null;
const radiusTooSmall = suggestedReachM !== null && watch.settings.alarmRadiusM < suggestedReachM;
return (
<div className="anchor-watch-setup">
<div className="anchor-point-summary">
<MapPin size={17} aria-hidden="true" />
<span>
<strong>Ankerpunkt gespeichert</strong>
<small>
{watch.anchorPoint ? formatCoordinate(watch.anchorPoint) : ""}
{watch.anchorCaptureAccuracyM !== null && ` · GPS ±${Math.round(watch.anchorCaptureAccuracyM)} m`}
{watch.anchorSetAtMs !== null && ` · ${formatClock(watch.anchorSetAtMs)}`}
</small>
</span>
<button type="button" onClick={watch.captureAnchor} aria-label="Ankerpunkt an aktueller Position neu setzen">
<RefreshCw size={16} aria-hidden="true" />
</button>
</div>
<AnchorSettingsForm watch={watch} />
<TideAndRodeSummary watch={watch} />
{radiusTooSmall && (
<p className="anchor-inline-warning">
<AlertTriangle size={15} aria-hidden="true" />
Der Radius liegt unter der rechnerischen horizontalen Reichweite von etwa {suggestedReachM} m. Normales Schwojen kann bereits alarmieren; Bootslänge und GPS-Unsicherheit kommen noch hinzu.
</p>
)}
{watch.settingsError && <p className="anchor-inline-alert" role="alert">{watch.settingsError}</p>}
{watch.operationError && <p className="anchor-inline-alert" role="alert">{watch.operationError}</p>}
<div className="anchor-setup-actions">
<button className="anchor-secondary-action" type="button" onClick={onCancel}>Abbrechen</button>
<button className="anchor-primary-action" type="button" onClick={() => void watch.arm()}>
<BellRing size={17} aria-hidden="true" />
Wache starten
</button>
</div>
<small className="anchor-safety-note">
Tidendaten und Scope-Rechnung sind Planungshilfen. Grund, Anker, Wind, Wellen, Strom, Schwell und Abstand zu Gefahren müssen vor Ort beurteilt werden.
</small>
</div>
);
}
function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchModel; compact?: boolean }) {
const fields: Array<{
key: keyof AnchorWatchSettings;
label: string;
unit: string;
min: number;
max: number;
step: number;
}> = [
{ key: "depthAtSetM", label: "Tiefe beim Setzen", unit: "m", min: 0.1, max: 200, step: 0.1 },
{ key: "bowRollerHeightM", label: "Bugrolle über Wasser", unit: "m", min: 0, max: 20, step: 0.1 },
{ key: "deployedRodeLengthM", label: "Kette / Leine draußen", unit: "m", min: 1, max: 2_000, step: 1 },
{ key: "safetyAllowanceM", label: "Wasserstandsreserve", unit: "m", min: 0, max: 10, step: 0.1 },
{ key: "alarmRadiusM", label: "Alarmradius ab Anker", unit: "m", min: 10, max: 2_000, step: 5 }
];
return (
<div className="anchor-settings" data-compact={compact}>
{fields.map((field) => (
<label key={field.key} htmlFor={`anchor-${field.key}`}>
<span>{field.label}</span>
<span>
<input
id={`anchor-${field.key}`}
type="number"
inputMode="decimal"
min={field.min}
max={field.max}
step={field.step}
value={watch.settings[field.key]}
onChange={(event) => watch.updateSettings({ [field.key]: Number(event.target.value) })}
/>
{field.unit}
</span>
</label>
))}
<label htmlFor="anchor-scopeRatio">
<span>Gewähltes Verhältnis</span>
<select
id="anchor-scopeRatio"
value={watch.settings.scopeRatio}
onChange={(event) => watch.updateSettings({ scopeRatio: Number(event.target.value) })}
>
{[3, 4, 5, 6, 7, 8, 10].map((value) => <option key={value} value={value}>{value}:1</option>)}
</select>
</label>
<label htmlFor="anchor-horizonHours">
<span>Tidenzeitraum</span>
<select
id="anchor-horizonHours"
value={watch.settings.horizonHours}
onChange={(event) => watch.updateSettings({ horizonHours: Number(event.target.value) })}
>
<option value={12}>12 Stunden</option>
<option value={24}>24 Stunden</option>
<option value={48}>48 Stunden</option>
</select>
</label>
</div>
);
}
function TideAndRodeSummary({ watch, active = false }: { watch: AnchorWatchModel; active?: boolean }) {
const tideWindow = active ? watch.remainingTideWindow : watch.tideWindow;
const plan = watch.rodePlan;
const complete = tideWindow?.coverage === "complete" && plan?.calculationComplete;
const stationFar = Boolean(watch.tide && watch.tide.distanceKm > 30);
return (
<div className="anchor-planning-summary">
<section className="anchor-tide-card" data-incomplete={!complete} data-far={stationFar}>
<span className="anchor-card-icon"><Waves size={18} aria-hidden="true" /></span>
<div>
<small>{active ? "TIDE AB JETZT" : "TIDE AB ANKERSETZEN"}</small>
{watch.tideLoading && !watch.tide ? (
<strong><LoaderCircle className="anchor-spinner" size={15} aria-hidden="true" /> Wird geladen </strong>
) : tideWindow?.maximumRiseM !== null && tideWindow?.maximumRiseM !== undefined ? (
<strong>max. +{tideWindow.maximumRiseM.toFixed(2)} m · Hub {formatNullable(tideWindow.tidalRangeM)} m</strong>
) : (
<strong>Nicht berechenbar</strong>
)}
<span>
{watch.tide
? `${watch.tide.station} · ${watch.tide.distanceKm.toFixed(1)} km entfernt · Stand ${formatUpdatedAt(watch.tide.updatedAt)}`
: watch.tideError
? "Stationsprognose nicht erreichbar nicht als 0 m angesetzt"
: "Warte auf Stationsprognose"}
</span>
{tideWindow?.coverage === "partial" && <em>Prognose deckt den gewählten Zeitraum nur teilweise ab.</em>}
{stationFar && <em>Entfernter Pegel: lokale Tide kann deutlich abweichen.</em>}
</div>
</section>
<section
className="anchor-rode-card"
data-state={!plan?.calculationComplete ? "unknown" : plan.hasSufficientRode ? "safe" : "short"}
>
<span className="anchor-card-icon">
{plan?.calculationComplete && plan.hasSufficientRode
? <ShieldCheck size={18} aria-hidden="true" />
: <AlertTriangle size={18} aria-hidden="true" />}
</span>
<div>
<small>ANKERLEINEN-RESERVE</small>
{plan?.calculationComplete && plan.requiredRodeLengthM !== null && plan.rodeReserveM !== null ? (
<>
<strong>{plan.rodeReserveM >= 0 ? "+" : ""}{plan.rodeReserveM.toFixed(1)} m Reserve</strong>
<span>Rechnerisch {plan.requiredRodeLengthM.toFixed(1)} m bei {watch.settings.scopeRatio}:1 erforderlich</span>
</>
) : plan ? (
<>
<strong>Nicht bestätigt</strong>
<span>Ohne vollständige Tide mindestens {plan.minimumRequiredRodeLengthM.toFixed(1)} m; Zukunftsbedarf offen</span>
</>
) : (
<strong>Eingaben prüfen</strong>
)}
</div>
</section>
</div>
);
}
function AnchorWatchActive({
watch,
status,
nearLimit,
confirmStop,
onCancelStop,
onStop
}: {
watch: AnchorWatchModel;
status: { tone: string; title: string; detail: string };
nearLimit: boolean;
confirmStop: boolean;
onCancelStop: () => void;
onStop: () => void;
}) {
const distance = watch.watchResult?.distanceFromAnchorM;
const elapsedMs = watch.anchorSetAtMs === null ? 0 : Math.max(0, Date.now() - watch.anchorSetAtMs);
const remainingWindow = watch.remainingTideWindow;
return (
<div className="anchor-watch-active">
<section className="anchor-distance-hero" data-tone={status.tone} aria-live="polite">
<span>
{status.tone === "safe" ? <CheckCircle2 size={26} aria-hidden="true" /> : <AlertTriangle size={26} aria-hidden="true" />}
</span>
<div>
<small>ABSTAND / ALARMRADIUS</small>
<strong>{distance === null || distance === undefined ? "---" : Math.round(distance)} <em>/ {Math.round(watch.settings.alarmRadiusM)} m</em></strong>
</div>
</section>
<p className="anchor-watch-status" data-tone={status.tone} role={watch.positionAlarm ? "alert" : "status"}>
<strong>{status.title}</strong>
<span>{status.detail}</span>
</p>
<div className="anchor-live-metrics">
<span><small>GPS</small><strong>{watch.watchResult?.accuracyM === null || watch.watchResult?.accuracyM === undefined ? "--" : `±${Math.round(watch.watchResult.accuracyM)} m`}</strong></span>
<span><small>SEIT</small><strong>{formatDuration(elapsedMs)}</strong></span>
<span><small>TIDE NOCH</small><strong>{remainingWindow?.coverage === "complete" && remainingWindow.maximumRiseM !== null ? `+${remainingWindow.maximumRiseM.toFixed(2)} m` : "offen"}</strong></span>
<span><small>LEINE</small><strong>{watch.rodePlan?.rodeReserveM === null || watch.rodePlan?.rodeReserveM === undefined ? "offen" : `${watch.rodePlan.rodeReserveM >= 0 ? "+" : ""}${watch.rodePlan.rodeReserveM.toFixed(1)} m`}</strong></span>
</div>
{watch.positionAlarm && !watch.alarmAcknowledged && (
<button className="anchor-acknowledge-action" type="button" onClick={watch.acknowledgeAlarm}>
<BellRing size={17} aria-hidden="true" />
Alarm quittieren
</button>
)}
{watch.positionAlarm && watch.alarmAcknowledged && (
<p className="anchor-acknowledged">Alarmton quittiert · rote Warnanzeige bleibt aktiv</p>
)}
{watch.rodeShortfall && (
<p className="anchor-inline-alert" role="alert">
Nach der aktuellen Stationsprognose ist die eingegebene Kette/Leine rechnerisch zu kurz.
</p>
)}
{nearLimit && <p className="anchor-inline-warning">80 % des Alarmradius erreicht.</p>}
<details className="anchor-active-details">
<summary>Radius, Tide und Leinenrechnung</summary>
<AnchorSettingsForm watch={watch} compact />
<TideAndRodeSummary watch={watch} active />
</details>
<div className="anchor-stop-actions">
{confirmStop && <button className="anchor-secondary-action" type="button" onClick={onCancelStop}>Weiter überwachen</button>}
<button className="anchor-stop-action" type="button" onClick={onStop}>
{confirmStop ? "Wirklich beenden" : "Ankerwache beenden"}
</button>
</div>
<small className="anchor-safety-note">
App sichtbar und Display an lassen. Browser und Betriebssystem können GPS, Ton und Mitteilungen im Hintergrund anhalten. Watermaps ersetzt keine Ankerpeilung und keinen Ausguck.
</small>
</div>
);
}
function anchorStatus(watch: AnchorWatchModel, gpsStatus: GpsState["status"], nearLimit: boolean) {
if (watch.fixStale) {
return { tone: "alarm", title: "GPS-Fix veraltet", detail: "Die Ankerposition wird gerade nicht sicher überwacht." };
}
if (gpsStatus !== "tracking") {
return { tone: "alarm", title: "GPS ausgefallen", detail: "Position prüfen und GPS-Berechtigung wiederherstellen." };
}
if (watch.gpsUnreliable) {
return { tone: "alarm", title: "GPS zu ungenau", detail: "Keine sichere Aussage zum Schwojradius möglich." };
}
if (watch.watchResult?.alarmTriggered) {
return {
tone: "alarm",
title: "Außerhalb des Alarmradius",
detail: `Auch nach Abzug der GPS-Ungenauigkeit noch ${Math.round(watch.watchResult.conservativeDistanceFromAnchorM ?? 0)} m vom Ankerpunkt.`
};
}
if (nearLimit) {
return { tone: "warning", title: "Nahe am Alarmradius", detail: "Position und Peilmarken aufmerksam beobachten." };
}
return { tone: "safe", title: "Im überwachten Schwojkreis", detail: "Abstand wird mit jedem neuen GPS-Fix geprüft." };
}
function gpsLabel(status: GpsState["status"]) {
if (status === "requesting") return "GPS-Freigabe wird angefragt";
if (status === "denied") return "GPS-Freigabe abgelehnt";
if (status === "unavailable") return "GPS nicht verfügbar";
if (status === "error") return "GPS-Fehler";
return "GPS noch nicht gestartet";
}
function formatCoordinate(coordinate: { lat: number; lon: number }) {
return `${coordinate.lat.toFixed(5)}, ${coordinate.lon.toFixed(5)}`;
}
function formatClock(timestampMs: number) {
return new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(timestampMs);
}
function formatUpdatedAt(value: string) {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? formatClock(timestamp) : "unbekannt";
}
function formatDuration(milliseconds: number) {
const totalMinutes = Math.floor(milliseconds / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes} min`;
}
function formatNullable(value: number | null) {
return value === null ? "" : value.toFixed(2);
}
+42
View File
@@ -0,0 +1,42 @@
import { Compass } from "lucide-react";
type CompassDialProps = {
headingDeg: number | null;
source: string;
status: string;
targetHeadingDeg?: number | null;
onRequest: () => void;
};
export function CompassDial({ headingDeg, source, status, targetHeadingDeg = null, onRequest }: CompassDialProps) {
const displayHeading = headingDeg ?? 0;
return (
<button
className="compass-dial"
type="button"
title="Kompass aktivieren"
aria-label={
targetHeadingDeg === null
? "Kompass aktivieren"
: `Kompass aktivieren, Sollkurs ${Math.round(targetHeadingDeg)} Grad`
}
onClick={onRequest}
data-status={status}
>
<span className="compass-ring">
{targetHeadingDeg !== null && (
<span
className="compass-course-marker"
style={{ transform: `rotate(${targetHeadingDeg}deg)` }}
aria-hidden="true"
/>
)}
<span className="compass-needle" style={{ transform: `rotate(${displayHeading}deg)` }} />
<Compass size={18} aria-hidden="true" />
</span>
<span className="compass-value">{headingDeg === null ? "--" : Math.round(headingDeg)}</span>
<span className="compass-source">{source}</span>
</button>
);
}
+317
View File
@@ -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;
}
+616
View File
@@ -0,0 +1,616 @@
import {
AlertTriangle,
CloudSun,
MapPin,
Navigation2,
Thermometer,
Waves,
Wind,
X
} from "lucide-react";
import { useId, type ReactNode } from "react";
import type { MarineForecast, TideEvent, TideSummary } from "@watermaps/shared";
import "./ConditionsPanel.css";
export type ConditionsPositionSource = {
kind: "gps" | "fallback" | "unknown";
label?: string | null;
};
export type ConditionsRouteWeatherSample = {
label: "Start" | "Mitte" | "Ziel";
forecast: MarineForecast;
plannedTime?: string;
currentAlongRouteKn?: number | null;
};
/**
* Deliberately narrower than RouteWeatherReport. The complete report remains
* assignable while this panel stays independent of the report generator.
*/
export type ConditionsRouteWeatherReport = {
samples: ReadonlyArray<ConditionsRouteWeatherSample>;
severity?: "ok" | "caution" | "critical";
summary?: string | null;
source?: string | null;
updatedAt?: string | null;
unavailableSamples?: number;
};
export type ConditionsRouteTides = {
start: TideSummary | null;
middle?: TideSummary | null;
destination: TideSummary | null;
};
export type ConditionsPanelProps = {
forecast: MarineForecast | null;
tide: TideSummary | null;
positionSource?: ConditionsPositionSource;
currentLoading?: boolean;
currentError?: string | null;
routeWeatherReport?: ConditionsRouteWeatherReport | null;
routeTides?: ConditionsRouteTides | null;
routeLoading?: boolean;
routeError?: string | null;
onClose?: () => void;
className?: string;
/** Optional clock for deterministic consumers and tests. */
now?: number;
};
const ROUTE_SAMPLE_LABELS = ["Start", "Mitte", "Ziel"] as const;
export function ConditionsPanel({
forecast,
tide,
positionSource = { kind: "unknown" },
currentLoading = false,
currentError = null,
routeWeatherReport = null,
routeTides = null,
routeLoading = false,
routeError = null,
onClose,
className,
now = Date.now()
}: ConditionsPanelProps) {
const titleId = useId();
const currentTitleId = useId();
const tideTitleId = useId();
const routeTitleId = useId();
const hasCurrentData = Boolean(forecast || tide);
const hasRouteData = Boolean(routeWeatherReport || routeTides);
return (
<aside
className={joinClassNames("conditions-panel", className)}
aria-labelledby={titleId}
aria-busy={currentLoading || routeLoading}
data-position-source={positionSource.kind}
>
<header className="conditions-panel-header">
<div>
<span className="conditions-panel-kicker">
<CloudSun size={16} aria-hidden="true" />
Bedingungen
</span>
<h2 id={titleId}>Wetter &amp; Tide</h2>
<PositionSource source={positionSource} />
</div>
{onClose && (
<button
className="conditions-panel-close"
type="button"
onClick={onClose}
aria-label="Wetter und Tide schließen"
title="Schließen"
>
<X size={18} aria-hidden="true" />
</button>
)}
</header>
{currentLoading && (
<p className="conditions-panel-state" role="status" aria-live="polite">
Bedingungen für die aktuelle Position werden geladen
</p>
)}
{currentError && (
<p className="conditions-panel-state" data-state="warning" role="alert">
<AlertTriangle size={15} aria-hidden="true" />
{currentError}
</p>
)}
{!hasCurrentData && !currentLoading && !currentError && (
<p className="conditions-panel-state" data-state="empty">
Für diese Position liegen noch keine Wetter- oder Tidendaten vor.
</p>
)}
{(forecast || (hasCurrentData && !currentLoading)) && (
<section className="conditions-section" aria-labelledby={currentTitleId}>
<SectionHeading id={currentTitleId} icon={<Wind size={16} aria-hidden="true" />}>
Aktuelle Bedingungen
</SectionHeading>
{forecast ? (
<>
<dl className="conditions-metric-grid">
<Metric
icon={<Wind size={15} aria-hidden="true" />}
label="Wind"
value={formatWind(forecast)}
/>
<Metric
icon={<Waves size={15} aria-hidden="true" />}
label="Welle"
value={formatWave(forecast)}
/>
<Metric
icon={<Navigation2 size={15} aria-hidden="true" />}
label="Strömung"
value={formatCurrent(forecast)}
/>
<Metric
icon={<Thermometer size={15} aria-hidden="true" />}
label="Temperatur"
value={formatTemperature(forecast.temperatureC)}
/>
</dl>
<DataProvenance
source={forecast.source}
updatedAt={forecast.updatedAt}
validAt={forecast.forecastTime}
now={now}
/>
</>
) : (
<p className="conditions-inline-empty">Aktuelle Wetter- und Strömungsdaten fehlen.</p>
)}
</section>
)}
{(tide || (hasCurrentData && !currentLoading)) && (
<section className="conditions-section" aria-labelledby={tideTitleId}>
<SectionHeading id={tideTitleId} icon={<Waves size={16} aria-hidden="true" />}>
Tide an der Position
</SectionHeading>
{tide ? (
<>
<p className="conditions-station">
<MapPin size={14} aria-hidden="true" />
<strong>{safeText(tide.station, "Station unbekannt")}</strong>
<span>{formatDistanceKm(tide.distanceKm)} entfernt</span>
</p>
<dl className="conditions-tide-events">
<TideEventRow label="Nächstes Hochwasser" shortLabel="HW" event={tide.nextHigh} />
<TideEventRow label="Nächstes Niedrigwasser" shortLabel="NW" event={tide.nextLow} />
</dl>
<DataProvenance source={tide.source} updatedAt={tide.updatedAt} now={now} />
</>
) : (
<p className="conditions-inline-empty">Für diese Position fehlt eine passende Tidenstation.</p>
)}
</section>
)}
<section className="conditions-section conditions-route" aria-labelledby={routeTitleId}>
<SectionHeading id={routeTitleId} icon={<Navigation2 size={16} aria-hidden="true" />}>
Bedingungen auf der Strecke
</SectionHeading>
{routeLoading && (
<p className="conditions-panel-state" role="status" aria-live="polite">
Streckenprognose wird geladen
</p>
)}
{routeError && (
<p className="conditions-panel-state" data-state="warning" role="alert">
<AlertTriangle size={15} aria-hidden="true" />
{routeError}
</p>
)}
{routeWeatherReport && (
<RouteAssessment report={routeWeatherReport} now={now} />
)}
{hasRouteData ? (
<div className="conditions-route-samples">
{ROUTE_SAMPLE_LABELS.map((label) => (
<RouteSampleCard
key={label}
label={label}
sample={routeWeatherReport?.samples.find((candidate) => candidate.label === label) ?? null}
tide={
label === "Start"
? routeTides?.start ?? null
: label === "Mitte"
? routeTides?.middle ?? null
: label === "Ziel"
? routeTides?.destination ?? null
: null
}
routeTidesAvailable={Boolean(routeTides)}
now={now}
/>
))}
</div>
) : (
!routeLoading && (
<p className="conditions-panel-state" data-state="empty">
Nach der Routenberechnung erscheinen hier Start, Mitte und Ziel.
</p>
)
)}
</section>
<p className="conditions-panel-disclaimer">
Prognosen und entfernte Tidenstationen können lokal abweichen. Amtliche Warnungen, Pegel und
Befahrensregeln zusätzlich prüfen.
</p>
</aside>
);
}
function PositionSource({ source }: { source: ConditionsPositionSource }) {
const label =
source.kind === "gps"
? source.label
? `GPS · ${source.label}`
: "Aktuelle GPS-Position"
: source.kind === "fallback"
? source.label
? `Fallback · ${source.label}`
: "Fallback-Position"
: source.label || "Positionsquelle noch offen";
return (
<p className="conditions-position-source" data-source={source.kind}>
{source.kind === "gps" ? (
<Navigation2 size={14} aria-hidden="true" />
) : (
<MapPin size={14} aria-hidden="true" />
)}
<span>{label}</span>
</p>
);
}
function SectionHeading({
id,
icon,
children
}: {
id: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<h3 id={id} className="conditions-section-heading">
{icon}
{children}
</h3>
);
}
function Metric({
icon,
label,
value
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div className="conditions-metric">
<dt>
{icon}
{label}
</dt>
<dd>{value}</dd>
</div>
);
}
function TideEventRow({
label,
shortLabel,
event
}: {
label: string;
shortLabel: "HW" | "NW";
event: TideEvent | null;
}) {
return (
<div className="conditions-tide-event">
<dt>
<abbr title={label}>{shortLabel}</abbr>
</dt>
<dd>
{event ? (
<>
{isValidDate(event.time) ? (
<time dateTime={event.time}>{formatDateTime(event.time)}</time>
) : (
<span>Zeit offen</span>
)}
<span>{formatTideHeight(event.heightM)}</span>
</>
) : (
<span>Zeit und Höhe offen</span>
)}
</dd>
</div>
);
}
function DataProvenance({
source,
updatedAt,
validAt,
now
}: {
source?: string | null;
updatedAt?: string | null;
validAt?: string | null;
now: number;
}) {
return (
<p className="conditions-data-provenance">
<span>Quelle: {safeText(source, "nicht angegeben")}</span>
<span>
Stand:{" "}
{updatedAt && isValidDate(updatedAt) ? (
<time dateTime={updatedAt} title={formatDateTime(updatedAt)}>
{formatAge(updatedAt, now)}
</time>
) : (
"Zeit unbekannt"
)}
</span>
{validAt && isValidDate(validAt) && (
<span>
Gültig: <time dateTime={validAt}>{formatDateTime(validAt)}</time>
</span>
)}
</p>
);
}
function RouteAssessment({
report,
now
}: {
report: ConditionsRouteWeatherReport;
now: number;
}) {
const unavailableSamples = finiteNumber(report.unavailableSamples);
const hasUnavailableSamples = unavailableSamples !== null && unavailableSamples > 0;
return (
<div className="conditions-route-assessment" data-severity={report.severity ?? "unknown"}>
{(report.severity || report.summary) && (
<p>
{report.severity && <strong>{severityLabel(report.severity)}: </strong>}
{report.summary || "Streckenbedingungen teilweise verfügbar."}
</p>
)}
{hasUnavailableSamples && (
<p className="conditions-panel-state" data-state="warning">
<AlertTriangle size={14} aria-hidden="true" />
{unavailableSamples === 1
? "Für einen Streckenpunkt fehlt die Prognose."
: `Für ${unavailableSamples} Streckenpunkte fehlt die Prognose.`}
</p>
)}
{(report.source || report.updatedAt) && (
<DataProvenance source={report.source} updatedAt={report.updatedAt} now={now} />
)}
</div>
);
}
function RouteSampleCard({
label,
sample,
tide,
routeTidesAvailable,
now
}: {
label: (typeof ROUTE_SAMPLE_LABELS)[number];
sample: ConditionsRouteWeatherSample | null;
tide: TideSummary | null;
routeTidesAvailable: boolean;
now: number;
}) {
return (
<article className="conditions-route-sample" data-sample={label.toLowerCase()}>
<header>
<h4>{label}</h4>
{sample?.plannedTime && isValidDate(sample.plannedTime) && (
<time dateTime={sample.plannedTime}>{formatDateTime(sample.plannedTime)}</time>
)}
</header>
{sample ? (
<>
<dl>
<Metric label="Wind" value={formatWind(sample.forecast)} icon={<Wind size={14} aria-hidden="true" />} />
<Metric label="Welle" value={formatWave(sample.forecast)} icon={<Waves size={14} aria-hidden="true" />} />
<Metric
label="Strom"
value={formatRouteCurrent(sample)}
icon={<Navigation2 size={14} aria-hidden="true" />}
/>
</dl>
<DataProvenance
source={sample.forecast.source}
updatedAt={sample.forecast.updatedAt}
validAt={sample.forecast.forecastTime}
now={now}
/>
</>
) : (
<p className="conditions-inline-empty">Keine Wetterprognose für diesen Streckenpunkt.</p>
)}
{tide ? (
<div className="conditions-route-tide">
<strong>
{safeText(tide.station, "Tidenstation")} · {formatDistanceKm(tide.distanceKm)}
</strong>
<span>{formatCompactTideEvent("HW", tide.nextHigh)}</span>
<span>{formatCompactTideEvent("NW", tide.nextLow)}</span>
</div>
) : (
routeTidesAvailable && (
<p className="conditions-inline-empty">Keine passende Tide für {label.toLowerCase()}.</p>
)
)}
</article>
);
}
function formatWind(forecast: MarineForecast) {
return joinMeasurements(
formatMeasurement(forecast.windSpeed, "kn", 0),
formatDirection(forecast.windDirectionDeg)
);
}
function formatWave(forecast: MarineForecast) {
return joinMeasurements(
formatMeasurement(forecast.waveHeightM, "m", 1),
formatMeasurement(forecast.wavePeriodS, "s", 0),
formatDirection(forecast.waveDirectionDeg)
);
}
function formatCurrent(forecast: MarineForecast) {
return joinMeasurements(
formatMeasurement(forecast.oceanCurrentSpeedKn, "kn", 1),
formatDirection(forecast.oceanCurrentDirectionDeg)
);
}
function formatRouteCurrent(sample: ConditionsRouteWeatherSample) {
const alongRoute = finiteNumber(sample.currentAlongRouteKn);
if (alongRoute !== null) {
const sign = alongRoute > 0 ? "+" : "";
return `${sign}${alongRoute.toFixed(1)} kn entlang Route`;
}
return formatCurrent(sample.forecast);
}
function formatTemperature(value: number | null | undefined) {
const normalized = finiteNumber(value);
return normalized === null ? "Keine Daten" : `${normalized.toFixed(1)} °C`;
}
function formatMeasurement(value: number | null | undefined, unit: string, digits: number) {
const normalized = finiteNumber(value);
return normalized === null ? null : `${normalized.toFixed(digits)} ${unit}`;
}
function formatDirection(value: number | null | undefined) {
const normalized = finiteNumber(value);
if (normalized === null) {
return null;
}
const heading = ((normalized % 360) + 360) % 360;
const cardinal = ["N", "NO", "O", "SO", "S", "SW", "W", "NW"][
Math.round(heading / 45) % 8
];
return `${String(Math.round(heading)).padStart(3, "0")}° ${cardinal}`;
}
function formatDistanceKm(value: number) {
const normalized = finiteNumber(value);
return normalized === null
? "Entfernung unbekannt"
: `${normalized.toLocaleString("de-DE", { maximumFractionDigits: 1 })} km`;
}
function formatTideHeight(value: number | null | undefined) {
const normalized = finiteNumber(value);
return normalized === null ? "Höhe offen" : `${normalized.toFixed(2)} m`;
}
function formatCompactTideEvent(label: "HW" | "NW", event: TideEvent | null) {
if (!event) {
return `${label} offen`;
}
return `${label} ${formatDateTime(event.time)} · ${formatTideHeight(event.heightM)}`;
}
function formatDateTime(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
: "Zeit offen";
}
function formatAge(value: string, now: number) {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp) || !Number.isFinite(now)) {
return "Zeit unbekannt";
}
if (timestamp - now > 60_000) {
return formatDateTime(value);
}
const ageMs = Math.max(0, now - timestamp);
const minutes = Math.floor(ageMs / 60_000);
if (minutes < 1) {
return "gerade aktualisiert";
}
if (minutes < 60) {
return `vor ${minutes} Min.`;
}
const hours = Math.floor(minutes / 60);
if (hours < 48) {
return `vor ${hours} Std.`;
}
return `vor ${Math.floor(hours / 24)} Tagen`;
}
function severityLabel(severity: NonNullable<ConditionsRouteWeatherReport["severity"]>) {
switch (severity) {
case "critical":
return "Kritisch";
case "caution":
return "Achtung";
case "ok":
return "Unauffällig";
}
}
function joinMeasurements(...parts: Array<string | null>) {
const available = parts.filter((part): part is string => Boolean(part));
return available.length > 0 ? available.join(" · ") : "Keine Daten";
}
function finiteNumber(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function isValidDate(value: string) {
return Number.isFinite(Date.parse(value));
}
function safeText(value: string | null | undefined, fallback: string) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function joinClassNames(...values: Array<string | null | undefined | false>) {
return values.filter(Boolean).join(" ");
}
@@ -0,0 +1,238 @@
.course-assistant-panel {
position: absolute;
z-index: 8;
left: 10px;
right: 10px;
bottom: calc(86px + env(safe-area-inset-bottom));
width: min(430px, calc(100vw - 20px));
margin: 0 auto;
border: 2px solid rgba(15, 76, 92, 0.42);
border-radius: 14px;
padding: 10px;
display: grid;
gap: 7px;
background: rgba(246, 249, 247, 0.97);
color: #10242b;
box-shadow: 0 14px 38px rgba(7, 25, 29, 0.28);
backdrop-filter: blur(18px);
}
.course-assistant-panel[data-alert="true"] {
border-color: #c44a30;
}
.course-assistant-header,
.course-assistant-header > span,
.course-assistant-header button,
.course-assistant-status {
display: flex;
align-items: center;
}
.course-assistant-header {
min-height: 36px;
justify-content: space-between;
gap: 8px;
}
.course-assistant-header > span {
gap: 7px;
color: #0f4c5c;
font-size: 14px;
}
.course-assistant-header button {
min-width: 72px;
min-height: 44px;
justify-content: center;
gap: 6px;
border-radius: 9px;
background: #e2ece9;
color: #23434c;
font-size: 12px;
font-weight: 850;
}
.course-assistant-main {
min-height: 72px;
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 10px;
align-items: center;
border-radius: 11px;
padding: 7px 10px;
background: #0f4c5c;
color: #ffffff;
}
.course-assistant-panel[data-alert="true"] .course-assistant-main {
background: #7c3327;
}
.course-assistant-arrow {
width: 48px;
height: 48px;
display: grid;
place-items: center;
transform-origin: 50% 50%;
color: #ffce66;
}
.course-assistant-arrow[data-muted="true"] {
opacity: 0.38;
}
.course-assistant-arrow svg {
transform: rotate(-45deg);
}
.course-assistant-course {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
column-gap: 8px;
}
.course-assistant-course > span {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.08em;
}
.course-assistant-course strong {
grid-row: 1 / span 2;
grid-column: 2;
font-variant-numeric: tabular-nums;
font-size: clamp(29px, 9vw, 42px);
line-height: 1;
white-space: nowrap;
}
.course-assistant-course small {
min-width: 0;
overflow: hidden;
line-height: 1.1;
color: #d9edeb;
font-size: 12px;
font-weight: 850;
}
.course-assistant-status,
.course-assistant-turn,
.course-assistant-safety {
margin: 0;
}
.course-assistant-status {
min-height: 30px;
gap: 6px;
border-radius: 8px;
padding: 6px 8px;
background: #dceee6;
color: #196f5c;
font-size: 12px;
font-weight: 850;
line-height: 1.3;
}
.course-assistant-panel[data-alert="true"] .course-assistant-status {
background: #ffe1dc;
color: #9d2c22;
}
.course-assistant-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
}
.course-assistant-metrics > span {
min-width: 0;
border-radius: 8px;
padding: 5px 7px;
display: grid;
gap: 2px;
background: #edf3f1;
}
.course-assistant-metrics small {
color: #607278;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.04em;
}
.course-assistant-metrics strong {
overflow: hidden;
text-overflow: ellipsis;
color: #16323a;
font-size: 14px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.course-assistant-progress {
height: 5px;
overflow: hidden;
border-radius: 99px;
background: #d5e1dd;
}
.course-assistant-progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: #d89c28;
}
.course-assistant-turn {
border-radius: 8px;
padding: 6px 8px;
background: #fff1cc;
color: #805900;
font-size: 12px;
font-weight: 850;
}
.course-assistant-safety {
color: #607278;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
}
.app-shell[data-guidance-active="true"] .route-panel-toggle {
top: calc(env(safe-area-inset-top) + 62px);
bottom: auto;
}
.app-shell[data-guidance-active="true"] .data-badge {
top: calc(env(safe-area-inset-top) + 266px);
bottom: auto;
}
@media (min-width: 720px) {
.course-assistant-panel {
left: auto;
right: 12px;
width: 390px;
margin: 0;
}
}
@media (max-height: 680px) {
.course-assistant-safety {
display: none;
}
.course-assistant-panel {
gap: 5px;
}
}
@media (prefers-reduced-motion: reduce) {
.course-assistant-arrow {
transition: none;
}
}
@@ -0,0 +1,178 @@
import { AlertTriangle, Navigation, Square } from "lucide-react";
import type { RouteGuidanceResult } from "@watermaps/shared";
import "./CourseAssistantPanel.css";
export type CourseAssistantPanelProps = {
guidance: RouteGuidanceResult | null;
gpsStatus: string;
headingDeg: number | null;
headingSource: "COG" | "HDG" | "--";
accuracyM: number | null;
fixStale: boolean;
onStop: () => void;
};
export function CourseAssistantPanel({
guidance,
gpsStatus,
headingDeg,
headingSource,
accuracyM,
fixStale,
onStop
}: CourseAssistantPanelProps) {
const waitingForGps = !guidance && !fixStale;
const suppressSteering =
!guidance || fixStale || guidance.status === "gps-unreliable" || guidance.status === "arrived";
const alertState = Boolean(fixStale || guidance?.status === "off-route" || guidance?.status === "gps-unreliable");
const correction = guidance?.courseCorrectionDeg ?? null;
const progress = guidance ? Math.round(guidance.progressRatio * 100) : 0;
const statusText = guidanceStatusText(guidance, fixStale, gpsStatus);
return (
<aside
className="course-assistant-panel"
aria-label="Kursassistent"
data-alert={alertState}
data-status={fixStale ? "stale-fix" : guidance?.status ?? "waiting-gps"}
>
<header className="course-assistant-header">
<span>
<Navigation size={17} aria-hidden="true" />
<strong>Kursassistent</strong>
</span>
<button type="button" onClick={onStop} aria-label="Kursassistent stoppen">
<Square size={14} aria-hidden="true" />
Stop
</button>
</header>
<div className="course-assistant-main">
<span
className="course-assistant-arrow"
data-muted={suppressSteering}
style={{ transform: `rotate(${suppressSteering || correction === null ? 0 : correction}deg)` }}
aria-hidden="true"
>
<Navigation size={32} />
</span>
<div className="course-assistant-course">
<span>SOLL ÜBER GRUND</span>
<strong>{suppressSteering ? "---" : formatCourse(guidance.desiredCourseDeg)}</strong>
<small>{suppressSteering ? "Keine verlässliche Steueranweisung" : correctionText(correction)}</small>
</div>
</div>
<p className="course-assistant-status" aria-live="polite">
{alertState && <AlertTriangle size={14} aria-hidden="true" />}
<span>{waitingForGps ? "GPS-Fix wird ermittelt …" : statusText}</span>
</p>
{guidance && (
<>
<div className="course-assistant-metrics">
<span>
<small>IST {headingSource}</small>
<strong>{headingDeg === null ? "---" : formatActualHeading(headingDeg, headingSource)}</strong>
</span>
<span>
<small>QUERABSTAND</small>
<strong>{formatCrossTrack(guidance)}</strong>
</span>
<span>
<small>REST</small>
<strong>{formatNauticalMiles(guidance.remainingRouteDistanceM)}</strong>
</span>
</div>
<div
className="course-assistant-progress"
role="progressbar"
aria-label="Routenfortschritt"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
aria-valuetext={`${progress} Prozent`}
>
<span style={{ width: `${progress}%` }} />
</div>
{guidance.nextTurn && guidance.status !== "arrived" && (
<p className="course-assistant-turn">
{turnLabel(guidance.nextTurn.direction)} in {formatDistance(guidance.nextTurn.distanceM)}
{` · danach ${formatCourse(guidance.nextTurn.outgoingCourseDeg)}`}
</p>
)}
</>
)}
<small className="course-assistant-safety">
Steuert das Boot nicht. Sollkurs über Grund; Ufer, Tonnen, Verkehr und amtliche Unterlagen haben Vorrang.
{accuracyM !== null ? ` GPS ±${Math.round(accuracyM)} m.` : ""}
</small>
</aside>
);
}
function guidanceStatusText(guidance: RouteGuidanceResult | null, fixStale: boolean, gpsStatus: string) {
if (fixStale) return "GPS-Fix ist veraltet Kursanweisung pausiert.";
if (!guidance) {
if (gpsStatus === "denied") return "GPS-Freigabe wurde abgelehnt.";
if (gpsStatus === "unavailable") return "GPS ist auf diesem Gerät nicht verfügbar.";
if (gpsStatus === "error") return "GPS-Position konnte nicht gelesen werden.";
return "GPS-Fix wird ermittelt …";
}
switch (guidance.status) {
case "arrived":
return "Ziel erreicht.";
case "gps-unreliable":
return "GPS zu ungenau Kursanweisung pausiert.";
case "off-route":
return `Route um ${Math.round(guidance.distanceToRouteM)} m verlassen nur im freien Fahrwasser zurückkehren.`;
case "approaching-turn":
return guidance.nextTurn
? `${turnLabel(guidance.nextTurn.direction)} in ${formatDistance(guidance.nextTurn.distanceM)}.`
: "Kursänderung voraus.";
default:
return "Auf Route Sollkurs wird mit jedem GPS-Fix angepasst.";
}
}
function correctionText(value: number | null) {
if (value === null || !Number.isFinite(value)) return "COG noch nicht verfügbar";
const rounded = Math.round(Math.abs(value));
if (rounded <= 4) return "Kurs halten";
return `${rounded}° nach ${value > 0 ? "Steuerbord" : "Backbord"}`;
}
function formatCrossTrack(guidance: RouteGuidanceResult) {
const distance = Math.round(guidance.distanceToRouteM);
if (distance <= 3 || guidance.crossTrackSide === "on-route") return "auf Linie";
return `${distance} m ${guidance.crossTrackSide === "port" ? "Backbord" : "Steuerbord"}`;
}
function turnLabel(direction: NonNullable<RouteGuidanceResult["nextTurn"]>["direction"]) {
if (direction === "port") return "Backbord-Kursänderung";
if (direction === "starboard") return "Steuerbord-Kursänderung";
return "Wenden";
}
function formatCourse(value: number) {
const normalized = Math.round(((value % 360) + 360) % 360);
return `${String(normalized === 360 ? 0 : normalized).padStart(3, "0")}°T`;
}
function formatActualHeading(value: number, source: CourseAssistantPanelProps["headingSource"]) {
const formatted = formatCourse(value);
return source === "COG" ? formatted : formatted.replace("°T", "°");
}
function formatNauticalMiles(meters: number) {
const remainingM = Math.max(0, meters);
if (remainingM < 185) return `${Math.round(remainingM)} m`;
return `${(remainingM / 1852).toFixed(remainingM < 18_520 ? 1 : 0)} sm`;
}
function formatDistance(meters: number) {
return meters < 1000 ? `${Math.max(0, Math.round(meters))} m` : `${(meters / 1000).toFixed(1)} km`;
}
+44
View File
@@ -0,0 +1,44 @@
import { Component, Suspense, type ErrorInfo, type ReactNode } from "react";
type LazyContentProps = {
children: ReactNode;
pending: ReactNode;
failed: ReactNode;
};
type LazyLoadErrorBoundaryProps = {
children: ReactNode;
fallback: ReactNode;
};
type LazyLoadErrorBoundaryState = {
failed: boolean;
};
export function LazyContent({ children, pending, failed }: LazyContentProps) {
return (
<LazyLoadErrorBoundary fallback={failed}>
<Suspense fallback={pending}>{children}</Suspense>
</LazyLoadErrorBoundary>
);
}
class LazyLoadErrorBoundary extends Component<
LazyLoadErrorBoundaryProps,
LazyLoadErrorBoundaryState
> {
state: LazyLoadErrorBoundaryState = { failed: false };
static getDerivedStateFromError(): LazyLoadErrorBoundaryState {
return { failed: true };
}
componentDidCatch(_error: unknown, _errorInfo: ErrorInfo) {
// The local fallback keeps the rest of the navigation UI usable. A reload
// can then pick up a newer PWA chunk after a deployment.
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
import { useEffect, useRef } from "react";
import { Globe2, Mail, Phone, X } from "lucide-react";
import type { Coordinate } from "@watermaps/shared";
export type MarineFeatureDetails = {
id: string;
layer: "locks" | "harbours";
name: string;
typeLabel: "Schleuse" | "Hafen";
coordinate: Coordinate;
phone: string | null;
website: string | null;
email: string | null;
vhf: string | null;
openingHours: string | null;
operator: string | null;
address: string | null;
source: string | null;
sourceUrl?: string | null;
updatedAt: string | null;
memberCount?: number | null;
};
type MarineFeatureInfoProps = {
feature: MarineFeatureDetails;
onClose: () => void;
};
export function MarineFeatureInfo({ feature, onClose }: MarineFeatureInfoProps) {
const dialogRef = useRef<HTMLElement | null>(null);
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
const onCloseRef = useRef(onClose);
const phone = presentValue(feature.phone);
const website = presentValue(feature.website);
const email = presentValue(feature.email);
const vhf = presentValue(feature.vhf);
const openingHours = presentValue(feature.openingHours);
const operator = presentValue(feature.operator);
const address = presentValue(feature.address);
const source = presentValue(feature.source);
const updatedAt = presentValue(feature.updatedAt);
const phoneHref = phone ? telephoneHref(phone) : null;
const websiteHref = website ? websiteUrl(website) : null;
const sourceHref = feature.sourceUrl ? websiteUrl(feature.sourceUrl) : null;
const hasContactActions = Boolean(phoneHref || websiteHref || email);
const hasOperatingDetails = Boolean(vhf || openingHours || operator || address);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
previouslyFocusedElementRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
closeButtonRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onCloseRef.current();
return;
}
if (event.key !== "Tab" || !dialogRef.current) {
return;
}
const focusableElements = getFocusableElements(dialogRef.current);
if (focusableElements.length === 0) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (!firstElement || !lastElement) {
return;
}
const activeElement = document.activeElement;
const focusIsOutsideDialog =
!(activeElement instanceof Node) || !dialogRef.current.contains(activeElement);
if (event.shiftKey && (activeElement === firstElement || focusIsOutsideDialog)) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && (activeElement === lastElement || focusIsOutsideDialog)) {
event.preventDefault();
firstElement.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
const previouslyFocusedElement = previouslyFocusedElementRef.current;
if (previouslyFocusedElement?.isConnected) {
previouslyFocusedElement.focus();
}
previouslyFocusedElementRef.current = null;
};
}, []);
return (
<div
className="marine-feature-info-layer"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}
>
<button
className="marine-feature-info-backdrop"
type="button"
aria-label="Detailansicht schließen"
tabIndex={-1}
onClick={onClose}
/>
<section
ref={dialogRef}
className="marine-feature-info"
role="dialog"
aria-modal="true"
aria-labelledby="marine-feature-info-title"
aria-describedby="marine-feature-info-contact-note"
tabIndex={-1}
>
<header className="marine-feature-info-header">
<div>
<span className="marine-feature-type">{feature.typeLabel}</span>
<h2 id="marine-feature-info-title">{feature.name}</h2>
</div>
<button
ref={closeButtonRef}
className="marine-feature-info-close"
type="button"
aria-label="Informationen schließen"
title="Informationen schließen"
onClick={onClose}
>
<X size={20} aria-hidden="true" />
</button>
</header>
{hasContactActions ? (
<div className="marine-feature-actions" role="group" aria-label="Kontaktmöglichkeiten">
{phoneHref && phone && (
<a
className="marine-feature-action"
data-action="phone"
href={phoneHref}
aria-label={`${feature.name} anrufen: ${phone}`}
>
<Phone size={20} aria-hidden="true" />
<span>
<strong>Anrufen</strong>
<small>{phone}</small>
</span>
</a>
)}
{websiteHref && (
<a
className="marine-feature-action"
data-action="website"
href={websiteHref}
target="_blank"
rel="noreferrer"
aria-label={`Website von ${feature.name} in einem neuen Tab öffnen`}
>
<Globe2 size={20} aria-hidden="true" />
<span>
<strong>Website</strong>
<small>Öffnen</small>
</span>
</a>
)}
{email && (
<a
className="marine-feature-action"
data-action="email"
href={`mailto:${email}`}
aria-label={`E-Mail an ${feature.name}: ${email}`}
>
<Mail size={20} aria-hidden="true" />
<span>
<strong>E-Mail</strong>
<small>{email}</small>
</span>
</a>
)}
</div>
) : (
<p className="marine-feature-contact-empty">Keine direkten Kontaktdaten hinterlegt.</p>
)}
{hasOperatingDetails && (
<dl className="marine-feature-details marine-feature-operating-details">
{vhf && <DetailRow label="UKW / VHF">{vhf}</DetailRow>}
{openingHours && <DetailRow label="Öffnungszeiten">{openingHours}</DetailRow>}
{operator && <DetailRow label="Betreiber">{operator}</DetailRow>}
{address && <DetailRow label="Adresse">{address}</DetailRow>}
</dl>
)}
<details className="marine-feature-metadata">
<summary>Daten &amp; Quelle</summary>
<dl className="marine-feature-details">
<DetailRow label="Koordinaten">{formatCoordinate(feature.coordinate)}</DetailRow>
{(source || sourceHref) && (
<DetailRow label="Quelle">
{sourceHref ? (
<a href={sourceHref} target="_blank" rel="noreferrer">
{source ?? "Quelldatensatz öffnen"}
</a>
) : (
source
)}
</DetailRow>
)}
{updatedAt && <DetailRow label="Datenstand">{formatTimestamp(updatedAt)}</DetailRow>}
{(feature.memberCount ?? 1) > 1 && (
<DetailRow label="Zusammengeführt">{feature.memberCount} Kartenobjekte</DetailRow>
)}
</dl>
</details>
<p id="marine-feature-info-contact-note" className="marine-feature-contact-note">
Kontaktdaten können unvollständig oder veraltet sein. Vor der Fahrt bei der zuständigen Stelle prüfen.
</p>
</section>
</div>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function formatCoordinate(coordinate: Coordinate) {
return `${coordinate.lat.toFixed(5)}°, ${coordinate.lon.toFixed(5)}°`;
}
function presentValue(value: string | null | undefined) {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function getFocusableElements(container: HTMLElement) {
const selector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"summary",
'[tabindex]:not([tabindex="-1"])',
].join(",");
return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter((element) => {
if (element.getAttribute("aria-hidden") === "true" || element.closest("[hidden]")) {
return false;
}
const closedDetails = element.closest("details:not([open])");
return !closedDetails || element.tagName === "SUMMARY";
});
}
function telephoneHref(value: string) {
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
return compact ? `tel:${compact}` : null;
}
function websiteUrl(value: string) {
const trimmed = value.trim().split(/[;,]/)[0]?.trim();
if (!trimmed) {
return null;
}
try {
const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
}
function formatTimestamp(value: string | null) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })
: value;
}
@@ -0,0 +1,154 @@
import { AlertTriangle, ExternalLink, Gauge, Radio, ShipWheel } from "lucide-react";
import type { NavigationDataSnapshot, NavigationSourceStatus, WaterLevel } from "@watermaps/shared";
type NavigationDataPanelProps = {
snapshot: NavigationDataSnapshot | null;
loading: boolean;
error: string | null;
};
export function NavigationDataPanel({ snapshot, loading, error }: NavigationDataPanelProps) {
if (!loading && !snapshot && !error) {
return null;
}
return (
<section className="navigation-data-panel" aria-label="Live-Fahrtdaten">
<header>
<Radio size={15} aria-hidden="true" />
<strong>Live-Fahrtdaten</strong>
{snapshot && <span>{formatClock(snapshot.generatedAt)}</span>}
</header>
{loading && <p>WSV-Daten werden geladen</p>}
{error && (
<p className="navigation-data-warning">
<AlertTriangle size={14} aria-hidden="true" /> {error}
</p>
)}
{snapshot && (
<>
{snapshot.waterLevels.length > 0 ? (
<div className="water-level-list">
{snapshot.waterLevels.slice(0, 8).map((level) => (
<WaterLevelRow key={level.stationId} level={level} />
))}
</div>
) : (
<p>Keine passenden PEGELONLINE-Messstellen gefunden.</p>
)}
{snapshot.lockOperations.map((lock) => (
<div className="navigation-operation" key={lock.id} data-state={lock.operatingState}>
<ShipWheel size={14} aria-hidden="true" />
<span>
<strong>{lock.name}</strong>
{lock.regularHours ?? lock.note ?? "Betriebsinformation ohne Zeitangabe"}
</span>
</div>
))}
{snapshot.notices.map((notice) => (
<a className="navigation-notice" key={notice.id} href={notice.sourceUrl} target="_blank" rel="noreferrer">
<AlertTriangle size={14} aria-hidden="true" />
<span>
<strong>{notice.title}</strong>
{notice.location ?? notice.waterway ?? "ELWIS-Nachricht"}
</span>
</a>
))}
<div className="navigation-source-list">
{snapshot.sources.map((source) => (
<SourceLink key={`${source.kind}:${source.id}`} source={source} />
))}
</div>
<p className="navigation-data-disclaimer">
Live-Daten können verzögert oder unvollständig sein. Schleusenabweichungen und Sperrungen vor Abfahrt
zusätzlich in ELWIS prüfen.
</p>
</>
)}
</section>
);
}
function WaterLevelRow({ level }: { level: WaterLevel }) {
return (
<a href={level.sourceUrl} target="_blank" rel="noreferrer" data-state={level.stateMnwMhw}>
<Gauge size={14} aria-hidden="true" />
<span>
<strong>{level.stationName}</strong>
{level.waterway} {level.waterwayKm !== null ? `km ${formatNumber(level.waterwayKm)}` : ""}
</span>
<span>
<strong>
{formatNumber(level.value)} {level.unit}
</strong>
{waterLevelLabel(level.stateMnwMhw)} · {formatDateTime(level.measuredAt)}
</span>
</a>
);
}
function SourceLink({ source }: { source: NavigationSourceStatus }) {
return (
<a href={source.sourceUrl} target="_blank" rel="noreferrer" data-state={source.state}>
<span>{source.label}</span>
<span>{sourceStateLabel(source.state)}</span>
<ExternalLink size={12} aria-hidden="true" />
{source.warning && <small>{source.warning}</small>}
</a>
);
}
function sourceStateLabel(state: NavigationSourceStatus["state"]) {
switch (state) {
case "live":
return "Live";
case "cached":
return "Cache";
case "stale":
return "Veraltet";
case "unavailable":
return "Nicht erreichbar";
default:
return "Offiziell prüfen";
}
}
function waterLevelLabel(state: WaterLevel["stateMnwMhw"]) {
switch (state) {
case "low":
return "niedrig";
case "normal":
return "normal";
case "high":
return "hoch";
case "out-dated":
return "veraltet";
case "commented":
return "kommentiert";
default:
return "Status offen";
}
}
function formatNumber(value: number) {
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(value);
}
function formatDateTime(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
: "Zeit offen";
}
function formatClock(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
: "";
}
@@ -0,0 +1,134 @@
import { Anchor, Bell, CloudSun, Route as RouteIcon, type LucideIcon } from "lucide-react";
export const NAVIGATION_TOOL_ORDER = [
"anchor",
"conditions",
"upcoming",
"route"
] as const;
export type NavigationToolId = (typeof NAVIGATION_TOOL_ORDER)[number];
export type ActiveTool = NavigationToolId | null;
export type NavigationToolStatus = "idle" | "active" | "caution" | "alarm" | "stale";
type NavigationToolDefinition = {
id: NavigationToolId;
label: string;
Icon: LucideIcon;
};
const NAVIGATION_TOOLS: readonly NavigationToolDefinition[] = [
{ id: "anchor", label: "Ankerwache", Icon: Anchor },
{ id: "conditions", label: "Wetter und Tide", Icon: CloudSun },
{ id: "upcoming", label: "Als Nächstes", Icon: Bell },
{ id: "route", label: "Route", Icon: RouteIcon }
];
export type NavigationToolRailProps = {
activeTool: ActiveTool;
onSelect: (tool: NavigationToolId) => void;
statuses?: Partial<Record<NavigationToolId, NavigationToolStatus>>;
badges?: Partial<Record<NavigationToolId, number | string | null>>;
disabledTools?: Partial<Record<NavigationToolId, boolean>>;
workspaceId?: string;
className?: string;
ariaLabel?: string;
};
export function NavigationToolRail({
activeTool,
onSelect,
statuses = {},
badges = {},
disabledTools = {},
workspaceId = "navigation-workspace",
className,
ariaLabel = "Kartenwerkzeuge"
}: NavigationToolRailProps) {
return (
<nav className={classNames("navigation-tool-rail", className)} aria-label={ariaLabel}>
{NAVIGATION_TOOLS.map(({ id, label, Icon }) => {
const active = activeTool === id;
const status = statuses[id] ?? "idle";
const badge = formatBadge(badges[id]);
const accessibleLabel = toolAriaLabel(label, status, badge, active);
return (
<button
key={id}
className="navigation-tool-rail-button"
type="button"
disabled={Boolean(disabledTools[id])}
data-tool={id}
data-status={status}
data-active={active}
aria-controls={workspaceId}
aria-expanded={active}
aria-pressed={active}
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() => onSelect(id)}
>
<Icon size={21} aria-hidden="true" />
<span className="navigation-tool-rail-label" aria-hidden="true">
{label}
</span>
{badge && (
<span className="navigation-tool-rail-badge" aria-hidden="true">
{badge}
</span>
)}
</button>
);
})}
</nav>
);
}
export function navigationToolLabel(tool: NavigationToolId) {
return NAVIGATION_TOOLS.find((definition) => definition.id === tool)?.label ?? tool;
}
function toolAriaLabel(
label: string,
status: NavigationToolStatus,
badge: string | null,
active: boolean
) {
const parts = [label, statusLabel(status)];
if (badge) {
parts.push(`${badge} Hinweise`);
}
parts.push(active ? "geöffnet" : "öffnen");
return parts.join(", ");
}
function statusLabel(status: NavigationToolStatus) {
switch (status) {
case "active":
return "aktiv";
case "caution":
return "Warnung";
case "alarm":
return "Alarm";
case "stale":
return "Daten veraltet";
default:
return "bereit";
}
}
function formatBadge(value: number | string | null | undefined) {
if (typeof value === "number") {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
return value > 99 ? "99+" : String(Math.floor(value));
}
const normalized = value?.trim();
return normalized || null;
}
function classNames(...values: Array<string | null | undefined | false>) {
return values.filter(Boolean).join(" ");
}
@@ -0,0 +1,188 @@
import {
ArrowDown,
ArrowLeft,
ArrowUp,
X
} from "lucide-react";
import type { ReactNode } from "react";
import {
navigationToolLabel,
type ActiveTool,
type NavigationToolStatus
} from "./NavigationToolRail";
export type NavigationSheetState = "compact" | "half" | "full";
export type NavigationWorkspacePresentation =
| "responsive"
| "bottom-sheet"
| "overlay-drawer"
| "docked-drawer";
export type NavigationWorkspaceProps = {
activeTool: ActiveTool;
children: ReactNode;
onClose: () => void;
id?: string;
title?: string;
summary?: ReactNode;
leading?: ReactNode;
footer?: ReactNode;
status?: NavigationToolStatus;
sheetState?: NavigationSheetState;
onSheetStateChange?: (state: NavigationSheetState) => void;
presentation?: NavigationWorkspacePresentation;
onBack?: () => void;
backLabel?: string;
closeLabel?: string;
busy?: boolean;
className?: string;
};
export function NavigationWorkspace({
activeTool,
children,
onClose,
id = "navigation-workspace",
title,
summary,
leading,
footer,
status = "idle",
sheetState = "half",
onSheetStateChange,
presentation = "responsive",
onBack,
backLabel = "Zurück",
closeLabel,
busy = false,
className
}: NavigationWorkspaceProps) {
if (!activeTool) {
return null;
}
const toolLabel = navigationToolLabel(activeTool);
const heading = title ?? toolLabel;
const headingId = `${id}-title`;
const bodyId = `${id}-body`;
const sizeAction = workspaceSizeAction(sheetState);
const compact = sheetState === "compact";
return (
<aside
id={id}
className={classNames(
"navigation-workspace",
`navigation-workspace--${presentation}`,
className
)}
aria-labelledby={headingId}
aria-busy={busy}
data-tool={activeTool}
data-status={status}
data-presentation={presentation}
data-sheet-state={sheetState}
>
<span className="navigation-workspace-grip" aria-hidden="true">
</span>
<header className="navigation-workspace-header">
{onBack && (
<button
className="navigation-workspace-back"
type="button"
onClick={onBack}
aria-label={backLabel}
title={backLabel}
>
<ArrowLeft size={19} aria-hidden="true" />
</button>
)}
{leading && (
<span className="navigation-workspace-leading" aria-hidden="true">
{leading}
</span>
)}
<div className="navigation-workspace-heading">
<strong id={headingId}>{heading}</strong>
{summary && <div className="navigation-workspace-summary">{summary}</div>}
</div>
{onSheetStateChange && (
<button
className="navigation-workspace-size-action"
type="button"
onClick={() => onSheetStateChange(sizeAction.nextState)}
aria-controls={bodyId}
aria-label={sizeAction.label}
title={sizeAction.label}
>
{sizeAction.direction === "up" ? (
<ArrowUp size={19} aria-hidden="true" />
) : (
<ArrowDown size={19} aria-hidden="true" />
)}
</button>
)}
<button
className="navigation-workspace-close"
type="button"
onClick={onClose}
aria-label={closeLabel ?? `${toolLabel} schließen`}
title={closeLabel ?? `${toolLabel} schließen`}
>
<X size={19} aria-hidden="true" />
</button>
</header>
<div
id={bodyId}
className="navigation-workspace-body"
hidden={compact}
data-collapsed={compact}
>
{children}
</div>
{footer && (
<footer className="navigation-workspace-footer" hidden={compact}>
{footer}
</footer>
)}
</aside>
);
}
function workspaceSizeAction(state: NavigationSheetState): {
nextState: NavigationSheetState;
label: string;
direction: "up" | "down";
} {
if (state === "compact") {
return {
nextState: "half",
label: "Arbeitsbereich auf halbe Höhe vergrößern",
direction: "up"
};
}
if (state === "half") {
return {
nextState: "full",
label: "Arbeitsbereich auf volle Höhe vergrößern",
direction: "up"
};
}
return {
nextState: "compact",
label: "Arbeitsbereich auf kompakte Höhe verkleinern",
direction: "down"
};
}
function classNames(...values: Array<string | null | undefined | false>) {
return values.filter(Boolean).join(" ");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
import { AlertTriangle, Waves } from "lucide-react";
import type { TideEvent, TideSummary } from "@watermaps/shared";
export type RouteTidePlan = {
start: TideSummary | null;
middle?: TideSummary | null;
destination: TideSummary | null;
};
type RouteTidePanelProps = {
plan: RouteTidePlan | null;
loading: boolean;
error: string | null;
};
export function RouteTidePanel({ plan, loading, error }: RouteTidePanelProps) {
if (!loading && !plan && !error) {
return null;
}
return (
<section className="route-tide-panel" aria-label="Tidenplanung">
<header>
<Waves size={15} aria-hidden="true" />
<strong>Tide zur Fahrtzeit</strong>
</header>
{loading && <p>Tidenfenster werden geladen.</p>}
{error && (
<p className="route-tide-warning">
<AlertTriangle size={14} aria-hidden="true" /> {error}
</p>
)}
{plan && (
<div className="route-tide-locations">
<TideLocation label="Start" summary={plan.start} />
<TideLocation label="Mitte" summary={plan.middle ?? null} />
<TideLocation label="Ziel" summary={plan.destination} />
</div>
)}
<small>Stationsabstand und Bezugsnull beachten; Wasserstände ersetzen keine amtliche Tiefenprüfung.</small>
</section>
);
}
function TideLocation({ label, summary }: { label: string; summary: TideSummary | null }) {
return (
<article>
<strong>{label}</strong>
{summary ? (
<>
<span>
{summary.station} · {summary.distanceKm.toLocaleString("de-DE", { maximumFractionDigits: 1 })} km
</span>
<span>{formatEvent("HW", summary.nextHigh)}</span>
<span>{formatEvent("NW", summary.nextLow)}</span>
</>
) : (
<span>Keine passende Vorhersage</span>
)}
</article>
);
}
function formatEvent(label: string, event: TideEvent | null) {
if (!event) {
return `${label} offen`;
}
const date = new Date(event.time);
const time = Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", { weekday: "short", hour: "2-digit", minute: "2-digit" })
: "Zeit offen";
const height = event.heightM !== null ? ` · ${event.heightM.toFixed(2)} m` : "";
return `${label} ${time}${height}`;
}
+209
View File
@@ -0,0 +1,209 @@
import { Activity, AlertTriangle, Anchor, Navigation2, Waves, Wind } from "lucide-react";
import type { ReactNode } from "react";
import type { MarineForecast, RouteGuidanceResult, TideSummary } from "@watermaps/shared";
import type { GpsState } from "../hooks/useGeolocation";
type StatusBarProps = {
gps: GpsState;
forecast: MarineForecast | null;
tide: TideSummary | null;
routeWarningCount: number;
mode?: "planning" | "route" | "guidance" | "anchor";
guidance?: RouteGuidanceResult | null;
anchor?: {
distanceFromAnchorM: number | null;
alarmRadiusM: number;
alarm: boolean;
maximumTideRiseM: number | null;
} | null;
};
export function StatusBar({
gps,
forecast,
tide,
routeWarningCount,
mode,
guidance = null,
anchor = null
}: StatusBarProps) {
const resolvedMode =
mode ??
(routeWarningCount > 0
? "route"
: gps.status === "requesting" || gps.status === "tracking"
? "guidance"
: "planning");
const gpsValue = gps.position
? gps.accuracyM === null
? "Position aktiv"
: `±${gps.accuracyM} m`
: gpsStatusLabel(gps.status);
const speedValue = gps.speedKn === null ? "-- kn" : `${gps.speedKn.toFixed(1)} kn`;
const tideValue = tide?.nextHigh
? `HW ${new Date(tide.nextHigh.time).toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit"
})}`
: "Keine Daten";
const conditionsValue = formatConditions(forecast);
const items =
resolvedMode === "anchor"
? [
{
icon: anchor?.alarm
? <AlertTriangle size={15} aria-hidden="true" />
: <Anchor size={15} aria-hidden="true" />,
label: "Anker",
value: anchor?.alarm
? "Alarm"
: anchor?.distanceFromAnchorM === null || anchor?.distanceFromAnchorM === undefined
? `Radius ${Math.round(anchor?.alarmRadiusM ?? 0)} m`
: `${Math.round(anchor.distanceFromAnchorM)} / ${Math.round(anchor.alarmRadiusM)} m`
},
{
icon: <Navigation2 size={15} aria-hidden="true" />,
label: "GPS",
value: gpsValue
},
{
icon: <Waves size={15} aria-hidden="true" />,
label: "Tidenanstieg",
value:
anchor?.maximumTideRiseM === null || anchor?.maximumTideRiseM === undefined
? "Keine Daten"
: `+${anchor.maximumTideRiseM.toFixed(1)} m`
}
]
: resolvedMode === "guidance"
? [
{
icon: <Navigation2 size={15} aria-hidden="true" />,
label: "Sollkurs",
value: guidance ? formatCourse(guidance.desiredCourseDeg) : "Warte auf GPS"
},
{
icon: <Activity size={15} aria-hidden="true" />,
label: "Abweichung",
value: guidance ? formatCrossTrack(guidance) : "--"
},
routeWarningCount > 0
? {
icon: <AlertTriangle size={15} aria-hidden="true" />,
label: "Warnungen",
value: `${routeWarningCount} offen`
}
: {
icon: <Activity size={15} aria-hidden="true" />,
label: "SOG",
value: speedValue
}
]
: resolvedMode === "route"
? [
{
icon:
routeWarningCount > 0
? <AlertTriangle size={15} aria-hidden="true" />
: <Navigation2 size={15} aria-hidden="true" />,
label: "Warnungen",
value: routeWarningCount > 0 ? `${routeWarningCount} offen` : "Keine offenen"
},
{
icon: <Navigation2 size={15} aria-hidden="true" />,
label: "GPS",
value: gpsValue
},
gps.position
? {
icon: <Activity size={15} aria-hidden="true" />,
label: "SOG",
value: speedValue
}
: {
icon: <Waves size={15} aria-hidden="true" />,
label: "Tide",
value: tideValue
}
]
: [
{
icon: <Navigation2 size={15} aria-hidden="true" />,
label: "GPS",
value: gpsValue
},
{
icon: <Wind size={15} aria-hidden="true" />,
label: "Wetter",
value: conditionsValue
},
{
icon: <Waves size={15} aria-hidden="true" />,
label: "Tide",
value: tideValue
}
];
return (
<footer
className="status-bar"
aria-label="Navigationsstatus"
style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}
>
{items.map((item) => (
<StatusItem key={item.label} icon={item.icon} label={item.label} value={item.value} />
))}
</footer>
);
}
function StatusItem({ icon, label, value }: { icon?: ReactNode; label: string; value: string }) {
return (
<span className="status-item">
{icon}
<span className="status-label">{label}</span>
<strong>{value}</strong>
</span>
);
}
function gpsStatusLabel(status: GpsState["status"]): string {
switch (status) {
case "idle":
return "Aus";
case "requesting":
return "Position wird gesucht";
case "tracking":
return "Position aktiv";
case "denied":
return "Zugriff verweigert";
case "unavailable":
return "Nicht verfügbar";
case "error":
return "Fehler";
}
}
function formatConditions(forecast: MarineForecast | null): string {
if (!forecast || (forecast.windSpeed === null && forecast.waveHeightM === null)) {
return "Keine Daten";
}
const wind = forecast.windSpeed === null ? null : `${Math.round(forecast.windSpeed)} kn`;
const wave = forecast.waveHeightM === null ? null : `${forecast.waveHeightM.toFixed(1)} m`;
return [wind, wave].filter((value): value is string => value !== null).join(" · ");
}
function formatCourse(value: number) {
const normalized = Math.round(((value % 360) + 360) % 360) % 360;
return `${String(normalized).padStart(3, "0")}°T`;
}
function formatCrossTrack(guidance: RouteGuidanceResult) {
const distanceM = Math.round(guidance.distanceToRouteM);
if (distanceM <= 3 || guidance.crossTrackSide === "on-route") {
return "Auf Linie";
}
return `${distanceM} m ${guidance.crossTrackSide === "port" ? "Bb" : "Stb"}`;
}
@@ -0,0 +1,435 @@
/* Upcoming route events ---------------------------------------------------- */
.upcoming-events-panel {
display: grid;
gap: 10px;
}
.upcoming-events-header {
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.upcoming-events-header > span {
display: flex;
align-items: center;
gap: 7px;
color: #0f4c5c;
}
.upcoming-events-header small {
color: #607278;
font-size: 10px;
font-weight: 800;
}
.upcoming-events-message {
min-height: 40px;
margin: 0;
border-radius: 9px;
padding: 7px 9px;
gap: 7px;
background: #fff1cc;
color: #805900;
font-size: 11px;
font-weight: 800;
}
.upcoming-events-filters {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
}
.upcoming-events-filters button {
min-width: 0;
min-height: 44px;
border: 1px solid rgba(15, 76, 92, 0.12);
border-radius: 9px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
background: #edf3f1;
color: #526a72;
font-size: 10px;
font-weight: 850;
}
.upcoming-events-filters button[data-active="true"] {
border-color: #0f4c5c;
background: #dceee6;
color: #196f5c;
}
.upcoming-events-filters button small {
min-width: 18px;
height: 18px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(15, 76, 92, 0.1);
font-size: 9px;
}
.upcoming-event-hero {
border: 1px solid rgba(15, 76, 92, 0.15);
border-radius: 12px;
padding: 10px;
display: grid;
gap: 7px;
background: #dceee6;
}
.upcoming-event-hero[data-status="caution"] {
border-color: #d89c28;
background: #fff1cc;
}
.upcoming-event-hero[data-status="alarm"] {
border-color: #c44a30;
background: #ffe1dc;
}
.upcoming-event-hero > small {
color: #526a72;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.upcoming-event-hero-main {
min-width: 0;
min-height: 74px;
border-radius: 9px;
padding: 8px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
grid-template-rows: auto auto auto;
align-items: center;
gap: 2px 8px;
background: rgba(255, 255, 255, 0.72);
color: #17343c;
text-align: left;
}
.upcoming-event-hero-main > svg {
grid-row: 1 / span 3;
}
.upcoming-event-hero-main > span:nth-of-type(1) {
min-width: 0;
display: grid;
gap: 1px;
}
.upcoming-event-hero-main > span:nth-of-type(1) strong {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.upcoming-event-hero-main > span:nth-of-type(1) span,
.upcoming-event-hero-eta,
.upcoming-event-hero-fact {
color: #607278;
font-size: 10px;
font-weight: 750;
}
.upcoming-event-hero-distance {
color: #0f4c5c;
font-size: 15px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.upcoming-event-hero-eta {
grid-column: 3;
}
.upcoming-event-hero-fact {
grid-column: 2 / -1;
overflow-wrap: anywhere;
}
.upcoming-events-list-section {
display: grid;
gap: 6px;
}
.upcoming-events-list-section h3 {
margin: 0;
color: #526a72;
font-size: 11px;
}
.upcoming-events-list {
margin: 0;
padding: 0;
display: grid;
gap: 5px;
list-style: none;
}
.upcoming-event-row {
width: 100%;
min-height: 60px;
border: 1px solid rgba(15, 76, 92, 0.1);
border-radius: 10px;
padding: 7px 8px;
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
background: #ffffff;
color: #17343c;
text-align: left;
}
.upcoming-event-row[data-status="caution"] {
border-color: #d89c28;
background: #fff9e8;
}
.upcoming-event-row[data-status="alarm"] {
border-color: #c44a30;
background: #fff0ed;
}
.upcoming-event-row-icon {
width: 36px;
height: 36px;
border-radius: 9px;
display: grid;
place-items: center;
background: #dceee6;
color: #196f5c;
}
.upcoming-event-row-content,
.upcoming-event-row-progress {
min-width: 0;
display: grid;
gap: 2px;
}
.upcoming-event-row-content strong {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.upcoming-event-row-content small,
.upcoming-event-row-progress small {
color: #607278;
font-size: 9px;
font-weight: 700;
}
.upcoming-event-row-progress {
justify-items: end;
font-variant-numeric: tabular-nums;
}
.upcoming-event-row-progress strong {
color: #0f4c5c;
font-size: 12px;
}
.upcoming-event-contact-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 6px;
}
.upcoming-event-contact-actions a,
.upcoming-event-map-action {
min-height: 44px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
background: #0f4c5c;
color: #ffffff;
font-size: 12px;
font-weight: 850;
text-decoration: none;
}
.upcoming-event-contact-actions[data-compact="true"] a {
background: rgba(15, 76, 92, 0.9);
}
.upcoming-event-detail-header {
min-height: 50px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.upcoming-event-detail-header > button {
width: 44px;
height: 44px;
border-radius: 9px;
display: grid;
place-items: center;
background: #e2ece9;
color: #23434c;
}
.upcoming-event-detail-header > span,
.upcoming-event-detail-header > span > span {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.upcoming-event-detail-header > span > span {
display: grid;
gap: 1px;
}
.upcoming-event-detail-header small {
color: #607278;
font-size: 10px;
font-weight: 850;
}
.upcoming-event-detail-header strong {
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.upcoming-event-detail-hero {
border-radius: 11px;
padding: 10px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
background: #dceee6;
}
.upcoming-event-detail[data-status="caution"] .upcoming-event-detail-hero {
background: #fff1cc;
}
.upcoming-event-detail[data-status="alarm"] .upcoming-event-detail-hero {
background: #ffe1dc;
}
.upcoming-event-detail-hero > span {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 2px 6px;
color: #526a72;
font-size: 10px;
}
.upcoming-event-detail-hero > span > svg {
grid-row: 1 / span 2;
}
.upcoming-event-detail-hero strong {
color: #17343c;
font-size: 15px;
}
.upcoming-event-detail-facts {
margin: 0;
border: 1px solid rgba(15, 76, 92, 0.1);
border-radius: 10px;
overflow: hidden;
}
.upcoming-event-detail-facts > div {
min-height: 44px;
border-bottom: 1px solid rgba(15, 76, 92, 0.08);
padding: 7px 9px;
display: grid;
grid-template-columns: minmax(100px, 0.75fr) minmax(0, 1.25fr);
align-items: center;
gap: 8px;
}
.upcoming-event-detail-facts > div:last-child {
border-bottom: 0;
}
.upcoming-event-detail-facts dt {
color: #607278;
font-size: 10px;
font-weight: 850;
}
.upcoming-event-detail-facts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #17343c;
font-size: 12px;
font-weight: 750;
}
.upcoming-event-radio-value {
display: inline-flex;
align-items: center;
gap: 5px;
}
.upcoming-event-map-action {
width: 100%;
background: #e2ece9;
color: #23434c;
}
.upcoming-events-state {
min-height: 210px;
place-content: center;
justify-items: center;
color: #526a72;
text-align: center;
}
.upcoming-events-state > svg {
color: #0f4c5c;
}
.upcoming-events-state > strong {
color: #17343c;
font-size: 15px;
}
.upcoming-events-state > p,
.upcoming-events-empty-filter {
max-width: 32rem;
margin: 0;
color: #607278;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.upcoming-events-empty-filter {
min-height: 96px;
border: 1px dashed #b9cbc7;
border-radius: 10px;
padding: 16px;
display: grid;
place-content: center;
text-align: center;
}
@@ -0,0 +1,759 @@
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
Clock3,
Globe2,
Landmark,
LoaderCircle,
LockKeyhole,
Mail,
MapPin,
Phone,
Radio,
Route as RouteIcon,
Ship,
type LucideIcon
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
type RouteEventKind,
type UpcomingRouteEvent
} from "../routeEvents";
import "./UpcomingEventsPanel.css";
type EventFilter = "all" | RouteEventKind;
type EventFilterDefinition = {
id: EventFilter;
label: string;
};
const EVENT_FILTERS: readonly EventFilterDefinition[] = [
{ id: "all", label: "Alle" },
{ id: "harbour", label: "Häfen" },
{ id: "lock", label: "Schleusen" },
{ id: "bridge", label: "Brücken" }
];
const EVENT_KIND_LABELS: Record<RouteEventKind, string> = {
harbour: "Hafen",
lock: "Schleuse",
bridge: "Brücke"
};
const EVENT_KIND_ICONS: Record<RouteEventKind, LucideIcon> = {
harbour: Ship,
lock: LockKeyhole,
bridge: Landmark
};
export type UpcomingEventsPanelProps = {
events: readonly UpcomingRouteEvent[];
hasRoute: boolean;
loading?: boolean;
error?: string | null;
onShowOnMap?: (event: UpcomingRouteEvent) => void;
className?: string;
};
export function UpcomingEventsPanel({
events,
hasRoute,
loading = false,
error = null,
onShowOnMap,
className
}: UpcomingEventsPanelProps) {
const [filter, setFilter] = useState<EventFilter>("all");
const [selectedEventKey, setSelectedEventKey] = useState<string | null>(null);
const orderedEvents = useMemo(
() =>
events
.map((event, index) => ({ event, index }))
.sort(
(left, right) =>
finiteDistance(left.event.remainingNm) - finiteDistance(right.event.remainingNm) ||
left.index - right.index
)
.map(({ event }) => event),
[events]
);
const selectedEvent =
orderedEvents.find((event) => routeEventKey(event) === selectedEventKey) ?? null;
const filteredEvents = useMemo(
() =>
filter === "all"
? orderedEvents
: orderedEvents.filter((event) => event.kind === filter),
[filter, orderedEvents]
);
const counts = useMemo(
() => ({
all: orderedEvents.length,
harbour: orderedEvents.filter((event) => event.kind === "harbour").length,
lock: orderedEvents.filter((event) => event.kind === "lock").length,
bridge: orderedEvents.filter((event) => event.kind === "bridge").length
}),
[orderedEvents]
);
useEffect(() => {
if (!hasRoute || (selectedEventKey && !selectedEvent)) {
setSelectedEventKey(null);
}
}, [hasRoute, selectedEvent, selectedEventKey]);
if (!hasRoute) {
return (
<PanelState
className={className}
icon={RouteIcon}
title="Noch keine Route"
message="Plane zuerst eine Route. Danach erscheinen Häfen, Schleusen und Brücken in Fahrtreihenfolge."
/>
);
}
if (loading && orderedEvents.length === 0) {
return (
<PanelState
className={className}
icon={LoaderCircle}
title="Ereignisse werden geladen"
message="Die nächsten Häfen, Schleusen und Brücken entlang der Route werden ermittelt."
live
/>
);
}
if (error && orderedEvents.length === 0) {
return (
<PanelState
className={className}
icon={AlertTriangle}
title="Ereignisse nicht erreichbar"
message={error}
alert
/>
);
}
if (orderedEvents.length === 0) {
return (
<PanelState
className={className}
icon={CalendarClock}
title="Keine bevorstehenden Ereignisse"
message="Auf dem verbleibenden Routenabschnitt wurden keine Häfen, Schleusen oder Brücken gefunden."
/>
);
}
if (selectedEvent) {
return (
<EventDrilldown
className={className}
event={selectedEvent}
onBack={() => setSelectedEventKey(null)}
onShowOnMap={onShowOnMap}
/>
);
}
const nextEvent = filteredEvents[0] ?? null;
return (
<section
className={classNames("upcoming-events-panel", className)}
aria-label="Bevorstehende Ereignisse"
aria-busy={loading}
>
<header className="upcoming-events-header">
<span>
<CalendarClock size={18} aria-hidden="true" />
<strong>Bevorstehend</strong>
</span>
<small>{orderedEvents.length} auf der Route</small>
</header>
{loading && (
<p className="upcoming-events-message" role="status" aria-live="polite">
<LoaderCircle size={16} aria-hidden="true" />
Ereignisse werden aktualisiert.
</p>
)}
{error && (
<p className="upcoming-events-message" role="alert">
<AlertTriangle size={16} aria-hidden="true" />
{error}
</p>
)}
<div className="upcoming-events-filters" role="group" aria-label="Ereignisse filtern">
{EVENT_FILTERS.map(({ id, label }) => (
<button
key={id}
type="button"
data-active={filter === id}
aria-pressed={filter === id}
onClick={() => setFilter(id)}
>
<span>{label}</span>
<small aria-label={`${counts[id]} Ereignisse`}>{counts[id]}</small>
</button>
))}
</div>
{nextEvent ? (
<>
<NextEventHero
event={nextEvent}
onOpen={() => setSelectedEventKey(routeEventKey(nextEvent))}
/>
<section className="upcoming-events-list-section" aria-labelledby="upcoming-events-list-title">
<h3 id="upcoming-events-list-title">
{filter === "all"
? "Alle Ereignisse in Fahrtreihenfolge"
: `${filterLabel(filter)} in Fahrtreihenfolge`}
</h3>
<ol className="upcoming-events-list">
{filteredEvents.map((event) => (
<li key={routeEventKey(event)}>
<EventRow
event={event}
onOpen={() => setSelectedEventKey(routeEventKey(event))}
/>
</li>
))}
</ol>
</section>
</>
) : (
<p className="upcoming-events-empty-filter" role="status">
Keine {filterLabel(filter)} auf dem verbleibenden Routenabschnitt.
</p>
)}
</section>
);
}
function NextEventHero({
event,
onOpen
}: {
event: UpcomingRouteEvent;
onOpen: () => void;
}) {
const Icon = EVENT_KIND_ICONS[event.kind];
const contact = eventContact(event);
return (
<article
className="upcoming-event-hero"
data-kind={event.kind}
data-status={eventStatus(event)}
aria-labelledby={`next-event-${safeDomId(routeEventKey(event))}`}
>
<small>Nächstes Ereignis</small>
<button className="upcoming-event-hero-main" type="button" onClick={onOpen}>
<Icon size={24} aria-hidden="true" />
<span>
<strong id={`next-event-${safeDomId(routeEventKey(event))}`}>{event.name}</strong>
<span>{EVENT_KIND_LABELS[event.kind]}</span>
</span>
<span className="upcoming-event-hero-distance">{formatDistance(event.remainingNm)}</span>
<span className="upcoming-event-hero-eta">{formatEta(event)}</span>
<span className="upcoming-event-hero-fact">{importantFact(event)}</span>
</button>
<ContactActions
name={event.name}
phone={contact.phone}
website={contact.website}
email={contact.email}
compact
/>
</article>
);
}
function EventRow({
event,
onOpen
}: {
event: UpcomingRouteEvent;
onOpen: () => void;
}) {
const Icon = EVENT_KIND_ICONS[event.kind];
return (
<button
className="upcoming-event-row"
type="button"
onClick={onOpen}
data-kind={event.kind}
data-status={eventStatus(event)}
aria-label={`${EVENT_KIND_LABELS[event.kind]} ${event.name}, ${formatDistance(
event.remainingNm
)}, ${formatEta(event)}, ${importantFact(event)}. Details öffnen`}
>
<span className="upcoming-event-row-icon">
<Icon size={19} aria-hidden="true" />
</span>
<span className="upcoming-event-row-content">
<strong>{event.name}</strong>
<small>{importantFact(event)}</small>
</span>
<span className="upcoming-event-row-progress">
<strong>{formatDistance(event.remainingNm)}</strong>
<small>{formatEta(event)}</small>
</span>
</button>
);
}
function EventDrilldown({
event,
onBack,
onShowOnMap,
className
}: {
event: UpcomingRouteEvent;
onBack: () => void;
onShowOnMap?: (event: UpcomingRouteEvent) => void;
className?: string;
}) {
const Icon = EVENT_KIND_ICONS[event.kind];
const contact = eventContact(event);
return (
<section
className={classNames("upcoming-events-panel", "upcoming-event-detail", className)}
aria-labelledby="upcoming-event-detail-title"
data-kind={event.kind}
data-status={eventStatus(event)}
>
<header className="upcoming-event-detail-header">
<button type="button" onClick={onBack} aria-label="Zurück zur Ereignisliste" title="Zurück">
<ArrowLeft size={19} aria-hidden="true" />
</button>
<span>
<Icon size={20} aria-hidden="true" />
<span>
<small>{EVENT_KIND_LABELS[event.kind]}</small>
<strong id="upcoming-event-detail-title">{event.name}</strong>
</span>
</span>
</header>
<div className="upcoming-event-detail-hero">
<span>
<RouteIcon size={17} aria-hidden="true" />
<strong>{formatDistance(event.remainingNm)}</strong>
verbleibend
</span>
<span>
<Clock3 size={17} aria-hidden="true" />
<strong>{formatEta(event)}</strong>
</span>
</div>
<dl className="upcoming-event-detail-facts">
<DetailRow label="Wichtigster Hinweis">{importantFact(event)}</DetailRow>
<DetailRow label="Abstand von der Route">
{formatDistance(event.distanceFromRouteNm)}
</DetailRow>
{event.eta && (
<DetailRow label="ETA-Grundlage">{etaBasisLabel(event)}</DetailRow>
)}
<KindSpecificDetails event={event} />
<EventDataDetails event={event} />
</dl>
<ContactActions
name={event.name}
phone={contact.phone}
website={contact.website}
email={contact.email}
/>
{onShowOnMap && (
<button
className="upcoming-event-map-action"
type="button"
onClick={() => onShowOnMap(event)}
>
<MapPin size={18} aria-hidden="true" />
Auf Karte zeigen
</button>
)}
</section>
);
}
function KindSpecificDetails({ event }: { event: UpcomingRouteEvent }) {
if (event.kind === "harbour") {
const amenities = availableAmenities(event);
return (
<>
<DetailRow label="Typ">{event.feature.kind === "marina" ? "Marina" : "Hafen"}</DetailRow>
<DetailRow label="Öffnungszeiten">
{event.feature.openingHours ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="UKW / VHF">
{event.feature.vhf ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="Betreiber">
{event.feature.operator ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="Adresse">
{event.feature.address ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="Ausstattung">
{amenities.length > 0 ? amenities.join(" · ") : "Nicht hinterlegt"}
</DetailRow>
</>
);
}
if (event.kind === "lock") {
return (
<>
<DetailRow label="Öffnungszeiten">
{event.feature.openingHours ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="UKW / VHF">
{event.feature.vhf ? (
<span className="upcoming-event-radio-value">
<Radio size={15} aria-hidden="true" />
{event.feature.vhf}
</span>
) : (
"Nicht hinterlegt"
)}
</DetailRow>
<DetailRow label="Betreiber">
{event.feature.operator ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="Adresse">
{event.feature.address ?? "Nicht hinterlegt"}
</DetailRow>
</>
);
}
return (
<>
<DetailRow label="Durchfahrtshöhe">
{event.feature.clearanceLabel ??
formatMeters(event.feature.clearanceM) ??
"Nicht bekannt"}
</DetailRow>
<DetailRow label="Benötigte Höhe">
{formatMeters(event.feature.requiredAirDraftM) ?? "Nicht bekannt"}
</DetailRow>
<DetailRow label="Reserve">
{bridgeMargin(event.feature.marginM)}
</DetailRow>
</>
);
}
function EventDataDetails({ event }: { event: UpcomingRouteEvent }) {
const source =
event.kind === "bridge" ? event.feature.source : event.feature.source ?? null;
const updatedAt =
event.kind === "bridge" ? null : event.feature.updatedAt ?? null;
return (
<>
<DetailRow label="Koordinaten">
{event.coordinate.lat.toFixed(5)}, {event.coordinate.lon.toFixed(5)}
</DetailRow>
<DetailRow label="Quelle">{source || "Nicht angegeben"}</DetailRow>
{updatedAt && (
<DetailRow label="Datenstand">{formatTimestamp(updatedAt)}</DetailRow>
)}
</>
);
}
function ContactActions({
name,
phone,
website,
email,
compact = false
}: {
name: string;
phone: string | null;
website: string | null;
email: string | null;
compact?: boolean;
}) {
const phoneHref = phone ? telephoneHref(phone) : null;
const safeWebsite = website ? websiteHref(website) : null;
const safeEmail = email?.trim() || null;
if (!phoneHref && !safeWebsite && !safeEmail) {
return null;
}
return (
<div className="upcoming-event-contact-actions" data-compact={compact}>
{phoneHref && (
<a href={phoneHref} aria-label={`${name} anrufen`}>
<Phone size={17} aria-hidden="true" />
Anrufen
</a>
)}
{safeWebsite && (
<a
href={safeWebsite}
target="_blank"
rel="noreferrer"
aria-label={`Website von ${name} öffnen`}
>
<Globe2 size={17} aria-hidden="true" />
Website
</a>
)}
{safeEmail && (
<a href={`mailto:${safeEmail}`} aria-label={`E-Mail an ${name} schreiben`}>
<Mail size={17} aria-hidden="true" />
E-Mail
</a>
)}
</div>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function PanelState({
icon: Icon,
title,
message,
live = false,
alert = false,
className
}: {
icon: LucideIcon;
title: string;
message: string;
live?: boolean;
alert?: boolean;
className?: string;
}) {
return (
<section
className={classNames("upcoming-events-panel", "upcoming-events-state", className)}
aria-label="Bevorstehende Ereignisse"
role={alert ? "alert" : live ? "status" : undefined}
aria-live={live ? "polite" : undefined}
>
<Icon size={26} aria-hidden="true" />
<strong>{title}</strong>
<p>{message}</p>
</section>
);
}
function eventContact(event: UpcomingRouteEvent) {
if (event.kind === "harbour" || event.kind === "lock") {
return {
phone: event.feature.phone ?? null,
website: event.feature.website ?? null,
email: event.feature.email ?? null
};
}
return { phone: null, website: null, email: null };
}
function importantFact(event: UpcomingRouteEvent) {
if (event.kind === "harbour") {
const amenities = availableAmenities(event);
if (amenities.length > 0) {
return amenities.slice(0, 3).join(" · ");
}
if (event.feature.phone || event.feature.website) {
return "Kontaktdaten vorhanden";
}
return "Ausstattung nicht hinterlegt";
}
if (event.kind === "lock") {
if (event.feature.openingHours) {
return `Öffnung: ${event.feature.openingHours}`;
}
if (event.feature.vhf) {
return `UKW / VHF ${event.feature.vhf}`;
}
return "Betriebszeiten nicht hinterlegt";
}
if (event.feature.marginM !== null) {
return event.feature.marginM < 0
? `${Math.abs(event.feature.marginM).toFixed(1)} m zu niedrig`
: `${event.feature.marginM.toFixed(1)} m Reserve`;
}
if (event.feature.clearanceLabel) {
return `Durchfahrt ${event.feature.clearanceLabel}`;
}
if (event.feature.clearanceM !== null) {
return `Durchfahrt ${event.feature.clearanceM.toFixed(1)} m`;
}
return "Durchfahrtshöhe unbekannt";
}
function eventStatus(event: UpcomingRouteEvent) {
if (event.kind !== "bridge") {
return "idle";
}
switch (event.feature.status) {
case "too_low":
return "alarm";
case "tight":
return "caution";
case "unknown":
return "stale";
default:
return "idle";
}
}
function availableAmenities(event: Extract<UpcomingRouteEvent, { kind: "harbour" }>) {
const amenities = event.feature.amenities ?? {};
return [
["fuel", "Kraftstoff"],
["water", "Wasser"],
["electricity", "Strom"],
["overnight", "Übernachtung"],
["waste", "Entsorgung"]
].flatMap(([id, label]) => {
const availability = amenities[id as keyof typeof amenities];
return availability === true || availability === "available" ? [label] : [];
});
}
function formatDistance(value: number) {
if (!Number.isFinite(value)) {
return "-- sm";
}
if (value > 0 && value < 0.1) {
return "< 0,1 sm";
}
return `${value.toLocaleString("de-DE", {
minimumFractionDigits: value < 10 ? 1 : 0,
maximumFractionDigits: 1
})} sm`;
}
function formatEta(event: UpcomingRouteEvent) {
if (!event.eta) {
return "ETA offen";
}
const date = new Date(event.eta.estimatedAt);
if (!Number.isFinite(date.getTime())) {
return "ETA offen";
}
const today = new Date();
const sameDay =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
return `ETA ${date.toLocaleString("de-DE", {
weekday: sameDay ? undefined : "short",
day: sameDay ? undefined : "2-digit",
month: sameDay ? undefined : "2-digit",
hour: "2-digit",
minute: "2-digit"
})}`;
}
function etaBasisLabel(event: UpcomingRouteEvent) {
if (!event.eta) {
return "Nicht verfügbar";
}
const speed =
event.eta.speedSource === "gps-sog"
? "GPS-Fahrt über Grund"
: "geplante Bootsgeschwindigkeit";
const reference =
event.eta.referenceSource === "current-time"
? "ab jetzt"
: "ab geplanter Abfahrt";
return `${event.eta.speedKn.toFixed(1)} kn ${speed}, ${reference}`;
}
function bridgeMargin(value: number | null) {
if (value === null || !Number.isFinite(value)) {
return "Nicht bekannt";
}
return value < 0
? `${Math.abs(value).toFixed(1)} m zu niedrig`
: `${value.toFixed(1)} m Reserve`;
}
function formatMeters(value: number | null) {
return value !== null && Number.isFinite(value) ? `${value.toFixed(1)} m` : null;
}
function formatTimestamp(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})
: value;
}
function filterLabel(filter: EventFilter) {
return EVENT_FILTERS.find((definition) => definition.id === filter)?.label ?? "Ereignisse";
}
function finiteDistance(value: number) {
return Number.isFinite(value) ? Math.max(0, value) : Number.POSITIVE_INFINITY;
}
function routeEventKey(event: UpcomingRouteEvent) {
return `${event.kind}:${event.id}`;
}
function safeDomId(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
}
function telephoneHref(value: string) {
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
return compact ? `tel:${compact}` : null;
}
function websiteHref(value: string) {
const normalized = value.trim();
if (!normalized) {
return null;
}
try {
const url = new URL(
/^[a-z][a-z\d+.-]*:/i.test(normalized) ? normalized : `https://${normalized}`
);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
function classNames(...values: Array<string | null | undefined | false>) {
return values.filter(Boolean).join(" ");
}
@@ -0,0 +1,154 @@
.voyage-navigation-tools {
border-top: 1px solid rgba(18, 46, 55, 0.12);
padding-top: 9px;
display: grid;
gap: 8px;
}
.voyage-tools-heading {
min-height: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #10242b;
font-size: 13px;
}
.voyage-tools-heading span {
color: #4c6269;
font-size: 10px;
font-weight: 800;
}
.voyage-tools-actions,
.deviation-alarm-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.voyage-tools-actions button,
.offline-voyage-picker button,
.deviation-alarm-button {
min-height: 36px;
border-radius: 8px;
padding: 0 9px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: #e6f1ed;
color: #0f4c5c;
font-size: 11px;
font-weight: 850;
}
.offline-voyage-picker {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 38px;
gap: 6px;
}
.offline-voyage-picker label {
grid-column: 1 / -1;
color: #4c6269;
font-size: 10px;
font-weight: 800;
}
.offline-voyage-picker select {
min-width: 0;
height: 36px;
border: 1px solid rgba(18, 46, 55, 0.18);
border-radius: 8px;
padding: 0 8px;
background: #ffffff;
color: #10242b;
font: inherit;
font-size: 11px;
font-weight: 750;
}
.offline-voyage-picker .danger-action {
width: 38px;
padding: 0;
background: #ffe1dc;
color: #9d2c22;
}
.deviation-alarm-controls label {
min-height: 42px;
border-radius: 8px;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
background: #eef3f0;
color: #4c6269;
font-size: 10px;
font-weight: 800;
}
.deviation-alarm-controls label span {
display: inline-flex;
align-items: center;
gap: 3px;
}
.deviation-alarm-controls input {
width: 61px;
height: 30px;
border: 1px solid rgba(18, 46, 55, 0.18);
border-radius: 7px;
padding: 0 6px;
background: #fff;
color: #10242b;
text-align: right;
}
.deviation-alarm-button {
min-height: 42px;
background: #0f4c5c;
color: #fff;
}
.deviation-alarm-button[data-active="true"] {
background: #196f5c;
}
.deviation-alarm-status,
.offline-voyage-status {
margin: 0;
border-radius: 8px;
padding: 7px 9px;
background: #dceee6;
color: #196f5c;
font-size: 11px;
font-weight: 800;
line-height: 1.35;
}
.deviation-alarm-status[data-off-route="true"] {
background: #ffe1dc;
color: #9d2c22;
}
.offline-voyage-status {
background: #eef3f0;
color: #4c6269;
}
.voyage-privacy-note {
color: #607278;
font-size: 9px;
font-weight: 650;
line-height: 1.4;
}
@media (max-width: 390px) {
.voyage-tools-heading span {
display: none;
}
}
@@ -0,0 +1,229 @@
import { Bell, BellOff, Download, FolderOpen, Save, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { RouteResult } from "@watermaps/shared";
import { useRouteDeviationAlarm } from "../hooks/useRouteDeviationAlarm";
import { downloadRouteGpx } from "../lib/gpx";
import {
deleteOfflineVoyage,
listOfflineVoyages,
loadOfflineVoyage,
requestPersistentOfflineStorage,
saveOfflineVoyage,
type OfflineVoyage,
type OfflineVoyagePlanInput
} from "../lib/offline-route";
import "./VoyageNavigationTools.css";
export type VoyageNavigationToolsProps = {
route: RouteResult | null;
plan?: OfflineVoyagePlanInput;
defaultDeviationThresholdM?: number;
courseAssistantActive?: boolean;
onLoadOfflineVoyage?: (voyage: OfflineVoyage) => void;
};
export function VoyageNavigationTools({
route,
plan,
defaultDeviationThresholdM = 100,
courseAssistantActive = false,
onLoadOfflineVoyage
}: VoyageNavigationToolsProps) {
const [savedVoyages, setSavedVoyages] = useState<OfflineVoyage[]>([]);
const [selectedVoyageId, setSelectedVoyageId] = useState("");
const [storageMessage, setStorageMessage] = useState<string | null>(null);
const [thresholdM, setThresholdM] = useState(() => clampThreshold(defaultDeviationThresholdM));
const alarm = useRouteDeviationAlarm(route, thresholdM);
useEffect(() => {
if (courseAssistantActive && alarm.active) {
alarm.stop();
}
}, [alarm.active, alarm.stop, courseAssistantActive]);
const refreshSavedVoyages = () => {
try {
const next = listOfflineVoyages();
setSavedVoyages(next);
setSelectedVoyageId((current) => next.some((voyage) => voyage.id === current) ? current : next[0]?.id ?? "");
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
useEffect(refreshSavedVoyages, []);
const selectedVoyage = useMemo(
() => savedVoyages.find((voyage) => voyage.id === selectedVoyageId) ?? null,
[savedVoyages, selectedVoyageId]
);
const exportGpx = () => {
if (!route) {
return;
}
try {
downloadRouteGpx(route);
setStorageMessage("GPX-Datei wurde zum Download bereitgestellt.");
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
const saveRoute = async () => {
if (!route) {
return;
}
try {
const voyage = saveOfflineVoyage({ route, plan });
void requestPersistentOfflineStorage();
refreshSavedVoyages();
setSelectedVoyageId(voyage.id);
setStorageMessage(`${voyage.name}“ ist auf diesem Gerät offline verfügbar.`);
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
const loadRoute = () => {
if (!selectedVoyage) {
return;
}
try {
const voyage = loadOfflineVoyage(selectedVoyage.id);
if (!voyage) {
setStorageMessage("Die gespeicherte Route ist nicht mehr verfügbar.");
refreshSavedVoyages();
return;
}
onLoadOfflineVoyage?.(voyage);
setStorageMessage(`${voyage.name}“ wurde offline geladen.`);
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
const removeRoute = () => {
if (!selectedVoyage) {
return;
}
try {
deleteOfflineVoyage(selectedVoyage.id);
setStorageMessage(`${selectedVoyage.name}“ wurde vom Gerät gelöscht.`);
refreshSavedVoyages();
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
return (
<section className="voyage-navigation-tools" aria-label="Navigation und Offline-Route">
<div className="voyage-tools-heading">
<strong>Unterwegs</strong>
<span>GPX · Offline · Kursalarm</span>
</div>
<div className="voyage-tools-actions">
<button type="button" onClick={exportGpx} disabled={!route} aria-label="Route als GPX exportieren">
<Download size={15} aria-hidden="true" />
GPX
</button>
<button type="button" onClick={saveRoute} disabled={!route} aria-label="Route offline speichern">
<Save size={15} aria-hidden="true" />
Offline speichern
</button>
</div>
{savedVoyages.length > 0 && (
<div className="offline-voyage-picker">
<label htmlFor="offline-voyage-select">Gespeicherte Route</label>
<select
id="offline-voyage-select"
value={selectedVoyageId}
onChange={(event) => setSelectedVoyageId(event.target.value)}
>
{savedVoyages.map((voyage) => (
<option key={voyage.id} value={voyage.id}>
{voyage.name} · {new Date(voyage.savedAt).toLocaleDateString("de-DE")}
</option>
))}
</select>
<button type="button" onClick={loadRoute} aria-label="Offline-Route laden">
<FolderOpen size={15} aria-hidden="true" />
Laden
</button>
<button className="danger-action" type="button" onClick={removeRoute} aria-label="Offline-Route löschen">
<Trash2 size={15} aria-hidden="true" />
</button>
</div>
)}
<div className="deviation-alarm-controls">
<label htmlFor="route-deviation-threshold">
Warnen ab
<span>
<input
id="route-deviation-threshold"
type="number"
inputMode="numeric"
min={25}
max={2_000}
step={25}
value={thresholdM}
disabled={alarm.active || courseAssistantActive}
onChange={(event) => setThresholdM(clampThreshold(Number(event.target.value)))}
/>
m
</span>
</label>
<button
type="button"
className="deviation-alarm-button"
data-active={alarm.active}
disabled={courseAssistantActive || (!route && !alarm.active)}
onClick={alarm.active ? alarm.stop : alarm.start}
aria-label={
courseAssistantActive
? "Kursalarm ist im Kursassistenten enthalten"
: alarm.active
? "Abweichungsalarm stoppen"
: "Abweichungsalarm starten"
}
>
{alarm.active ? <BellOff size={16} aria-hidden="true" /> : <Bell size={16} aria-hidden="true" />}
{courseAssistantActive ? "Im Assistenten aktiv" : alarm.active ? "Alarm stoppen" : "Kursalarm starten"}
</button>
</div>
{courseAssistantActive && (
<p className="deviation-alarm-status" role="status">
Querabweichung und einmalige Warnsignale werden vom Kursassistenten übernommen.
</p>
)}
{alarm.message && (
<p className="deviation-alarm-status" data-off-route={alarm.isOffRoute} role={alarm.isOffRoute ? "alert" : "status"}>
{alarm.message}
{alarm.accuracyM !== null && alarm.reliable && ` · GPS ±${Math.round(alarm.accuracyM)} m`}
</p>
)}
{storageMessage && <p className="offline-voyage-status" role="status">{storageMessage}</p>}
<small className="voyage-privacy-note">
GPS wird erst nach Kursalarm starten für diesen separaten Alarm angefragt; der Kursassistent nutzt den ebenfalls bewusst
gestarteten Live-GPS-Datenstrom der Karte. Positionen bleiben im Browser und werden weder gespeichert noch übertragen.
Bereits aufgerufene Kartenausschnitte kann die installierte App zeitlich begrenzt zwischenspeichern.
</small>
</section>
);
}
function clampThreshold(value: number): number {
if (!Number.isFinite(value)) {
return 100;
}
return Math.min(2_000, Math.max(25, Math.round(value)));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Offline-Funktion nicht verfügbar.";
}
+167
View File
@@ -0,0 +1,167 @@
.voyage-plan {
display: grid;
gap: 9px;
border-top: 1px solid rgba(18, 46, 55, 0.12);
padding-top: 10px;
color: #10242b;
}
.voyage-plan-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 10px;
}
.voyage-plan-header h2 {
margin: 2px 0 0;
font-size: 15px;
}
.voyage-plan-summary,
.voyage-plan-requirements,
.voyage-plan-leg-route,
.voyage-plan-waypoints,
.voyage-plan-unconfirmed-stop,
.voyage-plan-harbour-contact {
margin: 0;
}
.voyage-plan-summary {
color: #314b54;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.voyage-plan-requirements {
color: #4c6269;
font-size: 11px;
line-height: 1.4;
}
.voyage-plan-warnings,
.voyage-plan-legs,
.voyage-plan-amenities {
margin: 0;
padding: 0;
list-style: none;
}
.voyage-plan-warnings {
display: grid;
gap: 5px;
}
.voyage-plan-warnings li {
border-radius: 8px;
padding: 7px 9px;
background: #e8f0f1;
color: #314b54;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.voyage-plan-warnings li[data-severity="caution"] {
background: #fff1cc;
color: #805900;
}
.voyage-plan-warnings li[data-severity="critical"] {
background: #ffe1dc;
color: #9d2c22;
}
.voyage-plan-legs {
display: grid;
gap: 7px;
counter-reset: voyage-day;
}
.voyage-plan-leg {
border: 1px solid rgba(18, 46, 55, 0.12);
border-radius: 9px;
padding: 9px;
background: #f8faf9;
}
.voyage-plan-leg article,
.voyage-plan-harbour {
display: grid;
gap: 6px;
}
.voyage-plan-leg article > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
}
.voyage-plan-leg article > header span,
.voyage-plan-waypoints {
color: #60737a;
font-size: 10px;
font-weight: 750;
}
.voyage-plan-leg-route {
color: #10242b;
font-size: 12px;
font-weight: 850;
}
.voyage-plan-amenities {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.voyage-plan-amenities li {
min-height: 24px;
border-radius: 999px;
padding: 4px 7px;
background: #eef3f0;
color: #60737a;
font-size: 9px;
font-weight: 800;
}
.voyage-plan-amenities li[data-availability="available"] {
background: #dceee6;
color: #196f5c;
}
.voyage-plan-amenities li[data-availability="unavailable"] {
background: #f0e8e4;
color: #74483d;
}
.voyage-plan-unconfirmed-stop {
border-radius: 7px;
padding: 6px 8px;
background: #fff1cc;
color: #805900;
font-size: 10px;
font-weight: 850;
}
.voyage-plan-harbour-contact {
font-size: 11px;
font-weight: 800;
}
.voyage-plan-harbour-contact a {
color: #075f78;
text-underline-offset: 2px;
}
@media (max-width: 460px) {
.voyage-plan-header {
align-items: start;
flex-direction: column;
gap: 4px;
}
}
+144
View File
@@ -0,0 +1,144 @@
import {
VOYAGE_AMENITIES,
voyageAmenityLabel,
type ProjectedVoyageHarbour,
type VoyageAmenity,
type VoyageAmenityAvailability,
type VoyagePlan as VoyagePlanResult
} from "@watermaps/shared";
import "./VoyagePlan.css";
type VoyagePlanProps = {
plan: VoyagePlanResult | null;
};
export function VoyagePlan({ plan }: VoyagePlanProps) {
if (!plan) {
return null;
}
return (
<section className="voyage-plan" aria-labelledby="voyage-plan-title">
<header className="voyage-plan-header">
<div>
<span className="eyebrow">Reiseplanung</span>
<h2 id="voyage-plan-title">Etappenplan</h2>
</div>
<p className="voyage-plan-summary">
{plan.legs.length} {plan.legs.length === 1 ? "Tag" : "Tage"} · {formatNm(plan.totalDistanceNm)} ·{" "}
{formatDuration(plan.totalDurationHours)}
</p>
</header>
{plan.requiredAmenities.length > 0 ? (
<p className="voyage-plan-requirements">
Benötigte Versorgung: {plan.requiredAmenities.map(voyageAmenityLabel).join(", ")}
</p>
) : null}
{plan.warnings.length > 0 ? (
<ul className="voyage-plan-warnings" aria-label="Hinweise zum Etappenplan">
{plan.warnings.map((warning, index) => (
<li key={`${warning.code}-${warning.day ?? 0}-${index}`} data-severity={warning.severity}>
{warning.message}
</li>
))}
</ul>
) : null}
<ol className="voyage-plan-legs">
{plan.legs.map((leg) => (
<li key={leg.day} className="voyage-plan-leg">
<article>
<header>
<strong>Tag {leg.day}</strong>
<span>
{formatNm(leg.distanceNm)} · {formatDuration(leg.durationHours)}
</span>
</header>
<p className="voyage-plan-leg-route">
{leg.start.name} {leg.end.name}
</p>
{leg.waypoints.length > 0 ? (
<p className="voyage-plan-waypoints">
Via: {leg.waypoints.map((waypoint) => waypoint.name).join(" → ")}
</p>
) : null}
{leg.end.harbour ? <HarbourSupply harbour={leg.end.harbour} /> : null}
{leg.end.type === "route" ? (
<p className="voyage-plan-unconfirmed-stop">Kein bestätigter Liegeplatz</p>
) : null}
</article>
</li>
))}
</ol>
</section>
);
}
function HarbourSupply({ harbour }: { harbour: ProjectedVoyageHarbour }) {
return (
<div className="voyage-plan-harbour">
<ul className="voyage-plan-amenities" aria-label={`Versorgung in ${harbour.name}`}>
{VOYAGE_AMENITIES.map((amenity) => (
<Amenity key={amenity} amenity={amenity} availability={harbour.amenities[amenity]} />
))}
</ul>
{harbour.phone || harbour.website ? (
<p className="voyage-plan-harbour-contact">
{harbour.phone ? <a href={telephoneHref(harbour.phone)}>Hafen anrufen</a> : null}
{harbour.phone && harbour.website ? " · " : null}
{harbour.website ? (
<a href={websiteHref(harbour.website)} target="_blank" rel="noreferrer">
Website
</a>
) : null}
</p>
) : null}
</div>
);
}
function Amenity({
amenity,
availability
}: {
amenity: VoyageAmenity;
availability: VoyageAmenityAvailability;
}) {
const status =
availability === "available"
? "verfügbar"
: availability === "unavailable"
? "nicht verfügbar"
: "unbekannt";
const symbol = availability === "available" ? "✓" : availability === "unavailable" ? "" : "?";
return (
<li data-availability={availability} aria-label={`${voyageAmenityLabel(amenity)}: ${status}`}>
<span aria-hidden="true">{symbol}</span> {voyageAmenityLabel(amenity)}
</li>
);
}
function formatNm(value: number) {
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 1 })} sm`;
}
function formatDuration(hours: number) {
const totalMinutes = Math.max(0, Math.round(hours * 60));
const fullHours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${fullHours} h ${String(minutes).padStart(2, "0")} min`;
}
function telephoneHref(value: string) {
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
return `tel:${compact}`;
}
function websiteHref(value: string) {
const trimmed = value.trim();
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
}
+463
View File
@@ -0,0 +1,463 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
analyzeTideWindow,
calculateAnchorRodePlan,
evaluateAnchorWatch,
type Coordinate,
type TideSummary
} from "@watermaps/shared";
import { getNearestTide } from "../api";
import type { GpsState } from "./useGeolocation";
export type AnchorWatchPhase = "idle" | "set" | "armed";
export type AnchorWatchSettings = {
depthAtSetM: number;
bowRollerHeightM: number;
deployedRodeLengthM: number;
scopeRatio: number;
safetyAllowanceM: number;
alarmRadiusM: number;
horizonHours: number;
};
export const DEFAULT_ANCHOR_WATCH_SETTINGS: AnchorWatchSettings = {
depthAtSetM: 3,
bowRollerHeightM: 1,
deployedRodeLengthM: 30,
scopeRatio: 6,
safetyAllowanceM: 0.5,
alarmRadiusM: 35,
horizonHours: 24
};
const MAX_CAPTURE_ACCURACY_M = 30;
const MAX_FIX_AGE_MS = 10_000;
const STALE_FIX_AFTER_MS = 20_000;
const TIDE_REFRESH_MS = 15 * 60 * 1_000;
const REPEAT_ALARM_MS = 60_000;
type WakeLockSentinelLike = {
released?: boolean;
release: () => Promise<void>;
};
type NavigatorWithWakeLock = Navigator & {
wakeLock?: {
request: (type: "screen") => Promise<WakeLockSentinelLike>;
};
};
export function useAnchorWatch(gps: Pick<
GpsState,
"status" | "position" | "accuracyM" | "timestampMs"
>) {
const [phase, setPhase] = useState<AnchorWatchPhase>("idle");
const [anchorPoint, setAnchorPoint] = useState<Coordinate | null>(null);
const [anchorSetAtMs, setAnchorSetAtMs] = useState<number | null>(null);
const [anchorCaptureAccuracyM, setAnchorCaptureAccuracyM] = useState<number | null>(null);
const [settings, setSettings] = useState<AnchorWatchSettings>(DEFAULT_ANCHOR_WATCH_SETTINGS);
const [operationError, setOperationError] = useState<string | null>(null);
const [tide, setTide] = useState<TideSummary | null>(null);
const [tideLoading, setTideLoading] = useState(false);
const [tideError, setTideError] = useState<string | null>(null);
const [clockMs, setClockMs] = useState(() => Date.now());
const [alarmAcknowledged, setAlarmAcknowledged] = useState(false);
const tideRequestIdRef = useRef(0);
const previousAlarmRef = useRef(false);
const previousRodeShortfallRef = useRef(false);
const audioContextRef = useRef<AudioContext | null>(null);
const updateSettings = useCallback((next: Partial<AnchorWatchSettings>) => {
setSettings((current) => ({ ...current, ...next }));
setOperationError(null);
}, []);
const captureAnchor = useCallback(() => {
const now = Date.now();
if (gps.status !== "tracking" || !gps.position || gps.timestampMs === null) {
setOperationError("Für den Ankerpunkt wird zuerst ein aktueller GPS-Fix benötigt.");
return false;
}
if (now - gps.timestampMs > MAX_FIX_AGE_MS) {
setOperationError("Der GPS-Fix ist älter als 10 Sekunden. Bitte auf einen neuen Fix warten.");
return false;
}
if (gps.accuracyM === null || gps.accuracyM > MAX_CAPTURE_ACCURACY_M) {
setOperationError(
`GPS noch zu ungenau${gps.accuracyM === null ? "" : `${Math.round(gps.accuracyM)} m)`}. Ankerpunkt erst bei höchstens ±${MAX_CAPTURE_ACCURACY_M} m setzen.`
);
return false;
}
tideRequestIdRef.current += 1;
setAnchorPoint({ ...gps.position });
setAnchorSetAtMs(gps.timestampMs);
setAnchorCaptureAccuracyM(gps.accuracyM);
setPhase("set");
setOperationError(null);
setTide(null);
setTideError(null);
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousRodeShortfallRef.current = false;
return true;
}, [gps.accuracyM, gps.position, gps.status, gps.timestampMs]);
const reset = useCallback(() => {
tideRequestIdRef.current += 1;
setPhase("idle");
setAnchorPoint(null);
setAnchorSetAtMs(null);
setAnchorCaptureAccuracyM(null);
setOperationError(null);
setTide(null);
setTideLoading(false);
setTideError(null);
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousRodeShortfallRef.current = false;
const audioContext = audioContextRef.current;
audioContextRef.current = null;
void audioContext?.close().catch(() => undefined);
}, []);
const settingsError = useMemo(() => validateSettings(settings), [settings]);
const arm = useCallback(async () => {
if (!anchorPoint || anchorSetAtMs === null) {
setOperationError("Zuerst „Anker gefallen“ wählen und den Ankerpunkt setzen.");
return false;
}
const validationError = validateSettings(settings);
if (validationError) {
setOperationError(validationError);
return false;
}
setOperationError(null);
setClockMs(Date.now());
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
setPhase("armed");
audioContextRef.current = createAlarmAudioContext();
void audioContextRef.current?.resume().catch(() => undefined);
if (typeof Notification !== "undefined" && Notification.permission === "default") {
try {
await Notification.requestPermission();
} catch {
// The persistent in-app warning remains available when notifications
// are unsupported or denied.
}
}
return true;
}, [anchorPoint, anchorSetAtMs, settings]);
useEffect(() => {
if (!anchorPoint || anchorSetAtMs === null || phase === "idle") {
return;
}
let active = true;
const refresh = async () => {
const requestId = tideRequestIdRef.current + 1;
tideRequestIdRef.current = requestId;
setTideLoading(true);
try {
const summary = await getNearestTide(anchorPoint, new Date(anchorSetAtMs).toISOString());
if (active && tideRequestIdRef.current === requestId) {
setTide(summary);
setTideError(null);
}
} catch (error) {
if (active && tideRequestIdRef.current === requestId) {
setTide(null);
setTideError(error instanceof Error ? error.message : "Tidenprognose nicht erreichbar");
}
} finally {
if (active && tideRequestIdRef.current === requestId) {
setTideLoading(false);
}
}
};
void refresh();
const intervalId = window.setInterval(refresh, TIDE_REFRESH_MS);
return () => {
active = false;
window.clearInterval(intervalId);
};
}, [anchorPoint, anchorSetAtMs, phase]);
useEffect(() => {
if (phase === "idle") {
return;
}
setClockMs(Date.now());
const intervalId = window.setInterval(() => setClockMs(Date.now()), 5_000);
return () => window.clearInterval(intervalId);
}, [phase, gps.timestampMs]);
const tideWindow = useMemo(
() => tide && anchorSetAtMs !== null
? analyzeTideWindow(tide, anchorSetAtMs, settings.horizonHours)
: null,
[anchorSetAtMs, settings.horizonHours, tide]
);
const remainingTideWindow = useMemo(
() => tide ? analyzeTideWindow(tide, clockMs, settings.horizonHours) : null,
[clockMs, settings.horizonHours, tide]
);
const rodePlan = useMemo(
() => calculateAnchorRodePlan({
depthAtSetM: settings.depthAtSetM,
bowRollerHeightM: settings.bowRollerHeightM,
deployedRodeLengthM: settings.deployedRodeLengthM,
scopeRatio: settings.scopeRatio,
safetyAllowanceM: settings.safetyAllowanceM,
tideWindow
}),
[settings, tideWindow]
);
const watchResult = useMemo(
() => anchorPoint && gps.position
? evaluateAnchorWatch({
anchorPoint,
position: gps.position,
alarmRadiusM: settings.alarmRadiusM,
accuracyM: gps.accuracyM,
maxReliableAccuracyM: MAX_CAPTURE_ACCURACY_M
})
: null,
[anchorPoint, gps.accuracyM, gps.position, settings.alarmRadiusM]
);
const fixStale = phase === "armed" && (
gps.timestampMs === null || Math.max(0, clockMs - gps.timestampMs) > STALE_FIX_AFTER_MS
);
const gpsUnavailable = phase === "armed" && gps.status !== "tracking";
const gpsUnreliable = phase === "armed" && Boolean(watchResult && !watchResult.positionReliable);
const positionAlarm = phase === "armed" && (
fixStale || gpsUnavailable || gpsUnreliable || !watchResult || watchResult.alarmTriggered
);
const rodeShortfall = Boolean(
phase === "armed" && rodePlan?.calculationComplete && (rodePlan.rodeReserveM ?? 0) < 0
);
useEffect(() => {
if (phase !== "armed") {
previousAlarmRef.current = false;
return;
}
if (!positionAlarm) {
previousAlarmRef.current = false;
setAlarmAcknowledged(false);
return;
}
if (!previousAlarmRef.current) {
setAlarmAcknowledged(false);
emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current
);
}
previousAlarmRef.current = true;
}, [fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
useEffect(() => {
if (phase !== "armed" || !positionAlarm || alarmAcknowledged) {
return;
}
const intervalId = window.setInterval(() => {
emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current
);
}, REPEAT_ALARM_MS);
return () => window.clearInterval(intervalId);
}, [alarmAcknowledged, fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
useEffect(() => {
if (phase === "armed" && rodeShortfall && !previousRodeShortfallRef.current) {
const shortfallM = Math.abs(rodePlan?.rodeReserveM ?? 0);
emitAnchorAlert(
`Nach Stationsprognose fehlen rechnerisch etwa ${shortfallM.toFixed(1)} m Ankerleine oder Kette.`,
audioContextRef.current
);
}
previousRodeShortfallRef.current = rodeShortfall;
}, [phase, rodePlan?.rodeReserveM, rodeShortfall]);
useEffect(() => {
if (phase !== "armed" || typeof navigator === "undefined") {
return;
}
let active = true;
let sentinel: WakeLockSentinelLike | null = null;
const acquire = async () => {
const wakeLock = (navigator as NavigatorWithWakeLock).wakeLock;
if (!wakeLock || document.visibilityState !== "visible") {
return;
}
try {
sentinel = await wakeLock.request("screen");
if (!active) {
await sentinel.release();
}
} catch {
sentinel = null;
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && (!sentinel || sentinel.released)) {
void acquire();
}
};
void acquire();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
active = false;
document.removeEventListener("visibilitychange", handleVisibilityChange);
void sentinel?.release().catch(() => undefined);
};
}, [phase]);
useEffect(() => () => {
const audioContext = audioContextRef.current;
audioContextRef.current = null;
void audioContext?.close().catch(() => undefined);
}, []);
const acknowledgeAlarm = useCallback(() => setAlarmAcknowledged(true), []);
return {
phase,
anchorPoint,
anchorSetAtMs,
anchorCaptureAccuracyM,
settings,
settingsError,
tide,
tideLoading,
tideError,
tideWindow,
remainingTideWindow,
rodePlan,
watchResult,
fixStale,
gpsUnreliable,
positionAlarm,
rodeShortfall,
alarmAcknowledged,
operationError,
maxCaptureAccuracyM: MAX_CAPTURE_ACCURACY_M,
captureAnchor,
updateSettings,
arm,
acknowledgeAlarm,
reset
};
}
function validateSettings(settings: AnchorWatchSettings): string | null {
if (!positive(settings.depthAtSetM)) return "Die Tiefe beim Setzen muss größer als 0 m sein.";
if (!nonNegative(settings.bowRollerHeightM)) return "Die Höhe der Bugrolle darf nicht negativ sein.";
if (!positive(settings.deployedRodeLengthM)) return "Die ausgesteckte Länge muss größer als 0 m sein.";
if (!Number.isFinite(settings.scopeRatio) || settings.scopeRatio < 2 || settings.scopeRatio > 15) {
return "Das gewählte Verhältnis muss zwischen 2:1 und 15:1 liegen.";
}
if (!nonNegative(settings.safetyAllowanceM)) return "Die Wasserstandsreserve darf nicht negativ sein.";
if (!Number.isFinite(settings.alarmRadiusM) || settings.alarmRadiusM < 10 || settings.alarmRadiusM > 2_000) {
return "Der Alarmradius muss zwischen 10 m und 2.000 m liegen.";
}
if (!Number.isFinite(settings.horizonHours) || settings.horizonHours < 6 || settings.horizonHours > 72) {
return "Der Tidenzeitraum muss zwischen 6 und 72 Stunden liegen.";
}
return null;
}
function positive(value: number) {
return Number.isFinite(value) && value > 0;
}
function nonNegative(value: number) {
return Number.isFinite(value) && value >= 0;
}
function anchorAlertMessage({
fixStale,
gpsUnavailable,
gpsUnreliable,
watchResult
}: {
fixStale: boolean;
gpsUnavailable: boolean;
gpsUnreliable: boolean;
watchResult: ReturnType<typeof evaluateAnchorWatch>;
}) {
if (fixStale) return "Kein aktueller GPS-Fix Ankerposition kann nicht sicher überwacht werden.";
if (gpsUnavailable) return "GPS ist ausgefallen Ankerposition kann nicht überwacht werden.";
if (gpsUnreliable) return "GPS ist zu ungenau Ankerposition kann nicht sicher überwacht werden.";
if (watchResult?.alarmTriggered) {
return `Ankeralarm: ${Math.round(watchResult.distanceFromAnchorM)} m vom gesetzten Ankerpunkt entfernt.`;
}
return "Ankerwache hat keine auswertbare Position.";
}
function emitAnchorAlert(message: string, audioContext: AudioContext | null) {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate([300, 120, 300, 120, 500]);
}
playAlarmTone(audioContext);
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
try {
new Notification("Watermaps Ankerwache", {
body: message,
tag: "watermaps-anchor-watch",
requireInteraction: true
});
} catch {
// The live panel remains the primary warning surface.
}
}
}
function createAlarmAudioContext(): AudioContext | null {
if (typeof window === "undefined") {
return null;
}
const AudioContextConstructor = window.AudioContext ?? (
window as typeof window & { webkitAudioContext?: typeof AudioContext }
).webkitAudioContext;
if (!AudioContextConstructor) {
return null;
}
try {
return new AudioContextConstructor();
} catch {
return null;
}
}
function playAlarmTone(audioContext: AudioContext | null) {
if (!audioContext || audioContext.state === "closed") {
return;
}
void audioContext.resume().then(() => {
const startAt = audioContext.currentTime;
for (const offset of [0, 0.32, 0.64]) {
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
oscillator.type = "square";
oscillator.frequency.value = 880;
gain.gain.setValueAtTime(0.0001, startAt + offset);
gain.gain.exponentialRampToValueAtTime(0.18, startAt + offset + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + offset + 0.2);
oscillator.connect(gain);
gain.connect(audioContext.destination);
oscillator.start(startAt + offset);
oscillator.stop(startAt + offset + 0.21);
}
}).catch(() => undefined);
}
+86
View File
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useState } from "react";
import { normalizeHeadingDeg } from "@watermaps/shared";
type DeviceOrientationWithWebkit = DeviceOrientationEvent & {
webkitCompassHeading?: number;
};
type DeviceOrientationConstructor = typeof DeviceOrientationEvent & {
requestPermission?: (absolute?: boolean) => Promise<"granted" | "denied">;
};
export type CompassState = {
status: "idle" | "requesting" | "active" | "denied" | "unavailable" | "error";
headingDeg: number | null;
message: string | null;
};
export function useCompass(fallbackCourseDeg: number | null) {
const [state, setState] = useState<CompassState>({
status: "idle",
headingDeg: null,
message: null
});
const handleOrientation = useCallback((event: DeviceOrientationEvent) => {
const orientation = event as DeviceOrientationWithWebkit;
const heading =
typeof orientation.webkitCompassHeading === "number"
? orientation.webkitCompassHeading
: typeof event.alpha === "number"
? 360 - event.alpha
: null;
if (heading !== null) {
setState({
status: "active",
headingDeg: Math.round(normalizeHeadingDeg(heading)),
message: null
});
}
}, []);
const removeOrientationListeners = useCallback(() => {
window.removeEventListener("deviceorientationabsolute", handleOrientation);
window.removeEventListener("deviceorientation", handleOrientation);
}, [handleOrientation]);
const request = useCallback(async () => {
if (typeof window === "undefined" || !("DeviceOrientationEvent" in window)) {
setState((current) => ({ ...current, status: "unavailable", message: "Kompass nicht verfügbar" }));
return;
}
try {
setState((current) => ({ ...current, status: "requesting", message: null }));
const ctor = DeviceOrientationEvent as DeviceOrientationConstructor;
if (typeof ctor.requestPermission === "function") {
const permission = await ctor.requestPermission(true);
if (permission !== "granted") {
setState((current) => ({ ...current, status: "denied", message: "Kompass gesperrt" }));
return;
}
}
removeOrientationListeners();
window.addEventListener("deviceorientationabsolute", handleOrientation);
window.addEventListener("deviceorientation", handleOrientation);
setState((current) => ({ ...current, status: "active" }));
} catch (error) {
setState((current) => ({
...current,
status: "error",
message: error instanceof Error ? error.message : "Kompassfehler"
}));
}
}, [handleOrientation, removeOrientationListeners]);
useEffect(() => removeOrientationListeners, [removeOrientationListeners]);
return {
...state,
headingDeg: state.headingDeg ?? fallbackCourseDeg,
source: state.headingDeg !== null ? "HDG" : fallbackCourseDeg !== null ? "COG" : "N",
request
};
}
+114
View File
@@ -0,0 +1,114 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
calculateRouteGuidance,
type Coordinate,
type RouteGuidanceResult,
type RouteResult
} from "@watermaps/shared";
export type CourseAssistantInput = {
route: RouteResult | null;
position: Coordinate | null;
accuracyM: number | null;
speedKn: number | null;
headingDeg: number | null;
fixTimestampMs: number | null;
};
const STALE_FIX_AFTER_MS = 15_000;
/**
* Keeps route-following state in memory while reusing the app's single GPS
* stream. It never starts a sensor or transmits a position by itself.
*/
export function useCourseAssistant({
route,
position,
accuracyM,
speedKn,
headingDeg,
fixTimestampMs
}: CourseAssistantInput) {
const [active, setActive] = useState(false);
const [clockMs, setClockMs] = useState(() => Date.now());
const activeRouteRef = useRef<RouteResult | null>(null);
const progressMRef = useRef<number | null>(null);
const previousSignalRef = useRef<string | null>(null);
const stop = useCallback(() => {
activeRouteRef.current = null;
progressMRef.current = null;
previousSignalRef.current = null;
setActive(false);
}, []);
const start = useCallback(() => {
if (!route) return;
activeRouteRef.current = route;
progressMRef.current = null;
previousSignalRef.current = null;
setClockMs(Date.now());
setActive(true);
}, [route]);
useEffect(() => {
if (activeRouteRef.current && activeRouteRef.current !== route) {
stop();
}
}, [route, stop]);
useEffect(() => {
if (!active) return;
setClockMs(Date.now());
const intervalId = window.setInterval(() => setClockMs(Date.now()), 5_000);
return () => window.clearInterval(intervalId);
}, [active, fixTimestampMs]);
const fixStale = Boolean(
active &&
position &&
fixTimestampMs !== null &&
Math.max(0, clockMs - fixTimestampMs) > STALE_FIX_AFTER_MS
);
const guidance = useMemo<RouteGuidanceResult | null>(() => {
if (!active || activeRouteRef.current !== route || !route || !position || fixStale) {
return null;
}
return calculateRouteGuidance({
route,
position,
accuracyM,
speedKn,
headingDeg,
previousProgressM: progressMRef.current
});
}, [accuracyM, active, fixStale, headingDeg, position, route, speedKn]);
useEffect(() => {
if (guidance) {
progressMRef.current = guidance.progressM;
}
}, [guidance]);
useEffect(() => {
if (!active || !guidance) return;
const signal = guidanceSignal(guidance);
if (signal !== previousSignalRef.current && typeof navigator.vibrate === "function") {
if (guidance.status === "off-route") navigator.vibrate([200, 100, 200]);
if (guidance.status === "arrived") navigator.vibrate([300, 150, 300]);
if (guidance.status === "approaching-turn") navigator.vibrate(120);
}
previousSignalRef.current = signal;
}, [active, guidance]);
return { active, guidance, fixStale, start, stop };
}
function guidanceSignal(guidance: RouteGuidanceResult) {
if (guidance.status === "approaching-turn" && guidance.nextTurn) {
const turn = guidance.nextTurn.coordinate;
return `turn:${turn.lat.toFixed(5)}:${turn.lon.toFixed(5)}`;
}
return guidance.status;
}
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { haversineDistanceM, initialBearingDeg, type Coordinate } from "@watermaps/shared";
export type GpsState = {
status: "idle" | "requesting" | "tracking" | "denied" | "unavailable" | "error";
position: Coordinate | null;
accuracyM: number | null;
speedKn: number | null;
courseDeg: number | null;
timestampMs: number | null;
message: string | null;
};
const MS_TO_KN = 1.94384449;
export function useGeolocation() {
const watchId = useRef<number | null>(null);
const courseAnchor = useRef<{ coord: Coordinate; accuracyM: number } | null>(null);
const [state, setState] = useState<GpsState>({
status: "idle",
position: null,
accuracyM: null,
speedKn: null,
courseDeg: null,
timestampMs: null,
message: null
});
const clearActiveWatch = useCallback(() => {
if (watchId.current !== null) {
navigator.geolocation.clearWatch(watchId.current);
watchId.current = null;
}
}, []);
const stop = useCallback(() => {
clearActiveWatch();
courseAnchor.current = null;
setState({
status: "idle",
position: null,
accuracyM: null,
speedKn: null,
courseDeg: null,
timestampMs: null,
message: null
});
}, [clearActiveWatch]);
const start = useCallback(() => {
if (typeof window !== "undefined" && window.isSecureContext === false) {
setState((current) => ({
...current,
status: "unavailable",
message: "GPS benötigt HTTPS oder localhost. Öffne die App auf dem iPhone über einen HTTPS-Link."
}));
return;
}
if (!("geolocation" in navigator)) {
setState((current) => ({ ...current, status: "unavailable", message: "GPS nicht verfügbar" }));
return;
}
clearActiveWatch();
courseAnchor.current = null;
setState((current) => ({ ...current, status: "requesting", message: null }));
watchId.current = navigator.geolocation.watchPosition(
(position) => {
// Freshness is based on when this watch callback reached the app. Some
// embedded/WebKit implementations expose a non-epoch timestamp even
// though the DOM type is an EpochTimeStamp. maximumAge below already
// limits how old the accepted sensor fix may be.
const receivedAtMs = Date.now();
const coord = {
lat: position.coords.latitude,
lon: position.coords.longitude
};
const nativeCourse =
typeof position.coords.heading === "number" && Number.isFinite(position.coords.heading)
? position.coords.heading
: null;
const speedKn =
typeof position.coords.speed === "number" && Number.isFinite(position.coords.speed)
? position.coords.speed * MS_TO_KN
: null;
const accuracyM = Math.max(0, position.coords.accuracy);
const anchor = courseAnchor.current;
let derivedCourse: number | null = nativeCourse;
if (nativeCourse !== null) {
courseAnchor.current = { coord, accuracyM };
} else if (!anchor) {
courseAnchor.current = { coord, accuracyM };
} else {
const movementM = haversineDistanceM(anchor.coord, coord);
const minimumMovementM = Math.max(3, Math.min(12, Math.max(anchor.accuracyM, accuracyM) * 0.5));
if (movementM >= minimumMovementM) {
derivedCourse = initialBearingDeg(anchor.coord, coord);
courseAnchor.current = { coord, accuracyM };
}
}
setState((current) => ({
status: "tracking",
position: coord,
accuracyM: Math.round(accuracyM),
speedKn,
courseDeg: derivedCourse ?? current.courseDeg,
timestampMs: receivedAtMs,
message: null
}));
},
(error) => {
const status = error.code === error.PERMISSION_DENIED ? "denied" : "error";
setState((current) => ({ ...current, status, message: error.message }));
},
{
enableHighAccuracy: true,
timeout: 12_000,
maximumAge: 2_000
}
);
}, [clearActiveWatch]);
useEffect(() => clearActiveWatch, [clearActiveWatch]);
return { ...state, start, stop };
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useState } from "react";
import type { Coordinate, MarineForecast, TideSummary } from "@watermaps/shared";
import { getMarineForecast, getNearestTide } from "../api";
export type MarineDataState = {
forecast: MarineForecast | null;
tide: TideSummary | null;
loading: boolean;
error: string | null;
forecastError: string | null;
tideError: string | null;
queryPosition: Coordinate | null;
refreshedAt: string | null;
};
const REFRESH_INTERVAL_MS = 10 * 60_000;
export function useMarineData(position: Coordinate | null): MarineDataState {
const key = useMemo(() => {
if (!position) {
return null;
}
return `${position.lat.toFixed(2)}:${position.lon.toFixed(2)}`;
}, [position]);
const queryPosition = useMemo<Coordinate | null>(() => {
if (!key) {
return null;
}
const [lat, lon] = key.split(":").map(Number);
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat: lat!, lon: lon! } : null;
}, [key]);
const [state, setState] = useState<MarineDataState>({
forecast: null,
tide: null,
loading: false,
error: null,
forecastError: null,
tideError: null,
queryPosition: null,
refreshedAt: null
});
useEffect(() => {
if (!queryPosition || !key) {
return;
}
let cancelled = false;
const refresh = () => {
setState((current) => {
const sameQuery =
current.queryPosition?.lat === queryPosition.lat &&
current.queryPosition?.lon === queryPosition.lon;
return {
...current,
forecast: sameQuery ? current.forecast : null,
tide: sameQuery ? current.tide : null,
loading: true,
error: null,
forecastError: null,
tideError: null,
queryPosition
};
});
void Promise.allSettled([
getMarineForecast(queryPosition),
getNearestTide(queryPosition)
]).then(([forecastResult, tideResult]) => {
if (cancelled) {
return;
}
setState((current) => {
const forecastError =
forecastResult.status === "rejected" ? "Wetterdaten nicht erreichbar" : null;
const tideError =
tideResult.status === "rejected" ? "Tidendaten nicht erreichbar" : null;
return {
...current,
forecast:
forecastResult.status === "fulfilled"
? forecastResult.value
: current.forecast,
tide:
tideResult.status === "fulfilled"
? tideResult.value
: current.tide,
loading: false,
error:
forecastError && tideError ? "Metocean-Daten nicht erreichbar" : null,
forecastError,
tideError,
queryPosition,
refreshedAt: new Date().toISOString()
};
});
});
};
refresh();
const intervalId = window.setInterval(refresh, REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [key, queryPosition]);
return state;
}
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { Coordinate, RouteResult } from "@watermaps/shared";
import { evaluateRouteDeviation } from "../lib/route-deviation";
export type RouteDeviationAlarmStatus =
| "idle"
| "requesting"
| "tracking"
| "denied"
| "unavailable"
| "error";
export type RouteDeviationAlarmState = {
status: RouteDeviationAlarmStatus;
position: Coordinate | null;
accuracyM: number | null;
distanceM: number | null;
reliable: boolean;
isOffRoute: boolean;
message: string | null;
};
const INITIAL_STATE: RouteDeviationAlarmState = {
status: "idle",
position: null,
accuracyM: null,
distanceM: null,
reliable: true,
isOffRoute: false,
message: null
};
/**
* Watches the device position only after start() is called from a user action.
* Coordinates are evaluated in memory and are never persisted or transmitted.
*/
export function useRouteDeviationAlarm(route: RouteResult | null, thresholdM: number) {
const [state, setState] = useState<RouteDeviationAlarmState>(INITIAL_STATE);
const watchId = useRef<number | null>(null);
const activeRoute = useRef<RouteResult | null>(route);
const threshold = useRef(thresholdM);
const offRoute = useRef(false);
const monitoringGeneration = useRef(0);
activeRoute.current = route;
threshold.current = thresholdM;
const stop = useCallback(() => {
monitoringGeneration.current += 1;
if (watchId.current !== null && typeof navigator !== "undefined" && "geolocation" in navigator) {
navigator.geolocation.clearWatch(watchId.current);
watchId.current = null;
}
offRoute.current = false;
setState(INITIAL_STATE);
}, []);
const start = useCallback(() => {
if (!activeRoute.current) {
setState({ ...INITIAL_STATE, status: "error", message: "Zuerst eine Route berechnen oder offline laden." });
return;
}
if (typeof window !== "undefined" && window.isSecureContext === false) {
setState({ ...INITIAL_STATE, status: "unavailable", message: "Der Abweichungsalarm benötigt HTTPS oder localhost." });
return;
}
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
setState({ ...INITIAL_STATE, status: "unavailable", message: "GPS ist auf diesem Gerät nicht verfügbar." });
return;
}
if (watchId.current !== null) {
navigator.geolocation.clearWatch(watchId.current);
}
monitoringGeneration.current += 1;
const generation = monitoringGeneration.current;
offRoute.current = false;
setState({ ...INITIAL_STATE, status: "requesting", message: "GPS-Freigabe wird angefragt …" });
try {
watchId.current = navigator.geolocation.watchPosition(
(position) => {
if (monitoringGeneration.current !== generation) {
return;
}
const coordinate = { lat: position.coords.latitude, lon: position.coords.longitude };
const result = activeRoute.current
? evaluateRouteDeviation(coordinate, activeRoute.current, {
thresholdM: threshold.current,
accuracyM: position.coords.accuracy
})
: null;
if (!result) {
setState({
...INITIAL_STATE,
status: "error",
message: "Der Abstand zu dieser Route konnte nicht bestimmt werden."
});
return;
}
if (result.isOffRoute && !offRoute.current && typeof navigator.vibrate === "function") {
navigator.vibrate([200, 100, 200]);
}
offRoute.current = result.isOffRoute;
setState({
status: "tracking",
position: coordinate,
accuracyM: result.accuracyM,
distanceM: result.distanceM,
reliable: result.reliable,
isOffRoute: result.isOffRoute,
message: result.reliable
? result.isOffRoute
? `Achtung: ${Math.round(result.distanceM)} m von der Route entfernt.`
: `Auf Kurs · ${Math.round(result.distanceM)} m zur Route.`
: `GPS noch zu ungenau (±${Math.round(result.accuracyM ?? 0)} m) kein Alarm.`
});
},
(error) => {
if (monitoringGeneration.current !== generation) {
return;
}
watchId.current = null;
const denied = error.code === error.PERMISSION_DENIED;
setState({
...INITIAL_STATE,
status: denied ? "denied" : "error",
message: denied ? "GPS-Freigabe wurde abgelehnt." : error.message || "GPS-Position nicht verfügbar."
});
},
{
enableHighAccuracy: true,
timeout: 15_000,
maximumAge: 3_000
}
);
} catch {
watchId.current = null;
setState({ ...INITIAL_STATE, status: "unavailable", message: "GPS konnte nicht gestartet werden." });
}
}, []);
useEffect(() => stop, [stop]);
// A newly selected route requires a deliberate restart, so the monitor can
// never silently continue against a different voyage.
const previousRoute = useRef(route);
useEffect(() => {
if (previousRoute.current !== route) {
previousRoute.current = route;
if (watchId.current !== null) {
stop();
}
}
}, [route, stop]);
return {
...state,
active: watchId.current !== null,
start,
stop
};
}
+157
View File
@@ -0,0 +1,157 @@
import type { RouteResult } from "@watermaps/shared";
const DEFAULT_CREATOR = "Watermaps";
export type GpxExportOptions = {
name?: string;
description?: string;
creator?: string;
createdAt?: Date | string;
};
/**
* Creates a standards-compliant GPX 1.1 document containing both the planned
* route and a track. Keeping both makes the export useful in navigation apps
* which support only one of the two GPX representations.
*/
export function createRouteGpx(route: RouteResult, options: GpxExportOptions = {}): string {
const coordinates = validRouteCoordinates(route);
const name = cleanText(options.name ?? route.name ?? "Watermaps Bootsroute", 120);
const description = cleanText(
options.description ?? `${route.distanceNm.toFixed(1)} sm · ${route.routingMode === "fairway" ? "Fahrwasserroute" : "Bootsroute"}`,
500
);
const creator = cleanText(options.creator ?? DEFAULT_CREATOR, 120);
const createdAt = normalizeDate(options.createdAt);
const source = cleanText(route.dataSources.join(", ") || DEFAULT_CREATOR, 500);
const warningSummary = cleanText(
route.warnings.map((warning) => warning.message).join(" · ") || "Nicht amtliche Routenplanung",
1_000
);
const bounds = routeBounds(coordinates);
const routePoints = coordinates.map(([lon, lat]) => ` <rtept lat="${formatCoordinate(lat)}" lon="${formatCoordinate(lon)}"/>`).join("\n");
const trackPoints = coordinates.map(([lon, lat]) => ` <trkpt lat="${formatCoordinate(lat)}" lon="${formatCoordinate(lon)}"/>`).join("\n");
return [
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
`<gpx version="1.1" creator="${escapeXml(creator)}" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">`,
" <metadata>",
` <name>${escapeXml(name)}</name>`,
` <desc>${escapeXml(description)}</desc>`,
" <author>",
` <name>${escapeXml(creator)}</name>`,
" </author>",
` <time>${createdAt}</time>`,
" <keywords>Watermaps, Bootsroute, Navigation</keywords>",
` <bounds minlat="${formatCoordinate(bounds.minLat)}" minlon="${formatCoordinate(bounds.minLon)}" maxlat="${formatCoordinate(bounds.maxLat)}" maxlon="${formatCoordinate(bounds.maxLon)}"/>`,
" </metadata>",
" <rte>",
` <name>${escapeXml(name)}</name>`,
` <cmt>${escapeXml(warningSummary)}</cmt>`,
` <desc>${escapeXml(description)}</desc>`,
` <src>${escapeXml(source)}</src>`,
" <number>1</number>",
" <type>Motorboating</type>",
routePoints,
" </rte>",
" <trk>",
` <name>${escapeXml(name)}</name>`,
` <cmt>${escapeXml(warningSummary)}</cmt>`,
` <desc>${escapeXml(description)}</desc>`,
` <src>${escapeXml(source)}</src>`,
" <number>1</number>",
" <type>Motorboating</type>",
" <trkseg>",
trackPoints,
" </trkseg>",
" </trk>",
"</gpx>",
""
].join("\n");
}
export function downloadRouteGpx(route: RouteResult, options: GpxExportOptions = {}): void {
if (typeof document === "undefined" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
throw new Error("GPX-Download wird von diesem Browser nicht unterstützt.");
}
const name = cleanText(options.name ?? route.name ?? "Watermaps-Route", 120);
const filename = `${safeFilename(name)}.gpx`;
const blobUrl = URL.createObjectURL(new Blob([createRouteGpx(route, options)], { type: "application/gpx+xml;charset=utf-8" }));
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
link.rel = "noopener";
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
}
function validRouteCoordinates(route: RouteResult): [number, number][] {
if (route.geometry.type !== "LineString" || route.geometry.coordinates.length < 2) {
throw new Error("Die Route enthält nicht genügend Punkte für einen GPX-Export.");
}
const coordinates = route.geometry.coordinates.map((coordinate) => {
const [lon, lat] = coordinate;
if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
throw new Error("Die Route enthält ungültige Koordinaten.");
}
return [lon, lat] as [number, number];
});
return coordinates;
}
function routeBounds(coordinates: [number, number][]) {
let minLat = 90;
let maxLat = -90;
let minLon = 180;
let maxLon = -180;
for (const [lon, lat] of coordinates) {
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
}
return { minLat, maxLat, minLon, maxLon };
}
function normalizeDate(value: Date | string | undefined): string {
const date = value instanceof Date ? value : value ? new Date(value) : new Date();
if (!Number.isFinite(date.getTime())) {
throw new Error("Ungültiger Zeitstempel für den GPX-Export.");
}
return date.toISOString();
}
function formatCoordinate(value: number): string {
return value.toFixed(7).replace(/\.?0+$/, "");
}
function cleanText(value: string, maxLength: number): string {
const cleaned = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ").trim();
return cleaned.slice(0, maxLength) || DEFAULT_CREATOR;
}
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function safeFilename(value: string): string {
const normalized = value
.normalize("NFKD")
.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-.]+|[-.]+$/g, "")
.slice(0, 80);
return normalized || "Watermaps-Route";
}
+351
View File
@@ -0,0 +1,351 @@
import type { Coordinate, RouteResult, RouteWarning, VesselProfile } from "@watermaps/shared";
export const OFFLINE_VOYAGES_STORAGE_KEY = "watermaps.offline-voyages.v1";
export const LEGACY_OFFLINE_VOYAGES_STORAGE_KEY = "seacompass.offline-voyages.v1";
export const MAX_OFFLINE_VOYAGES = 20;
const MAX_SERIALIZED_BYTES = 4_000_000;
const MAX_ROUTE_POINTS = 25_000;
export type OfflineVoyagePlan = {
start: Coordinate;
destination: Coordinate;
waypoints: Coordinate[];
vesselProfile: VesselProfile | null;
departureAt: string | null;
notes: string | null;
};
export type OfflineVoyagePlanInput = Partial<OfflineVoyagePlan>;
export type OfflineVoyage = {
schemaVersion: 1;
id: string;
name: string;
savedAt: string;
plan: OfflineVoyagePlan;
route: RouteResult;
};
export type SaveOfflineVoyageInput = {
route: RouteResult;
name?: string;
plan?: OfflineVoyagePlanInput;
};
export type OfflineVoyageRecordOptions = {
id?: string;
savedAt?: Date | string;
};
export class OfflineVoyageStorageError extends Error {
override name = "OfflineVoyageStorageError";
}
/**
* Saves one validated snapshot atomically. Only route-planning fields are
* whitelisted; live GPS positions are deliberately never persisted.
*/
export function saveOfflineVoyage(
input: SaveOfflineVoyageInput,
storage: Storage = browserStorage(),
options: OfflineVoyageRecordOptions = {}
): OfflineVoyage {
const record = createOfflineVoyageRecord(input, options);
const records = listOfflineVoyages(storage).filter((item) => item.id !== record.id);
const next = [record, ...records].slice(0, MAX_OFFLINE_VOYAGES);
const serialized = JSON.stringify(next);
if (serialized.length > MAX_SERIALIZED_BYTES) {
throw new OfflineVoyageStorageError("Die Route ist zu groß für den Offline-Speicher.");
}
try {
storage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, serialized);
} catch {
throw new OfflineVoyageStorageError("Die Route konnte auf diesem Gerät nicht gespeichert werden.");
}
return record;
}
export function listOfflineVoyages(storage: Storage = browserStorage()): OfflineVoyage[] {
let serialized: string | null;
try {
serialized =
storage.getItem(OFFLINE_VOYAGES_STORAGE_KEY) ??
storage.getItem(LEGACY_OFFLINE_VOYAGES_STORAGE_KEY);
} catch {
throw new OfflineVoyageStorageError("Der Offline-Speicher ist nicht verfügbar.");
}
if (!serialized || serialized.length > MAX_SERIALIZED_BYTES) {
return [];
}
try {
const parsed: unknown = JSON.parse(serialized);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.slice(0, MAX_OFFLINE_VOYAGES)
.map(parseOfflineVoyage)
.filter((item): item is OfflineVoyage => item !== null)
.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
} catch {
return [];
}
}
export function loadOfflineVoyage(id: string, storage: Storage = browserStorage()): OfflineVoyage | null {
const safeId = limitedString(id, 160);
if (!safeId) {
return null;
}
return listOfflineVoyages(storage).find((record) => record.id === safeId) ?? null;
}
export function deleteOfflineVoyage(id: string, storage: Storage = browserStorage()): boolean {
const records = listOfflineVoyages(storage);
const next = records.filter((record) => record.id !== id);
if (next.length === records.length) {
return false;
}
try {
storage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, JSON.stringify(next));
} catch {
throw new OfflineVoyageStorageError("Die Offline-Route konnte nicht gelöscht werden.");
}
return true;
}
/** Best-effort protection against automatic browser eviction after a user saves a route. */
export async function requestPersistentOfflineStorage(): Promise<boolean | null> {
if (typeof navigator === "undefined" || !navigator.storage?.persist) {
return null;
}
try {
if (await navigator.storage.persisted()) {
return true;
}
return await navigator.storage.persist();
} catch {
return null;
}
}
export function createOfflineVoyageRecord(
input: SaveOfflineVoyageInput,
options: OfflineVoyageRecordOptions = {}
): OfflineVoyage {
const route = normalizeRoute(input.route);
const first = route.geometry.coordinates[0]!;
const last = route.geometry.coordinates.at(-1)!;
const savedAt = normalizeIsoDate(options.savedAt ?? new Date());
const name = limitedString(input.name ?? route.name ?? "Offline-Bootsroute", 120) || "Offline-Bootsroute";
const id = limitedString(options.id ?? createId(savedAt), 160);
if (!id) {
throw new OfflineVoyageStorageError("Ungültige Kennung für die Offline-Route.");
}
const defaultStart = { lat: first[1], lon: first[0] };
const defaultDestination = { lat: last[1], lon: last[0] };
const planInput = input.plan ?? {};
return {
schemaVersion: 1,
id,
name,
savedAt,
plan: {
start: normalizeCoordinate(planInput.start ?? defaultStart),
destination: normalizeCoordinate(planInput.destination ?? defaultDestination),
waypoints: normalizeCoordinateList(planInput.waypoints ?? []),
vesselProfile: planInput.vesselProfile ? normalizeVesselProfile(planInput.vesselProfile) : null,
departureAt: planInput.departureAt ? normalizeIsoDate(planInput.departureAt) : null,
notes: planInput.notes ? limitedString(planInput.notes, 2_000) : null
},
route
};
}
function parseOfflineVoyage(value: unknown): OfflineVoyage | null {
if (!isRecord(value) || value.schemaVersion !== 1) {
return null;
}
try {
const id = limitedString(value.id, 160);
const name = limitedString(value.name, 120);
if (!id || !name || !isRecord(value.plan)) {
return null;
}
const plan = value.plan;
return {
schemaVersion: 1,
id,
name,
savedAt: normalizeIsoDate(value.savedAt),
plan: {
start: normalizeCoordinate(plan.start),
destination: normalizeCoordinate(plan.destination),
waypoints: normalizeCoordinateList(plan.waypoints),
vesselProfile: plan.vesselProfile === null ? null : normalizeVesselProfile(plan.vesselProfile),
departureAt: plan.departureAt === null ? null : normalizeIsoDate(plan.departureAt),
notes: plan.notes === null ? null : limitedString(plan.notes, 2_000)
},
route: normalizeRoute(value.route)
};
} catch {
return null;
}
}
function normalizeRoute(value: unknown): RouteResult {
if (!isRecord(value) || !isRecord(value.geometry) || value.geometry.type !== "LineString") {
throw new OfflineVoyageStorageError("Die Offline-Route hat ein ungültiges Format.");
}
const coordinates = normalizeLineCoordinates(value.geometry.coordinates);
const distanceNm = finiteNumber(value.distanceNm, 0, 100_000);
const eta = value.eta === null ? null : limitedString(value.eta, 100);
const minKnownDepthM = value.minKnownDepthM === null ? null : finiteNumber(value.minKnownDepthM, 0, 20_000);
const unknownDepthRatio = finiteNumber(value.unknownDepthRatio, 0, 1);
const warnings = normalizeWarnings(value.warnings);
const dataSources = normalizeStrings(value.dataSources, 100, 500);
const id = value.id === undefined ? undefined : limitedString(value.id, 160);
const name = value.name === undefined ? undefined : limitedString(value.name, 120);
const routingMode = value.routingMode === "manual" || value.routingMode === "fairway" ? value.routingMode : undefined;
const departureTime =
typeof value.departureTime === "string" ? normalizeIsoDate(value.departureTime) : undefined;
const durationMinutes =
value.durationMinutes === undefined ? undefined : finiteNumber(value.durationMinutes, 0, 10_000_000);
return {
...(id ? { id } : {}),
...(name ? { name } : {}),
geometry: { type: "LineString", coordinates },
distanceNm,
eta,
warnings,
minKnownDepthM,
unknownDepthRatio,
dataSources,
...(departureTime ? { departureTime } : {}),
...(durationMinutes !== undefined ? { durationMinutes } : {}),
...(routingMode ? { routingMode } : {})
};
}
function normalizeLineCoordinates(value: unknown): [number, number][] {
if (!Array.isArray(value) || value.length < 2 || value.length > MAX_ROUTE_POINTS) {
throw new OfflineVoyageStorageError("Die Offline-Route enthält keine gültige Liniengeometrie.");
}
return value.map((item) => {
if (!Array.isArray(item) || item.length < 2) {
throw new OfflineVoyageStorageError("Die Offline-Route enthält ungültige Koordinaten.");
}
const lon = finiteNumber(item[0], -180, 180);
const lat = finiteNumber(item[1], -90, 90);
return [lon, lat];
});
}
function normalizeCoordinateList(value: unknown): Coordinate[] {
if (!Array.isArray(value) || value.length > 1_000) {
throw new OfflineVoyageStorageError("Die Wegpunktliste ist ungültig.");
}
return value.map(normalizeCoordinate);
}
function normalizeCoordinate(value: unknown): Coordinate {
if (!isRecord(value)) {
throw new OfflineVoyageStorageError("Eine Plankoordinate ist ungültig.");
}
return {
lat: finiteNumber(value.lat, -90, 90),
lon: finiteNumber(value.lon, -180, 180)
};
}
function normalizeVesselProfile(value: unknown): VesselProfile {
if (!isRecord(value)) {
throw new OfflineVoyageStorageError("Das Bootsprofil ist ungültig.");
}
const profile: VesselProfile = {
draughtM: finiteNumber(value.draughtM, 0, 100),
safetyReserveM: finiteNumber(value.safetyReserveM, 0, 100)
};
if (value.airDraftM !== undefined) {
profile.airDraftM = finiteNumber(value.airDraftM, 0, 200);
}
if (value.beamM !== undefined) {
profile.beamM = finiteNumber(value.beamM, 0, 200);
}
if (value.cruiseSpeedKn !== undefined) {
profile.cruiseSpeedKn = finiteNumber(value.cruiseSpeedKn, 0.1, 200);
}
return profile;
}
function normalizeWarnings(value: unknown): RouteWarning[] {
if (!Array.isArray(value) || value.length > 500) {
throw new OfflineVoyageStorageError("Die Routenwarnungen sind ungültig.");
}
return value.map((warning) => {
if (!isRecord(warning)) {
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
}
const code = limitedString(warning.code, 100);
const message = limitedString(warning.message, 1_000);
const severity = warning.severity;
if (!code || !message || (severity !== "info" && severity !== "caution" && severity !== "critical")) {
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
}
return {
code,
message,
severity,
...(warning.coordinate === undefined ? {} : { coordinate: normalizeCoordinate(warning.coordinate) })
};
});
}
function normalizeStrings(value: unknown, maxItems: number, maxLength: number): string[] {
if (!Array.isArray(value) || value.length > maxItems) {
throw new OfflineVoyageStorageError("Die Quellenangaben der Route sind ungültig.");
}
return value.map((item) => limitedString(item, maxLength)).filter((item) => item.length > 0);
}
function finiteNumber(value: unknown, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
throw new OfflineVoyageStorageError("Die Offline-Route enthält einen ungültigen Zahlenwert.");
}
return value;
}
function limitedString(value: unknown, maxLength: number): string {
return typeof value === "string" ? value.replace(/[\u0000-\u001F\u007F]/g, " ").trim().slice(0, maxLength) : "";
}
function normalizeIsoDate(value: unknown): string {
const date = value instanceof Date ? value : typeof value === "string" ? new Date(value) : new Date(Number.NaN);
if (!Number.isFinite(date.getTime())) {
throw new OfflineVoyageStorageError("Der Zeitstempel der Offline-Route ist ungültig.");
}
return date.toISOString();
}
function createId(savedAt: string): string {
const randomId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: Math.random().toString(36).slice(2, 14);
return `voyage-${savedAt.replace(/\D/g, "").slice(0, 14)}-${randomId}`;
}
function browserStorage(): Storage {
if (typeof window === "undefined" || !window.localStorage) {
throw new OfflineVoyageStorageError("Der Offline-Speicher wird von diesem Browser nicht unterstützt.");
}
return window.localStorage;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+176
View File
@@ -0,0 +1,176 @@
import type { Coordinate, GeoJsonLineString, RouteResult } from "@watermaps/shared";
const EARTH_RADIUS_M = 6_371_008.8;
const MIN_SEGMENT_ANGLE = 1e-12;
export type RouteDeviationOptions = {
thresholdM?: number;
accuracyM?: number | null;
maxAccuracyM?: number;
};
export type RouteDeviationResult = {
distanceM: number;
conservativeDistanceM: number;
thresholdM: number;
accuracyM: number | null;
reliable: boolean;
isOffRoute: boolean;
};
/** Returns the shortest geodesic distance from a point to the route segments. */
export function distanceToRouteM(
position: Coordinate,
route: RouteResult | GeoJsonLineString | readonly [number, number][]
): number | null {
if (!isCoordinate(position)) {
return null;
}
const coordinates = routeCoordinates(route);
if (coordinates.length === 0 || coordinates.some((coordinate) => !isGeoJsonCoordinate(coordinate))) {
return null;
}
if (coordinates.length === 1) {
return angularDistance(position, toCoordinate(coordinates[0]!)) * EARTH_RADIUS_M;
}
let closestM = Number.POSITIVE_INFINITY;
for (let index = 1; index < coordinates.length; index += 1) {
const start = toCoordinate(coordinates[index - 1]!);
const end = toCoordinate(coordinates[index]!);
closestM = Math.min(closestM, distanceToGreatCircleSegmentM(position, start, end));
}
return Number.isFinite(closestM) ? closestM : null;
}
/**
* Applies GPS accuracy conservatively: an alarm is emitted only when even the
* nearest edge of the reported accuracy circle is outside the corridor.
*/
export function evaluateRouteDeviation(
position: Coordinate,
route: RouteResult | GeoJsonLineString | readonly [number, number][],
options: RouteDeviationOptions = {}
): RouteDeviationResult | null {
const thresholdM = finiteRange(options.thresholdM ?? 100, 10, 10_000);
const distanceM = distanceToRouteM(position, route);
if (distanceM === null) {
return null;
}
const accuracyM = typeof options.accuracyM === "number" && Number.isFinite(options.accuracyM) && options.accuracyM >= 0
? options.accuracyM
: null;
const maxAccuracyM = finiteRange(options.maxAccuracyM ?? Math.max(100, thresholdM * 2), 10, 20_000);
const reliable = accuracyM === null || accuracyM <= maxAccuracyM;
const conservativeDistanceM = Math.max(0, distanceM - (accuracyM ?? 0));
return {
distanceM,
conservativeDistanceM,
thresholdM,
accuracyM,
reliable,
isOffRoute: reliable && conservativeDistanceM > thresholdM
};
}
function distanceToGreatCircleSegmentM(point: Coordinate, start: Coordinate, end: Coordinate): number {
const segmentAngle = angularDistance(start, end);
if (segmentAngle < MIN_SEGMENT_ANGLE) {
return angularDistance(point, start) * EARTH_RADIUS_M;
}
const pointAngle = angularDistance(start, point);
if (pointAngle < MIN_SEGMENT_ANGLE) {
return 0;
}
const segmentBearing = initialBearingRad(start, end);
const pointBearing = initialBearingRad(start, point);
const bearingDelta = pointBearing - segmentBearing;
const crossTrackAngle = Math.asin(clamp(Math.sin(pointAngle) * Math.sin(bearingDelta), -1, 1));
const alongTrackAngle = Math.atan2(
Math.sin(pointAngle) * Math.cos(bearingDelta),
Math.cos(pointAngle)
);
if (alongTrackAngle <= 0) {
return pointAngle * EARTH_RADIUS_M;
}
if (alongTrackAngle >= segmentAngle) {
return angularDistance(point, end) * EARTH_RADIUS_M;
}
return Math.abs(crossTrackAngle) * EARTH_RADIUS_M;
}
function angularDistance(a: Coordinate, b: Coordinate): number {
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const deltaLat = lat2 - lat1;
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
const haversine = Math.sin(deltaLat / 2) ** 2
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 2 * Math.asin(Math.sqrt(clamp(haversine, 0, 1)));
}
function initialBearingRad(a: Coordinate, b: Coordinate): number {
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
return Math.atan2(
Math.sin(deltaLon) * Math.cos(lat2),
Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon)
);
}
function routeCoordinates(route: RouteResult | GeoJsonLineString | readonly [number, number][]): readonly [number, number][] {
if (Array.isArray(route)) {
return route as readonly [number, number][];
}
if ("geometry" in route) {
return route.geometry.coordinates;
}
return (route as GeoJsonLineString).coordinates;
}
function isGeoJsonCoordinate(value: unknown): value is [number, number] {
return Array.isArray(value)
&& value.length >= 2
&& typeof value[0] === "number"
&& Number.isFinite(value[0])
&& value[0] >= -180
&& value[0] <= 180
&& typeof value[1] === "number"
&& Number.isFinite(value[1])
&& value[1] >= -90
&& value[1] <= 90;
}
function isCoordinate(value: Coordinate): boolean {
return Number.isFinite(value.lat)
&& value.lat >= -90
&& value.lat <= 90
&& Number.isFinite(value.lon)
&& value.lon >= -180
&& value.lon <= 180;
}
function toCoordinate(value: [number, number]): Coordinate {
return { lon: value[0], lat: value[1] };
}
function toRadians(value: number): number {
return value * Math.PI / 180;
}
function normalizeRadians(value: number): number {
return ((value + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI;
}
function finiteRange(value: number, min: number, max: number): number {
return Number.isFinite(value) ? clamp(value, min, max) : min;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+10
View File
@@ -0,0 +1,10 @@
import "./styles/app.css";
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+322
View File
@@ -0,0 +1,322 @@
import {
orderWaypointsAlongRoute,
type Coordinate,
type RouteResult,
type VoyageHarbour
} from "@watermaps/shared";
import type { RouteBridgeAssessment } from "./routeWeatherReport";
import type { RouteLock } from "./voyageHarbours";
export type RouteEventKind = "harbour" | "lock" | "bridge";
export type RouteEventCorridors = Record<RouteEventKind, number>;
export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly<RouteEventCorridors> = Object.freeze({
harbour: 1.5,
lock: 0.25,
bridge: 0.08
});
export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed";
export type RouteEventEtaReferenceSource = "current-time" | "route-departure";
/**
* ETA assumptions are deliberately supplied by the caller. This prevents a
* stale GPS speed or a planned cruise speed from being presented without its
* provenance.
*/
export type RouteEventEtaBasis = {
speedKn: number;
speedSource: RouteEventEtaSpeedSource;
referenceTime: string | number | Date;
referenceSource: RouteEventEtaReferenceSource;
};
export type RouteEventEta = {
estimatedAt: string;
minutesFromProgress: number;
speedKn: number;
speedSource: RouteEventEtaSpeedSource;
referenceTime: string;
referenceSource: RouteEventEtaReferenceSource;
};
type RouteEventBase = {
kind: RouteEventKind;
id: string;
name: string;
coordinate: Coordinate;
/** Position of the projected feature along the routed geometry. */
routeDistanceNm: number;
/** Shortest lateral distance between the feature and the route. */
distanceFromRouteNm: number;
remainingNm: number;
eta: RouteEventEta | null;
};
export type HarbourRouteEvent = RouteEventBase & {
kind: "harbour";
feature: VoyageHarbour;
};
export type LockRouteEvent = RouteEventBase & {
kind: "lock";
feature: RouteLock;
};
export type BridgeRouteEvent = RouteEventBase & {
kind: "bridge";
feature: RouteBridgeAssessment;
};
export type UpcomingRouteEvent =
| HarbourRouteEvent
| LockRouteEvent
| BridgeRouteEvent;
export type NextRouteEventsByKind = {
harbour: HarbourRouteEvent | null;
lock: LockRouteEvent | null;
bridge: BridgeRouteEvent | null;
};
export type UpcomingRouteEventsInput = {
route: Pick<RouteResult, "geometry" | "distanceNm">;
harbours?: readonly VoyageHarbour[];
locks?: readonly RouteLock[];
bridges?: readonly RouteBridgeAssessment[];
/** Progress along the route. Invalid or negative values resolve to zero. */
progressNm?: number | null;
corridorsNm?: Partial<RouteEventCorridors>;
etaBasis?: RouteEventEtaBasis | null;
};
type RouteEventCandidate =
| {
projectionId: string;
kind: "harbour";
id: string;
name: string;
coordinate: Coordinate;
feature: VoyageHarbour;
}
| {
projectionId: string;
kind: "lock";
id: string;
name: string;
coordinate: Coordinate;
feature: RouteLock;
}
| {
projectionId: string;
kind: "bridge";
id: string;
name: string;
coordinate: Coordinate;
feature: RouteBridgeAssessment;
};
/**
* Projects all supplied facilities onto the route, applies a corridor per
* facility type and returns only the current or upcoming facilities in route
* order.
*
* Every feature is projected again. In particular,
* RouteBridgeAssessment.distanceNm is intentionally ignored because it is the
* bridge's lateral distance to the route, not its distance along the route.
*/
export function upcomingRouteEvents(
input: UpcomingRouteEventsInput
): UpcomingRouteEvent[] {
const progressNm = normalizeProgress(input.progressNm);
const corridors = resolveCorridors(input.corridorsNm);
const candidates = routeEventCandidates(input);
if (candidates.length === 0) {
return [];
}
const candidatesByProjectionId = new Map(
candidates.map((candidate) => [candidate.projectionId, candidate])
);
const projected = orderWaypointsAlongRoute(
candidates.map((candidate) => ({
id: candidate.projectionId,
name: candidate.name,
coordinate: candidate.coordinate
})),
input.route
);
return projected.flatMap<UpcomingRouteEvent>((projection) => {
const candidate = candidatesByProjectionId.get(projection.id);
if (
!candidate ||
projection.distanceFromRouteNm > corridors[candidate.kind] ||
projection.routeDistanceNm < progressNm
) {
return [];
}
const remainingNm = Math.max(0, projection.routeDistanceNm - progressNm);
const common = {
kind: candidate.kind,
id: candidate.id,
name: candidate.name,
coordinate: candidate.coordinate,
routeDistanceNm: projection.routeDistanceNm,
distanceFromRouteNm: projection.distanceFromRouteNm,
remainingNm,
eta: estimateRouteEventEta(remainingNm, input.etaBasis)
};
switch (candidate.kind) {
case "harbour":
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
case "lock":
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
case "bridge":
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
}
});
}
export function nextRouteEventsByKind(
events: readonly UpcomingRouteEvent[]
): NextRouteEventsByKind {
const next: NextRouteEventsByKind = {
harbour: null,
lock: null,
bridge: null
};
for (const event of events) {
switch (event.kind) {
case "harbour":
next.harbour ??= event;
break;
case "lock":
next.lock ??= event;
break;
case "bridge":
next.bridge ??= event;
break;
}
}
return next;
}
function routeEventCandidates(input: UpcomingRouteEventsInput): RouteEventCandidate[] {
const candidates: RouteEventCandidate[] = [];
input.harbours?.forEach((feature, index) => {
candidates.push({
projectionId: projectionId("harbour", index, feature.id),
kind: "harbour",
id: feature.id,
name: feature.name,
coordinate: feature.coordinate,
feature
});
});
input.locks?.forEach((feature, index) => {
candidates.push({
projectionId: projectionId("lock", index, feature.id),
kind: "lock",
id: feature.id,
name: feature.name,
coordinate: feature.coordinate,
feature
});
});
input.bridges?.forEach((feature, index) => {
candidates.push({
projectionId: projectionId("bridge", index, feature.id),
kind: "bridge",
id: feature.id,
name: feature.name ?? feature.label ?? "Brücke",
coordinate: feature.coordinate,
feature
});
});
return candidates;
}
function projectionId(kind: RouteEventKind, index: number, featureId: string) {
return `${kind}:${index}:${featureId}`;
}
function resolveCorridors(
overrides: Partial<RouteEventCorridors> | undefined
): RouteEventCorridors {
return {
harbour: nonNegativeOrDefault(
overrides?.harbour,
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour
),
lock: nonNegativeOrDefault(
overrides?.lock,
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.lock
),
bridge: nonNegativeOrDefault(
overrides?.bridge,
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.bridge
)
};
}
function normalizeProgress(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, value)
: 0;
}
function nonNegativeOrDefault(value: number | undefined, fallback: number) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: fallback;
}
function estimateRouteEventEta(
remainingNm: number,
basis: RouteEventEtaBasis | null | undefined
): RouteEventEta | null {
if (
!basis ||
!Number.isFinite(basis.speedKn) ||
basis.speedKn <= 0
) {
return null;
}
const referenceTimestamp = timestampValue(basis.referenceTime);
if (referenceTimestamp === null) {
return null;
}
const minutesFromProgress = (remainingNm / basis.speedKn) * 60;
const estimatedTimestamp =
referenceTimestamp + minutesFromProgress * 60_000;
if (!Number.isFinite(estimatedTimestamp)) {
return null;
}
return {
estimatedAt: new Date(estimatedTimestamp).toISOString(),
minutesFromProgress,
speedKn: basis.speedKn,
speedSource: basis.speedSource,
referenceTime: new Date(referenceTimestamp).toISOString(),
referenceSource: basis.referenceSource
};
}
function timestampValue(value: string | number | Date): number | null {
const timestamp =
value instanceof Date
? value.getTime()
: typeof value === "number"
? value
: Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
+792
View File
@@ -0,0 +1,792 @@
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import {
haversineDistanceNm,
initialBearingDeg,
type Coordinate,
type MarineForecast,
type RouteResult,
type VesselProfile
} from "@watermaps/shared";
export type RouteWeatherSample = {
label: "Start" | "Mitte" | "Ziel";
coordinate: Coordinate;
forecast: MarineForecast;
plannedTime?: string;
routeBearingDeg?: number | null;
currentAlongRouteKn?: number | null;
};
export type RouteWeatherReport = {
samples: RouteWeatherSample[];
maxWaveHeightM: number | null;
maxWindSpeedKn: number | null;
maxWavePeriodS: number | null;
strongestWindDirectionDeg: number | null;
highestWaveDirectionDeg: number | null;
severity: "ok" | "caution" | "critical";
summary: string;
source: string;
updatedAt: string;
unavailableSamples: number;
bridgeReport: RouteBridgeReport | null;
departureTime: string;
adjustedEta: string | null;
currentAdjustmentMinutes: number | null;
averageAlongRouteCurrentKn: number | null;
};
type FetchForecast = (coordinate: Coordinate, at?: string) => Promise<MarineForecast>;
type FetchFeatures = (params: { bbox: [number, number, number, number]; layers: string[] }) => Promise<FeatureCollection>;
type LonLat = [number, number];
type ProjectedPoint = { x: number; y: number };
export type RouteBridgeStatus = "passable" | "tight" | "too_low" | "unknown";
export type RouteBridgeAssessment = {
id: string;
name: string | null;
label: string;
coordinate: Coordinate;
distanceNm: number;
clearanceM: number | null;
clearanceLabel: string | null;
requiredAirDraftM: number | null;
marginM: number | null;
status: RouteBridgeStatus;
source: string;
};
export type RouteBridgeReport = {
bridges: RouteBridgeAssessment[];
requiredAirDraftM: number | null;
checkedCount: number;
unknownCount: number;
tooLowCount: number;
tightCount: number;
minClearanceM: number | null;
severity: "ok" | "caution" | "critical";
summary: string;
source: string;
updatedAt: string;
};
const SAMPLE_TARGETS: Array<{ label: RouteWeatherSample["label"]; ratio: number }> = [
{ label: "Start", ratio: 0 },
{ label: "Mitte", ratio: 0.5 },
{ label: "Ziel", ratio: 1 }
];
const ROUTE_BRIDGE_BBOX_MARGIN_DEG = 0.02;
const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08;
const BRIDGE_TIGHT_MARGIN_M = 0.5;
export async function createRouteWeatherReport(
route: RouteResult,
fetchForecast: FetchForecast
): Promise<RouteWeatherReport>;
export async function createRouteWeatherReport(
route: RouteResult,
vesselProfile: VesselProfile,
fetchForecast: FetchForecast,
fetchFeatures?: FetchFeatures,
departureTime?: string
): Promise<RouteWeatherReport>;
export async function createRouteWeatherReport(
route: RouteResult,
vesselProfileOrFetchForecast: VesselProfile | FetchForecast,
maybeFetchForecast?: FetchForecast,
fetchFeatures?: FetchFeatures,
departureTime?: string
): Promise<RouteWeatherReport> {
const vesselProfile: VesselProfile =
typeof vesselProfileOrFetchForecast === "function"
? { draughtM: 0, safetyReserveM: 0 }
: vesselProfileOrFetchForecast;
const fetchForecast =
typeof vesselProfileOrFetchForecast === "function" ? vesselProfileOrFetchForecast : maybeFetchForecast;
if (!fetchForecast) {
throw new Error("Wetterbericht nicht erreichbar");
}
const plannedDeparture = validIso(departureTime ?? route.departureTime) ?? new Date().toISOString();
const cruiseSpeedKn = normalizeSpeed(vesselProfile.cruiseSpeedKn);
const samplePoints = sampleRoute(route).map((sample) => ({
...sample,
plannedTime: new Date(
Date.parse(plannedDeparture) + (route.distanceNm * sample.ratio / cruiseSpeedKn) * 60 * 60 * 1000
).toISOString()
}));
const [results, bridgeReport] = await Promise.all([
Promise.allSettled(
samplePoints.map(async (sample) => ({
...sample,
forecast: await fetchForecast(sample.coordinate, sample.plannedTime)
}))
),
fetchFeatures
? createRouteBridgeReport(route, vesselProfile, fetchFeatures).catch(() => unavailableBridgeReport(vesselProfile))
: Promise.resolve(null)
]);
const samples = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : []));
if (samples.length === 0) {
throw new Error("Wetterbericht nicht erreichbar");
}
const samplesWithCurrent = samples.map((sample) => ({
...sample,
currentAlongRouteKn: alongRouteCurrentKn(sample.forecast, sample.routeBearingDeg)
}));
return summarizeRouteWeather(samplesWithCurrent, results.length - samples.length, bridgeReport, {
departureTime: plannedDeparture,
distanceNm: route.distanceNm,
cruiseSpeedKn
});
}
export async function createRouteBridgeReport(
route: RouteResult,
vesselProfile: Pick<VesselProfile, "airDraftM">,
fetchFeatures: FetchFeatures
): Promise<RouteBridgeReport> {
const routeLine = routeLonLatLine(route);
if (routeLine.length === 0) {
return summarizeBridgeReport([], normalizeMeters(vesselProfile.airDraftM));
}
const features = await fetchFeatures({
bbox: bboxForLine(routeLine, ROUTE_BRIDGE_BBOX_MARGIN_DEG),
layers: ["bridges"]
});
const requiredAirDraftM = normalizeMeters(vesselProfile.airDraftM);
const bridges = features.features
.map((feature) => bridgeAssessmentFromFeature(feature, routeLine, requiredAirDraftM))
.filter((bridge): bridge is RouteBridgeAssessment => Boolean(bridge))
.filter((bridge) => bridge.distanceNm <= ROUTE_BRIDGE_MAX_DISTANCE_NM);
const deduped = dedupeBridges(bridges);
return summarizeBridgeReport(
deduped.sort((left, right) => left.distanceNm - right.distanceNm),
requiredAirDraftM
);
}
export function summarizeRouteWeather(
samples: RouteWeatherSample[],
unavailableSamples = 0,
bridgeReport: RouteBridgeReport | null = null,
planning?: { departureTime: string; distanceNm: number; cruiseSpeedKn: number }
): RouteWeatherReport {
const maxWaveHeightM = maxValue(samples.map((sample) => sample.forecast.waveHeightM));
const maxWindSpeedKn = maxValue(samples.map((sample) => sample.forecast.windSpeed));
const maxWavePeriodS = maxValue(samples.map((sample) => sample.forecast.wavePeriodS));
const strongestWind = maxBy(samples, (sample) => sample.forecast.windSpeed);
const highestWave = maxBy(samples, (sample) => sample.forecast.waveHeightM);
const severity = weatherSeverity(maxWindSpeedKn, maxWaveHeightM);
const currentComponents = samples
.map((sample) => sample.currentAlongRouteKn)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
const averageAlongRouteCurrentKn =
currentComponents.length > 0
? currentComponents.reduce((sum, value) => sum + value, 0) / currentComponents.length
: null;
const currentTiming = currentAdjustedTiming(planning, averageAlongRouteCurrentKn);
return {
samples,
maxWaveHeightM,
maxWindSpeedKn,
maxWavePeriodS,
strongestWindDirectionDeg: strongestWind?.forecast.windDirectionDeg ?? null,
highestWaveDirectionDeg: highestWave?.forecast.waveDirectionDeg ?? null,
severity,
summary: weatherSummary(severity, maxWindSpeedKn, maxWaveHeightM),
source: unique(samples.map((sample) => sample.forecast.source)).join(", "),
updatedAt: latestIso(samples.map((sample) => sample.forecast.updatedAt)) ?? new Date().toISOString(),
unavailableSamples,
bridgeReport,
departureTime: planning?.departureTime ?? new Date().toISOString(),
adjustedEta: currentTiming.adjustedEta,
currentAdjustmentMinutes: currentTiming.adjustmentMinutes,
averageAlongRouteCurrentKn
};
}
function sampleRoute(route: RouteResult) {
const points = route.geometry.coordinates.map(([lon, lat]) => ({ lat, lon }));
const uniqueSamples = new Map<
string,
{
label: RouteWeatherSample["label"];
coordinate: Coordinate;
ratio: number;
routeBearingDeg: number | null;
}
>();
for (const target of SAMPLE_TARGETS) {
const coordinate = coordinateAtProgress(points, target.ratio);
const key = `${coordinate.lat.toFixed(3)}:${coordinate.lon.toFixed(3)}`;
uniqueSamples.set(key, {
label: target.label,
coordinate,
ratio: target.ratio,
routeBearingDeg: routeBearingAtProgress(points, target.ratio)
});
}
return [...uniqueSamples.values()];
}
function routeBearingAtProgress(points: Coordinate[], ratio: number): number | null {
if (points.length < 2) {
return null;
}
const before = coordinateAtProgress(points, Math.max(0, ratio - 0.01));
const after = coordinateAtProgress(points, Math.min(1, ratio + 0.01));
if (haversineDistanceNm(before, after) < 0.001) {
return null;
}
return initialBearingDeg(before, after);
}
function alongRouteCurrentKn(forecast: MarineForecast, routeBearingDeg?: number | null): number | null {
const speedKn = forecast.oceanCurrentSpeedKn;
const directionDeg = forecast.oceanCurrentDirectionDeg;
if (
typeof speedKn !== "number" ||
!Number.isFinite(speedKn) ||
typeof directionDeg !== "number" ||
!Number.isFinite(directionDeg) ||
typeof routeBearingDeg !== "number"
) {
return null;
}
const angleRad = (((directionDeg - routeBearingDeg + 540) % 360) - 180) * (Math.PI / 180);
return Math.round(speedKn * Math.cos(angleRad) * 100) / 100;
}
function currentAdjustedTiming(
planning: { departureTime: string; distanceNm: number; cruiseSpeedKn: number } | undefined,
averageCurrentKn: number | null
): { adjustedEta: string | null; adjustmentMinutes: number | null } {
if (!planning || averageCurrentKn === null) {
return { adjustedEta: null, adjustmentMinutes: null };
}
const effectiveSpeedKn = Math.max(0.5, planning.cruiseSpeedKn + averageCurrentKn);
const baseMinutes = (planning.distanceNm / planning.cruiseSpeedKn) * 60;
const adjustedMinutes = (planning.distanceNm / effectiveSpeedKn) * 60;
return {
adjustedEta: new Date(Date.parse(planning.departureTime) + adjustedMinutes * 60 * 1000).toISOString(),
adjustmentMinutes: Math.round(adjustedMinutes - baseMinutes)
};
}
function normalizeSpeed(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 6;
}
function validIso(value: string | undefined): string | null {
const timestamp = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
}
function coordinateAtProgress(points: Coordinate[], ratio: number): Coordinate {
if (points.length === 0) {
return { lat: 0, lon: 0 };
}
if (ratio <= 0 || points.length === 1) {
return points[0]!;
}
if (ratio >= 1) {
return points.at(-1)!;
}
const segmentLengths = points.slice(1).map((point, index) => haversineDistanceNm(points[index]!, point));
const totalDistanceNm = segmentLengths.reduce((sum, length) => sum + length, 0);
const targetDistanceNm = totalDistanceNm * ratio;
let traveledNm = 0;
for (let index = 0; index < segmentLengths.length; index += 1) {
const segmentLengthNm = segmentLengths[index]!;
if (traveledNm + segmentLengthNm >= targetDistanceNm) {
const start = points[index]!;
const end = points[index + 1]!;
const segmentRatio = segmentLengthNm === 0 ? 0 : (targetDistanceNm - traveledNm) / segmentLengthNm;
return {
lat: start.lat + (end.lat - start.lat) * segmentRatio,
lon: start.lon + (end.lon - start.lon) * segmentRatio
};
}
traveledNm += segmentLengthNm;
}
return points.at(-1)!;
}
function maxValue(values: Array<number | null | undefined>) {
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return valid.length > 0 ? Math.max(...valid) : null;
}
function minValue(values: Array<number | null | undefined>) {
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return valid.length > 0 ? Math.min(...valid) : null;
}
function maxBy<T>(values: T[], selector: (value: T) => number | null | undefined) {
return values.reduce<T | null>((best, value) => {
const candidate = selector(value);
if (candidate === null || candidate === undefined || !Number.isFinite(candidate)) {
return best;
}
const bestValue = best ? selector(best) : null;
return bestValue === null || bestValue === undefined || candidate > bestValue ? value : best;
}, null);
}
function weatherSeverity(windSpeedKn: number | null, waveHeightM: number | null) {
if (windSpeedKn === null && waveHeightM === null) {
return "caution";
}
if ((windSpeedKn !== null && windSpeedKn >= 27) || (waveHeightM !== null && waveHeightM >= 2)) {
return "critical";
}
if ((windSpeedKn !== null && windSpeedKn >= 16) || (waveHeightM !== null && waveHeightM >= 1)) {
return "caution";
}
return "ok";
}
function weatherSummary(
severity: RouteWeatherReport["severity"],
windSpeedKn: number | null,
waveHeightM: number | null
) {
if (windSpeedKn === null && waveHeightM === null) {
return "Keine belastbare Wetter- oder Wellenprognose für die gewählte Abfahrtszeit.";
}
const wind = windSpeedKn !== null ? `${Math.round(windSpeedKn)} kn Wind` : "Wind unbekannt";
const wave = waveHeightM !== null ? `${waveHeightM.toFixed(1)} m Welle` : "Welle unbekannt";
if (severity === "critical") {
return `Kritische Bedingungen: bis ${wind}, ${wave}.`;
}
if (severity === "caution") {
return `Aufmerksam fahren: bis ${wind}, ${wave}.`;
}
return `Ruhige Bedingungen: bis ${wind}, ${wave}.`;
}
function latestIso(values: string[]) {
const timestamps = values.map((value) => Date.parse(value)).filter((value) => Number.isFinite(value));
return timestamps.length > 0 ? new Date(Math.max(...timestamps)).toISOString() : null;
}
function unique(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
function routeLonLatLine(route: RouteResult): LonLat[] {
return route.geometry.coordinates.map(([lon, lat]) => [lon, lat]);
}
function bboxForLine(line: LonLat[], marginDeg: number): [number, number, number, number] {
const lons = line.map(([lon]) => lon);
const lats = line.map(([, lat]) => lat);
return [
Math.min(...lons) - marginDeg,
Math.min(...lats) - marginDeg,
Math.max(...lons) + marginDeg,
Math.max(...lats) + marginDeg
];
}
function bridgeAssessmentFromFeature(
feature: Feature,
routeLine: LonLat[],
requiredAirDraftM: number | null
): RouteBridgeAssessment | null {
if (!feature.geometry) {
return null;
}
const bridgeLines = geometryLineStrings(feature.geometry);
if (bridgeLines.length === 0) {
return null;
}
const distanceNm = minDistanceBetweenLinesNm(bridgeLines, routeLine);
if (!Number.isFinite(distanceNm)) {
return null;
}
const coordinate = centroid(bridgeLines.flat());
if (!coordinate) {
return null;
}
const properties = (feature.properties ?? {}) as Record<string, unknown>;
const clearanceM = normalizeMeters(properties.clearance_m);
const name = stringProperty(properties.name);
const clearanceLabel = stringProperty(properties.clearance_label) ?? (clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null);
const label = stringProperty(properties.label) ?? name ?? clearanceLabel ?? "Brücke";
const status = bridgeStatus(clearanceM, requiredAirDraftM);
const marginM = clearanceM !== null && requiredAirDraftM !== null ? clearanceM - requiredAirDraftM : null;
const idCandidate = feature.id ?? properties.source_id ?? `${coordinate.lat.toFixed(5)}:${coordinate.lon.toFixed(5)}`;
return {
id: String(idCandidate),
name,
label,
coordinate,
distanceNm,
clearanceM,
clearanceLabel,
requiredAirDraftM,
marginM,
status,
source: stringProperty(properties.source) ?? "OSM/Geofabrik"
};
}
function summarizeBridgeReport(
bridges: RouteBridgeAssessment[],
requiredAirDraftM: number | null
): RouteBridgeReport {
const knownClearanceBridges = bridges.filter((bridge) => bridge.clearanceM !== null);
const unknownCount = bridges.length - knownClearanceBridges.length;
const checkedCount = requiredAirDraftM === null ? 0 : knownClearanceBridges.length;
const tooLowCount = bridges.filter((bridge) => bridge.status === "too_low").length;
const tightCount = bridges.filter((bridge) => bridge.status === "tight").length;
const minKnownClearanceM = minValue(knownClearanceBridges.map((bridge) => bridge.clearanceM));
const severity = bridgeSeverity(bridges);
return {
bridges,
requiredAirDraftM,
checkedCount,
unknownCount,
tooLowCount,
tightCount,
minClearanceM: minKnownClearanceM,
severity,
summary: bridgeSummary({
bridgeCount: bridges.length,
unknownCount,
tooLowCount,
tightCount,
minClearanceM: minKnownClearanceM,
requiredAirDraftM
}),
source: unique(bridges.map((bridge) => bridge.source)).join(", ") || "OSM/Geofabrik",
updatedAt: new Date().toISOString()
};
}
function unavailableBridgeReport(vesselProfile: Pick<VesselProfile, "airDraftM">): RouteBridgeReport {
return {
bridges: [],
requiredAirDraftM: normalizeMeters(vesselProfile.airDraftM),
checkedCount: 0,
unknownCount: 0,
tooLowCount: 0,
tightCount: 0,
minClearanceM: null,
severity: "caution",
summary: "Brückenprüfung nicht erreichbar. Durchfahrtshöhen vor Abfahrt extern prüfen.",
source: "OSM/Geofabrik",
updatedAt: new Date().toISOString()
};
}
function bridgeStatus(clearanceM: number | null, requiredAirDraftM: number | null): RouteBridgeStatus {
if (clearanceM === null || requiredAirDraftM === null) {
return "unknown";
}
const marginM = clearanceM - requiredAirDraftM;
if (marginM < 0) {
return "too_low";
}
if (marginM < BRIDGE_TIGHT_MARGIN_M) {
return "tight";
}
return "passable";
}
function bridgeSeverity(bridges: RouteBridgeAssessment[]): RouteBridgeReport["severity"] {
if (bridges.some((bridge) => bridge.status === "too_low")) {
return "critical";
}
if (bridges.some((bridge) => bridge.status === "tight" || bridge.status === "unknown")) {
return "caution";
}
return "ok";
}
function bridgeSummary({
bridgeCount,
unknownCount,
tooLowCount,
tightCount,
minClearanceM,
requiredAirDraftM
}: {
bridgeCount: number;
unknownCount: number;
tooLowCount: number;
tightCount: number;
minClearanceM: number | null;
requiredAirDraftM: number | null;
}) {
if (bridgeCount === 0) {
return "Keine Brücken im Routenkorridor erkannt.";
}
if (requiredAirDraftM === null) {
return `${bridgeCount} Brücken erkannt. Bootshöhe fehlt, Durchfahrt nicht bewertbar.`;
}
if (tooLowCount > 0) {
return `Nicht passierbar: ${tooLowCount} Brücke(n) niedriger als ${formatMeters(requiredAirDraftM)} Bootshöhe.`;
}
if (tightCount > 0) {
return `Knapp: ${tightCount} Brücke(n) mit weniger als ${formatMeters(BRIDGE_TIGHT_MARGIN_M)} Reserve.`;
}
if (unknownCount > 0) {
return `${unknownCount} Brücke(n) ohne Höhenangabe. Durchfahrt vor Abfahrt prüfen.`;
}
return `Brücken passierbar: ${bridgeCount} Brücke(n), min. ${formatMeters(minClearanceM ?? requiredAirDraftM)} Durchfahrt.`;
}
function dedupeBridges(bridges: RouteBridgeAssessment[]) {
const byId = new Map<string, RouteBridgeAssessment>();
for (const bridge of bridges) {
const existing = byId.get(bridge.id);
if (!existing || bridge.distanceNm < existing.distanceNm) {
byId.set(bridge.id, bridge);
}
}
return [...byId.values()];
}
function geometryLineStrings(geometry: Geometry): LonLat[][] {
switch (geometry.type) {
case "Point":
return [singlePositionLine(geometry.coordinates)].filter((line) => line.length > 0);
case "MultiPoint":
return geometry.coordinates.map(singlePositionLine).filter((line) => line.length > 0);
case "LineString":
return [positionsToLine(geometry.coordinates)].filter((line) => line.length > 0);
case "MultiLineString":
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
case "Polygon":
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
case "MultiPolygon":
return geometry.coordinates.flat().map(positionsToLine).filter((line) => line.length > 0);
case "GeometryCollection":
return geometry.geometries.flatMap(geometryLineStrings);
}
}
function singlePositionLine(position: Position): LonLat[] {
const coordinate = positionToLonLat(position);
return coordinate ? [coordinate] : [];
}
function positionsToLine(positions: Position[]): LonLat[] {
return positions.map(positionToLonLat).filter((coordinate): coordinate is LonLat => Boolean(coordinate));
}
function positionToLonLat(position: Position): LonLat | null {
const [lon, lat] = position;
return typeof lon === "number" && typeof lat === "number" && Number.isFinite(lon) && Number.isFinite(lat)
? [lon, lat]
: null;
}
function minDistanceBetweenLinesNm(featureLines: LonLat[][], routeLine: LonLat[]) {
if (routeLine.length === 0) {
return Number.POSITIVE_INFINITY;
}
const origin = routeLine[0]!;
let minDistanceNm = Number.POSITIVE_INFINITY;
for (const featureLine of featureLines) {
if (featureLine.length === 0) {
continue;
}
if (featureLine.length === 1) {
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(featureLine[0]!, routeLine, origin));
continue;
}
if (routeLine.length === 1) {
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(routeLine[0]!, featureLine, origin));
continue;
}
for (let featureIndex = 1; featureIndex < featureLine.length; featureIndex += 1) {
const featureStart = featureLine[featureIndex - 1]!;
const featureEnd = featureLine[featureIndex]!;
for (let routeIndex = 1; routeIndex < routeLine.length; routeIndex += 1) {
minDistanceNm = Math.min(
minDistanceNm,
segmentDistanceNm(featureStart, featureEnd, routeLine[routeIndex - 1]!, routeLine[routeIndex]!, origin)
);
}
}
}
return minDistanceNm;
}
function minPointToLineDistanceNm(point: LonLat, line: LonLat[], origin: LonLat) {
if (line.length === 0) {
return Number.POSITIVE_INFINITY;
}
if (line.length === 1) {
return distanceBetweenProjected(project(point, origin), project(line[0]!, origin));
}
let minDistanceNm = Number.POSITIVE_INFINITY;
for (let index = 1; index < line.length; index += 1) {
minDistanceNm = Math.min(
minDistanceNm,
pointToSegmentDistance(project(point, origin), project(line[index - 1]!, origin), project(line[index]!, origin))
);
}
return minDistanceNm;
}
function segmentDistanceNm(startA: LonLat, endA: LonLat, startB: LonLat, endB: LonLat, origin: LonLat) {
const a = project(startA, origin);
const b = project(endA, origin);
const c = project(startB, origin);
const d = project(endB, origin);
if (segmentsIntersect(a, b, c, d)) {
return 0;
}
return Math.min(
pointToSegmentDistance(a, c, d),
pointToSegmentDistance(b, c, d),
pointToSegmentDistance(c, a, b),
pointToSegmentDistance(d, a, b)
);
}
function project([lon, lat]: LonLat, [originLon, originLat]: LonLat): ProjectedPoint {
const averageLatRad = ((lat + originLat) / 2) * (Math.PI / 180);
return {
x: (lon - originLon) * 60 * Math.cos(averageLatRad),
y: (lat - originLat) * 60
};
}
function segmentsIntersect(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint, d: ProjectedPoint) {
const o1 = orientation(a, b, c);
const o2 = orientation(a, b, d);
const o3 = orientation(c, d, a);
const o4 = orientation(c, d, b);
if (o1 !== o2 && o3 !== o4) {
return true;
}
return (
(o1 === 0 && onSegment(a, c, b)) ||
(o2 === 0 && onSegment(a, d, b)) ||
(o3 === 0 && onSegment(c, a, d)) ||
(o4 === 0 && onSegment(c, b, d))
);
}
function orientation(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
if (Math.abs(value) < 1e-9) {
return 0;
}
return value > 0 ? 1 : 2;
}
function onSegment(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
return (
b.x <= Math.max(a.x, c.x) + 1e-9 &&
b.x >= Math.min(a.x, c.x) - 1e-9 &&
b.y <= Math.max(a.y, c.y) + 1e-9 &&
b.y >= Math.min(a.y, c.y) - 1e-9
);
}
function pointToSegmentDistance(point: ProjectedPoint, start: ProjectedPoint, end: ProjectedPoint) {
const dx = end.x - start.x;
const dy = end.y - start.y;
if (dx === 0 && dy === 0) {
return distanceBetweenProjected(point, start);
}
const ratio = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
return distanceBetweenProjected(point, {
x: start.x + ratio * dx,
y: start.y + ratio * dy
});
}
function distanceBetweenProjected(left: ProjectedPoint, right: ProjectedPoint) {
return Math.hypot(left.x - right.x, left.y - right.y);
}
function centroid(coordinates: LonLat[]): Coordinate | null {
if (coordinates.length === 0) {
return null;
}
const sum = coordinates.reduce(
(total, [lon, lat]) => ({
lon: total.lon + lon,
lat: total.lat + lat
}),
{ lon: 0, lat: 0 }
);
return {
lon: sum.lon / coordinates.length,
lat: sum.lat / coordinates.length
};
}
function stringProperty(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function normalizeMeters(value: unknown) {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value !== "string") {
return null;
}
const match = value.replace(",", ".").match(/\d+(?:\.\d+)?/);
if (!match) {
return null;
}
const parsed = Number(match[0]);
return Number.isFinite(parsed) ? parsed : null;
}
function formatMeters(value: number) {
return Number.isInteger(value) ? `${value} m` : `${value.toFixed(1)} m`;
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+218
View File
@@ -0,0 +1,218 @@
import {
harbourAmenitiesFromProperties,
orderWaypointsAlongRoute,
type RouteResult,
type VoyageHarbour
} from "@watermaps/shared";
import type { FeatureCollection, GeoJsonProperties, Geometry } from "geojson";
export type RouteLock = {
id: string;
name: string;
coordinate: { lat: number; lon: number };
routeDistanceNm: number;
distanceFromRouteNm: number;
openingHours: string | null;
phone: string | null;
email?: string | null;
vhf: string | null;
website: string | null;
operator?: string | null;
address?: string | null;
source?: string | null;
sourceUrl?: string | null;
updatedAt?: string | null;
};
/** Converts the normalized harbour layer returned by /api/features for planning. */
export function voyageHarboursFromFeatures(
collection: FeatureCollection<Geometry, GeoJsonProperties>
): VoyageHarbour[] {
const harbours = new Map<string, VoyageHarbour>();
for (const feature of collection.features) {
if (feature.geometry?.type !== "Point" || feature.properties?.layer !== "harbours") {
continue;
}
const [lon, lat] = feature.geometry.coordinates;
if (typeof lon !== "number" || typeof lat !== "number" || !Number.isFinite(lon) || !Number.isFinite(lat)) {
continue;
}
const properties = feature.properties;
const id = String(feature.id ?? properties.sourceId ?? properties.source_id ?? `${lat}:${lon}`);
const seamarkType = stringValue(properties["seamark:type"]);
const kind =
stringValue(properties.leisure) === "marina" || seamarkType === "marina"
? "marina"
: "harbour";
harbours.set(id, {
id,
name: displayString(properties.name) ?? (kind === "marina" ? "Marina" : "Hafen"),
coordinate: { lat, lon },
kind,
amenities: harbourAmenitiesFromProperties(properties),
phone: firstString(properties, ["contact:phone", "phone", "telephone", "contact_phone"]),
website: firstString(properties, ["contact:website", "website", "url", "contact_website"]),
email: firstString(properties, ["contact:email", "email", "contact_email"]),
vhf: firstString(properties, [
"vhf",
"vhf_channel",
"radio_channel",
"contact:vhf",
"seamark:harbour:radio_channel"
]),
openingHours: firstString(properties, ["openingHours", "opening_hours", "service_times"]),
operator: firstString(properties, ["operator", "operator:name", "owner"]),
address: featureAddress(properties),
source: firstString(properties, ["source", "data_source", "attribution"]),
sourceUrl: firstString(properties, ["sourceUrl", "source_url", "enrichmentSourceUrl"]),
updatedAt: firstString(properties, [
"updatedAt",
"updated_at",
"fetchedAt",
"fetched_at",
"timestamp",
"@timestamp"
])
});
}
return [...harbours.values()];
}
/** Returns lock points that are close enough to plausibly lie on the routed waterway. */
export function routeLocksFromFeatures(
collection: FeatureCollection<Geometry, GeoJsonProperties>,
route: Pick<RouteResult, "geometry" | "distanceNm">,
maxDistanceFromRouteNm = 0.25
): RouteLock[] {
const details = new Map<
string,
Omit<RouteLock, "routeDistanceNm" | "distanceFromRouteNm">
>();
for (const feature of collection.features) {
if (feature.geometry?.type !== "Point" || feature.properties?.layer !== "locks") {
continue;
}
const [lon, lat] = feature.geometry.coordinates;
if (typeof lon !== "number" || typeof lat !== "number" || !Number.isFinite(lon) || !Number.isFinite(lat)) {
continue;
}
const properties = feature.properties;
const id = String(feature.id ?? properties.sourceId ?? properties.source_id ?? `${lat}:${lon}`);
details.set(id, {
id,
name: displayString(properties.name) ?? "Schleuse",
coordinate: { lat, lon },
openingHours: displayString(properties.openingHours) ?? displayString(properties.opening_hours),
phone: firstString(properties, ["contact:phone", "phone", "telephone", "contact_phone"]),
email: firstString(properties, ["contact:email", "email", "contact_email"]),
vhf: firstString(properties, [
"vhf",
"vhf_channel",
"radio_channel",
"contact:vhf",
"seamark:radio_station:channel"
]),
website: firstString(properties, ["contact:website", "website", "url", "contact_website"]),
operator: firstString(properties, ["operator", "operator:name", "owner"]),
address: featureAddress(properties),
source: firstString(properties, ["source", "data_source", "attribution"]),
sourceUrl: firstString(properties, ["sourceUrl", "source_url", "enrichmentSourceUrl"]),
updatedAt: firstString(properties, [
"updatedAt",
"updated_at",
"fetchedAt",
"fetched_at",
"timestamp",
"@timestamp"
])
});
}
const ordered = orderWaypointsAlongRoute(
[...details.values()].map(({ id, name, coordinate }) => ({ id, name, coordinate })),
route
);
return ordered
.filter((lock) => lock.distanceFromRouteNm <= maxDistanceFromRouteNm)
.map((lock) => ({ ...details.get(lock.id)!, routeDistanceNm: lock.routeDistanceNm, distanceFromRouteNm: lock.distanceFromRouteNm }));
}
/** Returns a padded request box that covers every point of a routed line. */
export function routeFeatureBounds(
route: Pick<RouteResult, "geometry">,
paddingNm = 2
): [number, number, number, number] {
if (!Number.isFinite(paddingNm) || paddingNm < 0) {
throw new RangeError("paddingNm must be a non-negative finite number");
}
const coordinates = route.geometry.coordinates;
if (coordinates.length === 0) {
throw new RangeError("route.geometry must contain coordinates");
}
let minLon = Infinity;
let minLat = Infinity;
let maxLon = -Infinity;
let maxLat = -Infinity;
for (const [lon, lat] of coordinates) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
throw new RangeError("route.geometry must contain finite coordinates");
}
minLon = Math.min(minLon, lon);
minLat = Math.min(minLat, lat);
maxLon = Math.max(maxLon, lon);
maxLat = Math.max(maxLat, lat);
}
const latitudePadding = paddingNm / 60;
const highestAbsoluteLatitude = Math.min(89, Math.max(Math.abs(minLat), Math.abs(maxLat)));
const longitudePadding = paddingNm / (60 * Math.cos(highestAbsoluteLatitude * (Math.PI / 180)));
return [
Math.max(-180, minLon - longitudePadding),
Math.max(-90, minLat - latitudePadding),
Math.min(180, maxLon + longitudePadding),
Math.min(90, maxLat + latitudePadding)
];
}
function displayString(value: unknown) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed || null;
}
function stringValue(value: unknown) {
return displayString(value)?.toLowerCase() ?? "";
}
function firstString(properties: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const value = displayString(properties[key]);
if (value) {
return value;
}
}
return null;
}
function featureAddress(properties: Record<string, unknown>) {
const fullAddress = firstString(properties, ["contact:address", "addr:full", "address"]);
if (fullAddress) {
return fullAddress;
}
const streetLine = [
firstString(properties, ["addr:street"]),
firstString(properties, ["addr:housenumber"])
].filter(Boolean).join(" ");
const cityLine = [
firstString(properties, ["addr:postcode"]),
firstString(properties, ["addr:city", "addr:place"])
].filter(Boolean).join(" ");
return [streetLine, cityLine].filter(Boolean).join(", ") || null;
}
+148
View File
@@ -0,0 +1,148 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TideSummary } from "@watermaps/shared";
import type { GpsState } from "../src/hooks/useGeolocation";
const apiMocks = vi.hoisted(() => ({ getNearestTide: vi.fn() }));
vi.mock("../src/api", () => apiMocks);
import { useAnchorWatch } from "../src/hooks/useAnchorWatch";
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
beforeEach(() => {
apiMocks.getNearestTide.mockReset();
apiMocks.getNearestTide.mockResolvedValue(tideFixture());
});
afterEach(() => {
cleanup();
vi.useRealTimers();
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
else Reflect.deleteProperty(navigator, "vibrate");
});
describe("useAnchorWatch", () => {
it("captures an immutable anchor point only after an explicit action", async () => {
const gps = gpsFix({ lat: 53.2, lon: 7.1 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: gps } }
);
expect(result.current.phase).toBe("idle");
expect(result.current.anchorPoint).toBeNull();
act(() => expect(result.current.captureAnchor()).toBe(true));
expect(result.current.phase).toBe("set");
expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 });
rerender({ value: gpsFix({ lat: 53.21, lon: 7.11 }) });
expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 });
await waitFor(() => expect(result.current.tide?.station).toBe("Testpegel"));
expect(apiMocks.getNearestTide).toHaveBeenCalledWith(
{ lat: 53.2, lon: 7.1 },
expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/)
);
});
it("rejects stale and inaccurate fixes instead of silently setting a point", () => {
const stale = gpsFix({ lat: 53.2, lon: 7.1 }, { timestampMs: Date.now() - 20_000 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: stale } }
);
act(() => expect(result.current.captureAnchor()).toBe(false));
expect(result.current.operationError).toMatch(/älter als 10 Sekunden/);
expect(result.current.anchorPoint).toBeNull();
rerender({ value: gpsFix({ lat: 53.2, lon: 7.1 }, { accuracyM: 55 }) });
act(() => expect(result.current.captureAnchor()).toBe(false));
expect(result.current.operationError).toMatch(/GPS noch zu ungenau/);
expect(result.current.anchorPoint).toBeNull();
});
it("warns and vibrates once a conservative GPS distance exceeds the radius", async () => {
const vibrate = vi.fn();
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
const initial = gpsFix({ lat: 0, lon: 0 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: initial } }
);
act(() => {
result.current.captureAnchor();
result.current.updateSettings({ alarmRadiusM: 100, deployedRodeLengthM: 100 });
});
await act(async () => {
expect(await result.current.arm()).toBe(true);
});
expect(result.current.positionAlarm).toBe(false);
rerender({
value: gpsFix(
{ lat: 150 / 111_195.08, lon: 0 },
{ accuracyM: 10, timestampMs: Date.now() }
)
});
await waitFor(() => expect(result.current.positionAlarm).toBe(true));
expect(result.current.watchResult?.alarmTriggered).toBe(true);
expect(result.current.watchResult?.conservativeDistanceFromAnchorM).toBeGreaterThan(100);
expect(vibrate).toHaveBeenCalledTimes(1);
rerender({
value: gpsFix(
{ lat: 155 / 111_195.08, lon: 0 },
{ accuracyM: 10, timestampMs: Date.now() }
)
});
expect(vibrate).toHaveBeenCalledTimes(1);
});
it("keeps the positional watch usable but never confirms rode sufficiency from partial tide data", async () => {
apiMocks.getNearestTide.mockResolvedValue(tideFixture(6));
const { result } = renderHook(() => useAnchorWatch(gpsFix({ lat: 53.2, lon: 7.1 })));
act(() => result.current.captureAnchor());
await waitFor(() => expect(result.current.tideWindow?.coverage).toBe("partial"));
expect(result.current.rodePlan?.calculationComplete).toBe(false);
expect(result.current.rodePlan?.rodeReserveM).toBeNull();
await act(async () => {
expect(await result.current.arm()).toBe(true);
});
expect(result.current.phase).toBe("armed");
});
});
function gpsFix(
position: { lat: number; lon: number },
overrides: Partial<Pick<GpsState, "accuracyM" | "timestampMs" | "status">> = {}
): Pick<GpsState, "status" | "position" | "accuracyM" | "timestampMs"> {
return {
status: overrides.status ?? "tracking",
position,
accuracyM: overrides.accuracyM ?? 5,
timestampMs: overrides.timestampMs ?? Date.now()
};
}
function tideFixture(hours = 30): TideSummary {
const now = Date.now();
return {
station: "Testpegel",
distanceKm: 3.2,
nextHigh: null,
nextLow: null,
waterLevelCurve: [
{ time: new Date(now - 60 * 60_000).toISOString(), predictedM: 1 },
{ time: new Date(now + 6 * 60 * 60_000).toISOString(), predictedM: 2 },
{ time: new Date(now + hours * 60 * 60_000).toISOString(), predictedM: 0.5 }
],
source: "Test",
updatedAt: new Date(now).toISOString()
};
}
+229
View File
@@ -0,0 +1,229 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { MarineForecast, TideSummary } from "@watermaps/shared";
import {
ConditionsPanel,
type ConditionsRouteWeatherReport,
type ConditionsRouteWeatherSample
} from "../src/components/ConditionsPanel";
afterEach(cleanup);
const NOW = Date.parse("2026-07-23T10:00:00.000Z");
describe("ConditionsPanel", () => {
it("labels GPS as the source and shows the current marine weather metrics", () => {
render(
<ConditionsPanel
forecast={forecast()}
tide={null}
positionSource={{ kind: "gps" }}
now={NOW}
/>
);
expect(screen.getByRole("complementary", { name: "Wetter & Tide" })).toHaveAttribute(
"data-position-source",
"gps"
);
expect(screen.getByText("Aktuelle GPS-Position")).toBeVisible();
const currentConditions = screen
.getByRole("heading", { name: "Aktuelle Bedingungen" })
.closest("section");
expect(currentConditions).not.toBeNull();
expect(within(currentConditions!).getByText("12 kn · 270° W")).toBeVisible();
expect(within(currentConditions!).getByText("0.8 m · 6 s · 225° SW")).toBeVisible();
expect(within(currentConditions!).getByText("0.7 kn · 090° O")).toBeVisible();
expect(within(currentConditions!).getByText("14.2 °C")).toBeVisible();
expect(within(currentConditions!).getByText("vor 15 Min.")).toBeVisible();
expect(within(currentConditions!).getByText("Quelle: Test-Meteo")).toBeVisible();
});
it("shows the nearest tide station and both high- and low-water events", () => {
render(
<ConditionsPanel
forecast={null}
tide={tideSummary("Pegel Emden", 4.2, 1.72, 0.31)}
positionSource={{ kind: "gps", label: "Außenhafen" }}
now={NOW}
/>
);
expect(screen.getByText("GPS · Außenhafen")).toBeVisible();
expect(screen.getByText("Pegel Emden")).toBeVisible();
expect(screen.getByText("4,2 km entfernt")).toBeVisible();
const tideEvents = document.querySelector(".conditions-tide-events");
expect(tideEvents).not.toBeNull();
expect(tideEvents).toHaveTextContent(/HW.*1\.72 m/);
expect(tideEvents).toHaveTextContent(/NW.*0\.31 m/);
expect(within(tideEvents!).getByTitle("Nächstes Hochwasser")).toHaveTextContent("HW");
expect(within(tideEvents!).getByTitle("Nächstes Niedrigwasser")).toHaveTextContent("NW");
});
it("renders Start, Mitte and Ziel with weather and the middle tide", () => {
const routeWeatherReport: ConditionsRouteWeatherReport = {
severity: "caution",
summary: "Wind nimmt zur Mitte der Strecke zu.",
source: "Streckenprognose",
updatedAt: "2026-07-23T09:40:00.000Z",
unavailableSamples: 0,
samples: [
routeSample("Start", { windSpeed: 8, waveHeightM: 0.3 }),
routeSample("Mitte", { windSpeed: 17, waveHeightM: 0.9 }, -0.2),
routeSample("Ziel", { windSpeed: 11, waveHeightM: 0.5 })
]
};
render(
<ConditionsPanel
forecast={null}
tide={null}
positionSource={{ kind: "fallback", label: "Routenstart" }}
routeWeatherReport={routeWeatherReport}
routeTides={{
start: tideSummary("Startpegel", 2.1, 1.1, 0.2),
middle: tideSummary("Mittelplate", 7.5, 1.48, 0.18),
destination: tideSummary("Zielpegel", 3.4, 1.25, 0.14)
}}
now={NOW}
/>
);
expect(screen.getByText("Fallback · Routenstart")).toBeVisible();
expect(screen.getByText("Wind nimmt zur Mitte der Strecke zu.")).toBeVisible();
const start = routeCard("Start");
const middle = routeCard("Mitte");
const destination = routeCard("Ziel");
expect(start).toHaveTextContent("Startpegel · 2,1 km");
expect(middle).toHaveTextContent("17 kn");
expect(middle).toHaveTextContent("-0.2 kn entlang Route");
expect(middle).toHaveTextContent("Mittelplate · 7,5 km");
expect(middle).toHaveTextContent(/HW.*1\.48 m/);
expect(middle).toHaveTextContent(/NW.*0\.18 m/);
expect(destination).toHaveTextContent("Zielpegel · 3,4 km");
});
it("keeps partial data visible while reporting current and route failures", () => {
const partialReport: ConditionsRouteWeatherReport = {
severity: "caution",
summary: "Streckenprognose ist unvollständig.",
unavailableSamples: 2,
samples: [routeSample("Start", { windSpeed: 9, waveHeightM: 0.4 })]
};
render(
<ConditionsPanel
forecast={forecast({ windSpeed: 9 })}
tide={null}
positionSource={{ kind: "fallback", label: "Letzte GPS-Position" }}
currentError="Tidendaten nicht erreichbar."
routeWeatherReport={partialReport}
routeTides={{ start: null, middle: null, destination: null }}
routeError="Streckentiden nur teilweise erreichbar."
now={NOW}
/>
);
expect(screen.getAllByRole("alert")).toHaveLength(2);
expect(screen.getByText("Tidendaten nicht erreichbar.")).toBeVisible();
expect(screen.getByText("Streckentiden nur teilweise erreichbar.")).toBeVisible();
const currentConditions = screen
.getByRole("heading", { name: "Aktuelle Bedingungen" })
.closest("section");
expect(currentConditions).not.toBeNull();
expect(within(currentConditions!).getByText("9 kn · 270° W")).toBeVisible();
expect(screen.getByText("Für diese Position fehlt eine passende Tidenstation.")).toBeVisible();
expect(screen.getByText("Für 2 Streckenpunkte fehlt die Prognose.")).toBeVisible();
expect(routeCard("Start")).toHaveTextContent("9 kn");
expect(routeCard("Mitte")).toHaveTextContent("Keine Wetterprognose für diesen Streckenpunkt.");
expect(routeCard("Ziel")).toHaveTextContent("Keine Wetterprognose für diesen Streckenpunkt.");
expect(screen.getAllByText(/Keine passende Tide für/)).toHaveLength(3);
});
it("shows useful empty states when neither position nor route conditions exist", () => {
render(
<ConditionsPanel
forecast={null}
tide={null}
positionSource={{ kind: "unknown" }}
now={NOW}
/>
);
expect(screen.getByText("Positionsquelle noch offen")).toBeVisible();
expect(
screen.getByText("Für diese Position liegen noch keine Wetter- oder Tidendaten vor.")
).toBeVisible();
expect(
screen.getByText("Nach der Routenberechnung erscheinen hier Start, Mitte und Ziel.")
).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
function routeCard(label: "Start" | "Mitte" | "Ziel") {
const card = screen.getByRole("heading", { name: label, level: 4 }).closest("article");
expect(card).not.toBeNull();
return card!;
}
function routeSample(
label: ConditionsRouteWeatherSample["label"],
overrides: Partial<MarineForecast>,
currentAlongRouteKn: number | null = 0.3
): ConditionsRouteWeatherSample {
return {
label,
plannedTime: `2026-07-23T${label === "Start" ? "10" : label === "Mitte" ? "12" : "14"}:00:00.000Z`,
currentAlongRouteKn,
forecast: forecast(overrides)
};
}
function forecast(overrides: Partial<MarineForecast> = {}): MarineForecast {
return {
waveHeightM: 0.8,
waveDirectionDeg: 225,
wavePeriodS: 5.6,
windSpeed: 12.4,
windDirectionDeg: 270,
temperatureC: 14.2,
oceanCurrentSpeedKn: 0.7,
oceanCurrentDirectionDeg: 90,
forecastTime: "2026-07-23T10:00:00.000Z",
source: "Test-Meteo",
updatedAt: "2026-07-23T09:45:00.000Z",
...overrides
};
}
function tideSummary(
station: string,
distanceKm: number,
highWaterM: number,
lowWaterM: number
): TideSummary {
return {
station,
distanceKm,
nextHigh: {
type: "high",
time: "2026-07-23T12:30:00.000Z",
heightM: highWaterM
},
nextLow: {
type: "low",
time: "2026-07-23T18:45:00.000Z",
heightM: lowWaterM
},
waterLevelCurve: [],
source: "Test-Tide",
updatedAt: "2026-07-23T09:50:00.000Z"
};
}
@@ -0,0 +1,108 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { useCourseAssistant, type CourseAssistantInput } from "../src/hooks/useCourseAssistant";
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
afterEach(() => {
cleanup();
vi.useRealTimers();
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
else Reflect.deleteProperty(navigator, "vibrate");
});
describe("useCourseAssistant", () => {
it("never starts automatically and calculates only after an explicit start", () => {
const { result } = renderHook(() => useCourseAssistant(input()));
expect(result.current.active).toBe(false);
expect(result.current.guidance).toBeNull();
act(() => result.current.start());
expect(result.current.active).toBe(true);
expect(result.current.guidance?.status).toBe("on-route");
expect(result.current.guidance?.desiredCourseDeg).toBeCloseTo(90, 0);
});
it("stops deliberately when a different route is selected", () => {
const first = route("first", [[0, 0], [0.02, 0]]);
const second = route("second", [[0, 0], [0, 0.02]]);
const { result, rerender } = renderHook(
({ currentRoute }) => useCourseAssistant(input(currentRoute)),
{ initialProps: { currentRoute: first } }
);
act(() => result.current.start());
expect(result.current.active).toBe(true);
rerender({ currentRoute: second });
expect(result.current.active).toBe(false);
expect(result.current.guidance).toBeNull();
});
it("pauses guidance when no fresh GPS fix arrives", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-20T10:00:00.000Z"));
const fixTimestampMs = Date.now();
const { result } = renderHook(() => useCourseAssistant(input(routeFixture, fixTimestampMs)));
act(() => result.current.start());
expect(result.current.guidance).not.toBeNull();
act(() => vi.advanceTimersByTime(20_000));
expect(result.current.fixStale).toBe(true);
expect(result.current.guidance).toBeNull();
});
it("vibrates once when crossing into the off-route state", () => {
const vibrate = vi.fn();
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
const { result, rerender } = renderHook(
({ lat }) => useCourseAssistant(input(routeFixture, Date.now(), lat)),
{ initialProps: { lat: 0 } }
);
act(() => result.current.start());
expect(vibrate).not.toHaveBeenCalled();
rerender({ lat: 0.003 });
expect(result.current.guidance?.status).toBe("off-route");
expect(vibrate).toHaveBeenCalledTimes(1);
rerender({ lat: 0.0031 });
expect(vibrate).toHaveBeenCalledTimes(1);
});
});
function input(
currentRoute: RouteResult = routeFixture,
fixTimestampMs = Date.now(),
lat = 0
): CourseAssistantInput {
return {
route: currentRoute,
position: { lon: 0.001, lat },
accuracyM: 5,
speedKn: 6,
headingDeg: 90,
fixTimestampMs
};
}
function route(id: string, coordinates: [number, number][]): RouteResult {
return {
id,
name: id,
geometry: { type: "LineString", coordinates },
distanceNm: 1,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"],
routingMode: "fairway"
};
}
const routeFixture = route("test-route", [[0, 0], [0.02, 0]]);
@@ -0,0 +1,83 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { calculateRouteGuidance } from "@watermaps/shared";
import { CourseAssistantPanel } from "../src/components/CourseAssistantPanel";
afterEach(cleanup);
describe("CourseAssistantPanel", () => {
it("shows the continuously calculated target course and correction", () => {
const guidance = calculateRouteGuidance({
route: [[0, 0], [0.02, 0]],
position: { lon: 0.001, lat: 0 },
headingDeg: 80,
speedKn: 6,
accuracyM: 5
});
const onStop = vi.fn();
render(
<CourseAssistantPanel
guidance={guidance}
gpsStatus="tracking"
headingDeg={80}
headingSource="COG"
accuracyM={5}
fixStale={false}
onStop={onStop}
/>
);
expect(screen.getByRole("complementary", { name: "Kursassistent" })).toHaveTextContent("090°T");
expect(screen.getByText("10° nach Steuerbord")).toBeVisible();
expect(screen.getByText("Auf Route Sollkurs wird mit jedem GPS-Fix angepasst.")).toBeVisible();
expect(screen.getByText("IST COG").parentElement).toHaveTextContent("080°T");
fireEvent.click(screen.getByRole("button", { name: "Kursassistent stoppen" }));
expect(onStop).toHaveBeenCalledTimes(1);
});
it("suppresses steering advice while GPS accuracy is insufficient", () => {
const guidance = calculateRouteGuidance({
route: [[0, 0], [0.02, 0]],
position: { lon: 0.001, lat: 0 },
headingDeg: 80,
speedKn: 6,
accuracyM: 250
});
render(
<CourseAssistantPanel
guidance={guidance}
gpsStatus="tracking"
headingDeg={80}
headingSource="COG"
accuracyM={250}
fixStale={false}
onStop={vi.fn()}
/>
);
expect(guidance?.status).toBe("gps-unreliable");
expect(screen.getByText("Keine verlässliche Steueranweisung")).toBeVisible();
expect(screen.getByText(/GPS zu ungenau/)).toBeVisible();
expect(screen.getByRole("complementary", { name: "Kursassistent" })).toHaveAttribute("data-alert", "true");
});
it("pauses when the latest GPS fix is stale", () => {
render(
<CourseAssistantPanel
guidance={null}
gpsStatus="tracking"
headingDeg={null}
headingSource="--"
accuracyM={null}
fixStale
onStop={vi.fn()}
/>
);
expect(screen.getByText(/GPS-Fix ist veraltet/)).toBeVisible();
expect(screen.getByText("---")).toBeVisible();
});
});
+219
View File
@@ -0,0 +1,219 @@
import { expect, test } from "@playwright/test";
test("renders the iPhone PWA shell without overlapping core controls", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
await page.goto("/");
await page.getByRole("button", { name: "GPS starten" }).click();
await expect(page.getByText("Watermaps")).toBeVisible();
await expect(page.getByTestId("map-container")).toBeVisible();
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
await expect(page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" })).toBeVisible();
await expect(page.getByLabel("Abfahrt")).toBeVisible();
await page.getByText("Unterwegs: GPX · Offline · Kursalarm").click();
await expect(page.getByRole("region", { name: "Navigation und Offline-Route" })).toBeVisible();
await page.getByRole("button", { name: "Layer" }).click();
await expect(page.getByLabel("Layer Auswahl").getByText("Brücken")).toBeVisible();
await expect(page.getByLabel("Layer Auswahl").getByText("Tiefen")).toBeVisible();
await expect(page.getByLabel("Layer Auswahl").getByText("Schleusen")).toBeVisible();
await expect(page.getByLabel("Layer Auswahl").getByText("Häfen")).toBeVisible();
});
test("sets start and destination only after explicit map picking actions", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
await page.goto("/");
await expect(page.getByText("Start und Ziel setzen")).toBeVisible();
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
const mapBox = await page.getByTestId("map-container").boundingBox();
if (!mapBox) {
throw new Error("Map container not visible");
}
const mapTapY = mapBox.y + Math.min(132, mapBox.height * 0.2);
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
await expect(page.getByText("Start und Ziel setzen")).toBeVisible();
await page.getByRole("button", { name: "Start auf Karte setzen" }).click();
await expect(page.getByText("Startpunkt auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + mapBox.width / 3, mapTapY);
await expect(page.getByText(/^Start \d/)).toBeVisible();
await page.getByRole("button", { name: "Ziel auf Karte setzen" }).click();
await expect(page.getByText("Ziel auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + (mapBox.width * 2) / 3, mapTapY);
await expect(page.getByText(/^Ziel \d/)).toBeVisible();
await expect(page.getByText("Route bereit zur Prüfung")).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
await page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" }).click();
await expect(page.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
await expect(page.getByText(/^Z1 /)).toBeVisible();
});
test("does not expose Borkum or Hamm as direct route shortcuts", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("button", { name: /Borkum/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Hamm/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
});
test("can hide and restore the route planner for a map-only view", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Routenfenster ausblenden" }).click();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
await expect(page.getByRole("button", { name: "Routenfenster einblenden" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeHidden();
await page.getByRole("button", { name: "Routenfenster einblenden" }).click();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
});
test("cycles the mobile route sheet through half, full, and compact heights", async ({ page }) => {
await page.goto("/");
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
await expect(routePanel).toHaveAttribute("data-sheet-state", "half");
const halfBox = await routePanel.boundingBox();
await page
.getByRole("button", { name: "Routenfenster auf volle Höhe vergrößern" })
.click();
await expect(routePanel).toHaveAttribute("data-sheet-state", "full");
const fullBox = await routePanel.boundingBox();
expect(fullBox && halfBox && fullBox.height > halfBox.height + 80).toBe(true);
await page
.getByRole("button", { name: "Routenfenster auf kompakte Höhe verkleinern" })
.click();
await expect(routePanel).toHaveAttribute("data-sheet-state", "compact");
const compactBox = await routePanel.boundingBox();
expect(compactBox && halfBox && compactBox.height < halfBox.height - 80).toBe(true);
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeHidden();
await page.setViewportSize({ width: 900, height: 844 });
await expect(routePanel).toHaveAttribute("data-sheet-state", "half");
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
});
test("starts a visible dynamic course assistant only after route planning", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
await page.route("**/api/routes", async (request) => {
await request.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "guidance-e2e",
name: "Testkurs Ost",
geometry: { type: "LineString", coordinates: [[12.09, 54.18], [12.11, 54.18]] },
distanceNm: 0.7,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["E2E"],
routingMode: "fairway"
})
});
});
await page.goto("/");
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
const mapBox = await page.getByTestId("map-container").boundingBox();
if (!mapBox) throw new Error("Map container not visible");
const tapY = mapBox.y + Math.min(132, mapBox.height * 0.2);
await page.getByRole("button", { name: "Start auf Karte setzen" }).click();
await page.touchscreen.tap(mapBox.x + mapBox.width / 3, tapY);
await page.getByRole("button", { name: "Ziel auf Karte setzen" }).click();
await page.touchscreen.tap(mapBox.x + (mapBox.width * 2) / 3, tapY);
await page.getByRole("button", { name: "Route berechnen" }).click();
const startAssistant = page.getByRole("button", { name: "Navigation starten" });
await expect(startAssistant).toBeVisible();
await startAssistant.click();
const assistant = page.getByRole("complementary", { name: "Kursassistent" });
await expect(assistant).toBeVisible();
await expect(assistant.getByText("SOLL ÜBER GRUND")).toBeVisible();
await expect(assistant.getByRole("button", { name: "Kursassistent stoppen" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
const assistantBox = await assistant.boundingBox();
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
expect(assistantBox && statusBox && assistantBox.y + assistantBox.height <= statusBox.y + 1).toBe(true);
});
test("sets and arms the anchor watch only through the explicit two-step flow", async ({ page, context }) => {
const now = Date.now();
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 53.2159, longitude: 6.5766 });
await page.route("**/api/tides/nearest?*", async (request) => {
await request.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
station: "Testpegel Emden",
distanceKm: 4.2,
nextHigh: null,
nextLow: null,
waterLevelCurve: [
{ time: new Date(now - 60 * 60_000).toISOString(), predictedM: 1 },
{ time: new Date(now + 6 * 60 * 60_000).toISOString(), predictedM: 2 },
{ time: new Date(now + 30 * 60 * 60_000).toISOString(), predictedM: 0.5 }
],
source: "E2E",
updatedAt: new Date(now).toISOString()
})
});
});
await page.goto("/");
await expect(page.getByRole("complementary", { name: "Ankerwache" })).toHaveCount(0);
await page.getByRole("button", { name: "GPS starten" }).click();
await page.getByRole("button", { name: "Ankerwache öffnen" }).click();
const panel = page.getByRole("complementary", { name: "Ankerwache" });
await expect(panel).toBeVisible();
await expect(panel.getByText(/GPS ±\d+ m/)).toBeVisible();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
await panel.getByRole("button", { name: "Anker gefallen Position jetzt setzen" }).click();
await expect(panel.getByText("Ankerpunkt gespeichert")).toBeVisible();
await panel.getByLabel("Tiefe beim Setzen").fill("4");
await panel.getByLabel("Bugrolle über Wasser").fill("1");
await panel.getByLabel("Kette / Leine draußen").fill("40");
await panel.getByLabel("Gewähltes Verhältnis").selectOption("5");
await expect(panel.getByText(/Testpegel Emden · 4\.2 km entfernt/)).toBeVisible();
await expect(panel.getByText(/m Reserve/)).toBeVisible();
await panel.getByRole("button", { name: "Wache starten" }).click();
await expect(panel.getByText("Ankerwache aktiv")).toBeVisible();
await expect(panel.getByText("Im überwachten Schwojkreis")).toBeVisible();
const panelBox = await panel.boundingBox();
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
expect(panelBox && statusBox && panelBox.y + panelBox.height <= statusBox.y + 1).toBe(true);
await panel.getByRole("button", { name: "Ankerwache beenden" }).click();
await expect(panel.getByRole("button", { name: "Wirklich beenden" })).toBeVisible();
await panel.getByRole("button", { name: "Wirklich beenden" }).click();
await expect(panel).toBeHidden();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
});
+123
View File
@@ -0,0 +1,123 @@
import { expect, test } from "@playwright/test";
test("docks the route planner beside the map and releases the full map when collapsed", async ({
page
}) => {
await page.goto("/");
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
const map = page.getByTestId("map-container");
const status = page.getByLabel("Navigationsstatus");
await expect(routePanel).toBeVisible();
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
const dockedPanelBox = await routePanel.boundingBox();
const dockedMapBox = await map.boundingBox();
const statusBox = await status.boundingBox();
expect(dockedPanelBox).not.toBeNull();
expect(dockedMapBox).not.toBeNull();
expect(statusBox).not.toBeNull();
expect(dockedPanelBox!.x).toBe(0);
expect(dockedPanelBox!.width).toBe(400);
expect(dockedPanelBox!.x + dockedPanelBox!.width).toBeLessThanOrEqual(dockedMapBox!.x);
expect(dockedPanelBox!.y + dockedPanelBox!.height).toBeLessThanOrEqual(statusBox!.y + 1);
await routePanel.getByRole("button", { name: "Routenfenster ausblenden" }).click();
await expect(routePanel).toBeHidden();
await expect.poll(async () => (await map.boundingBox())?.x).toBe(0);
await expect.poll(async () => {
return page.locator(".maplibregl-canvas").evaluate((node) => {
const canvas = node as HTMLCanvasElement;
return Math.abs(canvas.width / window.devicePixelRatio - canvas.clientWidth);
});
}).toBeLessThan(2);
await page.getByRole("button", { name: "Routenfenster einblenden" }).click();
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
});
test("switches from floating tablet panel to docked desktop sidebar at 1100 pixels", async ({
page
}) => {
await page.goto("/");
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
const map = page.getByTestId("map-container");
await page.setViewportSize({ width: 1099, height: 900 });
await expect.poll(async () => (await map.boundingBox())?.x).toBe(0);
const floatingPanelBox = await routePanel.boundingBox();
expect(floatingPanelBox?.x).toBe(10);
expect(floatingPanelBox?.width).toBe(360);
await page.setViewportSize({ width: 1100, height: 900 });
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
const dockedPanelBox = await routePanel.boundingBox();
expect(dockedPanelBox?.x).toBe(0);
expect(dockedPanelBox?.width).toBe(400);
});
test("shows marine contact details as a right-hand desktop drawer", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
const requestedFeatureLayers: string[][] = [];
await page.route("**/api/features**", async (route) => {
const requestedLayers =
new URL(route.request().url()).searchParams.get("layers")?.split(",") ?? [];
requestedFeatureLayers.push(requestedLayers);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
type: "FeatureCollection",
features: requestedLayers.includes("harbours")
? [
{
type: "Feature",
id: "e2e-test-harbour",
geometry: { type: "Point", coordinates: [12.09, 54.18] },
properties: {
layer: "harbours",
name: "Testhafen Rostock",
phone: "+49 381 123456",
website: "https://example.com",
vhf: "Kanal 12",
source: "E2E"
}
}
]
: []
})
});
});
await page.goto("/");
await expect(page.locator(".data-badge")).toHaveAttribute("data-ready", "true");
await page.getByRole("button", { name: "GPS starten" }).click();
await page.getByRole("button", { name: "Position zentrieren" }).click();
await expect.poll(() => requestedFeatureLayers.flat().sort().join(",")).toContain("harbours");
const featureButton = page.getByRole("button", {
name: "Informationen zu Hafen Testhafen Rostock",
includeHidden: true
});
await expect(featureButton).toBeAttached({ timeout: 10_000 });
await featureButton.focus();
await featureButton.press("Enter");
const drawer = page.getByRole("dialog", { name: "Testhafen Rostock" });
await expect(drawer).toBeVisible();
await expect(drawer.getByRole("link", { name: /Testhafen Rostock anrufen/ })).toBeVisible();
const drawerBox = await drawer.boundingBox();
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
expect(drawerBox).not.toBeNull();
expect(statusBox).not.toBeNull();
expect(drawerBox!.x + drawerBox!.width).toBeGreaterThanOrEqual(1426);
expect(drawerBox!.y).toBeGreaterThanOrEqual(62);
expect(drawerBox!.y + drawerBox!.height).toBeLessThanOrEqual(statusBox!.y);
await page.keyboard.press("Escape");
await expect(drawer).toBeHidden();
await expect(featureButton).toBeFocused();
});
+61
View File
@@ -0,0 +1,61 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useGeolocation } from "../src/hooks/useGeolocation";
const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, "geolocation");
afterEach(() => {
cleanup();
if (originalGeolocation) Object.defineProperty(navigator, "geolocation", originalGeolocation);
else Reflect.deleteProperty(navigator, "geolocation");
});
describe("useGeolocation", () => {
it("derives COG only after movement exceeds GPS noise and clears the watch on stop", () => {
let success: PositionCallback | undefined;
const watchPosition = vi.fn((callback: PositionCallback) => {
success = callback;
return 23;
});
const clearWatch = vi.fn();
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: { watchPosition, clearWatch, getCurrentPosition: vi.fn() }
});
const { result } = renderHook(() => useGeolocation());
act(() => result.current.start());
act(() => success?.(positionAt(53, 7, 5, 1_000)));
expect(result.current.courseDeg).toBeNull();
act(() => success?.(positionAt(53, 7.00001, 5, 2_000)));
expect(result.current.courseDeg).toBeNull();
const receivedAtMs = Date.now();
act(() => success?.(positionAt(53, 7.0001, 5, 3_000)));
expect(result.current.courseDeg).toBeCloseTo(90, 0);
expect(result.current.timestampMs).toBeGreaterThanOrEqual(receivedAtMs);
act(() => result.current.stop());
expect(clearWatch).toHaveBeenCalledWith(23);
expect(result.current.status).toBe("idle");
expect(result.current.position).toBeNull();
});
});
function positionAt(lat: number, lon: number, accuracy: number, timestamp: number): GeolocationPosition {
return {
coords: {
latitude: lat,
longitude: lon,
accuracy,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: 3,
toJSON: () => ({})
},
timestamp,
toJSON: () => ({})
};
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { createRouteGpx } from "../src/lib/gpx";
describe("GPX export", () => {
it("creates parseable GPX 1.1 route and track metadata", () => {
const gpx = createRouteGpx(routeFixture, {
name: "Emden & Hamm <Test>",
createdAt: "2026-07-19T12:00:00.000Z"
});
const document = new DOMParser().parseFromString(gpx, "application/xml");
const root = document.documentElement;
expect(document.querySelector("parsererror")).toBeNull();
expect(root.localName).toBe("gpx");
expect(root.getAttribute("version")).toBe("1.1");
expect(root.namespaceURI).toBe("http://www.topografix.com/GPX/1/1");
expect(document.getElementsByTagNameNS(root.namespaceURI, "metadata")).toHaveLength(1);
expect(document.getElementsByTagNameNS(root.namespaceURI, "rte")).toHaveLength(1);
expect(document.getElementsByTagNameNS(root.namespaceURI, "trk")).toHaveLength(1);
expect(document.getElementsByTagNameNS(root.namespaceURI, "rtept")).toHaveLength(3);
expect(document.getElementsByTagNameNS(root.namespaceURI, "trkpt")).toHaveLength(3);
expect(document.getElementsByTagNameNS(root.namespaceURI, "time")[0]?.textContent).toBe("2026-07-19T12:00:00.000Z");
expect(gpx).toContain("Emden &amp; Hamm &lt;Test&gt;");
expect(gpx).toContain("minlat=\"51.6814536\"");
});
it("rejects invalid or incomplete geometry", () => {
expect(() => createRouteGpx({
...routeFixture,
geometry: { type: "LineString", coordinates: [[7, 53]] }
})).toThrow(/nicht genügend Punkte/);
expect(() => createRouteGpx({
...routeFixture,
geometry: { type: "LineString", coordinates: [[7, 53], [181, 52]] }
})).toThrow(/ungültige Koordinaten/);
});
});
const routeFixture: RouteResult = {
id: "emden-hamm",
name: "Emden Hamm",
geometry: {
type: "LineString",
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]]
},
distanceNm: 153.69,
eta: null,
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich & vor Ort prüfen" }],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OpenStreetMap <curated>"],
routingMode: "fairway"
};
+64
View File
@@ -0,0 +1,64 @@
import "@testing-library/jest-dom/vitest";
import { lazy } from "react";
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LazyContent } from "../src/components/LazyContent";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("LazyContent", () => {
it("keeps the surrounding app shell visible while a feature chunk loads", async () => {
let resolveModule: ((module: { default: () => React.JSX.Element }) => void) | undefined;
const Feature = lazy(
() =>
new Promise<{ default: () => React.JSX.Element }>((resolve) => {
resolveModule = resolve;
})
);
render(
<main>
<h1>Watermaps</h1>
<LazyContent
pending={<p role="status">Modul wird geladen </p>}
failed={<p role="alert">Modul fehlt.</p>}
>
<Feature />
</LazyContent>
</main>
);
expect(screen.getByRole("heading", { name: "Watermaps" })).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent("Modul wird geladen");
await act(async () => {
resolveModule?.({ default: () => <section>Funktion bereit</section> });
});
expect(await screen.findByText("Funktion bereit")).toBeVisible();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("contains a failed lazy import inside its local fallback", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const BrokenFeature = lazy(() => Promise.reject(new Error("Chunk fehlt")));
render(
<main>
<h1>Watermaps</h1>
<LazyContent
pending={<p role="status">Modul wird geladen </p>}
failed={<p role="alert">Modul konnte nicht geladen werden.</p>}
>
<BrokenFeature />
</LazyContent>
</main>
);
expect(await screen.findByRole("alert")).toHaveTextContent("nicht geladen");
expect(screen.getByRole("heading", { name: "Watermaps" })).toBeVisible();
});
});
+866
View File
@@ -0,0 +1,866 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AppConfig, RouteResult } from "@watermaps/shared";
import { MarineFeatureInfo, type MarineFeatureDetails } from "../src/components/MarineFeatureInfo";
const maplibreState = vi.hoisted(() => ({ current: null as any, markerConstructions: 0 }));
const apiMocks = vi.hoisted(() => ({ getMapFeatures: vi.fn() }));
vi.mock("../src/api", () => apiMocks);
vi.mock("maplibre-gl", () => {
class MockLngLatBounds {
extend = vi.fn(() => this);
}
class MockMap {
handlers = new globalThis.Map<string, Set<(event?: any) => void>>();
sources = new globalThis.Map<
string,
{
setData: ReturnType<typeof vi.fn>;
getClusterExpansionZoom: ReturnType<typeof vi.fn>;
}
>();
sourceDefinitions = new globalThis.Map<string, any>();
layers = new Set<string>();
layerDefinitions = new globalThis.Map<string, any>();
renderedFeatures: any[] = [];
zoom = 13;
container: HTMLElement;
fitBounds = vi.fn(() => this);
easeTo = vi.fn(() => this);
flyTo = vi.fn(() => this);
setLayoutProperty = vi.fn();
moveLayer = vi.fn();
remove = vi.fn();
constructor(options: { container: HTMLElement }) {
this.container = options.container;
this.container.addEventListener("click", () => {
this.emit("click", { lngLat: { lng: 7.8, lat: 51.7 }, point: { x: 10, y: 10 } });
});
maplibreState.current = this;
}
addControl() {
return this;
}
on(eventName: string, handler: (event?: any) => void) {
this.handlers.set(eventName, new Set([...(this.handlers.get(eventName) ?? []), handler]));
return this;
}
off(eventName: string, handler: (event?: any) => void) {
this.handlers.get(eventName)?.delete(handler);
return this;
}
emit(eventName: string, event?: any) {
for (const handler of this.handlers.get(eventName) ?? []) {
handler(event);
}
}
addSource(id: string, definition: any) {
this.sourceDefinitions.set(id, definition);
this.sources.set(id, {
setData: vi.fn(),
getClusterExpansionZoom: vi.fn(async () => 15)
});
}
getSource(id: string) {
return this.sources.get(id);
}
addLayer(layer: { id: string }) {
this.layers.add(layer.id);
this.layerDefinitions.set(layer.id, layer);
}
getLayer(id: string) {
return this.layers.has(id) ? { id } : undefined;
}
getCanvas() {
return this.container;
}
getCanvasContainer() {
return this.container;
}
getContainer() {
return this.container;
}
unproject() {
return { lng: 7.8, lat: 51.7 };
}
project() {
return { x: 10, y: 10 };
}
queryRenderedFeatures() {
return this.renderedFeatures;
}
isStyleLoaded() {
return true;
}
getZoom() {
return this.zoom;
}
getBounds() {
return {
getWest: () => 7,
getSouth: () => 51,
getEast: () => 9,
getNorth: () => 54
};
}
}
class MockMarker {
element: HTMLElement;
constructor(options: { element: HTMLElement }) {
maplibreState.markerConstructions += 1;
this.element = options.element;
}
setLngLat() {
return this;
}
addTo(map: MockMap) {
map.container.append(this.element);
return this;
}
remove() {
this.element.remove();
return this;
}
}
class MockControl {}
return {
default: {
Map: MockMap,
Marker: MockMarker,
LngLatBounds: MockLngLatBounds,
AttributionControl: MockControl,
ScaleControl: MockControl
}
};
});
import { MapView } from "../src/components/MapView";
const config: AppConfig = {
appName: "Watermaps",
region: "Test",
disclaimer: "Test",
featureFlags: {},
layers: [
{
id: "base",
name: "Basiskarte",
kind: "style",
url: "https://example.test/style.json",
attribution: "Test",
defaultVisible: true
}
],
attribution: []
};
const marineFeatures = [
{
type: "Feature" as const,
id: "lock-1",
geometry: { type: "Point" as const, coordinates: [7.61, 52.01] },
properties: {
layer: "locks",
name: "Schleuse Nord",
"contact:phone": "+49 123 456",
website: "https://schleuse.example",
email: "schleuse@example.test",
vhf_channel: "Kanal 20",
opening_hours: "Mo-Su 06:00-22:00",
operator: "WSV",
"addr:street": "Am Kanal",
"addr:housenumber": "1",
"addr:postcode": "12345",
"addr:city": "Hafenstadt",
source: "OSM/Geofabrik"
}
},
{
type: "Feature" as const,
id: "harbour-1",
geometry: { type: "Point" as const, coordinates: [7.72, 51.82] },
properties: {
layer: "harbours",
name: "Stadthafen",
source: "Hafenbetreiber"
}
}
];
beforeEach(() => {
maplibreState.current = null;
maplibreState.markerConstructions = 0;
apiMocks.getMapFeatures.mockReset();
apiMocks.getMapFeatures.mockImplementation(async ({ layers }: { layers: string[] }) => ({
type: "FeatureCollection",
features: marineFeatures.filter((feature) => layers.includes(feature.properties.layer))
}));
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("MapView marine feature information", () => {
it("loads clustered lock and harbour info layers only at a close zoom level", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
maplibreState.current.zoom = 9;
await act(async () => maplibreState.current.emit("load"));
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument();
expect(apiMocks.getMapFeatures).toHaveBeenLastCalledWith(
expect.objectContaining({ layers: expect.not.arrayContaining(["locks", "harbours"]) })
);
expect(maplibreState.current.sourceDefinitions.get("marine-contact-pois")).toEqual(
expect.objectContaining({ type: "geojson", cluster: true, clusterMaxZoom: 14 })
);
expect(maplibreState.current.layerDefinitions.get("lock-info-circles")).toEqual(
expect.objectContaining({ type: "circle", minzoom: 12, source: "marine-contact-pois" })
);
expect(maplibreState.current.layerDefinitions.get("contact-cluster-count")).toEqual(
expect.objectContaining({ type: "symbol", minzoom: 12, source: "marine-contact-pois" })
);
apiMocks.getMapFeatures.mockClear();
maplibreState.current.zoom = 12;
await act(async () => maplibreState.current.emit("zoomend"));
await act(async () => maplibreState.current.emit("moveend"));
expect(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument();
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1);
expect(maplibreState.markerConstructions).toBe(0);
expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0);
maplibreState.current.zoom = 11;
await act(async () => maplibreState.current.emit("zoom"));
expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument();
});
it("opens a rendered POI before route-coordinate picking and keeps static accessible buttons", async () => {
const onPickCoordinate = vi.fn();
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode="destination"
route={null}
onPickCoordinate={onPickCoordinate}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" });
expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument();
expect(apiMocks.getMapFeatures).toHaveBeenCalledWith(
expect.objectContaining({ layers: expect.arrayContaining(["locks", "harbours"]) })
);
maplibreState.current.renderedFeatures = [marineFeatures[0]];
await act(async () =>
maplibreState.current.emit("click", {
point: { x: 12, y: 18 },
lngLat: { lng: 7.61, lat: 52.01 }
})
);
expect(onPickCoordinate).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible();
expect(screen.getByRole("link", { name: /Schleuse Nord anrufen/i })).toHaveAttribute(
"href",
"tel:+49123456"
);
expect(screen.getByRole("link", { name: /Website von Schleuse Nord/i })).toHaveAttribute(
"href",
"https://schleuse.example/"
);
expect(screen.getByText("Kanal 20")).toBeVisible();
expect(screen.getByText("Am Kanal 1, 12345 Hafenstadt")).toBeVisible();
});
it("expands a contact cluster instead of treating it as a picked route coordinate", async () => {
const onPickCoordinate = vi.fn();
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode="destination"
route={null}
onPickCoordinate={onPickCoordinate}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
maplibreState.current.renderedFeatures = [
{
type: "Feature",
geometry: { type: "Point", coordinates: [7.7, 52] },
properties: { cluster: true, cluster_id: 42, point_count: 12 }
}
];
await act(async () =>
maplibreState.current.emit("click", {
point: { x: 20, y: 20 },
lngLat: { lng: 7.7, lat: 52 }
})
);
const contactSource = maplibreState.current.getSource("marine-contact-pois");
expect(contactSource.getClusterExpansionZoom).toHaveBeenCalledWith(42);
await waitFor(() =>
expect(maplibreState.current.easeTo).toHaveBeenCalledWith(
expect.objectContaining({ center: [7.7, 52], zoom: 15, essential: true })
)
);
expect(onPickCoordinate).not.toHaveBeenCalled();
});
it("removes accessible POIs and closes their panel when a feature layer is disabled", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Hafen Stadthafen" }));
expect(screen.getByRole("dialog", { name: "Stadthafen" })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Layer" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Häfen ab Zoom 12" }));
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Informationen zu Hafen Stadthafen" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Stadthafen" })).not.toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeVisible();
});
it("replaces stale static POI controls after the visible map area is refreshed", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" }));
expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible();
apiMocks.getMapFeatures.mockResolvedValue({
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "lock-2",
geometry: { type: "Point", coordinates: [8.1, 52.2] },
properties: { layer: "locks", name: "Schleuse Süd", source: "Test" }
}
]
});
await act(async () => maplibreState.current.emit("moveend"));
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Schleuse Nord" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Süd" })).toBeVisible();
});
});
it("keeps map movement handlers constant for a dense contact response", async () => {
apiMocks.getMapFeatures.mockResolvedValue({
type: "FeatureCollection",
features: Array.from({ length: 250 }, (_, index) => ({
type: "Feature",
id: `lock-${index}`,
geometry: { type: "Point", coordinates: [7 + index / 10_000, 52] },
properties: { layer: "locks", name: `Schleuse ${index}` }
}))
});
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
await waitFor(() => expect(maplibreState.current.getSource("marine-contact-pois").setData).toHaveBeenCalled());
expect(maplibreState.markerConstructions).toBe(0);
expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0);
expect(maplibreState.current.handlers.get("moveend")?.size ?? 0).toBe(1);
});
it("aborts a superseded feature request", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" });
let firstSignal: AbortSignal | undefined;
apiMocks.getMapFeatures.mockImplementationOnce(
({ signal }: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
firstSignal = signal;
signal?.addEventListener("abort", () => reject(new DOMException("Abgebrochen", "AbortError")), {
once: true
});
})
);
await act(async () => maplibreState.current.emit("moveend"));
await waitFor(() => expect(firstSignal).toBeDefined());
apiMocks.getMapFeatures.mockResolvedValueOnce({ type: "FeatureCollection", features: marineFeatures });
await act(async () => maplibreState.current.emit("moveend"));
expect(firstSignal?.aborted).toBe(true);
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3));
});
it("fits the map to a newly calculated route", async () => {
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
const route: RouteResult = {
geometry: {
type: "LineString",
coordinates: [
[7.18, 53.34],
[7.81, 51.68]
]
},
distanceNm: 120,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
};
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={route}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
expect(maplibreState.current.fitBounds).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ maxZoom: 14, essential: true })
);
});
it("focuses a route event requested by the shared navigation workspace", async () => {
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={null}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={{
key: "lock:test:1",
coordinate: { lat: 52.1, lon: 7.4 },
zoom: 15
}}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
expect(maplibreState.current.flyTo).toHaveBeenCalledWith({
center: [7.4, 52.1],
zoom: 15,
essential: true
});
});
it("applies an existing route-event focus after the map style has loaded", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={{
key: "harbour:early:1",
coordinate: { lat: 53.2, lon: 6.9 },
zoom: 13
}}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
expect(maplibreState.current.flyTo).not.toHaveBeenCalled();
await act(async () => maplibreState.current.emit("load"));
expect(maplibreState.current.flyTo).toHaveBeenCalledWith({
center: [6.9, 53.2],
zoom: 13,
essential: true
});
});
it("draws the live guidance vector and lookahead target without DOM markers", async () => {
render(
<MapView
config={config}
position={{ lat: 52, lon: 7 }}
accuracyM={6}
startPoint={{ lat: 52, lon: 7 }}
destination={{ lat: 52, lon: 7.1 }}
pickMode={null}
route={null}
guidanceActive
guidanceTarget={{ lat: 52.001, lon: 7.01 }}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
const definition = maplibreState.current.sourceDefinitions.get("route-guidance");
expect(definition).toEqual(expect.objectContaining({ type: "geojson" }));
expect(definition.data.features).toHaveLength(2);
expect(definition.data.features[0].geometry).toEqual({
type: "LineString",
coordinates: [[7, 52], [7.01, 52.001]]
});
expect(maplibreState.current.layerDefinitions.get("route-guidance-line")).toEqual(
expect.objectContaining({ type: "line", source: "route-guidance" })
);
expect(maplibreState.markerConstructions).toBe(0);
});
});
describe("MapView anchor watch", () => {
it("renders one geodesic metre-based alarm ring and updates it without markers", async () => {
const onPickCoordinate = vi.fn();
const onMapReady = vi.fn();
const { rerender } = render(
<MapView
config={config}
position={{ lat: 53.2001, lon: 7.1001 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
anchorPoint={{ lat: 53.2, lon: 7.1 }}
anchorAlarmRadiusM={80}
anchorWatchActive
anchorAlarm={false}
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
const initial = maplibreState.current.sourceDefinitions.get("anchor-watch").data;
const radius = initial.features.find((feature: any) => feature.properties.kind === "radius");
expect(radius.geometry.type).toBe("Polygon");
expect(radius.geometry.coordinates[0]).toHaveLength(65);
expect(initial.features.filter((feature: any) => feature.properties.kind === "anchor")).toHaveLength(1);
expect(initial.features.filter((feature: any) => feature.properties.kind === "distance")).toHaveLength(1);
expect(maplibreState.current.layerDefinitions.get("anchor-watch-radius-fill")).toEqual(
expect.objectContaining({ type: "fill", source: "anchor-watch" })
);
expect(maplibreState.current.easeTo).toHaveBeenCalledWith(
expect.objectContaining({ center: [7.1, 53.2], zoom: 15 })
);
expect(maplibreState.markerConstructions).toBe(0);
rerender(
<MapView
config={config}
position={{ lat: 53.2015, lon: 7.1 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
anchorPoint={{ lat: 53.2, lon: 7.1 }}
anchorAlarmRadiusM={100}
anchorWatchActive
anchorAlarm
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
const source = maplibreState.current.sources.get("anchor-watch");
await waitFor(() => expect(source.setData).toHaveBeenCalled());
const updated = source.setData.mock.calls.at(-1)?.[0];
expect(updated.features.every((feature: any) => feature.properties.alarm === true)).toBe(true);
expect(updated.features.find((feature: any) => feature.properties.kind === "radius").geometry.coordinates[0]).toHaveLength(65);
});
});
describe("MapView waypoints", () => {
it("creates a labelled waypoint source, updates it, and shows the waypoint picking hint", async () => {
const stableOnPickCoordinate = vi.fn();
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={{ lat: 53.34, lon: 7.18 }}
destination={{ lat: 51.68, lon: 7.8 }}
waypoints={[
{ lat: 52.8, lon: 7.25 },
{ lat: 52.1, lon: 7.5 }
]}
pickMode="waypoint"
route={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
expect(screen.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
await act(async () => maplibreState.current.emit("load"));
expect(maplibreState.current.sourceDefinitions.get("waypoints")).toEqual({
type: "geojson",
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { label: "Z1" },
geometry: { type: "Point", coordinates: [7.25, 52.8] }
},
{
type: "Feature",
properties: { label: "Z2" },
geometry: { type: "Point", coordinates: [7.5, 52.1] }
}
]
}
});
expect(maplibreState.current.layerDefinitions.get("waypoint-label")).toEqual(
expect.objectContaining({ source: "waypoints" })
);
const waypointSource = maplibreState.current.getSource("waypoints");
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={{ lat: 53.34, lon: 7.18 }}
destination={{ lat: 51.68, lon: 7.8 }}
waypoints={[{ lat: 52.4, lon: 7.65 }]}
pickMode="waypoint"
route={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
expect(waypointSource?.setData).toHaveBeenLastCalledWith({
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { label: "Z1" },
geometry: { type: "Point", coordinates: [7.65, 52.4] }
}
]
});
});
});
describe("MarineFeatureInfo", () => {
it("summarizes missing contact information without rendering empty data rows and closes with Escape", () => {
const onClose = vi.fn();
const feature: MarineFeatureDetails = {
id: "harbour-missing",
layer: "harbours",
name: "Unbemannter Hafen",
typeLabel: "Hafen",
coordinate: { lat: 51.7, lon: 7.8 },
phone: null,
website: null,
email: null,
vhf: null,
openingHours: null,
operator: null,
address: null,
source: null,
updatedAt: null
};
render(<MarineFeatureInfo feature={feature} onClose={onClose} />);
expect(screen.getByText("Keine direkten Kontaktdaten hinterlegt.")).toBeVisible();
expect(screen.queryByText("Nicht hinterlegt")).not.toBeInTheDocument();
expect(screen.getByText("Daten & Quelle")).toBeVisible();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("links the enrichment provenance and explains merged map objects", () => {
const feature: MarineFeatureDetails = {
id: "lock-enriched",
layer: "locks",
name: "Schleuse Werries",
typeLabel: "Schleuse",
coordinate: { lat: 51.69508, lon: 7.86708 },
phone: "+49 2381 9019-290",
website: null,
email: null,
vhf: "22",
openingHours: null,
operator: "WSV",
address: null,
source: "EuRIS + OpenStreetMap",
sourceUrl: "https://www.eurisportal.eu/visuris/api/Locks_v2/GetLock?isrs=DEHMM00301LOCKS00404",
updatedAt: "2026-07-20T10:00:00.000Z",
memberCount: 4
};
render(<MarineFeatureInfo feature={feature} onClose={vi.fn()} />);
expect(screen.getByRole("link", { name: /Schleuse Werries anrufen/i })).toHaveAttribute(
"href",
"tel:+4923819019290"
);
fireEvent.click(screen.getByText("Daten & Quelle"));
expect(screen.getByRole("link", { name: "EuRIS + OpenStreetMap" })).toHaveAttribute(
"href",
expect.stringContaining("DEHMM00301LOCKS00404")
);
expect(screen.getByText("4 Kartenobjekte")).toBeVisible();
});
});
+68
View File
@@ -0,0 +1,68 @@
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const apiMocks = vi.hoisted(() => ({
getMarineForecast: vi.fn(),
getNearestTide: vi.fn()
}));
vi.mock("../src/api", () => apiMocks);
import { useMarineData } from "../src/hooks/useMarineData";
beforeEach(() => {
apiMocks.getMarineForecast.mockResolvedValue({ source: "test", updatedAt: new Date().toISOString() });
apiMocks.getNearestTide.mockResolvedValue({
station: "test",
distanceKm: 1,
nextHigh: null,
nextLow: null,
waterLevelCurve: [],
source: "test",
updatedAt: new Date().toISOString()
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("useMarineData", () => {
it("does not refetch weather and tide for every GPS jitter inside the same rounded cell", async () => {
const { result, rerender } = renderHook(
({ position }) => useMarineData(position),
{ initialProps: { position: { lat: 53.201, lon: 7.101 } } }
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(1);
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(1);
rerender({ position: { lat: 53.203, lon: 7.103 } });
await Promise.resolve();
expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(1);
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(1);
rerender({ position: { lat: 53.216, lon: 7.116 } });
await waitFor(() => expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(2));
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(2);
});
it("keeps partial data visible and exposes the failed source separately", async () => {
apiMocks.getMarineForecast.mockRejectedValueOnce(new Error("forecast offline"));
const { result } = renderHook(() =>
useMarineData({ lat: 53.2159, lon: 6.5766 })
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.forecast).toBeNull();
expect(result.current.tide?.station).toBe("test");
expect(result.current.forecastError).toBe("Wetterdaten nicht erreichbar");
expect(result.current.tideError).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.queryPosition).toEqual({ lat: 53.22, lon: 6.58 });
expect(result.current.refreshedAt).toEqual(expect.any(String));
});
});
@@ -0,0 +1,120 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
NavigationToolRail,
type NavigationToolId
} from "../src/components/NavigationToolRail";
import { NavigationWorkspace } from "../src/components/NavigationWorkspace";
afterEach(cleanup);
describe("NavigationToolRail", () => {
it("renders the fixed tool order with accessible state and badge information", () => {
const onSelect = vi.fn<(tool: NavigationToolId) => void>();
render(
<NavigationToolRail
activeTool="conditions"
onSelect={onSelect}
workspaceId="test-workspace"
statuses={{
anchor: "alarm",
conditions: "active",
upcoming: "caution",
route: "stale"
}}
badges={{ upcoming: 123 }}
/>
);
const rail = screen.getByRole("navigation", { name: "Kartenwerkzeuge" });
const buttons = within(rail).getAllByRole("button");
expect(buttons.map((button) => button.dataset.tool)).toEqual([
"anchor",
"conditions",
"upcoming",
"route"
]);
const anchor = screen.getByRole("button", { name: /Ankerwache, Alarm, öffnen/ });
expect(anchor).toHaveAttribute("data-status", "alarm");
expect(anchor).toHaveAttribute("aria-controls", "test-workspace");
expect(anchor).toHaveAttribute("aria-expanded", "false");
expect(anchor).toHaveAttribute("aria-pressed", "false");
const conditions = screen.getByRole("button", {
name: /Wetter und Tide, aktiv, geöffnet/
});
expect(conditions).toHaveAttribute("aria-expanded", "true");
expect(conditions).toHaveAttribute("aria-pressed", "true");
const upcoming = screen.getByRole("button", {
name: /Als Nächstes, Warnung, 99\+ Hinweise, öffnen/
});
expect(upcoming).toHaveAttribute("data-status", "caution");
expect(within(upcoming).getByText("99+")).toBeVisible();
const route = screen.getByRole("button", { name: /Route, Daten veraltet, öffnen/ });
fireEvent.click(route);
expect(onSelect).toHaveBeenCalledWith("route");
});
});
describe("NavigationWorkspace", () => {
it("keeps compact content inaccessible and exposes controlled size and close actions", () => {
const onSheetStateChange = vi.fn();
const onClose = vi.fn();
const onBack = vi.fn();
const { container, rerender } = render(
<NavigationWorkspace
id="test-workspace"
activeTool="route"
title="Routenübersicht"
summary="12 sm bis zum Ziel"
status="active"
sheetState="compact"
presentation="bottom-sheet"
onSheetStateChange={onSheetStateChange}
onBack={onBack}
onClose={onClose}
footer={<button type="button">Navigation starten</button>}
>
<button type="button">Route bearbeiten</button>
</NavigationWorkspace>
);
const workspace = screen.getByRole("complementary", { name: "Routenübersicht" });
expect(workspace).toHaveAttribute("data-tool", "route");
expect(workspace).toHaveAttribute("data-status", "active");
expect(workspace).toHaveAttribute("data-presentation", "bottom-sheet");
expect(workspace).toHaveAttribute("data-sheet-state", "compact");
expect(container.querySelector(".navigation-workspace-body")).toHaveAttribute("hidden");
expect(container.querySelector(".navigation-workspace-footer")).toHaveAttribute("hidden");
expect(screen.queryByRole("button", { name: "Route bearbeiten" })).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "Arbeitsbereich auf halbe Höhe vergrößern" })
);
expect(onSheetStateChange).toHaveBeenCalledWith("half");
fireEvent.click(screen.getByRole("button", { name: "Zurück" }));
expect(onBack).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Route schließen" }));
expect(onClose).toHaveBeenCalledTimes(1);
rerender(
<NavigationWorkspace
id="test-workspace"
activeTool="route"
title="Routenübersicht"
sheetState="half"
onClose={onClose}
>
<button type="button">Route bearbeiten</button>
</NavigationWorkspace>
);
expect(screen.getByRole("button", { name: "Route bearbeiten" })).toBeVisible();
expect(container.querySelector(".navigation-workspace-body")).not.toHaveAttribute("hidden");
});
});
+108
View File
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import {
OFFLINE_VOYAGES_STORAGE_KEY,
createOfflineVoyageRecord,
deleteOfflineVoyage,
listOfflineVoyages,
loadOfflineVoyage,
saveOfflineVoyage
} from "../src/lib/offline-route";
beforeEach(() => localStorage.clear());
describe("offline voyage storage", () => {
it("stores and restores a validated route and voyage plan", () => {
const saved = saveOfflineVoyage(
{
route: routeFixture,
name: "Törn Emden Hamm",
plan: {
vesselProfile: {
draughtM: 1.4,
safetyReserveM: 0.5,
airDraftM: 2.8,
beamM: 3.2,
cruiseSpeedKn: 6
},
waypoints: [{ lat: 51.65, lon: 7.34 }],
departureAt: "2026-07-20T06:00:00.000Z",
notes: "Schleusen vor Abfahrt prüfen"
}
},
localStorage,
{ id: "voyage-test", savedAt: "2026-07-19T12:00:00.000Z" }
);
expect(saved.plan.start).toEqual({ lat: 53.344167, lon: 7.186111 });
expect(saved.plan.destination).toEqual({ lat: 51.6814536, lon: 7.8042615 });
expect(saved.route.departureTime).toBe("2026-07-20T06:00:00.000Z");
expect(saved.route.durationMinutes).toBe(120);
expect(saved.route.alternatives).toBeUndefined();
expect(listOfflineVoyages(localStorage)).toHaveLength(1);
const restored = loadOfflineVoyage("voyage-test", localStorage);
expect(restored).toEqual(saved);
expect(restored?.route).toEqual(
expect.objectContaining({
departureTime: "2026-07-20T06:00:00.000Z",
durationMinutes: 120
})
);
expect(deleteOfflineVoyage("voyage-test", localStorage)).toBe(true);
expect(loadOfflineVoyage("voyage-test", localStorage)).toBeNull();
});
it("ignores corrupted or untrusted persisted values", () => {
localStorage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, "not-json");
expect(listOfflineVoyages(localStorage)).toEqual([]);
localStorage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, JSON.stringify([{
schemaVersion: 1,
id: "bad",
name: "Bad",
savedAt: "no date",
plan: {},
route: { geometry: { type: "LineString", coordinates: [[999, 0], [0, 0]] } }
}]));
expect(listOfflineVoyages(localStorage)).toEqual([]);
});
it("rejects unsafe route coordinates before writing", () => {
expect(() => createOfflineVoyageRecord({
route: {
...routeFixture,
geometry: { type: "LineString", coordinates: [[7, 53], [Number.NaN, 52]] }
}
})).toThrow(/ungültige Koordinaten|Zahlenwert/);
expect(localStorage.getItem(OFFLINE_VOYAGES_STORAGE_KEY)).toBeNull();
});
});
const routeFixture: RouteResult = {
id: "emden-hamm",
name: "Emden Hamm",
geometry: {
type: "LineString",
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]]
},
distanceNm: 153.69,
departureTime: "2026-07-20T06:00:00.000Z",
durationMinutes: 120,
eta: "2026-07-20T08:00:00.000Z",
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich" }],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OSM"],
routingMode: "fairway",
alternatives: [{
id: "unused",
name: "Alternative",
geometry: { type: "LineString", coordinates: [[7, 53], [7.1, 52]] },
distanceNm: 160,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OSM"]
}]
};
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { distanceToRouteM, evaluateRouteDeviation } from "../src/lib/route-deviation";
describe("route deviation", () => {
it("calculates the cross-track distance to a route segment", () => {
const distance = distanceToRouteM(
{ lat: 0.001, lon: 0.005 },
[[0, 0], [0.01, 0]]
);
expect(distance).toBeGreaterThan(110);
expect(distance).toBeLessThan(112.5);
});
it("uses the endpoint when the nearest point lies beyond the segment", () => {
const distance = distanceToRouteM(
{ lat: 0, lon: 0.02 },
[[0, 0], [0.01, 0]]
);
expect(distance).toBeGreaterThan(1_110);
expect(distance).toBeLessThan(1_113);
});
it("handles a short segment crossing the antimeridian", () => {
const distance = distanceToRouteM(
{ lat: 0.001, lon: 180 },
[[179.9, 0], [-179.9, 0]]
);
expect(distance).toBeGreaterThan(110);
expect(distance).toBeLessThan(113);
});
it("accounts conservatively for GPS accuracy and suppresses unreliable alarms", () => {
const route = [[0, 0], [0.01, 0]] as const;
const near = evaluateRouteDeviation({ lat: 0.001, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 30 });
const away = evaluateRouteDeviation({ lat: 0.002, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 30 });
const inaccurate = evaluateRouteDeviation({ lat: 0.01, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 500 });
expect(near?.isOffRoute).toBe(false);
expect(near?.conservativeDistanceM).toBeLessThan(100);
expect(away?.isOffRoute).toBe(true);
expect(inaccurate?.reliable).toBe(false);
expect(inaccurate?.isOffRoute).toBe(false);
});
it("returns null instead of alarming for invalid or missing route data", () => {
expect(distanceToRouteM({ lat: Number.NaN, lon: 7 }, [[7, 53], [7.1, 53.1]])).toBeNull();
expect(distanceToRouteM({ lat: 53, lon: 7 }, [])).toBeNull();
});
});
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import type {
RouteResult,
VoyageHarbour
} from "@watermaps/shared";
import {
nextRouteEventsByKind,
upcomingRouteEvents
} from "../src/routeEvents";
import type { RouteBridgeAssessment } from "../src/routeWeatherReport";
import type { RouteLock } from "../src/voyageHarbours";
const route: RouteResult = {
id: "eastbound",
geometry: {
type: "LineString",
coordinates: [
[0, 0],
[1, 0]
]
},
distanceNm: 60,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["test"]
};
describe("upcomingRouteEvents", () => {
it("projects mixed facilities, filters passed and off-corridor entries, and orders them along the route", () => {
const events = upcomingRouteEvents({
route,
progressNm: 12,
harbours: [
harbour("passed-harbour", 0.1, 0),
harbour("next-harbour", 0.6, 0),
harbour("off-route-harbour", 0.5, 0.05)
],
locks: [
lock("next-lock", 0.4, 0, 999, 0),
lock("off-route-lock", 0.5, 0.01, 1, 0)
],
bridges: [
bridge("next-bridge", 0.3, 0.0005, 99),
bridge("off-route-bridge", 0.35, 0.01, 0.001)
]
});
expect(events.map((event) => `${event.kind}:${event.id}`)).toEqual([
"bridge:next-bridge",
"lock:next-lock",
"harbour:next-harbour"
]);
expect(events.every((event) => event.routeDistanceNm >= 12)).toBe(true);
expect(events.every((event) => event.remainingNm >= 0)).toBe(true);
});
it("reprojects locks and bridges instead of trusting their existing distance fields", () => {
const [projectedLock, projectedBridge] = upcomingRouteEvents({
route,
locks: [lock("lock", 0.25, 0, 999, 777)],
bridges: [bridge("bridge", 0.75, 0, 999)]
});
expect(projectedLock?.kind).toBe("lock");
expect(projectedLock?.routeDistanceNm).toBeCloseTo(15, 0);
expect(projectedLock?.distanceFromRouteNm).toBeCloseTo(0, 6);
expect(projectedBridge?.kind).toBe("bridge");
expect(projectedBridge?.routeDistanceNm).toBeCloseTo(45, 0);
expect(projectedBridge?.distanceFromRouteNm).toBeCloseTo(0, 6);
});
it("uses a corridor override without changing the defaults for other kinds", () => {
const events = upcomingRouteEvents({
route,
corridorsNm: { harbour: 4 },
harbours: [harbour("detour", 0.5, 0.05)],
locks: [lock("off-route-lock", 0.5, 0.01, 0, 0)]
});
expect(events.map((event) => event.id)).toEqual(["detour"]);
});
it("calculates ETA and retains both speed and reference-time provenance", () => {
const [event] = upcomingRouteEvents({
route,
progressNm: 6,
locks: [lock("lock", 0.5, 0, 0, 0)],
etaBasis: {
speedKn: 6,
speedSource: "vessel-cruise-speed",
referenceTime: "2026-07-23T08:00:00.000Z",
referenceSource: "route-departure"
}
});
expect(event).toBeDefined();
expect(event!.remainingNm).toBeCloseTo(24, 0);
expect(event!.eta).toMatchObject({
speedKn: 6,
speedSource: "vessel-cruise-speed",
referenceTime: "2026-07-23T08:00:00.000Z",
referenceSource: "route-departure"
});
expect(event!.eta!.minutesFromProgress).toBeCloseTo(
(event!.remainingNm / 6) * 60,
8
);
expect(Date.parse(event!.eta!.estimatedAt)).toBeCloseTo(
Date.parse("2026-07-23T08:00:00.000Z") +
event!.eta!.minutesFromProgress * 60_000,
0
);
});
it("omits ETA for an invalid assumption rather than inventing a speed", () => {
const [event] = upcomingRouteEvents({
route,
harbours: [harbour("harbour", 0.5, 0)],
etaBasis: {
speedKn: 0,
speedSource: "gps-sog",
referenceTime: "not-a-time",
referenceSource: "current-time"
}
});
expect(event?.eta).toBeNull();
});
it("selects the first upcoming event of each kind from an ordered list", () => {
const events = upcomingRouteEvents({
route,
harbours: [
harbour("harbour-2", 0.8, 0),
harbour("harbour-1", 0.2, 0)
],
locks: [lock("lock-1", 0.3, 0, 0, 0)]
});
const next = nextRouteEventsByKind(events);
expect(next.harbour?.id).toBe("harbour-1");
expect(next.lock?.id).toBe("lock-1");
expect(next.bridge).toBeNull();
});
});
function harbour(
id: string,
lon: number,
lat: number
): VoyageHarbour {
return {
id,
name: id,
kind: "harbour",
coordinate: { lon, lat }
};
}
function lock(
id: string,
lon: number,
lat: number,
routeDistanceNm: number,
distanceFromRouteNm: number
): RouteLock {
return {
id,
name: id,
coordinate: { lon, lat },
routeDistanceNm,
distanceFromRouteNm,
openingHours: null,
phone: null,
vhf: null,
website: null
};
}
function bridge(
id: string,
lon: number,
lat: number,
distanceNm: number
): RouteBridgeAssessment {
return {
id,
name: id,
label: id,
coordinate: { lon, lat },
distanceNm,
clearanceM: null,
clearanceLabel: null,
requiredAirDraftM: null,
marginM: null,
status: "unknown",
source: "test"
};
}
+605
View File
@@ -0,0 +1,605 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RoutePlanner } from "../src/components/RoutePlanner";
import type { RouteWeatherReport } from "../src/routeWeatherReport";
afterEach(() => cleanup());
describe("RoutePlanner", () => {
it("submits vessel profile when start and destination exist", () => {
const onSubmit = vi.fn();
render(
<RoutePlanner
startPoint={{ lat: 54.18, lon: 12.08 }}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={{ lat: 54.2, lon: 12.1 }}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={onSubmit}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Route berechnen" }));
expect(onSubmit).toHaveBeenCalledWith({
start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.2, lon: 12.1 },
waypoints: [],
departureTime: expect.any(String),
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
});
});
it("submits ordered waypoints together with the selected departure time", () => {
const onSubmit = vi.fn();
const waypoints = [
{ lat: 52.4, lon: 7.1 },
{ lat: 51.9, lon: 7.45 }
];
render(
<RoutePlanner
startPoint={{ lat: 53.344167, lon: 7.186111 }}
gpsPosition={null}
destination={{ lat: 51.6814536, lon: 7.8042615 }}
waypoints={waypoints}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={onSubmit}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onPickWaypoint={vi.fn()}
onRemoveWaypoint={vi.fn()}
onMoveWaypoint={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
const localDeparture = "2026-07-20T08:30";
fireEvent.change(screen.getByLabelText("Abfahrt"), { target: { value: localDeparture } });
fireEvent.click(screen.getByRole("button", { name: "Route berechnen" }));
expect(onSubmit).toHaveBeenCalledWith({
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 51.6814536, lon: 7.8042615 },
waypoints,
departureTime: new Date(localDeparture).toISOString(),
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
});
});
it("offers an explicit start picking action", () => {
const onPickStart = vi.fn();
render(
<RoutePlanner
startPoint={null}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={onPickStart}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Start auf Karte setzen" }));
expect(onPickStart).toHaveBeenCalledTimes(1);
});
it("offers an explicit destination picking action", () => {
const onPickDestination = vi.fn();
render(
<RoutePlanner
startPoint={null}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={onPickDestination}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Ziel auf Karte setzen" }));
expect(onPickDestination).toHaveBeenCalledTimes(1);
});
it("does not offer Borkum or Hamm as direct destination shortcuts", () => {
render(
<RoutePlanner
startPoint={null}
gpsPosition={null}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
expect(screen.queryByRole("button", { name: /Borkum/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Hamm/i })).not.toBeInTheDocument();
});
it("lets the skipper switch between returned route alternatives", () => {
const onSelectRoute = vi.fn();
const primary = routeOption("primary", "Hauptroute", 12);
const alternative = routeOption("alternative", "Alternative 1", 14.5);
render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={primary}
routeOptions={[primary, alternative]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={onSelectRoute}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /Alternative 1/ }));
expect(onSelectRoute).toHaveBeenCalledWith("alternative");
});
it("can collapse the route planner", () => {
const onCollapse = vi.fn();
render(
<RoutePlanner
startPoint={null}
gpsPosition={null}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={onCollapse}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Routenfenster ausblenden" }));
expect(onCollapse).toHaveBeenCalledTimes(1);
});
it("controls mobile sheet heights and leaves compact mode when the desktop layout starts", () => {
let desktopLayout = false;
let changeListener: (() => void) | null = null;
const originalMatchMedia = window.matchMedia;
const mediaQuery = {
get matches() {
return desktopLayout;
},
media: "(min-width: 720px)",
onchange: null,
addEventListener: vi.fn((_type: string, listener: () => void) => {
changeListener = listener;
}),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
} as unknown as MediaQueryList;
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => mediaQuery)
});
const { container, unmount } = render(
<RoutePlanner
startPoint={null}
gpsPosition={null}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
const panel = container.querySelector("aside.route-panel");
const body = container.querySelector(".route-panel-body");
expect(panel).toHaveAttribute("data-sheet-state", "half");
fireEvent.click(screen.getByRole("button", { name: "Routenfenster auf volle Höhe vergrößern" }));
expect(panel).toHaveAttribute("data-sheet-state", "full");
fireEvent.click(screen.getByRole("button", { name: "Routenfenster auf kompakte Höhe verkleinern" }));
expect(panel).toHaveAttribute("data-sheet-state", "compact");
expect(body).toHaveAttribute("hidden");
desktopLayout = true;
act(() => changeListener?.());
expect(panel).toHaveAttribute("data-sheet-state", "half");
expect(body).not.toHaveAttribute("hidden");
unmount();
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: originalMatchMedia
});
});
it("offers the course assistant only after a route was planned", () => {
const onStartGuidance = vi.fn();
const { rerender } = render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onStartGuidance={onStartGuidance}
onCollapse={vi.fn()}
/>
);
expect(screen.queryByRole("button", { name: "Navigation starten" })).not.toBeInTheDocument();
rerender(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={routeOption("primary", "Hauptroute", 4)}
routeOptions={[routeOption("primary", "Hauptroute", 4)]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onStartGuidance={onStartGuidance}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Navigation starten" }));
expect(onStartGuidance).toHaveBeenCalledTimes(1);
});
it("opens the compact result view and keeps critical warnings above secondary route details", () => {
const result = {
...routeOption("primary", "Hauptroute", 4),
warnings: [
{ code: "weather", severity: "caution" as const, message: "Wind aufmerksam beobachten." },
{ code: "bridge", severity: "critical" as const, message: "Brücke ist zu niedrig." }
]
};
render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={result}
routeOptions={[result]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
expect(screen.getByText("Routenergebnis")).toBeVisible();
expect(screen.getByText("Brücke ist zu niedrig.")).toBeVisible();
expect(screen.getByText("Wind aufmerksam beobachten.")).toBeVisible();
expect(screen.queryByRole("button", { name: "Start auf Karte setzen" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Plan ändern" }));
expect(screen.getByText("Route planen")).toBeVisible();
expect(screen.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
expect(screen.getByRole("button", { name: "Zur Route" })).toBeVisible();
});
it("shows the route weather report after a route was calculated", () => {
render(
<RoutePlanner
startPoint={{ lat: 54.18, lon: 12.08 }}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={{ lat: 54.2, lon: 12.1 }}
result={{
geometry: { type: "LineString", coordinates: [[12.08, 54.18], [12.1, 54.2]] },
distanceNm: 4.2,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
}}
routeOptions={[]}
weatherReport={weatherReportFixture}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
expect(screen.getByRole("region", { name: "Fahrtbericht" })).toBeVisible();
expect(screen.getByText(/Aufmerksam fahren/)).toBeVisible();
expect(screen.getByText("18 kn 270°")).toBeVisible();
expect(screen.getByText("1.2 m 290°")).toBeVisible();
expect(screen.getByText(/Nicht passierbar/)).toBeVisible();
expect(screen.getByText("Niedrige Brücke")).toBeVisible();
});
it("keeps lock-delay planning and the current-adjusted ETA in the embedded route tool", () => {
const adjustedReport: RouteWeatherReport = {
...weatherReportFixture,
adjustedEta: "2026-07-13T14:45:00.000Z",
currentAdjustmentMinutes: 45,
averageAlongRouteCurrentKn: -0.6
};
const result = routeOption("primary", "Hauptroute", 18);
render(
<RoutePlanner
startPoint={{ lat: 53.3, lon: 7.2 }}
gpsPosition={null}
destination={{ lat: 52.9, lon: 7.4 }}
result={result}
routeOptions={[result]}
weatherReport={adjustedReport}
weatherLoading={false}
weatherError={null}
routeLocks={[
{
id: "lock-1",
name: "Testschleuse",
coordinate: { lat: 53.1, lon: 7.3 },
routeDistanceNm: 8,
distanceFromRouteNm: 0.02,
openingHours: "06:00-22:00",
phone: "+49 123 456",
vhf: "20",
website: null
}
]}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
operationalPanelsVisible={false}
embedded
/>
);
expect(screen.getByText(/Strömungs-ETA/)).toBeVisible();
fireEvent.click(screen.getByText("Schleusenplanung · 1 auf der Route"));
expect(screen.getByRole("spinbutton", { name: /Pauschale/ })).toHaveValue(20);
expect(screen.getByText(/Plan-ETA inkl. Schleusenpuffer/)).toBeVisible();
expect(screen.queryByRole("region", { name: "Fahrtbericht" })).not.toBeInTheDocument();
});
});
const weatherReportFixture: RouteWeatherReport = {
samples: [
{
label: "Start",
coordinate: { lat: 54.18, lon: 12.08 },
forecast: {
waveHeightM: 0.8,
waveDirectionDeg: 280,
wavePeriodS: 4,
windSpeed: 12,
windDirectionDeg: 260,
weatherCode: 2,
temperatureC: 18,
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z"
}
},
{
label: "Ziel",
coordinate: { lat: 54.2, lon: 12.1 },
forecast: {
waveHeightM: 1.2,
waveDirectionDeg: 290,
wavePeriodS: 5,
windSpeed: 18,
windDirectionDeg: 270,
weatherCode: 3,
temperatureC: 18,
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z"
}
}
],
maxWaveHeightM: 1.2,
maxWindSpeedKn: 18,
maxWavePeriodS: 5,
strongestWindDirectionDeg: 270,
highestWaveDirectionDeg: 290,
severity: "caution",
summary: "Aufmerksam fahren: bis 18 kn Wind, 1.2 m Welle.",
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z",
unavailableSamples: 0,
departureTime: "2026-07-13T12:00:00.000Z",
adjustedEta: null,
currentAdjustmentMinutes: null,
averageAlongRouteCurrentKn: null,
bridgeReport: {
bridges: [
{
id: "bridge-low",
name: "Niedrige Brücke",
label: "Niedrige Brücke H 2.2 m",
coordinate: { lat: 54.19, lon: 12.09 },
distanceNm: 0,
clearanceM: 2.2,
clearanceLabel: "H 2.2 m",
requiredAirDraftM: 2.5,
marginM: -0.3,
status: "too_low",
source: "OSM/Geofabrik"
}
],
requiredAirDraftM: 2.5,
checkedCount: 1,
unknownCount: 0,
tooLowCount: 1,
tightCount: 0,
minClearanceM: 2.2,
severity: "critical",
summary: "Nicht passierbar: 1 Brücke(n) niedriger als 2.5 m Bootshöhe.",
source: "OSM/Geofabrik",
updatedAt: "2026-07-13T12:00:00.000Z"
}
};
function routeOption(id: string, name: string, distanceNm: number) {
return {
id,
name,
geometry: { type: "LineString" as const, coordinates: [[7, 52], [7.1, 52]] as [number, number][] },
distanceNm,
eta: "2026-07-13T14:00:00.000Z",
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway" as const
};
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import type { MarineForecast, RouteResult } from "@watermaps/shared";
import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport";
describe("route weather report", () => {
it("samples start, middle and destination forecasts", async () => {
const requested: Array<{ lat: number; lon: number }> = [];
const report = await createRouteWeatherReport(routeFixture, async (coordinate) => {
requested.push(coordinate);
return forecast({
windSpeed: coordinate.lon > 7.1 ? 18 : 10,
waveHeightM: coordinate.lon > 7.1 ? 1.2 : 0.4
});
});
expect(requested).toHaveLength(3);
expect(report.maxWindSpeedKn).toBe(18);
expect(report.maxWaveHeightM).toBe(1.2);
expect(report.severity).toBe("caution");
expect(report.summary).toContain("Aufmerksam fahren");
expect(report.bridgeReport).toBeNull();
});
it("projects forecasts along the route timeline and adjusts ETA for along-route current", async () => {
const requestedTimes: Array<string | undefined> = [];
const departureTime = "2026-07-20T06:00:00.000Z";
const timedRoute: RouteResult = {
...routeFixture,
geometry: {
type: "LineString",
coordinates: [[7, 52], [7.2, 52], [7.4, 52]]
},
distanceNm: 20,
departureTime,
durationMinutes: 240,
eta: "2026-07-20T10:00:00.000Z"
};
const report = await createRouteWeatherReport(
timedRoute,
{ draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 5 },
async (_coordinate, at) => {
requestedTimes.push(at);
return forecast({
oceanCurrentSpeedKn: 1,
oceanCurrentDirectionDeg: 90,
forecastTime: at
});
}
);
expect(requestedTimes).toEqual([
"2026-07-20T06:00:00.000Z",
"2026-07-20T08:00:00.000Z",
"2026-07-20T10:00:00.000Z"
]);
expect(report.samples.map((sample) => sample.plannedTime)).toEqual(requestedTimes);
expect(report.samples.map((sample) => sample.currentAlongRouteKn)).toEqual([1, 1, 1]);
expect(report.departureTime).toBe(departureTime);
expect(report.averageAlongRouteCurrentKn).toBe(1);
expect(report.currentAdjustmentMinutes).toBe(-40);
expect(report.adjustedEta).toBe("2026-07-20T09:20:00.000Z");
});
it("adds a bridge report and marks a route as blocked by a low bridge", async () => {
const report = await createRouteWeatherReport(
routeFixture,
{ draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 3 },
async () => forecast({}),
async (params) => {
expect(params.layers).toEqual(["bridges"]);
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "bridge-low",
properties: {
layer: "bridges",
name: "Niedrige Brücke",
clearance_m: 2.7,
clearance_label: "H 2.7 m",
label: "Niedrige Brücke H 2.7 m",
source: "OSM/Geofabrik"
},
geometry: {
type: "LineString",
coordinates: [
[6.99, 53.5],
[7.21, 53.5]
]
}
},
{
type: "Feature",
id: "bridge-high",
properties: {
layer: "bridges",
name: "Hohe Brücke",
clearance_m: 4.2,
clearance_label: "H 4.2 m",
label: "Hohe Brücke H 4.2 m",
source: "OSM/Geofabrik"
},
geometry: {
type: "LineString",
coordinates: [
[7.18, 53.68],
[7.22, 53.68]
]
}
}
]
};
}
);
expect(report.bridgeReport?.severity).toBe("critical");
expect(report.bridgeReport?.tooLowCount).toBe(1);
expect(report.bridgeReport?.checkedCount).toBe(2);
expect(report.bridgeReport?.summary).toContain("Nicht passierbar");
expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke");
});
it("marks critical weather when wind or wave thresholds are exceeded", () => {
const report = summarizeRouteWeather([
{
label: "Mitte",
coordinate: { lat: 53.5, lon: 7.1 },
forecast: forecast({ windSpeed: 28, waveHeightM: 1.1 })
}
]);
expect(report.severity).toBe("critical");
expect(report.summary).toContain("Kritische Bedingungen");
});
});
const routeFixture: RouteResult = {
geometry: {
type: "LineString",
coordinates: [
[7, 53.3],
[7.1, 53.5],
[7.2, 53.7]
]
},
distanceNm: 25,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
};
function forecast(overrides: Partial<MarineForecast>): MarineForecast {
return {
waveHeightM: 0.4,
waveDirectionDeg: 280,
wavePeriodS: 4,
windSpeed: 10,
windDirectionDeg: 260,
weatherCode: 2,
temperatureC: 18,
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z",
...overrides
};
}
+86
View File
@@ -0,0 +1,86 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { MarineForecast, RouteGuidanceResult } from "@watermaps/shared";
import { StatusBar } from "../src/components/StatusBar";
import type { GpsState } from "../src/hooks/useGeolocation";
afterEach(cleanup);
const idleGps: GpsState = {
status: "idle",
position: null,
accuracyM: null,
speedKn: null,
courseDeg: null,
timestampMs: null,
message: null
};
describe("StatusBar", () => {
it("shows only three planning values without claiming that a route is safe", () => {
const forecast = {
windSpeed: 12,
waveHeightM: 0.8
} as MarineForecast;
const { container } = render(
<StatusBar
gps={idleGps}
forecast={forecast}
tide={null}
routeWarningCount={0}
mode="planning"
/>
);
expect(container.querySelectorAll(".status-item")).toHaveLength(3);
expect(screen.getByText("12 kn · 0.8 m")).toBeVisible();
expect(screen.queryByText("Route OK")).not.toBeInTheDocument();
});
it("prioritizes course, cross-track error and open warnings during guidance", () => {
const guidance = {
desiredCourseDeg: 87,
distanceToRouteM: 24,
crossTrackSide: "starboard"
} as RouteGuidanceResult;
render(
<StatusBar
gps={{ ...idleGps, status: "tracking", speedKn: 6.2 }}
forecast={null}
tide={null}
routeWarningCount={2}
mode="guidance"
guidance={guidance}
/>
);
expect(screen.getByText("087°T")).toBeVisible();
expect(screen.getByText("24 m Stb")).toBeVisible();
expect(screen.getByText("2 offen")).toBeVisible();
});
it("shows anchor drift, GPS and the expected tide rise in anchor mode", () => {
render(
<StatusBar
gps={{ ...idleGps, status: "tracking", position: { lat: 53.2, lon: 7.1 }, accuracyM: 5 }}
forecast={null}
tide={null}
routeWarningCount={0}
mode="anchor"
anchor={{
distanceFromAnchorM: 11.5,
alarmRadiusM: 40,
alarm: false,
maximumTideRiseM: 1.3
}}
/>
);
expect(screen.getByText("12 / 40 m")).toBeVisible();
expect(screen.getByText("±5 m")).toBeVisible();
expect(screen.getByText("+1.3 m")).toBeVisible();
});
});
@@ -0,0 +1,197 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { UpcomingRouteEvent } from "../src/routeEvents";
import { UpcomingEventsPanel } from "../src/components/UpcomingEventsPanel";
afterEach(cleanup);
describe("UpcomingEventsPanel", () => {
it("shows the nearest event first, preserves chronological list order and filters by kind", () => {
render(<UpcomingEventsPanel events={events} hasRoute />);
const hero = screen.getByText("Nächstes Ereignis").closest("article");
expect(hero).not.toBeNull();
expect(within(hero!).getByText("Schleuse Nah")).toBeVisible();
expect(within(hero!).getByText("3,0 sm")).toBeVisible();
expect(within(hero!).getByText(/ETA/)).toBeVisible();
const list = screen.getByRole("list");
expect(
within(list)
.getAllByRole("button")
.map((button) => button.textContent)
).toEqual([
expect.stringContaining("Schleuse Nah"),
expect.stringContaining("Brücke Mitte"),
expect.stringContaining("Hafen Weit")
]);
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute(
"aria-pressed",
"true"
);
expect(screen.getAllByText("Brücke Mitte")).toHaveLength(2);
expect(screen.queryByText("Schleuse Nah")).not.toBeInTheDocument();
expect(screen.queryByText("Hafen Weit")).not.toBeInTheDocument();
expect(within(screen.getByRole("list")).getAllByRole("button")).toHaveLength(1);
});
it("opens a lock drilldown with direct contact, VHF, hours and map actions", () => {
const onShowOnMap = vi.fn();
render(
<UpcomingEventsPanel
events={events}
hasRoute
onShowOnMap={onShowOnMap}
/>
);
const hero = screen.getByText("Nächstes Ereignis").closest("article");
fireEvent.click(within(hero!).getByRole("button", { name: /Schleuse Nah/ }));
const detail = screen.getByRole("region", { name: "Schleuse Nah" });
expect(within(detail).getByText("MoFr 08:0018:00")).toBeVisible();
expect(within(detail).getByText("Kanal 12")).toBeVisible();
expect(within(detail).getByRole("link", { name: "Schleuse Nah anrufen" })).toHaveAttribute(
"href",
"tel:+4949123456"
);
expect(
within(detail).getByRole("link", { name: "Website von Schleuse Nah öffnen" })
).toHaveAttribute("href", "https://lock.example/");
fireEvent.click(within(detail).getByRole("button", { name: "Auf Karte zeigen" }));
expect(onShowOnMap).toHaveBeenCalledWith(events[0]);
fireEvent.click(within(detail).getByRole("button", { name: "Zurück zur Ereignisliste" }));
expect(screen.getByText("Nächstes Ereignis")).toBeVisible();
});
it("shows bridge clearance, required height and reserve in its drilldown", () => {
render(<UpcomingEventsPanel events={events} hasRoute />);
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
const hero = screen.getByText("Nächstes Ereignis").closest("article");
fireEvent.click(within(hero!).getByRole("button", { name: /Brücke Mitte/ }));
const detail = screen.getByRole("region", { name: "Brücke Mitte" });
expect(within(detail).getByText("H 4,2 m")).toBeVisible();
expect(within(detail).getByText("3.8 m")).toBeVisible();
const reserve = within(detail).getByText("Reserve").closest("div");
expect(reserve).toHaveTextContent("0.4 m Reserve");
expect(within(detail).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument();
});
it.each([
{
props: { hasRoute: false, events: [] as UpcomingRouteEvent[] },
text: "Noch keine Route",
role: undefined
},
{
props: { hasRoute: true, events: [] as UpcomingRouteEvent[], loading: true },
text: "Ereignisse werden geladen",
role: "status"
},
{
props: {
hasRoute: true,
events: [] as UpcomingRouteEvent[],
error: "Datenquelle antwortet nicht"
},
text: "Ereignisse nicht erreichbar",
role: "alert"
},
{
props: { hasRoute: true, events: [] as UpcomingRouteEvent[] },
text: "Keine bevorstehenden Ereignisse",
role: undefined
}
])("renders the state '$text'", ({ props, text, role }) => {
render(<UpcomingEventsPanel {...props} />);
expect(screen.getByText(text)).toBeVisible();
if (role) {
expect(screen.getByRole(role)).toBeVisible();
}
});
});
const events: UpcomingRouteEvent[] = [
{
kind: "lock",
id: "lock-near",
name: "Schleuse Nah",
coordinate: { lat: 53.1, lon: 7.1 },
routeDistanceNm: 8,
distanceFromRouteNm: 0.02,
remainingNm: 3,
eta: eta("2026-07-23T12:30:00.000Z", 30),
feature: {
id: "lock-near",
name: "Schleuse Nah",
coordinate: { lat: 53.1, lon: 7.1 },
routeDistanceNm: 8,
distanceFromRouteNm: 0.02,
openingHours: "MoFr 08:0018:00",
phone: "+49 49 123456",
vhf: "Kanal 12",
website: "lock.example"
}
},
{
kind: "harbour",
id: "harbour-far",
name: "Hafen Weit",
coordinate: { lat: 53.3, lon: 7.3 },
routeDistanceNm: 17,
distanceFromRouteNm: 0.4,
remainingNm: 12,
eta: eta("2026-07-23T14:00:00.000Z", 120),
feature: {
id: "harbour-far",
name: "Hafen Weit",
coordinate: { lat: 53.3, lon: 7.3 },
kind: "marina",
amenities: { water: "available", electricity: true },
phone: null,
website: null
}
},
{
kind: "bridge",
id: "bridge-middle",
name: "Brücke Mitte",
coordinate: { lat: 53.2, lon: 7.2 },
routeDistanceNm: 11,
distanceFromRouteNm: 0.01,
remainingNm: 6,
eta: eta("2026-07-23T13:00:00.000Z", 60),
feature: {
id: "bridge-middle",
name: "Brücke Mitte",
label: "Brücke Mitte H 4,2 m",
coordinate: { lat: 53.2, lon: 7.2 },
distanceNm: 0.01,
clearanceM: 4.2,
clearanceLabel: "H 4,2 m",
requiredAirDraftM: 3.8,
marginM: 0.4,
status: "tight",
source: "Test"
}
}
];
function eta(estimatedAt: string, minutesFromProgress: number) {
return {
estimatedAt,
minutesFromProgress,
speedKn: 6,
speedSource: "vessel-cruise-speed" as const,
referenceTime: "2026-07-23T12:00:00.000Z",
referenceSource: "current-time" as const
};
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import type { FeatureCollection, Geometry } from "geojson";
import {
routeFeatureBounds,
routeLocksFromFeatures,
voyageHarboursFromFeatures
} from "../src/voyageHarbours";
describe("voyage harbour feature adapter", () => {
it("turns normalized point features into amenity-aware harbour candidates", () => {
const collection: FeatureCollection<Geometry> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "hamm-marina",
geometry: { type: "Point", coordinates: [7.8, 51.68] },
properties: {
layer: "harbours",
name: "Marina Hamm",
leisure: "marina",
phone: "+49 2381 123",
website: "hamm.example",
email: "hafen@hamm.example",
vhf: "12",
opening_hours: "täglich 08:00-20:00",
operator: "Hafen Hamm",
"addr:street": "Uferweg",
"addr:housenumber": "4",
"addr:postcode": "59000",
"addr:city": "Hamm",
power_supply: "yes",
drinking_water: "yes",
guest_berths: 8
}
},
{
type: "Feature",
geometry: { type: "LineString", coordinates: [[7, 52], [8, 52]] },
properties: { layer: "harbours", name: "Keine Punktgeometrie" }
},
{
type: "Feature",
geometry: { type: "Point", coordinates: [7.7, 51.7] },
properties: { layer: "locks", name: "Keine Marina" }
}
]
};
expect(voyageHarboursFromFeatures(collection)).toEqual([
expect.objectContaining({
id: "hamm-marina",
name: "Marina Hamm",
kind: "marina",
coordinate: { lat: 51.68, lon: 7.8 },
email: "hafen@hamm.example",
vhf: "12",
openingHours: "täglich 08:00-20:00",
operator: "Hafen Hamm",
address: "Uferweg 4, 59000 Hamm",
amenities: expect.objectContaining({
electricity: "available",
water: "available",
overnight: "available"
})
})
]);
});
it("calculates a nautical-mile padded feature request box", () => {
const bounds = routeFeatureBounds(
{
geometry: {
type: "LineString",
coordinates: [[7, 52], [8, 53]]
}
},
6
);
expect(bounds[0]).toBeLessThan(6.84);
expect(bounds[1]).toBeCloseTo(51.9, 5);
expect(bounds[2]).toBeGreaterThan(8.16);
expect(bounds[3]).toBeCloseTo(53.1, 5);
});
it("filters and orders route locks while preserving opening hours and phone", () => {
const collection: FeatureCollection<Geometry> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "lock-late",
geometry: { type: "Point", coordinates: [7.8, 52] },
properties: {
layer: "locks",
name: "Schleuse Ost",
openingHours: "Mo-Su 06:00-22:00",
phone: "+49 2381 200"
}
},
{
type: "Feature",
id: "lock-off-route",
geometry: { type: "Point", coordinates: [7.5, 52.02] },
properties: { layer: "locks", name: "Entfernte Schleuse" }
},
{
type: "Feature",
id: "lock-early",
geometry: { type: "Point", coordinates: [7.2, 52] },
properties: {
layer: "locks",
name: "Schleuse West",
opening_hours: "nach Anmeldung",
phone: "+49 2381 100"
}
},
{
type: "Feature",
id: "harbour-on-route",
geometry: { type: "Point", coordinates: [7.4, 52] },
properties: { layer: "harbours", name: "Kein Schleusenpunkt" }
}
]
};
const locks = routeLocksFromFeatures(collection, {
geometry: {
type: "LineString",
coordinates: [[7, 52], [8, 52]]
},
distanceNm: 41
});
expect(locks.map((lock) => lock.id)).toEqual(["lock-early", "lock-late"]);
expect(locks[0]).toEqual(
expect.objectContaining({
name: "Schleuse West",
openingHours: "nach Anmeldung",
phone: "+49 2381 100",
distanceFromRouteNm: 0
})
);
expect(locks[1]).toEqual(
expect.objectContaining({
name: "Schleuse Ost",
openingHours: "Mo-Su 06:00-22:00",
phone: "+49 2381 200",
distanceFromRouteNm: 0
})
);
expect(locks[0]!.routeDistanceNm).toBeLessThan(locks[1]!.routeDistanceNm);
});
});
@@ -0,0 +1,133 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { VoyageNavigationTools } from "../src/components/VoyageNavigationTools";
const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, "geolocation");
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
beforeEach(() => localStorage.clear());
afterEach(() => {
cleanup();
localStorage.clear();
restoreNavigatorProperty("geolocation", originalGeolocation);
restoreNavigatorProperty("vibrate", originalVibrate);
});
describe("VoyageNavigationTools", () => {
it("does not request geolocation before the skipper starts the alarm", () => {
const watchPosition = vi.fn(() => 17);
installGeolocation(watchPosition, vi.fn());
render(<VoyageNavigationTools route={routeFixture} />);
expect(watchPosition).not.toHaveBeenCalled();
expect(screen.getByText(/GPS wird erst nach/)).toBeInTheDocument();
});
it("does not open a second GPS watch while the course assistant is active", () => {
const watchPosition = vi.fn(() => 17);
installGeolocation(watchPosition, vi.fn());
render(<VoyageNavigationTools route={routeFixture} courseAssistantActive />);
expect(screen.getByRole("button", { name: "Kursalarm ist im Kursassistenten enthalten" })).toBeDisabled();
expect(screen.getByText(/Querabweichung.*Kursassistenten/)).toBeVisible();
expect(watchPosition).not.toHaveBeenCalled();
});
it("starts explicitly and warns when the vessel leaves the route", () => {
let success: PositionCallback | undefined;
const watchPosition = vi.fn((next: PositionCallback) => {
success = next;
return 42;
});
const clearWatch = vi.fn();
const vibrate = vi.fn();
installGeolocation(watchPosition, clearWatch);
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
render(<VoyageNavigationTools route={routeFixture} />);
fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm starten" }));
expect(watchPosition).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Abweichungsalarm stoppen" })).toBeInTheDocument();
act(() => success?.(positionAt(53.01, 7.005, 5)));
expect(screen.getByRole("alert")).toHaveTextContent(/von der Route entfernt/);
expect(vibrate).toHaveBeenCalledWith([200, 100, 200]);
fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm stoppen" }));
expect(clearWatch).toHaveBeenCalledWith(42);
});
it("saves and restores the route and plan on this device", () => {
const onLoad = vi.fn();
render(
<VoyageNavigationTools
route={routeFixture}
plan={{
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 },
departureAt: "2026-07-20T06:00:00.000Z"
}}
onLoadOfflineVoyage={onLoad}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Route offline speichern" }));
expect(screen.getByText(/ist auf diesem Gerät offline verfügbar/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Offline-Route laden" }));
expect(onLoad).toHaveBeenCalledTimes(1);
expect(onLoad.mock.calls[0]?.[0].route.geometry).toEqual(routeFixture.geometry);
expect(onLoad.mock.calls[0]?.[0].plan.vesselProfile.cruiseSpeedKn).toBe(6);
});
});
function installGeolocation(watchPosition: typeof navigator.geolocation.watchPosition, clearWatch: typeof navigator.geolocation.clearWatch) {
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: { watchPosition, clearWatch, getCurrentPosition: vi.fn() }
});
}
function restoreNavigatorProperty(name: "geolocation" | "vibrate", descriptor: PropertyDescriptor | undefined) {
if (descriptor) {
Object.defineProperty(navigator, name, descriptor);
} else {
Reflect.deleteProperty(navigator, name);
}
}
function positionAt(lat: number, lon: number, accuracy: number): GeolocationPosition {
return {
coords: {
latitude: lat,
longitude: lon,
accuracy,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: () => ({})
},
timestamp: Date.now(),
toJSON: () => ({})
};
}
const routeFixture: RouteResult = {
id: "test-route",
name: "Testfahrt",
geometry: { type: "LineString", coordinates: [[7, 53], [7.01, 53]] },
distanceNm: 0.4,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"],
routingMode: "fairway"
};
+142
View File
@@ -0,0 +1,142 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { VoyagePlan as VoyagePlanResult } from "@watermaps/shared";
import { VoyagePlan } from "../src/components/VoyagePlan";
afterEach(() => cleanup());
describe("VoyagePlan", () => {
it("shows daily legs, waypoints, harbour supply and contacts", () => {
render(<VoyagePlan plan={plan()} />);
expect(screen.getByRole("heading", { name: "Etappenplan" })).toBeInTheDocument();
expect(screen.getByText(/2 Tage/)).toHaveTextContent("2 Tage · 72,5 sm · 12 h 05 min");
expect(screen.getByText("Start → Marina Mitte")).toBeInTheDocument();
expect(screen.getByText("Via: Schleuse Eins")).toBeInTheDocument();
const supply = screen.getByRole("list", { name: "Versorgung in Marina Mitte" });
expect(within(supply).getByLabelText("Strom: verfügbar")).toBeInTheDocument();
expect(within(supply).getByLabelText("Treibstoff: nicht verfügbar")).toBeInTheDocument();
expect(within(supply).getByLabelText("Entsorgung: unbekannt")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Hafen anrufen" })).toHaveAttribute("href", "tel:+4923811234");
expect(screen.getByRole("link", { name: "Website" })).toHaveAttribute("href", "https://hafen.example");
});
it("renders unconfirmed stops and planning warnings clearly", () => {
const result = plan();
result.legs[0] = {
...result.legs[0]!,
end: {
type: "route",
name: "Tagesziel auf der Route",
coordinate: { lat: 52, lon: 7.5 },
routeDistanceNm: 40,
distanceFromRouteNm: 0,
harbour: null
}
};
result.warnings = [
{
code: "NO_SUITABLE_HARBOUR",
severity: "caution",
message: "Kein geeigneter Hafen gefunden.",
day: 1
}
];
render(<VoyagePlan plan={result} />);
expect(screen.getByText("Kein bestätigter Liegeplatz")).toBeInTheDocument();
expect(screen.getByRole("list", { name: "Hinweise zum Etappenplan" })).toHaveTextContent(
"Kein geeigneter Hafen gefunden."
);
});
it("renders nothing until a plan exists", () => {
const { container } = render(<VoyagePlan plan={null} />);
expect(container).toBeEmptyDOMElement();
});
});
function plan(): VoyagePlanResult {
const start = {
type: "start" as const,
name: "Start",
coordinate: { lat: 52, lon: 7 },
routeDistanceNm: 0,
distanceFromRouteNm: 0,
harbour: null
};
const harbour = {
id: "marina-mitte",
name: "Marina Mitte",
kind: "marina" as const,
coordinate: { lat: 52, lon: 7.5 },
phone: "+49 (2381) 1234",
website: "hafen.example",
amenities: {
electricity: "available" as const,
water: "available" as const,
fuel: "unavailable" as const,
waste: "unknown" as const,
overnight: "available" as const
},
routeDistanceNm: 40,
distanceFromRouteNm: 0.25
};
const middle = {
type: "harbour" as const,
name: harbour.name,
coordinate: harbour.coordinate,
routeDistanceNm: harbour.routeDistanceNm,
distanceFromRouteNm: harbour.distanceFromRouteNm,
harbour
};
const destination = {
type: "destination" as const,
name: "Ziel",
coordinate: { lat: 52, lon: 8 },
routeDistanceNm: 72,
distanceFromRouteNm: 0,
harbour: null
};
return {
legs: [
{
day: 1,
start,
end: middle,
routeDistanceNm: 40,
distanceNm: 40.25,
durationHours: 6.708,
waypoints: [
{
id: "lock-one",
name: "Schleuse Eins",
coordinate: { lat: 52, lon: 7.2 },
sequence: 1,
routeDistanceNm: 12,
distanceFromRouteNm: 0
}
]
},
{
day: 2,
start: middle,
end: destination,
routeDistanceNm: 32,
distanceNm: 32.25,
durationHours: 5.375,
waypoints: []
}
],
orderedWaypoints: [],
warnings: [],
requiredAmenities: ["water", "overnight"],
totalRouteDistanceNm: 72,
totalDistanceNm: 72.5,
totalDurationHours: 12.0833,
maxCruisingDistancePerDayNm: 42
};
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts", "vitest.config.ts"]
}
+113
View File
@@ -0,0 +1,113 @@
import basicSsl from "@vitejs/plugin-basic-ssl";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
const useHttps =
process.env.WATERMAPS_HTTPS === "true" ||
process.env.SEACOMPASS_HTTPS === "true" ||
process.env.HTTPS === "true";
export default defineConfig({
plugins: [
react(),
...(useHttps ? [basicSsl()] : []),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.svg"],
manifest: {
name: "Watermaps",
short_name: "Watermaps",
description: "Bootsrouten, Etappen, Live-Wasserstände, Offline-Navigation, GPS, Wetter und Tide.",
theme_color: "#0f4c5c",
background_color: "#f5f7f4",
display: "standalone",
orientation: "portrait",
scope: "/",
start_url: "/",
icons: [
{
src: "/favicon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any maskable"
}
]
},
workbox: {
navigateFallback: "/",
runtimeCaching: [
{
urlPattern: /^https:\/\/tiles\.openseamap\.org\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "openseamap-tiles",
cacheableResponse: {
statuses: [0, 200]
},
expiration: {
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24 * 14
}
}
},
{
urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i,
handler: "StaleWhileRevalidate",
options: {
// Runtime caching sees only resources requested during normal map
// use. It deliberately does not prefetch whole route corridors.
cacheName: "openfreemap-visited-resources",
cacheableResponse: {
statuses: [0, 200]
},
expiration: {
maxEntries: 1_200,
maxAgeSeconds: 60 * 60 * 24 * 30
}
}
}
]
}
})
],
build: {
manifest: true,
// MapLibre is delivered upstream as one pre-bundled module. Its isolated
// chunk is intentionally large and checked separately after each build.
chunkSizeWarningLimit: 1_100,
rollupOptions: {
output: {
onlyExplicitManualChunks: true,
manualChunks(id) {
const normalizedId = id.replaceAll("\\", "/");
if (
normalizedId.includes("/node_modules/maplibre-gl/") &&
!normalizedId.endsWith(".css")
) {
return "map-engine";
}
if (
normalizedId.includes("/node_modules/react/") ||
normalizedId.includes("/node_modules/react-dom/") ||
normalizedId.includes("/node_modules/scheduler/")
) {
return "react-vendor";
}
}
}
}
},
server: {
proxy: {
"/api": {
target: "http://localhost:5174",
changeOrigin: true
},
"/health": {
target: "http://localhost:5174",
changeOrigin: true
}
}
}
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
exclude: ["tests/e2e/**", "node_modules/**", "dist/**"]
}
});