Merge pull request 'release: 2026-03-23 (4건 커밋)' (#151) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m58s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m58s
This commit is contained in:
커밋
f36e1b297b
@ -4,7 +4,26 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [2026-03-20.3]
|
## [2026-03-23]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설
|
||||||
|
- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드)
|
||||||
|
- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴)
|
||||||
|
- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보)
|
||||||
|
- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함)
|
||||||
|
- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x)
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정
|
||||||
|
- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소)
|
||||||
|
- deck.gl 레이어 호버 시 pointer 커서 표시
|
||||||
|
- prediction 증분 수집 버그 수정 (vessel_store.py)
|
||||||
|
|
||||||
|
## [2026-03-20]
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
- deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL)
|
- deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL)
|
||||||
@ -12,22 +31,14 @@
|
|||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
- NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조
|
- NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조
|
||||||
|
- Python 분석 결과 오버레이: 위험도 마커 + 다크베셀/GPS 스푸핑 경고
|
||||||
### 수정
|
|
||||||
- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선
|
|
||||||
|
|
||||||
## [2026-03-20.2]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- Python 분석 결과 오버레이: 위험도 마커(CRITICAL/HIGH/MEDIUM) + 다크베셀/GPS 스푸핑 경고
|
|
||||||
- AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계
|
- AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계
|
||||||
- 불법어선/다크베셀/중국어선감시 Python 분석 연동
|
- 불법어선/다크베셀/중국어선감시 Python 분석 연동
|
||||||
- Backend vessel-analysis REST API + DB 테이블 복원
|
- Backend vessel-analysis REST API + DB 테이블 복원
|
||||||
|
- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류
|
||||||
|
|
||||||
## [2026-03-20]
|
### 수정
|
||||||
|
- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선
|
||||||
### 추가
|
|
||||||
- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류 (경도 하드코딩 → point-in-polygon)
|
|
||||||
|
|
||||||
## [2026-03-19]
|
## [2026-03-19]
|
||||||
|
|
||||||
|
|||||||
@ -1881,6 +1881,11 @@
|
|||||||
border-top-color: rgba(10, 10, 26, 0.96) !important;
|
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,
|
.gl-popup .maplibregl-popup-close-button,
|
||||||
.event-popup .maplibregl-popup-close-button {
|
.event-popup .maplibregl-popup-close-button {
|
||||||
color: #aaa !important;
|
color: #aaa !important;
|
||||||
|
|||||||
@ -22,6 +22,22 @@ import { useAuth } from './hooks/useAuth';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import LoginPage from './components/auth/LoginPage';
|
import LoginPage from './components/auth/LoginPage';
|
||||||
import CollectorMonitor from './components/common/CollectorMonitor';
|
import CollectorMonitor from './components/common/CollectorMonitor';
|
||||||
|
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
|
||||||
|
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
|
||||||
|
import { EAST_ASIA_PORTS } from './data/ports';
|
||||||
|
import { KOREAN_AIRPORTS } from './services/airports';
|
||||||
|
import { MILITARY_BASES } from './data/militaryBases';
|
||||||
|
import { GOV_BUILDINGS } from './data/govBuildings';
|
||||||
|
import { KOREA_WIND_FARMS } from './data/windFarms';
|
||||||
|
import { NK_LAUNCH_SITES } from './data/nkLaunchSites';
|
||||||
|
import { NK_MISSILE_EVENTS } from './data/nkMissileEvents';
|
||||||
|
import { COAST_GUARD_FACILITIES } from './services/coastGuard';
|
||||||
|
import { NAV_WARNINGS } from './services/navWarning';
|
||||||
|
import { PIRACY_ZONES } from './services/piracy';
|
||||||
|
import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
|
||||||
|
import { HAZARD_FACILITIES } from './data/hazardFacilities';
|
||||||
|
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities';
|
||||||
|
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -65,6 +81,16 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
oilFacilities: true,
|
oilFacilities: true,
|
||||||
meFacilities: true,
|
meFacilities: true,
|
||||||
militaryOnly: false,
|
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)
|
// Korea tab layer visibility (lifted from KoreaMap)
|
||||||
@ -89,6 +115,21 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
nkMissile: true,
|
nkMissile: true,
|
||||||
cnFishing: false,
|
cnFishing: false,
|
||||||
militaryOnly: 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) => {
|
const toggleKoreaLayer = useCallback((key: string) => {
|
||||||
@ -148,6 +189,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
||||||
|
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
|
||||||
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
||||||
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
|
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
|
||||||
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
|
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
|
||||||
@ -321,6 +363,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
<span className="text-[11px]">🎣</span>
|
<span className="text-[11px]">🎣</span>
|
||||||
중국어선감시
|
중국어선감시
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mode-btn ${showFieldAnalysis ? 'active' : ''}`}
|
||||||
|
onClick={() => setShowFieldAnalysis(v => !v)}
|
||||||
|
title="현장분석"
|
||||||
|
>
|
||||||
|
<span className="text-[11px]">📊</span>
|
||||||
|
현장분석
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -459,6 +510,18 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
|
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
|
||||||
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
{ 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}
|
hiddenAcCategories={hiddenAcCategories}
|
||||||
hiddenShipCategories={hiddenShipCategories}
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
onAcCategoryToggle={toggleAcCategory}
|
onAcCategoryToggle={toggleAcCategory}
|
||||||
@ -553,6 +616,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
<>
|
<>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<div className="map-panel">
|
<div className="map-panel">
|
||||||
|
{showFieldAnalysis && (
|
||||||
|
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} />
|
||||||
|
)}
|
||||||
<KoreaMap
|
<KoreaMap
|
||||||
ships={koreaFiltersResult.filteredShips}
|
ships={koreaFiltersResult.filteredShips}
|
||||||
allShips={koreaData.visibleShips}
|
allShips={koreaData.visibleShips}
|
||||||
@ -579,23 +645,51 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
satelliteCount={koreaData.satPositions.length}
|
satelliteCount={koreaData.satPositions.length}
|
||||||
extraLayers={[
|
extraLayers={[
|
||||||
// 해양안전
|
// 해양안전
|
||||||
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', group: '해양안전' },
|
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length, group: '해양안전' },
|
||||||
{ key: 'cctv', label: t('layers.cctv'), color: '#ff6b6b', count: 15, group: '해양안전' },
|
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length, group: '해양안전' },
|
||||||
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 56, group: '해양안전' },
|
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length, group: '해양안전' },
|
||||||
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', group: '해양안전' },
|
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length, group: '해양안전' },
|
||||||
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', group: '해양안전' },
|
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1, group: '해양안전' },
|
||||||
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', group: '해양안전' },
|
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length, group: '해양안전' },
|
||||||
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', group: '해양안전' },
|
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length, group: '해양안전' },
|
||||||
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: 4, group: '해양안전' },
|
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length, group: '해양안전' },
|
||||||
{ key: 'cnFishing', label: '🎣 중국어선 어구', color: '#f97316', group: '해양안전' },
|
|
||||||
// 국가기관망
|
// 국가기관망
|
||||||
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '국가기관망' },
|
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length, group: '국가기관망' },
|
||||||
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 59, group: '국가기관망' },
|
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length, group: '국가기관망' },
|
||||||
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: 8, group: '국가기관망' },
|
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length, group: '국가기관망' },
|
||||||
{ key: 'ports', label: '항구', color: '#3b82f6', count: 46, group: '국가기관망' },
|
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length, group: '국가기관망' },
|
||||||
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: 38, group: '국가기관망' },
|
// 에너지/발전시설
|
||||||
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: 32, group: '국가기관망' },
|
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
|
||||||
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: 19, group: '국가기관망' },
|
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length, group: '에너지/발전시설' },
|
||||||
|
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length, group: '에너지/발전시설' },
|
||||||
|
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' },
|
||||||
|
// 위험시설
|
||||||
|
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' },
|
||||||
|
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' },
|
||||||
|
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' },
|
||||||
|
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' },
|
||||||
|
// 산업공정/제조시설
|
||||||
|
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' },
|
||||||
|
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' },
|
||||||
|
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' },
|
||||||
|
]}
|
||||||
|
overseasItems={[
|
||||||
|
{
|
||||||
|
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
|
||||||
|
count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length,
|
||||||
|
children: [
|
||||||
|
{ key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length },
|
||||||
|
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
|
||||||
|
count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length,
|
||||||
|
children: [
|
||||||
|
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
|
||||||
|
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
|
||||||
|
],
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
hiddenAcCategories={hiddenAcCategories}
|
hiddenAcCategories={hiddenAcCategories}
|
||||||
hiddenShipCategories={hiddenShipCategories}
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useMemo, useState, useCallback } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { GeoEvent, Ship } from '../../types';
|
import type { GeoEvent, Ship } from '../../types';
|
||||||
import type { OsintItem } from '../../services/osint';
|
import type { OsintItem } from '../../services/osint';
|
||||||
|
import { getDisasterNews, getDisasterCatIcon, getDisasterCatColor } from '../../services/disasterNews';
|
||||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis';
|
import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis';
|
||||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||||
@ -329,7 +330,7 @@ const NEWS_CATEGORY_COLORS: Record<BreakingNews['category'], string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_OSINT: OsintItem[] = [];
|
const EMPTY_OSINT: OsintItem[] = [];
|
||||||
const EMPTY_SHIPS: import('../types').Ship[] = [];
|
const EMPTY_SHIPS: Ship[] = [];
|
||||||
|
|
||||||
function useTimeAgo() {
|
function useTimeAgo() {
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
@ -597,7 +598,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||||
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
||||||
<span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
|
<span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
|
||||||
{onToggleHighlightKorean && dashboardTab === 'iran' && (
|
{onToggleHighlightKorean && (dashboardTab as string) === 'iran' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`korean-highlight-toggle ${highlightKoreanShips ? 'active' : ''}`}
|
className={`korean-highlight-toggle ${highlightKoreanShips ? 'active' : ''}`}
|
||||||
@ -761,6 +762,57 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* 재난/안전뉴스 */}
|
||||||
|
{isLive && (() => {
|
||||||
|
const disasterItems = getDisasterNews();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="osint-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('disaster-news')}>
|
||||||
|
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('disaster-news') ? '▶' : '▼'}</span>
|
||||||
|
<span style={{ fontSize: 11 }}>🚨</span>
|
||||||
|
<span className="osint-title">재난/안전뉴스</span>
|
||||||
|
<span className="osint-count">{disasterItems.length}</span>
|
||||||
|
<a
|
||||||
|
href="https://www.safekorea.go.kr/idsiSFK/neo/sfk/cs/sfc/dis/disasterNewsList.jsp?menuSeq=619"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ marginLeft: 'auto', fontSize: 9, color: 'var(--kcg-dim)', textDecoration: 'none' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
안전코리아 ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{!collapsed.has('disaster-news') && (
|
||||||
|
<div className="osint-list" style={{ maxHeight: 310, overflowY: 'auto' }}>
|
||||||
|
{disasterItems.map(item => {
|
||||||
|
const icon = getDisasterCatIcon(item.category);
|
||||||
|
const color = getDisasterCatColor(item.category);
|
||||||
|
const isRecent = Date.now() - item.timestamp < 3600_000 * 6;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={item.id}
|
||||||
|
className={`osint-item${isRecent ? ' osint-recent' : ''}`}
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<div className="osint-item-top">
|
||||||
|
<span className="osint-cat-tag" style={{ background: color }}>
|
||||||
|
{icon} {item.category === 'sea' ? '해양' : item.category === 'chemical' ? '화학' : item.category === 'fire' ? '화재' : item.category === 'earthquake' ? '지진' : item.category === 'flood' ? '홍수' : item.category === 'typhoon' ? '태풍' : item.category === 'safety' ? '안전' : '재난'}
|
||||||
|
</span>
|
||||||
|
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="osint-item-title">{item.title}</div>
|
||||||
|
<div className="osint-item-source">{item.source}</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* OSINT 피드 — 한국: 일반(general) 제외, 해양 관련만 표시 (라이브/리플레이 모두) */}
|
{/* OSINT 피드 — 한국: 일반(general) 제외, 해양 관련만 표시 (라이브/리플레이 모두) */}
|
||||||
{osintFeed.length > 0 && (
|
{osintFeed.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -107,12 +107,27 @@ interface ExtraLayer {
|
|||||||
group?: string;
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_META: Record<string, { label: string; color: string }> = {
|
const GROUP_META: Record<string, { label: string; color: string; superGroup?: string }> = {
|
||||||
'항공망': { label: '항공망', color: '#22d3ee' },
|
'항공망': { label: '항공망', color: '#22d3ee' },
|
||||||
'국가기관망': { label: '국가기관망', color: '#f59e0b' },
|
'해양안전': { label: '해양안전', color: '#3b82f6' },
|
||||||
'해양안전': { label: '해양안전', color: '#3b82f6' },
|
'국가기관망': { label: '국가기관망', color: '#f59e0b' },
|
||||||
|
'위험시설': { label: '위험시설', color: '#ef4444', superGroup: '위험/산업 인프라' },
|
||||||
|
'에너지/발전시설': { label: '에너지/발전시설', color: '#a855f7', superGroup: '위험/산업 인프라' },
|
||||||
|
'산업공정/제조시설': { label: '산업공정/제조시설', color: '#0ea5e9', superGroup: '위험/산업 인프라' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SUPER_GROUP_META: Record<string, { label: string; color: string }> = {
|
||||||
|
'위험/산업 인프라': { label: '위험/산업 인프라', color: '#f97316' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OverseasItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
count?: number;
|
||||||
|
children?: OverseasItem[];
|
||||||
|
}
|
||||||
|
|
||||||
interface LayerPanelProps {
|
interface LayerPanelProps {
|
||||||
layers: Record<string, boolean>;
|
layers: Record<string, boolean>;
|
||||||
onToggle: (key: string) => void;
|
onToggle: (key: string) => void;
|
||||||
@ -122,6 +137,7 @@ interface LayerPanelProps {
|
|||||||
shipTotal: number;
|
shipTotal: number;
|
||||||
satelliteCount: number;
|
satelliteCount: number;
|
||||||
extraLayers?: ExtraLayer[];
|
extraLayers?: ExtraLayer[];
|
||||||
|
overseasItems?: OverseasItem[];
|
||||||
hiddenAcCategories: Set<string>;
|
hiddenAcCategories: Set<string>;
|
||||||
hiddenShipCategories: Set<string>;
|
hiddenShipCategories: Set<string>;
|
||||||
onAcCategoryToggle: (cat: string) => void;
|
onAcCategoryToggle: (cat: string) => void;
|
||||||
@ -143,6 +159,7 @@ export function LayerPanel({
|
|||||||
shipTotal,
|
shipTotal,
|
||||||
satelliteCount,
|
satelliteCount,
|
||||||
extraLayers,
|
extraLayers,
|
||||||
|
overseasItems,
|
||||||
hiddenAcCategories,
|
hiddenAcCategories,
|
||||||
hiddenShipCategories,
|
hiddenShipCategories,
|
||||||
onAcCategoryToggle,
|
onAcCategoryToggle,
|
||||||
@ -174,9 +191,10 @@ export function LayerPanel({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const militaryCount = Object.entries(aircraftByCategory)
|
const _militaryCount = Object.entries(aircraftByCategory)
|
||||||
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
|
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
|
||||||
.reduce((sum, [, c]) => sum + c, 0);
|
.reduce((sum, [, c]) => sum + c, 0);
|
||||||
|
void _militaryCount; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layer-panel">
|
<div className="layer-panel">
|
||||||
@ -186,7 +204,8 @@ export function LayerPanel({
|
|||||||
{/* Ships tree */}
|
{/* Ships tree */}
|
||||||
<LayerTreeItem
|
<LayerTreeItem
|
||||||
layerKey="ships"
|
layerKey="ships"
|
||||||
label={`${t('layers.ships')} (${shipTotal})`}
|
label={t('layers.ships')}
|
||||||
|
count={shipTotal}
|
||||||
color="#fb923c"
|
color="#fb923c"
|
||||||
active={layers.ships}
|
active={layers.ships}
|
||||||
expandable
|
expandable
|
||||||
@ -297,7 +316,8 @@ export function LayerPanel({
|
|||||||
<>
|
<>
|
||||||
<LayerTreeItem
|
<LayerTreeItem
|
||||||
layerKey="nationality"
|
layerKey="nationality"
|
||||||
label={`국적 분류 (${Object.values(shipsByNationality).reduce((a, b) => a + b, 0)})`}
|
label="국적 분류"
|
||||||
|
count={Object.values(shipsByNationality).reduce((a, b) => a + b, 0)}
|
||||||
color="#8b5cf6"
|
color="#8b5cf6"
|
||||||
active
|
active
|
||||||
expandable
|
expandable
|
||||||
@ -342,7 +362,8 @@ export function LayerPanel({
|
|||||||
{/* Aircraft tree */}
|
{/* Aircraft tree */}
|
||||||
<LayerTreeItem
|
<LayerTreeItem
|
||||||
layerKey="aircraft"
|
layerKey="aircraft"
|
||||||
label={`${t('layers.aircraft')} (${aircraftTotal})`}
|
label={t('layers.aircraft')}
|
||||||
|
count={aircraftTotal}
|
||||||
color="#22d3ee"
|
color="#22d3ee"
|
||||||
active={layers.aircraft}
|
active={layers.aircraft}
|
||||||
expandable
|
expandable
|
||||||
@ -401,7 +422,8 @@ export function LayerPanel({
|
|||||||
{/* Satellites */}
|
{/* Satellites */}
|
||||||
<LayerTreeItem
|
<LayerTreeItem
|
||||||
layerKey="satellites"
|
layerKey="satellites"
|
||||||
label={`${t('layers.satellites')} (${satelliteCount})`}
|
label={t('layers.satellites')}
|
||||||
|
count={satelliteCount}
|
||||||
color="#ef4444"
|
color="#ef4444"
|
||||||
active={layers.satellites}
|
active={layers.satellites}
|
||||||
onToggle={() => onToggle('satellites')}
|
onToggle={() => onToggle('satellites')}
|
||||||
@ -421,47 +443,92 @@ export function LayerPanel({
|
|||||||
ungrouped.push(el);
|
ungrouped.push(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 수퍼그룹 별로 그룹 분류
|
||||||
|
const superGrouped: Record<string, string[]> = {}; // superGroup → groupNames[]
|
||||||
|
const noSuperGroup: string[] = [];
|
||||||
|
for (const groupName of Object.keys(grouped)) {
|
||||||
|
const sg = GROUP_META[groupName]?.superGroup;
|
||||||
|
if (sg) {
|
||||||
|
if (!superGrouped[sg]) superGrouped[sg] = [];
|
||||||
|
superGrouped[sg].push(groupName);
|
||||||
|
} else {
|
||||||
|
noSuperGroup.push(groupName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderGroup = (groupName: string, indent = false) => {
|
||||||
|
const meta = GROUP_META[groupName] || { label: groupName, color: '#888' };
|
||||||
|
const isGroupExpanded = expanded.has(`group-${groupName}`);
|
||||||
|
const items = grouped[groupName] || [];
|
||||||
|
return (
|
||||||
|
<div key={groupName} style={indent ? { paddingLeft: 10 } : undefined}>
|
||||||
|
<LayerTreeItem
|
||||||
|
layerKey={`group-${groupName}`}
|
||||||
|
label={meta.label}
|
||||||
|
color={meta.color}
|
||||||
|
active
|
||||||
|
expandable
|
||||||
|
isExpanded={isGroupExpanded}
|
||||||
|
onToggle={() => toggleExpand(`group-${groupName}`)}
|
||||||
|
onExpand={() => toggleExpand(`group-${groupName}`)}
|
||||||
|
/>
|
||||||
|
{isGroupExpanded && (
|
||||||
|
<div className="layer-tree-children">
|
||||||
|
{items.map(el => (
|
||||||
|
<LayerTreeItem
|
||||||
|
key={el.key}
|
||||||
|
layerKey={el.key}
|
||||||
|
label={el.label}
|
||||||
|
count={el.count}
|
||||||
|
color={el.color}
|
||||||
|
active={layers[el.key] ?? false}
|
||||||
|
onToggle={() => onToggle(el.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Grouped layers */}
|
{/* 수퍼그룹 없는 그룹들 (항공망·해양안전·국가기관망) */}
|
||||||
{Object.entries(grouped).map(([groupName, items]) => {
|
{noSuperGroup.map(g => renderGroup(g))}
|
||||||
const meta = GROUP_META[groupName] || { label: groupName, color: '#888' };
|
|
||||||
const isGroupExpanded = expanded.has(`group-${groupName}`);
|
{/* 수퍼그룹으로 묶인 그룹들 */}
|
||||||
|
{Object.entries(superGrouped).map(([sgName, groupNames]) => {
|
||||||
|
const sgMeta = SUPER_GROUP_META[sgName] || { label: sgName, color: '#f97316' };
|
||||||
|
const isSgExpanded = expanded.has(`supergroup-${sgName}`);
|
||||||
return (
|
return (
|
||||||
<div key={groupName}>
|
<div key={sgName}>
|
||||||
<LayerTreeItem
|
<LayerTreeItem
|
||||||
layerKey={`group-${groupName}`}
|
layerKey={`supergroup-${sgName}`}
|
||||||
label={meta.label}
|
label={sgMeta.label}
|
||||||
color={meta.color}
|
color={sgMeta.color}
|
||||||
active
|
active
|
||||||
expandable
|
expandable
|
||||||
isExpanded={isGroupExpanded}
|
isExpanded={isSgExpanded}
|
||||||
onToggle={() => toggleExpand(`group-${groupName}`)}
|
onToggle={() => toggleExpand(`supergroup-${sgName}`)}
|
||||||
onExpand={() => toggleExpand(`group-${groupName}`)}
|
onExpand={() => toggleExpand(`supergroup-${sgName}`)}
|
||||||
/>
|
/>
|
||||||
{isGroupExpanded && (
|
{isSgExpanded && (
|
||||||
<div className="layer-tree-children">
|
<div className="layer-tree-children">
|
||||||
{items.map(el => (
|
{groupNames.map(g => renderGroup(g, true))}
|
||||||
<LayerTreeItem
|
|
||||||
key={el.key}
|
|
||||||
layerKey={el.key}
|
|
||||||
label={el.count != null ? `${el.label} (${el.count})` : el.label}
|
|
||||||
color={el.color}
|
|
||||||
active={layers[el.key] ?? false}
|
|
||||||
onToggle={() => onToggle(el.key)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Ungrouped layers */}
|
|
||||||
|
{/* 그룹 없는 개별 레이어 */}
|
||||||
{ungrouped.map(el => (
|
{ungrouped.map(el => (
|
||||||
<LayerTreeItem
|
<LayerTreeItem
|
||||||
key={el.key}
|
key={el.key}
|
||||||
layerKey={el.key}
|
layerKey={el.key}
|
||||||
label={el.count != null ? `${el.label} (${el.count})` : el.label}
|
label={el.label}
|
||||||
|
count={el.count}
|
||||||
color={el.color}
|
color={el.color}
|
||||||
active={layers[el.key] ?? false}
|
active={layers[el.key] ?? false}
|
||||||
onToggle={() => onToggle(el.key)}
|
onToggle={() => onToggle(el.key)}
|
||||||
@ -473,14 +540,54 @@ export function LayerPanel({
|
|||||||
|
|
||||||
<div className="layer-divider" />
|
<div className="layer-divider" />
|
||||||
|
|
||||||
{/* Military only filter */}
|
{/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */}
|
||||||
<LayerTreeItem
|
<LayerTreeItem
|
||||||
layerKey="militaryOnly"
|
layerKey="overseas-section"
|
||||||
label={`${t('layers.militaryOnly')} (${militaryCount})`}
|
label="해외시설"
|
||||||
|
count={overseasItems?.reduce((sum, item) => {
|
||||||
|
const parentOn = layers[item.key] ? 1 : 0;
|
||||||
|
const childrenOn = item.children?.filter(c => layers[c.key]).length ?? 0;
|
||||||
|
return sum + parentOn + childrenOn;
|
||||||
|
}, 0) ?? 0}
|
||||||
color="#f97316"
|
color="#f97316"
|
||||||
active={layers.militaryOnly ?? false}
|
active={expanded.has('overseas-section')}
|
||||||
onToggle={() => onToggle('militaryOnly')}
|
expandable
|
||||||
|
isExpanded={expanded.has('overseas-section')}
|
||||||
|
onToggle={() => toggleExpand('overseas-section')}
|
||||||
|
onExpand={() => toggleExpand('overseas-section')}
|
||||||
/>
|
/>
|
||||||
|
{expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && (
|
||||||
|
<div className="layer-tree-children">
|
||||||
|
{overseasItems.map(item => (
|
||||||
|
<div key={item.key}>
|
||||||
|
<LayerTreeItem
|
||||||
|
layerKey={item.key}
|
||||||
|
label={item.label}
|
||||||
|
color={item.color}
|
||||||
|
active={layers[item.key] ?? false}
|
||||||
|
expandable={!!item.children?.length}
|
||||||
|
isExpanded={expanded.has(`overseas-${item.key}`)}
|
||||||
|
onToggle={() => onToggle(item.key)}
|
||||||
|
onExpand={() => toggleExpand(`overseas-${item.key}`)}
|
||||||
|
/>
|
||||||
|
{item.children?.length && expanded.has(`overseas-${item.key}`) && (
|
||||||
|
<div className="layer-tree-children">
|
||||||
|
{item.children.map(child => (
|
||||||
|
<LayerTreeItem
|
||||||
|
key={child.key}
|
||||||
|
layerKey={child.key}
|
||||||
|
label={child.label}
|
||||||
|
color={child.color}
|
||||||
|
active={layers[child.key] ?? false}
|
||||||
|
onToggle={() => onToggle(child.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -495,6 +602,7 @@ function LayerTreeItem({
|
|||||||
active,
|
active,
|
||||||
expandable,
|
expandable,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
count,
|
||||||
onToggle,
|
onToggle,
|
||||||
onExpand,
|
onExpand,
|
||||||
}: {
|
}: {
|
||||||
@ -504,6 +612,7 @@ function LayerTreeItem({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
expandable?: boolean;
|
expandable?: boolean;
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
|
count?: number;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onExpand?: () => void;
|
onExpand?: () => void;
|
||||||
}) {
|
}) {
|
||||||
@ -523,13 +632,16 @@ function LayerTreeItem({
|
|||||||
type="button"
|
type="button"
|
||||||
className={`layer-toggle ${active ? 'active' : ''}`}
|
className={`layer-toggle ${active ? 'active' : ''}`}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
style={{ padding: 0, gap: '6px' }}
|
style={{ padding: 0, gap: '6px', flex: 1, width: '100%' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="layer-dot"
|
className="layer-dot"
|
||||||
style={{ backgroundColor: active ? color : '#444' }}
|
style={{ backgroundColor: active ? color : '#444' }}
|
||||||
/>
|
/>
|
||||||
{label}
|
<span style={{ flex: 1 }}>{label}</span>
|
||||||
|
{count != null && (
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--kcg-dim)', flexShrink: 0 }}>{count}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
82
frontend/src/components/korea/CnFacilityLayer.tsx
Normal file
82
frontend/src/components/korea/CnFacilityLayer.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
|
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES, type CnFacility } from '../../data/cnFacilities';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: 'power' | 'military';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBTYPE_META: Record<CnFacility['subType'], { color: string; icon: string; label: string }> = {
|
||||||
|
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: '육군/사령부' },
|
||||||
|
shipyard: { color: '#f59e0b', icon: '⚙', label: '조선소' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CnFacilityLayer({ type }: Props) {
|
||||||
|
const [popup, setPopup] = useState<CnFacility | null>(null);
|
||||||
|
const facilities = type === 'power' ? CN_POWER_PLANTS : CN_MILITARY_FACILITIES;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{facilities.map(f => {
|
||||||
|
const meta = SUBTYPE_META[f.subType];
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={f.id}
|
||||||
|
longitude={f.lng}
|
||||||
|
latitude={f.lat}
|
||||||
|
anchor="center"
|
||||||
|
onClick={e => { e.originalEvent.stopPropagation(); setPopup(f); }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
title={f.name}
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: meta.color,
|
||||||
|
border: '2px solid rgba(255,255,255,0.7)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: `0 0 6px ${meta.color}88`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.icon}
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{popup && (
|
||||||
|
<Popup
|
||||||
|
longitude={popup.lng}
|
||||||
|
latitude={popup.lat}
|
||||||
|
anchor="bottom"
|
||||||
|
onClose={() => setPopup(null)}
|
||||||
|
closeOnClick={false}
|
||||||
|
maxWidth="220px"
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, lineHeight: 1.6, padding: '2px 4px' }}>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4 }}>{popup.name}</div>
|
||||||
|
<div style={{ color: SUBTYPE_META[popup.subType].color, marginBottom: 4 }}>
|
||||||
|
{SUBTYPE_META[popup.subType].label}
|
||||||
|
</div>
|
||||||
|
{popup.operator && (
|
||||||
|
<div><span style={{ opacity: 0.6 }}>운영:</span> {popup.operator}</div>
|
||||||
|
)}
|
||||||
|
{popup.description && (
|
||||||
|
<div style={{ marginTop: 4, opacity: 0.85 }}>{popup.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
855
frontend/src/components/korea/FieldAnalysisModal.tsx
Normal file
855
frontend/src/components/korea/FieldAnalysisModal.tsx
Normal file
@ -0,0 +1,855 @@
|
|||||||
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
|
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
|
||||||
|
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||||
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
|
import { lookupPermittedShip } from '../../services/chnPrmShip';
|
||||||
|
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
|
||||||
|
|
||||||
|
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
|
||||||
|
const mtPhotoCache = new Map<string, string | null>();
|
||||||
|
|
||||||
|
async function loadMarineTrafficPhoto(mmsi: string): Promise<string | null> {
|
||||||
|
if (mtPhotoCache.has(mmsi)) return mtPhotoCache.get(mmsi) ?? null;
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const url = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { mtPhotoCache.set(mmsi, url); resolve(url); };
|
||||||
|
img.onerror = () => { mtPhotoCache.set(mmsi, null); resolve(null); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// S&P Global 이미지 캐시
|
||||||
|
const spgCache = new Map<string, string | null>();
|
||||||
|
|
||||||
|
async function loadSpgPhoto(imo: string, shipImagePath: string): Promise<string | null> {
|
||||||
|
if (spgCache.has(imo)) return spgCache.get(imo) ?? null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/signal-batch/api/v1/shipimg/${imo}`);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data: Array<{ picId: number; path: string }> = await res.json();
|
||||||
|
const url = data.length > 0 ? `${data[0].path}_2.jpg` : `${shipImagePath.replace(/_[12]\.\w+$/, '')}_2.jpg`;
|
||||||
|
spgCache.set(imo, url);
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
const fallback = `${shipImagePath.replace(/_[12]\.\w+$/, '')}_2.jpg`;
|
||||||
|
spgCache.set(imo, fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 항상 다크 테마 색상 팔레트
|
||||||
|
const C = {
|
||||||
|
bg: '#07101A',
|
||||||
|
bg2: '#0C1825',
|
||||||
|
bg3: '#112033',
|
||||||
|
panel: '#040C14',
|
||||||
|
green: '#00E676',
|
||||||
|
cyan: '#18FFFF',
|
||||||
|
amber: '#FFD740',
|
||||||
|
red: '#FF5252',
|
||||||
|
purple: '#E040FB',
|
||||||
|
ink: '#CFE2F3',
|
||||||
|
ink2: '#7EA8C4',
|
||||||
|
ink3: '#3D6480',
|
||||||
|
border: '#1A3350',
|
||||||
|
border2: '#0E2035',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// AIS 수신 기준 선박 상태 분류 (Python 결과 없을 때 fallback)
|
||||||
|
function classifyStateFallback(ship: Ship): string {
|
||||||
|
const ageMins = (Date.now() - ship.lastSeen) / 60000;
|
||||||
|
if (ageMins > 20) return 'AIS_LOSS';
|
||||||
|
if (ship.speed <= 0.5) return 'STATIONARY';
|
||||||
|
if (ship.speed >= 5.0) return 'SAILING';
|
||||||
|
return 'FISHING';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python RiskLevel → 경보 등급 매핑
|
||||||
|
function riskToAlert(level: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' {
|
||||||
|
if (level === 'CRITICAL') return 'CRITICAL';
|
||||||
|
if (level === 'HIGH') return 'WATCH';
|
||||||
|
if (level === 'MEDIUM') return 'MONITOR';
|
||||||
|
return 'NORMAL';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(s: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
FISHING: '조업중', SAILING: '항행중', STATIONARY: '정박', AIS_LOSS: 'AIS소실',
|
||||||
|
};
|
||||||
|
return map[s] ?? s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneLabel(z: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
TERRITORIAL: '영해(침범!)', CONTIGUOUS: '접속수역', EEZ: 'EEZ', BEYOND: 'EEZ외측',
|
||||||
|
};
|
||||||
|
return map[z] ?? z;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface ProcessedVessel {
|
||||||
|
ship: Ship;
|
||||||
|
zone: string;
|
||||||
|
state: string;
|
||||||
|
alert: 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL';
|
||||||
|
vtype: string;
|
||||||
|
cluster: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
ts: string;
|
||||||
|
mmsi: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
level: 'critical' | 'watch' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ships: Ship[];
|
||||||
|
vesselAnalysis?: UseVesselAnalysisResult;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PIPE_STEPS = [
|
||||||
|
{ num: '01', name: 'AIS 전처리' },
|
||||||
|
{ num: '02', name: '행동 상태 탐지' },
|
||||||
|
{ num: '03', name: '궤적 리샘플링' },
|
||||||
|
{ num: '04', name: '특징 벡터 추출' },
|
||||||
|
{ num: '05', name: '규칙 기반 분류' },
|
||||||
|
{ num: '06', name: 'BIRCH 군집화' },
|
||||||
|
{ num: '07', name: '계절 활동 분석' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
|
||||||
|
|
||||||
|
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||||
|
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
|
||||||
|
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
|
||||||
|
const [activeFilter, setActiveFilter] = useState('ALL');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [pipeStep, setPipeStep] = useState(0);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
// 중국 어선만 필터
|
||||||
|
const cnFishing = useMemo(() => ships.filter(s => {
|
||||||
|
if (s.flag !== 'CN') return false;
|
||||||
|
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
|
return cat === 'fishing' || s.category === 'fishing';
|
||||||
|
}), [ships]);
|
||||||
|
|
||||||
|
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
|
||||||
|
const processed = useMemo((): ProcessedVessel[] => {
|
||||||
|
return cnFishing.map(ship => {
|
||||||
|
const dto = analysisMap.get(ship.mmsi);
|
||||||
|
|
||||||
|
// 수역: Python → GeoJSON 폴리곤 fallback
|
||||||
|
let zone: string;
|
||||||
|
if (dto) {
|
||||||
|
zone = dto.algorithms.location.zone;
|
||||||
|
} else {
|
||||||
|
const zoneInfo = classifyFishingZone(ship.lat, ship.lng);
|
||||||
|
zone = zoneInfo.zone === 'OUTSIDE' ? 'EEZ_OR_BEYOND' : zoneInfo.zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 행동 상태: Python → AIS fallback
|
||||||
|
const state = dto?.algorithms.activity.state ?? classifyStateFallback(ship);
|
||||||
|
|
||||||
|
// 경보 등급: Python 위험도 직접 사용
|
||||||
|
const alert = dto ? riskToAlert(dto.algorithms.riskScore.level) : 'NORMAL';
|
||||||
|
|
||||||
|
// 어구 분류: Python classification
|
||||||
|
const vtype = dto?.classification.vesselType ?? 'UNKNOWN';
|
||||||
|
|
||||||
|
// 클러스터: Python cluster ID
|
||||||
|
const clusterId = dto?.algorithms.cluster.clusterId ?? -1;
|
||||||
|
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
|
||||||
|
|
||||||
|
return { ship, zone, state, alert, vtype, cluster };
|
||||||
|
});
|
||||||
|
}, [cnFishing, analysisMap]);
|
||||||
|
|
||||||
|
// 필터 + 정렬
|
||||||
|
const displayed = useMemo(() => {
|
||||||
|
return processed
|
||||||
|
.filter(v => {
|
||||||
|
if (activeFilter === 'CRITICAL' && v.alert !== 'CRITICAL') return false;
|
||||||
|
if (activeFilter === 'FISHING' && v.state !== 'FISHING') return false;
|
||||||
|
if (activeFilter === 'AIS_LOSS' && v.state !== 'AIS_LOSS') return false;
|
||||||
|
if (activeFilter === 'TERRITORIAL' && v.zone !== 'TERRITORIAL') return false;
|
||||||
|
if (search && !v.ship.mmsi.includes(search) && !v.ship.name.toLowerCase().includes(search)) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => ALERT_ORDER[a.alert] - ALERT_ORDER[b.alert]);
|
||||||
|
}, [processed, activeFilter, search]);
|
||||||
|
|
||||||
|
// 통계 — Python 분석 결과 기반
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
let gpsAnomaly = 0;
|
||||||
|
for (const v of processed) {
|
||||||
|
const dto = analysisMap.get(v.ship.mmsi);
|
||||||
|
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: processed.length,
|
||||||
|
territorial: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length,
|
||||||
|
fishing: processed.filter(v => v.state === 'FISHING').length,
|
||||||
|
aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length,
|
||||||
|
gpsAnomaly,
|
||||||
|
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
|
||||||
|
trawl: processed.filter(v => v.vtype === 'TRAWL').length,
|
||||||
|
purse: processed.filter(v => v.vtype === 'PURSE').length,
|
||||||
|
};
|
||||||
|
}, [processed, analysisMap]);
|
||||||
|
|
||||||
|
// 구역별 카운트 — Python zone 분류 기반
|
||||||
|
const zoneCounts = useMemo(() => ({
|
||||||
|
terr: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length,
|
||||||
|
cont: processed.filter(v => v.zone === 'CONTIGUOUS_ZONE' || v.zone === 'ZONE_II').length,
|
||||||
|
eez: processed.filter(v => v.zone === 'EEZ_OR_BEYOND' || v.zone === 'ZONE_III' || v.zone === 'ZONE_IV').length,
|
||||||
|
beyond: processed.filter(v => !['TERRITORIAL_SEA', 'CONTIGUOUS_ZONE', 'EEZ_OR_BEYOND', 'ZONE_I', 'ZONE_II', 'ZONE_III', 'ZONE_IV'].includes(v.zone)).length,
|
||||||
|
}), [processed]);
|
||||||
|
|
||||||
|
// 초기 경보 로그 생성
|
||||||
|
useEffect(() => {
|
||||||
|
const initLogs: LogEntry[] = processed
|
||||||
|
.filter(v => v.alert === 'CRITICAL' || v.alert === 'WATCH')
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((v, i) => {
|
||||||
|
const t = new Date(Date.now() - i * 4 * 60000);
|
||||||
|
const ts = t.toTimeString().slice(0, 8);
|
||||||
|
const type =
|
||||||
|
v.zone === 'TERRITORIAL' ? '영해 내 불법조업 탐지' :
|
||||||
|
v.state === 'AIS_LOSS' ? 'AIS 신호 소실 — Dark Vessel 의심' :
|
||||||
|
'접속수역 조업 행위 감지';
|
||||||
|
return { ts, mmsi: v.ship.mmsi, name: v.ship.name || '(Unknown)', type, level: v.alert === 'CRITICAL' ? 'critical' : 'watch' };
|
||||||
|
});
|
||||||
|
setLogs(initLogs);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// AI 파이프라인 애니메이션
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setPipeStep(s => s + 1), 1200);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 시계 tick
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setTick(s => s + 1), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
void tick; // used to force re-render for clock
|
||||||
|
|
||||||
|
// Escape 키 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const selectedVessel = useMemo(() =>
|
||||||
|
selectedMmsi ? processed.find(v => v.ship.mmsi === selectedMmsi) ?? null : null,
|
||||||
|
[selectedMmsi, processed],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 허가 정보
|
||||||
|
const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle');
|
||||||
|
const [permitData, setPermitData] = useState<ChnPrmShipInfo | null>(null);
|
||||||
|
|
||||||
|
// 선박 사진
|
||||||
|
const [photoUrl, setPhotoUrl] = useState<string | null | undefined>(undefined); // undefined=로딩, null=없음
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedVessel) return;
|
||||||
|
const { ship } = selectedVessel;
|
||||||
|
|
||||||
|
// 허가 조회
|
||||||
|
setPermitStatus('loading');
|
||||||
|
setPermitData(null);
|
||||||
|
lookupPermittedShip(ship.mmsi).then(data => {
|
||||||
|
setPermitData(data);
|
||||||
|
setPermitStatus(data ? 'found' : 'not-found');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사진 로드: S&P Global 우선, 없으면 MarineTraffic
|
||||||
|
setPhotoUrl(undefined);
|
||||||
|
if (ship.imo && ship.shipImagePath) {
|
||||||
|
loadSpgPhoto(ship.imo, ship.shipImagePath).then(url => {
|
||||||
|
if (url) { setPhotoUrl(url); return; }
|
||||||
|
loadMarineTrafficPhoto(ship.mmsi).then(setPhotoUrl);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loadMarineTrafficPhoto(ship.mmsi).then(setPhotoUrl);
|
||||||
|
}
|
||||||
|
}, [selectedMmsi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const addLog = useCallback((mmsi: string, name: string, type: string, level: 'critical' | 'watch') => {
|
||||||
|
const ts = new Date().toTimeString().slice(0, 8);
|
||||||
|
setLogs(prev => [{ ts, mmsi, name, type, level }, ...prev].slice(0, 60));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const downloadCsv = useCallback(() => {
|
||||||
|
const headers = ['MMSI', '선명', '위도', '경도', 'SOG(kt)', '침로(°)', '상태', '선종', '구역', '클러스터', '경보등급', '마지막수신(분전)'];
|
||||||
|
const rows = processed.map(v => {
|
||||||
|
const ageMins = Math.floor((Date.now() - v.ship.lastSeen) / 60000);
|
||||||
|
return [
|
||||||
|
v.ship.mmsi,
|
||||||
|
v.ship.name || '',
|
||||||
|
v.ship.lat.toFixed(5),
|
||||||
|
v.ship.lng.toFixed(5),
|
||||||
|
v.state === 'AIS_LOSS' ? '' : v.ship.speed.toFixed(1),
|
||||||
|
v.state === 'AIS_LOSS' ? '' : String(v.ship.course),
|
||||||
|
stateLabel(v.state),
|
||||||
|
v.vtype,
|
||||||
|
zoneLabel(v.zone),
|
||||||
|
v.cluster,
|
||||||
|
v.alert,
|
||||||
|
String(ageMins),
|
||||||
|
].map(s => `"${s}"`).join(',');
|
||||||
|
});
|
||||||
|
const csv = [headers.join(','), ...rows].join('\n');
|
||||||
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `cn_fishing_vessels_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [processed]);
|
||||||
|
|
||||||
|
// 색상 헬퍼
|
||||||
|
const alertColor = (al: string) => ({ CRITICAL: C.red, WATCH: C.amber, MONITOR: C.cyan, NORMAL: C.green }[al] ?? C.ink3);
|
||||||
|
const zoneColor = (z: string) => ({ TERRITORIAL: C.red, CONTIGUOUS: C.amber, EEZ: C.cyan, BEYOND: C.green }[z] ?? C.ink3);
|
||||||
|
const stateColor = (s: string) => ({ FISHING: C.amber, SAILING: C.cyan, STATIONARY: C.green, AIS_LOSS: C.red }[s] ?? C.ink3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 2000,
|
||||||
|
background: 'rgba(2,6,14,0.96)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
fontFamily: "'IBM Plex Mono', 'Noto Sans KR', monospace",
|
||||||
|
}}>
|
||||||
|
{/* ── 헤더 */}
|
||||||
|
<div style={{
|
||||||
|
background: C.panel,
|
||||||
|
borderBottom: `1px solid ${C.border}`,
|
||||||
|
padding: '10px 20px',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: C.green, fontSize: 9, letterSpacing: 3 }}>▶ FIELD ANALYSIS</span>
|
||||||
|
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700, letterSpacing: 1 }}>중국 불법어업 현장분석 대시보드</span>
|
||||||
|
<span style={{ color: C.ink3, fontSize: 10 }}>AIS · 규칙분류 · BIRCH · Shepperson(2017) · Yan et al.(2022)</span>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||||
|
<span style={{ color: C.green, fontSize: 10, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: C.green, display: 'inline-block', animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,82,82,0.1)', border: `1px solid rgba(255,82,82,0.4)`,
|
||||||
|
color: C.red, padding: '4px 14px', cursor: 'pointer',
|
||||||
|
fontSize: 11, borderRadius: 2, fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕ 닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 통계 스트립 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 8, padding: '8px 12px',
|
||||||
|
background: C.bg, flexShrink: 0,
|
||||||
|
borderBottom: `1px solid ${C.border}`,
|
||||||
|
}}>
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<div key={label} style={{
|
||||||
|
flex: 1, background: C.bg2, border: `1px solid ${C.border}`,
|
||||||
|
borderRadius: 3, padding: '8px 10px', textAlign: 'center',
|
||||||
|
borderTop: `2px solid ${color}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 9, color: C.ink3, letterSpacing: 1 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, color, lineHeight: 1.2 }}>{val}</div>
|
||||||
|
<div style={{ fontSize: 9, color: C.ink3 }}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 메인 그리드 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flex: 1, overflow: 'hidden',
|
||||||
|
background: C.bg,
|
||||||
|
}}>
|
||||||
|
{/* ── 좌측 패널: 구역 현황 + AI 파이프라인 */}
|
||||||
|
<div style={{
|
||||||
|
width: 240, flexShrink: 0,
|
||||||
|
background: C.panel, borderRight: `1px solid ${C.border}`,
|
||||||
|
overflow: 'auto', padding: '10px 12px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 8, paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
|
||||||
|
구역별 현황
|
||||||
|
<span style={{ float: 'right', color: C.green, fontSize: 8 }}>●</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{([
|
||||||
|
{ 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 (
|
||||||
|
<div key={label} style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
|
<span style={{ fontSize: 10, color }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color }}>{count}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 4, background: C.border2, borderRadius: 2, overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', width: `${Math.min((count / max) * 100, 100)}%`, background: color, borderRadius: 2, transition: 'width 0.5s' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 9, color: C.ink3, marginTop: 2 }}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
|
||||||
|
AI 파이프라인 상태
|
||||||
|
<span style={{ float: 'right', color: C.green, fontSize: 8 }}>●</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{PIPE_STEPS.map((step, idx) => {
|
||||||
|
const isRunning = idx === pipeStep % PIPE_STEPS.length;
|
||||||
|
return (
|
||||||
|
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
|
||||||
|
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
|
||||||
|
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 8, padding: '1px 6px', borderRadius: 2,
|
||||||
|
background: isRunning ? 'rgba(0,230,118,0.15)' : 'rgba(0,230,118,0.06)',
|
||||||
|
border: `1px solid ${isRunning ? C.green : C.border}`,
|
||||||
|
color: isRunning ? C.green : C.ink3,
|
||||||
|
fontWeight: isRunning ? 700 : 400,
|
||||||
|
}}>
|
||||||
|
{isRunning ? 'PROC' : 'OK'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{[
|
||||||
|
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
|
||||||
|
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
|
||||||
|
].map(step => (
|
||||||
|
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
|
||||||
|
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
|
||||||
|
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 8, padding: '1px 6px', borderRadius: 2,
|
||||||
|
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: step.color,
|
||||||
|
}}>
|
||||||
|
{step.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 알고리즘 기준 요약 */}
|
||||||
|
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
|
||||||
|
알고리즘 기준
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 9, color }}>{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 중앙 패널: 선박 테이블 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* 필터 바 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 6, padding: '8px 12px', alignItems: 'center',
|
||||||
|
background: C.bg2, borderBottom: `1px solid ${C.border}`, flexShrink: 0,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
{[
|
||||||
|
{ key: 'ALL', label: '전체' },
|
||||||
|
{ key: 'CRITICAL', label: '긴급 경보' },
|
||||||
|
{ key: 'FISHING', label: '조업 중' },
|
||||||
|
{ key: 'AIS_LOSS', label: 'AIS 소실' },
|
||||||
|
{ key: 'TERRITORIAL', label: '영해 내' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveFilter(key)}
|
||||||
|
style={{
|
||||||
|
padding: '3px 10px', fontSize: 10, cursor: 'pointer',
|
||||||
|
borderRadius: 2, fontFamily: 'inherit',
|
||||||
|
background: activeFilter === key ? 'rgba(0,230,118,0.15)' : C.bg3,
|
||||||
|
border: `1px solid ${activeFilter === key ? C.green : C.border}`,
|
||||||
|
color: activeFilter === key ? C.green : C.ink2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: C.ink3, fontSize: 10, whiteSpace: 'nowrap' }}>
|
||||||
|
표시: <span style={{ color: C.cyan }}>{displayed.length}</span> 척
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={downloadCsv}
|
||||||
|
title="CSV 다운로드"
|
||||||
|
style={{
|
||||||
|
padding: '3px 10px', fontSize: 10, cursor: 'pointer',
|
||||||
|
borderRadius: 2, fontFamily: 'inherit', whiteSpace: 'nowrap',
|
||||||
|
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: C.cyan,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↓ CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'separate', borderSpacing: 0 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ position: 'sticky', top: 0, background: C.panel, zIndex: 1 }}>
|
||||||
|
{['AIS', 'MMSI', '선명', '위도', '경도', 'SOG', '침로', '상태', '선종', '구역', '클러스터', '경보', '수신'].map(h => (
|
||||||
|
<th key={h} style={{
|
||||||
|
padding: '6px 8px', fontSize: 9, color: C.ink3, fontWeight: 600,
|
||||||
|
letterSpacing: 1, textAlign: 'left',
|
||||||
|
borderBottom: `1px solid ${C.border}`, whiteSpace: 'nowrap',
|
||||||
|
}}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr
|
||||||
|
key={v.ship.mmsi}
|
||||||
|
onClick={() => setSelectedMmsi(v.ship.mmsi)}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? 'rgba(0,230,118,0.08)' : rowBg,
|
||||||
|
cursor: 'pointer',
|
||||||
|
outline: isSelected ? `1px solid ${C.green}` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '5px 8px' }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 7, height: 7, borderRadius: '50%',
|
||||||
|
background: v.state === 'AIS_LOSS' ? C.red : C.green,
|
||||||
|
}} />
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 10, color: C.cyan, whiteSpace: 'nowrap', padding: '5px 8px' }}>{v.ship.mmsi}</td>
|
||||||
|
<td style={{ fontSize: 10, color: '#fff', padding: '5px 8px', maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{v.ship.name || '(Unknown)'}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 10, color: C.ink2, padding: '5px 8px', whiteSpace: 'nowrap' }}>{v.ship.lat.toFixed(3)}°N</td>
|
||||||
|
<td style={{ fontSize: 10, color: C.ink2, padding: '5px 8px', whiteSpace: 'nowrap' }}>{v.ship.lng.toFixed(3)}°E</td>
|
||||||
|
<td style={{ fontSize: 10, color: C.amber, padding: '5px 8px', whiteSpace: 'nowrap' }}>
|
||||||
|
{v.state === 'AIS_LOSS' ? '—' : `${v.ship.speed.toFixed(1)}kt`}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 10, color: C.ink2, padding: '5px 8px', whiteSpace: 'nowrap' }}>
|
||||||
|
{v.state !== 'AIS_LOSS' ? `${v.ship.course}°` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '5px 8px' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, padding: '2px 5px', borderRadius: 2,
|
||||||
|
background: `${stateColor(v.state)}22`,
|
||||||
|
border: `1px solid ${stateColor(v.state)}66`,
|
||||||
|
color: stateColor(v.state),
|
||||||
|
}}>
|
||||||
|
{stateLabel(v.state)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '5px 8px' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, padding: '2px 5px', borderRadius: 2,
|
||||||
|
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: C.cyan,
|
||||||
|
}}>
|
||||||
|
{v.vtype}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '5px 8px' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, padding: '2px 5px', borderRadius: 2, whiteSpace: 'nowrap',
|
||||||
|
background: `${zoneColor(v.zone)}15`,
|
||||||
|
border: `1px solid ${zoneColor(v.zone)}55`,
|
||||||
|
color: zoneColor(v.zone),
|
||||||
|
}}>
|
||||||
|
{zoneLabel(v.zone)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '5px 8px', fontSize: 10, color: v.cluster !== '—' ? C.purple : C.ink3 }}>
|
||||||
|
{v.cluster}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '5px 8px' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, padding: '2px 5px', borderRadius: 2,
|
||||||
|
background: `${alertColor(v.alert)}15`,
|
||||||
|
border: `1px solid ${alertColor(v.alert)}55`,
|
||||||
|
color: alertColor(v.alert),
|
||||||
|
}}>
|
||||||
|
{v.alert}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 9, color: C.ink3, padding: '5px 8px', whiteSpace: 'nowrap' }}>
|
||||||
|
{ageMins < 60 ? `${ageMins}분전` : `${Math.floor(ageMins / 60)}시간전`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{displayed.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={13} style={{ padding: 32, textAlign: 'center', color: C.ink3, fontSize: 11 }}>
|
||||||
|
탐지된 중국 어선 없음
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 범례 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 16, padding: '6px 12px', alignItems: 'center',
|
||||||
|
background: C.bg2, borderTop: `1px solid ${C.border}`,
|
||||||
|
fontSize: 10, flexShrink: 0, flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
{[
|
||||||
|
{ color: C.red, label: 'CRITICAL — 즉시대응' },
|
||||||
|
{ color: C.amber, label: 'WATCH — 집중모니터링' },
|
||||||
|
{ color: C.cyan, label: 'MONITOR — 주시' },
|
||||||
|
{ color: C.green, label: 'NORMAL — 정상' },
|
||||||
|
].map(({ color, label }) => (
|
||||||
|
<span key={label} style={{ display: 'flex', alignItems: 'center', gap: 5, color: C.ink2 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span style={{ marginLeft: 'auto', color: C.ink3, fontSize: 9 }}>
|
||||||
|
AIS 4분 갱신 | Python 7단계 파이프라인 | DBSCAN 3NM 클러스터 | GeoJSON 수역 분류
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 우측 패널: 선박 상세 + 허가 정보 + 사진 + 경보 로그 */}
|
||||||
|
<div style={{
|
||||||
|
width: 280, flexShrink: 0,
|
||||||
|
background: C.panel, borderLeft: `1px solid ${C.border}`,
|
||||||
|
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* 패널 헤더 */}
|
||||||
|
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, padding: '10px 12px 6px', borderBottom: `1px solid ${C.border}`, flexShrink: 0 }}>
|
||||||
|
선박 상세 정보
|
||||||
|
<span style={{ float: 'right', color: C.green, fontSize: 8 }}>●</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스크롤 영역: 상세 + 허가 + 사진 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||||
|
{selectedVessel ? (
|
||||||
|
<>
|
||||||
|
{/* 기본 상세 필드 */}
|
||||||
|
<div style={{ padding: '8px 12px' }}>
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: `1px solid ${C.border2}` }}>
|
||||||
|
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 10, color, fontWeight: 600, textAlign: 'right', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addLog(selectedVessel.ship.mmsi, selectedVessel.ship.name || '', '대응 명령 발령', 'critical')}
|
||||||
|
style={{ flex: 1, padding: '5px 0', fontSize: 9, cursor: 'pointer', background: 'rgba(255,82,82,0.1)', border: `1px solid rgba(255,82,82,0.4)`, color: C.red, borderRadius: 2, fontFamily: 'inherit' }}
|
||||||
|
>대응 명령</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addLog(selectedVessel.ship.mmsi, selectedVessel.ship.name || '', 'ENG/드론 투입 명령', 'watch')}
|
||||||
|
style={{ flex: 1, padding: '5px 0', fontSize: 9, cursor: 'pointer', background: 'rgba(255,215,64,0.08)', border: `1px solid rgba(255,215,64,0.3)`, color: C.amber, borderRadius: 2, fontFamily: 'inherit' }}
|
||||||
|
>ENG/드론</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 허가 정보 */}
|
||||||
|
<div style={{ borderTop: `1px solid ${C.border}`, padding: '8px 12px' }}>
|
||||||
|
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 7 }}>허가 정보</div>
|
||||||
|
|
||||||
|
{/* 허가 여부 배지 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 9, color: C.ink3 }}>허가 여부</span>
|
||||||
|
{permitStatus === 'loading' && (
|
||||||
|
<span style={{ fontSize: 9, color: C.ink3 }}>조회 중...</span>
|
||||||
|
)}
|
||||||
|
{permitStatus === 'found' && (
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 10px', borderRadius: 2, background: 'rgba(0,230,118,0.15)', border: `1px solid ${C.green}`, color: C.green }}>
|
||||||
|
✓ 허가 선박
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{permitStatus === 'not-found' && (
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 10px', borderRadius: 2, background: 'rgba(255,82,82,0.12)', border: `1px solid ${C.red}`, color: C.red }}>
|
||||||
|
✕ 미등록 선박
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 허가 내역 (데이터 있을 때) */}
|
||||||
|
{permitStatus === 'found' && permitData && (
|
||||||
|
<div style={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 3, padding: '7px 10px' }}>
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: `1px solid ${C.border2}` }}>
|
||||||
|
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 9, color: C.ink, textAlign: 'right', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 미등록 안내 */}
|
||||||
|
{permitStatus === 'not-found' && (
|
||||||
|
<div style={{ background: 'rgba(255,82,82,0.06)', border: `1px solid rgba(255,82,82,0.2)`, borderRadius: 3, padding: '7px 10px' }}>
|
||||||
|
<div style={{ fontSize: 9, color: '#FF8A80', lineHeight: 1.6 }}>
|
||||||
|
한중어업협정 허가 DB에 등록되지 않은 선박입니다.<br />
|
||||||
|
불법어업 의심 — 추가 조사 및 조치 필요
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 선박 사진 */}
|
||||||
|
<div style={{ borderTop: `1px solid ${C.border}`, padding: '8px 12px 12px' }}>
|
||||||
|
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 7 }}>선박 사진</div>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: 140,
|
||||||
|
background: C.bg3, border: `1px solid ${C.border}`,
|
||||||
|
borderRadius: 3, overflow: 'hidden',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{photoUrl === undefined && (
|
||||||
|
<span style={{ fontSize: 9, color: C.ink3 }}>로딩 중...</span>
|
||||||
|
)}
|
||||||
|
{photoUrl === null && (
|
||||||
|
<span style={{ fontSize: 9, color: C.ink3 }}>사진 없음</span>
|
||||||
|
)}
|
||||||
|
{photoUrl && (
|
||||||
|
<img
|
||||||
|
src={photoUrl}
|
||||||
|
alt={selectedVessel.ship.name || '선박'}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
onError={() => setPhotoUrl(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{photoUrl && (
|
||||||
|
<div style={{ fontSize: 8, color: C.ink3, marginTop: 4, textAlign: 'right' }}>
|
||||||
|
© MarineTraffic / S&P Global
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '24px 0', textAlign: 'center', color: C.ink3, fontSize: 10 }}>
|
||||||
|
테이블에서 선박을 선택하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 경보 로그 — 하단 고정 */}
|
||||||
|
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, padding: '6px 12px', borderTop: `1px solid ${C.border}`, borderBottom: `1px solid ${C.border}`, flexShrink: 0, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>실시간 경보 로그</span>
|
||||||
|
<span style={{ color: C.ink3, fontSize: 8 }}>{logs.length}건</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '0 0 160px', overflow: 'auto' }}>
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
padding: '5px 12px',
|
||||||
|
borderBottom: `1px solid ${C.border2}`,
|
||||||
|
borderLeft: `2px solid ${log.level === 'critical' ? C.red : log.level === 'watch' ? C.amber : C.cyan}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 9, color: C.ink3 }}>{log.ts}</div>
|
||||||
|
<div style={{ fontSize: 10, lineHeight: 1.4, color: log.level === 'critical' ? '#FF8A80' : log.level === 'watch' ? '#FFE57F' : '#80DEEA' }}>
|
||||||
|
<span style={{ color: C.cyan }}>{log.mmsi}</span> {log.name} — {log.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center', color: C.ink3, fontSize: 10 }}>경보 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { Source, Layer } from 'react-map-gl/maplibre';
|
import { Source, Layer, Popup, useMap } from 'react-map-gl/maplibre';
|
||||||
import type { GeoJSON } from 'geojson';
|
import type { GeoJSON } from 'geojson';
|
||||||
|
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
||||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||||
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
|
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
|
||||||
import type { FleetCompany } from '../../services/vesselAnalysis';
|
import type { FleetCompany } from '../../services/vesselAnalysis';
|
||||||
@ -11,6 +12,12 @@ export interface SelectedGearGroupData {
|
|||||||
groupName: string;
|
groupName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SelectedFleetData {
|
||||||
|
clusterId: number;
|
||||||
|
ships: Ship[];
|
||||||
|
companyName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ships: Ship[];
|
ships: Ship[];
|
||||||
analysisMap: Map<string, VesselAnalysisDto>;
|
analysisMap: Map<string, VesselAnalysisDto>;
|
||||||
@ -18,6 +25,7 @@ interface Props {
|
|||||||
onShipSelect?: (mmsi: string) => void;
|
onShipSelect?: (mmsi: string) => void;
|
||||||
onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
|
onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
|
||||||
onSelectedGearChange?: (data: SelectedGearGroupData | null) => void;
|
onSelectedGearChange?: (data: SelectedGearGroupData | null) => void;
|
||||||
|
onSelectedFleetChange?: (data: SelectedFleetData | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별
|
// 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별
|
||||||
@ -90,18 +98,135 @@ interface ClusterLineFeature {
|
|||||||
|
|
||||||
type ClusterFeature = ClusterPolygonFeature | 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<Map<number, FleetCompany>>(new Map());
|
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
|
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
|
||||||
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
|
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
|
||||||
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
|
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
|
||||||
const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(null);
|
const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(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<number, string[]>; shipMap: Map<string, Ship>; gearGroupMap: Map<string, { parent: Ship | null; gears: Ship[] }>; onFleetZoom: Props['onFleetZoom'] }>({ clusters, shipMap: new Map(), gearGroupMap: new Map(), onFleetZoom });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFleetCompanies().then(setCompanies).catch(() => {});
|
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 맵 (어구 매칭용)
|
// 선박명 → mmsi 맵 (어구 매칭용)
|
||||||
const gearsByParent = useMemo(() => {
|
const gearsByParent = useMemo(() => {
|
||||||
const map = new Map<string, Ship[]>(); // parent_mmsi → gears
|
const map = new Map<string, Ship[]>(); // parent_mmsi → gears
|
||||||
@ -180,6 +305,9 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|||||||
return map;
|
return map;
|
||||||
}, [ships]);
|
}, [ships]);
|
||||||
|
|
||||||
|
// stale closure 방지 — shipMap/gearGroupMap 선언 이후 갱신
|
||||||
|
dataRef.current = { clusters, shipMap, gearGroupMap, onFleetZoom };
|
||||||
|
|
||||||
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용)
|
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedGearGroup) {
|
if (!selectedGearGroup) {
|
||||||
@ -194,6 +322,22 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|||||||
}
|
}
|
||||||
}, [selectedGearGroup, gearGroupMap, onSelectedGearChange]);
|
}, [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
|
// 비허가 어구 클러스터 GeoJSON
|
||||||
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
||||||
const features: GeoJSON.Feature[] = [];
|
const features: GeoJSON.Feature[] = [];
|
||||||
@ -457,6 +601,67 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
|
{/* 폴리곤 호버 툴팁 */}
|
||||||
|
{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 (
|
||||||
|
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
||||||
|
closeButton={false} closeOnClick={false} anchor="bottom"
|
||||||
|
className="gl-popup" maxWidth="220px"
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
||||||
|
<div style={{ fontWeight: 700, color: clusterColor(cid), marginBottom: 3 }}>
|
||||||
|
{company?.nameCn || `선단 #${cid}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#94a3b8' }}>선박 {mmsiList.length}척 · 어구 {gearCount}개</div>
|
||||||
|
{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 ? (
|
||||||
|
<div key={mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
|
||||||
|
{role === 'LEADER' ? '★' : '·'} {s.name || mmsi} <span style={{ color: '#4a6b82' }}>{s.speed?.toFixed(1)}kt</span>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 상세 보기</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hoverTooltip.type === 'gear') {
|
||||||
|
const name = hoverTooltip.id as string;
|
||||||
|
const entry = gearGroupMap.get(name);
|
||||||
|
if (!entry) return null;
|
||||||
|
return (
|
||||||
|
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
||||||
|
closeButton={false} closeOnClick={false} anchor="bottom"
|
||||||
|
className="gl-popup" maxWidth="220px"
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
||||||
|
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
|
||||||
|
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}>어구 {entry.gears.length}개</span>
|
||||||
|
</div>
|
||||||
|
{entry.parent && (
|
||||||
|
<div style={{ fontSize: 9, color: '#fbbf24' }}>모선: {entry.parent.name || entry.parent.mmsi}</div>
|
||||||
|
)}
|
||||||
|
{selectedGearGroup === name && entry.gears.slice(0, 5).map(g => (
|
||||||
|
<div key={g.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
|
||||||
|
· {g.name || g.mmsi}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 선택/해제</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 선단 목록 패널 */}
|
{/* 선단 목록 패널 */}
|
||||||
<div style={panelStyle}>
|
<div style={panelStyle}>
|
||||||
<div style={headerStyle}>
|
<div style={headerStyle}>
|
||||||
|
|||||||
110
frontend/src/components/korea/HazardFacilityLayer.tsx
Normal file
110
frontend/src/components/korea/HazardFacilityLayer.tsx
Normal file
@ -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<HazardType, { icon: string; color: string; label: string; bgColor: string }> = {
|
||||||
|
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<HazardFacility | null>(null);
|
||||||
|
const meta = TYPE_META[type];
|
||||||
|
const facilities = HAZARD_FACILITIES.filter(f => f.type === type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{facilities.map(f => (
|
||||||
|
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||||
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center cursor-pointer"
|
||||||
|
style={{ filter: `drop-shadow(0 0 4px ${meta.color}99)` }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 20, height: 20, borderRadius: 4,
|
||||||
|
background: 'rgba(0,0,0,0.75)',
|
||||||
|
border: `1.5px solid ${meta.color}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 11,
|
||||||
|
}}>
|
||||||
|
{meta.icon}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 5, color: meta.color, marginTop: 1,
|
||||||
|
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||||
|
whiteSpace: 'nowrap', fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{f.nameKo.length > 12 ? f.nameKo.slice(0, 12) + '..' : f.nameKo}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<Popup
|
||||||
|
longitude={selected.lng} latitude={selected.lat}
|
||||||
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
|
anchor="bottom" maxWidth="300px" className="gl-popup"
|
||||||
|
>
|
||||||
|
<div className="popup-body-sm" style={{ minWidth: 230 }}>
|
||||||
|
<div className="popup-header" style={{
|
||||||
|
background: meta.color, color: '#fff', gap: 6, padding: '6px 10px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16 }}>{meta.icon}</span>
|
||||||
|
<strong style={{ fontSize: 13 }}>{selected.nameKo}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||||
|
<span style={{
|
||||||
|
background: meta.color, color: '#fff',
|
||||||
|
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
background: 'rgba(239,68,68,0.2)', color: '#ef4444', border: '1px solid #ef4444',
|
||||||
|
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
⚠️ 위험시설
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, color: '#cbd5e1', marginBottom: 6, lineHeight: 1.5 }}>
|
||||||
|
{selected.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||||
|
{selected.address && (
|
||||||
|
<div><span className="popup-label">주소 : </span><strong>{selected.address}</strong></div>
|
||||||
|
)}
|
||||||
|
{selected.operator && (
|
||||||
|
<div><span className="popup-label">운영자 : </span><strong>{selected.operator}</strong></div>
|
||||||
|
)}
|
||||||
|
{selected.capacity && (
|
||||||
|
<div><span className="popup-label">처리규모 : </span><strong>{selected.capacity}</strong></div>
|
||||||
|
)}
|
||||||
|
<div><span className="popup-label">시설명(EN) : </span>{selected.name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 6, fontSize: 10, color: '#64748b' }}>
|
||||||
|
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
frontend/src/components/korea/JpFacilityLayer.tsx
Normal file
81
frontend/src/components/korea/JpFacilityLayer.tsx
Normal file
@ -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<JpFacility['subType'], { color: string; icon: string; label: string }> = {
|
||||||
|
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<JpFacility | null>(null);
|
||||||
|
const facilities = type === 'power' ? JP_POWER_PLANTS : JP_MILITARY_FACILITIES;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{facilities.map(f => {
|
||||||
|
const meta = SUBTYPE_META[f.subType];
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={f.id}
|
||||||
|
longitude={f.lng}
|
||||||
|
latitude={f.lat}
|
||||||
|
anchor="center"
|
||||||
|
onClick={e => { e.originalEvent.stopPropagation(); setPopup(f); }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
title={f.name}
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: meta.color,
|
||||||
|
border: '2px solid rgba(255,255,255,0.7)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: `0 0 6px ${meta.color}88`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.icon}
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{popup && (
|
||||||
|
<Popup
|
||||||
|
longitude={popup.lng}
|
||||||
|
latitude={popup.lat}
|
||||||
|
anchor="bottom"
|
||||||
|
onClose={() => setPopup(null)}
|
||||||
|
closeOnClick={false}
|
||||||
|
maxWidth="220px"
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, lineHeight: 1.6, padding: '2px 4px' }}>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4 }}>{popup.name}</div>
|
||||||
|
<div style={{ color: SUBTYPE_META[popup.subType].color, marginBottom: 4 }}>
|
||||||
|
{SUBTYPE_META[popup.subType].label}
|
||||||
|
</div>
|
||||||
|
{popup.operator && (
|
||||||
|
<div><span style={{ opacity: 0.6 }}>운영:</span> {popup.operator}</div>
|
||||||
|
)}
|
||||||
|
{popup.description && (
|
||||||
|
<div style={{ marginTop: 4, opacity: 0.85 }}>{popup.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,9 +20,10 @@ import { EezLayer } from './EezLayer';
|
|||||||
// PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer,
|
// PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer,
|
||||||
// NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨
|
// NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨
|
||||||
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
||||||
|
// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨
|
||||||
import { AnalysisOverlay } from './AnalysisOverlay';
|
import { AnalysisOverlay } from './AnalysisOverlay';
|
||||||
import { FleetClusterLayer } from './FleetClusterLayer';
|
import { FleetClusterLayer } from './FleetClusterLayer';
|
||||||
import type { SelectedGearGroupData } from './FleetClusterLayer';
|
import type { SelectedGearGroupData, SelectedFleetData } from './FleetClusterLayer';
|
||||||
import { FishingZoneLayer } from './FishingZoneLayer';
|
import { FishingZoneLayer } from './FishingZoneLayer';
|
||||||
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
|
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
|
||||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
@ -138,6 +139,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
|
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
|
||||||
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
|
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
|
||||||
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
|
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
|
||||||
|
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
|
||||||
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
|
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
|
||||||
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
||||||
|
|
||||||
@ -173,12 +175,20 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 줌 레벨별 아이콘/심볼 스케일 배율
|
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=1.0x 기준, 2단계씩 상향
|
||||||
|
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
|
||||||
const zoomScale = useMemo(() => {
|
const zoomScale = useMemo(() => {
|
||||||
if (zoomLevel <= 6) return 0.6;
|
if (zoomLevel <= 4) return 0.8;
|
||||||
if (zoomLevel <= 9) return 1.0;
|
if (zoomLevel <= 5) return 0.9;
|
||||||
if (zoomLevel <= 12) return 1.4;
|
if (zoomLevel <= 6) return 1.0;
|
||||||
return 1.8;
|
if (zoomLevel <= 7) return 1.2;
|
||||||
|
if (zoomLevel <= 8) return 1.5;
|
||||||
|
if (zoomLevel <= 9) return 1.8;
|
||||||
|
if (zoomLevel <= 10) return 2.2;
|
||||||
|
if (zoomLevel <= 11) return 2.5;
|
||||||
|
if (zoomLevel <= 12) return 2.8;
|
||||||
|
if (zoomLevel <= 13) return 3.5;
|
||||||
|
return 4.2;
|
||||||
}, [zoomLevel]);
|
}, [zoomLevel]);
|
||||||
|
|
||||||
// 불법어선 강조 — deck.gl ScatterplotLayer + TextLayer
|
// 불법어선 강조 — deck.gl ScatterplotLayer + TextLayer
|
||||||
@ -273,6 +283,21 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
piracy: layers.piracy ?? false,
|
piracy: layers.piracy ?? false,
|
||||||
infra: layers.infra ?? false,
|
infra: layers.infra ?? false,
|
||||||
infraFacilities: infra,
|
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),
|
onPick: (info) => setStaticPickInfo(info),
|
||||||
sizeScale: zoomScale,
|
sizeScale: zoomScale,
|
||||||
});
|
});
|
||||||
@ -353,6 +378,88 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
return layers;
|
return layers;
|
||||||
}, [selectedGearData, zoomScale]);
|
}, [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 레이어
|
// 분석 결과 deck.gl 레이어
|
||||||
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
|
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
|
||||||
: koreaFilters.darkVessel ? 'darkVessel'
|
: koreaFilters.darkVessel ? 'darkVessel'
|
||||||
@ -502,6 +609,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
|
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
|
||||||
{koreaFilters.illegalFishing && <FishingZoneLayer />}
|
{koreaFilters.illegalFishing && <FishingZoneLayer />}
|
||||||
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
|
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
|
||||||
|
{/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */}
|
||||||
{layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && (
|
{layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && (
|
||||||
<FleetClusterLayer
|
<FleetClusterLayer
|
||||||
ships={allShips ?? ships}
|
ships={allShips ?? ships}
|
||||||
@ -510,6 +618,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
onShipSelect={handleAnalysisShipSelect}
|
onShipSelect={handleAnalysisShipSelect}
|
||||||
onFleetZoom={handleFleetZoom}
|
onFleetZoom={handleFleetZoom}
|
||||||
onSelectedGearChange={setSelectedGearData}
|
onSelectedGearChange={setSelectedGearData}
|
||||||
|
onSelectedFleetChange={setSelectedFleetData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
|
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
|
||||||
@ -528,30 +637,203 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
illegalFishingLabelLayer,
|
illegalFishingLabelLayer,
|
||||||
zoneLabelsLayer,
|
zoneLabelsLayer,
|
||||||
...selectedGearLayers,
|
...selectedGearLayers,
|
||||||
|
...selectedFleetLayers,
|
||||||
...analysisDeckLayers,
|
...analysisDeckLayers,
|
||||||
].filter(Boolean)} />
|
].filter(Boolean)} />
|
||||||
{/* 정적 마커 클릭 Popup */}
|
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
|
||||||
{staticPickInfo && (() => {
|
{staticPickInfo && (() => {
|
||||||
const obj = staticPickInfo.object;
|
const obj = staticPickInfo.object;
|
||||||
|
const kind = staticPickInfo.kind;
|
||||||
const lat = obj.lat ?? obj.launchLat ?? 0;
|
const lat = obj.lat ?? obj.launchLat ?? 0;
|
||||||
const lng = obj.lng ?? obj.launchLng ?? 0;
|
const lng = obj.lng ?? obj.launchLng ?? 0;
|
||||||
if (!lat || !lng) return null;
|
if (!lat || !lng) return null;
|
||||||
|
|
||||||
|
// ── kind + subType 기반 메타 결정 ──
|
||||||
|
const SUB_META: Record<string, Record<string, { icon: string; color: string; label: string }>> = {
|
||||||
|
hazard: {
|
||||||
|
petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지' },
|
||||||
|
lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지' },
|
||||||
|
oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크' },
|
||||||
|
hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물항만하역' },
|
||||||
|
nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소' },
|
||||||
|
thermal: { icon: '🔥', color: '#64748b', label: '화력발전소' },
|
||||||
|
shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소' },
|
||||||
|
wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장' },
|
||||||
|
heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소' },
|
||||||
|
},
|
||||||
|
overseas: {
|
||||||
|
nuclear: { icon: '☢️', color: '#ef4444', label: '핵발전소' },
|
||||||
|
thermal: { icon: '🔥', color: '#f97316', label: '화력발전소' },
|
||||||
|
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
|
||||||
|
airbase: { icon: '✈️', color: '#22d3ee', label: '공군기지' },
|
||||||
|
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
|
||||||
|
shipyard:{ icon: '🚢', color: '#94a3b8', label: '조선소' },
|
||||||
|
},
|
||||||
|
militaryBase: {
|
||||||
|
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
|
||||||
|
airforce:{ icon: '✈️', color: '#22d3ee', label: '공군기지' },
|
||||||
|
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
|
||||||
|
missile: { icon: '🚀', color: '#ef4444', label: '미사일기지' },
|
||||||
|
joint: { icon: '⭐', color: '#f59e0b', label: '합동사령부' },
|
||||||
|
},
|
||||||
|
govBuilding: {
|
||||||
|
executive: { icon: '🏛️', color: '#f59e0b', label: '행정부' },
|
||||||
|
legislature: { icon: '🏛️', color: '#3b82f6', label: '입법부' },
|
||||||
|
military_hq: { icon: '⭐', color: '#ef4444', label: '군사령부' },
|
||||||
|
intelligence: { icon: '🔍', color: '#8b5cf6', label: '정보기관' },
|
||||||
|
foreign: { icon: '🌐', color: '#06b6d4', label: '외교부' },
|
||||||
|
maritime: { icon: '⚓', color: '#0ea5e9', label: '해양기관' },
|
||||||
|
defense: { icon: '🛡️', color: '#dc2626', label: '국방부' },
|
||||||
|
},
|
||||||
|
nkLaunch: {
|
||||||
|
icbm: { icon: '🚀', color: '#dc2626', label: 'ICBM 발사장' },
|
||||||
|
irbm: { icon: '🚀', color: '#ef4444', label: 'IRBM/MRBM' },
|
||||||
|
srbm: { icon: '🎯', color: '#f97316', label: '단거리탄도미사일' },
|
||||||
|
slbm: { icon: '🔱', color: '#3b82f6', label: 'SLBM 발사' },
|
||||||
|
cruise: { icon: '✈️', color: '#8b5cf6', label: '순항미사일' },
|
||||||
|
artillery: { icon: '💥', color: '#eab308', label: '해안포/장사정포' },
|
||||||
|
mlrs: { icon: '💥', color: '#f59e0b', label: '방사포(MLRS)' },
|
||||||
|
},
|
||||||
|
coastGuard: {
|
||||||
|
hq: { icon: '🏢', color: '#3b82f6', label: '본청' },
|
||||||
|
regional: { icon: '🏢', color: '#60a5fa', label: '지방청' },
|
||||||
|
station: { icon: '🚔', color: '#22d3ee', label: '해양경찰서' },
|
||||||
|
substation: { icon: '🏠', color: '#94a3b8', label: '파출소' },
|
||||||
|
vts: { icon: '📡', color: '#f59e0b', label: 'VTS센터' },
|
||||||
|
navy: { icon: '⚓', color: '#3b82f6', label: '해군부대' },
|
||||||
|
},
|
||||||
|
airport: {
|
||||||
|
international: { icon: '✈️', color: '#a78bfa', label: '국제공항' },
|
||||||
|
domestic: { icon: '🛩️', color: '#60a5fa', label: '국내공항' },
|
||||||
|
military: { icon: '✈️', color: '#ef4444', label: '군용비행장' },
|
||||||
|
},
|
||||||
|
navWarning: {
|
||||||
|
danger: { icon: '⚠️', color: '#ef4444', label: '위험' },
|
||||||
|
caution: { icon: '⚠️', color: '#eab308', label: '주의' },
|
||||||
|
info: { icon: 'ℹ️', color: '#3b82f6', label: '정보' },
|
||||||
|
},
|
||||||
|
piracy: {
|
||||||
|
critical: { icon: '☠️', color: '#ef4444', label: '극고위험' },
|
||||||
|
high: { icon: '☠️', color: '#f97316', label: '고위험' },
|
||||||
|
moderate: { icon: '☠️', color: '#eab308', label: '주의' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_DEFAULT: Record<string, { icon: string; color: string; label: string }> = {
|
||||||
|
port: { icon: '⚓', color: '#3b82f6', label: '항구' },
|
||||||
|
windFarm: { icon: '🌀', color: '#00bcd4', label: '풍력단지' },
|
||||||
|
militaryBase: { icon: '🪖', color: '#ef4444', label: '군사시설' },
|
||||||
|
govBuilding: { icon: '🏛️', color: '#f59e0b', label: '정부기관' },
|
||||||
|
nkLaunch: { icon: '🚀', color: '#dc2626', label: '북한 발사장' },
|
||||||
|
nkMissile: { icon: '💣', color: '#ef4444', label: '미사일 낙하' },
|
||||||
|
coastGuard: { icon: '🚔', color: '#4dabf7', label: '해양경찰' },
|
||||||
|
airport: { icon: '✈️', color: '#a78bfa', label: '공항' },
|
||||||
|
navWarning: { icon: '⚠️', color: '#eab308', label: '항행경보' },
|
||||||
|
piracy: { icon: '☠️', color: '#ef4444', label: '해적위험' },
|
||||||
|
infra: { icon: '⚡', color: '#ffeb3b', label: '발전/변전' },
|
||||||
|
hazard: { icon: '⚠️', color: '#ef4444', label: '위험시설' },
|
||||||
|
cnFacility: { icon: '📍', color: '#ef4444', label: '중국시설' },
|
||||||
|
jpFacility: { icon: '📍', color: '#f472b6', label: '일본시설' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// subType 키 결정
|
||||||
|
const subKey = obj.type ?? obj.subType ?? obj.level ?? '';
|
||||||
|
const subGroup = kind === 'cnFacility' || kind === 'jpFacility' ? 'overseas' : kind;
|
||||||
|
const meta = SUB_META[subGroup]?.[subKey] ?? KIND_DEFAULT[kind] ?? { icon: '📍', color: '#64748b', label: kind };
|
||||||
|
|
||||||
|
// 국가 플래그
|
||||||
|
const COUNTRY_FLAG: Record<string, string> = { CN: '🇨🇳', JP: '🇯🇵', KP: '🇰🇵', KR: '🇰🇷', TW: '🇹🇼' };
|
||||||
|
const flag = kind === 'cnFacility' ? '🇨🇳' : kind === 'jpFacility' ? '🇯🇵' : COUNTRY_FLAG[obj.country] ?? '';
|
||||||
|
const countryName = kind === 'cnFacility' ? '중국' : kind === 'jpFacility' ? '일본'
|
||||||
|
: { CN: '중국', JP: '일본', KP: '북한', KR: '한국', TW: '대만' }[obj.country] ?? '';
|
||||||
|
|
||||||
|
// 이름 결정
|
||||||
|
const title = obj.nameKo || obj.name || obj.launchNameKo || obj.title || kind;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup longitude={lng} latitude={lat} anchor="bottom"
|
<Popup longitude={lng} latitude={lat} anchor="bottom"
|
||||||
onClose={() => setStaticPickInfo(null)}
|
onClose={() => setStaticPickInfo(null)} closeOnClick={false}
|
||||||
closeOnClick={false}
|
maxWidth="280px" className="gl-popup"
|
||||||
style={{ maxWidth: 280 }}
|
|
||||||
>
|
>
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 11, color: '#333', padding: 4 }}>
|
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>
|
{/* 컬러 헤더 */}
|
||||||
{obj.nameKo || obj.name || obj.launchNameKo || obj.type || staticPickInfo.kind}
|
<div className="popup-header" style={{ background: meta.color, color: '#000', gap: 6, padding: '4px 8px' }}>
|
||||||
|
<span>{meta.icon}</span> {title}
|
||||||
|
</div>
|
||||||
|
{/* 배지 행 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{
|
||||||
|
background: meta.color, color: '#000',
|
||||||
|
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
{flag && (
|
||||||
|
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
|
||||||
|
{flag} {countryName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{kind === 'hazard' && (
|
||||||
|
<span style={{
|
||||||
|
background: 'rgba(239,68,68,0.15)', color: '#ef4444',
|
||||||
|
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 600,
|
||||||
|
border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
}}>⚠️ 위험시설</span>
|
||||||
|
)}
|
||||||
|
{kind === 'port' && (
|
||||||
|
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
|
||||||
|
{obj.type === 'major' ? '주요항' : '중소항'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{kind === 'airport' && obj.intl && (
|
||||||
|
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>국제선</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 설명 */}
|
||||||
|
{obj.description && (
|
||||||
|
<div style={{ fontSize: 10, color: '#999', marginBottom: 4, lineHeight: 1.5 }}>{obj.description}</div>
|
||||||
|
)}
|
||||||
|
{obj.detail && (
|
||||||
|
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.detail}</div>
|
||||||
|
)}
|
||||||
|
{obj.note && (
|
||||||
|
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.note}</div>
|
||||||
|
)}
|
||||||
|
{/* 필드 그리드 */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{obj.operator && <div><span className="popup-label">운영: </span>{obj.operator}</div>}
|
||||||
|
{obj.capacity && <div><span className="popup-label">규모: </span><strong>{obj.capacity}</strong></div>}
|
||||||
|
{obj.output && <div><span className="popup-label">출력: </span><strong>{obj.output}</strong></div>}
|
||||||
|
{obj.source && <div><span className="popup-label">연료: </span>{obj.source}</div>}
|
||||||
|
{obj.capacityMW && <div><span className="popup-label">용량: </span><strong>{obj.capacityMW}MW</strong></div>}
|
||||||
|
{obj.turbines && <div><span className="popup-label">터빈: </span>{obj.turbines}기</div>}
|
||||||
|
{obj.status && <div><span className="popup-label">상태: </span>{obj.status}</div>}
|
||||||
|
{obj.year && <div><span className="popup-label">연도: </span>{obj.year}년</div>}
|
||||||
|
{obj.region && <div><span className="popup-label">지역: </span>{obj.region}</div>}
|
||||||
|
{obj.org && <div><span className="popup-label">기관: </span>{obj.org}</div>}
|
||||||
|
{obj.area && <div><span className="popup-label">해역: </span>{obj.area}</div>}
|
||||||
|
{obj.altitude && <div><span className="popup-label">고도: </span>{obj.altitude}</div>}
|
||||||
|
{obj.address && <div><span className="popup-label">주소: </span>{obj.address}</div>}
|
||||||
|
{obj.recentUse && <div><span className="popup-label">최근 사용: </span>{obj.recentUse}</div>}
|
||||||
|
{obj.recentIncidents != null && <div><span className="popup-label">최근 1년: </span><strong>{obj.recentIncidents}건</strong></div>}
|
||||||
|
{obj.icao && <div><span className="popup-label">ICAO: </span>{obj.icao}</div>}
|
||||||
|
{kind === 'nkMissile' && (
|
||||||
|
<>
|
||||||
|
{obj.typeKo && <div><span className="popup-label">미사일: </span>{obj.typeKo}</div>}
|
||||||
|
{obj.date && <div><span className="popup-label">발사일: </span>{obj.date} {obj.time}</div>}
|
||||||
|
{obj.distanceKm && <div><span className="popup-label">사거리: </span>{obj.distanceKm}km</div>}
|
||||||
|
{obj.altitudeKm && <div><span className="popup-label">최고고도: </span>{obj.altitudeKm}km</div>}
|
||||||
|
{obj.flightMin && <div><span className="popup-label">비행시간: </span>{obj.flightMin}분</div>}
|
||||||
|
{obj.launchNameKo && <div><span className="popup-label">발사지: </span>{obj.launchNameKo}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{obj.name && obj.nameKo && obj.name !== obj.nameKo && (
|
||||||
|
<div><span className="popup-label">영문: </span>{obj.name}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
|
||||||
|
{lat.toFixed(4)}°N, {lng.toFixed(4)}°E
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{obj.description && <div style={{ fontSize: 10, color: '#666' }}>{obj.description}</div>}
|
|
||||||
{obj.date && <div style={{ fontSize: 10 }}>날짜: {obj.date} {obj.time || ''}</div>}
|
|
||||||
{obj.missileType && <div style={{ fontSize: 10 }}>미사일: {obj.missileType}</div>}
|
|
||||||
{obj.range && <div style={{ fontSize: 10 }}>사거리: {obj.range}</div>}
|
|
||||||
{obj.operator && <div style={{ fontSize: 10 }}>운영: {obj.operator}</div>}
|
|
||||||
{obj.capacity && <div style={{ fontSize: 10 }}>용량: {obj.capacity}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,7 +12,10 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
export function DeckGLOverlay({ layers }: Props) {
|
export function DeckGLOverlay({ layers }: Props) {
|
||||||
const overlay = useControl<MapboxOverlay>(
|
const overlay = useControl<MapboxOverlay>(
|
||||||
() => new MapboxOverlay({ interleaved: true }),
|
() => new MapboxOverlay({
|
||||||
|
interleaved: true,
|
||||||
|
getCursor: ({ isHovering }) => isHovering ? 'pointer' : '',
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
overlay.setProps({ layers });
|
overlay.setProps({ layers });
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -526,16 +526,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Source id="ships-source" type="geojson" data={shipGeoJson} promoteId="mmsi">
|
<Source id="ships-source" type="geojson" data={shipGeoJson} promoteId="mmsi">
|
||||||
{/* Hovered ship highlight ring */}
|
{/* Hovered ship highlight ring — feature-state는 paint에서만 지원 */}
|
||||||
<Layer
|
<Layer
|
||||||
id="ships-hover-ring"
|
id="ships-hover-ring"
|
||||||
type="circle"
|
type="circle"
|
||||||
filter={['boolean', ['feature-state', 'hovered'], false]}
|
|
||||||
paint={{
|
paint={{
|
||||||
'circle-radius': 18,
|
'circle-radius': ['case', ['boolean', ['feature-state', 'hovered'], false], 18, 0],
|
||||||
'circle-color': 'rgba(255, 255, 255, 0.1)',
|
'circle-color': 'rgba(255, 255, 255, 0.1)',
|
||||||
'circle-stroke-color': '#ffffff',
|
'circle-stroke-color': '#ffffff',
|
||||||
'circle-stroke-width': 2,
|
'circle-stroke-width': ['case', ['boolean', ['feature-state', 'hovered'], false], 2, 0],
|
||||||
'circle-stroke-opacity': 0.9,
|
'circle-stroke-opacity': 0.9,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -560,7 +559,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
layout={{
|
layout={{
|
||||||
'visibility': highlightKorean ? 'visible' : 'none',
|
'visibility': highlightKorean ? 'visible' : 'none',
|
||||||
'text-field': ['get', 'name'],
|
'text-field': ['get', 'name'],
|
||||||
'text-size': 9,
|
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 8, 6, 9, 8, 11, 10, 14, 12, 16, 13, 18, 14, 20],
|
||||||
'text-offset': [0, 2.2],
|
'text-offset': [0, 2.2],
|
||||||
'text-anchor': 'top',
|
'text-anchor': 'top',
|
||||||
'text-allow-overlap': false,
|
'text-allow-overlap': false,
|
||||||
@ -578,7 +577,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
type="symbol"
|
type="symbol"
|
||||||
layout={{
|
layout={{
|
||||||
'icon-image': 'ship-triangle',
|
'icon-image': 'ship-triangle',
|
||||||
'icon-size': ['get', 'size'],
|
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||||
|
4, ['*', ['get', 'size'], 0.8],
|
||||||
|
6, ['*', ['get', 'size'], 1.0],
|
||||||
|
8, ['*', ['get', 'size'], 1.5],
|
||||||
|
10, ['*', ['get', 'size'], 2.2],
|
||||||
|
12, ['*', ['get', 'size'], 2.8],
|
||||||
|
13, ['*', ['get', 'size'], 3.5],
|
||||||
|
14, ['*', ['get', 'size'], 4.2],
|
||||||
|
],
|
||||||
'icon-rotate': ['get', 'heading'],
|
'icon-rotate': ['get', 'heading'],
|
||||||
'icon-rotation-alignment': 'map',
|
'icon-rotation-alignment': 'map',
|
||||||
'icon-allow-overlap': true,
|
'icon-allow-overlap': true,
|
||||||
|
|||||||
141
frontend/src/data/cnFacilities.ts
Normal file
141
frontend/src/data/cnFacilities.ts
Normal file
@ -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: '동중국해 주력 함대 기지',
|
||||||
|
},
|
||||||
|
];
|
||||||
520
frontend/src/data/hazardFacilities.ts
Normal file
520
frontend/src/data/hazardFacilities.ts
Normal file
@ -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: '동해항 인근 시멘트·석회 생산·원료저장시설.',
|
||||||
|
},
|
||||||
|
];
|
||||||
150
frontend/src/data/jpFacilities.ts
Normal file
150
frontend/src/data/jpFacilities.ts
Normal file
@ -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 배치, 남서항공방면대 사령부',
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -25,6 +25,12 @@ import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWa
|
|||||||
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../services/piracy';
|
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../services/piracy';
|
||||||
import type { PiracyZone } from '../services/piracy';
|
import type { PiracyZone } from '../services/piracy';
|
||||||
import type { PowerFacility } from '../services/infra';
|
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 ───────────────────────────────
|
// ─── Type alias to avoid 'any' in PickingInfo ───────────────────────────────
|
||||||
|
|
||||||
@ -39,7 +45,10 @@ export type StaticPickedObject =
|
|||||||
| KoreanAirport
|
| KoreanAirport
|
||||||
| NavWarning
|
| NavWarning
|
||||||
| PiracyZone
|
| PiracyZone
|
||||||
| PowerFacility;
|
| PowerFacility
|
||||||
|
| HazardFacility
|
||||||
|
| CnFacility
|
||||||
|
| JpFacility;
|
||||||
|
|
||||||
export type StaticLayerKind =
|
export type StaticLayerKind =
|
||||||
| 'port'
|
| 'port'
|
||||||
@ -52,7 +61,10 @@ export type StaticLayerKind =
|
|||||||
| 'airport'
|
| 'airport'
|
||||||
| 'navWarning'
|
| 'navWarning'
|
||||||
| 'piracy'
|
| 'piracy'
|
||||||
| 'infra';
|
| 'infra'
|
||||||
|
| 'hazard'
|
||||||
|
| 'cnFacility'
|
||||||
|
| 'jpFacility';
|
||||||
|
|
||||||
export interface StaticPickInfo {
|
export interface StaticPickInfo {
|
||||||
kind: StaticLayerKind;
|
kind: StaticLayerKind;
|
||||||
@ -72,6 +84,11 @@ interface StaticLayerConfig {
|
|||||||
piracy: boolean;
|
piracy: boolean;
|
||||||
infra: boolean;
|
infra: boolean;
|
||||||
infraFacilities: PowerFacility[];
|
infraFacilities: PowerFacility[];
|
||||||
|
hazardTypes: HazardType[];
|
||||||
|
cnPower: boolean;
|
||||||
|
cnMilitary: boolean;
|
||||||
|
jpPower: boolean;
|
||||||
|
jpMilitary: boolean;
|
||||||
onPick: (info: StaticPickInfo) => void;
|
onPick: (info: StaticPickInfo) => void;
|
||||||
sizeScale?: number; // 줌 레벨별 스케일 배율 (기본 1.0)
|
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<string, { icon: string; color: [number, number, number, number] }> = {
|
||||||
|
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<HazardFacility>({
|
||||||
|
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<HazardFacility>) => {
|
||||||
|
if (info.object) config.onPick({ kind: 'hazard', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<HazardFacility>({
|
||||||
|
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 * sc,
|
||||||
|
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<string, { icon: string; color: [number, number, number, number] }> = {
|
||||||
|
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<CnFacility>({
|
||||||
|
id: 'static-cn-emoji',
|
||||||
|
data: cnData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => CN_META[d.subType]?.icon ?? '📍',
|
||||||
|
getSize: 16 * sc,
|
||||||
|
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'center',
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<CnFacility>) => {
|
||||||
|
if (info.object) config.onPick({ kind: 'cnFacility', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<CnFacility>({
|
||||||
|
id: 'static-cn-label',
|
||||||
|
data: cnData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => d.name,
|
||||||
|
getSize: 9 * sc,
|
||||||
|
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<string, { icon: string; color: [number, number, number, number] }> = {
|
||||||
|
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<JpFacility>({
|
||||||
|
id: 'static-jp-emoji',
|
||||||
|
data: jpData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => JP_META[d.subType]?.icon ?? '📍',
|
||||||
|
getSize: 16 * sc,
|
||||||
|
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'center',
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<JpFacility>) => {
|
||||||
|
if (info.object) config.onPick({ kind: 'jpFacility', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<JpFacility>({
|
||||||
|
id: 'static-jp-label',
|
||||||
|
data: jpData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => d.name,
|
||||||
|
getSize: 9 * sc,
|
||||||
|
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;
|
return layers;
|
||||||
// infraFacilities는 배열 참조가 바뀌어야 갱신
|
// infraFacilities는 배열 참조가 바뀌어야 갱신
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -882,6 +1069,11 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] {
|
|||||||
config.nkMissile,
|
config.nkMissile,
|
||||||
config.infra,
|
config.infra,
|
||||||
config.infraFacilities,
|
config.infraFacilities,
|
||||||
|
config.hazardTypes,
|
||||||
|
config.cnPower,
|
||||||
|
config.cnMilitary,
|
||||||
|
config.jpPower,
|
||||||
|
config.jpMilitary,
|
||||||
config.onPick,
|
config.onPick,
|
||||||
config.sizeScale,
|
config.sizeScale,
|
||||||
]);
|
]);
|
||||||
@ -889,5 +1081,6 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] {
|
|||||||
|
|
||||||
// Re-export types that KoreaMap will need for Popup rendering
|
// 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 { 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
|
// 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 };
|
export { CG_TYPE_COLOR, PORT_COUNTRY_COLOR, WIND_COLOR, NW_ORG_COLOR, PIRACY_LEVEL_COLOR, getMissileColor };
|
||||||
|
|||||||
@ -77,7 +77,7 @@
|
|||||||
"airports": "공항",
|
"airports": "공항",
|
||||||
"sensorCharts": "센서 차트",
|
"sensorCharts": "센서 차트",
|
||||||
"oilFacilities": "유전시설",
|
"oilFacilities": "유전시설",
|
||||||
"militaryOnly": "군용기만",
|
"militaryOnly": "해외시설",
|
||||||
"infra": "발전/변전",
|
"infra": "발전/변전",
|
||||||
"cables": "해저케이블",
|
"cables": "해저케이블",
|
||||||
"cctv": "CCTV",
|
"cctv": "CCTV",
|
||||||
|
|||||||
146
frontend/src/services/disasterNews.ts
Normal file
146
frontend/src/services/disasterNews.ts
Normal file
@ -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<DisasterNewsItem['category'], string> = {
|
||||||
|
typhoon: '🌀',
|
||||||
|
flood: '🌊',
|
||||||
|
earthquake: '⚡',
|
||||||
|
fire: '🔥',
|
||||||
|
sea: '⚓',
|
||||||
|
chemical: '☣️',
|
||||||
|
safety: '🦺',
|
||||||
|
general: '📢',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAT_COLOR: Record<DisasterNewsItem['category'], string> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -243,7 +243,67 @@ function extractMELocation(text: string): { lat: number; lng: number } | null {
|
|||||||
// ── CENTCOM 최신 게시물 (수동 업데이트 — RSS 대체) ──
|
// ── CENTCOM 최신 게시물 (수동 업데이트 — RSS 대체) ──
|
||||||
// Nitter/RSSHub 모두 X.com 차단으로 사용 불가하므로 주요 CENTCOM 게시물 수동 관리
|
// Nitter/RSSHub 모두 X.com 차단으로 사용 불가하므로 주요 CENTCOM 게시물 수동 관리
|
||||||
const CENTCOM_POSTS: { text: string; date: string; url: string }[] = [
|
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',
|
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',
|
date: '2026-03-16T06:00:00Z',
|
||||||
@ -404,7 +464,132 @@ async function fetchXCentcom(): Promise<OsintItem[]> {
|
|||||||
|
|
||||||
// ── Pinned OSINT articles (manually curated) ──
|
// ── Pinned OSINT articles (manually curated) ──
|
||||||
const PINNED_IRAN: OsintItem[] = [
|
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',
|
id: 'pinned-kr-isfahan-0316',
|
||||||
timestamp: new Date('2026-03-16T10:00:00+09:00').getTime(),
|
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',
|
url: 'https://www.yna.co.kr',
|
||||||
category: 'military',
|
category: 'military',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 32.65,
|
lat: 32.65, lng: 51.67,
|
||||||
lng: 51.67,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pinned-kr-ceasefire-0316',
|
id: 'pinned-kr-ceasefire-0316',
|
||||||
@ -424,42 +608,18 @@ const PINNED_IRAN: OsintItem[] = [
|
|||||||
url: 'https://www.voakorea.com',
|
url: 'https://www.voakorea.com',
|
||||||
category: 'diplomacy',
|
category: 'diplomacy',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 35.69,
|
lat: 35.69, lng: 51.39,
|
||||||
lng: 51.39,
|
|
||||||
},
|
},
|
||||||
// ── 3월 15일 ──
|
// ── 3월 15일 ──
|
||||||
{
|
{
|
||||||
id: 'pinned-kr-hormuz-派兵-0315',
|
id: 'pinned-kr-hormuz-파병-0315',
|
||||||
timestamp: new Date('2026-03-15T18:00:00+09:00').getTime(),
|
timestamp: new Date('2026-03-15T18:00:00+09:00').getTime(),
|
||||||
title: '[단독] 트럼프, 한국 등 5개국에 호르무즈 군함 파견 요구… 청해부대식 파병 논의',
|
title: '[단독] 트럼프, 한국 등 5개국에 호르무즈 군함 파견 요구… 청해부대식 파병 논의',
|
||||||
source: '뉴데일리',
|
source: '뉴데일리',
|
||||||
url: 'https://www.newdaily.co.kr',
|
url: 'https://www.newdaily.co.kr',
|
||||||
category: 'military',
|
category: 'military',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.56,
|
lat: 26.56, lng: 56.25,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pinned-kr-kospi-0315',
|
id: 'pinned-kr-kospi-0315',
|
||||||
@ -469,8 +629,7 @@ const PINNED_IRAN: OsintItem[] = [
|
|||||||
url: 'https://biz.newdaily.co.kr',
|
url: 'https://biz.newdaily.co.kr',
|
||||||
category: 'oil',
|
category: 'oil',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 37.57,
|
lat: 37.57, lng: 126.98,
|
||||||
lng: 126.98,
|
|
||||||
},
|
},
|
||||||
// ── 3월 14일 ──
|
// ── 3월 14일 ──
|
||||||
{
|
{
|
||||||
@ -481,8 +640,7 @@ const PINNED_IRAN: OsintItem[] = [
|
|||||||
url: 'https://www.mk.co.kr',
|
url: 'https://www.mk.co.kr',
|
||||||
category: 'oil',
|
category: 'oil',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.56,
|
lat: 26.56, lng: 56.25,
|
||||||
lng: 56.25,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pinned-kr-hormuz-shutdown-0314',
|
id: 'pinned-kr-hormuz-shutdown-0314',
|
||||||
@ -492,8 +650,7 @@ const PINNED_IRAN: OsintItem[] = [
|
|||||||
url: 'https://news.ifm.kr',
|
url: 'https://news.ifm.kr',
|
||||||
category: 'shipping',
|
category: 'shipping',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.56,
|
lat: 26.56, lng: 56.25,
|
||||||
lng: 56.25,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pinned-kr-tanker-0314',
|
id: 'pinned-kr-tanker-0314',
|
||||||
@ -503,8 +660,7 @@ const PINNED_IRAN: OsintItem[] = [
|
|||||||
url: 'https://www.bloomberg.com',
|
url: 'https://www.bloomberg.com',
|
||||||
category: 'shipping',
|
category: 'shipping',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.56,
|
lat: 26.56, lng: 56.25,
|
||||||
lng: 56.25,
|
|
||||||
},
|
},
|
||||||
// ── 3월 13일 ──
|
// ── 3월 13일 ──
|
||||||
{
|
{
|
||||||
@ -515,8 +671,7 @@ const PINNED_IRAN: OsintItem[] = [
|
|||||||
url: 'https://www.yna.co.kr',
|
url: 'https://www.yna.co.kr',
|
||||||
category: 'shipping',
|
category: 'shipping',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.56,
|
lat: 26.56, lng: 56.25,
|
||||||
lng: 56.25,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pinned-kr-hormuz-0313b',
|
id: 'pinned-kr-hormuz-0313b',
|
||||||
@ -526,8 +681,7 @@ const PINNED_IRAN: OsintItem[] = [
|
|||||||
url: 'https://news.kbs.co.kr',
|
url: 'https://news.kbs.co.kr',
|
||||||
category: 'military',
|
category: 'military',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.30,
|
lat: 26.30, lng: 56.50,
|
||||||
lng: 56.50,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pinned-kr-ship-0312',
|
id: 'pinned-kr-ship-0312',
|
||||||
@ -537,14 +691,88 @@ const PINNED_IRAN: OsintItem[] = [
|
|||||||
url: 'https://news.sbs.co.kr',
|
url: 'https://news.sbs.co.kr',
|
||||||
category: 'shipping',
|
category: 'shipping',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.20,
|
lat: 26.20, lng: 56.60,
|
||||||
lng: 56.60,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Pinned OSINT articles (Korea maritime/security) ──
|
// ── Pinned OSINT articles (Korea maritime/security) ──
|
||||||
const PINNED_KOREA: OsintItem[] = [
|
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',
|
id: 'pin-kr-nk-missile-0315',
|
||||||
timestamp: new Date('2026-03-15T07:00:00+09:00').getTime(),
|
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',
|
url: 'https://www.yna.co.kr',
|
||||||
category: 'military',
|
category: 'military',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 39.00,
|
lat: 39.00, lng: 127.00,
|
||||||
lng: 127.00,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pin-kr-nk-kimyojong-0315',
|
id: 'pin-kr-nk-kimyojong-0315',
|
||||||
@ -564,8 +791,7 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://news.kbs.co.kr',
|
url: 'https://news.kbs.co.kr',
|
||||||
category: 'military',
|
category: 'military',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 39.00,
|
lat: 39.00, lng: 125.75,
|
||||||
lng: 125.75,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pin-kr-hormuz-deploy-0315',
|
id: 'pin-kr-hormuz-deploy-0315',
|
||||||
@ -575,32 +801,9 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://www.newdaily.co.kr',
|
url: 'https://www.newdaily.co.kr',
|
||||||
category: 'military',
|
category: 'military',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.56,
|
lat: 26.56, lng: 56.25,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
// ── 3월 14일 ──
|
// ── 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',
|
id: 'pin-kr-freedom-shield-0314',
|
||||||
timestamp: new Date('2026-03-14T09:00:00+09:00').getTime(),
|
timestamp: new Date('2026-03-14T09:00:00+09:00').getTime(),
|
||||||
@ -609,8 +812,7 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://imnews.imbc.com',
|
url: 'https://imnews.imbc.com',
|
||||||
category: 'military',
|
category: 'military',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 37.50,
|
lat: 37.50, lng: 127.00,
|
||||||
lng: 127.00,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pin-kr-hmm-0314',
|
id: 'pin-kr-hmm-0314',
|
||||||
@ -620,8 +822,7 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://www.haesanews.com',
|
url: 'https://www.haesanews.com',
|
||||||
category: 'shipping',
|
category: 'shipping',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.00,
|
lat: 26.00, lng: 56.00,
|
||||||
lng: 56.00,
|
|
||||||
},
|
},
|
||||||
// ── 3월 13일 ──
|
// ── 3월 13일 ──
|
||||||
{
|
{
|
||||||
@ -632,8 +833,7 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://en.sedaily.com',
|
url: 'https://en.sedaily.com',
|
||||||
category: 'oil',
|
category: 'oil',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 37.57,
|
lat: 37.57, lng: 126.98,
|
||||||
lng: 126.98,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pin-kr-coast-guard-0313',
|
id: 'pin-kr-coast-guard-0313',
|
||||||
@ -643,8 +843,7 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://www.asiae.co.kr',
|
url: 'https://www.asiae.co.kr',
|
||||||
category: 'maritime_traffic',
|
category: 'maritime_traffic',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 37.67,
|
lat: 37.67, lng: 125.70,
|
||||||
lng: 125.70,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pin-kr-nk-destroyer-0312',
|
id: 'pin-kr-nk-destroyer-0312',
|
||||||
@ -654,8 +853,7 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://www.aei.org',
|
url: 'https://www.aei.org',
|
||||||
category: 'military',
|
category: 'military',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 39.80,
|
lat: 39.80, lng: 127.50,
|
||||||
lng: 127.50,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pin-kr-oil-reserve-0312',
|
id: 'pin-kr-oil-reserve-0312',
|
||||||
@ -665,19 +863,7 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://www.hankyung.com',
|
url: 'https://www.hankyung.com',
|
||||||
category: 'oil',
|
category: 'oil',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 36.97,
|
lat: 36.97, lng: 126.83,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pin-kr-chinese-fishing-0311',
|
id: 'pin-kr-chinese-fishing-0311',
|
||||||
@ -687,19 +873,7 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://www.asiaa.co.kr',
|
url: 'https://www.asiaa.co.kr',
|
||||||
category: 'fishing',
|
category: 'fishing',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 37.67,
|
lat: 37.67, lng: 125.50,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pin-kr-ships-hormuz-0311',
|
id: 'pin-kr-ships-hormuz-0311',
|
||||||
@ -709,8 +883,7 @@ const PINNED_KOREA: OsintItem[] = [
|
|||||||
url: 'https://www.seoul.co.kr',
|
url: 'https://www.seoul.co.kr',
|
||||||
category: 'shipping',
|
category: 'shipping',
|
||||||
language: 'ko',
|
language: 'ko',
|
||||||
lat: 26.56,
|
lat: 26.56, lng: 56.25,
|
||||||
lng: 56.25,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -144,6 +144,16 @@ export interface LayerVisibility {
|
|||||||
oilFacilities: boolean;
|
oilFacilities: boolean;
|
||||||
meFacilities: boolean;
|
meFacilities: boolean;
|
||||||
militaryOnly: 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';
|
export type AppMode = 'replay' | 'live';
|
||||||
|
|||||||
19
prediction/cache/vessel_store.py
vendored
19
prediction/cache/vessel_store.py
vendored
@ -113,12 +113,29 @@ class VesselStore:
|
|||||||
for mmsi, group in df_all.groupby('mmsi'):
|
for mmsi, group in df_all.groupby('mmsi'):
|
||||||
self._tracks[str(mmsi)] = group.reset_index(drop=True)
|
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)
|
vessel_count = len(self._tracks)
|
||||||
point_count = sum(len(v) for v in self._tracks.values())
|
point_count = sum(len(v) for v in self._tracks.values())
|
||||||
logger.info(
|
logger.info(
|
||||||
'initial load complete: %d vessels, %d total points',
|
'initial load complete: %d vessels, %d total points, last_bucket=%s',
|
||||||
vessel_count,
|
vessel_count,
|
||||||
point_count,
|
point_count,
|
||||||
|
self._last_bucket,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.refresh_static_info()
|
self.refresh_static_info()
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user