- 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>
1204 lines
49 KiB
TypeScript
1204 lines
49 KiB
TypeScript
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;
|