Bootssymbol hinzugefügt
This commit is contained in:
@@ -5,6 +5,7 @@ iPhone-taugliche Browser-PWA für Bootsfahrer: Karte, GPS, Kompass, Wetter/Welle
|
||||
## Funktionen
|
||||
|
||||
- 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
|
||||
- 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
|
||||
@@ -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
|
||||
- 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
|
||||
|
||||
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:
|
||||
|
||||
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.
|
||||
3. Erst „Wache starten“ schaltet Positionsalarm, Warnton, Vibration, optionale Browser-Mitteilungen und – soweit unterstützt – eine Bildschirm-Wachhalteanforderung ein.
|
||||
2. Die Höhe der Bugrolle und die Bootslänge werden aus „Mein Boot“ übernommen.
|
||||
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.
|
||||
|
||||
|
||||
+77
-11
@@ -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 {
|
||||
calculateRouteGuidance,
|
||||
haversineDistanceNm,
|
||||
type BoatProfile,
|
||||
type VoyageHarbour,
|
||||
type AppConfig,
|
||||
type Coordinate,
|
||||
@@ -37,10 +38,12 @@ import type { RouteTidePlan } from "./components/RouteTidePanel";
|
||||
import { StatusBar } from "./components/StatusBar";
|
||||
import { useCompass } from "./hooks/useCompass";
|
||||
import { useAnchorWatch } from "./hooks/useAnchorWatch";
|
||||
import { useBoatProfile } from "./hooks/useBoatProfile";
|
||||
import { useCourseAssistant } from "./hooks/useCourseAssistant";
|
||||
import { useGeolocation } from "./hooks/useGeolocation";
|
||||
import { useMarineData } from "./hooks/useMarineData";
|
||||
import type { OfflineVoyage } from "./lib/offline-route";
|
||||
import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile";
|
||||
import {
|
||||
upcomingRouteEvents,
|
||||
type RouteEventEtaBasis,
|
||||
@@ -61,6 +64,11 @@ const LazyAnchorWatchPanel = lazy(() =>
|
||||
default: module.AnchorWatchPanel
|
||||
}))
|
||||
);
|
||||
const LazyBoatProfilePanel = lazy(() =>
|
||||
import("./components/BoatProfilePanel").then((module) => ({
|
||||
default: module.BoatProfilePanel
|
||||
}))
|
||||
);
|
||||
const LazyCourseAssistantPanel = lazy(() =>
|
||||
import("./components/CourseAssistantPanel").then((module) => ({
|
||||
default: module.CourseAssistantPanel
|
||||
@@ -93,7 +101,15 @@ export function App() {
|
||||
const gps = useGeolocation();
|
||||
const compass = useCompass(gps.courseDeg);
|
||||
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 [workspaceSheetState, setWorkspaceSheetState] =
|
||||
useState<NavigationSheetState>("half");
|
||||
@@ -450,6 +466,7 @@ export function App() {
|
||||
);
|
||||
const toolStatuses = useMemo<Partial<Record<NavigationToolId, NavigationToolStatus>>>(
|
||||
() => ({
|
||||
boat: boatProfileStored ? "active" : "caution",
|
||||
anchor: anchorAlarm
|
||||
? "alarm"
|
||||
: anchorWatch.phase === "idle"
|
||||
@@ -481,6 +498,7 @@ export function App() {
|
||||
[
|
||||
anchorAlarm,
|
||||
anchorWatch.phase,
|
||||
boatProfileStored,
|
||||
courseAssistant.active,
|
||||
guidanceAlarm,
|
||||
eventWarningCount,
|
||||
@@ -527,14 +545,12 @@ export function App() {
|
||||
start,
|
||||
destination: requestedDestination,
|
||||
waypoints: requestedWaypoints,
|
||||
departureTime,
|
||||
vesselProfile
|
||||
departureTime
|
||||
}: {
|
||||
start: Coordinate;
|
||||
destination: Coordinate;
|
||||
waypoints: Coordinate[];
|
||||
departureTime: string;
|
||||
vesselProfile: VesselProfile;
|
||||
}) => {
|
||||
if (!start) {
|
||||
setRouteError("Startpunkt fehlt");
|
||||
@@ -552,6 +568,7 @@ export function App() {
|
||||
routeWeatherRequestId.current += 1;
|
||||
const requestId = routeRequestId.current + 1;
|
||||
routeRequestId.current = requestId;
|
||||
const vesselProfile = toVesselProfile(currentBoat);
|
||||
|
||||
try {
|
||||
const result = await createRoute({
|
||||
@@ -581,7 +598,7 @@ export function App() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[loadRouteWeather]
|
||||
[currentBoat, loadRouteWeather]
|
||||
);
|
||||
|
||||
const clearRouteOutcome = useCallback(() => {
|
||||
@@ -597,6 +614,20 @@ export function App() {
|
||||
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) => {
|
||||
setRouteStart(coordinate);
|
||||
clearRouteOutcome();
|
||||
@@ -761,7 +792,9 @@ export function App() {
|
||||
? "GPS wird gestartet"
|
||||
: "GPS starten";
|
||||
const workspaceSummary =
|
||||
activeTool === "anchor"
|
||||
activeTool === "boat"
|
||||
? boatWorkspaceSummary(currentBoat, boatProfileStored)
|
||||
: activeTool === "anchor"
|
||||
? anchorWorkspaceSummary(anchorWatch)
|
||||
: activeTool === "conditions"
|
||||
? conditionsWorkspaceSummary(marineData.forecast, marineData.tide)
|
||||
@@ -865,6 +898,7 @@ export function App() {
|
||||
onSelect={selectTool}
|
||||
statuses={toolStatuses}
|
||||
badges={{
|
||||
boat: boatProfileStored ? null : "!",
|
||||
anchor: anchorAlarm ? "!" : null,
|
||||
upcoming: eventWarningCount,
|
||||
route: guidanceAlarm ? "!" : routeWarningCount
|
||||
@@ -875,11 +909,27 @@ export function App() {
|
||||
activeTool={activeTool}
|
||||
onClose={closeWorkspace}
|
||||
summary={workspaceSummary}
|
||||
leading={activeTool === "boat" ? <Ship size={20} /> : undefined}
|
||||
status={activeToolStatus}
|
||||
sheetState={workspaceSheetState}
|
||||
onSheetStateChange={setWorkspaceSheetState}
|
||||
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" && (
|
||||
<LazyContent
|
||||
pending={<WorkspaceLoadState label="Ankerwache" />}
|
||||
@@ -888,6 +938,7 @@ export function App() {
|
||||
<LazyAnchorWatchPanel
|
||||
watch={anchorWatch}
|
||||
gps={gps}
|
||||
boatLengthM={currentBoat.lengthM}
|
||||
onStartGps={gps.start}
|
||||
onClose={closeWorkspace}
|
||||
/>
|
||||
@@ -908,8 +959,8 @@ export function App() {
|
||||
kind: "gps",
|
||||
label:
|
||||
gps.accuracyM === null
|
||||
? "Position am Boot"
|
||||
: `Position am Boot · ±${Math.round(gps.accuracyM)} m`
|
||||
? `Position auf ${currentBoat.name}`
|
||||
: `Position auf ${currentBoat.name} · ±${Math.round(gps.accuracyM)} m`
|
||||
}
|
||||
: { kind: "unknown", label: "GPS noch nicht aktiv" }
|
||||
}
|
||||
@@ -983,6 +1034,8 @@ export function App() {
|
||||
routeTides={routeTides}
|
||||
routeTidesLoading={routeTidesLoading}
|
||||
routeTidesError={routeTidesError}
|
||||
boatProfile={currentBoat}
|
||||
routeVesselProfile={activeVesselProfile}
|
||||
onLoadOfflineVoyage={loadOfflineVoyage}
|
||||
loading={routeLoading}
|
||||
error={routeError}
|
||||
@@ -999,6 +1052,10 @@ export function App() {
|
||||
onSelectRoute={selectRouteOption}
|
||||
guidanceActive={courseAssistant.active}
|
||||
onStartGuidance={startCourseAssistant}
|
||||
onEditBoat={() => {
|
||||
setActiveTool("boat");
|
||||
setWorkspaceSheetState("half");
|
||||
}}
|
||||
onCollapse={closeWorkspace}
|
||||
operationalPanelsVisible={false}
|
||||
embedded
|
||||
@@ -1018,7 +1075,7 @@ export function App() {
|
||||
<span>
|
||||
{anchorAlarm
|
||||
? 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."
|
||||
: courseAssistant.fixStale
|
||||
? "Navigation pausiert: Der GPS-Fix ist veraltet."
|
||||
@@ -1155,6 +1212,15 @@ function conditionsToolStatus(
|
||||
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>) {
|
||||
const distanceM = watch.watchResult?.distanceFromAnchorM;
|
||||
if (watch.phase === "armed") {
|
||||
@@ -1210,7 +1276,7 @@ function routeWorkspaceSummary(
|
||||
guidance: RouteGuidanceResult | null
|
||||
) {
|
||||
if (!route) {
|
||||
return "Start, Ziel und Bootsprofil festlegen";
|
||||
return "Start und Ziel festlegen · aktuelles Boot wird verwendet";
|
||||
}
|
||||
if (guidanceActive) {
|
||||
const remainingNm = guidance ? guidance.remainingRouteDistanceM / 1_852 : null;
|
||||
|
||||
@@ -175,6 +175,26 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -21,11 +21,18 @@ type AnchorWatchModel = ReturnType<typeof useAnchorWatch>;
|
||||
export type AnchorWatchPanelProps = {
|
||||
watch: AnchorWatchModel;
|
||||
gps: Pick<GpsState, "status" | "accuracyM" | "timestampMs">;
|
||||
boatLengthM?: number;
|
||||
onStartGps: () => 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 armed = watch.phase === "armed";
|
||||
const hasAlarm = armed && (watch.positionAlarm || watch.rodeShortfall);
|
||||
@@ -83,7 +90,7 @@ export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatc
|
||||
onStop={stop}
|
||||
/>
|
||||
) : (
|
||||
<AnchorWatchSetup watch={watch} onCancel={cancel} />
|
||||
<AnchorWatchSetup watch={watch} boatLengthM={boatLengthM} onCancel={cancel} />
|
||||
)}
|
||||
</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
|
||||
? Math.ceil(Math.max(
|
||||
watch.rodePlan.horizontalReachAtSetM,
|
||||
watch.rodePlan.horizontalReachM ?? 0
|
||||
) + 5)
|
||||
) + boatAllowanceM + 5)
|
||||
: null;
|
||||
const radiusTooSmall = suggestedReachM !== null && watch.settings.alarmRadiusM < suggestedReachM;
|
||||
|
||||
@@ -161,7 +180,11 @@ function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCanc
|
||||
{radiusTooSmall && (
|
||||
<p className="anchor-inline-warning">
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
Der Radius liegt unter der rechnerischen horizontalen Reichweite von etwa {suggestedReachM} m. Normales Schwojen kann bereits alarmieren; Bootslänge und GPS-Unsicherheit kommen noch hinzu.
|
||||
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>
|
||||
)}
|
||||
{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;
|
||||
}> = [
|
||||
{ key: "depthAtSetM", label: "Tiefe beim Setzen", unit: "m", min: 0.1, max: 200, step: 0.1 },
|
||||
{ key: "bowRollerHeightM", label: "Bugrolle über Wasser", unit: "m", min: 0, max: 20, step: 0.1 },
|
||||
{ key: "deployedRodeLengthM", label: "Kette / Leine draußen", unit: "m", min: 1, max: 2_000, step: 1 },
|
||||
{ key: "safetyAllowanceM", label: "Wasserstandsreserve", unit: "m", min: 0, max: 10, step: 0.1 },
|
||||
{ key: "alarmRadiusM", label: "Alarmradius ab Anker", unit: "m", min: 10, max: 2_000, step: 5 }
|
||||
@@ -199,6 +221,10 @@ function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchMode
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<label key={field.key} htmlFor={`anchor-${field.key}`}>
|
||||
<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>
|
||||
</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 && (
|
||||
<button className="anchor-acknowledge-action" type="button" onClick={watch.acknowledgeAlarm}>
|
||||
<BellRing size={17} aria-hidden="true" />
|
||||
Alarm quittieren
|
||||
{watch.driftAlarm ? "Driftalarm quittieren" : "Alarmton quittieren"}
|
||||
</button>
|
||||
)}
|
||||
{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 && (
|
||||
<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)}`;
|
||||
}
|
||||
|
||||
function formatMetersCompact(value: number) {
|
||||
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`;
|
||||
}
|
||||
|
||||
function formatClock(timestampMs: number) {
|
||||
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;
|
||||
}
|
||||
@@ -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 = [
|
||||
"boat",
|
||||
"anchor",
|
||||
"conditions",
|
||||
"upcoming",
|
||||
@@ -18,6 +26,7 @@ type NavigationToolDefinition = {
|
||||
};
|
||||
|
||||
const NAVIGATION_TOOLS: readonly NavigationToolDefinition[] = [
|
||||
{ id: "boat", label: "Mein Boot", Icon: Ship },
|
||||
{ id: "anchor", label: "Ankerwache", Icon: Anchor },
|
||||
{ id: "conditions", label: "Wetter und Tide", Icon: CloudSun },
|
||||
{ id: "upcoming", label: "Als Nächstes", Icon: Bell },
|
||||
@@ -43,7 +52,7 @@ export function NavigationToolRail({
|
||||
disabledTools = {},
|
||||
workspaceId = "navigation-workspace",
|
||||
className,
|
||||
ariaLabel = "Kartenwerkzeuge"
|
||||
ariaLabel = "Navigationswerkzeuge"
|
||||
}: NavigationToolRailProps) {
|
||||
return (
|
||||
<nav className={classNames("navigation-tool-rail", className)} aria-label={ariaLabel}>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
buildVoyagePlan,
|
||||
VOYAGE_AMENITIES,
|
||||
voyageAmenityLabel,
|
||||
type BoatProfile,
|
||||
type Coordinate,
|
||||
type NavigationDataSnapshot,
|
||||
type RouteResult,
|
||||
@@ -32,6 +33,11 @@ import {
|
||||
} from "@watermaps/shared";
|
||||
import type { RouteWeatherReport } from "../routeWeatherReport";
|
||||
import type { OfflineVoyage } from "../lib/offline-route";
|
||||
import {
|
||||
boatCategoryLabel,
|
||||
DEFAULT_BOAT_PROFILE,
|
||||
toVesselProfile
|
||||
} from "../lib/boat-profile";
|
||||
import type { RouteLock } from "../voyageHarbours";
|
||||
import { LazyContent } from "./LazyContent";
|
||||
import { NavigationDataPanel } from "./NavigationDataPanel";
|
||||
@@ -58,6 +64,8 @@ type RoutePlannerProps = {
|
||||
routeTides?: RouteTidePlan | null;
|
||||
routeTidesLoading?: boolean;
|
||||
routeTidesError?: string | null;
|
||||
boatProfile?: BoatProfile;
|
||||
routeVesselProfile?: VesselProfile | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
pickMode: "start" | "destination" | "waypoint" | null;
|
||||
@@ -66,7 +74,6 @@ type RoutePlannerProps = {
|
||||
destination: Coordinate;
|
||||
waypoints: Coordinate[];
|
||||
departureTime: string;
|
||||
vesselProfile: VesselProfile;
|
||||
}) => void;
|
||||
onPickStart: () => void;
|
||||
onPickDestination: () => void;
|
||||
@@ -80,6 +87,7 @@ type RoutePlannerProps = {
|
||||
onSelectRoute: (routeId: string) => void;
|
||||
guidanceActive?: boolean;
|
||||
onStartGuidance?: () => void;
|
||||
onEditBoat?: () => void;
|
||||
operationalPanelsVisible?: boolean;
|
||||
embedded?: boolean;
|
||||
onCollapse: () => void;
|
||||
@@ -113,6 +121,8 @@ export function RoutePlanner({
|
||||
routeTides = null,
|
||||
routeTidesLoading = false,
|
||||
routeTidesError = null,
|
||||
boatProfile = DEFAULT_BOAT_PROFILE,
|
||||
routeVesselProfile = null,
|
||||
loading,
|
||||
error,
|
||||
pickMode,
|
||||
@@ -129,15 +139,11 @@ export function RoutePlanner({
|
||||
onSelectRoute,
|
||||
guidanceActive = false,
|
||||
onStartGuidance = () => undefined,
|
||||
onEditBoat = () => undefined,
|
||||
operationalPanelsVisible = true,
|
||||
embedded = false,
|
||||
onCollapse
|
||||
}: 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 [maxCruisingHoursPerDay, setMaxCruisingHoursPerDay] = useState(8);
|
||||
const [requiredAmenities, setRequiredAmenities] = useState<VoyageAmenity[]>(["overnight"]);
|
||||
@@ -149,6 +155,16 @@ export function RoutePlanner({
|
||||
const [voyageToolsRequested, setVoyageToolsRequested] = useState(false);
|
||||
const [sheetState, setSheetState] = useState<RouteSheetState>("half");
|
||||
const previousResult = useRef(result);
|
||||
const vesselProfile = useMemo(() => toVesselProfile(boatProfile), [boatProfile]);
|
||||
const planningVesselProfile = result && routeVesselProfile
|
||||
? routeVesselProfile
|
||||
: vesselProfile;
|
||||
const speedKn = planningVesselProfile.cruiseSpeedKn ?? boatProfile.cruiseSpeedKn;
|
||||
const routeProfileDiffers = Boolean(
|
||||
result &&
|
||||
routeVesselProfile &&
|
||||
!sameVesselProfile(routeVesselProfile, vesselProfile)
|
||||
);
|
||||
const canRoute = Boolean(startPoint && destination);
|
||||
const isPickingStart = pickMode === "start";
|
||||
const isPickingDestination = pickMode === "destination";
|
||||
@@ -387,7 +403,7 @@ export function RoutePlanner({
|
||||
open={routeOptionsOpen}
|
||||
onToggle={(event) => setRouteOptionsOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary>Routenoptionen: Boot · Zwischenziele</summary>
|
||||
<summary>Routenoptionen: Zwischenziele</summary>
|
||||
<section className="waypoint-editor" aria-label="Zwischenziele">
|
||||
<div className="waypoint-editor-heading">
|
||||
<strong>Zwischenziele</strong>
|
||||
@@ -446,65 +462,35 @@ export function RoutePlanner({
|
||||
)}
|
||||
</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>
|
||||
|
||||
<section
|
||||
className="current-boat-summary"
|
||||
aria-label={`Aktuelles Boot: ${boatProfile.name}`}
|
||||
data-route-profile-differs={routeProfileDiffers}
|
||||
>
|
||||
<span className="current-boat-summary-icon">
|
||||
<ShipWheel size={18} aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<strong>{boatProfile.name}</strong>
|
||||
<small>
|
||||
{boatCategoryLabel(boatProfile.category)} · {formatProfileMeters(boatProfile.lengthM)}
|
||||
{" × "}
|
||||
{formatProfileMeters(boatProfile.beamM)} · Tiefgang{" "}
|
||||
{formatProfileMeters(boatProfile.draughtM)}
|
||||
</small>
|
||||
{routeProfileDiffers && (
|
||||
<em role="status">
|
||||
Diese geladene Route wurde mit einem anderen Bootsprofil berechnet.
|
||||
</em>
|
||||
)}
|
||||
</span>
|
||||
<button className="secondary-action compact-action" type="button" onClick={onEditBoat}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<label className="departure-planning">
|
||||
<span>
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
@@ -529,8 +515,7 @@ export function RoutePlanner({
|
||||
start: startPoint,
|
||||
destination,
|
||||
waypoints,
|
||||
departureTime: toIsoOrNow(departureTime),
|
||||
vesselProfile: { draughtM, safetyReserveM: reserveM, airDraftM, beamM, cruiseSpeedKn: speedKn }
|
||||
departureTime: toIsoOrNow(departureTime)
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -582,6 +567,17 @@ export function RoutePlanner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{routeProfileDiffers && (
|
||||
<section className="warning-list" aria-label="Abweichendes Bootsprofil" role="alert">
|
||||
<strong>Route gehört zu einem anderen Bootsprofil</strong>
|
||||
<p>
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
Die geladene Offline-Route behält ihre damaligen Maße. Für {boatProfile.name} muss
|
||||
sie mit dem aktuellen Bootsprofil neu berechnet werden.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(error || criticalWarnings.length > 0) && (
|
||||
<section
|
||||
className="warning-list route-priority-warnings"
|
||||
@@ -913,13 +909,7 @@ export function RoutePlanner({
|
||||
start: startPoint,
|
||||
destination,
|
||||
waypoints,
|
||||
vesselProfile: {
|
||||
draughtM,
|
||||
safetyReserveM: reserveM,
|
||||
airDraftM,
|
||||
beamM,
|
||||
cruiseSpeedKn: speedKn
|
||||
},
|
||||
vesselProfile: planningVesselProfile,
|
||||
departureAt: result?.departureTime ?? toIsoOrNow(departureTime)
|
||||
}
|
||||
: undefined
|
||||
@@ -979,6 +969,20 @@ function formatMeters(value: number | null | undefined) {
|
||||
return typeof value === "number" ? `${value.toFixed(1)} m` : "-- m";
|
||||
}
|
||||
|
||||
function formatProfileMeters(value: number) {
|
||||
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`;
|
||||
}
|
||||
|
||||
function sameVesselProfile(left: VesselProfile, right: VesselProfile) {
|
||||
return (
|
||||
left.draughtM === right.draughtM &&
|
||||
left.safetyReserveM === right.safetyReserveM &&
|
||||
left.airDraftM === right.airDraftM &&
|
||||
left.beamM === right.beamM &&
|
||||
left.cruiseSpeedKn === right.cruiseSpeedKn
|
||||
);
|
||||
}
|
||||
|
||||
function formatBridgeMargin(value: number | null | undefined) {
|
||||
if (typeof value !== "number") {
|
||||
return "offen";
|
||||
|
||||
@@ -21,6 +21,10 @@ export type AnchorWatchSettings = {
|
||||
horizonHours: number;
|
||||
};
|
||||
|
||||
export type AnchorWatchBoatDefaults = {
|
||||
bowRollerHeightM?: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_ANCHOR_WATCH_SETTINGS: AnchorWatchSettings = {
|
||||
depthAtSetM: 3,
|
||||
bowRollerHeightM: 1,
|
||||
@@ -35,7 +39,8 @@ const MAX_CAPTURE_ACCURACY_M = 30;
|
||||
const MAX_FIX_AGE_MS = 10_000;
|
||||
const STALE_FIX_AFTER_MS = 20_000;
|
||||
const TIDE_REFRESH_MS = 15 * 60 * 1_000;
|
||||
const REPEAT_ALARM_MS = 60_000;
|
||||
const REPEAT_ALERT_NOTIFICATION_MS = 60_000;
|
||||
const REPEAT_AUDIBLE_DRIFT_ALARM_MS = 1_250;
|
||||
|
||||
type WakeLockSentinelLike = {
|
||||
released?: boolean;
|
||||
@@ -51,12 +56,17 @@ type NavigatorWithWakeLock = Navigator & {
|
||||
export function useAnchorWatch(gps: Pick<
|
||||
GpsState,
|
||||
"status" | "position" | "accuracyM" | "timestampMs"
|
||||
>) {
|
||||
>, boatDefaults: AnchorWatchBoatDefaults = {}) {
|
||||
const [phase, setPhase] = useState<AnchorWatchPhase>("idle");
|
||||
const [anchorPoint, setAnchorPoint] = useState<Coordinate | null>(null);
|
||||
const [anchorSetAtMs, setAnchorSetAtMs] = useState<number | null>(null);
|
||||
const [anchorCaptureAccuracyM, setAnchorCaptureAccuracyM] = useState<number | null>(null);
|
||||
const [settings, setSettings] = useState<AnchorWatchSettings>(DEFAULT_ANCHOR_WATCH_SETTINGS);
|
||||
const [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 [tide, setTide] = useState<TideSummary | null>(null);
|
||||
const [tideLoading, setTideLoading] = useState(false);
|
||||
@@ -65,6 +75,7 @@ export function useAnchorWatch(gps: Pick<
|
||||
const [alarmAcknowledged, setAlarmAcknowledged] = useState(false);
|
||||
const tideRequestIdRef = useRef(0);
|
||||
const previousAlarmRef = useRef(false);
|
||||
const previousDriftAlarmRef = useRef(false);
|
||||
const previousRodeShortfallRef = useRef(false);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
|
||||
@@ -73,6 +84,18 @@ export function useAnchorWatch(gps: Pick<
|
||||
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 now = Date.now();
|
||||
if (gps.status !== "tracking" || !gps.position || gps.timestampMs === null) {
|
||||
@@ -100,6 +123,7 @@ export function useAnchorWatch(gps: Pick<
|
||||
setTideError(null);
|
||||
setAlarmAcknowledged(false);
|
||||
previousAlarmRef.current = false;
|
||||
previousDriftAlarmRef.current = false;
|
||||
previousRodeShortfallRef.current = false;
|
||||
return true;
|
||||
}, [gps.accuracyM, gps.position, gps.status, gps.timestampMs]);
|
||||
@@ -116,6 +140,7 @@ export function useAnchorWatch(gps: Pick<
|
||||
setTideError(null);
|
||||
setAlarmAcknowledged(false);
|
||||
previousAlarmRef.current = false;
|
||||
previousDriftAlarmRef.current = false;
|
||||
previousRodeShortfallRef.current = false;
|
||||
const audioContext = audioContextRef.current;
|
||||
audioContextRef.current = null;
|
||||
@@ -139,6 +164,7 @@ export function useAnchorWatch(gps: Pick<
|
||||
setClockMs(Date.now());
|
||||
setAlarmAcknowledged(false);
|
||||
previousAlarmRef.current = false;
|
||||
previousDriftAlarmRef.current = false;
|
||||
setPhase("armed");
|
||||
audioContextRef.current = createAlarmAudioContext();
|
||||
void audioContextRef.current?.resume().catch(() => undefined);
|
||||
@@ -241,6 +267,8 @@ export function useAnchorWatch(gps: Pick<
|
||||
const positionAlarm = phase === "armed" && (
|
||||
fixStale || gpsUnavailable || gpsUnreliable || !watchResult || watchResult.alarmTriggered
|
||||
);
|
||||
const driftAlarm = phase === "armed" && Boolean(watchResult?.alarmTriggered);
|
||||
const audibleDriftAlarmActive = driftAlarm && !alarmAcknowledged;
|
||||
const rodeShortfall = Boolean(
|
||||
phase === "armed" && rodePlan?.calculationComplete && (rodePlan.rodeReserveM ?? 0) < 0
|
||||
);
|
||||
@@ -259,11 +287,41 @@ export function useAnchorWatch(gps: Pick<
|
||||
setAlarmAcknowledged(false);
|
||||
emitAnchorAlert(
|
||||
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
|
||||
audioContextRef.current
|
||||
audioContextRef.current,
|
||||
{ signal: !driftAlarm }
|
||||
);
|
||||
}
|
||||
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(() => {
|
||||
if (phase !== "armed" || !positionAlarm || alarmAcknowledged) {
|
||||
@@ -272,11 +330,21 @@ export function useAnchorWatch(gps: Pick<
|
||||
const intervalId = window.setInterval(() => {
|
||||
emitAnchorAlert(
|
||||
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
|
||||
audioContextRef.current
|
||||
audioContextRef.current,
|
||||
{ signal: !driftAlarm }
|
||||
);
|
||||
}, REPEAT_ALARM_MS);
|
||||
}, REPEAT_ALERT_NOTIFICATION_MS);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [alarmAcknowledged, fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
|
||||
}, [
|
||||
alarmAcknowledged,
|
||||
driftAlarm,
|
||||
fixStale,
|
||||
gpsUnavailable,
|
||||
gpsUnreliable,
|
||||
phase,
|
||||
positionAlarm,
|
||||
watchResult
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === "armed" && rodeShortfall && !previousRodeShortfallRef.current) {
|
||||
@@ -348,6 +416,8 @@ export function useAnchorWatch(gps: Pick<
|
||||
fixStale,
|
||||
gpsUnreliable,
|
||||
positionAlarm,
|
||||
driftAlarm,
|
||||
audibleDriftAlarmActive,
|
||||
rodeShortfall,
|
||||
alarmAcknowledged,
|
||||
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 {
|
||||
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.";
|
||||
@@ -405,11 +479,15 @@ function anchorAlertMessage({
|
||||
return "Ankerwache hat keine auswertbare Position.";
|
||||
}
|
||||
|
||||
function emitAnchorAlert(message: string, audioContext: AudioContext | null) {
|
||||
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
|
||||
navigator.vibrate([300, 120, 300, 120, 500]);
|
||||
}
|
||||
function emitAnchorAlert(
|
||||
message: string,
|
||||
audioContext: AudioContext | null,
|
||||
options: { signal?: boolean } = {}
|
||||
) {
|
||||
if (options.signal !== false) {
|
||||
emitAlarmVibration();
|
||||
playAlarmTone(audioContext);
|
||||
}
|
||||
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
|
||||
try {
|
||||
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 {
|
||||
if (typeof window === "undefined") {
|
||||
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") {
|
||||
return () => undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
const oscillators: OscillatorNode[] = [];
|
||||
const gains: GainNode[] = [];
|
||||
|
||||
void audioContext.resume().then(() => {
|
||||
if (cancelled || audioContext.state === "closed") {
|
||||
return;
|
||||
}
|
||||
void audioContext.resume().then(() => {
|
||||
const startAt = audioContext.currentTime;
|
||||
for (const offset of [0, 0.32, 0.64]) {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
oscillators.push(oscillator);
|
||||
gains.push(gain);
|
||||
oscillator.type = "square";
|
||||
oscillator.frequency.value = 880;
|
||||
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);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(audioContext.destination);
|
||||
oscillator.onended = () => {
|
||||
oscillator.disconnect();
|
||||
gain.disconnect();
|
||||
};
|
||||
oscillator.start(startAt + offset);
|
||||
oscillator.stop(startAt + offset + 0.21);
|
||||
}
|
||||
}).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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -801,6 +801,64 @@ button:disabled {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
@@ -2105,21 +2163,42 @@ button:disabled {
|
||||
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 {
|
||||
top: calc(env(safe-area-inset-top) + 344px);
|
||||
top: calc(env(safe-area-inset-top) + 402px);
|
||||
}
|
||||
|
||||
.app-shell .locate-toggle {
|
||||
top: calc(env(safe-area-inset-top) + 344px);
|
||||
top: calc(env(safe-area-inset-top) + 402px);
|
||||
right: 70px;
|
||||
}
|
||||
|
||||
.app-shell .layer-panel {
|
||||
top: calc(env(safe-area-inset-top) + 396px);
|
||||
top: calc(env(safe-area-inset-top) + 454px);
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ vi.mock("../src/api", () => apiMocks);
|
||||
import { useAnchorWatch } from "../src/hooks/useAnchorWatch";
|
||||
|
||||
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
|
||||
const originalAudioContext = Object.getOwnPropertyDescriptor(window, "AudioContext");
|
||||
const originalWebkitAudioContext = Object.getOwnPropertyDescriptor(window, "webkitAudioContext");
|
||||
|
||||
beforeEach(() => {
|
||||
apiMocks.getNearestTide.mockReset();
|
||||
@@ -20,9 +22,25 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
|
||||
else Reflect.deleteProperty(navigator, "vibrate");
|
||||
restoreProperty(window, "AudioContext", originalAudioContext);
|
||||
restoreProperty(window, "webkitAudioContext", originalWebkitAudioContext);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const gps = gpsFix({ lat: 53.2, lon: 7.1 });
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -102,6 +120,61 @@ describe("useAnchorWatch", () => {
|
||||
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 () => {
|
||||
apiMocks.getNearestTide.mockResolvedValue(tideFixture(6));
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("button", { name: "Start 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.getByLabel("Abfahrt")).toBeVisible();
|
||||
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("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 expect(page.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
|
||||
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
|
||||
|
||||
@@ -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");
|
||||
expect(buttons.map((button) => button.dataset.tool)).toEqual([
|
||||
"boat",
|
||||
"anchor",
|
||||
"conditions",
|
||||
"upcoming",
|
||||
"route"
|
||||
]);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Mein Boot, bereit, öffnen/ })).toBeVisible();
|
||||
|
||||
const anchor = screen.getByRole("button", { name: /Ankerwache, Alarm, öffnen/ });
|
||||
expect(anchor).toHaveAttribute("data-status", "alarm");
|
||||
expect(anchor).toHaveAttribute("aria-controls", "test-workspace");
|
||||
|
||||
@@ -2,12 +2,13 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RoutePlanner } from "../src/components/RoutePlanner";
|
||||
import { DEFAULT_BOAT_PROFILE } from "../src/lib/boat-profile";
|
||||
import type { RouteWeatherReport } from "../src/routeWeatherReport";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("RoutePlanner", () => {
|
||||
it("submits vessel profile when start and destination exist", () => {
|
||||
it("uses the current boat while submitting route coordinates", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<RoutePlanner
|
||||
@@ -19,6 +20,16 @@ describe("RoutePlanner", () => {
|
||||
weatherReport={null}
|
||||
weatherLoading={false}
|
||||
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}
|
||||
error={null}
|
||||
pickMode={null}
|
||||
@@ -39,9 +50,10 @@ describe("RoutePlanner", () => {
|
||||
start: { lat: 54.18, lon: 12.08 },
|
||||
destination: { lat: 54.2, lon: 12.1 },
|
||||
waypoints: [],
|
||||
departureTime: expect.any(String),
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
|
||||
departureTime: expect.any(String)
|
||||
});
|
||||
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", () => {
|
||||
@@ -86,8 +98,7 @@ describe("RoutePlanner", () => {
|
||||
start: { lat: 53.344167, lon: 7.186111 },
|
||||
destination: { lat: 51.6814536, lon: 7.8042615 },
|
||||
waypoints,
|
||||
departureTime: new Date(localDeparture).toISOString(),
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
|
||||
departureTime: new Date(localDeparture).toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,32 @@ export type VesselProfile = {
|
||||
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 RouteWarning = {
|
||||
|
||||
Reference in New Issue
Block a user