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