From 19e5ff23aad9190c50d507e13e0bed0d07840a4c Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 10:06:58 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20Phase=201=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=E2=80=94=20App.tsx=20=EB=B6=84=ED=95=B4=20(771=EC=A4=84?= =?UTF-8?q?=E2=86=92163=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx: 탭 전환 + 공통 헤더(MON/LANG/THEME) + SharedFilterProvider만 유지 - IranDashboard: 이란 전용 상태/JSX + 헤더 Portal (모드토글/맵모드/카운트) - KoreaDashboard: 한국 전용 상태/JSX + 헤더 Portal (필터버튼/카운트) - SharedFilterContext: hiddenAcCategories/hiddenShipCategories 공유 상태 - useSharedFilters: Context 소비 훅 (react-refresh 호환) - showFieldAnalysis를 KoreaDashboard 내부로 이동 - 헤더 슬롯(dashboard-header-slot/dashboard-counts-slot)으로 탭별 UI Portal --- frontend/src/App.tsx | 780 ++---------------- .../src/components/iran/IranDashboard.tsx | 57 +- .../src/components/korea/KoreaDashboard.tsx | 58 +- 3 files changed, 192 insertions(+), 703 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d94524a..d97bd9e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,43 +1,15 @@ 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 { useVesselAnalysis } from './hooks/useVesselAnalysis'; -import type { GeoEvent, LayerVisibility, AppMode } from './types'; +import type { 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 { FieldAnalysisModal } from './components/korea/FieldAnalysisModal'; -// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨) -import { EAST_ASIA_PORTS } from './data/ports'; -import { KOREAN_AIRPORTS } from './services/airports'; -import { MILITARY_BASES } from './data/militaryBases'; -import { GOV_BUILDINGS } from './data/govBuildings'; -import { KOREA_WIND_FARMS } from './data/windFarms'; -import { NK_LAUNCH_SITES } from './data/nkLaunchSites'; -import { NK_MISSILE_EVENTS } from './data/nkMissileEvents'; -import { COAST_GUARD_FACILITIES } from './services/coastGuard'; -import { NAV_WARNINGS } from './services/navWarning'; -import { PIRACY_ZONES } from './services/piracy'; -import { KOREA_SUBMARINE_CABLES } from './services/submarineCable'; -import { HAZARD_FACILITIES } from './data/hazardFacilities'; -import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities'; -import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities'; +import { SharedFilterProvider } from './contexts/SharedFilterContext'; +import { IranDashboard } from './components/iran/IranDashboard'; +import { KoreaDashboard } from './components/korea/KoreaDashboard'; import './App.css'; function App() { @@ -68,133 +40,18 @@ interface AuthenticatedAppProps { function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [appMode, setAppMode] = useState('live'); - const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); - const [layers, setLayers] = useState({ - events: true, - aircraft: true, - satellites: true, - ships: true, - koreanShips: true, - airports: true, - sensorCharts: false, - oilFacilities: true, - meFacilities: true, - militaryOnly: false, - overseasUS: false, - overseasUK: false, - overseasIran: false, - overseasUAE: false, - overseasSaudi: false, - overseasOman: false, - overseasQatar: false, - overseasKuwait: false, - overseasIraq: false, - overseasBahrain: false, - }); - - // Korea tab layer visibility (lifted from KoreaMap) - const [koreaLayers, setKoreaLayers] = useState>({ - ships: true, - aircraft: true, - satellites: true, - infra: true, - cables: true, - cctv: true, - airports: true, - coastGuard: true, - navWarning: true, - osint: true, - eez: true, - piracy: true, - windFarm: true, - ports: true, - militaryBases: true, - govBuildings: true, - nkLaunch: true, - nkMissile: true, - cnFishing: false, - militaryOnly: false, - overseasChina: false, - overseasJapan: false, - cnPower: false, - cnMilitary: false, - jpPower: false, - jpMilitary: false, - hazardPetrochemical: false, - hazardLng: false, - hazardOilTank: false, - hazardPort: false, - energyNuclear: false, - energyThermal: false, - industryShipyard: false, - industryWastewater: false, - industryHeavy: false, - }); - - const toggleKoreaLayer = useCallback((key: string) => { - setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] })); - }, []); - - // Category filter state (shared across tabs) - const [hiddenAcCategories, setHiddenAcCategories] = useState>(new Set()); - const [hiddenShipCategories, setHiddenShipCategories] = useState>(new Set()); - - const toggleAcCategory = useCallback((cat: string) => { - setHiddenAcCategories(prev => { - const next = new Set(prev); - if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } - return next; - }); - }, []); - - const toggleShipCategory = useCallback((cat: string) => { - setHiddenShipCategories(prev => { - const next = new Set(prev); - if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } - return next; - }); - }, []); - - // Nationality filter state (Korea tab) - const [hiddenNationalities, setHiddenNationalities] = useState>(new Set()); - const toggleNationality = useCallback((nat: string) => { - setHiddenNationalities(prev => { - const next = new Set(prev); - if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } - return next; - }); - }, []); - - // Fishing vessel nationality filter state - const [hiddenFishingNats, setHiddenFishingNats] = useState>(new Set()); - const toggleFishingNat = useCallback((nat: string) => { - setHiddenFishingNats(prev => { - const next = new Set(prev); - if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } - return next; - }); - }, []); - - const [flyToTarget, setFlyToTarget] = useState(null); + const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); + const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); // 1시간마다 전체 데이터 강제 리프레시 const [refreshKey, setRefreshKey] = useState(0); useEffect(() => { const HOUR_MS = 3600_000; - const interval = setInterval(() => { - setRefreshKey(k => k + 1); - }, HOUR_MS); + const interval = setInterval(() => setRefreshKey(k => k + 1), HOUR_MS); return () => clearInterval(interval); }, []); - const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); - const [showFieldAnalysis, setShowFieldAnalysis] = useState(false); - const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); - const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); - const [focusShipMmsi, setFocusShipMmsi] = useState(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(); @@ -204,567 +61,102 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }, [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, - hiddenNationalities, - refreshKey, - }); - - // Vessel analysis (Python prediction 결과) - const vesselAnalysis = useVesselAnalysis(dashboardTab === 'korea'); - - // Korea filters hook - const koreaFiltersResult = useKoreaFilters( - koreaData.ships, - koreaData.visibleShips, - currentTime, - vesselAnalysis.analysisMap, - ); - - 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 }); - }, []); + const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime; return ( -
-
- {/* Dashboard Tabs (replaces title) */} -
- - -
- - {/* Mode Toggle */} - {dashboardTab === 'iran' && ( -
-
- ⚔️ - D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))} -
+ +
+
+ {/* Dashboard Tabs */} +
+ + {/* 탭별 모드/필터 영역 — 각 대시보드가 headerSlot으로 렌더링 */} +
+ +
+
+
+ + + +
+
+ + {isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')} +
+ {user && ( +
+ {user.picture && ( + + )} + {user.name} + +
+ )} +
+
+ + {dashboardTab === 'iran' && ( + )} {dashboardTab === 'korea' && ( -
- - - - - - - - -
+ )} - {dashboardTab === 'iran' && ( -
- - - -
+ {showCollectorMonitor && ( + setShowCollectorMonitor(false)} /> )} - -
-
- {dashboardTab === 'iran' ? iranData.aircraft.length : koreaData.aircraft.length} AC - {dashboardTab === 'iran' ? iranData.militaryCount : koreaData.militaryCount} MIL - {dashboardTab === 'iran' ? iranData.ships.length : koreaData.ships.length} SHIP - {dashboardTab === 'iran' ? iranData.satPositions.length : koreaData.satPositions.length} SAT -
-
- - - -
-
- - {isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')} -
- {user && ( -
- {user.picture && ( - - )} - {user.name} - -
- )} -
-
- - {/* ═══════════════════════════════════════ - IRAN DASHBOARD - ═══════════════════════════════════════ */} - {dashboardTab === 'iran' && ( - <> -
-
- {mapMode === 'flat' ? ( - setFlyToTarget(null)} - hoveredShipMmsi={hoveredShipMmsi} - focusShipMmsi={focusShipMmsi} - onFocusShipClear={() => setFocusShipMmsi(null)} - seismicMarker={seismicMarker} - /> - ) : mapMode === 'globe' ? ( - - ) : ( - setFocusShipMmsi(null)} - flyToTarget={flyToTarget} - onFlyToDone={() => setFlyToTarget(null)} - seismicMarker={seismicMarker} - /> - )} -
- } - 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: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 }, - { key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, - ]} - overseasItems={[ - { key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' }, - { key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' }, - { key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' }, - { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' }, - { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' }, - { key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' }, - { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' }, - { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' }, - { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' }, - { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' }, - ]} - hiddenAcCategories={hiddenAcCategories} - hiddenShipCategories={hiddenShipCategories} - onAcCategoryToggle={toggleAcCategory} - onShipCategoryToggle={toggleShipCategory} - /> -
-
- - -
- - {layers.sensorCharts && ( -
- { - setFlyToTarget({ lat, lng, zoom: 8 }); - setSeismicMarker({ lat, lng, magnitude, place }); - }} - /> -
- )} - -
- {isLive ? ( - - ) : ( - <> - - - - )} - -
- - )} - - {/* ═══════════════════════════════════════ - KOREA DASHBOARD - ═══════════════════════════════════════ */} - {dashboardTab === 'korea' && ( - <> -
-
- {showFieldAnalysis && ( - setShowFieldAnalysis(false)} /> - )} - -
- f.type === 'nuclear').length, group: '에너지/발전시설' }, - { key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' }, - // 위험시설 - { key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' }, - { key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' }, - { key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' }, - { key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' }, - // 산업공정/제조시설 - { key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' }, - { key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' }, - { key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' }, - ]} - overseasItems={[ - { - key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444', - count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length, - children: [ - { key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length }, - { key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length }, - ], - }, - { - key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6', - count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length, - children: [ - { key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length }, - { key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length }, - ], - }, - ]} - hiddenAcCategories={hiddenAcCategories} - hiddenShipCategories={hiddenShipCategories} - onAcCategoryToggle={toggleAcCategory} - onShipCategoryToggle={toggleShipCategory} - shipsByNationality={koreaData.shipsByNationality} - hiddenNationalities={hiddenNationalities} - onNationalityToggle={toggleNationality} - fishingByNationality={koreaData.fishingByNationality} - hiddenFishingNats={hiddenFishingNats} - onFishingNatToggle={toggleFishingNat} - /> -
-
- - -
- -
- {isLive ? ( - - ) : ( - <> - - - - )} -
- - )} - - {showCollectorMonitor && ( - setShowCollectorMonitor(false)} /> - )} -
+ + ); } diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx index f40a4cf..ae63a59 100644 --- a/frontend/src/components/iran/IranDashboard.tsx +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { ReplayMap } from './ReplayMap'; import type { FlyToTarget } from './ReplayMap'; import { GlobeMap } from './GlobeMap'; @@ -39,6 +40,8 @@ interface IranDashboardProps { }; timeZone: 'KST' | 'UTC'; onTimeZoneChange: (tz: 'KST' | 'UTC') => void; + appMode: AppMode; + onAppModeChange: (mode: AppMode) => void; } const INITIAL_LAYERS: LayerVisibility = { @@ -64,8 +67,6 @@ const INITIAL_LAYERS: LayerVisibility = { overseasBahrain: false, }; -const appModeFromIsLive = (isLive: boolean): AppMode => (isLive ? 'live' : 'replay'); - const IranDashboard = ({ currentTime, isLive, @@ -74,10 +75,12 @@ const IranDashboard = ({ monitor, timeZone, onTimeZoneChange, + appMode, + onAppModeChange, }: IranDashboardProps) => { const { t } = useTranslation(); - const [mapMode, _setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); + const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); const [layers, setLayers] = useState(INITIAL_LAYERS); const [seismicMarker, setSeismicMarker] = useState<{ lat: number; @@ -93,7 +96,7 @@ const IranDashboard = ({ useSharedFilters(); const iranData = useIranData({ - appMode: appModeFromIsLive(isLive), + appMode, currentTime, isLive, hiddenAcCategories, @@ -110,8 +113,54 @@ const IranDashboard = ({ setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); }, []); + // 헤더 슬롯 Portal — 이란 모드 토글 + 맵 모드 + 카운트 + const headerSlot = document.getElementById('dashboard-header-slot'); + const countsSlot = document.getElementById('dashboard-counts-slot'); + return ( <> + {headerSlot && createPortal( + <> +
+
+ ⚔️ + D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))} +
+ + +
+
+ + + +
+ , + headerSlot, + )} + {countsSlot && createPortal( +
+ {iranData.aircraft.length} AC + {iranData.militaryCount} MIL + {iranData.ships.length} SHIP + {iranData.satPositions.length} SAT +
, + countsSlot, + )}
{mapMode === 'flat' ? ( diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index 190a45c..3482521 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { KoreaMap } from './KoreaMap'; import { FieldAnalysisModal } from './FieldAnalysisModal'; @@ -64,8 +65,6 @@ export interface KoreaDashboardProps { monitor: MonitorControls; timeZone: 'KST' | 'UTC'; onTimeZoneChange: (tz: 'KST' | 'UTC') => void; - showFieldAnalysis: boolean; - onFieldAnalysisClose: () => void; } const KoreaDashboard = ({ @@ -76,9 +75,8 @@ const KoreaDashboard = ({ monitor, timeZone, onTimeZoneChange, - showFieldAnalysis, - onFieldAnalysisClose, }: KoreaDashboardProps) => { + const [showFieldAnalysis, setShowFieldAnalysis] = useState(false); const { t } = useTranslation(); const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = @@ -166,15 +164,65 @@ const KoreaDashboard = ({ // Tab switching is managed by parent (App.tsx); no-op here }, []); + // 헤더 슬롯 Portal — 한국 필터 버튼 + 카운트 + const headerSlot = document.getElementById('dashboard-header-slot'); + const countsSlot = document.getElementById('dashboard-counts-slot'); + return ( <> + {headerSlot && createPortal( +
+ + + + + + + + +
, + headerSlot, + )} + {countsSlot && createPortal( +
+ {koreaData.aircraft.length} AC + {koreaData.militaryCount} MIL + {koreaData.ships.length} SHIP + {koreaData.satPositions.length} SAT +
, + countsSlot, + )}
{showFieldAnalysis && ( setShowFieldAnalysis(false)} /> )}