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; }; type NavigatorWithWakeLock = Navigator & { wakeLock?: { request: (type: "screen") => Promise; }; }; export function useAnchorWatch(gps: Pick< GpsState, "status" | "position" | "accuracyM" | "timestampMs" >, boatDefaults: AnchorWatchBoatDefaults = {}) { const [phase, setPhase] = useState("idle"); const [anchorPoint, setAnchorPoint] = useState(null); const [anchorSetAtMs, setAnchorSetAtMs] = useState(null); const [anchorCaptureAccuracyM, setAnchorCaptureAccuracyM] = useState(null); const [settings, setSettings] = useState(() => ({ ...DEFAULT_ANCHOR_WATCH_SETTINGS, bowRollerHeightM: validBowRollerHeight(boatDefaults.bowRollerHeightM) ? boatDefaults.bowRollerHeightM : DEFAULT_ANCHOR_WATCH_SETTINGS.bowRollerHeightM })); const [operationError, setOperationError] = useState(null); const [tide, setTide] = useState(null); const [tideLoading, setTideLoading] = useState(false); const [tideError, setTideError] = useState(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(null); const updateSettings = useCallback((next: Partial) => { 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; }) { 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(); } }; }