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
+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();
}
};
}