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, 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, 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>(new Map()); const generationRef = useRef(0); const [rev, setRev] = useState(0); const [snapshot, setSnapshot] = useState({ 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 }; }