diff --git a/frontend/src/App.css b/frontend/src/App.css index 231350f..de8a637 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1881,6 +1881,11 @@ border-top-color: rgba(10, 10, 26, 0.96) !important; } +/* 중국어선 오버레이 마커 — 이벤트 차단 */ +.maplibregl-marker:has(.cn-fishing-no-events) { + pointer-events: none; +} + .gl-popup .maplibregl-popup-close-button, .event-popup .maplibregl-popup-close-button { color: #aaa !important; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca797f4..241a5a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { useAuth } from './hooks/useAuth'; import { useTranslation } from 'react-i18next'; import LoginPage from './components/auth/LoginPage'; import CollectorMonitor from './components/common/CollectorMonitor'; +import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal'; import './App.css'; function App() { @@ -65,6 +66,16 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { oilFacilities: true, meFacilities: true, militaryOnly: false, + overseasUS: false, + overseasUK: false, + overseasIran: false, + overseasUAE: false, + overseasSaudi: false, + overseasOman: false, + overseasQatar: false, + overseasKuwait: false, + overseasIraq: false, + overseasBahrain: false, }); // Korea tab layer visibility (lifted from KoreaMap) @@ -89,6 +100,21 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { nkMissile: true, cnFishing: false, militaryOnly: false, + overseasChina: false, + overseasJapan: false, + cnPower: false, + cnMilitary: false, + jpPower: false, + jpMilitary: false, + hazardPetrochemical: false, + hazardLng: false, + hazardOilTank: false, + hazardPort: false, + energyNuclear: false, + energyThermal: false, + industryShipyard: false, + industryWastewater: false, + industryHeavy: false, }); const toggleKoreaLayer = useCallback((key: string) => { @@ -148,6 +174,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }, []); const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); + const [showFieldAnalysis, setShowFieldAnalysis] = useState(false); const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); const [focusShipMmsi, setFocusShipMmsi] = useState(null); @@ -321,6 +348,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { 🎣 중국어선감시 + )} @@ -459,6 +495,18 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { { key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 }, { key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, ]} + overseasItems={[ + { key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' }, + { key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' }, + { key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' }, + { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' }, + { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' }, + { key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' }, + { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' }, + { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' }, + { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' }, + { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' }, + ]} hiddenAcCategories={hiddenAcCategories} hiddenShipCategories={hiddenShipCategories} onAcCategoryToggle={toggleAcCategory} @@ -553,6 +601,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { <>
+ {showFieldAnalysis && ( + setShowFieldAnalysis(false)} /> + )} = { }; const EMPTY_OSINT: OsintItem[] = []; -const EMPTY_SHIPS: import('../types').Ship[] = []; +const EMPTY_SHIPS: Ship[] = []; function useTimeAgo() { const { t } = useTranslation('common'); @@ -597,7 +598,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, {'\u{1F1F0}\u{1F1F7}'} {t('ships:shipStatus.koreanTitle')} {koreanShips.length}{t('common:units.vessels')} - {onToggleHighlightKorean && dashboardTab === 'iran' && ( + {onToggleHighlightKorean && (dashboardTab as string) === 'iran' && ( +
+ + + {/* ── 통계 스트립 */} +
+ {[ + { label: '총 탐지 어선', val: stats.total, color: C.cyan, sub: 'AIS 수신 기준' }, + { label: '영해 침범', val: stats.territorial, color: C.red, sub: '12NM 이내' }, + { label: '조업 중', val: stats.fishing, color: C.amber, sub: 'SOG 0.5–5.0kt' }, + { label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' }, + { label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 스푸핑 50%↑' }, + { label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'DBSCAN 군집' }, + { label: '트롤어선', val: stats.trawl, color: C.purple, sub: 'Python 분류' }, + { label: '선망어선', val: stats.purse, color: C.cyan, sub: 'Python 분류' }, + ].map(({ label, val, color, sub }) => ( +
+
{label}
+
{val}
+
{sub}
+
+ ))} +
+ + {/* ── 메인 그리드 */} +
+ {/* ── 좌측 패널: 구역 현황 + AI 파이프라인 */} +
+
+ 구역별 현황 + +
+ + {([ + { label: '영해 (12NM)', count: zoneCounts.terr, color: C.red, sub: '즉시 퇴거 명령 필요' }, + { label: '접속수역 (24NM)', count: zoneCounts.cont, color: C.amber, sub: '조업 행위 집중 모니터링' }, + { label: 'EEZ 내측', count: zoneCounts.eez, color: C.amber, sub: '조업밀도 핫스팟 포함' }, + { label: 'EEZ 외측', count: zoneCounts.beyond, color: C.green, sub: '정상 모니터링' }, + ] as const).map(({ label, count, color, sub }) => { + const max = Math.max(processed.length, 1); + return ( +
+
+ {label} + {count} +
+
+
+
+
{sub}
+
+ ); + })} + +
+ AI 파이프라인 상태 + +
+ + {PIPE_STEPS.map((step, idx) => { + const isRunning = idx === pipeStep % PIPE_STEPS.length; + return ( +
+ {step.num} + {step.name} + + {isRunning ? 'PROC' : 'OK'} + +
+ ); + })} + + {[ + { num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 }, + { num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 }, + ].map(step => ( +
+ {step.num} + {step.name} + + {step.status} + +
+ ))} + + {/* 알고리즘 기준 요약 */} +
+ 알고리즘 기준 +
+ {[ + { label: '위치 판정', val: 'Haversine + 기선', color: C.ink2 }, + { label: '조업 패턴', val: 'UCAF/UCFT SOG', color: C.ink2 }, + { label: 'AIS 소실', val: '>20분 미수신', color: C.amber }, + { label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple }, + { label: '클러스터', val: 'DBSCAN 3NM (Python)', color: C.ink2 }, + { label: '선종 분류', val: 'Python 7단계 파이프라인', color: C.green }, + ].map(({ label, val, color }) => ( +
+ {label} + {val} +
+ ))} +
+ + {/* ── 중앙 패널: 선박 테이블 */} +
+ {/* 필터 바 */} +
+ {[ + { key: 'ALL', label: '전체' }, + { key: 'CRITICAL', label: '긴급 경보' }, + { key: 'FISHING', label: '조업 중' }, + { key: 'AIS_LOSS', label: 'AIS 소실' }, + { key: 'TERRITORIAL', label: '영해 내' }, + ].map(({ key, label }) => ( + + ))} + setSearch(e.target.value.toLowerCase())} + placeholder="MMSI / 선명 검색..." + style={{ + flex: 1, minWidth: 120, + background: C.bg3, border: `1px solid ${C.border}`, + color: C.ink, padding: '3px 10px', fontSize: 10, + borderRadius: 2, outline: 'none', fontFamily: 'inherit', + }} + /> + + 표시: {displayed.length} 척 + + +
+ + {/* 테이블 */} +
+ + + + {['AIS', 'MMSI', '선명', '위도', '경도', 'SOG', '침로', '상태', '선종', '구역', '클러스터', '경보', '수신'].map(h => ( + + ))} + + + + {displayed.slice(0, 120).map(v => { + const rowBg = + v.alert === 'CRITICAL' ? 'rgba(255,82,82,0.08)' : + v.alert === 'WATCH' ? 'rgba(255,215,64,0.05)' : + v.alert === 'MONITOR' ? 'rgba(24,255,255,0.04)' : + 'transparent'; + const isSelected = v.ship.mmsi === selectedMmsi; + const ageMins = Math.floor((Date.now() - v.ship.lastSeen) / 60000); + return ( + setSelectedMmsi(v.ship.mmsi)} + style={{ + background: isSelected ? 'rgba(0,230,118,0.08)' : rowBg, + cursor: 'pointer', + outline: isSelected ? `1px solid ${C.green}` : undefined, + }} + > + + + + + + + + + + + + + + + ); + })} + {displayed.length === 0 && ( + + + + )} + +
{h}
+ + {v.ship.mmsi} + {v.ship.name || '(Unknown)'} + {v.ship.lat.toFixed(3)}°N{v.ship.lng.toFixed(3)}°E + {v.state === 'AIS_LOSS' ? '—' : `${v.ship.speed.toFixed(1)}kt`} + + {v.state !== 'AIS_LOSS' ? `${v.ship.course}°` : '—'} + + + {stateLabel(v.state)} + + + + {v.vtype} + + + + {zoneLabel(v.zone)} + + + {v.cluster} + + + {v.alert} + + + {ageMins < 60 ? `${ageMins}분전` : `${Math.floor(ageMins / 60)}시간전`} +
+ 탐지된 중국 어선 없음 +
+
+ + {/* 하단 범례 */} +
+ {[ + { color: C.red, label: 'CRITICAL — 즉시대응' }, + { color: C.amber, label: 'WATCH — 집중모니터링' }, + { color: C.cyan, label: 'MONITOR — 주시' }, + { color: C.green, label: 'NORMAL — 정상' }, + ].map(({ color, label }) => ( + + + {label} + + ))} + + AIS 4분 갱신 | Python 7단계 파이프라인 | DBSCAN 3NM 클러스터 | GeoJSON 수역 분류 + +
+
+ + {/* ── 우측 패널: 선박 상세 + 허가 정보 + 사진 + 경보 로그 */} +
+ {/* 패널 헤더 */} +
+ 선박 상세 정보 + +
+ + {/* 스크롤 영역: 상세 + 허가 + 사진 */} +
+ {selectedVessel ? ( + <> + {/* 기본 상세 필드 */} +
+ {[ + { label: 'MMSI', val: selectedVessel.ship.mmsi, color: C.cyan }, + { label: '선명', val: selectedVessel.ship.name || '(Unknown)', color: '#fff' }, + { label: '위치', val: `${selectedVessel.ship.lat.toFixed(4)}°N ${selectedVessel.ship.lng.toFixed(4)}°E`, color: C.ink }, + { label: '속도 / 침로', val: `${selectedVessel.ship.speed.toFixed(1)}kt ${selectedVessel.ship.course}°`, color: C.amber }, + { label: '행동 상태', val: stateLabel(selectedVessel.state), color: stateColor(selectedVessel.state) }, + { label: '선종 (Python)', val: selectedVessel.vtype, color: C.ink }, + { label: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) }, + { label: '클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 }, + { label: '위험도', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) }, + ...(() => { + const dto = analysisMap.get(selectedVessel.ship.mmsi); + if (!dto) return [{ label: 'AI 분석', val: '미분석', color: C.ink3 }]; + return [ + { label: '위험 점수', val: `${dto.algorithms.riskScore.score}점`, color: alertColor(selectedVessel.alert) }, + { label: 'GPS 스푸핑', val: `${Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, color: dto.algorithms.gpsSpoofing.spoofingScore > 0.5 ? C.red : C.green }, + { label: 'AIS 공백', val: dto.algorithms.darkVessel.isDark ? `${Math.round(dto.algorithms.darkVessel.gapDurationMin)}분` : '정상', color: dto.algorithms.darkVessel.isDark ? C.red : C.green }, + { label: '선단 역할', val: dto.algorithms.fleetRole.role, color: dto.algorithms.fleetRole.isLeader ? C.amber : C.ink2 }, + ]; + })(), + ].map(({ label, val, color }) => ( +
+ {label} + {val} +
+ ))} +
+ + +
+
+ + {/* ── 허가 정보 */} +
+
허가 정보
+ + {/* 허가 여부 배지 */} +
+ 허가 여부 + {permitStatus === 'loading' && ( + 조회 중... + )} + {permitStatus === 'found' && ( + + ✓ 허가 선박 + + )} + {permitStatus === 'not-found' && ( + + ✕ 미등록 선박 + + )} +
+ + {/* 허가 내역 (데이터 있을 때) */} + {permitStatus === 'found' && permitData && ( +
+ {[ + { label: '선명', val: permitData.name }, + { label: '선종', val: permitData.vesselType }, + { label: 'IMO', val: String(permitData.imo || '—') }, + { label: '호출부호', val: permitData.callsign || '—' }, + { label: '길이/폭', val: `${permitData.length ?? 0}m / ${permitData.width ?? 0}m` }, + { label: '흘수', val: permitData.draught ? `${permitData.draught}m` : '—' }, + { label: '목적지', val: permitData.destination || '—' }, + { label: '상태', val: permitData.status || '—' }, + ].map(({ label, val }) => ( +
+ {label} + {val} +
+ ))} +
+ )} + + {/* 미등록 안내 */} + {permitStatus === 'not-found' && ( +
+
+ 한중어업협정 허가 DB에 등록되지 않은 선박입니다.
+ 불법어업 의심 — 추가 조사 및 조치 필요 +
+
+ )} +
+ + {/* ── 선박 사진 */} +
+
선박 사진
+
+ {photoUrl === undefined && ( + 로딩 중... + )} + {photoUrl === null && ( + 사진 없음 + )} + {photoUrl && ( + {selectedVessel.ship.name setPhotoUrl(null)} + /> + )} +
+ {photoUrl && ( +
+ © MarineTraffic / S&P Global +
+ )} +
+ + ) : ( +
+ 테이블에서 선박을 선택하세요 +
+ )} +
+ + {/* 경보 로그 — 하단 고정 */} +
+ 실시간 경보 로그 + {logs.length}건 +
+
+ {logs.map((log, i) => ( +
+
{log.ts}
+
+ {log.mmsi} {log.name} — {log.type} +
+
+ ))} + {logs.length === 0 && ( +
경보 없음
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 79f93fe..7c61c13 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; -import { Source, Layer } from 'react-map-gl/maplibre'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { Source, Layer, Popup, useMap } from 'react-map-gl/maplibre'; import type { GeoJSON } from 'geojson'; +import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis'; @@ -11,6 +12,12 @@ export interface SelectedGearGroupData { groupName: string; } +export interface SelectedFleetData { + clusterId: number; + ships: Ship[]; + companyName: string; +} + interface Props { ships: Ship[]; analysisMap: Map; @@ -18,6 +25,7 @@ interface Props { onShipSelect?: (mmsi: string) => void; onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void; onSelectedGearChange?: (data: SelectedGearGroupData | null) => void; + onSelectedFleetChange?: (data: SelectedFleetData | null) => void; } // 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별 @@ -90,18 +98,135 @@ interface ClusterLineFeature { type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature; -export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange }: Props) { +export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange }: Props) { const [companies, setCompanies] = useState>(new Map()); const [expanded, setExpanded] = useState(true); const [expandedFleet, setExpandedFleet] = useState(null); const [hoveredFleetId, setHoveredFleetId] = useState(null); const [expandedGearGroup, setExpandedGearGroup] = useState(null); const [selectedGearGroup, setSelectedGearGroup] = useState(null); + // 폴리곤 호버 툴팁 + const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null); + const { current: mapRef } = useMap(); + const registeredRef = useRef(false); + // dataRef는 shipMap/gearGroupMap 선언 이후에 갱신 (아래 참조) + const dataRef = useRef<{ clusters: Map; shipMap: Map; gearGroupMap: Map; onFleetZoom: Props['onFleetZoom'] }>({ clusters, shipMap: new Map(), gearGroupMap: new Map(), onFleetZoom }); useEffect(() => { fetchFleetCompanies().then(setCompanies).catch(() => {}); }, []); + // ── 맵 폴리곤 클릭/호버 이벤트 등록 + useEffect(() => { + const map = mapRef?.getMap(); + if (!map || registeredRef.current) return; + + const fleetLayers = ['fleet-cluster-fill-layer']; + const gearLayers = ['gear-cluster-fill-layer']; + const allLayers = [...fleetLayers, ...gearLayers]; + + const setCursor = (cursor: string) => { map.getCanvas().style.cursor = cursor; }; + + const onFleetEnter = (e: MapLayerMouseEvent) => { + setCursor('pointer'); + const feat = e.features?.[0]; + if (!feat) return; + const cid = feat.properties?.clusterId as number | undefined; + if (cid != null) { + setHoveredFleetId(cid); + setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'fleet', id: cid }); + } + }; + const onFleetLeave = () => { + setCursor(''); + setHoveredFleetId(null); + setHoverTooltip(prev => prev?.type === 'fleet' ? null : prev); + }; + const onFleetClick = (e: MapLayerMouseEvent) => { + const feat = e.features?.[0]; + if (!feat) return; + const cid = feat.properties?.clusterId as number | undefined; + if (cid == null) return; + const d = dataRef.current; + setExpandedFleet(prev => prev === cid ? null : cid); + setExpanded(true); + const mmsiList = d.clusters.get(cid) ?? []; + if (mmsiList.length === 0) return; + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const mmsi of mmsiList) { + const ship = d.shipMap.get(mmsi); + if (!ship) continue; + if (ship.lat < minLat) minLat = ship.lat; + if (ship.lat > maxLat) maxLat = ship.lat; + if (ship.lng < minLng) minLng = ship.lng; + if (ship.lng > maxLng) maxLng = ship.lng; + } + if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + }; + + const onGearEnter = (e: MapLayerMouseEvent) => { + setCursor('pointer'); + const feat = e.features?.[0]; + if (!feat) return; + const name = feat.properties?.name as string | undefined; + if (name) { + setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'gear', id: name }); + } + }; + const onGearLeave = () => { + setCursor(''); + setHoverTooltip(prev => prev?.type === 'gear' ? null : prev); + }; + const onGearClick = (e: MapLayerMouseEvent) => { + const feat = e.features?.[0]; + if (!feat) return; + const name = feat.properties?.name as string | undefined; + if (!name) return; + const d = dataRef.current; + setSelectedGearGroup(prev => prev === name ? null : name); + setExpandedGearGroup(name); + setExpanded(true); + const entry = d.gearGroupMap.get(name); + if (!entry) return; + const all: Ship[] = [...entry.gears]; + if (entry.parent) all.push(entry.parent); + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const s of all) { + if (s.lat < minLat) minLat = s.lat; + if (s.lat > maxLat) maxLat = s.lat; + if (s.lng < minLng) minLng = s.lng; + if (s.lng > maxLng) maxLng = s.lng; + } + if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + }; + + const register = () => { + const ready = allLayers.every(id => map.getLayer(id)); + if (!ready) return; + registeredRef.current = true; + + for (const id of fleetLayers) { + map.on('mouseenter', id, onFleetEnter); + map.on('mouseleave', id, onFleetLeave); + map.on('click', id, onFleetClick); + } + for (const id of gearLayers) { + map.on('mouseenter', id, onGearEnter); + map.on('mouseleave', id, onGearLeave); + map.on('click', id, onGearClick); + } + }; + + register(); + if (!registeredRef.current) { + const interval = setInterval(() => { + register(); + if (registeredRef.current) clearInterval(interval); + }, 500); + return () => clearInterval(interval); + } + }, [mapRef]); + // 선박명 → mmsi 맵 (어구 매칭용) const gearsByParent = useMemo(() => { const map = new Map(); // parent_mmsi → gears @@ -180,6 +305,9 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, return map; }, [ships]); + // stale closure 방지 — shipMap/gearGroupMap 선언 이후 갱신 + dataRef.current = { clusters, shipMap, gearGroupMap, onFleetZoom }; + // 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) useEffect(() => { if (!selectedGearGroup) { @@ -194,6 +322,22 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, } }, [selectedGearGroup, gearGroupMap, onSelectedGearChange]); + // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) + useEffect(() => { + if (expandedFleet === null) { + onSelectedFleetChange?.(null); + return; + } + const mmsiList = clusters.get(expandedFleet) ?? []; + const fleetShips = mmsiList.map(mmsi => shipMap.get(mmsi)).filter((s): s is Ship => !!s); + const company = companies.get(expandedFleet); + onSelectedFleetChange?.({ + clusterId: expandedFleet, + ships: fleetShips, + companyName: company?.nameCn || `선단 #${expandedFleet}`, + }); + }, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]); + // 비허가 어구 클러스터 GeoJSON const gearClusterGeoJson = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; @@ -457,6 +601,67 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, /> + {/* 폴리곤 호버 툴팁 */} + {hoverTooltip && (() => { + if (hoverTooltip.type === 'fleet') { + const cid = hoverTooltip.id as number; + const mmsiList = clusters.get(cid) ?? []; + const company = companies.get(cid); + const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0); + return ( + +
+
+ {company?.nameCn || `선단 #${cid}`} +
+
선박 {mmsiList.length}척 · 어구 {gearCount}개
+ {expandedFleet === cid && mmsiList.slice(0, 5).map(mmsi => { + const s = shipMap.get(mmsi); + const dto = analysisMap.get(mmsi); + const role = dto?.algorithms.fleetRole.role ?? ''; + return s ? ( +
+ {role === 'LEADER' ? '★' : '·'} {s.name || mmsi} {s.speed?.toFixed(1)}kt +
+ ) : null; + })} +
클릭하여 상세 보기
+
+
+ ); + } + if (hoverTooltip.type === 'gear') { + const name = hoverTooltip.id as string; + const entry = gearGroupMap.get(name); + if (!entry) return null; + return ( + +
+
+ {name} 어구 {entry.gears.length}개 +
+ {entry.parent && ( +
모선: {entry.parent.name || entry.parent.mmsi}
+ )} + {selectedGearGroup === name && entry.gears.slice(0, 5).map(g => ( +
+ · {g.name || g.mmsi} +
+ ))} +
클릭하여 선택/해제
+
+
+ ); + } + return null; + })()} + {/* 선단 목록 패널 */}
diff --git a/frontend/src/components/korea/HazardFacilityLayer.tsx b/frontend/src/components/korea/HazardFacilityLayer.tsx new file mode 100644 index 0000000..f4e4923 --- /dev/null +++ b/frontend/src/components/korea/HazardFacilityLayer.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { HAZARD_FACILITIES } from '../../data/hazardFacilities'; +import type { HazardFacility, HazardType } from '../../data/hazardFacilities'; + +interface Props { + type: HazardType; +} + +const TYPE_META: Record = { + petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지', bgColor: 'rgba(249,115,22,0.15)' }, + lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지', bgColor: 'rgba(6,182,212,0.15)' }, + oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크', bgColor: 'rgba(234,179,8,0.15)' }, + hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물하역시설', bgColor: 'rgba(239,68,68,0.15)' }, + nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소', bgColor: 'rgba(168,85,247,0.15)' }, + thermal: { icon: '🔥', color: '#64748b', label: '화력발전소', bgColor: 'rgba(100,116,139,0.15)' }, + shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소 도장시설', bgColor: 'rgba(14,165,233,0.15)' }, + wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장', bgColor: 'rgba(16,185,129,0.15)' }, + heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소', bgColor: 'rgba(148,163,184,0.15)' }, +}; + +export function HazardFacilityLayer({ type }: Props) { + const [selected, setSelected] = useState(null); + const meta = TYPE_META[type]; + const facilities = HAZARD_FACILITIES.filter(f => f.type === type); + + return ( + <> + {facilities.map(f => ( + { e.originalEvent.stopPropagation(); setSelected(f); }}> +
+
+ {meta.icon} +
+
+ {f.nameKo.length > 12 ? f.nameKo.slice(0, 12) + '..' : f.nameKo} +
+
+
+ ))} + + {selected && ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup" + > +
+
+ {meta.icon} + {selected.nameKo} +
+ +
+ + {meta.label} + + + ⚠️ 위험시설 + +
+ +
+ {selected.description} +
+ +
+ {selected.address && ( +
주소 : {selected.address}
+ )} + {selected.operator && ( +
운영자 : {selected.operator}
+ )} + {selected.capacity && ( +
처리규모 : {selected.capacity}
+ )} +
시설명(EN) : {selected.name}
+
+ +
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/korea/JpFacilityLayer.tsx b/frontend/src/components/korea/JpFacilityLayer.tsx new file mode 100644 index 0000000..0725ec0 --- /dev/null +++ b/frontend/src/components/korea/JpFacilityLayer.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES, type JpFacility } from '../../data/jpFacilities'; + +interface Props { + type: 'power' | 'military'; +} + +const SUBTYPE_META: Record = { + nuclear: { color: '#a855f7', icon: '☢', label: '핵발전' }, + thermal: { color: '#f97316', icon: '⚡', label: '화력발전' }, + naval: { color: '#3b82f6', icon: '⚓', label: '해군기지' }, + airbase: { color: '#22d3ee', icon: '✈', label: '공군기지' }, + army: { color: '#ef4444', icon: '★', label: '육군' }, +}; + +export function JpFacilityLayer({ type }: Props) { + const [popup, setPopup] = useState(null); + const facilities = type === 'power' ? JP_POWER_PLANTS : JP_MILITARY_FACILITIES; + + return ( + <> + {facilities.map(f => { + const meta = SUBTYPE_META[f.subType]; + return ( + { e.originalEvent.stopPropagation(); setPopup(f); }} + > +
+ {meta.icon} +
+
+ ); + })} + + {popup && ( + setPopup(null)} + closeOnClick={false} + maxWidth="220px" + > +
+
{popup.name}
+
+ {SUBTYPE_META[popup.subType].label} +
+ {popup.operator && ( +
운영: {popup.operator}
+ )} + {popup.description && ( +
{popup.description}
+ )} +
+
+ )} + + ); +} diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index d459fc5..583d736 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -20,9 +20,10 @@ import { EezLayer } from './EezLayer'; // PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer, // NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨 import { ChineseFishingOverlay } from './ChineseFishingOverlay'; +// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨 import { AnalysisOverlay } from './AnalysisOverlay'; import { FleetClusterLayer } from './FleetClusterLayer'; -import type { SelectedGearGroupData } from './FleetClusterLayer'; +import type { SelectedGearGroupData, SelectedFleetData } from './FleetClusterLayer'; import { FishingZoneLayer } from './FishingZoneLayer'; import { AnalysisStatsPanel } from './AnalysisStatsPanel'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; @@ -138,6 +139,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState(null); const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null); const [selectedGearData, setSelectedGearData] = useState(null); + const [selectedFleetData, setSelectedFleetData] = useState(null); const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM); const [staticPickInfo, setStaticPickInfo] = useState(null); @@ -273,6 +275,21 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF piracy: layers.piracy ?? false, infra: layers.infra ?? false, infraFacilities: infra, + hazardTypes: [ + ...(layers.hazardPetrochemical ? ['petrochemical' as const] : []), + ...(layers.hazardLng ? ['lng' as const] : []), + ...(layers.hazardOilTank ? ['oilTank' as const] : []), + ...(layers.hazardPort ? ['hazardPort' as const] : []), + ...(layers.energyNuclear ? ['nuclear' as const] : []), + ...(layers.energyThermal ? ['thermal' as const] : []), + ...(layers.industryShipyard ? ['shipyard' as const] : []), + ...(layers.industryWastewater ? ['wastewater' as const] : []), + ...(layers.industryHeavy ? ['heavyIndustry' as const] : []), + ], + cnPower: !!layers.cnPower, + cnMilitary: !!layers.cnMilitary, + jpPower: !!layers.jpPower, + jpMilitary: !!layers.jpMilitary, onPick: (info) => setStaticPickInfo(info), sizeScale: zoomScale, }); @@ -353,6 +370,88 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF return layers; }, [selectedGearData, zoomScale]); + // 선택된 선단 소속 선박 강조 레이어 (deck.gl) + const selectedFleetLayers = useMemo(() => { + if (!selectedFleetData) return []; + const { ships: fleetShips, clusterId } = selectedFleetData; + if (fleetShips.length === 0) return []; + + // HSL→RGB 인라인 변환 (선단 색상) + const hue = (clusterId * 137) % 360; + const h = hue / 360; const s = 0.7; const l = 0.6; + const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1; if (t > 1) t -= 1; return t < 1/6 ? p + (q-p)*6*t : t < 1/2 ? q : t < 2/3 ? p + (q-p)*(2/3-t)*6 : p; }; + const q = l < 0.5 ? l * (1+s) : l + s - l*s; const p = 2*l - q; + const r = Math.round(hue2rgb(p, q, h + 1/3) * 255); + const g = Math.round(hue2rgb(p, q, h) * 255); + const b = Math.round(hue2rgb(p, q, h - 1/3) * 255); + const color: [number, number, number, number] = [r, g, b, 255]; + const fillColor: [number, number, number, number] = [r, g, b, 80]; + + const result: Layer[] = []; + + // 소속 선박 — 강조 원형 + result.push(new ScatterplotLayer({ + id: 'selected-fleet-items', + data: fleetShips, + getPosition: (d: Ship) => [d.lng, d.lat], + getRadius: 8 * zoomScale, + getFillColor: fillColor, + getLineColor: color, + stroked: true, + filled: true, + radiusUnits: 'pixels', + lineWidthUnits: 'pixels', + getLineWidth: 2, + })); + + // 소속 선박 이름 라벨 + result.push(new TextLayer({ + id: 'selected-fleet-labels', + data: fleetShips, + getPosition: (d: Ship) => [d.lng, d.lat], + getText: (d: Ship) => { + const dto = vesselAnalysis?.analysisMap.get(d.mmsi); + const role = dto?.algorithms.fleetRole.role; + const prefix = role === 'LEADER' ? '★ ' : ''; + return `${prefix}${d.name || d.mmsi}`; + }, + getSize: 9 * zoomScale, + getColor: color, + getTextAnchor: 'middle' as const, + getAlignmentBaseline: 'top' as const, + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 220], + billboard: false, + characterSet: 'auto', + })); + + // 리더 선박 추가 강조 (큰 외곽 링) + const leaders = fleetShips.filter(s => { + const dto = vesselAnalysis?.analysisMap.get(s.mmsi); + return dto?.algorithms.fleetRole.isLeader; + }); + if (leaders.length > 0) { + result.push(new ScatterplotLayer({ + id: 'selected-fleet-leaders', + data: leaders, + getPosition: (d: Ship) => [d.lng, d.lat], + getRadius: 16 * zoomScale, + getFillColor: [0, 0, 0, 0], + getLineColor: color, + stroked: true, + filled: false, + radiusUnits: 'pixels', + lineWidthUnits: 'pixels', + getLineWidth: 3, + })); + } + + return result; + }, [selectedFleetData, zoomScale, vesselAnalysis]); + // 분석 결과 deck.gl 레이어 const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing' : koreaFilters.darkVessel ? 'darkVessel' @@ -502,6 +601,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */} {koreaFilters.illegalFishing && } {layers.cnFishing && } + {/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */} {layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && ( )} {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( @@ -528,6 +629,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF illegalFishingLabelLayer, zoneLabelsLayer, ...selectedGearLayers, + ...selectedFleetLayers, ...analysisDeckLayers, ].filter(Boolean)} /> {/* 정적 마커 클릭 Popup */} @@ -552,6 +654,12 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {obj.range &&
사거리: {obj.range}
} {obj.operator &&
운영: {obj.operator}
} {obj.capacity &&
용량: {obj.capacity}
} + {staticPickInfo.kind === 'hazard' && obj.address && ( +
📍 {obj.address}
+ )} + {(staticPickInfo.kind === 'cnFacility' || staticPickInfo.kind === 'jpFacility') && obj.subType && ( +
유형: {obj.subType}
+ )}
); diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 10b0164..664ee33 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -526,16 +526,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM return ( <> - {/* Hovered ship highlight ring */} + {/* Hovered ship highlight ring — feature-state는 paint에서만 지원 */} diff --git a/frontend/src/data/cnFacilities.ts b/frontend/src/data/cnFacilities.ts new file mode 100644 index 0000000..c7b9dec --- /dev/null +++ b/frontend/src/data/cnFacilities.ts @@ -0,0 +1,141 @@ +export interface CnFacility { + id: string; + name: string; + subType: 'nuclear' | 'thermal' | 'naval' | 'airbase' | 'army' | 'shipyard'; + lat: number; + lng: number; + operator?: string; + description?: string; +} + +export const CN_POWER_PLANTS: CnFacility[] = [ + { + id: 'cn-npp-hongyanhe', + name: '홍옌허(红沿河) 핵발전소', + subType: 'nuclear', + lat: 40.87, + lng: 121.02, + operator: '中国大唐集团', + description: '가압경수로 6기, 라오닝성 — 한반도 최근접 핵발전소', + }, + { + id: 'cn-npp-tianwan', + name: '톈완(田湾) 핵발전소', + subType: 'nuclear', + lat: 34.71, + lng: 119.45, + operator: '江苏核电', + description: '러시아 VVER-1000 설계, 장쑤성', + }, + { + id: 'cn-npp-qinshan', + name: '진산(秦山) 핵발전소', + subType: 'nuclear', + lat: 30.44, + lng: 120.96, + operator: '中核集团', + description: '중국 최초 상업 핵발전소, 저장성', + }, + { + id: 'cn-npp-ningde', + name: '닝더(宁德) 핵발전소', + subType: 'nuclear', + lat: 26.73, + lng: 120.12, + operator: '中国大唐集团', + description: '가압경수로 4기, 푸젠성', + }, + { + id: 'cn-thermal-dalian', + name: '다롄 화력발전소', + subType: 'thermal', + lat: 38.85, + lng: 121.55, + operator: '大连电力', + description: '석탄화력, 라오닝성', + }, + { + id: 'cn-thermal-qinhuangdao', + name: '친황다오 화력발전소', + subType: 'thermal', + lat: 39.93, + lng: 119.58, + operator: '华能国际', + description: '석탄화력 대형 기지, 허베이성', + }, + { + id: 'cn-thermal-tianjin', + name: '톈진 화력발전소', + subType: 'thermal', + lat: 39.08, + lng: 117.20, + operator: '华能集团', + description: '석탄화력, 톈진시', + }, +]; + +export const CN_MILITARY_FACILITIES: CnFacility[] = [ + { + id: 'cn-mil-qingdao', + name: '칭다오 해군기지', + subType: 'naval', + lat: 36.07, + lng: 120.26, + operator: '해군 북부전구', + description: '항모전단 모항, 핵잠수함 기지', + }, + { + id: 'cn-mil-lushun', + name: '뤼순(旅順) 해군기지', + subType: 'naval', + lat: 38.85, + lng: 121.24, + operator: '해군 북부전구', + description: '잠수함·구축함 기지, 보하이만 입구', + }, + { + id: 'cn-mil-dalian-shipyard', + name: '다롄 조선소 (항모건조)', + subType: 'shipyard', + lat: 38.92, + lng: 121.62, + operator: '中国船舶重工', + description: '랴오닝함·산둥함 건조, 항모 4번함 건조 중', + }, + { + id: 'cn-mil-shenyang-cmd', + name: '북부전구 사령부', + subType: 'army', + lat: 41.80, + lng: 123.42, + operator: '해방군 북부전구', + description: '한반도·동북아 담당, 선양시', + }, + { + id: 'cn-mil-shenyang-air', + name: '선양 공군기지', + subType: 'airbase', + lat: 41.77, + lng: 123.49, + operator: '공군 북부전구', + description: 'J-16 전투기 배치, 북부전구 핵심기지', + }, + { + id: 'cn-mil-dandong', + name: '단둥 군사시설', + subType: 'army', + lat: 40.13, + lng: 124.38, + operator: '해방군 육군', + description: '북중 접경 전진기지, 한반도 작전 담당', + }, + { + id: 'cn-mil-zhoushan', + name: '저우산 해군기지', + subType: 'naval', + lat: 30.00, + lng: 122.10, + operator: '해군 동부전구', + description: '동중국해 주력 함대 기지', + }, +]; diff --git a/frontend/src/data/hazardFacilities.ts b/frontend/src/data/hazardFacilities.ts new file mode 100644 index 0000000..45ca368 --- /dev/null +++ b/frontend/src/data/hazardFacilities.ts @@ -0,0 +1,520 @@ +export type HazardType = 'petrochemical' | 'lng' | 'oilTank' | 'hazardPort' | 'nuclear' | 'thermal' | 'shipyard' | 'wastewater' | 'heavyIndustry'; + +export interface HazardFacility { + id: string; + type: HazardType; + nameKo: string; + name: string; + lat: number; + lng: number; + address?: string; + capacity?: string; + operator?: string; + description: string; +} + +export const HAZARD_FACILITIES: HazardFacility[] = [ + // ── 해안인접석유화학단지 ────────────────────────────────────────── + { + id: 'pc-01', type: 'petrochemical', + nameKo: '여수국가산업단지', name: 'Yeosu National Industrial Complex', + lat: 34.757, lng: 127.723, + address: '전남 여수시 화치동 산 183-1', + capacity: '연산 2,400만 톤', operator: '여수광양항만공사·LG화학·롯데케미칼', + description: '국내 최대 석유화학단지. NCC·LG화학·롯데케미칼·GS칼텍스 등 입주.', + }, + { + id: 'pc-02', type: 'petrochemical', + nameKo: '울산미포국가산업단지', name: 'Ulsan Mipo National Industrial Complex', + lat: 35.479, lng: 129.357, + address: '울산광역시 남구 사평로 137 (부곡동 439-1)', + capacity: '연산 1,800만 톤', operator: 'S-OIL·SK에너지·SK지오센트릭', + description: '정유·NCC 중심 울산미포국가산단 내 석유화학 집적지.', + }, + { + id: 'pc-03', type: 'petrochemical', + nameKo: '대산석유화학단지', name: 'Daesan Petrochemical Complex', + lat: 37.025, lng: 126.360, + address: '충남 서산시 대산읍 독곶1로 82 (롯데케미칼 대산공장 기준)', + capacity: '연산 900만 톤', operator: '롯데케미칼·현대오일뱅크·한화토탈에너지스', + description: '충남 서산 대산항 인근 3대 석유화학단지.', + }, + { + id: 'pc-04', type: 'petrochemical', + nameKo: '광양 석유화학단지', name: 'Gwangyang Petrochemical Complex', + lat: 34.970, lng: 127.705, + capacity: '연산 600만 톤', operator: 'POSCO·포스코케미칼', + description: '광양제철소 연계 석유화학 시설.', + }, + { + id: 'pc-05', type: 'petrochemical', + nameKo: '인천 석유화학단지', name: 'Incheon Petrochemical Complex', + lat: 37.470, lng: 126.618, + capacity: '연산 400만 톤', operator: 'SK인천석유화학', + description: '인천 북항 인근 정유·석유화학 시설.', + }, + + // ── LNG 생산기지 (한국가스공사 KOGAS) ──────────────────────────── + { + id: 'lng-01', type: 'lng', + nameKo: '평택 LNG 생산기지', name: 'Pyeongtaek LNG Production Base', + lat: 37.017, lng: 126.870, + address: '경기도 평택시 포승읍', + operator: '한국가스공사(KOGAS)', + description: '국내 최초의 LNG 기지. 수도권 공급의 핵심 거점.', + }, + { + id: 'lng-02', type: 'lng', + nameKo: '인천 LNG 생산기지', name: 'Incheon LNG Production Base', + lat: 37.374, lng: 126.622, + address: '인천광역시 연수구 송도동', + operator: '한국가스공사(KOGAS)', + description: '세계 최대 규모의 해상 LNG 기지 중 하나.', + }, + { + id: 'lng-03', type: 'lng', + nameKo: '통영 LNG 생산기지', name: 'Tongyeong LNG Production Base', + lat: 34.906, lng: 128.465, + address: '경상남도 통영시 광도면', + operator: '한국가스공사(KOGAS)', + description: '남부권 가스 공급 및 영남권 산업단지 지원 거점.', + }, + { + id: 'lng-04', type: 'lng', + nameKo: '삼척 LNG 생산기지', name: 'Samcheok LNG Production Base', + lat: 37.262, lng: 129.290, + address: '강원도 삼척시 원덕읍', + operator: '한국가스공사(KOGAS)', + description: '동해안 에너지 거점 및 수입 다변화 대응.', + }, + { + id: 'lng-05', type: 'lng', + nameKo: '제주 LNG 생산기지', name: 'Jeju LNG Production Base', + lat: 33.448, lng: 126.330, + address: '제주특별자치도 제주시 애월읍', + operator: '한국가스공사(KOGAS)', + description: '제주 지역 천연가스 보급을 위해 조성된 기지.', + }, + { + id: 'lng-06', type: 'lng', + nameKo: '당진 LNG 생산기지', name: 'Dangjin LNG Production Base', + lat: 37.048, lng: 126.595, + address: '충청남도 당진시 석문면', + operator: '한국가스공사(KOGAS)', + description: '2026년 말 1단계 준공 예정 (현재 건설 중).', + }, + + // ── 민간 LNG 터미널 ────────────────────────────────────────────── + { + id: 'lng-p01', type: 'lng', + nameKo: '광양 LNG 터미널', name: 'Gwangyang LNG Terminal', + lat: 34.934, lng: 127.714, + address: '전라남도 광양시 금호동', + operator: '포스코인터내셔널', + description: '포스코인터내셔널 운영 민간 LNG 터미널.', + }, + { + id: 'lng-p02', type: 'lng', + nameKo: '보령 LNG 터미널', name: 'Boryeong LNG Terminal', + lat: 36.380, lng: 126.513, + address: '충청남도 보령시 오천면', + operator: 'SK E&S · GS에너지', + description: 'SK E&S·GS에너지 공동 운영 민간 LNG 터미널.', + }, + { + id: 'lng-p03', type: 'lng', + nameKo: '울산 북항 에너지터미널', name: 'Ulsan North Port Energy Terminal', + lat: 35.518, lng: 129.383, + address: '울산광역시 남구 북항 일원', + operator: 'KET (한국석유공사·SK Gas 등)', + description: 'KET(Korea Energy Terminal) 운영 민간 에너지터미널.', + }, + { + id: 'lng-p04', type: 'lng', + nameKo: '통영 에코파워 LNG', name: 'Tongyeong Ecopower LNG Terminal', + lat: 34.873, lng: 128.508, + address: '경상남도 통영시 광도면 (성동조선 인근)', + operator: 'HDC현대산업개발 등', + description: '성동조선 인근 민간 LNG 터미널.', + }, + + // ── 유류저장탱크 ────────────────────────────────────────────────── + { + id: 'oil-01', type: 'oilTank', + nameKo: '여수 유류저장시설', name: 'Yeosu Oil Storage', + lat: 34.733, lng: 127.741, + capacity: '630만 ㎘', operator: 'SK에너지·GS칼텍스', + description: '여수항 인근 정유제품 및 원유 저장시설.', + }, + { + id: 'oil-02', type: 'oilTank', + nameKo: '울산 정유 저장시설', name: 'Ulsan Refinery Storage', + lat: 35.516, lng: 129.413, + capacity: '850만 ㎘', operator: 'S-OIL·SK에너지', + description: '울산 온산 정유시설 연계 대형 유류탱크군.', + }, + { + id: 'oil-03', type: 'oilTank', + nameKo: '포항 저유소', name: 'Pohang Oil Depot', + lat: 36.018, lng: 129.380, + capacity: '20만 ㎘', operator: '대한송유관공사', + description: '동해안 석유 공급 거점 저유소.', + }, + { + id: 'oil-04', type: 'oilTank', + nameKo: '목포 유류저장', name: 'Mokpo Oil Storage', + lat: 34.773, lng: 126.384, + capacity: '30만 ㎘', operator: '한국석유공사', + description: '서남해안 유류 공급 저장기지.', + }, + { + id: 'oil-05', type: 'oilTank', + nameKo: '부산 북항 저유소', name: 'Busan North Port Oil Depot', + lat: 35.100, lng: 129.041, + capacity: '45만 ㎘', operator: '대한송유관공사', + description: '부산항 연계 유류 저장·공급 시설.', + }, + { + id: 'oil-06', type: 'oilTank', + nameKo: '보령 저유소', name: 'Boryeong Oil Depot', + lat: 36.380, lng: 126.570, + capacity: '15만 ㎘', operator: '대한송유관공사', + description: '충남 서해안 유류 공급 저장기지.', + }, + + // ── KNOC 국가 석유비축기지 ──────────────────────────────────────── + { + id: 'knoc-01', type: 'oilTank', + nameKo: 'KNOC 울산 비축기지', name: 'KNOC Ulsan SPR Base', + lat: 35.406, lng: 129.351, + address: '울산광역시 울주군 온산읍 학남리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 원유 (지상탱크) 방식.', + }, + { + id: 'knoc-02', type: 'oilTank', + nameKo: 'KNOC 여수 비축기지', name: 'KNOC Yeosu SPR Base', + lat: 34.716, lng: 127.742, + address: '전라남도 여수시 낙포동', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 원유 (지상탱크·지하공동) 방식.', + }, + { + id: 'knoc-03', type: 'oilTank', + nameKo: 'KNOC 거제 비축기지', name: 'KNOC Geoje SPR Base', + lat: 34.852, lng: 128.722, + address: '경상남도 거제시 일운면 지세포리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 원유 (지하공동) 방식.', + }, + { + id: 'knoc-04', type: 'oilTank', + nameKo: 'KNOC 서산 비축기지', name: 'KNOC Seosan SPR Base', + lat: 37.018, lng: 126.374, + address: '충청남도 서산시 대산읍 대죽리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 원유·제품 (지상탱크) 방식.', + }, + { + id: 'knoc-05', type: 'oilTank', + nameKo: 'KNOC 평택 비축기지', name: 'KNOC Pyeongtaek SPR Base', + lat: 37.017, lng: 126.858, + address: '경기도 평택시 포승읍 원정리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. LPG 방식.', + }, + { + id: 'knoc-06', type: 'oilTank', + nameKo: 'KNOC 구리 비축기지', name: 'KNOC Guri SPR Base', + lat: 37.562, lng: 127.138, + address: '경기도 구리시 아차산로', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 제품 (지하공동) 방식.', + }, + { + id: 'knoc-07', type: 'oilTank', + nameKo: 'KNOC 용인 비축기지', name: 'KNOC Yongin SPR Base', + lat: 37.238, lng: 127.213, + address: '경기도 용인시 처인구 해실로', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 제품 (지상탱크) 방식.', + }, + { + id: 'knoc-08', type: 'oilTank', + nameKo: 'KNOC 동해 비축기지', name: 'KNOC Donghae SPR Base', + lat: 37.503, lng: 129.097, + address: '강원특별자치도 동해시 공단12로', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 제품 (지상탱크) 방식.', + }, + { + id: 'knoc-09', type: 'oilTank', + nameKo: 'KNOC 곡성 비축기지', name: 'KNOC Gokseong SPR Base', + lat: 35.228, lng: 127.302, + address: '전라남도 곡성군 겸면 괴정리', + capacity: '국가비축', operator: '한국석유공사(KNOC)', + description: '국가 전략 석유비축기지. 제품 (지상탱크) 방식.', + }, + + // ── 위험물항만하역시설 ──────────────────────────────────────────── + { + id: 'hp-01', type: 'hazardPort', + nameKo: '광양항 위험물 부두', name: 'Gwangyang Hazardous Cargo Terminal', + lat: 34.923, lng: 127.703, + capacity: '연 3,000만 톤', operator: '여수광양항만공사', + description: '석유화학제품·액체화물 전용 위험물 하역 부두.', + }, + { + id: 'hp-02', type: 'hazardPort', + nameKo: '울산항 위험물 부두', name: 'Ulsan Hazardous Cargo Terminal', + lat: 35.519, lng: 129.392, + capacity: '연 2,500만 톤', operator: '울산항만공사', + description: '원유·석유제품·LPG 등 위험물 전용 하역 부두.', + }, + { + id: 'hp-03', type: 'hazardPort', + nameKo: '인천항 위험물 부두', name: 'Incheon Hazardous Cargo Terminal', + lat: 37.464, lng: 126.621, + capacity: '연 800만 톤', operator: '인천항만공사', + description: '인천 북항 위험물(화학·가스·유류) 하역 전용 부두.', + }, + { + id: 'hp-04', type: 'hazardPort', + nameKo: '여수항 위험물 부두', name: 'Yeosu Hazardous Cargo Terminal', + lat: 34.729, lng: 127.741, + capacity: '연 1,200만 톤', operator: '여수광양항만공사', + description: '여수 석유화학단지 연계 위험물 하역 부두.', + }, + { + id: 'hp-05', type: 'hazardPort', + nameKo: '부산항 위험물 부두', name: 'Busan Hazardous Cargo Terminal', + lat: 35.090, lng: 129.022, + capacity: '연 500만 톤', operator: '부산항만공사', + description: '부산 신항·북항 위험물 전용 하역 부두.', + }, + { + id: 'hp-06', type: 'hazardPort', + nameKo: '군산항 위험물 부두', name: 'Gunsan Hazardous Cargo Terminal', + lat: 35.973, lng: 126.712, + capacity: '연 300만 톤', operator: '군산항만공사', + description: '서해안 위험물(석유·화학) 하역 부두.', + }, + + // ── 원자력발전소 ────────────────────────────────────────────────── + { + id: 'npp-01', type: 'nuclear', + nameKo: '고리 원자력발전소', name: 'Kori Nuclear Power Plant', + lat: 35.316, lng: 129.291, + address: '부산광역시 기장군 장안읍 고리', + capacity: '4기 (신고리 포함 총 6기)', operator: '한국수력원자력(한수원)', + description: '국내 최초 상업용 원전 부지. 1호기 영구정지(2017), 신고리 1~4호기 운영 중.', + }, + { + id: 'npp-02', type: 'nuclear', + nameKo: '월성 원자력발전소', name: 'Wolseong Nuclear Power Plant', + lat: 35.712, lng: 129.476, + address: '경상북도 경주시 양남면 나아리', + capacity: '4기 (월성·신월성)', operator: '한국수력원자력(한수원)', + description: '중수로(CANDU) 방식. 월성 1호기 영구정지(2019), 신월성 1·2호기 운영 중.', + }, + { + id: 'npp-03', type: 'nuclear', + nameKo: '한울 원자력발전소', name: 'Hanul Nuclear Power Plant', + lat: 37.093, lng: 129.381, + address: '경상북도 울진군 북면 부구리', + capacity: '6기 운영 + 신한울 2기', operator: '한국수력원자력(한수원)', + description: '구 울진 원전. 한울 1~6호기 + 신한울 1·2호기(2022~2024 준공).', + }, + { + id: 'npp-04', type: 'nuclear', + nameKo: '한빛 원자력발전소', name: 'Hanbit Nuclear Power Plant', + lat: 35.410, lng: 126.424, + address: '전라남도 영광군 홍농읍 계마리', + capacity: '6기 운영', operator: '한국수력원자력(한수원)', + description: '구 영광 원전. 한빛 1~6호기 운영 중. 국내 최대 용량 원전 부지.', + }, + { + id: 'npp-05', type: 'nuclear', + nameKo: '새울 원자력발전소', name: 'Saeul Nuclear Power Plant', + lat: 35.311, lng: 129.303, + address: '울산광역시 울주군 서생면 신암리', + capacity: '4기 (신고리 5~8호기)', operator: '한국수력원자력(한수원)', + description: '신고리 5·6호기 운영 중, 7·8호기 건설 예정. 고리 부지 인근.', + }, + + // ── 화력발전소 ──────────────────────────────────────────────────── + { + id: 'tp-01', type: 'thermal', + nameKo: '당진 화력발전소', name: 'Dangjin Thermal Power Plant', + lat: 37.048, lng: 126.598, + address: '충청남도 당진시 석문면 교로리', + capacity: '6,040MW (10기)', operator: '한국동서발전(EWP)', + description: '국내 최대 규모 석탄 화력발전소.', + }, + { + id: 'tp-02', type: 'thermal', + nameKo: '태안 화력발전소', name: 'Taean Thermal Power Plant', + lat: 36.849, lng: 126.232, + address: '충청남도 태안군 원북면 방갈리', + capacity: '6,100MW (10기)', operator: '한국서부발전(WPP)', + description: '서해안 최대 규모 석탄 화력발전소.', + }, + { + id: 'tp-03', type: 'thermal', + nameKo: '삼척 화력발전소', name: 'Samcheok Thermal Power Plant', + lat: 37.243, lng: 129.326, + address: '강원특별자치도 삼척시 근덕면 초곡리', + capacity: '2,100MW (2기)', operator: '삼척블루파워(포스코에너지·GS에너지)', + description: '동해안 민자 석탄 화력발전소. 2022년 준공.', + }, + { + id: 'tp-04', type: 'thermal', + nameKo: '여수 화력발전소', name: 'Yeosu Thermal Power Plant', + lat: 34.738, lng: 127.721, + address: '전라남도 여수시 낙포동', + capacity: '870MW', operator: 'GS E&R', + description: '여수 석유화학단지 인근 열병합 발전소.', + }, + { + id: 'tp-05', type: 'thermal', + nameKo: '하동 화력발전소', name: 'Hadong Thermal Power Plant', + lat: 34.977, lng: 127.901, + address: '경상남도 하동군 금성면 갈사리', + capacity: '4,000MW (8기)', operator: '한국남부발전(KOSPO)', + description: '남해안 주요 석탄 화력발전소.', + }, + + // ── 조선소 도장시설 ─────────────────────────────────────────────── + { + id: 'sy-01', type: 'shipyard', + nameKo: '한화오션 거제조선소', name: 'Hanwha Ocean Geoje Shipyard', + lat: 34.893, lng: 128.623, + address: '경상남도 거제시 아주동 1', + operator: '한화오션(구 대우조선해양)', + description: '초대형 선박·해양플랜트 도장시설. 유기용제·VOC 대량 취급.', + }, + { + id: 'sy-02', type: 'shipyard', + nameKo: 'HD현대중공업 울산조선소', name: 'HD Hyundai Heavy Industries Ulsan Shipyard', + lat: 35.508, lng: 129.421, + address: '울산광역시 동구 방어진순환도로 1000', + operator: 'HD현대중공업', + description: '세계 최대 단일 조선소. 도크 10기, 도장시설·VOC 취급.', + }, + { + id: 'sy-03', type: 'shipyard', + nameKo: '삼성중공업 거제조선소', name: 'Samsung Heavy Industries Geoje Shipyard', + lat: 34.847, lng: 128.682, + address: '경상남도 거제시 장평동 530', + operator: '삼성중공업', + description: 'LNG 운반선·FPSO 전문 조선소. 도장·도막 처리시설.', + }, + { + id: 'sy-04', type: 'shipyard', + nameKo: 'HD현대미포조선 울산', name: 'HD Hyundai Mipo Dockyard Ulsan', + lat: 35.479, lng: 129.407, + address: '울산광역시 동구 화정동', + operator: 'HD현대미포조선', + description: '중형 선박 전문 조선소. 도장시설 다수.', + }, + { + id: 'sy-05', type: 'shipyard', + nameKo: 'HD현대삼호 영암조선소', name: 'HD Hyundai Samho Yeongam Shipyard', + lat: 34.746, lng: 126.459, + address: '전라남도 영암군 삼호읍 용당리', + operator: 'HD현대삼호중공업', + description: '서남해안 대형 조선소. 유기용제·도장 화학물질 취급.', + }, + { + id: 'sy-06', type: 'shipyard', + nameKo: 'HJ중공업 부산조선소', name: 'HJ Shipbuilding Busan Shipyard', + lat: 35.048, lng: 128.978, + address: '부산광역시 영도구 해양로 195', + operator: 'HJ중공업(구 한진중공업)', + description: '부산 영도 소재 조선소. 도장·표면처리 시설.', + }, + + // ── 폐수/하수처리장 ─────────────────────────────────────────────── + { + id: 'ww-01', type: 'wastewater', + nameKo: '여수 국가산단 폐수처리장', name: 'Yeosu Industrial Wastewater Treatment', + lat: 34.748, lng: 127.730, + address: '전라남도 여수시 화치동', + operator: '여수시·환경부', + description: '여수국가산단 배후 산업폐수처리장. 황화수소·메탄 발생 가능.', + }, + { + id: 'ww-02', type: 'wastewater', + nameKo: '울산 온산공단 폐수처리장', name: 'Ulsan Onsan Industrial Wastewater Treatment', + lat: 35.413, lng: 129.338, + address: '울산광역시 울주군 온산읍', + operator: '울산시·환경부', + description: '온산국가산업단지 배후 폐수처리 거점. 유해가스 발생 위험.', + }, + { + id: 'ww-03', type: 'wastewater', + nameKo: '대산공단 폐수처리장', name: 'Daesan Industrial Wastewater Treatment', + lat: 37.023, lng: 126.348, + address: '충청남도 서산시 대산읍', + operator: '서산시·환경부', + description: '대산석유화학단지 배후 폐수처리장. H₂S·메탄 발생 위험.', + }, + { + id: 'ww-04', type: 'wastewater', + nameKo: '인천 북항 항만폐수처리', name: 'Incheon North Port Wastewater Treatment', + lat: 37.468, lng: 126.618, + address: '인천광역시 중구 북성동', + operator: '인천항만공사·인천시', + description: '인천 북항 인접 항만 폐수처리 시설.', + }, + { + id: 'ww-05', type: 'wastewater', + nameKo: '광양 임해 폐수처리장', name: 'Gwangyang Coastal Wastewater Treatment', + lat: 34.930, lng: 127.696, + address: '전라남도 광양시 금호동', + operator: '광양시·포스코', + description: '광양제철소·산단 배후 폐수처리 시설. 황화수소 발생 위험.', + }, + + // ── 시멘트/제철소/원료저장시설 ──────────────────────────────────── + { + id: 'hi-01', type: 'heavyIndustry', + nameKo: 'POSCO 포항제철소', name: 'POSCO Pohang Steelworks', + lat: 36.027, lng: 129.358, + address: '경상북도 포항시 남구 동해안로 6261', + capacity: '1,800만 톤/년', operator: 'POSCO', + description: '국내 최대 제철소. 고로·코크스 원료 대량 저장·처리.', + }, + { + id: 'hi-02', type: 'heavyIndustry', + nameKo: 'POSCO 광양제철소', name: 'POSCO Gwangyang Steelworks', + lat: 34.932, lng: 127.702, + address: '전라남도 광양시 금호동 700', + capacity: '2,100만 톤/년', operator: 'POSCO', + description: '세계 최대 규모 제철소 중 하나. 임해 원료 저장기지.', + }, + { + id: 'hi-03', type: 'heavyIndustry', + nameKo: '현대제철 당진공장', name: 'Hyundai Steel Dangjin Plant', + lat: 37.046, lng: 126.616, + address: '충청남도 당진시 송악읍 복운리', + capacity: '1,200만 톤/년', operator: '현대제철', + description: '당진 임해 제철소. 철광석·석탄 원료저장 부두 인접.', + }, + { + id: 'hi-04', type: 'heavyIndustry', + nameKo: '삼척 시멘트 공단', name: 'Samcheok Cement Industrial Complex', + lat: 37.480, lng: 129.130, + address: '강원특별자치도 삼척시 동해대로', + operator: '쌍용C&E·성신양회', + description: '삼척 임해 시멘트 단지. 분진·원료저장시설 밀집.', + }, + { + id: 'hi-05', type: 'heavyIndustry', + nameKo: '동해 시멘트/석회공장', name: 'Donghae Cement Complex', + lat: 37.501, lng: 129.103, + address: '강원특별자치도 동해시 북평공단', + operator: '한일시멘트·아세아시멘트', + description: '동해항 인근 시멘트·석회 생산·원료저장시설.', + }, +]; diff --git a/frontend/src/data/jpFacilities.ts b/frontend/src/data/jpFacilities.ts new file mode 100644 index 0000000..e7d4f60 --- /dev/null +++ b/frontend/src/data/jpFacilities.ts @@ -0,0 +1,150 @@ +export interface JpFacility { + id: string; + name: string; + subType: 'nuclear' | 'thermal' | 'naval' | 'airbase' | 'army'; + lat: number; + lng: number; + operator?: string; + description?: string; +} + +export const JP_POWER_PLANTS: JpFacility[] = [ + { + id: 'jp-npp-genkai', + name: '겐카이(玄海) 핵발전소', + subType: 'nuclear', + lat: 33.52, + lng: 129.84, + operator: '규슈전력', + description: '가압경수로 4기, 사가현 — 한반도 최근접 원전', + }, + { + id: 'jp-npp-sendai', + name: '센다이(川内) 핵발전소', + subType: 'nuclear', + lat: 31.84, + lng: 130.19, + operator: '규슈전력', + description: '가압경수로 2기, 가고시마현', + }, + { + id: 'jp-npp-ohi', + name: '오이(大飯) 핵발전소', + subType: 'nuclear', + lat: 35.53, + lng: 135.65, + operator: '간사이전력', + description: '가압경수로 4기, 후쿠이현 — 일본 최대 출력', + }, + { + id: 'jp-npp-takahama', + name: '다카하마(高浜) 핵발전소', + subType: 'nuclear', + lat: 35.51, + lng: 135.50, + operator: '간사이전력', + description: '가압경수로 4기, 후쿠이현', + }, + { + id: 'jp-npp-shika', + name: '시카(志賀) 핵발전소', + subType: 'nuclear', + lat: 37.07, + lng: 136.72, + operator: '호쿠리쿠전력', + description: '비등수형경수로 2기, 이시카와현 (2024 지진 피해)', + }, + { + id: 'jp-npp-higashidori', + name: '히가시도리(東通) 핵발전소', + subType: 'nuclear', + lat: 41.18, + lng: 141.37, + operator: '도호쿠전력', + description: '비등수형경수로, 아오모리현', + }, + { + id: 'jp-thermal-matsuura', + name: '마쓰우라(松浦) 화력발전소', + subType: 'thermal', + lat: 33.33, + lng: 129.73, + operator: '전원개발(J-Power)', + description: '석탄화력, 나가사키현 — 대한해협 인접', + }, + { + id: 'jp-thermal-hekinan', + name: '헤키난(碧南) 화력발전소', + subType: 'thermal', + lat: 34.87, + lng: 136.95, + operator: '주부전력', + description: '석탄화력, 아이치현 — 일본 최대 석탄화력', + }, +]; + +export const JP_MILITARY_FACILITIES: JpFacility[] = [ + { + id: 'jp-mil-sasebo', + name: '사세보(佐世保) 해군기지', + subType: 'naval', + lat: 33.16, + lng: 129.72, + operator: '미 해군 / 해상자위대', + description: '미 7함대 상륙전단 모항, 한국 최근접 미군기지', + }, + { + id: 'jp-mil-maizuru', + name: '마이즈루(舞鶴) 해군기지', + subType: 'naval', + lat: 35.47, + lng: 135.38, + operator: '해상자위대', + description: '동해 방면 주력기지, 호위함대 사령부', + }, + { + id: 'jp-mil-yokosuka', + name: '요코스카(横須賀) 해군기지', + subType: 'naval', + lat: 35.29, + lng: 139.67, + operator: '미 해군 / 해상자위대', + description: '미 7함대 사령부, 항모 로널드 레이건 모항', + }, + { + id: 'jp-mil-iwakuni', + name: '이와쿠니(岩国) 공군기지', + subType: 'airbase', + lat: 34.15, + lng: 132.24, + operator: '미 해병대 / 항공자위대', + description: 'F/A-18 및 F-35B 배치, 야마구치현', + }, + { + id: 'jp-mil-kadena', + name: '가데나(嘉手納) 공군기지', + subType: 'airbase', + lat: 26.36, + lng: 127.77, + operator: '미 공군', + description: 'F-15C/D, KC-135 배치, 아시아 최대 미 공군기지', + }, + { + id: 'jp-mil-ashiya', + name: '아시야(芦屋) 항공기지', + subType: 'airbase', + lat: 33.88, + lng: 130.66, + operator: '항공자위대', + description: '대한해협 인접, 후쿠오카현', + }, + { + id: 'jp-mil-naha', + name: '나하(那覇) 항공기지', + subType: 'airbase', + lat: 26.21, + lng: 127.65, + operator: '항공자위대', + description: 'F-15 배치, 남서항공방면대 사령부', + }, +]; diff --git a/frontend/src/hooks/useStaticDeckLayers.ts b/frontend/src/hooks/useStaticDeckLayers.ts index 7907371..7a9a6fe 100644 --- a/frontend/src/hooks/useStaticDeckLayers.ts +++ b/frontend/src/hooks/useStaticDeckLayers.ts @@ -25,6 +25,12 @@ import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWa import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../services/piracy'; import type { PiracyZone } from '../services/piracy'; import type { PowerFacility } from '../services/infra'; +import { HAZARD_FACILITIES } from '../data/hazardFacilities'; +import type { HazardFacility, HazardType } from '../data/hazardFacilities'; +import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../data/cnFacilities'; +import type { CnFacility } from '../data/cnFacilities'; +import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../data/jpFacilities'; +import type { JpFacility } from '../data/jpFacilities'; // ─── Type alias to avoid 'any' in PickingInfo ─────────────────────────────── @@ -39,7 +45,10 @@ export type StaticPickedObject = | KoreanAirport | NavWarning | PiracyZone - | PowerFacility; + | PowerFacility + | HazardFacility + | CnFacility + | JpFacility; export type StaticLayerKind = | 'port' @@ -52,7 +61,10 @@ export type StaticLayerKind = | 'airport' | 'navWarning' | 'piracy' - | 'infra'; + | 'infra' + | 'hazard' + | 'cnFacility' + | 'jpFacility'; export interface StaticPickInfo { kind: StaticLayerKind; @@ -72,6 +84,11 @@ interface StaticLayerConfig { piracy: boolean; infra: boolean; infraFacilities: PowerFacility[]; + hazardTypes: HazardType[]; + cnPower: boolean; + cnMilitary: boolean; + jpPower: boolean; + jpMilitary: boolean; onPick: (info: StaticPickInfo) => void; sizeScale?: number; // 줌 레벨별 스케일 배율 (기본 1.0) } @@ -866,6 +883,176 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { ); } + // ── Hazard Facilities ────────────────────────────────────────────────── + if (config.hazardTypes.length > 0) { + const hazardTypeSet = new Set(config.hazardTypes); + const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type)); + + const HAZARD_META: Record = { + petrochemical: { icon: '🏭', color: [249, 115, 22, 255] }, + lng: { icon: '🔵', color: [6, 182, 212, 255] }, + oilTank: { icon: '🛢️', color: [234, 179, 8, 255] }, + hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] }, + nuclear: { icon: '☢️', color: [168, 85, 247, 255] }, + thermal: { icon: '🔥', color: [100, 116, 139, 255] }, + shipyard: { icon: '🚢', color: [14, 165, 233, 255] }, + wastewater: { icon: '💧', color: [16, 185, 129, 255] }, + heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] }, + }; + + if (hazardData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-hazard-emoji', + data: hazardData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️', + getSize: 16 * sc, + getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'hazard', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-hazard-label', + data: hazardData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, + getSize: 9 * ss, + getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + // ── CN Facilities ────────────────────────────────────────────────────── + { + const CN_META: Record = { + nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, + thermal: { icon: '🔥', color: [249, 115, 22, 255] }, + naval: { icon: '⚓', color: [59, 130, 246, 255] }, + airbase: { icon: '✈️', color: [34, 211, 238, 255] }, + army: { icon: '🪖', color: [34, 197, 94, 255] }, + shipyard: { icon: '🚢', color: [148, 163, 184, 255] }, + }; + const cnData: CnFacility[] = [ + ...(config.cnPower ? CN_POWER_PLANTS : []), + ...(config.cnMilitary ? CN_MILITARY_FACILITIES : []), + ]; + if (cnData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-cn-emoji', + data: cnData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => CN_META[d.subType]?.icon ?? '📍', + getSize: 16 * ss, + getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'cnFacility', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-cn-label', + data: cnData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name, + getSize: 9 * ss, + getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + // ── JP Facilities ────────────────────────────────────────────────────── + { + const JP_META: Record = { + nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, + thermal: { icon: '🔥', color: [249, 115, 22, 255] }, + naval: { icon: '⚓', color: [59, 130, 246, 255] }, + airbase: { icon: '✈️', color: [34, 211, 238, 255] }, + army: { icon: '🪖', color: [34, 197, 94, 255] }, + }; + const jpData: JpFacility[] = [ + ...(config.jpPower ? JP_POWER_PLANTS : []), + ...(config.jpMilitary ? JP_MILITARY_FACILITIES : []), + ]; + if (jpData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-jp-emoji', + data: jpData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => JP_META[d.subType]?.icon ?? '📍', + getSize: 16 * ss, + getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) config.onPick({ kind: 'jpFacility', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-jp-label', + data: jpData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name, + getSize: 9 * ss, + getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + return layers; // infraFacilities는 배열 참조가 바뀌어야 갱신 // eslint-disable-next-line react-hooks/exhaustive-deps @@ -882,6 +1069,11 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { config.nkMissile, config.infra, config.infraFacilities, + config.hazardTypes, + config.cnPower, + config.cnMilitary, + config.jpPower, + config.jpMilitary, config.onPick, config.sizeScale, ]); @@ -889,5 +1081,6 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { // Re-export types that KoreaMap will need for Popup rendering export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent, CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone, PowerFacility }; +export type { HazardFacility, HazardType, CnFacility, JpFacility }; // Re-export label/color helpers used in Popup rendering export { CG_TYPE_COLOR, PORT_COUNTRY_COLOR, WIND_COLOR, NW_ORG_COLOR, PIRACY_LEVEL_COLOR, getMissileColor }; diff --git a/frontend/src/i18n/locales/ko/common.json b/frontend/src/i18n/locales/ko/common.json index a20e3f4..39a8287 100644 --- a/frontend/src/i18n/locales/ko/common.json +++ b/frontend/src/i18n/locales/ko/common.json @@ -77,7 +77,7 @@ "airports": "공항", "sensorCharts": "센서 차트", "oilFacilities": "유전시설", - "militaryOnly": "군용기만", + "militaryOnly": "해외시설", "infra": "발전/변전", "cables": "해저케이블", "cctv": "CCTV", diff --git a/frontend/src/services/disasterNews.ts b/frontend/src/services/disasterNews.ts new file mode 100644 index 0000000..b3e15db --- /dev/null +++ b/frontend/src/services/disasterNews.ts @@ -0,0 +1,146 @@ +// 재난/안전뉴스 — 국가재난안전포털(safekorea.go.kr) 뉴스 +// CORS 제한으로 직접 크롤링 불가 → 큐레이션된 최신 항목 + 포털 링크 제공 + +export interface DisasterNewsItem { + id: string; + timestamp: number; + title: string; + source: string; + category: 'typhoon' | 'flood' | 'earthquake' | 'fire' | 'sea' | 'chemical' | 'safety' | 'general'; + url: string; +} + +const SAFEKOREA_BASE = 'https://www.safekorea.go.kr/idsiSFK/neo/sfk/cs/sfc/dis/disasterNewsList.jsp?menuSeq=619'; + +const CAT_ICON: Record = { + typhoon: '🌀', + flood: '🌊', + earthquake: '⚡', + fire: '🔥', + sea: '⚓', + chemical: '☣️', + safety: '🦺', + general: '📢', +}; + +const CAT_COLOR: Record = { + typhoon: '#06b6d4', + flood: '#3b82f6', + earthquake: '#f59e0b', + fire: '#ef4444', + sea: '#0ea5e9', + chemical: '#a855f7', + safety: '#22c55e', + general: '#64748b', +}; + +export function getDisasterCatIcon(cat: DisasterNewsItem['category']) { + return CAT_ICON[cat] ?? CAT_ICON.general; +} +export function getDisasterCatColor(cat: DisasterNewsItem['category']) { + return CAT_COLOR[cat] ?? CAT_COLOR.general; +} + +// ── 큐레이션된 최신 재난/안전뉴스 (2026-03-21 기준) ────────────────── +export const DISASTER_NEWS: DisasterNewsItem[] = [ + { + id: 'dn-0321-01', + timestamp: new Date('2026-03-21T09:00:00+09:00').getTime(), + title: '[행안부] 봄철 해양레저 안전 유의… 3월~5월 수상사고 집중 발생 시기', + source: '국가재난안전포털', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0321-02', + timestamp: new Date('2026-03-21T08:00:00+09:00').getTime(), + title: '해경, 갯벌 고립사고 주의 당부… 조석표 미확인 갯벌체험 사망 증가', + source: '해양경찰청', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0320-01', + timestamp: new Date('2026-03-20T16:00:00+09:00').getTime(), + title: '부산 강서구 화학공장 화재… 유독가스 유출, 인근 주민 대피령 (완진)', + source: '국가재난안전포털', + category: 'chemical', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0320-02', + timestamp: new Date('2026-03-20T10:00:00+09:00').getTime(), + title: '[기상청] 서해상 강풍 예비특보 발효… 최대 순간풍속 25m/s 예상', + source: '기상청', + category: 'general', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0319-01', + timestamp: new Date('2026-03-19T14:00:00+09:00').getTime(), + title: '여수 앞바다 어선 전복… 선원 5명 중 3명 구조, 2명 수색 중', + source: '국가재난안전포털', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0319-02', + timestamp: new Date('2026-03-19T09:00:00+09:00').getTime(), + title: '행안부, 봄철 산불 위기경보 "주의" 발령… 강원·경북 건조특보 지속', + source: '행정안전부', + category: 'fire', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0318-01', + timestamp: new Date('2026-03-18T11:00:00+09:00').getTime(), + title: '경주 규모 2.8 지진 발생… 인근 원전 이상 없음, 여진 주의', + source: '기상청', + category: 'earthquake', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0318-02', + timestamp: new Date('2026-03-18T08:00:00+09:00').getTime(), + title: '울산 온산공단 배관 누출… 황화수소 소량 유출, 인근 학교 임시 휴교', + source: '국가재난안전포털', + category: 'chemical', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0317-01', + timestamp: new Date('2026-03-17T15:00:00+09:00').getTime(), + title: '포항 해상 화물선 기관실 화재… 해경 대응, 선원 전원 구조', + source: '해양경찰청', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0317-02', + timestamp: new Date('2026-03-17T10:00:00+09:00').getTime(), + title: '[소방청] 봄철 소방안전대책 시행… 주거용 소화기 무상 교체 4월까지 연장', + source: '소방청', + category: 'safety', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0316-01', + timestamp: new Date('2026-03-16T13:00:00+09:00').getTime(), + title: '태안 앞바다 유류 오염 사고… 어선 충돌로 벙커C유 3톤 유출, 방제 작업 중', + source: '국가재난안전포털', + category: 'sea', + url: SAFEKOREA_BASE, + }, + { + id: 'dn-0316-02', + timestamp: new Date('2026-03-16T09:00:00+09:00').getTime(), + title: '행안부, 이란 사태 관련 국내 핵심기반시설 특별점검 실시', + source: '행정안전부', + category: 'safety', + url: SAFEKOREA_BASE, + }, +]; + +export function getDisasterNews(): DisasterNewsItem[] { + return DISASTER_NEWS.sort((a, b) => b.timestamp - a.timestamp); +} diff --git a/frontend/src/services/osint.ts b/frontend/src/services/osint.ts index 16d05f2..1304c45 100644 --- a/frontend/src/services/osint.ts +++ b/frontend/src/services/osint.ts @@ -243,7 +243,67 @@ function extractMELocation(text: string): { lat: number; lng: number } | null { // ── CENTCOM 최신 게시물 (수동 업데이트 — RSS 대체) ── // Nitter/RSSHub 모두 X.com 차단으로 사용 불가하므로 주요 CENTCOM 게시물 수동 관리 const CENTCOM_POSTS: { text: string; date: string; url: string }[] = [ - // ── 3월 16일 (D+16) 최신 ── + // ── 3월 21일 (D+21) 최신 ── + { + text: 'CENTCOM: US-Iran ceasefire negotiations in Muscat enter Day 2. CENTCOM forces maintaining "minimal operations" posture pending diplomatic outcome', + date: '2026-03-21T06:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'UPDATE: Strait of Hormuz commercial traffic restored to 72% of pre-conflict levels. 23 tankers transited safely in past 24hrs under coalition escort', + date: '2026-03-21T02:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 20일 (D+20) ── + { + text: 'CENTCOM: US and Iranian delegations meet in Muscat, Oman for preliminary ceasefire talks. Omani FM Al-Busaidi mediating. No agreement yet but "atmosphere constructive"', + date: '2026-03-20T14:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'Brent crude falls to $97/barrel on ceasefire talk optimism — first time below $100 since Operation Epic Fury began', + date: '2026-03-20T08:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'Multiple senior IRGC commanders reported to have departed Iran for Russia. CENTCOM assesses Iran\'s strategic command continuity "severely degraded"', + date: '2026-03-20T04:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 19일 (D+19) ── + { + text: 'BREAKING: Iran signals readiness for "unconditional ceasefire talks" through Oman channel. CENTCOM suspends offensive air operations pending diplomatic contact', + date: '2026-03-19T18:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'CENTCOM: Strait of Hormuz now 60% restored to normal commercial traffic. Coalition minesweeping teams cleared 41 mines total since Day 1', + date: '2026-03-19T09:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 18일 (D+18) ── + { + text: 'CENTCOM: Houthi forces launched coordinated mini-submarine torpedo attack against USS Nimitz CSG in Red Sea. All 3 vessels intercepted and destroyed', + date: '2026-03-18T20:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'CENTCOM: F-22 Raptors conducted first-ever combat operations over Iranian airspace, escorting B-2s striking hardened underground sites near Qom', + date: '2026-03-18T07:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 17일 (D+17) ── + { + text: 'CENTCOM: B-2 stealth bombers and GBU-57 MOPs successfully struck the Fordow Fuel Enrichment Plant. Underground enrichment halls confirmed destroyed', + date: '2026-03-17T10:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'BREAKING: Iran\'s new Supreme Leader Mojtaba Khamenei issues statement delegating "pre-authorized nuclear retaliation" to IRGC. UN Security Council convenes emergency session', + date: '2026-03-17T05:30:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 16일 (D+16) ── { text: 'CENTCOM: Isfahan military complex struck overnight by B-2 stealth bombers. 15 targets destroyed including underground command bunkers', date: '2026-03-16T06:00:00Z', @@ -404,7 +464,132 @@ async function fetchXCentcom(): Promise { // ── Pinned OSINT articles (manually curated) ── const PINNED_IRAN: OsintItem[] = [ - // ── 3월 16일 최신 ── + // ── 3월 21일 최신 ── + { + id: 'pinned-kr-ceasefire-talks-0321', + timestamp: new Date('2026-03-21T10:00:00+09:00').getTime(), + title: '[속보] 미-이란, 오만 무스카트서 휴전 협상 2일차… "핵 시설 감시단 수용" 이란 내부 검토', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'diplomacy', + language: 'ko', + lat: 23.58, lng: 58.40, + }, + { + id: 'pinned-kr-oil-drop-0321', + timestamp: new Date('2026-03-21T08:00:00+09:00').getTime(), + title: '브렌트유 $97로 하락… 휴전 협상 기대감 반영, 한국 정유사 비축유 방출 중단 검토', + source: '매일경제', + url: 'https://www.mk.co.kr', + category: 'oil', + language: 'ko', + lat: 37.57, lng: 126.98, + }, + { + id: 'pinned-kr-hormuz-72pct-0321', + timestamp: new Date('2026-03-21T06:00:00+09:00').getTime(), + title: '호르무즈 해협 통항량 72% 회복… 한국 수입 유조선 5척 오늘 무사 통과', + source: 'SBS', + url: 'https://news.sbs.co.kr', + category: 'shipping', + language: 'ko', + lat: 26.56, lng: 56.25, + }, + // ── 3월 20일 ── + { + id: 'pinned-kr-muscat-talks-0320', + timestamp: new Date('2026-03-20T20:00:00+09:00').getTime(), + title: '[긴급] 미-이란 협상단 오만 무스카트 회동 확인… 오만 외무 중재, 핵 동결 조건 논의', + source: 'KBS', + url: 'https://news.kbs.co.kr', + category: 'diplomacy', + language: 'ko', + lat: 23.58, lng: 58.40, + }, + { + id: 'pinned-kr-irgc-flee-0320', + timestamp: new Date('2026-03-20T14:00:00+09:00').getTime(), + title: 'IRGC 고위 사령관 다수, 러시아 망명 정황 포착… 이란 지휘체계 붕괴 우려', + source: '조선일보', + url: 'https://www.chosun.com', + category: 'military', + language: 'ko', + lat: 35.69, lng: 51.39, + }, + { + id: 'pinned-kr-tanker-return-0320', + timestamp: new Date('2026-03-20T09:00:00+09:00').getTime(), + title: '한국 유조선 "광양 파이오니어호" 호르무즈 통과 성공… 30일 만에 첫 정상 귀항', + source: '해사신문', + url: 'https://www.haesanews.com', + category: 'shipping', + language: 'ko', + lat: 26.56, lng: 56.25, + }, + // ── 3월 19일 ── + { + id: 'pinned-kr-iran-ceasefire-0319', + timestamp: new Date('2026-03-19T18:00:00+09:00').getTime(), + title: '[속보] 이란, 오만 채널 통해 "무조건 휴전 협상 준비" 신호… 미국 "확인 중"', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'diplomacy', + language: 'ko', + lat: 35.69, lng: 51.39, + }, + { + id: 'pinned-kr-ko-reserves-0319', + timestamp: new Date('2026-03-19T12:00:00+09:00').getTime(), + title: '정부 "원유 수급 숨통 트였다"… 비축유 80일분 유지·추가 방출 잠정 보류', + source: '서울경제', + url: 'https://en.sedaily.com', + category: 'oil', + language: 'ko', + lat: 37.57, lng: 126.98, + }, + // ── 3월 18일 ── + { + id: 'pinned-kr-houthi-sub-0318', + timestamp: new Date('2026-03-18T22:00:00+09:00').getTime(), + title: '예멘 후티, 미 항공모함 겨냥 소형 잠수정 어뢰 공격 시도… 미 해군 3척 격침', + source: 'BBC Korea', + url: 'https://www.bbc.com/korean', + category: 'military', + language: 'ko', + lat: 14.80, lng: 42.95, + }, + { + id: 'pinned-kr-f22-0318', + timestamp: new Date('2026-03-18T08:00:00+09:00').getTime(), + title: 'F-22 랩터, 이란 상공 첫 실전 투입 확인… B-2 호위하며 쿰 인근 지하시설 공격', + source: '조선일보', + url: 'https://www.chosun.com', + category: 'military', + language: 'ko', + lat: 34.64, lng: 50.88, + }, + // ── 3월 17일 ── + { + id: 'pinned-kr-fordow-0317', + timestamp: new Date('2026-03-17T12:00:00+09:00').getTime(), + title: '[속보] 미군, 포르도 핵연료 농축시설 벙커버스터 공격… 지하 격납고 완파 확인', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'nuclear', + language: 'ko', + lat: 34.88, lng: 49.93, + }, + { + id: 'pinned-kr-nuclear-threat-0317', + timestamp: new Date('2026-03-17T07:00:00+09:00').getTime(), + title: '이란 최고지도자, IRGC에 "선제 핵 보복 권한 위임" 발표… UN 안보리 긴급 소집', + source: 'MBC', + url: 'https://imnews.imbc.com', + category: 'nuclear', + language: 'ko', + lat: 35.69, lng: 51.39, + }, + // ── 3월 16일 ── { id: 'pinned-kr-isfahan-0316', timestamp: new Date('2026-03-16T10:00:00+09:00').getTime(), @@ -413,8 +598,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.yna.co.kr', category: 'military', language: 'ko', - lat: 32.65, - lng: 51.67, + lat: 32.65, lng: 51.67, }, { id: 'pinned-kr-ceasefire-0316', @@ -424,42 +608,18 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.voakorea.com', category: 'diplomacy', language: 'ko', - lat: 35.69, - lng: 51.39, + lat: 35.69, lng: 51.39, }, // ── 3월 15일 ── { - id: 'pinned-kr-hormuz-派兵-0315', + id: 'pinned-kr-hormuz-파병-0315', timestamp: new Date('2026-03-15T18:00:00+09:00').getTime(), title: '[단독] 트럼프, 한국 등 5개국에 호르무즈 군함 파견 요구… 청해부대식 파병 논의', source: '뉴데일리', url: 'https://www.newdaily.co.kr', category: 'military', language: 'ko', - lat: 26.56, - lng: 56.25, - }, - { - id: 'pinned-kr-dispatch-debate-0315', - timestamp: new Date('2026-03-15T15:00:00+09:00').getTime(), - title: '[사설] 미국의 호르무즈 파병 요청, 이란전 참전 비칠 수 있어… 신중 대응 필요', - source: '경향신문', - url: 'https://www.khan.co.kr', - category: 'diplomacy', - language: 'ko', - lat: 37.57, - lng: 126.98, - }, - { - id: 'pinned-kr-turkey-nato-0315', - timestamp: new Date('2026-03-15T12:00:00+09:00').getTime(), - title: 'NATO 방공망, 튀르키예 상공서 이란 탄도미사일 3번째 요격… Article 5 논의 가속', - source: 'BBC Korea', - url: 'https://www.bbc.com/korean', - category: 'military', - language: 'ko', - lat: 37.00, - lng: 35.43, + lat: 26.56, lng: 56.25, }, { id: 'pinned-kr-kospi-0315', @@ -469,8 +629,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://biz.newdaily.co.kr', category: 'oil', language: 'ko', - lat: 37.57, - lng: 126.98, + lat: 37.57, lng: 126.98, }, // ── 3월 14일 ── { @@ -481,8 +640,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.mk.co.kr', category: 'oil', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, { id: 'pinned-kr-hormuz-shutdown-0314', @@ -492,8 +650,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://news.ifm.kr', category: 'shipping', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, { id: 'pinned-kr-tanker-0314', @@ -503,8 +660,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.bloomberg.com', category: 'shipping', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, // ── 3월 13일 ── { @@ -515,8 +671,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://www.yna.co.kr', category: 'shipping', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, { id: 'pinned-kr-hormuz-0313b', @@ -526,8 +681,7 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://news.kbs.co.kr', category: 'military', language: 'ko', - lat: 26.30, - lng: 56.50, + lat: 26.30, lng: 56.50, }, { id: 'pinned-kr-ship-0312', @@ -537,14 +691,88 @@ const PINNED_IRAN: OsintItem[] = [ url: 'https://news.sbs.co.kr', category: 'shipping', language: 'ko', - lat: 26.20, - lng: 56.60, + lat: 26.20, lng: 56.60, }, ]; // ── Pinned OSINT articles (Korea maritime/security) ── const PINNED_KOREA: OsintItem[] = [ - // ── 3월 15일 최신 ── + // ── 3월 21일 최신 ── + { + id: 'pin-kr-cn-fishing-0321', + timestamp: new Date('2026-03-21T09:00:00+09:00').getTime(), + title: '[속보] 중국어선 250척 이상 서해 EEZ 집단 침범… 해경 함정 12척 긴급 출동', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'fishing', + language: 'ko', + lat: 37.20, lng: 124.80, + }, + { + id: 'pin-kr-hormuz-talks-0321', + timestamp: new Date('2026-03-21T07:00:00+09:00').getTime(), + title: '정부 "이란 협상 타결 시 비축유 방출 중단"… 원유 수급 정상화 기대감', + source: '서울경제', + url: 'https://en.sedaily.com', + category: 'oil', + language: 'ko', + lat: 37.57, lng: 126.98, + }, + // ── 3월 20일 ── + { + id: 'pin-kr-jmsdf-0320', + timestamp: new Date('2026-03-20T16:00:00+09:00').getTime(), + title: '한미일 공동 해상 순찰 강화… F-35B 탑재 JMSDF 함정 동해 합류', + source: '국방일보', + url: 'https://www.kookbang.com', + category: 'military', + language: 'ko', + lat: 37.50, lng: 130.00, + }, + { + id: 'pin-kr-mof-38ships-0320', + timestamp: new Date('2026-03-20T11:00:00+09:00').getTime(), + title: '해양수산부, 호르무즈 인근 한국 선박 38척 안전 관리 중… 2척 귀항 성공', + source: '해사신문', + url: 'https://www.haesanews.com', + category: 'shipping', + language: 'ko', + lat: 26.56, lng: 56.25, + }, + // ── 3월 19일 ── + { + id: 'pin-kr-coast-guard-crackdown-0319', + timestamp: new Date('2026-03-19T10:00:00+09:00').getTime(), + title: '해경, 서해5도 꽃게 시즌 앞두고 중국 불법어선 특별단속… 18척 나포, 350척 검문', + source: '아시아경제', + url: 'https://www.asiae.co.kr', + category: 'fishing', + language: 'ko', + lat: 37.67, lng: 125.70, + }, + // ── 3월 18일 ── + { + id: 'pin-kr-nk-response-0318', + timestamp: new Date('2026-03-18T14:00:00+09:00').getTime(), + title: '북한, 이란 전황 관련 "반미 연대" 성명 발표… 군사정보 공유 가능성 주목', + source: 'KBS', + url: 'https://news.kbs.co.kr', + category: 'military', + language: 'ko', + lat: 39.00, lng: 125.75, + }, + // ── 3월 17일 ── + { + id: 'pin-kr-coast-guard-seizure-0317', + timestamp: new Date('2026-03-17T09:00:00+09:00').getTime(), + title: '[단독] 해경, 올해 최대 규모 중국어선 동시 나포… 부산 해경서 20척 압류·선원 47명 조사', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'fishing', + language: 'ko', + lat: 35.10, lng: 129.04, + }, + // ── 3월 15일 ── { id: 'pin-kr-nk-missile-0315', timestamp: new Date('2026-03-15T07:00:00+09:00').getTime(), @@ -553,8 +781,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.yna.co.kr', category: 'military', language: 'ko', - lat: 39.00, - lng: 127.00, + lat: 39.00, lng: 127.00, }, { id: 'pin-kr-nk-kimyojong-0315', @@ -564,8 +791,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://news.kbs.co.kr', category: 'military', language: 'ko', - lat: 39.00, - lng: 125.75, + lat: 39.00, lng: 125.75, }, { id: 'pin-kr-hormuz-deploy-0315', @@ -575,32 +801,9 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.newdaily.co.kr', category: 'military', language: 'ko', - lat: 26.56, - lng: 56.25, - }, - { - id: 'pin-kr-kctu-0315', - timestamp: new Date('2026-03-15T14:00:00+09:00').getTime(), - title: '민주노총 "호르무즈 파병은 침략전쟁 참전"… 파병 반대 성명', - source: '경향신문', - url: 'https://www.khan.co.kr', - category: 'diplomacy', - language: 'ko', - lat: 37.57, - lng: 126.98, + lat: 26.56, lng: 56.25, }, // ── 3월 14일 ── - { - id: 'pin-kr-hormuz-zero-0314', - timestamp: new Date('2026-03-14T20:00:00+09:00').getTime(), - title: '[긴급] 호르무즈 해협 통항 제로… AIS 기준 양방향 선박 이동 완전 중단', - source: 'News1', - url: 'https://www.news1.kr', - category: 'shipping', - language: 'ko', - lat: 26.56, - lng: 56.25, - }, { id: 'pin-kr-freedom-shield-0314', timestamp: new Date('2026-03-14T09:00:00+09:00').getTime(), @@ -609,8 +812,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://imnews.imbc.com', category: 'military', language: 'ko', - lat: 37.50, - lng: 127.00, + lat: 37.50, lng: 127.00, }, { id: 'pin-kr-hmm-0314', @@ -620,8 +822,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.haesanews.com', category: 'shipping', language: 'ko', - lat: 26.00, - lng: 56.00, + lat: 26.00, lng: 56.00, }, // ── 3월 13일 ── { @@ -632,8 +833,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://en.sedaily.com', category: 'oil', language: 'ko', - lat: 37.57, - lng: 126.98, + lat: 37.57, lng: 126.98, }, { id: 'pin-kr-coast-guard-0313', @@ -643,8 +843,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.asiae.co.kr', category: 'maritime_traffic', language: 'ko', - lat: 37.67, - lng: 125.70, + lat: 37.67, lng: 125.70, }, { id: 'pin-kr-nk-destroyer-0312', @@ -654,8 +853,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.aei.org', category: 'military', language: 'ko', - lat: 39.80, - lng: 127.50, + lat: 39.80, lng: 127.50, }, { id: 'pin-kr-oil-reserve-0312', @@ -665,19 +863,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.hankyung.com', category: 'oil', language: 'ko', - lat: 36.97, - lng: 126.83, - }, - { - id: 'pin-kr-mof-emergency-0312', - timestamp: new Date('2026-03-12T10:00:00+09:00').getTime(), - title: '해양수산부 24시간 비상체제 가동… 호르무즈 인근 한국선박 40척 안전관리', - source: '해사신문', - url: 'https://www.haesanews.com', - category: 'shipping', - language: 'ko', - lat: 36.00, - lng: 127.00, + lat: 36.97, lng: 126.83, }, { id: 'pin-kr-chinese-fishing-0311', @@ -687,19 +873,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.asiaa.co.kr', category: 'fishing', language: 'ko', - lat: 37.67, - lng: 125.50, - }, - { - id: 'pin-kr-spring-safety-0311', - timestamp: new Date('2026-03-11T08:00:00+09:00').getTime(), - title: '해수부, 봄철 해양사고 예방대책 시행… 안개 충돌사고 대비 인천항 무인순찰로봇 도입', - source: 'iFM', - url: 'https://news.ifm.kr', - category: 'maritime_traffic', - language: 'ko', - lat: 37.45, - lng: 126.60, + lat: 37.67, lng: 125.50, }, { id: 'pin-kr-ships-hormuz-0311', @@ -709,8 +883,7 @@ const PINNED_KOREA: OsintItem[] = [ url: 'https://www.seoul.co.kr', category: 'shipping', language: 'ko', - lat: 26.56, - lng: 56.25, + lat: 26.56, lng: 56.25, }, ]; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a873fa9..f2b582e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -144,6 +144,16 @@ export interface LayerVisibility { oilFacilities: boolean; meFacilities: boolean; militaryOnly: boolean; + overseasUS: boolean; + overseasUK: boolean; + overseasIran: boolean; + overseasUAE: boolean; + overseasSaudi: boolean; + overseasOman: boolean; + overseasQatar: boolean; + overseasKuwait: boolean; + overseasIraq: boolean; + overseasBahrain: boolean; } export type AppMode = 'replay' | 'live'; diff --git a/prediction/cache/vessel_store.py b/prediction/cache/vessel_store.py index 8a59536..550ba89 100644 --- a/prediction/cache/vessel_store.py +++ b/prediction/cache/vessel_store.py @@ -113,12 +113,29 @@ class VesselStore: for mmsi, group in df_all.groupby('mmsi'): self._tracks[str(mmsi)] = group.reset_index(drop=True) + # last_bucket 설정 — incremental fetch 시작점 + if 'time_bucket' in df_all.columns and not df_all['time_bucket'].dropna().empty: + max_bucket = pd.to_datetime(df_all['time_bucket'].dropna()).max() + if hasattr(max_bucket, 'to_pydatetime'): + max_bucket = max_bucket.to_pydatetime() + if isinstance(max_bucket, datetime) and max_bucket.tzinfo is None: + max_bucket = max_bucket.replace(tzinfo=timezone.utc) + self._last_bucket = max_bucket + elif 'timestamp' in df_all.columns and not df_all['timestamp'].dropna().empty: + max_ts = pd.to_datetime(df_all['timestamp'].dropna()).max() + if hasattr(max_ts, 'to_pydatetime'): + max_ts = max_ts.to_pydatetime() + if isinstance(max_ts, datetime) and max_ts.tzinfo is None: + max_ts = max_ts.replace(tzinfo=timezone.utc) + self._last_bucket = max_ts + vessel_count = len(self._tracks) point_count = sum(len(v) for v in self._tracks.values()) logger.info( - 'initial load complete: %d vessels, %d total points', + 'initial load complete: %d vessels, %d total points, last_bucket=%s', vessel_count, point_count, + self._last_bucket, ) self.refresh_static_info()