import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { fetchEvents, fetchEventsByRange } from '../services/api'; import { fetchAircraftFromBackend, fetchAircraftByRange } from '../services/aircraftApi'; import { getSampleAircraft } from '../data/sampleAircraft'; import { fetchSatelliteTLE, propagateAll } from '../services/celestrak'; import { fetchShips } from '../services/ships'; import { fetchOsintFeed, fetchOsintByRange } from '../services/osint'; import type { OsintItem } from '../services/osint'; import { fetchSeismic, fetchPressure } from '../services/sensorApi'; import type { SeismicDto, PressureDto } from '../services/sensorApi'; import { propagateAircraft, propagateShips } from '../services/propagation'; import { getMarineTrafficCategory } from '../utils/marineTraffic'; import type { GeoEvent, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types'; import { damagedShips } from '../data/damagedShips'; type DataSource = 'dummy' | 'api'; interface UseIranDataArgs { appMode: AppMode; currentTime: number; isLive: boolean; hiddenAcCategories: Set; hiddenShipCategories: Set; refreshKey: number; dashboardTab: 'iran' | 'korea'; dataSource?: DataSource; } interface UseIranDataResult { aircraft: Aircraft[]; ships: Ship[]; visibleAircraft: Aircraft[]; visibleShips: Ship[]; satPositions: SatellitePosition[]; events: GeoEvent[]; mergedEvents: GeoEvent[]; seismicData: SeismicDto[]; pressureData: PressureDto[]; osintFeed: OsintItem[]; aircraftByCategory: Record; militaryCount: number; shipsByCategory: Record; koreanShips: Ship[]; koreanShipsByCategory: Record; } const SENSOR_POLL_INTERVAL = 600_000; // 10 min const SHIP_POLL_INTERVAL = 300_000; // 5 min const SHIP_STALE_MS = 3_600_000; // 60 min export function useIranData({ appMode, currentTime, isLive, hiddenAcCategories, hiddenShipCategories, refreshKey, dashboardTab, dataSource = 'dummy', }: UseIranDataArgs): UseIranDataResult { const IRAN_T0 = '2026-03-01T00:00:00Z'; const isApi = dataSource === 'api'; const [events, setEvents] = useState([]); const [seismicData, setSeismicData] = useState([]); const [pressureData, setPressureData] = useState([]); const [baseAircraft, setBaseAircraft] = useState([]); const [baseShips, setBaseShips] = useState([]); const [satellites, setSatellites] = useState([]); const [satPositions, setSatPositions] = useState([]); const [osintFeed, setOsintFeed] = useState([]); const satTimeRef = useRef(0); const sensorInitRef = useRef(false); const shipMapRef = useRef>(new Map()); // Load initial data (events + satellites) useEffect(() => { if (isApi) { fetchEventsByRange(IRAN_T0, new Date().toISOString()).then(setEvents).catch(() => {}); } else { fetchEvents().then(setEvents).catch(() => {}); } fetchSatelliteTLE().then(setSatellites).catch(() => {}); }, [refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps // Sensor data: initial full 48h load + 10min polling (incremental merge) useEffect(() => { const loadFull = async () => { try { const [seismic, pressure] = await Promise.all([ fetchSeismic(), // default 2880 min = 48h fetchPressure(), ]); setSeismicData(seismic); setPressureData(pressure); sensorInitRef.current = true; } catch { /* keep previous */ } }; const loadIncremental = async () => { if (!sensorInitRef.current) return; try { const [seismic, pressure] = await Promise.all([ fetchSeismic(11), // 11 min window (overlap) fetchPressure(11), ]); setSeismicData(prev => mergeSensor(prev, seismic, s => s.usgsId, 2880)); setPressureData(prev => mergeSensor(prev, pressure, p => `${p.station}-${p.timestamp}`, 2880)); } catch { /* keep previous */ } }; loadFull(); const interval = setInterval(loadIncremental, SENSOR_POLL_INTERVAL); return () => clearInterval(interval); }, [refreshKey]); // Fetch base aircraft data (LIVE: backend, REPLAY: sample or API) useEffect(() => { const load = async () => { if (appMode === 'live') { const result = await fetchAircraftFromBackend('iran'); if (result.length > 0) setBaseAircraft(result); } else if (isApi) { const result = await fetchAircraftByRange('iran', IRAN_T0, new Date().toISOString()); if (result.length > 0) setBaseAircraft(result); } else { setBaseAircraft(getSampleAircraft()); } }; load(); const interval = setInterval(load, appMode === 'live' ? 60_000 : 300_000); return () => clearInterval(interval); }, [appMode, refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps // Fetch Iran ship data: initial 60min, then 5min polling with 6min window + merge + stale cleanup const mergeShips = useCallback((newShips: Ship[]) => { const map = shipMapRef.current; for (const s of newShips) { map.set(s.mmsi, s); } // Remove stale ships (lastSeen > 60 min ago) const cutoff = Date.now() - SHIP_STALE_MS; for (const [mmsi, ship] of map) { if (ship.lastSeen < cutoff) map.delete(mmsi); } setBaseShips(Array.from(map.values())); }, []); useEffect(() => { let initialDone = false; const loadInitial = async () => { try { const data = await fetchShips(60); // 초기: 60분 데이터 if (data.length > 0) { shipMapRef.current = new Map(data.map(s => [s.mmsi, s])); setBaseShips(data); initialDone = true; } } catch { /* keep previous */ } }; const loadIncremental = async () => { if (!initialDone) return; try { const data = await fetchShips(6); // polling: 6분 데이터 if (data.length > 0) mergeShips(data); } catch { /* keep previous */ } }; loadInitial(); const interval = setInterval(loadIncremental, SHIP_POLL_INTERVAL); return () => clearInterval(interval); }, [appMode, refreshKey, mergeShips]); // Fetch OSINT feed — live mode (both tabs) + replay mode (iran tab) useEffect(() => { const shouldFetch = isLive || dashboardTab === 'iran'; if (!shouldFetch) { setOsintFeed([]); return; } const load = async () => { try { let data: OsintItem[]; if (isApi && !isLive) { data = await fetchOsintByRange('iran', IRAN_T0, new Date().toISOString()); } else { data = await fetchOsintFeed('iran'); } if (data.length > 0) setOsintFeed(data); } catch { /* keep previous */ } }; setOsintFeed([]); load(); const interval = setInterval(load, isApi ? 300_000 : 120_000); return () => clearInterval(interval); }, [isLive, dashboardTab, refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps // Propagate satellite positions — throttle to every 2s of real time useEffect(() => { if (satellites.length === 0) return; const now = Date.now(); if (now - satTimeRef.current < 2000) return; satTimeRef.current = now; const positions = propagateAll(satellites, new Date(currentTime)); setSatPositions(positions); }, [satellites, currentTime]); // Propagate aircraft positions based on current time const aircraft = useMemo( () => propagateAircraft(baseAircraft, currentTime), [baseAircraft, currentTime], ); // Propagate ship positions based on current time const ships = useMemo( () => propagateShips(baseShips, currentTime, isLive), [baseShips, currentTime, isLive], ); // Category-filtered data for map rendering const visibleAircraft = useMemo( () => aircraft.filter(a => !hiddenAcCategories.has(a.category)), [aircraft, hiddenAcCategories], ); const visibleShips = useMemo( () => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))), [ships, hiddenShipCategories], ); // OSINT → GeoEvent 변환 const osintEvents = useMemo((): GeoEvent[] => { if (dashboardTab !== 'iran' || osintFeed.length === 0) return []; const STRIKE_PATTERN = /strike|attack|bomb|airstrike|hit|destroy|blast|공습|타격|폭격|파괴|피격/i; const MISSILE_PATTERN = /missile|launch|drone|발사|미사일|드론/i; const EXPLOSION_PATTERN = /explo|blast|deton|fire|폭발|화재|폭파/i; const INTERCEPT_PATTERN = /intercept|shoot.*down|defense|요격|격추|방어/i; const IMPACT_PATTERN = /impact|hit|struck|damage|casualt|피격|타격|피해|사상/i; const categoryToType: Record = { military: 'osint', shipping: 'osint', oil: 'osint', nuclear: 'osint', diplomacy: 'osint', }; return osintFeed .filter(item => { if (!item.lat || !item.lng) return false; return item.category in categoryToType; }) .map((item): GeoEvent => { let eventType: GeoEvent['type'] = 'osint'; const title = item.title; if (IMPACT_PATTERN.test(title)) eventType = 'impact'; else if (STRIKE_PATTERN.test(title)) eventType = 'airstrike'; else if (MISSILE_PATTERN.test(title)) eventType = 'missile_launch'; else if (EXPLOSION_PATTERN.test(title)) eventType = 'explosion'; else if (INTERCEPT_PATTERN.test(title)) eventType = 'intercept'; let source: GeoEvent['source'] | undefined; if (/US|미국|America|Pentagon|CENTCOM/i.test(title)) source = 'US'; else if (/Israel|이스라엘|IAF|IDF/i.test(title)) source = 'IL'; else if (/Iran|이란|IRGC/i.test(title)) source = 'IR'; else if (/Houthi|후티|Hezbollah|헤즈볼라|PMF|proxy|대리/i.test(title)) source = 'proxy'; return { id: `osint-${item.id}`, timestamp: item.timestamp, lat: item.lat!, lng: item.lng!, type: eventType, source, label: `[OSINT] ${item.title}`, description: `출처: ${item.source} | ${item.url}`, intensity: eventType === 'impact' ? 80 : eventType === 'airstrike' ? 70 : 50, }; }); }, [osintFeed, dashboardTab]); // 피격 선박 → GeoEvent 변환 const damageEvents = useMemo(() => damagedShips.map(s => ({ id: `dmg-${s.id}`, timestamp: s.damagedAt, lat: s.lat, lng: s.lng, type: 'sea_attack' as const, source: s.flag === 'IR' ? 'IR' as const : undefined, label: `${s.name} (${s.flag}) — ${s.cause}`, description: s.description, intensity: s.damage === 'sunk' ? 100 : s.damage === 'severe' ? 75 : s.damage === 'moderate' ? 50 : 25, })), []); // 기본 이벤트 + OSINT 이벤트 + 피격 선박 병합 (시간순 정렬) // API 모드: DB에 이미 sampleEvents+damagedShips 포함 → damageEvents 중복 방지 const mergedEvents = useMemo(() => { const extra = isApi ? [] : damageEvents; return [...events, ...osintEvents, ...extra].sort((a, b) => a.timestamp - b.timestamp); }, [events, osintEvents, damageEvents, isApi]); // Aircraft stats const aircraftByCategory = useMemo(() => { const counts: Record = {}; for (const ac of aircraft) { counts[ac.category] = (counts[ac.category] || 0) + 1; } return counts; }, [aircraft]); const militaryCount = useMemo( () => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length, [aircraft], ); // Ship stats — MT classification const shipsByCategory = useMemo(() => { const counts: Record = {}; for (const s of ships) { const mtCat = getMarineTrafficCategory(s.typecode, s.category); counts[mtCat] = (counts[mtCat] || 0) + 1; } return counts; }, [ships]); // Korean ship stats const koreanShips = useMemo(() => ships.filter(s => s.flag === 'KR'), [ships]); const koreanShipsByCategory = useMemo(() => { const counts: Record = {}; for (const s of koreanShips) { const mtCat = getMarineTrafficCategory(s.typecode, s.category); counts[mtCat] = (counts[mtCat] || 0) + 1; } return counts; }, [koreanShips]); return { aircraft, ships, visibleAircraft, visibleShips, satPositions, events, mergedEvents, seismicData, pressureData, osintFeed, aircraftByCategory, militaryCount, shipsByCategory, koreanShips, koreanShipsByCategory, }; } /** * 센서 데이터 병합: 새 데이터를 기존 배열에 추가, 중복 제거, 오래된 데이터 제거 */ function mergeSensor( existing: T[], incoming: T[], keyFn: (item: T) => string, maxMinutes: number, ): T[] { const cutoff = Date.now() - maxMinutes * 60_000; const map = new Map(); for (const item of existing) { if (item.timestamp >= cutoff) map.set(keyFn(item), item); } for (const item of incoming) { if (item.timestamp >= cutoff) map.set(keyFn(item), item); } return Array.from(map.values()).sort((a, b) => a.timestamp - b.timestamp); }