gc-wing/apps/web/src/features/aisPolling/useAisTargetPolling.ts
htlee 39d9cc9db1 feat(ais): 초기 로드를 chnprmship API로 전환
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:23:18 +09:00

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 };
}