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

This commit is contained in:
BuTzZ
2026-07-26 12:31:49 +02:00
parent c9a20aacd7
commit 55ce94066e
19 changed files with 1969 additions and 122 deletions
+77 -11
View File
@@ -1,8 +1,9 @@
import { AlertTriangle, Compass, Navigation, ShieldAlert } from "lucide-react";
import { AlertTriangle, Compass, Navigation, ShieldAlert, Ship } from "lucide-react";
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
calculateRouteGuidance,
haversineDistanceNm,
type BoatProfile,
type VoyageHarbour,
type AppConfig,
type Coordinate,
@@ -37,10 +38,12 @@ import type { RouteTidePlan } from "./components/RouteTidePanel";
import { StatusBar } from "./components/StatusBar";
import { useCompass } from "./hooks/useCompass";
import { useAnchorWatch } from "./hooks/useAnchorWatch";
import { useBoatProfile } from "./hooks/useBoatProfile";
import { useCourseAssistant } from "./hooks/useCourseAssistant";
import { useGeolocation } from "./hooks/useGeolocation";
import { useMarineData } from "./hooks/useMarineData";
import type { OfflineVoyage } from "./lib/offline-route";
import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile";
import {
upcomingRouteEvents,
type RouteEventEtaBasis,
@@ -61,6 +64,11 @@ const LazyAnchorWatchPanel = lazy(() =>
default: module.AnchorWatchPanel
}))
);
const LazyBoatProfilePanel = lazy(() =>
import("./components/BoatProfilePanel").then((module) => ({
default: module.BoatProfilePanel
}))
);
const LazyCourseAssistantPanel = lazy(() =>
import("./components/CourseAssistantPanel").then((module) => ({
default: module.CourseAssistantPanel
@@ -93,7 +101,15 @@ export function App() {
const gps = useGeolocation();
const compass = useCompass(gps.courseDeg);
const marineData = useMarineData(gps.position);
const anchorWatch = useAnchorWatch(gps);
const {
profile: currentBoat,
stored: boatProfileStored,
save: persistBoatProfile,
remove: removeBoatProfile
} = useBoatProfile();
const anchorWatch = useAnchorWatch(gps, {
bowRollerHeightM: currentBoat.bowRollerHeightM
});
const [activeTool, setActiveTool] = useState<ActiveTool>("route");
const [workspaceSheetState, setWorkspaceSheetState] =
useState<NavigationSheetState>("half");
@@ -450,6 +466,7 @@ export function App() {
);
const toolStatuses = useMemo<Partial<Record<NavigationToolId, NavigationToolStatus>>>(
() => ({
boat: boatProfileStored ? "active" : "caution",
anchor: anchorAlarm
? "alarm"
: anchorWatch.phase === "idle"
@@ -481,6 +498,7 @@ export function App() {
[
anchorAlarm,
anchorWatch.phase,
boatProfileStored,
courseAssistant.active,
guidanceAlarm,
eventWarningCount,
@@ -527,14 +545,12 @@ export function App() {
start,
destination: requestedDestination,
waypoints: requestedWaypoints,
departureTime,
vesselProfile
departureTime
}: {
start: Coordinate;
destination: Coordinate;
waypoints: Coordinate[];
departureTime: string;
vesselProfile: VesselProfile;
}) => {
if (!start) {
setRouteError("Startpunkt fehlt");
@@ -552,6 +568,7 @@ export function App() {
routeWeatherRequestId.current += 1;
const requestId = routeRequestId.current + 1;
routeRequestId.current = requestId;
const vesselProfile = toVesselProfile(currentBoat);
try {
const result = await createRoute({
@@ -581,7 +598,7 @@ export function App() {
}
}
},
[loadRouteWeather]
[currentBoat, loadRouteWeather]
);
const clearRouteOutcome = useCallback(() => {
@@ -597,6 +614,20 @@ export function App() {
setRouteLoading(false);
}, []);
const saveCurrentBoat = useCallback((profile: BoatProfile) => {
const stored = persistBoatProfile(profile);
clearRouteOutcome();
return stored;
}, [clearRouteOutcome, persistBoatProfile]);
const deleteCurrentBoat = useCallback(() => {
const removed = removeBoatProfile();
if (removed) {
clearRouteOutcome();
}
return removed;
}, [clearRouteOutcome, removeBoatProfile]);
const setRouteStartPoint = useCallback((coordinate: Coordinate) => {
setRouteStart(coordinate);
clearRouteOutcome();
@@ -761,7 +792,9 @@ export function App() {
? "GPS wird gestartet"
: "GPS starten";
const workspaceSummary =
activeTool === "anchor"
activeTool === "boat"
? boatWorkspaceSummary(currentBoat, boatProfileStored)
: activeTool === "anchor"
? anchorWorkspaceSummary(anchorWatch)
: activeTool === "conditions"
? conditionsWorkspaceSummary(marineData.forecast, marineData.tide)
@@ -865,6 +898,7 @@ export function App() {
onSelect={selectTool}
statuses={toolStatuses}
badges={{
boat: boatProfileStored ? null : "!",
anchor: anchorAlarm ? "!" : null,
upcoming: eventWarningCount,
route: guidanceAlarm ? "!" : routeWarningCount
@@ -875,11 +909,27 @@ export function App() {
activeTool={activeTool}
onClose={closeWorkspace}
summary={workspaceSummary}
leading={activeTool === "boat" ? <Ship size={20} /> : undefined}
status={activeToolStatus}
sheetState={workspaceSheetState}
onSheetStateChange={setWorkspaceSheetState}
busy={workspaceBusy}
>
{activeTool === "boat" && (
<LazyContent
pending={<WorkspaceLoadState label="Mein Boot" />}
failed={<WorkspaceLoadState label="Mein Boot" failed />}
>
<LazyBoatProfilePanel
profile={currentBoat}
stored={boatProfileStored}
hasActiveRoute={Boolean(route)}
onSave={saveCurrentBoat}
onDelete={deleteCurrentBoat}
/>
</LazyContent>
)}
{activeTool === "anchor" && (
<LazyContent
pending={<WorkspaceLoadState label="Ankerwache" />}
@@ -888,6 +938,7 @@ export function App() {
<LazyAnchorWatchPanel
watch={anchorWatch}
gps={gps}
boatLengthM={currentBoat.lengthM}
onStartGps={gps.start}
onClose={closeWorkspace}
/>
@@ -908,8 +959,8 @@ export function App() {
kind: "gps",
label:
gps.accuracyM === null
? "Position am Boot"
: `Position am Boot · ±${Math.round(gps.accuracyM)} m`
? `Position auf ${currentBoat.name}`
: `Position auf ${currentBoat.name} · ±${Math.round(gps.accuracyM)} m`
}
: { kind: "unknown", label: "GPS noch nicht aktiv" }
}
@@ -983,6 +1034,8 @@ export function App() {
routeTides={routeTides}
routeTidesLoading={routeTidesLoading}
routeTidesError={routeTidesError}
boatProfile={currentBoat}
routeVesselProfile={activeVesselProfile}
onLoadOfflineVoyage={loadOfflineVoyage}
loading={routeLoading}
error={routeError}
@@ -999,6 +1052,10 @@ export function App() {
onSelectRoute={selectRouteOption}
guidanceActive={courseAssistant.active}
onStartGuidance={startCourseAssistant}
onEditBoat={() => {
setActiveTool("boat");
setWorkspaceSheetState("half");
}}
onCollapse={closeWorkspace}
operationalPanelsVisible={false}
embedded
@@ -1018,7 +1075,7 @@ export function App() {
<span>
{anchorAlarm
? anchorWatch.positionAlarm
? "Ankeralarm: Das Boot liegt außerhalb des überwachten Radius."
? `Ankeralarm: ${currentBoat.name} liegt außerhalb des überwachten Radius.`
: "Ankerwarnung: Die gegebene Kette oder Leine reicht für den erwarteten Wasserstand nicht aus."
: courseAssistant.fixStale
? "Navigation pausiert: Der GPS-Fix ist veraltet."
@@ -1155,6 +1212,15 @@ function conditionsToolStatus(
return Date.now() - oldestTimestamp > 90 * 60_000 ? "stale" : "active";
}
function boatWorkspaceSummary(profile: BoatProfile, stored: boolean) {
if (!stored) {
return "Vorgabewerte aktiv · bitte Bootsdaten speichern";
}
return `${profile.name} · ${boatCategoryLabel(profile.category)} · ${profile.lengthM.toLocaleString("de-DE", {
maximumFractionDigits: 2
})} × ${profile.beamM.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`;
}
function anchorWorkspaceSummary(watch: ReturnType<typeof useAnchorWatch>) {
const distanceM = watch.watchResult?.distanceFromAnchorM;
if (watch.phase === "armed") {
@@ -1210,7 +1276,7 @@ function routeWorkspaceSummary(
guidance: RouteGuidanceResult | null
) {
if (!route) {
return "Start, Ziel und Bootsprofil festlegen";
return "Start und Ziel festlegen · aktuelles Boot wird verwendet";
}
if (guidanceActive) {
const remainingNm = guidance ? guidance.remainingRouteDistanceM / 1_852 : null;
@@ -175,6 +175,26 @@
font-weight: 850;
}
.anchor-profile-setting {
min-width: 0;
min-height: 58px;
border: 1px solid #c5d8d2;
border-radius: 9px;
padding: 7px 8px;
display: grid;
align-content: center;
gap: 4px;
background: #e3efeb;
color: #526a72;
font-size: 12px;
font-weight: 850;
}
.anchor-profile-setting strong {
color: #0f4c5c;
font-size: 13px;
}
.anchor-settings label > span:last-child {
display: flex;
align-items: center;
+45 -8
View File
@@ -21,11 +21,18 @@ type AnchorWatchModel = ReturnType<typeof useAnchorWatch>;
export type AnchorWatchPanelProps = {
watch: AnchorWatchModel;
gps: Pick<GpsState, "status" | "accuracyM" | "timestampMs">;
boatLengthM?: number;
onStartGps: () => void;
onClose: () => void;
};
export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatchPanelProps) {
export function AnchorWatchPanel({
watch,
gps,
boatLengthM,
onStartGps,
onClose
}: AnchorWatchPanelProps) {
const [confirmStop, setConfirmStop] = useState(false);
const armed = watch.phase === "armed";
const hasAlarm = armed && (watch.positionAlarm || watch.rodeShortfall);
@@ -83,7 +90,7 @@ export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatc
onStop={stop}
/>
) : (
<AnchorWatchSetup watch={watch} onCancel={cancel} />
<AnchorWatchSetup watch={watch} boatLengthM={boatLengthM} onCancel={cancel} />
)}
</aside>
);
@@ -129,12 +136,24 @@ function AnchorCapture({
);
}
function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCancel: () => void }) {
function AnchorWatchSetup({
watch,
boatLengthM,
onCancel
}: {
watch: AnchorWatchModel;
boatLengthM?: number;
onCancel: () => void;
}) {
const boatAllowanceM =
typeof boatLengthM === "number" && Number.isFinite(boatLengthM) && boatLengthM > 0
? boatLengthM
: 0;
const suggestedReachM = watch.rodePlan
? Math.ceil(Math.max(
watch.rodePlan.horizontalReachAtSetM,
watch.rodePlan.horizontalReachM ?? 0
) + 5)
) + boatAllowanceM + 5)
: null;
const radiusTooSmall = suggestedReachM !== null && watch.settings.alarmRadiusM < suggestedReachM;
@@ -161,7 +180,11 @@ function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCanc
{radiusTooSmall && (
<p className="anchor-inline-warning">
<AlertTriangle size={15} aria-hidden="true" />
Der Radius liegt unter der rechnerischen horizontalen Reichweite von etwa {suggestedReachM} m. Normales Schwojen kann bereits alarmieren; Bootslänge und GPS-Unsicherheit kommen noch hinzu.
Der Radius liegt unter der rechnerischen Reichweite von etwa {suggestedReachM} m
{boatAllowanceM > 0
? ` einschließlich ${formatMetersCompact(boatAllowanceM)} Bootslänge`
: ""}. Normales Schwojen kann bereits alarmieren; 5 m Zusatzpuffer sind berücksichtigt,
die aktuelle GPS-Unsicherheit muss zusätzlich beachtet werden.
</p>
)}
{watch.settingsError && <p className="anchor-inline-alert" role="alert">{watch.settingsError}</p>}
@@ -191,7 +214,6 @@ function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchMode
step: number;
}> = [
{ key: "depthAtSetM", label: "Tiefe beim Setzen", unit: "m", min: 0.1, max: 200, step: 0.1 },
{ key: "bowRollerHeightM", label: "Bugrolle über Wasser", unit: "m", min: 0, max: 20, step: 0.1 },
{ key: "deployedRodeLengthM", label: "Kette / Leine draußen", unit: "m", min: 1, max: 2_000, step: 1 },
{ key: "safetyAllowanceM", label: "Wasserstandsreserve", unit: "m", min: 0, max: 10, step: 0.1 },
{ key: "alarmRadiusM", label: "Alarmradius ab Anker", unit: "m", min: 10, max: 2_000, step: 5 }
@@ -199,6 +221,10 @@ function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchMode
return (
<div className="anchor-settings" data-compact={compact}>
<div className="anchor-profile-setting">
<span>Bugrolle über Wasser</span>
<strong>{formatMetersCompact(watch.settings.bowRollerHeightM)} · Mein Boot</strong>
</div>
{fields.map((field) => (
<label key={field.key} htmlFor={`anchor-${field.key}`}>
<span>{field.label}</span>
@@ -347,14 +373,21 @@ function AnchorWatchActive({
<span><small>LEINE</small><strong>{watch.rodePlan?.rodeReserveM === null || watch.rodePlan?.rodeReserveM === undefined ? "offen" : `${watch.rodePlan.rodeReserveM >= 0 ? "+" : ""}${watch.rodePlan.rodeReserveM.toFixed(1)} m`}</strong></span>
</div>
{watch.audibleDriftAlarmActive && (
<p className="anchor-inline-alert" role="alert">
Akustischer Driftalarm aktiv · ertönt bis zur ausdrücklichen Quittierung.
</p>
)}
{watch.positionAlarm && !watch.alarmAcknowledged && (
<button className="anchor-acknowledge-action" type="button" onClick={watch.acknowledgeAlarm}>
<BellRing size={17} aria-hidden="true" />
Alarm quittieren
{watch.driftAlarm ? "Driftalarm quittieren" : "Alarmton quittieren"}
</button>
)}
{watch.positionAlarm && watch.alarmAcknowledged && (
<p className="anchor-acknowledged">Alarmton quittiert · rote Warnanzeige bleibt aktiv</p>
<p className="anchor-acknowledged">
{watch.driftAlarm ? "Driftalarm quittiert" : "Alarmton quittiert"} · rote Warnanzeige bleibt aktiv
</p>
)}
{watch.rodeShortfall && (
<p className="anchor-inline-alert" role="alert">
@@ -417,6 +450,10 @@ function formatCoordinate(coordinate: { lat: number; lon: number }) {
return `${coordinate.lat.toFixed(5)}, ${coordinate.lon.toFixed(5)}`;
}
function formatMetersCompact(value: number) {
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`;
}
function formatClock(timestampMs: number) {
return new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(timestampMs);
}
@@ -0,0 +1,293 @@
.boat-profile-panel {
display: grid;
gap: 12px;
color: #10242b;
}
.boat-profile-panel-intro {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 10px;
}
.boat-profile-panel-icon {
width: 42px;
height: 42px;
border-radius: 11px;
display: grid;
place-items: center;
background: #dceee6;
color: #196f5c;
}
.boat-profile-panel-title {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 9px;
}
.boat-profile-panel-title h2 {
margin: 0;
color: #17343c;
font-size: 18px;
}
.boat-profile-panel-title span {
min-height: 24px;
border-radius: 999px;
padding: 0 8px;
display: inline-flex;
align-items: center;
background: #fff1cc;
color: #805900;
font-size: 10px;
font-weight: 850;
}
.boat-profile-panel-title span[data-stored="true"] {
background: #dceee6;
color: #196f5c;
}
.boat-profile-panel-intro p {
margin: 4px 0 0;
color: #526a72;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
}
.boat-profile-route-warning,
.boat-profile-cookie-note,
.boat-profile-feedback {
margin: 0;
border-radius: 9px;
padding: 9px 10px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 8px;
font-size: 12px;
line-height: 1.4;
}
.boat-profile-route-warning {
border: 1px solid rgba(128, 89, 0, 0.18);
background: #fff1cc;
color: #805900;
}
.boat-profile-route-warning p,
.boat-profile-cookie-note p {
margin: 0;
}
.boat-profile-form {
display: grid;
gap: 12px;
}
.boat-profile-identity,
.boat-profile-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.boat-profile-field {
min-width: 0;
display: grid;
align-content: start;
gap: 5px;
color: #314b54;
}
.boat-profile-field > label {
font-size: 12px;
font-weight: 850;
}
.boat-profile-field input,
.boat-profile-field select {
width: 100%;
height: 44px;
min-width: 0;
border: 1px solid rgba(18, 46, 55, 0.22);
border-radius: 8px;
padding: 0 9px;
background: #ffffff;
color: #10242b;
}
.boat-profile-field select {
cursor: pointer;
}
.boat-profile-field input:invalid:not(:focus),
.boat-profile-field select:invalid:not(:focus) {
border-color: rgba(157, 44, 34, 0.6);
}
.boat-profile-field small {
min-height: 30px;
color: #607278;
font-size: 10px;
font-weight: 700;
line-height: 1.35;
}
.boat-profile-dimensions {
min-width: 0;
margin: 0;
border: 1px solid rgba(15, 76, 92, 0.13);
border-radius: 11px;
padding: 10px;
display: grid;
gap: 9px;
background: rgba(237, 243, 241, 0.72);
}
.boat-profile-dimensions legend {
padding: 0 5px;
color: #17343c;
font-size: 13px;
font-weight: 900;
}
.boat-profile-dimensions > p {
margin: 0;
color: #526a72;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.boat-profile-anchor-details .boat-profile-field-grid {
grid-template-columns: minmax(0, 1fr);
}
.boat-profile-anchor-details .boat-profile-field {
max-width: 360px;
}
.boat-profile-number-input {
position: relative;
display: block;
}
.boat-profile-number-input input {
padding-right: 38px;
}
.boat-profile-number-input > span {
position: absolute;
top: 50%;
right: 10px;
color: #607278;
font-size: 11px;
font-weight: 900;
transform: translateY(-50%);
pointer-events: none;
}
.boat-profile-cookie-note {
border: 1px solid rgba(15, 76, 92, 0.12);
background: #e8f1f3;
color: #3d5962;
}
.boat-profile-feedback {
align-items: center;
background: #dceee6;
color: #196f5c;
font-weight: 800;
}
.boat-profile-feedback[data-kind="error"] {
background: #ffe1dc;
color: #9d2c22;
}
.boat-profile-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.boat-profile-actions > button,
.boat-profile-delete-confirmation button {
min-height: 44px;
border-radius: 8px;
padding: 7px 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 12px;
font-weight: 850;
}
.boat-profile-save {
background: #0f4c5c;
color: #ffffff;
}
.boat-profile-delete {
border: 1px solid rgba(157, 44, 34, 0.24);
background: #fff0ed;
color: #9d2c22;
}
.boat-profile-actions > .boat-profile-save:only-child {
grid-column: 1 / -1;
}
.boat-profile-delete-confirmation {
border: 1px solid rgba(157, 44, 34, 0.26);
border-radius: 10px;
padding: 10px;
display: grid;
gap: 9px;
background: #fff0ed;
color: #81251d;
}
.boat-profile-delete-confirmation p {
margin: 0;
font-size: 12px;
font-weight: 750;
line-height: 1.4;
}
.boat-profile-delete-confirmation > div {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 7px;
}
.boat-profile-delete-confirmation button {
border: 1px solid rgba(157, 44, 34, 0.2);
background: #ffffff;
color: #81251d;
}
.boat-profile-delete-confirmation button:last-child {
background: #9d2c22;
color: #ffffff;
}
@media (max-width: 420px) {
.boat-profile-identity,
.boat-profile-field-grid,
.boat-profile-actions {
grid-template-columns: 1fr;
}
.boat-profile-actions > .boat-profile-save:only-child {
grid-column: auto;
}
}
@@ -0,0 +1,459 @@
import {
AlertTriangle,
CheckCircle2,
Cookie,
Save,
Sailboat,
Trash2,
X
} from "lucide-react";
import { useEffect, useId, useState, type FormEvent } from "react";
import type { BoatCategory, BoatProfile } from "@watermaps/shared";
import {
BOAT_CATEGORY_OPTIONS,
validateBoatProfile
} from "../lib/boat-profile";
import "./BoatProfilePanel.css";
export type BoatProfilePanelProps = {
profile: BoatProfile;
stored: boolean;
hasActiveRoute?: boolean;
onSave: (profile: BoatProfile) => boolean;
onDelete: () => boolean;
};
type Feedback = {
kind: "success" | "error";
message: string;
};
type BoatProfileDraft = {
name: string;
category: BoatCategory;
lengthM: string;
beamM: string;
airDraftM: string;
draughtM: string;
safetyReserveM: string;
cruiseSpeedKn: string;
bowRollerHeightM: string;
};
type NumericDraftKey = Exclude<keyof BoatProfileDraft, "name" | "category">;
export function BoatProfilePanel({
profile,
stored,
hasActiveRoute = false,
onSave,
onDelete
}: BoatProfilePanelProps) {
const headingId = useId();
const categoryHelpId = useId();
const deleteConfirmationId = useId();
const [draft, setDraft] = useState<BoatProfileDraft>(() => toDraft(profile));
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
setDraft(toDraft(profile));
}, [profile]);
useEffect(() => {
if (!stored) {
setConfirmDelete(false);
}
}, [stored]);
const updateName = (name: string) => {
setDraft((current) => ({ ...current, name }));
setFeedback(null);
};
const updateCategory = (category: BoatCategory) => {
setDraft((current) => ({ ...current, category }));
setFeedback(null);
};
const updateNumber = (key: NumericDraftKey, value: string) => {
setDraft((current) => ({ ...current, [key]: value }));
setFeedback(null);
};
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setConfirmDelete(false);
const validation = validateBoatProfile(toProfile(draft));
if (!validation.valid) {
setFeedback({ kind: "error", message: validation.error });
return;
}
if (!onSave(validation.profile)) {
setFeedback({
kind: "error",
message:
"Die Bootsdaten konnten nicht im Cookie gespeichert werden. Bitte prüfe die Cookie-Einstellungen dieses Browsers."
});
return;
}
setDraft(toDraft(validation.profile));
setFeedback({
kind: "success",
message: "Die Bootsdaten wurden in diesem Browser gespeichert."
});
};
const deleteProfile = () => {
if (!onDelete()) {
setFeedback({
kind: "error",
message:
"Die gespeicherten Bootsdaten konnten nicht gelöscht werden. Bitte prüfe die Cookie-Einstellungen dieses Browsers."
});
return;
}
setConfirmDelete(false);
setFeedback({
kind: "success",
message: "Die gespeicherten Bootsdaten wurden aus diesem Browser gelöscht."
});
};
return (
<section className="boat-profile-panel" aria-labelledby={headingId}>
<header className="boat-profile-panel-intro">
<span className="boat-profile-panel-icon" aria-hidden="true">
<Sailboat size={23} />
</span>
<div>
<div className="boat-profile-panel-title">
<h2 id={headingId}>Aktuelles Boot</h2>
<span data-stored={stored}>
{stored ? "Im Cookie gespeichert" : "Noch nicht gespeichert"}
</span>
</div>
<p>
Diese Werte gelten automatisch für neue Routen und die weiteren
Navigationswerkzeuge.
</p>
</div>
</header>
{hasActiveRoute && (
<div className="boat-profile-route-warning">
<AlertTriangle size={18} aria-hidden="true" />
<p>
<strong>Aktive Route:</strong> Beim Speichern wird sie verworfen,
weil sie noch mit den bisherigen Bootsdaten berechnet wurde.
</p>
</div>
)}
<form className="boat-profile-form" onSubmit={save}>
<div className="boat-profile-identity">
<TextField
label="Name des Bootes"
help="Wird in der App als aktuelles Boot angezeigt."
value={draft.name}
onChange={updateName}
/>
<div className="boat-profile-field">
<label htmlFor={`${categoryHelpId}-input`}>Kategorie</label>
<select
id={`${categoryHelpId}-input`}
value={draft.category}
onChange={(event) =>
updateCategory(event.target.value as BoatCategory)
}
aria-describedby={categoryHelpId}
required
>
{BOAT_CATEGORY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<small id={categoryHelpId}>
Beschreibt den Bootstyp; beeinflusst das Routing derzeit nicht.
</small>
</div>
</div>
<fieldset className="boat-profile-dimensions">
<legend>Abmessungen und Fahrtwerte</legend>
<p>
Bitte die Werte für den normalen Fahrzustand eintragen. Sie
beeinflussen Durchfahrt, Tiefenprüfung und Fahrtdauer.
</p>
<div className="boat-profile-field-grid">
<NumberField
label="Länge über alles"
help="Größte feste Länge von Bug bis Heck."
unit="m"
min={0.01}
max={400}
value={draft.lengthM}
onChange={(value) => updateNumber("lengthM", value)}
/>
<NumberField
label="Breite"
help="Größte Breite des Bootes."
unit="m"
min={0.01}
max={80}
value={draft.beamM}
onChange={(value) => updateNumber("beamM", value)}
/>
<NumberField
label="Durchfahrtshöhe ab Wasserlinie"
help="Bis zum höchsten festen Punkt; wichtig für Brücken."
unit="m"
min={0.01}
max={80}
value={draft.airDraftM}
onChange={(value) => updateNumber("airDraftM", value)}
/>
<NumberField
label="Tiefgang"
help="Wasserlinie bis zum tiefsten Punkt."
unit="m"
min={0.01}
max={15}
value={draft.draughtM}
onChange={(value) => updateNumber("draughtM", value)}
/>
<NumberField
label="Sicherheitsreserve"
help="Zusätzlicher Abstand zum Grund beim Routing."
unit="m"
min={0}
max={10}
value={draft.safetyReserveM}
onChange={(value) => updateNumber("safetyReserveM", value)}
/>
<NumberField
label="Reisegeschwindigkeit"
help="Normale, dauerhaft fahrbare Geschwindigkeit."
unit="kn"
min={0.01}
max={80}
value={draft.cruiseSpeedKn}
onChange={(value) => updateNumber("cruiseSpeedKn", value)}
/>
</div>
</fieldset>
<fieldset className="boat-profile-dimensions boat-profile-anchor-details">
<legend>Ankern</legend>
<p>
Dieser Wert ist von der Durchfahrtshöhe getrennt und wird für die
Ketten- und Trossenberechnung verwendet.
</p>
<div className="boat-profile-field-grid">
<NumberField
label="Bugrolle über Wasserlinie"
help="Senkrechter Abstand von der Wasserlinie bis zum Austritt der Ankerkette."
unit="m"
min={0}
max={20}
value={draft.bowRollerHeightM}
onChange={(value) => updateNumber("bowRollerHeightM", value)}
/>
</div>
</fieldset>
<div className="boat-profile-cookie-note">
<Cookie size={18} aria-hidden="true" />
<p>
Die Bootsdaten werden in einem Cookie dieses Browsers gespeichert
und bei Anfragen an Watermaps mitgesendet. Keine persönlichen Daten
eintragen. Die Daten lassen sich hier jederzeit löschen.
</p>
</div>
{feedback && (
<p
className="boat-profile-feedback"
data-kind={feedback.kind}
role={feedback.kind === "error" ? "alert" : "status"}
aria-live="polite"
>
{feedback.kind === "success" ? (
<CheckCircle2 size={17} aria-hidden="true" />
) : (
<AlertTriangle size={17} aria-hidden="true" />
)}
{feedback.message}
</p>
)}
<div className="boat-profile-actions">
{stored && (
<button
className="boat-profile-delete"
type="button"
onClick={() => {
setConfirmDelete(true);
setFeedback(null);
}}
aria-expanded={confirmDelete}
aria-controls={deleteConfirmationId}
>
<Trash2 size={17} aria-hidden="true" />
Bootsdaten löschen
</button>
)}
<button className="boat-profile-save" type="submit">
<Save size={17} aria-hidden="true" />
Bootsdaten speichern
</button>
</div>
{stored && confirmDelete && (
<div
id={deleteConfirmationId}
className="boat-profile-delete-confirmation"
role="group"
aria-label="Löschen der Bootsdaten bestätigen"
>
<p>
Bootsdaten wirklich aus diesem Browser löschen? Neue Routen
verwenden danach bis zum erneuten Speichern wieder die
Standardwerte.
</p>
<div>
<button
type="button"
onClick={() => setConfirmDelete(false)}
>
<X size={16} aria-hidden="true" />
Abbrechen
</button>
<button type="button" onClick={deleteProfile}>
<Trash2 size={16} aria-hidden="true" />
Endgültig löschen
</button>
</div>
</div>
)}
</form>
</section>
);
}
function TextField({
label,
help,
value,
onChange
}: {
label: string;
help: string;
value: string;
onChange: (value: string) => void;
}) {
const id = useId();
const helpId = `${id}-help`;
return (
<div className="boat-profile-field">
<label htmlFor={id}>{label}</label>
<input
id={id}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
aria-describedby={helpId}
autoComplete="off"
minLength={1}
maxLength={80}
required
/>
<small id={helpId}>{help}</small>
</div>
);
}
function NumberField({
label,
help,
unit,
min,
max,
value,
onChange
}: {
label: string;
help: string;
unit: "m" | "kn";
min: number;
max: number;
value: string;
onChange: (value: string) => void;
}) {
const id = useId();
const helpId = `${id}-help`;
return (
<div className="boat-profile-field">
<label htmlFor={id}>{label}</label>
<span className="boat-profile-number-input">
<input
id={id}
type="number"
inputMode="decimal"
value={value}
onChange={(event) => onChange(event.target.value)}
aria-describedby={helpId}
min={min}
max={max}
step="any"
required
/>
<span aria-hidden="true">{unit}</span>
</span>
<small id={helpId}>{help}</small>
</div>
);
}
function toDraft(profile: BoatProfile): BoatProfileDraft {
return {
name: profile.name,
category: profile.category,
lengthM: String(profile.lengthM),
beamM: String(profile.beamM),
airDraftM: String(profile.airDraftM),
draughtM: String(profile.draughtM),
safetyReserveM: String(profile.safetyReserveM),
cruiseSpeedKn: String(profile.cruiseSpeedKn),
bowRollerHeightM: String(profile.bowRollerHeightM)
};
}
function toProfile(draft: BoatProfileDraft): BoatProfile {
return {
name: draft.name,
category: draft.category,
lengthM: parseNumber(draft.lengthM),
beamM: parseNumber(draft.beamM),
airDraftM: parseNumber(draft.airDraftM),
draughtM: parseNumber(draft.draughtM),
safetyReserveM: parseNumber(draft.safetyReserveM),
cruiseSpeedKn: parseNumber(draft.cruiseSpeedKn),
bowRollerHeightM: parseNumber(draft.bowRollerHeightM)
};
}
function parseNumber(value: string): number {
return value.trim() ? Number(value) : Number.NaN;
}
+11 -2
View File
@@ -1,6 +1,14 @@
import { Anchor, Bell, CloudSun, Route as RouteIcon, type LucideIcon } from "lucide-react";
import {
Anchor,
Bell,
CloudSun,
Route as RouteIcon,
Ship,
type LucideIcon
} from "lucide-react";
export const NAVIGATION_TOOL_ORDER = [
"boat",
"anchor",
"conditions",
"upcoming",
@@ -18,6 +26,7 @@ type NavigationToolDefinition = {
};
const NAVIGATION_TOOLS: readonly NavigationToolDefinition[] = [
{ id: "boat", label: "Mein Boot", Icon: Ship },
{ id: "anchor", label: "Ankerwache", Icon: Anchor },
{ id: "conditions", label: "Wetter und Tide", Icon: CloudSun },
{ id: "upcoming", label: "Als Nächstes", Icon: Bell },
@@ -43,7 +52,7 @@ export function NavigationToolRail({
disabledTools = {},
workspaceId = "navigation-workspace",
className,
ariaLabel = "Kartenwerkzeuge"
ariaLabel = "Navigationswerkzeuge"
}: NavigationToolRailProps) {
return (
<nav className={classNames("navigation-tool-rail", className)} aria-label={ariaLabel}>
+77 -73
View File
@@ -23,6 +23,7 @@ import {
buildVoyagePlan,
VOYAGE_AMENITIES,
voyageAmenityLabel,
type BoatProfile,
type Coordinate,
type NavigationDataSnapshot,
type RouteResult,
@@ -32,6 +33,11 @@ import {
} from "@watermaps/shared";
import type { RouteWeatherReport } from "../routeWeatherReport";
import type { OfflineVoyage } from "../lib/offline-route";
import {
boatCategoryLabel,
DEFAULT_BOAT_PROFILE,
toVesselProfile
} from "../lib/boat-profile";
import type { RouteLock } from "../voyageHarbours";
import { LazyContent } from "./LazyContent";
import { NavigationDataPanel } from "./NavigationDataPanel";
@@ -58,6 +64,8 @@ type RoutePlannerProps = {
routeTides?: RouteTidePlan | null;
routeTidesLoading?: boolean;
routeTidesError?: string | null;
boatProfile?: BoatProfile;
routeVesselProfile?: VesselProfile | null;
loading: boolean;
error: string | null;
pickMode: "start" | "destination" | "waypoint" | null;
@@ -66,7 +74,6 @@ type RoutePlannerProps = {
destination: Coordinate;
waypoints: Coordinate[];
departureTime: string;
vesselProfile: VesselProfile;
}) => void;
onPickStart: () => void;
onPickDestination: () => void;
@@ -80,6 +87,7 @@ type RoutePlannerProps = {
onSelectRoute: (routeId: string) => void;
guidanceActive?: boolean;
onStartGuidance?: () => void;
onEditBoat?: () => void;
operationalPanelsVisible?: boolean;
embedded?: boolean;
onCollapse: () => void;
@@ -113,6 +121,8 @@ export function RoutePlanner({
routeTides = null,
routeTidesLoading = false,
routeTidesError = null,
boatProfile = DEFAULT_BOAT_PROFILE,
routeVesselProfile = null,
loading,
error,
pickMode,
@@ -129,15 +139,11 @@ export function RoutePlanner({
onSelectRoute,
guidanceActive = false,
onStartGuidance = () => undefined,
onEditBoat = () => undefined,
operationalPanelsVisible = true,
embedded = false,
onCollapse
}: RoutePlannerProps) {
const [draughtM, setDraughtM] = useState(1.4);
const [reserveM, setReserveM] = useState(0.5);
const [airDraftM, setAirDraftM] = useState(2.5);
const [beamM, setBeamM] = useState(3.2);
const [speedKn, setSpeedKn] = useState(6);
const [departureTime, setDepartureTime] = useState(() => localDateTimeValue(new Date()));
const [maxCruisingHoursPerDay, setMaxCruisingHoursPerDay] = useState(8);
const [requiredAmenities, setRequiredAmenities] = useState<VoyageAmenity[]>(["overnight"]);
@@ -149,6 +155,16 @@ export function RoutePlanner({
const [voyageToolsRequested, setVoyageToolsRequested] = useState(false);
const [sheetState, setSheetState] = useState<RouteSheetState>("half");
const previousResult = useRef(result);
const vesselProfile = useMemo(() => toVesselProfile(boatProfile), [boatProfile]);
const planningVesselProfile = result && routeVesselProfile
? routeVesselProfile
: vesselProfile;
const speedKn = planningVesselProfile.cruiseSpeedKn ?? boatProfile.cruiseSpeedKn;
const routeProfileDiffers = Boolean(
result &&
routeVesselProfile &&
!sameVesselProfile(routeVesselProfile, vesselProfile)
);
const canRoute = Boolean(startPoint && destination);
const isPickingStart = pickMode === "start";
const isPickingDestination = pickMode === "destination";
@@ -387,7 +403,7 @@ export function RoutePlanner({
open={routeOptionsOpen}
onToggle={(event) => setRouteOptionsOpen(event.currentTarget.open)}
>
<summary>Routenoptionen: Boot · Zwischenziele</summary>
<summary>Routenoptionen: Zwischenziele</summary>
<section className="waypoint-editor" aria-label="Zwischenziele">
<div className="waypoint-editor-heading">
<strong>Zwischenziele</strong>
@@ -446,65 +462,35 @@ export function RoutePlanner({
)}
</section>
<div className="route-grid">
<label>
Tiefgang (m)
<input
value={draughtM}
min={0.1}
max={15}
step={0.1}
type="number"
onChange={(event) => setDraughtM(Number(event.target.value))}
/>
</label>
<label>
Sicherheitsreserve (m)
<input
value={reserveM}
min={0}
max={10}
step={0.1}
type="number"
onChange={(event) => setReserveM(Number(event.target.value))}
/>
</label>
<label>
Durchfahrtshöhe (m)
<input
value={airDraftM}
min={0.5}
max={40}
step={0.1}
type="number"
onChange={(event) => setAirDraftM(Number(event.target.value))}
/>
</label>
<label>
Geschwindigkeit (kn)
<input
value={speedKn}
min={1}
max={80}
step={0.5}
type="number"
onChange={(event) => setSpeedKn(Number(event.target.value))}
/>
</label>
<label>
Breite (m)
<input
value={beamM}
min={0.5}
max={40}
step={0.1}
type="number"
onChange={(event) => setBeamM(Number(event.target.value))}
/>
</label>
</div>
</details>
<section
className="current-boat-summary"
aria-label={`Aktuelles Boot: ${boatProfile.name}`}
data-route-profile-differs={routeProfileDiffers}
>
<span className="current-boat-summary-icon">
<ShipWheel size={18} aria-hidden="true" />
</span>
<span>
<strong>{boatProfile.name}</strong>
<small>
{boatCategoryLabel(boatProfile.category)} · {formatProfileMeters(boatProfile.lengthM)}
{" × "}
{formatProfileMeters(boatProfile.beamM)} · Tiefgang{" "}
{formatProfileMeters(boatProfile.draughtM)}
</small>
{routeProfileDiffers && (
<em role="status">
Diese geladene Route wurde mit einem anderen Bootsprofil berechnet.
</em>
)}
</span>
<button className="secondary-action compact-action" type="button" onClick={onEditBoat}>
Bearbeiten
</button>
</section>
<label className="departure-planning">
<span>
<Clock3 size={14} aria-hidden="true" />
@@ -529,8 +515,7 @@ export function RoutePlanner({
start: startPoint,
destination,
waypoints,
departureTime: toIsoOrNow(departureTime),
vesselProfile: { draughtM, safetyReserveM: reserveM, airDraftM, beamM, cruiseSpeedKn: speedKn }
departureTime: toIsoOrNow(departureTime)
});
}}
>
@@ -582,6 +567,17 @@ export function RoutePlanner({
)}
</div>
{routeProfileDiffers && (
<section className="warning-list" aria-label="Abweichendes Bootsprofil" role="alert">
<strong>Route gehört zu einem anderen Bootsprofil</strong>
<p>
<AlertTriangle size={14} aria-hidden="true" />
Die geladene Offline-Route behält ihre damaligen Maße. Für {boatProfile.name} muss
sie mit dem aktuellen Bootsprofil neu berechnet werden.
</p>
</section>
)}
{(error || criticalWarnings.length > 0) && (
<section
className="warning-list route-priority-warnings"
@@ -913,13 +909,7 @@ export function RoutePlanner({
start: startPoint,
destination,
waypoints,
vesselProfile: {
draughtM,
safetyReserveM: reserveM,
airDraftM,
beamM,
cruiseSpeedKn: speedKn
},
vesselProfile: planningVesselProfile,
departureAt: result?.departureTime ?? toIsoOrNow(departureTime)
}
: undefined
@@ -979,6 +969,20 @@ function formatMeters(value: number | null | undefined) {
return typeof value === "number" ? `${value.toFixed(1)} m` : "-- m";
}
function formatProfileMeters(value: number) {
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 2 })} m`;
}
function sameVesselProfile(left: VesselProfile, right: VesselProfile) {
return (
left.draughtM === right.draughtM &&
left.safetyReserveM === right.safetyReserveM &&
left.airDraftM === right.airDraftM &&
left.beamM === right.beamM &&
left.cruiseSpeedKn === right.cruiseSpeedKn
);
}
function formatBridgeMargin(value: number | null | undefined) {
if (typeof value !== "number") {
return "offen";
+137 -14
View File
@@ -21,6 +21,10 @@ export type AnchorWatchSettings = {
horizonHours: number;
};
export type AnchorWatchBoatDefaults = {
bowRollerHeightM?: number;
};
export const DEFAULT_ANCHOR_WATCH_SETTINGS: AnchorWatchSettings = {
depthAtSetM: 3,
bowRollerHeightM: 1,
@@ -35,7 +39,8 @@ const MAX_CAPTURE_ACCURACY_M = 30;
const MAX_FIX_AGE_MS = 10_000;
const STALE_FIX_AFTER_MS = 20_000;
const TIDE_REFRESH_MS = 15 * 60 * 1_000;
const REPEAT_ALARM_MS = 60_000;
const REPEAT_ALERT_NOTIFICATION_MS = 60_000;
const REPEAT_AUDIBLE_DRIFT_ALARM_MS = 1_250;
type WakeLockSentinelLike = {
released?: boolean;
@@ -51,12 +56,17 @@ type NavigatorWithWakeLock = Navigator & {
export function useAnchorWatch(gps: Pick<
GpsState,
"status" | "position" | "accuracyM" | "timestampMs"
>) {
>, boatDefaults: AnchorWatchBoatDefaults = {}) {
const [phase, setPhase] = useState<AnchorWatchPhase>("idle");
const [anchorPoint, setAnchorPoint] = useState<Coordinate | null>(null);
const [anchorSetAtMs, setAnchorSetAtMs] = useState<number | null>(null);
const [anchorCaptureAccuracyM, setAnchorCaptureAccuracyM] = useState<number | null>(null);
const [settings, setSettings] = useState<AnchorWatchSettings>(DEFAULT_ANCHOR_WATCH_SETTINGS);
const [settings, setSettings] = useState<AnchorWatchSettings>(() => ({
...DEFAULT_ANCHOR_WATCH_SETTINGS,
bowRollerHeightM: validBowRollerHeight(boatDefaults.bowRollerHeightM)
? boatDefaults.bowRollerHeightM
: DEFAULT_ANCHOR_WATCH_SETTINGS.bowRollerHeightM
}));
const [operationError, setOperationError] = useState<string | null>(null);
const [tide, setTide] = useState<TideSummary | null>(null);
const [tideLoading, setTideLoading] = useState(false);
@@ -65,6 +75,7 @@ export function useAnchorWatch(gps: Pick<
const [alarmAcknowledged, setAlarmAcknowledged] = useState(false);
const tideRequestIdRef = useRef(0);
const previousAlarmRef = useRef(false);
const previousDriftAlarmRef = useRef(false);
const previousRodeShortfallRef = useRef(false);
const audioContextRef = useRef<AudioContext | null>(null);
@@ -73,6 +84,18 @@ export function useAnchorWatch(gps: Pick<
setOperationError(null);
}, []);
useEffect(() => {
const bowRollerHeightM = boatDefaults.bowRollerHeightM;
if (!validBowRollerHeight(bowRollerHeightM)) {
return;
}
setSettings((current) =>
current.bowRollerHeightM === bowRollerHeightM
? current
: { ...current, bowRollerHeightM }
);
}, [boatDefaults.bowRollerHeightM]);
const captureAnchor = useCallback(() => {
const now = Date.now();
if (gps.status !== "tracking" || !gps.position || gps.timestampMs === null) {
@@ -100,6 +123,7 @@ export function useAnchorWatch(gps: Pick<
setTideError(null);
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousDriftAlarmRef.current = false;
previousRodeShortfallRef.current = false;
return true;
}, [gps.accuracyM, gps.position, gps.status, gps.timestampMs]);
@@ -116,6 +140,7 @@ export function useAnchorWatch(gps: Pick<
setTideError(null);
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousDriftAlarmRef.current = false;
previousRodeShortfallRef.current = false;
const audioContext = audioContextRef.current;
audioContextRef.current = null;
@@ -139,6 +164,7 @@ export function useAnchorWatch(gps: Pick<
setClockMs(Date.now());
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousDriftAlarmRef.current = false;
setPhase("armed");
audioContextRef.current = createAlarmAudioContext();
void audioContextRef.current?.resume().catch(() => undefined);
@@ -241,6 +267,8 @@ export function useAnchorWatch(gps: Pick<
const positionAlarm = phase === "armed" && (
fixStale || gpsUnavailable || gpsUnreliable || !watchResult || watchResult.alarmTriggered
);
const driftAlarm = phase === "armed" && Boolean(watchResult?.alarmTriggered);
const audibleDriftAlarmActive = driftAlarm && !alarmAcknowledged;
const rodeShortfall = Boolean(
phase === "armed" && rodePlan?.calculationComplete && (rodePlan.rodeReserveM ?? 0) < 0
);
@@ -259,11 +287,41 @@ export function useAnchorWatch(gps: Pick<
setAlarmAcknowledged(false);
emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current
audioContextRef.current,
{ signal: !driftAlarm }
);
}
previousAlarmRef.current = true;
}, [fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
}, [driftAlarm, fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
useEffect(() => {
if (phase !== "armed" || !driftAlarm) {
previousDriftAlarmRef.current = false;
return;
}
if (!previousDriftAlarmRef.current) {
setAlarmAcknowledged(false);
}
previousDriftAlarmRef.current = true;
}, [driftAlarm, phase]);
useEffect(() => {
if (!audibleDriftAlarmActive) {
return;
}
let stopCurrentTone = emitPersistentDriftSignal(audioContextRef.current);
const intervalId = window.setInterval(() => {
stopCurrentTone();
stopCurrentTone = emitPersistentDriftSignal(audioContextRef.current);
}, REPEAT_AUDIBLE_DRIFT_ALARM_MS);
return () => {
window.clearInterval(intervalId);
stopCurrentTone();
stopVibration();
};
}, [audibleDriftAlarmActive]);
useEffect(() => {
if (phase !== "armed" || !positionAlarm || alarmAcknowledged) {
@@ -272,11 +330,21 @@ export function useAnchorWatch(gps: Pick<
const intervalId = window.setInterval(() => {
emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current
audioContextRef.current,
{ signal: !driftAlarm }
);
}, REPEAT_ALARM_MS);
}, REPEAT_ALERT_NOTIFICATION_MS);
return () => window.clearInterval(intervalId);
}, [alarmAcknowledged, fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
}, [
alarmAcknowledged,
driftAlarm,
fixStale,
gpsUnavailable,
gpsUnreliable,
phase,
positionAlarm,
watchResult
]);
useEffect(() => {
if (phase === "armed" && rodeShortfall && !previousRodeShortfallRef.current) {
@@ -348,6 +416,8 @@ export function useAnchorWatch(gps: Pick<
fixStale,
gpsUnreliable,
positionAlarm,
driftAlarm,
audibleDriftAlarmActive,
rodeShortfall,
alarmAcknowledged,
operationError,
@@ -360,6 +430,10 @@ export function useAnchorWatch(gps: Pick<
};
}
function validBowRollerHeight(value: number | undefined): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 20;
}
function validateSettings(settings: AnchorWatchSettings): string | null {
if (!positive(settings.depthAtSetM)) return "Die Tiefe beim Setzen muss größer als 0 m sein.";
if (!nonNegative(settings.bowRollerHeightM)) return "Die Höhe der Bugrolle darf nicht negativ sein.";
@@ -405,11 +479,15 @@ function anchorAlertMessage({
return "Ankerwache hat keine auswertbare Position.";
}
function emitAnchorAlert(message: string, audioContext: AudioContext | null) {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate([300, 120, 300, 120, 500]);
function emitAnchorAlert(
message: string,
audioContext: AudioContext | null,
options: { signal?: boolean } = {}
) {
if (options.signal !== false) {
emitAlarmVibration();
playAlarmTone(audioContext);
}
playAlarmTone(audioContext);
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
try {
new Notification("Watermaps Ankerwache", {
@@ -423,6 +501,23 @@ function emitAnchorAlert(message: string, audioContext: AudioContext | null) {
}
}
function emitPersistentDriftSignal(audioContext: AudioContext | null) {
emitAlarmVibration();
return playAlarmTone(audioContext);
}
function emitAlarmVibration() {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate([300, 120, 300, 120, 500]);
}
}
function stopVibration() {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate(0);
}
}
function createAlarmAudioContext(): AudioContext | null {
if (typeof window === "undefined") {
return null;
@@ -440,15 +535,24 @@ function createAlarmAudioContext(): AudioContext | null {
}
}
function playAlarmTone(audioContext: AudioContext | null) {
function playAlarmTone(audioContext: AudioContext | null): () => void {
if (!audioContext || audioContext.state === "closed") {
return;
return () => undefined;
}
let cancelled = false;
const oscillators: OscillatorNode[] = [];
const gains: GainNode[] = [];
void audioContext.resume().then(() => {
if (cancelled || audioContext.state === "closed") {
return;
}
const startAt = audioContext.currentTime;
for (const offset of [0, 0.32, 0.64]) {
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
oscillators.push(oscillator);
gains.push(gain);
oscillator.type = "square";
oscillator.frequency.value = 880;
gain.gain.setValueAtTime(0.0001, startAt + offset);
@@ -456,8 +560,27 @@ function playAlarmTone(audioContext: AudioContext | null) {
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + offset + 0.2);
oscillator.connect(gain);
gain.connect(audioContext.destination);
oscillator.onended = () => {
oscillator.disconnect();
gain.disconnect();
};
oscillator.start(startAt + offset);
oscillator.stop(startAt + offset + 0.21);
}
}).catch(() => undefined);
return () => {
cancelled = true;
for (const oscillator of oscillators) {
try {
oscillator.stop();
} catch {
// The oscillator may already have ended.
}
oscillator.disconnect();
}
for (const gain of gains) {
gain.disconnect();
}
};
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useState } from "react";
import type { BoatProfile } from "@watermaps/shared";
import {
DEFAULT_BOAT_PROFILE,
deleteBoatProfileCookie,
readBoatProfileCookie,
validateBoatProfile,
writeBoatProfileCookie
} from "../lib/boat-profile";
type BoatProfileState = {
profile: BoatProfile;
stored: boolean;
};
export function useBoatProfile() {
const [state, setState] = useState<BoatProfileState>(() => {
const storedProfile = readBoatProfileCookie();
return {
profile: storedProfile ?? DEFAULT_BOAT_PROFILE,
stored: Boolean(storedProfile)
};
});
const save = useCallback((candidate: BoatProfile) => {
const validation = validateBoatProfile(candidate);
if (!validation.valid) {
return false;
}
const stored = writeBoatProfileCookie(validation.profile);
setState({ profile: validation.profile, stored });
return stored;
}, []);
const remove = useCallback(() => {
const removed = deleteBoatProfileCookie();
if (removed) {
setState({ profile: DEFAULT_BOAT_PROFILE, stored: false });
}
return removed;
}, []);
return {
profile: state.profile,
stored: state.stored,
save,
remove
};
}
+276
View File
@@ -0,0 +1,276 @@
import type {
BoatCategory,
BoatProfile,
VesselProfile
} from "@watermaps/shared";
export const BOAT_PROFILE_COOKIE_NAME = "watermaps.vessel-profile";
export const BOAT_PROFILE_COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
const MAX_ENCODED_COOKIE_VALUE_LENGTH = 2_048;
const PROFILE_VERSION = 1;
export const BOAT_CATEGORY_OPTIONS: readonly {
value: BoatCategory;
label: string;
}[] = [
{ value: "sailboat", label: "Segelboot" },
{ value: "motorboat", label: "Motorboot" },
{ value: "motor-sailer", label: "Motorsegler" },
{ value: "multihull", label: "Mehrrumpfboot" },
{ value: "houseboat", label: "Hausboot" },
{ value: "human-powered", label: "Muskelbetriebenes Boot" },
{ value: "other", label: "Sonstiges Boot" }
];
export const DEFAULT_BOAT_PROFILE: BoatProfile = {
name: "Mein Boot",
category: "other",
lengthM: 10,
beamM: 3.2,
draughtM: 1.4,
airDraftM: 2.5,
bowRollerHeightM: 1,
safetyReserveM: 0.5,
cruiseSpeedKn: 6
};
type StoredBoatProfile = {
v: typeof PROFILE_VERSION;
updatedAt: string;
profile: BoatProfile;
};
export type BoatProfileValidationResult =
| { valid: true; profile: BoatProfile }
| { valid: false; error: string };
export function validateBoatProfile(value: unknown): BoatProfileValidationResult {
if (!isRecord(value)) {
return invalid("Das Bootsprofil ist unvollständig.");
}
const name = normalizeBoatName(value.name);
if (!name) {
return invalid("Der Bootsname muss zwischen 1 und 80 Zeichen lang sein.");
}
if (!isBoatCategory(value.category)) {
return invalid("Bitte eine gültige Bootskategorie wählen.");
}
const ranges: Array<{
key: keyof Pick<
BoatProfile,
| "lengthM"
| "beamM"
| "draughtM"
| "airDraftM"
| "bowRollerHeightM"
| "safetyReserveM"
| "cruiseSpeedKn"
>;
label: string;
min: number;
max: number;
allowZero?: boolean;
}> = [
{ key: "lengthM", label: "Länge", min: 0, max: 400 },
{ key: "beamM", label: "Breite", min: 0, max: 80 },
{ key: "draughtM", label: "Tiefgang", min: 0, max: 15 },
{ key: "airDraftM", label: "Durchfahrtshöhe", min: 0, max: 80 },
{
key: "bowRollerHeightM",
label: "Bugrollenhöhe",
min: 0,
max: 20,
allowZero: true
},
{
key: "safetyReserveM",
label: "Sicherheitsreserve",
min: 0,
max: 10,
allowZero: true
},
{ key: "cruiseSpeedKn", label: "Reisegeschwindigkeit", min: 0, max: 80 }
];
for (const range of ranges) {
const fieldValue = value[range.key];
if (
typeof fieldValue !== "number" ||
!Number.isFinite(fieldValue) ||
(range.allowZero ? fieldValue < range.min : fieldValue <= range.min) ||
fieldValue > range.max
) {
const lowerBound = range.allowZero ? `${range.min}` : `mehr als ${range.min}`;
return invalid(`${range.label} muss zwischen ${lowerBound} und ${range.max} liegen.`);
}
}
return {
valid: true,
profile: {
name,
category: value.category,
lengthM: value.lengthM as number,
beamM: value.beamM as number,
draughtM: value.draughtM as number,
airDraftM: value.airDraftM as number,
bowRollerHeightM: value.bowRollerHeightM as number,
safetyReserveM: value.safetyReserveM as number,
cruiseSpeedKn: value.cruiseSpeedKn as number
}
};
}
export function toVesselProfile(profile: BoatProfile): VesselProfile {
return {
draughtM: profile.draughtM,
safetyReserveM: profile.safetyReserveM,
airDraftM: profile.airDraftM,
beamM: profile.beamM,
cruiseSpeedKn: profile.cruiseSpeedKn
};
}
export function boatCategoryLabel(category: BoatCategory): string {
return (
BOAT_CATEGORY_OPTIONS.find((option) => option.value === category)?.label ??
"Sonstiges Boot"
);
}
export function readBoatProfileCookie(
cookieString: string | undefined = browserCookieString()
): BoatProfile | null {
if (cookieString === undefined) {
return null;
}
const encodedValue = findCookieValue(cookieString, BOAT_PROFILE_COOKIE_NAME);
if (!encodedValue || encodedValue.length > MAX_ENCODED_COOKIE_VALUE_LENGTH) {
return null;
}
try {
const stored = JSON.parse(decodeURIComponent(encodedValue)) as unknown;
if (
!isRecord(stored) ||
stored.v !== PROFILE_VERSION ||
typeof stored.updatedAt !== "string" ||
!Number.isFinite(Date.parse(stored.updatedAt))
) {
return null;
}
const validation = validateBoatProfile(stored.profile);
return validation.valid ? validation.profile : null;
} catch {
return null;
}
}
export function serializeBoatProfileCookie(
profile: BoatProfile,
{
secure = browserUsesHttps(),
updatedAt = new Date().toISOString()
}: { secure?: boolean; updatedAt?: string } = {}
): string | null {
const validation = validateBoatProfile(profile);
if (!validation.valid || !Number.isFinite(Date.parse(updatedAt))) {
return null;
}
const stored: StoredBoatProfile = {
v: PROFILE_VERSION,
updatedAt,
profile: validation.profile
};
const encodedValue = encodeURIComponent(JSON.stringify(stored));
if (encodedValue.length > MAX_ENCODED_COOKIE_VALUE_LENGTH) {
return null;
}
return [
`${BOAT_PROFILE_COOKIE_NAME}=${encodedValue}`,
"Path=/",
`Max-Age=${BOAT_PROFILE_COOKIE_MAX_AGE_SECONDS}`,
"SameSite=Lax",
...(secure ? ["Secure"] : [])
].join("; ");
}
export function writeBoatProfileCookie(profile: BoatProfile): boolean {
if (typeof document === "undefined") {
return false;
}
const serialized = serializeBoatProfileCookie(profile);
if (!serialized) {
return false;
}
const expectedValue = serialized.slice(
BOAT_PROFILE_COOKIE_NAME.length + 1,
serialized.indexOf(";")
);
document.cookie = serialized;
return findCookieValue(document.cookie, BOAT_PROFILE_COOKIE_NAME) === expectedValue;
}
export function deleteBoatProfileCookie(): boolean {
if (typeof document === "undefined") {
return false;
}
document.cookie = [
`${BOAT_PROFILE_COOKIE_NAME}=`,
"Path=/",
"Max-Age=0",
"SameSite=Lax",
...(browserUsesHttps() ? ["Secure"] : [])
].join("; ");
return findCookieValue(document.cookie, BOAT_PROFILE_COOKIE_NAME) === null;
}
function invalid(error: string): BoatProfileValidationResult {
return { valid: false, error };
}
function normalizeBoatName(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.normalize("NFC").trim();
if (
normalized.length < 1 ||
normalized.length > 80 ||
/[\u0000-\u001f\u007f-\u009f]/u.test(normalized)
) {
return null;
}
return normalized;
}
function isBoatCategory(value: unknown): value is BoatCategory {
return BOAT_CATEGORY_OPTIONS.some((option) => option.value === value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function browserCookieString(): string | undefined {
return typeof document === "undefined" ? undefined : document.cookie;
}
function browserUsesHttps(): boolean {
return typeof window !== "undefined" && window.location.protocol === "https:";
}
function findCookieValue(cookieString: string, name: string): string | null {
for (const cookie of cookieString.split(";")) {
const trimmed = cookie.trim();
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex < 0 || trimmed.slice(0, separatorIndex) !== name) {
continue;
}
return trimmed.slice(separatorIndex + 1);
}
return null;
}
+83 -4
View File
@@ -801,6 +801,64 @@ button:disabled {
color: #10242b;
}
.current-boat-summary {
border: 1px solid rgba(15, 76, 92, 0.16);
border-radius: 10px;
padding: 9px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
background: #edf4f1;
}
.current-boat-summary[data-route-profile-differs="true"] {
border-color: #d89c28;
background: #fff7e3;
}
.current-boat-summary-icon {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
background: #d8e8e3;
color: #0f4c5c;
}
.current-boat-summary > span:nth-child(2) {
min-width: 0;
display: grid;
gap: 2px;
}
.current-boat-summary strong,
.current-boat-summary small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-boat-summary strong {
color: #17343c;
font-size: 13px;
}
.current-boat-summary small {
color: #536a71;
font-size: 11px;
font-weight: 700;
}
.current-boat-summary em {
color: #805900;
font-size: 10px;
font-style: normal;
font-weight: 750;
line-height: 1.3;
}
.departure-planning {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -2105,21 +2163,42 @@ button:disabled {
pointer-events: auto;
}
/* Map utilities are deliberately separated from the four voyage tools. */
/* Map utilities are deliberately separated from the five voyage tools. */
.app-shell .layer-toggle {
top: calc(env(safe-area-inset-top) + 344px);
top: calc(env(safe-area-inset-top) + 402px);
}
.app-shell .locate-toggle {
top: calc(env(safe-area-inset-top) + 344px);
top: calc(env(safe-area-inset-top) + 402px);
right: 70px;
}
.app-shell .layer-panel {
top: calc(env(safe-area-inset-top) + 396px);
top: calc(env(safe-area-inset-top) + 454px);
right: 12px;
}
@media (max-height: 620px) and (orientation: landscape) {
.navigation-tool-rail {
top: calc(env(safe-area-inset-top) + 64px);
gap: 6px;
}
.navigation-tool-rail-button {
width: 42px;
height: 42px;
}
.app-shell .layer-toggle,
.app-shell .locate-toggle {
top: calc(env(safe-area-inset-top) + 310px);
}
.app-shell .layer-panel {
top: calc(env(safe-area-inset-top) + 358px);
}
}
.app-shell[data-workspace-open="true"] .data-badge {
display: none;
}