1111 lines
37 KiB
TypeScript
1111 lines
37 KiB
TypeScript
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}`;
|
||
}
|