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
+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;
}