kcg-monitoring/frontend/src/App.tsx
htlee 2534faa488 feat: 프론트엔드 모노레포 이관 + signal-batch 연동 + Tailwind/i18n/테마 전환
- frontend/ 폴더로 프론트엔드 전체 이관
- signal-batch API 연동 (한국 선박 위치 데이터)
- Tailwind CSS 4 + CSS 변수 테마 토큰 (dark/light)
- i18next 다국어 (ko/en) 인프라 + 28개 컴포넌트 적용
- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 범례)
- Google OAuth 로그인 화면 + DEV LOGIN 우회
- 외부 API CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak)
- ShipLayer 이미지 탭 전환 (signal-batch / MarineTraffic)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:54:41 +09:00

1204 lines
49 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div
className="flex min-h-screen items-center justify-center"
style={{ backgroundColor: 'var(--kcg-bg)', color: 'var(--kcg-muted)' }}
>
Loading...
</div>
);
}
if (!isAuthenticated) {
return <LoginPage onGoogleLogin={login} onDevLogin={devLogin} />;
}
return <AuthenticatedApp user={user} onLogout={logout} />;
}
interface AuthenticatedAppProps {
user: { email: string; name: string; picture?: string } | null;
onLogout: () => Promise<void>;
}
function AuthenticatedApp(_props: AuthenticatedAppProps) {
const [appMode, setAppMode] = useState<AppMode>('live');
const [events, setEvents] = useState<GeoEvent[]>([]);
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
const [baseAircraft, setBaseAircraft] = useState<Aircraft[]>([]);
const [baseShips, setBaseShips] = useState<Ship[]>([]);
const [baseShipsKorea, setBaseShipsKorea] = useState<Ship[]>([]);
const [baseAircraftKorea, setBaseAircraftKorea] = useState<Aircraft[]>([]);
const [satellitesKorea, setSatellitesKorea] = useState<Satellite[]>([]);
const [satPositionsKorea, setSatPositionsKorea] = useState<SatellitePosition[]>([]);
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
const [satellites, setSatellites] = useState<Satellite[]>([]);
const [satPositions, setSatPositions] = useState<SatellitePosition[]>([]);
const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite');
const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran');
const [layers, setLayers] = useState<LayerVisibility>({
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<Record<string, boolean>>({
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<Set<string>>(new Set());
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(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<FlyToTarget | null>(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<string, Aircraft>();
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<string, Aircraft>();
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<string, GeoEvent['type']> = {
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<string, number> = {};
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<string, number> = {};
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<string, number> = {};
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<string, number> = {};
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<string, number> = {};
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<Map<string, number>>(new Map());
const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간
const transshipSuspects = useMemo(() => {
if (!koreaFilters.illegalTransship) return new Set<string>();
const suspects = new Set<string>();
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<string>();
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<Map<string, { seen: number[]; lastGapStart: number | null }>>(new Map());
const ONE_HOUR_MS = 60 * 60 * 1000;
const darkVesselSet = useMemo(() => {
if (!koreaFilters.darkVessel) return new Set<string>();
const now = currentTime;
const history = aisHistoryRef.current;
const result = new Set<string>();
// 현재 보이는 선박 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<Map<string, number>>(new Map());
const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간
const cableWatchSet = useMemo(() => {
if (!koreaFilters.cableWatch) return new Set<string>();
const result = new Set<string>();
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<string>();
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<Set<string>>(new Set());
const [dokdoAlerts, setDokdoAlerts] = useState<{ mmsi: string; name: string; dist: number; time: number }[]>([]);
const dokdoWatchSet = useMemo(() => {
if (!koreaFilters.dokdoWatch) return new Set<string>();
const result = new Set<string>();
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 (
<div className={`app ${isLive ? 'app-live' : ''}`}>
<header className="app-header">
{/* Dashboard Tabs (replaces title) */}
<div className="dash-tabs">
<button
className={`dash-tab ${dashboardTab === 'iran' ? 'active' : ''}`}
onClick={() => setDashboardTab('iran')}
>
<span className="dash-tab-flag">🇮🇷</span>
{t('tabs.iran')}
</button>
<button
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
onClick={() => setDashboardTab('korea')}
>
<span className="dash-tab-flag">🇰🇷</span>
{t('tabs.korea')}
</button>
</div>
{/* Mode Toggle */}
{dashboardTab === 'iran' && (
<div className="mode-toggle">
<div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
<span className="text-[13px]"></span>
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
</div>
<button
className={`mode-btn ${appMode === 'live' ? 'active live' : ''}`}
onClick={() => setAppMode('live')}
>
<span className="mode-dot-icon" />
{t('mode.live')}
</button>
<button
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
onClick={() => setAppMode('replay')}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
{t('mode.replay')}
</button>
</div>
)}
{dashboardTab === 'korea' && (
<div className="mode-toggle">
<button
className={`mode-btn ${koreaFilters.illegalFishing ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalFishing: !prev.illegalFishing }))}
title={t('filters.illegalFishing')}
>
<span className="text-[11px]">🚫🐟</span>
{t('filters.illegalFishing')}
</button>
<button
className={`mode-btn ${koreaFilters.illegalTransship ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalTransship: !prev.illegalTransship }))}
title={t('filters.illegalTransship')}
>
<span className="text-[11px]"></span>
{t('filters.illegalTransship')}
</button>
<button
className={`mode-btn ${koreaFilters.darkVessel ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, darkVessel: !prev.darkVessel }))}
title={t('filters.darkVessel')}
>
<span className="text-[11px]">👻</span>
{t('filters.darkVessel')}
</button>
<button
className={`mode-btn ${koreaFilters.cableWatch ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, cableWatch: !prev.cableWatch }))}
title={t('filters.cableWatch')}
>
<span className="text-[11px]">🔌</span>
{t('filters.cableWatch')}
</button>
<button
className={`mode-btn ${koreaFilters.dokdoWatch ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, dokdoWatch: !prev.dokdoWatch }))}
title={t('filters.dokdoWatch')}
>
<span className="text-[11px]">🏝</span>
{t('filters.dokdoWatch')}
</button>
<button
className={`mode-btn ${koreaFilters.ferryWatch ? 'active live' : ''}`}
onClick={() => setKoreaFilters(prev => ({ ...prev, ferryWatch: !prev.ferryWatch }))}
title={t('filters.ferryWatch')}
>
<span className="text-[11px]">🚢</span>
{t('filters.ferryWatch')}
</button>
</div>
)}
{dashboardTab === 'iran' && (
<div className="map-mode-toggle">
<button
className={`map-mode-btn ${mapMode === 'flat' ? 'active' : ''}`}
onClick={() => setMapMode('flat')}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
{t('mapMode.flat')}
</button>
<button
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
onClick={() => setMapMode('globe')}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
{t('mapMode.globe')}
</button>
<button
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
onClick={() => setMapMode('satellite')}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
{t('mapMode.satellite')}
</button>
</div>
)}
<div className="header-info">
<div className="header-counts">
<span className="count-item ac-count">{dashboardTab === 'iran' ? aircraft.length : aircraftKorea.length} AC</span>
<span className="count-item mil-count">{dashboardTab === 'iran' ? militaryCount : koreaMilitaryCount} MIL</span>
<span className="count-item ship-count">{dashboardTab === 'iran' ? ships.length : koreaShips.length} SHIP</span>
<span className="count-item sat-count">{dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT</span>
</div>
<div className="header-toggles">
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
{i18n.language === 'ko' ? 'KO' : 'EN'}
</button>
<button type="button" className="header-toggle-btn" onClick={toggleTheme} title="Theme">
{theme === 'dark' ? '🌙' : '☀️'}
</button>
</div>
<div className="header-status">
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
</div>
</div>
</header>
{/* ═══════════════════════════════════════
IRAN DASHBOARD
═══════════════════════════════════════ */}
{dashboardTab === 'iran' && (
<>
<main className="app-main">
<div className="map-panel">
{mapMode === 'flat' ? (
<ReplayMap
key="map-iran"
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
aircraft={visibleAircraft}
satellites={satPositions}
ships={visibleShips}
layers={layers}
flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)}
/>
) : mapMode === 'globe' ? (
<GlobeMap
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
aircraft={visibleAircraft}
satellites={satPositions}
ships={visibleShips}
layers={layers}
/>
) : (
<SatelliteMap
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
aircraft={visibleAircraft}
satellites={satPositions}
ships={visibleShips}
layers={layers}
/>
)}
<div className="map-overlay-left">
<LayerPanel
layers={layers as unknown as Record<string, boolean>}
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}
/>
</div>
</div>
<aside className="side-panel">
<EventLog
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
totalShipCount={ships.length}
koreanShips={koreanShips}
koreanShipsByCategory={koreanShipsByCategory}
osintFeed={osintFeed}
isLive={isLive}
dashboardTab={dashboardTab}
onTabChange={setDashboardTab}
ships={ships}
/>
</aside>
</main>
{layers.sensorCharts && (
<section className="charts-panel">
<SensorChart
data={sensorData}
currentTime={currentTime}
startTime={startTime}
endTime={endTime}
/>
</section>
)}
<footer className="app-footer">
{isLive ? (
<LiveControls
currentTime={monitor.state.currentTime}
historyMinutes={monitor.state.historyMinutes}
onHistoryChange={monitor.setHistoryMinutes}
aircraftCount={aircraft.length}
shipCount={ships.length}
satelliteCount={satPositions.length}
/>
) : (
<>
<ReplayControls
isPlaying={replay.state.isPlaying}
speed={replay.state.speed}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
onPlay={replay.play}
onPause={replay.pause}
onReset={replay.reset}
onSpeedChange={replay.setSpeed}
onRangeChange={replay.setRange}
/>
<TimelineSlider
currentTime={replay.state.currentTime}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
events={mergedEvents}
onSeek={replay.seek}
onEventFlyTo={handleEventFlyTo}
/>
</>
)}
</footer>
</>
)}
{/* ═══════════════════════════════════════
KOREA DASHBOARD
═══════════════════════════════════════ */}
{dashboardTab === 'korea' && (
<>
<main className="app-main">
<div className="map-panel">
<KoreaMap
ships={koreaFilteredShips}
aircraft={visibleAircraftKorea}
satellites={satPositionsKorea}
layers={koreaLayers}
osintFeed={osintFeed}
currentTime={currentTime}
koreaFilters={koreaFilters}
transshipSuspects={transshipSuspects}
cableWatchSuspects={cableWatchSet}
dokdoWatchSuspects={dokdoWatchSet}
dokdoAlerts={dokdoAlerts}
/>
<div className="map-overlay-left">
<LayerPanel
layers={koreaLayers}
onToggle={toggleKoreaLayer}
aircraftByCategory={koreaAircraftByCategory}
aircraftTotal={aircraftKorea.length}
shipsByMtCategory={koreaShipsByCategory}
shipTotal={koreaShips.length}
satelliteCount={satPositionsKorea.length}
extraLayers={[
{ key: 'infra', label: t('layers.infra'), color: '#ffc107' },
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff' },
{ key: 'cctv', label: t('layers.cctv'), color: '#ff6b6b', count: 15 },
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 16 },
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 46 },
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308' },
{ key: 'osint', label: t('layers.osint'), color: '#ef4444' },
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6' },
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444' },
]}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
onShipCategoryToggle={toggleShipCategory}
/>
</div>
</div>
<aside className="side-panel">
<EventLog
events={isLive ? [] : events}
currentTime={currentTime}
totalShipCount={koreaShips.length}
koreanShips={koreaKoreanShips}
koreanShipsByCategory={koreaShipsByCategory}
chineseShips={koreaChineseShips}
osintFeed={osintFeed}
isLive={isLive}
dashboardTab={dashboardTab}
onTabChange={setDashboardTab}
ships={koreaShips}
/>
</aside>
</main>
<footer className="app-footer">
{isLive ? (
<LiveControls
currentTime={monitor.state.currentTime}
historyMinutes={monitor.state.historyMinutes}
onHistoryChange={monitor.setHistoryMinutes}
aircraftCount={aircraftKorea.length}
shipCount={koreaShips.length}
satelliteCount={satPositionsKorea.length}
/>
) : (
<>
<ReplayControls
isPlaying={replay.state.isPlaying}
speed={replay.state.speed}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
onPlay={replay.play}
onPause={replay.pause}
onReset={replay.reset}
onSpeedChange={replay.setSpeed}
onRangeChange={replay.setRange}
/>
<TimelineSlider
currentTime={replay.state.currentTime}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
events={mergedEvents}
onSeek={replay.seek}
onEventFlyTo={handleEventFlyTo}
/>
</>
)}
</footer>
</>
)}
</div>
);
}
export default App;