Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
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 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_ALARM_MS = 60_000;
|
||||
|
||||
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"
|
||||
>) {
|
||||
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 [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 previousRodeShortfallRef = useRef(false);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
|
||||
const updateSettings = useCallback((next: Partial<AnchorWatchSettings>) => {
|
||||
setSettings((current) => ({ ...current, ...next }));
|
||||
setOperationError(null);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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 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
|
||||
);
|
||||
}
|
||||
previousAlarmRef.current = true;
|
||||
}, [fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "armed" || !positionAlarm || alarmAcknowledged) {
|
||||
return;
|
||||
}
|
||||
const intervalId = window.setInterval(() => {
|
||||
emitAnchorAlert(
|
||||
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
|
||||
audioContextRef.current
|
||||
);
|
||||
}, REPEAT_ALARM_MS);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [alarmAcknowledged, 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,
|
||||
rodeShortfall,
|
||||
alarmAcknowledged,
|
||||
operationError,
|
||||
maxCaptureAccuracyM: MAX_CAPTURE_ACCURACY_M,
|
||||
captureAnchor,
|
||||
updateSettings,
|
||||
arm,
|
||||
acknowledgeAlarm,
|
||||
reset
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
|
||||
navigator.vibrate([300, 120, 300, 120, 500]);
|
||||
}
|
||||
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 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) {
|
||||
if (!audioContext || audioContext.state === "closed") {
|
||||
return;
|
||||
}
|
||||
void audioContext.resume().then(() => {
|
||||
const startAt = audioContext.currentTime;
|
||||
for (const offset of [0, 0.32, 0.64]) {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
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.start(startAt + offset);
|
||||
oscillator.stop(startAt + offset + 0.21);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user