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(["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("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 ( ); } 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}`; }