Bootssymbol hinzugefügt
This commit is contained in:
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user