255 lines
7.2 KiB
TypeScript
255 lines
7.2 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets";
|
|
import { searchChnprmship } from "../../entities/aisTarget/api/searchChnprmship";
|
|
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 = {
|
|
/** 초기 chnprmship API 호출 시 minutes (기본 120) */
|
|
chnprmshipMinutes?: number;
|
|
/** 주기적 폴링 시 search API minutes (기본 2) */
|
|
incrementalMinutes?: number;
|
|
/** 폴링 주기 ms (기본 60_000) */
|
|
intervalMs?: number;
|
|
/** 보존 기간 (기본 chnprmshipMinutes) */
|
|
retentionMinutes?: number;
|
|
/** incremental 폴링 시 bbox 필터 */
|
|
bbox?: string;
|
|
/** incremental 폴링 시 중심 경도 */
|
|
centerLon?: number;
|
|
/** incremental 폴링 시 중심 위도 */
|
|
centerLat?: number;
|
|
/** incremental 폴링 시 반경(m) */
|
|
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 chnprmshipMinutes = opts.chnprmshipMinutes ?? 120;
|
|
const incrementalMinutes = opts.incrementalMinutes ?? 2;
|
|
const intervalMs = opts.intervalMs ?? 60_000;
|
|
const retentionMinutes = opts.retentionMinutes ?? chnprmshipMinutes;
|
|
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;
|
|
|
|
function applyResult(res: { data: AisTarget[]; message: string }, minutes: number) {
|
|
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;
|
|
|
|
setSnapshot({
|
|
status: "ready",
|
|
error: null,
|
|
lastFetchAt: new Date().toISOString(),
|
|
lastFetchMinutes: minutes,
|
|
lastMessage: res.message,
|
|
total,
|
|
lastUpserted: upserted,
|
|
lastInserted: inserted,
|
|
lastDeleted: deleted,
|
|
});
|
|
setRev((r) => r + 1);
|
|
}
|
|
|
|
async function runInitial(minutes: number) {
|
|
try {
|
|
setSnapshot((s) => ({ ...s, status: "loading", error: null }));
|
|
const res = await searchChnprmship({ minutes }, controller.signal);
|
|
applyResult(res, minutes);
|
|
} catch (e) {
|
|
if (cancelled || generation !== generationRef.current) return;
|
|
setSnapshot((s) => ({
|
|
...s,
|
|
status: "error",
|
|
error: e instanceof Error ? e.message : String(e),
|
|
}));
|
|
}
|
|
}
|
|
|
|
async function runIncremental(minutes: number) {
|
|
try {
|
|
setSnapshot((s) => ({ ...s, error: null }));
|
|
const res = await searchAisTargets(
|
|
{ minutes, bbox, centerLon, centerLat, radiusMeters },
|
|
controller.signal,
|
|
);
|
|
applyResult(res, minutes);
|
|
} catch (e) {
|
|
if (cancelled || generation !== generationRef.current) return;
|
|
setSnapshot((s) => ({
|
|
...s,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Reset store when polling config changes.
|
|
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);
|
|
|
|
// 초기 로드: chnprmship API 1회 호출
|
|
void runInitial(chnprmshipMinutes);
|
|
|
|
// 주기적 폴링: search API로 incremental 업데이트
|
|
const id = window.setInterval(() => void runIncremental(incrementalMinutes), intervalMs);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
controller.abort();
|
|
window.clearInterval(id);
|
|
};
|
|
}, [
|
|
chnprmshipMinutes,
|
|
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 };
|
|
}
|