import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { ReplayMap } from './components/ReplayMap'; import type { FlyToTarget } from './components/ReplayMap'; import { GlobeMap } from './components/GlobeMap'; import { SatelliteMap } from './components/SatelliteMap'; import { KoreaMap } from './components/KoreaMap'; import { TimelineSlider } from './components/TimelineSlider'; import { ReplayControls } from './components/ReplayControls'; import { LiveControls } from './components/LiveControls'; import { SensorChart } from './components/SensorChart'; import { EventLog } from './components/EventLog'; import { LayerPanel } from './components/LayerPanel'; import { useReplay } from './hooks/useReplay'; import { useMonitor } from './hooks/useMonitor'; import { fetchEvents, fetchSensorData } from './services/api'; import { fetchAircraftOpenSky } from './services/opensky'; import { fetchMilitaryAircraft, fetchAllAircraftLive, fetchMilitaryAircraftKorea, fetchAllAircraftLiveKorea } from './services/airplaneslive'; import { fetchSatelliteTLE, fetchSatelliteTLEKorea, propagateAll } from './services/celestrak'; import { fetchAircraftOpenSkyKorea } from './services/opensky'; import { fetchShips, fetchShipsKorea } from './services/ships'; import { fetchOsintFeed } from './services/osint'; import { KOREA_SUBMARINE_CABLES } from './services/submarineCable'; import type { OsintItem } from './services/osint'; import { propagateAircraft, propagateShips } from './services/propagation'; import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, LayerVisibility, AppMode } from './types'; import { useTheme } from './hooks/useTheme'; import { useAuth } from './hooks/useAuth'; import { useTranslation } from 'react-i18next'; import LoginPage from './components/auth/LoginPage'; import './App.css'; // MarineTraffic-style ship classification // Maps S&P STAT5CODE prefixes and our custom typecodes to MT categories function getMarineTrafficCategory(typecode?: string, category?: string): string { if (!typecode) { // Fallback to our internal category if (category === 'tanker') return 'tanker'; if (category === 'cargo') return 'cargo'; if (category === 'destroyer' || category === 'warship' || category === 'carrier' || category === 'patrol') return 'military'; return 'unspecified'; } const code = typecode.toUpperCase(); // Our custom typecodes if (code === 'VLCC' || code === 'LNG' || code === 'LPG') return 'tanker'; if (code === 'CONT' || code === 'BULK') return 'cargo'; if (code === 'DDH' || code === 'DDG' || code === 'CVN' || code === 'FFG' || code === 'LCS' || code === 'MCM' || code === 'PC') return 'military'; // S&P STAT5CODE (IHS StatCode5) — first 2 chars determine main category // A1x = Tankers (crude, products, chemical, LPG, LNG) if (code.startsWith('A1')) return 'tanker'; // A2x = Bulk carriers if (code.startsWith('A2')) return 'cargo'; // A3x = General cargo / Container / Reefer / Ro-Ro if (code.startsWith('A3')) return 'cargo'; // B1x / B2x = Passenger / Cruise / Ferry if (code.startsWith('B')) return 'passenger'; // C1x = Fishing if (code.startsWith('C')) return 'fishing'; // D1x = Offshore (tugs, supply, etc.) if (code.startsWith('D')) return 'tug_special'; // E = Other activities (research, cable layers, dredgers) if (code.startsWith('E')) return 'tug_special'; // X = Non-propelled (barges) if (code.startsWith('X')) return 'unspecified'; // S&P VesselType strings const lower = code.toLowerCase(); if (lower.includes('tanker')) return 'tanker'; if (lower.includes('cargo') || lower.includes('container') || lower.includes('bulk')) return 'cargo'; if (lower.includes('passenger') || lower.includes('cruise') || lower.includes('ferry')) return 'passenger'; if (lower.includes('fishing')) return 'fishing'; if (lower.includes('tug') || lower.includes('supply') || lower.includes('offshore')) return 'tug_special'; if (lower.includes('high speed')) return 'high_speed'; if (lower.includes('pleasure') || lower.includes('yacht') || lower.includes('sailing')) return 'pleasure'; return 'unspecified'; } function App() { const { user, isLoading: authLoading, isAuthenticated, login, devLogin, logout } = useAuth(); if (authLoading) { return (
Loading...
); } if (!isAuthenticated) { return ; } return ; } interface AuthenticatedAppProps { user: { email: string; name: string; picture?: string } | null; onLogout: () => Promise; } function AuthenticatedApp(_props: AuthenticatedAppProps) { const [appMode, setAppMode] = useState('live'); const [events, setEvents] = useState([]); const [sensorData, setSensorData] = useState([]); const [baseAircraft, setBaseAircraft] = useState([]); const [baseShips, setBaseShips] = useState([]); const [baseShipsKorea, setBaseShipsKorea] = useState([]); const [baseAircraftKorea, setBaseAircraftKorea] = useState([]); const [satellitesKorea, setSatellitesKorea] = useState([]); const [satPositionsKorea, setSatPositionsKorea] = useState([]); const [osintFeed, setOsintFeed] = useState([]); const [satellites, setSatellites] = useState([]); const [satPositions, setSatPositions] = useState([]); const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); const [layers, setLayers] = useState({ events: true, aircraft: true, satellites: true, ships: true, koreanShips: false, airports: true, sensorCharts: true, oilFacilities: true, militaryOnly: false, }); // Korea tab layer visibility (lifted from KoreaMap) const [koreaLayers, setKoreaLayers] = useState>({ ships: true, aircraft: true, satellites: true, infra: true, cables: true, cctv: true, airports: true, coastGuard: true, navWarning: true, osint: true, eez: true, piracy: true, militaryOnly: false, }); const toggleKoreaLayer = useCallback((key: string) => { setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] })); }, []); // Category filter state (shared across tabs) const [hiddenAcCategories, setHiddenAcCategories] = useState>(new Set()); const [hiddenShipCategories, setHiddenShipCategories] = useState>(new Set()); const toggleAcCategory = useCallback((cat: string) => { setHiddenAcCategories(prev => { const next = new Set(prev); if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } return next; }); }, []); const toggleShipCategory = useCallback((cat: string) => { setHiddenShipCategories(prev => { const next = new Set(prev); if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } return next; }); }, []); const [flyToTarget, setFlyToTarget] = useState(null); // 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트) const [refreshKey, setRefreshKey] = useState(0); useEffect(() => { const HOUR_MS = 3600_000; const interval = setInterval(() => { console.log('[REFRESH] 1시간 주기 전체 데이터 갱신'); setRefreshKey(k => k + 1); }, HOUR_MS); return () => clearInterval(interval); }, []); // Korea monitoring filters (independent toggles) const [koreaFilters, setKoreaFilters] = useState({ illegalFishing: false, illegalTransship: false, darkVessel: false, cableWatch: false, dokdoWatch: false, ferryWatch: false, }); const replay = useReplay(); const monitor = useMonitor(); const { theme, toggleTheme } = useTheme(); const { t, i18n } = useTranslation(); const toggleLang = useCallback(() => { i18n.changeLanguage(i18n.language === 'ko' ? 'en' : 'ko'); }, [i18n]); const isLive = appMode === 'live'; // Unified time values based on current mode const currentTime = appMode === 'live' ? monitor.state.currentTime : replay.state.currentTime; const startTime = appMode === 'live' ? monitor.startTime : replay.state.startTime; const endTime = appMode === 'live' ? monitor.endTime : replay.state.endTime; // Load initial data — each source independently to avoid one failure blocking all useEffect(() => { fetchEvents().then(setEvents).catch(() => {}); fetchSensorData().then(setSensorData).catch(() => {}); fetchSatelliteTLE().then(setSatellites).catch(() => {}); }, [refreshKey]); // Fetch base aircraft data // LIVE: OpenSky (민간기) + Airplanes.live (모든 항공기 + 군용기) → 실시간 병합 // REPLAY: OpenSky (샘플 폴백) + military → 리플레이 useEffect(() => { const load = async () => { if (appMode === 'live') { // 라이브: 3개 소스 동시 가져오기 — OpenSky + Airplanes.live + Military const [opensky, allLive, mil] = await Promise.all([ fetchAircraftOpenSky().catch(() => [] as Aircraft[]), fetchAllAircraftLive().catch(() => [] as Aircraft[]), fetchMilitaryAircraft().catch(() => [] as Aircraft[]), ]); // 1) Airplanes.live 기본 + mil 카테고리 보강 const milMap = new Map(mil.map(a => [a.icao24, a])); const merged = new Map(); for (const ac of allLive) { const milAc = milMap.get(ac.icao24); if (milAc) { merged.set(ac.icao24, { ...ac, category: milAc.category, typecode: milAc.typecode || ac.typecode }); } else { merged.set(ac.icao24, ac); } } // 2) mil에만 있는 항공기 추가 for (const m of mil) { if (!merged.has(m.icao24)) merged.set(m.icao24, m); } // 3) OpenSky 데이터 추가 (Airplanes.live에 없는 항공기만) for (const ac of opensky) { if (!merged.has(ac.icao24)) merged.set(ac.icao24, ac); } const result = Array.from(merged.values()); if (result.length > 0) setBaseAircraft(result); } else { // 리플레이: 기존 로직 (OpenSky 샘플 + military) const [opensky, mil] = await Promise.all([ fetchAircraftOpenSky(), fetchMilitaryAircraft(), ]); const milIcaos = new Set(mil.map(a => a.icao24)); const merged = [...mil, ...opensky.filter(a => !milIcaos.has(a.icao24))]; setBaseAircraft(merged); } }; load(); const interval = setInterval(load, 15_000); return () => clearInterval(interval); }, [appMode, refreshKey]); // Fetch base ship data — never overwrite with empty to prevent flicker useEffect(() => { const load = async () => { try { const data = await fetchShips(); if (data.length > 0) { setBaseShips(data); } } catch { // keep previous data } }; load(); const interval = setInterval(load, 15_000); return () => clearInterval(interval); }, [appMode, refreshKey]); // Fetch Korea region ship data (signal-batch, 4-min cycle) useEffect(() => { const load = async () => { try { const data = await fetchShipsKorea(); if (data.length > 0) setBaseShipsKorea(data); } catch { /* keep previous */ } }; load(); const interval = setInterval(load, 240_000); return () => clearInterval(interval); }, [appMode, refreshKey]); // Fetch Korea satellite TLE data useEffect(() => { fetchSatelliteTLEKorea().then(setSatellitesKorea).catch(() => {}); }, [refreshKey]); // Fetch Korea aircraft data useEffect(() => { const load = async () => { const [opensky, allLive, mil] = await Promise.all([ fetchAircraftOpenSkyKorea().catch(() => [] as Aircraft[]), fetchAllAircraftLiveKorea().catch(() => [] as Aircraft[]), fetchMilitaryAircraftKorea().catch(() => [] as Aircraft[]), ]); const milMap = new Map(mil.map(a => [a.icao24, a])); const merged = new Map(); for (const ac of allLive) { const milAc = milMap.get(ac.icao24); merged.set(ac.icao24, milAc ? { ...ac, category: milAc.category, typecode: milAc.typecode || ac.typecode } : ac); } for (const m of mil) { if (!merged.has(m.icao24)) merged.set(m.icao24, m); } for (const ac of opensky) { if (!merged.has(ac.icao24)) merged.set(ac.icao24, ac); } const result = Array.from(merged.values()); if (result.length > 0) setBaseAircraftKorea(result); }; load(); const interval = setInterval(load, 25_000); return () => clearInterval(interval); }, [refreshKey]); // 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 { const data = await fetchOsintFeed(dashboardTab); if (data.length > 0) setOsintFeed(data); } catch { /* keep previous */ } }; setOsintFeed([]); // clear while loading new focus load(); const interval = setInterval(load, 120_000); return () => clearInterval(interval); }, [isLive, dashboardTab, refreshKey]); // OSINT → GeoEvent 변환: 피격/군사 정보를 타임라인 이벤트로 반영 const osintEvents = useMemo((): GeoEvent[] => { if (dashboardTab !== 'iran' || osintFeed.length === 0) return []; // OSINT 카테고리 → GeoEvent 타입 매핑 (피격 정보 기본) const categoryToType: Record = { military: 'osint', shipping: 'osint', oil: 'osint', nuclear: 'osint', diplomacy: 'osint', }; // 피격/공습 키워드 → 구체적 이벤트 타입 분류 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; return osintFeed .filter(item => { // lat/lng가 있는 항목만 타임라인에 반영 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]); // 기본 이벤트 + OSINT 이벤트 병합 (시간순 정렬) const mergedEvents = useMemo(() => { if (osintEvents.length === 0) return events; return [...events, ...osintEvents].sort((a, b) => a.timestamp - b.timestamp); }, [events, osintEvents]); // Propagate satellite positions — throttle to every 2s of real time const satTimeRef = useRef(0); 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 Korea satellite positions const satTimeKoreaRef = useRef(0); useEffect(() => { if (satellitesKorea.length === 0) return; const now = Date.now(); if (now - satTimeKoreaRef.current < 2000) return; satTimeKoreaRef.current = now; const positions = propagateAll(satellitesKorea, new Date(currentTime)); setSatPositionsKorea(positions); }, [satellitesKorea, currentTime]); // Propagate Korea aircraft (live only — no waypoint propagation needed) const aircraftKorea = useMemo(() => baseAircraftKorea, [baseAircraftKorea]); // 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], ); // Korea region ships (separate data) const koreaShips = useMemo( () => propagateShips(baseShipsKorea, currentTime, isLive), [baseShipsKorea, 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], ); const visibleAircraftKorea = useMemo( () => aircraftKorea.filter(a => !hiddenAcCategories.has(a.category)), [aircraftKorea, hiddenAcCategories], ); const visibleKoreaShips = useMemo( () => koreaShips.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))), [koreaShips, hiddenShipCategories], ); const toggleLayer = useCallback((key: keyof LayerVisibility) => { setLayers(prev => ({ ...prev, [key]: !prev[key] })); }, []); // Handle event card click from timeline: fly to location on map const handleEventFlyTo = useCallback((event: GeoEvent) => { setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); }, []); // 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], ); const koreaMilitaryCount = useMemo( () => aircraftKorea.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length, [aircraftKorea], ); // Ship stats — MT classification (matches map icon colors) 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 — MarineTraffic-style classification 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]); // Korea region stats (for Korea dashboard) const koreaKoreanShips = useMemo(() => koreaShips.filter(s => s.flag === 'KR'), [koreaShips]); const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]); const koreaShipsByCategory = useMemo(() => { const counts: Record = {}; for (const s of koreaShips) { const mtCat = getMarineTrafficCategory(s.typecode, s.category); counts[mtCat] = (counts[mtCat] || 0) + 1; } return counts; }, [koreaShips]); // Korea aircraft stats const koreaAircraftByCategory = useMemo(() => { const counts: Record = {}; for (const ac of aircraftKorea) { counts[ac.category] = (counts[ac.category] || 0) + 1; } return counts; }, [aircraftKorea]); // Korea filtered ships by monitoring mode (independent toggles, additive highlight) const anyFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch; // 불법환적 의심 선박 탐지: 100m 이내 근접 + 저속/정박 + 1시간 이상 유지 + 연안 제외 // 근접 쌍별 최초 감지 시각 추적 (pairKey → timestamp) const proximityStartRef = useRef>(new Map()); const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간 const transshipSuspects = useMemo(() => { if (!koreaFilters.illegalTransship) return new Set(); const suspects = new Set(); const isOffshore = (s: Ship) => { const nearCoastWest = s.lng > 125.5 && s.lng < 130.0 && s.lat > 33.5 && s.lat < 38.5; if (nearCoastWest) { const distFromEastCoast = s.lng - 129.5; const distFromWestCoast = 126.0 - s.lng; const distFromSouthCoast = 34.5 - s.lat; if (distFromEastCoast > 0.15 || distFromWestCoast > 0.15 || distFromSouthCoast > 0.15) return true; return false; } return true; }; const isNearForeignCoast = (s: Ship) => { if (s.lng < 123.5 && s.lat > 25 && s.lat < 40) return true; if (s.lng > 130.5 && s.lat > 30 && s.lat < 46) return true; if (s.lng > 129.1 && s.lng < 129.6 && s.lat > 34.0 && s.lat < 34.8) return true; if (s.lng > 129.5 && s.lat > 31 && s.lat < 34) return true; return false; }; const candidates = koreaShips.filter(s => { if (s.speed >= 2) return false; const mtCat = getMarineTrafficCategory(s.typecode, s.category); if (mtCat !== 'tanker' && mtCat !== 'cargo' && mtCat !== 'fishing') return false; if (isNearForeignCoast(s)) return false; return isOffshore(s); }); const now = currentTime; const prevMap = proximityStartRef.current; const currentPairs = new Set(); const PROXIMITY_DEG = 0.001; // ~110m for (let i = 0; i < candidates.length; i++) { for (let j = i + 1; j < candidates.length; j++) { const a = candidates[i]; const b = candidates[j]; const dlat = Math.abs(a.lat - b.lat); const dlng = Math.abs(a.lng - b.lng) * Math.cos((a.lat * Math.PI) / 180); if (dlat < PROXIMITY_DEG && dlng < PROXIMITY_DEG) { const pairKey = [a.mmsi, b.mmsi].sort().join(':'); currentPairs.add(pairKey); // 최초 감지 시 시작 시각 기록 if (!prevMap.has(pairKey)) { prevMap.set(pairKey, now); } // 1시간 이상 근접 유지 시 환적 의심 const startTime = prevMap.get(pairKey)!; if (now - startTime >= TRANSSHIP_DURATION_MS) { suspects.add(a.mmsi); suspects.add(b.mmsi); } } } } // 더 이상 근접하지 않는 쌍은 추적 해제 for (const key of prevMap.keys()) { if (!currentPairs.has(key)) prevMap.delete(key); } return suspects; }, [koreaShips, koreaFilters.illegalTransship, currentTime]); // 다크베셀 탐지: AIS 신호 이력 추적 // mmsi → { lastSeenTs[], gapTotal, toggleCount } const aisHistoryRef = useRef>(new Map()); const ONE_HOUR_MS = 60 * 60 * 1000; const darkVesselSet = useMemo(() => { if (!koreaFilters.darkVessel) return new Set(); const now = currentTime; const history = aisHistoryRef.current; const result = new Set(); // 현재 보이는 선박 mmsi 집합 const currentMmsis = new Set(koreaShips.map(s => s.mmsi)); // 현재 보이는 선박: 신호 기록 갱신 for (const s of koreaShips) { let h = history.get(s.mmsi); if (!h) { h = { seen: [], lastGapStart: null }; history.set(s.mmsi, h); } // AIS 신호가 꺼졌다 다시 켜진 경우 (gap이 있었으면 toggle +1) if (h.lastGapStart !== null) { const gapDuration = now - h.lastGapStart; // 1시간 이상 신호 끊김 후 재등장 = 다크베셀 if (gapDuration >= ONE_HOUR_MS) { result.add(s.mmsi); } h.lastGapStart = null; } // seen 타임스탬프 기록 (최근 20개만 유지) h.seen.push(now); if (h.seen.length > 20) h.seen = h.seen.slice(-20); // 신호 껐다켰다 패턴: lastSeen이 현재보다 많이 이전 (불규칙 AIS) // lastSeen 대비 현재 시간 차이가 크면 신호 불안정 const aisAge = now - s.lastSeen; if (aisAge > ONE_HOUR_MS) { result.add(s.mmsi); } // 신호 온오프 패턴: seen 기록에서 간격 분석 if (h.seen.length >= 4) { let gapCount = 0; for (let k = 1; k < h.seen.length; k++) { const gap = h.seen[k] - h.seen[k - 1]; // 정상 갱신 주기(~30초)보다 5배 이상 차이 = 신호 끊김 if (gap > 150_000) gapCount++; } // 3회 이상 신호 끊김 패턴 = 껐다켰다 if (gapCount >= 3) { result.add(s.mmsi); } } } // 이전에 보였지만 지금 안 보이는 선박: gap 시작 기록 for (const [mmsi, h] of history.entries()) { if (!currentMmsis.has(mmsi) && h.lastGapStart === null) { h.lastGapStart = now; } } // 오래된 이력 정리 (6시간 이상 미관측) const SIX_HOURS = 6 * ONE_HOUR_MS; for (const [mmsi, h] of history.entries()) { if (h.seen.length > 0 && now - h.seen[h.seen.length - 1] > SIX_HOURS && !currentMmsis.has(mmsi)) { history.delete(mmsi); } } return result; }, [koreaShips, koreaFilters.darkVessel, currentTime]); // 해저케이블 감시: 케이블 라인 ~1km 이내 + 0.6노트 이하 + 3시간 이상 체류 const cableNearStartRef = useRef>(new Map()); const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간 const cableWatchSet = useMemo(() => { if (!koreaFilters.cableWatch) return new Set(); const result = new Set(); const CABLE_PROX_DEG = 0.01; // ~1.1km // 케이블 세그먼트 수집 const segments: [number, number, number, number][] = []; for (const cable of KOREA_SUBMARINE_CABLES) { for (let k = 0; k < cable.route.length - 1; k++) { segments.push([cable.route[k][0], cable.route[k][1], cable.route[k + 1][0], cable.route[k + 1][1]]); } } // 점-선분 최소 거리 (도 단위 근사) const distToSegment = (px: number, py: number, x1: number, y1: number, x2: number, y2: number) => { const dx = x2 - x1; const dy = y2 - y1; if (dx === 0 && dy === 0) return Math.hypot(px - x1, py - y1); const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))); const cx = x1 + t * dx; const cy = y1 + t * dy; const dlng = (px - cx) * Math.cos((py * Math.PI) / 180); return Math.hypot(dlng, py - cy); }; const now = currentTime; const prevMap = cableNearStartRef.current; const currentNear = new Set(); for (const s of koreaShips) { if (s.speed > 0.6) continue; // 0.6노트 이하만 let nearCable = false; for (const [x1, y1, x2, y2] of segments) { if (distToSegment(s.lng, s.lat, x1, y1, x2, y2) < CABLE_PROX_DEG) { nearCable = true; break; } } if (!nearCable) continue; currentNear.add(s.mmsi); // 최초 감지 시 시작 시각 기록 if (!prevMap.has(s.mmsi)) { prevMap.set(s.mmsi, now); } // 3시간 이상 케이블 위 체류 시 의심 선박 const startTime = prevMap.get(s.mmsi)!; if (now - startTime >= CABLE_DURATION_MS) { result.add(s.mmsi); } } // 케이블 근처 벗어난 선박은 추적 해제 for (const mmsi of prevMap.keys()) { if (!currentNear.has(mmsi)) prevMap.delete(mmsi); } return result; }, [koreaShips, koreaFilters.cableWatch, currentTime]); // 독도감시: 독도 영해(12해리≈22km) 접근 일본 선박 탐지 + 알림 const DOKDO = { lat: 37.2417, lng: 131.8647 }; const TERRITORIAL_DEG = 0.2; // ~22km (12해리) const ALERT_DEG = 0.4; // ~44km (접근 경고 범위) const dokdoAlertedRef = useRef>(new Set()); const [dokdoAlerts, setDokdoAlerts] = useState<{ mmsi: string; name: string; dist: number; time: number }[]>([]); const dokdoWatchSet = useMemo(() => { if (!koreaFilters.dokdoWatch) return new Set(); const result = new Set(); const newAlerts: { mmsi: string; name: string; dist: number; time: number }[] = []; const alerted = dokdoAlertedRef.current; for (const s of koreaShips) { // 일본 국적 선박만 감시 if (s.flag !== 'JP') continue; const dDokdo = Math.hypot( (s.lng - DOKDO.lng) * Math.cos((DOKDO.lat * Math.PI) / 180), s.lat - DOKDO.lat, ); // 영해 내 진입 if (dDokdo < TERRITORIAL_DEG) { result.add(s.mmsi); if (!alerted.has(s.mmsi)) { alerted.add(s.mmsi); const distKm = Math.round(dDokdo * 111); newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime }); } } // 접근 경고 (영해 밖이지만 가까움) else if (dDokdo < ALERT_DEG) { result.add(s.mmsi); if (!alerted.has(`warn-${s.mmsi}`)) { alerted.add(`warn-${s.mmsi}`); const distKm = Math.round(dDokdo * 111); newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime }); } } } // 벗어난 선박 추적 해제 const currentJP = new Set(koreaShips.filter(s => s.flag === 'JP').map(s => s.mmsi)); for (const key of alerted) { const mmsi = key.replace('warn-', ''); if (!currentJP.has(mmsi)) alerted.delete(key); } if (newAlerts.length > 0) { setDokdoAlerts(prev => [...newAlerts, ...prev].slice(0, 10)); } return result; }, [koreaShips, koreaFilters.dokdoWatch, currentTime]); const koreaFilteredShips = useMemo(() => { if (!anyFilterOn) return visibleKoreaShips; return visibleKoreaShips.filter(s => { const mtCat = getMarineTrafficCategory(s.typecode, s.category); if (koreaFilters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true; if (koreaFilters.illegalTransship && transshipSuspects.has(s.mmsi)) return true; if (koreaFilters.darkVessel && darkVesselSet.has(s.mmsi)) return true; if (koreaFilters.cableWatch && cableWatchSet.has(s.mmsi)) return true; if (koreaFilters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true; if (koreaFilters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true; return false; }); }, [visibleKoreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]); return (
{/* Dashboard Tabs (replaces title) */}
{/* Mode Toggle */} {dashboardTab === 'iran' && (
⚔️ D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
)} {dashboardTab === 'korea' && (
)} {dashboardTab === 'iran' && (
)}
{dashboardTab === 'iran' ? aircraft.length : aircraftKorea.length} AC {dashboardTab === 'iran' ? militaryCount : koreaMilitaryCount} MIL {dashboardTab === 'iran' ? ships.length : koreaShips.length} SHIP {dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
{/* ═══════════════════════════════════════ IRAN DASHBOARD ═══════════════════════════════════════ */} {dashboardTab === 'iran' && ( <>
{mapMode === 'flat' ? ( setFlyToTarget(null)} /> ) : mapMode === 'globe' ? ( ) : ( )}
} onToggle={toggleLayer as (key: string) => void} aircraftByCategory={aircraftByCategory} aircraftTotal={aircraft.length} shipsByMtCategory={shipsByCategory} shipTotal={ships.length} satelliteCount={satPositions.length} extraLayers={[ { key: 'events', label: t('layers.events'), color: '#a855f7' }, { key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: koreanShips.length }, { key: 'airports', label: t('layers.airports'), color: '#f59e0b' }, { key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' }, { key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, ]} hiddenAcCategories={hiddenAcCategories} hiddenShipCategories={hiddenShipCategories} onAcCategoryToggle={toggleAcCategory} onShipCategoryToggle={toggleShipCategory} />
{layers.sensorCharts && (
)}
{isLive ? ( ) : ( <> )}
)} {/* ═══════════════════════════════════════ KOREA DASHBOARD ═══════════════════════════════════════ */} {dashboardTab === 'korea' && ( <>
)}
); } export default App;