import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import type { VesselAnalysisDto, RiskLevel } from '../types'; import { fetchVesselAnalysis } from '../services/vesselAnalysis'; const POLL_INTERVAL_MS = 5 * 60_000; // 5분 const STALE_MS = 30 * 60_000; // 30분 export interface AnalysisStats { total: number; critical: number; high: number; medium: number; low: number; dark: number; spoofing: number; clusterCount: number; } export interface UseVesselAnalysisResult { analysisMap: Map; stats: AnalysisStats; clusters: Map; isLoading: boolean; lastUpdated: number; } const EMPTY_STATS: AnalysisStats = { total: 0, critical: 0, high: 0, medium: 0, low: 0, dark: 0, spoofing: 0, clusterCount: 0, }; export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult { const mapRef = useRef>(new Map()); const [version, setVersion] = useState(0); const [isLoading, setIsLoading] = useState(false); const [lastUpdated, setLastUpdated] = useState(0); const doFetch = useCallback(async () => { if (!enabled) return; setIsLoading(true); try { const items = await fetchVesselAnalysis(); const now = Date.now(); const map = mapRef.current; // stale 제거 for (const [mmsi, dto] of map) { const ts = new Date(dto.timestamp).getTime(); if (now - ts > STALE_MS) map.delete(mmsi); } // 새 결과 merge for (const item of items) { map.set(item.mmsi, item); } setLastUpdated(now); setVersion(v => v + 1); } catch { // 에러 시 기존 데이터 유지 (graceful degradation) } finally { setIsLoading(false); } }, [enabled]); useEffect(() => { doFetch(); const t = setInterval(doFetch, POLL_INTERVAL_MS); return () => clearInterval(t); }, [doFetch]); const analysisMap = mapRef.current; const stats = useMemo((): AnalysisStats => { if (analysisMap.size === 0) return EMPTY_STATS; let critical = 0, high = 0, medium = 0, low = 0, dark = 0, spoofing = 0; const clusterIds = new Set(); for (const dto of analysisMap.values()) { const level: RiskLevel = dto.algorithms.riskScore.level; if (level === 'CRITICAL') critical++; else if (level === 'HIGH') high++; else if (level === 'MEDIUM') medium++; else low++; if (dto.algorithms.darkVessel.isDark) dark++; if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) spoofing++; if (dto.algorithms.cluster.clusterId >= 0) { clusterIds.add(dto.algorithms.cluster.clusterId); } } return { total: analysisMap.size, critical, high, medium, low, dark, spoofing, clusterCount: clusterIds.size, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [version]); const clusters = useMemo((): Map => { const result = new Map(); for (const [mmsi, dto] of analysisMap) { const cid = dto.algorithms.cluster.clusterId; if (cid < 0) continue; const arr = result.get(cid); if (arr) arr.push(mmsi); else result.set(cid, [mmsi]); } return result; // eslint-disable-next-line react-hooks/exhaustive-deps }, [version]); return { analysisMap, stats, clusters, isLoading, lastUpdated }; }