Compare commits

...

27 커밋

작성자 SHA1 메시지 날짜
Nan Kyung Lee
d53eff8287 fix: Google TTS CORS 우회 — Vite 프록시 /api/gtts 추가 2026-03-24 15:59:33 +09:00
Nan Kyung Lee
4ab7990e5d fix: 중국어 TTS → Google Translate TTS로 변경 (고품질 발음) 2026-03-24 15:57:37 +09:00
Nan Kyung Lee
2c4535e57e fix: 중국어 TTS 끊김 해결 — Chrome pause/resume keepalive 2026-03-24 15:56:35 +09:00
Nan Kyung Lee
8b74f455df feat(korea): 중국어 경고문 TTS 음성 재생 (Web Speech API)
- 경고문 옆 🔊 버튼 클릭 → 중국어(zh-CN) 음성 재생
- SpeechSynthesis API 사용 (브라우저 내장, API 키 불필요)
- 재생 중 버튼 애니메이션 (pulse) + 배경 하이라이트
- 재생 속도 0.85x (확성기 방송용 느린 발화)
- 클릭: 클립보드 복사 / 🔊: 음성 재생 분리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:54:43 +09:00
Nan Kyung Lee
1aa887fce4 feat(korea): 작전가이드 3탭 구성 — 실시간탐지 + 대응절차 + 조치기준
- 3개 탭: 실시간 탐지 / 대응 절차 / 조치 기준
- 의심 선박 클릭 → 자동으로 대응 절차 탭 전환
- 선박 추정 업종(PT/GN/PS/FC/GEAR) 자동 분류 → 해당 STEP 표시
- 중국어 경고문 업종별 배치 (클릭 → 클립보드 복사)
  PT: 4개, GN: 4개, PS: 4개, FC: 3개, GEAR: 1개
- 조치 기준 탭: 8대 위반유형 테이블 + 감시 강화 시기
- GC-KCG-2026-001 제7장 작전가이드 PDF 전문 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:51:04 +09:00
Nan Kyung Lee
612973e9ab feat(korea): 임검침로 해상 루트 — 육지 우회 경유점 자동 삽입
- 한반도 해안 웨이포인트 14개 정의 (서해→남해→동해 시계방향)
- 육지 바운딩박스 2개 (본토 + 제주도)
- 직선이 육지 관통 시 해안 경유점 자동 삽입
- 시계/반시계 경로 중 짧은 쪽 자동 선택
- 직선 통과 가능 시 그대로 직선 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:45:09 +09:00
Nan Kyung Lee
468a4a2424 feat(korea): 작전가이드 임검침로 점선 시각화 — 해경→의심선박 루트
- 작전가이드에서 선박 클릭 시 해경 기지→선박 점선 표시
- 위험도별 색상 (CRITICAL 빨강, HIGH 노랑, MEDIUM 파랑)
- 중간 지점에 거리(NM) + 출발지→도착지 라벨
- 해경 기지: 닻() 마커, 대상 선박: 색상 원형 마커
- OpsRoute 타입 export, KoreaMap에 opsRoute prop 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:41:47 +09:00
Nan Kyung Lee
4edb8236f3 feat(korea): 작전가이드 창 드래그 이동 가능 + 크기 조절 2026-03-24 15:37:23 +09:00
Nan Kyung Lee
e4b6b1502b fix(korea): 작전가이드 선박 클릭 → 지도 이동 연결 (externalFlyTo prop) 2026-03-24 15:34:37 +09:00
Nan Kyung Lee
297d8aa56d refactor(korea): 작전가이드 → 실전형 순찰 루트 가이드로 변경
- 해경 기지 선택 → 주변 불법어선·어구 자동 탐지
- 탐색 반경 10~100NM 설정 가능
- 중국 선박 대상 위험도 자동 판정 (CRITICAL/HIGH/MEDIUM)
  - 비허가 수역 진입 → CRITICAL
  - 수역I 저인망 의심 → HIGH
  - 다크베셀 (AIS 비정상) → HIGH
  - 어구/어망 AIS 신호 → HIGH
  - 조업 추정 (2~6kn) → MEDIUM
  - 운반선/환적 의심 → MEDIUM
- 우선순위 정렬: 위험도 → 거리순
- 선박 클릭 → 지도 이동 (flyTo)
- 순찰 루트 제안 (가장 가까운 고위험 대상부터)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:30:39 +09:00
Nan Kyung Lee
f4ec6dd0f5 feat(korea): 경비함정 작전 가이드 모달 추가 (GC-KCG-2026-001 제7장)
- 탑메뉴 '작전가이드' 버튼 추가 (현장분석 옆)
- OpsGuideModal: 7개 탭 구성
  1. 작전 개요 (톤급별 구역/기간/임무 + 7일 스케줄)
  2. PT 저인망 대응 5단계 (접근 금지구역, 중국어 경고문)
  3. GN 유자망 대응 5단계 (다크베셀 탐지, AIS 재가동)
  4. PS 위망 선단 대응 5단계 (단독접근 금지, 宁波海裕)
  5. FC 운반선 환적 대응 4단계 (환적 신뢰도 판정)
  6. 어구 수거 절차 4단계 (자망/정치망/통발 식별)
  7. 조치 기준 (8대 위반유형 알람 등급 + 감시 강화 시기)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:25:32 +09:00
Nan Kyung Lee
c98d6ba353 refactor: 보고서 버튼 현장분석 헤더로 이동 (LIVE↔닫기 사이) 2026-03-24 09:19:49 +09:00
Nan Kyung Lee
8f9dd0b546 feat(korea): 중국어선 감시현황 자동 보고서 생성 기능
- 한국 현황 탑메뉴에 '보고서' 버튼 추가
- ReportModal: 현재 실시간 데이터 기반 7개 섹션 자동 보고서
  1. 전체 해양 현황 (선박수, 국적별)
  2. 중국어선 활동 분석 (속도별 상태)
  3. 어구/어망 유형별 분석 (GB/T 5147 기반)
  4. 특정어업수역별 분포 (I~IV + 수역 외)
  5. 위험 평가 (다크베셀, 수역 외, 조업 중)
  6. 국적별 선박 TOP 10
  7. 건의사항 5건
