gc-wing/apps/web/src/features/aisPolling/useAisTargetPolling.ts

240 lines
6.5 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets";
import type { AisTarget } from "../../entities/aisTarget/model/types";
export type AisPollingStatus = "idle" | "loading" | "ready" | "error";
export type AisPollingSnapshot = {
status: AisPollingStatus;
error: string | null;
lastFetchAt: string | null;
lastFetchMinutes: number | null;
lastMessage: string | null;
total: number;
lastUpserted: number;
lastInserted: number;
lastDeleted: number;
};
export type AisPollingOptions = {
initialMinutes?: number;
bootstrapMinutes?: number;
incrementalMinutes?: number;
intervalMs?: number;
retentionMinutes?: number;
bbox?: string;
centerLon?: number;
centerLat?: number;
radiusMeters?: number;
enabled?: boolean;
};
function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) {
let inserted = 0;
let upserted = 0;
for (const r of rows) {
if (!r || typeof r.mmsi !== "number") continue;
const prev = store.get(r.mmsi);
if (!prev) {
store.set(r.mmsi, r);
inserted += 1;
upserted += 1;
continue;
}
// Keep newer rows only. If backend returns same/older timestamp, skip.
const prevTs = Date.parse(prev.messageTimestamp || "");
const nextTs = Date.parse(r.messageTimestamp || "");
if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue;
store.set(r.mmsi, r);
upserted += 1;
}
return { inserted, upserted };
}
function parseBbox(raw: string | undefined) {
if (!raw) return null;
const parts = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (parts.length !== 4) return null;
const [lonMin, latMin, lonMax, latMax] = parts.map((p) => Number(p));
const ok =
Number.isFinite(lonMin) &&
Number.isFinite(latMin) &&
Number.isFinite(lonMax) &&
Number.isFinite(latMax) &&
lonMin >= -180 &&
lonMax <= 180 &&
latMin >= -90 &&
latMax <= 90 &&
lonMin < lonMax &&
latMin < latMax;
if (!ok) return null;
return { lonMin, latMin, lonMax, latMax };
}
function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bboxRaw: string | undefined) {
const cutoffMs = Date.now() - retentionMinutes * 60_000;
const bbox = parseBbox(bboxRaw);
let deleted = 0;
for (const [mmsi, t] of store.entries()) {
const ts = Date.parse(t.messageTimestamp || "");
if (Number.isFinite(ts) && ts < cutoffMs) {
store.delete(mmsi);
deleted += 1;
continue;
}
if (bbox) {
const lat = t.lat;
const lon = t.lon;
if (typeof lat !== "number" || typeof lon !== "number") {
store.delete(mmsi);
deleted += 1;
continue;
}
if (lon < bbox.lonMin || lon > bbox.lonMax || lat < bbox.latMin || lat > bbox.latMax) {
store.delete(mmsi);
deleted += 1;
continue;
}
}
}
return deleted;
}
export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const initialMinutes = opts.initialMinutes ?? 60;
const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes;
const incrementalMinutes = opts.incrementalMinutes ?? 1;
const intervalMs = opts.intervalMs ?? 60_000;
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
const enabled = opts.enabled ?? true;
const bbox = opts.bbox;
const centerLon = opts.centerLon;
const centerLat = opts.centerLat;
const radiusMeters = opts.radiusMeters;
const storeRef = useRef<Map<number, AisTarget>>(new Map());
const generationRef = useRef(0);
const [rev, setRev] = useState(0);
const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({
status: "idle",
error: null,
lastFetchAt: null,
lastFetchMinutes: null,
lastMessage: null,
total: 0,
lastUpserted: 0,
lastInserted: 0,
lastDeleted: 0,
});
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const controller = new AbortController();
const generation = ++generationRef.current;
async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") {
try {
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
const res = await searchAisTargets(
{
minutes,
bbox,
centerLon,
centerLat,
radiusMeters,
},
controller.signal,
);
if (cancelled || generation !== generationRef.current) return;
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
const total = storeRef.current.size;
const lastFetchAt = new Date().toISOString();
setSnapshot({
status: "ready",
error: null,
lastFetchAt,
lastFetchMinutes: minutes,
lastMessage: res.message,
total,
lastUpserted: upserted,
lastInserted: inserted,
lastDeleted: deleted,
});
setRev((r) => r + 1);
} catch (e) {
if (cancelled || generation !== generationRef.current) return;
setSnapshot((s) => ({
...s,
status: context === "incremental" ? s.status : "error",
error: e instanceof Error ? e.message : String(e),
}));
}
}
// Reset store when polling config changes (bbox, retention, etc).
storeRef.current = new Map();
setSnapshot({
status: "loading",
error: null,
lastFetchAt: null,
lastFetchMinutes: null,
lastMessage: null,
total: 0,
lastUpserted: 0,
lastInserted: 0,
lastDeleted: 0,
});
setRev((r) => r + 1);
void run(bootstrapMinutes, "bootstrap");
if (bootstrapMinutes !== initialMinutes) {
void run(initialMinutes, "initial");
}
const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs);
return () => {
cancelled = true;
controller.abort();
window.clearInterval(id);
};
}, [
initialMinutes,
bootstrapMinutes,
incrementalMinutes,
intervalMs,
retentionMinutes,
bbox,
centerLon,
centerLat,
radiusMeters,
enabled,
]);
const targets = useMemo(() => {
// `rev` is a version counter so we recompute the array snapshot when the store changes.
void rev;
return Array.from(storeRef.current.values());
}, [rev]);
return { targets, snapshot };
}