2026-02-15 11:22:38 +09:00
|
|
|
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;
|
|
|
|
|
incrementalMinutes?: number;
|
|
|
|
|
intervalMs?: number;
|
|
|
|
|
retentionMinutes?: number;
|
|
|
|
|
bbox?: string;
|
2026-02-15 13:58:07 +09:00
|
|
|
centerLon?: number;
|
|
|
|
|
centerLat?: number;
|
|
|
|
|
radiusMeters?: number;
|
2026-02-15 11:22:38 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prefer newer records if the upstream ever returns stale items.
|
|
|
|
|
const prevTs = prev.messageTimestamp ?? "";
|
|
|
|
|
const nextTs = r.messageTimestamp ?? "";
|
|
|
|
|
if (nextTs && prevTs && 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 incrementalMinutes = opts.incrementalMinutes ?? 1;
|
|
|
|
|
const intervalMs = opts.intervalMs ?? 60_000;
|
|
|
|
|
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
|
|
|
|
|
const enabled = opts.enabled ?? true;
|
|
|
|
|
const bbox = opts.bbox;
|
2026-02-15 13:58:07 +09:00
|
|
|
const centerLon = opts.centerLon;
|
|
|
|
|
const centerLat = opts.centerLat;
|
|
|
|
|
const radiusMeters = opts.radiusMeters;
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
const storeRef = useRef<Map<number, AisTarget>>(new Map());
|
|
|
|
|
const inFlightRef = useRef(false);
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
async function run(minutes: number) {
|
|
|
|
|
if (inFlightRef.current) return;
|
|
|
|
|
inFlightRef.current = true;
|
|
|
|
|
try {
|
|
|
|
|
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
|
|
|
|
|
|
2026-02-15 13:58:07 +09:00
|
|
|
const res = await searchAisTargets(
|
|
|
|
|
{
|
|
|
|
|
minutes,
|
|
|
|
|
bbox,
|
|
|
|
|
centerLon,
|
|
|
|
|
centerLat,
|
|
|
|
|
radiusMeters,
|
|
|
|
|
},
|
|
|
|
|
controller.signal,
|
|
|
|
|
);
|
2026-02-15 11:22:38 +09:00
|
|
|
if (cancelled) 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) return;
|
|
|
|
|
setSnapshot((s) => ({
|
|
|
|
|
...s,
|
|
|
|
|
status: "error",
|
|
|
|
|
error: e instanceof Error ? e.message : String(e),
|
|
|
|
|
}));
|
|
|
|
|
} finally {
|
|
|
|
|
inFlightRef.current = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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(initialMinutes);
|
|
|
|
|
const id = window.setInterval(() => void run(incrementalMinutes), intervalMs);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
controller.abort();
|
|
|
|
|
window.clearInterval(id);
|
|
|
|
|
};
|
2026-02-15 13:58:07 +09:00
|
|
|
}, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, centerLon, centerLat, radiusMeters, enabled]);
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|