- 인쇄/PDF 내보내기 기능 (새 창 → window.print)
- 한중어업협정 허가현황 기반 자동 위반 판정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:41 +09:00
Nan Kyung Lee
df269bf19b feat(iran): S&P Global Marine Risk Note 반영 — 이란 상선공격 27척 피격 데이터
- S&P Global Market Intelligence (2026-03-19) 보고서 기반
- 이란 상선 공격 총 30건 중 식별 가능한 27척 데이터 추가
- 선박별: IMO, 국적, 유형, 피격 일시, 위치, 피해 정도
- 유형별: 탱커 52%, 벌크선 21%, 컨테이너 17%, 예인선 7%
- 해역별: UAE 48%, 오만 28%, 쿠웨이트/카타르 등
- 기존 리플레이 이벤트 ID와 연동

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:07:57 +09:00
Nan Kyung Lee
a9573b020f fix: fishing-zones Polygon→MultiPolygon 변환 — KoreaMap 런타임 에러 해결 2026-03-23 16:35:04 +09:00
Nan Kyung Lee
5296e0df19 fix: fishing-zones-wgs84.json id 필드 추가 (ZONE_I~IV) — 런타임 크래시 해결 2026-03-23 16:28:03 +09:00
Nan Kyung Lee
be77d97eb3 feat(korea): AI 해양분석 챗 (Qwen 2.5) + 이란 발전소 29개 확장 + UI 개선
- AI 해양분석 챗패널 추가 (AiChatPanel, Ollama/Qwen 2.5:7b)
- 시스템 프롬프트에 실시간 선박 데이터 자동 주입
- 보라/퍼플 톤 UI 차별화
- Vite 프록시 /ollama 추가
- 이란 발전소 20→29개 확장 (Wikipedia 기반 좌표/용량 보정)
- 선박 현황 폰트 사이즈 축소 (11→9px, 13→10px)
- OSINT LIVE 3개, 재난뉴스 2개 표시 + 스크롤
- 한국/중국 선박현황, 조업분석 기본 접힘
- AI 해양분석 기본 펼침

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:17:19 +09:00
Nan Kyung Lee
8448ea7985 fix(iran): 해외시설 3단계 레이어 복원 — overseasItems IIFE + count + 이스라엘 2026-03-23 11:12:11 +09:00
Nan Kyung Lee
0aff7302e6 fix: MEEnergyHazardLayer WindTurbineIcon 내부 정의, 선단패널 오른쪽 이동, fishing-zones 데이터 보정 2026-03-23 10:31:02 +09:00
Nan Kyung Lee
409e618a39 chore: develop 브랜치 동기화 — 충돌 해결 2026-03-23 10:06:38 +09:00
Nan Kyung Lee
6e37bc1f2d feat(iran): 해외시설 에너지/위험 3단계 레이어 + 나탄즈-디모나 리플레이 이벤트
- 해외시설 10개국 에너지/위험시설 데이터 56개소 (meEnergyHazardFacilities.ts)
- 이란 발전소 8→20개 확장 (화력/수력/원자력/풍력/태양광)
- 3단계 레이어 트리: 국가 → 에너지/위험 → 세부시설 (발전소/풍력/원자력/화력/석유화학/LNG/유류/위험물)
- 해외시설 총합 카운트 표시 + 각 단계별 시설 수 자동 계산
- MEEnergyHazardLayer: 시설별 SVG/이모지 아이콘 + 팝업
- 풍력단지 아이콘 한국 현황과 동일 (WindTurbineIcon export)
- 풍력단지 색상 진하게 (#00bcd4 → #0891b2)
- 풍력단지 팝업 공통 스타일 적용
- 영국 → 이스라엘 교체 (overseasUK → overseasIsrael)
- LayerVisibility 인덱스 시그니처 추가 (동적 레이어 키 지원)
- D+20 나탄즈-디모나 핵시설 교차공격 리플레이 이벤트 6건
- 에쉬콜 발전소 좌표 수정 (아슈도드 정확 위치)
- Java 17 호환: Thread.ofVirtual() → new Thread() (로컬 빌드용)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:01:27 +09:00
Nan Kyung Lee
444b7a4a8d feat(layer): 해외시설 하위 중국·일본 발전소/군사시설 레이어 추가
- cnFacilities.ts: 중국 핵·화력발전소 7개, 군사시설 7개 데이터
- jpFacilities.ts: 일본 핵·화력발전소 8개, 군사시설 7개 데이터
- CnFacilityLayer / JpFacilityLayer: 마커+팝업 레이어 컴포넌트
- LayerPanel: OverseasItem에 children 계층 지원 추가
- App.tsx: cnPower/cnMilitary/jpPower/jpMilitary 레이어 상태 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:17:34 +09:00
Nan Kyung Lee
e18a1a4932 feat(layer): 위험/산업 인프라 레이어 그룹 및 UI 개선
- 위험시설: 석유화학단지(5), LNG기지(10), 유류탱크(15), 위험물항만(6) 추가
- 에너지/발전시설: 원자력(5), 화력(5) 추가; 발전/변전·풍력단지 그룹 이동
- 산업공정/제조시설: 조선소(6), 폐수처리(5), 시멘트/제철소(5) 추가
- 위험/산업 인프라 수퍼그룹 신설 (3단계 계층 구조)
- LayerPanel: 레이어 수량을 우측 숫자 뱃지로 표시 (괄호 제거)
- 해외시설 하위항목: 이란탭=호르무즈 10개국, 한국탭=중국·일본
- EventLog: 재난/안전뉴스 섹션 추가 (한국탭), OSINT 접기/펼치기
- OSINT 뉴스 2026-03-21 기준으로 업데이트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:47:44 +09:00
83b3d80c6d feat: Python 어선 분류기 + 배포 설정 + 백엔드 모니터링 프록시
- prediction/: FastAPI 7단계 분류 파이프라인 + 6개 탐지 알고리즘
  - snpdb 궤적 조회 → 인메모리 캐시(13K척) → 분류 → kcgdb 저장
  - APScheduler 5분 주기, Python 3.9 호환
  - 버그 수정: @property last_bucket, SQL INTERVAL 바인딩, rollback, None 가드
  - 보안: DB 비밀번호 하드코딩 제거 → env 환경변수 필수
- deploy/kcg-prediction.service: systemd 서비스 (redis-211, 포트 8001)
- deploy.yml: prediction CI/CD 배포 단계 추가 (192.168.1.18:32023)
- backend: PredictionProxyController (health/status/trigger 프록시)
- backend: AppProperties predictionBaseUrl + AuthFilter 인증 예외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:07:40 +09:00
feabf16114 feat: 중국어선 분석 인프라 — 허가어선 API 연동 + vessel-analysis 백엔드 + 결과 포맷 확정
- Frontend: ChnPrmShipInfo 타입 + chnPrmShip.ts 서비스 (signal-batch 허가어선 API)
- Frontend: FieldAnalysisModal fetchVesselPermit → lookupPermittedShip 교체
- Frontend: 더미 라벨 정리 (LightGBM → 규칙기반, BD-09/레이더 → STANDBY/미연동)
- Frontend: VesselAnalysisResult 인터페이스 정의 (Python 분석 결과 수신용)
- Backend: vessel-analysis REST API (Entity/Repository/Service/Controller)
- Backend: DB 마이그레이션 005 (kcg.vessel_analysis_results 테이블)
- Backend: AuthFilter 인증 예외 + CacheConfig 캐시 등록

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:00:16 +09:00
Nan Kyung Lee
5cf69a1d22 feat: 현장분석 팝업 추가 — 중국 불법어업 현장분석 대시보드
- 한국 현황 탭 상단에 현장분석 버튼 추가 (지도 위 팝업)
- 통계 스트립: 총탐지/영해침범/조업중/AIS소실/클러스터/선종 분류
- 구역별 현황 + AI 파이프라인 상태 (LightGBM/BIRCH/UCAF)
- 선박 테이블: 필터/검색/경보 등급 정렬 + CSV 내보내기
- 선박 선택 시 허가 정보 조회 + 선박 사진 (S&P Global/MarineTraffic)
- 대응 명령 / ENG드론 버튼으로 경보 로그 기록

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:25:38 +09:00
Nan Kyung Lee
d000807909 fix: 중국어선감시 지도 멈춤 해결 — 마커 수 제한 + 이벤트 차단 CSS, 탑메뉴 불법어선 제거 2026-03-20 08:54:32 +09:00
23개의 변경된 파일1893개의 추가작업 그리고 51개의 파일을 삭제

파일 보기

@ -19,7 +19,7 @@
<description>KCG Monitoring Dashboard Backend</description>
<properties>
<java.version>21</java.version>
<java.version>17</java.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>

파일 보기

@ -86,7 +86,7 @@ public class AirplanesLiveCollector {
@PostConstruct
public void init() {
Thread.ofVirtual().name("aircraft-init").start(() -> {
new Thread(() -> {
doInitialLoad("iran", IRAN_QUERIES, iranRegionBuffers);
iranInitDone = true;
mergePointResults("iran", iranRegionBuffers);
@ -96,7 +96,7 @@ public class AirplanesLiveCollector {
koreaInitDone = true;
mergePointResults("korea", koreaRegionBuffers);
log.info("Airplanes.live 한국 초기 로드 완료");
});
}, "aircraft-init").start();
}
private void doInitialLoad(String region, List<RegionQuery> queries, Map<String, List<AircraftDto>> buffers) {

파일 보기

@ -58,12 +58,12 @@ public class OsintCollector {
@PostConstruct
public void init() {
Thread.ofVirtual().name("osint-init").start(() -> {
new Thread(() -> {
log.info("OSINT 초기 캐시 로드 시작");
refreshCache("iran");
refreshCache("korea");
log.info("OSINT 초기 캐시 로드 완료");
});
}, "osint-init").start();
}
@Scheduled(initialDelay = 30_000, fixedDelay = 10_000)

파일 보기

@ -49,11 +49,11 @@ public class SatelliteCollector {
@PostConstruct
public void init() {
Thread.ofVirtual().name("satellite-init").start(() -> {
new Thread(() -> {
log.info("위성 TLE 초기 캐시 로드 시작");
loadCacheFromDb();
log.info("위성 TLE 초기 캐시 로드 완료");
});
}, "satellite-init").start();
}
@Scheduled(initialDelay = 60_000, fixedDelay = 600_000)

파일 보기

@ -38,10 +38,10 @@ public class PressureCollector {
@PostConstruct
public void init() {
Thread.ofVirtual().name("pressure-init").start(() -> {
new Thread(() -> {
log.info("Open-Meteo 기압 데이터 초기 로드");
collect();
});
}, "pressure-init").start();
}
@Scheduled(initialDelay = 45_000, fixedDelay = 600_000)

파일 보기

@ -31,10 +31,10 @@ public class SeismicCollector {
@PostConstruct
public void init() {
Thread.ofVirtual().name("seismic-init").start(() -> {
new Thread(() -> {
log.info("USGS 지진 데이터 초기 로드");
collect();
});
}, "seismic-init").start();
}
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)

파일 보기

@ -24,6 +24,10 @@ import { useTranslation } from 'react-i18next';
import LoginPage from './components/auth/LoginPage';
import CollectorMonitor from './components/common/CollectorMonitor';
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
import { ReportModal } from './components/korea/ReportModal';
import { OpsGuideModal } from './components/korea/OpsGuideModal';
import type { OpsRoute } from './components/korea/OpsGuideModal';
import { filterFacilities } from './data/meEnergyHazardFacilities';
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
import { EAST_ASIA_PORTS } from './data/ports';
import { KOREAN_AIRPORTS } from './services/airports';
@ -83,7 +87,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
meFacilities: true,
militaryOnly: false,
overseasUS: false,
overseasUK: false,
overseasIsrael: false,
overseasIran: false,
overseasUAE: false,
overseasSaudi: false,
@ -191,6 +195,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
const [showReport, setShowReport] = useState(false);
const [showOpsGuide, setShowOpsGuide] = useState(false);
const [opsRoute, setOpsRoute] = useState<OpsRoute | null>(null);
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
@ -374,6 +381,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
<span className="text-[11px]">📊</span>
</button>
<button
type="button"
className={`mode-btn ${showOpsGuide ? 'active' : ''}`}
onClick={() => setShowOpsGuide(v => !v)}
title="경비함정 작전 가이드"
>
<span className="text-[11px]"></span>
</button>
</div>
)}
@ -512,18 +528,45 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
]}
overseasItems={[
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' },
{ key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' },
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' },
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' },
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' },
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' },
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' },
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' },
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' },
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' },
]}
overseasItems={(() => {
const fc = (ck: string, st?: string) => filterFacilities(ck, st as never).length;
const energyChildren = (ck: string) => [
{ key: `${ck}Power`, label: '발전소', color: '#a855f7', count: fc(ck, 'power') },
{ key: `${ck}Wind`, label: '풍력단지', color: '#22d3ee', count: fc(ck, 'wind') },
{ key: `${ck}Nuclear`, label: '원자력발전소', color: '#f59e0b', count: fc(ck, 'nuclear') },
{ key: `${ck}Thermal`, label: '화력발전소', color: '#64748b', count: fc(ck, 'thermal') },
];
const hazardChildren = (ck: string) => [
{ key: `${ck}Petrochem`, label: '석유화학단지', color: '#f97316', count: fc(ck, 'petrochem') },
{ key: `${ck}Lng`, label: 'LNG저장기지', color: '#0ea5e9', count: fc(ck, 'lng') },
{ key: `${ck}OilTank`, label: '유류저장탱크', color: '#eab308', count: fc(ck, 'oil_tank') },
{ key: `${ck}HazPort`, label: '위험물항만하역시설', color: '#dc2626', count: fc(ck, 'haz_port') },
];
const fullCountry = (key: string, label: string, color: string, ck: string) => ({
key, label, color, children: [
{ key: `${ck}Energy`, label: '에너지/발전시설', color: '#a855f7', children: energyChildren(ck) },
{ key: `${ck}Hazard`, label: '위험시설', color: '#ef4444', children: hazardChildren(ck) },
],
});
const compactCountry = (key: string, label: string, color: string, ck: string) => ({
key, label, color, children: [
{ key: `${ck}Energy`, label: '에너지/발전시설', color: '#a855f7', count: filterFacilities(ck).filter(f => f.category === 'energy').length },
{ key: `${ck}Hazard`, label: '위험시설', color: '#ef4444', count: filterFacilities(ck).filter(f => f.category === 'hazard').length },
],
});
return [
fullCountry('overseasUS', '🇺🇸 미국', '#3b82f6', 'us'),
fullCountry('overseasIsrael', '🇮🇱 이스라엘', '#0ea5e9', 'il'),
fullCountry('overseasIran', '🇮🇷 이란', '#22c55e', 'ir'),
fullCountry('overseasUAE', '🇦🇪 UAE', '#f59e0b', 'ae'),
fullCountry('overseasSaudi', '🇸🇦 사우디아라비아', '#84cc16', 'sa'),
compactCountry('overseasOman', '🇴🇲 오만', '#e11d48', 'om'),
compactCountry('overseasQatar', '🇶🇦 카타르', '#8b5cf6', 'qa'),
compactCountry('overseasKuwait', '🇰🇼 쿠웨이트', '#f97316', 'kw'),
compactCountry('overseasIraq', '🇮🇶 이라크', '#65a30d', 'iq'),
compactCountry('overseasBahrain', '🇧🇭 바레인', '#e11d48', 'bh'),
];
})()}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
@ -619,7 +662,18 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
<main className="app-main">
<div className="map-panel">
{showFieldAnalysis && (
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} />
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} onReport={() => setShowReport(true)} />
)}
{showReport && (
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} />
)}
{showOpsGuide && (
<OpsGuideModal
ships={koreaData.ships}
onClose={() => { setShowOpsGuide(false); setOpsRoute(null); }}
onFlyTo={(lat, lng, zoom) => setFlyToTarget({ lat, lng, zoom })}
onRouteSelect={setOpsRoute}
/>
)}
<KoreaMap
ships={koreaFiltersResult.filteredShips}
@ -635,6 +689,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
vesselAnalysis={vesselAnalysis}
externalFlyTo={flyToTarget}
onExternalFlyToDone={() => setFlyToTarget(null)}
opsRoute={opsRoute}
/>
<div className="map-overlay-left">
<LayerPanel

파일 보기

@ -6,6 +6,7 @@ import { getDisasterNews, getDisasterCatIcon, getDisasterCatColor } from '../../
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis';
import type { FishingGearType } from '../../utils/fishingAnalysis';
import { AiChatPanel } from '../korea/AiChatPanel';
type DashboardTab = 'iran' | 'korea';
@ -349,7 +350,7 @@ function useTimeAgo() {
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS, highlightKoreanShips = false, onToggleHighlightKorean, onShipHover, onShipClick }: Props) {
const { t } = useTranslation(['common', 'events', 'ships']);
const timeAgo = useTimeAgo();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships']));
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships', 'cn-fishing']));
const toggleCollapse = useCallback((key: string) => {
setCollapsed(prev => {
const next = new Set(prev);
@ -633,12 +634,12 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
borderLeft: `3px solid ${mtColor}`,
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: mtColor, flexShrink: 0 }} />
<span style={{ fontSize: 11, fontWeight: 700, minWidth: 70, color: mtColor }}>{mtLabel}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--kcg-text)' }}>
{list.length}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
<span style={{ fontSize: 9, fontWeight: 700, minWidth: 60, color: mtColor }}>{mtLabel}</span>
<span style={{ fontSize: 10, fontWeight: 700, color: 'var(--kcg-text)' }}>
{list.length}<span style={{ fontSize: 8, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
</span>
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
<span style={{ fontSize: 9, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
<span style={{ fontSize: 8, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
</div>
);
})}
@ -688,12 +689,12 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
borderLeft: `3px solid ${mtColor}`,
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: mtColor, flexShrink: 0 }} />
<span style={{ fontSize: 11, fontWeight: 700, minWidth: 70, color: mtColor }}>{mtLabel}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--kcg-text)' }}>
{list.length}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
<span style={{ fontSize: 9, fontWeight: 700, minWidth: 60, color: mtColor }}>{mtLabel}</span>
<span style={{ fontSize: 10, fontWeight: 700, color: 'var(--kcg-text)' }}>
{list.length}<span style={{ fontSize: 8, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
</span>
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
<span style={{ fontSize: 9, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
<span style={{ fontSize: 8, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
</div>
);
})}
@ -783,7 +784,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
</a>
</div>
{!collapsed.has('disaster-news') && (
<div className="osint-list" style={{ maxHeight: 310, overflowY: 'auto' }}>
<div className="osint-list" style={{ maxHeight: 110, overflowY: 'auto' }}>
{disasterItems.map(item => {
const icon = getDisasterCatIcon(item.category);
const color = getDisasterCatColor(item.category);
@ -832,7 +833,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
})()}</span>
</div>
{!collapsed.has('osint-korea') && (
<div className="osint-list">
<div className="osint-list" style={{ maxHeight: 165, overflowY: 'auto' }}>
{(() => {
const filtered = osintFeed.filter(item => item.category !== 'general' && item.category !== 'oil');
const seen = new Set<string>();
@ -879,6 +880,16 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
)}
</>
)}
{/* AI 해양분석 챗 */}
{isLive && dashboardTab === 'korea' && (
<AiChatPanel
ships={ships}
koreanShipCount={_koreanShipsByCategory ? Object.values(_koreanShipsByCategory).reduce((a, b) => a + b, 0) : 0}
chineseShipCount={chineseShips?.length ?? 0}
totalShipCount={_totalShipCount}
/>
)}
</div>
);
}

파일 보기

@ -129,6 +129,11 @@ interface OverseasItem {
children?: OverseasItem[];
}
function countOverseasLeaves(item: OverseasItem): number {
if (!item.children?.length) return item.count ?? 0;
return item.children.reduce((sum, c) => sum + countOverseasLeaves(c), 0);
}
interface LayerPanelProps {
layers: Record<string, boolean>;
onToggle: (key: string) => void;
@ -574,14 +579,34 @@ export function LayerPanel({
{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 key={child.key}>
<LayerTreeItem
layerKey={child.key}
label={child.label}
color={child.color}
count={child.count ?? countOverseasLeaves(child)}
active={layers[child.key] ?? false}
expandable={!!child.children?.length}
isExpanded={expanded.has(`overseas-${child.key}`)}
onToggle={() => onToggle(child.key)}
onExpand={child.children?.length ? () => toggleExpand(`overseas-${child.key}`) : undefined}
/>
{child.children?.length && expanded.has(`overseas-${child.key}`) && (
<div className="layer-tree-children">
{child.children.map(gc => (
<LayerTreeItem
key={gc.key}
layerKey={gc.key}
label={gc.label}
color={gc.color}
count={gc.count}
active={layers[gc.key] ?? false}
onToggle={() => onToggle(gc.key)}
/>
))}
</div>
)}
</div>
))}
</div>
)}

파일 보기

@ -0,0 +1,199 @@
import { Marker, Popup } from 'react-map-gl/maplibre';
import { useState, useMemo } from 'react';
import {
ME_ENERGY_HAZARD_FACILITIES,
SUB_TYPE_META,
layerKeyToSubType,
layerKeyToCountry,
type EnergyHazardFacility,
} from '../../data/meEnergyHazardFacilities';
import type { FacilitySubType } from '../../data/meEnergyHazardFacilities';
function WindTurbineIcon({ size = 18, color = '#0891b2' }: { size?: number; color?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 32 32" fill="none">
<path d="M15 14 L14.2 29 L17.8 29 L17 14 Z" fill={color} opacity="0.7" />
<rect x="11" y="28.5" width="10" height="2" rx="1" fill={color} opacity="0.5" />
<ellipse cx="16" cy="12" rx="2.5" ry="1.5" fill={color} />
<circle cx="16" cy="12" r="1.2" fill="#fff" opacity="0.9" />
<circle cx="16" cy="12" r="0.6" fill={color} />
<path d="M16 12 L15 1.5 Q16 0.5 17 1.5 Z" fill={color} opacity="0.85" />
<path d="M16 12 L24.5 18 Q24 19.5 22.5 18.5 Z" fill={color} opacity="0.85" />
<path d="M16 12 L7.5 18 Q8 19.5 9.5 18.5 Z" fill={color} opacity="0.85" />
<path d="M3 30 Q5.5 28 8 30 Q10.5 32 13 30" stroke={color} strokeWidth="0.8" fill="none" opacity="0.4" />
<path d="M19 30 Q21.5 28 24 30 Q26.5 32 29 30" stroke={color} strokeWidth="0.8" fill="none" opacity="0.4" />
</svg>
);
}
function FacilityIcon({ subType, color, size = 18 }: { subType: FacilitySubType; color: string; size?: number }) {
const s = size;
switch (subType) {
case 'power':
return <span style={{ fontSize: s }}></span>;
case 'nuclear':
return <span style={{ fontSize: s }}></span>;
case 'thermal':
return <span style={{ fontSize: s }}>🏭</span>;
case 'petrochem': // Petrochemical - oil drum with pipe
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
<ellipse cx="12" cy="6" rx="7" ry="3" fill={color} opacity="0.5" />
<rect x="5" y="6" width="14" height="14" rx="1" fill={color} opacity="0.6" />
<ellipse cx="12" cy="20" rx="7" ry="3" fill={color} opacity="0.5" />
<line x1="8" y1="6" x2="8" y2="20" stroke={color} strokeWidth="0.8" opacity="0.8" />
<line x1="16" y1="6" x2="16" y2="20" stroke={color} strokeWidth="0.8" opacity="0.8" />
<path d="M12 3 L12 1" stroke={color} strokeWidth="1.5" />
<circle cx="12" cy="0.5" r="0.5" fill={color} />
</svg>
);
case 'lng': // LNG - snowflake/cold tank
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="13" r="8" fill={color} opacity="0.2" stroke={color} strokeWidth="1" />
<line x1="12" y1="5" x2="12" y2="21" stroke={color} strokeWidth="1.5" />
<line x1="4" y1="13" x2="20" y2="13" stroke={color} strokeWidth="1.5" />
<line x1="6.3" y1="7.3" x2="17.7" y2="18.7" stroke={color} strokeWidth="1.2" />
<line x1="17.7" y1="7.3" x2="6.3" y2="18.7" stroke={color} strokeWidth="1.2" />
<circle cx="12" cy="13" r="2" fill={color} opacity="0.6" />
</svg>
);
case 'oil_tank': // Oil tank - cylinder
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
<ellipse cx="12" cy="7" rx="8" ry="4" fill={color} opacity="0.6" />
<rect x="4" y="7" width="16" height="12" fill={color} opacity="0.4" />
<ellipse cx="12" cy="19" rx="8" ry="4" fill={color} opacity="0.6" />
<path d="M4 7 v12" stroke={color} strokeWidth="1" />
<path d="M20 7 v12" stroke={color} strokeWidth="1" />
</svg>
);
case 'haz_port': // Hazardous port - warning triangle
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
<path d="M12 2 L22 20 H2 Z" fill={color} opacity="0.3" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
<line x1="12" y1="8" x2="12" y2="14" stroke={color} strokeWidth="2" strokeLinecap="round" />
<circle cx="12" cy="17" r="1.2" fill={color} />
</svg>
);
default:
return <span style={{ fontSize: s * 0.8 }}>📍</span>;
}
}
interface Props {
layers: Record<string, boolean>;
}
export function MEEnergyHazardLayer({ layers }: Props) {
const [selected, setSelected] = useState<EnergyHazardFacility | null>(null);
// Collect active country+subType combos from layer keys
const visibleFacilities = useMemo(() => {
const active = new Set<string>(); // "countryKey:subType"
// Also check parent energy/hazard keys (e.g. omEnergy -> show all om energy)
const energySubTypes = ['power', 'wind', 'nuclear', 'thermal'] as const;
const hazardSubTypes = ['petrochem', 'lng', 'oil_tank', 'haz_port'] as const;
for (const [key, on] of Object.entries(layers)) {
if (!on) continue;
const ck = layerKeyToCountry(key);
const st = layerKeyToSubType(key);
if (ck && st) {
active.add(`${ck}:${st}`);
}
// Parent energy key (e.g. irEnergy) -> activate all energy subtypes for that country
if (ck && key.endsWith('Energy')) {
for (const s of energySubTypes) active.add(`${ck}:${s}`);
}
if (ck && key.endsWith('Hazard')) {
for (const s of hazardSubTypes) active.add(`${ck}:${s}`);
}
}
if (active.size === 0) return [];
return ME_ENERGY_HAZARD_FACILITIES.filter(f =>
active.has(`${f.countryKey}:${f.subType}`)
);
}, [layers]);
if (visibleFacilities.length === 0) return null;
return (
<>
{visibleFacilities.map(f => {
const meta = SUB_TYPE_META[f.subType];
return (
<Marker
key={f.id}
latitude={f.lat}
longitude={f.lng}
anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); setSelected(f); }}
>
<div
title={f.nameKo}
style={{
cursor: 'pointer',
filter: 'drop-shadow(0 0 3px rgba(0,0,0,0.7))',
textAlign: 'center',
lineHeight: 1,
}}
>
{f.subType === 'wind' ? (
<WindTurbineIcon size={18} color={meta.color} />
) : (
<FacilityIcon subType={f.subType} color={meta.color} size={18} />
)}
</div>
</Marker>
);
})}
{selected && (
<Popup
latitude={selected.lat}
longitude={selected.lng}
anchor="bottom"
closeOnClick={false}
onClose={() => setSelected(null)}
maxWidth="260px"
className="facility-popup"
>
<div style={{
background: '#1a1e2e', color: '#e2e8f0', padding: '8px 10px',
borderRadius: 6, fontSize: 11, lineHeight: 1.5,
}}>
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>
{SUB_TYPE_META[selected.subType].icon} {selected.nameKo}
</div>
<div style={{ fontSize: 10, color: '#94a3b8', marginBottom: 4 }}>{selected.name}</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 4, flexWrap: 'wrap' }}>
<span style={{
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 600,
background: SUB_TYPE_META[selected.subType].color + '30',
color: SUB_TYPE_META[selected.subType].color,
border: `1px solid ${SUB_TYPE_META[selected.subType].color}50`,
}}>
{SUB_TYPE_META[selected.subType].label}
</span>
{selected.capacityMW && (
<span style={{
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 600,
background: 'rgba(255,255,255,0.08)', color: '#e2e8f0',
}}>
{selected.capacityMW.toLocaleString()} MW
</span>
)}
</div>
<div style={{ fontSize: 10, color: '#94a3b8' }}>{selected.description}</div>
<div style={{ fontSize: 9, color: '#64748b', marginTop: 4 }}>
{selected.lat.toFixed(4)}N, {selected.lng.toFixed(4)}E
</div>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -10,6 +10,7 @@ import { SeismicMarker } from '../layers/SeismicMarker';
import { OilFacilityLayer } from './OilFacilityLayer';
import { AirportLayer } from './AirportLayer';
import { MEFacilityLayer } from './MEFacilityLayer';
import { MEEnergyHazardLayer } from './MEEnergyHazardLayer';
import { iranOilFacilities } from '../../data/oilFacilities';
import { middleEastAirports } from '../../data/airports';
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
@ -273,6 +274,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
{layers.meFacilities && <MEFacilityLayer />}
<MEEnergyHazardLayer layers={layers} />
</Map>
);
}

