From 5e55a495bc324a3237a0214d214a1b1b36b816b8 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 07:25:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(frontend):=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=E2=80=94=20=EA=B3=B5=ED=86=B5/=ED=83=AD=EB=B3=84?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20+=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=9B=85=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/ 서브디렉토리 재배치: common/, layers/, iran/, korea/ - App.tsx God Component 분해: 1,179줄 → 588줄 (50% 감소) - useIranData: 이란 데이터 로딩 + propagation + OSINT 병합 - useKoreaData: 한국 데이터 로딩 + propagation - useKoreaFilters: 감시 로직 (환적/다크베셀/케이블/독도) 분리 - getMarineTrafficCategory → utils/marineTraffic.ts 추출 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 839 +++--------------- .../{ => common}/CollectorMonitor.tsx | 0 .../src/components/{ => common}/EventLog.tsx | 0 .../components/{ => common}/EventStrip.tsx | 0 .../components/{ => common}/LayerPanel.tsx | 0 .../components/{ => common}/LiveControls.tsx | 0 .../{ => common}/ReplayControls.tsx | 0 .../components/{ => common}/SensorChart.tsx | 0 .../{ => common}/TimelineSlider.tsx | 0 .../components/{ => iran}/AirportLayer.tsx | 0 .../src/components/{ => iran}/GlobeMap.tsx | 0 .../{ => iran}/OilFacilityLayer.tsx | 0 .../src/components/{ => iran}/ReplayMap.tsx | 8 +- .../components/{ => iran}/SatelliteMap.tsx | 8 +- .../src/components/{ => korea}/CctvLayer.tsx | 0 .../{ => korea}/CoastGuardLayer.tsx | 0 .../src/components/{ => korea}/EezLayer.tsx | 0 .../src/components/{ => korea}/InfraLayer.tsx | 0 .../{ => korea}/KoreaAirportLayer.tsx | 0 .../src/components/{ => korea}/KoreaMap.tsx | 6 +- .../{ => korea}/NavWarningLayer.tsx | 0 .../components/{ => korea}/OsintMapLayer.tsx | 0 .../components/{ => korea}/PiracyLayer.tsx | 0 .../{ => korea}/SubmarineCableLayer.tsx | 0 .../components/{ => layers}/AircraftLayer.tsx | 0 .../{ => layers}/DamagedShipLayer.tsx | 0 .../{ => layers}/SatelliteLayer.tsx | 0 .../src/components/{ => layers}/ShipLayer.tsx | 0 frontend/src/hooks/useIranData.ts | 256 ++++++ frontend/src/hooks/useKoreaData.ts | 160 ++++ frontend/src/hooks/useKoreaFilters.ts | 320 +++++++ frontend/src/services/api.ts | 4 +- frontend/src/utils/marineTraffic.ts | 47 + 33 files changed, 920 insertions(+), 728 deletions(-) rename frontend/src/components/{ => common}/CollectorMonitor.tsx (100%) rename frontend/src/components/{ => common}/EventLog.tsx (100%) rename frontend/src/components/{ => common}/EventStrip.tsx (100%) rename frontend/src/components/{ => common}/LayerPanel.tsx (100%) rename frontend/src/components/{ => common}/LiveControls.tsx (100%) rename frontend/src/components/{ => common}/ReplayControls.tsx (100%) rename frontend/src/components/{ => common}/SensorChart.tsx (100%) rename frontend/src/components/{ => common}/TimelineSlider.tsx (100%) rename frontend/src/components/{ => iran}/AirportLayer.tsx (100%) rename frontend/src/components/{ => iran}/GlobeMap.tsx (100%) rename frontend/src/components/{ => iran}/OilFacilityLayer.tsx (100%) rename frontend/src/components/{ => iran}/ReplayMap.tsx (98%) rename frontend/src/components/{ => iran}/SatelliteMap.tsx (97%) rename frontend/src/components/{ => korea}/CctvLayer.tsx (100%) rename frontend/src/components/{ => korea}/CoastGuardLayer.tsx (100%) rename frontend/src/components/{ => korea}/EezLayer.tsx (100%) rename frontend/src/components/{ => korea}/InfraLayer.tsx (100%) rename frontend/src/components/{ => korea}/KoreaAirportLayer.tsx (100%) rename frontend/src/components/{ => korea}/KoreaMap.tsx (98%) rename frontend/src/components/{ => korea}/NavWarningLayer.tsx (100%) rename frontend/src/components/{ => korea}/OsintMapLayer.tsx (100%) rename frontend/src/components/{ => korea}/PiracyLayer.tsx (100%) rename frontend/src/components/{ => korea}/SubmarineCableLayer.tsx (100%) rename frontend/src/components/{ => layers}/AircraftLayer.tsx (100%) rename frontend/src/components/{ => layers}/DamagedShipLayer.tsx (100%) rename frontend/src/components/{ => layers}/SatelliteLayer.tsx (100%) rename frontend/src/components/{ => layers}/ShipLayer.tsx (100%) create mode 100644 frontend/src/hooks/useIranData.ts create mode 100644 frontend/src/hooks/useKoreaData.ts create mode 100644 frontend/src/hooks/useKoreaFilters.ts create mode 100644 frontend/src/utils/marineTraffic.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bd84db9..7321f26 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,82 +1,28 @@ -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 { 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 { fetchEvents, fetchSensorData } from './services/api'; -import { fetchAircraftFromBackend } from './services/aircraftApi'; -import { getSampleAircraft } from './data/sampleAircraft'; -import { fetchSatelliteTLE, fetchSatelliteTLEKorea, propagateAll } from './services/celestrak'; -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 { 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/CollectorMonitor'; +import CollectorMonitor from './components/common/CollectorMonitor'; 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(); @@ -105,17 +51,6 @@ interface AuthenticatedAppProps { function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [appMode, setAppMode] = useState('live'); - const [events, setEvents] = useState([]); - const [sensorData, setSensorData] = useState([]); - const [baseAircraft, setBaseAircraft] = useState([]); - const [baseShips, setBaseShips] = useState([]); - const [baseShipsKorea, setBaseShipsKorea] = useState([]); - const [baseAircraftKorea, setBaseAircraftKorea] = useState([]); - const [satellitesKorea, setSatellitesKorea] = useState([]); - const [satPositionsKorea, setSatPositionsKorea] = useState([]); - const [osintFeed, setOsintFeed] = useState([]); - const [satellites, setSatellites] = useState([]); - const [satPositions, setSatPositions] = useState([]); const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); const [layers, setLayers] = useState({ @@ -173,27 +108,16 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [flyToTarget, setFlyToTarget] = useState(null); - // 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트) + // 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 [showCollectorMonitor, setShowCollectorMonitor] = useState(false); const replay = useReplay(); @@ -211,216 +135,31 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { 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]); + // Iran data hook + const iranData = useIranData({ + appMode, + currentTime, + isLive, + hiddenAcCategories, + hiddenShipCategories, + refreshKey, + dashboardTab, + }); - // Fetch base aircraft data - // LIVE: 백엔드 /api/kcg/aircraft?region=iran 호출 - // REPLAY: 하드코딩된 시나리오 샘플 데이터 사용 - useEffect(() => { - const load = async () => { - if (appMode === 'live') { - const result = await fetchAircraftFromBackend('iran'); - if (result.length > 0) setBaseAircraft(result); - } else { - // 리플레이: 하드코딩 시나리오 샘플 - setBaseAircraft(getSampleAircraft()); - } - }; - load(); - const interval = setInterval(load, 60_000); - return () => clearInterval(interval); - }, [appMode, refreshKey]); + // Korea data hook + const koreaData = useKoreaData({ + currentTime, + isLive, + hiddenAcCategories, + hiddenShipCategories, + refreshKey, + }); - // Fetch Iran ship data (signal-batch + sample military, 5-min cycle) - useEffect(() => { - const load = async () => { - try { - const data = await fetchShips(); - if (data.length > 0) { - setBaseShips(data); - } - } catch { - // keep previous data - } - }; - load(); - const interval = setInterval(load, 300_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 result = await fetchAircraftFromBackend('korea'); - if (result.length > 0) setBaseAircraftKorea(result); - }; - load(); - const interval = setInterval(load, 60_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 = { - 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], + // Korea filters hook + const koreaFiltersResult = useKoreaFilters( + koreaData.ships, + koreaData.visibleShips, + currentTime, ); const toggleLayer = useCallback((key: keyof LayerVisibility) => { @@ -432,355 +171,13 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); }, []); - // Aircraft stats - const aircraftByCategory = useMemo(() => { - const counts: Record = {}; - 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 = {}; - 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 = {}; - 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 = {}; - 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 = {}; - 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>(new Map()); - const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간 - - const transshipSuspects = useMemo(() => { - if (!koreaFilters.illegalTransship) return new Set(); - - const suspects = new Set(); - 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(); - 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>(new Map()); - const ONE_HOUR_MS = 60 * 60 * 1000; - - const darkVesselSet = useMemo(() => { - if (!koreaFilters.darkVessel) return new Set(); - - const now = currentTime; - const history = aisHistoryRef.current; - const result = new Set(); - - // 현재 보이는 선박 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>(new Map()); - const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간 - - const cableWatchSet = useMemo(() => { - if (!koreaFilters.cableWatch) return new Set(); - const result = new Set(); - 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(); - - 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>(new Set()); - const [dokdoAlerts, setDokdoAlerts] = useState<{ mmsi: string; name: string; dist: number; time: number }[]>([]); - - const dokdoWatchSet = useMemo(() => { - if (!koreaFilters.dokdoWatch) return new Set(); - const result = new Set(); - 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 (
{/* Dashboard Tabs (replaces title) */}
+
diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 271538f..5bd7292 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -129,7 +129,7 @@ const LOCAL_SHIP_PHOTOS: Record = { interface VesselPhotoData { url: string; } const vesselPhotoCache = new Map(); -type PhotoSource = 'signal-batch' | 'marinetraffic'; +type PhotoSource = 'spglobal' | 'marinetraffic'; interface VesselPhotoProps { mmsi: string; @@ -137,15 +137,20 @@ interface VesselPhotoProps { shipImagePath?: string | null; } +function toHighRes(path: string): string { + return path.replace(/_1\.(jpg|jpeg|png)$/i, '_2.$1'); +} + function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { - const { t } = useTranslation('ships'); const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; - // Determine available tabs - const hasSignalBatch = !!shipImagePath; - const defaultTab: PhotoSource = hasSignalBatch ? 'signal-batch' : 'marinetraffic'; + const hasSPGlobal = !!shipImagePath; + const defaultTab: PhotoSource = hasSPGlobal ? 'spglobal' : 'marinetraffic'; const [activeTab, setActiveTab] = useState(defaultTab); + // S&P Global image error state + const [spgError, setSpgError] = useState(false); + // MarineTraffic image state (lazy loaded) const [mtPhoto, setMtPhoto] = useState(() => { return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined; @@ -165,8 +170,8 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { let currentUrl: string | null = null; if (localUrl) { currentUrl = localUrl; - } else if (activeTab === 'signal-batch' && shipImagePath) { - currentUrl = shipImagePath; + } else if (activeTab === 'spglobal' && shipImagePath && !spgError) { + currentUrl = toHighRes(shipImagePath); } else if (activeTab === 'marinetraffic' && mtPhoto) { currentUrl = mtPhoto.url; } @@ -183,17 +188,19 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { ); } + const noPhoto = (!hasSPGlobal || spgError) && mtPhoto === null; + return (
- {hasSignalBatch && ( + {hasSPGlobal && (
setActiveTab('signal-batch')} + onClick={() => setActiveTab('spglobal')} > - signal-batch + S&P Global
)}
{ (e.target as HTMLImageElement).style.display = 'none'; }} + onError={(e) => { + const el = e.target as HTMLImageElement; + if (activeTab === 'spglobal') { + setSpgError(true); + el.style.display = 'none'; + } else { + el.style.display = 'none'; + } + }} /> + ) : noPhoto ? ( +
+ No photo available +
) : ( activeTab === 'marinetraffic' && mtPhoto === undefined - ?
{t('popup.loading')}
- : null + ?
Loading...
+ :
+ No photo available +
)}
); diff --git a/frontend/src/services/infra.ts b/frontend/src/services/infra.ts index 6da037b..f479ffe 100644 --- a/frontend/src/services/infra.ts +++ b/frontend/src/services/infra.ts @@ -12,17 +12,6 @@ export interface PowerFacility { voltage?: string; // for substations } -// Overpass QL: power plants + wind generators + substations in South Korea -const OVERPASS_QUERY = ` -[out:json][timeout:30][bbox:33,124,39,132]; -( - nwr["power"="plant"]; - nwr["power"="generator"]["generator:source"="wind"]; - nwr["power"="substation"]["substation"="transmission"]; -); -out center 500; -`; - let cachedData: PowerFacility[] | null = null; let lastFetch = 0; const CACHE_MS = 600_000; // 10 min cache @@ -30,67 +19,10 @@ const CACHE_MS = 600_000; // 10 min cache export async function fetchKoreaInfra(): Promise { if (cachedData && Date.now() - lastFetch < CACHE_MS) return cachedData; - try { - const url = `/api/overpass/api/interpreter`; - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `data=${encodeURIComponent(OVERPASS_QUERY)}`, - }); - - if (!res.ok) throw new Error(`Overpass ${res.status}`); - const json = await res.json(); - - const facilities: PowerFacility[] = []; - - for (const el of json.elements || []) { - const tags = el.tags || {}; - const lat = el.lat ?? el.center?.lat; - const lng = el.lon ?? el.center?.lon; - if (lat == null || lng == null) continue; - - const isPower = tags.power; - if (isPower === 'plant') { - facilities.push({ - id: `plant-${el.id}`, - type: 'plant', - name: tags.name || tags['name:ko'] || tags['name:en'] || 'Power Plant', - lat, lng, - source: tags['plant:source'] || tags['generator:source'] || undefined, - output: tags['plant:output:electricity'] || undefined, - operator: tags.operator || undefined, - }); - } else if (isPower === 'generator' && tags['generator:source'] === 'wind') { - facilities.push({ - id: `wind-${el.id}`, - type: 'plant', - name: tags.name || tags['name:ko'] || tags['name:en'] || '풍력발전기', - lat, lng, - source: 'wind', - output: tags['generator:output:electricity'] || undefined, - operator: tags.operator || undefined, - }); - } else if (isPower === 'substation') { - facilities.push({ - id: `sub-${el.id}`, - type: 'substation', - name: tags.name || tags['name:ko'] || tags['name:en'] || 'Substation', - lat, lng, - voltage: tags.voltage || undefined, - operator: tags.operator || undefined, - }); - } - } - - console.log(`Overpass: ${facilities.length} power facilities in Korea (${facilities.filter(f => f.type === 'plant').length} plants, ${facilities.filter(f => f.type === 'substation').length} substations)`); - cachedData = facilities; - lastFetch = Date.now(); - return facilities; - } catch (err) { - console.warn('Overpass API failed, using fallback data:', err); - if (cachedData) return cachedData; - return getFallbackInfra(); - } + // 정적 데이터 사용 (Overpass API는 프로덕션 nginx에서 미지원 + fallback 데이터로 충분) + cachedData = getFallbackInfra(); + lastFetch = Date.now(); + return cachedData; } // Fallback: major Korean power plants (in case API fails) -- 2.45.2 From d11bd253fc31d075723d7c0a43f15a7b71800169 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 07:40:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 821de9b..c2c3886 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,16 @@ ## [Unreleased] +### 변경 +- 프론트엔드 패키지 구조 리팩토링: components/ → common/layers/iran/korea/ 분리 +- App.tsx God Component 분해: 1,179줄 → 588줄 (데이터 훅 3개 추출) +- 선박 모달 사진 탭: signal-batch → S&P Global 명칭 변경, 고화질(_2) 기본 표시 +- Overpass API 외부 호출 제거 → 정적 인프라 데이터 사용 + +### 수정 +- LiveControls KST 시간 이중 오프셋(+9h×2) 버그 수정 + KST/UTC 토글 추가 +- nginx /shipimg/ 프록시: 정적파일 regex 우선매칭 방지 (^~ 추가) + ## [2026-03-18] ### 추가 -- 2.45.2