Merge branch 'feature/chnprmship-polling' into develop

This commit is contained in:
htlee 2026-02-16 12:23:23 +09:00
커밋 77e3a531e8
3개의 변경된 파일96개의 추가작업 그리고 50개의 파일을 삭제

파일 보기

@ -0,0 +1,32 @@
import type { AisTargetSearchResponse } from '../model/types';
export async function searchChnprmship(
params: { minutes: number },
signal?: AbortSignal,
): Promise<AisTargetSearchResponse> {
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;
}

파일 보기

@ -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<number, AisTarget>, 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,

파일 보기

@ -81,11 +81,10 @@ export function DashboardPage() {
const [apiBbox, setApiBbox] = useState<string | undefined>(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,