- onShipClick: focusMmsi + flyToTarget(zoom 10) 동시 설정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
617 lines
26 KiB
TypeScript
617 lines
26 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { ReplayMap } from './components/iran/ReplayMap';
|
||
import type { FlyToTarget } from './components/iran/ReplayMap';
|
||
import { GlobeMap } from './components/iran/GlobeMap';
|
||
import { SatelliteMap } from './components/iran/SatelliteMap';
|
||
import { KoreaMap } from './components/korea/KoreaMap';
|
||
import { TimelineSlider } from './components/common/TimelineSlider';
|
||
import { ReplayControls } from './components/common/ReplayControls';
|
||
import { LiveControls } from './components/common/LiveControls';
|
||
import { SensorChart } from './components/common/SensorChart';
|
||
import { EventLog } from './components/common/EventLog';
|
||
import { LayerPanel } from './components/common/LayerPanel';
|
||
import { useReplay } from './hooks/useReplay';
|
||
import { useMonitor } from './hooks/useMonitor';
|
||
import { useIranData } from './hooks/useIranData';
|
||
import { useKoreaData } from './hooks/useKoreaData';
|
||
import { useKoreaFilters } from './hooks/useKoreaFilters';
|
||
import type { GeoEvent, 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 CollectorMonitor from './components/common/CollectorMonitor';
|
||
import './App.css';
|
||
|
||
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({ user, onLogout }: AuthenticatedAppProps) {
|
||
const [appMode, setAppMode] = useState<AppMode>('live');
|
||
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: true,
|
||
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(() => {
|
||
setRefreshKey(k => k + 1);
|
||
}, HOUR_MS);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
||
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
||
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
|
||
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
|
||
const [seismicMarker, setSeismicMarker] = useState<{ lat: number; lng: number; magnitude: number; place: string } | null>(null);
|
||
|
||
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;
|
||
|
||
// Iran data hook
|
||
const iranData = useIranData({
|
||
appMode,
|
||
currentTime,
|
||
isLive,
|
||
hiddenAcCategories,
|
||
hiddenShipCategories,
|
||
refreshKey,
|
||
dashboardTab,
|
||
});
|
||
|
||
// Korea data hook
|
||
const koreaData = useKoreaData({
|
||
currentTime,
|
||
isLive,
|
||
hiddenAcCategories,
|
||
hiddenShipCategories,
|
||
refreshKey,
|
||
});
|
||
|
||
// Korea filters hook
|
||
const koreaFiltersResult = useKoreaFilters(
|
||
koreaData.ships,
|
||
koreaData.visibleShips,
|
||
currentTime,
|
||
);
|
||
|
||
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 });
|
||
}, []);
|
||
|
||
return (
|
||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||
<header className="app-header">
|
||
{/* Dashboard Tabs (replaces title) */}
|
||
<div className="dash-tabs">
|
||
<button
|
||
type="button"
|
||
className={`dash-tab ${dashboardTab === 'iran' ? 'active' : ''}`}
|
||
onClick={() => setDashboardTab('iran')}
|
||
>
|
||
<span className="dash-tab-flag">🇮🇷</span>
|
||
{t('tabs.iran')}
|
||
</button>
|
||
<button
|
||
type="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
|
||
type="button"
|
||
className={`mode-btn ${appMode === 'live' ? 'active live' : ''}`}
|
||
onClick={() => setAppMode('live')}
|
||
>
|
||
<span className="mode-dot-icon" />
|
||
{t('mode.live')}
|
||
</button>
|
||
<button
|
||
type="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
|
||
type="button"
|
||
className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
|
||
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)}
|
||
title={t('filters.illegalFishing')}
|
||
>
|
||
<span className="text-[11px]">🚫🐟</span>
|
||
{t('filters.illegalFishing')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`mode-btn ${koreaFiltersResult.filters.illegalTransship ? 'active live' : ''}`}
|
||
onClick={() => koreaFiltersResult.setFilter('illegalTransship', !koreaFiltersResult.filters.illegalTransship)}
|
||
title={t('filters.illegalTransship')}
|
||
>
|
||
<span className="text-[11px]">⚓</span>
|
||
{t('filters.illegalTransship')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`mode-btn ${koreaFiltersResult.filters.darkVessel ? 'active live' : ''}`}
|
||
onClick={() => koreaFiltersResult.setFilter('darkVessel', !koreaFiltersResult.filters.darkVessel)}
|
||
title={t('filters.darkVessel')}
|
||
>
|
||
<span className="text-[11px]">👻</span>
|
||
{t('filters.darkVessel')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`mode-btn ${koreaFiltersResult.filters.cableWatch ? 'active live' : ''}`}
|
||
onClick={() => koreaFiltersResult.setFilter('cableWatch', !koreaFiltersResult.filters.cableWatch)}
|
||
title={t('filters.cableWatch')}
|
||
>
|
||
<span className="text-[11px]">🔌</span>
|
||
{t('filters.cableWatch')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`mode-btn ${koreaFiltersResult.filters.dokdoWatch ? 'active live' : ''}`}
|
||
onClick={() => koreaFiltersResult.setFilter('dokdoWatch', !koreaFiltersResult.filters.dokdoWatch)}
|
||
title={t('filters.dokdoWatch')}
|
||
>
|
||
<span className="text-[11px]">🏝️</span>
|
||
{t('filters.dokdoWatch')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`mode-btn ${koreaFiltersResult.filters.ferryWatch ? 'active live' : ''}`}
|
||
onClick={() => koreaFiltersResult.setFilter('ferryWatch', !koreaFiltersResult.filters.ferryWatch)}
|
||
title={t('filters.ferryWatch')}
|
||
>
|
||
<span className="text-[11px]">🚢</span>
|
||
{t('filters.ferryWatch')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{dashboardTab === 'iran' && (
|
||
<div className="map-mode-toggle">
|
||
<button
|
||
type="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
|
||
type="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
|
||
type="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' ? iranData.aircraft.length : koreaData.aircraft.length} AC</span>
|
||
<span className="count-item mil-count">{dashboardTab === 'iran' ? iranData.militaryCount : koreaData.militaryCount} MIL</span>
|
||
<span className="count-item ship-count">{dashboardTab === 'iran' ? iranData.ships.length : koreaData.ships.length} SHIP</span>
|
||
<span className="count-item sat-count">{dashboardTab === 'iran' ? iranData.satPositions.length : koreaData.satPositions.length} SAT</span>
|
||
</div>
|
||
<div className="header-toggles">
|
||
<button
|
||
type="button"
|
||
className="header-toggle-btn"
|
||
onClick={() => setShowCollectorMonitor(v => !v)}
|
||
title="수집기 모니터링"
|
||
>
|
||
MON
|
||
</button>
|
||
<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>
|
||
{user && (
|
||
<div className="header-user">
|
||
{user.picture && (
|
||
<img src={user.picture} alt="" className="header-user-avatar" referrerPolicy="no-referrer" />
|
||
)}
|
||
<span className="header-user-name">{user.name}</span>
|
||
<button type="button" className="header-toggle-btn" onClick={onLogout} title="Logout">
|
||
⏻
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
{/* ═══════════════════════════════════════
|
||
IRAN DASHBOARD
|
||
═══════════════════════════════════════ */}
|
||
{dashboardTab === 'iran' && (
|
||
<>
|
||
<main className="app-main">
|
||
<div className="map-panel">
|
||
{mapMode === 'flat' ? (
|
||
<ReplayMap
|
||
key="map-iran"
|
||
events={isLive ? [] : iranData.mergedEvents}
|
||
currentTime={currentTime}
|
||
aircraft={iranData.visibleAircraft}
|
||
satellites={iranData.satPositions}
|
||
ships={iranData.visibleShips}
|
||
layers={layers}
|
||
flyToTarget={flyToTarget}
|
||
onFlyToDone={() => setFlyToTarget(null)}
|
||
hoveredShipMmsi={hoveredShipMmsi}
|
||
focusShipMmsi={focusShipMmsi}
|
||
onFocusShipClear={() => setFocusShipMmsi(null)}
|
||
seismicMarker={seismicMarker}
|
||
/>
|
||
) : mapMode === 'globe' ? (
|
||
<GlobeMap
|
||
events={isLive ? [] : iranData.mergedEvents}
|
||
currentTime={currentTime}
|
||
aircraft={iranData.visibleAircraft}
|
||
satellites={iranData.satPositions}
|
||
ships={iranData.visibleShips}
|
||
layers={layers}
|
||
/>
|
||
) : (
|
||
<SatelliteMap
|
||
events={isLive ? [] : iranData.mergedEvents}
|
||
currentTime={currentTime}
|
||
aircraft={iranData.visibleAircraft}
|
||
satellites={iranData.satPositions}
|
||
ships={iranData.visibleShips}
|
||
layers={layers}
|
||
hoveredShipMmsi={hoveredShipMmsi}
|
||
focusShipMmsi={focusShipMmsi}
|
||
onFocusShipClear={() => setFocusShipMmsi(null)}
|
||
flyToTarget={flyToTarget}
|
||
onFlyToDone={() => setFlyToTarget(null)}
|
||
seismicMarker={seismicMarker}
|
||
/>
|
||
)}
|
||
<div className="map-overlay-left">
|
||
<LayerPanel
|
||
layers={layers as unknown as Record<string, boolean>}
|
||
onToggle={toggleLayer as (key: string) => void}
|
||
aircraftByCategory={iranData.aircraftByCategory}
|
||
aircraftTotal={iranData.aircraft.length}
|
||
shipsByMtCategory={iranData.shipsByCategory}
|
||
shipTotal={iranData.ships.length}
|
||
satelliteCount={iranData.satPositions.length}
|
||
extraLayers={[
|
||
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
|
||
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.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 ? [] : iranData.mergedEvents}
|
||
currentTime={currentTime}
|
||
totalShipCount={iranData.ships.length}
|
||
koreanShips={iranData.koreanShips}
|
||
koreanShipsByCategory={iranData.koreanShipsByCategory}
|
||
osintFeed={iranData.osintFeed}
|
||
isLive={isLive}
|
||
dashboardTab={dashboardTab}
|
||
onTabChange={setDashboardTab}
|
||
ships={iranData.ships}
|
||
highlightKoreanShips={layers.koreanShips}
|
||
onToggleHighlightKorean={() => setLayers(prev => ({ ...prev, koreanShips: !prev.koreanShips }))}
|
||
onShipHover={setHoveredShipMmsi}
|
||
onShipClick={(mmsi) => {
|
||
setFocusShipMmsi(mmsi);
|
||
const ship = iranData.ships.find(s => s.mmsi === mmsi);
|
||
if (ship) setFlyToTarget({ lat: ship.lat, lng: ship.lng, zoom: 10 });
|
||
}}
|
||
/>
|
||
</aside>
|
||
</main>
|
||
|
||
{layers.sensorCharts && (
|
||
<section className="charts-panel">
|
||
<SensorChart
|
||
seismicData={iranData.seismicData}
|
||
pressureData={iranData.pressureData}
|
||
currentTime={currentTime}
|
||
historyMinutes={monitor.state.historyMinutes}
|
||
onSeismicClick={(lat, lng, magnitude, place) => {
|
||
setFlyToTarget({ lat, lng, zoom: 8 });
|
||
setSeismicMarker({ lat, lng, magnitude, place });
|
||
}}
|
||
/>
|
||
</section>
|
||
)}
|
||
|
||
<footer className="app-footer">
|
||
{isLive ? (
|
||
<LiveControls
|
||
currentTime={monitor.state.currentTime}
|
||
historyMinutes={monitor.state.historyMinutes}
|
||
onHistoryChange={monitor.setHistoryMinutes}
|
||
aircraftCount={iranData.aircraft.length}
|
||
shipCount={iranData.ships.length}
|
||
satelliteCount={iranData.satPositions.length}
|
||
timeZone={timeZone}
|
||
onTimeZoneChange={setTimeZone}
|
||
/>
|
||
) : (
|
||
<>
|
||
<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={iranData.mergedEvents}
|
||
onSeek={replay.seek}
|
||
onEventFlyTo={handleEventFlyTo}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
</footer>
|
||
</>
|
||
)}
|
||
|
||
{/* ═══════════════════════════════════════
|
||
KOREA DASHBOARD
|
||
═══════════════════════════════════════ */}
|
||
{dashboardTab === 'korea' && (
|
||
<>
|
||
<main className="app-main">
|
||
<div className="map-panel">
|
||
<KoreaMap
|
||
ships={koreaFiltersResult.filteredShips}
|
||
aircraft={koreaData.visibleAircraft}
|
||
satellites={koreaData.satPositions}
|
||
layers={koreaLayers}
|
||
osintFeed={koreaData.osintFeed}
|
||
currentTime={currentTime}
|
||
koreaFilters={koreaFiltersResult.filters}
|
||
transshipSuspects={koreaFiltersResult.transshipSuspects}
|
||
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
|
||
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||
/>
|
||
<div className="map-overlay-left">
|
||
<LayerPanel
|
||
layers={koreaLayers}
|
||
onToggle={toggleKoreaLayer}
|
||
aircraftByCategory={koreaData.aircraftByCategory}
|
||
aircraftTotal={koreaData.aircraft.length}
|
||
shipsByMtCategory={koreaData.shipsByCategory}
|
||
shipTotal={koreaData.ships.length}
|
||
satelliteCount={koreaData.satPositions.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 ? [] : iranData.events}
|
||
currentTime={currentTime}
|
||
totalShipCount={koreaData.ships.length}
|
||
koreanShips={koreaData.koreaKoreanShips}
|
||
koreanShipsByCategory={koreaData.shipsByCategory}
|
||
chineseShips={koreaData.koreaChineseShips}
|
||
osintFeed={koreaData.osintFeed}
|
||
isLive={isLive}
|
||
dashboardTab={dashboardTab}
|
||
onTabChange={setDashboardTab}
|
||
ships={koreaData.ships}
|
||
/>
|
||
</aside>
|
||
</main>
|
||
|
||
<footer className="app-footer">
|
||
{isLive ? (
|
||
<LiveControls
|
||
currentTime={monitor.state.currentTime}
|
||
historyMinutes={monitor.state.historyMinutes}
|
||
onHistoryChange={monitor.setHistoryMinutes}
|
||
aircraftCount={koreaData.aircraft.length}
|
||
shipCount={koreaData.ships.length}
|
||
satelliteCount={koreaData.satPositions.length}
|
||
timeZone={timeZone}
|
||
onTimeZoneChange={setTimeZone}
|
||
/>
|
||
) : (
|
||
<>
|
||
<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={iranData.mergedEvents}
|
||
onSeek={replay.seek}
|
||
onEventFlyTo={handleEventFlyTo}
|
||
/>
|
||
</>
|
||
)}
|
||
</footer>
|
||
</>
|
||
)}
|
||
|
||
{showCollectorMonitor && (
|
||
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|