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