Files
watermaps/apps/web/src/components/RoutePlanner.tsx
T
BuTzZ 55ce94066e
Test and publish container images / test (push) Successful in 2m20s
Test and publish container images / publish (push) Failing after 4s
Bootssymbol hinzugefügt
2026-07-26 12:31:49 +02:00

1111 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AlertTriangle,
ArrowDown,
ArrowUp,
ChevronDown,
Clock3,
CloudSun,
Landmark,
LocateFixed,
MapPin,
Plus,
Navigation,
Route,
ShieldAlert,
ShipWheel,
Waves,
Wind,
X,
Trash2
} from "lucide-react";
import { lazy, useEffect, useMemo, useRef, useState } from "react";
import {
buildVoyagePlan,
VOYAGE_AMENITIES,
voyageAmenityLabel,
type BoatProfile,
type Coordinate,
type NavigationDataSnapshot,
type RouteResult,
type VesselProfile,
type VoyageAmenity,
type VoyageHarbour
} from "@watermaps/shared";
import type { RouteWeatherReport } from "../routeWeatherReport";
import type { OfflineVoyage } from "../lib/offline-route";
import {
boatCategoryLabel,
DEFAULT_BOAT_PROFILE,
toVesselProfile
} from "../lib/boat-profile";
import type { RouteLock } from "../voyageHarbours";
import { LazyContent } from "./LazyContent";
import { NavigationDataPanel } from "./NavigationDataPanel";
import { RouteTidePanel, type RouteTidePlan } from "./RouteTidePanel";
import { VoyagePlan } from "./VoyagePlan";
type RoutePlannerProps = {
startPoint: Coordinate | null;
gpsPosition: Coordinate | null;
destination: Coordinate | null;
waypoints?: Coordinate[];
result: RouteResult | null;
routeOptions: RouteResult[];
weatherReport: RouteWeatherReport | null;
weatherLoading: boolean;
weatherError: string | null;
navigationData?: NavigationDataSnapshot | null;
navigationDataLoading?: boolean;
navigationDataError?: string | null;
routeHarbours?: VoyageHarbour[];
routeHarboursLoading?: boolean;
routeHarboursError?: string | null;
routeLocks?: RouteLock[];
routeTides?: RouteTidePlan | null;
routeTidesLoading?: boolean;
routeTidesError?: string | null;
boatProfile?: BoatProfile;
routeVesselProfile?: VesselProfile | null;
loading: boolean;
error: string | null;
pickMode: "start" | "destination" | "waypoint" | null;
onSubmit: (request: {
start: Coordinate;
destination: Coordinate;
waypoints: Coordinate[];
departureTime: string;
}) => void;
onPickStart: () => void;
onPickDestination: () => void;
onPickWaypoint?: () => void;
onRemoveWaypoint?: (index: number) => void;
onMoveWaypoint?: (index: number, direction: -1 | 1) => void;
onLoadOfflineVoyage?: (voyage: OfflineVoyage) => void;
onClearStart: () => void;
onClearDestination: () => void;
onUseGpsAsStart: () => void;
onSelectRoute: (routeId: string) => void;
guidanceActive?: boolean;
onStartGuidance?: () => void;
onEditBoat?: () => void;
operationalPanelsVisible?: boolean;
embedded?: boolean;
onCollapse: () => void;
};
type RouteSheetState = "compact" | "half" | "full";
const LazyVoyageNavigationTools = lazy(() =>
import("./VoyageNavigationTools").then((module) => ({
default: module.VoyageNavigationTools
}))
);
export function RoutePlanner({
startPoint,
gpsPosition,
destination,
waypoints = [],
result,
routeOptions,
weatherReport,
weatherLoading,
weatherError,
navigationData = null,
navigationDataLoading = false,
navigationDataError = null,
routeHarbours = [],
routeHarboursLoading = false,
routeHarboursError = null,
routeLocks = [],
routeTides = null,
routeTidesLoading = false,
routeTidesError = null,
boatProfile = DEFAULT_BOAT_PROFILE,
routeVesselProfile = null,
loading,
error,
pickMode,
onSubmit,
onPickStart,
onPickDestination,
onPickWaypoint = () => undefined,
onRemoveWaypoint = () => undefined,
onMoveWaypoint = () => undefined,
onLoadOfflineVoyage,
onClearStart,
onClearDestination,
onUseGpsAsStart,
onSelectRoute,
guidanceActive = false,
onStartGuidance = () => undefined,
onEditBoat = () => undefined,
operationalPanelsVisible = true,
embedded = false,
onCollapse
}: RoutePlannerProps) {
const [departureTime, setDepartureTime] = useState(() => localDateTimeValue(new Date()));
const [maxCruisingHoursPerDay, setMaxCruisingHoursPerDay] = useState(8);
const [requiredAmenities, setRequiredAmenities] = useState<VoyageAmenity[]>(["overnight"]);
const [lockDelayMinutes, setLockDelayMinutes] = useState(20);
const [plannerView, setPlannerView] = useState<"input" | "result">(() => (result ? "result" : "input"));
const [routeOptionsOpen, setRouteOptionsOpen] = useState(
() => isRouteOptionsInitiallyOpen(pickMode, waypoints)
);
const [voyageToolsRequested, setVoyageToolsRequested] = useState(false);
const [sheetState, setSheetState] = useState<RouteSheetState>("half");
const previousResult = useRef(result);
const vesselProfile = useMemo(() => toVesselProfile(boatProfile), [boatProfile]);
const planningVesselProfile = result && routeVesselProfile
? routeVesselProfile
: vesselProfile;
const speedKn = planningVesselProfile.cruiseSpeedKn ?? boatProfile.cruiseSpeedKn;
const routeProfileDiffers = Boolean(
result &&
routeVesselProfile &&
!sameVesselProfile(routeVesselProfile, vesselProfile)
);
const canRoute = Boolean(startPoint && destination);
const isPickingStart = pickMode === "start";
const isPickingDestination = pickMode === "destination";
const isPickingWaypoint = pickMode === "waypoint";
const severity = useMemo(() => {
if (!result) {
return "idle";
}
if (result.warnings.some((warning) => warning.severity === "critical")) {
return "critical";
}
if (result.warnings.some((warning) => warning.severity === "caution")) {
return "caution";
}
return "ok";
}, [result]);
const voyagePlan = useMemo(() => {
if (!result) {
return null;
}
return buildVoyagePlan({
route: result,
cruiseSpeedKn: speedKn,
maxCruisingHoursPerDay,
harbours: routeHarbours,
waypoints: waypoints.map((coordinate, index) => ({
id: `waypoint-${index + 1}`,
name: `Zwischenziel ${index + 1}`,
coordinate
})),
requiredAmenities,
maxHarbourDetourNm: 2.5
});
}, [maxCruisingHoursPerDay, requiredAmenities, result, routeHarbours, speedKn, waypoints]);
const operationalEta = useMemo(() => {
const baseEta = weatherReport?.adjustedEta ?? result?.eta;
const timestamp = baseEta ? Date.parse(baseEta) : Number.NaN;
if (!Number.isFinite(timestamp) || routeLocks.length === 0) {
return null;
}
return new Date(timestamp + routeLocks.length * lockDelayMinutes * 60_000).toISOString();
}, [lockDelayMinutes, result?.eta, routeLocks.length, weatherReport?.adjustedEta]);
const criticalWarnings = result?.warnings.filter((warning) => warning.severity === "critical") ?? [];
const otherWarnings = result?.warnings.filter((warning) => warning.severity !== "critical") ?? [];
useEffect(() => {
if (!result) {
setPlannerView("input");
} else if (result !== previousResult.current) {
setPlannerView("result");
setSheetState("half");
}
previousResult.current = result;
}, [result]);
useEffect(() => {
if (isPickingWaypoint) {
setRouteOptionsOpen(true);
}
}, [isPickingWaypoint]);
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return;
}
const desktopLayout = window.matchMedia("(min-width: 720px)");
const resetMobileSheetState = () => {
if (desktopLayout.matches) {
setSheetState("half");
}
};
resetMobileSheetState();
if (typeof desktopLayout.addEventListener === "function") {
desktopLayout.addEventListener("change", resetMobileSheetState);
return () => desktopLayout.removeEventListener("change", resetMobileSheetState);
}
desktopLayout.addListener(resetMobileSheetState);
return () => desktopLayout.removeListener(resetMobileSheetState);
}, []);
const sheetSizeAction = routeSheetSizeAction(sheetState);
return (
<aside
className="route-panel"
aria-label="Routenplanung"
aria-busy={loading}
data-sheet-state={sheetState}
data-embedded={embedded}
>
{!embedded && (
<>
<span className="route-panel-grip" aria-hidden="true">
</span>
<div className="route-panel-header">
<div className="route-panel-title">
<Route size={18} aria-hidden="true" />
<strong>{plannerView === "result" ? "Routenergebnis" : "Route planen"}</strong>
</div>
{plannerView === "input" && result && (
<button
className="secondary-action compact-action"
type="button"
onClick={() => {
setPlannerView("result");
setSheetState("half");
}}
>
Zur Route
</button>
)}
<button
className="icon-action route-panel-size-action"
type="button"
onClick={() => setSheetState(sheetSizeAction.nextState)}
title={sheetSizeAction.label}
aria-label={sheetSizeAction.label}
>
{sheetSizeAction.direction === "up" ? (
<ArrowUp size={16} aria-hidden="true" />
) : (
<ArrowDown size={16} aria-hidden="true" />
)}
</button>
<button
className="icon-action"
type="button"
onClick={onCollapse}
title="Routenfenster ausblenden"
aria-label="Routenfenster ausblenden"
>
<ChevronDown size={16} aria-hidden="true" />
</button>
</div>
</>
)}
{embedded && plannerView === "input" && result && (
<button
className="secondary-action route-return-action"
type="button"
onClick={() => setPlannerView("result")}
>
Zur berechneten Route
</button>
)}
<div
className="route-panel-body"
hidden={!embedded && sheetState === "compact"}
style={!embedded && sheetState === "compact" ? undefined : { display: "contents" }}
>
<small className="route-data-disclaimer">
<ShieldAlert size={14} aria-hidden="true" />
Planungshilfe · nicht amtlich
</small>
{plannerView === "input" && (
<>
<div className="route-point-actions">
<button
className="secondary-action"
type="button"
onClick={onPickStart}
title="Start auf Karte setzen"
aria-label="Start auf Karte setzen"
data-active={isPickingStart}
aria-pressed={isPickingStart}
>
<Navigation size={16} aria-hidden="true" />
<span>{isPickingStart ? "Karte anklicken" : startPoint ? "Start ändern" : "Start setzen"}</span>
</button>
<button
className="secondary-action compact-action"
type="button"
onClick={onUseGpsAsStart}
title="GPS als Start setzen"
aria-label="GPS als Start setzen"
disabled={!gpsPosition}
>
<LocateFixed size={16} aria-hidden="true" />
<span>GPS</span>
</button>
{startPoint && (
<button
className="icon-action"
type="button"
onClick={onClearStart}
title="Start löschen"
aria-label="Start löschen"
>
<X size={16} aria-hidden="true" />
</button>
)}
</div>
<div className="route-point-actions">
<button
className="secondary-action"
type="button"
onClick={onPickDestination}
title="Ziel auf Karte setzen"
aria-label="Ziel auf Karte setzen"
data-active={isPickingDestination}
aria-pressed={isPickingDestination}
>
<MapPin size={16} aria-hidden="true" />
<span>{isPickingDestination ? "Karte anklicken" : destination ? "Ziel ändern" : "Ziel setzen"}</span>
</button>
{destination && (
<button
className="icon-action"
type="button"
onClick={onClearDestination}
title="Ziel löschen"
aria-label="Ziel löschen"
>
<X size={16} aria-hidden="true" />
</button>
)}
</div>
<div className="route-point-status" data-ready={canRoute}>
<span title={startPoint ? formatCoordinate(startPoint) : "Kein Start gesetzt"}>
<strong>Start</strong>{" "}
{startPoint ? formatCoordinate(startPoint) : "setzen"}
</span>
<span title={destination ? formatCoordinate(destination) : "Kein Ziel gesetzt"}>
<strong>Ziel</strong>{" "}
{destination ? formatCoordinate(destination) : "setzen"}
</span>
</div>
<details
className="voyage-tools-disclosure"
open={routeOptionsOpen}
onToggle={(event) => setRouteOptionsOpen(event.currentTarget.open)}
>
<summary>Routenoptionen: Zwischenziele</summary>
<section className="waypoint-editor" aria-label="Zwischenziele">
<div className="waypoint-editor-heading">
<strong>Zwischenziele</strong>
<button
className="secondary-action compact-action"
type="button"
onClick={onPickWaypoint}
data-active={isPickingWaypoint}
aria-pressed={isPickingWaypoint}
aria-label="Zwischenziel auf der Karte hinzufügen"
>
<Plus size={15} aria-hidden="true" />
<span>{isPickingWaypoint ? "Karte anklicken" : "Hinzufügen"}</span>
</button>
</div>
{waypoints.length === 0 ? (
<p className="waypoint-empty">Optional für Wunschkanäle, Schleusen oder Etappenhäfen.</p>
) : (
<ol className="waypoint-list">
{waypoints.map((waypoint, index) => (
<li key={`${waypoint.lat}:${waypoint.lon}:${index}`}>
<span>
<strong>Z{index + 1}</strong> {formatCoordinate(waypoint)}
</span>
<div>
<button
className="icon-action"
type="button"
onClick={() => onMoveWaypoint(index, -1)}
disabled={index === 0}
aria-label={`Zwischenziel ${index + 1} nach oben verschieben`}
>
<ArrowUp size={14} aria-hidden="true" />
</button>
<button
className="icon-action"
type="button"
onClick={() => onMoveWaypoint(index, 1)}
disabled={index === waypoints.length - 1}
aria-label={`Zwischenziel ${index + 1} nach unten verschieben`}
>
<ArrowDown size={14} aria-hidden="true" />
</button>
<button
className="icon-action"
type="button"
onClick={() => onRemoveWaypoint(index)}
aria-label={`Zwischenziel ${index + 1} entfernen`}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
</li>
))}
</ol>
)}
</section>
</details>
<section
className="current-boat-summary"
aria-label={`Aktuelles Boot: ${boatProfile.name}`}
data-route-profile-differs={routeProfileDiffers}
>
<span className="current-boat-summary-icon">
<ShipWheel size={18} aria-hidden="true" />
</span>
<span>
<strong>{boatProfile.name}</strong>
<small>
{boatCategoryLabel(boatProfile.category)} · {formatProfileMeters(boatProfile.lengthM)}
{" × "}
{formatProfileMeters(boatProfile.beamM)} · Tiefgang{" "}
{formatProfileMeters(boatProfile.draughtM)}
</small>
{routeProfileDiffers && (
<em role="status">
Diese geladene Route wurde mit einem anderen Bootsprofil berechnet.
</em>
)}
</span>
<button className="secondary-action compact-action" type="button" onClick={onEditBoat}>
Bearbeiten
</button>
</section>
<label className="departure-planning">
<span>
<Clock3 size={14} aria-hidden="true" />
Abfahrt
</span>
<input
type="datetime-local"
value={departureTime}
onChange={(event) => setDepartureTime(event.target.value)}
/>
</label>
<button
className="primary-action"
type="button"
disabled={!canRoute || loading}
onClick={() => {
if (!startPoint || !destination) {
return;
}
onSubmit({
start: startPoint,
destination,
waypoints,
departureTime: toIsoOrNow(departureTime)
});
}}
>
<ShipWheel size={16} aria-hidden="true" />
{loading ? "Route wird berechnet" : "Route berechnen"}
</button>
<div className="route-result" data-severity="idle">
<span>
{pickMode
? "Karte anklicken"
: canRoute
? "Route bereit zur Prüfung"
: "Start und Ziel setzen"}
</span>
</div>
{error && (
<div className="warning-list" role="alert">
<strong>Route nicht berechenbar</strong>
<p>
<AlertTriangle size={14} aria-hidden="true" />
{error}
</p>
</div>
)}
</>
)}
{plannerView === "result" && result && (
<>
<div className="route-result" data-severity={severity}>
<span>
<strong>{result.name ?? "Route"}</strong> · {result.distanceNm.toFixed(1)} sm
</span>
<span>
{result.routingMode === "fairway"
? "Fahrwasser"
: result.unknownDepthRatio > 0
? `${Math.round(result.unknownDepthRatio * 100)}% unbekannt`
: "Tiefe OK"}
</span>
{(weatherReport?.adjustedEta ?? result.eta) && (
<span>
{weatherReport?.adjustedEta
? `Strömungs-${formatEta(weatherReport.adjustedEta)}`
: formatEta(result.eta!)}
</span>
)}
</div>
{routeProfileDiffers && (
<section className="warning-list" aria-label="Abweichendes Bootsprofil" role="alert">
<strong>Route gehört zu einem anderen Bootsprofil</strong>
<p>
<AlertTriangle size={14} aria-hidden="true" />
Die geladene Offline-Route behält ihre damaligen Maße. Für {boatProfile.name} muss
sie mit dem aktuellen Bootsprofil neu berechnet werden.
</p>
</section>
)}
{(error || criticalWarnings.length > 0) && (
<section
className="warning-list route-priority-warnings"
aria-label="Kritische Hinweise"
role="alert"
>
<strong>Kritische Hinweise</strong>
{error && (
<p>
<AlertTriangle size={14} aria-hidden="true" />
{error}
</p>
)}
{criticalWarnings.map((warning, index) => (
<p key={`${warning.code}-${index}`}>
<AlertTriangle size={14} aria-hidden="true" />
{warning.message}
</p>
))}
</section>
)}
{otherWarnings.length > 0 && (
<section className="warning-list" aria-label="Weitere Hinweise">
<strong>Weitere Hinweise</strong>
{otherWarnings.slice(0, 3).map((warning, index) => (
<p key={`${warning.code}-${index}`}>
<AlertTriangle size={14} aria-hidden="true" />
{warning.message}
</p>
))}
{otherWarnings.length > 3 && (
<details className="voyage-tools-disclosure">
<summary>{otherWarnings.length - 3} weitere Hinweise</summary>
<div className="warning-list">
{otherWarnings.slice(3).map((warning, index) => (
<p key={`${warning.code}-${index + 3}`}>
<AlertTriangle size={14} aria-hidden="true" />
{warning.message}
</p>
))}
</div>
</details>
)}
</section>
)}
{routeOptions.length > 1 && (
<section className="route-options" aria-label="Routenalternativen">
{routeOptions.map((option, index) => (
<button
key={option.id ?? `${index}-${option.distanceNm}`}
type="button"
data-active={option.id ? option.id === result?.id : index === 0}
aria-pressed={option.id ? option.id === result?.id : index === 0}
onClick={() => option.id && onSelectRoute(option.id)}
>
<strong>{option.name ?? (index === 0 ? "Hauptroute" : `Alternative ${index}`)}</strong>
<span>{option.distanceNm.toFixed(1)} sm</span>
<span>{formatTravelTime(option.distanceNm, speedKn)}</span>
</button>
))}
</section>
)}
<button
className="secondary-action"
type="button"
onClick={() => setPlannerView("input")}
>
Plan ändern
</button>
{result && (
<section className="course-assistant-launch" aria-label="Navigation starten">
<span>
<Navigation size={17} aria-hidden="true" />
<span>
<strong>Navigation mit Kursassistent</strong>
<small>Dynamischer Sollkurs entlang der geplanten Route</small>
</span>
</span>
<button
type="button"
onClick={onStartGuidance}
disabled={guidanceActive}
data-active={guidanceActive}
aria-label={guidanceActive ? "Navigation läuft" : "Navigation starten"}
>
{guidanceActive ? "Läuft" : "Navigation starten"}
</button>
</section>
)}
<details className="voyage-tools-disclosure">
<summary>Amtliche Fahrtdaten: Pegel · Meldungen</summary>
<NavigationDataPanel
snapshot={navigationData}
loading={navigationDataLoading}
error={navigationDataError}
/>
{operationalPanelsVisible && (
<RouteTidePanel plan={routeTides} loading={routeTidesLoading} error={routeTidesError} />
)}
</details>
{result && routeLocks.length > 0 && (
<details className="voyage-tools-disclosure">
<summary>Schleusenplanung · {routeLocks.length} auf der Route</summary>
<section className="route-lock-plan" aria-label="Schleusenplanung">
<header>
<strong>{routeLocks.length} Schleusen auf der Route</strong>
<label>
Pauschale
<span>
<input
type="number"
min={0}
max={240}
step={5}
value={lockDelayMinutes}
onChange={(event) => setLockDelayMinutes(clampLockDelay(Number(event.target.value)))}
/>
min/Schleuse
</span>
</label>
</header>
{operationalEta && (
<p>
Plan-ETA inkl. Schleusenpuffer: <strong>{formatDateTime(operationalEta)}</strong>
</p>
)}
<ol>
{routeLocks.slice(0, 6).map((lock) => (
<li key={lock.id}>
<span>
<strong>{lock.name}</strong>
{lock.openingHours ?? "Betriebszeit nicht hinterlegt"}
</span>
<span>
{lock.phone ? <a href={telephoneHref(lock.phone)}>Anrufen</a> : null}
{lock.vhf ? ` UKW ${lock.vhf}` : null}
</span>
</li>
))}
</ol>
<small>
Der Puffer ist eine eigene Planannahme, keine Live-Wartezeit. Aktuelle Abweichungen über ELWIS prüfen.
</small>
</section>
</details>
)}
{result && (
<details className="voyage-tools-disclosure">
<summary>Etappen und Häfen</summary>
<section className="voyage-planning-controls" aria-label="Etappeneinstellungen">
<div className="voyage-planning-heading">
<strong>Etappen</strong>
<label>
Fahrt pro Tag
<span>
<input
type="number"
min={1}
max={24}
step={0.5}
value={maxCruisingHoursPerDay}
onChange={(event) => setMaxCruisingHoursPerDay(clampDailyHours(Number(event.target.value)))}
/>
h
</span>
</label>
</div>
<fieldset>
<legend>Am Etappenhafen benötigt</legend>
{VOYAGE_AMENITIES.map((amenity) => (
<label key={amenity}>
<input
type="checkbox"
checked={requiredAmenities.includes(amenity)}
onChange={() => setRequiredAmenities((current) => toggleAmenity(current, amenity))}
/>
{voyageAmenityLabel(amenity)}
</label>
))}
</fieldset>
{routeHarboursLoading && (
<p className="voyage-planning-status">Häfen entlang der Route werden geladen.</p>
)}
{routeHarboursError && <p className="voyage-planning-status">{routeHarboursError}</p>}
{!routeHarboursLoading && !routeHarboursError && (
<p className="voyage-planning-status">
{routeHarbours.length} Hafen-/Marina-Kandidaten geprüft.
</p>
)}
</section>
<VoyagePlan plan={voyagePlan} />
</details>
)}
{operationalPanelsVisible && (weatherLoading || weatherReport || weatherError) && (
<section className="route-weather-report" aria-label="Fahrtbericht">
<div className="route-weather-heading">
<CloudSun size={16} aria-hidden="true" />
<strong>Fahrtbericht</strong>
{weatherReport && <span>{formatUpdatedAt(weatherReport.updatedAt)}</span>}
</div>
{weatherLoading && <p className="route-weather-message">Wetterbericht wird geladen</p>}
{weatherReport && (
<>
<div className="route-weather-summary" data-severity={weatherReport.severity}>
{weatherReport.summary}
</div>
<div className="route-weather-metrics">
<span>
<Wind size={14} aria-hidden="true" />
{formatKn(weatherReport.maxWindSpeedKn)}
{formatDirection(weatherReport.strongestWindDirectionDeg)}
</span>
<span>
<Waves size={14} aria-hidden="true" />
{formatMeters(weatherReport.maxWaveHeightM)}
{formatDirection(weatherReport.highestWaveDirectionDeg)}
</span>
<span>{formatSeconds(weatherReport.maxWavePeriodS)}</span>
{typeof weatherReport.averageAlongRouteCurrentKn === "number" && (
<span>
Strom {weatherReport.averageAlongRouteCurrentKn >= 0 ? "+" : ""}
{weatherReport.averageAlongRouteCurrentKn.toFixed(1)} kn
</span>
)}
</div>
{weatherReport.adjustedEta && (
<p className="route-weather-message">
Strömungs-ETA {formatDateTime(weatherReport.adjustedEta)}
{weatherReport.currentAdjustmentMinutes !== null &&
` (${formatSignedMinutes(weatherReport.currentAdjustmentMinutes)})`}
</p>
)}
<div className="route-weather-samples">
{weatherReport.samples.map((sample) => (
<div key={sample.label}>
<strong>
{sample.label}
{sample.plannedTime ? ` ${formatClock(sample.plannedTime)}` : ""}
</strong>
<span>{formatKn(sample.forecast.windSpeed)}</span>
<span>{formatMeters(sample.forecast.waveHeightM)}</span>
<span>{weatherText(sample.forecast.weatherCode)}</span>
</div>
))}
</div>
{weatherReport.unavailableSamples > 0 && (
<p className="route-weather-message">
{weatherReport.unavailableSamples} Messpunkt nicht erreichbar
</p>
)}
{weatherReport.bridgeReport && (
<div className="route-bridge-report" data-severity={weatherReport.bridgeReport.severity}>
<div className="route-bridge-summary">
<Landmark size={14} aria-hidden="true" />
<span>{weatherReport.bridgeReport.summary}</span>
</div>
<div className="route-bridge-metrics">
<span>Boot {formatMeters(weatherReport.bridgeReport.requiredAirDraftM)}</span>
<span>Min {formatMeters(weatherReport.bridgeReport.minClearanceM)}</span>
<span>
{weatherReport.bridgeReport.checkedCount}/{weatherReport.bridgeReport.bridges.length} geprüft
</span>
</div>
{weatherReport.bridgeReport.bridges.length > 0 && (
<div className="route-bridge-list">
{weatherReport.bridgeReport.bridges.slice(0, 4).map((bridge) => (
<div key={bridge.id} data-status={bridge.status}>
<strong>{bridge.name ?? "Brücke"}</strong>
<span>{bridge.clearanceLabel ?? "H unbekannt"}</span>
<span>{formatBridgeMargin(bridge.marginM)}</span>
</div>
))}
{weatherReport.bridgeReport.bridges.length > 4 && (
<p>+{weatherReport.bridgeReport.bridges.length - 4} weitere Brücken</p>
)}
</div>
)}
</div>
)}
</>
)}
{weatherError && !weatherLoading && (
<p className="route-weather-message">
<AlertTriangle size={14} aria-hidden="true" />
{weatherError}
</p>
)}
</section>
)}
</>
)}
<details
className="voyage-tools-disclosure"
onToggle={(event) => {
if (event.currentTarget.open) {
setVoyageToolsRequested(true);
}
}}
>
<summary>Unterwegs: GPX · Offline · Kursalarm</summary>
{voyageToolsRequested && (
<LazyContent
pending={<p className="lazy-inline-state" role="status">Fahrtwerkzeuge werden geladen </p>}
failed={
<p className="lazy-inline-state" role="alert">
Fahrtwerkzeuge konnten nicht geladen werden. App bitte neu laden.
</p>
}
>
<LazyVoyageNavigationTools
route={result}
courseAssistantActive={guidanceActive}
plan={
startPoint && destination
? {
start: startPoint,
destination,
waypoints,
vesselProfile: planningVesselProfile,
departureAt: result?.departureTime ?? toIsoOrNow(departureTime)
}
: undefined
}
onLoadOfflineVoyage={onLoadOfflineVoyage}
/>
</LazyContent>
)}
</details>
</div>
</aside>
);
}
function formatCoordinate(coordinate: Coordinate) {
return `${coordinate.lat.toFixed(4)}, ${coordinate.lon.toFixed(4)}`;
}
function isRouteOptionsInitiallyOpen(
pickMode: RoutePlannerProps["pickMode"],
waypoints: Coordinate[]
) {
return pickMode === "waypoint" || waypoints.length > 0;
}
function routeSheetSizeAction(state: RouteSheetState): {
nextState: RouteSheetState;
label: string;
direction: "up" | "down";
} {
if (state === "compact") {
return {
nextState: "half",
label: "Routenfenster auf halbe Höhe vergrößern",
direction: "up"
};
}
if (state === "half") {
return {
nextState: "full",
label: "Routenfenster auf volle Höhe vergrößern",
direction: "up"
};
}
return {
nextState: "compact",
label: "Routenfenster auf kompakte Höhe verkleinern",
direction: "down"
};
}
function formatKn(value: number | null | undefined) {
return typeof value === "number" ? `${Math.round(value)} kn` : "-- kn";
}
function formatMeters(value: number | null | undefined) {
return typeof value === "number" ? `${value.toFixed(1)} m` : "-- m";
}
function formatProfileMeters(value: number) {
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`;
}
function sameVesselProfile(left: VesselProfile, right: VesselProfile) {
return (
left.draughtM === right.draughtM &&
left.safetyReserveM === right.safetyReserveM &&
left.airDraftM === right.airDraftM &&
left.beamM === right.beamM &&
left.cruiseSpeedKn === right.cruiseSpeedKn
);
}
function formatBridgeMargin(value: number | null | undefined) {
if (typeof value !== "number") {
return "offen";
}
if (value < 0) {
return `${Math.abs(value).toFixed(1)} m zu niedrig`;
}
return `+${value.toFixed(1)} m`;
}
function formatSeconds(value: number | null | undefined) {
return typeof value === "number" ? `${Math.round(value)} s Periode` : "-- s Periode";
}
function formatDirection(value: number | null | undefined) {
return typeof value === "number" ? ` ${Math.round(value)}°` : "";
}
function formatUpdatedAt(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
: "";
}
function formatEta(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? `ETA ${date.toLocaleString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit"
})}`
: "";
}
function formatDateTime(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", { weekday: "short", hour: "2-digit", minute: "2-digit" })
: "offen";
}
function formatClock(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
: "";
}
function formatSignedMinutes(value: number) {
if (value === 0) {
return "±0 min";
}
return `${value > 0 ? "+" : ""}${value} min`;
}
function formatTravelTime(distanceNm: number, speedKn: number) {
if (!Number.isFinite(speedKn) || speedKn <= 0) {
return "Dauer offen";
}
const minutes = Math.round((distanceNm / speedKn) * 60);
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return hours > 0 ? `${hours} h ${remainder.toString().padStart(2, "0")} min` : `${remainder} min`;
}
function weatherText(code: number | null | undefined) {
if (code === null || code === undefined) {
return "Wetter offen";
}
if (code <= 1) {
return "klar";
}
if (code <= 3) {
return "bewölkt";
}
if (code < 60) {
return "Sicht prüfen";
}
if (code < 80) {
return "Regen";
}
if (code < 95) {
return "Schauer";
}
return "Gewitter";
}
function localDateTimeValue(date: Date) {
const offsetMs = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}
function toIsoOrNow(value: string) {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : new Date().toISOString();
}
function clampDailyHours(value: number) {
if (!Number.isFinite(value)) {
return 8;
}
return Math.min(24, Math.max(1, Math.round(value * 2) / 2));
}
function toggleAmenity(current: VoyageAmenity[], amenity: VoyageAmenity) {
return current.includes(amenity)
? current.filter((candidate) => candidate !== amenity)
: [...current, amenity];
}
function clampLockDelay(value: number) {
if (!Number.isFinite(value)) {
return 20;
}
return Math.min(240, Math.max(0, Math.round(value / 5) * 5));
}
function telephoneHref(value: string) {
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
return `tel:${compact}`;
}