115 lines
3.3 KiB
TypeScript
115 lines
3.3 KiB
TypeScript
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<string, VesselAnalysisDto>;
|
|
stats: AnalysisStats;
|
|
clusters: Map<number, string[]>;
|
|
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<Map<string, VesselAnalysisDto>>(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<number>();
|
|
|
|
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<number, string[]> => {
|
|
const result = new Map<number, string[]>();
|
|
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 };
|
|
}
|