Files
watermaps/apps/web/src/hooks/useAnchorWatch.ts
T
BuTzZ 55ce94066e
Test and publish container images / test (push) Successful in 2m20s
Test and publish container images / publish (push) Failing after 4s
Bootssymbol hinzugefügt
2026-07-26 12:31:49 +02:00

587 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
analyzeTideWindow,
calculateAnchorRodePlan,
evaluateAnchorWatch,
type Coordinate,
type TideSummary
} from "@watermaps/shared";
import { getNearestTide } from "../api";
import type { GpsState } from "./useGeolocation";
export type AnchorWatchPhase = "idle" | "set" | "armed";
export type AnchorWatchSettings = {
depthAtSetM: number;
bowRollerHeightM: number;
deployedRodeLengthM: number;
scopeRatio: number;
safetyAllowanceM: number;
alarmRadiusM: number;
horizonHours: number;
};
export type AnchorWatchBoatDefaults = {
bowRollerHeightM?: number;
};
export const DEFAULT_ANCHOR_WATCH_SETTINGS: AnchorWatchSettings = {
depthAtSetM: 3,
bowRollerHeightM: 1,
deployedRodeLengthM: 30,
scopeRatio: 6,
safetyAllowanceM: 0.5,
alarmRadiusM: 35,
horizonHours: 24
};
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_ALERT_NOTIFICATION_MS = 60_000;
const REPEAT_AUDIBLE_DRIFT_ALARM_MS = 1_250;
type WakeLockSentinelLike = {
released?: boolean;
release: () => Promise<void>;
};
type NavigatorWithWakeLock = Navigator & {
wakeLock?: {
request: (type: "screen") => Promise<WakeLockSentinelLike>;
};
};
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,
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);
const [tideError, setTideError] = useState<string | null>(null);
const [clockMs, setClockMs] = useState(() => Date.now());
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);
const updateSettings = useCallback((next: Partial<AnchorWatchSettings>) => {
setSettings((current) => ({ ...current, ...next }));
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) {
setOperationError("Für den Ankerpunkt wird zuerst ein aktueller GPS-Fix benötigt.");
return false;
}
if (now - gps.timestampMs > MAX_FIX_AGE_MS) {
setOperationError("Der GPS-Fix ist älter als 10 Sekunden. Bitte auf einen neuen Fix warten.");
return false;
}
if (gps.accuracyM === null || gps.accuracyM > MAX_CAPTURE_ACCURACY_M) {
setOperationError(
`GPS noch zu ungenau${gps.accuracyM === null ? "" : ` (±${Math.round(gps.accuracyM)} m)`}. Ankerpunkt erst bei höchstens ±${MAX_CAPTURE_ACCURACY_M} m setzen.`
);
return false;
}
tideRequestIdRef.current += 1;
setAnchorPoint({ ...gps.position });
setAnchorSetAtMs(gps.timestampMs);
setAnchorCaptureAccuracyM(gps.accuracyM);
setPhase("set");
setOperationError(null);
setTide(null);
setTideError(null);
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousDriftAlarmRef.current = false;
previousRodeShortfallRef.current = false;
return true;
}, [gps.accuracyM, gps.position, gps.status, gps.timestampMs]);
const reset = useCallback(() => {
tideRequestIdRef.current += 1;
setPhase("idle");
setAnchorPoint(null);
setAnchorSetAtMs(null);
setAnchorCaptureAccuracyM(null);
setOperationError(null);
setTide(null);
setTideLoading(false);
setTideError(null);
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousDriftAlarmRef.current = false;
previousRodeShortfallRef.current = false;
const audioContext = audioContextRef.current;
audioContextRef.current = null;
void audioContext?.close().catch(() => undefined);
}, []);
const settingsError = useMemo(() => validateSettings(settings), [settings]);
const arm = useCallback(async () => {
if (!anchorPoint || anchorSetAtMs === null) {
setOperationError("Zuerst „Anker gefallen“ wählen und den Ankerpunkt setzen.");
return false;
}
const validationError = validateSettings(settings);
if (validationError) {
setOperationError(validationError);
return false;
}
setOperationError(null);
setClockMs(Date.now());
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousDriftAlarmRef.current = false;
setPhase("armed");
audioContextRef.current = createAlarmAudioContext();
void audioContextRef.current?.resume().catch(() => undefined);
if (typeof Notification !== "undefined" && Notification.permission === "default") {
try {
await Notification.requestPermission();
} catch {
// The persistent in-app warning remains available when notifications
// are unsupported or denied.
}
}
return true;
}, [anchorPoint, anchorSetAtMs, settings]);
useEffect(() => {
if (!anchorPoint || anchorSetAtMs === null || phase === "idle") {
return;
}
let active = true;
const refresh = async () => {
const requestId = tideRequestIdRef.current + 1;
tideRequestIdRef.current = requestId;
setTideLoading(true);
try {
const summary = await getNearestTide(anchorPoint, new Date(anchorSetAtMs).toISOString());
if (active && tideRequestIdRef.current === requestId) {
setTide(summary);
setTideError(null);
}
} catch (error) {
if (active && tideRequestIdRef.current === requestId) {
setTide(null);
setTideError(error instanceof Error ? error.message : "Tidenprognose nicht erreichbar");
}
} finally {
if (active && tideRequestIdRef.current === requestId) {
setTideLoading(false);
}
}
};
void refresh();
const intervalId = window.setInterval(refresh, TIDE_REFRESH_MS);
return () => {
active = false;
window.clearInterval(intervalId);
};
}, [anchorPoint, anchorSetAtMs, phase]);
useEffect(() => {
if (phase === "idle") {
return;
}
setClockMs(Date.now());
const intervalId = window.setInterval(() => setClockMs(Date.now()), 5_000);
return () => window.clearInterval(intervalId);
}, [phase, gps.timestampMs]);
const tideWindow = useMemo(
() => tide && anchorSetAtMs !== null
? analyzeTideWindow(tide, anchorSetAtMs, settings.horizonHours)
: null,
[anchorSetAtMs, settings.horizonHours, tide]
);
const remainingTideWindow = useMemo(
() => tide ? analyzeTideWindow(tide, clockMs, settings.horizonHours) : null,
[clockMs, settings.horizonHours, tide]
);
const rodePlan = useMemo(
() => calculateAnchorRodePlan({
depthAtSetM: settings.depthAtSetM,
bowRollerHeightM: settings.bowRollerHeightM,
deployedRodeLengthM: settings.deployedRodeLengthM,
scopeRatio: settings.scopeRatio,
safetyAllowanceM: settings.safetyAllowanceM,
tideWindow
}),
[settings, tideWindow]
);
const watchResult = useMemo(
() => anchorPoint && gps.position
? evaluateAnchorWatch({
anchorPoint,
position: gps.position,
alarmRadiusM: settings.alarmRadiusM,
accuracyM: gps.accuracyM,
maxReliableAccuracyM: MAX_CAPTURE_ACCURACY_M
})
: null,
[anchorPoint, gps.accuracyM, gps.position, settings.alarmRadiusM]
);
const fixStale = phase === "armed" && (
gps.timestampMs === null || Math.max(0, clockMs - gps.timestampMs) > STALE_FIX_AFTER_MS
);
const gpsUnavailable = phase === "armed" && gps.status !== "tracking";
const gpsUnreliable = phase === "armed" && Boolean(watchResult && !watchResult.positionReliable);
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
);
useEffect(() => {
if (phase !== "armed") {
previousAlarmRef.current = false;
return;
}
if (!positionAlarm) {
previousAlarmRef.current = false;
setAlarmAcknowledged(false);
return;
}
if (!previousAlarmRef.current) {
setAlarmAcknowledged(false);
emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current,
{ signal: !driftAlarm }
);
}
previousAlarmRef.current = true;
}, [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) {
return;
}
const intervalId = window.setInterval(() => {
emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current,
{ signal: !driftAlarm }
);
}, REPEAT_ALERT_NOTIFICATION_MS);
return () => window.clearInterval(intervalId);
}, [
alarmAcknowledged,
driftAlarm,
fixStale,
gpsUnavailable,
gpsUnreliable,
phase,
positionAlarm,
watchResult
]);
useEffect(() => {
if (phase === "armed" && rodeShortfall && !previousRodeShortfallRef.current) {
const shortfallM = Math.abs(rodePlan?.rodeReserveM ?? 0);
emitAnchorAlert(
`Nach Stationsprognose fehlen rechnerisch etwa ${shortfallM.toFixed(1)} m Ankerleine oder Kette.`,
audioContextRef.current
);
}
previousRodeShortfallRef.current = rodeShortfall;
}, [phase, rodePlan?.rodeReserveM, rodeShortfall]);
useEffect(() => {
if (phase !== "armed" || typeof navigator === "undefined") {
return;
}
let active = true;
let sentinel: WakeLockSentinelLike | null = null;
const acquire = async () => {
const wakeLock = (navigator as NavigatorWithWakeLock).wakeLock;
if (!wakeLock || document.visibilityState !== "visible") {
return;
}
try {
sentinel = await wakeLock.request("screen");
if (!active) {
await sentinel.release();
}
} catch {
sentinel = null;
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && (!sentinel || sentinel.released)) {
void acquire();
}
};
void acquire();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
active = false;
document.removeEventListener("visibilitychange", handleVisibilityChange);
void sentinel?.release().catch(() => undefined);
};
}, [phase]);
useEffect(() => () => {
const audioContext = audioContextRef.current;
audioContextRef.current = null;
void audioContext?.close().catch(() => undefined);
}, []);
const acknowledgeAlarm = useCallback(() => setAlarmAcknowledged(true), []);
return {
phase,
anchorPoint,
anchorSetAtMs,
anchorCaptureAccuracyM,
settings,
settingsError,
tide,
tideLoading,
tideError,
tideWindow,
remainingTideWindow,
rodePlan,
watchResult,
fixStale,
gpsUnreliable,
positionAlarm,
driftAlarm,
audibleDriftAlarmActive,
rodeShortfall,
alarmAcknowledged,
operationError,
maxCaptureAccuracyM: MAX_CAPTURE_ACCURACY_M,
captureAnchor,
updateSettings,
arm,
acknowledgeAlarm,
reset
};
}
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.";
if (!positive(settings.deployedRodeLengthM)) return "Die ausgesteckte Länge muss größer als 0 m sein.";
if (!Number.isFinite(settings.scopeRatio) || settings.scopeRatio < 2 || settings.scopeRatio > 15) {
return "Das gewählte Verhältnis muss zwischen 2:1 und 15:1 liegen.";
}
if (!nonNegative(settings.safetyAllowanceM)) return "Die Wasserstandsreserve darf nicht negativ sein.";
if (!Number.isFinite(settings.alarmRadiusM) || settings.alarmRadiusM < 10 || settings.alarmRadiusM > 2_000) {
return "Der Alarmradius muss zwischen 10 m und 2.000 m liegen.";
}
if (!Number.isFinite(settings.horizonHours) || settings.horizonHours < 6 || settings.horizonHours > 72) {
return "Der Tidenzeitraum muss zwischen 6 und 72 Stunden liegen.";
}
return null;
}
function positive(value: number) {
return Number.isFinite(value) && value > 0;
}
function nonNegative(value: number) {
return Number.isFinite(value) && value >= 0;
}
function anchorAlertMessage({
fixStale,
gpsUnavailable,
gpsUnreliable,
watchResult
}: {
fixStale: boolean;
gpsUnavailable: boolean;
gpsUnreliable: boolean;
watchResult: ReturnType<typeof evaluateAnchorWatch>;
}) {
if (fixStale) return "Kein aktueller GPS-Fix Ankerposition kann nicht sicher überwacht werden.";
if (gpsUnavailable) return "GPS ist ausgefallen Ankerposition kann nicht überwacht werden.";
if (gpsUnreliable) return "GPS ist zu ungenau Ankerposition kann nicht sicher überwacht werden.";
if (watchResult?.alarmTriggered) {
return `Ankeralarm: ${Math.round(watchResult.distanceFromAnchorM)} m vom gesetzten Ankerpunkt entfernt.`;
}
return "Ankerwache hat keine auswertbare Position.";
}
function emitAnchorAlert(
message: string,
audioContext: AudioContext | null,
options: { signal?: boolean } = {}
) {
if (options.signal !== false) {
emitAlarmVibration();
playAlarmTone(audioContext);
}
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
try {
new Notification("Watermaps Ankerwache", {
body: message,
tag: "watermaps-anchor-watch",
requireInteraction: true
});
} catch {
// The live panel remains the primary warning surface.
}
}
}
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;
}
const AudioContextConstructor = window.AudioContext ?? (
window as typeof window & { webkitAudioContext?: typeof AudioContext }
).webkitAudioContext;
if (!AudioContextConstructor) {
return null;
}
try {
return new AudioContextConstructor();
} catch {
return null;
}
}
function playAlarmTone(audioContext: AudioContext | null): () => void {
if (!audioContext || audioContext.state === "closed") {
return () => undefined;
}
let cancelled = false;
const oscillators: OscillatorNode[] = [];
const gains: GainNode[] = [];
void audioContext.resume().then(() => {
if (cancelled || audioContext.state === "closed") {
return;
}
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);
gain.gain.exponentialRampToValueAtTime(0.18, startAt + offset + 0.02);
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();
}
};
}