Initial Watermaps import

This commit is contained in:
BuTzZ
2026-07-24 11:29:24 +02:00
commit 57f7b4dedb
129 changed files with 43136 additions and 0 deletions
+463
View File
@@ -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);
}