Initial Watermaps import
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
AppConfig,
|
||||
Coordinate,
|
||||
MarineForecast,
|
||||
NavigationDataSnapshot,
|
||||
RouteRequest,
|
||||
RouteResult,
|
||||
TideSummary
|
||||
} from "@watermaps/shared";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
|
||||
async function getJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseErrorMessage(response));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function responseErrorMessage(response: Response) {
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string; error?: string };
|
||||
return body.message ?? body.error ?? `${response.status} ${response.statusText}`;
|
||||
} catch {
|
||||
return `${response.status} ${response.statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<AppConfig> {
|
||||
return getJson<AppConfig>("/api/config");
|
||||
}
|
||||
|
||||
export function getMarineForecast(position: Coordinate, at?: string): Promise<MarineForecast> {
|
||||
const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) });
|
||||
if (at) {
|
||||
search.set("at", at);
|
||||
}
|
||||
return getJson<MarineForecast>(`/api/weather/marine?${search.toString()}`);
|
||||
}
|
||||
|
||||
export function getNearestTide(position: Coordinate, at?: string): Promise<TideSummary> {
|
||||
const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) });
|
||||
if (at) {
|
||||
search.set("at", at);
|
||||
}
|
||||
return getJson<TideSummary>(`/api/tides/nearest?${search.toString()}`);
|
||||
}
|
||||
|
||||
export function getNavigationData(params: {
|
||||
waterways?: string[];
|
||||
stationIds?: string[];
|
||||
lockIds?: string[];
|
||||
}): Promise<NavigationDataSnapshot> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.waterways?.length) {
|
||||
search.set("waterways", params.waterways.join(","));
|
||||
}
|
||||
if (params.stationIds?.length) {
|
||||
search.set("stationIds", params.stationIds.join(","));
|
||||
}
|
||||
if (params.lockIds?.length) {
|
||||
search.set("lockIds", params.lockIds.join(","));
|
||||
}
|
||||
return getJson<NavigationDataSnapshot>(`/api/navigation/live?${search.toString()}`);
|
||||
}
|
||||
|
||||
export function createRoute(request: RouteRequest): Promise<RouteResult> {
|
||||
return postJson<RouteResult>("/api/routes", request);
|
||||
}
|
||||
|
||||
export function getMapFeatures(params: {
|
||||
bbox: [number, number, number, number];
|
||||
layers: string[];
|
||||
signal?: AbortSignal;
|
||||
}): Promise<FeatureCollection> {
|
||||
const search = new URLSearchParams({
|
||||
bbox: params.bbox.join(","),
|
||||
layers: params.layers.join(",")
|
||||
});
|
||||
return getJson<FeatureCollection>(`/api/features?${search.toString()}`, { signal: params.signal });
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
.anchor-watch-panel {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: calc(86px + env(safe-area-inset-bottom));
|
||||
width: min(520px, calc(100vw - 20px));
|
||||
max-height: min(74vh, calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 118px));
|
||||
max-height: min(74dvh, calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 118px));
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 2px solid rgba(15, 76, 92, 0.42);
|
||||
border-radius: 14px;
|
||||
padding: 11px;
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
background: rgba(246, 249, 247, 0.98);
|
||||
color: #10242b;
|
||||
box-shadow: 0 14px 38px rgba(7, 25, 29, 0.28);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.anchor-watch-panel[data-alert="true"] {
|
||||
border-color: #c44a30;
|
||||
}
|
||||
|
||||
.anchor-watch-header,
|
||||
.anchor-watch-header > span,
|
||||
.anchor-point-summary,
|
||||
.anchor-gps-readiness,
|
||||
.anchor-inline-warning,
|
||||
.anchor-inline-alert,
|
||||
.anchor-tide-card,
|
||||
.anchor-rode-card,
|
||||
.anchor-primary-action,
|
||||
.anchor-secondary-action,
|
||||
.anchor-acknowledge-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.anchor-watch-header {
|
||||
min-height: 38px;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.anchor-watch-header > span {
|
||||
gap: 7px;
|
||||
color: #0f4c5c;
|
||||
}
|
||||
|
||||
.anchor-watch-header button,
|
||||
.anchor-point-summary button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #e2ece9;
|
||||
color: #23434c;
|
||||
}
|
||||
|
||||
.anchor-capture,
|
||||
.anchor-watch-setup,
|
||||
.anchor-watch-active {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.anchor-capture > p {
|
||||
margin: 0;
|
||||
color: #334e56;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.anchor-gps-readiness,
|
||||
.anchor-point-summary {
|
||||
min-height: 52px;
|
||||
gap: 9px;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
background: #edf3f1;
|
||||
color: #526a72;
|
||||
}
|
||||
|
||||
.anchor-gps-readiness[data-ready="true"] {
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
}
|
||||
|
||||
.anchor-gps-readiness > span,
|
||||
.anchor-point-summary > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.anchor-gps-readiness strong,
|
||||
.anchor-point-summary strong {
|
||||
color: #16323a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.anchor-gps-readiness small,
|
||||
.anchor-point-summary small {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.anchor-primary-action,
|
||||
.anchor-secondary-action,
|
||||
.anchor-acknowledge-action,
|
||||
.anchor-stop-action {
|
||||
min-height: 44px;
|
||||
border-radius: 9px;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.anchor-primary-action {
|
||||
background: #0f4c5c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.anchor-secondary-action {
|
||||
background: #e2ece9;
|
||||
color: #23434c;
|
||||
}
|
||||
|
||||
.anchor-acknowledge-action {
|
||||
width: 100%;
|
||||
background: #c44a30;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.anchor-stop-action {
|
||||
min-width: 150px;
|
||||
background: #f1ded9;
|
||||
color: #8b3024;
|
||||
}
|
||||
|
||||
.anchor-safety-note {
|
||||
display: block;
|
||||
color: #607278;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.anchor-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anchor-settings label {
|
||||
min-width: 0;
|
||||
min-height: 58px;
|
||||
border-radius: 9px;
|
||||
padding: 6px 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
background: #edf3f1;
|
||||
color: #526a72;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.anchor-settings label > span:last-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #16323a;
|
||||
}
|
||||
|
||||
.anchor-settings input,
|
||||
.anchor-settings select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
border: 1px solid #bdcfca;
|
||||
border-radius: 7px;
|
||||
padding: 0 7px;
|
||||
background: #ffffff;
|
||||
color: #10242b;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.anchor-settings[data-compact="true"] {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.anchor-planning-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anchor-tide-card,
|
||||
.anchor-rode-card {
|
||||
min-width: 0;
|
||||
min-height: 80px;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
border-radius: 9px;
|
||||
padding: 8px;
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
}
|
||||
|
||||
.anchor-tide-card[data-incomplete="true"],
|
||||
.anchor-tide-card[data-far="true"],
|
||||
.anchor-rode-card[data-state="unknown"] {
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
}
|
||||
|
||||
.anchor-rode-card[data-state="short"] {
|
||||
background: #ffe1dc;
|
||||
color: #9d2c22;
|
||||
}
|
||||
|
||||
.anchor-card-icon {
|
||||
flex: 0 0 auto;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.anchor-tide-card > div,
|
||||
.anchor-rode-card > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.anchor-tide-card small,
|
||||
.anchor-rode-card small,
|
||||
.anchor-live-metrics small {
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.anchor-tide-card strong,
|
||||
.anchor-rode-card strong {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: currentColor;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.anchor-tide-card span,
|
||||
.anchor-rode-card span,
|
||||
.anchor-tide-card em {
|
||||
color: currentColor;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.anchor-tide-card em {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.anchor-spinner {
|
||||
animation: anchor-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes anchor-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.anchor-inline-warning,
|
||||
.anchor-inline-alert {
|
||||
margin: 0;
|
||||
gap: 6px;
|
||||
border-radius: 9px;
|
||||
padding: 8px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.anchor-inline-warning {
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
}
|
||||
|
||||
.anchor-inline-alert {
|
||||
background: #ffe1dc;
|
||||
color: #9d2c22;
|
||||
}
|
||||
|
||||
.anchor-setup-actions,
|
||||
.anchor-stop-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.25fr);
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.anchor-distance-hero {
|
||||
min-height: 78px;
|
||||
border-radius: 11px;
|
||||
padding: 9px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #0f4c5c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.anchor-distance-hero[data-tone="warning"] {
|
||||
background: #805900;
|
||||
}
|
||||
|
||||
.anchor-distance-hero[data-tone="alarm"] {
|
||||
background: #8b3024;
|
||||
}
|
||||
|
||||
.anchor-distance-hero > span {
|
||||
color: #ffce66;
|
||||
}
|
||||
|
||||
.anchor-distance-hero > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.anchor-distance-hero small {
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.anchor-distance-hero strong {
|
||||
font-size: clamp(30px, 10vw, 44px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.anchor-distance-hero em {
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anchor-watch-status {
|
||||
margin: 0;
|
||||
border-radius: 9px;
|
||||
padding: 7px 9px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.anchor-watch-status[data-tone="warning"] {
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
}
|
||||
|
||||
.anchor-watch-status[data-tone="alarm"] {
|
||||
background: #ffe1dc;
|
||||
color: #9d2c22;
|
||||
}
|
||||
|
||||
.anchor-live-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.anchor-live-metrics > span {
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
background: #edf3f1;
|
||||
}
|
||||
|
||||
.anchor-live-metrics small {
|
||||
color: #607278;
|
||||
}
|
||||
|
||||
.anchor-live-metrics strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #16323a;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anchor-acknowledged {
|
||||
margin: 0;
|
||||
color: #9d2c22;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.anchor-active-details {
|
||||
border-radius: 9px;
|
||||
padding: 8px;
|
||||
background: #edf3f1;
|
||||
}
|
||||
|
||||
.anchor-active-details summary {
|
||||
cursor: pointer;
|
||||
color: #23434c;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.app-shell[data-anchor-watch-active="true"] .data-badge,
|
||||
.app-shell[data-anchor-panel-open="true"] .data-badge {
|
||||
top: calc(env(safe-area-inset-top) + 266px);
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.anchor-watch-panel {
|
||||
left: auto;
|
||||
right: 12px;
|
||||
width: 430px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 390px) {
|
||||
.anchor-planning-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.anchor-settings {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.anchor-live-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anchor-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Anchor,
|
||||
BellRing,
|
||||
CheckCircle2,
|
||||
Crosshair,
|
||||
LoaderCircle,
|
||||
MapPin,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Waves,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { GpsState } from "../hooks/useGeolocation";
|
||||
import { type AnchorWatchSettings, useAnchorWatch } from "../hooks/useAnchorWatch";
|
||||
import "./AnchorWatchPanel.css";
|
||||
|
||||
type AnchorWatchModel = ReturnType<typeof useAnchorWatch>;
|
||||
|
||||
export type AnchorWatchPanelProps = {
|
||||
watch: AnchorWatchModel;
|
||||
gps: Pick<GpsState, "status" | "accuracyM" | "timestampMs">;
|
||||
onStartGps: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatchPanelProps) {
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
const armed = watch.phase === "armed";
|
||||
const hasAlarm = armed && (watch.positionAlarm || watch.rodeShortfall);
|
||||
const distanceM = watch.watchResult?.distanceFromAnchorM ?? null;
|
||||
const nearLimit = Boolean(
|
||||
armed &&
|
||||
!watch.positionAlarm &&
|
||||
distanceM !== null &&
|
||||
distanceM >= watch.settings.alarmRadiusM * 0.8
|
||||
);
|
||||
const status = anchorStatus(watch, gps.status, nearLimit);
|
||||
|
||||
const cancel = () => {
|
||||
watch.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!confirmStop) {
|
||||
setConfirmStop(true);
|
||||
return;
|
||||
}
|
||||
cancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="anchor-watch-panel"
|
||||
aria-label="Ankerwache"
|
||||
data-phase={watch.phase}
|
||||
data-alert={hasAlarm}
|
||||
data-near-limit={nearLimit}
|
||||
>
|
||||
<header className="anchor-watch-header">
|
||||
<span>
|
||||
<Anchor size={19} aria-hidden="true" />
|
||||
<strong>{armed ? "Ankerwache aktiv" : watch.phase === "set" ? "Ankerwache einrichten" : "Ankerwache"}</strong>
|
||||
</span>
|
||||
{!armed && (
|
||||
<button type="button" onClick={cancel} aria-label="Ankerwache schließen">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{watch.phase === "idle" ? (
|
||||
<AnchorCapture watch={watch} gps={gps} onStartGps={onStartGps} />
|
||||
) : armed ? (
|
||||
<AnchorWatchActive
|
||||
watch={watch}
|
||||
status={status}
|
||||
nearLimit={nearLimit}
|
||||
confirmStop={confirmStop}
|
||||
onCancelStop={() => setConfirmStop(false)}
|
||||
onStop={stop}
|
||||
/>
|
||||
) : (
|
||||
<AnchorWatchSetup watch={watch} onCancel={cancel} />
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function AnchorCapture({
|
||||
watch,
|
||||
gps,
|
||||
onStartGps
|
||||
}: {
|
||||
watch: AnchorWatchModel;
|
||||
gps: AnchorWatchPanelProps["gps"];
|
||||
onStartGps: () => void;
|
||||
}) {
|
||||
const gpsReady = gps.status === "tracking" && gps.accuracyM !== null;
|
||||
return (
|
||||
<div className="anchor-capture">
|
||||
<p>
|
||||
Setze den Punkt genau dann, wenn der Anker den Grund erreicht. Watermaps verschiebt ihn danach nicht mit dem Boot.
|
||||
</p>
|
||||
<div className="anchor-gps-readiness" data-ready={gpsReady}>
|
||||
<Crosshair size={18} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{gpsReady ? `GPS ±${Math.round(gps.accuracyM ?? 0)} m` : gpsLabel(gps.status)}</strong>
|
||||
<small>Erforderlich: frischer Fix mit höchstens ±{watch.maxCaptureAccuracyM} m</small>
|
||||
</span>
|
||||
</div>
|
||||
{gps.status !== "tracking" && (
|
||||
<button className="anchor-secondary-action" type="button" onClick={onStartGps}>
|
||||
<Crosshair size={17} aria-hidden="true" />
|
||||
GPS starten
|
||||
</button>
|
||||
)}
|
||||
<button className="anchor-primary-action" type="button" onClick={watch.captureAnchor}>
|
||||
<Anchor size={18} aria-hidden="true" />
|
||||
Anker gefallen – Position jetzt setzen
|
||||
</button>
|
||||
{watch.operationError && <p className="anchor-inline-alert" role="alert">{watch.operationError}</p>}
|
||||
<small className="anchor-safety-note">
|
||||
Keine automatische Ankererkennung: Ein Browser-GPS kann das Fallenlassen nicht zuverlässig erkennen.
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCancel: () => void }) {
|
||||
const suggestedReachM = watch.rodePlan
|
||||
? Math.ceil(Math.max(
|
||||
watch.rodePlan.horizontalReachAtSetM,
|
||||
watch.rodePlan.horizontalReachM ?? 0
|
||||
) + 5)
|
||||
: null;
|
||||
const radiusTooSmall = suggestedReachM !== null && watch.settings.alarmRadiusM < suggestedReachM;
|
||||
|
||||
return (
|
||||
<div className="anchor-watch-setup">
|
||||
<div className="anchor-point-summary">
|
||||
<MapPin size={17} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>Ankerpunkt gespeichert</strong>
|
||||
<small>
|
||||
{watch.anchorPoint ? formatCoordinate(watch.anchorPoint) : "–"}
|
||||
{watch.anchorCaptureAccuracyM !== null && ` · GPS ±${Math.round(watch.anchorCaptureAccuracyM)} m`}
|
||||
{watch.anchorSetAtMs !== null && ` · ${formatClock(watch.anchorSetAtMs)}`}
|
||||
</small>
|
||||
</span>
|
||||
<button type="button" onClick={watch.captureAnchor} aria-label="Ankerpunkt an aktueller Position neu setzen">
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnchorSettingsForm watch={watch} />
|
||||
<TideAndRodeSummary watch={watch} />
|
||||
|
||||
{radiusTooSmall && (
|
||||
<p className="anchor-inline-warning">
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
Der Radius liegt unter der rechnerischen horizontalen Reichweite von etwa {suggestedReachM} m. Normales Schwojen kann bereits alarmieren; Bootslänge und GPS-Unsicherheit kommen noch hinzu.
|
||||
</p>
|
||||
)}
|
||||
{watch.settingsError && <p className="anchor-inline-alert" role="alert">{watch.settingsError}</p>}
|
||||
{watch.operationError && <p className="anchor-inline-alert" role="alert">{watch.operationError}</p>}
|
||||
|
||||
<div className="anchor-setup-actions">
|
||||
<button className="anchor-secondary-action" type="button" onClick={onCancel}>Abbrechen</button>
|
||||
<button className="anchor-primary-action" type="button" onClick={() => void watch.arm()}>
|
||||
<BellRing size={17} aria-hidden="true" />
|
||||
Wache starten
|
||||
</button>
|
||||
</div>
|
||||
<small className="anchor-safety-note">
|
||||
Tidendaten und Scope-Rechnung sind Planungshilfen. Grund, Anker, Wind, Wellen, Strom, Schwell und Abstand zu Gefahren müssen vor Ort beurteilt werden.
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchModel; compact?: boolean }) {
|
||||
const fields: Array<{
|
||||
key: keyof AnchorWatchSettings;
|
||||
label: string;
|
||||
unit: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}> = [
|
||||
{ key: "depthAtSetM", label: "Tiefe beim Setzen", unit: "m", min: 0.1, max: 200, step: 0.1 },
|
||||
{ key: "bowRollerHeightM", label: "Bugrolle über Wasser", unit: "m", min: 0, max: 20, step: 0.1 },
|
||||
{ key: "deployedRodeLengthM", label: "Kette / Leine draußen", unit: "m", min: 1, max: 2_000, step: 1 },
|
||||
{ key: "safetyAllowanceM", label: "Wasserstandsreserve", unit: "m", min: 0, max: 10, step: 0.1 },
|
||||
{ key: "alarmRadiusM", label: "Alarmradius ab Anker", unit: "m", min: 10, max: 2_000, step: 5 }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="anchor-settings" data-compact={compact}>
|
||||
{fields.map((field) => (
|
||||
<label key={field.key} htmlFor={`anchor-${field.key}`}>
|
||||
<span>{field.label}</span>
|
||||
<span>
|
||||
<input
|
||||
id={`anchor-${field.key}`}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={watch.settings[field.key]}
|
||||
onChange={(event) => watch.updateSettings({ [field.key]: Number(event.target.value) })}
|
||||
/>
|
||||
{field.unit}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<label htmlFor="anchor-scopeRatio">
|
||||
<span>Gewähltes Verhältnis</span>
|
||||
<select
|
||||
id="anchor-scopeRatio"
|
||||
value={watch.settings.scopeRatio}
|
||||
onChange={(event) => watch.updateSettings({ scopeRatio: Number(event.target.value) })}
|
||||
>
|
||||
{[3, 4, 5, 6, 7, 8, 10].map((value) => <option key={value} value={value}>{value}:1</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label htmlFor="anchor-horizonHours">
|
||||
<span>Tidenzeitraum</span>
|
||||
<select
|
||||
id="anchor-horizonHours"
|
||||
value={watch.settings.horizonHours}
|
||||
onChange={(event) => watch.updateSettings({ horizonHours: Number(event.target.value) })}
|
||||
>
|
||||
<option value={12}>12 Stunden</option>
|
||||
<option value={24}>24 Stunden</option>
|
||||
<option value={48}>48 Stunden</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TideAndRodeSummary({ watch, active = false }: { watch: AnchorWatchModel; active?: boolean }) {
|
||||
const tideWindow = active ? watch.remainingTideWindow : watch.tideWindow;
|
||||
const plan = watch.rodePlan;
|
||||
const complete = tideWindow?.coverage === "complete" && plan?.calculationComplete;
|
||||
const stationFar = Boolean(watch.tide && watch.tide.distanceKm > 30);
|
||||
|
||||
return (
|
||||
<div className="anchor-planning-summary">
|
||||
<section className="anchor-tide-card" data-incomplete={!complete} data-far={stationFar}>
|
||||
<span className="anchor-card-icon"><Waves size={18} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<small>{active ? "TIDE AB JETZT" : "TIDE AB ANKERSETZEN"}</small>
|
||||
{watch.tideLoading && !watch.tide ? (
|
||||
<strong><LoaderCircle className="anchor-spinner" size={15} aria-hidden="true" /> Wird geladen …</strong>
|
||||
) : tideWindow?.maximumRiseM !== null && tideWindow?.maximumRiseM !== undefined ? (
|
||||
<strong>max. +{tideWindow.maximumRiseM.toFixed(2)} m · Hub {formatNullable(tideWindow.tidalRangeM)} m</strong>
|
||||
) : (
|
||||
<strong>Nicht berechenbar</strong>
|
||||
)}
|
||||
<span>
|
||||
{watch.tide
|
||||
? `${watch.tide.station} · ${watch.tide.distanceKm.toFixed(1)} km entfernt · Stand ${formatUpdatedAt(watch.tide.updatedAt)}`
|
||||
: watch.tideError
|
||||
? "Stationsprognose nicht erreichbar – nicht als 0 m angesetzt"
|
||||
: "Warte auf Stationsprognose"}
|
||||
</span>
|
||||
{tideWindow?.coverage === "partial" && <em>Prognose deckt den gewählten Zeitraum nur teilweise ab.</em>}
|
||||
{stationFar && <em>Entfernter Pegel: lokale Tide kann deutlich abweichen.</em>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="anchor-rode-card"
|
||||
data-state={!plan?.calculationComplete ? "unknown" : plan.hasSufficientRode ? "safe" : "short"}
|
||||
>
|
||||
<span className="anchor-card-icon">
|
||||
{plan?.calculationComplete && plan.hasSufficientRode
|
||||
? <ShieldCheck size={18} aria-hidden="true" />
|
||||
: <AlertTriangle size={18} aria-hidden="true" />}
|
||||
</span>
|
||||
<div>
|
||||
<small>ANKERLEINEN-RESERVE</small>
|
||||
{plan?.calculationComplete && plan.requiredRodeLengthM !== null && plan.rodeReserveM !== null ? (
|
||||
<>
|
||||
<strong>{plan.rodeReserveM >= 0 ? "+" : ""}{plan.rodeReserveM.toFixed(1)} m Reserve</strong>
|
||||
<span>Rechnerisch {plan.requiredRodeLengthM.toFixed(1)} m bei {watch.settings.scopeRatio}:1 erforderlich</span>
|
||||
</>
|
||||
) : plan ? (
|
||||
<>
|
||||
<strong>Nicht bestätigt</strong>
|
||||
<span>Ohne vollständige Tide mindestens {plan.minimumRequiredRodeLengthM.toFixed(1)} m; Zukunftsbedarf offen</span>
|
||||
</>
|
||||
) : (
|
||||
<strong>Eingaben prüfen</strong>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnchorWatchActive({
|
||||
watch,
|
||||
status,
|
||||
nearLimit,
|
||||
confirmStop,
|
||||
onCancelStop,
|
||||
onStop
|
||||
}: {
|
||||
watch: AnchorWatchModel;
|
||||
status: { tone: string; title: string; detail: string };
|
||||
nearLimit: boolean;
|
||||
confirmStop: boolean;
|
||||
onCancelStop: () => void;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
const distance = watch.watchResult?.distanceFromAnchorM;
|
||||
const elapsedMs = watch.anchorSetAtMs === null ? 0 : Math.max(0, Date.now() - watch.anchorSetAtMs);
|
||||
const remainingWindow = watch.remainingTideWindow;
|
||||
|
||||
return (
|
||||
<div className="anchor-watch-active">
|
||||
<section className="anchor-distance-hero" data-tone={status.tone} aria-live="polite">
|
||||
<span>
|
||||
{status.tone === "safe" ? <CheckCircle2 size={26} aria-hidden="true" /> : <AlertTriangle size={26} aria-hidden="true" />}
|
||||
</span>
|
||||
<div>
|
||||
<small>ABSTAND / ALARMRADIUS</small>
|
||||
<strong>{distance === null || distance === undefined ? "---" : Math.round(distance)} <em>/ {Math.round(watch.settings.alarmRadiusM)} m</em></strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="anchor-watch-status" data-tone={status.tone} role={watch.positionAlarm ? "alert" : "status"}>
|
||||
<strong>{status.title}</strong>
|
||||
<span>{status.detail}</span>
|
||||
</p>
|
||||
|
||||
<div className="anchor-live-metrics">
|
||||
<span><small>GPS</small><strong>{watch.watchResult?.accuracyM === null || watch.watchResult?.accuracyM === undefined ? "--" : `±${Math.round(watch.watchResult.accuracyM)} m`}</strong></span>
|
||||
<span><small>SEIT</small><strong>{formatDuration(elapsedMs)}</strong></span>
|
||||
<span><small>TIDE NOCH</small><strong>{remainingWindow?.coverage === "complete" && remainingWindow.maximumRiseM !== null ? `+${remainingWindow.maximumRiseM.toFixed(2)} m` : "offen"}</strong></span>
|
||||
<span><small>LEINE</small><strong>{watch.rodePlan?.rodeReserveM === null || watch.rodePlan?.rodeReserveM === undefined ? "offen" : `${watch.rodePlan.rodeReserveM >= 0 ? "+" : ""}${watch.rodePlan.rodeReserveM.toFixed(1)} m`}</strong></span>
|
||||
</div>
|
||||
|
||||
{watch.positionAlarm && !watch.alarmAcknowledged && (
|
||||
<button className="anchor-acknowledge-action" type="button" onClick={watch.acknowledgeAlarm}>
|
||||
<BellRing size={17} aria-hidden="true" />
|
||||
Alarm quittieren
|
||||
</button>
|
||||
)}
|
||||
{watch.positionAlarm && watch.alarmAcknowledged && (
|
||||
<p className="anchor-acknowledged">Alarmton quittiert · rote Warnanzeige bleibt aktiv</p>
|
||||
)}
|
||||
{watch.rodeShortfall && (
|
||||
<p className="anchor-inline-alert" role="alert">
|
||||
Nach der aktuellen Stationsprognose ist die eingegebene Kette/Leine rechnerisch zu kurz.
|
||||
</p>
|
||||
)}
|
||||
{nearLimit && <p className="anchor-inline-warning">80 % des Alarmradius erreicht.</p>}
|
||||
|
||||
<details className="anchor-active-details">
|
||||
<summary>Radius, Tide und Leinenrechnung</summary>
|
||||
<AnchorSettingsForm watch={watch} compact />
|
||||
<TideAndRodeSummary watch={watch} active />
|
||||
</details>
|
||||
|
||||
<div className="anchor-stop-actions">
|
||||
{confirmStop && <button className="anchor-secondary-action" type="button" onClick={onCancelStop}>Weiter überwachen</button>}
|
||||
<button className="anchor-stop-action" type="button" onClick={onStop}>
|
||||
{confirmStop ? "Wirklich beenden" : "Ankerwache beenden"}
|
||||
</button>
|
||||
</div>
|
||||
<small className="anchor-safety-note">
|
||||
App sichtbar und Display an lassen. Browser und Betriebssystem können GPS, Ton und Mitteilungen im Hintergrund anhalten. Watermaps ersetzt keine Ankerpeilung und keinen Ausguck.
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function anchorStatus(watch: AnchorWatchModel, gpsStatus: GpsState["status"], nearLimit: boolean) {
|
||||
if (watch.fixStale) {
|
||||
return { tone: "alarm", title: "GPS-Fix veraltet", detail: "Die Ankerposition wird gerade nicht sicher überwacht." };
|
||||
}
|
||||
if (gpsStatus !== "tracking") {
|
||||
return { tone: "alarm", title: "GPS ausgefallen", detail: "Position prüfen und GPS-Berechtigung wiederherstellen." };
|
||||
}
|
||||
if (watch.gpsUnreliable) {
|
||||
return { tone: "alarm", title: "GPS zu ungenau", detail: "Keine sichere Aussage zum Schwojradius möglich." };
|
||||
}
|
||||
if (watch.watchResult?.alarmTriggered) {
|
||||
return {
|
||||
tone: "alarm",
|
||||
title: "Außerhalb des Alarmradius",
|
||||
detail: `Auch nach Abzug der GPS-Ungenauigkeit noch ${Math.round(watch.watchResult.conservativeDistanceFromAnchorM ?? 0)} m vom Ankerpunkt.`
|
||||
};
|
||||
}
|
||||
if (nearLimit) {
|
||||
return { tone: "warning", title: "Nahe am Alarmradius", detail: "Position und Peilmarken aufmerksam beobachten." };
|
||||
}
|
||||
return { tone: "safe", title: "Im überwachten Schwojkreis", detail: "Abstand wird mit jedem neuen GPS-Fix geprüft." };
|
||||
}
|
||||
|
||||
function gpsLabel(status: GpsState["status"]) {
|
||||
if (status === "requesting") return "GPS-Freigabe wird angefragt";
|
||||
if (status === "denied") return "GPS-Freigabe abgelehnt";
|
||||
if (status === "unavailable") return "GPS nicht verfügbar";
|
||||
if (status === "error") return "GPS-Fehler";
|
||||
return "GPS noch nicht gestartet";
|
||||
}
|
||||
|
||||
function formatCoordinate(coordinate: { lat: number; lon: number }) {
|
||||
return `${coordinate.lat.toFixed(5)}, ${coordinate.lon.toFixed(5)}`;
|
||||
}
|
||||
|
||||
function formatClock(timestampMs: number) {
|
||||
return new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(timestampMs);
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? formatClock(timestamp) : "unbekannt";
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds: number) {
|
||||
const totalMinutes = Math.floor(milliseconds / 60_000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes} min`;
|
||||
}
|
||||
|
||||
function formatNullable(value: number | null) {
|
||||
return value === null ? "–" : value.toFixed(2);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Compass } from "lucide-react";
|
||||
|
||||
type CompassDialProps = {
|
||||
headingDeg: number | null;
|
||||
source: string;
|
||||
status: string;
|
||||
targetHeadingDeg?: number | null;
|
||||
onRequest: () => void;
|
||||
};
|
||||
|
||||
export function CompassDial({ headingDeg, source, status, targetHeadingDeg = null, onRequest }: CompassDialProps) {
|
||||
const displayHeading = headingDeg ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="compass-dial"
|
||||
type="button"
|
||||
title="Kompass aktivieren"
|
||||
aria-label={
|
||||
targetHeadingDeg === null
|
||||
? "Kompass aktivieren"
|
||||
: `Kompass aktivieren, Sollkurs ${Math.round(targetHeadingDeg)} Grad`
|
||||
}
|
||||
onClick={onRequest}
|
||||
data-status={status}
|
||||
>
|
||||
<span className="compass-ring">
|
||||
{targetHeadingDeg !== null && (
|
||||
<span
|
||||
className="compass-course-marker"
|
||||
style={{ transform: `rotate(${targetHeadingDeg}deg)` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="compass-needle" style={{ transform: `rotate(${displayHeading}deg)` }} />
|
||||
<Compass size={18} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="compass-value">{headingDeg === null ? "--" : Math.round(headingDeg)}</span>
|
||||
<span className="compass-source">{source}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/* Weather and tide --------------------------------------------------------- */
|
||||
|
||||
.conditions-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.conditions-panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.conditions-panel-header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conditions-panel-header h2 {
|
||||
margin: 3px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.conditions-panel-kicker,
|
||||
.conditions-position-source,
|
||||
.conditions-section-heading,
|
||||
.conditions-station,
|
||||
.conditions-panel-state,
|
||||
.conditions-data-provenance,
|
||||
.upcoming-events-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conditions-panel-kicker {
|
||||
gap: 6px;
|
||||
color: #0f4c5c;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.conditions-position-source {
|
||||
width: fit-content;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0 9px;
|
||||
gap: 5px;
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.conditions-position-source[data-source="fallback"],
|
||||
.conditions-position-source[data-source="unknown"] {
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
}
|
||||
|
||||
.conditions-panel-close {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #e2ece9;
|
||||
color: #23434c;
|
||||
}
|
||||
|
||||
.conditions-panel-state {
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
border-radius: 9px;
|
||||
padding: 8px 10px;
|
||||
gap: 7px;
|
||||
background: #edf3f1;
|
||||
color: #526a72;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.conditions-panel-state[data-state="warning"] {
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
}
|
||||
|
||||
.conditions-panel-state[data-state="empty"] {
|
||||
border: 1px dashed #b9cbc7;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.conditions-section {
|
||||
border: 1px solid rgba(15, 76, 92, 0.12);
|
||||
border-radius: 11px;
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
background: rgba(237, 243, 241, 0.72);
|
||||
}
|
||||
|
||||
.conditions-section-heading {
|
||||
margin: 0;
|
||||
gap: 7px;
|
||||
color: #17343c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.conditions-metric-grid,
|
||||
.conditions-route-sample > dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.conditions-metric {
|
||||
min-width: 0;
|
||||
min-height: 58px;
|
||||
border-radius: 9px;
|
||||
padding: 7px 8px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 3px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.conditions-metric dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #607278;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.conditions-metric dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #16323a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.conditions-data-provenance {
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
gap: 3px 10px;
|
||||
color: #607278;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.conditions-station {
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
gap: 5px;
|
||||
color: #526a72;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.conditions-station strong {
|
||||
color: #17343c;
|
||||
}
|
||||
|
||||
.conditions-tide-events {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.conditions-tide-event {
|
||||
min-height: 62px;
|
||||
border-radius: 9px;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.conditions-tide-event dt {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.conditions-tide-event dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
color: #526a72;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.conditions-tide-event time {
|
||||
color: #17343c;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.conditions-route-assessment {
|
||||
border-radius: 9px;
|
||||
padding: 8px;
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
}
|
||||
|
||||
.conditions-route-assessment[data-severity="caution"] {
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
}
|
||||
|
||||
.conditions-route-assessment[data-severity="critical"] {
|
||||
background: #ffe1dc;
|
||||
color: #9d2c22;
|
||||
}
|
||||
|
||||
.conditions-route-assessment > p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.conditions-route-assessment .conditions-data-provenance {
|
||||
margin-top: 5px;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.conditions-route-samples {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.conditions-route-sample {
|
||||
border: 1px solid rgba(15, 76, 92, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 9px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.conditions-route-sample > header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.conditions-route-sample h4 {
|
||||
margin: 0;
|
||||
color: #17343c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.conditions-route-sample > header time {
|
||||
color: #607278;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.conditions-route-sample .conditions-metric {
|
||||
min-height: 48px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.conditions-route-sample .conditions-metric:nth-child(3) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.conditions-route-tide {
|
||||
border-top: 1px solid rgba(15, 76, 92, 0.1);
|
||||
padding-top: 7px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
color: #526a72;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.conditions-route-tide strong {
|
||||
color: #17343c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.conditions-inline-empty {
|
||||
margin: 0;
|
||||
color: #607278;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.conditions-panel-disclaimer {
|
||||
margin: 0;
|
||||
color: #607278;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CloudSun,
|
||||
MapPin,
|
||||
Navigation2,
|
||||
Thermometer,
|
||||
Waves,
|
||||
Wind,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useId, type ReactNode } from "react";
|
||||
import type { MarineForecast, TideEvent, TideSummary } from "@watermaps/shared";
|
||||
import "./ConditionsPanel.css";
|
||||
|
||||
export type ConditionsPositionSource = {
|
||||
kind: "gps" | "fallback" | "unknown";
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
export type ConditionsRouteWeatherSample = {
|
||||
label: "Start" | "Mitte" | "Ziel";
|
||||
forecast: MarineForecast;
|
||||
plannedTime?: string;
|
||||
currentAlongRouteKn?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deliberately narrower than RouteWeatherReport. The complete report remains
|
||||
* assignable while this panel stays independent of the report generator.
|
||||
*/
|
||||
export type ConditionsRouteWeatherReport = {
|
||||
samples: ReadonlyArray<ConditionsRouteWeatherSample>;
|
||||
severity?: "ok" | "caution" | "critical";
|
||||
summary?: string | null;
|
||||
source?: string | null;
|
||||
updatedAt?: string | null;
|
||||
unavailableSamples?: number;
|
||||
};
|
||||
|
||||
export type ConditionsRouteTides = {
|
||||
start: TideSummary | null;
|
||||
middle?: TideSummary | null;
|
||||
destination: TideSummary | null;
|
||||
};
|
||||
|
||||
export type ConditionsPanelProps = {
|
||||
forecast: MarineForecast | null;
|
||||
tide: TideSummary | null;
|
||||
positionSource?: ConditionsPositionSource;
|
||||
currentLoading?: boolean;
|
||||
currentError?: string | null;
|
||||
routeWeatherReport?: ConditionsRouteWeatherReport | null;
|
||||
routeTides?: ConditionsRouteTides | null;
|
||||
routeLoading?: boolean;
|
||||
routeError?: string | null;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
/** Optional clock for deterministic consumers and tests. */
|
||||
now?: number;
|
||||
};
|
||||
|
||||
const ROUTE_SAMPLE_LABELS = ["Start", "Mitte", "Ziel"] as const;
|
||||
|
||||
export function ConditionsPanel({
|
||||
forecast,
|
||||
tide,
|
||||
positionSource = { kind: "unknown" },
|
||||
currentLoading = false,
|
||||
currentError = null,
|
||||
routeWeatherReport = null,
|
||||
routeTides = null,
|
||||
routeLoading = false,
|
||||
routeError = null,
|
||||
onClose,
|
||||
className,
|
||||
now = Date.now()
|
||||
}: ConditionsPanelProps) {
|
||||
const titleId = useId();
|
||||
const currentTitleId = useId();
|
||||
const tideTitleId = useId();
|
||||
const routeTitleId = useId();
|
||||
const hasCurrentData = Boolean(forecast || tide);
|
||||
const hasRouteData = Boolean(routeWeatherReport || routeTides);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={joinClassNames("conditions-panel", className)}
|
||||
aria-labelledby={titleId}
|
||||
aria-busy={currentLoading || routeLoading}
|
||||
data-position-source={positionSource.kind}
|
||||
>
|
||||
<header className="conditions-panel-header">
|
||||
<div>
|
||||
<span className="conditions-panel-kicker">
|
||||
<CloudSun size={16} aria-hidden="true" />
|
||||
Bedingungen
|
||||
</span>
|
||||
<h2 id={titleId}>Wetter & Tide</h2>
|
||||
<PositionSource source={positionSource} />
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
className="conditions-panel-close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Wetter und Tide schließen"
|
||||
title="Schließen"
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{currentLoading && (
|
||||
<p className="conditions-panel-state" role="status" aria-live="polite">
|
||||
Bedingungen für die aktuelle Position werden geladen …
|
||||
</p>
|
||||
)}
|
||||
{currentError && (
|
||||
<p className="conditions-panel-state" data-state="warning" role="alert">
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
{currentError}
|
||||
</p>
|
||||
)}
|
||||
{!hasCurrentData && !currentLoading && !currentError && (
|
||||
<p className="conditions-panel-state" data-state="empty">
|
||||
Für diese Position liegen noch keine Wetter- oder Tidendaten vor.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(forecast || (hasCurrentData && !currentLoading)) && (
|
||||
<section className="conditions-section" aria-labelledby={currentTitleId}>
|
||||
<SectionHeading id={currentTitleId} icon={<Wind size={16} aria-hidden="true" />}>
|
||||
Aktuelle Bedingungen
|
||||
</SectionHeading>
|
||||
|
||||
{forecast ? (
|
||||
<>
|
||||
<dl className="conditions-metric-grid">
|
||||
<Metric
|
||||
icon={<Wind size={15} aria-hidden="true" />}
|
||||
label="Wind"
|
||||
value={formatWind(forecast)}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Waves size={15} aria-hidden="true" />}
|
||||
label="Welle"
|
||||
value={formatWave(forecast)}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Navigation2 size={15} aria-hidden="true" />}
|
||||
label="Strömung"
|
||||
value={formatCurrent(forecast)}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Thermometer size={15} aria-hidden="true" />}
|
||||
label="Temperatur"
|
||||
value={formatTemperature(forecast.temperatureC)}
|
||||
/>
|
||||
</dl>
|
||||
<DataProvenance
|
||||
source={forecast.source}
|
||||
updatedAt={forecast.updatedAt}
|
||||
validAt={forecast.forecastTime}
|
||||
now={now}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="conditions-inline-empty">Aktuelle Wetter- und Strömungsdaten fehlen.</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(tide || (hasCurrentData && !currentLoading)) && (
|
||||
<section className="conditions-section" aria-labelledby={tideTitleId}>
|
||||
<SectionHeading id={tideTitleId} icon={<Waves size={16} aria-hidden="true" />}>
|
||||
Tide an der Position
|
||||
</SectionHeading>
|
||||
|
||||
{tide ? (
|
||||
<>
|
||||
<p className="conditions-station">
|
||||
<MapPin size={14} aria-hidden="true" />
|
||||
<strong>{safeText(tide.station, "Station unbekannt")}</strong>
|
||||
<span>{formatDistanceKm(tide.distanceKm)} entfernt</span>
|
||||
</p>
|
||||
<dl className="conditions-tide-events">
|
||||
<TideEventRow label="Nächstes Hochwasser" shortLabel="HW" event={tide.nextHigh} />
|
||||
<TideEventRow label="Nächstes Niedrigwasser" shortLabel="NW" event={tide.nextLow} />
|
||||
</dl>
|
||||
<DataProvenance source={tide.source} updatedAt={tide.updatedAt} now={now} />
|
||||
</>
|
||||
) : (
|
||||
<p className="conditions-inline-empty">Für diese Position fehlt eine passende Tidenstation.</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="conditions-section conditions-route" aria-labelledby={routeTitleId}>
|
||||
<SectionHeading id={routeTitleId} icon={<Navigation2 size={16} aria-hidden="true" />}>
|
||||
Bedingungen auf der Strecke
|
||||
</SectionHeading>
|
||||
|
||||
{routeLoading && (
|
||||
<p className="conditions-panel-state" role="status" aria-live="polite">
|
||||
Streckenprognose wird geladen …
|
||||
</p>
|
||||
)}
|
||||
{routeError && (
|
||||
<p className="conditions-panel-state" data-state="warning" role="alert">
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
{routeError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{routeWeatherReport && (
|
||||
<RouteAssessment report={routeWeatherReport} now={now} />
|
||||
)}
|
||||
|
||||
{hasRouteData ? (
|
||||
<div className="conditions-route-samples">
|
||||
{ROUTE_SAMPLE_LABELS.map((label) => (
|
||||
<RouteSampleCard
|
||||
key={label}
|
||||
label={label}
|
||||
sample={routeWeatherReport?.samples.find((candidate) => candidate.label === label) ?? null}
|
||||
tide={
|
||||
label === "Start"
|
||||
? routeTides?.start ?? null
|
||||
: label === "Mitte"
|
||||
? routeTides?.middle ?? null
|
||||
: label === "Ziel"
|
||||
? routeTides?.destination ?? null
|
||||
: null
|
||||
}
|
||||
routeTidesAvailable={Boolean(routeTides)}
|
||||
now={now}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!routeLoading && (
|
||||
<p className="conditions-panel-state" data-state="empty">
|
||||
Nach der Routenberechnung erscheinen hier Start, Mitte und Ziel.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="conditions-panel-disclaimer">
|
||||
Prognosen und entfernte Tidenstationen können lokal abweichen. Amtliche Warnungen, Pegel und
|
||||
Befahrensregeln zusätzlich prüfen.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PositionSource({ source }: { source: ConditionsPositionSource }) {
|
||||
const label =
|
||||
source.kind === "gps"
|
||||
? source.label
|
||||
? `GPS · ${source.label}`
|
||||
: "Aktuelle GPS-Position"
|
||||
: source.kind === "fallback"
|
||||
? source.label
|
||||
? `Fallback · ${source.label}`
|
||||
: "Fallback-Position"
|
||||
: source.label || "Positionsquelle noch offen";
|
||||
|
||||
return (
|
||||
<p className="conditions-position-source" data-source={source.kind}>
|
||||
{source.kind === "gps" ? (
|
||||
<Navigation2 size={14} aria-hidden="true" />
|
||||
) : (
|
||||
<MapPin size={14} aria-hidden="true" />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({
|
||||
id,
|
||||
icon,
|
||||
children
|
||||
}: {
|
||||
id: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<h3 id={id} className="conditions-section-heading">
|
||||
{icon}
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="conditions-metric">
|
||||
<dt>
|
||||
{icon}
|
||||
{label}
|
||||
</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TideEventRow({
|
||||
label,
|
||||
shortLabel,
|
||||
event
|
||||
}: {
|
||||
label: string;
|
||||
shortLabel: "HW" | "NW";
|
||||
event: TideEvent | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="conditions-tide-event">
|
||||
<dt>
|
||||
<abbr title={label}>{shortLabel}</abbr>
|
||||
</dt>
|
||||
<dd>
|
||||
{event ? (
|
||||
<>
|
||||
{isValidDate(event.time) ? (
|
||||
<time dateTime={event.time}>{formatDateTime(event.time)}</time>
|
||||
) : (
|
||||
<span>Zeit offen</span>
|
||||
)}
|
||||
<span>{formatTideHeight(event.heightM)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Zeit und Höhe offen</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataProvenance({
|
||||
source,
|
||||
updatedAt,
|
||||
validAt,
|
||||
now
|
||||
}: {
|
||||
source?: string | null;
|
||||
updatedAt?: string | null;
|
||||
validAt?: string | null;
|
||||
now: number;
|
||||
}) {
|
||||
return (
|
||||
<p className="conditions-data-provenance">
|
||||
<span>Quelle: {safeText(source, "nicht angegeben")}</span>
|
||||
<span>
|
||||
Stand:{" "}
|
||||
{updatedAt && isValidDate(updatedAt) ? (
|
||||
<time dateTime={updatedAt} title={formatDateTime(updatedAt)}>
|
||||
{formatAge(updatedAt, now)}
|
||||
</time>
|
||||
) : (
|
||||
"Zeit unbekannt"
|
||||
)}
|
||||
</span>
|
||||
{validAt && isValidDate(validAt) && (
|
||||
<span>
|
||||
Gültig: <time dateTime={validAt}>{formatDateTime(validAt)}</time>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteAssessment({
|
||||
report,
|
||||
now
|
||||
}: {
|
||||
report: ConditionsRouteWeatherReport;
|
||||
now: number;
|
||||
}) {
|
||||
const unavailableSamples = finiteNumber(report.unavailableSamples);
|
||||
const hasUnavailableSamples = unavailableSamples !== null && unavailableSamples > 0;
|
||||
|
||||
return (
|
||||
<div className="conditions-route-assessment" data-severity={report.severity ?? "unknown"}>
|
||||
{(report.severity || report.summary) && (
|
||||
<p>
|
||||
{report.severity && <strong>{severityLabel(report.severity)}: </strong>}
|
||||
{report.summary || "Streckenbedingungen teilweise verfügbar."}
|
||||
</p>
|
||||
)}
|
||||
{hasUnavailableSamples && (
|
||||
<p className="conditions-panel-state" data-state="warning">
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
{unavailableSamples === 1
|
||||
? "Für einen Streckenpunkt fehlt die Prognose."
|
||||
: `Für ${unavailableSamples} Streckenpunkte fehlt die Prognose.`}
|
||||
</p>
|
||||
)}
|
||||
{(report.source || report.updatedAt) && (
|
||||
<DataProvenance source={report.source} updatedAt={report.updatedAt} now={now} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteSampleCard({
|
||||
label,
|
||||
sample,
|
||||
tide,
|
||||
routeTidesAvailable,
|
||||
now
|
||||
}: {
|
||||
label: (typeof ROUTE_SAMPLE_LABELS)[number];
|
||||
sample: ConditionsRouteWeatherSample | null;
|
||||
tide: TideSummary | null;
|
||||
routeTidesAvailable: boolean;
|
||||
now: number;
|
||||
}) {
|
||||
return (
|
||||
<article className="conditions-route-sample" data-sample={label.toLowerCase()}>
|
||||
<header>
|
||||
<h4>{label}</h4>
|
||||
{sample?.plannedTime && isValidDate(sample.plannedTime) && (
|
||||
<time dateTime={sample.plannedTime}>{formatDateTime(sample.plannedTime)}</time>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{sample ? (
|
||||
<>
|
||||
<dl>
|
||||
<Metric label="Wind" value={formatWind(sample.forecast)} icon={<Wind size={14} aria-hidden="true" />} />
|
||||
<Metric label="Welle" value={formatWave(sample.forecast)} icon={<Waves size={14} aria-hidden="true" />} />
|
||||
<Metric
|
||||
label="Strom"
|
||||
value={formatRouteCurrent(sample)}
|
||||
icon={<Navigation2 size={14} aria-hidden="true" />}
|
||||
/>
|
||||
</dl>
|
||||
<DataProvenance
|
||||
source={sample.forecast.source}
|
||||
updatedAt={sample.forecast.updatedAt}
|
||||
validAt={sample.forecast.forecastTime}
|
||||
now={now}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="conditions-inline-empty">Keine Wetterprognose für diesen Streckenpunkt.</p>
|
||||
)}
|
||||
|
||||
{tide ? (
|
||||
<div className="conditions-route-tide">
|
||||
<strong>
|
||||
{safeText(tide.station, "Tidenstation")} · {formatDistanceKm(tide.distanceKm)}
|
||||
</strong>
|
||||
<span>{formatCompactTideEvent("HW", tide.nextHigh)}</span>
|
||||
<span>{formatCompactTideEvent("NW", tide.nextLow)}</span>
|
||||
</div>
|
||||
) : (
|
||||
routeTidesAvailable && (
|
||||
<p className="conditions-inline-empty">Keine passende Tide für {label.toLowerCase()}.</p>
|
||||
)
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function formatWind(forecast: MarineForecast) {
|
||||
return joinMeasurements(
|
||||
formatMeasurement(forecast.windSpeed, "kn", 0),
|
||||
formatDirection(forecast.windDirectionDeg)
|
||||
);
|
||||
}
|
||||
|
||||
function formatWave(forecast: MarineForecast) {
|
||||
return joinMeasurements(
|
||||
formatMeasurement(forecast.waveHeightM, "m", 1),
|
||||
formatMeasurement(forecast.wavePeriodS, "s", 0),
|
||||
formatDirection(forecast.waveDirectionDeg)
|
||||
);
|
||||
}
|
||||
|
||||
function formatCurrent(forecast: MarineForecast) {
|
||||
return joinMeasurements(
|
||||
formatMeasurement(forecast.oceanCurrentSpeedKn, "kn", 1),
|
||||
formatDirection(forecast.oceanCurrentDirectionDeg)
|
||||
);
|
||||
}
|
||||
|
||||
function formatRouteCurrent(sample: ConditionsRouteWeatherSample) {
|
||||
const alongRoute = finiteNumber(sample.currentAlongRouteKn);
|
||||
if (alongRoute !== null) {
|
||||
const sign = alongRoute > 0 ? "+" : "";
|
||||
return `${sign}${alongRoute.toFixed(1)} kn entlang Route`;
|
||||
}
|
||||
return formatCurrent(sample.forecast);
|
||||
}
|
||||
|
||||
function formatTemperature(value: number | null | undefined) {
|
||||
const normalized = finiteNumber(value);
|
||||
return normalized === null ? "Keine Daten" : `${normalized.toFixed(1)} °C`;
|
||||
}
|
||||
|
||||
function formatMeasurement(value: number | null | undefined, unit: string, digits: number) {
|
||||
const normalized = finiteNumber(value);
|
||||
return normalized === null ? null : `${normalized.toFixed(digits)} ${unit}`;
|
||||
}
|
||||
|
||||
function formatDirection(value: number | null | undefined) {
|
||||
const normalized = finiteNumber(value);
|
||||
if (normalized === null) {
|
||||
return null;
|
||||
}
|
||||
const heading = ((normalized % 360) + 360) % 360;
|
||||
const cardinal = ["N", "NO", "O", "SO", "S", "SW", "W", "NW"][
|
||||
Math.round(heading / 45) % 8
|
||||
];
|
||||
return `${String(Math.round(heading)).padStart(3, "0")}° ${cardinal}`;
|
||||
}
|
||||
|
||||
function formatDistanceKm(value: number) {
|
||||
const normalized = finiteNumber(value);
|
||||
return normalized === null
|
||||
? "Entfernung unbekannt"
|
||||
: `${normalized.toLocaleString("de-DE", { maximumFractionDigits: 1 })} km`;
|
||||
}
|
||||
|
||||
function formatTideHeight(value: number | null | undefined) {
|
||||
const normalized = finiteNumber(value);
|
||||
return normalized === null ? "Höhe offen" : `${normalized.toFixed(2)} m`;
|
||||
}
|
||||
|
||||
function formatCompactTideEvent(label: "HW" | "NW", event: TideEvent | null) {
|
||||
if (!event) {
|
||||
return `${label} offen`;
|
||||
}
|
||||
return `${label} ${formatDateTime(event.time)} · ${formatTideHeight(event.heightM)}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime())
|
||||
? date.toLocaleString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
})
|
||||
: "Zeit offen";
|
||||
}
|
||||
|
||||
function formatAge(value: string, now: number) {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp) || !Number.isFinite(now)) {
|
||||
return "Zeit unbekannt";
|
||||
}
|
||||
if (timestamp - now > 60_000) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
const ageMs = Math.max(0, now - timestamp);
|
||||
const minutes = Math.floor(ageMs / 60_000);
|
||||
if (minutes < 1) {
|
||||
return "gerade aktualisiert";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `vor ${minutes} Min.`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) {
|
||||
return `vor ${hours} Std.`;
|
||||
}
|
||||
return `vor ${Math.floor(hours / 24)} Tagen`;
|
||||
}
|
||||
|
||||
function severityLabel(severity: NonNullable<ConditionsRouteWeatherReport["severity"]>) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "Kritisch";
|
||||
case "caution":
|
||||
return "Achtung";
|
||||
case "ok":
|
||||
return "Unauffällig";
|
||||
}
|
||||
}
|
||||
|
||||
function joinMeasurements(...parts: Array<string | null>) {
|
||||
const available = parts.filter((part): part is string => Boolean(part));
|
||||
return available.length > 0 ? available.join(" · ") : "Keine Daten";
|
||||
}
|
||||
|
||||
function finiteNumber(value: number | null | undefined) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function isValidDate(value: string) {
|
||||
return Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
function safeText(value: string | null | undefined, fallback: string) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function joinClassNames(...values: Array<string | null | undefined | false>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
.course-assistant-panel {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: calc(86px + env(safe-area-inset-bottom));
|
||||
width: min(430px, calc(100vw - 20px));
|
||||
margin: 0 auto;
|
||||
border: 2px solid rgba(15, 76, 92, 0.42);
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
background: rgba(246, 249, 247, 0.97);
|
||||
color: #10242b;
|
||||
box-shadow: 0 14px 38px rgba(7, 25, 29, 0.28);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.course-assistant-panel[data-alert="true"] {
|
||||
border-color: #c44a30;
|
||||
}
|
||||
|
||||
.course-assistant-header,
|
||||
.course-assistant-header > span,
|
||||
.course-assistant-header button,
|
||||
.course-assistant-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.course-assistant-header {
|
||||
min-height: 36px;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.course-assistant-header > span {
|
||||
gap: 7px;
|
||||
color: #0f4c5c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.course-assistant-header button {
|
||||
min-width: 72px;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 9px;
|
||||
background: #e2ece9;
|
||||
color: #23434c;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.course-assistant-main {
|
||||
min-height: 72px;
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border-radius: 11px;
|
||||
padding: 7px 10px;
|
||||
background: #0f4c5c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.course-assistant-panel[data-alert="true"] .course-assistant-main {
|
||||
background: #7c3327;
|
||||
}
|
||||
|
||||
.course-assistant-arrow {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-origin: 50% 50%;
|
||||
color: #ffce66;
|
||||
}
|
||||
|
||||
.course-assistant-arrow[data-muted="true"] {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.course-assistant-arrow svg {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.course-assistant-course {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
column-gap: 8px;
|
||||
}
|
||||
|
||||
.course-assistant-course > span {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.course-assistant-course strong {
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: clamp(29px, 9vw, 42px);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.course-assistant-course small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
line-height: 1.1;
|
||||
color: #d9edeb;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.course-assistant-status,
|
||||
.course-assistant-turn,
|
||||
.course-assistant-safety {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.course-assistant-status {
|
||||
min-height: 30px;
|
||||
gap: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.course-assistant-panel[data-alert="true"] .course-assistant-status {
|
||||
background: #ffe1dc;
|
||||
color: #9d2c22;
|
||||
}
|
||||
|
||||
.course-assistant-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.course-assistant-metrics > span {
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
padding: 5px 7px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
background: #edf3f1;
|
||||
}
|
||||
|
||||
.course-assistant-metrics small {
|
||||
color: #607278;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.course-assistant-metrics strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #16323a;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.course-assistant-progress {
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
border-radius: 99px;
|
||||
background: #d5e1dd;
|
||||
}
|
||||
|
||||
.course-assistant-progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #d89c28;
|
||||
}
|
||||
|
||||
.course-assistant-turn {
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.course-assistant-safety {
|
||||
color: #607278;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.app-shell[data-guidance-active="true"] .route-panel-toggle {
|
||||
top: calc(env(safe-area-inset-top) + 62px);
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.app-shell[data-guidance-active="true"] .data-badge {
|
||||
top: calc(env(safe-area-inset-top) + 266px);
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.course-assistant-panel {
|
||||
left: auto;
|
||||
right: 12px;
|
||||
width: 390px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 680px) {
|
||||
.course-assistant-safety {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.course-assistant-panel {
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.course-assistant-arrow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { AlertTriangle, Navigation, Square } from "lucide-react";
|
||||
import type { RouteGuidanceResult } from "@watermaps/shared";
|
||||
import "./CourseAssistantPanel.css";
|
||||
|
||||
export type CourseAssistantPanelProps = {
|
||||
guidance: RouteGuidanceResult | null;
|
||||
gpsStatus: string;
|
||||
headingDeg: number | null;
|
||||
headingSource: "COG" | "HDG" | "--";
|
||||
accuracyM: number | null;
|
||||
fixStale: boolean;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
export function CourseAssistantPanel({
|
||||
guidance,
|
||||
gpsStatus,
|
||||
headingDeg,
|
||||
headingSource,
|
||||
accuracyM,
|
||||
fixStale,
|
||||
onStop
|
||||
}: CourseAssistantPanelProps) {
|
||||
const waitingForGps = !guidance && !fixStale;
|
||||
const suppressSteering =
|
||||
!guidance || fixStale || guidance.status === "gps-unreliable" || guidance.status === "arrived";
|
||||
const alertState = Boolean(fixStale || guidance?.status === "off-route" || guidance?.status === "gps-unreliable");
|
||||
const correction = guidance?.courseCorrectionDeg ?? null;
|
||||
const progress = guidance ? Math.round(guidance.progressRatio * 100) : 0;
|
||||
const statusText = guidanceStatusText(guidance, fixStale, gpsStatus);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="course-assistant-panel"
|
||||
aria-label="Kursassistent"
|
||||
data-alert={alertState}
|
||||
data-status={fixStale ? "stale-fix" : guidance?.status ?? "waiting-gps"}
|
||||
>
|
||||
<header className="course-assistant-header">
|
||||
<span>
|
||||
<Navigation size={17} aria-hidden="true" />
|
||||
<strong>Kursassistent</strong>
|
||||
</span>
|
||||
<button type="button" onClick={onStop} aria-label="Kursassistent stoppen">
|
||||
<Square size={14} aria-hidden="true" />
|
||||
Stop
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="course-assistant-main">
|
||||
<span
|
||||
className="course-assistant-arrow"
|
||||
data-muted={suppressSteering}
|
||||
style={{ transform: `rotate(${suppressSteering || correction === null ? 0 : correction}deg)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Navigation size={32} />
|
||||
</span>
|
||||
<div className="course-assistant-course">
|
||||
<span>SOLL ÜBER GRUND</span>
|
||||
<strong>{suppressSteering ? "---" : formatCourse(guidance.desiredCourseDeg)}</strong>
|
||||
<small>{suppressSteering ? "Keine verlässliche Steueranweisung" : correctionText(correction)}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="course-assistant-status" aria-live="polite">
|
||||
{alertState && <AlertTriangle size={14} aria-hidden="true" />}
|
||||
<span>{waitingForGps ? "GPS-Fix wird ermittelt …" : statusText}</span>
|
||||
</p>
|
||||
|
||||
{guidance && (
|
||||
<>
|
||||
<div className="course-assistant-metrics">
|
||||
<span>
|
||||
<small>IST {headingSource}</small>
|
||||
<strong>{headingDeg === null ? "---" : formatActualHeading(headingDeg, headingSource)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>QUERABSTAND</small>
|
||||
<strong>{formatCrossTrack(guidance)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>REST</small>
|
||||
<strong>{formatNauticalMiles(guidance.remainingRouteDistanceM)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="course-assistant-progress"
|
||||
role="progressbar"
|
||||
aria-label="Routenfortschritt"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress}
|
||||
aria-valuetext={`${progress} Prozent`}
|
||||
>
|
||||
<span style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
{guidance.nextTurn && guidance.status !== "arrived" && (
|
||||
<p className="course-assistant-turn">
|
||||
{turnLabel(guidance.nextTurn.direction)} in {formatDistance(guidance.nextTurn.distanceM)}
|
||||
{` · danach ${formatCourse(guidance.nextTurn.outgoingCourseDeg)}`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<small className="course-assistant-safety">
|
||||
Steuert das Boot nicht. Sollkurs über Grund; Ufer, Tonnen, Verkehr und amtliche Unterlagen haben Vorrang.
|
||||
{accuracyM !== null ? ` GPS ±${Math.round(accuracyM)} m.` : ""}
|
||||
</small>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function guidanceStatusText(guidance: RouteGuidanceResult | null, fixStale: boolean, gpsStatus: string) {
|
||||
if (fixStale) return "GPS-Fix ist veraltet – Kursanweisung pausiert.";
|
||||
if (!guidance) {
|
||||
if (gpsStatus === "denied") return "GPS-Freigabe wurde abgelehnt.";
|
||||
if (gpsStatus === "unavailable") return "GPS ist auf diesem Gerät nicht verfügbar.";
|
||||
if (gpsStatus === "error") return "GPS-Position konnte nicht gelesen werden.";
|
||||
return "GPS-Fix wird ermittelt …";
|
||||
}
|
||||
switch (guidance.status) {
|
||||
case "arrived":
|
||||
return "Ziel erreicht.";
|
||||
case "gps-unreliable":
|
||||
return "GPS zu ungenau – Kursanweisung pausiert.";
|
||||
case "off-route":
|
||||
return `Route um ${Math.round(guidance.distanceToRouteM)} m verlassen – nur im freien Fahrwasser zurückkehren.`;
|
||||
case "approaching-turn":
|
||||
return guidance.nextTurn
|
||||
? `${turnLabel(guidance.nextTurn.direction)} in ${formatDistance(guidance.nextTurn.distanceM)}.`
|
||||
: "Kursänderung voraus.";
|
||||
default:
|
||||
return "Auf Route – Sollkurs wird mit jedem GPS-Fix angepasst.";
|
||||
}
|
||||
}
|
||||
|
||||
function correctionText(value: number | null) {
|
||||
if (value === null || !Number.isFinite(value)) return "COG noch nicht verfügbar";
|
||||
const rounded = Math.round(Math.abs(value));
|
||||
if (rounded <= 4) return "Kurs halten";
|
||||
return `${rounded}° nach ${value > 0 ? "Steuerbord" : "Backbord"}`;
|
||||
}
|
||||
|
||||
function formatCrossTrack(guidance: RouteGuidanceResult) {
|
||||
const distance = Math.round(guidance.distanceToRouteM);
|
||||
if (distance <= 3 || guidance.crossTrackSide === "on-route") return "auf Linie";
|
||||
return `${distance} m ${guidance.crossTrackSide === "port" ? "Backbord" : "Steuerbord"}`;
|
||||
}
|
||||
|
||||
function turnLabel(direction: NonNullable<RouteGuidanceResult["nextTurn"]>["direction"]) {
|
||||
if (direction === "port") return "Backbord-Kursänderung";
|
||||
if (direction === "starboard") return "Steuerbord-Kursänderung";
|
||||
return "Wenden";
|
||||
}
|
||||
|
||||
function formatCourse(value: number) {
|
||||
const normalized = Math.round(((value % 360) + 360) % 360);
|
||||
return `${String(normalized === 360 ? 0 : normalized).padStart(3, "0")}°T`;
|
||||
}
|
||||
|
||||
function formatActualHeading(value: number, source: CourseAssistantPanelProps["headingSource"]) {
|
||||
const formatted = formatCourse(value);
|
||||
return source === "COG" ? formatted : formatted.replace("°T", "°");
|
||||
}
|
||||
|
||||
function formatNauticalMiles(meters: number) {
|
||||
const remainingM = Math.max(0, meters);
|
||||
if (remainingM < 185) return `${Math.round(remainingM)} m`;
|
||||
return `${(remainingM / 1852).toFixed(remainingM < 18_520 ? 1 : 0)} sm`;
|
||||
}
|
||||
|
||||
function formatDistance(meters: number) {
|
||||
return meters < 1000 ? `${Math.max(0, Math.round(meters))} m` : `${(meters / 1000).toFixed(1)} km`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, Suspense, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
type LazyContentProps = {
|
||||
children: ReactNode;
|
||||
pending: ReactNode;
|
||||
failed: ReactNode;
|
||||
};
|
||||
|
||||
type LazyLoadErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
};
|
||||
|
||||
type LazyLoadErrorBoundaryState = {
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
export function LazyContent({ children, pending, failed }: LazyContentProps) {
|
||||
return (
|
||||
<LazyLoadErrorBoundary fallback={failed}>
|
||||
<Suspense fallback={pending}>{children}</Suspense>
|
||||
</LazyLoadErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
class LazyLoadErrorBoundary extends Component<
|
||||
LazyLoadErrorBoundaryProps,
|
||||
LazyLoadErrorBoundaryState
|
||||
> {
|
||||
state: LazyLoadErrorBoundaryState = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(): LazyLoadErrorBoundaryState {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(_error: unknown, _errorInfo: ErrorInfo) {
|
||||
// The local fallback keeps the rest of the navigation UI usable. A reload
|
||||
// can then pick up a newer PWA chunk after a deployment.
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Globe2, Mail, Phone, X } from "lucide-react";
|
||||
import type { Coordinate } from "@watermaps/shared";
|
||||
|
||||
export type MarineFeatureDetails = {
|
||||
id: string;
|
||||
layer: "locks" | "harbours";
|
||||
name: string;
|
||||
typeLabel: "Schleuse" | "Hafen";
|
||||
coordinate: Coordinate;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
email: string | null;
|
||||
vhf: string | null;
|
||||
openingHours: string | null;
|
||||
operator: string | null;
|
||||
address: string | null;
|
||||
source: string | null;
|
||||
sourceUrl?: string | null;
|
||||
updatedAt: string | null;
|
||||
memberCount?: number | null;
|
||||
};
|
||||
|
||||
type MarineFeatureInfoProps = {
|
||||
feature: MarineFeatureDetails;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function MarineFeatureInfo({ feature, onClose }: MarineFeatureInfoProps) {
|
||||
const dialogRef = useRef<HTMLElement | null>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
|
||||
const phone = presentValue(feature.phone);
|
||||
const website = presentValue(feature.website);
|
||||
const email = presentValue(feature.email);
|
||||
const vhf = presentValue(feature.vhf);
|
||||
const openingHours = presentValue(feature.openingHours);
|
||||
const operator = presentValue(feature.operator);
|
||||
const address = presentValue(feature.address);
|
||||
const source = presentValue(feature.source);
|
||||
const updatedAt = presentValue(feature.updatedAt);
|
||||
const phoneHref = phone ? telephoneHref(phone) : null;
|
||||
const websiteHref = website ? websiteUrl(website) : null;
|
||||
const sourceHref = feature.sourceUrl ? websiteUrl(feature.sourceUrl) : null;
|
||||
const hasContactActions = Boolean(phoneHref || websiteHref || email);
|
||||
const hasOperatingDetails = Boolean(vhf || openingHours || operator || address);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
previouslyFocusedElementRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab" || !dialogRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = getFocusableElements(dialogRef.current);
|
||||
if (focusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
dialogRef.current.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
if (!firstElement || !lastElement) {
|
||||
return;
|
||||
}
|
||||
const activeElement = document.activeElement;
|
||||
const focusIsOutsideDialog =
|
||||
!(activeElement instanceof Node) || !dialogRef.current.contains(activeElement);
|
||||
|
||||
if (event.shiftKey && (activeElement === firstElement || focusIsOutsideDialog)) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!event.shiftKey && (activeElement === lastElement || focusIsOutsideDialog)) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
const previouslyFocusedElement = previouslyFocusedElementRef.current;
|
||||
if (previouslyFocusedElement?.isConnected) {
|
||||
previouslyFocusedElement.focus();
|
||||
}
|
||||
previouslyFocusedElementRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="marine-feature-info-layer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onPointerUp={(event) => event.stopPropagation()}
|
||||
onTouchStart={(event) => event.stopPropagation()}
|
||||
onTouchEnd={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="marine-feature-info-backdrop"
|
||||
type="button"
|
||||
aria-label="Detailansicht schließen"
|
||||
tabIndex={-1}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="marine-feature-info"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="marine-feature-info-title"
|
||||
aria-describedby="marine-feature-info-contact-note"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className="marine-feature-info-header">
|
||||
<div>
|
||||
<span className="marine-feature-type">{feature.typeLabel}</span>
|
||||
<h2 id="marine-feature-info-title">{feature.name}</h2>
|
||||
</div>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
className="marine-feature-info-close"
|
||||
type="button"
|
||||
aria-label="Informationen schließen"
|
||||
title="Informationen schließen"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={20} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{hasContactActions ? (
|
||||
<div className="marine-feature-actions" role="group" aria-label="Kontaktmöglichkeiten">
|
||||
{phoneHref && phone && (
|
||||
<a
|
||||
className="marine-feature-action"
|
||||
data-action="phone"
|
||||
href={phoneHref}
|
||||
aria-label={`${feature.name} anrufen: ${phone}`}
|
||||
>
|
||||
<Phone size={20} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>Anrufen</strong>
|
||||
<small>{phone}</small>
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{websiteHref && (
|
||||
<a
|
||||
className="marine-feature-action"
|
||||
data-action="website"
|
||||
href={websiteHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Website von ${feature.name} in einem neuen Tab öffnen`}
|
||||
>
|
||||
<Globe2 size={20} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>Website</strong>
|
||||
<small>Öffnen</small>
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{email && (
|
||||
<a
|
||||
className="marine-feature-action"
|
||||
data-action="email"
|
||||
href={`mailto:${email}`}
|
||||
aria-label={`E-Mail an ${feature.name}: ${email}`}
|
||||
>
|
||||
<Mail size={20} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>E-Mail</strong>
|
||||
<small>{email}</small>
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="marine-feature-contact-empty">Keine direkten Kontaktdaten hinterlegt.</p>
|
||||
)}
|
||||
|
||||
{hasOperatingDetails && (
|
||||
<dl className="marine-feature-details marine-feature-operating-details">
|
||||
{vhf && <DetailRow label="UKW / VHF">{vhf}</DetailRow>}
|
||||
{openingHours && <DetailRow label="Öffnungszeiten">{openingHours}</DetailRow>}
|
||||
{operator && <DetailRow label="Betreiber">{operator}</DetailRow>}
|
||||
{address && <DetailRow label="Adresse">{address}</DetailRow>}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<details className="marine-feature-metadata">
|
||||
<summary>Daten & Quelle</summary>
|
||||
<dl className="marine-feature-details">
|
||||
<DetailRow label="Koordinaten">{formatCoordinate(feature.coordinate)}</DetailRow>
|
||||
{(source || sourceHref) && (
|
||||
<DetailRow label="Quelle">
|
||||
{sourceHref ? (
|
||||
<a href={sourceHref} target="_blank" rel="noreferrer">
|
||||
{source ?? "Quelldatensatz öffnen"}
|
||||
</a>
|
||||
) : (
|
||||
source
|
||||
)}
|
||||
</DetailRow>
|
||||
)}
|
||||
{updatedAt && <DetailRow label="Datenstand">{formatTimestamp(updatedAt)}</DetailRow>}
|
||||
{(feature.memberCount ?? 1) > 1 && (
|
||||
<DetailRow label="Zusammengeführt">{feature.memberCount} Kartenobjekte</DetailRow>
|
||||
)}
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<p id="marine-feature-info-contact-note" className="marine-feature-contact-note">
|
||||
Kontaktdaten können unvollständig oder veraltet sein. Vor der Fahrt bei der zuständigen Stelle prüfen.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCoordinate(coordinate: Coordinate) {
|
||||
return `${coordinate.lat.toFixed(5)}°, ${coordinate.lon.toFixed(5)}°`;
|
||||
}
|
||||
|
||||
function presentValue(value: string | null | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function getFocusableElements(container: HTMLElement) {
|
||||
const selector = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"summary",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(",");
|
||||
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter((element) => {
|
||||
if (element.getAttribute("aria-hidden") === "true" || element.closest("[hidden]")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const closedDetails = element.closest("details:not([open])");
|
||||
return !closedDetails || element.tagName === "SUMMARY";
|
||||
});
|
||||
}
|
||||
|
||||
function telephoneHref(value: string) {
|
||||
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
|
||||
return compact ? `tel:${compact}` : null;
|
||||
}
|
||||
|
||||
function websiteUrl(value: string) {
|
||||
const trimmed = value.trim().split(/[;,]/)[0]?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime())
|
||||
? date.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })
|
||||
: value;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { AlertTriangle, ExternalLink, Gauge, Radio, ShipWheel } from "lucide-react";
|
||||
import type { NavigationDataSnapshot, NavigationSourceStatus, WaterLevel } from "@watermaps/shared";
|
||||
|
||||
type NavigationDataPanelProps = {
|
||||
snapshot: NavigationDataSnapshot | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export function NavigationDataPanel({ snapshot, loading, error }: NavigationDataPanelProps) {
|
||||
if (!loading && !snapshot && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="navigation-data-panel" aria-label="Live-Fahrtdaten">
|
||||
<header>
|
||||
<Radio size={15} aria-hidden="true" />
|
||||
<strong>Live-Fahrtdaten</strong>
|
||||
{snapshot && <span>{formatClock(snapshot.generatedAt)}</span>}
|
||||
</header>
|
||||
|
||||
{loading && <p>WSV-Daten werden geladen</p>}
|
||||
{error && (
|
||||
<p className="navigation-data-warning">
|
||||
<AlertTriangle size={14} aria-hidden="true" /> {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{snapshot && (
|
||||
<>
|
||||
{snapshot.waterLevels.length > 0 ? (
|
||||
<div className="water-level-list">
|
||||
{snapshot.waterLevels.slice(0, 8).map((level) => (
|
||||
<WaterLevelRow key={level.stationId} level={level} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>Keine passenden PEGELONLINE-Messstellen gefunden.</p>
|
||||
)}
|
||||
|
||||
{snapshot.lockOperations.map((lock) => (
|
||||
<div className="navigation-operation" key={lock.id} data-state={lock.operatingState}>
|
||||
<ShipWheel size={14} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{lock.name}</strong>
|
||||
{lock.regularHours ?? lock.note ?? "Betriebsinformation ohne Zeitangabe"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{snapshot.notices.map((notice) => (
|
||||
<a className="navigation-notice" key={notice.id} href={notice.sourceUrl} target="_blank" rel="noreferrer">
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{notice.title}</strong>
|
||||
{notice.location ?? notice.waterway ?? "ELWIS-Nachricht"}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
|
||||
<div className="navigation-source-list">
|
||||
{snapshot.sources.map((source) => (
|
||||
<SourceLink key={`${source.kind}:${source.id}`} source={source} />
|
||||
))}
|
||||
</div>
|
||||
<p className="navigation-data-disclaimer">
|
||||
Live-Daten können verzögert oder unvollständig sein. Schleusenabweichungen und Sperrungen vor Abfahrt
|
||||
zusätzlich in ELWIS prüfen.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WaterLevelRow({ level }: { level: WaterLevel }) {
|
||||
return (
|
||||
<a href={level.sourceUrl} target="_blank" rel="noreferrer" data-state={level.stateMnwMhw}>
|
||||
<Gauge size={14} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{level.stationName}</strong>
|
||||
{level.waterway} {level.waterwayKm !== null ? `km ${formatNumber(level.waterwayKm)}` : ""}
|
||||
</span>
|
||||
<span>
|
||||
<strong>
|
||||
{formatNumber(level.value)} {level.unit}
|
||||
</strong>
|
||||
{waterLevelLabel(level.stateMnwMhw)} · {formatDateTime(level.measuredAt)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceLink({ source }: { source: NavigationSourceStatus }) {
|
||||
return (
|
||||
<a href={source.sourceUrl} target="_blank" rel="noreferrer" data-state={source.state}>
|
||||
<span>{source.label}</span>
|
||||
<span>{sourceStateLabel(source.state)}</span>
|
||||
<ExternalLink size={12} aria-hidden="true" />
|
||||
{source.warning && <small>{source.warning}</small>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceStateLabel(state: NavigationSourceStatus["state"]) {
|
||||
switch (state) {
|
||||
case "live":
|
||||
return "Live";
|
||||
case "cached":
|
||||
return "Cache";
|
||||
case "stale":
|
||||
return "Veraltet";
|
||||
case "unavailable":
|
||||
return "Nicht erreichbar";
|
||||
default:
|
||||
return "Offiziell prüfen";
|
||||
}
|
||||
}
|
||||
|
||||
function waterLevelLabel(state: WaterLevel["stateMnwMhw"]) {
|
||||
switch (state) {
|
||||
case "low":
|
||||
return "niedrig";
|
||||
case "normal":
|
||||
return "normal";
|
||||
case "high":
|
||||
return "hoch";
|
||||
case "out-dated":
|
||||
return "veraltet";
|
||||
case "commented":
|
||||
return "kommentiert";
|
||||
default:
|
||||
return "Status offen";
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime())
|
||||
? date.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
|
||||
: "Zeit offen";
|
||||
}
|
||||
|
||||
function formatClock(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime())
|
||||
? date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
|
||||
: "";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Anchor, Bell, CloudSun, Route as RouteIcon, type LucideIcon } from "lucide-react";
|
||||
|
||||
export const NAVIGATION_TOOL_ORDER = [
|
||||
"anchor",
|
||||
"conditions",
|
||||
"upcoming",
|
||||
"route"
|
||||
] as const;
|
||||
|
||||
export type NavigationToolId = (typeof NAVIGATION_TOOL_ORDER)[number];
|
||||
export type ActiveTool = NavigationToolId | null;
|
||||
export type NavigationToolStatus = "idle" | "active" | "caution" | "alarm" | "stale";
|
||||
|
||||
type NavigationToolDefinition = {
|
||||
id: NavigationToolId;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
};
|
||||
|
||||
const NAVIGATION_TOOLS: readonly NavigationToolDefinition[] = [
|
||||
{ id: "anchor", label: "Ankerwache", Icon: Anchor },
|
||||
{ id: "conditions", label: "Wetter und Tide", Icon: CloudSun },
|
||||
{ id: "upcoming", label: "Als Nächstes", Icon: Bell },
|
||||
{ id: "route", label: "Route", Icon: RouteIcon }
|
||||
];
|
||||
|
||||
export type NavigationToolRailProps = {
|
||||
activeTool: ActiveTool;
|
||||
onSelect: (tool: NavigationToolId) => void;
|
||||
statuses?: Partial<Record<NavigationToolId, NavigationToolStatus>>;
|
||||
badges?: Partial<Record<NavigationToolId, number | string | null>>;
|
||||
disabledTools?: Partial<Record<NavigationToolId, boolean>>;
|
||||
workspaceId?: string;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function NavigationToolRail({
|
||||
activeTool,
|
||||
onSelect,
|
||||
statuses = {},
|
||||
badges = {},
|
||||
disabledTools = {},
|
||||
workspaceId = "navigation-workspace",
|
||||
className,
|
||||
ariaLabel = "Kartenwerkzeuge"
|
||||
}: NavigationToolRailProps) {
|
||||
return (
|
||||
<nav className={classNames("navigation-tool-rail", className)} aria-label={ariaLabel}>
|
||||
{NAVIGATION_TOOLS.map(({ id, label, Icon }) => {
|
||||
const active = activeTool === id;
|
||||
const status = statuses[id] ?? "idle";
|
||||
const badge = formatBadge(badges[id]);
|
||||
const accessibleLabel = toolAriaLabel(label, status, badge, active);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
className="navigation-tool-rail-button"
|
||||
type="button"
|
||||
disabled={Boolean(disabledTools[id])}
|
||||
data-tool={id}
|
||||
data-status={status}
|
||||
data-active={active}
|
||||
aria-controls={workspaceId}
|
||||
aria-expanded={active}
|
||||
aria-pressed={active}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
onClick={() => onSelect(id)}
|
||||
>
|
||||
<Icon size={21} aria-hidden="true" />
|
||||
<span className="navigation-tool-rail-label" aria-hidden="true">
|
||||
{label}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="navigation-tool-rail-badge" aria-hidden="true">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function navigationToolLabel(tool: NavigationToolId) {
|
||||
return NAVIGATION_TOOLS.find((definition) => definition.id === tool)?.label ?? tool;
|
||||
}
|
||||
|
||||
function toolAriaLabel(
|
||||
label: string,
|
||||
status: NavigationToolStatus,
|
||||
badge: string | null,
|
||||
active: boolean
|
||||
) {
|
||||
const parts = [label, statusLabel(status)];
|
||||
if (badge) {
|
||||
parts.push(`${badge} Hinweise`);
|
||||
}
|
||||
parts.push(active ? "geöffnet" : "öffnen");
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function statusLabel(status: NavigationToolStatus) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "aktiv";
|
||||
case "caution":
|
||||
return "Warnung";
|
||||
case "alarm":
|
||||
return "Alarm";
|
||||
case "stale":
|
||||
return "Daten veraltet";
|
||||
default:
|
||||
return "bereit";
|
||||
}
|
||||
}
|
||||
|
||||
function formatBadge(value: number | string | null | undefined) {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return value > 99 ? "99+" : String(Math.floor(value));
|
||||
}
|
||||
const normalized = value?.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function classNames(...values: Array<string | null | undefined | false>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
navigationToolLabel,
|
||||
type ActiveTool,
|
||||
type NavigationToolStatus
|
||||
} from "./NavigationToolRail";
|
||||
|
||||
export type NavigationSheetState = "compact" | "half" | "full";
|
||||
export type NavigationWorkspacePresentation =
|
||||
| "responsive"
|
||||
| "bottom-sheet"
|
||||
| "overlay-drawer"
|
||||
| "docked-drawer";
|
||||
|
||||
export type NavigationWorkspaceProps = {
|
||||
activeTool: ActiveTool;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
id?: string;
|
||||
title?: string;
|
||||
summary?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
status?: NavigationToolStatus;
|
||||
sheetState?: NavigationSheetState;
|
||||
onSheetStateChange?: (state: NavigationSheetState) => void;
|
||||
presentation?: NavigationWorkspacePresentation;
|
||||
onBack?: () => void;
|
||||
backLabel?: string;
|
||||
closeLabel?: string;
|
||||
busy?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function NavigationWorkspace({
|
||||
activeTool,
|
||||
children,
|
||||
onClose,
|
||||
id = "navigation-workspace",
|
||||
title,
|
||||
summary,
|
||||
leading,
|
||||
footer,
|
||||
status = "idle",
|
||||
sheetState = "half",
|
||||
onSheetStateChange,
|
||||
presentation = "responsive",
|
||||
onBack,
|
||||
backLabel = "Zurück",
|
||||
closeLabel,
|
||||
busy = false,
|
||||
className
|
||||
}: NavigationWorkspaceProps) {
|
||||
if (!activeTool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolLabel = navigationToolLabel(activeTool);
|
||||
const heading = title ?? toolLabel;
|
||||
const headingId = `${id}-title`;
|
||||
const bodyId = `${id}-body`;
|
||||
const sizeAction = workspaceSizeAction(sheetState);
|
||||
const compact = sheetState === "compact";
|
||||
|
||||
return (
|
||||
<aside
|
||||
id={id}
|
||||
className={classNames(
|
||||
"navigation-workspace",
|
||||
`navigation-workspace--${presentation}`,
|
||||
className
|
||||
)}
|
||||
aria-labelledby={headingId}
|
||||
aria-busy={busy}
|
||||
data-tool={activeTool}
|
||||
data-status={status}
|
||||
data-presentation={presentation}
|
||||
data-sheet-state={sheetState}
|
||||
>
|
||||
<span className="navigation-workspace-grip" aria-hidden="true">
|
||||
━
|
||||
</span>
|
||||
|
||||
<header className="navigation-workspace-header">
|
||||
{onBack && (
|
||||
<button
|
||||
className="navigation-workspace-back"
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label={backLabel}
|
||||
title={backLabel}
|
||||
>
|
||||
<ArrowLeft size={19} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{leading && (
|
||||
<span className="navigation-workspace-leading" aria-hidden="true">
|
||||
{leading}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="navigation-workspace-heading">
|
||||
<strong id={headingId}>{heading}</strong>
|
||||
{summary && <div className="navigation-workspace-summary">{summary}</div>}
|
||||
</div>
|
||||
|
||||
{onSheetStateChange && (
|
||||
<button
|
||||
className="navigation-workspace-size-action"
|
||||
type="button"
|
||||
onClick={() => onSheetStateChange(sizeAction.nextState)}
|
||||
aria-controls={bodyId}
|
||||
aria-label={sizeAction.label}
|
||||
title={sizeAction.label}
|
||||
>
|
||||
{sizeAction.direction === "up" ? (
|
||||
<ArrowUp size={19} aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDown size={19} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="navigation-workspace-close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel ?? `${toolLabel} schließen`}
|
||||
title={closeLabel ?? `${toolLabel} schließen`}
|
||||
>
|
||||
<X size={19} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
id={bodyId}
|
||||
className="navigation-workspace-body"
|
||||
hidden={compact}
|
||||
data-collapsed={compact}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<footer className="navigation-workspace-footer" hidden={compact}>
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function workspaceSizeAction(state: NavigationSheetState): {
|
||||
nextState: NavigationSheetState;
|
||||
label: string;
|
||||
direction: "up" | "down";
|
||||
} {
|
||||
if (state === "compact") {
|
||||
return {
|
||||
nextState: "half",
|
||||
label: "Arbeitsbereich auf halbe Höhe vergrößern",
|
||||
direction: "up"
|
||||
};
|
||||
}
|
||||
if (state === "half") {
|
||||
return {
|
||||
nextState: "full",
|
||||
label: "Arbeitsbereich auf volle Höhe vergrößern",
|
||||
direction: "up"
|
||||
};
|
||||
}
|
||||
return {
|
||||
nextState: "compact",
|
||||
label: "Arbeitsbereich auf kompakte Höhe verkleinern",
|
||||
direction: "down"
|
||||
};
|
||||
}
|
||||
|
||||
function classNames(...values: Array<string | null | undefined | false>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import { AlertTriangle, Waves } from "lucide-react";
|
||||
import type { TideEvent, TideSummary } from "@watermaps/shared";
|
||||
|
||||
export type RouteTidePlan = {
|
||||
start: TideSummary | null;
|
||||
middle?: TideSummary | null;
|
||||
destination: TideSummary | null;
|
||||
};
|
||||
|
||||
type RouteTidePanelProps = {
|
||||
plan: RouteTidePlan | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export function RouteTidePanel({ plan, loading, error }: RouteTidePanelProps) {
|
||||
if (!loading && !plan && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="route-tide-panel" aria-label="Tidenplanung">
|
||||
<header>
|
||||
<Waves size={15} aria-hidden="true" />
|
||||
<strong>Tide zur Fahrtzeit</strong>
|
||||
</header>
|
||||
{loading && <p>Tidenfenster werden geladen.</p>}
|
||||
{error && (
|
||||
<p className="route-tide-warning">
|
||||
<AlertTriangle size={14} aria-hidden="true" /> {error}
|
||||
</p>
|
||||
)}
|
||||
{plan && (
|
||||
<div className="route-tide-locations">
|
||||
<TideLocation label="Start" summary={plan.start} />
|
||||
<TideLocation label="Mitte" summary={plan.middle ?? null} />
|
||||
<TideLocation label="Ziel" summary={plan.destination} />
|
||||
</div>
|
||||
)}
|
||||
<small>Stationsabstand und Bezugsnull beachten; Wasserstände ersetzen keine amtliche Tiefenprüfung.</small>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TideLocation({ label, summary }: { label: string; summary: TideSummary | null }) {
|
||||
return (
|
||||
<article>
|
||||
<strong>{label}</strong>
|
||||
{summary ? (
|
||||
<>
|
||||
<span>
|
||||
{summary.station} · {summary.distanceKm.toLocaleString("de-DE", { maximumFractionDigits: 1 })} km
|
||||
</span>
|
||||
<span>{formatEvent("HW", summary.nextHigh)}</span>
|
||||
<span>{formatEvent("NW", summary.nextLow)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Keine passende Vorhersage</span>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function formatEvent(label: string, event: TideEvent | null) {
|
||||
if (!event) {
|
||||
return `${label} offen`;
|
||||
}
|
||||
const date = new Date(event.time);
|
||||
const time = Number.isFinite(date.getTime())
|
||||
? date.toLocaleString("de-DE", { weekday: "short", hour: "2-digit", minute: "2-digit" })
|
||||
: "Zeit offen";
|
||||
const height = event.heightM !== null ? ` · ${event.heightM.toFixed(2)} m` : "";
|
||||
return `${label} ${time}${height}`;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Activity, AlertTriangle, Anchor, Navigation2, Waves, Wind } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { MarineForecast, RouteGuidanceResult, TideSummary } from "@watermaps/shared";
|
||||
import type { GpsState } from "../hooks/useGeolocation";
|
||||
|
||||
type StatusBarProps = {
|
||||
gps: GpsState;
|
||||
forecast: MarineForecast | null;
|
||||
tide: TideSummary | null;
|
||||
routeWarningCount: number;
|
||||
mode?: "planning" | "route" | "guidance" | "anchor";
|
||||
guidance?: RouteGuidanceResult | null;
|
||||
anchor?: {
|
||||
distanceFromAnchorM: number | null;
|
||||
alarmRadiusM: number;
|
||||
alarm: boolean;
|
||||
maximumTideRiseM: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function StatusBar({
|
||||
gps,
|
||||
forecast,
|
||||
tide,
|
||||
routeWarningCount,
|
||||
mode,
|
||||
guidance = null,
|
||||
anchor = null
|
||||
}: StatusBarProps) {
|
||||
const resolvedMode =
|
||||
mode ??
|
||||
(routeWarningCount > 0
|
||||
? "route"
|
||||
: gps.status === "requesting" || gps.status === "tracking"
|
||||
? "guidance"
|
||||
: "planning");
|
||||
const gpsValue = gps.position
|
||||
? gps.accuracyM === null
|
||||
? "Position aktiv"
|
||||
: `±${gps.accuracyM} m`
|
||||
: gpsStatusLabel(gps.status);
|
||||
const speedValue = gps.speedKn === null ? "-- kn" : `${gps.speedKn.toFixed(1)} kn`;
|
||||
const tideValue = tide?.nextHigh
|
||||
? `HW ${new Date(tide.nextHigh.time).toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
})}`
|
||||
: "Keine Daten";
|
||||
const conditionsValue = formatConditions(forecast);
|
||||
|
||||
const items =
|
||||
resolvedMode === "anchor"
|
||||
? [
|
||||
{
|
||||
icon: anchor?.alarm
|
||||
? <AlertTriangle size={15} aria-hidden="true" />
|
||||
: <Anchor size={15} aria-hidden="true" />,
|
||||
label: "Anker",
|
||||
value: anchor?.alarm
|
||||
? "Alarm"
|
||||
: anchor?.distanceFromAnchorM === null || anchor?.distanceFromAnchorM === undefined
|
||||
? `Radius ${Math.round(anchor?.alarmRadiusM ?? 0)} m`
|
||||
: `${Math.round(anchor.distanceFromAnchorM)} / ${Math.round(anchor.alarmRadiusM)} m`
|
||||
},
|
||||
{
|
||||
icon: <Navigation2 size={15} aria-hidden="true" />,
|
||||
label: "GPS",
|
||||
value: gpsValue
|
||||
},
|
||||
{
|
||||
icon: <Waves size={15} aria-hidden="true" />,
|
||||
label: "Tidenanstieg",
|
||||
value:
|
||||
anchor?.maximumTideRiseM === null || anchor?.maximumTideRiseM === undefined
|
||||
? "Keine Daten"
|
||||
: `+${anchor.maximumTideRiseM.toFixed(1)} m`
|
||||
}
|
||||
]
|
||||
: resolvedMode === "guidance"
|
||||
? [
|
||||
{
|
||||
icon: <Navigation2 size={15} aria-hidden="true" />,
|
||||
label: "Sollkurs",
|
||||
value: guidance ? formatCourse(guidance.desiredCourseDeg) : "Warte auf GPS"
|
||||
},
|
||||
{
|
||||
icon: <Activity size={15} aria-hidden="true" />,
|
||||
label: "Abweichung",
|
||||
value: guidance ? formatCrossTrack(guidance) : "--"
|
||||
},
|
||||
routeWarningCount > 0
|
||||
? {
|
||||
icon: <AlertTriangle size={15} aria-hidden="true" />,
|
||||
label: "Warnungen",
|
||||
value: `${routeWarningCount} offen`
|
||||
}
|
||||
: {
|
||||
icon: <Activity size={15} aria-hidden="true" />,
|
||||
label: "SOG",
|
||||
value: speedValue
|
||||
}
|
||||
]
|
||||
: resolvedMode === "route"
|
||||
? [
|
||||
{
|
||||
icon:
|
||||
routeWarningCount > 0
|
||||
? <AlertTriangle size={15} aria-hidden="true" />
|
||||
: <Navigation2 size={15} aria-hidden="true" />,
|
||||
label: "Warnungen",
|
||||
value: routeWarningCount > 0 ? `${routeWarningCount} offen` : "Keine offenen"
|
||||
},
|
||||
{
|
||||
icon: <Navigation2 size={15} aria-hidden="true" />,
|
||||
label: "GPS",
|
||||
value: gpsValue
|
||||
},
|
||||
gps.position
|
||||
? {
|
||||
icon: <Activity size={15} aria-hidden="true" />,
|
||||
label: "SOG",
|
||||
value: speedValue
|
||||
}
|
||||
: {
|
||||
icon: <Waves size={15} aria-hidden="true" />,
|
||||
label: "Tide",
|
||||
value: tideValue
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
icon: <Navigation2 size={15} aria-hidden="true" />,
|
||||
label: "GPS",
|
||||
value: gpsValue
|
||||
},
|
||||
{
|
||||
icon: <Wind size={15} aria-hidden="true" />,
|
||||
label: "Wetter",
|
||||
value: conditionsValue
|
||||
},
|
||||
{
|
||||
icon: <Waves size={15} aria-hidden="true" />,
|
||||
label: "Tide",
|
||||
value: tideValue
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="status-bar"
|
||||
aria-label="Navigationsstatus"
|
||||
style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<StatusItem key={item.label} icon={item.icon} label={item.label} value={item.value} />
|
||||
))}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusItem({ icon, label, value }: { icon?: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<span className="status-item">
|
||||
{icon}
|
||||
<span className="status-label">{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function gpsStatusLabel(status: GpsState["status"]): string {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return "Aus";
|
||||
case "requesting":
|
||||
return "Position wird gesucht";
|
||||
case "tracking":
|
||||
return "Position aktiv";
|
||||
case "denied":
|
||||
return "Zugriff verweigert";
|
||||
case "unavailable":
|
||||
return "Nicht verfügbar";
|
||||
case "error":
|
||||
return "Fehler";
|
||||
}
|
||||
}
|
||||
|
||||
function formatConditions(forecast: MarineForecast | null): string {
|
||||
if (!forecast || (forecast.windSpeed === null && forecast.waveHeightM === null)) {
|
||||
return "Keine Daten";
|
||||
}
|
||||
|
||||
const wind = forecast.windSpeed === null ? null : `${Math.round(forecast.windSpeed)} kn`;
|
||||
const wave = forecast.waveHeightM === null ? null : `${forecast.waveHeightM.toFixed(1)} m`;
|
||||
return [wind, wave].filter((value): value is string => value !== null).join(" · ");
|
||||
}
|
||||
|
||||
function formatCourse(value: number) {
|
||||
const normalized = Math.round(((value % 360) + 360) % 360) % 360;
|
||||
return `${String(normalized).padStart(3, "0")}°T`;
|
||||
}
|
||||
|
||||
function formatCrossTrack(guidance: RouteGuidanceResult) {
|
||||
const distanceM = Math.round(guidance.distanceToRouteM);
|
||||
if (distanceM <= 3 || guidance.crossTrackSide === "on-route") {
|
||||
return "Auf Linie";
|
||||
}
|
||||
return `${distanceM} m ${guidance.crossTrackSide === "port" ? "Bb" : "Stb"}`;
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
/* Upcoming route events ---------------------------------------------------- */
|
||||
|
||||
.upcoming-events-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upcoming-events-header {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upcoming-events-header > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #0f4c5c;
|
||||
}
|
||||
|
||||
.upcoming-events-header small {
|
||||
color: #607278;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.upcoming-events-message {
|
||||
min-height: 40px;
|
||||
margin: 0;
|
||||
border-radius: 9px;
|
||||
padding: 7px 9px;
|
||||
gap: 7px;
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.upcoming-events-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.upcoming-events-filters button {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
border: 1px solid rgba(15, 76, 92, 0.12);
|
||||
border-radius: 9px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: #edf3f1;
|
||||
color: #526a72;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.upcoming-events-filters button[data-active="true"] {
|
||||
border-color: #0f4c5c;
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
}
|
||||
|
||||
.upcoming-events-filters button small {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(15, 76, 92, 0.1);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.upcoming-event-hero {
|
||||
border: 1px solid rgba(15, 76, 92, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
background: #dceee6;
|
||||
}
|
||||
|
||||
.upcoming-event-hero[data-status="caution"] {
|
||||
border-color: #d89c28;
|
||||
background: #fff1cc;
|
||||
}
|
||||
|
||||
.upcoming-event-hero[data-status="alarm"] {
|
||||
border-color: #c44a30;
|
||||
background: #ffe1dc;
|
||||
}
|
||||
|
||||
.upcoming-event-hero > small {
|
||||
color: #526a72;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.upcoming-event-hero-main {
|
||||
min-width: 0;
|
||||
min-height: 74px;
|
||||
border-radius: 9px;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto auto auto;
|
||||
align-items: center;
|
||||
gap: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #17343c;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.upcoming-event-hero-main > svg {
|
||||
grid-row: 1 / span 3;
|
||||
}
|
||||
|
||||
.upcoming-event-hero-main > span:nth-of-type(1) {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.upcoming-event-hero-main > span:nth-of-type(1) strong {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upcoming-event-hero-main > span:nth-of-type(1) span,
|
||||
.upcoming-event-hero-eta,
|
||||
.upcoming-event-hero-fact {
|
||||
color: #607278;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.upcoming-event-hero-distance {
|
||||
color: #0f4c5c;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.upcoming-event-hero-eta {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.upcoming-event-hero-fact {
|
||||
grid-column: 2 / -1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.upcoming-events-list-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.upcoming-events-list-section h3 {
|
||||
margin: 0;
|
||||
color: #526a72;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.upcoming-events-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.upcoming-event-row {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
border: 1px solid rgba(15, 76, 92, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 7px 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #ffffff;
|
||||
color: #17343c;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.upcoming-event-row[data-status="caution"] {
|
||||
border-color: #d89c28;
|
||||
background: #fff9e8;
|
||||
}
|
||||
|
||||
.upcoming-event-row[data-status="alarm"] {
|
||||
border-color: #c44a30;
|
||||
background: #fff0ed;
|
||||
}
|
||||
|
||||
.upcoming-event-row-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
}
|
||||
|
||||
.upcoming-event-row-content,
|
||||
.upcoming-event-row-progress {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.upcoming-event-row-content strong {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upcoming-event-row-content small,
|
||||
.upcoming-event-row-progress small {
|
||||
color: #607278;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.upcoming-event-row-progress {
|
||||
justify-items: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.upcoming-event-row-progress strong {
|
||||
color: #0f4c5c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upcoming-event-contact-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.upcoming-event-contact-actions a,
|
||||
.upcoming-event-map-action {
|
||||
min-height: 44px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
background: #0f4c5c;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.upcoming-event-contact-actions[data-compact="true"] a {
|
||||
background: rgba(15, 76, 92, 0.9);
|
||||
}
|
||||
|
||||
.upcoming-event-detail-header {
|
||||
min-height: 50px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-header > button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #e2ece9;
|
||||
color: #23434c;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-header > span,
|
||||
.upcoming-event-detail-header > span > span {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-header > span > span {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-header small {
|
||||
color: #607278;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-header strong {
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-hero {
|
||||
border-radius: 11px;
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
background: #dceee6;
|
||||
}
|
||||
|
||||
.upcoming-event-detail[data-status="caution"] .upcoming-event-detail-hero {
|
||||
background: #fff1cc;
|
||||
}
|
||||
|
||||
.upcoming-event-detail[data-status="alarm"] .upcoming-event-detail-hero {
|
||||
background: #ffe1dc;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-hero > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 2px 6px;
|
||||
color: #526a72;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-hero > span > svg {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-hero strong {
|
||||
color: #17343c;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-facts {
|
||||
margin: 0;
|
||||
border: 1px solid rgba(15, 76, 92, 0.1);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-facts > div {
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid rgba(15, 76, 92, 0.08);
|
||||
padding: 7px 9px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 0.75fr) minmax(0, 1.25fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-facts > div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-facts dt {
|
||||
color: #607278;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.upcoming-event-detail-facts dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #17343c;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.upcoming-event-radio-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.upcoming-event-map-action {
|
||||
width: 100%;
|
||||
background: #e2ece9;
|
||||
color: #23434c;
|
||||
}
|
||||
|
||||
.upcoming-events-state {
|
||||
min-height: 210px;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
color: #526a72;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upcoming-events-state > svg {
|
||||
color: #0f4c5c;
|
||||
}
|
||||
|
||||
.upcoming-events-state > strong {
|
||||
color: #17343c;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.upcoming-events-state > p,
|
||||
.upcoming-events-empty-filter {
|
||||
max-width: 32rem;
|
||||
margin: 0;
|
||||
color: #607278;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.upcoming-events-empty-filter {
|
||||
min-height: 96px;
|
||||
border: 1px dashed #b9cbc7;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
Clock3,
|
||||
Globe2,
|
||||
Landmark,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Radio,
|
||||
Route as RouteIcon,
|
||||
Ship,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type RouteEventKind,
|
||||
type UpcomingRouteEvent
|
||||
} from "../routeEvents";
|
||||
import "./UpcomingEventsPanel.css";
|
||||
|
||||
type EventFilter = "all" | RouteEventKind;
|
||||
|
||||
type EventFilterDefinition = {
|
||||
id: EventFilter;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const EVENT_FILTERS: readonly EventFilterDefinition[] = [
|
||||
{ id: "all", label: "Alle" },
|
||||
{ id: "harbour", label: "Häfen" },
|
||||
{ id: "lock", label: "Schleusen" },
|
||||
{ id: "bridge", label: "Brücken" }
|
||||
];
|
||||
|
||||
const EVENT_KIND_LABELS: Record<RouteEventKind, string> = {
|
||||
harbour: "Hafen",
|
||||
lock: "Schleuse",
|
||||
bridge: "Brücke"
|
||||
};
|
||||
|
||||
const EVENT_KIND_ICONS: Record<RouteEventKind, LucideIcon> = {
|
||||
harbour: Ship,
|
||||
lock: LockKeyhole,
|
||||
bridge: Landmark
|
||||
};
|
||||
|
||||
export type UpcomingEventsPanelProps = {
|
||||
events: readonly UpcomingRouteEvent[];
|
||||
hasRoute: boolean;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onShowOnMap?: (event: UpcomingRouteEvent) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function UpcomingEventsPanel({
|
||||
events,
|
||||
hasRoute,
|
||||
loading = false,
|
||||
error = null,
|
||||
onShowOnMap,
|
||||
className
|
||||
}: UpcomingEventsPanelProps) {
|
||||
const [filter, setFilter] = useState<EventFilter>("all");
|
||||
const [selectedEventKey, setSelectedEventKey] = useState<string | null>(null);
|
||||
const orderedEvents = useMemo(
|
||||
() =>
|
||||
events
|
||||
.map((event, index) => ({ event, index }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
finiteDistance(left.event.remainingNm) - finiteDistance(right.event.remainingNm) ||
|
||||
left.index - right.index
|
||||
)
|
||||
.map(({ event }) => event),
|
||||
[events]
|
||||
);
|
||||
const selectedEvent =
|
||||
orderedEvents.find((event) => routeEventKey(event) === selectedEventKey) ?? null;
|
||||
const filteredEvents = useMemo(
|
||||
() =>
|
||||
filter === "all"
|
||||
? orderedEvents
|
||||
: orderedEvents.filter((event) => event.kind === filter),
|
||||
[filter, orderedEvents]
|
||||
);
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: orderedEvents.length,
|
||||
harbour: orderedEvents.filter((event) => event.kind === "harbour").length,
|
||||
lock: orderedEvents.filter((event) => event.kind === "lock").length,
|
||||
bridge: orderedEvents.filter((event) => event.kind === "bridge").length
|
||||
}),
|
||||
[orderedEvents]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRoute || (selectedEventKey && !selectedEvent)) {
|
||||
setSelectedEventKey(null);
|
||||
}
|
||||
}, [hasRoute, selectedEvent, selectedEventKey]);
|
||||
|
||||
if (!hasRoute) {
|
||||
return (
|
||||
<PanelState
|
||||
className={className}
|
||||
icon={RouteIcon}
|
||||
title="Noch keine Route"
|
||||
message="Plane zuerst eine Route. Danach erscheinen Häfen, Schleusen und Brücken in Fahrtreihenfolge."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && orderedEvents.length === 0) {
|
||||
return (
|
||||
<PanelState
|
||||
className={className}
|
||||
icon={LoaderCircle}
|
||||
title="Ereignisse werden geladen"
|
||||
message="Die nächsten Häfen, Schleusen und Brücken entlang der Route werden ermittelt."
|
||||
live
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && orderedEvents.length === 0) {
|
||||
return (
|
||||
<PanelState
|
||||
className={className}
|
||||
icon={AlertTriangle}
|
||||
title="Ereignisse nicht erreichbar"
|
||||
message={error}
|
||||
alert
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (orderedEvents.length === 0) {
|
||||
return (
|
||||
<PanelState
|
||||
className={className}
|
||||
icon={CalendarClock}
|
||||
title="Keine bevorstehenden Ereignisse"
|
||||
message="Auf dem verbleibenden Routenabschnitt wurden keine Häfen, Schleusen oder Brücken gefunden."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedEvent) {
|
||||
return (
|
||||
<EventDrilldown
|
||||
className={className}
|
||||
event={selectedEvent}
|
||||
onBack={() => setSelectedEventKey(null)}
|
||||
onShowOnMap={onShowOnMap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const nextEvent = filteredEvents[0] ?? null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={classNames("upcoming-events-panel", className)}
|
||||
aria-label="Bevorstehende Ereignisse"
|
||||
aria-busy={loading}
|
||||
>
|
||||
<header className="upcoming-events-header">
|
||||
<span>
|
||||
<CalendarClock size={18} aria-hidden="true" />
|
||||
<strong>Bevorstehend</strong>
|
||||
</span>
|
||||
<small>{orderedEvents.length} auf der Route</small>
|
||||
</header>
|
||||
|
||||
{loading && (
|
||||
<p className="upcoming-events-message" role="status" aria-live="polite">
|
||||
<LoaderCircle size={16} aria-hidden="true" />
|
||||
Ereignisse werden aktualisiert.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="upcoming-events-message" role="alert">
|
||||
<AlertTriangle size={16} aria-hidden="true" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="upcoming-events-filters" role="group" aria-label="Ereignisse filtern">
|
||||
{EVENT_FILTERS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-active={filter === id}
|
||||
aria-pressed={filter === id}
|
||||
onClick={() => setFilter(id)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<small aria-label={`${counts[id]} Ereignisse`}>{counts[id]}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nextEvent ? (
|
||||
<>
|
||||
<NextEventHero
|
||||
event={nextEvent}
|
||||
onOpen={() => setSelectedEventKey(routeEventKey(nextEvent))}
|
||||
/>
|
||||
|
||||
<section className="upcoming-events-list-section" aria-labelledby="upcoming-events-list-title">
|
||||
<h3 id="upcoming-events-list-title">
|
||||
{filter === "all"
|
||||
? "Alle Ereignisse in Fahrtreihenfolge"
|
||||
: `${filterLabel(filter)} in Fahrtreihenfolge`}
|
||||
</h3>
|
||||
<ol className="upcoming-events-list">
|
||||
{filteredEvents.map((event) => (
|
||||
<li key={routeEventKey(event)}>
|
||||
<EventRow
|
||||
event={event}
|
||||
onOpen={() => setSelectedEventKey(routeEventKey(event))}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<p className="upcoming-events-empty-filter" role="status">
|
||||
Keine {filterLabel(filter)} auf dem verbleibenden Routenabschnitt.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NextEventHero({
|
||||
event,
|
||||
onOpen
|
||||
}: {
|
||||
event: UpcomingRouteEvent;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const Icon = EVENT_KIND_ICONS[event.kind];
|
||||
const contact = eventContact(event);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="upcoming-event-hero"
|
||||
data-kind={event.kind}
|
||||
data-status={eventStatus(event)}
|
||||
aria-labelledby={`next-event-${safeDomId(routeEventKey(event))}`}
|
||||
>
|
||||
<small>Nächstes Ereignis</small>
|
||||
<button className="upcoming-event-hero-main" type="button" onClick={onOpen}>
|
||||
<Icon size={24} aria-hidden="true" />
|
||||
<span>
|
||||
<strong id={`next-event-${safeDomId(routeEventKey(event))}`}>{event.name}</strong>
|
||||
<span>{EVENT_KIND_LABELS[event.kind]}</span>
|
||||
</span>
|
||||
<span className="upcoming-event-hero-distance">{formatDistance(event.remainingNm)}</span>
|
||||
<span className="upcoming-event-hero-eta">{formatEta(event)}</span>
|
||||
<span className="upcoming-event-hero-fact">{importantFact(event)}</span>
|
||||
</button>
|
||||
<ContactActions
|
||||
name={event.name}
|
||||
phone={contact.phone}
|
||||
website={contact.website}
|
||||
email={contact.email}
|
||||
compact
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
event,
|
||||
onOpen
|
||||
}: {
|
||||
event: UpcomingRouteEvent;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const Icon = EVENT_KIND_ICONS[event.kind];
|
||||
|
||||
return (
|
||||
<button
|
||||
className="upcoming-event-row"
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
data-kind={event.kind}
|
||||
data-status={eventStatus(event)}
|
||||
aria-label={`${EVENT_KIND_LABELS[event.kind]} ${event.name}, ${formatDistance(
|
||||
event.remainingNm
|
||||
)}, ${formatEta(event)}, ${importantFact(event)}. Details öffnen`}
|
||||
>
|
||||
<span className="upcoming-event-row-icon">
|
||||
<Icon size={19} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="upcoming-event-row-content">
|
||||
<strong>{event.name}</strong>
|
||||
<small>{importantFact(event)}</small>
|
||||
</span>
|
||||
<span className="upcoming-event-row-progress">
|
||||
<strong>{formatDistance(event.remainingNm)}</strong>
|
||||
<small>{formatEta(event)}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDrilldown({
|
||||
event,
|
||||
onBack,
|
||||
onShowOnMap,
|
||||
className
|
||||
}: {
|
||||
event: UpcomingRouteEvent;
|
||||
onBack: () => void;
|
||||
onShowOnMap?: (event: UpcomingRouteEvent) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = EVENT_KIND_ICONS[event.kind];
|
||||
const contact = eventContact(event);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={classNames("upcoming-events-panel", "upcoming-event-detail", className)}
|
||||
aria-labelledby="upcoming-event-detail-title"
|
||||
data-kind={event.kind}
|
||||
data-status={eventStatus(event)}
|
||||
>
|
||||
<header className="upcoming-event-detail-header">
|
||||
<button type="button" onClick={onBack} aria-label="Zurück zur Ereignisliste" title="Zurück">
|
||||
<ArrowLeft size={19} aria-hidden="true" />
|
||||
</button>
|
||||
<span>
|
||||
<Icon size={20} aria-hidden="true" />
|
||||
<span>
|
||||
<small>{EVENT_KIND_LABELS[event.kind]}</small>
|
||||
<strong id="upcoming-event-detail-title">{event.name}</strong>
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="upcoming-event-detail-hero">
|
||||
<span>
|
||||
<RouteIcon size={17} aria-hidden="true" />
|
||||
<strong>{formatDistance(event.remainingNm)}</strong>
|
||||
verbleibend
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 size={17} aria-hidden="true" />
|
||||
<strong>{formatEta(event)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="upcoming-event-detail-facts">
|
||||
<DetailRow label="Wichtigster Hinweis">{importantFact(event)}</DetailRow>
|
||||
<DetailRow label="Abstand von der Route">
|
||||
{formatDistance(event.distanceFromRouteNm)}
|
||||
</DetailRow>
|
||||
{event.eta && (
|
||||
<DetailRow label="ETA-Grundlage">{etaBasisLabel(event)}</DetailRow>
|
||||
)}
|
||||
<KindSpecificDetails event={event} />
|
||||
<EventDataDetails event={event} />
|
||||
</dl>
|
||||
|
||||
<ContactActions
|
||||
name={event.name}
|
||||
phone={contact.phone}
|
||||
website={contact.website}
|
||||
email={contact.email}
|
||||
/>
|
||||
|
||||
{onShowOnMap && (
|
||||
<button
|
||||
className="upcoming-event-map-action"
|
||||
type="button"
|
||||
onClick={() => onShowOnMap(event)}
|
||||
>
|
||||
<MapPin size={18} aria-hidden="true" />
|
||||
Auf Karte zeigen
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function KindSpecificDetails({ event }: { event: UpcomingRouteEvent }) {
|
||||
if (event.kind === "harbour") {
|
||||
const amenities = availableAmenities(event);
|
||||
return (
|
||||
<>
|
||||
<DetailRow label="Typ">{event.feature.kind === "marina" ? "Marina" : "Hafen"}</DetailRow>
|
||||
<DetailRow label="Öffnungszeiten">
|
||||
{event.feature.openingHours ?? "Nicht hinterlegt"}
|
||||
</DetailRow>
|
||||
<DetailRow label="UKW / VHF">
|
||||
{event.feature.vhf ?? "Nicht hinterlegt"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Betreiber">
|
||||
{event.feature.operator ?? "Nicht hinterlegt"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Adresse">
|
||||
{event.feature.address ?? "Nicht hinterlegt"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Ausstattung">
|
||||
{amenities.length > 0 ? amenities.join(" · ") : "Nicht hinterlegt"}
|
||||
</DetailRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.kind === "lock") {
|
||||
return (
|
||||
<>
|
||||
<DetailRow label="Öffnungszeiten">
|
||||
{event.feature.openingHours ?? "Nicht hinterlegt"}
|
||||
</DetailRow>
|
||||
<DetailRow label="UKW / VHF">
|
||||
{event.feature.vhf ? (
|
||||
<span className="upcoming-event-radio-value">
|
||||
<Radio size={15} aria-hidden="true" />
|
||||
{event.feature.vhf}
|
||||
</span>
|
||||
) : (
|
||||
"Nicht hinterlegt"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Betreiber">
|
||||
{event.feature.operator ?? "Nicht hinterlegt"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Adresse">
|
||||
{event.feature.address ?? "Nicht hinterlegt"}
|
||||
</DetailRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailRow label="Durchfahrtshöhe">
|
||||
{event.feature.clearanceLabel ??
|
||||
formatMeters(event.feature.clearanceM) ??
|
||||
"Nicht bekannt"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Benötigte Höhe">
|
||||
{formatMeters(event.feature.requiredAirDraftM) ?? "Nicht bekannt"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Reserve">
|
||||
{bridgeMargin(event.feature.marginM)}
|
||||
</DetailRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDataDetails({ event }: { event: UpcomingRouteEvent }) {
|
||||
const source =
|
||||
event.kind === "bridge" ? event.feature.source : event.feature.source ?? null;
|
||||
const updatedAt =
|
||||
event.kind === "bridge" ? null : event.feature.updatedAt ?? null;
|
||||
return (
|
||||
<>
|
||||
<DetailRow label="Koordinaten">
|
||||
{event.coordinate.lat.toFixed(5)}, {event.coordinate.lon.toFixed(5)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Quelle">{source || "Nicht angegeben"}</DetailRow>
|
||||
{updatedAt && (
|
||||
<DetailRow label="Datenstand">{formatTimestamp(updatedAt)}</DetailRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactActions({
|
||||
name,
|
||||
phone,
|
||||
website,
|
||||
email,
|
||||
compact = false
|
||||
}: {
|
||||
name: string;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
email: string | null;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const phoneHref = phone ? telephoneHref(phone) : null;
|
||||
const safeWebsite = website ? websiteHref(website) : null;
|
||||
const safeEmail = email?.trim() || null;
|
||||
if (!phoneHref && !safeWebsite && !safeEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upcoming-event-contact-actions" data-compact={compact}>
|
||||
{phoneHref && (
|
||||
<a href={phoneHref} aria-label={`${name} anrufen`}>
|
||||
<Phone size={17} aria-hidden="true" />
|
||||
Anrufen
|
||||
</a>
|
||||
)}
|
||||
{safeWebsite && (
|
||||
<a
|
||||
href={safeWebsite}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Website von ${name} öffnen`}
|
||||
>
|
||||
<Globe2 size={17} aria-hidden="true" />
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
{safeEmail && (
|
||||
<a href={`mailto:${safeEmail}`} aria-label={`E-Mail an ${name} schreiben`}>
|
||||
<Mail size={17} aria-hidden="true" />
|
||||
E-Mail
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelState({
|
||||
icon: Icon,
|
||||
title,
|
||||
message,
|
||||
live = false,
|
||||
alert = false,
|
||||
className
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
message: string;
|
||||
live?: boolean;
|
||||
alert?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={classNames("upcoming-events-panel", "upcoming-events-state", className)}
|
||||
aria-label="Bevorstehende Ereignisse"
|
||||
role={alert ? "alert" : live ? "status" : undefined}
|
||||
aria-live={live ? "polite" : undefined}
|
||||
>
|
||||
<Icon size={26} aria-hidden="true" />
|
||||
<strong>{title}</strong>
|
||||
<p>{message}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function eventContact(event: UpcomingRouteEvent) {
|
||||
if (event.kind === "harbour" || event.kind === "lock") {
|
||||
return {
|
||||
phone: event.feature.phone ?? null,
|
||||
website: event.feature.website ?? null,
|
||||
email: event.feature.email ?? null
|
||||
};
|
||||
}
|
||||
return { phone: null, website: null, email: null };
|
||||
}
|
||||
|
||||
function importantFact(event: UpcomingRouteEvent) {
|
||||
if (event.kind === "harbour") {
|
||||
const amenities = availableAmenities(event);
|
||||
if (amenities.length > 0) {
|
||||
return amenities.slice(0, 3).join(" · ");
|
||||
}
|
||||
if (event.feature.phone || event.feature.website) {
|
||||
return "Kontaktdaten vorhanden";
|
||||
}
|
||||
return "Ausstattung nicht hinterlegt";
|
||||
}
|
||||
|
||||
if (event.kind === "lock") {
|
||||
if (event.feature.openingHours) {
|
||||
return `Öffnung: ${event.feature.openingHours}`;
|
||||
}
|
||||
if (event.feature.vhf) {
|
||||
return `UKW / VHF ${event.feature.vhf}`;
|
||||
}
|
||||
return "Betriebszeiten nicht hinterlegt";
|
||||
}
|
||||
|
||||
if (event.feature.marginM !== null) {
|
||||
return event.feature.marginM < 0
|
||||
? `${Math.abs(event.feature.marginM).toFixed(1)} m zu niedrig`
|
||||
: `${event.feature.marginM.toFixed(1)} m Reserve`;
|
||||
}
|
||||
if (event.feature.clearanceLabel) {
|
||||
return `Durchfahrt ${event.feature.clearanceLabel}`;
|
||||
}
|
||||
if (event.feature.clearanceM !== null) {
|
||||
return `Durchfahrt ${event.feature.clearanceM.toFixed(1)} m`;
|
||||
}
|
||||
return "Durchfahrtshöhe unbekannt";
|
||||
}
|
||||
|
||||
function eventStatus(event: UpcomingRouteEvent) {
|
||||
if (event.kind !== "bridge") {
|
||||
return "idle";
|
||||
}
|
||||
switch (event.feature.status) {
|
||||
case "too_low":
|
||||
return "alarm";
|
||||
case "tight":
|
||||
return "caution";
|
||||
case "unknown":
|
||||
return "stale";
|
||||
default:
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
function availableAmenities(event: Extract<UpcomingRouteEvent, { kind: "harbour" }>) {
|
||||
const amenities = event.feature.amenities ?? {};
|
||||
return [
|
||||
["fuel", "Kraftstoff"],
|
||||
["water", "Wasser"],
|
||||
["electricity", "Strom"],
|
||||
["overnight", "Übernachtung"],
|
||||
["waste", "Entsorgung"]
|
||||
].flatMap(([id, label]) => {
|
||||
const availability = amenities[id as keyof typeof amenities];
|
||||
return availability === true || availability === "available" ? [label] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function formatDistance(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "-- sm";
|
||||
}
|
||||
if (value > 0 && value < 0.1) {
|
||||
return "< 0,1 sm";
|
||||
}
|
||||
return `${value.toLocaleString("de-DE", {
|
||||
minimumFractionDigits: value < 10 ? 1 : 0,
|
||||
maximumFractionDigits: 1
|
||||
})} sm`;
|
||||
}
|
||||
|
||||
function formatEta(event: UpcomingRouteEvent) {
|
||||
if (!event.eta) {
|
||||
return "ETA offen";
|
||||
}
|
||||
const date = new Date(event.eta.estimatedAt);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return "ETA offen";
|
||||
}
|
||||
const today = new Date();
|
||||
const sameDay =
|
||||
date.getFullYear() === today.getFullYear() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getDate() === today.getDate();
|
||||
return `ETA ${date.toLocaleString("de-DE", {
|
||||
weekday: sameDay ? undefined : "short",
|
||||
day: sameDay ? undefined : "2-digit",
|
||||
month: sameDay ? undefined : "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
})}`;
|
||||
}
|
||||
|
||||
function etaBasisLabel(event: UpcomingRouteEvent) {
|
||||
if (!event.eta) {
|
||||
return "Nicht verfügbar";
|
||||
}
|
||||
const speed =
|
||||
event.eta.speedSource === "gps-sog"
|
||||
? "GPS-Fahrt über Grund"
|
||||
: "geplante Bootsgeschwindigkeit";
|
||||
const reference =
|
||||
event.eta.referenceSource === "current-time"
|
||||
? "ab jetzt"
|
||||
: "ab geplanter Abfahrt";
|
||||
return `${event.eta.speedKn.toFixed(1)} kn ${speed}, ${reference}`;
|
||||
}
|
||||
|
||||
function bridgeMargin(value: number | null) {
|
||||
if (value === null || !Number.isFinite(value)) {
|
||||
return "Nicht bekannt";
|
||||
}
|
||||
return value < 0
|
||||
? `${Math.abs(value).toFixed(1)} m zu niedrig`
|
||||
: `${value.toFixed(1)} m Reserve`;
|
||||
}
|
||||
|
||||
function formatMeters(value: number | null) {
|
||||
return value !== null && Number.isFinite(value) ? `${value.toFixed(1)} m` : null;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime())
|
||||
? date.toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
})
|
||||
: value;
|
||||
}
|
||||
|
||||
function filterLabel(filter: EventFilter) {
|
||||
return EVENT_FILTERS.find((definition) => definition.id === filter)?.label ?? "Ereignisse";
|
||||
}
|
||||
|
||||
function finiteDistance(value: number) {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function routeEventKey(event: UpcomingRouteEvent) {
|
||||
return `${event.kind}:${event.id}`;
|
||||
}
|
||||
|
||||
function safeDomId(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
function telephoneHref(value: string) {
|
||||
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
|
||||
return compact ? `tel:${compact}` : null;
|
||||
}
|
||||
|
||||
function websiteHref(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(
|
||||
/^[a-z][a-z\d+.-]*:/i.test(normalized) ? normalized : `https://${normalized}`
|
||||
);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function classNames(...values: Array<string | null | undefined | false>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
.voyage-navigation-tools {
|
||||
border-top: 1px solid rgba(18, 46, 55, 0.12);
|
||||
padding-top: 9px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.voyage-tools-heading {
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #10242b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.voyage-tools-heading span {
|
||||
color: #4c6269;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.voyage-tools-actions,
|
||||
.deviation-alarm-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.voyage-tools-actions button,
|
||||
.offline-voyage-picker button,
|
||||
.deviation-alarm-button {
|
||||
min-height: 36px;
|
||||
border-radius: 8px;
|
||||
padding: 0 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: #e6f1ed;
|
||||
color: #0f4c5c;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.offline-voyage-picker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto 38px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.offline-voyage-picker label {
|
||||
grid-column: 1 / -1;
|
||||
color: #4c6269;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.offline-voyage-picker select {
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
border: 1px solid rgba(18, 46, 55, 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 0 8px;
|
||||
background: #ffffff;
|
||||
color: #10242b;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.offline-voyage-picker .danger-action {
|
||||
width: 38px;
|
||||
padding: 0;
|
||||
background: #ffe1dc;
|
||||
color: #9d2c22;
|
||||
}
|
||||
|
||||
.deviation-alarm-controls label {
|
||||
min-height: 42px;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
background: #eef3f0;
|
||||
color: #4c6269;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.deviation-alarm-controls label span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.deviation-alarm-controls input {
|
||||
width: 61px;
|
||||
height: 30px;
|
||||
border: 1px solid rgba(18, 46, 55, 0.18);
|
||||
border-radius: 7px;
|
||||
padding: 0 6px;
|
||||
background: #fff;
|
||||
color: #10242b;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.deviation-alarm-button {
|
||||
min-height: 42px;
|
||||
background: #0f4c5c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.deviation-alarm-button[data-active="true"] {
|
||||
background: #196f5c;
|
||||
}
|
||||
|
||||
.deviation-alarm-status,
|
||||
.offline-voyage-status {
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
padding: 7px 9px;
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.deviation-alarm-status[data-off-route="true"] {
|
||||
background: #ffe1dc;
|
||||
color: #9d2c22;
|
||||
}
|
||||
|
||||
.offline-voyage-status {
|
||||
background: #eef3f0;
|
||||
color: #4c6269;
|
||||
}
|
||||
|
||||
.voyage-privacy-note {
|
||||
color: #607278;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 390px) {
|
||||
.voyage-tools-heading span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Bell, BellOff, Download, FolderOpen, Save, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { RouteResult } from "@watermaps/shared";
|
||||
import { useRouteDeviationAlarm } from "../hooks/useRouteDeviationAlarm";
|
||||
import { downloadRouteGpx } from "../lib/gpx";
|
||||
import {
|
||||
deleteOfflineVoyage,
|
||||
listOfflineVoyages,
|
||||
loadOfflineVoyage,
|
||||
requestPersistentOfflineStorage,
|
||||
saveOfflineVoyage,
|
||||
type OfflineVoyage,
|
||||
type OfflineVoyagePlanInput
|
||||
} from "../lib/offline-route";
|
||||
import "./VoyageNavigationTools.css";
|
||||
|
||||
export type VoyageNavigationToolsProps = {
|
||||
route: RouteResult | null;
|
||||
plan?: OfflineVoyagePlanInput;
|
||||
defaultDeviationThresholdM?: number;
|
||||
courseAssistantActive?: boolean;
|
||||
onLoadOfflineVoyage?: (voyage: OfflineVoyage) => void;
|
||||
};
|
||||
|
||||
export function VoyageNavigationTools({
|
||||
route,
|
||||
plan,
|
||||
defaultDeviationThresholdM = 100,
|
||||
courseAssistantActive = false,
|
||||
onLoadOfflineVoyage
|
||||
}: VoyageNavigationToolsProps) {
|
||||
const [savedVoyages, setSavedVoyages] = useState<OfflineVoyage[]>([]);
|
||||
const [selectedVoyageId, setSelectedVoyageId] = useState("");
|
||||
const [storageMessage, setStorageMessage] = useState<string | null>(null);
|
||||
const [thresholdM, setThresholdM] = useState(() => clampThreshold(defaultDeviationThresholdM));
|
||||
const alarm = useRouteDeviationAlarm(route, thresholdM);
|
||||
|
||||
useEffect(() => {
|
||||
if (courseAssistantActive && alarm.active) {
|
||||
alarm.stop();
|
||||
}
|
||||
}, [alarm.active, alarm.stop, courseAssistantActive]);
|
||||
|
||||
const refreshSavedVoyages = () => {
|
||||
try {
|
||||
const next = listOfflineVoyages();
|
||||
setSavedVoyages(next);
|
||||
setSelectedVoyageId((current) => next.some((voyage) => voyage.id === current) ? current : next[0]?.id ?? "");
|
||||
} catch (error) {
|
||||
setStorageMessage(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(refreshSavedVoyages, []);
|
||||
|
||||
const selectedVoyage = useMemo(
|
||||
() => savedVoyages.find((voyage) => voyage.id === selectedVoyageId) ?? null,
|
||||
[savedVoyages, selectedVoyageId]
|
||||
);
|
||||
|
||||
const exportGpx = () => {
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
downloadRouteGpx(route);
|
||||
setStorageMessage("GPX-Datei wurde zum Download bereitgestellt.");
|
||||
} catch (error) {
|
||||
setStorageMessage(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const saveRoute = async () => {
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const voyage = saveOfflineVoyage({ route, plan });
|
||||
void requestPersistentOfflineStorage();
|
||||
refreshSavedVoyages();
|
||||
setSelectedVoyageId(voyage.id);
|
||||
setStorageMessage(`„${voyage.name}“ ist auf diesem Gerät offline verfügbar.`);
|
||||
} catch (error) {
|
||||
setStorageMessage(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const loadRoute = () => {
|
||||
if (!selectedVoyage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const voyage = loadOfflineVoyage(selectedVoyage.id);
|
||||
if (!voyage) {
|
||||
setStorageMessage("Die gespeicherte Route ist nicht mehr verfügbar.");
|
||||
refreshSavedVoyages();
|
||||
return;
|
||||
}
|
||||
onLoadOfflineVoyage?.(voyage);
|
||||
setStorageMessage(`„${voyage.name}“ wurde offline geladen.`);
|
||||
} catch (error) {
|
||||
setStorageMessage(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const removeRoute = () => {
|
||||
if (!selectedVoyage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
deleteOfflineVoyage(selectedVoyage.id);
|
||||
setStorageMessage(`„${selectedVoyage.name}“ wurde vom Gerät gelöscht.`);
|
||||
refreshSavedVoyages();
|
||||
} catch (error) {
|
||||
setStorageMessage(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="voyage-navigation-tools" aria-label="Navigation und Offline-Route">
|
||||
<div className="voyage-tools-heading">
|
||||
<strong>Unterwegs</strong>
|
||||
<span>GPX · Offline · Kursalarm</span>
|
||||
</div>
|
||||
|
||||
<div className="voyage-tools-actions">
|
||||
<button type="button" onClick={exportGpx} disabled={!route} aria-label="Route als GPX exportieren">
|
||||
<Download size={15} aria-hidden="true" />
|
||||
GPX
|
||||
</button>
|
||||
<button type="button" onClick={saveRoute} disabled={!route} aria-label="Route offline speichern">
|
||||
<Save size={15} aria-hidden="true" />
|
||||
Offline speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{savedVoyages.length > 0 && (
|
||||
<div className="offline-voyage-picker">
|
||||
<label htmlFor="offline-voyage-select">Gespeicherte Route</label>
|
||||
<select
|
||||
id="offline-voyage-select"
|
||||
value={selectedVoyageId}
|
||||
onChange={(event) => setSelectedVoyageId(event.target.value)}
|
||||
>
|
||||
{savedVoyages.map((voyage) => (
|
||||
<option key={voyage.id} value={voyage.id}>
|
||||
{voyage.name} · {new Date(voyage.savedAt).toLocaleDateString("de-DE")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={loadRoute} aria-label="Offline-Route laden">
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
Laden
|
||||
</button>
|
||||
<button className="danger-action" type="button" onClick={removeRoute} aria-label="Offline-Route löschen">
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="deviation-alarm-controls">
|
||||
<label htmlFor="route-deviation-threshold">
|
||||
Warnen ab
|
||||
<span>
|
||||
<input
|
||||
id="route-deviation-threshold"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={25}
|
||||
max={2_000}
|
||||
step={25}
|
||||
value={thresholdM}
|
||||
disabled={alarm.active || courseAssistantActive}
|
||||
onChange={(event) => setThresholdM(clampThreshold(Number(event.target.value)))}
|
||||
/>
|
||||
m
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="deviation-alarm-button"
|
||||
data-active={alarm.active}
|
||||
disabled={courseAssistantActive || (!route && !alarm.active)}
|
||||
onClick={alarm.active ? alarm.stop : alarm.start}
|
||||
aria-label={
|
||||
courseAssistantActive
|
||||
? "Kursalarm ist im Kursassistenten enthalten"
|
||||
: alarm.active
|
||||
? "Abweichungsalarm stoppen"
|
||||
: "Abweichungsalarm starten"
|
||||
}
|
||||
>
|
||||
{alarm.active ? <BellOff size={16} aria-hidden="true" /> : <Bell size={16} aria-hidden="true" />}
|
||||
{courseAssistantActive ? "Im Assistenten aktiv" : alarm.active ? "Alarm stoppen" : "Kursalarm starten"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{courseAssistantActive && (
|
||||
<p className="deviation-alarm-status" role="status">
|
||||
Querabweichung und einmalige Warnsignale werden vom Kursassistenten übernommen.
|
||||
</p>
|
||||
)}
|
||||
{alarm.message && (
|
||||
<p className="deviation-alarm-status" data-off-route={alarm.isOffRoute} role={alarm.isOffRoute ? "alert" : "status"}>
|
||||
{alarm.message}
|
||||
{alarm.accuracyM !== null && alarm.reliable && ` · GPS ±${Math.round(alarm.accuracyM)} m`}
|
||||
</p>
|
||||
)}
|
||||
{storageMessage && <p className="offline-voyage-status" role="status">{storageMessage}</p>}
|
||||
|
||||
<small className="voyage-privacy-note">
|
||||
GPS wird erst nach „Kursalarm starten“ für diesen separaten Alarm angefragt; der Kursassistent nutzt den ebenfalls bewusst
|
||||
gestarteten Live-GPS-Datenstrom der Karte. Positionen bleiben im Browser und werden weder gespeichert noch übertragen.
|
||||
Bereits aufgerufene Kartenausschnitte kann die installierte App zeitlich begrenzt zwischenspeichern.
|
||||
</small>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function clampThreshold(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 100;
|
||||
}
|
||||
return Math.min(2_000, Math.max(25, Math.round(value)));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Offline-Funktion nicht verfügbar.";
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
.voyage-plan {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
border-top: 1px solid rgba(18, 46, 55, 0.12);
|
||||
padding-top: 10px;
|
||||
color: #10242b;
|
||||
}
|
||||
|
||||
.voyage-plan-header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voyage-plan-header h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.voyage-plan-summary,
|
||||
.voyage-plan-requirements,
|
||||
.voyage-plan-leg-route,
|
||||
.voyage-plan-waypoints,
|
||||
.voyage-plan-unconfirmed-stop,
|
||||
.voyage-plan-harbour-contact {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.voyage-plan-summary {
|
||||
color: #314b54;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voyage-plan-requirements {
|
||||
color: #4c6269;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.voyage-plan-warnings,
|
||||
.voyage-plan-legs,
|
||||
.voyage-plan-amenities {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.voyage-plan-warnings {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.voyage-plan-warnings li {
|
||||
border-radius: 8px;
|
||||
padding: 7px 9px;
|
||||
background: #e8f0f1;
|
||||
color: #314b54;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.voyage-plan-warnings li[data-severity="caution"] {
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
}
|
||||
|
||||
.voyage-plan-warnings li[data-severity="critical"] {
|
||||
background: #ffe1dc;
|
||||
color: #9d2c22;
|
||||
}
|
||||
|
||||
.voyage-plan-legs {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
counter-reset: voyage-day;
|
||||
}
|
||||
|
||||
.voyage-plan-leg {
|
||||
border: 1px solid rgba(18, 46, 55, 0.12);
|
||||
border-radius: 9px;
|
||||
padding: 9px;
|
||||
background: #f8faf9;
|
||||
}
|
||||
|
||||
.voyage-plan-leg article,
|
||||
.voyage-plan-harbour {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.voyage-plan-leg article > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.voyage-plan-leg article > header span,
|
||||
.voyage-plan-waypoints {
|
||||
color: #60737a;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.voyage-plan-leg-route {
|
||||
color: #10242b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.voyage-plan-amenities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.voyage-plan-amenities li {
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 7px;
|
||||
background: #eef3f0;
|
||||
color: #60737a;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.voyage-plan-amenities li[data-availability="available"] {
|
||||
background: #dceee6;
|
||||
color: #196f5c;
|
||||
}
|
||||
|
||||
.voyage-plan-amenities li[data-availability="unavailable"] {
|
||||
background: #f0e8e4;
|
||||
color: #74483d;
|
||||
}
|
||||
|
||||
.voyage-plan-unconfirmed-stop {
|
||||
border-radius: 7px;
|
||||
padding: 6px 8px;
|
||||
background: #fff1cc;
|
||||
color: #805900;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.voyage-plan-harbour-contact {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.voyage-plan-harbour-contact a {
|
||||
color: #075f78;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.voyage-plan-header {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
VOYAGE_AMENITIES,
|
||||
voyageAmenityLabel,
|
||||
type ProjectedVoyageHarbour,
|
||||
type VoyageAmenity,
|
||||
type VoyageAmenityAvailability,
|
||||
type VoyagePlan as VoyagePlanResult
|
||||
} from "@watermaps/shared";
|
||||
import "./VoyagePlan.css";
|
||||
|
||||
type VoyagePlanProps = {
|
||||
plan: VoyagePlanResult | null;
|
||||
};
|
||||
|
||||
export function VoyagePlan({ plan }: VoyagePlanProps) {
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="voyage-plan" aria-labelledby="voyage-plan-title">
|
||||
<header className="voyage-plan-header">
|
||||
<div>
|
||||
<span className="eyebrow">Reiseplanung</span>
|
||||
<h2 id="voyage-plan-title">Etappenplan</h2>
|
||||
</div>
|
||||
<p className="voyage-plan-summary">
|
||||
{plan.legs.length} {plan.legs.length === 1 ? "Tag" : "Tage"} · {formatNm(plan.totalDistanceNm)} ·{" "}
|
||||
{formatDuration(plan.totalDurationHours)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{plan.requiredAmenities.length > 0 ? (
|
||||
<p className="voyage-plan-requirements">
|
||||
Benötigte Versorgung: {plan.requiredAmenities.map(voyageAmenityLabel).join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{plan.warnings.length > 0 ? (
|
||||
<ul className="voyage-plan-warnings" aria-label="Hinweise zum Etappenplan">
|
||||
{plan.warnings.map((warning, index) => (
|
||||
<li key={`${warning.code}-${warning.day ?? 0}-${index}`} data-severity={warning.severity}>
|
||||
{warning.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<ol className="voyage-plan-legs">
|
||||
{plan.legs.map((leg) => (
|
||||
<li key={leg.day} className="voyage-plan-leg">
|
||||
<article>
|
||||
<header>
|
||||
<strong>Tag {leg.day}</strong>
|
||||
<span>
|
||||
{formatNm(leg.distanceNm)} · {formatDuration(leg.durationHours)}
|
||||
</span>
|
||||
</header>
|
||||
<p className="voyage-plan-leg-route">
|
||||
{leg.start.name} → {leg.end.name}
|
||||
</p>
|
||||
|
||||
{leg.waypoints.length > 0 ? (
|
||||
<p className="voyage-plan-waypoints">
|
||||
Via: {leg.waypoints.map((waypoint) => waypoint.name).join(" → ")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{leg.end.harbour ? <HarbourSupply harbour={leg.end.harbour} /> : null}
|
||||
{leg.end.type === "route" ? (
|
||||
<p className="voyage-plan-unconfirmed-stop">Kein bestätigter Liegeplatz</p>
|
||||
) : null}
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HarbourSupply({ harbour }: { harbour: ProjectedVoyageHarbour }) {
|
||||
return (
|
||||
<div className="voyage-plan-harbour">
|
||||
<ul className="voyage-plan-amenities" aria-label={`Versorgung in ${harbour.name}`}>
|
||||
{VOYAGE_AMENITIES.map((amenity) => (
|
||||
<Amenity key={amenity} amenity={amenity} availability={harbour.amenities[amenity]} />
|
||||
))}
|
||||
</ul>
|
||||
{harbour.phone || harbour.website ? (
|
||||
<p className="voyage-plan-harbour-contact">
|
||||
{harbour.phone ? <a href={telephoneHref(harbour.phone)}>Hafen anrufen</a> : null}
|
||||
{harbour.phone && harbour.website ? " · " : null}
|
||||
{harbour.website ? (
|
||||
<a href={websiteHref(harbour.website)} target="_blank" rel="noreferrer">
|
||||
Website
|
||||
</a>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Amenity({
|
||||
amenity,
|
||||
availability
|
||||
}: {
|
||||
amenity: VoyageAmenity;
|
||||
availability: VoyageAmenityAvailability;
|
||||
}) {
|
||||
const status =
|
||||
availability === "available"
|
||||
? "verfügbar"
|
||||
: availability === "unavailable"
|
||||
? "nicht verfügbar"
|
||||
: "unbekannt";
|
||||
const symbol = availability === "available" ? "✓" : availability === "unavailable" ? "–" : "?";
|
||||
return (
|
||||
<li data-availability={availability} aria-label={`${voyageAmenityLabel(amenity)}: ${status}`}>
|
||||
<span aria-hidden="true">{symbol}</span> {voyageAmenityLabel(amenity)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNm(value: number) {
|
||||
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 1 })} sm`;
|
||||
}
|
||||
|
||||
function formatDuration(hours: number) {
|
||||
const totalMinutes = Math.max(0, Math.round(hours * 60));
|
||||
const fullHours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return `${fullHours} h ${String(minutes).padStart(2, "0")} min`;
|
||||
}
|
||||
|
||||
function telephoneHref(value: string) {
|
||||
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
|
||||
return `tel:${compact}`;
|
||||
}
|
||||
|
||||
function websiteHref(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
analyzeTideWindow,
|
||||
calculateAnchorRodePlan,
|
||||
evaluateAnchorWatch,
|
||||
type Coordinate,
|
||||
type TideSummary
|
||||
} from "@watermaps/shared";
|
||||
import { getNearestTide } from "../api";
|
||||
import type { GpsState } from "./useGeolocation";
|
||||
|
||||
export type AnchorWatchPhase = "idle" | "set" | "armed";
|
||||
|
||||
export type AnchorWatchSettings = {
|
||||
depthAtSetM: number;
|
||||
bowRollerHeightM: number;
|
||||
deployedRodeLengthM: number;
|
||||
scopeRatio: number;
|
||||
safetyAllowanceM: number;
|
||||
alarmRadiusM: number;
|
||||
horizonHours: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_ANCHOR_WATCH_SETTINGS: AnchorWatchSettings = {
|
||||
depthAtSetM: 3,
|
||||
bowRollerHeightM: 1,
|
||||
deployedRodeLengthM: 30,
|
||||
scopeRatio: 6,
|
||||
safetyAllowanceM: 0.5,
|
||||
alarmRadiusM: 35,
|
||||
horizonHours: 24
|
||||
};
|
||||
|
||||
const MAX_CAPTURE_ACCURACY_M = 30;
|
||||
const MAX_FIX_AGE_MS = 10_000;
|
||||
const STALE_FIX_AFTER_MS = 20_000;
|
||||
const TIDE_REFRESH_MS = 15 * 60 * 1_000;
|
||||
const REPEAT_ALARM_MS = 60_000;
|
||||
|
||||
type WakeLockSentinelLike = {
|
||||
released?: boolean;
|
||||
release: () => Promise<void>;
|
||||
};
|
||||
|
||||
type NavigatorWithWakeLock = Navigator & {
|
||||
wakeLock?: {
|
||||
request: (type: "screen") => Promise<WakeLockSentinelLike>;
|
||||
};
|
||||
};
|
||||
|
||||
export function useAnchorWatch(gps: Pick<
|
||||
GpsState,
|
||||
"status" | "position" | "accuracyM" | "timestampMs"
|
||||
>) {
|
||||
const [phase, setPhase] = useState<AnchorWatchPhase>("idle");
|
||||
const [anchorPoint, setAnchorPoint] = useState<Coordinate | null>(null);
|
||||
const [anchorSetAtMs, setAnchorSetAtMs] = useState<number | null>(null);
|
||||
const [anchorCaptureAccuracyM, setAnchorCaptureAccuracyM] = useState<number | null>(null);
|
||||
const [settings, setSettings] = useState<AnchorWatchSettings>(DEFAULT_ANCHOR_WATCH_SETTINGS);
|
||||
const [operationError, setOperationError] = useState<string | null>(null);
|
||||
const [tide, setTide] = useState<TideSummary | null>(null);
|
||||
const [tideLoading, setTideLoading] = useState(false);
|
||||
const [tideError, setTideError] = useState<string | null>(null);
|
||||
const [clockMs, setClockMs] = useState(() => Date.now());
|
||||
const [alarmAcknowledged, setAlarmAcknowledged] = useState(false);
|
||||
const tideRequestIdRef = useRef(0);
|
||||
const previousAlarmRef = useRef(false);
|
||||
const previousRodeShortfallRef = useRef(false);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
|
||||
const updateSettings = useCallback((next: Partial<AnchorWatchSettings>) => {
|
||||
setSettings((current) => ({ ...current, ...next }));
|
||||
setOperationError(null);
|
||||
}, []);
|
||||
|
||||
const captureAnchor = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (gps.status !== "tracking" || !gps.position || gps.timestampMs === null) {
|
||||
setOperationError("Für den Ankerpunkt wird zuerst ein aktueller GPS-Fix benötigt.");
|
||||
return false;
|
||||
}
|
||||
if (now - gps.timestampMs > MAX_FIX_AGE_MS) {
|
||||
setOperationError("Der GPS-Fix ist älter als 10 Sekunden. Bitte auf einen neuen Fix warten.");
|
||||
return false;
|
||||
}
|
||||
if (gps.accuracyM === null || gps.accuracyM > MAX_CAPTURE_ACCURACY_M) {
|
||||
setOperationError(
|
||||
`GPS noch zu ungenau${gps.accuracyM === null ? "" : ` (±${Math.round(gps.accuracyM)} m)`}. Ankerpunkt erst bei höchstens ±${MAX_CAPTURE_ACCURACY_M} m setzen.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
tideRequestIdRef.current += 1;
|
||||
setAnchorPoint({ ...gps.position });
|
||||
setAnchorSetAtMs(gps.timestampMs);
|
||||
setAnchorCaptureAccuracyM(gps.accuracyM);
|
||||
setPhase("set");
|
||||
setOperationError(null);
|
||||
setTide(null);
|
||||
setTideError(null);
|
||||
setAlarmAcknowledged(false);
|
||||
previousAlarmRef.current = false;
|
||||
previousRodeShortfallRef.current = false;
|
||||
return true;
|
||||
}, [gps.accuracyM, gps.position, gps.status, gps.timestampMs]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
tideRequestIdRef.current += 1;
|
||||
setPhase("idle");
|
||||
setAnchorPoint(null);
|
||||
setAnchorSetAtMs(null);
|
||||
setAnchorCaptureAccuracyM(null);
|
||||
setOperationError(null);
|
||||
setTide(null);
|
||||
setTideLoading(false);
|
||||
setTideError(null);
|
||||
setAlarmAcknowledged(false);
|
||||
previousAlarmRef.current = false;
|
||||
previousRodeShortfallRef.current = false;
|
||||
const audioContext = audioContextRef.current;
|
||||
audioContextRef.current = null;
|
||||
void audioContext?.close().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const settingsError = useMemo(() => validateSettings(settings), [settings]);
|
||||
|
||||
const arm = useCallback(async () => {
|
||||
if (!anchorPoint || anchorSetAtMs === null) {
|
||||
setOperationError("Zuerst „Anker gefallen“ wählen und den Ankerpunkt setzen.");
|
||||
return false;
|
||||
}
|
||||
const validationError = validateSettings(settings);
|
||||
if (validationError) {
|
||||
setOperationError(validationError);
|
||||
return false;
|
||||
}
|
||||
|
||||
setOperationError(null);
|
||||
setClockMs(Date.now());
|
||||
setAlarmAcknowledged(false);
|
||||
previousAlarmRef.current = false;
|
||||
setPhase("armed");
|
||||
audioContextRef.current = createAlarmAudioContext();
|
||||
void audioContextRef.current?.resume().catch(() => undefined);
|
||||
|
||||
if (typeof Notification !== "undefined" && Notification.permission === "default") {
|
||||
try {
|
||||
await Notification.requestPermission();
|
||||
} catch {
|
||||
// The persistent in-app warning remains available when notifications
|
||||
// are unsupported or denied.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, [anchorPoint, anchorSetAtMs, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchorPoint || anchorSetAtMs === null || phase === "idle") {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
const refresh = async () => {
|
||||
const requestId = tideRequestIdRef.current + 1;
|
||||
tideRequestIdRef.current = requestId;
|
||||
setTideLoading(true);
|
||||
try {
|
||||
const summary = await getNearestTide(anchorPoint, new Date(anchorSetAtMs).toISOString());
|
||||
if (active && tideRequestIdRef.current === requestId) {
|
||||
setTide(summary);
|
||||
setTideError(null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (active && tideRequestIdRef.current === requestId) {
|
||||
setTide(null);
|
||||
setTideError(error instanceof Error ? error.message : "Tidenprognose nicht erreichbar");
|
||||
}
|
||||
} finally {
|
||||
if (active && tideRequestIdRef.current === requestId) {
|
||||
setTideLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void refresh();
|
||||
const intervalId = window.setInterval(refresh, TIDE_REFRESH_MS);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [anchorPoint, anchorSetAtMs, phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === "idle") {
|
||||
return;
|
||||
}
|
||||
setClockMs(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockMs(Date.now()), 5_000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [phase, gps.timestampMs]);
|
||||
|
||||
const tideWindow = useMemo(
|
||||
() => tide && anchorSetAtMs !== null
|
||||
? analyzeTideWindow(tide, anchorSetAtMs, settings.horizonHours)
|
||||
: null,
|
||||
[anchorSetAtMs, settings.horizonHours, tide]
|
||||
);
|
||||
const remainingTideWindow = useMemo(
|
||||
() => tide ? analyzeTideWindow(tide, clockMs, settings.horizonHours) : null,
|
||||
[clockMs, settings.horizonHours, tide]
|
||||
);
|
||||
const rodePlan = useMemo(
|
||||
() => calculateAnchorRodePlan({
|
||||
depthAtSetM: settings.depthAtSetM,
|
||||
bowRollerHeightM: settings.bowRollerHeightM,
|
||||
deployedRodeLengthM: settings.deployedRodeLengthM,
|
||||
scopeRatio: settings.scopeRatio,
|
||||
safetyAllowanceM: settings.safetyAllowanceM,
|
||||
tideWindow
|
||||
}),
|
||||
[settings, tideWindow]
|
||||
);
|
||||
const watchResult = useMemo(
|
||||
() => anchorPoint && gps.position
|
||||
? evaluateAnchorWatch({
|
||||
anchorPoint,
|
||||
position: gps.position,
|
||||
alarmRadiusM: settings.alarmRadiusM,
|
||||
accuracyM: gps.accuracyM,
|
||||
maxReliableAccuracyM: MAX_CAPTURE_ACCURACY_M
|
||||
})
|
||||
: null,
|
||||
[anchorPoint, gps.accuracyM, gps.position, settings.alarmRadiusM]
|
||||
);
|
||||
|
||||
const fixStale = phase === "armed" && (
|
||||
gps.timestampMs === null || Math.max(0, clockMs - gps.timestampMs) > STALE_FIX_AFTER_MS
|
||||
);
|
||||
const gpsUnavailable = phase === "armed" && gps.status !== "tracking";
|
||||
const gpsUnreliable = phase === "armed" && Boolean(watchResult && !watchResult.positionReliable);
|
||||
const positionAlarm = phase === "armed" && (
|
||||
fixStale || gpsUnavailable || gpsUnreliable || !watchResult || watchResult.alarmTriggered
|
||||
);
|
||||
const rodeShortfall = Boolean(
|
||||
phase === "armed" && rodePlan?.calculationComplete && (rodePlan.rodeReserveM ?? 0) < 0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "armed") {
|
||||
previousAlarmRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!positionAlarm) {
|
||||
previousAlarmRef.current = false;
|
||||
setAlarmAcknowledged(false);
|
||||
return;
|
||||
}
|
||||
if (!previousAlarmRef.current) {
|
||||
setAlarmAcknowledged(false);
|
||||
emitAnchorAlert(
|
||||
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
|
||||
audioContextRef.current
|
||||
);
|
||||
}
|
||||
previousAlarmRef.current = true;
|
||||
}, [fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "armed" || !positionAlarm || alarmAcknowledged) {
|
||||
return;
|
||||
}
|
||||
const intervalId = window.setInterval(() => {
|
||||
emitAnchorAlert(
|
||||
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
|
||||
audioContextRef.current
|
||||
);
|
||||
}, REPEAT_ALARM_MS);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [alarmAcknowledged, fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === "armed" && rodeShortfall && !previousRodeShortfallRef.current) {
|
||||
const shortfallM = Math.abs(rodePlan?.rodeReserveM ?? 0);
|
||||
emitAnchorAlert(
|
||||
`Nach Stationsprognose fehlen rechnerisch etwa ${shortfallM.toFixed(1)} m Ankerleine oder Kette.`,
|
||||
audioContextRef.current
|
||||
);
|
||||
}
|
||||
previousRodeShortfallRef.current = rodeShortfall;
|
||||
}, [phase, rodePlan?.rodeReserveM, rodeShortfall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "armed" || typeof navigator === "undefined") {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let sentinel: WakeLockSentinelLike | null = null;
|
||||
const acquire = async () => {
|
||||
const wakeLock = (navigator as NavigatorWithWakeLock).wakeLock;
|
||||
if (!wakeLock || document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sentinel = await wakeLock.request("screen");
|
||||
if (!active) {
|
||||
await sentinel.release();
|
||||
}
|
||||
} catch {
|
||||
sentinel = null;
|
||||
}
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible" && (!sentinel || sentinel.released)) {
|
||||
void acquire();
|
||||
}
|
||||
};
|
||||
void acquire();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
active = false;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
void sentinel?.release().catch(() => undefined);
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const audioContext = audioContextRef.current;
|
||||
audioContextRef.current = null;
|
||||
void audioContext?.close().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const acknowledgeAlarm = useCallback(() => setAlarmAcknowledged(true), []);
|
||||
|
||||
return {
|
||||
phase,
|
||||
anchorPoint,
|
||||
anchorSetAtMs,
|
||||
anchorCaptureAccuracyM,
|
||||
settings,
|
||||
settingsError,
|
||||
tide,
|
||||
tideLoading,
|
||||
tideError,
|
||||
tideWindow,
|
||||
remainingTideWindow,
|
||||
rodePlan,
|
||||
watchResult,
|
||||
fixStale,
|
||||
gpsUnreliable,
|
||||
positionAlarm,
|
||||
rodeShortfall,
|
||||
alarmAcknowledged,
|
||||
operationError,
|
||||
maxCaptureAccuracyM: MAX_CAPTURE_ACCURACY_M,
|
||||
captureAnchor,
|
||||
updateSettings,
|
||||
arm,
|
||||
acknowledgeAlarm,
|
||||
reset
|
||||
};
|
||||
}
|
||||
|
||||
function validateSettings(settings: AnchorWatchSettings): string | null {
|
||||
if (!positive(settings.depthAtSetM)) return "Die Tiefe beim Setzen muss größer als 0 m sein.";
|
||||
if (!nonNegative(settings.bowRollerHeightM)) return "Die Höhe der Bugrolle darf nicht negativ sein.";
|
||||
if (!positive(settings.deployedRodeLengthM)) return "Die ausgesteckte Länge muss größer als 0 m sein.";
|
||||
if (!Number.isFinite(settings.scopeRatio) || settings.scopeRatio < 2 || settings.scopeRatio > 15) {
|
||||
return "Das gewählte Verhältnis muss zwischen 2:1 und 15:1 liegen.";
|
||||
}
|
||||
if (!nonNegative(settings.safetyAllowanceM)) return "Die Wasserstandsreserve darf nicht negativ sein.";
|
||||
if (!Number.isFinite(settings.alarmRadiusM) || settings.alarmRadiusM < 10 || settings.alarmRadiusM > 2_000) {
|
||||
return "Der Alarmradius muss zwischen 10 m und 2.000 m liegen.";
|
||||
}
|
||||
if (!Number.isFinite(settings.horizonHours) || settings.horizonHours < 6 || settings.horizonHours > 72) {
|
||||
return "Der Tidenzeitraum muss zwischen 6 und 72 Stunden liegen.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function positive(value: number) {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function nonNegative(value: number) {
|
||||
return Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function anchorAlertMessage({
|
||||
fixStale,
|
||||
gpsUnavailable,
|
||||
gpsUnreliable,
|
||||
watchResult
|
||||
}: {
|
||||
fixStale: boolean;
|
||||
gpsUnavailable: boolean;
|
||||
gpsUnreliable: boolean;
|
||||
watchResult: ReturnType<typeof evaluateAnchorWatch>;
|
||||
}) {
|
||||
if (fixStale) return "Kein aktueller GPS-Fix – Ankerposition kann nicht sicher überwacht werden.";
|
||||
if (gpsUnavailable) return "GPS ist ausgefallen – Ankerposition kann nicht überwacht werden.";
|
||||
if (gpsUnreliable) return "GPS ist zu ungenau – Ankerposition kann nicht sicher überwacht werden.";
|
||||
if (watchResult?.alarmTriggered) {
|
||||
return `Ankeralarm: ${Math.round(watchResult.distanceFromAnchorM)} m vom gesetzten Ankerpunkt entfernt.`;
|
||||
}
|
||||
return "Ankerwache hat keine auswertbare Position.";
|
||||
}
|
||||
|
||||
function emitAnchorAlert(message: string, audioContext: AudioContext | null) {
|
||||
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
|
||||
navigator.vibrate([300, 120, 300, 120, 500]);
|
||||
}
|
||||
playAlarmTone(audioContext);
|
||||
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
|
||||
try {
|
||||
new Notification("Watermaps Ankerwache", {
|
||||
body: message,
|
||||
tag: "watermaps-anchor-watch",
|
||||
requireInteraction: true
|
||||
});
|
||||
} catch {
|
||||
// The live panel remains the primary warning surface.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createAlarmAudioContext(): AudioContext | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const AudioContextConstructor = window.AudioContext ?? (
|
||||
window as typeof window & { webkitAudioContext?: typeof AudioContext }
|
||||
).webkitAudioContext;
|
||||
if (!AudioContextConstructor) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new AudioContextConstructor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function playAlarmTone(audioContext: AudioContext | null) {
|
||||
if (!audioContext || audioContext.state === "closed") {
|
||||
return;
|
||||
}
|
||||
void audioContext.resume().then(() => {
|
||||
const startAt = audioContext.currentTime;
|
||||
for (const offset of [0, 0.32, 0.64]) {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
oscillator.type = "square";
|
||||
oscillator.frequency.value = 880;
|
||||
gain.gain.setValueAtTime(0.0001, startAt + offset);
|
||||
gain.gain.exponentialRampToValueAtTime(0.18, startAt + offset + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + offset + 0.2);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(audioContext.destination);
|
||||
oscillator.start(startAt + offset);
|
||||
oscillator.stop(startAt + offset + 0.21);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { normalizeHeadingDeg } from "@watermaps/shared";
|
||||
|
||||
type DeviceOrientationWithWebkit = DeviceOrientationEvent & {
|
||||
webkitCompassHeading?: number;
|
||||
};
|
||||
|
||||
type DeviceOrientationConstructor = typeof DeviceOrientationEvent & {
|
||||
requestPermission?: (absolute?: boolean) => Promise<"granted" | "denied">;
|
||||
};
|
||||
|
||||
export type CompassState = {
|
||||
status: "idle" | "requesting" | "active" | "denied" | "unavailable" | "error";
|
||||
headingDeg: number | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export function useCompass(fallbackCourseDeg: number | null) {
|
||||
const [state, setState] = useState<CompassState>({
|
||||
status: "idle",
|
||||
headingDeg: null,
|
||||
message: null
|
||||
});
|
||||
|
||||
const handleOrientation = useCallback((event: DeviceOrientationEvent) => {
|
||||
const orientation = event as DeviceOrientationWithWebkit;
|
||||
const heading =
|
||||
typeof orientation.webkitCompassHeading === "number"
|
||||
? orientation.webkitCompassHeading
|
||||
: typeof event.alpha === "number"
|
||||
? 360 - event.alpha
|
||||
: null;
|
||||
|
||||
if (heading !== null) {
|
||||
setState({
|
||||
status: "active",
|
||||
headingDeg: Math.round(normalizeHeadingDeg(heading)),
|
||||
message: null
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeOrientationListeners = useCallback(() => {
|
||||
window.removeEventListener("deviceorientationabsolute", handleOrientation);
|
||||
window.removeEventListener("deviceorientation", handleOrientation);
|
||||
}, [handleOrientation]);
|
||||
|
||||
const request = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !("DeviceOrientationEvent" in window)) {
|
||||
setState((current) => ({ ...current, status: "unavailable", message: "Kompass nicht verfügbar" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setState((current) => ({ ...current, status: "requesting", message: null }));
|
||||
const ctor = DeviceOrientationEvent as DeviceOrientationConstructor;
|
||||
if (typeof ctor.requestPermission === "function") {
|
||||
const permission = await ctor.requestPermission(true);
|
||||
if (permission !== "granted") {
|
||||
setState((current) => ({ ...current, status: "denied", message: "Kompass gesperrt" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
removeOrientationListeners();
|
||||
window.addEventListener("deviceorientationabsolute", handleOrientation);
|
||||
window.addEventListener("deviceorientation", handleOrientation);
|
||||
setState((current) => ({ ...current, status: "active" }));
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Kompassfehler"
|
||||
}));
|
||||
}
|
||||
}, [handleOrientation, removeOrientationListeners]);
|
||||
|
||||
useEffect(() => removeOrientationListeners, [removeOrientationListeners]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
headingDeg: state.headingDeg ?? fallbackCourseDeg,
|
||||
source: state.headingDeg !== null ? "HDG" : fallbackCourseDeg !== null ? "COG" : "N",
|
||||
request
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
calculateRouteGuidance,
|
||||
type Coordinate,
|
||||
type RouteGuidanceResult,
|
||||
type RouteResult
|
||||
} from "@watermaps/shared";
|
||||
|
||||
export type CourseAssistantInput = {
|
||||
route: RouteResult | null;
|
||||
position: Coordinate | null;
|
||||
accuracyM: number | null;
|
||||
speedKn: number | null;
|
||||
headingDeg: number | null;
|
||||
fixTimestampMs: number | null;
|
||||
};
|
||||
|
||||
const STALE_FIX_AFTER_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Keeps route-following state in memory while reusing the app's single GPS
|
||||
* stream. It never starts a sensor or transmits a position by itself.
|
||||
*/
|
||||
export function useCourseAssistant({
|
||||
route,
|
||||
position,
|
||||
accuracyM,
|
||||
speedKn,
|
||||
headingDeg,
|
||||
fixTimestampMs
|
||||
}: CourseAssistantInput) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [clockMs, setClockMs] = useState(() => Date.now());
|
||||
const activeRouteRef = useRef<RouteResult | null>(null);
|
||||
const progressMRef = useRef<number | null>(null);
|
||||
const previousSignalRef = useRef<string | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
activeRouteRef.current = null;
|
||||
progressMRef.current = null;
|
||||
previousSignalRef.current = null;
|
||||
setActive(false);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!route) return;
|
||||
activeRouteRef.current = route;
|
||||
progressMRef.current = null;
|
||||
previousSignalRef.current = null;
|
||||
setClockMs(Date.now());
|
||||
setActive(true);
|
||||
}, [route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeRouteRef.current && activeRouteRef.current !== route) {
|
||||
stop();
|
||||
}
|
||||
}, [route, stop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setClockMs(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockMs(Date.now()), 5_000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [active, fixTimestampMs]);
|
||||
|
||||
const fixStale = Boolean(
|
||||
active &&
|
||||
position &&
|
||||
fixTimestampMs !== null &&
|
||||
Math.max(0, clockMs - fixTimestampMs) > STALE_FIX_AFTER_MS
|
||||
);
|
||||
|
||||
const guidance = useMemo<RouteGuidanceResult | null>(() => {
|
||||
if (!active || activeRouteRef.current !== route || !route || !position || fixStale) {
|
||||
return null;
|
||||
}
|
||||
return calculateRouteGuidance({
|
||||
route,
|
||||
position,
|
||||
accuracyM,
|
||||
speedKn,
|
||||
headingDeg,
|
||||
previousProgressM: progressMRef.current
|
||||
});
|
||||
}, [accuracyM, active, fixStale, headingDeg, position, route, speedKn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (guidance) {
|
||||
progressMRef.current = guidance.progressM;
|
||||
}
|
||||
}, [guidance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !guidance) return;
|
||||
const signal = guidanceSignal(guidance);
|
||||
if (signal !== previousSignalRef.current && typeof navigator.vibrate === "function") {
|
||||
if (guidance.status === "off-route") navigator.vibrate([200, 100, 200]);
|
||||
if (guidance.status === "arrived") navigator.vibrate([300, 150, 300]);
|
||||
if (guidance.status === "approaching-turn") navigator.vibrate(120);
|
||||
}
|
||||
previousSignalRef.current = signal;
|
||||
}, [active, guidance]);
|
||||
|
||||
return { active, guidance, fixStale, start, stop };
|
||||
}
|
||||
|
||||
function guidanceSignal(guidance: RouteGuidanceResult) {
|
||||
if (guidance.status === "approaching-turn" && guidance.nextTurn) {
|
||||
const turn = guidance.nextTurn.coordinate;
|
||||
return `turn:${turn.lat.toFixed(5)}:${turn.lon.toFixed(5)}`;
|
||||
}
|
||||
return guidance.status;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { haversineDistanceM, initialBearingDeg, type Coordinate } from "@watermaps/shared";
|
||||
|
||||
export type GpsState = {
|
||||
status: "idle" | "requesting" | "tracking" | "denied" | "unavailable" | "error";
|
||||
position: Coordinate | null;
|
||||
accuracyM: number | null;
|
||||
speedKn: number | null;
|
||||
courseDeg: number | null;
|
||||
timestampMs: number | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
const MS_TO_KN = 1.94384449;
|
||||
|
||||
export function useGeolocation() {
|
||||
const watchId = useRef<number | null>(null);
|
||||
const courseAnchor = useRef<{ coord: Coordinate; accuracyM: number } | null>(null);
|
||||
const [state, setState] = useState<GpsState>({
|
||||
status: "idle",
|
||||
position: null,
|
||||
accuracyM: null,
|
||||
speedKn: null,
|
||||
courseDeg: null,
|
||||
timestampMs: null,
|
||||
message: null
|
||||
});
|
||||
|
||||
const clearActiveWatch = useCallback(() => {
|
||||
if (watchId.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchId.current);
|
||||
watchId.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
clearActiveWatch();
|
||||
courseAnchor.current = null;
|
||||
setState({
|
||||
status: "idle",
|
||||
position: null,
|
||||
accuracyM: null,
|
||||
speedKn: null,
|
||||
courseDeg: null,
|
||||
timestampMs: null,
|
||||
message: null
|
||||
});
|
||||
}, [clearActiveWatch]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (typeof window !== "undefined" && window.isSecureContext === false) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
status: "unavailable",
|
||||
message: "GPS benötigt HTTPS oder localhost. Öffne die App auf dem iPhone über einen HTTPS-Link."
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!("geolocation" in navigator)) {
|
||||
setState((current) => ({ ...current, status: "unavailable", message: "GPS nicht verfügbar" }));
|
||||
return;
|
||||
}
|
||||
|
||||
clearActiveWatch();
|
||||
courseAnchor.current = null;
|
||||
setState((current) => ({ ...current, status: "requesting", message: null }));
|
||||
|
||||
watchId.current = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
// Freshness is based on when this watch callback reached the app. Some
|
||||
// embedded/WebKit implementations expose a non-epoch timestamp even
|
||||
// though the DOM type is an EpochTimeStamp. maximumAge below already
|
||||
// limits how old the accepted sensor fix may be.
|
||||
const receivedAtMs = Date.now();
|
||||
const coord = {
|
||||
lat: position.coords.latitude,
|
||||
lon: position.coords.longitude
|
||||
};
|
||||
const nativeCourse =
|
||||
typeof position.coords.heading === "number" && Number.isFinite(position.coords.heading)
|
||||
? position.coords.heading
|
||||
: null;
|
||||
const speedKn =
|
||||
typeof position.coords.speed === "number" && Number.isFinite(position.coords.speed)
|
||||
? position.coords.speed * MS_TO_KN
|
||||
: null;
|
||||
const accuracyM = Math.max(0, position.coords.accuracy);
|
||||
const anchor = courseAnchor.current;
|
||||
let derivedCourse: number | null = nativeCourse;
|
||||
if (nativeCourse !== null) {
|
||||
courseAnchor.current = { coord, accuracyM };
|
||||
} else if (!anchor) {
|
||||
courseAnchor.current = { coord, accuracyM };
|
||||
} else {
|
||||
const movementM = haversineDistanceM(anchor.coord, coord);
|
||||
const minimumMovementM = Math.max(3, Math.min(12, Math.max(anchor.accuracyM, accuracyM) * 0.5));
|
||||
if (movementM >= minimumMovementM) {
|
||||
derivedCourse = initialBearingDeg(anchor.coord, coord);
|
||||
courseAnchor.current = { coord, accuracyM };
|
||||
}
|
||||
}
|
||||
|
||||
setState((current) => ({
|
||||
status: "tracking",
|
||||
position: coord,
|
||||
accuracyM: Math.round(accuracyM),
|
||||
speedKn,
|
||||
courseDeg: derivedCourse ?? current.courseDeg,
|
||||
timestampMs: receivedAtMs,
|
||||
message: null
|
||||
}));
|
||||
},
|
||||
(error) => {
|
||||
const status = error.code === error.PERMISSION_DENIED ? "denied" : "error";
|
||||
setState((current) => ({ ...current, status, message: error.message }));
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 12_000,
|
||||
maximumAge: 2_000
|
||||
}
|
||||
);
|
||||
}, [clearActiveWatch]);
|
||||
|
||||
useEffect(() => clearActiveWatch, [clearActiveWatch]);
|
||||
|
||||
return { ...state, start, stop };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Coordinate, MarineForecast, TideSummary } from "@watermaps/shared";
|
||||
import { getMarineForecast, getNearestTide } from "../api";
|
||||
|
||||
export type MarineDataState = {
|
||||
forecast: MarineForecast | null;
|
||||
tide: TideSummary | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
forecastError: string | null;
|
||||
tideError: string | null;
|
||||
queryPosition: Coordinate | null;
|
||||
refreshedAt: string | null;
|
||||
};
|
||||
|
||||
const REFRESH_INTERVAL_MS = 10 * 60_000;
|
||||
|
||||
export function useMarineData(position: Coordinate | null): MarineDataState {
|
||||
const key = useMemo(() => {
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
return `${position.lat.toFixed(2)}:${position.lon.toFixed(2)}`;
|
||||
}, [position]);
|
||||
const queryPosition = useMemo<Coordinate | null>(() => {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const [lat, lon] = key.split(":").map(Number);
|
||||
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat: lat!, lon: lon! } : null;
|
||||
}, [key]);
|
||||
const [state, setState] = useState<MarineDataState>({
|
||||
forecast: null,
|
||||
tide: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
forecastError: null,
|
||||
tideError: null,
|
||||
queryPosition: null,
|
||||
refreshedAt: null
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryPosition || !key) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
setState((current) => {
|
||||
const sameQuery =
|
||||
current.queryPosition?.lat === queryPosition.lat &&
|
||||
current.queryPosition?.lon === queryPosition.lon;
|
||||
return {
|
||||
...current,
|
||||
forecast: sameQuery ? current.forecast : null,
|
||||
tide: sameQuery ? current.tide : null,
|
||||
loading: true,
|
||||
error: null,
|
||||
forecastError: null,
|
||||
tideError: null,
|
||||
queryPosition
|
||||
};
|
||||
});
|
||||
|
||||
void Promise.allSettled([
|
||||
getMarineForecast(queryPosition),
|
||||
getNearestTide(queryPosition)
|
||||
]).then(([forecastResult, tideResult]) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setState((current) => {
|
||||
const forecastError =
|
||||
forecastResult.status === "rejected" ? "Wetterdaten nicht erreichbar" : null;
|
||||
const tideError =
|
||||
tideResult.status === "rejected" ? "Tidendaten nicht erreichbar" : null;
|
||||
return {
|
||||
...current,
|
||||
forecast:
|
||||
forecastResult.status === "fulfilled"
|
||||
? forecastResult.value
|
||||
: current.forecast,
|
||||
tide:
|
||||
tideResult.status === "fulfilled"
|
||||
? tideResult.value
|
||||
: current.tide,
|
||||
loading: false,
|
||||
error:
|
||||
forecastError && tideError ? "Metocean-Daten nicht erreichbar" : null,
|
||||
forecastError,
|
||||
tideError,
|
||||
queryPosition,
|
||||
refreshedAt: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
refresh();
|
||||
const intervalId = window.setInterval(refresh, REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [key, queryPosition]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Coordinate, RouteResult } from "@watermaps/shared";
|
||||
import { evaluateRouteDeviation } from "../lib/route-deviation";
|
||||
|
||||
export type RouteDeviationAlarmStatus =
|
||||
| "idle"
|
||||
| "requesting"
|
||||
| "tracking"
|
||||
| "denied"
|
||||
| "unavailable"
|
||||
| "error";
|
||||
|
||||
export type RouteDeviationAlarmState = {
|
||||
status: RouteDeviationAlarmStatus;
|
||||
position: Coordinate | null;
|
||||
accuracyM: number | null;
|
||||
distanceM: number | null;
|
||||
reliable: boolean;
|
||||
isOffRoute: boolean;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
const INITIAL_STATE: RouteDeviationAlarmState = {
|
||||
status: "idle",
|
||||
position: null,
|
||||
accuracyM: null,
|
||||
distanceM: null,
|
||||
reliable: true,
|
||||
isOffRoute: false,
|
||||
message: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Watches the device position only after start() is called from a user action.
|
||||
* Coordinates are evaluated in memory and are never persisted or transmitted.
|
||||
*/
|
||||
export function useRouteDeviationAlarm(route: RouteResult | null, thresholdM: number) {
|
||||
const [state, setState] = useState<RouteDeviationAlarmState>(INITIAL_STATE);
|
||||
const watchId = useRef<number | null>(null);
|
||||
const activeRoute = useRef<RouteResult | null>(route);
|
||||
const threshold = useRef(thresholdM);
|
||||
const offRoute = useRef(false);
|
||||
const monitoringGeneration = useRef(0);
|
||||
|
||||
activeRoute.current = route;
|
||||
threshold.current = thresholdM;
|
||||
|
||||
const stop = useCallback(() => {
|
||||
monitoringGeneration.current += 1;
|
||||
if (watchId.current !== null && typeof navigator !== "undefined" && "geolocation" in navigator) {
|
||||
navigator.geolocation.clearWatch(watchId.current);
|
||||
watchId.current = null;
|
||||
}
|
||||
offRoute.current = false;
|
||||
setState(INITIAL_STATE);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!activeRoute.current) {
|
||||
setState({ ...INITIAL_STATE, status: "error", message: "Zuerst eine Route berechnen oder offline laden." });
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined" && window.isSecureContext === false) {
|
||||
setState({ ...INITIAL_STATE, status: "unavailable", message: "Der Abweichungsalarm benötigt HTTPS oder localhost." });
|
||||
return;
|
||||
}
|
||||
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
|
||||
setState({ ...INITIAL_STATE, status: "unavailable", message: "GPS ist auf diesem Gerät nicht verfügbar." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (watchId.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchId.current);
|
||||
}
|
||||
monitoringGeneration.current += 1;
|
||||
const generation = monitoringGeneration.current;
|
||||
offRoute.current = false;
|
||||
setState({ ...INITIAL_STATE, status: "requesting", message: "GPS-Freigabe wird angefragt …" });
|
||||
|
||||
try {
|
||||
watchId.current = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
if (monitoringGeneration.current !== generation) {
|
||||
return;
|
||||
}
|
||||
const coordinate = { lat: position.coords.latitude, lon: position.coords.longitude };
|
||||
const result = activeRoute.current
|
||||
? evaluateRouteDeviation(coordinate, activeRoute.current, {
|
||||
thresholdM: threshold.current,
|
||||
accuracyM: position.coords.accuracy
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!result) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
status: "error",
|
||||
message: "Der Abstand zu dieser Route konnte nicht bestimmt werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.isOffRoute && !offRoute.current && typeof navigator.vibrate === "function") {
|
||||
navigator.vibrate([200, 100, 200]);
|
||||
}
|
||||
offRoute.current = result.isOffRoute;
|
||||
setState({
|
||||
status: "tracking",
|
||||
position: coordinate,
|
||||
accuracyM: result.accuracyM,
|
||||
distanceM: result.distanceM,
|
||||
reliable: result.reliable,
|
||||
isOffRoute: result.isOffRoute,
|
||||
message: result.reliable
|
||||
? result.isOffRoute
|
||||
? `Achtung: ${Math.round(result.distanceM)} m von der Route entfernt.`
|
||||
: `Auf Kurs · ${Math.round(result.distanceM)} m zur Route.`
|
||||
: `GPS noch zu ungenau (±${Math.round(result.accuracyM ?? 0)} m) – kein Alarm.`
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
if (monitoringGeneration.current !== generation) {
|
||||
return;
|
||||
}
|
||||
watchId.current = null;
|
||||
const denied = error.code === error.PERMISSION_DENIED;
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
status: denied ? "denied" : "error",
|
||||
message: denied ? "GPS-Freigabe wurde abgelehnt." : error.message || "GPS-Position nicht verfügbar."
|
||||
});
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15_000,
|
||||
maximumAge: 3_000
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
watchId.current = null;
|
||||
setState({ ...INITIAL_STATE, status: "unavailable", message: "GPS konnte nicht gestartet werden." });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => stop, [stop]);
|
||||
|
||||
// A newly selected route requires a deliberate restart, so the monitor can
|
||||
// never silently continue against a different voyage.
|
||||
const previousRoute = useRef(route);
|
||||
useEffect(() => {
|
||||
if (previousRoute.current !== route) {
|
||||
previousRoute.current = route;
|
||||
if (watchId.current !== null) {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
}, [route, stop]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
active: watchId.current !== null,
|
||||
start,
|
||||
stop
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { RouteResult } from "@watermaps/shared";
|
||||
|
||||
const DEFAULT_CREATOR = "Watermaps";
|
||||
|
||||
export type GpxExportOptions = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
creator?: string;
|
||||
createdAt?: Date | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a standards-compliant GPX 1.1 document containing both the planned
|
||||
* route and a track. Keeping both makes the export useful in navigation apps
|
||||
* which support only one of the two GPX representations.
|
||||
*/
|
||||
export function createRouteGpx(route: RouteResult, options: GpxExportOptions = {}): string {
|
||||
const coordinates = validRouteCoordinates(route);
|
||||
const name = cleanText(options.name ?? route.name ?? "Watermaps Bootsroute", 120);
|
||||
const description = cleanText(
|
||||
options.description ?? `${route.distanceNm.toFixed(1)} sm · ${route.routingMode === "fairway" ? "Fahrwasserroute" : "Bootsroute"}`,
|
||||
500
|
||||
);
|
||||
const creator = cleanText(options.creator ?? DEFAULT_CREATOR, 120);
|
||||
const createdAt = normalizeDate(options.createdAt);
|
||||
const source = cleanText(route.dataSources.join(", ") || DEFAULT_CREATOR, 500);
|
||||
const warningSummary = cleanText(
|
||||
route.warnings.map((warning) => warning.message).join(" · ") || "Nicht amtliche Routenplanung",
|
||||
1_000
|
||||
);
|
||||
const bounds = routeBounds(coordinates);
|
||||
const routePoints = coordinates.map(([lon, lat]) => ` <rtept lat="${formatCoordinate(lat)}" lon="${formatCoordinate(lon)}"/>`).join("\n");
|
||||
const trackPoints = coordinates.map(([lon, lat]) => ` <trkpt lat="${formatCoordinate(lat)}" lon="${formatCoordinate(lon)}"/>`).join("\n");
|
||||
|
||||
return [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
||||
`<gpx version="1.1" creator="${escapeXml(creator)}" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">`,
|
||||
" <metadata>",
|
||||
` <name>${escapeXml(name)}</name>`,
|
||||
` <desc>${escapeXml(description)}</desc>`,
|
||||
" <author>",
|
||||
` <name>${escapeXml(creator)}</name>`,
|
||||
" </author>",
|
||||
` <time>${createdAt}</time>`,
|
||||
" <keywords>Watermaps, Bootsroute, Navigation</keywords>",
|
||||
` <bounds minlat="${formatCoordinate(bounds.minLat)}" minlon="${formatCoordinate(bounds.minLon)}" maxlat="${formatCoordinate(bounds.maxLat)}" maxlon="${formatCoordinate(bounds.maxLon)}"/>`,
|
||||
" </metadata>",
|
||||
" <rte>",
|
||||
` <name>${escapeXml(name)}</name>`,
|
||||
` <cmt>${escapeXml(warningSummary)}</cmt>`,
|
||||
` <desc>${escapeXml(description)}</desc>`,
|
||||
` <src>${escapeXml(source)}</src>`,
|
||||
" <number>1</number>",
|
||||
" <type>Motorboating</type>",
|
||||
routePoints,
|
||||
" </rte>",
|
||||
" <trk>",
|
||||
` <name>${escapeXml(name)}</name>`,
|
||||
` <cmt>${escapeXml(warningSummary)}</cmt>`,
|
||||
` <desc>${escapeXml(description)}</desc>`,
|
||||
` <src>${escapeXml(source)}</src>`,
|
||||
" <number>1</number>",
|
||||
" <type>Motorboating</type>",
|
||||
" <trkseg>",
|
||||
trackPoints,
|
||||
" </trkseg>",
|
||||
" </trk>",
|
||||
"</gpx>",
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function downloadRouteGpx(route: RouteResult, options: GpxExportOptions = {}): void {
|
||||
if (typeof document === "undefined" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
|
||||
throw new Error("GPX-Download wird von diesem Browser nicht unterstützt.");
|
||||
}
|
||||
|
||||
const name = cleanText(options.name ?? route.name ?? "Watermaps-Route", 120);
|
||||
const filename = `${safeFilename(name)}.gpx`;
|
||||
const blobUrl = URL.createObjectURL(new Blob([createRouteGpx(route, options)], { type: "application/gpx+xml;charset=utf-8" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
link.rel = "noopener";
|
||||
link.hidden = true;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
||||
}
|
||||
|
||||
function validRouteCoordinates(route: RouteResult): [number, number][] {
|
||||
if (route.geometry.type !== "LineString" || route.geometry.coordinates.length < 2) {
|
||||
throw new Error("Die Route enthält nicht genügend Punkte für einen GPX-Export.");
|
||||
}
|
||||
|
||||
const coordinates = route.geometry.coordinates.map((coordinate) => {
|
||||
const [lon, lat] = coordinate;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||||
throw new Error("Die Route enthält ungültige Koordinaten.");
|
||||
}
|
||||
return [lon, lat] as [number, number];
|
||||
});
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
function routeBounds(coordinates: [number, number][]) {
|
||||
let minLat = 90;
|
||||
let maxLat = -90;
|
||||
let minLon = 180;
|
||||
let maxLon = -180;
|
||||
for (const [lon, lat] of coordinates) {
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
minLon = Math.min(minLon, lon);
|
||||
maxLon = Math.max(maxLon, lon);
|
||||
}
|
||||
return { minLat, maxLat, minLon, maxLon };
|
||||
}
|
||||
|
||||
function normalizeDate(value: Date | string | undefined): string {
|
||||
const date = value instanceof Date ? value : value ? new Date(value) : new Date();
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
throw new Error("Ungültiger Zeitstempel für den GPX-Export.");
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function formatCoordinate(value: number): string {
|
||||
return value.toFixed(7).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function cleanText(value: string, maxLength: number): string {
|
||||
const cleaned = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ").trim();
|
||||
return cleaned.slice(0, maxLength) || DEFAULT_CREATOR;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function safeFilename(value: string): string {
|
||||
const normalized = value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-.]+|[-.]+$/g, "")
|
||||
.slice(0, 80);
|
||||
return normalized || "Watermaps-Route";
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import type { Coordinate, RouteResult, RouteWarning, VesselProfile } from "@watermaps/shared";
|
||||
|
||||
export const OFFLINE_VOYAGES_STORAGE_KEY = "watermaps.offline-voyages.v1";
|
||||
export const LEGACY_OFFLINE_VOYAGES_STORAGE_KEY = "seacompass.offline-voyages.v1";
|
||||
export const MAX_OFFLINE_VOYAGES = 20;
|
||||
const MAX_SERIALIZED_BYTES = 4_000_000;
|
||||
const MAX_ROUTE_POINTS = 25_000;
|
||||
|
||||
export type OfflineVoyagePlan = {
|
||||
start: Coordinate;
|
||||
destination: Coordinate;
|
||||
waypoints: Coordinate[];
|
||||
vesselProfile: VesselProfile | null;
|
||||
departureAt: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type OfflineVoyagePlanInput = Partial<OfflineVoyagePlan>;
|
||||
|
||||
export type OfflineVoyage = {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
savedAt: string;
|
||||
plan: OfflineVoyagePlan;
|
||||
route: RouteResult;
|
||||
};
|
||||
|
||||
export type SaveOfflineVoyageInput = {
|
||||
route: RouteResult;
|
||||
name?: string;
|
||||
plan?: OfflineVoyagePlanInput;
|
||||
};
|
||||
|
||||
export type OfflineVoyageRecordOptions = {
|
||||
id?: string;
|
||||
savedAt?: Date | string;
|
||||
};
|
||||
|
||||
export class OfflineVoyageStorageError extends Error {
|
||||
override name = "OfflineVoyageStorageError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves one validated snapshot atomically. Only route-planning fields are
|
||||
* whitelisted; live GPS positions are deliberately never persisted.
|
||||
*/
|
||||
export function saveOfflineVoyage(
|
||||
input: SaveOfflineVoyageInput,
|
||||
storage: Storage = browserStorage(),
|
||||
options: OfflineVoyageRecordOptions = {}
|
||||
): OfflineVoyage {
|
||||
const record = createOfflineVoyageRecord(input, options);
|
||||
const records = listOfflineVoyages(storage).filter((item) => item.id !== record.id);
|
||||
const next = [record, ...records].slice(0, MAX_OFFLINE_VOYAGES);
|
||||
const serialized = JSON.stringify(next);
|
||||
if (serialized.length > MAX_SERIALIZED_BYTES) {
|
||||
throw new OfflineVoyageStorageError("Die Route ist zu groß für den Offline-Speicher.");
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, serialized);
|
||||
} catch {
|
||||
throw new OfflineVoyageStorageError("Die Route konnte auf diesem Gerät nicht gespeichert werden.");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function listOfflineVoyages(storage: Storage = browserStorage()): OfflineVoyage[] {
|
||||
let serialized: string | null;
|
||||
try {
|
||||
serialized =
|
||||
storage.getItem(OFFLINE_VOYAGES_STORAGE_KEY) ??
|
||||
storage.getItem(LEGACY_OFFLINE_VOYAGES_STORAGE_KEY);
|
||||
} catch {
|
||||
throw new OfflineVoyageStorageError("Der Offline-Speicher ist nicht verfügbar.");
|
||||
}
|
||||
if (!serialized || serialized.length > MAX_SERIALIZED_BYTES) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(serialized);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed
|
||||
.slice(0, MAX_OFFLINE_VOYAGES)
|
||||
.map(parseOfflineVoyage)
|
||||
.filter((item): item is OfflineVoyage => item !== null)
|
||||
.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function loadOfflineVoyage(id: string, storage: Storage = browserStorage()): OfflineVoyage | null {
|
||||
const safeId = limitedString(id, 160);
|
||||
if (!safeId) {
|
||||
return null;
|
||||
}
|
||||
return listOfflineVoyages(storage).find((record) => record.id === safeId) ?? null;
|
||||
}
|
||||
|
||||
export function deleteOfflineVoyage(id: string, storage: Storage = browserStorage()): boolean {
|
||||
const records = listOfflineVoyages(storage);
|
||||
const next = records.filter((record) => record.id !== id);
|
||||
if (next.length === records.length) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
storage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
throw new OfflineVoyageStorageError("Die Offline-Route konnte nicht gelöscht werden.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Best-effort protection against automatic browser eviction after a user saves a route. */
|
||||
export async function requestPersistentOfflineStorage(): Promise<boolean | null> {
|
||||
if (typeof navigator === "undefined" || !navigator.storage?.persist) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (await navigator.storage.persisted()) {
|
||||
return true;
|
||||
}
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOfflineVoyageRecord(
|
||||
input: SaveOfflineVoyageInput,
|
||||
options: OfflineVoyageRecordOptions = {}
|
||||
): OfflineVoyage {
|
||||
const route = normalizeRoute(input.route);
|
||||
const first = route.geometry.coordinates[0]!;
|
||||
const last = route.geometry.coordinates.at(-1)!;
|
||||
const savedAt = normalizeIsoDate(options.savedAt ?? new Date());
|
||||
const name = limitedString(input.name ?? route.name ?? "Offline-Bootsroute", 120) || "Offline-Bootsroute";
|
||||
const id = limitedString(options.id ?? createId(savedAt), 160);
|
||||
if (!id) {
|
||||
throw new OfflineVoyageStorageError("Ungültige Kennung für die Offline-Route.");
|
||||
}
|
||||
|
||||
const defaultStart = { lat: first[1], lon: first[0] };
|
||||
const defaultDestination = { lat: last[1], lon: last[0] };
|
||||
const planInput = input.plan ?? {};
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
name,
|
||||
savedAt,
|
||||
plan: {
|
||||
start: normalizeCoordinate(planInput.start ?? defaultStart),
|
||||
destination: normalizeCoordinate(planInput.destination ?? defaultDestination),
|
||||
waypoints: normalizeCoordinateList(planInput.waypoints ?? []),
|
||||
vesselProfile: planInput.vesselProfile ? normalizeVesselProfile(planInput.vesselProfile) : null,
|
||||
departureAt: planInput.departureAt ? normalizeIsoDate(planInput.departureAt) : null,
|
||||
notes: planInput.notes ? limitedString(planInput.notes, 2_000) : null
|
||||
},
|
||||
route
|
||||
};
|
||||
}
|
||||
|
||||
function parseOfflineVoyage(value: unknown): OfflineVoyage | null {
|
||||
if (!isRecord(value) || value.schemaVersion !== 1) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const id = limitedString(value.id, 160);
|
||||
const name = limitedString(value.name, 120);
|
||||
if (!id || !name || !isRecord(value.plan)) {
|
||||
return null;
|
||||
}
|
||||
const plan = value.plan;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
name,
|
||||
savedAt: normalizeIsoDate(value.savedAt),
|
||||
plan: {
|
||||
start: normalizeCoordinate(plan.start),
|
||||
destination: normalizeCoordinate(plan.destination),
|
||||
waypoints: normalizeCoordinateList(plan.waypoints),
|
||||
vesselProfile: plan.vesselProfile === null ? null : normalizeVesselProfile(plan.vesselProfile),
|
||||
departureAt: plan.departureAt === null ? null : normalizeIsoDate(plan.departureAt),
|
||||
notes: plan.notes === null ? null : limitedString(plan.notes, 2_000)
|
||||
},
|
||||
route: normalizeRoute(value.route)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoute(value: unknown): RouteResult {
|
||||
if (!isRecord(value) || !isRecord(value.geometry) || value.geometry.type !== "LineString") {
|
||||
throw new OfflineVoyageStorageError("Die Offline-Route hat ein ungültiges Format.");
|
||||
}
|
||||
const coordinates = normalizeLineCoordinates(value.geometry.coordinates);
|
||||
const distanceNm = finiteNumber(value.distanceNm, 0, 100_000);
|
||||
const eta = value.eta === null ? null : limitedString(value.eta, 100);
|
||||
const minKnownDepthM = value.minKnownDepthM === null ? null : finiteNumber(value.minKnownDepthM, 0, 20_000);
|
||||
const unknownDepthRatio = finiteNumber(value.unknownDepthRatio, 0, 1);
|
||||
const warnings = normalizeWarnings(value.warnings);
|
||||
const dataSources = normalizeStrings(value.dataSources, 100, 500);
|
||||
const id = value.id === undefined ? undefined : limitedString(value.id, 160);
|
||||
const name = value.name === undefined ? undefined : limitedString(value.name, 120);
|
||||
const routingMode = value.routingMode === "manual" || value.routingMode === "fairway" ? value.routingMode : undefined;
|
||||
const departureTime =
|
||||
typeof value.departureTime === "string" ? normalizeIsoDate(value.departureTime) : undefined;
|
||||
const durationMinutes =
|
||||
value.durationMinutes === undefined ? undefined : finiteNumber(value.durationMinutes, 0, 10_000_000);
|
||||
|
||||
return {
|
||||
...(id ? { id } : {}),
|
||||
...(name ? { name } : {}),
|
||||
geometry: { type: "LineString", coordinates },
|
||||
distanceNm,
|
||||
eta,
|
||||
warnings,
|
||||
minKnownDepthM,
|
||||
unknownDepthRatio,
|
||||
dataSources,
|
||||
...(departureTime ? { departureTime } : {}),
|
||||
...(durationMinutes !== undefined ? { durationMinutes } : {}),
|
||||
...(routingMode ? { routingMode } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLineCoordinates(value: unknown): [number, number][] {
|
||||
if (!Array.isArray(value) || value.length < 2 || value.length > MAX_ROUTE_POINTS) {
|
||||
throw new OfflineVoyageStorageError("Die Offline-Route enthält keine gültige Liniengeometrie.");
|
||||
}
|
||||
return value.map((item) => {
|
||||
if (!Array.isArray(item) || item.length < 2) {
|
||||
throw new OfflineVoyageStorageError("Die Offline-Route enthält ungültige Koordinaten.");
|
||||
}
|
||||
const lon = finiteNumber(item[0], -180, 180);
|
||||
const lat = finiteNumber(item[1], -90, 90);
|
||||
return [lon, lat];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCoordinateList(value: unknown): Coordinate[] {
|
||||
if (!Array.isArray(value) || value.length > 1_000) {
|
||||
throw new OfflineVoyageStorageError("Die Wegpunktliste ist ungültig.");
|
||||
}
|
||||
return value.map(normalizeCoordinate);
|
||||
}
|
||||
|
||||
function normalizeCoordinate(value: unknown): Coordinate {
|
||||
if (!isRecord(value)) {
|
||||
throw new OfflineVoyageStorageError("Eine Plankoordinate ist ungültig.");
|
||||
}
|
||||
return {
|
||||
lat: finiteNumber(value.lat, -90, 90),
|
||||
lon: finiteNumber(value.lon, -180, 180)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVesselProfile(value: unknown): VesselProfile {
|
||||
if (!isRecord(value)) {
|
||||
throw new OfflineVoyageStorageError("Das Bootsprofil ist ungültig.");
|
||||
}
|
||||
const profile: VesselProfile = {
|
||||
draughtM: finiteNumber(value.draughtM, 0, 100),
|
||||
safetyReserveM: finiteNumber(value.safetyReserveM, 0, 100)
|
||||
};
|
||||
if (value.airDraftM !== undefined) {
|
||||
profile.airDraftM = finiteNumber(value.airDraftM, 0, 200);
|
||||
}
|
||||
if (value.beamM !== undefined) {
|
||||
profile.beamM = finiteNumber(value.beamM, 0, 200);
|
||||
}
|
||||
if (value.cruiseSpeedKn !== undefined) {
|
||||
profile.cruiseSpeedKn = finiteNumber(value.cruiseSpeedKn, 0.1, 200);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
function normalizeWarnings(value: unknown): RouteWarning[] {
|
||||
if (!Array.isArray(value) || value.length > 500) {
|
||||
throw new OfflineVoyageStorageError("Die Routenwarnungen sind ungültig.");
|
||||
}
|
||||
return value.map((warning) => {
|
||||
if (!isRecord(warning)) {
|
||||
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
|
||||
}
|
||||
const code = limitedString(warning.code, 100);
|
||||
const message = limitedString(warning.message, 1_000);
|
||||
const severity = warning.severity;
|
||||
if (!code || !message || (severity !== "info" && severity !== "caution" && severity !== "critical")) {
|
||||
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
|
||||
}
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
severity,
|
||||
...(warning.coordinate === undefined ? {} : { coordinate: normalizeCoordinate(warning.coordinate) })
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStrings(value: unknown, maxItems: number, maxLength: number): string[] {
|
||||
if (!Array.isArray(value) || value.length > maxItems) {
|
||||
throw new OfflineVoyageStorageError("Die Quellenangaben der Route sind ungültig.");
|
||||
}
|
||||
return value.map((item) => limitedString(item, maxLength)).filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, min: number, max: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
||||
throw new OfflineVoyageStorageError("Die Offline-Route enthält einen ungültigen Zahlenwert.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function limitedString(value: unknown, maxLength: number): string {
|
||||
return typeof value === "string" ? value.replace(/[\u0000-\u001F\u007F]/g, " ").trim().slice(0, maxLength) : "";
|
||||
}
|
||||
|
||||
function normalizeIsoDate(value: unknown): string {
|
||||
const date = value instanceof Date ? value : typeof value === "string" ? new Date(value) : new Date(Number.NaN);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
throw new OfflineVoyageStorageError("Der Zeitstempel der Offline-Route ist ungültig.");
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function createId(savedAt: string): string {
|
||||
const randomId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2, 14);
|
||||
return `voyage-${savedAt.replace(/\D/g, "").slice(0, 14)}-${randomId}`;
|
||||
}
|
||||
|
||||
function browserStorage(): Storage {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
throw new OfflineVoyageStorageError("Der Offline-Speicher wird von diesem Browser nicht unterstützt.");
|
||||
}
|
||||
return window.localStorage;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Coordinate, GeoJsonLineString, RouteResult } from "@watermaps/shared";
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_008.8;
|
||||
const MIN_SEGMENT_ANGLE = 1e-12;
|
||||
|
||||
export type RouteDeviationOptions = {
|
||||
thresholdM?: number;
|
||||
accuracyM?: number | null;
|
||||
maxAccuracyM?: number;
|
||||
};
|
||||
|
||||
export type RouteDeviationResult = {
|
||||
distanceM: number;
|
||||
conservativeDistanceM: number;
|
||||
thresholdM: number;
|
||||
accuracyM: number | null;
|
||||
reliable: boolean;
|
||||
isOffRoute: boolean;
|
||||
};
|
||||
|
||||
/** Returns the shortest geodesic distance from a point to the route segments. */
|
||||
export function distanceToRouteM(
|
||||
position: Coordinate,
|
||||
route: RouteResult | GeoJsonLineString | readonly [number, number][]
|
||||
): number | null {
|
||||
if (!isCoordinate(position)) {
|
||||
return null;
|
||||
}
|
||||
const coordinates = routeCoordinates(route);
|
||||
if (coordinates.length === 0 || coordinates.some((coordinate) => !isGeoJsonCoordinate(coordinate))) {
|
||||
return null;
|
||||
}
|
||||
if (coordinates.length === 1) {
|
||||
return angularDistance(position, toCoordinate(coordinates[0]!)) * EARTH_RADIUS_M;
|
||||
}
|
||||
|
||||
let closestM = Number.POSITIVE_INFINITY;
|
||||
for (let index = 1; index < coordinates.length; index += 1) {
|
||||
const start = toCoordinate(coordinates[index - 1]!);
|
||||
const end = toCoordinate(coordinates[index]!);
|
||||
closestM = Math.min(closestM, distanceToGreatCircleSegmentM(position, start, end));
|
||||
}
|
||||
return Number.isFinite(closestM) ? closestM : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies GPS accuracy conservatively: an alarm is emitted only when even the
|
||||
* nearest edge of the reported accuracy circle is outside the corridor.
|
||||
*/
|
||||
export function evaluateRouteDeviation(
|
||||
position: Coordinate,
|
||||
route: RouteResult | GeoJsonLineString | readonly [number, number][],
|
||||
options: RouteDeviationOptions = {}
|
||||
): RouteDeviationResult | null {
|
||||
const thresholdM = finiteRange(options.thresholdM ?? 100, 10, 10_000);
|
||||
const distanceM = distanceToRouteM(position, route);
|
||||
if (distanceM === null) {
|
||||
return null;
|
||||
}
|
||||
const accuracyM = typeof options.accuracyM === "number" && Number.isFinite(options.accuracyM) && options.accuracyM >= 0
|
||||
? options.accuracyM
|
||||
: null;
|
||||
const maxAccuracyM = finiteRange(options.maxAccuracyM ?? Math.max(100, thresholdM * 2), 10, 20_000);
|
||||
const reliable = accuracyM === null || accuracyM <= maxAccuracyM;
|
||||
const conservativeDistanceM = Math.max(0, distanceM - (accuracyM ?? 0));
|
||||
|
||||
return {
|
||||
distanceM,
|
||||
conservativeDistanceM,
|
||||
thresholdM,
|
||||
accuracyM,
|
||||
reliable,
|
||||
isOffRoute: reliable && conservativeDistanceM > thresholdM
|
||||
};
|
||||
}
|
||||
|
||||
function distanceToGreatCircleSegmentM(point: Coordinate, start: Coordinate, end: Coordinate): number {
|
||||
const segmentAngle = angularDistance(start, end);
|
||||
if (segmentAngle < MIN_SEGMENT_ANGLE) {
|
||||
return angularDistance(point, start) * EARTH_RADIUS_M;
|
||||
}
|
||||
|
||||
const pointAngle = angularDistance(start, point);
|
||||
if (pointAngle < MIN_SEGMENT_ANGLE) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const segmentBearing = initialBearingRad(start, end);
|
||||
const pointBearing = initialBearingRad(start, point);
|
||||
const bearingDelta = pointBearing - segmentBearing;
|
||||
const crossTrackAngle = Math.asin(clamp(Math.sin(pointAngle) * Math.sin(bearingDelta), -1, 1));
|
||||
const alongTrackAngle = Math.atan2(
|
||||
Math.sin(pointAngle) * Math.cos(bearingDelta),
|
||||
Math.cos(pointAngle)
|
||||
);
|
||||
|
||||
if (alongTrackAngle <= 0) {
|
||||
return pointAngle * EARTH_RADIUS_M;
|
||||
}
|
||||
if (alongTrackAngle >= segmentAngle) {
|
||||
return angularDistance(point, end) * EARTH_RADIUS_M;
|
||||
}
|
||||
return Math.abs(crossTrackAngle) * EARTH_RADIUS_M;
|
||||
}
|
||||
|
||||
function angularDistance(a: Coordinate, b: Coordinate): number {
|
||||
const lat1 = toRadians(a.lat);
|
||||
const lat2 = toRadians(b.lat);
|
||||
const deltaLat = lat2 - lat1;
|
||||
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
|
||||
const haversine = Math.sin(deltaLat / 2) ** 2
|
||||
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
|
||||
return 2 * Math.asin(Math.sqrt(clamp(haversine, 0, 1)));
|
||||
}
|
||||
|
||||
function initialBearingRad(a: Coordinate, b: Coordinate): number {
|
||||
const lat1 = toRadians(a.lat);
|
||||
const lat2 = toRadians(b.lat);
|
||||
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
|
||||
return Math.atan2(
|
||||
Math.sin(deltaLon) * Math.cos(lat2),
|
||||
Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon)
|
||||
);
|
||||
}
|
||||
|
||||
function routeCoordinates(route: RouteResult | GeoJsonLineString | readonly [number, number][]): readonly [number, number][] {
|
||||
if (Array.isArray(route)) {
|
||||
return route as readonly [number, number][];
|
||||
}
|
||||
if ("geometry" in route) {
|
||||
return route.geometry.coordinates;
|
||||
}
|
||||
return (route as GeoJsonLineString).coordinates;
|
||||
}
|
||||
|
||||
function isGeoJsonCoordinate(value: unknown): value is [number, number] {
|
||||
return Array.isArray(value)
|
||||
&& value.length >= 2
|
||||
&& typeof value[0] === "number"
|
||||
&& Number.isFinite(value[0])
|
||||
&& value[0] >= -180
|
||||
&& value[0] <= 180
|
||||
&& typeof value[1] === "number"
|
||||
&& Number.isFinite(value[1])
|
||||
&& value[1] >= -90
|
||||
&& value[1] <= 90;
|
||||
}
|
||||
|
||||
function isCoordinate(value: Coordinate): boolean {
|
||||
return Number.isFinite(value.lat)
|
||||
&& value.lat >= -90
|
||||
&& value.lat <= 90
|
||||
&& Number.isFinite(value.lon)
|
||||
&& value.lon >= -180
|
||||
&& value.lon <= 180;
|
||||
}
|
||||
|
||||
function toCoordinate(value: [number, number]): Coordinate {
|
||||
return { lon: value[0], lat: value[1] };
|
||||
}
|
||||
|
||||
function toRadians(value: number): number {
|
||||
return value * Math.PI / 180;
|
||||
}
|
||||
|
||||
function normalizeRadians(value: number): number {
|
||||
return ((value + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI;
|
||||
}
|
||||
|
||||
function finiteRange(value: number, min: number, max: number): number {
|
||||
return Number.isFinite(value) ? clamp(value, min, max) : min;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import "./styles/app.css";
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
orderWaypointsAlongRoute,
|
||||
type Coordinate,
|
||||
type RouteResult,
|
||||
type VoyageHarbour
|
||||
} from "@watermaps/shared";
|
||||
import type { RouteBridgeAssessment } from "./routeWeatherReport";
|
||||
import type { RouteLock } from "./voyageHarbours";
|
||||
|
||||
export type RouteEventKind = "harbour" | "lock" | "bridge";
|
||||
|
||||
export type RouteEventCorridors = Record<RouteEventKind, number>;
|
||||
|
||||
export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly<RouteEventCorridors> = Object.freeze({
|
||||
harbour: 1.5,
|
||||
lock: 0.25,
|
||||
bridge: 0.08
|
||||
});
|
||||
|
||||
export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed";
|
||||
export type RouteEventEtaReferenceSource = "current-time" | "route-departure";
|
||||
|
||||
/**
|
||||
* ETA assumptions are deliberately supplied by the caller. This prevents a
|
||||
* stale GPS speed or a planned cruise speed from being presented without its
|
||||
* provenance.
|
||||
*/
|
||||
export type RouteEventEtaBasis = {
|
||||
speedKn: number;
|
||||
speedSource: RouteEventEtaSpeedSource;
|
||||
referenceTime: string | number | Date;
|
||||
referenceSource: RouteEventEtaReferenceSource;
|
||||
};
|
||||
|
||||
export type RouteEventEta = {
|
||||
estimatedAt: string;
|
||||
minutesFromProgress: number;
|
||||
speedKn: number;
|
||||
speedSource: RouteEventEtaSpeedSource;
|
||||
referenceTime: string;
|
||||
referenceSource: RouteEventEtaReferenceSource;
|
||||
};
|
||||
|
||||
type RouteEventBase = {
|
||||
kind: RouteEventKind;
|
||||
id: string;
|
||||
name: string;
|
||||
coordinate: Coordinate;
|
||||
/** Position of the projected feature along the routed geometry. */
|
||||
routeDistanceNm: number;
|
||||
/** Shortest lateral distance between the feature and the route. */
|
||||
distanceFromRouteNm: number;
|
||||
remainingNm: number;
|
||||
eta: RouteEventEta | null;
|
||||
};
|
||||
|
||||
export type HarbourRouteEvent = RouteEventBase & {
|
||||
kind: "harbour";
|
||||
feature: VoyageHarbour;
|
||||
};
|
||||
|
||||
export type LockRouteEvent = RouteEventBase & {
|
||||
kind: "lock";
|
||||
feature: RouteLock;
|
||||
};
|
||||
|
||||
export type BridgeRouteEvent = RouteEventBase & {
|
||||
kind: "bridge";
|
||||
feature: RouteBridgeAssessment;
|
||||
};
|
||||
|
||||
export type UpcomingRouteEvent =
|
||||
| HarbourRouteEvent
|
||||
| LockRouteEvent
|
||||
| BridgeRouteEvent;
|
||||
|
||||
export type NextRouteEventsByKind = {
|
||||
harbour: HarbourRouteEvent | null;
|
||||
lock: LockRouteEvent | null;
|
||||
bridge: BridgeRouteEvent | null;
|
||||
};
|
||||
|
||||
export type UpcomingRouteEventsInput = {
|
||||
route: Pick<RouteResult, "geometry" | "distanceNm">;
|
||||
harbours?: readonly VoyageHarbour[];
|
||||
locks?: readonly RouteLock[];
|
||||
bridges?: readonly RouteBridgeAssessment[];
|
||||
/** Progress along the route. Invalid or negative values resolve to zero. */
|
||||
progressNm?: number | null;
|
||||
corridorsNm?: Partial<RouteEventCorridors>;
|
||||
etaBasis?: RouteEventEtaBasis | null;
|
||||
};
|
||||
|
||||
type RouteEventCandidate =
|
||||
| {
|
||||
projectionId: string;
|
||||
kind: "harbour";
|
||||
id: string;
|
||||
name: string;
|
||||
coordinate: Coordinate;
|
||||
feature: VoyageHarbour;
|
||||
}
|
||||
| {
|
||||
projectionId: string;
|
||||
kind: "lock";
|
||||
id: string;
|
||||
name: string;
|
||||
coordinate: Coordinate;
|
||||
feature: RouteLock;
|
||||
}
|
||||
| {
|
||||
projectionId: string;
|
||||
kind: "bridge";
|
||||
id: string;
|
||||
name: string;
|
||||
coordinate: Coordinate;
|
||||
feature: RouteBridgeAssessment;
|
||||
};
|
||||
|
||||
/**
|
||||
* Projects all supplied facilities onto the route, applies a corridor per
|
||||
* facility type and returns only the current or upcoming facilities in route
|
||||
* order.
|
||||
*
|
||||
* Every feature is projected again. In particular,
|
||||
* RouteBridgeAssessment.distanceNm is intentionally ignored because it is the
|
||||
* bridge's lateral distance to the route, not its distance along the route.
|
||||
*/
|
||||
export function upcomingRouteEvents(
|
||||
input: UpcomingRouteEventsInput
|
||||
): UpcomingRouteEvent[] {
|
||||
const progressNm = normalizeProgress(input.progressNm);
|
||||
const corridors = resolveCorridors(input.corridorsNm);
|
||||
const candidates = routeEventCandidates(input);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidatesByProjectionId = new Map(
|
||||
candidates.map((candidate) => [candidate.projectionId, candidate])
|
||||
);
|
||||
const projected = orderWaypointsAlongRoute(
|
||||
candidates.map((candidate) => ({
|
||||
id: candidate.projectionId,
|
||||
name: candidate.name,
|
||||
coordinate: candidate.coordinate
|
||||
})),
|
||||
input.route
|
||||
);
|
||||
|
||||
return projected.flatMap<UpcomingRouteEvent>((projection) => {
|
||||
const candidate = candidatesByProjectionId.get(projection.id);
|
||||
if (
|
||||
!candidate ||
|
||||
projection.distanceFromRouteNm > corridors[candidate.kind] ||
|
||||
projection.routeDistanceNm < progressNm
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const remainingNm = Math.max(0, projection.routeDistanceNm - progressNm);
|
||||
const common = {
|
||||
kind: candidate.kind,
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
coordinate: candidate.coordinate,
|
||||
routeDistanceNm: projection.routeDistanceNm,
|
||||
distanceFromRouteNm: projection.distanceFromRouteNm,
|
||||
remainingNm,
|
||||
eta: estimateRouteEventEta(remainingNm, input.etaBasis)
|
||||
};
|
||||
|
||||
switch (candidate.kind) {
|
||||
case "harbour":
|
||||
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||
case "lock":
|
||||
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||
case "bridge":
|
||||
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function nextRouteEventsByKind(
|
||||
events: readonly UpcomingRouteEvent[]
|
||||
): NextRouteEventsByKind {
|
||||
const next: NextRouteEventsByKind = {
|
||||
harbour: null,
|
||||
lock: null,
|
||||
bridge: null
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.kind) {
|
||||
case "harbour":
|
||||
next.harbour ??= event;
|
||||
break;
|
||||
case "lock":
|
||||
next.lock ??= event;
|
||||
break;
|
||||
case "bridge":
|
||||
next.bridge ??= event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function routeEventCandidates(input: UpcomingRouteEventsInput): RouteEventCandidate[] {
|
||||
const candidates: RouteEventCandidate[] = [];
|
||||
|
||||
input.harbours?.forEach((feature, index) => {
|
||||
candidates.push({
|
||||
projectionId: projectionId("harbour", index, feature.id),
|
||||
kind: "harbour",
|
||||
id: feature.id,
|
||||
name: feature.name,
|
||||
coordinate: feature.coordinate,
|
||||
feature
|
||||
});
|
||||
});
|
||||
input.locks?.forEach((feature, index) => {
|
||||
candidates.push({
|
||||
projectionId: projectionId("lock", index, feature.id),
|
||||
kind: "lock",
|
||||
id: feature.id,
|
||||
name: feature.name,
|
||||
coordinate: feature.coordinate,
|
||||
feature
|
||||
});
|
||||
});
|
||||
input.bridges?.forEach((feature, index) => {
|
||||
candidates.push({
|
||||
projectionId: projectionId("bridge", index, feature.id),
|
||||
kind: "bridge",
|
||||
id: feature.id,
|
||||
name: feature.name ?? feature.label ?? "Brücke",
|
||||
coordinate: feature.coordinate,
|
||||
feature
|
||||
});
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function projectionId(kind: RouteEventKind, index: number, featureId: string) {
|
||||
return `${kind}:${index}:${featureId}`;
|
||||
}
|
||||
|
||||
function resolveCorridors(
|
||||
overrides: Partial<RouteEventCorridors> | undefined
|
||||
): RouteEventCorridors {
|
||||
return {
|
||||
harbour: nonNegativeOrDefault(
|
||||
overrides?.harbour,
|
||||
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour
|
||||
),
|
||||
lock: nonNegativeOrDefault(
|
||||
overrides?.lock,
|
||||
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.lock
|
||||
),
|
||||
bridge: nonNegativeOrDefault(
|
||||
overrides?.bridge,
|
||||
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.bridge
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProgress(value: number | null | undefined) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.max(0, value)
|
||||
: 0;
|
||||
}
|
||||
|
||||
function nonNegativeOrDefault(value: number | undefined, fallback: number) {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function estimateRouteEventEta(
|
||||
remainingNm: number,
|
||||
basis: RouteEventEtaBasis | null | undefined
|
||||
): RouteEventEta | null {
|
||||
if (
|
||||
!basis ||
|
||||
!Number.isFinite(basis.speedKn) ||
|
||||
basis.speedKn <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const referenceTimestamp = timestampValue(basis.referenceTime);
|
||||
if (referenceTimestamp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minutesFromProgress = (remainingNm / basis.speedKn) * 60;
|
||||
const estimatedTimestamp =
|
||||
referenceTimestamp + minutesFromProgress * 60_000;
|
||||
if (!Number.isFinite(estimatedTimestamp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
estimatedAt: new Date(estimatedTimestamp).toISOString(),
|
||||
minutesFromProgress,
|
||||
speedKn: basis.speedKn,
|
||||
speedSource: basis.speedSource,
|
||||
referenceTime: new Date(referenceTimestamp).toISOString(),
|
||||
referenceSource: basis.referenceSource
|
||||
};
|
||||
}
|
||||
|
||||
function timestampValue(value: string | number | Date): number | null {
|
||||
const timestamp =
|
||||
value instanceof Date
|
||||
? value.getTime()
|
||||
: typeof value === "number"
|
||||
? value
|
||||
: Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||
import {
|
||||
haversineDistanceNm,
|
||||
initialBearingDeg,
|
||||
type Coordinate,
|
||||
type MarineForecast,
|
||||
type RouteResult,
|
||||
type VesselProfile
|
||||
} from "@watermaps/shared";
|
||||
|
||||
export type RouteWeatherSample = {
|
||||
label: "Start" | "Mitte" | "Ziel";
|
||||
coordinate: Coordinate;
|
||||
forecast: MarineForecast;
|
||||
plannedTime?: string;
|
||||
routeBearingDeg?: number | null;
|
||||
currentAlongRouteKn?: number | null;
|
||||
};
|
||||
|
||||
export type RouteWeatherReport = {
|
||||
samples: RouteWeatherSample[];
|
||||
maxWaveHeightM: number | null;
|
||||
maxWindSpeedKn: number | null;
|
||||
maxWavePeriodS: number | null;
|
||||
strongestWindDirectionDeg: number | null;
|
||||
highestWaveDirectionDeg: number | null;
|
||||
severity: "ok" | "caution" | "critical";
|
||||
summary: string;
|
||||
source: string;
|
||||
updatedAt: string;
|
||||
unavailableSamples: number;
|
||||
bridgeReport: RouteBridgeReport | null;
|
||||
departureTime: string;
|
||||
adjustedEta: string | null;
|
||||
currentAdjustmentMinutes: number | null;
|
||||
averageAlongRouteCurrentKn: number | null;
|
||||
};
|
||||
|
||||
type FetchForecast = (coordinate: Coordinate, at?: string) => Promise<MarineForecast>;
|
||||
type FetchFeatures = (params: { bbox: [number, number, number, number]; layers: string[] }) => Promise<FeatureCollection>;
|
||||
type LonLat = [number, number];
|
||||
type ProjectedPoint = { x: number; y: number };
|
||||
|
||||
export type RouteBridgeStatus = "passable" | "tight" | "too_low" | "unknown";
|
||||
|
||||
export type RouteBridgeAssessment = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
label: string;
|
||||
coordinate: Coordinate;
|
||||
distanceNm: number;
|
||||
clearanceM: number | null;
|
||||
clearanceLabel: string | null;
|
||||
requiredAirDraftM: number | null;
|
||||
marginM: number | null;
|
||||
status: RouteBridgeStatus;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type RouteBridgeReport = {
|
||||
bridges: RouteBridgeAssessment[];
|
||||
requiredAirDraftM: number | null;
|
||||
checkedCount: number;
|
||||
unknownCount: number;
|
||||
tooLowCount: number;
|
||||
tightCount: number;
|
||||
minClearanceM: number | null;
|
||||
severity: "ok" | "caution" | "critical";
|
||||
summary: string;
|
||||
source: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const SAMPLE_TARGETS: Array<{ label: RouteWeatherSample["label"]; ratio: number }> = [
|
||||
{ label: "Start", ratio: 0 },
|
||||
{ label: "Mitte", ratio: 0.5 },
|
||||
{ label: "Ziel", ratio: 1 }
|
||||
];
|
||||
const ROUTE_BRIDGE_BBOX_MARGIN_DEG = 0.02;
|
||||
const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08;
|
||||
const BRIDGE_TIGHT_MARGIN_M = 0.5;
|
||||
|
||||
export async function createRouteWeatherReport(
|
||||
route: RouteResult,
|
||||
fetchForecast: FetchForecast
|
||||
): Promise<RouteWeatherReport>;
|
||||
export async function createRouteWeatherReport(
|
||||
route: RouteResult,
|
||||
vesselProfile: VesselProfile,
|
||||
fetchForecast: FetchForecast,
|
||||
fetchFeatures?: FetchFeatures,
|
||||
departureTime?: string
|
||||
): Promise<RouteWeatherReport>;
|
||||
export async function createRouteWeatherReport(
|
||||
route: RouteResult,
|
||||
vesselProfileOrFetchForecast: VesselProfile | FetchForecast,
|
||||
maybeFetchForecast?: FetchForecast,
|
||||
fetchFeatures?: FetchFeatures,
|
||||
departureTime?: string
|
||||
): Promise<RouteWeatherReport> {
|
||||
const vesselProfile: VesselProfile =
|
||||
typeof vesselProfileOrFetchForecast === "function"
|
||||
? { draughtM: 0, safetyReserveM: 0 }
|
||||
: vesselProfileOrFetchForecast;
|
||||
const fetchForecast =
|
||||
typeof vesselProfileOrFetchForecast === "function" ? vesselProfileOrFetchForecast : maybeFetchForecast;
|
||||
if (!fetchForecast) {
|
||||
throw new Error("Wetterbericht nicht erreichbar");
|
||||
}
|
||||
|
||||
const plannedDeparture = validIso(departureTime ?? route.departureTime) ?? new Date().toISOString();
|
||||
const cruiseSpeedKn = normalizeSpeed(vesselProfile.cruiseSpeedKn);
|
||||
const samplePoints = sampleRoute(route).map((sample) => ({
|
||||
...sample,
|
||||
plannedTime: new Date(
|
||||
Date.parse(plannedDeparture) + (route.distanceNm * sample.ratio / cruiseSpeedKn) * 60 * 60 * 1000
|
||||
).toISOString()
|
||||
}));
|
||||
const [results, bridgeReport] = await Promise.all([
|
||||
Promise.allSettled(
|
||||
samplePoints.map(async (sample) => ({
|
||||
...sample,
|
||||
forecast: await fetchForecast(sample.coordinate, sample.plannedTime)
|
||||
}))
|
||||
),
|
||||
fetchFeatures
|
||||
? createRouteBridgeReport(route, vesselProfile, fetchFeatures).catch(() => unavailableBridgeReport(vesselProfile))
|
||||
: Promise.resolve(null)
|
||||
]);
|
||||
const samples = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : []));
|
||||
|
||||
if (samples.length === 0) {
|
||||
throw new Error("Wetterbericht nicht erreichbar");
|
||||
}
|
||||
|
||||
const samplesWithCurrent = samples.map((sample) => ({
|
||||
...sample,
|
||||
currentAlongRouteKn: alongRouteCurrentKn(sample.forecast, sample.routeBearingDeg)
|
||||
}));
|
||||
|
||||
return summarizeRouteWeather(samplesWithCurrent, results.length - samples.length, bridgeReport, {
|
||||
departureTime: plannedDeparture,
|
||||
distanceNm: route.distanceNm,
|
||||
cruiseSpeedKn
|
||||
});
|
||||
}
|
||||
|
||||
export async function createRouteBridgeReport(
|
||||
route: RouteResult,
|
||||
vesselProfile: Pick<VesselProfile, "airDraftM">,
|
||||
fetchFeatures: FetchFeatures
|
||||
): Promise<RouteBridgeReport> {
|
||||
const routeLine = routeLonLatLine(route);
|
||||
if (routeLine.length === 0) {
|
||||
return summarizeBridgeReport([], normalizeMeters(vesselProfile.airDraftM));
|
||||
}
|
||||
|
||||
const features = await fetchFeatures({
|
||||
bbox: bboxForLine(routeLine, ROUTE_BRIDGE_BBOX_MARGIN_DEG),
|
||||
layers: ["bridges"]
|
||||
});
|
||||
const requiredAirDraftM = normalizeMeters(vesselProfile.airDraftM);
|
||||
const bridges = features.features
|
||||
.map((feature) => bridgeAssessmentFromFeature(feature, routeLine, requiredAirDraftM))
|
||||
.filter((bridge): bridge is RouteBridgeAssessment => Boolean(bridge))
|
||||
.filter((bridge) => bridge.distanceNm <= ROUTE_BRIDGE_MAX_DISTANCE_NM);
|
||||
const deduped = dedupeBridges(bridges);
|
||||
|
||||
return summarizeBridgeReport(
|
||||
deduped.sort((left, right) => left.distanceNm - right.distanceNm),
|
||||
requiredAirDraftM
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeRouteWeather(
|
||||
samples: RouteWeatherSample[],
|
||||
unavailableSamples = 0,
|
||||
bridgeReport: RouteBridgeReport | null = null,
|
||||
planning?: { departureTime: string; distanceNm: number; cruiseSpeedKn: number }
|
||||
): RouteWeatherReport {
|
||||
const maxWaveHeightM = maxValue(samples.map((sample) => sample.forecast.waveHeightM));
|
||||
const maxWindSpeedKn = maxValue(samples.map((sample) => sample.forecast.windSpeed));
|
||||
const maxWavePeriodS = maxValue(samples.map((sample) => sample.forecast.wavePeriodS));
|
||||
const strongestWind = maxBy(samples, (sample) => sample.forecast.windSpeed);
|
||||
const highestWave = maxBy(samples, (sample) => sample.forecast.waveHeightM);
|
||||
const severity = weatherSeverity(maxWindSpeedKn, maxWaveHeightM);
|
||||
const currentComponents = samples
|
||||
.map((sample) => sample.currentAlongRouteKn)
|
||||
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
const averageAlongRouteCurrentKn =
|
||||
currentComponents.length > 0
|
||||
? currentComponents.reduce((sum, value) => sum + value, 0) / currentComponents.length
|
||||
: null;
|
||||
const currentTiming = currentAdjustedTiming(planning, averageAlongRouteCurrentKn);
|
||||
|
||||
return {
|
||||
samples,
|
||||
maxWaveHeightM,
|
||||
maxWindSpeedKn,
|
||||
maxWavePeriodS,
|
||||
strongestWindDirectionDeg: strongestWind?.forecast.windDirectionDeg ?? null,
|
||||
highestWaveDirectionDeg: highestWave?.forecast.waveDirectionDeg ?? null,
|
||||
severity,
|
||||
summary: weatherSummary(severity, maxWindSpeedKn, maxWaveHeightM),
|
||||
source: unique(samples.map((sample) => sample.forecast.source)).join(", "),
|
||||
updatedAt: latestIso(samples.map((sample) => sample.forecast.updatedAt)) ?? new Date().toISOString(),
|
||||
unavailableSamples,
|
||||
bridgeReport,
|
||||
departureTime: planning?.departureTime ?? new Date().toISOString(),
|
||||
adjustedEta: currentTiming.adjustedEta,
|
||||
currentAdjustmentMinutes: currentTiming.adjustmentMinutes,
|
||||
averageAlongRouteCurrentKn
|
||||
};
|
||||
}
|
||||
|
||||
function sampleRoute(route: RouteResult) {
|
||||
const points = route.geometry.coordinates.map(([lon, lat]) => ({ lat, lon }));
|
||||
const uniqueSamples = new Map<
|
||||
string,
|
||||
{
|
||||
label: RouteWeatherSample["label"];
|
||||
coordinate: Coordinate;
|
||||
ratio: number;
|
||||
routeBearingDeg: number | null;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const target of SAMPLE_TARGETS) {
|
||||
const coordinate = coordinateAtProgress(points, target.ratio);
|
||||
const key = `${coordinate.lat.toFixed(3)}:${coordinate.lon.toFixed(3)}`;
|
||||
uniqueSamples.set(key, {
|
||||
label: target.label,
|
||||
coordinate,
|
||||
ratio: target.ratio,
|
||||
routeBearingDeg: routeBearingAtProgress(points, target.ratio)
|
||||
});
|
||||
}
|
||||
|
||||
return [...uniqueSamples.values()];
|
||||
}
|
||||
|
||||
function routeBearingAtProgress(points: Coordinate[], ratio: number): number | null {
|
||||
if (points.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const before = coordinateAtProgress(points, Math.max(0, ratio - 0.01));
|
||||
const after = coordinateAtProgress(points, Math.min(1, ratio + 0.01));
|
||||
if (haversineDistanceNm(before, after) < 0.001) {
|
||||
return null;
|
||||
}
|
||||
return initialBearingDeg(before, after);
|
||||
}
|
||||
|
||||
function alongRouteCurrentKn(forecast: MarineForecast, routeBearingDeg?: number | null): number | null {
|
||||
const speedKn = forecast.oceanCurrentSpeedKn;
|
||||
const directionDeg = forecast.oceanCurrentDirectionDeg;
|
||||
if (
|
||||
typeof speedKn !== "number" ||
|
||||
!Number.isFinite(speedKn) ||
|
||||
typeof directionDeg !== "number" ||
|
||||
!Number.isFinite(directionDeg) ||
|
||||
typeof routeBearingDeg !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const angleRad = (((directionDeg - routeBearingDeg + 540) % 360) - 180) * (Math.PI / 180);
|
||||
return Math.round(speedKn * Math.cos(angleRad) * 100) / 100;
|
||||
}
|
||||
|
||||
function currentAdjustedTiming(
|
||||
planning: { departureTime: string; distanceNm: number; cruiseSpeedKn: number } | undefined,
|
||||
averageCurrentKn: number | null
|
||||
): { adjustedEta: string | null; adjustmentMinutes: number | null } {
|
||||
if (!planning || averageCurrentKn === null) {
|
||||
return { adjustedEta: null, adjustmentMinutes: null };
|
||||
}
|
||||
const effectiveSpeedKn = Math.max(0.5, planning.cruiseSpeedKn + averageCurrentKn);
|
||||
const baseMinutes = (planning.distanceNm / planning.cruiseSpeedKn) * 60;
|
||||
const adjustedMinutes = (planning.distanceNm / effectiveSpeedKn) * 60;
|
||||
return {
|
||||
adjustedEta: new Date(Date.parse(planning.departureTime) + adjustedMinutes * 60 * 1000).toISOString(),
|
||||
adjustmentMinutes: Math.round(adjustedMinutes - baseMinutes)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSpeed(value: number | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 6;
|
||||
}
|
||||
|
||||
function validIso(value: string | undefined): string | null {
|
||||
const timestamp = value ? Date.parse(value) : Number.NaN;
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
||||
}
|
||||
|
||||
function coordinateAtProgress(points: Coordinate[], ratio: number): Coordinate {
|
||||
if (points.length === 0) {
|
||||
return { lat: 0, lon: 0 };
|
||||
}
|
||||
|
||||
if (ratio <= 0 || points.length === 1) {
|
||||
return points[0]!;
|
||||
}
|
||||
|
||||
if (ratio >= 1) {
|
||||
return points.at(-1)!;
|
||||
}
|
||||
|
||||
const segmentLengths = points.slice(1).map((point, index) => haversineDistanceNm(points[index]!, point));
|
||||
const totalDistanceNm = segmentLengths.reduce((sum, length) => sum + length, 0);
|
||||
const targetDistanceNm = totalDistanceNm * ratio;
|
||||
let traveledNm = 0;
|
||||
|
||||
for (let index = 0; index < segmentLengths.length; index += 1) {
|
||||
const segmentLengthNm = segmentLengths[index]!;
|
||||
if (traveledNm + segmentLengthNm >= targetDistanceNm) {
|
||||
const start = points[index]!;
|
||||
const end = points[index + 1]!;
|
||||
const segmentRatio = segmentLengthNm === 0 ? 0 : (targetDistanceNm - traveledNm) / segmentLengthNm;
|
||||
return {
|
||||
lat: start.lat + (end.lat - start.lat) * segmentRatio,
|
||||
lon: start.lon + (end.lon - start.lon) * segmentRatio
|
||||
};
|
||||
}
|
||||
traveledNm += segmentLengthNm;
|
||||
}
|
||||
|
||||
return points.at(-1)!;
|
||||
}
|
||||
|
||||
function maxValue(values: Array<number | null | undefined>) {
|
||||
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
return valid.length > 0 ? Math.max(...valid) : null;
|
||||
}
|
||||
|
||||
function minValue(values: Array<number | null | undefined>) {
|
||||
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
return valid.length > 0 ? Math.min(...valid) : null;
|
||||
}
|
||||
|
||||
function maxBy<T>(values: T[], selector: (value: T) => number | null | undefined) {
|
||||
return values.reduce<T | null>((best, value) => {
|
||||
const candidate = selector(value);
|
||||
if (candidate === null || candidate === undefined || !Number.isFinite(candidate)) {
|
||||
return best;
|
||||
}
|
||||
|
||||
const bestValue = best ? selector(best) : null;
|
||||
return bestValue === null || bestValue === undefined || candidate > bestValue ? value : best;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function weatherSeverity(windSpeedKn: number | null, waveHeightM: number | null) {
|
||||
if (windSpeedKn === null && waveHeightM === null) {
|
||||
return "caution";
|
||||
}
|
||||
if ((windSpeedKn !== null && windSpeedKn >= 27) || (waveHeightM !== null && waveHeightM >= 2)) {
|
||||
return "critical";
|
||||
}
|
||||
if ((windSpeedKn !== null && windSpeedKn >= 16) || (waveHeightM !== null && waveHeightM >= 1)) {
|
||||
return "caution";
|
||||
}
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function weatherSummary(
|
||||
severity: RouteWeatherReport["severity"],
|
||||
windSpeedKn: number | null,
|
||||
waveHeightM: number | null
|
||||
) {
|
||||
if (windSpeedKn === null && waveHeightM === null) {
|
||||
return "Keine belastbare Wetter- oder Wellenprognose für die gewählte Abfahrtszeit.";
|
||||
}
|
||||
const wind = windSpeedKn !== null ? `${Math.round(windSpeedKn)} kn Wind` : "Wind unbekannt";
|
||||
const wave = waveHeightM !== null ? `${waveHeightM.toFixed(1)} m Welle` : "Welle unbekannt";
|
||||
|
||||
if (severity === "critical") {
|
||||
return `Kritische Bedingungen: bis ${wind}, ${wave}.`;
|
||||
}
|
||||
if (severity === "caution") {
|
||||
return `Aufmerksam fahren: bis ${wind}, ${wave}.`;
|
||||
}
|
||||
return `Ruhige Bedingungen: bis ${wind}, ${wave}.`;
|
||||
}
|
||||
|
||||
function latestIso(values: string[]) {
|
||||
const timestamps = values.map((value) => Date.parse(value)).filter((value) => Number.isFinite(value));
|
||||
return timestamps.length > 0 ? new Date(Math.max(...timestamps)).toISOString() : null;
|
||||
}
|
||||
|
||||
function unique(values: string[]) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function routeLonLatLine(route: RouteResult): LonLat[] {
|
||||
return route.geometry.coordinates.map(([lon, lat]) => [lon, lat]);
|
||||
}
|
||||
|
||||
function bboxForLine(line: LonLat[], marginDeg: number): [number, number, number, number] {
|
||||
const lons = line.map(([lon]) => lon);
|
||||
const lats = line.map(([, lat]) => lat);
|
||||
|
||||
return [
|
||||
Math.min(...lons) - marginDeg,
|
||||
Math.min(...lats) - marginDeg,
|
||||
Math.max(...lons) + marginDeg,
|
||||
Math.max(...lats) + marginDeg
|
||||
];
|
||||
}
|
||||
|
||||
function bridgeAssessmentFromFeature(
|
||||
feature: Feature,
|
||||
routeLine: LonLat[],
|
||||
requiredAirDraftM: number | null
|
||||
): RouteBridgeAssessment | null {
|
||||
if (!feature.geometry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bridgeLines = geometryLineStrings(feature.geometry);
|
||||
if (bridgeLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const distanceNm = minDistanceBetweenLinesNm(bridgeLines, routeLine);
|
||||
if (!Number.isFinite(distanceNm)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const coordinate = centroid(bridgeLines.flat());
|
||||
if (!coordinate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const properties = (feature.properties ?? {}) as Record<string, unknown>;
|
||||
const clearanceM = normalizeMeters(properties.clearance_m);
|
||||
const name = stringProperty(properties.name);
|
||||
const clearanceLabel = stringProperty(properties.clearance_label) ?? (clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null);
|
||||
const label = stringProperty(properties.label) ?? name ?? clearanceLabel ?? "Brücke";
|
||||
const status = bridgeStatus(clearanceM, requiredAirDraftM);
|
||||
const marginM = clearanceM !== null && requiredAirDraftM !== null ? clearanceM - requiredAirDraftM : null;
|
||||
const idCandidate = feature.id ?? properties.source_id ?? `${coordinate.lat.toFixed(5)}:${coordinate.lon.toFixed(5)}`;
|
||||
|
||||
return {
|
||||
id: String(idCandidate),
|
||||
name,
|
||||
label,
|
||||
coordinate,
|
||||
distanceNm,
|
||||
clearanceM,
|
||||
clearanceLabel,
|
||||
requiredAirDraftM,
|
||||
marginM,
|
||||
status,
|
||||
source: stringProperty(properties.source) ?? "OSM/Geofabrik"
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeBridgeReport(
|
||||
bridges: RouteBridgeAssessment[],
|
||||
requiredAirDraftM: number | null
|
||||
): RouteBridgeReport {
|
||||
const knownClearanceBridges = bridges.filter((bridge) => bridge.clearanceM !== null);
|
||||
const unknownCount = bridges.length - knownClearanceBridges.length;
|
||||
const checkedCount = requiredAirDraftM === null ? 0 : knownClearanceBridges.length;
|
||||
const tooLowCount = bridges.filter((bridge) => bridge.status === "too_low").length;
|
||||
const tightCount = bridges.filter((bridge) => bridge.status === "tight").length;
|
||||
const minKnownClearanceM = minValue(knownClearanceBridges.map((bridge) => bridge.clearanceM));
|
||||
const severity = bridgeSeverity(bridges);
|
||||
|
||||
return {
|
||||
bridges,
|
||||
requiredAirDraftM,
|
||||
checkedCount,
|
||||
unknownCount,
|
||||
tooLowCount,
|
||||
tightCount,
|
||||
minClearanceM: minKnownClearanceM,
|
||||
severity,
|
||||
summary: bridgeSummary({
|
||||
bridgeCount: bridges.length,
|
||||
unknownCount,
|
||||
tooLowCount,
|
||||
tightCount,
|
||||
minClearanceM: minKnownClearanceM,
|
||||
requiredAirDraftM
|
||||
}),
|
||||
source: unique(bridges.map((bridge) => bridge.source)).join(", ") || "OSM/Geofabrik",
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableBridgeReport(vesselProfile: Pick<VesselProfile, "airDraftM">): RouteBridgeReport {
|
||||
return {
|
||||
bridges: [],
|
||||
requiredAirDraftM: normalizeMeters(vesselProfile.airDraftM),
|
||||
checkedCount: 0,
|
||||
unknownCount: 0,
|
||||
tooLowCount: 0,
|
||||
tightCount: 0,
|
||||
minClearanceM: null,
|
||||
severity: "caution",
|
||||
summary: "Brückenprüfung nicht erreichbar. Durchfahrtshöhen vor Abfahrt extern prüfen.",
|
||||
source: "OSM/Geofabrik",
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeStatus(clearanceM: number | null, requiredAirDraftM: number | null): RouteBridgeStatus {
|
||||
if (clearanceM === null || requiredAirDraftM === null) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const marginM = clearanceM - requiredAirDraftM;
|
||||
if (marginM < 0) {
|
||||
return "too_low";
|
||||
}
|
||||
if (marginM < BRIDGE_TIGHT_MARGIN_M) {
|
||||
return "tight";
|
||||
}
|
||||
return "passable";
|
||||
}
|
||||
|
||||
function bridgeSeverity(bridges: RouteBridgeAssessment[]): RouteBridgeReport["severity"] {
|
||||
if (bridges.some((bridge) => bridge.status === "too_low")) {
|
||||
return "critical";
|
||||
}
|
||||
if (bridges.some((bridge) => bridge.status === "tight" || bridge.status === "unknown")) {
|
||||
return "caution";
|
||||
}
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function bridgeSummary({
|
||||
bridgeCount,
|
||||
unknownCount,
|
||||
tooLowCount,
|
||||
tightCount,
|
||||
minClearanceM,
|
||||
requiredAirDraftM
|
||||
}: {
|
||||
bridgeCount: number;
|
||||
unknownCount: number;
|
||||
tooLowCount: number;
|
||||
tightCount: number;
|
||||
minClearanceM: number | null;
|
||||
requiredAirDraftM: number | null;
|
||||
}) {
|
||||
if (bridgeCount === 0) {
|
||||
return "Keine Brücken im Routenkorridor erkannt.";
|
||||
}
|
||||
|
||||
if (requiredAirDraftM === null) {
|
||||
return `${bridgeCount} Brücken erkannt. Bootshöhe fehlt, Durchfahrt nicht bewertbar.`;
|
||||
}
|
||||
|
||||
if (tooLowCount > 0) {
|
||||
return `Nicht passierbar: ${tooLowCount} Brücke(n) niedriger als ${formatMeters(requiredAirDraftM)} Bootshöhe.`;
|
||||
}
|
||||
|
||||
if (tightCount > 0) {
|
||||
return `Knapp: ${tightCount} Brücke(n) mit weniger als ${formatMeters(BRIDGE_TIGHT_MARGIN_M)} Reserve.`;
|
||||
}
|
||||
|
||||
if (unknownCount > 0) {
|
||||
return `${unknownCount} Brücke(n) ohne Höhenangabe. Durchfahrt vor Abfahrt prüfen.`;
|
||||
}
|
||||
|
||||
return `Brücken passierbar: ${bridgeCount} Brücke(n), min. ${formatMeters(minClearanceM ?? requiredAirDraftM)} Durchfahrt.`;
|
||||
}
|
||||
|
||||
function dedupeBridges(bridges: RouteBridgeAssessment[]) {
|
||||
const byId = new Map<string, RouteBridgeAssessment>();
|
||||
for (const bridge of bridges) {
|
||||
const existing = byId.get(bridge.id);
|
||||
if (!existing || bridge.distanceNm < existing.distanceNm) {
|
||||
byId.set(bridge.id, bridge);
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
function geometryLineStrings(geometry: Geometry): LonLat[][] {
|
||||
switch (geometry.type) {
|
||||
case "Point":
|
||||
return [singlePositionLine(geometry.coordinates)].filter((line) => line.length > 0);
|
||||
case "MultiPoint":
|
||||
return geometry.coordinates.map(singlePositionLine).filter((line) => line.length > 0);
|
||||
case "LineString":
|
||||
return [positionsToLine(geometry.coordinates)].filter((line) => line.length > 0);
|
||||
case "MultiLineString":
|
||||
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
|
||||
case "Polygon":
|
||||
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
|
||||
case "MultiPolygon":
|
||||
return geometry.coordinates.flat().map(positionsToLine).filter((line) => line.length > 0);
|
||||
case "GeometryCollection":
|
||||
return geometry.geometries.flatMap(geometryLineStrings);
|
||||
}
|
||||
}
|
||||
|
||||
function singlePositionLine(position: Position): LonLat[] {
|
||||
const coordinate = positionToLonLat(position);
|
||||
return coordinate ? [coordinate] : [];
|
||||
}
|
||||
|
||||
function positionsToLine(positions: Position[]): LonLat[] {
|
||||
return positions.map(positionToLonLat).filter((coordinate): coordinate is LonLat => Boolean(coordinate));
|
||||
}
|
||||
|
||||
function positionToLonLat(position: Position): LonLat | null {
|
||||
const [lon, lat] = position;
|
||||
return typeof lon === "number" && typeof lat === "number" && Number.isFinite(lon) && Number.isFinite(lat)
|
||||
? [lon, lat]
|
||||
: null;
|
||||
}
|
||||
|
||||
function minDistanceBetweenLinesNm(featureLines: LonLat[][], routeLine: LonLat[]) {
|
||||
if (routeLine.length === 0) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
const origin = routeLine[0]!;
|
||||
let minDistanceNm = Number.POSITIVE_INFINITY;
|
||||
for (const featureLine of featureLines) {
|
||||
if (featureLine.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (featureLine.length === 1) {
|
||||
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(featureLine[0]!, routeLine, origin));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (routeLine.length === 1) {
|
||||
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(routeLine[0]!, featureLine, origin));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let featureIndex = 1; featureIndex < featureLine.length; featureIndex += 1) {
|
||||
const featureStart = featureLine[featureIndex - 1]!;
|
||||
const featureEnd = featureLine[featureIndex]!;
|
||||
for (let routeIndex = 1; routeIndex < routeLine.length; routeIndex += 1) {
|
||||
minDistanceNm = Math.min(
|
||||
minDistanceNm,
|
||||
segmentDistanceNm(featureStart, featureEnd, routeLine[routeIndex - 1]!, routeLine[routeIndex]!, origin)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return minDistanceNm;
|
||||
}
|
||||
|
||||
function minPointToLineDistanceNm(point: LonLat, line: LonLat[], origin: LonLat) {
|
||||
if (line.length === 0) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
if (line.length === 1) {
|
||||
return distanceBetweenProjected(project(point, origin), project(line[0]!, origin));
|
||||
}
|
||||
|
||||
let minDistanceNm = Number.POSITIVE_INFINITY;
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
minDistanceNm = Math.min(
|
||||
minDistanceNm,
|
||||
pointToSegmentDistance(project(point, origin), project(line[index - 1]!, origin), project(line[index]!, origin))
|
||||
);
|
||||
}
|
||||
return minDistanceNm;
|
||||
}
|
||||
|
||||
function segmentDistanceNm(startA: LonLat, endA: LonLat, startB: LonLat, endB: LonLat, origin: LonLat) {
|
||||
const a = project(startA, origin);
|
||||
const b = project(endA, origin);
|
||||
const c = project(startB, origin);
|
||||
const d = project(endB, origin);
|
||||
|
||||
if (segmentsIntersect(a, b, c, d)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
pointToSegmentDistance(a, c, d),
|
||||
pointToSegmentDistance(b, c, d),
|
||||
pointToSegmentDistance(c, a, b),
|
||||
pointToSegmentDistance(d, a, b)
|
||||
);
|
||||
}
|
||||
|
||||
function project([lon, lat]: LonLat, [originLon, originLat]: LonLat): ProjectedPoint {
|
||||
const averageLatRad = ((lat + originLat) / 2) * (Math.PI / 180);
|
||||
return {
|
||||
x: (lon - originLon) * 60 * Math.cos(averageLatRad),
|
||||
y: (lat - originLat) * 60
|
||||
};
|
||||
}
|
||||
|
||||
function segmentsIntersect(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint, d: ProjectedPoint) {
|
||||
const o1 = orientation(a, b, c);
|
||||
const o2 = orientation(a, b, d);
|
||||
const o3 = orientation(c, d, a);
|
||||
const o4 = orientation(c, d, b);
|
||||
|
||||
if (o1 !== o2 && o3 !== o4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(o1 === 0 && onSegment(a, c, b)) ||
|
||||
(o2 === 0 && onSegment(a, d, b)) ||
|
||||
(o3 === 0 && onSegment(c, a, d)) ||
|
||||
(o4 === 0 && onSegment(c, b, d))
|
||||
);
|
||||
}
|
||||
|
||||
function orientation(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
|
||||
const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
|
||||
if (Math.abs(value) < 1e-9) {
|
||||
return 0;
|
||||
}
|
||||
return value > 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
function onSegment(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
|
||||
return (
|
||||
b.x <= Math.max(a.x, c.x) + 1e-9 &&
|
||||
b.x >= Math.min(a.x, c.x) - 1e-9 &&
|
||||
b.y <= Math.max(a.y, c.y) + 1e-9 &&
|
||||
b.y >= Math.min(a.y, c.y) - 1e-9
|
||||
);
|
||||
}
|
||||
|
||||
function pointToSegmentDistance(point: ProjectedPoint, start: ProjectedPoint, end: ProjectedPoint) {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
if (dx === 0 && dy === 0) {
|
||||
return distanceBetweenProjected(point, start);
|
||||
}
|
||||
|
||||
const ratio = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
|
||||
return distanceBetweenProjected(point, {
|
||||
x: start.x + ratio * dx,
|
||||
y: start.y + ratio * dy
|
||||
});
|
||||
}
|
||||
|
||||
function distanceBetweenProjected(left: ProjectedPoint, right: ProjectedPoint) {
|
||||
return Math.hypot(left.x - right.x, left.y - right.y);
|
||||
}
|
||||
|
||||
function centroid(coordinates: LonLat[]): Coordinate | null {
|
||||
if (coordinates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sum = coordinates.reduce(
|
||||
(total, [lon, lat]) => ({
|
||||
lon: total.lon + lon,
|
||||
lat: total.lat + lat
|
||||
}),
|
||||
{ lon: 0, lat: 0 }
|
||||
);
|
||||
|
||||
return {
|
||||
lon: sum.lon / coordinates.length,
|
||||
lat: sum.lat / coordinates.length
|
||||
};
|
||||
}
|
||||
|
||||
function stringProperty(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizeMeters(value: unknown) {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = value.replace(",", ".").match(/\d+(?:\.\d+)?/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(match[0]);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function formatMeters(value: number) {
|
||||
return Number.isInteger(value) ? `${value} m` : `${value.toFixed(1)} m`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
harbourAmenitiesFromProperties,
|
||||
orderWaypointsAlongRoute,
|
||||
type RouteResult,
|
||||
type VoyageHarbour
|
||||
} from "@watermaps/shared";
|
||||
import type { FeatureCollection, GeoJsonProperties, Geometry } from "geojson";
|
||||
|
||||
export type RouteLock = {
|
||||
id: string;
|
||||
name: string;
|
||||
coordinate: { lat: number; lon: number };
|
||||
routeDistanceNm: number;
|
||||
distanceFromRouteNm: number;
|
||||
openingHours: string | null;
|
||||
phone: string | null;
|
||||
email?: string | null;
|
||||
vhf: string | null;
|
||||
website: string | null;
|
||||
operator?: string | null;
|
||||
address?: string | null;
|
||||
source?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
/** Converts the normalized harbour layer returned by /api/features for planning. */
|
||||
export function voyageHarboursFromFeatures(
|
||||
collection: FeatureCollection<Geometry, GeoJsonProperties>
|
||||
): VoyageHarbour[] {
|
||||
const harbours = new Map<string, VoyageHarbour>();
|
||||
|
||||
for (const feature of collection.features) {
|
||||
if (feature.geometry?.type !== "Point" || feature.properties?.layer !== "harbours") {
|
||||
continue;
|
||||
}
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
if (typeof lon !== "number" || typeof lat !== "number" || !Number.isFinite(lon) || !Number.isFinite(lat)) {
|
||||
continue;
|
||||
}
|
||||
const properties = feature.properties;
|
||||
const id = String(feature.id ?? properties.sourceId ?? properties.source_id ?? `${lat}:${lon}`);
|
||||
const seamarkType = stringValue(properties["seamark:type"]);
|
||||
const kind =
|
||||
stringValue(properties.leisure) === "marina" || seamarkType === "marina"
|
||||
? "marina"
|
||||
: "harbour";
|
||||
|
||||
harbours.set(id, {
|
||||
id,
|
||||
name: displayString(properties.name) ?? (kind === "marina" ? "Marina" : "Hafen"),
|
||||
coordinate: { lat, lon },
|
||||
kind,
|
||||
amenities: harbourAmenitiesFromProperties(properties),
|
||||
phone: firstString(properties, ["contact:phone", "phone", "telephone", "contact_phone"]),
|
||||
website: firstString(properties, ["contact:website", "website", "url", "contact_website"]),
|
||||
email: firstString(properties, ["contact:email", "email", "contact_email"]),
|
||||
vhf: firstString(properties, [
|
||||
"vhf",
|
||||
"vhf_channel",
|
||||
"radio_channel",
|
||||
"contact:vhf",
|
||||
"seamark:harbour:radio_channel"
|
||||
]),
|
||||
openingHours: firstString(properties, ["openingHours", "opening_hours", "service_times"]),
|
||||
operator: firstString(properties, ["operator", "operator:name", "owner"]),
|
||||
address: featureAddress(properties),
|
||||
source: firstString(properties, ["source", "data_source", "attribution"]),
|
||||
sourceUrl: firstString(properties, ["sourceUrl", "source_url", "enrichmentSourceUrl"]),
|
||||
updatedAt: firstString(properties, [
|
||||
"updatedAt",
|
||||
"updated_at",
|
||||
"fetchedAt",
|
||||
"fetched_at",
|
||||
"timestamp",
|
||||
"@timestamp"
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
return [...harbours.values()];
|
||||
}
|
||||
|
||||
/** Returns lock points that are close enough to plausibly lie on the routed waterway. */
|
||||
export function routeLocksFromFeatures(
|
||||
collection: FeatureCollection<Geometry, GeoJsonProperties>,
|
||||
route: Pick<RouteResult, "geometry" | "distanceNm">,
|
||||
maxDistanceFromRouteNm = 0.25
|
||||
): RouteLock[] {
|
||||
const details = new Map<
|
||||
string,
|
||||
Omit<RouteLock, "routeDistanceNm" | "distanceFromRouteNm">
|
||||
>();
|
||||
|
||||
for (const feature of collection.features) {
|
||||
if (feature.geometry?.type !== "Point" || feature.properties?.layer !== "locks") {
|
||||
continue;
|
||||
}
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
if (typeof lon !== "number" || typeof lat !== "number" || !Number.isFinite(lon) || !Number.isFinite(lat)) {
|
||||
continue;
|
||||
}
|
||||
const properties = feature.properties;
|
||||
const id = String(feature.id ?? properties.sourceId ?? properties.source_id ?? `${lat}:${lon}`);
|
||||
details.set(id, {
|
||||
id,
|
||||
name: displayString(properties.name) ?? "Schleuse",
|
||||
coordinate: { lat, lon },
|
||||
openingHours: displayString(properties.openingHours) ?? displayString(properties.opening_hours),
|
||||
phone: firstString(properties, ["contact:phone", "phone", "telephone", "contact_phone"]),
|
||||
email: firstString(properties, ["contact:email", "email", "contact_email"]),
|
||||
vhf: firstString(properties, [
|
||||
"vhf",
|
||||
"vhf_channel",
|
||||
"radio_channel",
|
||||
"contact:vhf",
|
||||
"seamark:radio_station:channel"
|
||||
]),
|
||||
website: firstString(properties, ["contact:website", "website", "url", "contact_website"]),
|
||||
operator: firstString(properties, ["operator", "operator:name", "owner"]),
|
||||
address: featureAddress(properties),
|
||||
source: firstString(properties, ["source", "data_source", "attribution"]),
|
||||
sourceUrl: firstString(properties, ["sourceUrl", "source_url", "enrichmentSourceUrl"]),
|
||||
updatedAt: firstString(properties, [
|
||||
"updatedAt",
|
||||
"updated_at",
|
||||
"fetchedAt",
|
||||
"fetched_at",
|
||||
"timestamp",
|
||||
"@timestamp"
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = orderWaypointsAlongRoute(
|
||||
[...details.values()].map(({ id, name, coordinate }) => ({ id, name, coordinate })),
|
||||
route
|
||||
);
|
||||
return ordered
|
||||
.filter((lock) => lock.distanceFromRouteNm <= maxDistanceFromRouteNm)
|
||||
.map((lock) => ({ ...details.get(lock.id)!, routeDistanceNm: lock.routeDistanceNm, distanceFromRouteNm: lock.distanceFromRouteNm }));
|
||||
}
|
||||
|
||||
/** Returns a padded request box that covers every point of a routed line. */
|
||||
export function routeFeatureBounds(
|
||||
route: Pick<RouteResult, "geometry">,
|
||||
paddingNm = 2
|
||||
): [number, number, number, number] {
|
||||
if (!Number.isFinite(paddingNm) || paddingNm < 0) {
|
||||
throw new RangeError("paddingNm must be a non-negative finite number");
|
||||
}
|
||||
const coordinates = route.geometry.coordinates;
|
||||
if (coordinates.length === 0) {
|
||||
throw new RangeError("route.geometry must contain coordinates");
|
||||
}
|
||||
|
||||
let minLon = Infinity;
|
||||
let minLat = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
let maxLat = -Infinity;
|
||||
for (const [lon, lat] of coordinates) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
|
||||
throw new RangeError("route.geometry must contain finite coordinates");
|
||||
}
|
||||
minLon = Math.min(minLon, lon);
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLon = Math.max(maxLon, lon);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
}
|
||||
|
||||
const latitudePadding = paddingNm / 60;
|
||||
const highestAbsoluteLatitude = Math.min(89, Math.max(Math.abs(minLat), Math.abs(maxLat)));
|
||||
const longitudePadding = paddingNm / (60 * Math.cos(highestAbsoluteLatitude * (Math.PI / 180)));
|
||||
return [
|
||||
Math.max(-180, minLon - longitudePadding),
|
||||
Math.max(-90, minLat - latitudePadding),
|
||||
Math.min(180, maxLon + longitudePadding),
|
||||
Math.min(90, maxLat + latitudePadding)
|
||||
];
|
||||
}
|
||||
|
||||
function displayString(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return displayString(value)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function firstString(properties: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = displayString(properties[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function featureAddress(properties: Record<string, unknown>) {
|
||||
const fullAddress = firstString(properties, ["contact:address", "addr:full", "address"]);
|
||||
if (fullAddress) {
|
||||
return fullAddress;
|
||||
}
|
||||
const streetLine = [
|
||||
firstString(properties, ["addr:street"]),
|
||||
firstString(properties, ["addr:housenumber"])
|
||||
].filter(Boolean).join(" ");
|
||||
const cityLine = [
|
||||
firstString(properties, ["addr:postcode"]),
|
||||
firstString(properties, ["addr:city", "addr:place"])
|
||||
].filter(Boolean).join(" ");
|
||||
return [streetLine, cityLine].filter(Boolean).join(", ") || null;
|
||||
}
|
||||
Reference in New Issue
Block a user