kcg-monitoring/frontend/src/App.tsx
htlee 448f1b6804 fix: 한국 선박 목록 클릭 시 지도 포커스 이동 + 모달 동시 호출
- onShipClick: focusMmsi + flyToTarget(zoom 10) 동시 설정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:28:00 +09:00

617 lines
26 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 } 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;