diff --git a/apps/web/src/entities/aisTarget/api/searchChnprmship.ts b/apps/web/src/entities/aisTarget/api/searchChnprmship.ts new file mode 100644 index 0000000..272e3c7 --- /dev/null +++ b/apps/web/src/entities/aisTarget/api/searchChnprmship.ts @@ -0,0 +1,32 @@ +import type { AisTargetSearchResponse } from '../model/types'; + +export async function searchChnprmship( + params: { minutes: number }, + signal?: AbortSignal, +): Promise { + const base = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, ''); + const u = new URL(`${base}/api/ais-target/chnprmship`, window.location.origin); + u.searchParams.set('minutes', String(params.minutes)); + + const res = await fetch(u, { signal, headers: { accept: 'application/json' } }); + const txt = await res.text(); + let json: unknown = null; + try { + json = JSON.parse(txt); + } catch { + // ignore + } + if (!res.ok) { + const msg = + json && typeof json === 'object' && typeof (json as { message?: unknown }).message === 'string' + ? (json as { message: string }).message + : txt.slice(0, 200) || res.statusText; + throw new Error(`chnprmship API failed: ${res.status} ${msg}`); + } + + if (!json || typeof json !== 'object') throw new Error('chnprmship API returned invalid payload'); + const parsed = json as AisTargetSearchResponse; + if (!parsed.success) throw new Error(parsed.message || 'chnprmship API returned success=false'); + + return parsed; +} diff --git a/apps/web/src/features/aisPolling/useAisTargetPolling.ts b/apps/web/src/features/aisPolling/useAisTargetPolling.ts index 8524e65..59b1aff 100644 --- a/apps/web/src/features/aisPolling/useAisTargetPolling.ts +++ b/apps/web/src/features/aisPolling/useAisTargetPolling.ts @@ -1,5 +1,6 @@ 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"; @@ -17,14 +18,21 @@ export type AisPollingSnapshot = { }; export type AisPollingOptions = { - initialMinutes?: number; - bootstrapMinutes?: number; + /** 초기 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; }; @@ -112,11 +120,10 @@ function pruneStore(store: Map, retentionMinutes: number, bbo } export function useAisTargetPolling(opts: AisPollingOptions = {}) { - const initialMinutes = opts.initialMinutes ?? 60; - const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes; - const incrementalMinutes = opts.incrementalMinutes ?? 1; + const chnprmshipMinutes = opts.chnprmshipMinutes ?? 120; + const incrementalMinutes = opts.incrementalMinutes ?? 2; const intervalMs = opts.intervalMs ?? 60_000; - const retentionMinutes = opts.retentionMinutes ?? initialMinutes; + const retentionMinutes = opts.retentionMinutes ?? chnprmshipMinutes; const enabled = opts.enabled ?? true; const bbox = opts.bbox; const centerLon = opts.centerLon; @@ -146,50 +153,60 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { const controller = new AbortController(); const generation = ++generationRef.current; - async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") { + 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: 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); + 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: context === "incremental" ? s.status : "error", + status: "error", error: e instanceof Error ? e.message : String(e), })); } } - // Reset store when polling config changes (bbox, retention, etc). + 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", @@ -204,12 +221,11 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { }); setRev((r) => r + 1); - void run(bootstrapMinutes, "bootstrap"); - if (bootstrapMinutes !== initialMinutes) { - void run(initialMinutes, "initial"); - } + // 초기 로드: chnprmship API 1회 호출 + void runInitial(chnprmshipMinutes); - const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs); + // 주기적 폴링: search API로 incremental 업데이트 + const id = window.setInterval(() => void runIncremental(incrementalMinutes), intervalMs); return () => { cancelled = true; @@ -217,8 +233,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { window.clearInterval(id); }; }, [ - initialMinutes, - bootstrapMinutes, + chnprmshipMinutes, incrementalMinutes, intervalMs, retentionMinutes, diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index e85426f..ca6de78 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -81,11 +81,10 @@ export function DashboardPage() { const [apiBbox, setApiBbox] = useState(undefined); const { targets, snapshot } = useAisTargetPolling({ - initialMinutes: 60, - bootstrapMinutes: 10, + chnprmshipMinutes: 120, incrementalMinutes: 2, intervalMs: 60_000, - retentionMinutes: 90, + retentionMinutes: 120, bbox: useApiBbox ? apiBbox : undefined, centerLon: useApiBbox ? undefined : AIS_CENTER.lon, centerLat: useApiBbox ? undefined : AIS_CENTER.lat,