587 lines
18 KiB
TypeScript
587 lines
18 KiB
TypeScript
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<void>;
|
||
};
|
||
|
||
type NavigatorWithWakeLock = Navigator & {
|
||
wakeLock?: {
|
||
request: (type: "screen") => Promise<WakeLockSentinelLike>;
|
||
};
|
||
};
|
||
|
||
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,
|
||
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);
|
||
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 previousDriftAlarmRef = useRef(false);
|
||
const previousRodeShortfallRef = useRef(false);
|
||
const audioContextRef = useRef<AudioContext | null>(null);
|
||
|
||
const updateSettings = useCallback((next: Partial<AnchorWatchSettings>) => {
|
||
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<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,
|
||
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();
|
||
}
|
||
};
|
||
}
|