파일 보기

@ -0,0 +1,264 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import type { Ship } from '../../types';
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
interface Props {
ships: Ship[];
koreanShipCount: number;
chineseShipCount: number;
totalShipCount: number;
}
const OLLAMA_URL = '/ollama/api/chat';
function buildSystemPrompt(props: Props): string {
const { ships, koreanShipCount, chineseShipCount, totalShipCount } = props;
// 선박 유형별 통계
const byType: Record<string, number> = {};
const byFlag: Record<string, number> = {};
ships.forEach(s => {
byType[s.category || 'unknown'] = (byType[s.category || 'unknown'] || 0) + 1;
byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1;
});
// 중국 어선 통계
const cnFishing = ships.filter(s => s.flag === 'CN' && (s.category === 'fishing' || s.typecode === '30'));
const cnFishingOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 5);
return `당신은 대한민국 해양경찰청의 해양상황 분석 AI 어시스턴트입니다.
.
##
- 선박: ${totalShipCount}
- 선박: ${koreanShipCount}
- 선박: ${chineseShipCount}
- 어선: ${cnFishing.length} ( 추정: ${cnFishingOperating.length})
##
${Object.entries(byType).map(([k, v]) => `- ${k}: ${v}`).join('\n')}
## ()
${Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([k, v]) => `- ${k}: ${v}`).join('\n')}
##
- 906 (PT 323, GN 200, PS 16, OT 1 13, FC 31)
- I~IV에서만
- 휴어기: PT/OT 4/16~10/15, GN 6/2~8/31
- (AIS )
##
-
-
-
-
- `;
}
export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShipCount }: Props) {
const [isOpen, setIsOpen] = useState(true);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (isOpen) inputRef.current?.focus();
}, [isOpen]);
const sendMessage = useCallback(async () => {
if (!input.trim() || isLoading) return;
const userMsg: ChatMessage = { role: 'user', content: input.trim(), timestamp: Date.now() };
setMessages(prev => [...prev, userMsg]);
setInput('');
setIsLoading(true);
try {
const systemPrompt = buildSystemPrompt({ ships, koreanShipCount, chineseShipCount, totalShipCount });
const apiMessages = [
{ role: 'system', content: systemPrompt },
...messages.filter(m => m.role !== 'system').map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: userMsg.content },
];
const res = await fetch(OLLAMA_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'qwen2.5:7b',
messages: apiMessages,
stream: false,
options: { temperature: 0.3, num_predict: 1024 },
}),
});
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
const data = await res.json();
const assistantMsg: ChatMessage = {
role: 'assistant',
content: data.message?.content || '응답을 생성할 수 없습니다.',
timestamp: Date.now(),
};
setMessages(prev => [...prev, assistantMsg]);
} catch (err) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}. Ollama가 실행 중인지 확인하세요.`,
timestamp: Date.now(),
}]);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages, ships, koreanShipCount, chineseShipCount, totalShipCount]);
const quickQuestions = [
'현재 해양 상황을 요약해줘',
'중국어선 불법조업 의심 분석해줘',
'서해 위험도를 평가해줘',
'다크베셀 현황 분석해줘',
];
return (
<div style={{
borderTop: '1px solid rgba(168,85,247,0.2)',
marginTop: 8,
}}>
{/* Toggle header */}
<div
onClick={() => setIsOpen(p => !p)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 8px', cursor: 'pointer',
background: isOpen ? 'rgba(168,85,247,0.12)' : 'rgba(168,85,247,0.04)',
borderRadius: 4,
borderLeft: '2px solid rgba(168,85,247,0.5)',
}}
>
<span style={{ fontSize: 12 }}>🤖</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#c4b5fd' }}>AI </span>
<span style={{ fontSize: 8, color: '#8b5cf6', marginLeft: 2, background: 'rgba(139,92,246,0.15)', padding: '1px 5px', borderRadius: 3 }}>Qwen 2.5</span>
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#8b5cf6' }}>
{isOpen ? '▼' : '▶'}
</span>
</div>
{/* Chat body */}
{isOpen && (
<div style={{
display: 'flex', flexDirection: 'column',
height: 360, background: 'rgba(88,28,135,0.08)',
borderRadius: '0 0 6px 6px', overflow: 'hidden',
borderLeft: '2px solid rgba(168,85,247,0.3)',
borderBottom: '1px solid rgba(168,85,247,0.15)',
}}>
{/* Messages */}
<div style={{
flex: 1, overflowY: 'auto', padding: '6px 8px',
display: 'flex', flexDirection: 'column', gap: 6,
}}>
{messages.length === 0 && (
<div style={{ padding: '12px 0', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#a78bfa', marginBottom: 8 }}>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{quickQuestions.map((q, i) => (
<button
key={i}
onClick={() => { setInput(q); }}
style={{
background: 'rgba(139,92,246,0.08)',
border: '1px solid rgba(139,92,246,0.25)',
borderRadius: 4, padding: '4px 8px',
fontSize: 9, color: '#a78bfa',
cursor: 'pointer', textAlign: 'left',
}}
>
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
style={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
background: msg.role === 'user'
? 'rgba(139,92,246,0.25)'
: 'rgba(168,85,247,0.08)',
borderRadius: msg.role === 'user' ? '8px 8px 2px 8px' : '8px 8px 8px 2px',
padding: '6px 8px',
fontSize: 10,
color: '#e2e8f0',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{msg.content}
</div>
))}
{isLoading && (
<div style={{
alignSelf: 'flex-start', padding: '6px 8px',
background: 'rgba(168,85,247,0.08)', borderRadius: 8,
fontSize: 10, color: '#a78bfa',
}}>
...
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div style={{
display: 'flex', gap: 4, padding: '6px 8px',
borderTop: '1px solid rgba(255,255,255,0.06)',
background: 'rgba(0,0,0,0.15)',
}}>
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
placeholder="해양 상황 질문..."
disabled={isLoading}
style={{
flex: 1, background: 'rgba(139,92,246,0.06)',
border: '1px solid rgba(139,92,246,0.2)',
borderRadius: 4, padding: '5px 8px',
fontSize: 10, color: '#e2e8f0', outline: 'none',
}}
/>
<button
onClick={sendMessage}
disabled={isLoading || !input.trim()}
style={{
background: isLoading || !input.trim() ? '#334155' : '#7c3aed',
border: 'none', borderRadius: 4,
padding: '4px 10px', fontSize: 10, fontWeight: 700,
color: '#fff', cursor: isLoading ? 'not-allowed' : 'pointer',
}}
>
</button>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -109,6 +109,7 @@ interface Props {
ships: Ship[];
vesselAnalysis?: UseVesselAnalysisResult;
onClose: () => void;
onReport?: () => void;
}
const PIPE_STEPS = [
@ -123,7 +124,7 @@ const PIPE_STEPS = [
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onReport }: Props) {
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
const [activeFilter, setActiveFilter] = useState('ALL');
@ -348,6 +349,19 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
LIVE
</span>
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
{onReport && (
<button
type="button"
onClick={onReport}
style={{
background: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.4)',
color: '#60a5fa', padding: '4px 14px', cursor: 'pointer',
fontSize: 11, borderRadius: 2, fontFamily: 'inherit', fontWeight: 700,
}}
>
📋
</button>
)}
<button
type="button"
onClick={onClose}

파일 보기

@ -492,7 +492,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
const panelStyle: React.CSSProperties = {
position: 'absolute',
bottom: 60,
left: 10,
right: 10,
zIndex: 10,
minWidth: 220,
maxWidth: 300,

파일 보기

@ -60,6 +60,9 @@ interface Props {
dokdoWatchSuspects: Set<string>;
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
vesselAnalysis?: UseVesselAnalysisResult;
externalFlyTo?: { lat: number; lng: number; zoom: number } | null;
onExternalFlyToDone?: () => void;
opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null;
}
// MarineTraffic-style: satellite + dark ocean + nautical overlay
@ -101,6 +104,93 @@ const MAP_STYLE = {
],
};
// ═══ Sea routing — avoid Korean peninsula land mass ═══
// Coastal waypoints around Korea (clockwise from NW)
const SEA_WAYPOINTS: [number, number][] = [
[124.5, 37.8], // 서해 북부 (백령도 서)
[124.0, 36.5], // 서해 중부
[124.5, 35.5], // 서해 남부
[125.0, 34.5], // 서남해 (신안)
[126.0, 33.5], // 남해 서단 (제주 서)
[126.5, 33.2], // 제주 남서
[127.5, 33.0], // 제주 남
[128.5, 33.5], // 제주 동
[129.0, 34.5], // 남해 동단 (거제)
[129.5, 35.2], // 부산 남
[129.8, 36.0], // 동해 남부 (울산)
[130.0, 37.0], // 동해 중부
[129.5, 37.8], // 동해 북부 (강릉)
[129.0, 38.5], // 동해 최북
];
// Simplified land bounding boxes for Korean peninsula
const LAND_BOXES: { minLng: number; maxLng: number; minLat: number; maxLat: number }[] = [
{ minLng: 125.5, maxLng: 129.5, minLat: 34.3, maxLat: 38.6 }, // 한반도 본토
{ minLng: 126.1, maxLng: 126.9, minLat: 33.2, maxLat: 33.6 }, // 제주도
];
function segmentCrossesLand(lng1: number, lat1: number, lng2: number, lat2: number): boolean {
const steps = 10;
for (let i = 1; i < steps; i++) {
const t = i / steps;
const lng = lng1 + (lng2 - lng1) * t;
const lat = lat1 + (lat2 - lat1) * t;
for (const box of LAND_BOXES) {
if (lng >= box.minLng && lng <= box.maxLng && lat >= box.minLat && lat <= box.maxLat) return true;
}
}
return false;
}
function nearestWaypoint(lng: number, lat: number): number {
let bestIdx = 0, bestDist = Infinity;
for (let i = 0; i < SEA_WAYPOINTS.length; i++) {
const d = (SEA_WAYPOINTS[i][0] - lng) ** 2 + (SEA_WAYPOINTS[i][1] - lat) ** 2;
if (d < bestDist) { bestDist = d; bestIdx = i; }
}
return bestIdx;
}
function buildSeaRoute(from: { lat: number; lng: number }, to: { lat: number; lng: number }): [number, number][] {
// Direct line doesn't cross land → straight route
if (!segmentCrossesLand(from.lng, from.lat, to.lng, to.lat)) {
return [[from.lng, from.lat], [to.lng, to.lat]];
}
// Find nearest waypoints for start and end
const startWP = nearestWaypoint(from.lng, from.lat);
const endWP = nearestWaypoint(to.lng, to.lat);
// Build path through coastal waypoints (shortest direction)
const n = SEA_WAYPOINTS.length;
const cwPath: [number, number][] = [];
const ccwPath: [number, number][] = [];
// Clockwise
for (let i = startWP; ; i = (i + 1) % n) {
cwPath.push(SEA_WAYPOINTS[i]);
if (i === endWP) break;
if (cwPath.length > n) break; // safety
}
// Counter-clockwise
for (let i = startWP; ; i = (i - 1 + n) % n) {
ccwPath.push(SEA_WAYPOINTS[i]);
if (i === endWP) break;
if (ccwPath.length > n) break;
}
const waypoints = cwPath.length <= ccwPath.length ? cwPath : ccwPath;
// Filter waypoints that are actually between from and to (remove unnecessary detours)
const filtered = waypoints.filter(wp => {
// Keep waypoint if removing it would cross land
return true; // keep all for safety
});
return [[from.lng, from.lat], ...filtered, [to.lng, to.lat]];
}
// Korea-centered view
const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 };
const KOREA_MAP_ZOOM = 6;
@ -132,7 +222,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
ferryWatch: 'filters.ferryWatchMonitor',
};
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) {
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [infra, setInfra] = useState<PowerFacility[]>([]);
@ -156,6 +246,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
}
}, [flyToTarget]);
useEffect(() => {
if (externalFlyTo && mapRef.current) {
mapRef.current.flyTo({ center: [externalFlyTo.lng, externalFlyTo.lat], zoom: externalFlyTo.zoom, duration: 1500 });
onExternalFlyToDone?.();
}
}, [externalFlyTo, onExternalFlyToDone]);
useEffect(() => {
if (!selectedAnalysisMmsi) setTrackCoords(null);
}, [selectedAnalysisMmsi]);
@ -933,6 +1030,54 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
onExpandedChange={setAnalysisPanelOpen}
/>
)}
{/* 작전가이드 임검침로 점선 — 해상 루트 (육지 우회) */}
{opsRoute && (() => {
const riskColor = opsRoute.riskLevel === 'CRITICAL' ? '#ef4444' : opsRoute.riskLevel === 'HIGH' ? '#f59e0b' : '#3b82f6';
const coords = buildSeaRoute(opsRoute.from, opsRoute.to);
const routeGeoJson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [{
type: 'Feature', properties: {},
geometry: { type: 'LineString', coordinates: coords },
}],
};
const midIdx = Math.floor(coords.length / 2);
const midLng = coords[midIdx][0];
const midLat = coords[midIdx][1];
return (
<>
<Source id="ops-route-line" type="geojson" data={routeGeoJson}>
<Layer id="ops-route-dash" type="line" paint={{
'line-color': riskColor,
'line-width': 2.5,
'line-dasharray': [4, 4],
'line-opacity': 0.8,
}} />
</Source>
<Marker longitude={opsRoute.from.lng} latitude={opsRoute.from.lat} anchor="center">
<div style={{ fontSize: 18, filter: 'drop-shadow(0 0 4px rgba(0,0,0,0.8))' }}></div>
</Marker>
<Marker longitude={opsRoute.to.lng} latitude={opsRoute.to.lat} anchor="center">
<div style={{
width: 12, height: 12, borderRadius: '50%',
background: riskColor, border: '2px solid #fff',
boxShadow: `0 0 8px ${riskColor}`,
}} />
</Marker>
<Marker longitude={midLng} latitude={midLat} anchor="bottom">
<div style={{
background: 'rgba(0,0,0,0.8)', padding: '2px 6px', borderRadius: 3,
border: `1px solid ${riskColor}`, fontSize: 9, color: '#fff', fontWeight: 700,
whiteSpace: 'nowrap', textAlign: 'center',
}}>
{opsRoute.distanceNM.toFixed(1)} NM
<div style={{ fontSize: 7, color: riskColor }}>{opsRoute.from.name} {opsRoute.to.name}</div>
</div>
</Marker>
</>
);
})()}
</Map>
);
}

파일 보기

@ -0,0 +1,410 @@
import { useState, useMemo, useRef, useCallback } from 'react';
import type { Ship } from '../../types';
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../../services/coastGuard';
import type { CoastGuardFacility } from '../../services/coastGuard';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { analyzeFishing, classifyFishingZone } from '../../utils/fishingAnalysis';
export interface OpsRoute {
from: { lat: number; lng: number; name: string };
to: { lat: number; lng: number; name: string; mmsi: string };
distanceNM: number;
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM';
}
interface Props {
ships: Ship[];
onClose: () => void;
onFlyTo?: (lat: number, lng: number, zoom: number) => void;
onRouteSelect?: (route: OpsRoute | null) => void;
}
interface SuspectVessel {
ship: Ship;
distance: number;
reasons: string[];
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM';
estimatedType: 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN';
}
function haversineNM(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 3440.065;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
const RISK_COLOR = { CRITICAL: '#ef4444', HIGH: '#f59e0b', MEDIUM: '#3b82f6' };
const RISK_ICON = { CRITICAL: '🔴', HIGH: '🟡', MEDIUM: '🔵' };
type Tab = 'detect' | 'procedure' | 'alert';
// ── 중국어 경고문 ──
const CN_WARNINGS: Record<string, { zh: string; ko: string; usage: string }[]> = {
PT: [
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF Ch.16 + 확성기' },
{ zh: '请出示捕捞许可证', ko: '어업허가증을 제시하시오', usage: '승선 검사 시' },
{ zh: '请出示作业日志', ko: '조업일지를 제시하시오', usage: '어획량 확인' },
{ zh: '你的网目不符合规定', ko: '망목이 규정에 미달합니다', usage: '어구 검사 (54mm 미만)' },
],
GN: [
{ zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀 대응' },
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
{ zh: '你在非许可区域作业', ko: '비허가 구역에서 조업 중입니다', usage: '수역 이탈 시' },
{ zh: '请立即收回渔网', ko: '어망을 즉시 회수하시오', usage: '불법 자망 발견' },
],
PS: [
{ zh: '所有船只立即停止作业', ko: '모든 선박 즉시 조업 중단', usage: '선단 제압 시' },
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
{ zh: '关闭集鱼灯', ko: '집어등을 끄시오', usage: '조명선 대응' },
{ zh: '不要试图逃跑', ko: '도주를 시도하지 마시오', usage: '도주 시' },
],
FC: [
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
{ zh: '请出示货物清单', ko: '화물 목록을 제시하시오', usage: '환적 검사' },
{ zh: '禁止转运渔获物', ko: '어획물 환적을 금지합니다', usage: '환적 현장' },
],
GEAR: [
{ zh: '这些渔具属于非法设置', ko: '이 어구는 불법 설치되었습니다', usage: '어구 수거 시' },
],
UNKNOWN: [
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: '기본 경고' },
{ zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀' },
],
};
function estimateVesselType(ship: Ship): 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN' {
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
const isGear = /[_]\d+[_]|%$/.test(ship.name);
if (isGear) return 'GEAR';
if (cat === 'cargo' || (cat === 'unspecified' && (ship.length || 0) > 50)) return 'FC';
if (cat !== 'fishing' && ship.category !== 'fishing' && ship.typecode !== '30') return 'UNKNOWN';
const spd = ship.speed || 0;
if (spd >= 7) return 'PS';
if (spd < 1.5) return 'GN';
return 'PT';
}
export function OpsGuideModal({ ships, onClose, onFlyTo, onRouteSelect }: Props) {
const [selectedKCG, setSelectedKCG] = useState<CoastGuardFacility | null>(null);
const [searchRadius, setSearchRadius] = useState(30);
const [pos, setPos] = useState({ x: 60, y: 60 });
const [tab, setTab] = useState<Tab>('detect');
const [selectedSuspect, setSelectedSuspect] = useState<SuspectVessel | null>(null);
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const onDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
const onMove = (ev: MouseEvent) => {
if (!dragRef.current) return;
setPos({ x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: dragRef.current.origY + (ev.clientY - dragRef.current.startY) });
};
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [pos]);
const kcgBases = useMemo(() =>
COAST_GUARD_FACILITIES.filter(f => ['hq', 'regional', 'station', 'navy'].includes(f.type)).sort((a, b) => a.name.localeCompare(b.name)),
[]);
const suspects = useMemo<SuspectVessel[]>(() => {
if (!selectedKCG) return [];
const results: SuspectVessel[] = [];
for (const ship of ships) {
if (ship.flag !== 'CN') continue;
const dist = haversineNM(selectedKCG.lat, selectedKCG.lng, ship.lat, ship.lng);
if (dist > searchRadius) continue;
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
const isFishing = cat === 'fishing' || ship.category === 'fishing' || ship.typecode === '30';
const isGear = /[_]\d+[_]|%$/.test(ship.name);
const zone = classifyFishingZone(ship.lat, ship.lng);
const reasons: string[] = [];
let riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' = 'MEDIUM';
if (isFishing && zone.zone === 'OUTSIDE') { reasons.push('비허가 수역 진입'); riskLevel = 'CRITICAL'; }
if (isFishing && zone.zone === 'ZONE_I' && ship.speed >= 2 && ship.speed <= 5) { reasons.push('수역I 저인망 의심'); riskLevel = 'HIGH'; }
if (isFishing && ship.speed === 0 && (!ship.heading || ship.heading === 0)) { reasons.push('다크베셀 의심'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; }
if (isFishing && ship.speed >= 2 && ship.speed <= 6) reasons.push(`조업 추정 (${ship.speed.toFixed(1)}kn)`);
if (isGear) { reasons.push('어구/어망 AIS'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; }
if (!isFishing && (cat === 'cargo' || cat === 'unspecified') && ship.speed < 3) reasons.push('운반선/환적 의심');
if (reasons.length > 0) results.push({ ship, distance: dist, reasons, riskLevel, estimatedType: estimateVesselType(ship) });
}
return results.sort((a, b) => ({ CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[a.riskLevel] - { CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[b.riskLevel]) || a.distance - b.distance);
}, [selectedKCG, ships, searchRadius]);
const criticalCount = suspects.filter(s => s.riskLevel === 'CRITICAL').length;
const highCount = suspects.filter(s => s.riskLevel === 'HIGH').length;
const [speakingIdx, setSpeakingIdx] = useState<number | null>(null);
const copyToClipboard = (text: string, idx: number) => {
navigator.clipboard.writeText(text).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1500); });
};
const audioRef = useRef<HTMLAudioElement | null>(null);
const speakChinese = useCallback((text: string, idx: number) => {
// Stop previous
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
setSpeakingIdx(idx);
const encoded = encodeURIComponent(text);
const url = `/api/gtts?ie=UTF-8&client=tw-ob&tl=zh-CN&q=${encoded}`;
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => setSpeakingIdx(null);
audio.onerror = () => setSpeakingIdx(null);
audio.play().catch(() => setSpeakingIdx(null));
}, []);
const handleSuspectClick = (s: SuspectVessel) => {
setSelectedSuspect(s);
setTab('procedure');
onFlyTo?.(s.ship.lat, s.ship.lng, 10);
if (selectedKCG) {
onRouteSelect?.({ from: { lat: selectedKCG.lat, lng: selectedKCG.lng, name: selectedKCG.name }, to: { lat: s.ship.lat, lng: s.ship.lng, name: s.ship.name || s.ship.mmsi, mmsi: s.ship.mmsi }, distanceNM: s.distance, riskLevel: s.riskLevel });
}
};
const TYPE_LABEL: Record<string, string> = { PT: '저인망(PT)', GN: '유자망(GN)', PS: '위망(PS)', FC: '운반선(FC)', GEAR: '어구/어망', UNKNOWN: '미분류' };
return (
<div style={{ position: 'fixed', left: pos.x, top: pos.y, zIndex: 9999, width: 760, maxHeight: '85vh', overflow: 'hidden', background: '#0a0f1a', borderRadius: 8, border: '1px solid #1e293b', display: 'flex', flexDirection: 'column', boxShadow: '0 8px 32px rgba(0,0,0,0.6)', resize: 'both' }}>
{/* Header */}
<div onMouseDown={onDragStart} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderBottom: '1px solid #1e293b', background: 'rgba(30,58,95,0.5)', cursor: 'grab', userSelect: 'none', flexShrink: 0 }}>
<span style={{ fontSize: 10, color: '#475569', letterSpacing: 2 }}></span>
<span style={{ fontSize: 14 }}></span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}> </span>
<button onClick={onClose} style={{ marginLeft: 'auto', background: 'rgba(255,82,82,0.1)', border: '1px solid rgba(255,82,82,0.4)', color: '#ef4444', padding: '4px 12px', cursor: 'pointer', fontSize: 10, borderRadius: 2 }}></button>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 2, padding: '4px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0 }}>
{([['detect', '🔍 실시간 탐지'], ['procedure', '📋 대응 절차'], ['alert', '🚨 조치 기준']] as [Tab, string][]).map(([k, l]) => (
<button key={k} onClick={() => setTab(k)} style={{
padding: '3px 10px', fontSize: 10, fontWeight: 700, borderRadius: 3, cursor: 'pointer',
background: tab === k ? 'rgba(59,130,246,0.2)' : 'transparent',
border: tab === k ? '1px solid rgba(59,130,246,0.4)' : '1px solid transparent',
color: tab === k ? '#60a5fa' : '#64748b',
}}>{l}</button>
))}
</div>
{/* Controls (detect tab) */}
{tab === 'detect' && (
<div style={{ display: 'flex', gap: 8, padding: '6px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0, alignItems: 'center', flexWrap: 'wrap' }}>
<select value={selectedKCG?.id ?? ''} onChange={e => { const f = kcgBases.find(b => b.id === Number(e.target.value)); setSelectedKCG(f || null); }} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0', minWidth: 160 }}>
<option value=""> </option>
{kcgBases.map(b => <option key={b.id} value={b.id}>[{CG_TYPE_LABEL[b.type]}] {b.name}</option>)}
</select>
<select value={searchRadius} onChange={e => setSearchRadius(Number(e.target.value))} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0' }}>
{[10, 20, 30, 50, 100].map(n => <option key={n} value={n}>{n} NM</option>)}
</select>
{selectedKCG && <div style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9 }}>
<span style={{ color: '#ef4444', fontWeight: 700 }}>🔴 {criticalCount}</span>
<span style={{ color: '#f59e0b', fontWeight: 700 }}>🟡 {highCount}</span>
<span style={{ color: '#3b82f6', fontWeight: 700 }}>🔵 {suspects.length}</span>
</div>}
</div>
)}
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '8px 16px', minHeight: 200 }}>
{/* ── TAB: 실시간 탐지 ── */}
{tab === 'detect' && (<>
{!selectedKCG ? (
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}> · </div>
) : suspects.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px 0', color: '#22c55e', fontSize: 11 }}> {selectedKCG.name} {searchRadius}NM </div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{suspects.map((s, i) => (
<div key={s.ship.mmsi} onClick={() => handleSuspectClick(s)} style={{
background: '#111827', borderRadius: 4, padding: '6px 10px', borderLeft: `3px solid ${RISK_COLOR[s.riskLevel]}`, cursor: 'pointer',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
<span style={{ color: '#475569', minWidth: 18 }}>#{i + 1}</span>
<span>{RISK_ICON[s.riskLevel]}</span>
<span style={{ fontSize: 8, fontWeight: 700, padding: '0 4px', borderRadius: 2, background: RISK_COLOR[s.riskLevel] + '20', color: RISK_COLOR[s.riskLevel] }}>{s.riskLevel}</span>
<span style={{ fontWeight: 700, color: '#e2e8f0' }}>{s.ship.name || s.ship.mmsi}</span>
<span style={{ fontSize: 8, color: '#64748b' }}>[{TYPE_LABEL[s.estimatedType]}]</span>
<span style={{ marginLeft: 'auto', color: '#60a5fa', fontWeight: 700 }}>{s.distance.toFixed(1)} NM</span>
</div>
<div style={{ display: 'flex', gap: 3, marginTop: 2, flexWrap: 'wrap' }}>
{s.reasons.map((r, j) => <span key={j} style={{ fontSize: 7, padding: '0 4px', borderRadius: 2, background: 'rgba(255,255,255,0.05)', color: '#94a3b8' }}>{r}</span>)}
</div>
</div>
))}
</div>
)}
</>)}
{/* ── TAB: 대응 절차 ── */}
{tab === 'procedure' && (<>
{selectedSuspect ? (
<div style={{ fontSize: 10, color: '#e2e8f0', lineHeight: 1.7 }}>
{/* 선박 정보 */}
<div style={{ background: '#111827', borderRadius: 6, padding: '8px 12px', border: `1px solid ${RISK_COLOR[selectedSuspect.riskLevel]}30`, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{RISK_ICON[selectedSuspect.riskLevel]}</span>
<span style={{ fontWeight: 700, fontSize: 12 }}>{selectedSuspect.ship.name || selectedSuspect.ship.mmsi}</span>
<span style={{ fontSize: 9, padding: '1px 6px', borderRadius: 3, background: RISK_COLOR[selectedSuspect.riskLevel] + '20', color: RISK_COLOR[selectedSuspect.riskLevel], fontWeight: 700 }}>{selectedSuspect.riskLevel}</span>
<span style={{ fontSize: 9, color: '#64748b' }}>: {TYPE_LABEL[selectedSuspect.estimatedType]}</span>
</div>
<div style={{ fontSize: 9, color: '#64748b', marginTop: 2 }}>MMSI: {selectedSuspect.ship.mmsi} | SOG: {selectedSuspect.ship.speed?.toFixed(1)}kn | {selectedSuspect.distance.toFixed(1)} NM</div>
</div>
{/* 업종별 대응 절차 */}
<ProcedureSteps type={selectedSuspect.estimatedType} />
{/* 중국어 경고문 */}
<div style={{ marginTop: 12, borderTop: '1px solid #1e293b', paddingTop: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#f59e0b', marginBottom: 6 }}>📢 (클릭: 복사 | 🔊: )</div>
{(CN_WARNINGS[selectedSuspect.estimatedType] || CN_WARNINGS.UNKNOWN).map((w, i) => (
<div key={i} style={{
background: copiedIdx === i ? 'rgba(34,197,94,0.15)' : speakingIdx === i ? 'rgba(251,191,36,0.1)' : '#111827',
border: copiedIdx === i ? '1px solid rgba(34,197,94,0.4)' : speakingIdx === i ? '1px solid rgba(251,191,36,0.4)' : '1px solid #1e293b',
borderRadius: 4, padding: '6px 10px', marginBottom: 4, transition: 'all 0.2s',
display: 'flex', alignItems: 'flex-start', gap: 8,
}}>
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => copyToClipboard(w.zh, i)}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fbbf24' }}>{w.zh}</div>
<div style={{ fontSize: 9, color: '#94a3b8' }}>{w.ko}</div>
<div style={{ fontSize: 8, color: '#475569' }}>
: {w.usage}
{copiedIdx === i && <span style={{ color: '#22c55e', fontWeight: 700, marginLeft: 4 }}> </span>}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); speakChinese(w.zh, i); }}
style={{
background: speakingIdx === i ? 'rgba(251,191,36,0.3)' : 'rgba(251,191,36,0.1)',
border: '1px solid rgba(251,191,36,0.3)',
borderRadius: 4, padding: '4px 8px', cursor: 'pointer',
fontSize: 14, lineHeight: 1, flexShrink: 0,
animation: speakingIdx === i ? 'pulse 1s ease-in-out infinite' : 'none',
}}
title="중국어 음성 재생"
>
{speakingIdx === i ? '🔊' : '🔈'}
</button>
</div>
))}
</div>
</div>
) : (
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}>
<br/>
</div>
)}
</>)}
{/* ── TAB: 조치 기준 ── */}
{tab === 'alert' && (<AlertTable />)}
</div>
{/* Footer */}
<div style={{ padding: '4px 16px', borderTop: '1px solid #1e293b', fontSize: 8, color: '#475569', flexShrink: 0 }}>
GC-KCG-2026-001 | 906 | 수역: Point-in-Polygon |
</div>
</div>
);
}
// ── 업종별 대응 절차 컴포넌트 ──
const step: React.CSSProperties = { background: '#1e293b', borderRadius: 4, padding: '6px 10px', margin: '4px 0' };
const stepN: React.CSSProperties = { display: 'inline-block', background: '#3b82f6', color: '#fff', borderRadius: 3, padding: '0 5px', fontSize: 8, fontWeight: 700, marginRight: 4 };
const warn: React.CSSProperties = { background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, padding: '4px 8px', margin: '4px 0', fontSize: 9, color: '#fca5a5' };
function ProcedureSteps({ type }: { type: string }) {
switch (type) {
case 'PT': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🔴 2 (PT) </div>
<div style={warn}> () </div>
<div style={step}><span style={stepN}>1</span><b>/</b> AIS MMSI DB . · , </div>
<div style={step}><span style={stepN}>2</span><b>/</b> 45° . VHF Ch.16 3. </div>
<div style={step}><span style={stepN}>3</span><b> </b> (C21-xxxxx) ( 100/) (54mm)</div>
<div style={step}><span style={stepN}>4</span><b> </b> (4/16~10/15) | | </div>
<div style={step}><span style={stepN}>5</span><b>/</b> 위반: 목포··· . 경미: 경고 . </div>
</>);
case 'GN': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟡 (GN) </div>
<div style={warn}> ( )</div>
<div style={step}><span style={stepN}>1</span><b> </b> + SAR . 1NM </div>
<div style={step}><span style={stepN}>2</span><b> </b> 90° </div>
<div style={step}><span style={stepN}>3</span><b>AIS </b> "请打开AIS" . MMSI . </div>
<div style={step}><span style={stepN}>4</span><b> </b> (C25-xxxxx) (I발견) (28/) ·</div>
<div style={step}><span style={stepN}>5</span><b> </b> /. . GPS· </div>
</>);
case 'PS': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟣 (PS) </div>
<div style={warn}> , . </div>
<div style={step}><span style={stepN}>1</span><b> /</b> + . 3+ . </div>
<div style={step}><span style={stepN}>2</span><b> </b> EO/. MMSI . </div>
<div style={step}><span style={stepN}>3</span><b> </b> ·· . () </div>
<div style={step}><span style={stepN}>4</span><b> </b> 모선: C23-xxxxx, 1,500/. /조명선: 0톤 </div>
<div style={step}><span style={stepN}>5</span><b>/</b> · . VHF . · </div>
</>);
case 'FC': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟠 (FC) </div>
<div style={step}><span style={stepN}>1</span><b> </b> FC+ 0.5NM + 2kn + 30 HIGH. </div>
<div style={step}><span style={stepN}>2</span><b> </b> / . . MMSI·· </div>
<div style={step}><span style={stepN}>3</span><b> </b> 운반선: 화물··. 조업선: 허가량 </div>
<div style={step}><span style={stepN}>4</span><b>/</b> · . . . </div>
</>);
case 'GEAR': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🪤 </div>
<div style={warn}> / . </div>
<div style={step}><span style={stepN}>1</span><b>/</b> GPS(WGS84), , , , (··)</div>
<div style={step}><span style={stepN}>2</span><b> </b> , · . . </div>
<div style={step}><span style={stepN}>3</span><b> </b> RIB/. . · </div>
<div style={step}><span style={stepN}>4</span><b> </b> . · . </div>
</>);
default: return (<div style={{ color: '#64748b', fontSize: 10 }}> . .</div>);
}
}
function AlertTable() {
const rows = [
{ type: '미등록 선박', criteria: 'MMSI 허가DB 미등록', action: '즉시 정선·나포', level: 'CRITICAL', note: '허가증 불소지 추가 확인' },
{ type: '휴어기 조업', criteria: 'C21·C22: 4/16~10/15\nC25: 6/2~8/31', action: '즉시 나포', level: 'CRITICAL', note: '날짜 자동 판별' },
{ type: '허가 수역 이탈', criteria: '비허가 수역 진입', action: '경고 후 나포', level: 'HIGH', note: 'PT: I·IV이탈 GN: I이탈' },
{ type: 'PT 부속선 분리', criteria: '본선 이격 3NM+', action: '양선 동시 나포', level: 'HIGH→CRIT', note: '311쌍 실시간 모니터링' },
{ type: '환적 현장 포착', criteria: 'FC+조업선 0.5NM+2kn+30분', action: '촬영 후 양선 나포', level: 'HIGH', note: '증거 촬영 최우선' },
{ type: '불법 어구 발견', criteria: '표지 없음/미허가', action: '즉시 수거·기록', level: '자체판단', note: 'GPS 등록, 반복 요주의' },
{ type: '할당량 초과', criteria: '80~100%+ 초과', action: '계량·초과 시 압수', level: 'CRITICAL', note: 'GN 28톤 현장 계량' },
{ type: '다크베셀', criteria: 'AIS 공백 6시간+', action: '접근·임검', level: 'HIGH', note: 'SAR 교차 확인' },
];
const lc = (l: string) => l.includes('CRIT') ? '#ef4444' : l === 'HIGH' ? '#f59e0b' : '#64748b';
return (
<div style={{ fontSize: 10, color: '#e2e8f0' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#60a5fa', marginBottom: 6 }}>🚨 </div>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9 }}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={th}> </th><th style={th}> </th><th style={th}> </th><th style={th}></th><th style={th}></th>
</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}><td style={td}>{r.type}</td><td style={{ ...td, whiteSpace: 'pre-line' }}>{r.criteria}</td><td style={td}>{r.action}</td>
<td style={{ ...td, color: lc(r.level), fontWeight: 700 }}>{r.level}</td><td style={{ ...td, color: '#64748b', fontSize: 8 }}>{r.note}</td></tr>
))}
</tbody>
</table>
<div style={{ marginTop: 12, fontSize: 11, fontWeight: 700, color: '#60a5fa' }}>📅 </div>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9, marginTop: 4 }}>
<thead><tr style={{ background: '#1e293b' }}><th style={th}></th><th style={th}></th><th style={th}></th></tr></thead>
<tbody>
<tr><td style={{ ...td, fontWeight: 700 }}>7~8</td><td style={td}>PS 16 </td><td style={{ ...td, color: '#ef4444' }}>C21·C22·C25 </td></tr>
<tr><td style={{ ...td, fontWeight: 700 }}>5</td><td style={td}>GN만 </td><td style={td}>(C21·C22) </td></tr>
<tr><td style={{ ...td, fontWeight: 700 }}>4·10</td><td style={td}> </td><td style={td}>4/16, 10/16 </td></tr>
<tr><td style={{ ...td, fontWeight: 700 }}>1~3</td><td style={td}> </td><td style={td}>· </td></tr>
</tbody>
</table>
</div>
);
}
const th: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 6px', textAlign: 'left', color: '#e2e8f0', fontSize: 8, fontWeight: 700 };
const td: React.CSSProperties = { border: '1px solid #1e293b', padding: '2px 6px', fontSize: 9 };

파일 보기

@ -0,0 +1,258 @@
import { useMemo, useRef } from 'react';
import type { Ship } from '../../types';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone } from '../../utils/fishingAnalysis';
import type { FishingGearType } from '../../utils/fishingAnalysis';
interface Props {
ships: Ship[];
onClose: () => void;
}
function now() {
const d = new Date();
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
export function ReportModal({ ships, onClose }: Props) {
const reportRef = useRef<HTMLDivElement>(null);
const timestamp = useMemo(() => now(), []);
// Ship statistics
const stats = useMemo(() => {
const kr = ships.filter(s => s.flag === 'KR');
const cn = ships.filter(s => s.flag === 'CN');
const cnFishing = cn.filter(s => {
const cat = getMarineTrafficCategory(s.typecode, s.category);
return cat === 'fishing' || s.category === 'fishing' || s.typecode === '30';
});
// CN fishing by speed
const cnAnchored = cnFishing.filter(s => s.speed < 1);
const cnLowSpeed = cnFishing.filter(s => s.speed >= 1 && s.speed < 3);
const cnOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 6);
const cnSailing = cnFishing.filter(s => s.speed > 6);
// Gear analysis
const fishingStats = aggregateFishingStats(cn);
// Zone analysis
const zoneStats: Record<string, number> = { ZONE_I: 0, ZONE_II: 0, ZONE_III: 0, ZONE_IV: 0, OUTSIDE: 0 };
cnFishing.forEach(s => {
const z = classifyFishingZone(s.lat, s.lng);
zoneStats[z.zone] = (zoneStats[z.zone] || 0) + 1;
});
// Dark vessels (AIS gap)
const darkSuspect = cnFishing.filter(s => s.speed === 0 && (!s.heading || s.heading === 0));
// Ship types
const byType: Record<string, number> = {};
ships.forEach(s => {
const cat = getMarineTrafficCategory(s.typecode, s.category);
byType[cat] = (byType[cat] || 0) + 1;
});
// By nationality top 10
const byFlag: Record<string, number> = {};
ships.forEach(s => { byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1; });
const topFlags = Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10);
return { total: ships.length, kr, cn, cnFishing, cnAnchored, cnLowSpeed, cnOperating, cnSailing, fishingStats, zoneStats, darkSuspect, byType, topFlags };
}, [ships]);
const handlePrint = () => {
const content = reportRef.current;
if (!content) return;
const win = window.open('', '_blank');
if (!win) return;
win.document.write(`
<html><head><title> - ${timestamp}</title>
<style>
body { font-family: 'Malgun Gothic', sans-serif; padding: 40px; color: #1a1a1a; line-height: 1.8; font-size: 12px; }
h1 { font-size: 20px; border-bottom: 2px solid #1e3a5f; padding-bottom: 8px; color: #1e3a5f; }
h2 { font-size: 15px; color: #1e3a5f; margin-top: 24px; border-left: 4px solid #1e3a5f; padding-left: 8px; }
h3 { font-size: 13px; color: #333; margin-top: 16px; }
table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 11px; }
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; }
th { background: #1e3a5f; color: #fff; font-weight: 700; }
tr:nth-child(even) { background: #f5f7fa; }
.badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; }
.critical { background: #dc2626; color: #fff; }
.high { background: #f59e0b; color: #000; }
.medium { background: #3b82f6; color: #fff; }
.footer { margin-top: 30px; font-size: 9px; color: #888; border-top: 1px solid #ddd; padding-top: 8px; }
@media print { body { padding: 20px; } }
</style></head><body>${content.innerHTML}</body></html>
`);
win.document.close();
win.print();
};
const gearEntries = Object.entries(stats.fishingStats.byGear) as [FishingGearType, number][];
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={onClose}>
<div
style={{
width: '90vw', maxWidth: 900, maxHeight: '90vh', overflow: 'auto',
background: '#0f172a', borderRadius: 8, border: '1px solid rgba(99,179,237,0.3)',
}}
onClick={e => e.stopPropagation()}
>
{/* Toolbar */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(30,58,95,0.5)',
}}>
<span style={{ fontSize: 14 }}>📋</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}> </span>
<span style={{ fontSize: 9, color: '#64748b' }}>{timestamp} </span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
<button onClick={handlePrint} style={{
background: '#3b82f6', border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#fff', cursor: 'pointer',
}}>🖨 / PDF</button>
<button onClick={onClose} style={{
background: '#334155', border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#94a3b8', cursor: 'pointer',
}}> </button>
</div>
</div>
{/* Report Content */}
<div ref={reportRef} style={{ padding: '16px 24px', color: '#cbd5e1', fontSize: 11, lineHeight: 1.7 }}>
<h1 style={{ fontSize: 18, color: '#60a5fa', borderBottom: '2px solid #1e3a5f', paddingBottom: 6 }}>
</h1>
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 12 }}>
문서번호: GC-KCG-RPT-AUTO | : {timestamp} | 작성: KCG AI |
</div>
{/* 1. 전체 현황 */}
<h2 style={{ fontSize: 14, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 16 }}>1. </h2>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 10 }}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}></th><th style={thStyle}></th><th style={thStyle}></th>
</tr></thead>
<tbody>
<tr><td style={tdStyle}> </td><td style={tdBold}>{stats.total.toLocaleString()}</td><td style={tdStyle}>100%</td></tr>
<tr><td style={tdStyle}>🇰🇷 </td><td style={tdBold}>{stats.kr.length.toLocaleString()}</td><td style={tdStyle}>{pct(stats.kr.length, stats.total)}</td></tr>
<tr><td style={tdStyle}>🇨🇳 </td><td style={tdBold}>{stats.cn.length.toLocaleString()}</td><td style={tdStyle}>{pct(stats.cn.length, stats.total)}</td></tr>
<tr style={{ background: '#1e293b' }}><td style={tdStyle}>🇨🇳 </td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnFishing.length.toLocaleString()}</td><td style={tdStyle}>{pct(stats.cnFishing.length, stats.total)}</td></tr>
</tbody>
</table>
{/* 2. 중국어선 상세 */}
<h2 style={h2Style}>2. </h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}> </th><th style={thStyle}></th><th style={thStyle}></th><th style={thStyle}> </th>
</tr></thead>
<tbody>
<tr><td style={tdStyle}> (0~1kn)</td><td style={tdBold}>{stats.cnAnchored.length}</td><td style={tdStyle}>{pct(stats.cnAnchored.length, stats.cnFishing.length)}</td><td style={tdDim}>SOG {'<'} 1 knot</td></tr>
<tr><td style={tdStyle}>🔵 (1~3kn)</td><td style={tdBold}>{stats.cnLowSpeed.length}</td><td style={tdStyle}>{pct(stats.cnLowSpeed.length, stats.cnFishing.length)}</td><td style={tdDim}>· </td></tr>
<tr style={{ background: 'rgba(245,158,11,0.1)' }}><td style={tdStyle}>🟡 (2~6kn)</td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnOperating.length}</td><td style={tdStyle}>{pct(stats.cnOperating.length, stats.cnFishing.length)}</td><td style={tdDim}>/ </td></tr>
<tr><td style={tdStyle}>🟢 (6+kn)</td><td style={tdBold}>{stats.cnSailing.length}</td><td style={tdStyle}>{pct(stats.cnSailing.length, stats.cnFishing.length)}</td><td style={tdDim}>/</td></tr>
</tbody>
</table>
{/* 3. 어구별 분석 */}
<h2 style={h2Style}>3. / </h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}> </th><th style={thStyle}> </th><th style={thStyle}></th><th style={thStyle}> </th>
</tr></thead>
<tbody>
{gearEntries.map(([gear, count]) => {
const meta = GEAR_LABELS[gear];
return (
<tr key={gear}>
<td style={tdStyle}><span style={{ color: meta?.color || '#888' }}>{meta?.icon || '🎣'}</span> {meta?.label || gear}</td>
<td style={tdBold}>{count}</td>
<td style={tdStyle}>{meta?.riskLevel === 'CRITICAL' ? '◉ CRITICAL' : meta?.riskLevel === 'HIGH' ? '⚠ HIGH' : '△ MED'}</td>
<td style={tdStyle}>{meta?.confidence || '-'}</td>
</tr>
);
})}
</tbody>
</table>
{/* 4. 수역별 분포 */}
<h2 style={h2Style}>4. </h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}></th><th style={thStyle}> </th><th style={thStyle}> (3)</th><th style={thStyle}></th>
</tr></thead>
<tbody>
<tr><td style={tdStyle}> I ()</td><td style={tdBold}>{stats.zoneStats.ZONE_I}</td><td style={tdDim}>PS, FC만</td><td style={tdDim}>PT/OT/GN </td></tr>
<tr><td style={tdStyle}> II ()</td><td style={tdBold}>{stats.zoneStats.ZONE_II}</td><td style={tdDim}> </td><td style={tdDim}>-</td></tr>
<tr><td style={tdStyle}> III ()</td><td style={tdBold}>{stats.zoneStats.ZONE_III}</td><td style={tdDim}> </td><td style={tdDim}> </td></tr>
<tr><td style={tdStyle}> IV ()</td><td style={tdBold}>{stats.zoneStats.ZONE_IV}</td><td style={tdDim}>GN, PS, FC</td><td style={tdDim}>PT/OT </td></tr>
<tr style={{ background: 'rgba(239,68,68,0.1)' }}><td style={tdStyle}> </td><td style={{ ...tdBold, color: '#ef4444' }}>{stats.zoneStats.OUTSIDE}</td><td style={tdDim}>-</td><td style={{ ...tdDim, color: '#ef4444' }}> </td></tr>
</tbody>
</table>
{/* 5. 위험 분석 */}
<h2 style={h2Style}>5. </h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}> </th><th style={thStyle}> </th><th style={thStyle}></th>
</tr></thead>
<tbody>
<tr><td style={tdStyle}> </td><td style={tdBold}>{stats.darkSuspect.length}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.darkSuspect.length > 10 ? '#dc2626' : '#f59e0b' }}>{stats.darkSuspect.length > 10 ? 'HIGH' : 'MEDIUM'}</span></td></tr>
<tr><td style={tdStyle}> </td><td style={tdBold}>{stats.zoneStats.OUTSIDE}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.zoneStats.OUTSIDE > 0 ? '#dc2626' : '#22c55e' }}>{stats.zoneStats.OUTSIDE > 0 ? 'CRITICAL' : 'NORMAL'}</span></td></tr>
<tr><td style={tdStyle}> </td><td style={tdBold}>{stats.cnOperating.length}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: '#3b82f6' }}>MONITOR</span></td></tr>
</tbody>
</table>
{/* 6. 국적별 현황 */}
<h2 style={h2Style}>6. (TOP 10)</h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}></th><th style={thStyle}></th><th style={thStyle}></th><th style={thStyle}></th>
</tr></thead>
<tbody>
{stats.topFlags.map(([flag, count], i) => (
<tr key={flag}><td style={tdStyle}>{i + 1}</td><td style={tdStyle}>{flag}</td><td style={tdBold}>{count.toLocaleString()}</td><td style={tdStyle}>{pct(count, stats.total)}</td></tr>
))}
</tbody>
</table>
{/* 7. 건의사항 */}
<h2 style={h2Style}>7. </h2>
<div style={{ fontSize: 10, color: '#94a3b8', paddingLeft: 12 }}>
<p>1. 3 , <strong style={{ color: '#f59e0b' }}> - </strong> </p>
<p>2. {stats.darkSuspect.length} <strong style={{ color: '#ef4444' }}>SAR </strong> </p>
<p>3. {stats.zoneStats.OUTSIDE} <strong style={{ color: '#ef4444' }}> </strong> </p>
<p>4. 4/16 <strong> </strong> </p>
<p>5. 16 <strong> </strong> </p>
</div>
{/* Footer */}
<div style={{ marginTop: 24, paddingTop: 8, borderTop: '1px solid rgba(255,255,255,0.1)', fontSize: 8, color: '#475569' }}>
KCG . | : {timestamp} | 데이터: 실시간 AIS | 분석: AI |
</div>
</div>
</div>
</div>
);
}
// Styles
const h2Style: React.CSSProperties = { fontSize: 13, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 20 };
const tableStyle: React.CSSProperties = { borderCollapse: 'collapse', width: '100%', fontSize: 10, marginTop: 6 };
const thStyle: React.CSSProperties = { border: '1px solid #334155', padding: '4px 8px', textAlign: 'left', color: '#e2e8f0', fontSize: 9, fontWeight: 700 };
const tdStyle: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 8px', fontSize: 10 };
const tdBold: React.CSSProperties = { ...tdStyle, fontWeight: 700, color: '#e2e8f0' };
const tdDim: React.CSSProperties = { ...tdStyle, color: '#64748b', fontSize: 9 };
const badgeStyle: React.CSSProperties = { display: 'inline-block', padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700, color: '#fff' };
function pct(n: number, total: number): string {
if (!total) return '-';
return `${((n / total) * 100).toFixed(1)}%`;
}

파일 보기

@ -145,4 +145,57 @@ export const damagedShips: DamagedShip[] = [
description: 'IRGC 게쉼섬 고속정 기지 공습. 고속정 20여 척 파괴/대파.',
eventId: 'd12-us5',
},
// ═══ S&P Global Marine Risk Note (2026-03-19) ═══
// 이란의 상선 공격 30건 — 호르무즈 해협 중심
// UAE 해역 48%, 오만 해역 28%, 기타 24%
// DAY 0 — 2026-03-01 (6척 동시 공격)
{ id: 'spg-01', name: 'SKYLIGHT', flag: 'MH', type: 'Chemical/Products Tanker', lat: 26.12, lng: 56.28, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9396737, 마셜제도 국적 화학탱커. UAE 해역 피격.', eventId: 'imp1' },
{ id: 'spg-02', name: 'STAR ELECTRA', flag: 'LR', type: 'Bulk Carrier', lat: 25.85, lng: 56.42, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9442536, 라이베리아 국적 벌크선. UAE 해역 피격.', eventId: 'imp1' },
{ id: 'spg-03', name: 'HERCULES STAR', flag: 'GI', type: 'Products Tanker', lat: 26.35, lng: 56.15, damagedAt: T0, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9916135, 지브롤터 국적 유조선. UAE 해역.', eventId: 'imp1' },
{ id: 'spg-04', name: 'SEA LA DONNA', flag: 'LR', type: 'Chemical/Products Tanker', lat: 25.98, lng: 56.55, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9380532, 라이베리아 화학탱커.', eventId: 'imp1' },
{ id: 'spg-05', name: 'OCEAN ELECTRA', flag: 'LR', type: 'Products Tanker', lat: 26.22, lng: 56.35, damagedAt: T0, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9402782, 라이베리아 유조선.', eventId: 'imp1' },
{ id: 'spg-06', name: 'AYEH', flag: 'AE', type: 'Deck Cargo Ship', lat: 25.55, lng: 55.80, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 1075181, UAE 국적 갑판화물선.', eventId: 'imp1' },
// DAY 1 — 2026-03-02 (3척)
{ id: 'spg-07', name: 'STENA IMPERATIVE', flag: 'US', type: 'Chemical/Products Tanker', lat: 26.55, lng: 56.10, damagedAt: T0 + 1 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9666077, 미국 국적 화학탱커. 미국 선박 최초 피격.', eventId: 'imp1' },
{ id: 'spg-08', name: 'MKD VYOM', flag: 'MH', type: 'Crude/Oil Products Tanker', lat: 25.70, lng: 56.90, damagedAt: T0 + 1 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9284386, 마셜제도 원유탱커. 오만 해역.', eventId: 'imp1' },
{ id: 'spg-09', name: 'ATHE NOVA', flag: 'HN', type: 'Asphalt/Bitumen Tanker', lat: 25.40, lng: 57.20, damagedAt: T0 + 1 * DAY, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9188116, 온두라스 아스팔트탱커. 오만 해역.', eventId: 'imp1' },
// DAY 2 — 2026-03-03 (3척)
{ id: 'spg-10', name: 'PELAGIA', flag: 'MT', type: 'Bulk Carrier', lat: 26.30, lng: 56.50, damagedAt: T0 + 2 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9433626, 몰타 벌크선.', eventId: 'd3-sea1' },
{ id: 'spg-11', name: 'GOLD OAK', flag: 'PA', type: 'Bulk Carrier', lat: 25.60, lng: 57.10, damagedAt: T0 + 2 * DAY, cause: '기뢰 접촉', damage: 'moderate', description: 'IMO 9806342, 파나마 벌크선. 오만 해역.', eventId: 'd3-sea1' },
{ id: 'spg-12', name: 'LIBRA TRADER', flag: 'IN', type: 'Crude Oil Tanker', lat: 26.05, lng: 56.30, damagedAt: T0 + 2 * DAY, cause: 'IRGC 공격', damage: 'severe', description: 'IMO 9562673, 인도 원유탱커.', eventId: 'd3-sea1' },
// DAY 3 — 2026-03-04 (3척)
{ id: 'spg-13', name: 'SAFEEN PRESTIGE', flag: 'MT', type: 'Container Ship', lat: 25.90, lng: 56.40, damagedAt: T0 + 3 * DAY, cause: 'IRGC 드론', damage: 'moderate', description: 'IMO 9593517, 몰타 컨테이너선.', eventId: 'imp2' },
{ id: 'spg-14', name: 'MSC GRACE', flag: 'LR', type: 'Container Ship', lat: 26.40, lng: 56.20, damagedAt: T0 + 3 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9987366, 라이베리아 MSC 컨테이너선.', eventId: 'imp2' },
// DAY 4 — 2026-03-05 (1척)
{ id: 'spg-15', name: 'SONANGOL NAMIBE', flag: 'BS', type: 'Crude Oil Tanker', lat: 26.15, lng: 56.45, damagedAt: T0 + 4 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9325049, 바하마 원유탱커. UAE 해역.', eventId: 'imp2' },
// DAY 5 — 2026-03-06 (2척)
{ id: 'spg-16', name: 'PRIMA', flag: 'MT', type: 'Tanker', lat: 25.80, lng: 56.60, damagedAt: T0 + 5 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9427433, 몰타 탱커.', eventId: 'imp3' },
{ id: 'spg-17', name: 'MUSSAFAH 2', flag: 'AE', type: 'Tug', lat: 25.50, lng: 55.60, damagedAt: T0 + 5 * DAY, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9522051, UAE 예인선.', eventId: 'imp3' },
// DAY 6 — 2026-03-07 (1척)
{ id: 'spg-18', name: 'LOUIS P', flag: 'MH', type: 'Chemical/Products Tanker', lat: 26.60, lng: 56.05, damagedAt: T0 + 6 * DAY, cause: 'IRGC 드론', damage: 'moderate', description: 'IMO 9749336, 마셜제도 화학탱커.', eventId: 'imp3' },
// DAY 10 — 2026-03-11 (4척)
{ id: 'spg-19', name: 'MAYUREE NAREE', flag: 'TH', type: 'Bulk Carrier', lat: 25.45, lng: 57.30, damagedAt: T0 + 10 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9323649, 태국 벌크선. 오만 해역.', eventId: 'd12-sea1' },
{ id: 'spg-20', name: 'STAR GWYNETH', flag: 'MH', type: 'Bulk Carrier', lat: 26.20, lng: 56.55, damagedAt: T0 + 10 * DAY, cause: '기뢰 접촉', damage: 'moderate', description: 'IMO 9301031, 마셜제도 벌크선.', eventId: 'd12-sea1' },
{ id: 'spg-21', name: 'ONE MAJESTY', flag: 'JP', type: 'Container Ship', lat: 25.75, lng: 56.80, damagedAt: T0 + 10 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9424912, 일본 ONE 컨테이너선. 오만 해역.', eventId: 'd12-sea1' },
{ id: 'spg-22', name: 'EXPRESS ROME', flag: 'LR', type: 'Container Ship', lat: 26.00, lng: 56.35, damagedAt: T0 + 10 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9484936, 라이베리아 컨테이너선.', eventId: 'd12-sea1' },
// DAY 11 — 2026-03-12 (3척)
{ id: 'spg-23', name: 'ZEFYROS', flag: 'MT', type: 'Chemical/Products Tanker', lat: 26.45, lng: 56.15, damagedAt: T0 + 11 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9515917, 몰타 화학탱커.', eventId: 'd12-sea3' },
{ id: 'spg-24', name: 'SAFESEA VISHNU', flag: 'MH', type: 'Crude/Oil Products Tanker', lat: 25.65, lng: 57.00, damagedAt: T0 + 11 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9327009, 마셜제도 원유탱커. 오만 해역.', eventId: 'd12-sea3' },
{ id: 'spg-25', name: 'SOURCE BLESSING', flag: 'LR', type: 'Container Ship', lat: 26.10, lng: 56.40, damagedAt: T0 + 11 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9243198, 라이베리아 컨테이너선.', eventId: 'd12-sea3' },
// DAY 15 — 2026-03-16 (1척)
{ id: 'spg-26', name: 'GAS AL AHMADIAH', flag: 'KW', type: 'LPG Tanker', lat: 29.20, lng: 48.80, damagedAt: T0 + 15 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9849629, 쿠웨이트 LPG탱커. 쿠웨이트 해역.', eventId: 'd12-sea6' },
// DAY 18 — 2026-03-19 (2척)
{ id: 'spg-27', name: 'HALUL 69', flag: 'QA', type: 'Anchor Handling Tug Supply', lat: 25.95, lng: 51.55, damagedAt: T0 + 18 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9671577, 카타르 AHTS. 카타르 해역.', eventId: 'd12-p5' },
];

파일 보기

@ -0,0 +1,194 @@
// Middle East Energy & Hazard Facilities (OSINT + OpenStreetMap)
export type FacilitySubType =
| 'power' | 'wind' | 'nuclear' | 'thermal' // energy
| 'petrochem' | 'lng' | 'oil_tank' | 'haz_port'; // hazard
export interface EnergyHazardFacility {
id: string;
name: string;
nameKo: string;
lat: number;
lng: number;
country: string; // ISO-2
countryKey: string; // overseas layer key prefix (us, il, ir, ae, sa, om, qa, kw, iq, bh)
category: 'energy' | 'hazard';
subType: FacilitySubType;
capacityMW?: number;
description: string;
}
export const SUB_TYPE_META: Record<FacilitySubType, { label: string; color: string; icon: string }> = {
power: { label: '발전소', color: '#a855f7', icon: '⚡' },
wind: { label: '풍력단지', color: '#22d3ee', icon: '🌬' },
nuclear: { label: '원자력발전소', color: '#f59e0b', icon: '☢' },
thermal: { label: '화력발전소', color: '#64748b', icon: '🏭' },
petrochem: { label: '석유화학단지', color: '#f97316', icon: '🛢' },
lng: { label: 'LNG저장기지', color: '#0ea5e9', icon: '❄' },
oil_tank: { label: '유류저장탱크', color: '#eab308', icon: '🛢' },
haz_port: { label: '위험물항만하역시설', color: '#dc2626', icon: '⚠' },
};
// layer key -> subType mapping
export function layerKeyToSubType(key: string): FacilitySubType | null {
if (key.endsWith('Power')) return 'power';
if (key.endsWith('Wind')) return 'wind';
if (key.endsWith('Nuclear')) return 'nuclear';
if (key.endsWith('Thermal')) return 'thermal';
if (key.endsWith('Petrochem')) return 'petrochem';
if (key.endsWith('Lng')) return 'lng';
if (key.endsWith('OilTank')) return 'oil_tank';
if (key.endsWith('HazPort')) return 'haz_port';
return null;
}
export function layerKeyToCountry(key: string): string | null {
const m = key.match(/^(us|il|ir|ae|sa|om|qa|kw|iq|bh)/);
return m ? m[1] : null;
}
export const ME_ENERGY_HAZARD_FACILITIES: EnergyHazardFacility[] = [
// ════════════════════════════════════════════
// 🇺🇸 미국 (중동 주둔 시설 + 에너지 인프라)
// ════════════════════════════════════════════
{ id: 'US-E01', name: 'Al Udeid Power Plant', nameKo: '알우데이드 발전소', lat: 25.1175, lng: 51.3150, country: 'US', countryKey: 'us', category: 'energy', subType: 'power', capacityMW: 200, description: '미군 알우데이드 기지 전용 발전시설' },
{ id: 'US-H01', name: 'Bahrain NAVSUP Fuel Depot', nameKo: '바레인 미해군 유류저장소', lat: 26.2361, lng: 50.6036, country: 'US', countryKey: 'us', category: 'hazard', subType: 'oil_tank', description: 'NSA Bahrain 유류 보급 시설' },
{ id: 'US-H02', name: 'Jebel Ali US Navy Fuel Terminal', nameKo: '제벨알리 미해군 연료터미널', lat: 25.0100, lng: 55.0600, country: 'US', countryKey: 'us', category: 'hazard', subType: 'haz_port', description: '미 제5함대 연료 보급 항만' },
// ════════════════════════════════════════════
// 🇮🇱 이스라엘
// ════════════════════════════════════════════
// Energy
{ id: 'IL-E01', name: 'Orot Rabin Power Station', nameKo: '오롯 라빈 화력발전소', lat: 32.3915, lng: 34.8610, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 2590, description: '이스라엘 최대 석탄/가스 복합 발전소 (하데라)' },
{ id: 'IL-E02', name: 'Rutenberg Power Station', nameKo: '루텐베르그 화력발전소', lat: 31.6200, lng: 34.5300, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 2250, description: '아슈켈론 석탄 화력발전소' },
{ id: 'IL-E03', name: 'Eshkol Power Station', nameKo: '에쉬콜 발전소', lat: 31.7940, lng: 34.6350, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 1096, description: '아슈도드 해안 천연가스 복합화력 (IEC 운영)' },
{ id: 'IL-E04', name: 'Hagit Power Station', nameKo: '하깃 발전소', lat: 32.5600, lng: 35.0800, country: 'IL', countryKey: 'il', category: 'energy', subType: 'power', capacityMW: 600, description: '북부 가스터빈 발전소' },
{ id: 'IL-E05', name: 'Dimona Nuclear Research Center', nameKo: '디모나 원자력연구센터', lat: 31.0014, lng: 35.1467, country: 'IL', countryKey: 'il', category: 'energy', subType: 'nuclear', description: '네게브 원자력연구시설 (IRR-2)' },
{ id: 'IL-E06', name: 'Ashalim Solar Power Station', nameKo: '아샬림 태양광발전소', lat: 31.1300, lng: 34.6600, country: 'IL', countryKey: 'il', category: 'energy', subType: 'power', capacityMW: 310, description: '네게브 사막 CSP+PV 복합 발전' },
// Hazard
{ id: 'IL-H01', name: 'Haifa Bay Petrochemical Complex', nameKo: '하이파만 석유화학단지', lat: 32.8100, lng: 35.0500, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'petrochem', description: 'Oil Refineries Ltd. + Bazan Group 정유/석유화학 단지' },
{ id: 'IL-H02', name: 'Ashdod Oil Terminal', nameKo: '아시도드 유류터미널', lat: 31.8200, lng: 34.6350, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'oil_tank', description: 'EAPC 원유 수입 터미널 + 저장탱크' },
{ id: 'IL-H03', name: 'Ashkelon Desalination & Energy Hub', nameKo: '아슈켈론 에너지허브', lat: 31.6100, lng: 34.5400, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'haz_port', description: '해수담수화 + LNG 수입 터미널' },
{ id: 'IL-H04', name: 'Eilat-Ashkelon Pipeline Terminal', nameKo: 'EAPC 에일라트 터미널', lat: 29.5500, lng: 34.9500, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'oil_tank', description: '홍해 원유 수입 파이프라인 터미널' },
// ════════════════════════════════════════════
// 🇮🇷 이란 (Wikipedia + OSINT 기반)
// 총 설치용량 ~85,000MW, 화력 95%+, 수력 ~12,000MW
// ════════════════════════════════════════════
// ── 화력발전소 (Thermal) ──
{ id: 'IR-E01', name: 'Damavand Power Plant', nameKo: '다마반드 발전소', lat: 35.5200, lng: 51.9800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2900, description: '이란 최대 화력발전소, 테헤란 남동 50km (가스복합)' },
{ id: 'IR-E02', name: 'Shahid Salimi (Neka) Power Plant', nameKo: '샤히드 살리미(네카) 발전소', lat: 36.6500, lng: 53.3300, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2214, description: '마잔다란주, 이란 2위 화력 (카스피해 연안)' },
{ id: 'IR-E03', name: 'Shahid Rajaee Combined Cycle', nameKo: '샤히드 라자이 복합화력', lat: 36.3700, lng: 49.9900, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2042, description: '가즈빈주, 이란 3위 복합화력' },
{ id: 'IR-E04', name: 'Ramin Steam Power Plant', nameKo: '라민 증기화력발전소', lat: 31.3100, lng: 48.7400, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1890, description: '후제스탄주 아바즈 인근 증기터빈' },
{ id: 'IR-E05', name: 'Shahid Montazeri Power Plant', nameKo: '샤히드 몬타제리 발전소', lat: 32.6500, lng: 51.6800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1600, description: '이스파한, 1984년 가동 개시' },
{ id: 'IR-E06', name: 'Parand Combined Cycle', nameKo: '파란드 복합화력', lat: 35.4700, lng: 51.0100, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1536, description: '테헤란 서남부 복합화력' },
{ id: 'IR-E07', name: 'Tabriz Thermal Power Plant', nameKo: '타브리즈 화력발전소', lat: 38.0600, lng: 46.3200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1386, description: '동아제르바이잔주' },
{ id: 'IR-E08', name: 'Bandar Abbas Power Plant', nameKo: '반다르아바스 발전소', lat: 27.2000, lng: 56.2500, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1057, description: '호르무즈해협 연안' },
{ id: 'IR-E09', name: 'Besat Power Plant', nameKo: '베사트 발전소', lat: 35.8300, lng: 50.9800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1000, description: '테헤란 남부 가스터빈' },
{ id: 'IR-E10', name: 'Tous Power Plant', nameKo: '투스 발전소', lat: 36.3100, lng: 59.5800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1470, description: '마슈하드 인근 복합화력' },
{ id: 'IR-E11', name: 'Fars (Shahid Dastjerdi) Power Plant', nameKo: '파르스 발전소', lat: 29.6000, lng: 52.5000, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1028, description: '시라즈 인근 가스복합' },
{ id: 'IR-E12', name: 'Hormozgan Power Plant', nameKo: '호르모즈간 발전소', lat: 27.1800, lng: 56.3000, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 906, description: '호르모즈간주 가스복합' },
{ id: 'IR-E13', name: 'Shahid Mofateh Power Plant', nameKo: '샤히드 모파테 발전소', lat: 34.7700, lng: 48.5200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1000, description: '하메단주 복합화력' },
{ id: 'IR-E14', name: 'Kerman Combined Cycle', nameKo: '케르만 복합화력', lat: 30.2600, lng: 57.0700, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 928, description: '케르만주 복합화력' },
{ id: 'IR-E15', name: 'Yazd Combined Cycle', nameKo: '야즈드 복합화력', lat: 31.9000, lng: 54.3700, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 948, description: '야즈드주 가스복합' },
// ── 수력발전소 (Hydro) ──
{ id: 'IR-E16', name: 'Karun-3 (Shahid Rajaee) Dam', nameKo: '카룬-3 수력발전소', lat: 31.8055, lng: 50.0893, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2280, description: '이란 최대 수력, 후제스탄주 이제 SE 28km, 8기' },
{ id: 'IR-E17', name: 'Shahid Abbaspour (Karun-1) Dam', nameKo: '카룬-1 (샤히드 아바스푸르) 수력', lat: 32.0519, lng: 49.6069, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2000, description: '후제스탄주 마스제드솔레이만 NE 50km' },
{ id: 'IR-E18', name: 'Karun-4 Dam', nameKo: '카룬-4 수력발전소', lat: 31.5969, lng: 50.4712, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 1000, description: '카룬강 상류, 2011년 가동' },
{ id: 'IR-E19', name: 'Dez Dam Hydropower', nameKo: '데즈댐 수력발전소', lat: 32.6053, lng: 48.4640, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 520, description: '후제스탄주 안디메쉬크 NE 20km, 8기' },
{ id: 'IR-E20', name: 'Masjed Soleiman Dam', nameKo: '마스제드솔레이만 수력', lat: 32.0300, lng: 49.2800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2000, description: '카룬강 하류, 대형 아치댐' },
// ── 원자력/핵시설 (Nuclear) ──
{ id: 'IR-E21', name: 'Bushehr Nuclear Power Plant', nameKo: '부셰르 원자력발전소', lat: 28.8267, lng: 50.8867, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', capacityMW: 915, description: '이란 유일 상업 원전 (VVER-1000), 1995 러시아 계약' },
{ id: 'IR-E22', name: 'Natanz Enrichment Facility', nameKo: '나탄즈 우라늄농축시설', lat: 33.7250, lng: 51.7267, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '주요 원심분리기 농축시설 (지하)' },
{ id: 'IR-E23', name: 'Fordow Enrichment Facility', nameKo: '포르도 우라늄농축시설', lat: 34.8800, lng: 51.5800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '지하 농축시설 (FFEP, 쿰 인근 산속)' },
{ id: 'IR-E24', name: 'Isfahan Nuclear Technology Center', nameKo: '이스파한 핵기술센터 (UCF)', lat: 32.7200, lng: 51.7200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '우라늄전환시설 + 연구용 원자로' },
{ id: 'IR-E25', name: 'Arak Heavy Water Reactor (IR-40)', nameKo: '아라크 중수로', lat: 34.0400, lng: 49.2400, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: 'IR-40 중수 연구용 원자로 (마르카지주)' },
{ id: 'IR-E26', name: 'Darkhovin Nuclear Power Plant', nameKo: '다르코빈 원자력발전소', lat: 31.3700, lng: 48.3200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', capacityMW: 360, description: '이란 자체 건설 원전 (2007 착공, 후제스탄주)' },
// ── 풍력 (Wind) ──
{ id: 'IR-E27', name: 'Manjil-Rudbar Wind Farm', nameKo: '만질-루드바르 풍력단지', lat: 36.7400, lng: 49.4200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'wind', capacityMW: 101, description: '길란주, 이란 최대 풍력 (2003 가동)' },
{ id: 'IR-E28', name: 'Binaloud Wind Farm', nameKo: '비날루드 풍력단지', lat: 36.2200, lng: 58.7500, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'wind', capacityMW: 28, description: '라자비호라산주 니샤푸르 인근, 43기 x 660kW' },
// ── 태양광 (Solar) ──
{ id: 'IR-E29', name: 'Zarand Solar Power Plant', nameKo: '자란드 태양광발전소', lat: 30.8100, lng: 56.5600, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 10, description: '케르만주 태양광 시범단지' },
// Hazard
{ id: 'IR-H01', name: 'South Pars Gas Complex (Assaluyeh)', nameKo: '사우스파르스 가스단지 (아살루예)', lat: 27.4800, lng: 52.6100, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: '세계 최대 가스전 육상 처리시설 (20+ 페이즈)' },
{ id: 'IR-H02', name: 'Kharg Island Oil Terminal', nameKo: '하르그섬 원유터미널', lat: 29.2300, lng: 50.3100, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'oil_tank', description: '이란 원유 수출의 90% 처리 (저장 2,800만 배럴)' },
{ id: 'IR-H03', name: 'Bandar Imam Khomeini Petrochemical', nameKo: '반다르 이맘호메이니 석유화학', lat: 30.4300, lng: 49.0800, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: 'Mahshahr 특별경제구역 석유화학단지' },
{ id: 'IR-H04', name: 'Tombak LNG Terminal', nameKo: '톰박 LNG터미널', lat: 27.5200, lng: 52.5500, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'lng', description: 'Iran LNG 수출 터미널 (건설중)' },
{ id: 'IR-H05', name: 'Bandar Abbas Oil Refinery', nameKo: '반다르아바스 정유소', lat: 27.2100, lng: 56.2800, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: '일 320,000배럴 정유시설' },
{ id: 'IR-H06', name: 'Lavan Island Oil Terminal', nameKo: '라반섬 원유터미널', lat: 26.8100, lng: 53.3600, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'oil_tank', description: '페르시아만 원유 저장/선적 시설' },
// ════════════════════════════════════════════
// 🇦🇪 UAE
// ════════════════════════════════════════════
// Energy
{ id: 'AE-E01', name: 'Barakah Nuclear Power Plant', nameKo: '바라카 원자력발전소', lat: 23.9592, lng: 52.2567, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'nuclear', capacityMW: 5600, description: '아랍 최초 상업 원전 (APR-1400 x4)' },
{ id: 'AE-E02', name: 'Jebel Ali Power & Desalination', nameKo: '제벨알리 발전/담수', lat: 25.0200, lng: 55.1100, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'thermal', capacityMW: 8695, description: '세계 최대 복합 발전/담수 단지' },
{ id: 'AE-E03', name: 'Shams Solar Power Station', nameKo: '샴스 태양광발전소', lat: 23.5800, lng: 53.7100, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'power', capacityMW: 100, description: '아부다비 CSP 태양열 발전' },
{ id: 'AE-E04', name: 'Hassyan Clean Coal Power Plant', nameKo: '하시안 청정석탄발전소', lat: 24.9600, lng: 55.0300, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'thermal', capacityMW: 2400, description: '두바이 석탄→가스 전환 중' },
// Hazard
{ id: 'AE-H01', name: 'Ruwais Industrial Complex (ADNOC)', nameKo: '루와이스 산업단지 (ADNOC)', lat: 24.1100, lng: 52.7300, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'petrochem', description: 'ADNOC 정유/석유화학 통합단지 (세계 최대급)' },
{ id: 'AE-H02', name: 'Das Island LNG Terminal', nameKo: '다스섬 LNG터미널', lat: 25.1600, lng: 52.8700, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'lng', description: 'ADGAS LNG 수출 터미널 (연 570만톤)' },
{ id: 'AE-H03', name: 'Fujairah Oil Terminal (FOSC)', nameKo: '푸자이라 유류터미널', lat: 25.1200, lng: 56.3400, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'oil_tank', description: '세계 3대 벙커링 허브 (저장 1,400만m3)' },
{ id: 'AE-H04', name: 'Jebel Ali Free Zone Port', nameKo: '제벨알리 자유무역항', lat: 25.0000, lng: 55.0700, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'haz_port', description: '중동 최대 항만 (위험물 취급)' },
// ════════════════════════════════════════════
// 🇸🇦 사우디아라비아
// ════════════════════════════════════════════
// Energy
{ id: 'SA-E01', name: 'Shoaiba Power & Desalination', nameKo: '쇼아이바 발전/담수', lat: 20.7000, lng: 39.5100, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 5600, description: '홍해 연안 세계 최대급 복합 발전/담수' },
{ id: 'SA-E02', name: 'Rabigh Power Plant', nameKo: '라비그 발전소', lat: 22.8000, lng: 39.0200, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 2100, description: '홍해 연안 가스복합 발전소' },
{ id: 'SA-E03', name: 'Dumat Al Jandal Wind Farm', nameKo: '두마트알잔달 풍력단지', lat: 29.8100, lng: 39.8700, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'wind', capacityMW: 400, description: '중동 최대 풍력단지' },
{ id: 'SA-E04', name: 'Jubail IWPP', nameKo: '주바일 발전소', lat: 27.0200, lng: 49.6200, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 2745, description: '동부 산업도시 복합 발전' },
// Hazard
{ id: 'SA-H01', name: 'Ras Tanura Oil Terminal', nameKo: '라스타누라 원유터미널', lat: 26.6400, lng: 50.1600, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'oil_tank', description: '세계 최대 해상 원유 선적 시설 (일 600만 배럴)' },
{ id: 'SA-H02', name: 'Jubail Industrial City (SABIC)', nameKo: '주바일 산업단지 (SABIC)', lat: 27.0000, lng: 49.6500, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'petrochem', description: '세계 최대 석유화학 산업단지' },
{ id: 'SA-H03', name: 'Yanbu Industrial City', nameKo: '얀부 산업단지', lat: 23.9600, lng: 38.2400, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'petrochem', description: '홍해 연안 정유/석유화학 단지' },
{ id: 'SA-H04', name: 'Ras Al-Khair LNG Import', nameKo: '라스알카이르 LNG', lat: 27.4800, lng: 49.2600, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'lng', description: 'LNG 수입/가스화 터미널' },
{ id: 'SA-H05', name: 'Abqaiq Oil Processing', nameKo: '아브카이크 원유처리시설', lat: 25.9400, lng: 49.6800, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'oil_tank', description: '세계 최대 원유 안정화 시설 (2019 공격 대상)' },
// ════════════════════════════════════════════
// 🇴🇲 오만
// ════════════════════════════════════════════
{ id: 'OM-E01', name: 'Barka Power & Desalination', nameKo: '바르카 발전/담수', lat: 23.6800, lng: 57.8700, country: 'OM', countryKey: 'om', category: 'energy', subType: 'thermal', capacityMW: 2007, description: 'GDF Suez 운영 복합발전' },
{ id: 'OM-E02', name: 'Dhofar Wind Farm', nameKo: '도파르 풍력단지', lat: 17.0200, lng: 54.1000, country: 'OM', countryKey: 'om', category: 'energy', subType: 'wind', capacityMW: 50, description: 'GCC 최초 대형 풍력단지' },
{ id: 'OM-H01', name: 'Sohar Industrial Port', nameKo: '소하르 산업항', lat: 24.3600, lng: 56.7400, country: 'OM', countryKey: 'om', category: 'hazard', subType: 'petrochem', description: '정유소+석유화학+알루미늄 제련단지' },
{ id: 'OM-H02', name: 'Qalhat LNG Terminal', nameKo: '칼하트 LNG터미널', lat: 22.9200, lng: 59.3700, country: 'OM', countryKey: 'om', category: 'hazard', subType: 'lng', description: 'Oman LNG 수출 (연 1,060만톤)' },
// ════════════════════════════════════════════
// 🇶🇦 카타르
// ════════════════════════════════════════════
{ id: 'QA-E01', name: 'Ras Laffan Power Plant', nameKo: '라스라판 발전소', lat: 25.9100, lng: 51.5500, country: 'QA', countryKey: 'qa', category: 'energy', subType: 'thermal', capacityMW: 2730, description: '카타르 최대 발전소' },
{ id: 'QA-H01', name: 'Ras Laffan Industrial City', nameKo: '라스라판 산업단지', lat: 25.9200, lng: 51.5300, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'lng', description: '세계 최대 LNG 수출기지 (QatarEnergy, 연 7,700만톤)' },
{ id: 'QA-H02', name: 'Mesaieed Industrial City', nameKo: '메사이드 산업단지', lat: 24.9900, lng: 51.5600, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'petrochem', description: 'QatarEnergy 정유/석유화학/비료 단지' },
{ id: 'QA-H03', name: 'Dukhan Oil Field Terminal', nameKo: '두칸 유전터미널', lat: 25.4300, lng: 50.7700, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'oil_tank', description: '서부 해안 육상 유전 터미널' },
// ════════════════════════════════════════════
// 🇰🇼 쿠웨이트
// ════════════════════════════════════════════
{ id: 'KW-E01', name: 'Az-Zour Power Plant', nameKo: '아즈주르 발전소', lat: 28.7200, lng: 48.3800, country: 'KW', countryKey: 'kw', category: 'energy', subType: 'thermal', capacityMW: 4800, description: '쿠웨이트 최대 발전/담수' },
{ id: 'KW-H01', name: 'Mina Al Ahmadi Refinery', nameKo: '미나알아흐마디 정유소', lat: 29.0600, lng: 48.1500, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'petrochem', description: 'KNPC 운영 (일 466,000배럴)' },
{ id: 'KW-H02', name: 'Az-Zour LNG Import Terminal', nameKo: '아즈주르 LNG터미널', lat: 28.7100, lng: 48.3500, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'lng', description: '쿠웨이트 LNG 수입 터미널' },
{ id: 'KW-H03', name: 'Mina Abdullah Oil Tank Farm', nameKo: '미나압둘라 유류저장기지', lat: 29.0000, lng: 48.1700, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'oil_tank', description: '남부 원유 저장/선적' },
// ════════════════════════════════════════════
// 🇮🇶 이라크
// ════════════════════════════════════════════
{ id: 'IQ-E01', name: 'Basra Gas Power Plant', nameKo: '바스라 가스발전소', lat: 30.5100, lng: 47.7800, country: 'IQ', countryKey: 'iq', category: 'energy', subType: 'thermal', capacityMW: 1500, description: '남부 이라크 최대 발전소' },
{ id: 'IQ-H01', name: 'Basra Oil Terminal (ABOT)', nameKo: '알바스라 원유터미널', lat: 29.6800, lng: 48.8000, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'oil_tank', description: '이라크 원유 수출의 85% (페르시아만)' },
{ id: 'IQ-H02', name: 'Khor Al-Zubair Port', nameKo: '코르알주바이르 항', lat: 30.1700, lng: 47.8700, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'haz_port', description: '이라크 주요 위험물 하역항' },
{ id: 'IQ-H03', name: 'Rumaila Oil Field', nameKo: '루마일라 유전', lat: 30.6300, lng: 47.4300, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'oil_tank', description: '이라크 최대 유전 (일 150만 배럴)' },
// ════════════════════════════════════════════
// 🇧🇭 바레인
// ════════════════════════════════════════════
{ id: 'BH-E01', name: 'Al Dur Power & Water Plant', nameKo: '알두르 발전/담수', lat: 25.9400, lng: 50.6200, country: 'BH', countryKey: 'bh', category: 'energy', subType: 'thermal', capacityMW: 1234, description: '바레인 최대 발전소' },
{ id: 'BH-H01', name: 'Sitra Oil Refinery (BAPCO)', nameKo: '시트라 정유소 (BAPCO)', lat: 26.1500, lng: 50.6100, country: 'BH', countryKey: 'bh', category: 'hazard', subType: 'petrochem', description: '바레인 유일 정유시설 (일 267,000배럴)' },
{ id: 'BH-H02', name: 'Khalifa Bin Salman Port', nameKo: '칼리파빈살만항', lat: 26.0200, lng: 50.5500, country: 'BH', countryKey: 'bh', category: 'hazard', subType: 'haz_port', description: '바레인 주요 무역항 (위험물 하역)' },
];
// Helper: filter by country key and subType
export function filterFacilities(countryKey: string, subType?: FacilitySubType): EnergyHazardFacility[] {
return ME_ENERGY_HAZARD_FACILITIES.filter(f =>
f.countryKey === countryKey && (subType ? f.subType === subType : true)
);
}

파일 보기

@ -1429,6 +1429,48 @@ export const sampleEvents: GeoEvent[] = [
label: 'UN 안보리 — 호르무즈 해협 긴급회의 소집',
description: 'UN 안보리, 호르무즈 해협 상선 피격 관련 긴급회의 소집. 중국·러시아 즉각 휴전 촉구, 미국 항행자유 강조.',
},
// ═══ D+20 (2026-03-21) 나탄즈-디모나 핵시설 교차공격 ═══
{
id: 'd20-il1', timestamp: T0 + 20 * DAY + 4 * HOUR,
lat: 33.7250, lng: 51.7267, type: 'strike',
label: '나탄즈 — 이스라엘 핵시설 공습',
description: 'IAF, 이란 나탄즈 우라늄 농축시설 정밀 타격. 이란 원자력청 "나탄즈 농축시설이 공격 표적이 됐다" 확인. IAEA 방사능 유출 미확인.',
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Natanz_nuclear.jpg/320px-Natanz_nuclear.jpg',
imageCaption: '나탄즈 핵시설 위성사진 (Wikimedia Commons)',
},
{
id: 'd20-ir-assess', timestamp: T0 + 20 * DAY + 6 * HOUR,
lat: 33.7250, lng: 51.7267, type: 'alert',
label: '나탄즈 — 이란 방사능 조사 착수',
description: '이란 원자력안전센터, 나탄즈 시설 인근 방사성 오염물질 배출 가능성 정밀 기술 조사. "현재까지 방사성 물질 누출 보고 없음, 인근 주민 위협 없음" 발표.',
},
{
id: 'd20-ir1', timestamp: T0 + 20 * DAY + 10 * HOUR,
lat: 31.0014, lng: 35.1467, type: 'strike',
label: '디모나 — 이란 보복 미사일 공격',
description: 'IRGC, 나탄즈 피격 보복으로 이스라엘 디모나 핵연구센터 겨냥 탄도미사일 발사. 이스라엘 방공 요격 실패, 최소 30명 이상 사상자 발생. 핵연구센터 직접 피해는 미확인.',
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Negev_Nuclear_Research_Center.jpg/320px-Negev_Nuclear_Research_Center.jpg',
imageCaption: '디모나 네게브 핵연구센터 (Wikimedia Commons)',
},
{
id: 'd20-il-def', timestamp: T0 + 20 * DAY + 10.5 * HOUR,
lat: 31.0014, lng: 35.1467, type: 'alert',
label: '디모나 — 요격 실패 조사 착수',
description: '이스라엘군, 이란발 탄도미사일 요격 실패 경위 조사 착수. 요격 미사일이 목표물 격추에 실패, 미사일이 마을에 충돌. 막대한 재산 피해.',
},
{
id: 'd20-iaea', timestamp: T0 + 20 * DAY + 12 * HOUR,
lat: 48.2082, lng: 16.3738, type: 'alert',
label: 'IAEA — 양측 핵시설 상황 파악 중',
description: 'IAEA, 나탄즈 및 디모나 핵시설 상황 파악 중. 그로시 사무총장 "핵사고 위험 회피 위해 군사행동 자제 거듭 촉구". 양측 시설 모두 비정상 방사능 수치 미감지.',
},
{
id: 'd20-p1', timestamp: T0 + 20 * DAY + 14 * HOUR,
lat: 38.8977, lng: -77.0365, type: 'alert',
label: '워싱턴 — 미국 핵시설 공격 우려 성명',
description: '미 국무부, 이란의 디모나 공격에 강력 규탄. "핵시설 겨냥 군사행동은 국제법 중대 위반" 경고. 이스라엘 방공체계 지원 강화 발표.',
},
];
// 24시간 동안 10분 간격 센서 데이터 생성

File diff suppressed because one or more lines are too long

파일 보기

@ -145,7 +145,8 @@ export interface LayerVisibility {
meFacilities: boolean;
militaryOnly: boolean;
overseasUS: boolean;
overseasUK: boolean;
overseasIsrael: boolean;
[key: string]: boolean;
overseasIran: boolean;
overseasUAE: boolean;
overseasSaudi: boolean;

파일 보기

@ -110,6 +110,21 @@ export default defineConfig(({ mode }): UserConfig => ({
changeOrigin: true,
secure: false,
},
'/ollama': {
target: 'http://localhost:11434',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ollama/, ''),
},
'/api/gtts': {
target: 'https://translate.google.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/gtts/, '/translate_tts'),
secure: true,
headers: {
'Referer': 'https://translate.google.com/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
},
},
},
}))