From 42e50c3b58a9dc0eaba029ab9282d2bb1e8152da Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 04:30:32 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(backend):=20ddl-auto=20validate=20?= =?UTF-8?q?=E2=86=92=20none=20(PostGIS=20geometry=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 19fc170..8ab9a87 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -3,7 +3,7 @@ spring: active: ${SPRING_PROFILES_ACTIVE:local} jpa: hibernate: - ddl-auto: validate + ddl-auto: none properties: hibernate: default_schema: kcg From ef342769d461dfe48d0e981eb534d467721a41f5 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 04:38:25 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(deploy):=20application-prod.yml=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=82=B4=EC=9E=A5=20+=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EC=84=A4=EC=A0=95=20=EC=B0=B8=EC=A1=B0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application-prod.yml을 .gitignore에서 제거, 환경변수 참조 방식으로 코드에 포함 - deploy.yml에 DB_PASSWORD secret 추가 - systemd에서 -Dspring.config.additional-location 제거 - 서버 외부 application-prod.yml 의존 해소 --- .gitea/workflows/deploy.yml | 2 ++ .gitignore | 1 - backend/src/main/resources/application-prod.yml | 15 +++++++++++++++ deploy/kcg-backend.service | 1 - 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/resources/application-prod.yml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 67c1678..cd2633e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -52,6 +52,7 @@ jobs: env: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} JWT_SECRET: ${{ secrets.JWT_SECRET }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} run: | DEPLOY_DIR=/deploy/kcg-backend mkdir -p $DEPLOY_DIR/backup @@ -66,6 +67,7 @@ jobs: : > $DEPLOY_DIR/.env [ -n "$GOOGLE_CLIENT_ID" ] && echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" >> $DEPLOY_DIR/.env [ -n "$JWT_SECRET" ] && echo "JWT_SECRET=${JWT_SECRET}" >> $DEPLOY_DIR/.env + [ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD=${DB_PASSWORD}" >> $DEPLOY_DIR/.env # JAR 교체 + 재시작 트리거 cp backend/target/kcg.jar $DEPLOY_DIR/kcg.jar diff --git a/.gitignore b/.gitignore index 83d80c2..49616a5 100644 --- a/.gitignore +++ b/.gitignore @@ -77,7 +77,6 @@ frontend/node_modules/ backend/target/ backend/.env backend/src/main/resources/application-local.yml -backend/src/main/resources/application-prod.yml # === Prediction === prediction/__pycache__/ diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..b62d921 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,15 @@ +spring: + datasource: + url: ${DB_URL:jdbc:postgresql://211.208.115.83:5432/kcgdb?currentSchema=kcg,public} + username: ${DB_USERNAME:kcg_app} + password: ${DB_PASSWORD} +app: + jwt: + secret: ${JWT_SECRET} + expiration-ms: ${JWT_EXPIRATION_MS:86400000} + google: + client-id: ${GOOGLE_CLIENT_ID} + auth: + allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr} + cors: + allowed-origins: http://localhost:5173,https://kcg.gc-si.dev diff --git a/deploy/kcg-backend.service b/deploy/kcg-backend.service index 3367540..bac0ea8 100644 --- a/deploy/kcg-backend.service +++ b/deploy/kcg-backend.service @@ -11,7 +11,6 @@ EnvironmentFile=-/devdata/services/kcg/backend/.env ExecStart=/usr/lib/jvm/java-21-openjdk-21.0.10.0.7-1.el9.x86_64/bin/java \ -Xms2g -Xmx4g \ -Dspring.profiles.active=prod \ - -Dspring.config.additional-location=file:/devdata/services/kcg/backend/ \ -jar /devdata/services/kcg/backend/kcg.jar Restart=on-failure From 0fd32081b094483e533b85384ceabb1ffae16035 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 07:41:19 +0900 Subject: [PATCH 3/5] =?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+=20UI=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#38)=20Co-authored-by:=20htlee=20=20Co-commi?= =?UTF-8?q?tted-by:=20htlee=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/nginx-kcg.conf | 4 +- docs/RELEASE-NOTES.md | 10 + frontend/src/App.tsx | 839 +++--------------- frontend/src/components/LiveControls.tsx | 57 -- .../{ => common}/CollectorMonitor.tsx | 0 .../src/components/{ => common}/EventLog.tsx | 0 .../components/{ => common}/EventStrip.tsx | 0 .../components/{ => common}/LayerPanel.tsx | 0 .../src/components/common/LiveControls.tsx | 88 ++ .../{ => 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 | 49 +- 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/services/infra.ts | 76 +- frontend/src/utils/marineTraffic.ts | 47 + 37 files changed, 1059 insertions(+), 873 deletions(-) delete mode 100644 frontend/src/components/LiveControls.tsx 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%) create mode 100644 frontend/src/components/common/LiveControls.tsx 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 (92%) 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/deploy/nginx-kcg.conf b/deploy/nginx-kcg.conf index 4820fea..a4c9821 100644 --- a/deploy/nginx-kcg.conf +++ b/deploy/nginx-kcg.conf @@ -49,8 +49,8 @@ server { proxy_read_timeout 30s; } - # ── 선박 이미지 프록시 ── - location /shipimg/ { + # ── 선박 이미지 프록시 (^~ = regex 정적캐시 규칙보다 우선) ── + location ^~ /shipimg/ { proxy_pass https://wing.gc-si.dev/shipimg/; proxy_set_header Host wing.gc-si.dev; proxy_ssl_server_name on; 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] ### 추가 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/CollectorMonitor.tsx b/frontend/src/components/common/CollectorMonitor.tsx similarity index 100% rename from frontend/src/components/CollectorMonitor.tsx rename to frontend/src/components/common/CollectorMonitor.tsx diff --git a/frontend/src/components/EventLog.tsx b/frontend/src/components/common/EventLog.tsx similarity index 100% rename from frontend/src/components/EventLog.tsx rename to frontend/src/components/common/EventLog.tsx diff --git a/frontend/src/components/EventStrip.tsx b/frontend/src/components/common/EventStrip.tsx similarity index 100% rename from frontend/src/components/EventStrip.tsx rename to frontend/src/components/common/EventStrip.tsx diff --git a/frontend/src/components/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx similarity index 100% rename from frontend/src/components/LayerPanel.tsx rename to frontend/src/components/common/LayerPanel.tsx diff --git a/frontend/src/components/common/LiveControls.tsx b/frontend/src/components/common/LiveControls.tsx new file mode 100644 index 0000000..20d73af --- /dev/null +++ b/frontend/src/components/common/LiveControls.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; + +interface Props { + currentTime: number; + historyMinutes: number; + onHistoryChange: (minutes: number) => void; + aircraftCount: number; + shipCount: number; + satelliteCount: number; +} + +const HISTORY_PRESETS = [ + { label: '30M', minutes: 30 }, + { label: '1H', minutes: 60 }, + { label: '3H', minutes: 180 }, + { label: '6H', minutes: 360 }, + { label: '12H', minutes: 720 }, + { label: '24H', minutes: 1440 }, +]; + +function formatTime(epoch: number, tz: 'KST' | 'UTC'): string { + const d = new Date(epoch); + if (tz === 'UTC') { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`; + } + // KST: 브라우저 로컬 타임존 사용 (한국 환경에서 자동 KST) + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} KST`; +} + +export function LiveControls({ + currentTime, + historyMinutes, + onHistoryChange, +}: Props) { + const { t } = useTranslation(); + const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); + + return ( +
+
+ + {t('header.live')} +
+ +
+ {formatTime(currentTime, timeZone)} + +
+ +
+ +
+ {t('time.history')} +
+ {HISTORY_PRESETS.map(p => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/ReplayControls.tsx b/frontend/src/components/common/ReplayControls.tsx similarity index 100% rename from frontend/src/components/ReplayControls.tsx rename to frontend/src/components/common/ReplayControls.tsx diff --git a/frontend/src/components/SensorChart.tsx b/frontend/src/components/common/SensorChart.tsx similarity index 100% rename from frontend/src/components/SensorChart.tsx rename to frontend/src/components/common/SensorChart.tsx diff --git a/frontend/src/components/TimelineSlider.tsx b/frontend/src/components/common/TimelineSlider.tsx similarity index 100% rename from frontend/src/components/TimelineSlider.tsx rename to frontend/src/components/common/TimelineSlider.tsx diff --git a/frontend/src/components/AirportLayer.tsx b/frontend/src/components/iran/AirportLayer.tsx similarity index 100% rename from frontend/src/components/AirportLayer.tsx rename to frontend/src/components/iran/AirportLayer.tsx diff --git a/frontend/src/components/GlobeMap.tsx b/frontend/src/components/iran/GlobeMap.tsx similarity index 100% rename from frontend/src/components/GlobeMap.tsx rename to frontend/src/components/iran/GlobeMap.tsx diff --git a/frontend/src/components/OilFacilityLayer.tsx b/frontend/src/components/iran/OilFacilityLayer.tsx similarity index 100% rename from frontend/src/components/OilFacilityLayer.tsx rename to frontend/src/components/iran/OilFacilityLayer.tsx diff --git a/frontend/src/components/ReplayMap.tsx b/frontend/src/components/iran/ReplayMap.tsx similarity index 98% rename from frontend/src/components/ReplayMap.tsx rename to frontend/src/components/iran/ReplayMap.tsx index b924362..388b922 100644 --- a/frontend/src/components/ReplayMap.tsx +++ b/frontend/src/components/iran/ReplayMap.tsx @@ -2,10 +2,10 @@ import { useEffect, useMemo, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; -import { AircraftLayer } from './AircraftLayer'; -import { SatelliteLayer } from './SatelliteLayer'; -import { ShipLayer } from './ShipLayer'; -import { DamagedShipLayer } from './DamagedShipLayer'; +import { AircraftLayer } from '../layers/AircraftLayer'; +import { SatelliteLayer } from '../layers/SatelliteLayer'; +import { ShipLayer } from '../layers/ShipLayer'; +import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { OilFacilityLayer } from './OilFacilityLayer'; import { AirportLayer } from './AirportLayer'; import { iranOilFacilities } from '../data/oilFacilities'; diff --git a/frontend/src/components/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx similarity index 97% rename from frontend/src/components/SatelliteMap.tsx rename to frontend/src/components/iran/SatelliteMap.tsx index e7414b9..8ac21f6 100644 --- a/frontend/src/components/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -2,10 +2,10 @@ import { useMemo, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; -import { AircraftLayer } from './AircraftLayer'; -import { SatelliteLayer } from './SatelliteLayer'; -import { ShipLayer } from './ShipLayer'; -import { DamagedShipLayer } from './DamagedShipLayer'; +import { AircraftLayer } from '../layers/AircraftLayer'; +import { SatelliteLayer } from '../layers/SatelliteLayer'; +import { ShipLayer } from '../layers/ShipLayer'; +import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { OilFacilityLayer } from './OilFacilityLayer'; import { AirportLayer } from './AirportLayer'; import { iranOilFacilities } from '../data/oilFacilities'; diff --git a/frontend/src/components/CctvLayer.tsx b/frontend/src/components/korea/CctvLayer.tsx similarity index 100% rename from frontend/src/components/CctvLayer.tsx rename to frontend/src/components/korea/CctvLayer.tsx diff --git a/frontend/src/components/CoastGuardLayer.tsx b/frontend/src/components/korea/CoastGuardLayer.tsx similarity index 100% rename from frontend/src/components/CoastGuardLayer.tsx rename to frontend/src/components/korea/CoastGuardLayer.tsx diff --git a/frontend/src/components/EezLayer.tsx b/frontend/src/components/korea/EezLayer.tsx similarity index 100% rename from frontend/src/components/EezLayer.tsx rename to frontend/src/components/korea/EezLayer.tsx diff --git a/frontend/src/components/InfraLayer.tsx b/frontend/src/components/korea/InfraLayer.tsx similarity index 100% rename from frontend/src/components/InfraLayer.tsx rename to frontend/src/components/korea/InfraLayer.tsx diff --git a/frontend/src/components/KoreaAirportLayer.tsx b/frontend/src/components/korea/KoreaAirportLayer.tsx similarity index 100% rename from frontend/src/components/KoreaAirportLayer.tsx rename to frontend/src/components/korea/KoreaAirportLayer.tsx diff --git a/frontend/src/components/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx similarity index 98% rename from frontend/src/components/KoreaMap.tsx rename to frontend/src/components/korea/KoreaMap.tsx index cd49aaf..2443e36 100644 --- a/frontend/src/components/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -2,10 +2,10 @@ import { useRef, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; -import { ShipLayer } from './ShipLayer'; +import { ShipLayer } from '../layers/ShipLayer'; import { InfraLayer } from './InfraLayer'; -import { SatelliteLayer } from './SatelliteLayer'; -import { AircraftLayer } from './AircraftLayer'; +import { SatelliteLayer } from '../layers/SatelliteLayer'; +import { AircraftLayer } from '../layers/AircraftLayer'; import { SubmarineCableLayer } from './SubmarineCableLayer'; import { CctvLayer } from './CctvLayer'; import { KoreaAirportLayer } from './KoreaAirportLayer'; diff --git a/frontend/src/components/NavWarningLayer.tsx b/frontend/src/components/korea/NavWarningLayer.tsx similarity index 100% rename from frontend/src/components/NavWarningLayer.tsx rename to frontend/src/components/korea/NavWarningLayer.tsx diff --git a/frontend/src/components/OsintMapLayer.tsx b/frontend/src/components/korea/OsintMapLayer.tsx similarity index 100% rename from frontend/src/components/OsintMapLayer.tsx rename to frontend/src/components/korea/OsintMapLayer.tsx diff --git a/frontend/src/components/PiracyLayer.tsx b/frontend/src/components/korea/PiracyLayer.tsx similarity index 100% rename from frontend/src/components/PiracyLayer.tsx rename to frontend/src/components/korea/PiracyLayer.tsx diff --git a/frontend/src/components/SubmarineCableLayer.tsx b/frontend/src/components/korea/SubmarineCableLayer.tsx similarity index 100% rename from frontend/src/components/SubmarineCableLayer.tsx rename to frontend/src/components/korea/SubmarineCableLayer.tsx diff --git a/frontend/src/components/AircraftLayer.tsx b/frontend/src/components/layers/AircraftLayer.tsx similarity index 100% rename from frontend/src/components/AircraftLayer.tsx rename to frontend/src/components/layers/AircraftLayer.tsx diff --git a/frontend/src/components/DamagedShipLayer.tsx b/frontend/src/components/layers/DamagedShipLayer.tsx similarity index 100% rename from frontend/src/components/DamagedShipLayer.tsx rename to frontend/src/components/layers/DamagedShipLayer.tsx diff --git a/frontend/src/components/SatelliteLayer.tsx b/frontend/src/components/layers/SatelliteLayer.tsx similarity index 100% rename from frontend/src/components/SatelliteLayer.tsx rename to frontend/src/components/layers/SatelliteLayer.tsx diff --git a/frontend/src/components/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx similarity index 92% rename from frontend/src/components/ShipLayer.tsx rename to frontend/src/components/layers/ShipLayer.tsx index 271538f..5bd7292 100644 --- a/frontend/src/components/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/hooks/useIranData.ts b/frontend/src/hooks/useIranData.ts new file mode 100644 index 0000000..47cbb88 --- /dev/null +++ b/frontend/src/hooks/useIranData.ts @@ -0,0 +1,256 @@ +import { useState, useEffect, useMemo, useRef } from 'react'; +import { fetchEvents, fetchSensorData } from '../services/api'; +import { fetchAircraftFromBackend } from '../services/aircraftApi'; +import { getSampleAircraft } from '../data/sampleAircraft'; +import { fetchSatelliteTLE, propagateAll } from '../services/celestrak'; +import { fetchShips } from '../services/ships'; +import { fetchOsintFeed } from '../services/osint'; +import type { OsintItem } from '../services/osint'; +import { propagateAircraft, propagateShips } from '../services/propagation'; +import { getMarineTrafficCategory } from '../utils/marineTraffic'; +import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types'; + +interface UseIranDataArgs { + appMode: AppMode; + currentTime: number; + isLive: boolean; + hiddenAcCategories: Set; + hiddenShipCategories: Set; + refreshKey: number; + dashboardTab: 'iran' | 'korea'; +} + +interface UseIranDataResult { + aircraft: Aircraft[]; + ships: Ship[]; + visibleAircraft: Aircraft[]; + visibleShips: Ship[]; + satPositions: SatellitePosition[]; + events: GeoEvent[]; + mergedEvents: GeoEvent[]; + sensorData: SensorLog[]; + osintFeed: OsintItem[]; + aircraftByCategory: Record; + militaryCount: number; + shipsByCategory: Record; + koreanShips: Ship[]; + koreanShipsByCategory: Record; +} + +export function useIranData({ + appMode, + currentTime, + isLive, + hiddenAcCategories, + hiddenShipCategories, + refreshKey, + dashboardTab, +}: UseIranDataArgs): UseIranDataResult { + const [events, setEvents] = useState([]); + const [sensorData, setSensorData] = useState([]); + const [baseAircraft, setBaseAircraft] = useState([]); + const [baseShips, setBaseShips] = useState([]); + const [satellites, setSatellites] = useState([]); + const [satPositions, setSatPositions] = useState([]); + const [osintFeed, setOsintFeed] = useState([]); + + const satTimeRef = useRef(0); + + // Load initial data + useEffect(() => { + fetchEvents().then(setEvents).catch(() => {}); + fetchSensorData().then(setSensorData).catch(() => {}); + fetchSatelliteTLE().then(setSatellites).catch(() => {}); + }, [refreshKey]); + + // Fetch base aircraft data (LIVE: backend, REPLAY: sample) + 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]); + + // 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 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('iran'); + if (data.length > 0) setOsintFeed(data); + } catch { /* keep previous */ } + }; + setOsintFeed([]); + load(); + const interval = setInterval(load, 120_000); + return () => clearInterval(interval); + }, [isLive, dashboardTab, refreshKey]); + + // Propagate satellite positions — throttle to every 2s of real time + 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 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], + ); + + // 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], + ); + + // OSINT → GeoEvent 변환 + const osintEvents = useMemo((): GeoEvent[] => { + if (dashboardTab !== 'iran' || osintFeed.length === 0) return []; + + 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; + + const categoryToType: Record = { + military: 'osint', + shipping: 'osint', + oil: 'osint', + nuclear: 'osint', + diplomacy: 'osint', + }; + + return osintFeed + .filter(item => { + 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]); + + // 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], + ); + + // Ship stats — MT classification + 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 + 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]); + + return { + aircraft, + ships, + visibleAircraft, + visibleShips, + satPositions, + events, + mergedEvents, + sensorData, + osintFeed, + aircraftByCategory, + militaryCount, + shipsByCategory, + koreanShips, + koreanShipsByCategory, + }; +} diff --git a/frontend/src/hooks/useKoreaData.ts b/frontend/src/hooks/useKoreaData.ts new file mode 100644 index 0000000..65487ca --- /dev/null +++ b/frontend/src/hooks/useKoreaData.ts @@ -0,0 +1,160 @@ +import { useState, useEffect, useMemo, useRef } from 'react'; +import { fetchAircraftFromBackend } from '../services/aircraftApi'; +import { fetchSatelliteTLEKorea, propagateAll } from '../services/celestrak'; +import { fetchShipsKorea } from '../services/ships'; +import { fetchOsintFeed } from '../services/osint'; +import type { OsintItem } from '../services/osint'; +import { propagateAircraft, propagateShips } from '../services/propagation'; +import { getMarineTrafficCategory } from '../utils/marineTraffic'; +import type { Aircraft, Ship, Satellite, SatellitePosition } from '../types'; + +interface UseKoreaDataArgs { + currentTime: number; + isLive: boolean; + hiddenAcCategories: Set; + hiddenShipCategories: Set; + refreshKey: number; +} + +interface UseKoreaDataResult { + aircraft: Aircraft[]; + ships: Ship[]; + visibleAircraft: Aircraft[]; + visibleShips: Ship[]; + satPositions: SatellitePosition[]; + osintFeed: OsintItem[]; + koreaKoreanShips: Ship[]; + koreaChineseShips: Ship[]; + shipsByCategory: Record; + aircraftByCategory: Record; + militaryCount: number; +} + +export function useKoreaData({ + currentTime, + isLive, + hiddenAcCategories, + hiddenShipCategories, + refreshKey, +}: UseKoreaDataArgs): UseKoreaDataResult { + const [baseAircraftKorea, setBaseAircraftKorea] = useState([]); + const [baseShipsKorea, setBaseShipsKorea] = useState([]); + const [satellitesKorea, setSatellitesKorea] = useState([]); + const [satPositionsKorea, setSatPositionsKorea] = useState([]); + const [osintFeed, setOsintFeed] = useState([]); + + const satTimeKoreaRef = useRef(0); + + // 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 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); + }, [refreshKey]); + + // Fetch OSINT feed for Korea tab + useEffect(() => { + const load = async () => { + try { + const data = await fetchOsintFeed('korea'); + if (data.length > 0) setOsintFeed(data); + } catch { /* keep previous */ } + }; + load(); + const interval = setInterval(load, 120_000); + return () => clearInterval(interval); + }, [refreshKey]); + + // Propagate Korea satellite positions + 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 aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]); + + // Korea region ships + const ships = 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], + ); + + // Korea region stats + const koreaKoreanShips = useMemo(() => ships.filter(s => s.flag === 'KR'), [ships]); + const koreaChineseShips = useMemo(() => ships.filter(s => s.flag === 'CN'), [ships]); + + 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]); + + // Korea 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], + ); + + return { + aircraft, + ships, + visibleAircraft, + visibleShips, + satPositions: satPositionsKorea, + osintFeed, + koreaKoreanShips, + koreaChineseShips, + shipsByCategory, + aircraftByCategory, + militaryCount, + }; +} diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts new file mode 100644 index 0000000..135da1d --- /dev/null +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -0,0 +1,320 @@ +import { useState, useMemo, useRef } from 'react'; +import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable'; +import { getMarineTrafficCategory } from '../utils/marineTraffic'; +import type { Ship } from '../types'; + +interface KoreaFilters { + illegalFishing: boolean; + illegalTransship: boolean; + darkVessel: boolean; + cableWatch: boolean; + dokdoWatch: boolean; + ferryWatch: boolean; +} + +interface DokdoAlert { + mmsi: string; + name: string; + dist: number; + time: number; +} + +interface UseKoreaFiltersResult { + filters: KoreaFilters; + setFilter: (key: keyof KoreaFilters, value: boolean) => void; + filteredShips: Ship[]; + transshipSuspects: Set; + cableWatchSuspects: Set; + dokdoWatchSuspects: Set; + dokdoAlerts: DokdoAlert[]; + anyFilterOn: boolean; +} + +const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간 +const ONE_HOUR_MS = 60 * 60 * 1000; +const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간 +const DOKDO = { lat: 37.2417, lng: 131.8647 }; +const TERRITORIAL_DEG = 0.2; // ~22km (12해리) +const ALERT_DEG = 0.4; // ~44km + +export function useKoreaFilters( + koreaShips: Ship[], + visibleShips: Ship[], + currentTime: number, +): UseKoreaFiltersResult { + const [filters, setFilters] = useState({ + illegalFishing: false, + illegalTransship: false, + darkVessel: false, + cableWatch: false, + dokdoWatch: false, + ferryWatch: false, + }); + const [dokdoAlerts, setDokdoAlerts] = useState([]); + + const proximityStartRef = useRef>(new Map()); + const aisHistoryRef = useRef>(new Map()); + const cableNearStartRef = useRef>(new Map()); + const dokdoAlertedRef = useRef>(new Set()); + + const setFilter = (key: keyof KoreaFilters, value: boolean) => { + setFilters(prev => ({ ...prev, [key]: value })); + }; + + const anyFilterOn = + filters.illegalFishing || + filters.illegalTransship || + filters.darkVessel || + filters.cableWatch || + filters.dokdoWatch || + filters.ferryWatch; + + // 불법환적 의심 선박 탐지 + const transshipSuspects = useMemo(() => { + if (!filters.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); + } + const pairStartTime = prevMap.get(pairKey)!; + if (now - pairStartTime >= 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, filters.illegalTransship, currentTime]); + + // 다크베셀 탐지: AIS 신호 이력 추적 + const darkVesselSet = useMemo(() => { + if (!filters.darkVessel) return new Set(); + + const now = currentTime; + const history = aisHistoryRef.current; + const result = new Set(); + 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); + } + if (h.lastGapStart !== null) { + const gapDuration = now - h.lastGapStart; + if (gapDuration >= ONE_HOUR_MS) { + result.add(s.mmsi); + } + h.lastGapStart = null; + } + h.seen.push(now); + if (h.seen.length > 20) h.seen = h.seen.slice(-20); + + const aisAge = now - s.lastSeen; + if (aisAge > ONE_HOUR_MS) { + result.add(s.mmsi); + } + + 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]; + if (gap > 150_000) gapCount++; + } + if (gapCount >= 3) { + result.add(s.mmsi); + } + } + } + + for (const [mmsi, h] of history.entries()) { + if (!currentMmsis.has(mmsi) && h.lastGapStart === null) { + h.lastGapStart = now; + } + } + + 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, filters.darkVessel, currentTime]); + + // 해저케이블 감시 + const cableWatchSet = useMemo(() => { + if (!filters.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; + 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); + } + const cableStartTime = prevMap.get(s.mmsi)!; + if (now - cableStartTime >= CABLE_DURATION_MS) { + result.add(s.mmsi); + } + } + + for (const mmsi of prevMap.keys()) { + if (!currentNear.has(mmsi)) prevMap.delete(mmsi); + } + + return result; + }, [koreaShips, filters.cableWatch, currentTime]); + + // 독도감시 + const dokdoWatchSet = useMemo(() => { + if (!filters.dokdoWatch) return new Set(); + const result = new Set(); + const newAlerts: DokdoAlert[] = []; + 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, filters.dokdoWatch, currentTime]); + + // 필터링된 선박 목록 + const filteredShips = useMemo(() => { + if (!anyFilterOn) return visibleShips; + return visibleShips.filter(s => { + const mtCat = getMarineTrafficCategory(s.typecode, s.category); + if (filters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true; + if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true; + if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true; + if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true; + if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true; + if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true; + return false; + }); + }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]); + + return { + filters, + setFilter, + filteredShips, + transshipSuspects, + cableWatchSuspects: cableWatchSet, + dokdoWatchSuspects: dokdoWatchSet, + dokdoAlerts, + anyFilterOn, + }; +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d4b3048..7b175d1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -9,14 +9,14 @@ const defaultConfig: ApiConfig = { let cachedSensorData: SensorLog[] | null = null; -export async function fetchEvents(_config?: Partial): Promise { // eslint-disable-line @typescript-eslint/no-unused-vars +export async function fetchEvents(_config?: Partial): Promise { // In production, replace with actual API call: // const res = await fetch(config.eventsEndpoint); // return res.json(); return Promise.resolve(sampleEvents); } -export async function fetchSensorData(_config?: Partial): Promise { // eslint-disable-line @typescript-eslint/no-unused-vars +export async function fetchSensorData(_config?: Partial): Promise { // In production, replace with actual API call: // const res = await fetch(config.sensorEndpoint); // return res.json(); 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) diff --git a/frontend/src/utils/marineTraffic.ts b/frontend/src/utils/marineTraffic.ts new file mode 100644 index 0000000..ece0199 --- /dev/null +++ b/frontend/src/utils/marineTraffic.ts @@ -0,0 +1,47 @@ +// MarineTraffic-style ship classification +// Maps S&P STAT5CODE prefixes and our custom typecodes to MT categories +export 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'; +} From 4b41ed0d9d4fface35b1d7bceb1386ecbabfb0da Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 08:15:45 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=A7=80=EC=A7=84=ED=8C=8C(USGS)?= =?UTF-8?q?=20+=20=EA=B8=B0=EC=95=95(Open-Meteo)=20=EC=88=98=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20+=20DB=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20(#39)=20Co-authored-by:=20htlee=20=20Co-committed-by:=20htlee=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collector/sensor/PressureCollector.java | 113 ++++++++++++++++++ .../collector/sensor/SeismicCollector.java | 90 ++++++++++++++ .../java/gc/mda/kcg/config/CacheConfig.java | 5 +- .../kcg/domain/sensor/PressureReading.java | 49 ++++++++ .../sensor/PressureReadingRepository.java | 11 ++ .../mda/kcg/domain/sensor/SeismicEvent.java | 50 ++++++++ .../domain/sensor/SeismicEventRepository.java | 11 ++ .../kcg/domain/sensor/SensorController.java | 44 +++++++ .../gc/mda/kcg/domain/sensor/SensorDto.java | 51 ++++++++ database/migration/004_sensor_data.sql | 30 +++++ docs/RELEASE-NOTES.md | 8 ++ frontend/src/App.css | 6 + .../src/components/common/SensorChart.tsx | 34 +++--- 13 files changed, 487 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/collector/sensor/PressureCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/sensor/SeismicCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReading.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReadingRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEvent.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEventRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/SensorDto.java create mode 100644 database/migration/004_sensor_data.sql diff --git a/backend/src/main/java/gc/mda/kcg/collector/sensor/PressureCollector.java b/backend/src/main/java/gc/mda/kcg/collector/sensor/PressureCollector.java new file mode 100644 index 0000000..cb27cff --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/sensor/PressureCollector.java @@ -0,0 +1,113 @@ +package gc.mda.kcg.collector.sensor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.collector.CollectorStatusTracker; +import gc.mda.kcg.domain.sensor.PressureReading; +import gc.mda.kcg.domain.sensor.PressureReadingRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PressureCollector { + + private static final String[] STATION_NAMES = {"tehran", "isfahan", "bandar-abbas", "shiraz", "tabriz"}; + private static final double[] LATS = {35.69, 32.65, 27.19, 29.59, 38.08}; + private static final double[] LNGS = {51.39, 51.68, 56.27, 52.58, 46.29}; + + private static final String OPEN_METEO_URL = + "https://api.open-meteo.com/v1/forecast?" + + "latitude=35.69,32.65,27.19,29.59,38.08" + + "&longitude=51.39,51.68,56.27,52.58,46.29" + + "&hourly=surface_pressure&past_hours=24&forecast_hours=0&timezone=UTC"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final PressureReadingRepository repository; + private final CollectorStatusTracker tracker; + + @PostConstruct + public void init() { + Thread.ofVirtual().name("pressure-init").start(() -> { + log.info("Open-Meteo 기압 데이터 초기 로드"); + collect(); + }); + } + + @Scheduled(initialDelay = 45_000, fixedDelay = 600_000) + public void collectScheduled() { + collect(); + } + + private void collect() { + try { + String body = restTemplate.getForObject(OPEN_METEO_URL, String.class); + if (body == null || body.isBlank()) return; + + JsonNode root = objectMapper.readTree(body); + int saved = 0; + Instant now = Instant.now(); + + // Open-Meteo 멀티 위치: 배열 형태 응답 + if (root.isArray()) { + for (int i = 0; i < root.size() && i < STATION_NAMES.length; i++) { + saved += parseStation(root.get(i), STATION_NAMES[i], LATS[i], LNGS[i], now); + } + } else { + // 단일 위치 응답 (파라미터 1개일 때) + saved += parseStation(root, STATION_NAMES[0], LATS[0], LNGS[0], now); + } + + tracker.recordSuccess("pressure", "iran", saved); + log.debug("Open-Meteo 기압 수집 완료: {}건 저장", saved); + } catch (Exception e) { + tracker.recordFailure("pressure", "iran", e.getMessage()); + log.warn("Open-Meteo 기압 수집 실패: {}", e.getMessage()); + } + } + + private int parseStation(JsonNode stationData, String station, double lat, double lng, Instant now) { + JsonNode hourly = stationData.path("hourly"); + JsonNode times = hourly.path("time"); + JsonNode pressures = hourly.path("surface_pressure"); + if (!times.isArray() || !pressures.isArray()) return 0; + + int saved = 0; + for (int j = 0; j < times.size() && j < pressures.size(); j++) { + String timeStr = times.get(j).asText(); + double pressure = pressures.get(j).asDouble(Double.NaN); + if (Double.isNaN(pressure)) continue; + + try { + // Open-Meteo 시간 형식: "2026-03-18T07:00" (Z 없음) → Z 추가 + Instant readingTime = OffsetDateTime.parse(timeStr + "Z").toInstant(); + try { + repository.save(PressureReading.builder() + .station(station) + .lat(lat) + .lng(lng) + .pressureHpa(pressure) + .readingTime(readingTime) + .collectedAt(now) + .build()); + saved++; + } catch (Exception ignored) { + // unique constraint violation = 이미 존재, 무시 + } + } catch (DateTimeParseException e) { + log.debug("기압 시간 파싱 실패: {}", timeStr); + } + } + return saved; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/sensor/SeismicCollector.java b/backend/src/main/java/gc/mda/kcg/collector/sensor/SeismicCollector.java new file mode 100644 index 0000000..f734cf0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/sensor/SeismicCollector.java @@ -0,0 +1,90 @@ +package gc.mda.kcg.collector.sensor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.collector.CollectorStatusTracker; +import gc.mda.kcg.domain.sensor.SeismicEvent; +import gc.mda.kcg.domain.sensor.SeismicEventRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SeismicCollector { + + private static final String USGS_URL = + "https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson" + + "&minlatitude=25&maxlatitude=40&minlongitude=44&maxlongitude=63" + + "&orderby=time&limit=50&minmagnitude=2"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final SeismicEventRepository repository; + private final CollectorStatusTracker tracker; + + @PostConstruct + public void init() { + Thread.ofVirtual().name("seismic-init").start(() -> { + log.info("USGS 지진 데이터 초기 로드"); + collect(); + }); + } + + @Scheduled(initialDelay = 60_000, fixedDelay = 300_000) + public void collectScheduled() { + collect(); + } + + private void collect() { + try { + String body = restTemplate.getForObject(USGS_URL, String.class); + if (body == null || body.isBlank()) return; + + JsonNode root = objectMapper.readTree(body); + JsonNode features = root.path("features"); + if (!features.isArray()) return; + + int saved = 0; + Instant now = Instant.now(); + for (JsonNode f : features) { + JsonNode props = f.path("properties"); + JsonNode coords = f.path("geometry").path("coordinates"); + + String usgsId = f.path("id").asText(null); + if (usgsId == null) continue; + if (repository.existsByUsgsId(usgsId)) continue; + + double mag = props.path("mag").asDouble(0); + String place = props.path("place").asText(null); + long timeMs = props.path("time").asLong(0); + double lng = coords.get(0).asDouble(); + double lat = coords.get(1).asDouble(); + Double depth = coords.has(2) ? coords.get(2).asDouble() : null; + + repository.save(SeismicEvent.builder() + .usgsId(usgsId) + .magnitude(mag) + .depth(depth) + .lat(lat) + .lng(lng) + .place(place) + .eventTime(Instant.ofEpochMilli(timeMs)) + .collectedAt(now) + .build()); + saved++; + } + tracker.recordSuccess("seismic", "iran", saved); + log.debug("USGS 지진 수집 완료: {}건 저장", saved); + } catch (Exception e) { + tracker.recordFailure("seismic", "iran", e.getMessage()); + log.warn("USGS 지진 수집 실패: {}", e.getMessage()); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java index 525a01c..1d216f7 100644 --- a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java +++ b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java @@ -18,13 +18,16 @@ public class CacheConfig { public static final String OSINT_IRAN = "osint-iran"; public static final String OSINT_KOREA = "osint-korea"; public static final String SATELLITES = "satellites"; + public static final String SEISMIC = "seismic"; + public static final String PRESSURE = "pressure"; @Bean public CacheManager cacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager( AIRCRAFT_IRAN, AIRCRAFT_KOREA, OSINT_IRAN, OSINT_KOREA, - SATELLITES + SATELLITES, + SEISMIC, PRESSURE ); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(2, TimeUnit.DAYS) diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReading.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReading.java new file mode 100644 index 0000000..f405295 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReading.java @@ -0,0 +1,49 @@ +package gc.mda.kcg.domain.sensor; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table( + name = "pressure_readings", + schema = "kcg", + uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"}) +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PressureReading { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String station; + + @Column(nullable = false) + private double lat; + + @Column(nullable = false) + private double lng; + + @Column(nullable = false) + private double pressureHpa; + + @Column(nullable = false) + private Instant readingTime; + + @Column(nullable = false) + private Instant collectedAt; + + @PrePersist + protected void onCreate() { + if (collectedAt == null) { + collectedAt = Instant.now(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReadingRepository.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReadingRepository.java new file mode 100644 index 0000000..3c418c0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReadingRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.sensor; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface PressureReadingRepository extends JpaRepository { + List findByStationAndReadingTimeAfterOrderByReadingTimeAsc(String station, Instant since); + List findByReadingTimeAfterOrderByReadingTimeAsc(Instant since); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEvent.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEvent.java new file mode 100644 index 0000000..f877e3e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEvent.java @@ -0,0 +1,50 @@ +package gc.mda.kcg.domain.sensor; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "seismic_events", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SeismicEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 30) + private String usgsId; + + @Column(nullable = false) + private double magnitude; + + private Double depth; + + @Column(nullable = false) + private double lat; + + @Column(nullable = false) + private double lng; + + @Column(length = 255) + private String place; + + @Column(nullable = false) + private Instant eventTime; + + @Column(nullable = false) + private Instant collectedAt; + + @PrePersist + protected void onCreate() { + if (collectedAt == null) { + collectedAt = Instant.now(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEventRepository.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEventRepository.java new file mode 100644 index 0000000..5340f7e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEventRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.sensor; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface SeismicEventRepository extends JpaRepository { + boolean existsByUsgsId(String usgsId); + List findByEventTimeAfterOrderByEventTimeDesc(Instant since); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java new file mode 100644 index 0000000..3efa827 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java @@ -0,0 +1,44 @@ +package gc.mda.kcg.domain.sensor; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/sensor") +@RequiredArgsConstructor +public class SensorController { + + private final SeismicEventRepository seismicRepo; + private final PressureReadingRepository pressureRepo; + + /** + * 지진 이벤트 조회 (USGS 수집 데이터) + */ + @GetMapping("/seismic") + public Map getSeismic( + @RequestParam(defaultValue = "24") int hours) { + Instant since = Instant.now().minus(hours, ChronoUnit.HOURS); + List data = seismicRepo + .findByEventTimeAfterOrderByEventTimeDesc(since) + .stream().map(SensorDto.SeismicDto::from).toList(); + return Map.of("count", data.size(), "data", data); + } + + /** + * 기압 데이터 조회 (Open-Meteo 수집 데이터) + */ + @GetMapping("/pressure") + public Map getPressure( + @RequestParam(defaultValue = "24") int hours) { + Instant since = Instant.now().minus(hours, ChronoUnit.HOURS); + List data = pressureRepo + .findByReadingTimeAfterOrderByReadingTimeAsc(since) + .stream().map(SensorDto.PressureDto::from).toList(); + return Map.of("count", data.size(), "data", data); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorDto.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorDto.java new file mode 100644 index 0000000..8e06b56 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorDto.java @@ -0,0 +1,51 @@ +package gc.mda.kcg.domain.sensor; + +import lombok.Builder; +import lombok.Getter; + +public class SensorDto { + + @Getter + @Builder + public static class SeismicDto { + private String usgsId; + private double magnitude; + private Double depth; + private double lat; + private double lng; + private String place; + private long timestamp; // epoch ms + + public static SeismicDto from(SeismicEvent e) { + return SeismicDto.builder() + .usgsId(e.getUsgsId()) + .magnitude(e.getMagnitude()) + .depth(e.getDepth()) + .lat(e.getLat()) + .lng(e.getLng()) + .place(e.getPlace()) + .timestamp(e.getEventTime().toEpochMilli()) + .build(); + } + } + + @Getter + @Builder + public static class PressureDto { + private String station; + private double lat; + private double lng; + private double pressureHpa; + private long timestamp; // epoch ms + + public static PressureDto from(PressureReading r) { + return PressureDto.builder() + .station(r.getStation()) + .lat(r.getLat()) + .lng(r.getLng()) + .pressureHpa(r.getPressureHpa()) + .timestamp(r.getReadingTime().toEpochMilli()) + .build(); + } + } +} diff --git a/database/migration/004_sensor_data.sql b/database/migration/004_sensor_data.sql new file mode 100644 index 0000000..f937f0a --- /dev/null +++ b/database/migration/004_sensor_data.sql @@ -0,0 +1,30 @@ +SET search_path TO kcg, public; + +-- 지진 이벤트 (USGS) +CREATE TABLE IF NOT EXISTS seismic_events ( + id BIGSERIAL PRIMARY KEY, + usgs_id VARCHAR(30) UNIQUE NOT NULL, + magnitude DOUBLE PRECISION NOT NULL, + depth DOUBLE PRECISION, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + place VARCHAR(255), + event_time TIMESTAMPTZ NOT NULL, + collected_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_seismic_events_time ON seismic_events(event_time DESC); + +-- 기압 데이터 (Open-Meteo) +CREATE TABLE IF NOT EXISTS pressure_readings ( + id BIGSERIAL PRIMARY KEY, + station VARCHAR(50) NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + pressure_hpa DOUBLE PRECISION NOT NULL, + reading_time TIMESTAMPTZ NOT NULL, + collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(station, reading_time) +); + +CREATE INDEX IF NOT EXISTS idx_pressure_readings_time ON pressure_readings(reading_time DESC); diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index c2c3886..7163403 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,14 @@ ## [Unreleased] +### 추가 +- 지진파 수집기: USGS FDSN API, 이란 bbox(M2+), 5분 주기 +- 기압 수집기: Open-Meteo API, 이란 5개 관측점(테헤란/이스파한/반다르아바스/시라즈/타브리즈), 10분 주기 +- DB 테이블: seismic_events, pressure_readings (마이그레이션 004) +- REST API: GET /api/sensor/seismic, GET /api/sensor/pressure +- SensorChart 그래프 순서 변경: 지진파 → 기압 → 소음 → 방사선 +- 소음/방사선 차트에 (DEMO) 라벨 표시 + ### 변경 - 프론트엔드 패키지 구조 리팩토링: components/ → common/layers/iran/korea/ 분리 - App.tsx God Component 분해: 1,179줄 → 588줄 (데이터 훅 3개 추출) diff --git a/frontend/src/App.css b/frontend/src/App.css index a466923..1c27ecb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1131,6 +1131,12 @@ font-family: 'Courier New', monospace; } +.chart-demo-label { + font-size: 9px; + color: #ef4444; + opacity: 0.7; +} + /* Footer / Controls */ .app-footer { background: var(--bg-card); diff --git a/frontend/src/components/common/SensorChart.tsx b/frontend/src/components/common/SensorChart.tsx index 6f4077f..288a22d 100644 --- a/frontend/src/components/common/SensorChart.tsx +++ b/frontend/src/components/common/SensorChart.tsx @@ -54,19 +54,6 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
-
-

{t('sensor.noiseLevelDb')}

- - - - - - - - - -
-

{t('sensor.airPressureHpa')}

@@ -81,7 +68,26 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
-

{t('sensor.radiationUsv')}

+

+ {t('sensor.noiseLevelDb')}{' '} + (DEMO) +

+ + + + + + + + + +
+ +
+

+ {t('sensor.radiationUsv')}{' '} + (DEMO) +

From d87ce089c56677d6ee867f9248f3a30999e83be6 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 08:17:52 +0900 Subject: [PATCH 5/5] =?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=A0=95=EB=A6=AC=20(2026-03-18)=20(#40)?= =?UTF-8?q?=20Co-authored-by:=20htlee=20=20Co-committed-?= =?UTF-8?q?by:=20htlee=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 7163403..1bf517a 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,23 +4,24 @@ ## [Unreleased] +## [2026-03-18.2] + ### 추가 - 지진파 수집기: USGS FDSN API, 이란 bbox(M2+), 5분 주기 -- 기압 수집기: Open-Meteo API, 이란 5개 관측점(테헤란/이스파한/반다르아바스/시라즈/타브리즈), 10분 주기 -- DB 테이블: seismic_events, pressure_readings (마이그레이션 004) -- REST API: GET /api/sensor/seismic, GET /api/sensor/pressure -- SensorChart 그래프 순서 변경: 지진파 → 기압 → 소음 → 방사선 -- 소음/방사선 차트에 (DEMO) 라벨 표시 +- 기압 수집기: Open-Meteo API, 이란 5개 관측점, 10분 주기 +- DB: seismic_events, pressure_readings 테이블 (마이그레이션 004) +- REST: GET /api/sensor/seismic, GET /api/sensor/pressure ### 변경 - 프론트엔드 패키지 구조 리팩토링: components/ → common/layers/iran/korea/ 분리 -- App.tsx God Component 분해: 1,179줄 → 588줄 (데이터 훅 3개 추출) -- 선박 모달 사진 탭: signal-batch → S&P Global 명칭 변경, 고화질(_2) 기본 표시 -- Overpass API 외부 호출 제거 → 정적 인프라 데이터 사용 +- App.tsx 분해: 1,179줄 → 588줄 (useIranData, useKoreaData, useKoreaFilters 훅 추출) +- SensorChart 그래프 순서: 지진파 → 기압 → 소음(DEMO) → 방사선(DEMO) +- 선박 모달 사진 탭: S&P Global 명칭, 고화질(_2) 기본 표시 +- Overpass API 외부 호출 제거 → 정적 인프라 데이터 ### 수정 -- LiveControls KST 시간 이중 오프셋(+9h×2) 버그 수정 + KST/UTC 토글 추가 -- nginx /shipimg/ 프록시: 정적파일 regex 우선매칭 방지 (^~ 추가) +- LiveControls KST 시간 이중 오프셋(+9h×2) 수정 + KST/UTC 토글 +- nginx /shipimg/ 프록시: ^~ 추가 (정적파일 regex 우선매칭 방지) ## [2026-03-18]