Bootssymbol hinzugefügt
Test and publish container images / test (push) Successful in 2m20s
Test and publish container images / publish (push) Failing after 4s

This commit is contained in:
BuTzZ
2026-07-26 12:31:49 +02:00
parent c9a20aacd7
commit 55ce94066e
19 changed files with 1969 additions and 122 deletions
+25 -2
View File
@@ -5,6 +5,7 @@ iPhone-taugliche Browser-PWA für Bootsfahrer: Karte, GPS, Kompass, Wetter/Welle
## Funktionen ## Funktionen
- Routing auf See- und Binnenwasserstraßen statt Luftlinien - Routing auf See- und Binnenwasserstraßen statt Luftlinien
- zentrales „Aktuelles Boot“ mit Name, Kategorie, Abmessungen, Tiefgang, Reisegeschwindigkeit und Anker-Bugrollenhöhe als Browser-Cookie
- explizit gestarteter Kursassistent mit dynamischem Sollkurs über Grund, Querabweichung, Reststrecke und Kurswechsel-Hinweisen - explizit gestarteter Kursassistent mit dynamischem Sollkurs über Grund, Querabweichung, Reststrecke und Kurswechsel-Hinweisen
- zweistufige Ankerwache mit festem Ankerpunkt, GPS-Schwojkreis, Alarm, Tidenanstieg und nachvollziehbarer Ketten-/Leinenreserve - zweistufige Ankerwache mit festem Ankerpunkt, GPS-Schwojkreis, Alarm, Tidenanstieg und nachvollziehbarer Ketten-/Leinenreserve
- bis zu drei ausreichend unterschiedliche Routenvorschläge, sofern der Wasserstraßengraph echte Alternativen enthält - bis zu drei ausreichend unterschiedliche Routenvorschläge, sofern der Wasserstraßengraph echte Alternativen enthält
@@ -20,6 +21,21 @@ iPhone-taugliche Browser-PWA für Bootsfahrer: Karte, GPS, Kompass, Wetter/Welle
- GPX-1.1-Export, lokal gespeicherte Offline-Routen und ein nur auf Nutzeraktion gestarteter Kursabweichungsalarm - GPX-1.1-Export, lokal gespeicherte Offline-Routen und ein nur auf Nutzeraktion gestarteter Kursabweichungsalarm
- begrenztes Offline-Caching bereits besuchter Kartenressourcen ohne ungefragten Kacheldownload - begrenztes Offline-Caching bereits besuchter Kartenressourcen ohne ungefragten Kacheldownload
## Aktuelles Boot
Über das Bootssymbol in der Navigationsleiste wird ein zentrales Bootsprofil
gepflegt. Routenplanung, Tiefen- und Brückenprüfung, Fahrtdauer,
Etappenplanung sowie die bootsabhängigen Werte der Ankerwache verwenden dieses
Profil automatisch. Eine Änderung verwirft eine bereits berechnete Route, weil
deren Sicherheitsprüfung noch auf den alten Maßen beruht.
Das versionierte Profil wird für ein Jahr im Cookie
`watermaps.vessel-profile` dieses Browsers gespeichert. Es enthält nur
Bootsname, Kategorie und technische Fahrtwerte; persönliche Daten, MMSI,
Rufzeichen oder Kontaktdaten gehören nicht hinein. Alte Offline-Routen behalten
ihren damaligen Routingprofil-Snapshot und überschreiben das aktuelle Boot
nicht.
## Start ## Start
Der reguläre lokale Betrieb läuft als einzelner Produktionscontainer. Der Der reguläre lokale Betrieb läuft als einzelner Produktionscontainer. Der
@@ -169,8 +185,15 @@ Der Kursassistent steuert weder Ruder noch Maschine und gibt keine NMEA-Kommando
Die Ankerwache ist unabhängig von einer geplanten Route über das Ankersymbol rechts auf der Karte erreichbar. Sie startet nie automatisch: Die Ankerwache ist unabhängig von einer geplanten Route über das Ankersymbol rechts auf der Karte erreichbar. Sie startet nie automatisch:
1. Live-GPS starten und genau beim Erreichen des Grundes „Anker gefallen Position jetzt setzen“ wählen. Der Fix muss jünger als zehn Sekunden und auf höchstens 30 m genau sein. Der gespeicherte Ankerpunkt bleibt danach fest. 1. Live-GPS starten und genau beim Erreichen des Grundes „Anker gefallen Position jetzt setzen“ wählen. Der Fix muss jünger als zehn Sekunden und auf höchstens 30 m genau sein. Der gespeicherte Ankerpunkt bleibt danach fest.
2. Tiefe beim Setzen, Höhe der Bugrolle über Wasser, ausgesteckte Ketten-/Leinenlänge, gewünschtes Verhältnis, zusätzliche Wasserstandsreserve, Alarmradius und Tidenzeitraum prüfen. 2. Die Höhe der Bugrolle und die Bootslänge werden aus „Mein Boot“ übernommen.
3. Erst „Wache starten“ schaltet Positionsalarm, Warnton, Vibration, optionale Browser-Mitteilungen und soweit unterstützt eine Bildschirm-Wachhalteanforderung ein. Tiefe beim Setzen, ausgesteckte Ketten-/Leinenlänge, gewünschtes Verhältnis,
zusätzliche Wasserstandsreserve, Alarmradius und Tidenzeitraum bleiben
situative Angaben und müssen geprüft werden.
3. Erst „Wache starten“ schaltet Positionsalarm, Vibration, optionale
Browser-Mitteilungen und soweit unterstützt eine
Bildschirm-Wachhalteanforderung ein. Bei sicher festgestelltem Abdriften
außerhalb des Radius ertönt der Warnton fortlaufend, bis er ausdrücklich
quittiert wird; die optische Warnung bleibt danach aktiv.
Kursassistent, separater Kursalarm und Ankerwache laufen nicht parallel. Beim Öffnen der Ankerfunktion wird eine laufende Kursführung beendet; die geplante Route selbst bleibt erhalten. Kursassistent, separater Kursalarm und Ankerwache laufen nicht parallel. Beim Öffnen der Ankerfunktion wird eine laufende Kursführung beendet; die geplante Route selbst bleibt erhalten.
+77 -11
View File
@@ -1,8 +1,9 @@
import { AlertTriangle, Compass, Navigation, ShieldAlert } from "lucide-react"; import { AlertTriangle, Compass, Navigation, ShieldAlert, Ship } from "lucide-react";
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
calculateRouteGuidance, calculateRouteGuidance,
haversineDistanceNm, haversineDistanceNm,
type BoatProfile,
type VoyageHarbour, type VoyageHarbour,
type AppConfig, type AppConfig,
type Coordinate, type Coordinate,
@@ -37,10 +38,12 @@ import type { RouteTidePlan } from "./components/RouteTidePanel";
import { StatusBar } from "./components/StatusBar"; import { StatusBar } from "./components/StatusBar";
import { useCompass } from "./hooks/useCompass"; import { useCompass } from "./hooks/useCompass";
import { useAnchorWatch } from "./hooks/useAnchorWatch"; import { useAnchorWatch } from "./hooks/useAnchorWatch";
import { useBoatProfile } from "./hooks/useBoatProfile";
import { useCourseAssistant } from "./hooks/useCourseAssistant"; import { useCourseAssistant } from "./hooks/useCourseAssistant";
import { useGeolocation } from "./hooks/useGeolocation"; import { useGeolocation } from "./hooks/useGeolocation";
import { useMarineData } from "./hooks/useMarineData"; import { useMarineData } from "./hooks/useMarineData";
import type { OfflineVoyage } from "./lib/offline-route"; import type { OfflineVoyage } from "./lib/offline-route";
import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile";
import { import {
upcomingRouteEvents, upcomingRouteEvents,
type RouteEventEtaBasis, type RouteEventEtaBasis,
@@ -61,6 +64,11 @@ const LazyAnchorWatchPanel = lazy(() =>
default: module.AnchorWatchPanel default: module.AnchorWatchPanel
})) }))
); );
const LazyBoatProfilePanel = lazy(() =>
import("./components/BoatProfilePanel").then((module) => ({
default: module.BoatProfilePanel
}))
);
const LazyCourseAssistantPanel = lazy(() => const LazyCourseAssistantPanel = lazy(() =>
import("./components/CourseAssistantPanel").then((module) => ({ import("./components/CourseAssistantPanel").then((module) => ({
default: module.CourseAssistantPanel default: module.CourseAssistantPanel
@@ -93,7 +101,15 @@ export function App() {
const gps = useGeolocation(); const gps = useGeolocation();
const compass = useCompass(gps.courseDeg); const compass = useCompass(gps.courseDeg);
const marineData = useMarineData(gps.position); const marineData = useMarineData(gps.position);
const anchorWatch = useAnchorWatch(gps); const {
profile: currentBoat,
stored: boatProfileStored,
save: persistBoatProfile,
remove: removeBoatProfile
} = useBoatProfile();
const anchorWatch = useAnchorWatch(gps, {
bowRollerHeightM: currentBoat.bowRollerHeightM
});
const [activeTool, setActiveTool] = useState<ActiveTool>("route"); const [activeTool, setActiveTool] = useState<ActiveTool>("route");
const [workspaceSheetState, setWorkspaceSheetState] = const [workspaceSheetState, setWorkspaceSheetState] =
useState<NavigationSheetState>("half"); useState<NavigationSheetState>("half");
@@ -450,6 +466,7 @@ export function App() {
); );
const toolStatuses = useMemo<Partial<Record<NavigationToolId, NavigationToolStatus>>>( const toolStatuses = useMemo<Partial<Record<NavigationToolId, NavigationToolStatus>>>(
() => ({ () => ({
boat: boatProfileStored ? "active" : "caution",
anchor: anchorAlarm anchor: anchorAlarm
? "alarm" ? "alarm"
: anchorWatch.phase === "idle" : anchorWatch.phase === "idle"
@@ -481,6 +498,7 @@ export function App() {
[ [
anchorAlarm, anchorAlarm,
anchorWatch.phase, anchorWatch.phase,
boatProfileStored,
courseAssistant.active, courseAssistant.active,
guidanceAlarm, guidanceAlarm,
eventWarningCount, eventWarningCount,
@@ -527,14 +545,12 @@ export function App() {
start, start,
destination: requestedDestination, destination: requestedDestination,
waypoints: requestedWaypoints, waypoints: requestedWaypoints,
departureTime, departureTime
vesselProfile
}: { }: {
start: Coordinate; start: Coordinate;
destination: Coordinate; destination: Coordinate;
waypoints: Coordinate[]; waypoints: Coordinate[];
departureTime: string; departureTime: string;
vesselProfile: VesselProfile;
}) => { }) => {
if (!start) { if (!start) {
setRouteError("Startpunkt fehlt"); setRouteError("Startpunkt fehlt");
@@ -552,6 +568,7 @@ export function App() {
routeWeatherRequestId.current += 1; routeWeatherRequestId.current += 1;
const requestId = routeRequestId.current + 1; const requestId = routeRequestId.current + 1;
routeRequestId.current = requestId; routeRequestId.current = requestId;
const vesselProfile = toVesselProfile(currentBoat);
try { try {
const result = await createRoute({ const result = await createRoute({
@@ -581,7 +598,7 @@ export function App() {
} }
} }
}, },
[loadRouteWeather] [currentBoat, loadRouteWeather]
); );
const clearRouteOutcome = useCallback(() => { const clearRouteOutcome = useCallback(() => {
@@ -597,6 +614,20 @@ export function App() {
setRouteLoading(false); setRouteLoading(false);
}, []); }, []);
const saveCurrentBoat = useCallback((profile: BoatProfile) => {
const stored = persistBoatProfile(profile);
clearRouteOutcome();
return stored;
}, [clearRouteOutcome, persistBoatProfile]);
const deleteCurrentBoat = useCallback(() => {
const removed = removeBoatProfile();
if (removed) {
clearRouteOutcome();
}
return removed;
}, [clearRouteOutcome, removeBoatProfile]);
const setRouteStartPoint = useCallback((coordinate: Coordinate) => { const setRouteStartPoint = useCallback((coordinate: Coordinate) => {
setRouteStart(coordinate); setRouteStart(coordinate);
clearRouteOutcome(); clearRouteOutcome();
@@ -761,7 +792,9 @@ export function App() {
? "GPS wird gestartet" ? "GPS wird gestartet"
: "GPS starten"; : "GPS starten";
const workspaceSummary = const workspaceSummary =
activeTool === "anchor" activeTool === "boat"
? boatWorkspaceSummary(currentBoat, boatProfileStored)
: activeTool === "anchor"
? anchorWorkspaceSummary(anchorWatch) ? anchorWorkspaceSummary(anchorWatch)
: activeTool === "conditions" : activeTool === "conditions"
? conditionsWorkspaceSummary(marineData.forecast, marineData.tide) ? conditionsWorkspaceSummary(marineData.forecast, marineData.tide)
@@ -865,6 +898,7 @@ export function App() {
onSelect={selectTool} onSelect={selectTool}
statuses={toolStatuses} statuses={toolStatuses}
badges={{ badges={{
boat: boatProfileStored ? null : "!",
anchor: anchorAlarm ? "!" : null, anchor: anchorAlarm ? "!" : null,
upcoming: eventWarningCount, upcoming: eventWarningCount,
route: guidanceAlarm ? "!" : routeWarningCount route: guidanceAlarm ? "!" : routeWarningCount
@@ -875,11 +909,27 @@ export function App() {
activeTool={activeTool} activeTool={activeTool}
onClose={closeWorkspace} onClose={closeWorkspace}
summary={workspaceSummary} summary={workspaceSummary}
leading={activeTool === "boat" ? <Ship size={20} /> : undefined}
status={activeToolStatus} status={activeToolStatus}
sheetState={workspaceSheetState} sheetState={workspaceSheetState}
onSheetStateChange={setWorkspaceSheetState} onSheetStateChange={setWorkspaceSheetState}
busy={workspaceBusy} busy={workspaceBusy}
> >
{activeTool === "boat" && (
<LazyContent
pending={<WorkspaceLoadState label="Mein Boot" />}
failed={<WorkspaceLoadState label="Mein Boot" failed />}
>
<LazyBoatProfilePanel
profile={currentBoat}
stored={boatProfileStored}
hasActiveRoute={Boolean(route)}
onSave={saveCurrentBoat}
onDelete={deleteCurrentBoat}
/>
</LazyContent>
)}
{activeTool === "anchor" && ( {activeTool === "anchor" && (
<LazyContent <LazyContent
pending={<WorkspaceLoadState label="Ankerwache" />} pending={<WorkspaceLoadState label="Ankerwache" />}
@@ -888,6 +938,7 @@ export function App() {
<LazyAnchorWatchPanel <LazyAnchorWatchPanel
watch={anchorWatch} watch={anchorWatch}
gps={gps} gps={gps}
boatLengthM={currentBoat.lengthM}
onStartGps={gps.start} onStartGps={gps.start}
onClose={closeWorkspace} onClose={closeWorkspace}
/> />
@@ -908,8 +959,8 @@ export function App() {
kind: "gps", kind: "gps",
label: label:
gps.accuracyM === null gps.accuracyM === null
? "Position am Boot" ? `Position auf ${currentBoat.name}`
: `Position am Boot · ±${Math.round(gps.accuracyM)} m` : `Position auf ${currentBoat.name} · ±${Math.round(gps.accuracyM)} m`
} }
: { kind: "unknown", label: "GPS noch nicht aktiv" } : { kind: "unknown", label: "GPS noch nicht aktiv" }
} }
@@ -983,6 +1034,8 @@ export function App() {
routeTides={routeTides} routeTides={routeTides}
routeTidesLoading={routeTidesLoading} routeTidesLoading={routeTidesLoading}
routeTidesError={routeTidesError} routeTidesError={routeTidesError}
boatProfile={currentBoat}
routeVesselProfile={activeVesselProfile}
onLoadOfflineVoyage={loadOfflineVoyage} onLoadOfflineVoyage={loadOfflineVoyage}
loading={routeLoading} loading={routeLoading}
error={routeError} error={routeError}
@@ -999,6 +1052,10 @@ export function App() {
onSelectRoute={selectRouteOption} onSelectRoute={selectRouteOption}
guidanceActive={courseAssistant.active} guidanceActive={courseAssistant.active}
onStartGuidance={startCourseAssistant} onStartGuidance={startCourseAssistant}
onEditBoat={() => {
setActiveTool("boat");
setWorkspaceSheetState("half");
}}
onCollapse={closeWorkspace} onCollapse={closeWorkspace}
operationalPanelsVisible={false} operationalPanelsVisible={false}
embedded embedded
@@ -1018,7 +1075,7 @@ export function App() {
<span> <span>
{anchorAlarm {anchorAlarm
? anchorWatch.positionAlarm ? anchorWatch.positionAlarm
? "Ankeralarm: Das Boot liegt außerhalb des überwachten Radius." ? `Ankeralarm: ${currentBoat.name} liegt außerhalb des überwachten Radius.`
: "Ankerwarnung: Die gegebene Kette oder Leine reicht für den erwarteten Wasserstand nicht aus." : "Ankerwarnung: Die gegebene Kette oder Leine reicht für den erwarteten Wasserstand nicht aus."
: courseAssistant.fixStale : courseAssistant.fixStale
? "Navigation pausiert: Der GPS-Fix ist veraltet." ? "Navigation pausiert: Der GPS-Fix ist veraltet."
@@ -1155,6 +1212,15 @@ function conditionsToolStatus(
return Date.now() - oldestTimestamp > 90 * 60_000 ? "stale" : "active"; return Date.now() - oldestTimestamp > 90 * 60_000 ? "stale" : "active";
} }
function boatWorkspaceSummary(profile: BoatProfile, stored: boolean) {
if (!stored) {
return "Vorgabewerte aktiv · bitte Bootsdaten speichern";
}
return `${profile.name} · ${boatCategoryLabel(profile.category)} · ${profile.lengthM.toLocaleString("de-DE", {
maximumFractionDigits: 2
})} × ${profile.beamM.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`;
}
function anchorWorkspaceSummary(watch: ReturnType<typeof useAnchorWatch>) { function anchorWorkspaceSummary(watch: ReturnType<typeof useAnchorWatch>) {
const distanceM = watch.watchResult?.distanceFromAnchorM; const distanceM = watch.watchResult?.distanceFromAnchorM;
if (watch.phase === "armed") { if (watch.phase === "armed") {
@@ -1210,7 +1276,7 @@ function routeWorkspaceSummary(
guidance: RouteGuidanceResult | null guidance: RouteGuidanceResult | null
) { ) {
if (!route) { if (!route) {
return "Start, Ziel und Bootsprofil festlegen"; return "Start und Ziel festlegen · aktuelles Boot wird verwendet";
} }
if (guidanceActive) { if (guidanceActive) {
const remainingNm = guidance ? guidance.remainingRouteDistanceM / 1_852 : null; const remainingNm = guidance ? guidance.remainingRouteDistanceM / 1_852 : null;
@@ -175,6 +175,26 @@
font-weight: 850; font-weight: 850;
} }
.anchor-profile-setting {
min-width: 0;
min-height: 58px;
border: 1px solid #c5d8d2;
border-radius: 9px;
padding: 7px 8px;
display: grid;
align-content: center;
gap: 4px;
background: #e3efeb;
color: #526a72;
font-size: 12px;
font-weight: 850;
}
.anchor-profile-setting strong {
color: #0f4c5c;
font-size: 13px;
}
.anchor-settings label > span:last-child { .anchor-settings label > span:last-child {
display: flex; display: flex;
align-items: center; align-items: center;
+45 -8
View File
@@ -21,11 +21,18 @@ type AnchorWatchModel = ReturnType<typeof useAnchorWatch>;
export type AnchorWatchPanelProps = { export type AnchorWatchPanelProps = {
watch: AnchorWatchModel; watch: AnchorWatchModel;
gps: Pick<GpsState, "status" | "accuracyM" | "timestampMs">; gps: Pick<GpsState, "status" | "accuracyM" | "timestampMs">;
boatLengthM?: number;
onStartGps: () => void; onStartGps: () => void;
onClose: () => void; onClose: () => void;
}; };
export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatchPanelProps) { export function AnchorWatchPanel({
watch,
gps,
boatLengthM,
onStartGps,
onClose
}: AnchorWatchPanelProps) {
const [confirmStop, setConfirmStop] = useState(false); const [confirmStop, setConfirmStop] = useState(false);
const armed = watch.phase === "armed"; const armed = watch.phase === "armed";
const hasAlarm = armed && (watch.positionAlarm || watch.rodeShortfall); const hasAlarm = armed && (watch.positionAlarm || watch.rodeShortfall);
@@ -83,7 +90,7 @@ export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatc
onStop={stop} onStop={stop}
/> />
) : ( ) : (
<AnchorWatchSetup watch={watch} onCancel={cancel} /> <AnchorWatchSetup watch={watch} boatLengthM={boatLengthM} onCancel={cancel} />
)} )}
</aside> </aside>
); );
@@ -129,12 +136,24 @@ function AnchorCapture({
); );
} }
function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCancel: () => void }) { function AnchorWatchSetup({
watch,
boatLengthM,
onCancel
}: {
watch: AnchorWatchModel;
boatLengthM?: number;
onCancel: () => void;
}) {
const boatAllowanceM =
typeof boatLengthM === "number" && Number.isFinite(boatLengthM) && boatLengthM > 0
? boatLengthM
: 0;
const suggestedReachM = watch.rodePlan const suggestedReachM = watch.rodePlan
? Math.ceil(Math.max( ? Math.ceil(Math.max(
watch.rodePlan.horizontalReachAtSetM, watch.rodePlan.horizontalReachAtSetM,
watch.rodePlan.horizontalReachM ?? 0 watch.rodePlan.horizontalReachM ?? 0
) + 5) ) + boatAllowanceM + 5)
: null; : null;
const radiusTooSmall = suggestedReachM !== null && watch.settings.alarmRadiusM < suggestedReachM; const radiusTooSmall = suggestedReachM !== null && watch.settings.alarmRadiusM < suggestedReachM;
@@ -161,7 +180,11 @@ function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCanc
{radiusTooSmall && ( {radiusTooSmall && (
<p className="anchor-inline-warning"> <p className="anchor-inline-warning">
<AlertTriangle size={15} aria-hidden="true" /> <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. Der Radius liegt unter der rechnerischen Reichweite von etwa {suggestedReachM} m
{boatAllowanceM > 0
? ` einschließlich ${formatMetersCompact(boatAllowanceM)} Bootslänge`
: ""}. Normales Schwojen kann bereits alarmieren; 5 m Zusatzpuffer sind berücksichtigt,
die aktuelle GPS-Unsicherheit muss zusätzlich beachtet werden.
</p> </p>
)} )}
{watch.settingsError && <p className="anchor-inline-alert" role="alert">{watch.settingsError}</p>} {watch.settingsError && <p className="anchor-inline-alert" role="alert">{watch.settingsError}</p>}
@@ -191,7 +214,6 @@ function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchMode
step: number; step: number;
}> = [ }> = [
{ key: "depthAtSetM", label: "Tiefe beim Setzen", unit: "m", min: 0.1, max: 200, step: 0.1 }, { 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: "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: "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 } { key: "alarmRadiusM", label: "Alarmradius ab Anker", unit: "m", min: 10, max: 2_000, step: 5 }
@@ -199,6 +221,10 @@ function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchMode
return ( return (
<div className="anchor-settings" data-compact={compact}> <div className="anchor-settings" data-compact={compact}>
<div className="anchor-profile-setting">
<span>Bugrolle über Wasser</span>
<strong>{formatMetersCompact(watch.settings.bowRollerHeightM)} · Mein Boot</strong>
</div>
{fields.map((field) => ( {fields.map((field) => (
<label key={field.key} htmlFor={`anchor-${field.key}`}> <label key={field.key} htmlFor={`anchor-${field.key}`}>
<span>{field.label}</span> <span>{field.label}</span>
@@ -347,14 +373,21 @@ function AnchorWatchActive({
<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> <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> </div>
{watch.audibleDriftAlarmActive && (
<p className="anchor-inline-alert" role="alert">
Akustischer Driftalarm aktiv · ertönt bis zur ausdrücklichen Quittierung.
</p>
)}
{watch.positionAlarm && !watch.alarmAcknowledged && ( {watch.positionAlarm && !watch.alarmAcknowledged && (
<button className="anchor-acknowledge-action" type="button" onClick={watch.acknowledgeAlarm}> <button className="anchor-acknowledge-action" type="button" onClick={watch.acknowledgeAlarm}>
<BellRing size={17} aria-hidden="true" /> <BellRing size={17} aria-hidden="true" />
Alarm quittieren {watch.driftAlarm ? "Driftalarm quittieren" : "Alarmton quittieren"}
</button> </button>
)} )}
{watch.positionAlarm && watch.alarmAcknowledged && ( {watch.positionAlarm && watch.alarmAcknowledged && (
<p className="anchor-acknowledged">Alarmton quittiert · rote Warnanzeige bleibt aktiv</p> <p className="anchor-acknowledged">
{watch.driftAlarm ? "Driftalarm quittiert" : "Alarmton quittiert"} · rote Warnanzeige bleibt aktiv
</p>
)} )}
{watch.rodeShortfall && ( {watch.rodeShortfall && (
<p className="anchor-inline-alert" role="alert"> <p className="anchor-inline-alert" role="alert">
@@ -417,6 +450,10 @@ function formatCoordinate(coordinate: { lat: number; lon: number }) {
return `${coordinate.lat.toFixed(5)}, ${coordinate.lon.toFixed(5)}`; return `${coordinate.lat.toFixed(5)}, ${coordinate.lon.toFixed(5)}`;
} }
function formatMetersCompact(value: number) {
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`;
}
function formatClock(timestampMs: number) { function formatClock(timestampMs: number) {
return new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(timestampMs); return new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(timestampMs);
} }
@@ -0,0 +1,293 @@
.boat-profile-panel {
display: grid;
gap: 12px;
color: #10242b;
}
.boat-profile-panel-intro {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 10px;
}
.boat-profile-panel-icon {
width: 42px;
height: 42px;
border-radius: 11px;
display: grid;
place-items: center;
background: #dceee6;
color: #196f5c;
}
.boat-profile-panel-title {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 9px;
}
.boat-profile-panel-title h2 {
margin: 0;
color: #17343c;
font-size: 18px;
}
.boat-profile-panel-title span {
min-height: 24px;
border-radius: 999px;
padding: 0 8px;
display: inline-flex;
align-items: center;
background: #fff1cc;
color: #805900;
font-size: 10px;
font-weight: 850;
}
.boat-profile-panel-title span[data-stored="true"] {
background: #dceee6;
color: #196f5c;
}
.boat-profile-panel-intro p {
margin: 4px 0 0;
color: #526a72;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
}
.boat-profile-route-warning,
.boat-profile-cookie-note,
.boat-profile-feedback {
margin: 0;
border-radius: 9px;
padding: 9px 10px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 8px;
font-size: 12px;
line-height: 1.4;
}
.boat-profile-route-warning {
border: 1px solid rgba(128, 89, 0, 0.18);
background: #fff1cc;
color: #805900;
}
.boat-profile-route-warning p,
.boat-profile-cookie-note p {
margin: 0;
}
.boat-profile-form {
display: grid;
gap: 12px;
}
.boat-profile-identity,
.boat-profile-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.boat-profile-field {
min-width: 0;
display: grid;
align-content: start;
gap: 5px;
color: #314b54;
}
.boat-profile-field > label {
font-size: 12px;
font-weight: 850;
}
.boat-profile-field input,
.boat-profile-field select {
width: 100%;
height: 44px;
min-width: 0;
border: 1px solid rgba(18, 46, 55, 0.22);
border-radius: 8px;
padding: 0 9px;
background: #ffffff;
color: #10242b;
}
.boat-profile-field select {
cursor: pointer;
}
.boat-profile-field input:invalid:not(:focus),
.boat-profile-field select:invalid:not(:focus) {
border-color: rgba(157, 44, 34, 0.6);
}
.boat-profile-field small {
min-height: 30px;
color: #607278;
font-size: 10px;
font-weight: 700;
line-height: 1.35;
}
.boat-profile-dimensions {
min-width: 0;
margin: 0;
border: 1px solid rgba(15, 76, 92, 0.13);
border-radius: 11px;
padding: 10px;
display: grid;
gap: 9px;
background: rgba(237, 243, 241, 0.72);
}
.boat-profile-dimensions legend {
padding: 0 5px;
color: #17343c;
font-size: 13px;
font-weight: 900;
}
.boat-profile-dimensions > p {
margin: 0;
color: #526a72;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.boat-profile-anchor-details .boat-profile-field-grid {
grid-template-columns: minmax(0, 1fr);
}
.boat-profile-anchor-details .boat-profile-field {
max-width: 360px;
}
.boat-profile-number-input {
position: relative;
display: block;
}
.boat-profile-number-input input {
padding-right: 38px;
}
.boat-profile-number-input > span {
position: absolute;
top: 50%;
right: 10px;
color: #607278;
font-size: 11px;
font-weight: 900;
transform: translateY(-50%);
pointer-events: none;
}
.boat-profile-cookie-note {
border: 1px solid rgba(15, 76, 92, 0.12);
background: #e8f1f3;
color: #3d5962;
}
.boat-profile-feedback {
align-items: center;
background: #dceee6;
color: #196f5c;
font-weight: 800;
}
.boat-profile-feedback[data-kind="error"] {
background: #ffe1dc;
color: #9d2c22;
}
.boat-profile-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.boat-profile-actions > button,
.boat-profile-delete-confirmation button {
min-height: 44px;
border-radius: 8px;
padding: 7px 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 12px;
font-weight: 850;
}
.boat-profile-save {
background: #0f4c5c;
color: #ffffff;
}
.boat-profile-delete {
border: 1px solid rgba(157, 44, 34, 0.24);
background: #fff0ed;
color: #9d2c22;
}
.boat-profile-actions > .boat-profile-save:only-child {
grid-column: 1 / -1;
}
.boat-profile-delete-confirmation {
border: 1px solid rgba(157, 44, 34, 0.26);
border-radius: 10px;
padding: 10px;
display: grid;
gap: 9px;
background: #fff0ed;
color: #81251d;
}
.boat-profile-delete-confirmation p {
margin: 0;
font-size: 12px;
font-weight: 750;
line-height: 1.4;
}
.boat-profile-delete-confirmation > div {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 7px;
}
.boat-profile-delete-confirmation button {
border: 1px solid rgba(157, 44, 34, 0.2);
background: #ffffff;
color: #81251d;
}
.boat-profile-delete-confirmation button:last-child {
background: #9d2c22;
color: #ffffff;
}
@media (max-width: 420px) {
.boat-profile-identity,
.boat-profile-field-grid,
.boat-profile-actions {
grid-template-columns: 1fr;
}
.boat-profile-actions > .boat-profile-save:only-child {
grid-column: auto;
}
}
@@ -0,0 +1,459 @@
import {
AlertTriangle,
CheckCircle2,
Cookie,
Save,
Sailboat,
Trash2,
X
} from "lucide-react";
import { useEffect, useId, useState, type FormEvent } from "react";
import type { BoatCategory, BoatProfile } from "@watermaps/shared";
import {
BOAT_CATEGORY_OPTIONS,
validateBoatProfile
} from "../lib/boat-profile";
import "./BoatProfilePanel.css";
export type BoatProfilePanelProps = {
profile: BoatProfile;
stored: boolean;
hasActiveRoute?: boolean;
onSave: (profile: BoatProfile) => boolean;
onDelete: () => boolean;
};
type Feedback = {
kind: "success" | "error";
message: string;
};
type BoatProfileDraft = {
name: string;
category: BoatCategory;
lengthM: string;
beamM: string;
airDraftM: string;
draughtM: string;
safetyReserveM: string;
cruiseSpeedKn: string;
bowRollerHeightM: string;
};
type NumericDraftKey = Exclude<keyof BoatProfileDraft, "name" | "category">;
export function BoatProfilePanel({
profile,
stored,
hasActiveRoute = false,
onSave,
onDelete
}: BoatProfilePanelProps) {
const headingId = useId();
const categoryHelpId = useId();
const deleteConfirmationId = useId();
const [draft, setDraft] = useState<BoatProfileDraft>(() => toDraft(profile));
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
setDraft(toDraft(profile));
}, [profile]);
useEffect(() => {
if (!stored) {
setConfirmDelete(false);
}
}, [stored]);
const updateName = (name: string) => {
setDraft((current) => ({ ...current, name }));
setFeedback(null);
};
const updateCategory = (category: BoatCategory) => {
setDraft((current) => ({ ...current, category }));
setFeedback(null);
};
const updateNumber = (key: NumericDraftKey, value: string) => {
setDraft((current) => ({ ...current, [key]: value }));
setFeedback(null);
};
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setConfirmDelete(false);
const validation = validateBoatProfile(toProfile(draft));
if (!validation.valid) {
setFeedback({ kind: "error", message: validation.error });
return;
}
if (!onSave(validation.profile)) {
setFeedback({
kind: "error",
message:
"Die Bootsdaten konnten nicht im Cookie gespeichert werden. Bitte prüfe die Cookie-Einstellungen dieses Browsers."
});
return;
}
setDraft(toDraft(validation.profile));
setFeedback({
kind: "success",
message: "Die Bootsdaten wurden in diesem Browser gespeichert."
});
};
const deleteProfile = () => {
if (!onDelete()) {
setFeedback({
kind: "error",
message:
"Die gespeicherten Bootsdaten konnten nicht gelöscht werden. Bitte prüfe die Cookie-Einstellungen dieses Browsers."
});
return;
}
setConfirmDelete(false);
setFeedback({
kind: "success",
message: "Die gespeicherten Bootsdaten wurden aus diesem Browser gelöscht."
});
};
return (
<section className="boat-profile-panel" aria-labelledby={headingId}>
<header className="boat-profile-panel-intro">
<span className="boat-profile-panel-icon" aria-hidden="true">
<Sailboat size={23} />
</span>
<div>
<div className="boat-profile-panel-title">
<h2 id={headingId}>Aktuelles Boot</h2>
<span data-stored={stored}>
{stored ? "Im Cookie gespeichert" : "Noch nicht gespeichert"}
</span>
</div>
<p>
Diese Werte gelten automatisch für neue Routen und die weiteren
Navigationswerkzeuge.
</p>
</div>
</header>
{hasActiveRoute && (
<div className="boat-profile-route-warning">
<AlertTriangle size={18} aria-hidden="true" />
<p>
<strong>Aktive Route:</strong> Beim Speichern wird sie verworfen,
weil sie noch mit den bisherigen Bootsdaten berechnet wurde.
</p>
</div>
)}
<form className="boat-profile-form" onSubmit={save}>
<div className="boat-profile-identity">
<TextField
label="Name des Bootes"
help="Wird in der App als aktuelles Boot angezeigt."
value={draft.name}
onChange={updateName}
/>
<div className="boat-profile-field">
<label htmlFor={`${categoryHelpId}-input`}>Kategorie</label>
<select
id={`${categoryHelpId}-input`}
value={draft.category}
onChange={(event) =>
updateCategory(event.target.value as BoatCategory)
}
aria-describedby={categoryHelpId}
required
>
{BOAT_CATEGORY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<small id={categoryHelpId}>
Beschreibt den Bootstyp; beeinflusst das Routing derzeit nicht.
</small>
</div>
</div>
<fieldset className="boat-profile-dimensions">
<legend>Abmessungen und Fahrtwerte</legend>
<p>
Bitte die Werte für den normalen Fahrzustand eintragen. Sie
beeinflussen Durchfahrt, Tiefenprüfung und Fahrtdauer.
</p>
<div className="boat-profile-field-grid">
<NumberField
label="Länge über alles"
help="Größte feste Länge von Bug bis Heck."
unit="m"
min={0.01}
max={400}
value={draft.lengthM}
onChange={(value) => updateNumber("lengthM", value)}
/>
<NumberField
label="Breite"
help="Größte Breite des Bootes."
unit="m"
min={0.01}
max={80}
value={draft.beamM}
onChange={(value) => updateNumber("beamM", value)}
/>
<NumberField
label="Durchfahrtshöhe ab Wasserlinie"
help="Bis zum höchsten festen Punkt; wichtig für Brücken."
unit="m"
min={0.01}
max={80}
value={draft.airDraftM}
onChange={(value) => updateNumber("airDraftM", value)}
/>
<NumberField
label="Tiefgang"
help="Wasserlinie bis zum tiefsten Punkt."
unit="m"
min={0.01}
max={15}
value={draft.draughtM}
onChange={(value) => updateNumber("draughtM", value)}
/>
<NumberField
label="Sicherheitsreserve"
help="Zusätzlicher Abstand zum Grund beim Routing."
unit="m"
min={0}
max={10}
value={draft.safetyReserveM}
onChange={(value) => updateNumber("safetyReserveM", value)}
/>
<NumberField
label="Reisegeschwindigkeit"
help="Normale, dauerhaft fahrbare Geschwindigkeit."
unit="kn"
min={0.01}
max={80}
value={draft.cruiseSpeedKn}
onChange={(value) => updateNumber("cruiseSpeedKn", value)}
/>
</div>
</fieldset>
<fieldset className="boat-profile-dimensions boat-profile-anchor-details">
<legend>Ankern</legend>
<p>
Dieser Wert ist von der Durchfahrtshöhe getrennt und wird für die
Ketten- und Trossenberechnung verwendet.
</p>
<div className="boat-profile-field-grid">
<NumberField
label="Bugrolle über Wasserlinie"
help="Senkrechter Abstand von der Wasserlinie bis zum Austritt der Ankerkette."
unit="m"
min={0}
max={20}
value={draft.bowRollerHeightM}
onChange={(value) => updateNumber("bowRollerHeightM", value)}
/>
</div>
</fieldset>
<div className="boat-profile-cookie-note">
<Cookie size={18} aria-hidden="true" />
<p>
Die Bootsdaten werden in einem Cookie dieses Browsers gespeichert
und bei Anfragen an Watermaps mitgesendet. Keine persönlichen Daten
eintragen. Die Daten lassen sich hier jederzeit löschen.
</p>
</div>
{feedback && (
<p
className="boat-profile-feedback"
data-kind={feedback.kind}
role={feedback.kind === "error" ? "alert" : "status"}
aria-live="polite"
>
{feedback.kind === "success" ? (
<CheckCircle2 size={17} aria-hidden="true" />
) : (
<AlertTriangle size={17} aria-hidden="true" />
)}
{feedback.message}
</p>
)}
<div className="boat-profile-actions">
{stored && (
<button
className="boat-profile-delete"
type="button"
onClick={() => {
setConfirmDelete(true);
setFeedback(null);
}}
aria-expanded={confirmDelete}
aria-controls={deleteConfirmationId}
>
<Trash2 size={17} aria-hidden="true" />
Bootsdaten löschen
</button>
)}
<button className="boat-profile-save" type="submit">
<Save size={17} aria-hidden="true" />
Bootsdaten speichern
</button>
</div>
{stored && confirmDelete && (
<div
id={deleteConfirmationId}
className="boat-profile-delete-confirmation"
role="group"
aria-label="Löschen der Bootsdaten bestätigen"
>
<p>
Bootsdaten wirklich aus diesem Browser löschen? Neue Routen
verwenden danach bis zum erneuten Speichern wieder die
Standardwerte.
</p>
<div>
<button
type="button"
onClick={() => setConfirmDelete(false)}
>
<X size={16} aria-hidden="true" />
Abbrechen
</button>
<button type="button" onClick={deleteProfile}>
<Trash2 size={16} aria-hidden="true" />
Endgültig löschen
</button>
</div>
</div>
)}
</form>
</section>
);
}
function TextField({
label,
help,
value,
onChange
}: {
label: string;
help: string;
value: string;
onChange: (value: string) => void;
}) {
const id = useId();
const helpId = `${id}-help`;
return (
<div className="boat-profile-field">
<label htmlFor={id}>{label}</label>
<input
id={id}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
aria-describedby={helpId}
autoComplete="off"
minLength={1}
maxLength={80}
required
/>
<small id={helpId}>{help}</small>
</div>
);
}
function NumberField({
label,
help,
unit,
min,
max,
value,
onChange
}: {
label: string;
help: string;
unit: "m" | "kn";
min: number;
max: number;
value: string;
onChange: (value: string) => void;
}) {
const id = useId();
const helpId = `${id}-help`;
return (
<div className="boat-profile-field">
<label htmlFor={id}>{label}</label>
<span className="boat-profile-number-input">
<input
id={id}
type="number"
inputMode="decimal"
value={value}
onChange={(event) => onChange(event.target.value)}
aria-describedby={helpId}
min={min}
max={max}
step="any"
required
/>
<span aria-hidden="true">{unit}</span>
</span>
<small id={helpId}>{help}</small>
</div>
);
}
function toDraft(profile: BoatProfile): BoatProfileDraft {
return {
name: profile.name,
category: profile.category,
lengthM: String(profile.lengthM),
beamM: String(profile.beamM),
airDraftM: String(profile.airDraftM),
draughtM: String(profile.draughtM),
safetyReserveM: String(profile.safetyReserveM),
cruiseSpeedKn: String(profile.cruiseSpeedKn),
bowRollerHeightM: String(profile.bowRollerHeightM)
};
}
function toProfile(draft: BoatProfileDraft): BoatProfile {
return {
name: draft.name,
category: draft.category,
lengthM: parseNumber(draft.lengthM),
beamM: parseNumber(draft.beamM),
airDraftM: parseNumber(draft.airDraftM),
draughtM: parseNumber(draft.draughtM),
safetyReserveM: parseNumber(draft.safetyReserveM),
cruiseSpeedKn: parseNumber(draft.cruiseSpeedKn),
bowRollerHeightM: parseNumber(draft.bowRollerHeightM)
};
}
function parseNumber(value: string): number {
return value.trim() ? Number(value) : Number.NaN;
}
+11 -2
View File
@@ -1,6 +1,14 @@
import { Anchor, Bell, CloudSun, Route as RouteIcon, type LucideIcon } from "lucide-react"; import {
Anchor,
Bell,
CloudSun,
Route as RouteIcon,
Ship,
type LucideIcon
} from "lucide-react";
export const NAVIGATION_TOOL_ORDER = [ export const NAVIGATION_TOOL_ORDER = [
"boat",
"anchor", "anchor",
"conditions", "conditions",
"upcoming", "upcoming",
@@ -18,6 +26,7 @@ type NavigationToolDefinition = {
}; };
const NAVIGATION_TOOLS: readonly NavigationToolDefinition[] = [ const NAVIGATION_TOOLS: readonly NavigationToolDefinition[] = [
{ id: "boat", label: "Mein Boot", Icon: Ship },
{ id: "anchor", label: "Ankerwache", Icon: Anchor }, { id: "anchor", label: "Ankerwache", Icon: Anchor },
{ id: "conditions", label: "Wetter und Tide", Icon: CloudSun }, { id: "conditions", label: "Wetter und Tide", Icon: CloudSun },
{ id: "upcoming", label: "Als Nächstes", Icon: Bell }, { id: "upcoming", label: "Als Nächstes", Icon: Bell },
@@ -43,7 +52,7 @@ export function NavigationToolRail({
disabledTools = {}, disabledTools = {},
workspaceId = "navigation-workspace", workspaceId = "navigation-workspace",
className, className,
ariaLabel = "Kartenwerkzeuge" ariaLabel = "Navigationswerkzeuge"
}: NavigationToolRailProps) { }: NavigationToolRailProps) {
return ( return (
<nav className={classNames("navigation-tool-rail", className)} aria-label={ariaLabel}> <nav className={classNames("navigation-tool-rail", className)} aria-label={ariaLabel}>
+77 -73
View File
@@ -23,6 +23,7 @@ import {
buildVoyagePlan, buildVoyagePlan,
VOYAGE_AMENITIES, VOYAGE_AMENITIES,
voyageAmenityLabel, voyageAmenityLabel,
type BoatProfile,
type Coordinate, type Coordinate,
type NavigationDataSnapshot, type NavigationDataSnapshot,
type RouteResult, type RouteResult,
@@ -32,6 +33,11 @@ import {
} from "@watermaps/shared"; } from "@watermaps/shared";
import type { RouteWeatherReport } from "../routeWeatherReport"; import type { RouteWeatherReport } from "../routeWeatherReport";
import type { OfflineVoyage } from "../lib/offline-route"; import type { OfflineVoyage } from "../lib/offline-route";
import {
boatCategoryLabel,
DEFAULT_BOAT_PROFILE,
toVesselProfile
} from "../lib/boat-profile";
import type { RouteLock } from "../voyageHarbours"; import type { RouteLock } from "../voyageHarbours";
import { LazyContent } from "./LazyContent"; import { LazyContent } from "./LazyContent";
import { NavigationDataPanel } from "./NavigationDataPanel"; import { NavigationDataPanel } from "./NavigationDataPanel";
@@ -58,6 +64,8 @@ type RoutePlannerProps = {
routeTides?: RouteTidePlan | null; routeTides?: RouteTidePlan | null;
routeTidesLoading?: boolean; routeTidesLoading?: boolean;
routeTidesError?: string | null; routeTidesError?: string | null;
boatProfile?: BoatProfile;
routeVesselProfile?: VesselProfile | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
pickMode: "start" | "destination" | "waypoint" | null; pickMode: "start" | "destination" | "waypoint" | null;
@@ -66,7 +74,6 @@ type RoutePlannerProps = {
destination: Coordinate; destination: Coordinate;
waypoints: Coordinate[]; waypoints: Coordinate[];
departureTime: string; departureTime: string;
vesselProfile: VesselProfile;
}) => void; }) => void;
onPickStart: () => void; onPickStart: () => void;
onPickDestination: () => void; onPickDestination: () => void;
@@ -80,6 +87,7 @@ type RoutePlannerProps = {
onSelectRoute: (routeId: string) => void; onSelectRoute: (routeId: string) => void;
guidanceActive?: boolean; guidanceActive?: boolean;
onStartGuidance?: () => void; onStartGuidance?: () => void;
onEditBoat?: () => void;
operationalPanelsVisible?: boolean; operationalPanelsVisible?: boolean;
embedded?: boolean; embedded?: boolean;
onCollapse: () => void; onCollapse: () => void;
@@ -113,6 +121,8 @@ export function RoutePlanner({
routeTides = null, routeTides = null,
routeTidesLoading = false, routeTidesLoading = false,
routeTidesError = null, routeTidesError = null,
boatProfile = DEFAULT_BOAT_PROFILE,
routeVesselProfile = null,
loading, loading,
error, error,
pickMode, pickMode,
@@ -129,15 +139,11 @@ export function RoutePlanner({
onSelectRoute, onSelectRoute,
guidanceActive = false, guidanceActive = false,
onStartGuidance = () => undefined, onStartGuidance = () => undefined,
onEditBoat = () => undefined,
operationalPanelsVisible = true, operationalPanelsVisible = true,
embedded = false, embedded = false,
onCollapse onCollapse
}: RoutePlannerProps) { }: RoutePlannerProps) {
const [draughtM, setDraughtM] = useState(1.4);
const [reserveM, setReserveM] = useState(0.5);
const [airDraftM, setAirDraftM] = useState(2.5);
const [beamM, setBeamM] = useState(3.2);
const [speedKn, setSpeedKn] = useState(6);
const [departureTime, setDepartureTime] = useState(() => localDateTimeValue(new Date())); const [departureTime, setDepartureTime] = useState(() => localDateTimeValue(new Date()));
const [maxCruisingHoursPerDay, setMaxCruisingHoursPerDay] = useState(8); const [maxCruisingHoursPerDay, setMaxCruisingHoursPerDay] = useState(8);
const [requiredAmenities, setRequiredAmenities] = useState<VoyageAmenity[]>(["overnight"]); const [requiredAmenities, setRequiredAmenities] = useState<VoyageAmenity[]>(["overnight"]);
@@ -149,6 +155,16 @@ export function RoutePlanner({
const [voyageToolsRequested, setVoyageToolsRequested] = useState(false); const [voyageToolsRequested, setVoyageToolsRequested] = useState(false);
const [sheetState, setSheetState] = useState<RouteSheetState>("half"); const [sheetState, setSheetState] = useState<RouteSheetState>("half");
const previousResult = useRef(result); 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 canRoute = Boolean(startPoint && destination);
const isPickingStart = pickMode === "start"; const isPickingStart = pickMode === "start";
const isPickingDestination = pickMode === "destination"; const isPickingDestination = pickMode === "destination";
@@ -387,7 +403,7 @@ export function RoutePlanner({
open={routeOptionsOpen} open={routeOptionsOpen}
onToggle={(event) => setRouteOptionsOpen(event.currentTarget.open)} onToggle={(event) => setRouteOptionsOpen(event.currentTarget.open)}
> >
<summary>Routenoptionen: Boot · Zwischenziele</summary> <summary>Routenoptionen: Zwischenziele</summary>
<section className="waypoint-editor" aria-label="Zwischenziele"> <section className="waypoint-editor" aria-label="Zwischenziele">
<div className="waypoint-editor-heading"> <div className="waypoint-editor-heading">
<strong>Zwischenziele</strong> <strong>Zwischenziele</strong>
@@ -446,65 +462,35 @@ export function RoutePlanner({
)} )}
</section> </section>
<div className="route-grid">
<label>
Tiefgang (m)
<input
value={draughtM}
min={0.1}
max={15}
step={0.1}
type="number"
onChange={(event) => setDraughtM(Number(event.target.value))}
/>
</label>
<label>
Sicherheitsreserve (m)
<input
value={reserveM}
min={0}
max={10}
step={0.1}
type="number"
onChange={(event) => setReserveM(Number(event.target.value))}
/>
</label>
<label>
Durchfahrtshöhe (m)
<input
value={airDraftM}
min={0.5}
max={40}
step={0.1}
type="number"
onChange={(event) => setAirDraftM(Number(event.target.value))}
/>
</label>
<label>
Geschwindigkeit (kn)
<input
value={speedKn}
min={1}
max={80}
step={0.5}
type="number"
onChange={(event) => setSpeedKn(Number(event.target.value))}
/>
</label>
<label>
Breite (m)
<input
value={beamM}
min={0.5}
max={40}
step={0.1}
type="number"
onChange={(event) => setBeamM(Number(event.target.value))}
/>
</label>
</div>
</details> </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"> <label className="departure-planning">
<span> <span>
<Clock3 size={14} aria-hidden="true" /> <Clock3 size={14} aria-hidden="true" />
@@ -529,8 +515,7 @@ export function RoutePlanner({
start: startPoint, start: startPoint,
destination, destination,
waypoints, waypoints,
departureTime: toIsoOrNow(departureTime), departureTime: toIsoOrNow(departureTime)
vesselProfile: { draughtM, safetyReserveM: reserveM, airDraftM, beamM, cruiseSpeedKn: speedKn }
}); });
}} }}
> >
@@ -582,6 +567,17 @@ export function RoutePlanner({
)} )}
</div> </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) && ( {(error || criticalWarnings.length > 0) && (
<section <section
className="warning-list route-priority-warnings" className="warning-list route-priority-warnings"
@@ -913,13 +909,7 @@ export function RoutePlanner({
start: startPoint, start: startPoint,
destination, destination,
waypoints, waypoints,
vesselProfile: { vesselProfile: planningVesselProfile,
draughtM,
safetyReserveM: reserveM,
airDraftM,
beamM,
cruiseSpeedKn: speedKn
},
departureAt: result?.departureTime ?? toIsoOrNow(departureTime) departureAt: result?.departureTime ?? toIsoOrNow(departureTime)
} }
: undefined : undefined
@@ -979,6 +969,20 @@ function formatMeters(value: number | null | undefined) {
return typeof value === "number" ? `${value.toFixed(1)} m` : "-- m"; 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) { function formatBridgeMargin(value: number | null | undefined) {
if (typeof value !== "number") { if (typeof value !== "number") {
return "offen"; return "offen";
+137 -14
View File
@@ -21,6 +21,10 @@ export type AnchorWatchSettings = {
horizonHours: number; horizonHours: number;
}; };
export type AnchorWatchBoatDefaults = {
bowRollerHeightM?: number;
};
export const DEFAULT_ANCHOR_WATCH_SETTINGS: AnchorWatchSettings = { export const DEFAULT_ANCHOR_WATCH_SETTINGS: AnchorWatchSettings = {
depthAtSetM: 3, depthAtSetM: 3,
bowRollerHeightM: 1, bowRollerHeightM: 1,
@@ -35,7 +39,8 @@ const MAX_CAPTURE_ACCURACY_M = 30;
const MAX_FIX_AGE_MS = 10_000; const MAX_FIX_AGE_MS = 10_000;
const STALE_FIX_AFTER_MS = 20_000; const STALE_FIX_AFTER_MS = 20_000;
const TIDE_REFRESH_MS = 15 * 60 * 1_000; const TIDE_REFRESH_MS = 15 * 60 * 1_000;
const REPEAT_ALARM_MS = 60_000; const REPEAT_ALERT_NOTIFICATION_MS = 60_000;
const REPEAT_AUDIBLE_DRIFT_ALARM_MS = 1_250;
type WakeLockSentinelLike = { type WakeLockSentinelLike = {
released?: boolean; released?: boolean;
@@ -51,12 +56,17 @@ type NavigatorWithWakeLock = Navigator & {
export function useAnchorWatch(gps: Pick< export function useAnchorWatch(gps: Pick<
GpsState, GpsState,
"status" | "position" | "accuracyM" | "timestampMs" "status" | "position" | "accuracyM" | "timestampMs"
>) { >, boatDefaults: AnchorWatchBoatDefaults = {}) {
const [phase, setPhase] = useState<AnchorWatchPhase>("idle"); const [phase, setPhase] = useState<AnchorWatchPhase>("idle");
const [anchorPoint, setAnchorPoint] = useState<Coordinate | null>(null); const [anchorPoint, setAnchorPoint] = useState<Coordinate | null>(null);
const [anchorSetAtMs, setAnchorSetAtMs] = useState<number | null>(null); const [anchorSetAtMs, setAnchorSetAtMs] = useState<number | null>(null);
const [anchorCaptureAccuracyM, setAnchorCaptureAccuracyM] = useState<number | null>(null); const [anchorCaptureAccuracyM, setAnchorCaptureAccuracyM] = useState<number | null>(null);
const [settings, setSettings] = useState<AnchorWatchSettings>(DEFAULT_ANCHOR_WATCH_SETTINGS); const [settings, setSettings] = useState<AnchorWatchSettings>(() => ({
...DEFAULT_ANCHOR_WATCH_SETTINGS,
bowRollerHeightM: validBowRollerHeight(boatDefaults.bowRollerHeightM)
? boatDefaults.bowRollerHeightM
: DEFAULT_ANCHOR_WATCH_SETTINGS.bowRollerHeightM
}));
const [operationError, setOperationError] = useState<string | null>(null); const [operationError, setOperationError] = useState<string | null>(null);
const [tide, setTide] = useState<TideSummary | null>(null); const [tide, setTide] = useState<TideSummary | null>(null);
const [tideLoading, setTideLoading] = useState(false); const [tideLoading, setTideLoading] = useState(false);
@@ -65,6 +75,7 @@ export function useAnchorWatch(gps: Pick<
const [alarmAcknowledged, setAlarmAcknowledged] = useState(false); const [alarmAcknowledged, setAlarmAcknowledged] = useState(false);
const tideRequestIdRef = useRef(0); const tideRequestIdRef = useRef(0);
const previousAlarmRef = useRef(false); const previousAlarmRef = useRef(false);
const previousDriftAlarmRef = useRef(false);
const previousRodeShortfallRef = useRef(false); const previousRodeShortfallRef = useRef(false);
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
@@ -73,6 +84,18 @@ export function useAnchorWatch(gps: Pick<
setOperationError(null); setOperationError(null);
}, []); }, []);
useEffect(() => {
const bowRollerHeightM = boatDefaults.bowRollerHeightM;
if (!validBowRollerHeight(bowRollerHeightM)) {
return;
}
setSettings((current) =>
current.bowRollerHeightM === bowRollerHeightM
? current
: { ...current, bowRollerHeightM }
);
}, [boatDefaults.bowRollerHeightM]);
const captureAnchor = useCallback(() => { const captureAnchor = useCallback(() => {
const now = Date.now(); const now = Date.now();
if (gps.status !== "tracking" || !gps.position || gps.timestampMs === null) { if (gps.status !== "tracking" || !gps.position || gps.timestampMs === null) {
@@ -100,6 +123,7 @@ export function useAnchorWatch(gps: Pick<
setTideError(null); setTideError(null);
setAlarmAcknowledged(false); setAlarmAcknowledged(false);
previousAlarmRef.current = false; previousAlarmRef.current = false;
previousDriftAlarmRef.current = false;
previousRodeShortfallRef.current = false; previousRodeShortfallRef.current = false;
return true; return true;
}, [gps.accuracyM, gps.position, gps.status, gps.timestampMs]); }, [gps.accuracyM, gps.position, gps.status, gps.timestampMs]);
@@ -116,6 +140,7 @@ export function useAnchorWatch(gps: Pick<
setTideError(null); setTideError(null);
setAlarmAcknowledged(false); setAlarmAcknowledged(false);
previousAlarmRef.current = false; previousAlarmRef.current = false;
previousDriftAlarmRef.current = false;
previousRodeShortfallRef.current = false; previousRodeShortfallRef.current = false;
const audioContext = audioContextRef.current; const audioContext = audioContextRef.current;
audioContextRef.current = null; audioContextRef.current = null;
@@ -139,6 +164,7 @@ export function useAnchorWatch(gps: Pick<
setClockMs(Date.now()); setClockMs(Date.now());
setAlarmAcknowledged(false); setAlarmAcknowledged(false);
previousAlarmRef.current = false; previousAlarmRef.current = false;
previousDriftAlarmRef.current = false;
setPhase("armed"); setPhase("armed");
audioContextRef.current = createAlarmAudioContext(); audioContextRef.current = createAlarmAudioContext();
void audioContextRef.current?.resume().catch(() => undefined); void audioContextRef.current?.resume().catch(() => undefined);
@@ -241,6 +267,8 @@ export function useAnchorWatch(gps: Pick<
const positionAlarm = phase === "armed" && ( const positionAlarm = phase === "armed" && (
fixStale || gpsUnavailable || gpsUnreliable || !watchResult || watchResult.alarmTriggered fixStale || gpsUnavailable || gpsUnreliable || !watchResult || watchResult.alarmTriggered
); );
const driftAlarm = phase === "armed" && Boolean(watchResult?.alarmTriggered);
const audibleDriftAlarmActive = driftAlarm && !alarmAcknowledged;
const rodeShortfall = Boolean( const rodeShortfall = Boolean(
phase === "armed" && rodePlan?.calculationComplete && (rodePlan.rodeReserveM ?? 0) < 0 phase === "armed" && rodePlan?.calculationComplete && (rodePlan.rodeReserveM ?? 0) < 0
); );
@@ -259,11 +287,41 @@ export function useAnchorWatch(gps: Pick<
setAlarmAcknowledged(false); setAlarmAcknowledged(false);
emitAnchorAlert( emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }), anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current audioContextRef.current,
{ signal: !driftAlarm }
); );
} }
previousAlarmRef.current = true; previousAlarmRef.current = true;
}, [fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]); }, [driftAlarm, fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
useEffect(() => {
if (phase !== "armed" || !driftAlarm) {
previousDriftAlarmRef.current = false;
return;
}
if (!previousDriftAlarmRef.current) {
setAlarmAcknowledged(false);
}
previousDriftAlarmRef.current = true;
}, [driftAlarm, phase]);
useEffect(() => {
if (!audibleDriftAlarmActive) {
return;
}
let stopCurrentTone = emitPersistentDriftSignal(audioContextRef.current);
const intervalId = window.setInterval(() => {
stopCurrentTone();
stopCurrentTone = emitPersistentDriftSignal(audioContextRef.current);
}, REPEAT_AUDIBLE_DRIFT_ALARM_MS);
return () => {
window.clearInterval(intervalId);
stopCurrentTone();
stopVibration();
};
}, [audibleDriftAlarmActive]);
useEffect(() => { useEffect(() => {
if (phase !== "armed" || !positionAlarm || alarmAcknowledged) { if (phase !== "armed" || !positionAlarm || alarmAcknowledged) {
@@ -272,11 +330,21 @@ export function useAnchorWatch(gps: Pick<
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
emitAnchorAlert( emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }), anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current audioContextRef.current,
{ signal: !driftAlarm }
); );
}, REPEAT_ALARM_MS); }, REPEAT_ALERT_NOTIFICATION_MS);
return () => window.clearInterval(intervalId); return () => window.clearInterval(intervalId);
}, [alarmAcknowledged, fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]); }, [
alarmAcknowledged,
driftAlarm,
fixStale,
gpsUnavailable,
gpsUnreliable,
phase,
positionAlarm,
watchResult
]);
useEffect(() => { useEffect(() => {
if (phase === "armed" && rodeShortfall && !previousRodeShortfallRef.current) { if (phase === "armed" && rodeShortfall && !previousRodeShortfallRef.current) {
@@ -348,6 +416,8 @@ export function useAnchorWatch(gps: Pick<
fixStale, fixStale,
gpsUnreliable, gpsUnreliable,
positionAlarm, positionAlarm,
driftAlarm,
audibleDriftAlarmActive,
rodeShortfall, rodeShortfall,
alarmAcknowledged, alarmAcknowledged,
operationError, operationError,
@@ -360,6 +430,10 @@ export function useAnchorWatch(gps: Pick<
}; };
} }
function validBowRollerHeight(value: number | undefined): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 20;
}
function validateSettings(settings: AnchorWatchSettings): string | null { function validateSettings(settings: AnchorWatchSettings): string | null {
if (!positive(settings.depthAtSetM)) return "Die Tiefe beim Setzen muss größer als 0 m sein."; 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 (!nonNegative(settings.bowRollerHeightM)) return "Die Höhe der Bugrolle darf nicht negativ sein.";
@@ -405,11 +479,15 @@ function anchorAlertMessage({
return "Ankerwache hat keine auswertbare Position."; return "Ankerwache hat keine auswertbare Position.";
} }
function emitAnchorAlert(message: string, audioContext: AudioContext | null) { function emitAnchorAlert(
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") { message: string,
navigator.vibrate([300, 120, 300, 120, 500]); audioContext: AudioContext | null,
} options: { signal?: boolean } = {}
) {
if (options.signal !== false) {
emitAlarmVibration();
playAlarmTone(audioContext); playAlarmTone(audioContext);
}
if (typeof Notification !== "undefined" && Notification.permission === "granted") { if (typeof Notification !== "undefined" && Notification.permission === "granted") {
try { try {
new Notification("Watermaps Ankerwache", { new Notification("Watermaps Ankerwache", {
@@ -423,6 +501,23 @@ function emitAnchorAlert(message: string, audioContext: AudioContext | null) {
} }
} }
function emitPersistentDriftSignal(audioContext: AudioContext | null) {
emitAlarmVibration();
return playAlarmTone(audioContext);
}
function emitAlarmVibration() {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate([300, 120, 300, 120, 500]);
}
}
function stopVibration() {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate(0);
}
}
function createAlarmAudioContext(): AudioContext | null { function createAlarmAudioContext(): AudioContext | null {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return null; return null;
@@ -440,15 +535,24 @@ function createAlarmAudioContext(): AudioContext | null {
} }
} }
function playAlarmTone(audioContext: AudioContext | null) { function playAlarmTone(audioContext: AudioContext | null): () => void {
if (!audioContext || audioContext.state === "closed") { if (!audioContext || audioContext.state === "closed") {
return () => undefined;
}
let cancelled = false;
const oscillators: OscillatorNode[] = [];
const gains: GainNode[] = [];
void audioContext.resume().then(() => {
if (cancelled || audioContext.state === "closed") {
return; return;
} }
void audioContext.resume().then(() => {
const startAt = audioContext.currentTime; const startAt = audioContext.currentTime;
for (const offset of [0, 0.32, 0.64]) { for (const offset of [0, 0.32, 0.64]) {
const oscillator = audioContext.createOscillator(); const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain(); const gain = audioContext.createGain();
oscillators.push(oscillator);
gains.push(gain);
oscillator.type = "square"; oscillator.type = "square";
oscillator.frequency.value = 880; oscillator.frequency.value = 880;
gain.gain.setValueAtTime(0.0001, startAt + offset); gain.gain.setValueAtTime(0.0001, startAt + offset);
@@ -456,8 +560,27 @@ function playAlarmTone(audioContext: AudioContext | null) {
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + offset + 0.2); gain.gain.exponentialRampToValueAtTime(0.0001, startAt + offset + 0.2);
oscillator.connect(gain); oscillator.connect(gain);
gain.connect(audioContext.destination); gain.connect(audioContext.destination);
oscillator.onended = () => {
oscillator.disconnect();
gain.disconnect();
};
oscillator.start(startAt + offset); oscillator.start(startAt + offset);
oscillator.stop(startAt + offset + 0.21); oscillator.stop(startAt + offset + 0.21);
} }
}).catch(() => undefined); }).catch(() => undefined);
return () => {
cancelled = true;
for (const oscillator of oscillators) {
try {
oscillator.stop();
} catch {
// The oscillator may already have ended.
}
oscillator.disconnect();
}
for (const gain of gains) {
gain.disconnect();
}
};
} }
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useState } from "react";
import type { BoatProfile } from "@watermaps/shared";
import {
DEFAULT_BOAT_PROFILE,
deleteBoatProfileCookie,
readBoatProfileCookie,
validateBoatProfile,
writeBoatProfileCookie
} from "../lib/boat-profile";
type BoatProfileState = {
profile: BoatProfile;
stored: boolean;
};
export function useBoatProfile() {
const [state, setState] = useState<BoatProfileState>(() => {
const storedProfile = readBoatProfileCookie();
return {
profile: storedProfile ?? DEFAULT_BOAT_PROFILE,
stored: Boolean(storedProfile)
};
});
const save = useCallback((candidate: BoatProfile) => {
const validation = validateBoatProfile(candidate);
if (!validation.valid) {
return false;
}
const stored = writeBoatProfileCookie(validation.profile);
setState({ profile: validation.profile, stored });
return stored;
}, []);
const remove = useCallback(() => {
const removed = deleteBoatProfileCookie();
if (removed) {
setState({ profile: DEFAULT_BOAT_PROFILE, stored: false });
}
return removed;
}, []);
return {
profile: state.profile,
stored: state.stored,
save,
remove
};
}
+276
View File
@@ -0,0 +1,276 @@
import type {
BoatCategory,
BoatProfile,
VesselProfile
} from "@watermaps/shared";
export const BOAT_PROFILE_COOKIE_NAME = "watermaps.vessel-profile";
export const BOAT_PROFILE_COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
const MAX_ENCODED_COOKIE_VALUE_LENGTH = 2_048;
const PROFILE_VERSION = 1;
export const BOAT_CATEGORY_OPTIONS: readonly {
value: BoatCategory;
label: string;
}[] = [
{ value: "sailboat", label: "Segelboot" },
{ value: "motorboat", label: "Motorboot" },
{ value: "motor-sailer", label: "Motorsegler" },
{ value: "multihull", label: "Mehrrumpfboot" },
{ value: "houseboat", label: "Hausboot" },
{ value: "human-powered", label: "Muskelbetriebenes Boot" },
{ value: "other", label: "Sonstiges Boot" }
];
export const DEFAULT_BOAT_PROFILE: BoatProfile = {
name: "Mein Boot",
category: "other",
lengthM: 10,
beamM: 3.2,
draughtM: 1.4,
airDraftM: 2.5,
bowRollerHeightM: 1,
safetyReserveM: 0.5,
cruiseSpeedKn: 6
};
type StoredBoatProfile = {
v: typeof PROFILE_VERSION;
updatedAt: string;
profile: BoatProfile;
};
export type BoatProfileValidationResult =
| { valid: true; profile: BoatProfile }
| { valid: false; error: string };
export function validateBoatProfile(value: unknown): BoatProfileValidationResult {
if (!isRecord(value)) {
return invalid("Das Bootsprofil ist unvollständig.");
}
const name = normalizeBoatName(value.name);
if (!name) {
return invalid("Der Bootsname muss zwischen 1 und 80 Zeichen lang sein.");
}
if (!isBoatCategory(value.category)) {
return invalid("Bitte eine gültige Bootskategorie wählen.");
}
const ranges: Array<{
key: keyof Pick<
BoatProfile,
| "lengthM"
| "beamM"
| "draughtM"
| "airDraftM"
| "bowRollerHeightM"
| "safetyReserveM"
| "cruiseSpeedKn"
>;
label: string;
min: number;
max: number;
allowZero?: boolean;
}> = [
{ key: "lengthM", label: "Länge", min: 0, max: 400 },
{ key: "beamM", label: "Breite", min: 0, max: 80 },
{ key: "draughtM", label: "Tiefgang", min: 0, max: 15 },
{ key: "airDraftM", label: "Durchfahrtshöhe", min: 0, max: 80 },
{
key: "bowRollerHeightM",
label: "Bugrollenhöhe",
min: 0,
max: 20,
allowZero: true
},
{
key: "safetyReserveM",
label: "Sicherheitsreserve",
min: 0,
max: 10,
allowZero: true
},
{ key: "cruiseSpeedKn", label: "Reisegeschwindigkeit", min: 0, max: 80 }
];
for (const range of ranges) {
const fieldValue = value[range.key];
if (
typeof fieldValue !== "number" ||
!Number.isFinite(fieldValue) ||
(range.allowZero ? fieldValue < range.min : fieldValue <= range.min) ||
fieldValue > range.max
) {
const lowerBound = range.allowZero ? `${range.min}` : `mehr als ${range.min}`;
return invalid(`${range.label} muss zwischen ${lowerBound} und ${range.max} liegen.`);
}
}
return {
valid: true,
profile: {
name,
category: value.category,
lengthM: value.lengthM as number,
beamM: value.beamM as number,
draughtM: value.draughtM as number,
airDraftM: value.airDraftM as number,
bowRollerHeightM: value.bowRollerHeightM as number,
safetyReserveM: value.safetyReserveM as number,
cruiseSpeedKn: value.cruiseSpeedKn as number
}
};
}
export function toVesselProfile(profile: BoatProfile): VesselProfile {
return {
draughtM: profile.draughtM,
safetyReserveM: profile.safetyReserveM,
airDraftM: profile.airDraftM,
beamM: profile.beamM,
cruiseSpeedKn: profile.cruiseSpeedKn
};
}
export function boatCategoryLabel(category: BoatCategory): string {
return (
BOAT_CATEGORY_OPTIONS.find((option) => option.value === category)?.label ??
"Sonstiges Boot"
);
}
export function readBoatProfileCookie(
cookieString: string | undefined = browserCookieString()
): BoatProfile | null {
if (cookieString === undefined) {
return null;
}
const encodedValue = findCookieValue(cookieString, BOAT_PROFILE_COOKIE_NAME);
if (!encodedValue || encodedValue.length > MAX_ENCODED_COOKIE_VALUE_LENGTH) {
return null;
}
try {
const stored = JSON.parse(decodeURIComponent(encodedValue)) as unknown;
if (
!isRecord(stored) ||
stored.v !== PROFILE_VERSION ||
typeof stored.updatedAt !== "string" ||
!Number.isFinite(Date.parse(stored.updatedAt))
) {
return null;
}
const validation = validateBoatProfile(stored.profile);
return validation.valid ? validation.profile : null;
} catch {
return null;
}
}
export function serializeBoatProfileCookie(
profile: BoatProfile,
{
secure = browserUsesHttps(),
updatedAt = new Date().toISOString()
}: { secure?: boolean; updatedAt?: string } = {}
): string | null {
const validation = validateBoatProfile(profile);
if (!validation.valid || !Number.isFinite(Date.parse(updatedAt))) {
return null;
}
const stored: StoredBoatProfile = {
v: PROFILE_VERSION,
updatedAt,
profile: validation.profile
};
const encodedValue = encodeURIComponent(JSON.stringify(stored));
if (encodedValue.length > MAX_ENCODED_COOKIE_VALUE_LENGTH) {
return null;
}
return [
`${BOAT_PROFILE_COOKIE_NAME}=${encodedValue}`,
"Path=/",
`Max-Age=${BOAT_PROFILE_COOKIE_MAX_AGE_SECONDS}`,
"SameSite=Lax",
...(secure ? ["Secure"] : [])
].join("; ");
}
export function writeBoatProfileCookie(profile: BoatProfile): boolean {
if (typeof document === "undefined") {
return false;
}
const serialized = serializeBoatProfileCookie(profile);
if (!serialized) {
return false;
}
const expectedValue = serialized.slice(
BOAT_PROFILE_COOKIE_NAME.length + 1,
serialized.indexOf(";")
);
document.cookie = serialized;
return findCookieValue(document.cookie, BOAT_PROFILE_COOKIE_NAME) === expectedValue;
}
export function deleteBoatProfileCookie(): boolean {
if (typeof document === "undefined") {
return false;
}
document.cookie = [
`${BOAT_PROFILE_COOKIE_NAME}=`,
"Path=/",
"Max-Age=0",
"SameSite=Lax",
...(browserUsesHttps() ? ["Secure"] : [])
].join("; ");
return findCookieValue(document.cookie, BOAT_PROFILE_COOKIE_NAME) === null;
}
function invalid(error: string): BoatProfileValidationResult {
return { valid: false, error };
}
function normalizeBoatName(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.normalize("NFC").trim();
if (
normalized.length < 1 ||
normalized.length > 80 ||
/[\u0000-\u001f\u007f-\u009f]/u.test(normalized)
) {
return null;
}
return normalized;
}
function isBoatCategory(value: unknown): value is BoatCategory {
return BOAT_CATEGORY_OPTIONS.some((option) => option.value === value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function browserCookieString(): string | undefined {
return typeof document === "undefined" ? undefined : document.cookie;
}
function browserUsesHttps(): boolean {
return typeof window !== "undefined" && window.location.protocol === "https:";
}
function findCookieValue(cookieString: string, name: string): string | null {
for (const cookie of cookieString.split(";")) {
const trimmed = cookie.trim();
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex < 0 || trimmed.slice(0, separatorIndex) !== name) {
continue;
}
return trimmed.slice(separatorIndex + 1);
}
return null;
}
+83 -4
View File
@@ -801,6 +801,64 @@ button:disabled {
color: #10242b; color: #10242b;
} }
.current-boat-summary {
border: 1px solid rgba(15, 76, 92, 0.16);
border-radius: 10px;
padding: 9px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
background: #edf4f1;
}
.current-boat-summary[data-route-profile-differs="true"] {
border-color: #d89c28;
background: #fff7e3;
}
.current-boat-summary-icon {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
background: #d8e8e3;
color: #0f4c5c;
}
.current-boat-summary > span:nth-child(2) {
min-width: 0;
display: grid;
gap: 2px;
}
.current-boat-summary strong,
.current-boat-summary small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-boat-summary strong {
color: #17343c;
font-size: 13px;
}
.current-boat-summary small {
color: #536a71;
font-size: 11px;
font-weight: 700;
}
.current-boat-summary em {
color: #805900;
font-size: 10px;
font-style: normal;
font-weight: 750;
line-height: 1.3;
}
.departure-planning { .departure-planning {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
@@ -2105,21 +2163,42 @@ button:disabled {
pointer-events: auto; pointer-events: auto;
} }
/* Map utilities are deliberately separated from the four voyage tools. */ /* Map utilities are deliberately separated from the five voyage tools. */
.app-shell .layer-toggle { .app-shell .layer-toggle {
top: calc(env(safe-area-inset-top) + 344px); top: calc(env(safe-area-inset-top) + 402px);
} }
.app-shell .locate-toggle { .app-shell .locate-toggle {
top: calc(env(safe-area-inset-top) + 344px); top: calc(env(safe-area-inset-top) + 402px);
right: 70px; right: 70px;
} }
.app-shell .layer-panel { .app-shell .layer-panel {
top: calc(env(safe-area-inset-top) + 396px); top: calc(env(safe-area-inset-top) + 454px);
right: 12px; right: 12px;
} }
@media (max-height: 620px) and (orientation: landscape) {
.navigation-tool-rail {
top: calc(env(safe-area-inset-top) + 64px);
gap: 6px;
}
.navigation-tool-rail-button {
width: 42px;
height: 42px;
}
.app-shell .layer-toggle,
.app-shell .locate-toggle {
top: calc(env(safe-area-inset-top) + 310px);
}
.app-shell .layer-panel {
top: calc(env(safe-area-inset-top) + 358px);
}
}
.app-shell[data-workspace-open="true"] .data-badge { .app-shell[data-workspace-open="true"] .data-badge {
display: none; display: none;
} }
+122
View File
@@ -9,6 +9,8 @@ vi.mock("../src/api", () => apiMocks);
import { useAnchorWatch } from "../src/hooks/useAnchorWatch"; import { useAnchorWatch } from "../src/hooks/useAnchorWatch";
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate"); const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
const originalAudioContext = Object.getOwnPropertyDescriptor(window, "AudioContext");
const originalWebkitAudioContext = Object.getOwnPropertyDescriptor(window, "webkitAudioContext");
beforeEach(() => { beforeEach(() => {
apiMocks.getNearestTide.mockReset(); apiMocks.getNearestTide.mockReset();
@@ -20,9 +22,25 @@ afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate); if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
else Reflect.deleteProperty(navigator, "vibrate"); else Reflect.deleteProperty(navigator, "vibrate");
restoreProperty(window, "AudioContext", originalAudioContext);
restoreProperty(window, "webkitAudioContext", originalWebkitAudioContext);
}); });
describe("useAnchorWatch", () => { describe("useAnchorWatch", () => {
it("uses the current boat's bow roller height instead of a separate default", () => {
const gps = gpsFix({ lat: 53.2, lon: 7.1 });
const { result, rerender } = renderHook(
({ bowRollerHeightM }) =>
useAnchorWatch(gps, { bowRollerHeightM }),
{ initialProps: { bowRollerHeightM: 1.4 } }
);
expect(result.current.settings.bowRollerHeightM).toBe(1.4);
rerender({ bowRollerHeightM: 1.8 });
expect(result.current.settings.bowRollerHeightM).toBe(1.8);
});
it("captures an immutable anchor point only after an explicit action", async () => { it("captures an immutable anchor point only after an explicit action", async () => {
const gps = gpsFix({ lat: 53.2, lon: 7.1 }); const gps = gpsFix({ lat: 53.2, lon: 7.1 });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
@@ -102,6 +120,61 @@ describe("useAnchorWatch", () => {
expect(vibrate).toHaveBeenCalledTimes(1); expect(vibrate).toHaveBeenCalledTimes(1);
}); });
it("repeats the audible drift alarm until it is explicitly acknowledged", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-26T02:00:00.000Z"));
const vibrate = vi.fn();
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
const audio = installAudioContextMock();
const initial = gpsFix({ lat: 0, lon: 0 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: initial } }
);
act(() => {
result.current.captureAnchor();
result.current.updateSettings({ alarmRadiusM: 100, deployedRodeLengthM: 100 });
});
await act(async () => {
expect(await result.current.arm()).toBe(true);
});
rerender({
value: gpsFix(
{ lat: 150 / 111_195.08, lon: 0 },
{ accuracyM: 10, timestampMs: Date.now() }
)
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.driftAlarm).toBe(true);
expect(result.current.audibleDriftAlarmActive).toBe(true);
expect(audio.start).toHaveBeenCalledTimes(3);
expect(vibrate).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(1_300);
await Promise.resolve();
});
expect(audio.start).toHaveBeenCalledTimes(6);
expect(vibrate).toHaveBeenCalledTimes(2);
act(() => result.current.acknowledgeAlarm());
expect(result.current.alarmAcknowledged).toBe(true);
expect(result.current.audibleDriftAlarmActive).toBe(false);
const startsAfterAcknowledgement = audio.start.mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(5_000);
await Promise.resolve();
});
expect(audio.start).toHaveBeenCalledTimes(startsAfterAcknowledgement);
expect(vibrate).toHaveBeenLastCalledWith(0);
});
it("keeps the positional watch usable but never confirms rode sufficiency from partial tide data", async () => { it("keeps the positional watch usable but never confirms rode sufficiency from partial tide data", async () => {
apiMocks.getNearestTide.mockResolvedValue(tideFixture(6)); apiMocks.getNearestTide.mockResolvedValue(tideFixture(6));
const { result } = renderHook(() => useAnchorWatch(gpsFix({ lat: 53.2, lon: 7.1 }))); const { result } = renderHook(() => useAnchorWatch(gpsFix({ lat: 53.2, lon: 7.1 })));
@@ -146,3 +219,52 @@ function tideFixture(hours = 30): TideSummary {
updatedAt: new Date(now).toISOString() updatedAt: new Date(now).toISOString()
}; };
} }
function installAudioContextMock() {
const start = vi.fn();
const stop = vi.fn();
const context = {
state: "running",
currentTime: 0,
destination: {},
resume: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
createOscillator: vi.fn(() => ({
type: "sine",
frequency: { value: 0 },
connect: vi.fn(),
disconnect: vi.fn(),
start,
stop,
onended: null
})),
createGain: vi.fn(() => ({
gain: {
setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn()
},
connect: vi.fn(),
disconnect: vi.fn()
}))
};
function AudioContextMock() {
return context;
}
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: AudioContextMock
});
return { context, start, stop };
}
function restoreProperty(
target: object,
property: PropertyKey,
descriptor: PropertyDescriptor | undefined
) {
if (descriptor) {
Object.defineProperty(target, property, descriptor);
} else {
Reflect.deleteProperty(target, property);
}
}
+164
View File
@@ -0,0 +1,164 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BoatProfile } from "@watermaps/shared";
import { BoatProfilePanel } from "../src/components/BoatProfilePanel";
afterEach(cleanup);
describe("BoatProfilePanel", () => {
it("shows the current profile, all routing-relevant fields and cookie information", () => {
renderPanel();
expect(screen.getByRole("heading", { name: "Aktuelles Boot" })).toBeVisible();
expect(screen.getByLabelText("Name des Bootes")).toHaveValue("Seestern");
expect(screen.getByLabelText("Kategorie")).toHaveValue("sailboat");
expect(screen.getByLabelText("Länge über alles")).toHaveValue(10.4);
expect(screen.getByLabelText("Breite")).toHaveValue(3.3);
expect(
screen.getByLabelText("Durchfahrtshöhe ab Wasserlinie")
).toHaveValue(14.2);
expect(screen.getByLabelText("Tiefgang")).toHaveValue(1.8);
expect(screen.getByLabelText("Sicherheitsreserve")).toHaveValue(0.5);
expect(screen.getByLabelText("Reisegeschwindigkeit")).toHaveValue(6);
expect(screen.getByLabelText("Bugrolle über Wasserlinie")).toHaveValue(1.1);
expect(
screen.getByRole("group", { name: "Ankern" })
).toHaveTextContent("von der Durchfahrtshöhe getrennt");
expect(screen.getByText(/in einem Cookie dieses Browsers gespeichert/)).toBeVisible();
});
it("validates, normalizes and saves the edited profile", () => {
const onSave = vi.fn(() => true);
renderPanel({ onSave, hasActiveRoute: true });
expect(screen.getByText(/Beim Speichern wird sie verworfen/)).toBeVisible();
fireEvent.change(screen.getByLabelText("Name des Bootes"), {
target: { value: " Kleine Möwe " }
});
fireEvent.change(screen.getByLabelText("Kategorie"), {
target: { value: "motor-sailer" }
});
fireEvent.change(screen.getByLabelText("Breite"), {
target: { value: "3.6" }
});
fireEvent.change(screen.getByLabelText("Bugrolle über Wasserlinie"), {
target: { value: "1.25" }
});
fireEvent.click(
screen.getByRole("button", { name: "Bootsdaten speichern" })
);
expect(onSave).toHaveBeenCalledWith({
...PROFILE,
name: "Kleine Möwe",
category: "motor-sailer",
beamM: 3.6,
bowRollerHeightM: 1.25
});
expect(screen.getByRole("status")).toHaveTextContent(
"Die Bootsdaten wurden in diesem Browser gespeichert."
);
});
it("reports invalid values without calling the persistence callback", () => {
const onSave = vi.fn(() => true);
renderPanel({ onSave });
fireEvent.change(screen.getByLabelText("Reisegeschwindigkeit"), {
target: { value: "81" }
});
fireEvent.submit(
screen.getByRole("button", { name: "Bootsdaten speichern" }).closest("form")!
);
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
"Reisegeschwindigkeit muss zwischen mehr als 0 und 80 liegen."
);
});
it("explains when the browser refuses to save the cookie", () => {
renderPanel({ onSave: vi.fn(() => false) });
fireEvent.click(
screen.getByRole("button", { name: "Bootsdaten speichern" })
);
expect(screen.getByRole("alert")).toHaveTextContent(
"nicht im Cookie gespeichert"
);
});
it("only offers deletion for stored data and requires inline confirmation", () => {
const onDelete = vi.fn(() => true);
const { rerender } = renderPanel({ stored: false, onDelete });
expect(
screen.queryByRole("button", { name: "Bootsdaten löschen" })
).not.toBeInTheDocument();
rerender(
<BoatProfilePanel
profile={PROFILE}
stored
onSave={vi.fn(() => true)}
onDelete={onDelete}
/>
);
fireEvent.click(
screen.getByRole("button", { name: "Bootsdaten löschen" })
);
const confirmation = screen.getByRole("group", {
name: "Löschen der Bootsdaten bestätigen"
});
expect(onDelete).not.toHaveBeenCalled();
fireEvent.click(
within(confirmation).getByRole("button", { name: "Abbrechen" })
);
expect(
screen.queryByRole("group", {
name: "Löschen der Bootsdaten bestätigen"
})
).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "Bootsdaten löschen" })
);
fireEvent.click(
screen.getByRole("button", { name: "Endgültig löschen" })
);
expect(onDelete).toHaveBeenCalledTimes(1);
expect(screen.getByRole("status")).toHaveTextContent(
"aus diesem Browser gelöscht"
);
});
});
const PROFILE: BoatProfile = {
name: "Seestern",
category: "sailboat",
lengthM: 10.4,
beamM: 3.3,
draughtM: 1.8,
airDraftM: 14.2,
safetyReserveM: 0.5,
cruiseSpeedKn: 6,
bowRollerHeightM: 1.1
};
function renderPanel(
overrides: Partial<Parameters<typeof BoatProfilePanel>[0]> = {}
) {
const props: Parameters<typeof BoatProfilePanel>[0] = {
profile: PROFILE,
stored: true,
onSave: vi.fn(() => true),
onDelete: vi.fn(() => true),
...overrides
};
return render(<BoatProfilePanel {...props} />);
}
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it } from "vitest";
import type { BoatProfile } from "@watermaps/shared";
import {
BOAT_PROFILE_COOKIE_NAME,
DEFAULT_BOAT_PROFILE,
readBoatProfileCookie,
serializeBoatProfileCookie,
toVesselProfile,
validateBoatProfile,
writeBoatProfileCookie
} from "../src/lib/boat-profile";
afterEach(() => {
document.cookie = `${BOAT_PROFILE_COOKIE_NAME}=; Path=/; Max-Age=0`;
});
describe("boat profile cookie", () => {
it("round-trips a validated Unicode profile", () => {
const profile: BoatProfile = {
...DEFAULT_BOAT_PROFILE,
name: "Möwe ⛵; 50%=gut",
category: "sailboat",
lengthM: 9.4,
airDraftM: 13.2
};
expect(writeBoatProfileCookie(profile)).toBe(true);
expect(readBoatProfileCookie()).toEqual(profile);
});
it("uses a versioned host cookie with safe attributes", () => {
const serialized = serializeBoatProfileCookie(DEFAULT_BOAT_PROFILE, {
secure: true,
updatedAt: "2026-07-26T12:00:00.000Z"
});
expect(serialized).toContain(`${BOAT_PROFILE_COOKIE_NAME}=`);
expect(serialized).toContain("Path=/");
expect(serialized).toContain("Max-Age=31536000");
expect(serialized).toContain("SameSite=Lax");
expect(serialized).toContain("Secure");
expect(serialized).not.toContain("Domain=");
});
it("rejects damaged, unknown-version and out-of-range data", () => {
expect(readBoatProfileCookie(`${BOAT_PROFILE_COOKIE_NAME}=%E0%A4%A`)).toBeNull();
expect(
readBoatProfileCookie(
`${BOAT_PROFILE_COOKIE_NAME}=${encodeURIComponent(JSON.stringify({
v: 2,
updatedAt: new Date().toISOString(),
profile: DEFAULT_BOAT_PROFILE
}))}`
)
).toBeNull();
expect(
validateBoatProfile({ ...DEFAULT_BOAT_PROFILE, draughtM: Number.NaN }).valid
).toBe(false);
expect(
validateBoatProfile({ ...DEFAULT_BOAT_PROFILE, airDraftM: 81 }).valid
).toBe(false);
});
it("maps only routing-relevant fields to the API profile", () => {
const vessel = toVesselProfile({
...DEFAULT_BOAT_PROFILE,
name: "Seestern",
category: "motorboat",
lengthM: 12
});
expect(vessel).toEqual({
draughtM: 1.4,
safetyReserveM: 0.5,
airDraftM: 2.5,
beamM: 3.2,
cruiseSpeedKn: 6
});
expect(vessel).not.toHaveProperty("name");
expect(vessel).not.toHaveProperty("category");
expect(vessel).not.toHaveProperty("lengthM");
});
});
+2 -2
View File
@@ -12,7 +12,7 @@ test("renders the iPhone PWA shell without overlapping core controls", async ({
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible(); await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible(); await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible(); await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click(); await page.getByText("Routenoptionen: Zwischenziele").click();
await expect(page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" })).toBeVisible(); await expect(page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" })).toBeVisible();
await expect(page.getByLabel("Abfahrt")).toBeVisible(); await expect(page.getByLabel("Abfahrt")).toBeVisible();
await page.getByText("Unterwegs: GPX · Offline · Kursalarm").click(); await page.getByText("Unterwegs: GPX · Offline · Kursalarm").click();
@@ -55,7 +55,7 @@ test("sets start and destination only after explicit map picking actions", async
await expect(page.getByText(/^Ziel \d/)).toBeVisible(); await expect(page.getByText(/^Ziel \d/)).toBeVisible();
await expect(page.getByText("Route bereit zur Prüfung")).toBeVisible(); await expect(page.getByText("Route bereit zur Prüfung")).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click(); await page.getByText("Routenoptionen: Zwischenziele").click();
await page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" }).click(); await page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" }).click();
await expect(page.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible(); await expect(page.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY); await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
+4 -1
View File
@@ -28,15 +28,18 @@ describe("NavigationToolRail", () => {
/> />
); );
const rail = screen.getByRole("navigation", { name: "Kartenwerkzeuge" }); const rail = screen.getByRole("navigation", { name: "Navigationswerkzeuge" });
const buttons = within(rail).getAllByRole("button"); const buttons = within(rail).getAllByRole("button");
expect(buttons.map((button) => button.dataset.tool)).toEqual([ expect(buttons.map((button) => button.dataset.tool)).toEqual([
"boat",
"anchor", "anchor",
"conditions", "conditions",
"upcoming", "upcoming",
"route" "route"
]); ]);
expect(screen.getByRole("button", { name: /Mein Boot, bereit, öffnen/ })).toBeVisible();
const anchor = screen.getByRole("button", { name: /Ankerwache, Alarm, öffnen/ }); const anchor = screen.getByRole("button", { name: /Ankerwache, Alarm, öffnen/ });
expect(anchor).toHaveAttribute("data-status", "alarm"); expect(anchor).toHaveAttribute("data-status", "alarm");
expect(anchor).toHaveAttribute("aria-controls", "test-workspace"); expect(anchor).toHaveAttribute("aria-controls", "test-workspace");
+16 -5
View File
@@ -2,12 +2,13 @@ import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { RoutePlanner } from "../src/components/RoutePlanner"; import { RoutePlanner } from "../src/components/RoutePlanner";
import { DEFAULT_BOAT_PROFILE } from "../src/lib/boat-profile";
import type { RouteWeatherReport } from "../src/routeWeatherReport"; import type { RouteWeatherReport } from "../src/routeWeatherReport";
afterEach(() => cleanup()); afterEach(() => cleanup());
describe("RoutePlanner", () => { describe("RoutePlanner", () => {
it("submits vessel profile when start and destination exist", () => { it("uses the current boat while submitting route coordinates", () => {
const onSubmit = vi.fn(); const onSubmit = vi.fn();
render( render(
<RoutePlanner <RoutePlanner
@@ -19,6 +20,16 @@ describe("RoutePlanner", () => {
weatherReport={null} weatherReport={null}
weatherLoading={false} weatherLoading={false}
weatherError={null} weatherError={null}
boatProfile={{
...DEFAULT_BOAT_PROFILE,
name: "Seestern",
category: "sailboat",
lengthM: 9.4,
draughtM: 1.8,
airDraftM: 13.2,
beamM: 3.3,
cruiseSpeedKn: 6.5
}}
loading={false} loading={false}
error={null} error={null}
pickMode={null} pickMode={null}
@@ -39,9 +50,10 @@ describe("RoutePlanner", () => {
start: { lat: 54.18, lon: 12.08 }, start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.2, lon: 12.1 }, destination: { lat: 54.2, lon: 12.1 },
waypoints: [], waypoints: [],
departureTime: expect.any(String), departureTime: expect.any(String)
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
}); });
expect(screen.getByRole("region", { name: "Aktuelles Boot: Seestern" })).toBeVisible();
expect(screen.queryByLabelText("Tiefgang (m)")).not.toBeInTheDocument();
}); });
it("submits ordered waypoints together with the selected departure time", () => { it("submits ordered waypoints together with the selected departure time", () => {
@@ -86,8 +98,7 @@ describe("RoutePlanner", () => {
start: { lat: 53.344167, lon: 7.186111 }, start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 51.6814536, lon: 7.8042615 }, destination: { lat: 51.6814536, lon: 7.8042615 },
waypoints, waypoints,
departureTime: new Date(localDeparture).toISOString(), departureTime: new Date(localDeparture).toISOString()
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
}); });
}); });
+26
View File
@@ -11,6 +11,32 @@ export type VesselProfile = {
cruiseSpeedKn?: number; cruiseSpeedKn?: number;
}; };
export type BoatCategory =
| "sailboat"
| "motorboat"
| "motor-sailer"
| "multihull"
| "houseboat"
| "human-powered"
| "other";
/**
* The boat selected in the client. This is deliberately separate from
* VesselProfile: identity and overall length are local profile metadata and
* must not be added to routing requests that do not need them.
*/
export type BoatProfile = {
name: string;
category: BoatCategory;
lengthM: number;
beamM: number;
draughtM: number;
airDraftM: number;
bowRollerHeightM: number;
safetyReserveM: number;
cruiseSpeedKn: number;
};
export type RouteWarningSeverity = "info" | "caution" | "critical"; export type RouteWarningSeverity = "info" | "caution" | "critical";
export type RouteWarning = { export type RouteWarning = {