From d6de826d1d8afd9a6302fe13e5c6eb0b5012a75b Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 10:02:35 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20Phase=201=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=E2=80=94=20IranDashboard,=20KoreaDashboard,=20SharedFilterCont?= =?UTF-8?q?ext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SharedFilterContext + SharedFilterProvider: 카테고리 필터 공유 상태 - useSharedFilters: Context 소비 훅 (react-refresh 호환 분리) - IranDashboard: 이란 전용 상태/JSX 추출 (274줄) - KoreaDashboard: 한국 전용 상태/JSX 추출 (323줄) - App.tsx 통합은 후속 커밋 (헤더 상태 참조 리팩토링 필요) --- .../src/components/iran/IranDashboard.tsx | 274 +++++++++++++++ .../src/components/korea/KoreaDashboard.tsx | 323 ++++++++++++++++++ frontend/src/contexts/SharedFilterContext.tsx | 32 ++ frontend/src/contexts/sharedFilterState.ts | 10 + frontend/src/hooks/useSharedFilters.ts | 11 + 5 files changed, 650 insertions(+) create mode 100644 frontend/src/components/iran/IranDashboard.tsx create mode 100644 frontend/src/components/korea/KoreaDashboard.tsx create mode 100644 frontend/src/contexts/SharedFilterContext.tsx create mode 100644 frontend/src/contexts/sharedFilterState.ts create mode 100644 frontend/src/hooks/useSharedFilters.ts diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx new file mode 100644 index 0000000..f40a4cf --- /dev/null +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -0,0 +1,274 @@ +import { useState, useCallback } from 'react'; +import { ReplayMap } from './ReplayMap'; +import type { FlyToTarget } from './ReplayMap'; +import { GlobeMap } from './GlobeMap'; +import { SatelliteMap } from './SatelliteMap'; +import { SensorChart } from '../common/SensorChart'; +import { EventLog } from '../common/EventLog'; +import { LayerPanel } from '../common/LayerPanel'; +import { LiveControls } from '../common/LiveControls'; +import { ReplayControls } from '../common/ReplayControls'; +import { TimelineSlider } from '../common/TimelineSlider'; +import { useIranData } from '../../hooks/useIranData'; +import { useSharedFilters } from '../../hooks/useSharedFilters'; +import type { GeoEvent, LayerVisibility, AppMode } from '../../types'; +import { useTranslation } from 'react-i18next'; + +interface IranDashboardProps { + currentTime: number; + isLive: boolean; + refreshKey: number; + replay: { + state: { + isPlaying: boolean; + speed: number; + startTime: number; + endTime: number; + currentTime: number; + }; + play: () => void; + pause: () => void; + reset: () => void; + setSpeed: (s: number) => void; + setRange: (s: number, e: number) => void; + seek: (t: number) => void; + }; + monitor: { + state: { currentTime: number; historyMinutes: number }; + setHistoryMinutes: (m: number) => void; + }; + timeZone: 'KST' | 'UTC'; + onTimeZoneChange: (tz: 'KST' | 'UTC') => void; +} + +const INITIAL_LAYERS: LayerVisibility = { + 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, +}; + +const appModeFromIsLive = (isLive: boolean): AppMode => (isLive ? 'live' : 'replay'); + +const IranDashboard = ({ + currentTime, + isLive, + refreshKey, + replay, + monitor, + timeZone, + onTimeZoneChange, +}: IranDashboardProps) => { + const { t } = useTranslation(); + + const [mapMode, _setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); + const [layers, setLayers] = useState(INITIAL_LAYERS); + const [seismicMarker, setSeismicMarker] = useState<{ + lat: number; + lng: number; + magnitude: number; + place: string; + } | null>(null); + const [flyToTarget, setFlyToTarget] = useState(null); + const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); + const [focusShipMmsi, setFocusShipMmsi] = useState(null); + + const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = + useSharedFilters(); + + const iranData = useIranData({ + appMode: appModeFromIsLive(isLive), + currentTime, + isLive, + hiddenAcCategories, + hiddenShipCategories, + refreshKey, + dashboardTab: 'iran', + }); + + const toggleLayer = useCallback((key: keyof LayerVisibility) => { + setLayers(prev => ({ ...prev, [key]: !prev[key] })); + }, []); + + const handleEventFlyTo = useCallback((event: GeoEvent) => { + setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); + }, []); + + return ( + <> +
+
+ {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 ? ( + + ) : ( + <> + + + + )} +
+ + ); +}; + +export { IranDashboard }; +export type { IranDashboardProps }; diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx new file mode 100644 index 0000000..190a45c --- /dev/null +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -0,0 +1,323 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KoreaMap } from './KoreaMap'; +import { FieldAnalysisModal } from './FieldAnalysisModal'; +import { LayerPanel } from '../common/LayerPanel'; +import { EventLog } from '../common/EventLog'; +import { LiveControls } from '../common/LiveControls'; +import { ReplayControls } from '../common/ReplayControls'; +import { TimelineSlider } from '../common/TimelineSlider'; +import { useKoreaData } from '../../hooks/useKoreaData'; +import { useVesselAnalysis } from '../../hooks/useVesselAnalysis'; +import { useKoreaFilters } from '../../hooks/useKoreaFilters'; +import { useSharedFilters } from '../../hooks/useSharedFilters'; +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'; + +type DashboardTab = 'iran' | 'korea'; + +interface ReplayState { + isPlaying: boolean; + speed: number; + startTime: number; + endTime: number; + currentTime: number; +} + +interface ReplayControls { + state: ReplayState; + play: () => void; + pause: () => void; + reset: () => void; + setSpeed: (s: number) => void; + setRange: (s: number, e: number) => void; + seek: (t: number) => void; +} + +interface MonitorState { + currentTime: number; + historyMinutes: number; +} + +interface MonitorControls { + state: MonitorState; + setHistoryMinutes: (m: number) => void; +} + +export interface KoreaDashboardProps { + currentTime: number; + isLive: boolean; + refreshKey: number; + replay: ReplayControls; + monitor: MonitorControls; + timeZone: 'KST' | 'UTC'; + onTimeZoneChange: (tz: 'KST' | 'UTC') => void; + showFieldAnalysis: boolean; + onFieldAnalysisClose: () => void; +} + +const KoreaDashboard = ({ + currentTime, + isLive, + refreshKey, + replay, + monitor, + timeZone, + onTimeZoneChange, + showFieldAnalysis, + onFieldAnalysisClose, +}: KoreaDashboardProps) => { + const { t } = useTranslation(); + + const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = + useSharedFilters(); + + 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] })); + }, []); + + 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; + }); + }, []); + + 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 koreaData = useKoreaData({ + currentTime, + isLive, + hiddenAcCategories, + hiddenShipCategories, + hiddenNationalities, + refreshKey, + }); + + const vesselAnalysis = useVesselAnalysis(true); + + const koreaFiltersResult = useKoreaFilters( + koreaData.ships, + koreaData.visibleShips, + currentTime, + vesselAnalysis.analysisMap, + ); + + const handleTabChange = useCallback((_tab: DashboardTab) => { + // Tab switching is managed by parent (App.tsx); no-op here + }, []); + + return ( + <> +
+
+ {showFieldAnalysis && ( + + )} + +
+ 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 ? ( + + ) : ( + <> + + undefined} + /> + + )} +
+ + ); +}; + +export default KoreaDashboard; diff --git a/frontend/src/contexts/SharedFilterContext.tsx b/frontend/src/contexts/SharedFilterContext.tsx new file mode 100644 index 0000000..d89cb63 --- /dev/null +++ b/frontend/src/contexts/SharedFilterContext.tsx @@ -0,0 +1,32 @@ +import { useState, useCallback } from 'react'; +import { SharedFilterContext } from './sharedFilterState'; + +export { SharedFilterContext } from './sharedFilterState'; +export type { SharedFilterState } from './sharedFilterState'; + +export function SharedFilterProvider({ children }: { children: React.ReactNode }) { + 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; + }); + }, []); + + return ( + + {children} + + ); +} diff --git a/frontend/src/contexts/sharedFilterState.ts b/frontend/src/contexts/sharedFilterState.ts new file mode 100644 index 0000000..47fcf3e --- /dev/null +++ b/frontend/src/contexts/sharedFilterState.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +export interface SharedFilterState { + hiddenAcCategories: Set; + hiddenShipCategories: Set; + toggleAcCategory: (cat: string) => void; + toggleShipCategory: (cat: string) => void; +} + +export const SharedFilterContext = createContext(null); diff --git a/frontend/src/hooks/useSharedFilters.ts b/frontend/src/hooks/useSharedFilters.ts new file mode 100644 index 0000000..6b9609e --- /dev/null +++ b/frontend/src/hooks/useSharedFilters.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { SharedFilterContext } from '../contexts/sharedFilterState'; +import type { SharedFilterState } from '../contexts/sharedFilterState'; + +export type { SharedFilterState }; + +export function useSharedFilters(): SharedFilterState { + const ctx = useContext(SharedFilterContext); + if (!ctx) throw new Error('useSharedFilters must be inside SharedFilterProvider'); + return ctx; +}