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
222개의 변경된 파일5474개의 추가작업 그리고 33360개의 파일을 삭제

파일 보기

@ -148,10 +148,75 @@ jobs:
sleep 10
done
# ═══ Prediction (FastAPI) — CI/CD 제외, 수동 배포 ═══
# ssh redis-211 에서 수동 배포:
# scp prediction/*.py redis-211:/home/apps/kcg-prediction/
# ssh redis-211 "sudo systemctl restart kcg-prediction"
# ═══ Prediction (FastAPI → redis-211) ═══
- name: Deploy prediction via SSH
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
PRED_HOST: 192.168.1.18
PRED_PORT: 32023
run: |
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
SSH_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -p $PRED_PORT"
SCP_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -P $PRED_PORT"
REMOTE_DIR=/home/apps/kcg-prediction
# 코드 전송 (rsync 대체: tar + scp)
tar czf /tmp/prediction.tar.gz -C prediction --exclude='__pycache__' --exclude='venv' --exclude='.env' .
for attempt in 1 2 3; do
echo "SCP prediction attempt $attempt/3..."
if scp $SCP_OPTS /tmp/prediction.tar.gz root@$PRED_HOST:/tmp/prediction.tar.gz; then break; fi
[ "$attempt" -eq 3 ] && { echo "ERROR: SCP failed"; exit 1; }
sleep 10
done
# systemd 서비스 파일 전송
scp $SCP_OPTS deploy/kcg-prediction.service root@$PRED_HOST:/tmp/kcg-prediction.service
# 원격 설치 + 재시작 (단일 SSH — tar.gz는 SCP에서 이미 전송됨)
ssh $SSH_OPTS root@$PRED_HOST bash -s << 'SCRIPT'
set -e
REMOTE_DIR=/home/apps/kcg-prediction
mkdir -p $REMOTE_DIR
cd $REMOTE_DIR
# 코드 배포
tar xzf /tmp/prediction.tar.gz -C $REMOTE_DIR
# venv + 의존성
python3 -m venv venv 2>/dev/null || true
venv/bin/pip install -r requirements.txt -q
# SELinux 컨텍스트 (Rocky Linux)
chcon -R -t bin_t venv/bin/ 2>/dev/null || true
# systemd 서비스 갱신
if ! diff -q /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service >/dev/null 2>&1; then
cp /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service
systemctl daemon-reload
systemctl enable kcg-prediction
fi
# 재시작
systemctl restart kcg-prediction
# health 확인 (60초 — 초기 로드에 ~30초 소요)
for i in $(seq 1 12); do
if curl -sf http://localhost:8001/health > /dev/null 2>&1; then
echo "Prediction healthy (attempt ${i})"
rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service
exit 0
fi
sleep 5
done
echo "WARNING: Prediction health timeout (서비스는 시작됨, 초기 로드 진행 중)"
systemctl is-active kcg-prediction && echo "Service is active"
rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service
SCRIPT
echo "Prediction deployment completed"
- name: Cleanup
if: always()

4
.gitignore vendored
파일 보기

@ -29,10 +29,6 @@ coverage/
.prettiercache
*.tsbuildinfo
# === Codex CLI ===
AGENTS.md
.codex/
# === Claude Code ===
# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함
!.claude/

197
CLAUDE.md
파일 보기

@ -9,11 +9,10 @@
| 패키지 | 스택 | 비고 |
|--------|------|------|
| **Frontend** | React 19 + TypeScript 5.9 + Vite 7 + Tailwind CSS 4 | i18n(ko/en), 테마(dark/light), MapLibre GL + deck.gl GPU |
| **Backend** | Spring Boot 3.2.5 + Java 21 + PostgreSQL + PostGIS | Google OAuth + JWT, Caffeine 캐시 |
| **Prediction** | FastAPI + Python 3.9 + Shapely + APScheduler | 5분 주기 해양 분석 파이프라인 |
| **DB** | PostgreSQL 16 + PostGIS (211.208.115.83:5432/kcgdb, 스키마: kcg) | |
| **궤적 DB** | PostgreSQL (snpdb, 211.208.115.83:5432/snpdb, 스키마: signal) | LineStringM 궤적 |
| **Frontend** | React 19 + TypeScript + Vite 7 + Tailwind CSS 4 | i18n(ko/en), 테마(dark/light) |
| **Backend** | Spring Boot 3.2.5 + Java 17 + PostgreSQL | Google OAuth + JWT 인증 |
| **Prediction** | FastAPI (Python) | 향후 해양 분석 |
| **DB** | PostgreSQL 16 (211.208.115.83:5432/kcgdb, 스키마: kcg) | |
| **CI/CD** | Gitea Actions → nginx + systemd | main merge 시 자동 배포 |
## 빌드 및 실행
@ -30,135 +29,55 @@ npm run lint # ESLint 검증
### Backend
```bash
cd backend
sdk use java 21.0.9-amzn # JDK 21 필수
# 최초: application-local.yml 설정 필요
cp src/main/resources/application-local.yml.example src/main/resources/application-local.yml
mvn spring-boot:run -Dspring-boot.run.profiles=local # 개발서버 (포트 8080)
mvn compile # 컴파일 검증
mvn package # JAR 빌드 (target/kcg.jar)
```
### Prediction
```bash
cd prediction
pip install -r requirements.txt # shapely, scikit-learn, apscheduler 등
uvicorn main:app --host 0.0.0.0 --port 8001
```
### Database
```bash
# kcgdb (분석 결과)
psql -h 211.208.115.83 -U kcg_app -d kcgdb -f database/migration/009_group_polygons.sql
# snpdb (궤적 원본) — 읽기 전용, 별도 관리
psql -h 211.208.115.83 -U kcg_app -d kcgdb -f database/init.sql
psql -h 211.208.115.83 -U kcg_app -d kcgdb -f database/migration/001_initial_schema.sql
```
## 프로젝트 구조
```
frontend/ # React 19 + Vite 7 + Tailwind CSS 4 + deck.gl
frontend/ # React 19 + Vite 7 + Tailwind CSS 4
├── src/
│ ├── App.tsx # 인증가드 → SharedFilterProvider → FontScaleProvider
│ ├── contexts/ # SharedFilterContext, FontScaleContext
│ ├── styles/
│ │ ├── tokens.css # 테마 토큰 (dark/light), Tailwind @theme
│ │ ├── fonts.ts # FONT_MONO/FONT_SANS 상수 (@fontsource-variable)
│ │ └── tailwind.css
│ ├── components/
│ │ ├── common/ # LayerPanel, EventLog, FontScalePanel, ReplayControls
│ │ ├── layers/ # DeckGLOverlay, ShipLayer, AircraftLayer, SatelliteLayer
│ │ ├── iran/ # IranDashboard, ReplayMap, SatelliteMap, GlobeMap
│ │ │ # createIranOil/Airport/MEFacility/MEEnergyHazard Layers
│ │ ├── korea/ # KoreaDashboard, KoreaMap + 25개 레이어/패널
│ │ │ # FleetClusterLayer (API GeoJSON 렌더링)
│ │ │ # AnalysisStatsPanel, FieldAnalysisModal
│ │ └── auth/ # LoginPage
│ ├── hooks/
│ │ ├── useReplay, useMonitor, useAuth, useTheme
│ │ ├── useIranData (더미↔API 토글), useKoreaData, useKoreaFilters
│ │ ├── useVesselAnalysis (5분 폴링, mmsi별 분석 DTO)
│ │ ├── useGroupPolygons (5분 폴링, 선단/어구 폴리곤)
│ │ ├── useStaticDeckLayers (4개 서브훅 조합)
│ │ ├── useAnalysisDeckLayers (위험도/다크/스푸핑 마커)
│ │ ├── useFontScale, useLocalStorage, usePoll
│ │ └── layers/ # createPort/Navigation/Military/FacilityLayers
│ ├── services/ # API 클라이언트 (ships, aircraft, osint, vesselAnalysis 등)
│ ├── data/ # 정적 데이터 (공항, 유전, 샘플, 어업수역 GeoJSON)
│ └── i18n/ # i18next (ko/en)
│ ├── components/ # 28개 컴포넌트 (맵 레이어, UI 패널, 인증)
│ ├── hooks/ # useReplay, useMonitor, useTheme, useAuth
│ ├── services/ # API 서비스 (ships, opensky, osint, authApi 등)
│ ├── styles/ # tokens.css (테마 토큰), tailwind.css
│ ├── i18n/ # i18next (ko/en)
│ ├── data/ # 정적 데이터 (공항, 유전시설, 샘플)
│ └── App.tsx # 인증 가드 → LoginPage / AuthenticatedApp
├── package.json
└── vite.config.ts # Vite + 프록시 (/api/kcg → backend)
└── vite.config.ts # Vite + 프록시 (/api/kcg → backend, /signal-batch → wing)
backend/ # Spring Boot 3.2 + Java 21
backend/ # Spring Boot 3.2 + Java 17
├── src/main/java/gc/mda/kcg/
│ ├── auth/ # Google OAuth + JWT (AuthFilter: 인증 예외 경로 관리)
│ ├── config/ # CORS, Security, CacheConfig (Caffeine)
│ ├── domain/
│ │ ├── analysis/ # VesselAnalysisController/Service/Dto/Repository
│ │ ├── fleet/ # FleetCompanyController, GroupPolygonController/Service/Dto
│ │ ├── event/ # EventController/Service (이란 리플레이)
│ │ ├── aircraft/ # AircraftController/Service (시점 조회)
│ │ └── osint/ # OsintController/Service (시점 조회)
│ └── collector/ # GDELT, GoogleNews, CENTCOM (placeholder)
│ ├── auth/ # Google OAuth + JWT (gcsc.co.kr 제한)
│ ├── config/ # CORS, Security, AppProperties
│ ├── collector/ # 수집기 placeholder (GDELT, GoogleNews, CENTCOM)
│ └── domain/ # event, news, osint, aircraft (placeholder)
├── src/main/resources/
│ ├── application.yml # 공통 설정
│ ├── application-local.yml.example
│ └── application-prod.yml.example
└── pom.xml
prediction/ # FastAPI + Python 3.9 + APScheduler
├── main.py # FastAPI app + 스케줄러 초기화
├── scheduler.py # 5분 주기 분석 사이클 (7단계 파이프라인 + 폴리곤 생성)
├── fleet_tracker.py # 등록 선단 매칭 + 어구 정체성 추적
├── config.py # Settings (snpdb/kcgdb 접속정보)
├── cache/
│ └── vessel_store.py # 인메모리 AIS 캐시 (14K선박, 24h 윈도우)
├── algorithms/
│ ├── polygon_builder.py # Shapely 폴리곤 생성 (선단/어구 그룹)
│ ├── fleet.py # 선단 패턴 탐지 (PT/FC/PS)
│ ├── transshipment.py # 환적 탐지 (그리드 O(n log n))
│ ├── location.py # 특정어업수역 판정 (Point-in-Polygon)
│ └── ... # dark_vessel, spoofing, risk, fishing_pattern
├── pipeline/ # 7단계 분석 파이프라인
├── models/ # AnalysisResult
├── db/
│ ├── snpdb.py # 궤적 DB (읽기 전용)
│ └── kcgdb.py # 분석 결과 DB (UPSERT + 폴리곤 저장)
├── data/zones/ # 특정어업수역 GeoJSON (EPSG:3857)
└── requirements.txt # shapely, scikit-learn, apscheduler, pandas, numpy
database/ # PostgreSQL
├── init.sql # CREATE SCHEMA kcg
└── migration/001_initial_schema.sql # events, news, osint, users, login_history
database/ # PostgreSQL 마이그레이션
├── init.sql
└── migration/
├── 001_initial_schema.sql # events, news, osint, users
├── 002_aircraft_positions.sql # PostGIS 활성화
├── 005_vessel_analysis.sql # vessel_analysis_results
├── 007_fleet_registry.sql # fleet_companies, fleet_vessels, gear_identity_log
├── 008_transshipment.sql # 환적 탐지 칼럼 추가
└── 009_group_polygons.sql # group_polygon_snapshots (PostGIS Polygon)
deploy/ # systemd + nginx 배포 설정
.gitea/workflows/deploy.yml # CI/CD (main merge → 자동 빌드/배포)
prediction/ # FastAPI placeholder
deploy/ # systemd + nginx 배포 설정
.gitea/workflows/deploy.yml # CI/CD (main merge → 자동 빌드/배포)
```
## 주요 기능
### 한국 현황 대시보드
- **선박 모니터링**: 13K+ AIS 선박, MapLibre GPU-side filter, 카테고리/국적 토글
- **감시 탭**: 불법어선, 환적, 다크베셀, 해저케이블, 독도감시, 중국어선감시
- **선단/어구 폴리곤**: Python 서버사이드 생성 (Shapely + PostGIS) → API GeoJSON 렌더링
- FLEET 15개, GEAR_IN_ZONE 57개, GEAR_OUT_ZONE 45개 (5분 주기 갱신, 7일 히스토리)
- 가상 선박 마커 (ship-triangle + COG 회전 + zoom interpolate)
- 겹침 해결: queryRenderedFeatures → 다중 선택 팝업 + 호버 하이라이트
- **AI 분석**: Python 7단계 파이프라인, 위험도/다크베셀/스푸핑 deck.gl 오버레이
- **현장분석**: FieldAnalysisModal (어구/선단 분석 대시보드)
- **시설 레이어**: deck.gl IconLayer(SVG) + TextLayer, 줌 스케일 연동
### 이란 상황 대시보드
- **공습 리플레이**: 실데이터 Backend DB 기반 (더미↔API 토글)
- **유전/공항/군사시설**: deck.gl SVG 아이콘, 사막 대비 고채도 팔레트
- **센서 그래프**: 지진, 기압, 소음/방사선
- **위성지도/평면/3D Globe**: 3개 맵 모드
### 공통
- **웹폰트**: @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅
- **글꼴 크기 커스텀**: FontScalePanel (시설/선박/분석/지역 4그룹 × 0.5~2.0x)
- **LayerPanel**: 공통 트리 구조 (LayerTreeRenderer 재귀, 부모 캐스케이드)
- **인증**: Google OAuth + DEV LOGIN
- **localStorage**: 13개+ UI 상태 영속화
## 팀 스킬 사용 지침
### 중요: 스킬 실행 시 반드시 따라야 할 규칙
@ -175,11 +94,6 @@ deploy/ # systemd + nginx 배포 설정
3. **API 응답 검증**: MR 생성, 봇 승인, 머지 API 호출 후 반드시 상태를 확인.
실패 시 사용자에게 알리고 중단.
**MR/머지는 사용자 명시적 요청 없이 절대 진행 금지:**
- `git push` 완료 후에는 "푸시 완료" 보고만 하고 **중단**
- MR 생성은 사용자가 `/mr`, `/release` 호출 또는 "MR 해줘" 등 명시적 요청 시에만
- **스킬 없이 Gitea API를 직접 호출하여 MR/머지를 진행하지 말 것** — 스킬 절차(사용자 확인 단계)를 우회하는 것과 동일
### 스킬 목록
- `/push` — 커밋 + 푸시 (해시 비교 → 커밋 메시지 확인 → 푸시)
- `/mr` — 커밋 + 푸시 + MR 생성 (릴리즈 노트 갱신 포함)
@ -194,47 +108,12 @@ deploy/ # systemd + nginx 배포 설정
## 배포
| 서버 | 역할 | 경로/설정 |
|------|------|----------|
| **rocky-211** (192.168.1.20) | Frontend + Backend | `/devdata/services/kcg/` |
| | Frontend | nginx 정적 파일 (`/deploy/kcg/`) |
| | Backend | systemd `kcg-backend` (JDK 21, 2~4GB 힙) |
| | CI/CD | act runner Docker (node:24) |
| **redis-211** (192.168.1.18:32023) | Prediction | `/home/apps/kcg-prediction/` |
| | | systemd `kcg-prediction` (uvicorn 8001, venv) |
| **도메인** | | https://kcg.gc-si.dev |
| **nginx** | | `/etc/nginx/conf.d/kcg.conf` (SSL + SPA + API 프록시) |
| **DB** | kcgdb | 211.208.115.83:5432/kcgdb (유저: kcg_app, pw: Kcg2026monitor) |
| **DB** | snpdb | 211.208.115.83:5432/snpdb (유저: snp, pw: snp#8932, 읽기 전용) |
## 디버그 도구 가이드
### 원칙
- 디버그/개발 전용 기능은 `import.meta.env.DEV` 가드로 감싸서 **프로덕션 빌드에서 코드 자체가 제거**되도록 구현
- Vite production 빌드 시 `import.meta.env.DEV = false` → dead code elimination → 번들 미포함
- 무거운 DB 조회, 통계 계산 등도 DEV 가드 안이면 프로덕션에 영향 없음
### 파일 구조
- 디버그 컴포넌트: `frontend/src/components/{도메인}/debug/` 디렉토리에 분리
- 메인 컴포넌트에서는 import + DEV 가드로만 연결:
```tsx
import { DebugTool } from './debug/DebugTool';
const debug = import.meta.env.DEV ? useDebugHook() : null;
// JSX:
{debug && <DebugTool ... />}
```
### 기존 디버그 도구
| 도구 | 위치 | 기능 |
|------|------|------|
| CoordDebugTool | `korea/debug/CoordDebugTool.tsx` | Ctrl+Click 좌표 표시 (DD/DMS, 다중 포인트) |
### 디버그 도구 분류 기준
다음에 해당하면 디버그 도구로 분류하고, 불확실하면 사용자에게 확인:
- 개발/검증 목적의 좌표/데이터 표시 도구
- 프로덕션 사용자에게 불필요한 진단 정보
- 임시 데이터 시각화, 성능 프로파일링
- 특정 조건에서만 활성화되는 테스트 기능
- **도메인**: https://kcg.gc-si.dev
- **서버**: rocky-211 (SSH 접속, Gitea Actions 러너 = 배포 서버)
- **Frontend**: `/deploy/kcg/` (nginx 정적 파일 서빙)
- **Backend**: `/deploy/kcg-backend/kcg.jar` (systemd `kcg-backend` 서비스, JDK 17, 2~4GB 힙)
- **nginx**: `/etc/nginx/conf.d/kcg.conf` (SSL + SPA + API 프록시)
- **DB**: 211.208.115.83:5432/kcgdb (유저: kcg_app)
## 팀 규칙

파일 보기

@ -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>

파일 보기

@ -26,7 +26,6 @@ public class AuthFilter extends OncePerRequestFilter {
private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis";
private static final String PREDICTION_PATH_PREFIX = "/api/prediction/";
private static final String FLEET_PATH_PREFIX = "/api/fleet-";
private static final String EVENTS_PATH_PREFIX = "/api/events";
private final JwtProvider jwtProvider;
@ -38,8 +37,7 @@ public class AuthFilter extends OncePerRequestFilter {
|| path.startsWith(CCTV_PATH_PREFIX)
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX)
|| path.startsWith(PREDICTION_PATH_PREFIX)
|| path.startsWith(FLEET_PATH_PREFIX)
|| path.startsWith(EVENTS_PATH_PREFIX);
|| path.startsWith(FLEET_PATH_PREFIX);
}
@Override

파일 보기

@ -17,7 +17,7 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "login_history")
@Table(name = "login_history", schema = "kcg")
@Getter
@Builder
@NoArgsConstructor

파일 보기

@ -15,7 +15,7 @@ import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Table(name = "users", schema = "kcg")
@Getter
@Setter
@Builder

파일 보기

@ -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)

파일 보기

@ -21,7 +21,6 @@ public class CacheConfig {
public static final String SEISMIC = "seismic";
public static final String PRESSURE = "pressure";
public static final String VESSEL_ANALYSIS = "vessel-analysis";
public static final String GROUP_POLYGONS = "group-polygons";
@Bean
public CacheManager cacheManager() {
@ -30,8 +29,7 @@ public class CacheConfig {
OSINT_IRAN, OSINT_KOREA,
SATELLITES,
SEISMIC, PRESSURE,
VESSEL_ANALYSIS,
GROUP_POLYGONS
VESSEL_ANALYSIS
);
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(2, TimeUnit.DAYS)

파일 보기

@ -14,7 +14,7 @@ import java.util.List;
@Configuration
public class WebConfig {
@Value("${app.cors.allowed-origins:http://localhost:5174,http://localhost:5173}")
@Value("${app.cors.allowed-origins:http://localhost:5173}")
private List<String> allowedOrigins;
@Bean

파일 보기

@ -2,14 +2,12 @@ package gc.mda.kcg.domain.aircraft;
import gc.mda.kcg.collector.aircraft.AircraftCacheStore;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -22,24 +20,16 @@ public class AircraftController {
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
private final AircraftCacheStore cacheStore;
private final AircraftService aircraftService;
@GetMapping
public ResponseEntity<Map<String, Object>> getAircraft(
@RequestParam(defaultValue = "iran") String region,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
@RequestParam(defaultValue = "iran") String region) {
if (!VALID_REGIONS.contains(region)) {
return ResponseEntity.badRequest()
.body(Map.of("error", "유효하지 않은 region: " + region));
}
if (from != null && to != null) {
List<AircraftDto> results = aircraftService.getByDateRange(region, from, to);
return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results));
}
List<AircraftDto> aircraft = cacheStore.get(region);
long lastUpdated = cacheStore.getLastUpdated(region);

파일 보기

@ -7,7 +7,7 @@ import org.locationtech.jts.geom.Point;
import java.time.Instant;
@Entity
@Table(name = "aircraft_positions")
@Table(name = "aircraft_positions", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -1,17 +1,6 @@
package gc.mda.kcg.domain.aircraft;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.List;
public interface AircraftPositionRepository extends JpaRepository<AircraftPosition, Long> {
@Query("SELECT a FROM AircraftPosition a WHERE a.region = :region AND a.collectedAt BETWEEN :from AND :to ORDER BY a.collectedAt DESC")
List<AircraftPosition> findByRegionAndDateRange(
@Param("region") String region,
@Param("from") Instant from,
@Param("to") Instant to);
}

파일 보기

@ -1,51 +0,0 @@
package gc.mda.kcg.domain.aircraft;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AircraftService {
private final AircraftPositionRepository repository;
/**
* 시간 범위 항공기 위치를 조회하고 icao24 기준 최신 위치로 중복 제거하여 반환.
*/
public List<AircraftDto> getByDateRange(String region, Instant from, Instant to) {
List<AircraftPosition> positions = repository.findByRegionAndDateRange(region, from, to);
Map<String, AircraftDto> deduplicated = new LinkedHashMap<>();
for (AircraftPosition p : positions) {
deduplicated.putIfAbsent(p.getIcao24(), toDto(p));
}
return List.copyOf(deduplicated.values());
}
private AircraftDto toDto(AircraftPosition p) {
return AircraftDto.builder()
.icao24(p.getIcao24())
.callsign(p.getCallsign())
.lat(p.getPosition() != null ? p.getPosition().getY() : 0.0)
.lng(p.getPosition() != null ? p.getPosition().getX() : 0.0)
.altitude(p.getAltitude() != null ? p.getAltitude() : 0.0)
.velocity(p.getVelocity() != null ? p.getVelocity() : 0.0)
.heading(p.getHeading() != null ? p.getHeading() : 0.0)
.verticalRate(p.getVerticalRate() != null ? p.getVerticalRate() : 0.0)
.onGround(p.getOnGround() != null && p.getOnGround())
.category(p.getCategory())
.typecode(p.getTypecode())
.typeDesc(p.getTypeDesc())
.registration(p.getRegistration())
.operator(p.getOperator())
.squawk(p.getSquawk())
.lastSeen(p.getLastSeen() != null ? p.getLastSeen().toEpochMilli()
: p.getCollectedAt().toEpochMilli())
.build();
}
}

파일 보기

@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@ -23,6 +24,12 @@ public class VesselAnalysisController {
@GetMapping
public ResponseEntity<Map<String, Object>> getVesselAnalysis(
@RequestParam(required = false) String region) {
return ResponseEntity.ok(vesselAnalysisService.getLatestResultsWithStats());
List<VesselAnalysisDto> results = vesselAnalysisService.getLatestResults();
return ResponseEntity.ok(Map.of(
"count", results.size(),
"items", results
));
}
}

파일 보기

@ -39,7 +39,6 @@ public class VesselAnalysisDto {
private ClusterInfo cluster;
private FleetRoleInfo fleetRole;
private RiskScoreInfo riskScore;
private TransshipInfo transship;
}
@Getter
@ -100,15 +99,6 @@ public class VesselAnalysisDto {
private String level;
}
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class TransshipInfo {
private Boolean isSuspect;
private String pairMmsi;
private Integer durationMin;
}
public static VesselAnalysisDto from(VesselAnalysisResult r) {
return VesselAnalysisDto.builder()
.mmsi(r.getMmsi())
@ -151,11 +141,6 @@ public class VesselAnalysisDto {
.score(r.getRiskScore())
.level(r.getRiskLevel())
.build())
.transship(TransshipInfo.builder()
.isSuspect(r.getIsTransshipSuspect())
.pairMmsi(r.getTransshipPairMmsi())
.durationMin(r.getTransshipDurationMin())
.build())
.build())
.features(r.getFeatures())
.build();

파일 보기

@ -9,7 +9,7 @@ import java.time.Instant;
import java.util.Map;
@Entity
@Table(name = "vessel_analysis_results")
@Table(name = "vessel_analysis_results", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@ -76,14 +76,6 @@ public class VesselAnalysisResult {
@Column(length = 20)
private String riskLevel;
@Column(nullable = false)
private Boolean isTransshipSuspect;
@Column(length = 15)
private String transshipPairMmsi;
private Integer transshipDurationMin;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Double> features;
@ -102,8 +94,5 @@ public class VesselAnalysisResult {
if (isLeader == null) {
isLeader = false;
}
if (isTransshipSuspect == null) {
isTransshipSuspect = false;
}
}
}

파일 보기

@ -1,115 +1,56 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.domain.fleet.GroupPolygonService;
import gc.mda.kcg.config.CacheConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class VesselAnalysisService {
private final VesselAnalysisResultRepository repository;
private final GroupPolygonService groupPolygonService;
private static final long CACHE_TTL_MS = 2 * 60 * 60_000L; // 2시간
/** mmsi → 최신 분석 결과 (인메모리 캐시) */
private final Map<String, VesselAnalysisResult> cache = new ConcurrentHashMap<>();
private volatile Instant lastFetchTime = null;
private volatile long lastUpdatedAt = 0;
private final CacheManager cacheManager;
/**
* 최근 2시간 분석 결과 + 집계 통계.
* - 호출(warmup): 2시간 전체 조회 캐시 구축
* - 이후: lastFetchTime 이후 증분만 조회 캐시 병합
* - 2시간 초과 항목은 evict
* - 갱신 TTL 타이머 초기화
* 최근 1시간 분석 결과를 반환한다. mmsi별 최신 1건만.
* Caffeine 캐시(TTL 5분) 적용.
*/
public Map<String, Object> getLatestResultsWithStats() {
Instant now = Instant.now();
if (lastFetchTime == null || (System.currentTimeMillis() - lastUpdatedAt) > CACHE_TTL_MS) {
// warmup: 2시간 전체 조회
Instant since = now.minus(2, ChronoUnit.HOURS);
List<VesselAnalysisResult> rows = repository.findByAnalyzedAtAfter(since);
cache.clear();
for (VesselAnalysisResult r : rows) {
cache.merge(r.getMmsi(), r, (old, cur) ->
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
}
lastFetchTime = now;
lastUpdatedAt = System.currentTimeMillis();
log.info("vessel analysis cache warmup: {} vessels from DB", cache.size());
} else {
// 증분: lastFetchTime 이후만 조회
List<VesselAnalysisResult> rows = repository.findByAnalyzedAtAfter(lastFetchTime);
if (!rows.isEmpty()) {
for (VesselAnalysisResult r : rows) {
cache.merge(r.getMmsi(), r, (old, cur) ->
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
}
lastUpdatedAt = System.currentTimeMillis();
log.debug("vessel analysis incremental merge: {} new rows", rows.size());
}
lastFetchTime = now;
}
// 2시간 초과 항목 evict
Instant cutoff = now.minus(2, ChronoUnit.HOURS);
cache.entrySet().removeIf(e -> e.getValue().getAnalyzedAt().isBefore(cutoff));
// 집계 통계
int dark = 0, spoofing = 0, critical = 0, high = 0, medium = 0, low = 0;
Set<Integer> clusterIds = new HashSet<>();
for (VesselAnalysisResult r : cache.values()) {
if (Boolean.TRUE.equals(r.getIsDark())) dark++;
if (r.getSpoofingScore() != null && r.getSpoofingScore() > 0.5) spoofing++;
String level = r.getRiskLevel();
if (level != null) {
switch (level) {
case "CRITICAL" -> critical++;
case "HIGH" -> high++;
case "MEDIUM" -> medium++;
default -> low++;
}
} else {
low++;
}
if (r.getClusterId() != null && r.getClusterId() >= 0) {
clusterIds.add(r.getClusterId());
@SuppressWarnings("unchecked")
public List<VesselAnalysisDto> getLatestResults() {
Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS);
if (cache != null) {
Cache.ValueWrapper wrapper = cache.get("data");
if (wrapper != null) {
return (List<VesselAnalysisDto>) wrapper.get();
}
}
Map<String, Integer> gearStats = groupPolygonService.getGearStats();
Instant since = Instant.now().minus(2, ChronoUnit.HOURS);
// mmsi별 최신 analyzed_at 1건만 유지
Map<String, VesselAnalysisResult> latest = new LinkedHashMap<>();
for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) {
latest.merge(r.getMmsi(), r, (old, cur) ->
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
}
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("total", cache.size());
stats.put("dark", dark);
stats.put("spoofing", spoofing);
stats.put("critical", critical);
stats.put("high", high);
stats.put("medium", medium);
stats.put("low", low);
stats.put("clusterCount", clusterIds.size());
stats.put("gearGroups", gearStats.get("gearGroups"));
stats.put("gearCount", gearStats.get("gearCount"));
List<VesselAnalysisDto> results = cache.values().stream()
List<VesselAnalysisDto> results = latest.values().stream()
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
.map(VesselAnalysisDto::from)
.toList();
return Map.of(
"count", results.size(),
"items", results,
"stats", stats
);
if (cache != null) {
cache.put("data", results);
}
return results;
}
}

파일 보기

@ -1,42 +0,0 @@
package gc.mda.kcg.domain.event;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Map;
@Entity
@Table(name = "events")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_id", unique = true)
private String eventId;
private String title;
private String description;
private String source;
@Column(name = "latitude")
private Double latitude;
@Column(name = "longitude")
private Double longitude;
private Instant timestamp;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Object> rawData;
}

파일 보기

@ -1,34 +0,0 @@
package gc.mda.kcg.domain.event;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/events")
@RequiredArgsConstructor
public class EventController {
private final EventService eventService;
@GetMapping
public ResponseEntity<Map<String, Object>> getEvents(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
Instant f = from != null ? from : Instant.parse("2026-03-01T00:00:00Z");
Instant t = to != null ? to : Instant.now();
List<EventDto> results = eventService.getByDateRange(f, t);
return ResponseEntity.ok(Map.of("count", results.size(), "items", results));
}
@PostMapping("/import")
public ResponseEntity<Map<String, Object>> importEvents(@RequestBody List<EventDto> events) {
int imported = eventService.importEvents(events);
return ResponseEntity.ok(Map.of("imported", imported, "total", events.size()));
}
}

파일 보기

@ -1,49 +0,0 @@
package gc.mda.kcg.domain.event;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class EventDto {
private String id;
private long timestamp;
private Double lat;
private Double lng;
private String type;
private String source;
private String label;
private String description;
private Integer intensity;
public static EventDto from(Event e) {
return EventDto.builder()
.id(e.getEventId())
.timestamp(e.getTimestamp() != null ? e.getTimestamp().toEpochMilli() : 0)
.lat(e.getLatitude())
.lng(e.getLongitude())
.type(extractType(e))
.source(e.getSource())
.label(e.getTitle())
.description(e.getDescription())
.intensity(extractIntensity(e))
.build();
}
private static String extractType(Event e) {
if (e.getRawData() != null && e.getRawData().containsKey("type")) {
return String.valueOf(e.getRawData().get("type"));
}
return "alert";
}
private static Integer extractIntensity(Event e) {
if (e.getRawData() != null && e.getRawData().containsKey("intensity")) {
return ((Number) e.getRawData().get("intensity")).intValue();
}
return 50;
}
}

파일 보기

@ -1,13 +0,0 @@
package gc.mda.kcg.domain.event;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;
import java.util.List;
public interface EventRepository extends JpaRepository<Event, Long> {
List<Event> findByTimestampBetweenOrderByTimestampAsc(Instant from, Instant to);
boolean existsByEventId(String eventId);
}

파일 보기

@ -1,43 +0,0 @@
package gc.mda.kcg.domain.event;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class EventService {
private final EventRepository repository;
public List<EventDto> getByDateRange(Instant from, Instant to) {
return repository.findByTimestampBetweenOrderByTimestampAsc(from, to)
.stream().map(EventDto::from).toList();
}
public int importEvents(List<EventDto> dtos) {
int count = 0;
for (EventDto dto : dtos) {
if (dto.getId() != null && repository.existsByEventId(dto.getId())) continue;
Event e = Event.builder()
.eventId(dto.getId())
.title(dto.getLabel())
.description(dto.getDescription())
.source(dto.getSource())
.latitude(dto.getLat())
.longitude(dto.getLng())
.timestamp(Instant.ofEpochMilli(dto.getTimestamp()))
.rawData(Map.of(
"type", dto.getType() != null ? dto.getType() : "alert",
"intensity", dto.getIntensity() != null ? dto.getIntensity() : 50
))
.build();
repository.save(e);
count++;
}
return count;
}
}

파일 보기

@ -1,7 +1,6 @@
package gc.mda.kcg.domain.fleet;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
@ -18,14 +17,10 @@ public class FleetCompanyController {
private final JdbcTemplate jdbcTemplate;
@Value("${DB_SCHEMA:kcg}")
private String dbSchema;
@GetMapping
public ResponseEntity<List<Map<String, Object>>> getFleetCompanies() {
List<Map<String, Object>> results = jdbcTemplate.queryForList(
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM "
+ dbSchema + ".fleet_companies ORDER BY id"
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM kcg.fleet_companies ORDER BY id"
);
return ResponseEntity.ok(results);
}

파일 보기

@ -1,12 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GlobalParentCandidateExclusionRequest {
private String candidateMmsi;
private String actor;
private String comment;
}

파일 보기

@ -1,13 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GroupParentCandidateExclusionRequest {
private String candidateMmsi;
private Integer durationDays;
private String actor;
private String comment;
}

파일 보기

@ -1,26 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupParentInferenceDto {
private String groupType;
private String groupKey;
private String groupLabel;
private int subClusterId;
private String snapshotTime;
private String zoneName;
private Integer memberCount;
private String resolution;
private Integer candidateCount;
private ParentInferenceSummaryDto parentInference;
private List<ParentInferenceCandidateDto> candidates;
private Map<String, Object> evidenceSummary;
}

파일 보기

@ -1,13 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GroupParentInferenceReviewRequest {
private String action;
private String selectedParentMmsi;
private String actor;
private String comment;
}

파일 보기

@ -1,13 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GroupParentLabelSessionRequest {
private String selectedParentMmsi;
private Integer durationDays;
private String actor;
private String comment;
}

파일 보기

@ -1,123 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vessel-analysis/groups")
@RequiredArgsConstructor
public class GroupPolygonController {
private final GroupPolygonService groupPolygonService;
/**
* 전체 그룹 폴리곤 목록 (최신 스냅샷, 5분 캐시)
*/
@GetMapping
public ResponseEntity<Map<String, Object>> getGroups() {
List<GroupPolygonDto> groups = groupPolygonService.getLatestGroups();
return ResponseEntity.ok(Map.of(
"count", groups.size(),
"items", groups
));
}
/**
* 특정 그룹 상세 (멤버, 면적, 폴리곤 생성 근거)
*/
@GetMapping("/{groupKey}/detail")
public ResponseEntity<GroupPolygonDto> getGroupDetail(@PathVariable String groupKey) {
GroupPolygonDto detail = groupPolygonService.getGroupDetail(groupKey);
if (detail == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(detail);
}
/**
* 특정 그룹 히스토리 (시간별 폴리곤 변화)
*/
@GetMapping("/{groupKey}/history")
public ResponseEntity<List<GroupPolygonDto>> getGroupHistory(
@PathVariable String groupKey,
@RequestParam(defaultValue = "24") int hours) {
List<GroupPolygonDto> history = groupPolygonService.getGroupHistory(groupKey, hours);
return ResponseEntity.ok(history);
}
/**
* 특정 어구 그룹의 연관성 점수 (멀티모델)
*/
@GetMapping("/{groupKey}/correlations")
public ResponseEntity<Map<String, Object>> getGroupCorrelations(
@PathVariable String groupKey,
@RequestParam(defaultValue = "0.3") double minScore) {
List<Map<String, Object>> correlations = groupPolygonService.getGroupCorrelations(groupKey, minScore);
return ResponseEntity.ok(Map.of(
"groupKey", groupKey,
"count", correlations.size(),
"items", correlations
));
}
@GetMapping("/parent-inference/review")
public ResponseEntity<Map<String, Object>> getParentInferenceReview(
@RequestParam(defaultValue = "REVIEW_REQUIRED") String status,
@RequestParam(defaultValue = "100") int limit) {
List<GroupParentInferenceDto> items = groupPolygonService.getParentInferenceReview(status, limit);
return ResponseEntity.ok(Map.of(
"count", items.size(),
"items", items
));
}
@GetMapping("/{groupKey}/parent-inference")
public ResponseEntity<Map<String, Object>> getGroupParentInference(@PathVariable String groupKey) {
List<GroupParentInferenceDto> items = groupPolygonService.getGroupParentInference(groupKey);
return ResponseEntity.ok(Map.of(
"groupKey", groupKey,
"count", items.size(),
"items", items
));
}
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/review")
public ResponseEntity<?> reviewGroupParentInference(
@PathVariable String groupKey,
@PathVariable int subClusterId,
@RequestBody GroupParentInferenceReviewRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.reviewParentInference(groupKey, subClusterId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/label-sessions")
public ResponseEntity<?> createGroupParentLabelSession(
@PathVariable String groupKey,
@PathVariable int subClusterId,
@RequestBody GroupParentLabelSessionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.createGroupParentLabelSession(groupKey, subClusterId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions")
public ResponseEntity<?> createGroupCandidateExclusion(
@PathVariable String groupKey,
@PathVariable int subClusterId,
@RequestBody GroupParentCandidateExclusionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.createGroupCandidateExclusion(groupKey, subClusterId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}

파일 보기

@ -1,31 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupPolygonDto {
private String groupType;
private String groupKey;
private String groupLabel;
private int subClusterId;
private String snapshotTime;
private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON)
private double centerLat;
private double centerLon;
private double areaSqNm;
private int memberCount;
private String zoneId;
private String zoneName;
private List<Map<String, Object>> members;
private String color;
private String resolution;
private Integer candidateCount;
private ParentInferenceSummaryDto parentInference;
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,28 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentCandidateExclusionDto {
private Long id;
private String scopeType;
private String groupKey;
private Integer subClusterId;
private String candidateMmsi;
private String reasonType;
private Integer durationDays;
private String activeFrom;
private String activeUntil;
private String releasedAt;
private String releasedBy;
private String actor;
private String comment;
private Boolean active;
private Map<String, Object> metadata;
}

파일 보기

@ -1,30 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentInferenceCandidateDto {
private String candidateMmsi;
private String candidateName;
private Integer candidateVesselId;
private Integer rank;
private String candidateSource;
private Double finalScore;
private Double baseCorrScore;
private Double nameMatchScore;
private Double trackSimilarityScore;
private Double visitScore6h;
private Double proximityScore6h;
private Double activitySyncScore6h;
private Double stabilityScore;
private Double registryBonus;
private Double marginFromTop;
private Boolean trackAvailable;
private Map<String, Object> evidence;
}

파일 보기

@ -1,22 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentInferenceSummaryDto {
private String status;
private String normalizedParentName;
private String selectedParentMmsi;
private String selectedParentName;
private Double confidence;
private String decisionSource;
private Double topScore;
private Double scoreMargin;
private Integer stableCycles;
private String skipReason;
private String statusReason;
}

파일 보기

@ -1,95 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vessel-analysis/parent-inference")
@RequiredArgsConstructor
public class ParentInferenceWorkflowController {
private final GroupPolygonService groupPolygonService;
@GetMapping("/candidate-exclusions")
public ResponseEntity<Map<String, Object>> getCandidateExclusions(
@RequestParam(required = false) String scopeType,
@RequestParam(required = false) String groupKey,
@RequestParam(required = false) Integer subClusterId,
@RequestParam(required = false) String candidateMmsi,
@RequestParam(defaultValue = "true") boolean activeOnly,
@RequestParam(defaultValue = "100") int limit) {
List<ParentCandidateExclusionDto> items = groupPolygonService.getCandidateExclusions(
scopeType,
groupKey,
subClusterId,
candidateMmsi,
activeOnly,
limit
);
return ResponseEntity.ok(Map.of("count", items.size(), "items", items));
}
@PostMapping("/candidate-exclusions/global")
public ResponseEntity<?> createGlobalCandidateExclusion(@RequestBody GlobalParentCandidateExclusionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.createGlobalCandidateExclusion(request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/candidate-exclusions/{exclusionId}/release")
public ResponseEntity<?> releaseCandidateExclusion(
@PathVariable long exclusionId,
@RequestBody ParentWorkflowActionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.releaseCandidateExclusion(exclusionId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/label-sessions")
public ResponseEntity<Map<String, Object>> getLabelSessions(
@RequestParam(required = false) String groupKey,
@RequestParam(required = false) Integer subClusterId,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "true") boolean activeOnly,
@RequestParam(defaultValue = "100") int limit) {
List<ParentLabelSessionDto> items = groupPolygonService.getLabelSessions(
groupKey,
subClusterId,
status,
activeOnly,
limit
);
return ResponseEntity.ok(Map.of("count", items.size(), "items", items));
}
@PostMapping("/label-sessions/{labelSessionId}/cancel")
public ResponseEntity<?> cancelLabelSession(
@PathVariable long labelSessionId,
@RequestBody ParentWorkflowActionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.cancelLabelSession(labelSessionId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/label-sessions/{labelSessionId}/tracking")
public ResponseEntity<Map<String, Object>> getLabelSessionTracking(
@PathVariable long labelSessionId,
@RequestParam(defaultValue = "200") int limit) {
List<ParentLabelTrackingCycleDto> items = groupPolygonService.getLabelSessionTracking(labelSessionId, limit);
return ResponseEntity.ok(Map.of(
"labelSessionId", labelSessionId,
"count", items.size(),
"items", items
));
}
}

파일 보기

@ -1,31 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentLabelSessionDto {
private Long id;
private String groupKey;
private Integer subClusterId;
private String labelParentMmsi;
private String labelParentName;
private Integer labelParentVesselId;
private Integer durationDays;
private String status;
private String activeFrom;
private String activeUntil;
private String actor;
private String comment;
private String anchorSnapshotTime;
private Double anchorCenterLat;
private Double anchorCenterLon;
private Integer anchorMemberCount;
private Boolean active;
private Map<String, Object> metadata;
}

파일 보기

@ -1,31 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentLabelTrackingCycleDto {
private Long id;
private Long labelSessionId;
private String observedAt;
private String candidateSnapshotObservedAt;
private String autoStatus;
private String topCandidateMmsi;
private String topCandidateName;
private Double topCandidateScore;
private Double topCandidateMargin;
private Integer candidateCount;
private Boolean labeledCandidatePresent;
private Integer labeledCandidateRank;
private Double labeledCandidateScore;
private Double labeledCandidatePreBonusScore;
private Double labeledCandidateMarginFromTop;
private Boolean matchedTop1;
private Boolean matchedTop3;
private Map<String, Object> evidenceSummary;
}

파일 보기

@ -1,11 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ParentWorkflowActionRequest {
private String actor;
private String comment;
}

파일 보기

@ -4,14 +4,12 @@ import gc.mda.kcg.config.CacheConfig;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -25,26 +23,18 @@ public class OsintController {
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
private final CacheManager cacheManager;
private final OsintService osintService;
private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>();
@GetMapping
public ResponseEntity<Map<String, Object>> getOsint(
@RequestParam(defaultValue = "iran") String region,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
@RequestParam(defaultValue = "iran") String region) {
if (!VALID_REGIONS.contains(region)) {
return ResponseEntity.badRequest()
.body(Map.of("error", "유효하지 않은 region: " + region));
}
if (from != null && to != null) {
List<OsintDto> results = osintService.getByDateRange(region, from, to);
return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results));
}
String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA;
List<OsintDto> items = getCachedItems(cacheName);
long updatedAt = lastUpdated.getOrDefault(region, 0L);

파일 보기

@ -9,6 +9,7 @@ import java.time.Instant;
@Entity
@Table(
name = "osint_feeds",
schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"})
)
@Getter

파일 보기

@ -12,6 +12,4 @@ public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
boolean existsByTitle(String title);
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since);
List<OsintFeed> findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(String region, Instant from, Instant to);
}

파일 보기

@ -1,25 +0,0 @@
package gc.mda.kcg.domain.osint;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
@Service
@RequiredArgsConstructor
public class OsintService {
private final OsintFeedRepository repository;
/**
* 시간 범위 OSINT 피드를 조회하여 반환.
* focus(region) 필드 기준 필터링, publishedAt 기준 정렬.
*/
public List<OsintDto> getByDateRange(String region, Instant from, Instant to) {
return repository.findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(region, from, to)
.stream()
.map(OsintDto::from)
.toList();
}
}

파일 보기

@ -6,7 +6,7 @@ import lombok.*;
import java.time.Instant;
@Entity
@Table(name = "satellite_tle")
@Table(name = "satellite_tle", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -8,6 +8,7 @@ import java.time.Instant;
@Entity
@Table(
name = "pressure_readings",
schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"})
)
@Getter

파일 보기

@ -6,7 +6,7 @@ import lombok.*;
import java.time.Instant;
@Entity
@Table(name = "seismic_events")
@Table(name = "seismic_events", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -1,19 +1,16 @@
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg,public}
username: ${DB_USERNAME:kcg_user}
password: ${DB_PASSWORD:kcg_pass}
url: jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg
username: kcg_user
password: kcg_pass
app:
jwt:
secret: ${JWT_SECRET:local-dev-secret-key-32chars-minimum!!}
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
secret: local-dev-secret-key-32chars-minimum!!
expiration-ms: 86400000
google:
client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID}
client-id: YOUR_GOOGLE_CLIENT_ID
auth:
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
allowed-domain: gcsc.co.kr
collector:
open-sky-client-id: ${OPENSKY_CLIENT_ID:YOUR_OPENSKY_CLIENT_ID}
open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:YOUR_OPENSKY_CLIENT_SECRET}
prediction-base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
cors:
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173}
open-sky-client-id: YOUR_OPENSKY_CLIENT_ID
open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET

파일 보기

@ -16,4 +16,4 @@ app:
open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:}
prediction-base-url: ${PREDICTION_BASE_URL:http://192.168.1.18:8001}
cors:
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173,https://kcg.gc-si.dev}
allowed-origins: http://localhost:5173,https://kcg.gc-si.dev

파일 보기

@ -6,6 +6,6 @@ spring:
ddl-auto: none
properties:
hibernate:
default_schema: ${DB_SCHEMA:kcg}
default_schema: kcg
server:
port: ${SERVER_PORT:8080}
port: 8080

파일 보기

@ -1,7 +0,0 @@
-- 008: 환적 의심 탐지 필드 추가
SET search_path TO kcg, public;
ALTER TABLE vessel_analysis_results
ADD COLUMN IF NOT EXISTS is_transship_suspect BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS transship_pair_mmsi VARCHAR(15) DEFAULT '',
ADD COLUMN IF NOT EXISTS transship_duration_min INTEGER DEFAULT 0;

파일 보기

@ -1,49 +0,0 @@
-- 009: 선단/어구그룹 폴리곤 스냅샷 테이블
-- 5분 주기 APPEND, 7일 보존
SET search_path TO kcg, public;
CREATE TABLE IF NOT EXISTS kcg.group_polygon_snapshots (
id BIGSERIAL PRIMARY KEY,
-- 그룹 식별
group_type VARCHAR(20) NOT NULL, -- FLEET | GEAR_IN_ZONE | GEAR_OUT_ZONE
group_key VARCHAR(100) NOT NULL, -- fleet: company_id, gear: parent_name
group_label TEXT, -- 표시명 (회사명 또는 모선명)
-- 스냅샷 시각
snapshot_time TIMESTAMPTZ NOT NULL,
-- PostGIS geometry
polygon geometry(Polygon, 4326), -- convex hull + buffer (3점 미만 시 NULL)
center_point geometry(Point, 4326), -- 중심점
-- 지표
area_sq_nm DOUBLE PRECISION DEFAULT 0, -- 면적 (제곱 해리)
member_count INT NOT NULL DEFAULT 0, -- 소속 선박/어구 수
-- 수역 분류 (어구그룹용)
zone_id VARCHAR(20), -- ZONE_I ~ ZONE_IV | OUTSIDE
zone_name TEXT,
-- 멤버 상세 (JSONB 배열)
members JSONB NOT NULL DEFAULT '[]',
-- [{mmsi, name, lat, lon, sog, cog, role, isParent}]
-- 색상 힌트
color VARCHAR(20),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 조회 성능 인덱스
CREATE INDEX IF NOT EXISTS idx_gps_type_time
ON kcg.group_polygon_snapshots(group_type, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_key_time
ON kcg.group_polygon_snapshots(group_key, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_snapshot_time
ON kcg.group_polygon_snapshots(snapshot_time DESC);
-- 공간 인덱스
CREATE INDEX IF NOT EXISTS idx_gps_polygon_gist
ON kcg.group_polygon_snapshots USING GIST(polygon);

파일 보기

@ -1,146 +0,0 @@
-- 010: 어구 연관성 추적 시스템
-- - correlation_param_models: 파라미터 모델 마스터
-- - gear_correlation_raw_metrics: raw 메트릭 (타임스탬프 파티셔닝, 7일 보존)
-- - gear_correlation_scores: 모델별 어피니티 점수 (상태 테이블)
-- - system_config: 런타임 설정 (파티션 보관기간 등)
SET search_path TO kcg, public;
-- ── 파라미터 모델 ──
CREATE TABLE IF NOT EXISTS kcg.correlation_param_models (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
params JSONB NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- default 모델 삽입
INSERT INTO kcg.correlation_param_models (name, is_default, is_active, params, description)
VALUES ('default', TRUE, TRUE,
'{"alpha_base":0.30,"alpha_min":0.08,"alpha_decay_per_streak":0.005,"track_threshold":0.50,"polygon_threshold":0.70,"w_proximity":0.45,"w_visit":0.35,"w_activity":0.20,"w_dtw":0.30,"w_sog_corr":0.20,"w_heading":0.25,"w_prox_vv":0.25,"w_prox_persist":0.50,"w_drift":0.30,"w_signal_sync":0.20,"group_quiet_ratio":0.30,"normal_gap_hours":1.0,"decay_slow":0.015,"decay_fast":0.08,"stale_hours":6.0,"shadow_stay_bonus":0.10,"shadow_return_bonus":0.15,"candidate_radius_factor":3.0,"proximity_threshold_nm":5.0,"visit_threshold_nm":5.0,"night_bonus":1.3,"long_decay_days":7.0}',
'기본 추적 모델')
ON CONFLICT (name) DO NOTHING;
-- ── Raw 메트릭 (모델 독립, 5분마다 기록, 타임스탬프 파티셔닝) ──
CREATE TABLE IF NOT EXISTS kcg.gear_correlation_raw_metrics (
id BIGSERIAL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
group_key VARCHAR(100) NOT NULL,
target_mmsi VARCHAR(20) NOT NULL,
target_type VARCHAR(10) NOT NULL,
target_name VARCHAR(200),
-- Raw 메트릭 (모든 모델이 공유)
proximity_ratio DOUBLE PRECISION,
visit_score DOUBLE PRECISION,
activity_sync DOUBLE PRECISION,
dtw_similarity DOUBLE PRECISION,
speed_correlation DOUBLE PRECISION,
heading_coherence DOUBLE PRECISION,
drift_similarity DOUBLE PRECISION,
-- Shadow
shadow_stay BOOLEAN DEFAULT FALSE,
shadow_return BOOLEAN DEFAULT FALSE,
-- 상태
gear_group_active_ratio DOUBLE PRECISION,
PRIMARY KEY (id, observed_at)
) PARTITION BY RANGE (observed_at);
-- 일별 파티션 생성 함수
CREATE OR REPLACE FUNCTION kcg.create_raw_metric_partitions(days_ahead INT DEFAULT 3)
RETURNS void AS $$
DECLARE
d DATE;
partition_name TEXT;
BEGIN
FOR i IN 0..days_ahead LOOP
d := CURRENT_DATE + i;
partition_name := 'gear_correlation_raw_metrics_' || TO_CHAR(d, 'YYYYMMDD');
IF NOT EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = partition_name AND n.nspname = 'kcg'
) THEN
EXECUTE format(
'CREATE TABLE IF NOT EXISTS kcg.%I PARTITION OF kcg.gear_correlation_raw_metrics
FOR VALUES FROM (%L) TO (%L)',
partition_name, d, d + 1
);
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- 초기 파티션 생성 (오늘 + 3일)
SELECT kcg.create_raw_metric_partitions(3);
-- raw_metrics 인덱스
CREATE INDEX IF NOT EXISTS idx_raw_metrics_group_time
ON kcg.gear_correlation_raw_metrics (group_key, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_raw_metrics_target
ON kcg.gear_correlation_raw_metrics (target_mmsi, observed_at DESC);
-- ── 어피니티 점수 (모델별 독립, 상태 테이블) ──
CREATE TABLE IF NOT EXISTS kcg.gear_correlation_scores (
id BIGSERIAL PRIMARY KEY,
model_id INT NOT NULL REFERENCES kcg.correlation_param_models(id) ON DELETE CASCADE,
group_key VARCHAR(100) NOT NULL,
target_mmsi VARCHAR(20) NOT NULL,
target_type VARCHAR(10) NOT NULL,
target_name VARCHAR(200),
-- 모델별 점수 (EMA 결과)
current_score DOUBLE PRECISION DEFAULT 0,
streak_count INT DEFAULT 0,
observation_count INT DEFAULT 0,
-- Shadow 축적
shadow_bonus_total DOUBLE PRECISION DEFAULT 0,
shadow_stay_count INT DEFAULT 0,
shadow_return_count INT DEFAULT 0,
-- 상태
freeze_state VARCHAR(20) DEFAULT 'ACTIVE',
-- 시간
first_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (model_id, group_key, target_mmsi)
);
CREATE INDEX IF NOT EXISTS idx_gc_model_group
ON kcg.gear_correlation_scores (model_id, group_key, current_score DESC);
CREATE INDEX IF NOT EXISTS idx_gc_active
ON kcg.gear_correlation_scores (current_score DESC)
WHERE current_score >= 0.5;
-- ── 시스템 설정 (런타임 변경 가능, 재시작 불필요) ──
CREATE TABLE IF NOT EXISTS kcg.system_config (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by VARCHAR(100) DEFAULT 'system'
);
INSERT INTO kcg.system_config (key, value, description) VALUES
('partition.raw_metrics.retention_days', '7',
'raw_metrics 파티션 보관 기간 (일). 초과 시 파티션 DROP.'),
('partition.raw_metrics.create_ahead_days', '3',
'미래 파티션 미리 생성 일수.'),
('partition.scores.cleanup_days', '30',
'미관측 점수 레코드 정리 기간 (일).'),
('correlation.max_active_models', '5',
'동시 활성 모델 최대 수.')
ON CONFLICT (key) DO NOTHING;

파일 보기

@ -1,14 +0,0 @@
-- 011: group_polygon_snapshots에 resolution 컬럼 추가 (1h/6h 듀얼 폴리곤)
-- 기존 데이터는 DEFAULT '6h'로 취급
ALTER TABLE kcg.group_polygon_snapshots
ADD COLUMN IF NOT EXISTS resolution VARCHAR(8) DEFAULT '6h';
-- 기존 인덱스 교체: resolution 포함
DROP INDEX IF EXISTS kcg.idx_gps_type_time;
CREATE INDEX idx_gps_type_res_time
ON kcg.group_polygon_snapshots(group_type, resolution, snapshot_time DESC);
DROP INDEX IF EXISTS kcg.idx_gps_key_time;
CREATE INDEX idx_gps_key_res_time
ON kcg.group_polygon_snapshots(group_key, resolution, snapshot_time DESC);

파일 보기

@ -1,176 +0,0 @@
-- 012: 어구 그룹 모선 추론 저장소 + sub_cluster/resolution 스키마 정합성
SET search_path TO kcg, public;
-- ── live lab과 repo 마이그레이션 정합성 맞추기 ─────────────────────
ALTER TABLE kcg.group_polygon_snapshots
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE kcg.group_polygon_snapshots
ADD COLUMN IF NOT EXISTS resolution VARCHAR(20) NOT NULL DEFAULT '6h';
CREATE INDEX IF NOT EXISTS idx_gps_type_res_time
ON kcg.group_polygon_snapshots(group_type, resolution, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_key_res_time
ON kcg.group_polygon_snapshots(group_key, resolution, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_key_sub_time
ON kcg.group_polygon_snapshots(group_key, sub_cluster_id, snapshot_time DESC);
ALTER TABLE kcg.gear_correlation_raw_metrics
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_raw_metrics_group_sub_time
ON kcg.gear_correlation_raw_metrics(group_key, sub_cluster_id, observed_at DESC);
ALTER TABLE kcg.gear_correlation_scores
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE kcg.gear_correlation_scores
DROP CONSTRAINT IF EXISTS gear_correlation_scores_model_id_group_key_target_mmsi_key;
DROP INDEX IF EXISTS kcg.gear_correlation_scores_model_id_group_key_target_mmsi_key;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE connamespace = 'kcg'::regnamespace
AND conrelid = 'kcg.gear_correlation_scores'::regclass
AND conname = 'gear_correlation_scores_unique'
) THEN
ALTER TABLE kcg.gear_correlation_scores
ADD CONSTRAINT gear_correlation_scores_unique
UNIQUE (model_id, group_key, sub_cluster_id, target_mmsi);
END IF;
END;
$$ LANGUAGE plpgsql;
CREATE INDEX IF NOT EXISTS idx_gc_model_group_sub
ON kcg.gear_correlation_scores(model_id, group_key, sub_cluster_id, current_score DESC);
-- ── 그룹 단위 모선 추론 저장소 ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_candidate_snapshots (
id BIGSERIAL PRIMARY KEY,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
parent_name TEXT NOT NULL,
candidate_mmsi VARCHAR(20) NOT NULL,
candidate_name VARCHAR(200),
candidate_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
rank INT NOT NULL,
candidate_source VARCHAR(100) NOT NULL,
model_id INT REFERENCES kcg.correlation_param_models(id) ON DELETE SET NULL,
model_name VARCHAR(100),
base_corr_score DOUBLE PRECISION DEFAULT 0,
name_match_score DOUBLE PRECISION DEFAULT 0,
track_similarity_score DOUBLE PRECISION DEFAULT 0,
visit_score_6h DOUBLE PRECISION DEFAULT 0,
proximity_score_6h DOUBLE PRECISION DEFAULT 0,
activity_sync_score_6h DOUBLE PRECISION DEFAULT 0,
stability_score DOUBLE PRECISION DEFAULT 0,
registry_bonus DOUBLE PRECISION DEFAULT 0,
final_score DOUBLE PRECISION DEFAULT 0,
margin_from_top DOUBLE PRECISION DEFAULT 0,
evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (observed_at, group_key, sub_cluster_id, candidate_mmsi)
);
CREATE INDEX IF NOT EXISTS idx_ggpcs_group_time
ON kcg.gear_group_parent_candidate_snapshots(group_key, sub_cluster_id, observed_at DESC, rank ASC);
CREATE INDEX IF NOT EXISTS idx_ggpcs_candidate
ON kcg.gear_group_parent_candidate_snapshots(candidate_mmsi, observed_at DESC);
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_resolution (
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
parent_name TEXT NOT NULL,
normalized_parent_name VARCHAR(200),
status VARCHAR(40) NOT NULL,
selected_parent_mmsi VARCHAR(20),
selected_parent_name VARCHAR(200),
selected_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
confidence DOUBLE PRECISION,
decision_source VARCHAR(40),
top_score DOUBLE PRECISION DEFAULT 0,
second_score DOUBLE PRECISION DEFAULT 0,
score_margin DOUBLE PRECISION DEFAULT 0,
stable_cycles INT DEFAULT 0,
last_evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_promoted_at TIMESTAMPTZ,
approved_by VARCHAR(100),
approved_at TIMESTAMPTZ,
manual_comment TEXT,
rejected_candidate_mmsi VARCHAR(20),
rejected_at TIMESTAMPTZ,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_key, sub_cluster_id)
);
CREATE INDEX IF NOT EXISTS idx_ggpr_status
ON kcg.gear_group_parent_resolution(status, last_evaluated_at DESC);
CREATE INDEX IF NOT EXISTS idx_ggpr_parent
ON kcg.gear_group_parent_resolution(selected_parent_mmsi);
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_review_log (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
action VARCHAR(20) NOT NULL,
selected_parent_mmsi VARCHAR(20),
actor VARCHAR(100) NOT NULL,
comment TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ggprl_group_time
ON kcg.gear_group_parent_review_log(group_key, sub_cluster_id, created_at DESC);
-- ── copied schema 환경의 sequence 정렬 ─────────────────────────────
SELECT setval(
pg_get_serial_sequence('kcg.fleet_companies', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.fleet_companies), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.fleet_vessels', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.fleet_vessels), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.gear_identity_log', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.gear_identity_log), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.fleet_tracking_snapshot', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.fleet_tracking_snapshot), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.group_polygon_snapshots', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.group_polygon_snapshots), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.gear_correlation_scores', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.gear_correlation_scores), 1),
TRUE
);

파일 보기

@ -1,23 +0,0 @@
SET search_path TO kcg, public;
DELETE FROM kcg.gear_group_parent_candidate_snapshots
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_group_parent_review_log
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_group_parent_resolution
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_correlation_raw_metrics
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_correlation_scores
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.group_polygon_snapshots
WHERE group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')
AND LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_identity_log
WHERE LENGTH(REGEXP_REPLACE(UPPER(COALESCE(parent_name, name)), '[ _%\\-]', '', 'g')) < 4;

파일 보기

@ -1,125 +0,0 @@
-- 014: 어구 모선 검토 워크플로우 v2 phase 1
SET search_path TO kcg, public;
-- ── 그룹/전역 후보 제외 ───────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL,
group_key VARCHAR(100),
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL,
duration_days INT,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ,
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpce_scope CHECK (scope_type IN ('GROUP', 'GLOBAL')),
CONSTRAINT chk_gpce_reason CHECK (reason_type IN ('GROUP_WRONG_PARENT', 'GLOBAL_NOT_PARENT_TARGET')),
CONSTRAINT chk_gpce_group_scope CHECK (
(scope_type = 'GROUP' AND group_key IS NOT NULL AND sub_cluster_id IS NOT NULL AND duration_days IN (1, 3, 5) AND active_until IS NOT NULL)
OR
(scope_type = 'GLOBAL' AND duration_days IS NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_gpce_scope_mmsi_active
ON kcg.gear_parent_candidate_exclusions(scope_type, candidate_mmsi, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_group_active
ON kcg.gear_parent_candidate_exclusions(group_key, sub_cluster_id, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_active_until
ON kcg.gear_parent_candidate_exclusions(active_until);
-- ── 기간형 정답 라벨 세션 ────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpls_duration CHECK (duration_days IN (1, 3, 5)),
CONSTRAINT chk_gpls_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'CANCELLED'))
);
CREATE INDEX IF NOT EXISTS idx_gpls_group_active
ON kcg.gear_parent_label_sessions(group_key, sub_cluster_id, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_mmsi_active
ON kcg.gear_parent_label_sessions(label_parent_mmsi, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_active_until
ON kcg.gear_parent_label_sessions(active_until);
-- ── 라벨 세션 기간 중 cycle별 자동 추론 기록 ─────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gpltc_session_observed UNIQUE (label_session_id, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_gpltc_session_observed
ON kcg.gear_parent_label_tracking_cycles(label_session_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gpltc_top_candidate
ON kcg.gear_parent_label_tracking_cycles(top_candidate_mmsi);
-- ── active view ────────────────────────────────────────────────
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_candidate_exclusions AS
SELECT *
FROM kcg.gear_parent_candidate_exclusions
WHERE released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW());
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_label_sessions AS
SELECT *
FROM kcg.gear_parent_label_sessions
WHERE status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW();

파일 보기

@ -1,111 +0,0 @@
-- 015: 어구 모선 추론 episode continuity + prior bonus
SET search_path TO kcg, public;
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS normalized_parent_name VARCHAR(200);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS episode_id VARCHAR(64);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS episode_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS lineage_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS label_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
UPDATE kcg.gear_group_parent_candidate_snapshots
SET normalized_parent_name = regexp_replace(upper(COALESCE(parent_name, '')), '[[:space:]_%-]+', '', 'g')
WHERE normalized_parent_name IS NULL;
CREATE INDEX IF NOT EXISTS idx_ggpcs_episode_time
ON kcg.gear_group_parent_candidate_snapshots(episode_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_ggpcs_lineage_time
ON kcg.gear_group_parent_candidate_snapshots(normalized_parent_name, observed_at DESC);
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS episode_id VARCHAR(64);
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS continuity_source VARCHAR(32);
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS continuity_score DOUBLE PRECISION;
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS prior_bonus_total DOUBLE PRECISION NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_ggpr_episode
ON kcg.gear_group_parent_resolution(episode_id);
ALTER TABLE kcg.gear_parent_label_sessions
ADD COLUMN IF NOT EXISTS normalized_parent_name VARCHAR(200);
UPDATE kcg.gear_parent_label_sessions
SET normalized_parent_name = regexp_replace(upper(COALESCE(group_key, '')), '[[:space:]_%-]+', '', 'g')
WHERE normalized_parent_name IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpls_lineage_active
ON kcg.gear_parent_label_sessions(normalized_parent_name, active_from DESC);
CREATE TABLE IF NOT EXISTS kcg.gear_group_episodes (
episode_id VARCHAR(64) PRIMARY KEY,
lineage_key VARCHAR(200) NOT NULL,
group_key VARCHAR(100) NOT NULL,
normalized_parent_name VARCHAR(200) NOT NULL,
current_sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
continuity_source VARCHAR(32) NOT NULL DEFAULT 'NEW',
continuity_score DOUBLE PRECISION,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_snapshot_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
current_member_count INT NOT NULL DEFAULT 0,
current_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
current_center_point geometry(Point, 4326),
split_from_episode_id VARCHAR(64),
merged_from_episode_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
merged_into_episode_id VARCHAR(64),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gge_status CHECK (status IN ('ACTIVE', 'MERGED', 'EXPIRED')),
CONSTRAINT chk_gge_continuity CHECK (continuity_source IN ('NEW', 'CONTINUED', 'SPLIT_CONTINUE', 'SPLIT_NEW', 'MERGE_NEW', 'DIRECT_PARENT_MATCH'))
);
CREATE INDEX IF NOT EXISTS idx_gge_lineage_status_time
ON kcg.gear_group_episodes(lineage_key, status, last_snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gge_group_time
ON kcg.gear_group_episodes(group_key, current_sub_cluster_id, last_snapshot_time DESC);
CREATE TABLE IF NOT EXISTS kcg.gear_group_episode_snapshots (
id BIGSERIAL PRIMARY KEY,
episode_id VARCHAR(64) NOT NULL REFERENCES kcg.gear_group_episodes(episode_id) ON DELETE CASCADE,
lineage_key VARCHAR(200) NOT NULL,
group_key VARCHAR(100) NOT NULL,
normalized_parent_name VARCHAR(200) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
member_count INT NOT NULL DEFAULT 0,
member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
center_point geometry(Point, 4326),
continuity_source VARCHAR(32) NOT NULL,
continuity_score DOUBLE PRECISION,
parent_episode_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
top_candidate_mmsi VARCHAR(20),
top_candidate_score DOUBLE PRECISION,
resolution_status VARCHAR(40),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gges_episode_observed UNIQUE (episode_id, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_gges_lineage_observed
ON kcg.gear_group_episode_snapshots(lineage_key, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gges_group_observed
ON kcg.gear_group_episode_snapshots(group_key, sub_cluster_id, observed_at DESC);

파일 보기

@ -1,21 +0,0 @@
services:
ollama:
image: ollama/ollama:latest
container_name: kcg-ollama
restart: unless-stopped
ports:
- "11434:11434"
volumes:
- /home/kcg-ollama/data:/root/.ollama
deploy:
resources:
limits:
memory: 64G
reservations:
memory: 40G
environment:
- OLLAMA_NUM_PARALLEL=4
- OLLAMA_MAX_LOADED_MODELS=1
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_FLASH_ATTENTION=1
- OLLAMA_NUM_THREADS=48

파일 보기

@ -20,21 +20,6 @@ server {
try_files $uri $uri/ /index.html;
}
# ── AI Chat (SSE → Python prediction on redis-211) ──
location /api/prediction-chat {
rewrite ^/api/prediction-chat(.*)$ /api/v1/chat$1 break;
proxy_pass http://192.168.1.18:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 120s;
proxy_set_header Connection '';
chunked_transfer_encoding off;
}
# ── Backend API (direct) ──
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
@ -109,16 +94,6 @@ server {
proxy_ssl_server_name on;
}
# ── Google TTS 프록시 (중국어 경고문 음성) ──
location /api/gtts {
rewrite ^/api/gtts(.*)$ /translate_tts$1 break;
proxy_pass https://translate.google.com;
proxy_set_header Host translate.google.com;
proxy_set_header Referer "https://translate.google.com/";
proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
proxy_ssl_server_name on;
}
# gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

파일 보기

@ -1,514 +0,0 @@
# Gear Parent Inference Algorithm Spec
## 문서 목적
이 문서는 현재 구현된 어구 모선 추적 알고리즘을 모듈, 메서드, 파라미터, 판단 기준, 저장소, 엔드포인트, 영향 관계 기준으로 정리한 구현 명세다. `GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md`가 서술형 통합 문서라면, 이 문서는 구현과 후속 변경 작업에 바로 연결할 수 있는 참조 스펙이다.
## 1. 시스템 요약
### 1.1 현재 목적
- 최근 24시간 한국 수역 AIS를 캐시에 유지한다.
- 어구 이름 패턴과 위치를 기준으로 어구 그룹을 만든다.
- 주변 선박/오분류 어구를 correlation 후보로 평가한다.
- 후보 중 대표 모선 가능성이 높은 선박을 추론한다.
- 사람의 라벨/제외를 별도 저장소에 남겨 향후 모델 평가와 자동화 전환에 활용한다.
### 1.2 현재 점수 구조의 역할 구분
- `gear_correlation_scores.current_score`
- 후보 스크리닝용 correlation score
- EMA 기반 단기 메모리
- `gear_group_parent_candidate_snapshots.final_score`
- 모선 추론용 최종 후보 점수
- coverage-aware 보정과 이름/안정성/episode/lineage/label prior 반영
- `gear_group_parent_resolution`
- 그룹별 현재 추론 상태
- `gear_group_episodes`, `gear_group_episode_snapshots`
- `sub_cluster_id`와 분리된 continuity memory
- `gear_parent_label_tracking_cycles`
- 라벨 세션 동안의 자동 추론 성능 추적
## 2. 현재 DB 저장소와 유지 기간
| 저장소 | 역할 | 현재 유지 규칙 |
| --- | --- | --- |
| `group_polygon_snapshots` | `1h/1h-fb/6h` 그룹 스냅샷 | `7일` cleanup |
| `gear_correlation_raw_metrics` | correlation raw metric 시계열 | `7일` retention partition |
| `gear_correlation_scores` | correlation EMA score 현재 상태 | `30일` 미관측 시 cleanup |
| `gear_group_parent_candidate_snapshots` | cycle별 parent candidate snapshot | 현재 자동 cleanup 없음 |
| `gear_group_parent_resolution` | 그룹별 현재 추론 상태 1행 | 현재 자동 cleanup 없음 |
| `gear_group_episodes` | active/merged/expired episode 현재 상태 | 현재 자동 cleanup 없음 |
| `gear_group_episode_snapshots` | cycle별 episode continuity 스냅샷 | 현재 자동 cleanup 없음 |
| `gear_parent_candidate_exclusions` | 그룹/전역 후보 제외 | 기간 종료 또는 수동 해제까지 |
| `gear_parent_label_sessions` | 정답 라벨 세션 | 만료 시 `EXPIRED`, row는 유지 |
| `gear_parent_label_tracking_cycles` | 라벨 세션 cycle별 추적 | 현재 자동 cleanup 없음 |
## 3. 모듈 인덱스
### 3.1 시간/원천 적재
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/time_bucket.py` | `compute_safe_bucket()` | DB 적재 완료 전 bucket 차단 |
| `prediction/time_bucket.py` | `compute_initial_window_start()` | 초기 24h window 시작점 |
| `prediction/time_bucket.py` | `compute_incremental_window_start()` | overlap backfill 시작점 |
| `prediction/db/snpdb.py` | `fetch_all_tracks()` | safe bucket까지 초기 bulk 적재 |
| `prediction/db/snpdb.py` | `fetch_incremental()` | backfill 포함 증분 적재 |
| `prediction/cache/vessel_store.py` | `load_initial()` | 초기 메모리 캐시 구성 |
| `prediction/cache/vessel_store.py` | `merge_incremental()` | 증분 merge + dedupe |
| `prediction/cache/vessel_store.py` | `evict_stale()` | 24h sliding window 유지 |
### 3.2 어구 identity / 그룹
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/fleet_tracker.py` | `track_gear_identity()` | 어구 이름 파싱, identity log 관리 |
| `prediction/algorithms/gear_name_rules.py` | `normalize_parent_name()` | 모선명 정규화 |
| `prediction/algorithms/gear_name_rules.py` | `is_trackable_parent_name()` | 짧은 이름 제외 |
| `prediction/algorithms/polygon_builder.py` | `detect_gear_groups()` | 어구 그룹 및 서브클러스터 생성 |
| `prediction/algorithms/polygon_builder.py` | `build_all_group_snapshots()` | `1h/1h-fb/6h` 스냅샷 저장용 생성 |
### 3.3 correlation / parent inference
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/algorithms/gear_correlation.py` | `run_gear_correlation()` | raw metric + EMA score 계산 |
| `prediction/algorithms/gear_correlation.py` | `_compute_gear_vessel_metrics()` | proximity/visit/activity 계산 |
| `prediction/algorithms/gear_correlation.py` | `update_score()` | EMA + freeze/decay 상태 전이 |
| `prediction/algorithms/gear_parent_episode.py` | `build_episode_plan()` | continuity source와 episode assignment 계산 |
| `prediction/algorithms/gear_parent_episode.py` | `compute_prior_bonus_components()` | episode/lineage/label prior bonus 계산 |
| `prediction/algorithms/gear_parent_episode.py` | `sync_episode_states()` | `gear_group_episodes` upsert |
| `prediction/algorithms/gear_parent_episode.py` | `insert_episode_snapshots()` | episode snapshot append |
| `prediction/algorithms/gear_parent_inference.py` | `run_gear_parent_inference()` | 최종 모선 추론 실행 |
| `prediction/algorithms/gear_parent_inference.py` | `_build_candidate_scores()` | 후보별 상세 점수 계산 |
| `prediction/algorithms/gear_parent_inference.py` | `_name_match_score()` | 이름 점수 규칙 |
| `prediction/algorithms/gear_parent_inference.py` | `_build_track_coverage_metrics()` | coverage-aware evidence 계산 |
| `prediction/algorithms/gear_parent_inference.py` | `_select_status()` | 상태 전이 규칙 |
### 3.4 backend read model / workflow
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `GroupPolygonService.java` | group list/review/detail SQL | 최신 `1h` live + stale suppression read model |
| `ParentInferenceWorkflowController.java` | exclusion/label API | 사람 판단 저장소 API |
## 4. 메서드 상세
## 4.1 `prediction/time_bucket.py`
### `compute_safe_bucket(now: datetime | None = None) -> datetime`
- 입력:
- 현재 시각
- 출력:
- `safe_delay`를 뺀 뒤 5분 단위로 내림한 KST naive bucket
- 기준:
- `SNPDB_SAFE_DELAY_MIN`
- 영향:
- 초기 적재, 증분 적재, eviction 기준점
### `compute_incremental_window_start(last_bucket: datetime) -> datetime`
- 입력:
- 현재 캐시의 마지막 처리 bucket
- 출력:
- `last_bucket - SNPDB_BACKFILL_BUCKETS * 5m`
- 의미:
- 늦게 들어온 같은 bucket row 재흡수
## 4.2 `prediction/db/snpdb.py`
### `fetch_all_tracks(hours: int = 24) -> pd.DataFrame`
- 역할:
- safe bucket까지 최근 `N`시간 full load
- 핵심 쿼리 조건:
- bbox: `122,31,132,39`
- `time_bucket > window_start`
- `time_bucket <= safe_bucket`
- 출력 컬럼:
- `mmsi`, `timestamp`, `time_bucket`, `lat`, `lon`, `raw_sog`
### `fetch_incremental(last_bucket: datetime) -> pd.DataFrame`
- 역할:
- overlap backfill 포함 증분 load
- 핵심 쿼리 조건:
- `time_bucket > from_bucket`
- `time_bucket <= safe_bucket`
- 주의:
- 이미 본 bucket도 일부 다시 읽는 구조다
## 4.3 `prediction/cache/vessel_store.py`
### `load_initial(hours: int = 24) -> None`
- 역할:
- 초기 bulk DataFrame을 MMSI별 track cache로 구성
- 파생 효과:
- `_last_bucket` 갱신
- static info refresh
- permit registry refresh
### `merge_incremental(df_new: pd.DataFrame) -> None`
- 역할:
- 증분 batch merge
- 기준:
- `timestamp`, `time_bucket` 정렬
- `timestamp` 기준 dedupe
- 영향:
- 같은 bucket overlap backfill에서도 최종 row만 유지
### `evict_stale(hours: int = 24) -> None`
- 역할:
- sliding 24h 유지
- 기준:
- `time_bucket` 있으면 bucket 기준
- 없으면 timestamp fallback
## 4.4 `prediction/fleet_tracker.py`
### `track_gear_identity(gear_signals, conn) -> None`
- 역할:
- 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2` 추출
- `gear_identity_log` insert/update
- 입력:
- gear signal list
- 주요 기준:
- 정규화 길이 `< 4`면 건너뜀
- 같은 이름, 다른 MMSI는 identity migration 처리
- 영향:
- `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전 가능
## 4.5 `prediction/algorithms/polygon_builder.py`
### `detect_gear_groups(vessel_store) -> list[dict]`
- 역할:
- 어구 이름 기반 raw group 생성
- 거리 기반 서브클러스터 분리
- 근접 병합
- 입력:
- `all_positions`
- 주요 기준:
- 어구 패턴 매칭
- `440/441` 제외
- `is_trackable_parent_name()`
- `MAX_DIST_DEG = 0.15`
- 출력:
- `parent_name`, `parent_mmsi`, `sub_cluster_id`, `members`
### `build_all_group_snapshots(vessel_store, company_vessels, companies) -> list[dict]`
- 역할:
- `FLEET`, `GEAR_IN_ZONE`, `GEAR_OUT_ZONE``1h/1h-fb/6h` snapshot 생성
- 주요 기준:
- 같은 `parent_name` 전체 기준 1h active member 수
- `GEAR_OUT_ZONE` 최소 멤버 수
- parent nearby 시 `isParent=true`
## 4.6 `prediction/algorithms/gear_correlation.py`
### `run_gear_correlation(vessel_store, gear_groups, conn) -> dict`
- 역할:
- 그룹당 후보 탐색
- raw metric 저장
- EMA score 갱신
- 입력:
- `gear_groups`
- 출력:
- `updated`, `models`, `raw_inserted`
### `_compute_gear_vessel_metrics(gear_center_lat, gear_center_lon, gear_radius_nm, vessel_track, params) -> dict`
- 출력 metric:
- `proximity_ratio`
- `visit_score`
- `activity_sync`
- `composite`
- 한계:
- raw metric은 짧은 항적에 과대 우호적일 수 있음
- 이 문제는 parent inference 단계의 coverage-aware 보정에서 완화
### `update_score(prev_score, raw_score, streak, last_observed, now, gear_group_active_ratio, shadow_bonus, params) -> tuple`
- 상태:
- `ACTIVE`
- `PATTERN_DIVERGE`
- `GROUP_QUIET`
- `NORMAL_GAP`
- `SIGNAL_LOSS`
- 의미:
- correlation score는 장기 기억보다 short-memory EMA에 가깝다
## 4.7 `prediction/algorithms/gear_parent_inference.py`
### `run_gear_parent_inference(vessel_store, gear_groups, conn) -> dict[str, int]`
- 역할:
- direct parent 보강
- active exclusion/label 적용
- 후보 점수 계산
- 상태 전이
- snapshot/resolution/tracking 저장
### `_load_existing_resolution(conn, group_keys) -> dict`
- 역할:
- 현재 그룹의 이전 resolution 상태 로드
- 현재 쓰임:
- `PREVIOUS_SELECTION` 후보 seed
- `stable_cycles`
- `MANUAL_CONFIRMED` 유지
- reject cooldown
### `_build_candidate_scores(...) -> list[CandidateScore]`
- 후보 집합 원천:
- 상위 correlation 후보
- registry name exact bucket
- previous selection
- 제거 규칙:
- global exclusion
- group exclusion
- reject cooldown
- 점수 항목:
- `base_corr_score`
- `name_match_score`
- `track_similarity_score`
- `visit_score_6h`
- `proximity_score_6h`
- `activity_sync_score_6h`
- `stability_score`
- `registry_bonus`
- `china_mmsi_bonus` 후가산
### `_name_match_score(parent_name, candidate_name, registry) -> float`
- 규칙:
- 원문 동일 `1.0`
- 정규화 동일 `0.8`
- prefix/contains `0.5`
- 숫자 제거 후 문자 부분 동일 `0.3`
- else `0.0`
### `_build_track_coverage_metrics(center_track, vessel_track, gear_center_lat, gear_center_lon) -> dict`
- 역할:
- short-track 과대평가 방지용 증거 강도 계산
- 핵심 출력:
- `trackCoverageFactor`
- `visitCoverageFactor`
- `activityCoverageFactor`
- `coverageFactor`
- downstream:
- `track`, `visit`, `proximity`, `activity` raw score에 곱해 effective score 생성
## 4.8 `prediction/algorithms/gear_parent_episode.py`
### `build_episode_plan(groups, previous_by_lineage) -> EpisodePlan`
- 역할:
- 현재 cycle group을 이전 active episode와 매칭
- `NEW`, `CONTINUED`, `SPLIT_CONTINUE`, `SPLIT_NEW`, `MERGE_NEW` 결정
- 입력:
- `GroupEpisodeInput[]`
- 최근 `6h` active `EpisodeState[]`
- continuity score:
- `0.75 * member_jaccard + 0.25 * center_support`
- 기준:
- `member_jaccard`
- 중심점 거리 `12nm`
- continuity score threshold `0.45`
- merge score threshold `0.35`
- 출력:
- assignment map
- expired episode set
- merged target map
### `compute_prior_bonus_components(...) -> dict[str, float]`
- 역할:
- 동일 candidate에 대한 episode/lineage/label prior bonus 계산
- 입력 집계 범위:
- episode prior: `24h`
- lineage prior: `7d`
- label prior: `30d`
- cap:
- `episode <= 0.10`
- `lineage <= 0.05`
- `label <= 0.10`
- `total <= 0.20`
- 출력:
- `episodePriorBonus`
- `lineagePriorBonus`
- `labelPriorBonus`
- `priorBonusTotal`
### `sync_episode_states(conn, observed_at, plan) -> None`
- 역할:
- active/merged/expired episode 상태를 `gear_group_episodes`에 반영
- 기준:
- merge 대상은 `MERGED`
- continuity 없는 old episode는 `EXPIRED`
### `insert_episode_snapshots(conn, observed_at, plan, payloads) -> int`
- 역할:
- cycle별 continuity 결과와 top candidate/result를 `gear_group_episode_snapshots`에 저장
- 기록:
- `episode_id`
- `parent_episode_ids`
- `top_candidate_mmsi`
- `top_candidate_score`
- `resolution_status`
### `_select_status(top_candidate, margin, stable_cycles) -> tuple[str, str]`
- 상태:
- `NO_CANDIDATE`
- `AUTO_PROMOTED`
- `REVIEW_REQUIRED`
- `UNRESOLVED`
- auto promotion 조건:
- `target_type == VESSEL`
- `CORRELATION` source 포함
- `final_score >= 0.72`
- `margin >= 0.15`
- `stable_cycles >= 3`
- review 조건:
- `final_score >= 0.60`
## 5. 현재 엔드포인트 스펙
## 5.1 조회 계열
### `/api/kcg/vessel-analysis/groups/parent-inference/review`
- 역할:
- 최신 전역 `1h` 기준 검토 대기 목록
- 조건:
- stale resolution 숨김
- candidate count는 latest candidate snapshot 기준
### `/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference`
- 역할:
- 특정 그룹의 현재 live sub-cluster 상세
- 주의:
- “현재 최신 전역 `1h`에 실제 존재하는 sub-cluster만” 반환
### `/api/kcg/vessel-analysis/parent-inference/candidate-exclusions`
- 역할:
- 그룹/전역 제외 목록 조회
### `/api/kcg/vessel-analysis/parent-inference/label-sessions`
- 역할:
- active 또는 전체 라벨 세션 조회
## 5.2 액션 계열
### `POST /candidate-exclusions/global`
- 역할:
- 전역 후보 제외 생성
- 영향:
- 다음 cycle부터 모든 그룹에서 해당 MMSI 제거
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/exclude`
- 역할:
- 그룹 단위 후보 제외 생성
- 영향:
- 다음 cycle부터 해당 그룹에서만 제거
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/label`
- 역할:
- 기간형 정답 라벨 세션 생성
- 영향:
- 다음 cycle부터 tracking row 누적
## 6. 현재 기억 구조와 prior bonus
### 6.1 short-memory와 long-memory의 분리
- `gear_correlation_scores`
- EMA short-memory
- 미관측 시 decay
- 현재 후보 seed 역할
- `gear_group_parent_resolution`
- 현재 상태 1행
- same-episode가 아니면 `PREVIOUS_SELECTION` carry를 직접 사용하지 않음
- `gear_group_episodes`
- continuity memory
- `candidate_snapshots`
- bonus 집계 원천
### 6.2 현재 final score의 장기 기억 반영
현재는 과거 점수를 직접 carry하지 않고, 약한 prior bonus만 후가산한다.
```text
final_score =
current_signal_score
+ china_mmsi_bonus
+ prior_bonus_total
```
여기서 `prior_bonus_total`은:
- `episode_prior_bonus`
- `lineage_prior_bonus`
- `label_prior_bonus`
의 합이며 총합 cap은 `0.20`이다.
### 6.3 왜 weak prior인가
과거 점수를 그대로 넘기면:
- 다른 episode로 잘못 관성이 전이될 수 있다
- split/merge 이후 잘못된 top1이 고착될 수 있다
- 오래된 오답이 장기 drift로 남을 수 있다
그래서 현재 구현은 과거 점수를 “현재 score 자체”가 아니라 “작은 bonus”로만 쓴다.
## 7. 현재 continuity / prior 동작
### 7.1 episode continuity
- 같은 lineage 안에서 최근 `6h` active episode를 불러온다.
- continuity score가 높은 이전 episode가 있으면 `CONTINUED`
- 1개 parent episode가 여러 current cluster로 이어지면 `SPLIT_CONTINUE` + `SPLIT_NEW`
- 여러 previous episode가 하나 current cluster로 모이면 `MERGE_NEW`
- 어떤 current와도 연결되지 못한 old episode는 `EXPIRED`
### 7.2 prior 집계
| prior | 참조 범위 | 현재 집계 값 |
| --- | --- | --- |
| episode prior | 최근 동일 episode `24h` | seen_count, top1_count, avg_score, last_seen_at |
| lineage prior | 동일 이름 lineage `7d` | seen_count, top1_count, top3_count, avg_score, last_seen_at |
| label prior | 라벨 세션 `30d` | session_count, last_labeled_at |
### 7.3 구현 시 주의
- 과거 점수를 직접 누적하지 말 것
- prior는 bonus로만 사용하고 cap을 둘 것
- split/merge 이후 parent 후보 관성은 약하게만 상속할 것
- stale live sub-cluster와 vanished old sub-cluster를 혼동하지 않도록, aggregation도 최신 episode anchor를 기준으로 할 것
## 8. 참조 문서
- [GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md)
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2.md)
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md)

파일 보기

@ -1,677 +0,0 @@
# Gear Parent Inference Dataflow Paper
## 초록
이 문서는 `iran-airstrike-replay-codex`의 한국 수역 어구 모선 추적 체계를 코드 기준으로 복원하는 통합 기술 문서다. 범위는 `snpdb` 5분 궤적 적재, 인메모리 캐시 유지, 어구 그룹 검출, 서브클러스터 생성, `1h/1h-fb/6h` 폴리곤 스냅샷 저장, correlation 기반 후보 점수화, coverage-aware parent inference, `episode_id` 기반 연속성 계층, backend read model, review/exclusion/label v2까지 포함한다. 문서의 목적은 “현재 무엇이 구현되어 있고, 각 경우의 수에서 어떤 분기 규칙이 적용되는가”를 한 문서에서 복원 가능하게 만드는 것이다.
## 1. 범위와 전제
### 1.1 구현 기준
- frontend: `frontend/`
- backend: `backend/`
- prediction: `prediction/`
- schema migration: `database/migration/012_gear_parent_inference.sql`, `database/migration/014_gear_parent_workflow_v2_phase1.sql`, `database/migration/015_gear_parent_episode_tracking.sql`
### 1.2 실행 환경
- lab backend: `rocky-211:18083`
- lab prediction: `redis-211:18091`
- lab schema: `kcg_lab`
- 로컬 프론트 진입점: `yarn dev:lab`, `yarn dev:lab:ssh`
### 1.3 문서의 구분
- 구현됨:
- 현재 repo 코드와 lab 배포에 이미 반영된 규칙
- 후속 확장 후보:
- episode continuity 위에서 추가로 올릴 `focus mode`, richer episode lineage API, calibration report
## 2. 문제 정의
이 시스템은 한국 수역에서 AIS 신호를 이용해 아래 문제를 단계적으로 푼다.
1. 최근 24시간의 선박/어구 궤적을 메모리 캐시에 유지한다.
2. 동일한 어구 이름 계열을 공간적으로 묶어 어구 그룹을 만든다.
3. 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 생성한다.
4. 주변 선박 또는 잘못 분류된 어구 AIS를 후보로 수집하고 correlation 점수를 만든다.
5. 후보를 모선 추론 점수로 다시 환산한다.
6. 사람이 라벨/제외를 누적해 모델 정확도 고도화용 데이터셋을 만든다.
핵심 난점은 아래 세 가지다.
- DB 적재 지연 때문에 live incremental cache와 fresh reload가 다를 수 있다.
- 같은 `parent_name` 아래에서도 실제로는 여러 공간 덩어리로 갈라질 수 있다.
- 짧은 항적이 `track/proximity/activity`에서 과대평가될 수 있다.
## 3. 전체 아키텍처 흐름
```mermaid
flowchart LR
A["signal.t_vessel_tracks_5min<br/>5분 bucket linestringM"] --> B["prediction/db/snpdb.py<br/>safe bucket + overlap backfill"]
B --> C["prediction/cache/vessel_store.py<br/>24h in-memory cache"]
C --> D["prediction/fleet_tracker.py<br/>gear_identity_log / snapshot"]
C --> E["prediction/algorithms/polygon_builder.py<br/>gear group detect + sub-cluster + snapshots"]
E --> F["kcg_lab.group_polygon_snapshots"]
C --> G["prediction/algorithms/gear_correlation.py<br/>raw metrics + EMA score"]
G --> H["kcg_lab.gear_correlation_raw_metrics"]
G --> I["kcg_lab.gear_correlation_scores"]
F --> J["prediction/algorithms/gear_parent_inference.py<br/>candidate build + scoring + status"]
H --> J
I --> J
K["v2 exclusions / labels"] --> J
J --> L["kcg_lab.gear_group_parent_candidate_snapshots"]
J --> M["kcg_lab.gear_group_parent_resolution"]
J --> N["kcg_lab.gear_parent_label_tracking_cycles"]
F --> O["backend GroupPolygonService"]
L --> O
M --> O
N --> O
O --> P["frontend ParentReviewPanel"]
```
## 4. 원천 데이터와 시간 모델
### 4.1 원천 데이터 형식
원천은 `signal.t_vessel_tracks_5min`이며, `1 row = 1 MMSI = 5분 구간의 궤적 전체``LineStringM`으로 보관한다. 실제 위치 포인트는 `ST_DumpPoints(track_geom)`로 분해하고, 각 점의 timestamp는 `ST_M((dp).geom)`에서 꺼낸다. 구현 위치는 `prediction/db/snpdb.py`다.
### 4.2 safe watermark
현재 구현은 “DB 적재가 완료된 bucket만 읽는다”는 원칙을 따른다.
- `prediction/time_bucket.py`
- `compute_safe_bucket()`
- `compute_initial_window_start()`
- `compute_incremental_window_start()`
- 기본값:
- `SNPDB_SAFE_DELAY_MIN`
- `SNPDB_BACKFILL_BUCKETS`
핵심 규칙:
1. 초기 적재는 `now - safe_delay`를 5분 내림한 `safe_bucket`까지만 읽는다.
2. 증분 적재는 `last_bucket - backfill_window`부터 `safe_bucket`까지 다시 읽는다.
3. live cache는 `timestamp`가 아니라 `time_bucket` 기준으로 24시간 cutoff를 맞춘다.
### 4.3 왜 safe watermark가 필요한가
`time_bucket > last_bucket`만 사용하면, 늦게 들어온 같은 bucket row를 영구히 놓칠 수 있다. 현재 구현은 overlap backfill과 dedupe로 이 drift를 줄인다.
- 조회: `prediction/db/snpdb.py`
- 병합 dedupe: `prediction/cache/vessel_store.py`
## 5. Stage 1: 캐시 적재와 유지
### 5.1 초기 적재
`prediction/main.py`는 시작 시 `vessel_store.load_initial(24)`를 호출한다.
`prediction/cache/vessel_store.py`의 규칙:
1. `snpdb.fetch_all_tracks(hours)`로 최근 24시간을 safe bucket까지 읽는다.
2. MMSI별 DataFrame으로 `_tracks`를 구성한다.
3. 최대 `time_bucket``_last_bucket`으로 저장한다.
4. static info와 permit registry를 함께 refresh한다.
### 5.2 증분 병합
스케줄러는 `snpdb.fetch_incremental(vessel_store.last_bucket)`로 overlap backfill 구간을 다시 읽는다.
`merge_incremental()` 규칙:
1. 기존 DataFrame과 새 batch를 합친다.
2. `timestamp`, `time_bucket`으로 정렬한다.
3. `timestamp` 기준 중복은 `keep='last'`로 제거한다.
4. batch의 최대 `time_bucket`이 더 크면 `_last_bucket`을 갱신한다.
### 5.3 stale eviction
`evict_stale()`는 safe bucket 기준 24시간 이전 포인트를 제거한다. `time_bucket`이 있으면 bucket 기준, 없으면 timestamp 기준으로 fallback한다.
## 6. Stage 2: 어구 identity 추출
`prediction/fleet_tracker.py`는 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2`를 파싱하고 `gear_identity_log`를 관리한다.
### 6.1 이름 기반 필터
공통 규칙은 `prediction/algorithms/gear_name_rules.py`에 있다.
- 정규화:
- 대문자화
- 공백, `_`, `-`, `%` 제거
- 추적 가능 최소 길이:
- 정규화 길이 `>= 4`
`fleet_tracker.py``polygon_builder.py`는 모두 `is_trackable_parent_name()`을 사용한다. 즉 짧은 이름은 추론 이전, 그룹 생성 이전 단계부터 제외된다.
### 6.2 identity log 동작
`fleet_tracker.py`의 핵심 분기:
1. 같은 MMSI + 같은 이름:
- 기존 활성 row의 `last_seen_at`, 위치만 갱신
2. 같은 MMSI + 다른 이름:
- 기존 row 비활성화
- 새 row insert
3. 다른 MMSI + 같은 이름:
- 기존 row 비활성화
- 새 MMSI로 row insert
- 기존 `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전
## 7. Stage 3: 어구 그룹 생성과 서브클러스터
실제 어구 그룹은 `prediction/algorithms/polygon_builder.py``detect_gear_groups()`가 만든다.
### 7.1 1차 그룹화
규칙:
1. 최신 position 이름이 어구 패턴에 맞아야 한다.
2. `STALE_SEC`를 넘는 오래된 신호는 제외한다.
3. `440`, `441` MMSI는 어구 AIS 미사용으로 간주해 제외한다.
4. `is_trackable_parent_name(parent_raw)`를 만족해야 한다.
5. 같은 `parent_name`은 공백 제거 버전으로 묶는다.
### 7.2 서브클러스터 생성
같은 이름 아래에서도 거리 기반 연결성으로 덩어리를 나눈다.
- 거리 임계치: `MAX_DIST_DEG = 0.15`
- 연결 규칙:
- 각 어구가 클러스터 내 최소 1개와 `MAX_DIST_DEG` 이내면 같은 연결 요소
- 구현:
- Union-Find
모선이 이미 있으면, 모선과 가장 가까운 클러스터를 seed cluster로 간주한다.
### 7.3 `sub_cluster_id` 부여 규칙
현재 구현은 아래와 같다.
1. 클러스터가 1개면 `sub_cluster_id = 0`
2. 클러스터가 여러 개면 `1..N`
3. 이후 동일 `parent_key`의 두 서브그룹이 다시 근접 병합되면 `sub_cluster_id = 0`
`sub_cluster_id`는 영구 식별자가 아니라 “그 시점의 공간 분리 라벨”이다.
### 7.4 병합 규칙
동일 `parent_key`의 두 그룹이 다시 가까워지면:
1. 멤버를 합친다.
2. 부모 MMSI가 없는 큰 그룹에 작은 그룹의 `parent_mmsi`를 승계할 수 있다.
3. `sub_cluster_id = 0`으로 재설정한다.
### 7.5 스냅샷 생성 규칙
`build_all_group_snapshots()`는 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 만든다.
- `1h`
- 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `>= 2`
- `1h-fb`
- 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `< 2`
- 리플레이/일치율 추적용
- 라이브 현황에서 제외
- `6h`
- 6시간 내 stale이 아니어야 함
추가 규칙:
1. 서브클러스터 내 1h 활성 멤버가 2개 미만이면 최신 2개로 fallback display를 만든다.
2. 수역 외(`GEAR_OUT_ZONE`)인데 멤버 수가 `MIN_GEAR_GROUP_SIZE` 미만이면 스킵한다.
3. 모선이 있고, 멤버와 충분히 근접하면 `members[].isParent = true`로 같이 넣는다.
## 8. Stage 4: correlation 모델
`prediction/algorithms/gear_correlation.py`는 어구 그룹별 raw metric과 EMA score를 만든다.
### 8.1 후보 생성
입력:
- group center
- group radius
- active ratio
- group member MMSI set
출력 후보:
- 선박 후보(`VESSEL`)
- 잘못 분류된 어구 후보(`GEAR_BUOY`)
후보 수는 그룹당 최대 `30`개로 제한된다.
### 8.2 raw metric
선박 후보는 최근 6시간 항적 기반으로 아래 값을 만든다.
- `proximity_ratio`
- `visit_score`
- `activity_sync`
- `dtw_similarity`
어구 후보는 단순 거리 기반 `proximity_ratio`만 사용한다.
### 8.3 EMA score
모델 파라미터(`gear_correlation_param_models`)별로 아래를 수행한다.
1. composite score 계산
2. 이전 score와 streak를 읽는다
3. `update_score()`로 EMA 갱신
4. threshold 이상이거나 기존 row가 있으면 upsert
반대로 이번 사이클 후보군에서 빠진 기존 항목은 `OUT_OF_RANGE`로 fast decay된다.
### 8.4 correlation 산출물
- `gear_correlation_raw_metrics`
- `gear_correlation_scores`
여기까지는 “잠재적 모선/근접 대상”의 score이고, 최종 parent inference는 아직 아니다.
## 9. Stage 5: parent inference
`prediction/algorithms/gear_parent_inference.py`가 최종 모선 추론을 수행한다.
전체 진입점은 `run_gear_parent_inference(vessel_store, gear_groups, conn)`이다.
### 9.1 전체 분기 개요
```mermaid
flowchart TD
A["active gear group"] --> B{"direct parent member<br/>exists?"}
B -- yes --> C["DIRECT_PARENT_MATCH<br/>fresh resolution upsert"]
B -- no --> D{"trackable parent name?"}
D -- no --> E["SKIPPED_SHORT_NAME"]
D -- yes --> F["build candidate set"]
F --> G{"candidate exists?"}
G -- no --> H["NO_CANDIDATE"]
G -- yes --> I["score + rank + margin + stable cycles"]
I --> J{"auto promotion rule?"}
J -- yes --> K["AUTO_PROMOTED"]
J -- no --> L{"top score >= 0.60?"}
L -- yes --> M["REVIEW_REQUIRED"]
L -- no --> N["UNRESOLVED"]
```
### 9.1.1 episode continuity 선행 단계
현재 구현에서 `run_gear_parent_inference()`는 후보 점수를 만들기 전에 먼저 `prediction/algorithms/gear_parent_episode.py`를 호출해 active 그룹의 continuity를 계산한다.
입력:
- 현재 cycle `gear_groups`
- 정규화된 `parent_name`
- 최근 `6h` active `gear_group_episodes`
- 최근 `24h` episode prior, `7d` lineage prior, `30d` label prior 집계
핵심 규칙:
1. continuity score는 `0.75 * member_jaccard + 0.25 * center_support`다.
2. 중심점 지원값은 `12nm` 이내일수록 커진다.
3. continuity score가 충분하거나, overlap member가 있고 거리 조건을 만족하면 연결 후보로 본다.
4. 두 개 이상 active episode가 하나의 현재 cluster로 들어오면 `MERGE_NEW`다.
5. 하나의 episode가 여러 현재 cluster로 갈라지면 하나는 `SPLIT_CONTINUE`, 나머지는 `SPLIT_NEW`다.
6. 아무 previous episode와도 연결되지 않으면 `NEW`다.
7. 현재 cycle과 연결되지 못한 active episode는 `EXPIRED` 또는 `MERGED`로 종료한다.
현재 저장되는 continuity 메타데이터:
- `gear_group_parent_candidate_snapshots.episode_id`
- `gear_group_parent_resolution.episode_id`
- `gear_group_parent_resolution.continuity_source`
- `gear_group_parent_resolution.continuity_score`
- `gear_group_parent_resolution.prior_bonus_total`
- `gear_group_episodes`
- `gear_group_episode_snapshots`
### 9.2 direct parent 보강
최신 어구 그룹에 아래 중 하나가 있으면 후보 추론 대신 직접 모선 매칭으로 처리한다.
1. `members[].isParent = true`
2. `group.parent_mmsi` 존재
이 경우:
- `status = DIRECT_PARENT_MATCH`
- `decision_source = DIRECT_PARENT_MATCH`
- `confidence = 1.0`
- `candidateCount = 0`
단, 기존 상태가 `MANUAL_CONFIRMED`면 그 수동 상태를 유지한다.
### 9.3 짧은 이름 스킵
정규화 이름 길이 `< 4`면:
- 후보 생성 자체를 수행하지 않는다.
- `status = SKIPPED_SHORT_NAME`
- `decision_source = AUTO_SKIP`
### 9.4 후보 집합
후보 집합은 아래의 합집합이다.
1. default correlation model 상위 후보
2. registry name exact bucket
3. 기존 resolution의 `selected_parent_mmsi` 또는 이전 top candidate
여기에 아래를 적용한다.
- active global exclusion 제거
- active group exclusion 제거
- 최근 reject cooldown 후보 제거
### 9.5 이름 점수
현재 구현 규칙:
1. 원문 완전일치: `1.0`
2. 정규화 완전일치: `0.8`
3. prefix/contains: `0.5`
4. 숫자를 제거한 순수 문자 부분만 동일: `0.3`
5. 그 외: `0.0`
비교 대상:
- `parent_name`
- 후보 AIS 이름
- registry `name_cn`
- registry `name_en`
### 9.6 coverage-aware evidence
짧은 항적 과대평가를 막기 위해 raw score와 effective score를 분리한다.
evidence에 남는 값:
- `trackPointCount`
- `trackSpanMinutes`
- `overlapPointCount`
- `overlapSpanMinutes`
- `inZonePointCount`
- `inZoneSpanMinutes`
- `trackCoverageFactor`
- `visitCoverageFactor`
- `activityCoverageFactor`
- `coverageFactor`
현재 최종 점수에는 raw가 아니라 adjusted score가 들어간다.
### 9.7 점수 식
가중치 합은 아래다.
- `0.40 * base_corr`
- `0.15 * name_match`
- `0.15 * track_similarity_effective`
- `0.10 * visit_effective`
- `0.05 * proximity_effective`
- `0.05 * activity_effective`
- `0.10 * stability`
- `+ registry_bonus(0.05)`
그 다음 별도 후가산:
- `412/413` MMSI 보너스 `+0.15`
- 단, `preBonusScore >= 0.30`일 때만 적용
- `episode/lineage/label prior bonus`
- 최근 동일 episode `24h`
- 동일 lineage `7d`
- 라벨 세션 `30d`
- 총합 cap `0.20`
### 9.8 상태 전이
분기 조건:
- `NO_CANDIDATE`
- 후보가 하나도 없을 때
- `AUTO_PROMOTED`
- `target_type == VESSEL`
- candidate source에 `CORRELATION` 포함
- `final_score >= auto_promotion_threshold`
- `margin >= auto_promotion_margin`
- `stable_cycles >= auto_promotion_stable_cycles`
- `REVIEW_REQUIRED`
- `final_score >= 0.60`
- `UNRESOLVED`
- 나머지
추가 예외:
- 기존 상태가 `MANUAL_CONFIRMED`면 수동 상태를 유지한다.
- active label session이 있으면 tracking row를 별도로 적재한다.
### 9.9 산출물
- `gear_group_parent_candidate_snapshots`
- `gear_group_parent_resolution`
- `gear_parent_label_tracking_cycles`
- `gear_group_episodes`
- `gear_group_episode_snapshots`
## 10. Stage 6: backend read model
backend의 중심은 `backend/.../GroupPolygonService.java`다.
### 10.1 최신 1h만 라이브로 간주
group list, review queue, detail API는 모두 최신 전역 `1h` 스냅샷만 기준으로 삼는다.
핵심 효과:
1. `1h-fb`는 라이브 현황에서 기본 제외된다.
2. 이미 사라진 과거 sub-cluster는 detail API에서 다시 보이지 않는다.
### 10.2 stale inference 차단
`resolution.last_evaluated_at >= group.snapshot_time`인 경우만 join한다.
즉 최신 group snapshot보다 오래된 candidate/resolution은 detail/review/list에서 숨긴다. 이 규칙이 `ZHEDAIYU02433`, `ZHEDAIYU02394` 유형 stale 표시를 막는다.
### 10.3 detail API 의미
`/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference`
현재 의미:
- 해당 그룹의 최신 전역 `1h` live sub-cluster 집합
- 각 sub-cluster의 fresh resolution
- 각 sub-cluster의 latest candidate snapshot
## 11. Stage 7: review / exclusion / label v2
v2 Phase 1은 “자동 추론 결과”와 “사람 판단 데이터”를 분리하는 구조다.
### 11.1 사람 판단 저장소
- `gear_parent_candidate_exclusions`
- `gear_parent_label_sessions`
- `gear_parent_label_tracking_cycles`
### 11.2 액션 의미
- 그룹 제외:
- 특정 `group_key + sub_cluster_id`에서 특정 후보 MMSI를 일정 기간 제거
- 전체 후보 제외:
- 특정 MMSI를 모든 그룹 후보군에서 제거
- 정답 라벨:
- 특정 그룹에 대해 정답 parent MMSI를 `1/3/5일` 세션으로 지정
- prediction은 이후 cycle마다 top1/top3 여부를 추적
### 11.3 why v2
기존 `MANUAL_CONFIRMED`/`REJECT`는 운영 override 성격이 강했고, “모델 정확도 평가용 백데이터”와 섞였다. v2는 이 둘을 분리해 라벨을 평가 데이터로 쓰도록 한다.
## 12. 실제 경우의 수 분기표
| 경우 | 구현 위치 | 현재 동작 |
| --- | --- | --- |
| 이름 길이 `< 4` | `gear_name_rules.py`, `fleet_tracker.py`, `polygon_builder.py`, `gear_parent_inference.py` | identity/grouping/inference 단계에서 제외 또는 `SKIPPED_SHORT_NAME` |
| 직접 모선 포함 | `polygon_builder.py`, `gear_parent_inference.py` | `DIRECT_PARENT_MATCH` fresh resolution |
| 같은 이름, 멀리 떨어진 어구 | `polygon_builder.py` | 별도 sub-cluster 생성 |
| 두 서브클러스터가 다시 근접 | `polygon_builder.py` | 하나로 병합, `sub_cluster_id = 0` |
| group 전체 1h 활성 멤버 `< 2` | `polygon_builder.py` | `1h-fb` 생성, live 현황 제외 |
| 후보가 하나도 없음 | `gear_parent_inference.py` | `NO_CANDIDATE` |
| 짧은 항적이 우연히 근접 | `gear_parent_inference.py` | coverage-aware 보정으로 effective score 감소 |
| stale old inference가 남아 있음 | `GroupPolygonService.java` | 최신 group snapshot보다 오래되면 숨김 |
| 직접 parent가 이미 있음 | `gear_parent_inference.py` | 후보 계산 대신 direct parent resolution |
## 13. `sub_cluster_id`의 한계
현재 코드에서 `sub_cluster_id`는 영구 identity가 아니다.
이유:
1. 같은 이름 그룹의 공간 분리 수가 cycle마다 달라질 수 있다.
2. 병합되면 `0`으로 재설정된다.
3. 멤버가 추가/이탈해도 기존 번호 의미가 유지된다고 보장할 수 없다.
따라서 `group_key + sub_cluster_id`는 “현재 cycle의 공간 덩어리”를 가리키는 키로는 유효하지만, 장기 연속 추적 키로는 부적합하다.
## 14. Stage 8: `episode_id` continuity + prior bonus
### 14.1 목적
현재 구현의 `episode_id`는 “같은 어구 덩어리의 시간적 연속성”을 추적하는 별도 식별자다. `sub_cluster_id`를 대체하지 않고, 그 위에 얹는 계층이다.
핵심 목적:
- 작은 멤버 변화는 같은 episode로 이어 붙인다.
- 구조적 split/merge는 continuity source로 기록한다.
- long-memory는 `stable_cycles` 직접 승계가 아니라 약한 prior bonus로만 전달한다.
### 14.2 현재 저장소
- `gear_group_episodes`
- active/merged/expired episode 현재 상태
- `gear_group_episode_snapshots`
- cycle별 episode 스냅샷
- `gear_group_parent_candidate_snapshots`
- `episode_id`, `normalized_parent_name`,
`episode_prior_bonus`, `lineage_prior_bonus`, `label_prior_bonus`
- `gear_group_parent_resolution`
- `episode_id`, `continuity_source`, `continuity_score`, `prior_bonus_total`
### 14.3 continuity score
현재 continuity score는 아래다.
```text
continuity_score =
0.75 * member_jaccard
+ 0.25 * center_support
```
- `member_jaccard`
- 현재/이전 episode 멤버 MMSI Jaccard
- `center_support`
- 중심점 거리 `12nm` 이내일수록 높아지는 값
연결 후보 판단:
- continuity score `>= 0.45`
- 또는 overlap member가 있고 거리 조건을 만족하면 연결 후보로 인정
### 14.4 continuity source 규칙
- `NEW`
- 어떤 이전 episode와도 연결되지 않음
- `CONTINUED`
- 1:1 continuity
- `SPLIT_CONTINUE`
- 하나의 이전 episode가 여러 현재 cluster로 갈라졌고, 그중 주 가지
- `SPLIT_NEW`
- split로 새로 생성된 가지
- `MERGE_NEW`
- 2개 이상 active episode가 의미 있게 하나의 현재 cluster로 합쳐짐
- `DIRECT_PARENT_MATCH`
- 직접 모선 포함 그룹이 fresh resolution으로 정리되는 경우의 최종 resolution source
### 14.5 merge / split / expire
현재 구현 규칙:
1. split
- 가장 유사한 현재 cluster 1개는 기존 episode 유지
- 나머지는 새 episode 생성
- 새 episode에는 `split_from_episode_id` 저장
2. merge
- 2개 이상 previous episode가 같은 현재 cluster로 의미 있게 들어오면 새 episode 생성
- 이전 episode들은 `MERGED`, `merged_into_episode_id = 새 episode`
3. expire
- 최근 `6h` active episode가 현재 cycle 어떤 cluster와도 연결되지 않으면 `EXPIRED`
### 14.6 prior bonus 계층
현재 final score에는 signal score 뒤에 아래 prior bonus가 후가산된다.
- `episode_prior_bonus`
- 최근 동일 episode `24h`
- cap `0.10`
- `lineage_prior_bonus`
- 동일 정규화 이름 lineage `7d`
- cap `0.05`
- `label_prior_bonus`
- 동일 lineage 라벨 세션 `30d`
- cap `0.10`
- 총합 cap
- `0.20`
현재 후보가 이미 candidate set에 들어온 경우에만 적용하며, 과거 점수를 직접 carry하는 대신 약한 보너스로만 사용한다.
### 14.7 병합 후 후보 관성
질문 사례처럼 `A` episode 후보 `a`, `B` episode 후보 `b`가 있다가 병합 후 `b`가 더 적합해질 수 있다. 현재 구현은 병합 시 무조건 `A`를 유지하지 않고 새 episode를 생성해 `A/B` 둘 다의 history를 prior bonus 풀에서 재평가한다. 따라서 `b`는 완전 신규 후보처럼 0에서 시작하지 않지만, `A`의 과거 `stable_cycles`가 그대로 지배하지도 않는다.
## 15. 현재 episode 상태 흐름
```mermaid
stateDiagram-v2
[*] --> Active
Active --> Active: "CONTINUED / 소규모 멤버 변동"
Active --> Active: "SPLIT_CONTINUE"
Active --> Active: "MERGE_NEW로 새 episode 생성 후 연결"
Active --> Merged: "merged_into_episode_id 기록"
Active --> Expired: "최근 6h continuity 없음"
Merged --> [*]
Expired --> [*]
```
## 16. 결론
현재 구현은 아래를 모두 포함한다.
- safe watermark + overlap backfill 기반 incremental 안정화
- 짧은 이름 그룹 제거
- 거리 기반 sub-cluster와 `1h/1h-fb/6h` 스냅샷
- correlation + parent inference 분리
- coverage-aware score 보정
- stale inference 차단
- direct parent supplement
- v2 exclusion/label/tracking 저장소
- `episode_id` continuity와 prior bonus
남은 과제는 `episode` 자체보다, 이 continuity 계층을 read model과 시각화에서 더 설명력 있게 노출하는 것이다. 즉 다음 단계의 핵심은 episode 도입이 아니라, `episode lineage API`, calibration report, richer review analytics를 얹는 일이다.
## 17. 참고 코드
- `prediction/main.py`
- `prediction/time_bucket.py`
- `prediction/db/snpdb.py`
- `prediction/cache/vessel_store.py`
- `prediction/fleet_tracker.py`
- `prediction/algorithms/gear_name_rules.py`
- `prediction/algorithms/polygon_builder.py`
- `prediction/algorithms/gear_correlation.py`
- `prediction/algorithms/gear_parent_episode.py`
- `prediction/algorithms/gear_parent_inference.py`
- `backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java`
- `backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java`
- `database/migration/012_gear_parent_inference.sql`
- `database/migration/014_gear_parent_workflow_v2_phase1.sql`
- `database/migration/015_gear_parent_episode_tracking.sql`

파일 보기

@ -1,706 +0,0 @@
# Gear Parent Inference Workflow V2 Phase 1 Spec
## 목적
이 문서는 `GEAR-PARENT-INFERENCE-WORKFLOW-V2.md`의 첫 구현 단계를 바로 개발할 수 있는 수준으로 구체화한 명세다.
Phase 1 범위는 아래로 제한한다.
- DB 마이그레이션
- backend API 계약
- prediction exclusion/label read-write 지점
- 프론트의 최소 계약 변화
이번 단계에서는 실제 자동화/LLM 연결은 다루지 않는다.
## 범위 요약
### 포함
- 그룹 단위 후보 제외 `1/3/5일`
- 전역 후보 제외
- 정답 라벨 세션 `1/3/5일`
- 라벨 세션 기간 동안 cycle별 tracking 기록
- active exclusion을 parent inference 후보 생성에 반영
- exclusion/label 관리 API
### 제외
- 운영 `kcg` 스키마 반영
- 기존 `gear_correlation_scores` 산식 변경
- LLM reviewer
- label session의 anchor 기반 재매칭 보강
- UI 고도화 화면 전부
## 구현 원칙
1. 기존 자동 추론 저장소는 유지한다.
2. 새 사람 판단 데이터는 별도 테이블에 저장한다.
3. Phase 1에서는 `group_key + sub_cluster_id`를 세션 식별 기준으로 고정한다.
4. 기존 `CONFIRM/REJECT/RESET` API는 삭제하지 않지만, 새 UI에서는 사용하지 않는다.
5. 새 API와 prediction 로직은 `kcg_lab` 기준으로만 먼저 구현한다.
## DB 명세
## 1. `gear_parent_candidate_exclusions`
### 목적
- 그룹 단위 후보 제외와 전역 후보 제외를 단일 저장소에서 관리
### DDL 초안
```sql
CREATE TABLE IF NOT EXISTS kcg.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL,
group_key VARCHAR(100),
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL,
duration_days INT,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ,
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpce_scope CHECK (scope_type IN ('GROUP', 'GLOBAL')),
CONSTRAINT chk_gpce_reason CHECK (reason_type IN ('GROUP_WRONG_PARENT', 'GLOBAL_NOT_PARENT_TARGET')),
CONSTRAINT chk_gpce_group_scope CHECK (
(scope_type = 'GROUP' AND group_key IS NOT NULL AND sub_cluster_id IS NOT NULL AND duration_days IN (1, 3, 5) AND active_until IS NOT NULL)
OR
(scope_type = 'GLOBAL' AND duration_days IS NULL)
)
);
```
### 인덱스
```sql
CREATE INDEX IF NOT EXISTS idx_gpce_scope_mmsi_active
ON kcg.gear_parent_candidate_exclusions(scope_type, candidate_mmsi, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_group_active
ON kcg.gear_parent_candidate_exclusions(group_key, sub_cluster_id, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_active_until
ON kcg.gear_parent_candidate_exclusions(active_until);
```
### active 판정 규칙
active exclusion은 아래를 만족해야 한다.
```sql
released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW())
```
### 해석 규칙
- `GROUP`
- 특정 그룹에서만 해당 후보 제거
- `GLOBAL`
- 모든 그룹에서 해당 후보 제거
## 2. `gear_parent_label_sessions`
### 목적
- 정답 라벨 세션 저장
### DDL 초안
```sql
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpls_duration CHECK (duration_days IN (1, 3, 5)),
CONSTRAINT chk_gpls_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'CANCELLED'))
);
```
### 인덱스
```sql
CREATE INDEX IF NOT EXISTS idx_gpls_group_active
ON kcg.gear_parent_label_sessions(group_key, sub_cluster_id, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_mmsi_active
ON kcg.gear_parent_label_sessions(label_parent_mmsi, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_active_until
ON kcg.gear_parent_label_sessions(active_until);
```
### active 판정 규칙
```sql
status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW()
```
### 만료 처리 규칙
prediction 또는 backend batch에서 아래를 주기적으로 실행한다.
```sql
UPDATE kcg.gear_parent_label_sessions
SET status = 'EXPIRED', updated_at = NOW()
WHERE status = 'ACTIVE'
AND active_until <= NOW();
```
## 3. `gear_parent_label_tracking_cycles`
### 목적
- 활성 정답 라벨 세션 동안 cycle별 자동 추론 결과 저장
### DDL 초안
```sql
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gpltc_session_observed UNIQUE (label_session_id, observed_at)
);
```
### 인덱스
```sql
CREATE INDEX IF NOT EXISTS idx_gpltc_session_observed
ON kcg.gear_parent_label_tracking_cycles(label_session_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gpltc_top_candidate
ON kcg.gear_parent_label_tracking_cycles(top_candidate_mmsi);
```
## 4. 기존 `gear_group_parent_review_log` action 확장
### 새 action 목록
- `LABEL_PARENT`
- `EXCLUDE_GROUP`
- `EXCLUDE_GLOBAL`
- `RELEASE_EXCLUSION`
- `CANCEL_LABEL`
기존 action과 공존한다.
## migration 파일 제안
- `014_gear_parent_workflow_v2_phase1.sql`
구성 순서:
1. 새 테이블 3개 생성
2. 인덱스 생성
3. review log action 확장은 schema 변경 불필요
4. optional helper view 추가
## optional view 제안
### `vw_active_gear_parent_candidate_exclusions`
```sql
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_candidate_exclusions AS
SELECT *
FROM kcg.gear_parent_candidate_exclusions
WHERE released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW());
```
### `vw_active_gear_parent_label_sessions`
```sql
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_label_sessions AS
SELECT *
FROM kcg.gear_parent_label_sessions
WHERE status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW();
```
## backend API 명세
## 공통 정책
- 모든 write API는 `actor` 필수
- `group_key`, `sub_cluster_id`, `candidate_mmsi`, `selected_parent_mmsi`는 trim 후 저장
- 잘못된 기간은 `400 Bad Request`
- 중복 active session/exclusion 생성 시 `409 Conflict` 대신 동일 active row를 반환해도 됨
- Phase 1에서는 멱등성을 우선한다
## 1. 정답 라벨 세션 생성
### endpoint
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/label-sessions`
### request
```json
{
"selectedParentMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "수동 검토 확정"
}
```
### validation
- `selectedParentMmsi` 필수
- `durationDays in (1,3,5)`
- 동일 `groupKey + subClusterId`에 active label session이 이미 있으면 새 row 생성 금지
### response
```json
{
"groupKey": "58399",
"subClusterId": 0,
"action": "LABEL_PARENT",
"labelSession": {
"id": 12,
"status": "ACTIVE",
"labelParentMmsi": "412333326",
"labelParentName": "UWEIJINGYU51015",
"durationDays": 3,
"activeFrom": "2026-04-03T10:00:00+09:00",
"activeUntil": "2026-04-06T10:00:00+09:00",
"actor": "analyst-01",
"comment": "수동 검토 확정"
}
}
```
## 2. 그룹 후보 제외 생성
### endpoint
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions`
### request
```json
{
"candidateMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "이 그룹에서는 오답"
}
```
### 생성 규칙
- 내부적으로 `scopeType='GROUP'`
- `reasonType='GROUP_WRONG_PARENT'`
- 동일 `groupKey + subClusterId + candidateMmsi` active row가 있으면 재사용
### response
```json
{
"groupKey": "58399",
"subClusterId": 0,
"action": "EXCLUDE_GROUP",
"exclusion": {
"id": 33,
"scopeType": "GROUP",
"candidateMmsi": "412333326",
"durationDays": 3,
"activeFrom": "2026-04-03T10:00:00+09:00",
"activeUntil": "2026-04-06T10:00:00+09:00"
}
}
```
## 3. 전역 후보 제외 생성
### endpoint
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/global`
### request
```json
{
"candidateMmsi": "412333326",
"actor": "analyst-01",
"comment": "모든 어구에서 후보 제외"
}
```
### 생성 규칙
- `scopeType='GLOBAL'`
- `reasonType='GLOBAL_NOT_PARENT_TARGET'`
- `activeUntil = NULL`
- 동일 candidate active global exclusion이 있으면 재사용
## 4. exclusion 해제
### endpoint
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/{id}/release`
### request
```json
{
"actor": "analyst-01",
"comment": "해제"
}
```
### 동작
- `released_at = NOW()`
- `released_by = actor`
- `updated_at = NOW()`
## 5. label session 종료
### endpoint
`POST /api/vessel-analysis/parent-inference/label-sessions/{id}/cancel`
### request
```json
{
"actor": "analyst-01",
"comment": "조기 종료"
}
```
### 동작
- `status='CANCELLED'`
- `updated_at = NOW()`
## 6. active exclusion 조회
### endpoint
`GET /api/vessel-analysis/parent-inference/candidate-exclusions?status=ACTIVE&scopeType=GROUP|GLOBAL&candidateMmsi=...&groupKey=...`
### response 필드
- `id`
- `scopeType`
- `groupKey`
- `subClusterId`
- `candidateMmsi`
- `reasonType`
- `durationDays`
- `activeFrom`
- `activeUntil`
- `releasedAt`
- `actor`
- `comment`
- `isActive`
## 7. label session 목록
### endpoint
`GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE|EXPIRED|CANCELLED&groupKey=...`
### response 필드
- `id`
- `groupKey`
- `subClusterId`
- `labelParentMmsi`
- `labelParentName`
- `durationDays`
- `activeFrom`
- `activeUntil`
- `status`
- `actor`
- `comment`
- `latestTrackingSummary`
## 8. label tracking 상세
### endpoint
`GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking`
### response 필드
- `session`
- `count`
- `items[]`
- `observedAt`
- `autoStatus`
- `topCandidateMmsi`
- `topCandidateScore`
- `topCandidateMargin`
- `candidateCount`
- `labeledCandidatePresent`
- `labeledCandidateRank`
- `labeledCandidateScore`
- `labeledCandidatePreBonusScore`
- `matchedTop1`
- `matchedTop3`
## backend 구현 위치
### 새 DTO/Request 제안
- `GroupParentLabelSessionRequest`
- `GroupParentCandidateExclusionRequest`
- `ReleaseParentCandidateExclusionRequest`
- `CancelParentLabelSessionRequest`
- `ParentCandidateExclusionDto`
- `ParentLabelSessionDto`
- `ParentLabelTrackingCycleDto`
### service 추가 메서드 제안
- `createGroupCandidateExclusion(...)`
- `createGlobalCandidateExclusion(...)`
- `releaseCandidateExclusion(...)`
- `createLabelSession(...)`
- `cancelLabelSession(...)`
- `listCandidateExclusions(...)`
- `listLabelSessions(...)`
- `getLabelSessionTracking(...)`
## prediction 명세
## 적용 함수
중심 파일은 [prediction/algorithms/gear_parent_inference.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/gear_parent_inference.py)다.
### 새 load 함수
- `_load_active_candidate_exclusions(conn, group_keys)`
- `_load_active_label_sessions(conn, group_keys)`
### 반환 구조
`_load_active_candidate_exclusions`
```python
{
"global": {"412333326", "413000111"},
"group": {("58399", 0): {"412333326"}}
}
```
`_load_active_label_sessions`
```python
{
("58399", 0): {
"id": 12,
"label_parent_mmsi": "412333326",
"active_until": ...,
...
}
}
```
### 후보 pruning 순서
1. 기존 candidate union 생성
2. `GLOBAL` exclusion 제거
3. 해당 그룹의 `GROUP` exclusion 제거
4. 남은 후보만 scoring
### tracking row write 규칙
각 그룹 처리 후:
- active label session이 없으면 skip
- 있으면 현재 cycle 결과를 `gear_parent_label_tracking_cycles`에 upsert-like insert
필수 기록값:
- `label_session_id`
- `observed_at`
- `candidate_snapshot_observed_at`
- `auto_status`
- `top_candidate_mmsi`
- `top_candidate_score`
- `top_candidate_margin`
- `candidate_count`
- `labeled_candidate_present`
- `labeled_candidate_rank`
- `labeled_candidate_score`
- `labeled_candidate_pre_bonus_score`
- `matched_top1`
- `matched_top3`
### pre-bonus score 취득
현재 candidate evidence에 이미 아래가 있다.
- `evidence.scoreBreakdown.preBonusScore`
tracking row에서는 이 값을 직접 읽어 저장한다.
### resolution 처리 원칙
Phase 1에서는 다음을 적용한다.
- `LABEL_PARENT`, `EXCLUDE_GROUP`, `EXCLUDE_GLOBAL``gear_group_parent_resolution` 상태를 바꾸지 않는다.
- 자동 추론은 기존 상태 전이를 그대로 사용한다.
- legacy `MANUAL_CONFIRMED` 로직은 남겨두되, 새 UI에서는 호출하지 않는다.
## 프론트 최소 계약
## 기존 패널 액션 치환
현재:
- `확정`
- `24시간 제외`
Phase 1 새 기본 액션:
- `정답 라벨`
- `이 그룹에서 제외`
- `전체 후보 제외`
### 기간 선택 UI
- `정답 라벨`: `1일`, `3일`, `5일`
- `이 그룹에서 제외`: `1일`, `3일`, `5일`
- `전체 후보 제외`: 기간 없음
### 표시 정보
후보 card badge:
- `이 그룹 제외 중`
- `전체 후보 제외 중`
- `정답 라벨 대상`
그룹 summary box:
- active label session 여부
- active group exclusion count
## API 에러 규약
### 400
- 잘못된 duration
- 필수 필드 누락
- groupKey/subClusterId 없음
### 404
- 대상 group 없음
- exclusion/session id 없음
### 409
- active label session 중복 생성
단, Phase 1에서는 backend에서 충돌 시 기존 active row를 그대로 반환하는 방식도 허용한다.
## 테스트 기준
## DB
- GROUP exclusion active query가 정확히 동작
- GLOBAL exclusion active query가 정확히 동작
- label session 만료 시 `EXPIRED` 전환
## backend
- create/release exclusion API
- create/cancel label session API
- list APIs 필터 조건
## prediction
- active exclusion candidate pruning
- global/group exclusion 우선 적용
- label session tracking row 생성
- labeled candidate absent/present/top1/top3 케이스
## 수용 기준
1. 특정 그룹에서 후보 제외를 걸면 다음 cycle부터 그 그룹 후보 목록에서만 빠진다.
2. 전역 후보 제외를 걸면 모든 그룹 후보 목록에서 빠진다.
3. 정답 라벨 세션 생성 후 다음 cycle부터 tracking row가 쌓인다.
4. 자동 resolution은 계속 자동 상태를 유지한다.
5. 기존 manual override API를 쓰지 않아도 review/label/exclusion 흐름이 독립적으로 동작한다.
## Phase 1 이후 바로 이어질 일
### Phase 2
- 라벨 추적 대시보드
- exclusion 관리 화면
- 지표 요약 endpoint
- episode continuity read model 노출
- prior bonus calibration report
### Phase 3
- label session anchor 기반 재매칭
- group case/episode lineage API 확장
- calibration report
## 권장 구현 순서
1. `014_gear_parent_workflow_v2_phase1.sql`
2. backend DTO + controller/service
3. prediction active exclusion/load + tracking write
4. frontend 버튼 교체와 최소 조회 화면
이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.

파일 보기

@ -1,693 +0,0 @@
# Gear Parent Inference Workflow V2
## 문서 목적
이 문서는 lab 환경의 어구 모선 추적 워크플로우를 v1 운영 override 중심 구조에서,
`평가 데이터 축적 + 후보 제외 관리 + 기간형 정답 라벨 추적` 중심 구조로 재정의하는 설계서다.
대상 범위는 아래와 같다.
- `kcg_lab` 스키마
- `backend-lab` (`192.168.1.20:18083`)
- `prediction-lab` (`192.168.1.18:18091`)
- 로컬 프론트 `yarn dev:lab`
운영 `kcg` 스키마와 기존 데모 동작은 이번 설계 단계에서 변경하지 않는다.
현재 구현 기준으로는 v2 Phase 1 저장소/API가 이미 lab에 반영되어 있고, 그 위에 `015_gear_parent_episode_tracking.sql``prediction/algorithms/gear_parent_episode.py`를 통해 `episode continuity + prior bonus` 계층이 추가되었다. 이 문서는 여전히 워크플로우 설계서지만, 사람 판단 저장소와 자동 추론 저장소 분리 원칙은 현재 코드의 실제 기준이기도 하다.
## 배경
현재 v1은 자동 추론 결과와 사람 판단이 같은 저장소에 섞여 있다.
- `확정``gear_group_parent_resolution``MANUAL_CONFIRMED`로 덮어쓴다.
- `24시간 제외`는 특정 그룹에서 후보 1개를 24시간 숨긴다.
- 자동 추론은 계속 돌지만, 수동 판단이 최종 상태를 override한다.
이 구조는 단기 운용에는 편하지만, 아래 목적에는 맞지 않는다.
- 사람이 보면서 모델 가중치와 후보 생성 품질을 평가
- 정답/오답 사례를 데이터셋으로 축적
- 충분한 정확도 확보 후 자동화 또는 LLM 연결
따라서 v2에서는 `자동 추론`, `사람 라벨`, `후보 제외`를 분리한다.
## 핵심 목표
1. 자동 추론 상태는 계속 독립적으로 유지한다.
2. 사람 판단은 override가 아니라 별도 라벨/제외 데이터로 저장한다.
3. 그룹 단위 오답 라벨은 `1일 / 3일 / 5일` 기간형 후보 제외로 관리한다.
4. 전역 후보 제외는 모든 어구 그룹에서 동일 MMSI를 후보군에서 제거한다.
5. 정답 라벨은 `1일 / 3일 / 5일` 세션으로 만들고, 활성 기간 동안 자동 추론 결과를 별도 추적 로그로 남긴다.
6. 알고리즘은 DB exclusion/label 정보를 읽어 다음 cycle부터 바로 반영한다.
7. 향후 threshold 튜닝, 가산점 실험, LLM 연결 평가에 쓰일 수 있는 정량 지표를 만든다.
## 용어
- 자동 추론
- `gear_parent_inference`가 계산한 현재 cycle의 후보 점수와 추천 결과
- 그룹 제외
- 특정 `group_key + sub_cluster_id`에서 특정 후보 MMSI를 일정 기간 후보군에서 제거
- 전역 후보 제외
- 특정 MMSI를 모든 어구 그룹의 모선 후보군에서 제거
- 정답 라벨 세션
- 특정 어구 그룹에 대해 “이 MMSI가 정답 모선”이라고 사람이 지정하고, 일정 기간 자동 추론 결과를 추적하는 세션
- 라벨 추적
- 정답 라벨 세션 활성 기간 동안 자동 추론이 정답 후보를 어떻게 rank/score하는지 누적 저장하는 기록
## 현재 v1의 한계
### 1. `확정`이 평가 라벨이 아니라 운영 override다
- 현재 `CONFIRM`은 resolution을 `MANUAL_CONFIRMED`로 덮어쓴다.
- 이 경우 자동 추론의 실제 성능과 사람 판단이 섞여, 나중에 모델 정확도를 평가하기 어렵다.
### 2. `24시간 제외`는 기간과 범위가 너무 좁다
- 현재는 그룹 단위 24시간 mute만 가능하다.
- `1/3/5일`처럼 길이를 다르게 두고 비교할 수 없다.
- “이 MMSI는 아예 모선 후보 대상이 아니다”라는 전역 규칙을 넣을 수 없다.
### 3. 백데이터 축적 구조가 없다
- 현재는 review log는 남지만, “정답 후보가 cycle별로 몇 위였는지”, “점수가 어떻게 변했는지”, “후보군에 들어왔는지”를 체계적으로 저장하지 않는다.
### 4. 장기 세션에 대한 그룹 스코프가 약하다
- 현재 그룹 기준은 `group_key + sub_cluster_id`다.
- 기간형 라벨/제외를 도입하면 subcluster 재편성 리스크를 고려해야 한다.
## v2 설계 원칙
### 1. 자동 추론 저장소는 그대로 유지한다
아래 기존 저장소는 계속 자동 추론 전용으로 유지한다.
- `gear_group_parent_candidate_snapshots`
- `gear_group_parent_resolution`
- `gear_group_parent_review_log`
단, `review_log`의 의미는 “UI action audit”로 바꾸고, 더 이상 최종 라벨 저장소로 보지 않는다.
### 2. 사람 판단은 새 저장소로 분리한다
사람이 내린 판단은 아래 두 축으로 분리한다.
- 제외 축
- 이 그룹에서 제외
- 전체 후보 제외
- 정답 축
- 기간형 정답 라벨 세션
### 3. 제외는 후보 생성 이후의 gating layer로 둔다
전역 후보 제외는 raw correlation이나 원시 선박 분류를 지우지 않는다.
- `gear_correlation_scores`는 계속 쌓는다.
- exclusion은 parent inference candidate set에서만 hard filter로 적용한다.
이렇게 해야 원시 모델 출력과 사람 개입의 차이를 비교할 수 있다.
### 4. 라벨 세션 동안 자동 추론은 계속 돈다
정답 라벨 세션이 활성화되어도 자동 추론은 그대로 수행한다.
- UI의 기본 검토 대기에서는 숨길 수 있다.
- 하지만 prediction은 계속 candidate snapshot과 tracking record를 남긴다.
### 5. lab에서는 override보다 평가를 우선한다
v2 이후 lab에서 사람 버튼은 기본적으로 자동 resolution을 덮어쓰지 않는다.
- 운영 override가 필요해지면 추후 별도 action으로 분리한다.
- lab의 기본 목적은 평가 데이터 생성이다.
## 사용자 액션 재정의
### `정답 라벨`
의미:
- 해당 어구 그룹의 정답 모선으로 특정 MMSI를 지정
- `1일 / 3일 / 5일` 중 하나의 기간 동안 자동 추론 결과를 추적
동작:
1. `gear_parent_label_sessions`에 active session 생성
2. 다음 cycle부터 prediction이 이 그룹에 대한 추적 로그를 `gear_parent_label_tracking_cycles`에 누적
3. 기본 review queue에서는 해당 그룹을 숨기고, 별도 `라벨 추적` 목록으로 이동
4. 세션 종료 후에는 completed label dataset으로 남음
중요:
- 자동 resolution은 계속 자동 상태를 유지
- 점수에 수동 가산점/감점은 넣지 않음
### `이 그룹에서 제외`
의미:
- 해당 어구 그룹에서만 특정 후보 MMSI를 일정 기간 후보군에서 제외
기간:
- `1일`
- `3일`
- `5일`
동작:
1. `gear_parent_candidate_exclusions``scope_type='GROUP'` row 생성
2. 다음 cycle부터 해당 그룹의 candidate set에서 제거
3. 다른 그룹에는 영향 없음
4. 기간이 끝나면 자동으로 inactive 처리
용도:
- 이 후보는 이 어구 그룹의 모선이 아니라고 사람이 판단한 경우
- 단기/중기 관찰을 위해 일정 기간만 빼고 싶을 때
### `전체 후보 제외`
의미:
- 특정 MMSI는 모든 어구 그룹에서 모선 후보 대상이 아님
동작:
1. `gear_parent_candidate_exclusions``scope_type='GLOBAL'` row 생성
2. prediction candidate generation에서 모든 그룹에 대해 hard filter
3. 해제 전까지 계속 적용
초기 정책:
- 전역 후보 제외는 기본적으로 기간 없이 active 상태 유지
- 수동 `해제` 전까지 유지
용도:
- 패턴 분류상 선박으로 들어왔지만 실제 모선 후보가 아니라고 판단한 AIS
- 잘못된 유형의 신호가 반복적으로 후보군에 유입되는 경우
### `해제`
의미:
- 활성 그룹 제외, 전역 제외, 정답 라벨 세션을 조기 종료
동작:
- exclusion/session row에 `released_at`, `released_by` 또는 `status='CANCELLED'`를 기록
- 다음 cycle부터 알고리즘 적용 대상에서 빠짐
## DB 설계
### 1. `gear_parent_candidate_exclusions`
역할:
- 그룹 단위 제외와 전역 후보 제외를 모두 저장
- active list의 단일 진실원
권장 컬럼:
```sql
CREATE TABLE kcg_lab.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL, -- GROUP | GLOBAL
group_key VARCHAR(100), -- GROUP scope에서만 사용
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL, -- GROUP_WRONG_PARENT | GLOBAL_NOT_PARENT_TARGET
duration_days INT, -- GROUP scope는 1|3|5, GLOBAL은 NULL 허용
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ, -- GROUP scope는 필수, GLOBAL은 NULL 가능
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
권장 인덱스:
- `(scope_type, candidate_mmsi)`
- `(group_key, sub_cluster_id, active_from DESC)`
- `(released_at, active_until)`
조회 규칙:
active exclusion은 아래 조건으로 판단한다.
```sql
released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW())
```
### 2. `gear_parent_label_sessions`
역할:
- 특정 그룹에 대한 정답 라벨 세션 저장
권장 컬럼:
```sql
CREATE TABLE kcg_lab.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg_lab.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL, -- 1 | 3 | 5
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | EXPIRED | CANCELLED
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
설명:
- `anchor_*` 컬럼은 기간형 라벨 동안 subcluster가 재편성될 가능성에 대비한 보조 식별자다.
- phase 1에서는 실제 매칭은 `group_key + sub_cluster_id`를 기본으로 쓰고, anchor 정보는 저장만 한다.
### 3. `gear_parent_label_tracking_cycles`
역할:
- 활성 정답 라벨 세션 동안 cycle별 자동 추론 결과 저장
- 향후 정확도 지표의 기준 데이터
권장 컬럼:
```sql
CREATE TABLE kcg_lab.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg_lab.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
설명:
- 전체 후보 상세는 기존 `gear_group_parent_candidate_snapshots`를 그대로 사용한다.
- 여기에는 지표 계산에 직접 필요한 값만 요약 저장한다.
### 4. 기존 `gear_group_parent_review_log` 재사용
새 action 이름 예시:
- `LABEL_PARENT`
- `EXCLUDE_GROUP`
- `EXCLUDE_GLOBAL`
- `RELEASE_EXCLUSION`
- `CANCEL_LABEL`
즉, 별도 audit table를 또 만들기보다 기존 review log를 action log로 재사용한다.
## prediction 변경 설계
### 적용 지점
핵심 변경 지점은 [gear_parent_inference.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/gear_parent_inference.py), [fleet_tracker.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/fleet_tracker.py), [polygon_builder.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/polygon_builder.py) 중 `gear_parent_inference.py`가 중심이다.
### 1. active exclusion load
cycle 시작 시 아래 두 집합을 읽는다.
- `global_excluded_mmsis`
- `group_excluded_mmsis[(group_key, sub_cluster_id)]`
적용 위치:
- `_build_candidate_scores()`에서 candidate union 이후, 실제 scoring 전에 hard filter
규칙:
- GLOBAL exclusion은 모든 그룹에 적용
- GROUP exclusion은 해당 그룹에만 적용
- exclusion된 후보는 candidate snapshot에도 남기지 않음
중요:
- raw correlation score는 그대로 계산/저장
- exclusion은 parent inference candidate set에서만 적용
### 2. active label session load
cycle 시작 시 현재 unresolved/active gear group에 매칭되는 active label session을 읽는다.
phase 1 매칭 기준:
- `group_key`
- `sub_cluster_id`
phase 2 보강 기준:
- member overlap
- center distance
- anchor snapshot similarity
### 3. tracking cycle write
각 그룹의 자동 추론이 끝난 뒤, active label session이 있으면 `gear_parent_label_tracking_cycles`에 1 row를 쓴다.
기록 항목:
- 현재 auto top-1 후보
- auto top-1 점수/격차
- 후보 수
- 라벨 대상 MMSI가 현재 후보군에 존재하는지
- 존재한다면 rank/score/pre-bonus score
- top1/top3 일치 여부
### 4. resolution 저장 원칙 변경
v2 이후 lab에서는 아래를 원칙으로 한다.
- 자동 resolution은 자동 추론만 반영
- 사람 라벨은 resolution을 덮어쓰지 않음
즉 아래 legacy 상태는 새로 만들지 않는다.
- `MANUAL_CONFIRMED`
- `MANUAL_REJECT`
기존 row는 읽기 전용으로 남겨둘 수 있지만, v2 새 액션은 이 상태를 만들지 않는다.
### 5. exclusion이 적용된 경우의 상태 전이
후보 pruning 이후:
- 후보가 남으면 기존 자동 상태 전이 사용
- top1이 제외되어 후보가 비면 `NO_CANDIDATE`
- top1이 제외되어 top2가 승격되면 새 top1 기준으로 `AUTO_PROMOTED / REVIEW_REQUIRED / UNRESOLVED` 재판정
## backend API 설계
### 1. 정답 라벨 세션 생성
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/label-session`
request:
```json
{
"selectedParentMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "수동 확인"
}
```
response:
- 생성된 label session
- 현재 active label summary
### 2. 그룹 후보 제외 생성
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions`
request:
```json
{
"candidateMmsi": "412333326",
"scopeType": "GROUP",
"durationDays": 3,
"actor": "analyst-01",
"comment": "이 그룹에서는 오답"
}
```
### 3. 전역 후보 제외 생성
`POST /api/vessel-analysis/parent-inference/candidate-exclusions`
request:
```json
{
"candidateMmsi": "412333326",
"scopeType": "GLOBAL",
"actor": "analyst-01",
"comment": "모든 어구에서 모선 후보 대상 제외"
}
```
### 4. exclusion 해제
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/{id}/release`
### 5. label session 종료
`POST /api/vessel-analysis/parent-inference/label-sessions/{id}/cancel`
### 6. active exclusion 조회
`GET /api/vessel-analysis/parent-inference/candidate-exclusions?status=ACTIVE&scopeType=GLOBAL`
용도:
- “대상 선박이 어느 어구에서 제외중인지” 목록 관리
- 운영자 관리 화면
### 7. active label tracking 조회
`GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE`
`GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking`
### 8. 기존 review/detail API 확장
기존 `GroupParentInferenceDto`에 아래 요약을 추가한다.
- `activeLabelSession`
- `groupExclusionCount`
- `hasGlobalExclusionCandidate`
- `availableActions`
`ParentInferenceCandidateDto`에는 아래를 추가한다.
- `isExcludedInGroup`
- `isExcludedGlobally`
- `activeExclusionIds`
## 프론트엔드 설계
### 버튼 재구성
현재:
- `확정`
- `24시간 제외`
v2:
- `정답 라벨`
- `이 그룹에서 제외`
- `전체 후보 제외`
- `해제`
### 기간 선택
`정답 라벨``이 그룹에서 제외`는 버튼 클릭 후 아래 중 하나를 고르게 한다.
- `1일`
- `3일`
- `5일`
### 우측 모선 검토 패널 변화
- 후보 카드 상단 action area를 아래처럼 재구성
- `정답 라벨`
- `이 그룹에서 제외`
- `전체 후보 제외`
- 현재 후보에 active exclusion이 있으면 badge 표시
- `이 그룹 제외 중`
- `전체 후보 제외 중`
- 현재 그룹에 active label session이 있으면 summary box 표시
- 라벨 MMSI
- 남은 기간
- 최근 top1 일치율
### 새 목록
- `검토 대기`
- active label session이 없는 그룹만 기본 표시
- `라벨 추적`
- active label session이 있는 그룹
- `제외 대상 관리`
- active group/global exclusions
### 지도 표시 원칙
- active label session 그룹은 기본 review 색과 다른 badge 색을 사용
- globally excluded candidate는 raw correlation 패널에서는 참고로 보일 수 있지만, parent-review actionable candidate 목록에서는 숨김
## 지표 설계
정답 라벨 세션을 기반으로 최소 아래 지표를 계산한다.
### 핵심 지표
- top1 exact match rate
- top3 hit rate
- labeled candidate mean rank
- labeled candidate mean score
- time-to-first-top1
- session duration 동안 top1 일치 지속률
### 보정/실험 지표
- `412/413` 가산점 적용 전후 top1/top3 uplift
- pre-bonus score 대비 final score uplift
- global exclusion 적용 전후 오탐 감소량
- group exclusion 이후 대체 top1 품질 변화
### 운영 준비 지표
- auto-promoted 후보 중 라벨과 일치하는 비율
- high-confidence (`>= 0.72`) 구간 calibration
- label session 종료 시점 기준 `실무 참고 가능` threshold
## 단계별 구현 순서
### Phase 1. DB/Backend 계약
- 마이그레이션 추가
- `gear_parent_candidate_exclusions`
- `gear_parent_label_sessions`
- `gear_parent_label_tracking_cycles`
- backend DTO/API 추가
- 기존 `CONFIRM/REJECT/RESET`는 lab UI에서 숨기고 legacy로만 남김
### Phase 2. prediction 연동
- active exclusion load
- candidate pruning
- active label session load
- tracking cycle write
### Phase 3. 프론트 UI 전환
- 버튼 재구성
- 기간 선택 UI
- 라벨 추적 목록
- 제외 대상 관리 화면
### Phase 4. 지표와 리포트
- label session summary endpoint
- exclusion usage summary endpoint
- 실험 리포트 화면 또는 문서 산출
## 마이그레이션 전략
### 기존 v1 상태 처리
- `MANUAL_CONFIRMED`, `MANUAL_REJECT`는 새로 생성하지 않는다.
- 기존 row는 history로 남긴다.
- 필요하면 one-time migration으로 legacy `MANUAL_CONFIRMED``expired label session`으로 변환할 수 있다.
### 운영 영향 제한
- v2는 우선 `kcg_lab`에만 적용
- 운영 `kcg` 반영 전에는 사람이 직접 누르는 흐름과 tracking 지표가 충분히 쌓여야 함
## 수용 기준
### 기능 기준
- 그룹 제외가 다음 cycle부터 해당 그룹에서만 적용된다.
- 전역 후보 제외가 다음 cycle부터 모든 그룹에 적용된다.
- active exclusion list가 DB/API/UI에서 동일하게 보인다.
- 정답 라벨 세션 동안 cycle별 tracking row가 누락 없이 쌓인다.
### 데이터 기준
- label session당 최소 아래 값이 저장된다.
- top1 후보
- labeled candidate rank
- labeled candidate score
- candidate count
- observed_at
- exclusion row에는 scope, duration, actor, comment, active 기간이 남는다.
### 평가 기준
- `412/413` 가산점, threshold, exclusion 정책 변경 전후를 label session 데이터로 비교 가능해야 한다.
- 일정 기간 후 “자동 top1을 운영 참고값으로 써도 되는지”를 정량으로 판단할 수 있어야 한다.
## 열린 이슈
### 1. 그룹 스코프 안정성
`group_key + sub_cluster_id`가 며칠 동안 완전히 안정적인지 추가 확인이 필요하다.
현재 권장:
- phase 1은 기존 키를 그대로 사용
- 대신 `anchor_snapshot_time`, `anchor_center_point`, `anchor_member_mmsis`를 저장
### 2. 전역 후보 제외의 기간 정책
현재 제안은 “수동 해제 전까지 유지”다.
이유:
- 전역 제외는 단기 오답보다 “이 AIS는 parent candidate class가 아님”에 가깝다.
필요 시 추후 `1/3/5일` 옵션을 추가할 수 있다.
### 3. raw correlation UI 노출
전역 제외된 후보를 모델 패널에서 완전히 숨길지, `참고 제외` badge만 붙여 남길지는 사용성 확인이 필요하다.
현재 권장은 아래다.
- parent-review actionable 후보 목록에서는 숨김
- raw model/correlation 참고 패널에서는 badge와 함께 유지
## 권장 결론
v2의 핵심은 `사람 판단을 자동 추론의 override가 아니라 평가 데이터로 축적하는 것`이다.
따라서 다음 구현 우선순위는 아래가 맞다.
1. exclusion/label DB 추가
2. prediction candidate gating + tracking write
3. UI 액션 재정의
4. 지표 산출
그 다음 단계에서만 threshold 자동화, 가산점 조정, LLM 연결을 검토하는 것이 안전하다.

파일 보기

@ -4,190 +4,42 @@
## [Unreleased]
## [2026-04-04]
## [2026-03-23.2]
### 추가
- 어구 모선 추론(Gear Parent Inference) 시스템 — 다층 점수 모델 + Episode 연속성 + 자동 승격/검토 워크플로우
- Python: gear_parent_inference(1,428줄), gear_parent_episode(631줄), gear_name_rules
- Backend: ParentInferenceWorkflowController + GroupPolygonService 15개 API
- Frontend: ParentReviewPanel (모선 검토 대시보드) + React Flow 흐름도 시각화
- DB: migration 012~015 (후보 스냅샷, resolution, episode, 라벨 세션, 제외 관리)
- LoginPage DEV_LOGIN 환경변수 지원 (VITE_ENABLE_DEV_LOGIN)
- 중국어선감시 탭: CN 어선 + 어구 패턴 선박 필터링
- 중국어선감시 탭: 조업수역 ~Ⅳ 폴리곤 동시 표시
- 어구 그룹 수역 내/외 분류 (조업구역내 붉은색, 비허가 오렌지)
- 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구)
- 폴리곤 클릭·zoom 시 어구 행 자동 스크롤
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
- AI 분석 닫힘 시 위험도 마커 off
### 변경
- AI 분석 패널 위치 조정 (줌 버튼 간격 확보)
- 백엔드 vessel-analysis 조회 윈도우 1h → 2h
### 수정
- 모선 검토 대기 목록을 폴리곤 5분 폴링 데이터에서 파생하여 동기화 문제 해소
- 후보 소스 배지 축약 (CORRELATION→CORR, PREVIOUS_SELECTION→PREV 등)
- 1h 활성 판정을 parent_name 전체 합산 기준으로 변경
- vessel_store의 _last_bucket 타임존 오류 수정 (tz-naive KST 유지)
- time_bucket 수집 안전 윈도우 도입 — safe_bucket(12분 지연) + 3 bucket 백필
- 모선 추론 점수 가중치 조정 — 100%는 DIRECT_PARENT_MATCH 전용
- prediction proxy target을 nginx 경유로 변경
### 변경
- fleet_tracker: SQL 테이블명 qualified_table() 동적화 + is_trackable_parent_name 필터
- gear_correlation: 후보 track에 timestamp 필드 추가
- kcgdb: SQL 스키마 하드코딩 → qualified_table() 패턴 전환
## [2026-04-01]
### 추가
- 한국 현황 위성지도/ENC 토글 (gcnautical 벡터 타일 연동)
- ENC 스타일 설정 패널 (12개 심볼 토글 + 8개 색상 수정 + 초기화)
- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더)
- 리플레이 컨트롤러 A-B 구간 반복 기능
- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정)
- 리치 툴팁: 선박/어구 구분 + 모델 소속 컬러 표시 + 멤버 호버 강조
- 항공기 아이콘 줌레벨 기반 스케일 적용
- 심볼 크기 조정 패널 (선박/항공기 개별 0.5~2.0x)
- 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore)
- 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers)
- 선박 클릭 팝업 React 오버레이 전환 (ShipPopupOverlay + 드래그 지원)
- 선박 호버 툴팁 (이름, MMSI, 위치, 속도, 수신시각)
- 리플레이 집중 모드 — 주변 라이브 정보 숨김 토글
- 라벨 클러스터링 (줌 레벨별 그리드, z10+ 전체 표시)
- 어구 서브클러스터 독립 추적 (DB sub_cluster_id + Python group_key 고정)
- 서브클러스터별 독립 center trail (PathLayer 색상 구분)
### 변경
- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius
- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용
- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링
- 선단 폴리곤 색상: API 기본색 → 밝은 파스텔 팔레트 (바다 배경 대비)
- 멤버/연관 라벨: SDF outline → 검정 배경 블록 + fontScale.analysis 연동
- 모델 패널: 헤더→푸터 구조, 개별 확장/축소, 우클릭 툴팁 고정
### 수정
- 라이브 어구 현황에서 fallback 그룹 제외 (1h-fb resolution 분리)
- FLEET 타입 resolution='1h' 누락 수정
- DB resolution 컬럼 VARCHAR(4)→VARCHAR(8) 확장
- 어구 group_key 변동 → 이력 불연속 문제 해결 (sub_cluster_id 구조 전환)
- 한국 국적 선박(440/441) 어구 오탐 제외
- Backend correlation API 서브클러스터 중복 제거 (DISTINCT ON CTE)
- 리플레이 종료/탭 off 시 deck.gl 레이어 + gearReplayStore 완전 초기화
### 기타
- DB 마이그레이션: sub_cluster_id + resolution 컬럼, 인덱스 교체
## [2026-03-31]
### 추가
- 어구 연관성 프론트엔드 표시 — Backend API + 모델별 팝업/토글 UI
- 어구 그룹 멀티모델 폴리곤 오버레이 + 토글 패널
- 어구 리플레이 deck.gl + Zustand 전환 (TripsLayer GPU 트레일 + rAF 10fps)
- 리플레이 IconLayer (SVG ship-triangle/gear-diamond, COG 회전)
- 재생 컨트롤 확장: 항적/이름 토글, 일치율 드롭다운(50~90%), 개별 on/off
- 트랙 API 전체 모델 확장 — 모델별 점수 + 24h 트랙 반환
- 모델별 폴리곤 중심 경로 + 현재 중심점 렌더링 (모델명 라벨)
### 변경
- FleetClusterLayer 2357줄 → 10파일 리팩토링 (오케스트레이터 + 서브컴포넌트)
- 리플레이 렌더링: MapLibre GeoJSON → deck.gl (React re-render 20회/초 → 0회)
- 연관 선박 위치: 트랙 보간 우선, live 선박 fallback
- 토글 패널 위치 고정 + 모델 카드 가로 스크롤
- enabledVessels 토글 시 폴리곤 + 중심경로 동시 재계산
### 수정
- 이름 기반 레이어 항상 ON 고정 + 최상위 z-index (다른 모델에 가려지지 않음)
- Prediction API DB 접속 context manager 누락
- Prediction proxy rewrite 경로 불일치 (/api/prediction → /api)
- nginx prediction API 라우팅 추가
### 기타
- CI/CD: Prediction 자동 배포 제거 → 수동 배포 전환
- zustand 5.0.12, @deck.gl/geo-layers 9.2.11 의존성 추가
## [2026-03-26]
### 추가
- AI 해양분석 채팅: Ollama Qwen3 14B 로컬 LLM 기반 해양 상황 분석 챗봇
- Ollama Docker 컨테이너 (redis-211, CPU 64코어, 64GB RAM 할당)
- Python SSE 채팅 엔드포인트 + Redis 컨텍스트 캐싱 + 계정별 대화 히스토리
- 도메인 지식 시스템 + 사전 쿼리 패턴 매칭 + LLM Tool Calling (5개 도구)
- 채팅 UI: SSE 스트리밍 + 응답 타이머 + thinking 접기 + 확장/축소
### 변경
- AiChatPanel: 클라이언트 프롬프트 → Python 서버사이드 압축 프롬프트
- nginx SSE 프록시 + kcgdb 분석 요약 쿼리 추가
## [2026-03-25]
### 추가
- 현장분석 항적 미니맵: 선박 클릭 시 72시간 항적 + 현재 위치 표시
- 현장분석 위험도 점수 기준 섹션
- Python 경량 분석: 파이프라인 미통과 412* 선박 간이 위험도
- 폴리곤 히스토리 애니메이션: 12시간 타임라인 기반 재생 (중심 궤적 + 어구별 궤적 + 가상 아이콘)
- 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭) + 신호없음 구간 표시
- nginx /api/gtts 프록시 (Google TTS CORS 우회)
### 변경
- 위험도 용어 통일: HIGH→WATCH, MEDIUM→MONITOR, LOW→NORMAL (전체)
- 현장분석/보고서: 클라이언트 fallback 제거 → Python 분석 결과 전용
- 보고서: Python riskCounts 실데이터 기반 위험 평가
- 현장분석: AI 파이프라인 ON/OFF 실상태 + BD-09 실측 탐지 수
- 보고서 버튼: 현장분석 내부로 이동, 수역별 허가업종 동적 참조
- 분석 파이프라인: MIN_TRAJ_POINTS 100→20 (16척→684척 분석 대상 확대)
- risk.py: SOG 급변 count 위험도 점수 반영
- spoofing.py: BD09 오프셋 중국 MMSI(412*) 예외 처리
- VesselAnalysisService: Caffeine 캐시 → 인메모리 캐시 + 증분 갱신
- polygon_builder: STALE_SEC 3600→21600 (6시간, 어구 갭 P75 커버)
### 수정
- fishing_pattern.py: 마지막 조업 세그먼트 누락 버그 수정
- 히스토리 모드 시 현재 강조 레이어 (deck.gl + MapLibre) 정상 숨김
## [2026-03-24]
### 추가
- 선단/어구그룹 폴리곤 서버사이드 생성: Shapely convex hull + buffer → PostGIS 저장 (DB migration 009, 5분 APPEND, 7일 보존)
- Backend API: groups 목록/상세/히스토리 + vessel-analysis stats 필드 (집계 통계 서버 제공)
- 가상 선박 마커: ship-triangle 아이콘 (COG 회전 + zoom interpolate) + 어구 겹침 다중 선택 팝업
- AI 분석 통계 서버사이드 전환: dark/spoofing/risk/cluster/gear 집계를 Backend에서 계산
- 경비함정 작전가이드 모달: 3탭 + 임검침로 해상 루트 시각화 + 중국어 TTS
- 중국어선 감시현황 보고서 자동 생성 모달
- 웹폰트 내장: @fontsource-variable Inter/Noto Sans KR/Fira Code + 폰트 상수
- LayerPanel 공통 트리 구조: 재귀 렌더러 + 부모 캐스케이드 ON/OFF
- 위험시설/해외시설 SVG IconLayer 전환 (12 SVG 함수)
- 이란 리플레이 실데이터 전환: Events CRUD + 시점 조회 API + 피격 선박 27척
- 지도 글꼴 크기 커스텀: 4그룹 슬라이더 (0.5~2.0x)
- useGroupPolygons 훅 (5분 폴링) + useIranData dataSource 분기
### 변경
- FleetClusterLayer: 클라이언트 convexHull 제거 → API GeoJSON 렌더링 + 패널 아코디언 전환
- AI 분석 패널: 클라이언트 stats 계산 제거 → 서버 제공 (14K+ 순회 useMemo 삭제)
- 프론트 어구그룹 탐지 Python 이관 + 어구 클릭 시 좌측 패널 섹션 자동 전환
- 전체 font-family 통일 (CSS 55곳 + deck.gl 30곳) + 이란 시설물 사막 대비 고채도 팔레트
- feature/korea-layers-enhancement 브랜치 기능 → develop 아키텍처에 이식
### 수정
- 불법어선 탭 복원 + 어구 줌인 최대 제한 (maxZoom: 12)
- FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거)
## [2026-03-23]
### 추가
- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설
- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드)
- 이란 시설 deck.gl SVG 전환: 26개 고유 SVG 아이콘 (IconLayer + TextLayer)
- 중동 에너지/위험시설 데이터 84개 (meEnergyHazardFacilities)
- 환적탐지 Python 이관: 서버사이드 그리드 공간인덱스 O(n log n)
- 중국어선감시 탭: CN 어선 + 어구 패턴 필터링, 조업수역 폴리곤
- AI 해양분석 챗 UI (AiChatPanel, placeholder)
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴)
- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보)
- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기
### 변경
- App.tsx 분해: IranDashboard + KoreaDashboard 추출 (771줄→163줄)
- useStaticDeckLayers 분할: 레이어별 서브훅 4개
- DOM Marker → deck.gl 전환 + 줌 스케일 연동
- 한국 군사/정부/NK 아이콘: emoji → SVG IconLayer (19종)
- 선박 카테고리/국적 토글: MapLibre GPU-side filter 표현식
- LIVE 모드 currentTime 의존성 분리 → 매초 재계산 제거
- 시설 라벨 SDF 테두리 적용 (fontSettings.sdf + outlineWidth)
- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함)
- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x)
### 수정
- LIVE 모드 렌더링 최적화: useMonitor 1초 setInterval 제거
- 특정어업수역 실제 폴리곤 좌표 적용 (EPSG:3857→WGS84 변환)
- DB migration 008 적용 (AI 분석 API 500 에러 해결)
- 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정
- prediction 증분 수집 버그 수정
- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소)
- deck.gl 레이어 호버 시 pointer 커서 표시
- prediction 증분 수집 버그 수정 (vessel_store.py)
## [2026-03-20]

1
frontend/.gitignore vendored
파일 보기

@ -1 +0,0 @@
.claude/worktrees/

파일 보기

@ -1,13 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/kcg.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gear-parent-flow-viewer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/gearParentFlowMain.tsx"></script>
</body>
</html>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -11,17 +11,12 @@
},
"dependencies": {
"@deck.gl/core": "^9.2.11",
"@deck.gl/geo-layers": "^9.2.11",
"@deck.gl/layers": "^9.2.11",
"@deck.gl/mapbox": "^9.2.11",
"@fontsource-variable/fira-code": "^5.2.7",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/noto-sans-kr": "^5.2.10",
"@tailwindcss/vite": "^4.2.1",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4",
"@types/leaflet": "^1.9.21",
"@xyflow/react": "^12.10.2",
"date-fns": "^4.1.0",
"hls.js": "^1.6.15",
"i18next": "^25.8.18",
@ -34,8 +29,7 @@
"react-map-gl": "^8.1.0",
"recharts": "^3.8.0",
"satellite.js": "^6.0.2",
"tailwindcss": "^4.2.1",
"zustand": "^5.0.12"
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

파일 보기

@ -20,7 +20,7 @@
font-size: 14px;
font-weight: 700;
letter-spacing: 1.5px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
/* Map mode toggle */
@ -47,7 +47,7 @@
color: var(--kcg-dim);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.map-mode-btn:hover {
@ -79,7 +79,7 @@
.count-item {
font-size: 11px;
font-weight: 700;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 3px;
@ -103,7 +103,7 @@
color: var(--kcg-text-secondary);
font-size: 10px;
font-weight: 700;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 4px;
@ -126,7 +126,7 @@
font-weight: 700;
letter-spacing: 1px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.status-dot {
@ -240,7 +240,7 @@
letter-spacing: 1.5px;
color: var(--text-secondary);
margin-bottom: 8px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.layer-items {
@ -299,7 +299,7 @@
align-items: center;
padding: 2px 8px;
font-size: 10px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.stat-cat {
@ -318,7 +318,7 @@
letter-spacing: 1px;
color: var(--text-secondary);
padding: 2px 8px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
/* Layer tree */
@ -434,7 +434,7 @@
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
white-space: nowrap;
}
.dash-tab:hover {
@ -465,7 +465,7 @@
text-transform: uppercase;
color: var(--text-secondary);
border-bottom: 1px solid var(--kcg-border);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.event-list {
@ -504,7 +504,7 @@
white-space: nowrap;
height: fit-content;
margin-top: 2px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.event-content {
@ -521,7 +521,7 @@
font-size: 10px;
color: var(--text-secondary);
margin-top: 1px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.event-desc {
@ -559,7 +559,7 @@
background: var(--kcg-danger);
padding: 2px 6px;
border-radius: 2px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
animation: flash-pulse 1.5s ease-in-out infinite;
}
@ -573,7 +573,7 @@
font-weight: 700;
color: var(--text-secondary);
letter-spacing: 0.5px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.breaking-news-list {
@ -616,7 +616,7 @@
.breaking-news-time {
font-size: 9px;
color: var(--kcg-dim);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.breaking-news-headline {
@ -658,13 +658,13 @@
font-weight: 700;
letter-spacing: 1.5px;
color: var(--kcg-danger);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.osint-count {
margin-left: auto;
font-size: 10px;
color: var(--kcg-muted);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.osint-loading {
margin-left: auto;
@ -722,7 +722,7 @@
font-size: 9px;
color: var(--kcg-dim);
white-space: nowrap;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.osint-item-title {
font-size: 11px;
@ -757,17 +757,10 @@
.area-ship-header {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--kcg-hover);
flex-wrap: nowrap;
}
.area-ship-header:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.korean-highlight-toggle {
@ -775,7 +768,7 @@
padding: 1px 8px;
font-size: 9px;
font-weight: 700;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
border-radius: 3px;
border: 1px solid var(--kcg-border);
background: transparent;
@ -800,11 +793,7 @@
font-weight: 700;
letter-spacing: 0.5px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
font-family: 'Courier New', monospace;
}
.area-ship-total {
@ -812,9 +801,7 @@
font-size: 16px;
font-weight: 700;
color: #fb923c;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
white-space: nowrap;
flex-shrink: 0;
font-family: 'Courier New', monospace;
}
.kr-ship-header {
@ -833,7 +820,7 @@
font-weight: 700;
letter-spacing: 0.5px;
color: var(--text-primary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-total {
@ -841,7 +828,7 @@
font-size: 14px;
font-weight: 700;
color: var(--kcg-accent);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-ship-breakdown {
@ -872,7 +859,7 @@
flex: 1;
font-size: 10px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-count {
@ -880,7 +867,7 @@
font-weight: 700;
color: var(--text-primary);
text-align: right;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-ship-list {
@ -894,7 +881,7 @@
gap: 6px;
padding: 2px 0;
font-size: 9px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-ship-name {
@ -1039,7 +1026,7 @@
.korea-stat-num {
font-size: 20px;
font-weight: 800;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.korea-stat-card.total .korea-stat-num { color: var(--kcg-accent); }
.korea-stat-card.anchored .korea-stat-num { color: var(--kcg-danger); }
@ -1062,7 +1049,7 @@
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.korea-ship-section {
flex: 1;
@ -1080,7 +1067,7 @@
color: var(--kcg-muted);
border-bottom: 1px solid var(--kcg-border);
flex-shrink: 0;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.korea-ship-section-count {
color: var(--kcg-accent);
@ -1144,7 +1131,7 @@
.korea-ship-card-speed {
margin-left: auto;
color: var(--kcg-muted);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.korea-ship-card-dest {
font-size: 9px;
@ -1174,7 +1161,7 @@
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.chart-grid {
@ -1190,7 +1177,7 @@
color: var(--text-secondary);
margin-bottom: 0;
padding-left: 4px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.chart-demo-label {
@ -1206,7 +1193,7 @@
.ship-popup-body {
width: 300px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
font-size: 11px;
line-height: 1.4;
}
@ -1360,12 +1347,12 @@
}
.popup-body {
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.popup-body-sm {
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
font-size: 11px;
min-width: 220px;
}
@ -1444,10 +1431,6 @@
gap: 3px;
margin-left: 8px;
}
.data-source-toggle {
border-left: 1px solid rgba(255,255,255,0.15);
padding-left: 8px;
}
.speed-btn {
padding: 3px 8px;
@ -1459,7 +1442,7 @@
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.speed-btn:hover {
@ -1498,7 +1481,7 @@
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.range-btn:hover {
@ -1550,7 +1533,7 @@
font-weight: 700;
letter-spacing: 1px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.range-picker input[type="datetime-local"] {
@ -1560,7 +1543,7 @@
color: var(--text-primary);
padding: 4px 8px;
font-size: 11px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
outline: none;
transition: border-color 0.15s;
}
@ -1584,7 +1567,7 @@
background: rgba(34, 197, 94, 0.15);
color: var(--kcg-success);
cursor: pointer;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
transition: all 0.15s;
white-space: nowrap;
}
@ -1604,7 +1587,7 @@
font-size: 10px;
color: var(--text-secondary);
margin-bottom: 2px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.timeline-current {
@ -1740,7 +1723,7 @@
font-size: 9px;
font-weight: 700;
color: var(--kcg-dim);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
flex-shrink: 0;
}
@ -1787,7 +1770,7 @@
color: var(--text-secondary);
margin-bottom: 2px;
padding-left: 8px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
/* Aircraft, Satellite, Ship & Oil Tooltips - override Leaflet */
@ -1801,7 +1784,7 @@
border-radius: 3px !important;
padding: 2px 6px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace !important;
font-family: 'Courier New', monospace !important;
}
.aircraft-tooltip::before,
@ -1875,7 +1858,7 @@
border-radius: 3px !important;
padding: 2px 6px !important;
box-shadow: 0 2px 12px rgba(255, 0, 0, 0.4) !important;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace !important;
font-family: 'Courier New', monospace !important;
}
.impact-tooltip::before {
@ -1937,7 +1920,7 @@
pointer-events: none;
font-weight: 600;
font-size: 11px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
text-shadow: var(--kcg-map-label-shadow);
background: var(--kcg-glass);
border: 1px solid var(--kcg-border);
@ -1955,7 +1938,7 @@
color: var(--kcg-event-impact);
font-weight: 700;
font-size: 10px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
text-shadow: var(--kcg-map-impact-shadow);
background: rgba(40, 0, 0, 0.85);
border: 1px solid var(--kcg-event-impact);
@ -2008,34 +1991,17 @@
color: var(--kcg-muted) !important;
}
#dashboard-header-slot {
display: flex;
flex: 1;
gap: 6px;
align-items: center;
justify-content: center;
position: relative;
}
#dashboard-header-slot > .mode-toggle-left {
position: absolute;
left: 0;
}
/* ======================== */
/* Mode Toggle (LIVE/REPLAY) */
/* ======================== */
.mode-toggle {
display: flex;
flex-wrap: nowrap;
gap: 4px;
background: var(--kcg-subtle);
border: 1px solid var(--kcg-border);
border-radius: 6px;
padding: 3px;
overflow-x: auto;
scrollbar-width: none;
}
.mode-toggle::-webkit-scrollbar { display: none; }
.mode-btn {
display: flex;
@ -2051,7 +2017,7 @@
color: var(--kcg-dim);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.mode-btn:hover {
@ -2113,14 +2079,14 @@
font-weight: 700;
letter-spacing: 2px;
color: var(--kcg-danger);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.live-clock {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
padding: 4px 12px;
background: var(--kcg-hover);
@ -2139,7 +2105,7 @@
font-weight: 700;
letter-spacing: 1.5px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.history-presets {
@ -2157,7 +2123,7 @@
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.history-btn:hover {
@ -2182,7 +2148,7 @@
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
border: none;
background: transparent;
color: var(--text-secondary);
@ -2348,7 +2314,7 @@
max-height: 80vh;
overflow: auto;
color: var(--kcg-text, #e2e8f0);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: monospace;
font-size: 13px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
}
@ -2475,42 +2441,3 @@
text-align: center;
opacity: 0.5;
}
/* ── FontScalePanel ──────────────────────── */
.font-scale-section { margin-top: 4px; }
.font-scale-toggle {
width: 100%;
padding: 4px 8px;
font-size: 10px;
color: var(--kcg-text);
background: transparent;
border: none;
border-top: 1px solid rgba(255,255,255,0.08);
cursor: pointer;
display: flex;
justify-content: space-between;
}
.font-scale-toggle:hover { background: rgba(255,255,255,0.05); }
.font-scale-sliders { padding: 4px 8px; }
.font-scale-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 9px;
color: var(--kcg-dim);
margin-bottom: 3px;
}
.font-scale-row label { width: 60px; flex-shrink: 0; }
.font-scale-row input[type="range"] { flex: 1; height: 12px; accent-color: var(--kcg-primary, #3b82f6); }
.font-scale-row span { width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
.font-scale-reset {
width: 100%;
padding: 2px;
font-size: 9px;
color: var(--kcg-dim);
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 3px;
cursor: pointer;
margin-top: 4px;
}

파일 보기

@ -1,18 +1,48 @@
import { useState, useEffect, useCallback } from 'react';
import { useLocalStorage, useLocalStorageSet } from './hooks/useLocalStorage';
import { ReplayMap } from './components/iran/ReplayMap';
import type { FlyToTarget } from './components/iran/ReplayMap';
import { GlobeMap } from './components/iran/GlobeMap';
import { SatelliteMap } from './components/iran/SatelliteMap';
import { KoreaMap } from './components/korea/KoreaMap';
import { TimelineSlider } from './components/common/TimelineSlider';
import { ReplayControls } from './components/common/ReplayControls';
import { LiveControls } from './components/common/LiveControls';
import { SensorChart } from './components/common/SensorChart';
import { EventLog } from './components/common/EventLog';
import { LayerPanel } from './components/common/LayerPanel';
import { useReplay } from './hooks/useReplay';
import { useMonitor } from './hooks/useMonitor';
import { useLocalStorage } from './hooks/useLocalStorage';
import type { AppMode } from './types';
import { useIranData } from './hooks/useIranData';
import { useKoreaData } from './hooks/useKoreaData';
import { useKoreaFilters } from './hooks/useKoreaFilters';
import { useVesselAnalysis } from './hooks/useVesselAnalysis';
import type { GeoEvent, LayerVisibility, AppMode } from './types';
import { useTheme } from './hooks/useTheme';
import { useAuth } from './hooks/useAuth';
import { useTranslation } from 'react-i18next';
import LoginPage from './components/auth/LoginPage';
import CollectorMonitor from './components/common/CollectorMonitor';
import { SharedFilterProvider } from './contexts/SharedFilterContext';
import { FontScaleProvider } from './contexts/FontScaleContext';
import { SymbolScaleProvider } from './contexts/SymbolScaleContext';
import { IranDashboard } from './components/iran/IranDashboard';
import { KoreaDashboard } from './components/korea/KoreaDashboard';
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';
import { MILITARY_BASES } from './data/militaryBases';
import { GOV_BUILDINGS } from './data/govBuildings';
import { KOREA_WIND_FARMS } from './data/windFarms';
import { NK_LAUNCH_SITES } from './data/nkLaunchSites';
import { NK_MISSILE_EVENTS } from './data/nkMissileEvents';
import { COAST_GUARD_FACILITIES } from './services/coastGuard';
import { NAV_WARNINGS } from './services/navWarning';
import { PIRACY_ZONES } from './services/piracy';
import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
import { HAZARD_FACILITIES } from './data/hazardFacilities';
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities';
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities';
import './App.css';
function App() {
@ -43,18 +73,136 @@ interface AuthenticatedAppProps {
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const [appMode, setAppMode] = useState<AppMode>('live');
const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite');
const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran');
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
const [layers, setLayers] = useLocalStorage<LayerVisibility>('iranLayers', {
events: true,
aircraft: true,
satellites: true,
ships: true,
koreanShips: true,
airports: true,
sensorCharts: false,
oilFacilities: true,
meFacilities: true,
militaryOnly: false,
overseasUS: false,
overseasIsrael: false,
overseasIran: false,
overseasUAE: false,
overseasSaudi: false,
overseasOman: false,
overseasQatar: false,
overseasKuwait: false,
overseasIraq: false,
overseasBahrain: false,
});
// Korea tab layer visibility (lifted from KoreaMap)
const [koreaLayers, setKoreaLayers] = useLocalStorage<Record<string, boolean>>('koreaLayers', {
ships: true,
aircraft: true,
satellites: true,
infra: true,
cables: true,
cctv: true,
airports: true,
coastGuard: true,
navWarning: true,
osint: true,
eez: true,
piracy: true,
windFarm: true,
ports: true,
militaryBases: true,
govBuildings: true,
nkLaunch: true,
nkMissile: true,
cnFishing: false,
militaryOnly: false,
overseasChina: false,
overseasJapan: false,
cnPower: false,
cnMilitary: false,
jpPower: false,
jpMilitary: false,
hazardPetrochemical: false,
hazardLng: false,
hazardOilTank: false,
hazardPort: false,
energyNuclear: false,
energyThermal: false,
industryShipyard: false,
industryWastewater: false,
industryHeavy: false,
});
const toggleKoreaLayer = useCallback((key: string) => {
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, [setKoreaLayers]);
// Category filter state (shared across tabs)
const [hiddenAcCategories, setHiddenAcCategories] = useLocalStorageSet('hiddenAcCategories', new Set());
const [hiddenShipCategories, setHiddenShipCategories] = useLocalStorageSet('hiddenShipCategories', new Set());
const toggleAcCategory = useCallback((cat: string) => {
setHiddenAcCategories(prev => {
const next = new Set(prev);
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
return next;
});
}, [setHiddenAcCategories]);
const toggleShipCategory = useCallback((cat: string) => {
setHiddenShipCategories(prev => {
const next = new Set(prev);
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
return next;
});
}, [setHiddenShipCategories]);
// Nationality filter state (Korea tab)
const [hiddenNationalities, setHiddenNationalities] = useLocalStorageSet('hiddenNationalities', new Set());
const toggleNationality = useCallback((nat: string) => {
setHiddenNationalities(prev => {
const next = new Set(prev);
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
return next;
});
}, [setHiddenNationalities]);
// Fishing vessel nationality filter state
const [hiddenFishingNats, setHiddenFishingNats] = useLocalStorageSet('hiddenFishingNats', new Set());
const toggleFishingNat = useCallback((nat: string) => {
setHiddenFishingNats(prev => {
const next = new Set(prev);
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
return next;
});
}, [setHiddenFishingNats]);
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
// 1시간마다 전체 데이터 강제 리프레시
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
const HOUR_MS = 3600_000;
const interval = setInterval(() => setRefreshKey(k => k + 1), HOUR_MS);
const interval = setInterval(() => {
setRefreshKey(k => k + 1);
}, HOUR_MS);
return () => clearInterval(interval);
}, []);
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);
const [seismicMarker, setSeismicMarker] = useState<{ lat: number; lng: number; magnitude: number; place: string } | null>(null);
const replay = useReplay();
const monitor = useMonitor();
const { theme, toggleTheme } = useTheme();
@ -64,115 +212,618 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
}, [i18n]);
const isLive = appMode === 'live';
const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime;
// Unified time values based on current mode
const currentTime = appMode === 'live' ? monitor.state.currentTime : replay.state.currentTime;
// Iran data hook
const iranData = useIranData({
appMode,
currentTime,
isLive,
hiddenAcCategories,
hiddenShipCategories,
refreshKey,
dashboardTab,
});
// Korea data hook
const koreaData = useKoreaData({
currentTime,
isLive,
hiddenAcCategories,
hiddenShipCategories,
hiddenNationalities,
refreshKey,
});
// Vessel analysis (Python prediction 결과)
const vesselAnalysis = useVesselAnalysis(dashboardTab === 'korea');
// Korea filters hook
const koreaFiltersResult = useKoreaFilters(
koreaData.ships,
koreaData.visibleShips,
currentTime,
vesselAnalysis.analysisMap,
koreaLayers.cnFishing,
);
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, [setLayers]);
// Handle event card click from timeline: fly to location on map
const handleEventFlyTo = useCallback((event: GeoEvent) => {
setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 });
}, []);
return (
<FontScaleProvider>
<SymbolScaleProvider>
<SharedFilterProvider>
<div className={`app ${isLive ? 'app-live' : ''}`}>
<header className="app-header">
{/* Dashboard Tabs */}
<div className="dash-tabs">
<button
type="button"
className={`dash-tab ${dashboardTab === 'iran' ? 'active' : ''}`}
onClick={() => setDashboardTab('iran')}
>
<span className="dash-tab-flag">🇮🇷</span>
{t('tabs.iran')}
</button>
<button
type="button"
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
onClick={() => setDashboardTab('korea')}
>
<span className="dash-tab-flag">🇰🇷</span>
{t('tabs.korea')}
</button>
</div>
{/* 탭별 모드/필터 영역 — 각 대시보드가 headerSlot으로 렌더링 */}
<div id="dashboard-header-slot" />
<div className="header-info">
<div id="dashboard-counts-slot" />
<div className="header-toggles">
<button
type="button"
className="header-toggle-btn"
onClick={() => setShowCollectorMonitor(v => !v)}
title="수집기 모니터링"
>
MON
</button>
<a
className="header-toggle-btn"
href="/gear-parent-flow.html"
target="_blank"
rel="noreferrer"
title="어구 모선 추적 흐름도"
>
FLOW
</a>
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
{i18n.language === 'ko' ? 'KO' : 'EN'}
</button>
<button type="button" className="header-toggle-btn" onClick={toggleTheme} title="Theme">
{theme === 'dark' ? '🌙' : '☀️'}
</button>
</div>
<div className="header-status">
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
</div>
{user && (
<div className="header-user">
{user.picture && (
<img src={user.picture} alt="" className="header-user-avatar" referrerPolicy="no-referrer" />
)}
<span className="header-user-name">{user.name}</span>
<button type="button" className="header-toggle-btn" onClick={onLogout} title="Logout">
</button>
</div>
)}
</div>
</header>
<div className={`app ${isLive ? 'app-live' : ''}`}>
<header className="app-header">
{/* Dashboard Tabs (replaces title) */}
<div className="dash-tabs">
<button
type="button"
className={`dash-tab ${dashboardTab === 'iran' ? 'active' : ''}`}
onClick={() => setDashboardTab('iran')}
>
<span className="dash-tab-flag">🇮🇷</span>
{t('tabs.iran')}
</button>
<button
type="button"
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
onClick={() => setDashboardTab('korea')}
>
<span className="dash-tab-flag">🇰🇷</span>
{t('tabs.korea')}
</button>
</div>
{/* Mode Toggle */}
{dashboardTab === 'iran' && (
<IranDashboard
currentTime={currentTime}
isLive={isLive}
refreshKey={refreshKey}
replay={replay}
monitor={monitor}
timeZone={timeZone}
onTimeZoneChange={setTimeZone}
appMode={appMode}
onAppModeChange={setAppMode}
/>
<div className="mode-toggle">
<div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
<span className="text-[13px]"></span>
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
</div>
<button
type="button"
className={`mode-btn ${appMode === 'live' ? 'active live' : ''}`}
onClick={() => setAppMode('live')}
>
<span className="mode-dot-icon" />
{t('mode.live')}
</button>
<button
type="button"
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
onClick={() => setAppMode('replay')}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
{t('mode.replay')}
</button>
</div>
)}
{dashboardTab === 'korea' && (
<KoreaDashboard
currentTime={currentTime}
isLive={isLive}
refreshKey={refreshKey}
replay={replay}
monitor={monitor}
timeZone={timeZone}
onTimeZoneChange={setTimeZone}
/>
<div className="mode-toggle">
<button
type="button"
className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)}
title={t('filters.illegalFishing')}
>
<span className="text-[11px]">🚫🐟</span>
{t('filters.illegalFishing')}
</button>
<button
type="button"
className={`mode-btn ${koreaFiltersResult.filters.illegalTransship ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('illegalTransship', !koreaFiltersResult.filters.illegalTransship)}
title={t('filters.illegalTransship')}
>
<span className="text-[11px]"></span>
{t('filters.illegalTransship')}
</button>
<button
type="button"
className={`mode-btn ${koreaFiltersResult.filters.darkVessel ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('darkVessel', !koreaFiltersResult.filters.darkVessel)}
title={t('filters.darkVessel')}
>
<span className="text-[11px]">👻</span>
{t('filters.darkVessel')}
</button>
<button
type="button"
className={`mode-btn ${koreaFiltersResult.filters.cableWatch ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('cableWatch', !koreaFiltersResult.filters.cableWatch)}
title={t('filters.cableWatch')}
>
<span className="text-[11px]">🔌</span>
{t('filters.cableWatch')}
</button>
<button
type="button"
className={`mode-btn ${koreaFiltersResult.filters.dokdoWatch ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('dokdoWatch', !koreaFiltersResult.filters.dokdoWatch)}
title={t('filters.dokdoWatch')}
>
<span className="text-[11px]">🏝</span>
{t('filters.dokdoWatch')}
</button>
<button
type="button"
className={`mode-btn ${koreaFiltersResult.filters.ferryWatch ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('ferryWatch', !koreaFiltersResult.filters.ferryWatch)}
title={t('filters.ferryWatch')}
>
<span className="text-[11px]">🚢</span>
{t('filters.ferryWatch')}
</button>
<button
type="button"
className={`mode-btn ${koreaLayers.cnFishing ? 'active live' : ''}`}
onClick={() => toggleKoreaLayer('cnFishing')}
title="중국어선감시"
>
<span className="text-[11px]">🎣</span>
</button>
<button
type="button"
className={`mode-btn ${showFieldAnalysis ? 'active' : ''}`}
onClick={() => setShowFieldAnalysis(v => !v)}
title="현장분석"
>
<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>
)}
{showCollectorMonitor && (
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
{dashboardTab === 'iran' && (
<div className="map-mode-toggle">
<button
type="button"
className={`map-mode-btn ${mapMode === 'flat' ? 'active' : ''}`}
onClick={() => setMapMode('flat')}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
{t('mapMode.flat')}
</button>
<button
type="button"
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
onClick={() => setMapMode('globe')}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
{t('mapMode.globe')}
</button>
<button
type="button"
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
onClick={() => setMapMode('satellite')}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
{t('mapMode.satellite')}
</button>
</div>
)}
</div>
</SharedFilterProvider>
</SymbolScaleProvider>
</FontScaleProvider>
<div className="header-info">
<div className="header-counts">
<span className="count-item ac-count">{dashboardTab === 'iran' ? iranData.aircraft.length : koreaData.aircraft.length} AC</span>
<span className="count-item mil-count">{dashboardTab === 'iran' ? iranData.militaryCount : koreaData.militaryCount} MIL</span>
<span className="count-item ship-count">{dashboardTab === 'iran' ? iranData.ships.length : koreaData.ships.length} SHIP</span>
<span className="count-item sat-count">{dashboardTab === 'iran' ? iranData.satPositions.length : koreaData.satPositions.length} SAT</span>
</div>
<div className="header-toggles">
<button
type="button"
className="header-toggle-btn"
onClick={() => setShowCollectorMonitor(v => !v)}
title="수집기 모니터링"
>
MON
</button>
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
{i18n.language === 'ko' ? 'KO' : 'EN'}
</button>
<button type="button" className="header-toggle-btn" onClick={toggleTheme} title="Theme">
{theme === 'dark' ? '🌙' : '☀️'}
</button>
</div>
<div className="header-status">
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
</div>
{user && (
<div className="header-user">
{user.picture && (
<img src={user.picture} alt="" className="header-user-avatar" referrerPolicy="no-referrer" />
)}
<span className="header-user-name">{user.name}</span>
<button type="button" className="header-toggle-btn" onClick={onLogout} title="Logout">
</button>
</div>
)}
</div>
</header>
{/*
IRAN DASHBOARD
*/}
{dashboardTab === 'iran' && (
<>
<main className="app-main">
<div className="map-panel">
{mapMode === 'flat' ? (
<ReplayMap
key="map-iran"
events={isLive ? [] : iranData.mergedEvents}
currentTime={currentTime}
aircraft={iranData.visibleAircraft}
satellites={iranData.satPositions}
ships={iranData.visibleShips}
layers={layers}
flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)}
hoveredShipMmsi={hoveredShipMmsi}
focusShipMmsi={focusShipMmsi}
onFocusShipClear={() => setFocusShipMmsi(null)}
seismicMarker={seismicMarker}
/>
) : mapMode === 'globe' ? (
<GlobeMap
events={isLive ? [] : iranData.mergedEvents}
currentTime={currentTime}
aircraft={iranData.visibleAircraft}
satellites={iranData.satPositions}
ships={iranData.visibleShips}
layers={layers}
/>
) : (
<SatelliteMap
events={isLive ? [] : iranData.mergedEvents}
currentTime={currentTime}
aircraft={iranData.visibleAircraft}
satellites={iranData.satPositions}
ships={iranData.visibleShips}
layers={layers}
hoveredShipMmsi={hoveredShipMmsi}
focusShipMmsi={focusShipMmsi}
onFocusShipClear={() => setFocusShipMmsi(null)}
flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)}
seismicMarker={seismicMarker}
/>
)}
<div className="map-overlay-left">
<LayerPanel
layers={layers as unknown as Record<string, boolean>}
onToggle={toggleLayer as (key: string) => void}
aircraftByCategory={iranData.aircraftByCategory}
aircraftTotal={iranData.aircraft.length}
shipsByMtCategory={iranData.shipsByCategory}
shipTotal={iranData.ships.length}
satelliteCount={iranData.satPositions.length}
extraLayers={[
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.koreanShips.length },
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b' },
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' },
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
]}
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}
onShipCategoryToggle={toggleShipCategory}
/>
</div>
</div>
<aside className="side-panel">
<EventLog
events={isLive ? [] : iranData.mergedEvents}
currentTime={currentTime}
totalShipCount={iranData.ships.length}
koreanShips={iranData.koreanShips}
koreanShipsByCategory={iranData.koreanShipsByCategory}
osintFeed={iranData.osintFeed}
isLive={isLive}
dashboardTab={dashboardTab}
onTabChange={setDashboardTab}
ships={iranData.ships}
highlightKoreanShips={layers.koreanShips}
onToggleHighlightKorean={() => setLayers(prev => ({ ...prev, koreanShips: !prev.koreanShips }))}
onShipHover={setHoveredShipMmsi}
onShipClick={(mmsi) => {
setFocusShipMmsi(mmsi);
const ship = iranData.ships.find(s => s.mmsi === mmsi);
if (ship) setFlyToTarget({ lat: ship.lat, lng: ship.lng, zoom: 10 });
}}
/>
</aside>
</main>
{layers.sensorCharts && (
<section className="charts-panel">
<SensorChart
seismicData={iranData.seismicData}
pressureData={iranData.pressureData}
currentTime={currentTime}
historyMinutes={monitor.state.historyMinutes}
onSeismicClick={(lat, lng, magnitude, place) => {
setFlyToTarget({ lat, lng, zoom: 8 });
setSeismicMarker({ lat, lng, magnitude, place });
}}
/>
</section>
)}
<footer className="app-footer">
{isLive ? (
<LiveControls
currentTime={monitor.state.currentTime}
historyMinutes={monitor.state.historyMinutes}
onHistoryChange={monitor.setHistoryMinutes}
aircraftCount={iranData.aircraft.length}
shipCount={iranData.ships.length}
satelliteCount={iranData.satPositions.length}
timeZone={timeZone}
onTimeZoneChange={setTimeZone}
/>
) : (
<>
<ReplayControls
isPlaying={replay.state.isPlaying}
speed={replay.state.speed}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
onPlay={replay.play}
onPause={replay.pause}
onReset={replay.reset}
onSpeedChange={replay.setSpeed}
onRangeChange={replay.setRange}
/>
<TimelineSlider
currentTime={replay.state.currentTime}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
events={iranData.mergedEvents}
onSeek={replay.seek}
onEventFlyTo={handleEventFlyTo}
/>
</>
)}
</footer>
</>
)}
{/*
KOREA DASHBOARD
*/}
{dashboardTab === 'korea' && (
<>
<main className="app-main">
<div className="map-panel">
{showFieldAnalysis && (
<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}
allShips={koreaData.visibleShips}
aircraft={koreaData.visibleAircraft}
satellites={koreaData.satPositions}
layers={koreaLayers}
osintFeed={koreaData.osintFeed}
currentTime={currentTime}
koreaFilters={koreaFiltersResult.filters}
transshipSuspects={koreaFiltersResult.transshipSuspects}
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
vesselAnalysis={vesselAnalysis}
externalFlyTo={flyToTarget}
onExternalFlyToDone={() => setFlyToTarget(null)}
opsRoute={opsRoute}
/>
<div className="map-overlay-left">
<LayerPanel
layers={koreaLayers}
onToggle={toggleKoreaLayer}
aircraftByCategory={koreaData.aircraftByCategory}
aircraftTotal={koreaData.aircraft.length}
shipsByMtCategory={koreaData.shipsByCategory}
shipTotal={koreaData.ships.length}
satelliteCount={koreaData.satPositions.length}
extraLayers={[
// 해양안전
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length, group: '해양안전' },
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length, group: '해양안전' },
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length, group: '해양안전' },
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length, group: '해양안전' },
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1, group: '해양안전' },
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length, group: '해양안전' },
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length, group: '해양안전' },
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length, group: '해양안전' },
// 국가기관망
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length, group: '국가기관망' },
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length, group: '국가기관망' },
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length, group: '국가기관망' },
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length, group: '국가기관망' },
// 에너지/발전시설
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length, group: '에너지/발전시설' },
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length, group: '에너지/발전시설' },
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' },
// 위험시설
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' },
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' },
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' },
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' },
// 산업공정/제조시설
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' },
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' },
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' },
]}
overseasItems={[
{
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length,
children: [
{ key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length },
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length },
],
},
{
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length,
children: [
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
],
},
]}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
onShipCategoryToggle={toggleShipCategory}
shipsByNationality={koreaData.shipsByNationality}
hiddenNationalities={hiddenNationalities}
onNationalityToggle={toggleNationality}
fishingByNationality={koreaData.fishingByNationality}
hiddenFishingNats={hiddenFishingNats}
onFishingNatToggle={toggleFishingNat}
/>
</div>
</div>
<aside className="side-panel">
<EventLog
events={isLive ? [] : iranData.events}
currentTime={currentTime}
totalShipCount={koreaData.ships.length}
koreanShips={koreaData.koreaKoreanShips}
koreanShipsByCategory={koreaData.shipsByCategory}
chineseShips={koreaData.koreaChineseShips}
osintFeed={koreaData.osintFeed}
isLive={isLive}
dashboardTab={dashboardTab}
onTabChange={setDashboardTab}
ships={koreaData.ships}
/>
</aside>
</main>
<footer className="app-footer">
{isLive ? (
<LiveControls
currentTime={monitor.state.currentTime}
historyMinutes={monitor.state.historyMinutes}
onHistoryChange={monitor.setHistoryMinutes}
aircraftCount={koreaData.aircraft.length}
shipCount={koreaData.ships.length}
satelliteCount={koreaData.satPositions.length}
timeZone={timeZone}
onTimeZoneChange={setTimeZone}
/>
) : (
<>
<ReplayControls
isPlaying={replay.state.isPlaying}
speed={replay.state.speed}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
onPlay={replay.play}
onPause={replay.pause}
onReset={replay.reset}
onSpeedChange={replay.setSpeed}
onRangeChange={replay.setRange}
/>
<TimelineSlider
currentTime={replay.state.currentTime}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
events={iranData.mergedEvents}
onSeek={replay.seek}
onEventFlyTo={handleEventFlyTo}
/>
</>
)}
</footer>
</>
)}
{showCollectorMonitor && (
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
)}
</div>
);
}

파일 보기

@ -8,7 +8,6 @@ interface LoginPageProps {
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const IS_DEV = import.meta.env.DEV;
const DEV_LOGIN_ENABLED = IS_DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
function useGoogleIdentity(onCredential: (credential: string) => void) {
const btnRef = useRef<HTMLDivElement>(null);
@ -137,7 +136,7 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
)}
{/* Dev Login */}
{DEV_LOGIN_ENABLED && (
{IS_DEV && (
<>
<div className="w-full border-t border-kcg-border pt-4 text-center">
<span className="text-xs font-mono tracking-wider text-kcg-dim">

파일 보기

@ -260,7 +260,6 @@ const TYPE_LABELS: Record<GeoEvent['type'], string> = {
alert: 'ALERT',
impact: 'IMPACT',
osint: 'OSINT',
sea_attack: 'SEA ATK',
};
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
@ -271,7 +270,6 @@ const TYPE_COLORS: Record<GeoEvent['type'], string> = {
alert: 'var(--kcg-event-alert)',
impact: 'var(--kcg-event-impact)',
osint: 'var(--kcg-event-osint)',
sea_attack: '#0ea5e9',
};
// MarineTraffic-style ship type classification
@ -636,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>
);
})}
@ -691,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>
);
})}
@ -786,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);
@ -835,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>();
@ -883,9 +881,14 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
</>
)}
{/* AI 해양분석 챗 — 한국 탭 전용 */}
{dashboardTab === 'korea' && (
<AiChatPanel />
{/* 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>
);

파일 보기

@ -20,7 +20,6 @@ const TYPE_COLORS: Record<string, string> = {
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
const TYPE_KEYS: Record<string, string> = {

파일 보기

@ -1,45 +0,0 @@
import { useState } from 'react';
import { useFontScale } from '../../hooks/useFontScale';
import type { FontScaleConfig } from '../../contexts/fontScaleState';
const LABELS: Record<keyof FontScaleConfig, string> = {
facility: '시설 라벨',
ship: '선박 이름',
analysis: '분석 라벨',
area: '지역/국가명',
};
export function FontScalePanel() {
const { fontScale, setFontScale } = useFontScale();
const [open, setOpen] = useState(false);
const update = (key: keyof FontScaleConfig, val: number) => {
setFontScale({ ...fontScale, [key]: Math.round(val * 10) / 10 });
};
return (
<div className="font-scale-section">
<button type="button" className="font-scale-toggle" onClick={() => setOpen(!open)}>
<span>Aa </span>
<span>{open ? '▼' : '▶'}</span>
</button>
{open && (
<div className="font-scale-sliders">
{(Object.keys(LABELS) as (keyof FontScaleConfig)[]).map(key => (
<div key={key} className="font-scale-row">
<label>{LABELS[key]}</label>
<input type="range" min={0.5} max={2.0} step={0.1}
value={fontScale[key]}
onChange={e => update(key, parseFloat(e.target.value))} />
<span>{fontScale[key].toFixed(1)}</span>
</div>
))}
<button type="button" className="font-scale-reset"
onClick={() => setFontScale({ facility: 1.0, ship: 1.0, analysis: 1.0, area: 1.0 })}>
</button>
</div>
)}
</div>
);
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,8 +1,6 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export type DataSource = 'dummy' | 'api';
interface Props {
isPlaying: boolean;
speed: number;
@ -13,8 +11,6 @@ interface Props {
onReset: () => void;
onSpeedChange: (speed: number) => void;
onRangeChange: (start: number, end: number) => void;
dataSource?: DataSource;
onDataSourceChange?: (ds: DataSource) => void;
}
const SPEEDS = [1, 2, 4, 8, 16];
@ -55,8 +51,6 @@ export function ReplayControls({
onReset,
onSpeedChange,
onRangeChange,
dataSource,
onDataSourceChange,
}: Props) {
const { t } = useTranslation();
const [showPicker, setShowPicker] = useState(false);
@ -116,24 +110,6 @@ export function ReplayControls({
))}
</div>
{/* Data source toggle */}
{dataSource && onDataSourceChange && (
<div className="speed-controls data-source-toggle">
<button
className={`speed-btn ${dataSource === 'dummy' ? 'active' : ''}`}
onClick={() => onDataSourceChange('dummy')}
>
</button>
<button
className={`speed-btn ${dataSource === 'api' ? 'active' : ''}`}
onClick={() => onDataSourceChange('api')}
>
API
</button>
</div>
)}
{/* Spacer */}
<div className="flex-1" />

파일 보기

@ -1,43 +0,0 @@
import { useState } from 'react';
import { useSymbolScale } from '../../hooks/useSymbolScale';
import type { SymbolScaleConfig } from '../../contexts/symbolScaleState';
const LABELS: Record<keyof SymbolScaleConfig, string> = {
ship: '선박 심볼',
aircraft: '항공기 심볼',
};
export function SymbolScalePanel() {
const { symbolScale, setSymbolScale } = useSymbolScale();
const [open, setOpen] = useState(false);
const update = (key: keyof SymbolScaleConfig, val: number) => {
setSymbolScale({ ...symbolScale, [key]: Math.round(val * 10) / 10 });
};
return (
<div className="font-scale-section">
<button type="button" className="font-scale-toggle" onClick={() => setOpen(!open)}>
<span>&#9670; </span>
<span>{open ? '▼' : '▶'}</span>
</button>
{open && (
<div className="font-scale-sliders">
{(Object.keys(LABELS) as (keyof SymbolScaleConfig)[]).map(key => (
<div key={key} className="font-scale-row">
<label>{LABELS[key]}</label>
<input type="range" min={0.5} max={2.0} step={0.1}
value={symbolScale[key]}
onChange={e => update(key, parseFloat(e.target.value))} />
<span>{symbolScale[key].toFixed(1)}</span>
</div>
))}
<button type="button" className="font-scale-reset"
onClick={() => setSymbolScale({ ship: 1.0, aircraft: 1.0 })}>
</button>
</div>
)}
</div>
);
}

파일 보기

@ -21,7 +21,6 @@ const TYPE_COLORS: Record<string, string> = {
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
const TYPE_I18N_KEYS: Record<string, string> = {

파일 보기

@ -1,152 +0,0 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { middleEastAirports } from '../../data/airports';
import type { Airport } from '../../data/airports';
// ─── US base detection ───────────────────────────────────────────────────────
const US_BASE_ICAOS = new Set([
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
]);
function isUSBase(airport: Airport): boolean {
return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao);
}
// ─── Deduplication ───────────────────────────────────────────────────────────
const TYPE_PRIORITY: Record<Airport['type'], number> = {
military: 3, large: 2, medium: 1, small: 0,
};
function deduplicateByArea(airports: Airport[]): Airport[] {
const sorted = [...airports].sort((a, b) => {
const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0);
const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0);
return pb - pa;
});
const kept: Airport[] = [];
for (const ap of sorted) {
const tooClose = kept.some(
k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8,
);
if (!tooClose) kept.push(ap);
}
return kept;
}
const DEDUPLICATED_AIRPORTS = deduplicateByArea(middleEastAirports);
export const IRAN_AIRPORT_COUNT = DEDUPLICATED_AIRPORTS.length;
// ─── Colors ──────────────────────────────────────────────────────────────────
function getAirportColor(airport: Airport): string {
if (isUSBase(airport)) return '#60a5fa';
if (airport.type === 'military') return '#f87171';
return '#38bdf8';
}
// ─── SVG generators ──────────────────────────────────────────────────────────
function militaryPlaneSvg(color: string): string {
return `<path d="M12 8.5 L12.8 11 L16.5 12.5 L16.5 13.5 L12.8 12.8 L12.5 15 L13.5 15.8 L13.5 16.5 L12 16 L10.5 16.5 L10.5 15.8 L11.5 15 L11.2 12.8 L7.5 13.5 L7.5 12.5 L11.2 11 L12 8.5Z" fill="${color}"/>`;
}
function civilPlaneSvg(color: string): string {
return `<path d="M12 8c-.5 0-.8.3-.8.5v2.7l-4.2 2v1l4.2-1.1v2.5l-1.1.8v.8L12 16l1.9.5v-.8l-1.1-.8v-2.5l4.2 1.1v-1l-4.2-2V8.5c0-.2-.3-.5-.8-.5z" fill="${color}"/>`;
}
function airportSvg(airport: Airport): string {
const color = getAirportColor(airport);
const isMil = airport.type === 'military';
const size = airport.type === 'large' ? 32 : airport.type === 'small' ? 24 : 28;
const plane = isMil ? militaryPlaneSvg(color) : civilPlaneSvg(color);
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="2"/>
${plane}
</svg>`;
}
// ─── Module-level icon cache ─────────────────────────────────────────────────
const airportIconCache = new Map<string, string>();
function getAirportIconUrl(airport: Airport): string {
const isUS = isUSBase(airport);
const key = `${airport.type}-${isUS ? 'us' : 'std'}`;
if (!airportIconCache.has(key)) {
airportIconCache.set(key, svgToDataUri(airportSvg(airport)));
}
return airportIconCache.get(key)!;
}
function getIconDimension(airport: Airport): number {
return airport.type === 'large' ? 32 : airport.type === 'small' ? 24 : 28;
}
// ─── Label color ─────────────────────────────────────────────────────────────
function getAirportLabelColor(airport: Airport): [number, number, number, number] {
if (isUSBase(airport)) return [59, 130, 246, 255];
if (airport.type === 'military') return [239, 68, 68, 255];
return [245, 158, 11, 255];
}
// ─── Public interface ────────────────────────────────────────────────────────
export interface AirportLayerConfig {
visible: boolean;
sc: number;
onPick: (ap: Airport) => void;
}
export function createIranAirportLayers(config: AirportLayerConfig): Layer[] {
if (!config.visible) return [];
const { sc, onPick } = config;
return [
new IconLayer<Airport>({
id: 'iran-airport-icon',
data: DEDUPLICATED_AIRPORTS,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => {
const dim = getIconDimension(d);
return {
url: getAirportIconUrl(d),
width: dim,
height: dim,
anchorX: dim / 2,
anchorY: dim / 2,
};
},
getSize: (d) => (d.type === 'large' ? 16 : d.type === 'small' ? 12 : 14) * sc,
updateTriggers: { getSize: [sc] },
pickable: true,
onClick: (info: PickingInfo<Airport>) => {
if (info.object) onPick(info.object);
return true;
},
}),
new TextLayer<Airport>({
id: 'iran-airport-label',
data: DEDUPLICATED_AIRPORTS,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo ?? d.name,
getSize: 9 * sc,
updateTriggers: { getSize: [sc] },
getColor: (d) => getAirportLabelColor(d),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontWeight: 700,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
];
}

파일 보기

@ -0,0 +1,133 @@
import { memo, useMemo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import type { Airport } from '../../data/airports';
const US_BASE_ICAOS = new Set([
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
]);
function isUSBase(airport: Airport): boolean {
return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao);
}
const FLAG_EMOJI: Record<string, string> = {
IR: '\u{1F1EE}\u{1F1F7}', IQ: '\u{1F1EE}\u{1F1F6}', IL: '\u{1F1EE}\u{1F1F1}',
AE: '\u{1F1E6}\u{1F1EA}', SA: '\u{1F1F8}\u{1F1E6}', QA: '\u{1F1F6}\u{1F1E6}',
BH: '\u{1F1E7}\u{1F1ED}', KW: '\u{1F1F0}\u{1F1FC}', OM: '\u{1F1F4}\u{1F1F2}',
TR: '\u{1F1F9}\u{1F1F7}', JO: '\u{1F1EF}\u{1F1F4}', LB: '\u{1F1F1}\u{1F1E7}',
SY: '\u{1F1F8}\u{1F1FE}', EG: '\u{1F1EA}\u{1F1EC}', PK: '\u{1F1F5}\u{1F1F0}',
DJ: '\u{1F1E9}\u{1F1EF}', YE: '\u{1F1FE}\u{1F1EA}', SO: '\u{1F1F8}\u{1F1F4}',
};
const TYPE_LABELS: Record<Airport['type'], string> = {
large: 'International Airport', medium: 'Airport',
small: 'Regional Airport', military: 'Military Airbase',
};
interface Props { airports: Airport[]; }
const TYPE_PRIORITY: Record<Airport['type'], number> = {
military: 3, large: 2, medium: 1, small: 0,
};
// Keep one airport per area (~50km radius). Priority: military/US base > large > medium > small.
function deduplicateByArea(airports: Airport[]): Airport[] {
const sorted = [...airports].sort((a, b) => {
const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0);
const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0);
return pb - pa;
});
const kept: Airport[] = [];
for (const ap of sorted) {
const tooClose = kept.some(
k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8,
);
if (!tooClose) kept.push(ap);
}
return kept;
}
export const AirportLayer = memo(function AirportLayer({ airports }: Props) {
const filtered = useMemo(() => deduplicateByArea(airports), [airports]);
return (
<>
{filtered.map(ap => (
<AirportMarker key={ap.icao} airport={ap} />
))}
</>
);
});
function AirportMarker({ airport }: { airport: Airport }) {
const [showPopup, setShowPopup] = useState(false);
const isMil = airport.type === 'military';
const isUS = isUSBase(airport);
const color = isUS ? '#3b82f6' : isMil ? '#ef4444' : '#f59e0b';
const size = airport.type === 'large' ? 18 : airport.type === 'small' ? 12 : 16;
const flag = FLAG_EMOJI[airport.country] || '';
// Single circle with airplane inside (plane shifted down to center in circle)
const plane = isMil
? <path d="M12 8.5 L12.8 11 L16.5 12.5 L16.5 13.5 L12.8 12.8 L12.5 15 L13.5 15.8 L13.5 16.5 L12 16 L10.5 16.5 L10.5 15.8 L11.5 15 L11.2 12.8 L7.5 13.5 L7.5 12.5 L11.2 11 L12 8.5Z"
fill={color} />
: <path d="M12 8c-.5 0-.8.3-.8.5v2.7l-4.2 2v1l4.2-1.1v2.5l-1.1.8v.8L12 16l1.9.5v-.8l-1.1-.8v-2.5l4.2 1.1v-1l-4.2-2V8.5c0-.2-.3-.5-.8-.5z"
fill={color} />;
const icon = (
<svg viewBox="0 0 24 24" width={size} height={size}>
<circle cx={12} cy={12} r={10} fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth={2} />
{plane}
</svg>
);
return (
<>
<Marker longitude={airport.lng} latitude={airport.lat} anchor="center">
<div style={{ width: size, height: size, cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
{icon}
</div>
</Marker>
{showPopup && (
<Popup longitude={airport.lng} latitude={airport.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" offset={[0, -size / 2]} maxWidth="280px" className="gl-popup">
<div className="popup-body" style={{ minWidth: 220 }}>
<div className="popup-header" style={{ background: isUS ? '#1e3a5f' : isMil ? '#991b1b' : '#92400e' }}>
{isUS ? <span style={{ fontSize: 16 }}>{'\u{1F1FA}\u{1F1F8}'}</span>
: flag ? <span style={{ fontSize: 16 }}>{flag}</span> : null}
<strong style={{ flex: 1 }}>{airport.name}</strong>
</div>
{airport.nameKo && (
<div style={{ fontSize: 12, color: '#ccc', marginBottom: 6 }}>{airport.nameKo}</div>
)}
<div style={{ marginBottom: 8 }}>
<span style={{
background: color, color: isUS || isMil ? '#fff' : '#000',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{isUS ? 'US Military Base' : TYPE_LABELS[airport.type]}
</span>
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
{airport.iata && <div><span className="popup-label">IATA : </span><strong>{airport.iata}</strong></div>}
<div><span className="popup-label">ICAO : </span><strong>{airport.icao}</strong></div>
{airport.city && <div><span className="popup-label">City : </span>{airport.city}</div>}
<div><span className="popup-label">Country : </span>{airport.country}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{airport.lat.toFixed(4)}°{airport.lat >= 0 ? 'N' : 'S'}, {airport.lng.toFixed(4)}°{airport.lng >= 0 ? 'E' : 'W'}
</div>
{airport.iata && (
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
<a href={`https://www.flightradar24.com/airport/${airport.iata.toLowerCase()}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
Flightradar24 &rarr;
</a>
</div>
)}
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -21,7 +21,6 @@ const EVENT_COLORS: Record<string, string> = {
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
// Navy flag-based colors for military vessels

파일 보기

@ -1,356 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { createPortal } from 'react-dom';
import { IRAN_OIL_COUNT } from './createIranOilLayers';
import { IRAN_AIRPORT_COUNT } from './createIranAirportLayers';
import { ME_FACILITY_COUNT } from './createMEFacilityLayers';
import { ME_ENERGY_HAZARD_FACILITIES } from '../../data/meEnergyHazardFacilities';
import { ReplayMap } from './ReplayMap';
import type { FlyToTarget } from './ReplayMap';
import { GlobeMap } from './GlobeMap';
import { SatelliteMap } from './SatelliteMap';
import { SensorChart } from '../common/SensorChart';
import { EventLog } from '../common/EventLog';
import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel';
import { LiveControls } from '../common/LiveControls';
import { ReplayControls, type DataSource } from '../common/ReplayControls';
import { TimelineSlider } from '../common/TimelineSlider';
import { useIranData } from '../../hooks/useIranData';
import { useSharedFilters } from '../../hooks/useSharedFilters';
import type { GeoEvent, LayerVisibility, AppMode } from '../../types';
import { useTranslation } from 'react-i18next';
interface IranDashboardProps {
currentTime: number;
isLive: boolean;
refreshKey: number;
replay: {
state: {
isPlaying: boolean;
speed: number;
startTime: number;
endTime: number;
currentTime: number;
};
play: () => void;
pause: () => void;
reset: () => void;
setSpeed: (s: number) => void;
setRange: (s: number, e: number) => void;
seek: (t: number) => void;
};
monitor: {
state: { currentTime: number; historyMinutes: number };
setHistoryMinutes: (m: number) => void;
};
timeZone: 'KST' | 'UTC';
onTimeZoneChange: (tz: 'KST' | 'UTC') => void;
appMode: AppMode;
onAppModeChange: (mode: AppMode) => void;
}
const INITIAL_LAYERS: LayerVisibility = {
events: true,
aircraft: true,
satellites: true,
ships: true,
koreanShips: true,
airports: true,
sensorCharts: false,
oilFacilities: true,
meFacilities: true,
militaryOnly: false,
overseasUS: false,
overseasIsrael: false,
overseasIran: false,
overseasUAE: false,
overseasSaudi: false,
overseasOman: false,
overseasQatar: false,
overseasKuwait: false,
overseasIraq: false,
overseasBahrain: false,
};
const IranDashboard = ({
currentTime,
isLive,
refreshKey,
replay,
monitor,
timeZone,
onTimeZoneChange,
appMode,
onAppModeChange,
}: IranDashboardProps) => {
const { t } = useTranslation();
const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite');
const [layers, setLayers] = useState<LayerVisibility>(INITIAL_LAYERS);
const [seismicMarker, setSeismicMarker] = useState<{
lat: number;
lng: number;
magnitude: number;
place: string;
} | null>(null);
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
const [dataSource, setDataSource] = useLocalStorage<DataSource>('iranDataSource', 'dummy');
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
useSharedFilters();
const iranData = useIranData({
appMode,
currentTime,
isLive,
hiddenAcCategories,
hiddenShipCategories,
refreshKey,
dashboardTab: 'iran',
dataSource,
});
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []);
const batchToggleLayer = useCallback((keys: string[], value: boolean) => {
setLayers(prev => {
const next = { ...prev } as Record<string, boolean>;
for (const k of keys) next[k] = value;
return next as LayerVisibility;
});
}, []);
const handleEventFlyTo = useCallback((event: GeoEvent) => {
setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 });
}, []);
const meCountByCountry = useCallback((ck: string) => ME_ENERGY_HAZARD_FACILITIES.filter(f => f.countryKey === ck).length, []);
const layerTree = useMemo((): LayerTreeNode[] => [
{ key: 'ships', label: t('layers.ships'), color: '#fb923c', count: iranData.ships.length, specialRenderer: 'shipCategories' },
{
key: 'aviation', label: '항공망', color: '#22d3ee',
children: [
{ key: 'aircraft', label: t('layers.aircraft'), color: '#22d3ee', count: iranData.aircraft.length, specialRenderer: 'aircraftCategories' },
{ key: 'satellites', label: t('layers.satellites'), color: '#ef4444', count: iranData.satPositions.length },
],
},
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.koreanShips.length },
{ key: 'airports', label: t('layers.airports'), color: '#38bdf8', count: IRAN_AIRPORT_COUNT },
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#fb7185', count: IRAN_OIL_COUNT },
{ key: 'meFacilities', label: '주요시설/군사', color: '#f87171', count: ME_FACILITY_COUNT },
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
{
key: 'overseas', label: '해외시설', color: '#c084fc',
children: [
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#60a5fa', count: meCountByCountry('us') },
{ key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#ef4444', count: meCountByCountry('il') },
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#34d399', count: meCountByCountry('ir') },
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#38bdf8', count: meCountByCountry('ae') },
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#a3e635', count: meCountByCountry('sa') },
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#fb7185', count: meCountByCountry('om') },
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#a78bfa', count: meCountByCountry('qa') },
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f472b6', count: meCountByCountry('kw') },
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#86efac', count: meCountByCountry('iq') },
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#f87171', count: meCountByCountry('bh') },
],
},
], [iranData, t, meCountByCountry]);
// 헤더 슬롯 Portal — 이란 모드 토글 + 맵 모드 + 카운트
const headerSlot = document.getElementById('dashboard-header-slot');
const countsSlot = document.getElementById('dashboard-counts-slot');
return (
<>
{headerSlot && createPortal(
<>
<div className="mode-toggle mode-toggle-left">
<div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
<span className="text-[13px]"></span>
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
</div>
<button type="button" className={`mode-btn ${appMode === 'live' ? 'active live' : ''}`} onClick={() => onAppModeChange('live')}>
<span className="mode-dot-icon" />
{t('mode.live')}
</button>
<button type="button" className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`} onClick={() => onAppModeChange('replay')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
{t('mode.replay')}
</button>
</div>
<div className="map-mode-toggle">
<button type="button" className={`map-mode-btn ${mapMode === 'flat' ? 'active' : ''}`} onClick={() => setMapMode('flat')}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
{t('mapMode.flat')}
</button>
<button type="button" className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`} onClick={() => setMapMode('globe')}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
{t('mapMode.globe')}
</button>
<button type="button" className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`} onClick={() => setMapMode('satellite')}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
{t('mapMode.satellite')}
</button>
</div>
</>,
headerSlot,
)}
{countsSlot && createPortal(
<div className="header-counts">
<span className="count-item ac-count">{iranData.aircraft.length} AC</span>
<span className="count-item mil-count">{iranData.militaryCount} MIL</span>
<span className="count-item ship-count">{iranData.ships.length} SHIP</span>
<span className="count-item sat-count">{iranData.satPositions.length} SAT</span>
</div>,
countsSlot,
)}
<main className="app-main">
<div className="map-panel">
{mapMode === 'flat' ? (
<ReplayMap
key="map-iran"
events={isLive ? [] : iranData.mergedEvents}
currentTime={currentTime}
aircraft={iranData.visibleAircraft}
satellites={iranData.satPositions}
ships={iranData.visibleShips}
layers={layers}
flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)}
hoveredShipMmsi={hoveredShipMmsi}
focusShipMmsi={focusShipMmsi}
onFocusShipClear={() => setFocusShipMmsi(null)}
seismicMarker={seismicMarker}
/>
) : mapMode === 'globe' ? (
<GlobeMap
events={isLive ? [] : iranData.mergedEvents}
currentTime={currentTime}
aircraft={iranData.visibleAircraft}
satellites={iranData.satPositions}
ships={iranData.visibleShips}
layers={layers}
/>
) : (
<SatelliteMap
events={isLive ? [] : iranData.mergedEvents}
currentTime={currentTime}
aircraft={iranData.visibleAircraft}
satellites={iranData.satPositions}
ships={iranData.visibleShips}
layers={layers}
hoveredShipMmsi={hoveredShipMmsi}
focusShipMmsi={focusShipMmsi}
onFocusShipClear={() => setFocusShipMmsi(null)}
flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)}
seismicMarker={seismicMarker}
/>
)}
<div className="map-overlay-left">
<LayerPanel
layers={layers as unknown as Record<string, boolean>}
onToggle={toggleLayer as (key: string) => void}
onBatchToggle={batchToggleLayer}
tree={layerTree}
aircraftByCategory={iranData.aircraftByCategory}
aircraftTotal={iranData.aircraft.length}
shipsByMtCategory={iranData.shipsByCategory}
shipTotal={iranData.ships.length}
satelliteCount={iranData.satPositions.length}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
onShipCategoryToggle={toggleShipCategory}
/>
</div>
</div>
<aside className="side-panel">
<EventLog
events={isLive ? [] : iranData.mergedEvents}
currentTime={currentTime}
totalShipCount={iranData.ships.length}
koreanShips={iranData.koreanShips}
koreanShipsByCategory={iranData.koreanShipsByCategory}
osintFeed={iranData.osintFeed}
isLive={isLive}
dashboardTab="iran"
ships={iranData.ships}
highlightKoreanShips={layers.koreanShips}
onToggleHighlightKorean={() => setLayers(prev => ({ ...prev, koreanShips: !prev.koreanShips }))}
onShipHover={setHoveredShipMmsi}
onShipClick={(mmsi) => {
setFocusShipMmsi(mmsi);
const ship = iranData.ships.find(s => s.mmsi === mmsi);
if (ship) setFlyToTarget({ lat: ship.lat, lng: ship.lng, zoom: 10 });
}}
/>
</aside>
</main>
{layers.sensorCharts && (
<section className="charts-panel">
<SensorChart
seismicData={iranData.seismicData}
pressureData={iranData.pressureData}
currentTime={currentTime}
historyMinutes={monitor.state.historyMinutes}
onSeismicClick={(lat, lng, magnitude, place) => {
setFlyToTarget({ lat, lng, zoom: 8 });
setSeismicMarker({ lat, lng, magnitude, place });
}}
/>
</section>
)}
<footer className="app-footer">
{isLive ? (
<LiveControls
currentTime={monitor.state.currentTime}
historyMinutes={monitor.state.historyMinutes}
onHistoryChange={monitor.setHistoryMinutes}
aircraftCount={iranData.aircraft.length}
shipCount={iranData.ships.length}
satelliteCount={iranData.satPositions.length}
timeZone={timeZone}
onTimeZoneChange={onTimeZoneChange}
/>
) : (
<>
<ReplayControls
isPlaying={replay.state.isPlaying}
speed={replay.state.speed}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
onPlay={replay.play}
onPause={replay.pause}
onReset={replay.reset}
onSpeedChange={replay.setSpeed}
onRangeChange={replay.setRange}
dataSource={dataSource}
onDataSourceChange={setDataSource}
/>
<TimelineSlider
currentTime={replay.state.currentTime}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
events={iranData.mergedEvents}
onSeek={replay.seek}
onEventFlyTo={handleEventFlyTo}
/>
</>
)}
</footer>
</>
);
};
export { IranDashboard };
export type { IranDashboardProps };

파일 보기

@ -1,222 +1,199 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
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,
type FacilitySubType,
} from '../../data/meEnergyHazardFacilities';
import type { FacilitySubType } from '../../data/meEnergyHazardFacilities';
// LayerVisibility overseas key → countryKey mapping
const COUNTRY_KEY_TO_LAYER_KEY: Record<string, string> = {
us: 'overseasUS',
ir: 'overseasIran',
ae: 'overseasUAE',
sa: 'overseasSaudi',
om: 'overseasOman',
qa: 'overseasQatar',
kw: 'overseasKuwait',
iq: 'overseasIraq',
bh: 'overseasBahrain',
// il (Israel) is shown when meFacilities is true (no dedicated overseas key)
il: 'meFacilities',
};
function isFacilityVisible(f: EnergyHazardFacility, layers: Record<string, boolean>): boolean {
const countryLayerKey = COUNTRY_KEY_TO_LAYER_KEY[f.countryKey];
if (!countryLayerKey || !layers[countryLayerKey]) return false;
// Check sub-type toggle if present, otherwise fall through to country-level toggle
// Sub-type keys: e.g. "irPower", "ilNuclear", "usOilTank"
const subTypeKey = f.countryKey + capitalizeFirst(f.subType.replace('_', ''));
if (subTypeKey in layers) return !!layers[subTypeKey];
// Check category-level parent key: e.g. "irEnergy", "usHazard"
const categoryKey = f.countryKey + capitalizeFirst(f.category);
if (categoryKey in layers) return !!layers[categoryKey];
// Fall back to country-level toggle
return true;
}
function capitalizeFirst(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
// Pre-build layer-key → subType entries from layerKeyToSubType/layerKeyToCountry
// for reference — the actual filter uses the above isFacilityVisible logic.
// Exported for re-use elsewhere if needed.
export { layerKeyToSubType, layerKeyToCountry };
export interface MELayerConfig {
layers: Record<string, boolean>;
sc: number;
fs?: number;
onPick: (facility: EnergyHazardFacility) => void;
}
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b, alpha];
}
// ─── SVG icon functions ────────────────────────────────────────────────────────
function powerSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M13 5 L7 13 L12 13 L11 19 L17 11 L12 11 Z" fill="${color}" opacity="0.9"/>
</svg>`;
}
function windSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<circle cx="12" cy="12" r="1.5" fill="${color}"/>
<line x1="12" y1="10.5" x2="12" y2="5" stroke="${color}" stroke-width="1.2"/>
<path d="M12 5 Q15 5 15 7 Q15 9 12 10.5" fill="${color}" opacity="0.7"/>
<line x1="10.7" y1="11.25" x2="6" y2="8.5" stroke="${color}" stroke-width="1.2"/>
<path d="M6 8.5 Q4 11 5.5 13 Q7 14.5 10.7 13" fill="${color}" opacity="0.7"/>
<line x1="13.3" y1="12.75" x2="18" y2="15.5" stroke="${color}" stroke-width="1.2"/>
<path d="M18 15.5 Q20 13 18.5 11 Q17 9.5 13.3 11" fill="${color}" opacity="0.7"/>
</svg>`;
}
function nuclearSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<circle cx="12" cy="12" r="2" fill="${color}"/>
<path d="M12 10 Q10 7 7 7 Q6 9 7 11 Q9 12 12 12" fill="${color}" opacity="0.7"/>
<path d="M13.7 11 Q16 9 17 7 Q15 5 13 6 Q11 8 12 10" fill="${color}" opacity="0.7"/>
<path d="M10.3 13 Q7 13 6 16 Q8 18 11 17 Q13 15 13.7 13" fill="${color}" opacity="0.7"/>
<path d="M13.7 13 Q15 16 17 17 Q19 15 18 12 Q16 11 13.7 12" fill="${color}" opacity="0.7"/>
</svg>`;
}
function thermalSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<rect x="5" y="11" width="14" height="7" rx="1" fill="${color}" opacity="0.6"/>
<rect x="7" y="7" width="2" height="5" fill="${color}" opacity="0.6"/>
<rect x="11" y="5" width="2" height="7" fill="${color}" opacity="0.6"/>
<rect x="15" y="8" width="2" height="4" fill="${color}" opacity="0.6"/>
<path d="M8 5 Q8.5 3.5 9 5 Q9.5 3 10 5" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.8"/>
</svg>`;
}
function petrochemSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<rect x="10" y="5" width="4" height="8" rx="1" fill="${color}" opacity="0.65"/>
<ellipse cx="12" cy="14.5" rx="4.5" ry="2.5" fill="${color}" opacity="0.75"/>
<line x1="7" y1="10" x2="10" y2="10" stroke="${color}" stroke-width="1"/>
<line x1="14" y1="10" x2="17" y2="10" stroke="${color}" stroke-width="1"/>
<path d="M7 10 Q5.5 13 8 15" fill="none" stroke="${color}" stroke-width="0.8"/>
</svg>`;
}
function lngSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<line x1="9" y1="7" x2="9" y2="10" stroke="${color}" stroke-width="1.5"/>
<line x1="12" y1="5" x2="12" y2="10" stroke="${color}" stroke-width="1.5"/>
<line x1="15" y1="7" x2="15" y2="10" stroke="${color}" stroke-width="1.5"/>
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1"/>
<ellipse cx="12" cy="15" rx="5" ry="3.5" fill="${color}" opacity="0.65"/>
<line x1="12" y1="10" x2="12" y2="11.5" stroke="${color}" stroke-width="1"/>
</svg>`;
}
function oilTankSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<ellipse cx="12" cy="8" rx="5" ry="2" fill="${color}" opacity="0.5"/>
<rect x="7" y="8" width="10" height="8" fill="${color}" opacity="0.6"/>
<ellipse cx="12" cy="16" rx="5" ry="2" fill="${color}" opacity="0.8"/>
<line x1="9" y1="8" x2="9" y2="16" stroke="rgba(0,0,0,0.2)" stroke-width="0.5"/>
<line x1="15" y1="8" x2="15" y2="16" stroke="rgba(0,0,0,0.2)" stroke-width="0.5"/>
</svg>`;
}
function hazPortSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M12 5 L20 18 L4 18 Z" fill="${color}" opacity="0.7"/>
<line x1="12" y1="10" x2="12" y2="14" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
<circle cx="12" cy="16" r="1" fill="#fff"/>
</svg>`;
}
const SUB_TYPE_SVG_FN: Record<FacilitySubType, (color: string, size: number) => string> = {
power: powerSvg,
wind: windSvg,
nuclear: nuclearSvg,
thermal: thermalSvg,
petrochem: petrochemSvg,
lng: lngSvg,
oil_tank: oilTankSvg,
haz_port: hazPortSvg,
};
const iconCache = new Map<string, string>();
function getIconUrl(subType: FacilitySubType): string {
if (!iconCache.has(subType)) {
const color = SUB_TYPE_META[subType].color;
iconCache.set(subType, svgToDataUri(SUB_TYPE_SVG_FN[subType](color, 64)));
}
return iconCache.get(subType)!;
}
export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] {
const { layers, sc, onPick } = config;
const fs = config.fs ?? 1;
const visibleFacilities = ME_ENERGY_HAZARD_FACILITIES.filter(f =>
isFacilityVisible(f, layers),
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>
)}
</>
);
if (visibleFacilities.length === 0) return [];
const iconLayer = new IconLayer<EnergyHazardFacility>({
id: 'me-energy-hazard-icon',
data: visibleFacilities,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({ url: getIconUrl(d.subType), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
getSize: (d) => (d.category === 'hazard' ? 20 : 18) * sc,
updateTriggers: { getSize: [sc] },
pickable: true,
onClick: (info: PickingInfo<EnergyHazardFacility>) => {
if (info.object) onPick(info.object);
return true;
},
});
const labelLayer = new TextLayer<EnergyHazardFacility>({
id: 'me-energy-hazard-label',
data: visibleFacilities,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 12 * sc * fs,
updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 12],
fontFamily: FONT_MONO,
fontWeight: 700,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
});
return [iconLayer, labelLayer];
}

파일 보기

@ -1,180 +0,0 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { ME_FACILITIES } from '../../data/middleEastFacilities';
import type { MEFacility } from '../../data/middleEastFacilities';
export const ME_FACILITY_COUNT = ME_FACILITIES.length;
// ─── Type colors ──────────────────────────────────────────────────────────────
const TYPE_COLORS: Record<MEFacility['type'], string> = {
naval: '#60a5fa',
military_hq: '#f87171',
missile: '#ef4444',
intelligence: '#a78bfa',
government: '#c084fc',
radar: '#22d3ee',
};
// ─── SVG generators ──────────────────────────────────────────────────────────
// naval: anchor symbol
function navalSvg(color: string): string {
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
<circle cx="18" cy="10" r="3" fill="none" stroke="${color}" stroke-width="1.8"/>
<line x1="18" y1="13" x2="18" y2="28" stroke="${color}" stroke-width="1.8"/>
<line x1="10" y1="17" x2="26" y2="17" stroke="${color}" stroke-width="1.8"/>
<path d="M10 26 Q12 30 18 30 Q24 30 26 26" fill="none" stroke="${color}" stroke-width="1.8"/>
<line x1="10" y1="26" x2="8" y2="28" stroke="${color}" stroke-width="1.2"/>
<line x1="26" y1="26" x2="28" y2="28" stroke="${color}" stroke-width="1.2"/>
</svg>`;
}
// military_hq: star symbol
function militaryHqSvg(color: string): string {
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
<polygon points="18,6 21,15 30,15 23,20 26,29 18,24 10,29 13,20 6,15 15,15" fill="${color}" opacity="0.9"/>
</svg>`;
}
// missile: upward arrow / rocket shape
function missileSvg(color: string): string {
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
<path d="M18 5 L21 14 L21 24 L18 27 L15 24 L15 14 Z" fill="${color}" opacity="0.85"/>
<path d="M15 14 L10 18 L15 18 Z" fill="${color}" opacity="0.7"/>
<path d="M21 14 L26 18 L21 18 Z" fill="${color}" opacity="0.7"/>
<line x1="16" y1="27" x2="14" y2="32" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
<line x1="20" y1="27" x2="22" y2="32" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
<circle cx="18" cy="10" r="2" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1"/>
</svg>`;
}
// intelligence: magnifying glass
function intelligenceSvg(color: string): string {
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
<circle cx="16" cy="16" r="8" fill="none" stroke="${color}" stroke-width="2.2"/>
<line x1="22" y1="22" x2="30" y2="30" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="14" cy="14" r="3" fill="${color}" opacity="0.2"/>
</svg>`;
}
// government: pillars / building
function governmentSvg(color: string): string {
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
<rect x="6" y="30" width="24" height="2.5" rx="0.5" fill="${color}" opacity="0.8"/>
<rect x="8" y="27" width="20" height="3" rx="0.5" fill="${color}" opacity="0.6"/>
<rect x="9" y="14" width="2.5" height="13" fill="${color}" opacity="0.75"/>
<rect x="14" y="14" width="2.5" height="13" fill="${color}" opacity="0.75"/>
<rect x="19" y="14" width="2.5" height="13" fill="${color}" opacity="0.75"/>
<rect x="24" y="14" width="2.5" height="13" fill="${color}" opacity="0.75"/>
<path d="M6 14 L18 6 L30 14 Z" fill="${color}" opacity="0.8"/>
</svg>`;
}
// radar: radio waves / dish
function radarSvg(color: string): string {
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
<path d="M9 28 Q9 14 18 10 Q27 14 27 28" fill="${color}" opacity="0.15" stroke="${color}" stroke-width="1.5"/>
<path d="M12 28 Q12 17 18 13 Q24 17 24 28" fill="${color}" opacity="0.2" stroke="${color}" stroke-width="1"/>
<line x1="18" y1="10" x2="18" y2="28" stroke="${color}" stroke-width="1.5" opacity="0.6"/>
<circle cx="18" cy="10" r="2" fill="${color}" opacity="0.9"/>
<path d="M7 22 Q10 18 14 18" fill="none" stroke="${color}" stroke-width="1.2" opacity="0.55"/>
<path d="M29 22 Q26 18 22 18" fill="none" stroke="${color}" stroke-width="1.2" opacity="0.55"/>
<line x1="14" y1="28" x2="22" y2="28" stroke="${color}" stroke-width="2" opacity="0.6"/>
<line x1="18" y1="28" x2="18" y2="32" stroke="${color}" stroke-width="2" opacity="0.6"/>
</svg>`;
}
function buildMESvg(type: MEFacility['type'], color: string): string {
switch (type) {
case 'naval': return navalSvg(color);
case 'military_hq': return militaryHqSvg(color);
case 'missile': return missileSvg(color);
case 'intelligence': return intelligenceSvg(color);
case 'government': return governmentSvg(color);
case 'radar': return radarSvg(color);
}
}
// ─── Module-level icon cache ─────────────────────────────────────────────────
const meIconCache = new Map<string, string>();
function getMEIconUrl(type: MEFacility['type']): string {
if (!meIconCache.has(type)) {
meIconCache.set(type, svgToDataUri(buildMESvg(type, TYPE_COLORS[type])));
}
return meIconCache.get(type)!;
}
// ─── Label color ─────────────────────────────────────────────────────────────
function getMELabelColor(type: MEFacility['type']): [number, number, number, number] {
const hex = TYPE_COLORS[type];
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b, 255];
}
// ─── Public interface ────────────────────────────────────────────────────────
export interface MEFacilityLayerConfig {
visible: boolean;
sc: number;
onPick: (f: MEFacility) => void;
}
export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
if (!config.visible) return [];
const { sc, onPick } = config;
return [
new IconLayer<MEFacility>({
id: 'me-facility-icon',
data: ME_FACILITIES,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: getMEIconUrl(d.type),
width: 36,
height: 36,
anchorX: 18,
anchorY: 18,
}),
getSize: 16 * sc,
updateTriggers: { getSize: [sc] },
pickable: true,
onClick: (info: PickingInfo<MEFacility>) => {
if (info.object) onPick(info.object);
return true;
},
}),
new TextLayer<MEFacility>({
id: 'me-facility-label',
data: ME_FACILITIES,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
getSize: 9 * sc,
updateTriggers: { getSize: [sc] },
getColor: (d) => getMELabelColor(d.type),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontWeight: 700,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
];
}

파일 보기

@ -0,0 +1,80 @@
import { memo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities';
import type { MEFacility } from '../../data/middleEastFacilities';
export const MEFacilityLayer = memo(function MEFacilityLayer() {
const [selected, setSelected] = useState<MEFacility | null>(null);
return (
<>
{ME_FACILITIES.map(f => {
const meta = ME_FACILITY_TYPE_META[f.type];
return (
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
<div
className="flex flex-col items-center cursor-pointer"
style={{ filter: `drop-shadow(0 0 3px ${meta.color}88)` }}
>
<div style={{
width: 16, height: 16, borderRadius: 3,
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${meta.color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 9,
}}>
{meta.icon}
</div>
<div style={{
fontSize: 5, color: meta.color, marginTop: 1,
textShadow: '0 0 3px #000, 0 0 3px #000',
whiteSpace: 'nowrap', fontWeight: 700,
}}>
{f.nameKo.length > 12 ? f.nameKo.slice(0, 12) + '..' : f.nameKo}
</div>
</div>
</Marker>
);
})}
{selected && (() => {
const meta = ME_FACILITY_TYPE_META[selected.type];
return (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{selected.flag}</span>
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: meta.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{meta.label}
</span>
<span style={{
background: '#333', color: '#ccc',
padding: '2px 8px', borderRadius: 3, fontSize: 10,
}}>
{selected.country}
</span>
</div>
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{selected.description}
</div>
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
{selected.name}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°{selected.lat >= 0 ? 'N' : 'S'}, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
);
})()}
</>
);
});

파일 보기

@ -1,253 +0,0 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { iranOilFacilities } from '../../data/oilFacilities';
import type { OilFacility, OilFacilityType } from '../../types';
export const IRAN_OIL_COUNT = iranOilFacilities.length;
// ─── Type colors ──────────────────────────────────────────────────────────────
const TYPE_COLORS: Record<OilFacilityType, string> = {
refinery: '#fb7185',
oilfield: '#34d399',
gasfield: '#818cf8',
terminal: '#c084fc',
petrochemical: '#f472b6',
desalination: '#22d3ee',
};
// ─── SVG generators ──────────────────────────────────────────────────────────
function damageOverlaySvg(): string {
return `<line x1="4" y1="4" x2="32" y2="32" stroke="#ff0000" stroke-width="2.5" opacity="0.8"/>
<line x1="32" y1="4" x2="4" y2="32" stroke="#ff0000" stroke-width="2.5" opacity="0.8"/>
<circle cx="18" cy="18" r="15" fill="none" stroke="#ff0000" stroke-width="1.5" opacity="0.4"/>`;
}
function plannedOverlaySvg(): string {
return `<circle cx="18" cy="18" r="15" fill="none" stroke="#ff6600" stroke-width="2" stroke-dasharray="4 3" opacity="0.9"/>
<line x1="18" y1="0" x2="18" y2="4" stroke="#ff6600" stroke-width="1.5" opacity="0.7"/>
<line x1="18" y1="32" x2="18" y2="36" stroke="#ff6600" stroke-width="1.5" opacity="0.7"/>
<line x1="0" y1="18" x2="4" y2="18" stroke="#ff6600" stroke-width="1.5" opacity="0.7"/>
<line x1="32" y1="18" x2="36" y2="18" stroke="#ff6600" stroke-width="1.5" opacity="0.7"/>`;
}
function refinerySvg(color: string, damaged: boolean, planned: boolean): string {
const sc = damaged ? '#ff0000' : color;
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="refGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${color}" stop-opacity="0.5"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0.2"/>
</linearGradient>
</defs>
<circle cx="18" cy="18" r="17" fill="url(#refGrad)" stroke="${sc}" stroke-width="${damaged ? 2 : 1}" opacity="0.9"/>
<rect x="6" y="19" width="24" height="11" rx="1" fill="${color}" opacity="0.6"/>
<rect x="16" y="7" width="4" height="13" fill="${color}" opacity="0.7"/>
<rect x="9" y="12" width="4" height="8" fill="${color}" opacity="0.65"/>
<rect x="23" y="10" width="4" height="10" fill="${color}" opacity="0.65"/>
<circle cx="11" cy="10" r="1.5" fill="${color}" opacity="0.3"/>
<circle cx="18" cy="5" r="2" fill="${color}" opacity="0.3"/>
<circle cx="25" cy="8" r="1.5" fill="${color}" opacity="0.3"/>
<rect x="10" y="22" width="3" height="3" rx="0.5" fill="rgba(0,0,0,0.4)"/>
<rect x="16" y="22" width="3" height="3" rx="0.5" fill="rgba(0,0,0,0.4)"/>
<rect x="23" y="22" width="3" height="3" rx="0.5" fill="rgba(0,0,0,0.4)"/>
<line x1="13" y1="15" x2="16" y2="15" stroke="${color}" stroke-width="0.8" opacity="0.5"/>
<line x1="20" y1="13" x2="23" y2="13" stroke="${color}" stroke-width="0.8" opacity="0.5"/>
${damaged ? damageOverlaySvg() : ''}
${planned ? plannedOverlaySvg() : ''}
</svg>`;
}
function oilfieldSvg(color: string, damaged: boolean, planned: boolean): string {
const sc = damaged ? '#ff0000' : color;
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="31" width="28" height="2.5" rx="1" fill="${color}" opacity="0.7"/>
<line x1="18" y1="14" x2="12" y2="31" stroke="${sc}" stroke-width="1.5" opacity="0.85"/>
<line x1="18" y1="14" x2="24" y2="31" stroke="${sc}" stroke-width="1.5" opacity="0.85"/>
<line x1="14" y1="25" x2="22" y2="25" stroke="${color}" stroke-width="1" opacity="0.7"/>
<line x1="4" y1="12" x2="28" y2="10" stroke="${sc}" stroke-width="2" opacity="0.9"/>
<circle cx="18" cy="13" r="2" fill="${color}" opacity="0.8" stroke="${sc}" stroke-width="1"/>
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke="${sc}" stroke-width="1.5" opacity="0.85"/>
<line x1="5.5" y1="17" x2="5.5" y2="31" stroke="${color}" stroke-width="1.2" opacity="0.75"/>
<rect x="25" y="10" width="5" height="6" rx="1" fill="${color}" opacity="0.6" stroke="${sc}" stroke-width="1"/>
<line x1="27.5" y1="16" x2="27.5" y2="24" stroke="${color}" stroke-width="1.2" opacity="0.75"/>
<rect x="24" y="24" width="7" height="5" rx="1" fill="${color}" opacity="0.55" stroke="${sc}" stroke-width="1"/>
<rect x="3" y="28" width="5" height="3" rx="0.5" fill="${color}" opacity="0.65" stroke="${sc}" stroke-width="0.8"/>
<path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z" fill="${color}" opacity="0.85"/>
${damaged ? damageOverlaySvg() : ''}
${planned ? plannedOverlaySvg() : ''}
</svg>`;
}
function gasfieldSvg(color: string, damaged: boolean, planned: boolean): string {
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<line x1="10" y1="24" x2="8" y2="33" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
<line x1="14" y1="25" x2="13" y2="33" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
<line x1="22" y1="25" x2="23" y2="33" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
<line x1="26" y1="24" x2="28" y2="33" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
<line x1="9" y1="29" x2="14" y2="27" stroke="${color}" stroke-width="0.8" opacity="0.55"/>
<line x1="22" y1="27" x2="27" y2="29" stroke="${color}" stroke-width="0.8" opacity="0.55"/>
<line x1="7" y1="33" x2="29" y2="33" stroke="${color}" stroke-width="1.2" opacity="0.6"/>
<ellipse cx="18" cy="16" rx="12" ry="10" fill="${color}" opacity="0.45" stroke="${damaged ? '#ff0000' : color}" stroke-width="1.5"/>
<ellipse cx="16" cy="12" rx="7" ry="5" fill="${color}" opacity="0.3"/>
<ellipse cx="18" cy="16" rx="12" ry="2.5" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.55"/>
<rect x="16.5" y="4" width="3" height="3" rx="0.5" fill="${color}" opacity="0.7"/>
<line x1="18" y1="4" x2="18" y2="6" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
${damaged ? damageOverlaySvg() : ''}
${planned ? plannedOverlaySvg() : ''}
</svg>`;
}
function terminalSvg(color: string, damaged: boolean, planned: boolean): string {
const sc = damaged ? '#ff0000' : color;
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="10" r="4" fill="none" stroke="${sc}" stroke-width="2"/>
<line x1="18" y1="14" x2="18" y2="28" stroke="${color}" stroke-width="2"/>
<path d="M10 24 C10 28 14 32 18 32 C22 32 26 28 26 24" fill="none" stroke="${color}" stroke-width="2"/>
<line x1="18" y1="8" x2="18" y2="6" stroke="${color}" stroke-width="2"/>
<line x1="16" y1="6" x2="20" y2="6" stroke="${color}" stroke-width="2.5"/>
<path d="M6 24 L10 24" stroke="${color}" stroke-width="1.5"/>
<path d="M26 24 L30 24" stroke="${color}" stroke-width="1.5"/>
<polygon points="5,24 8,22 8,26" fill="${color}" opacity="0.7"/>
<polygon points="31,24 28,22 28,26" fill="${color}" opacity="0.7"/>
${damaged ? damageOverlaySvg() : ''}
${planned ? plannedOverlaySvg() : ''}
</svg>`;
}
function petrochemSvg(color: string, damaged: boolean, planned: boolean): string {
const sc = damaged ? '#ff0000' : '#fff';
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<path d="M14 6 L14 16 L8 30 L28 30 L22 16 L22 6Z" fill="${color}" opacity="0.7" stroke="${sc}" stroke-width="1"/>
<rect x="13" y="4" width="10" height="4" rx="1" fill="${color}" opacity="0.9" stroke="${sc}" stroke-width="0.8"/>
<path d="M11 22 L25 22 L28 30 L8 30Z" fill="${color}" opacity="0.5"/>
<circle cx="16" cy="25" r="1.5" fill="#c4b5fd" opacity="0.7"/>
<circle cx="20" cy="23" r="1" fill="#c4b5fd" opacity="0.6"/>
<circle cx="18" cy="27" r="1.2" fill="#c4b5fd" opacity="0.5"/>
${damaged ? damageOverlaySvg() : ''}
${planned ? plannedOverlaySvg() : ''}
</svg>`;
}
function desalSvg(color: string, damaged: boolean, planned: boolean): string {
const sc = damaged ? '#ff0000' : color;
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<path d="M14 3 C14 3 6 14 6 19 C6 23.5 9.5 27 14 27 C18.5 27 22 23.5 22 19 C22 14 14 3 14 3Z" fill="${color}" opacity="0.4" stroke="${sc}" stroke-width="1.2"/>
<path d="M14 10 C14 10 10 16 10 19 C10 21.5 11.8 23.5 14 23.5 C16.2 23.5 18 21.5 18 19 C18 16 14 10 14 10Z" fill="${color}" opacity="0.3"/>
<rect x="24" y="5" width="6" height="3" rx="1" fill="${color}" opacity="0.5" stroke="${sc}" stroke-width="0.8"/>
<line x1="27" y1="8" x2="27" y2="12" stroke="${sc}" stroke-width="1.5" opacity="0.7"/>
<path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8" fill="none" stroke="${sc}" stroke-width="0.8" opacity="0.6"/>
<circle cx="27" cy="14.5" r="1" fill="${color}" opacity="0.55"/>
<circle cx="27" cy="17" r="0.7" fill="${color}" opacity="0.45"/>
<rect x="23" y="20" width="9" height="12" rx="1.5" fill="${color}" opacity="0.4" stroke="${sc}" stroke-width="1"/>
<line x1="24" y1="24" x2="31" y2="24" stroke="${color}" stroke-width="0.6" opacity="0.5"/>
<line x1="24" y1="27" x2="31" y2="27" stroke="${color}" stroke-width="0.6" opacity="0.5"/>
<path d="M18 22 L23 22" stroke="${sc}" stroke-width="1" opacity="0.6"/>
<line x1="27.5" y1="32" x2="27.5" y2="34" stroke="${color}" stroke-width="1" opacity="0.55"/>
<line x1="4" y1="34" x2="33" y2="34" stroke="${color}" stroke-width="1" opacity="0.25"/>
${damaged ? damageOverlaySvg() : ''}
${planned ? plannedOverlaySvg() : ''}
</svg>`;
}
function buildSvg(type: OilFacilityType, color: string, damaged: boolean, planned: boolean): string {
switch (type) {
case 'refinery': return refinerySvg(color, damaged, planned);
case 'oilfield': return oilfieldSvg(color, damaged, planned);
case 'gasfield': return gasfieldSvg(color, damaged, planned);
case 'terminal': return terminalSvg(color, damaged, planned);
case 'petrochemical': return petrochemSvg(color, damaged, planned);
case 'desalination': return desalSvg(color, damaged, planned);
}
}
// ─── Module-level icon cache ─────────────────────────────────────────────────
const oilIconCache = new Map<string, string>();
function getOilIconUrl(type: OilFacilityType, damaged: boolean, planned: boolean): string {
const key = `${type}-${damaged ? 'd' : 'n'}-${planned ? 'p' : 'n'}`;
if (!oilIconCache.has(key)) {
const color = TYPE_COLORS[type];
oilIconCache.set(key, svgToDataUri(buildSvg(type, color, damaged, planned)));
}
return oilIconCache.get(key)!;
}
// ─── Label color ─────────────────────────────────────────────────────────────
function getLabelColor(type: OilFacilityType, damaged: boolean, planned: boolean): [number, number, number, number] {
if (damaged) return [255, 0, 0, 255];
if (planned) return [255, 102, 0, 255];
const hex = TYPE_COLORS[type];
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b, 255];
}
// ─── Public interface ────────────────────────────────────────────────────────
export interface OilLayerConfig {
visible: boolean;
sc: number;
currentTime: number;
onPick: (f: OilFacility) => void;
}
export function createIranOilLayers(config: OilLayerConfig): Layer[] {
if (!config.visible) return [];
const { sc, currentTime, onPick } = config;
return [
new IconLayer<OilFacility>({
id: 'iran-oil-icon',
data: iranOilFacilities,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => {
const isDamaged = !!(d.damaged && d.damagedAt && currentTime >= d.damagedAt);
const isPlanned = !!d.planned && !isDamaged;
return {
url: getOilIconUrl(d.type, isDamaged, isPlanned),
width: 36,
height: 36,
anchorX: 18,
anchorY: 18,
};
},
getSize: 18 * sc,
updateTriggers: { getSize: [sc], getIcon: [currentTime] },
pickable: true,
onClick: (info: PickingInfo<OilFacility>) => {
if (info.object) onPick(info.object);
return true;
},
}),
new TextLayer<OilFacility>({
id: 'iran-oil-label',
data: iranOilFacilities,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo,
getSize: 9 * sc,
updateTriggers: { getSize: [sc], getColor: [currentTime] },
getColor: (d) => {
const isDamaged = !!(d.damaged && d.damagedAt && currentTime >= d.damagedAt);
const isPlanned = !!d.planned && !isDamaged;
return getLabelColor(d.type, isDamaged, isPlanned);
},
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontWeight: 700,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
];
}

파일 보기

@ -0,0 +1,320 @@
import { memo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { OilFacility, OilFacilityType } from '../../types';
interface Props {
facilities: OilFacility[];
currentTime: number;
}
const TYPE_COLORS: Record<OilFacilityType, string> = {
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1',
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
};
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return String(n);
}
function getTooltipLabel(f: OilFacility): string {
if (f.capacityMgd) return `${formatNumber(f.capacityMgd)} MGD`;
if (f.capacityBpd) return `${formatNumber(f.capacityBpd)} bpd`;
if (f.reservesBbl) return `${f.reservesBbl}B bbl`;
if (f.reservesTcf) return `${f.reservesTcf} Tcf`;
if (f.capacityMcfd) return `${formatNumber(f.capacityMcfd)} Mcf/d`;
return '';
}
// Planned strike targeting ring (SVG 내부 — 위치 정확도)
function PlannedOverlay() {
return (
<>
<circle cx={18} cy={18} r={16} fill="none" stroke="#ff6600" strokeWidth={2}
strokeDasharray="4 3" opacity={0.9}>
<animate attributeName="r" values="14;17;14" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.9;0.5;0.9" dur="2s" repeatCount="indefinite" />
</circle>
{/* Crosshair lines */}
<line x1={18} y1={0} x2={18} y2={4} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
<line x1={18} y1={32} x2={18} y2={36} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
<line x1={0} y1={18} x2={4} y2={18} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
<line x1={32} y1={18} x2={36} y2={18} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
</>
);
}
// Shared damage overlay (X mark + circle)
function DamageOverlay() {
return (
<>
<line x1={4} y1={4} x2={32} y2={32} stroke="#ff0000" strokeWidth={2.5} opacity={0.8} />
<line x1={32} y1={4} x2={4} y2={32} stroke="#ff0000" strokeWidth={2.5} opacity={0.8} />
<circle cx={18} cy={18} r={15} fill="none" stroke="#ff0000" strokeWidth={1.5} opacity={0.4} />
</>
);
}
// SVG icon renderers (JSX versions)
function RefineryIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<defs>
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
<stop offset="100%" stopColor={color} stopOpacity={0.2} />
</linearGradient>
</defs>
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} />
<rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} />
<rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} />
<rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} />
<rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} />
<circle cx={11} cy={10} r={1.5} fill={color} opacity={0.3} />
<circle cx={18} cy={5} r={2} fill={color} opacity={0.3} />
<circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} />
<rect x={10} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<rect x={16} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<rect x={23} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function OilFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} />
<line x1={18} y1={14} x2={12} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
<line x1={18} y1={14} x2={24} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
<line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} />
<line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} />
<circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} />
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} />
<line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} />
<rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} />
<line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} />
<rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} />
<rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} />
<path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z"
fill={color} opacity={0.85} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function GasFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={14} y1={25} x2={13} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={22} y1={25} x2={23} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={9} y1={29} x2={14} y2={27} stroke={color} strokeWidth={0.8} opacity={0.55} />
<line x1={22} y1={27} x2={27} y2={29} stroke={color} strokeWidth={0.8} opacity={0.55} />
<line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} />
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} />
<ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} />
<ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} />
<rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} />
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function TerminalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<circle cx={18} cy={10} r={4} fill="none" stroke={damaged ? '#ff0000' : color} strokeWidth={2} />
<line x1={18} y1={14} x2={18} y2={28} stroke={color} strokeWidth={2} />
<path d="M10 24 C10 28 14 32 18 32 C22 32 26 28 26 24" fill="none" stroke={color} strokeWidth={2} />
<line x1={18} y1={8} x2={18} y2={6} stroke={color} strokeWidth={2} />
<line x1={16} y1={6} x2={20} y2={6} stroke={color} strokeWidth={2.5} />
<path d="M6 24 L10 24" stroke={color} strokeWidth={1.5} />
<path d="M26 24 L30 24" stroke={color} strokeWidth={1.5} />
<polygon points="5,24 8,22 8,26" fill={color} opacity={0.7} />
<polygon points="31,24 28,22 28,26" fill={color} opacity={0.7} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function PetrochemIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<path d="M14 6 L14 16 L8 30 L28 30 L22 16 L22 6Z" fill={color} opacity={0.7} stroke={damaged ? '#ff0000' : '#fff'} strokeWidth={1} />
<rect x={13} y={4} width={10} height={4} rx={1} fill={color} opacity={0.9} stroke={damaged ? '#ff0000' : '#fff'} strokeWidth={0.8} />
<path d="M11 22 L25 22 L28 30 L8 30Z" fill={color} opacity={0.5} />
<circle cx={16} cy={25} r={1.5} fill="#c4b5fd" opacity={0.7} />
<circle cx={20} cy={23} r={1} fill="#c4b5fd" opacity={0.6} />
<circle cx={18} cy={27} r={1.2} fill="#c4b5fd" opacity={0.5} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
function DesalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) {
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<path d="M14 3 C14 3 6 14 6 19 C6 23.5 9.5 27 14 27 C18.5 27 22 23.5 22 19 C22 14 14 3 14 3Z"
fill={color} opacity={0.4} stroke={sc} strokeWidth={1.2} />
<path d="M14 10 C14 10 10 16 10 19 C10 21.5 11.8 23.5 14 23.5 C16.2 23.5 18 21.5 18 19 C18 16 14 10 14 10Z"
fill={color} opacity={0.3} />
<rect x={24} y={5} width={6} height={3} rx={1} fill={color} opacity={0.5} stroke={sc} strokeWidth={0.8} />
<line x1={27} y1={8} x2={27} y2={12} stroke={sc} strokeWidth={1.5} opacity={0.7} />
<path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8"
fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} />
<circle cx={27} cy={14.5} r={1} fill={color} opacity={0.55} />
<circle cx={27} cy={17} r={0.7} fill={color} opacity={0.45} />
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
stroke={sc} strokeWidth={1} />
<line x1={24} y1={24} x2={31} y2={24} stroke={color} strokeWidth={0.6} opacity={0.5} />
<line x1={24} y1={27} x2={31} y2={27} stroke={color} strokeWidth={0.6} opacity={0.5} />
<path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} />
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
{damaged && <DamageOverlay />}
{planned && <PlannedOverlay />}
</svg>
);
}
// 모든 아이콘을 36x36 고정 크기로 렌더링 (anchor="center" 정렬용)
const ICON_RENDER_SIZE = 36;
function FacilityIconSvg({ facility, damaged, planned }: { facility: OilFacility; damaged: boolean; planned: boolean }) {
const color = TYPE_COLORS[facility.type];
const props = { size: ICON_RENDER_SIZE, color, damaged, planned };
switch (facility.type) {
case 'refinery': return <RefineryIcon {...props} />;
case 'oilfield': return <OilFieldIcon {...props} />;
case 'gasfield': return <GasFieldIcon {...props} />;
case 'terminal': return <TerminalIcon {...props} />;
case 'petrochemical': return <PetrochemIcon {...props} />;
case 'desalination': return <DesalIcon {...props} />;
}
}
export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, currentTime }: Props) {
return (
<>
{facilities.map(f => (
<FacilityMarker key={f.id} facility={f} currentTime={currentTime} />
))}
</>
);
});
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false);
const color = TYPE_COLORS[facility.type];
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
const isPlanned = !!facility.planned && !isDamaged;
const stat = getTooltipLabel(facility);
return (
<>
<Marker longitude={facility.lng} latitude={facility.lat} anchor="top-left" style={{ overflow: 'visible' }}>
<div
className="cursor-pointer"
style={{
width: ICON_RENDER_SIZE,
height: ICON_RENDER_SIZE,
position: 'relative',
transform: `translate(-${ICON_RENDER_SIZE / 2}px, -${ICON_RENDER_SIZE / 2}px)`,
}}
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
>
<FacilityIconSvg facility={facility} damaged={isDamaged} planned={isPlanned} />
{/* Label */}
<div className="gl-marker-label text-[8px]" style={{
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color,
}}>
{isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo}
{stat && <span className="text-[#aaa] text-[7px] ml-0.5">{stat}</span>}
</div>
</div>
</Marker>
{showPopup && (
<Popup longitude={facility.lng} latitude={facility.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="min-w-[220px] font-mono text-xs">
<div className="flex gap-1 items-center mb-1.5">
<span
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white"
style={{ background: color }}
>{t(`facility.type.${facility.type}`)}</span>
{isDamaged && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff0000]">
{t('facility.damaged')}
</span>
)}
{isPlanned && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff6600]">
{t('facility.plannedStrike')}
</span>
)}
</div>
<div className="font-bold text-[13px] my-1">{facility.nameKo}</div>
<div className="text-[10px] text-kcg-muted mb-1.5">{facility.name}</div>
<div className="bg-black/30 rounded p-1.5 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{facility.capacityBpd != null && (
<><span className="text-kcg-muted">{t('facility.production')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityBpd)} bpd</span></>
)}
{facility.capacityMgd != null && (
<><span className="text-kcg-muted">{t('facility.desalProduction')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityMgd)} MGD</span></>
)}
{facility.capacityMcfd != null && (
<><span className="text-kcg-muted">{t('facility.gasProduction')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
)}
{facility.reservesBbl != null && (
<><span className="text-kcg-muted">{t('facility.reserveOil')}</span>
<span className="text-white font-semibold">{facility.reservesBbl}B {t('facility.barrels')}</span></>
)}
{facility.reservesTcf != null && (
<><span className="text-kcg-muted">{t('facility.reserveGas')}</span>
<span className="text-white font-semibold">{facility.reservesTcf} Tcf</span></>
)}
{facility.operator && (
<><span className="text-kcg-muted">{t('facility.operator')}</span>
<span className="text-white">{facility.operator}</span></>
)}
</div>
{facility.description && (
<p className="mt-1.5 mb-0 text-[11px] text-kcg-text-secondary leading-snug">{facility.description}</p>
)}
{isPlanned && facility.plannedLabel && (
<div className="mt-1.5 px-2 py-1 text-[11px] rounded leading-snug bg-[rgba(255,102,0,0.15)] border border-[rgba(255,102,0,0.4)] text-[#ff9933]">
{facility.plannedLabel}
</div>
)}
<div className="text-[10px] text-kcg-dim mt-1.5">
{facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E
</div>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useEffect, useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
@ -7,17 +7,11 @@ import { SatelliteLayer } from '../layers/SatelliteLayer';
import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker';
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useFontScale } from '../../hooks/useFontScale';
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
import { createIranOilLayers } from './createIranOilLayers';
import type { OilFacility } from './createIranOilLayers';
import { createIranAirportLayers } from './createIranAirportLayers';
import type { Airport } from './createIranAirportLayers';
import { createMEFacilityLayers } from './createMEFacilityLayers';
import type { MEFacility } from './createMEFacilityLayers';
import { OilFacilityLayer } from './OilFacilityLayer';
import { AirportLayer } from './AirportLayer';
import { MEFacilityLayer } from './MEFacilityLayer';
import { iranOilFacilities } from '../../data/oilFacilities';
import { middleEastAirports } from '../../data/airports';
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import 'maplibre-gl/dist/maplibre-gl.css';
@ -92,7 +86,6 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
const SOURCE_COLORS: Record<string, string> = {
@ -118,49 +111,10 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
osint: 8,
};
type IranPickedFacility =
| { kind: 'oil'; data: OilFacility }
| { kind: 'airport'; data: Airport }
| { kind: 'meFacility'; data: MEFacility };
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, seismicMarker }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
const { fontScale } = useFontScale();
const [zoomLevel, setZoomLevel] = useState(5);
const zoomRef = useRef(5);
const handleZoom = useCallback((e: { viewState: { zoom: number } }) => {
const z = Math.floor(e.viewState.zoom);
if (z !== zoomRef.current) {
zoomRef.current = z;
setZoomLevel(z);
}
}, []);
const zoomScale = useMemo(() => {
if (zoomLevel <= 4) return 0.8;
if (zoomLevel <= 5) return 0.9;
if (zoomLevel <= 6) return 1.0;
if (zoomLevel <= 7) return 1.2;
if (zoomLevel <= 8) return 1.5;
if (zoomLevel <= 9) return 1.8;
if (zoomLevel <= 10) return 2.2;
if (zoomLevel <= 11) return 2.5;
if (zoomLevel <= 12) return 2.8;
if (zoomLevel <= 13) return 3.5;
return 4.2;
}, [zoomLevel]);
const iranDeckLayers = useMemo(() => [
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }),
], [layers, zoomScale, fontScale.facility]);
useEffect(() => {
if (flyToTarget && mapRef.current) {
@ -233,7 +187,6 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
initialViewState={{ longitude: initialCenter?.lng ?? 44, latitude: initialCenter?.lat ?? 31.5, zoom: initialZoom ?? 5 }}
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
onZoom={handleZoom}
>
<NavigationControl position="top-right" />
@ -244,7 +197,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 15 * fontScale.area,
'text-size': 15,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -263,7 +216,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-size': 12 * fontScale.area,
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -283,7 +236,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
minzoom={5}
layout={{
'text-field': ['get', 'name'],
'text-size': 10 * fontScale.area,
'text-size': 10,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -479,158 +432,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} hoveredMmsi={hoveredShipMmsi} focusMmsi={focusShipMmsi} onFocusClear={onFocusShipClear} />}
{layers.ships && <DamagedShipLayer currentTime={currentTime} />}
{seismicMarker && <SeismicMarker {...seismicMarker} />}
<DeckGLOverlay layers={iranDeckLayers} />
{mePickedFacility && (() => {
const meta = SUB_TYPE_META[mePickedFacility.subType];
return (
<Popup
longitude={mePickedFacility.lng}
latitude={mePickedFacility.lat}
onClose={() => setMePickedFacility(null)}
closeOnClick={false}
anchor="bottom"
maxWidth="320px"
className="gl-popup"
>
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{meta.icon} {mePickedFacility.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
{meta.label}
</span>
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{mePickedFacility.country}
</span>
{mePickedFacility.capacityMW !== undefined && (
<span style={{ background: '#222', color: '#94a3b8', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{mePickedFacility.capacityMW.toLocaleString()} MW
</span>
)}
</div>
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{mePickedFacility.description}
</div>
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
{mePickedFacility.name}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{mePickedFacility.lat.toFixed(4)}&deg;{mePickedFacility.lat >= 0 ? 'N' : 'S'}, {mePickedFacility.lng.toFixed(4)}&deg;E
</div>
</div>
</Popup>
);
})()}
{iranPickedFacility && (() => {
const { kind, data } = iranPickedFacility;
if (kind === 'oil') {
const OIL_TYPE_COLORS: Record<string, string> = {
refinery: '#fb7185', oilfield: '#34d399', gasfield: '#818cf8',
terminal: '#c084fc', petrochemical: '#f472b6', desalination: '#22d3ee',
};
const color = OIL_TYPE_COLORS[data.type] ?? '#888';
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{data.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
{data.type}
</span>
{data.operator && (
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{data.operator}
</span>
)}
</div>
{data.description && (
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{data.description}
</div>
)}
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
{data.name}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{data.lat.toFixed(4)}&deg;{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}&deg;E
</div>
</div>
</Popup>
);
}
if (kind === 'airport') {
const isMil = data.type === 'military';
const color = isMil ? '#f87171' : '#38bdf8';
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#0369a1', color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
{data.type === 'military' ? 'Military Airbase' : data.type === 'large' ? 'International Airport' : 'Airport'}
</span>
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{data.country}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px', fontSize: 11, marginBottom: 6 }}>
{data.iata && <><span style={{ color: '#888' }}>IATA</span><strong>{data.iata}</strong></>}
<span style={{ color: '#888' }}>ICAO</span><strong>{data.icao}</strong>
{data.city && <><span style={{ color: '#888' }}>City</span><span>{data.city}</span></>}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{data.lat.toFixed(4)}&deg;{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}&deg;{data.lng >= 0 ? 'E' : 'W'}
</div>
</div>
</Popup>
);
}
if (kind === 'meFacility') {
const meta = { naval: { label: 'Naval Base', color: '#60a5fa', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#f87171', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#ef4444', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#a78bfa', icon: '🔍' }, government: { label: 'Government', color: '#c084fc', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#22d3ee', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' };
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{data.flag}</span>
<strong style={{ fontSize: 13 }}>{meta.icon} {data.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
{meta.label}
</span>
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{data.country}
</span>
</div>
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{data.description}
</div>
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
{data.name}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{data.lat.toFixed(4)}&deg;{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}&deg;E
</div>
</div>
</Popup>
);
}
return null;
})()}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
{layers.meFacilities && <MEFacilityLayer />}
</Map>
);
}

파일 보기

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
@ -7,17 +7,12 @@ import { SatelliteLayer } from '../layers/SatelliteLayer';
import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker';
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useFontScale } from '../../hooks/useFontScale';
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
import { createIranOilLayers } from './createIranOilLayers';
import type { OilFacility } from './createIranOilLayers';
import { createIranAirportLayers } from './createIranAirportLayers';
import type { Airport } from './createIranAirportLayers';
import { createMEFacilityLayers } from './createMEFacilityLayers';
import type { MEFacility } from './createMEFacilityLayers';
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';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import maplibregl from 'maplibre-gl';
@ -75,7 +70,6 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
const SOURCE_COLORS: Record<string, string> = {
@ -101,49 +95,10 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
osint: 8,
};
type IranPickedFacility =
| { kind: 'oil'; data: OilFacility }
| { kind: 'airport'; data: Airport }
| { kind: 'meFacility'; data: MEFacility };
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, flyToTarget, onFlyToDone, seismicMarker }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
const { fontScale } = useFontScale();
const [zoomLevel, setZoomLevel] = useState(5);
const zoomRef = useRef(5);
const handleZoom = useCallback((e: { viewState: { zoom: number } }) => {
const z = Math.floor(e.viewState.zoom);
if (z !== zoomRef.current) {
zoomRef.current = z;
setZoomLevel(z);
}
}, []);
const zoomScale = useMemo(() => {
if (zoomLevel <= 4) return 0.8;
if (zoomLevel <= 5) return 0.9;
if (zoomLevel <= 6) return 1.0;
if (zoomLevel <= 7) return 1.2;
if (zoomLevel <= 8) return 1.5;
if (zoomLevel <= 9) return 1.8;
if (zoomLevel <= 10) return 2.2;
if (zoomLevel <= 11) return 2.5;
if (zoomLevel <= 12) return 2.8;
if (zoomLevel <= 13) return 3.5;
return 4.2;
}, [zoomLevel]);
const iranDeckLayers = useMemo(() => [
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }),
], [layers, zoomScale, fontScale.facility]);
useEffect(() => {
if (flyToTarget && mapRef.current) {
@ -179,53 +134,8 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
style={{ width: '100%', height: '100%' }}
mapStyle={SATELLITE_STYLE as maplibregl.StyleSpecification}
attributionControl={false}
onZoom={handleZoom}
>
<NavigationControl position="top-right" />
<DeckGLOverlay layers={iranDeckLayers} />
{mePickedFacility && (() => {
const meta = SUB_TYPE_META[mePickedFacility.subType];
return (
<Popup
longitude={mePickedFacility.lng}
latitude={mePickedFacility.lat}
onClose={() => setMePickedFacility(null)}
closeOnClick={false}
anchor="bottom"
maxWidth="320px"
className="gl-popup"
>
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{meta.icon} {mePickedFacility.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
{meta.label}
</span>
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{mePickedFacility.country}
</span>
{mePickedFacility.capacityMW !== undefined && (
<span style={{ background: '#222', color: '#94a3b8', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{mePickedFacility.capacityMW.toLocaleString()} MW
</span>
)}
</div>
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{mePickedFacility.description}
</div>
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
{mePickedFacility.name}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{mePickedFacility.lat.toFixed(4)}&deg;{mePickedFacility.lat >= 0 ? 'N' : 'S'}, {mePickedFacility.lng.toFixed(4)}&deg;E
</div>
</div>
</Popup>
);
})()}
{/* Korean country labels */}
<Source id="country-labels" type="geojson" data={countryLabels}>
@ -236,7 +146,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 15 * fontScale.area,
'text-size': 15,
'text-allow-overlap': false,
'text-ignore-placement': false,
}}
@ -253,7 +163,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 12 * fontScale.area,
'text-size': 12,
'text-allow-overlap': false,
}}
paint={{
@ -270,7 +180,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 10 * fontScale.area,
'text-size': 10,
'text-allow-overlap': false,
}}
paint={{
@ -355,119 +265,16 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
</Popup>
)}
{iranPickedFacility && (() => {
const { kind, data } = iranPickedFacility;
if (kind === 'oil') {
const OIL_TYPE_COLORS: Record<string, string> = {
refinery: '#fb7185', oilfield: '#34d399', gasfield: '#818cf8',
terminal: '#c084fc', petrochemical: '#f472b6', desalination: '#22d3ee',
};
const color = OIL_TYPE_COLORS[data.type] ?? '#888';
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{data.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
{data.type}
</span>
{data.operator && (
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{data.operator}
</span>
)}
</div>
{data.description && (
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{data.description}
</div>
)}
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
{data.name}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{data.lat.toFixed(4)}&deg;{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}&deg;E
</div>
</div>
</Popup>
);
}
if (kind === 'airport') {
const isMil = data.type === 'military';
const color = isMil ? '#f87171' : '#38bdf8';
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#0369a1', color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
{data.type === 'military' ? 'Military Airbase' : data.type === 'large' ? 'International Airport' : 'Airport'}
</span>
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{data.country}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px', fontSize: 11, marginBottom: 6 }}>
{data.iata && <><span style={{ color: '#888' }}>IATA</span><strong>{data.iata}</strong></>}
<span style={{ color: '#888' }}>ICAO</span><strong>{data.icao}</strong>
{data.city && <><span style={{ color: '#888' }}>City</span><span>{data.city}</span></>}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{data.lat.toFixed(4)}&deg;{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}&deg;{data.lng >= 0 ? 'E' : 'W'}
</div>
</div>
</Popup>
);
}
if (kind === 'meFacility') {
const meta = { naval: { label: 'Naval Base', color: '#60a5fa', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#f87171', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#ef4444', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#a78bfa', icon: '🔍' }, government: { label: 'Government', color: '#c084fc', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#22d3ee', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' };
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{data.flag}</span>
<strong style={{ fontSize: 13 }}>{meta.icon} {data.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
{meta.label}
</span>
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
{data.country}
</span>
</div>
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{data.description}
</div>
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
{data.name}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{data.lat.toFixed(4)}&deg;{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}&deg;E
</div>
</div>
</Popup>
);
}
return null;
})()}
{/* Overlay layers */}
{layers.aircraft && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.satellites && <SatelliteLayer satellites={satellites} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} hoveredMmsi={hoveredShipMmsi} focusMmsi={focusShipMmsi} onFocusClear={onFocusShipClear} />}
<DamagedShipLayer currentTime={currentTime} />
{seismicMarker && <SeismicMarker {...seismicMarker} />}
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
{layers.meFacilities && <MEFacilityLayer />}
<MEEnergyHazardLayer layers={layers} />
</Map>
);
}

파일 보기

@ -1,106 +0,0 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { middleEastAirports } from '../../data/airports';
import type { Airport } from '../../data/airports';
import { FONT_MONO } from '../../styles/fonts';
export { type Airport };
export const IRAN_AIRPORT_COUNT = middleEastAirports.length;
const US_BASE_ICAOS = new Set([
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
]);
function getAirportColor(airport: Airport): string {
const isMil = airport.type === 'military';
const isUS = isMil && US_BASE_ICAOS.has(airport.icao);
if (isUS) return '#60a5fa'; // blue-400
if (isMil) return '#f87171'; // red-400
if (airport.type === 'international') return '#38bdf8'; // sky-400 (was amber)
return '#a5b4fc'; // indigo-200 (was gray)
}
function airportSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
fill="${color}" stroke="#fff" stroke-width="0.3"/>
</svg>`;
}
const iconCache = new Map<string, string>();
function getIconUrl(airport: Airport): string {
const color = getAirportColor(airport);
const size = airport.type === 'military' && US_BASE_ICAOS.has(airport.icao) ? 48 : 40;
const key = `${color}-${size}`;
if (!iconCache.has(key)) {
iconCache.set(key, svgToDataUri(airportSvg(color, size)));
}
return iconCache.get(key)!;
}
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b, alpha];
}
export interface IranAirportLayerConfig {
visible: boolean;
sc: number;
fs?: number;
onPick: (airport: Airport) => void;
}
export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] {
const { visible, sc, onPick } = config;
const fs = config.fs ?? 1;
if (!visible) return [];
const iconLayer = new IconLayer<Airport>({
id: 'iran-airport-icon',
data: middleEastAirports,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => {
const isMilUS = d.type === 'military' && US_BASE_ICAOS.has(d.icao);
const sz = isMilUS ? 48 : 40;
return { url: getIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
},
getSize: (d) => (d.type === 'military' && US_BASE_ICAOS.has(d.icao) ? 20 : 16) * sc,
updateTriggers: { getSize: [sc] },
pickable: true,
onClick: (info: PickingInfo<Airport>) => {
if (info.object) onPick(info.object);
return true;
},
});
const labelLayer = new TextLayer<Airport>({
id: 'iran-airport-label',
data: middleEastAirports,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => {
const nameKo = d.nameKo ?? d.name;
return nameKo.length > 10 ? nameKo.slice(0, 10) + '..' : nameKo;
},
getSize: 11 * sc * fs,
updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(getAirportColor(d)),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontWeight: 700,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
});
return [iconLayer, labelLayer];
}

파일 보기

@ -1,156 +0,0 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { iranOilFacilities } from '../../data/oilFacilities';
import type { OilFacility, OilFacilityType } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
export { type OilFacility };
export const IRAN_OIL_COUNT = iranOilFacilities.length;
const TYPE_COLORS: Record<OilFacilityType, string> = {
refinery: '#fb7185', // rose-400 (was amber — desert에 묻힘)
oilfield: '#34d399', // emerald-400
gasfield: '#818cf8', // indigo-400
terminal: '#c084fc', // purple-400
petrochemical: '#f472b6', // pink-400
desalination: '#22d3ee', // cyan-400
};
function refinerySvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<rect x="5" y="10" width="14" height="8" rx="1" fill="${color}" opacity="0.7"/>
<rect x="7" y="6" width="2" height="5" fill="${color}" opacity="0.6"/>
<rect x="11" y="4" width="2" height="7" fill="${color}" opacity="0.6"/>
<rect x="15" y="7" width="2" height="4" fill="${color}" opacity="0.6"/>
</svg>`;
}
function oilfieldSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<line x1="6" y1="18" x2="18" y2="18" stroke="${color}" stroke-width="1.5"/>
<line x1="12" y1="8" x2="8" y2="18" stroke="${color}" stroke-width="1.5"/>
<line x1="12" y1="8" x2="16" y2="18" stroke="${color}" stroke-width="1.5"/>
<line x1="5" y1="10" x2="17" y2="9" stroke="${color}" stroke-width="2"/>
<circle cx="12" cy="8" r="1.5" fill="${color}"/>
</svg>`;
}
function gasfieldSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<ellipse cx="12" cy="14" rx="5" ry="4" fill="${color}" opacity="0.6"/>
<line x1="12" y1="10" x2="12" y2="5" stroke="${color}" stroke-width="1.5"/>
<path d="M9 7 Q12 4 15 7" fill="none" stroke="${color}" stroke-width="1"/>
<path d="M10 5.5 Q12 3 14 5.5" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.7"/>
</svg>`;
}
function terminalSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<line x1="12" y1="5" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
<path d="M8 9 Q12 6 16 9 Q16 14 12 16 Q8 14 8 9Z" fill="${color}" opacity="0.5"/>
<path d="M6 16 Q12 20 18 16" fill="none" stroke="${color}" stroke-width="1.2"/>
<line x1="8" y1="12" x2="16" y2="12" stroke="${color}" stroke-width="1"/>
</svg>`;
}
function petrochemSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<rect x="10" y="5" width="4" height="9" rx="1" fill="${color}" opacity="0.6"/>
<ellipse cx="12" cy="15" rx="4" ry="2.5" fill="${color}" opacity="0.7"/>
<line x1="7" y1="10" x2="10" y2="10" stroke="${color}" stroke-width="1"/>
<line x1="14" y1="10" x2="17" y2="10" stroke="${color}" stroke-width="1"/>
<path d="M7 10 Q6 13 8 15" fill="none" stroke="${color}" stroke-width="0.8"/>
</svg>`;
}
function desalinationSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M12 5 Q16 10 16 14 Q16 18 12 18 Q8 18 8 14 Q8 10 12 5Z" fill="${color}" opacity="0.7"/>
<path d="M10 14 Q12 16 14 14" fill="none" stroke="#fff" stroke-width="0.8" opacity="0.6"/>
</svg>`;
}
type SvgFn = (color: string, size: number) => string;
const TYPE_SVG_FN: Record<OilFacilityType, SvgFn> = {
refinery: refinerySvg,
oilfield: oilfieldSvg,
gasfield: gasfieldSvg,
terminal: terminalSvg,
petrochemical: petrochemSvg,
desalination: desalinationSvg,
};
const iconCache = new Map<string, string>();
function getIconUrl(type: OilFacilityType): string {
if (!iconCache.has(type)) {
const color = TYPE_COLORS[type];
iconCache.set(type, svgToDataUri(TYPE_SVG_FN[type](color, 64)));
}
return iconCache.get(type)!;
}
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b, alpha];
}
export interface IranOilLayerConfig {
visible: boolean;
sc: number;
fs?: number;
onPick: (facility: OilFacility) => void;
}
export function createIranOilLayers(config: IranOilLayerConfig): Layer[] {
const { visible, sc, onPick } = config;
const fs = config.fs ?? 1;
if (!visible) return [];
const iconLayer = new IconLayer<OilFacility>({
id: 'iran-oil-icon',
data: iranOilFacilities,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({ url: getIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
getSize: 18 * sc,
updateTriggers: { getSize: [sc] },
pickable: true,
onClick: (info: PickingInfo<OilFacility>) => {
if (info.object) onPick(info.object);
return true;
},
});
const labelLayer = new TextLayer<OilFacility>({
id: 'iran-oil-label',
data: iranOilFacilities,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo,
getSize: 12 * sc * fs,
updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(TYPE_COLORS[d.type]),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontWeight: 700,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
});
return [iconLayer, labelLayer];
}

파일 보기

@ -1,151 +0,0 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities';
import type { MEFacility } from '../../data/middleEastFacilities';
import { FONT_MONO } from '../../styles/fonts';
export { type MEFacility };
export const ME_FACILITY_COUNT = ME_FACILITIES.length;
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b, alpha];
}
function navalSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<line x1="12" y1="5" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1.5"/>
<path d="M7 9 Q6 13 8 15" fill="none" stroke="${color}" stroke-width="1"/>
<path d="M17 9 Q18 13 16 15" fill="none" stroke="${color}" stroke-width="1"/>
<path d="M8 15 Q12 18 16 15" fill="none" stroke="${color}" stroke-width="1.2"/>
<circle cx="12" cy="5" r="1.5" fill="${color}"/>
</svg>`;
}
function militaryHqSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<polygon points="12,5 13.8,10.1 19.2,10.1 14.7,13.2 16.5,18.3 12,15.2 7.5,18.3 9.3,13.2 4.8,10.1 10.2,10.1" fill="${color}" opacity="0.85"/>
</svg>`;
}
function missileSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<path d="M12 5 L14 10 L14 17 L12 19 L10 17 L10 10 Z" fill="${color}" opacity="0.85"/>
<path d="M10 13 L7 16 L10 15 Z" fill="${color}" opacity="0.7"/>
<path d="M14 13 L17 16 L14 15 Z" fill="${color}" opacity="0.7"/>
<path d="M11 10 L13 10 L13 8 Q12 6 11 8 Z" fill="#fff" opacity="0.5"/>
</svg>`;
}
function intelligenceSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<ellipse cx="11" cy="11" rx="5" ry="5" fill="none" stroke="${color}" stroke-width="1.5"/>
<circle cx="11" cy="11" r="2" fill="${color}" opacity="0.7"/>
<line x1="15" y1="15" x2="18" y2="18" stroke="${color}" stroke-width="2" stroke-linecap="round"/>
</svg>`;
}
function governmentSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<line x1="5" y1="18" x2="19" y2="18" stroke="${color}" stroke-width="1.2"/>
<line x1="12" y1="5" x2="5" y2="9" stroke="${color}" stroke-width="1"/>
<line x1="12" y1="5" x2="19" y2="9" stroke="${color}" stroke-width="1"/>
<line x1="5" y1="9" x2="19" y2="9" stroke="${color}" stroke-width="1"/>
<rect x="7" y="10" width="2" height="8" fill="${color}" opacity="0.7"/>
<rect x="11" y="10" width="2" height="8" fill="${color}" opacity="0.7"/>
<rect x="15" y="10" width="2" height="8" fill="${color}" opacity="0.7"/>
</svg>`;
}
function radarSvg(color: string, size: number): string {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
<ellipse cx="12" cy="9" rx="5" ry="3" fill="none" stroke="${color}" stroke-width="1.2"/>
<line x1="12" y1="12" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
<path d="M7 7 Q9 4 12 4 Q15 4 17 7" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.6"/>
<path d="M5 9 Q7 5 12 5 Q17 5 19 9" fill="none" stroke="${color}" stroke-width="0.6" opacity="0.4"/>
<circle cx="12" cy="9" r="1.2" fill="${color}"/>
</svg>`;
}
type MEFacilityType = MEFacility['type'];
type SvgFn = (color: string, size: number) => string;
const TYPE_SVG_FN: Record<MEFacilityType, SvgFn> = {
naval: navalSvg,
military_hq: militaryHqSvg,
missile: missileSvg,
intelligence: intelligenceSvg,
government: governmentSvg,
radar: radarSvg,
};
const iconCache = new Map<string, string>();
function getIconUrl(type: MEFacilityType): string {
if (!iconCache.has(type)) {
const color = ME_FACILITY_TYPE_META[type].color;
iconCache.set(type, svgToDataUri(TYPE_SVG_FN[type](color, 64)));
}
return iconCache.get(type)!;
}
export interface MEFacilityLayerConfig {
visible: boolean;
sc: number;
fs?: number;
onPick: (facility: MEFacility) => void;
}
export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
const { visible, sc, onPick } = config;
const fs = config.fs ?? 1;
if (!visible) return [];
const iconLayer = new IconLayer<MEFacility>({
id: 'me-facility-icon',
data: ME_FACILITIES,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({ url: getIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
getSize: 18 * sc,
updateTriggers: { getSize: [sc] },
pickable: true,
onClick: (info: PickingInfo<MEFacility>) => {
if (info.object) onPick(info.object);
return true;
},
});
const labelLayer = new TextLayer<MEFacility>({
id: 'me-facility-label',
data: ME_FACILITIES,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 12 * sc * fs,
updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 12],
fontFamily: FONT_MONO,
fontWeight: 700,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
});
return [iconLayer, labelLayer];
}

파일 보기

@ -1,76 +1,72 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useAuth } from '../../hooks/useAuth';
import type { Ship } from '../../types';
interface ChatMessage {
role: 'user' | 'assistant';
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
isStreaming?: boolean;
}
const AI_CHAT_URL = '/api/prediction-chat';
/** assistant 메시지에서 thinking(JSON tool call, 구분선 등)과 답변을 분리 */
function splitThinking(content: string): { thinking: string; answer: string } {
// 패턴: ```json...``` 블록 + ---\n_데이터 조회 완료..._\n\n 까지가 thinking
const thinkingPattern = /^([\s\S]*?```json[\s\S]*?```[\s\S]*?---\n_[^_]*_\n*)/;
const match = content.match(thinkingPattern);
if (match) {
return { thinking: match[1].trim(), answer: content.slice(match[0].length).trim() };
}
// ```json 블록만 있고 답변이 아직 안 온 경우 (스트리밍 중)
const jsonOnly = /^([\s\S]*```json[\s\S]*?```[\s\S]*)$/;
const m2 = content.match(jsonOnly);
if (m2 && !content.includes('---')) {
return { thinking: m2[1].trim(), answer: '' };
}
return { thinking: '', answer: content };
interface Props {
ships: Ship[];
koreanShipCount: number;
chineseShipCount: number;
totalShipCount: number;
}
export function AiChatPanel() {
const { user } = useAuth();
const userId = user?.email ?? 'anonymous';
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 [elapsed, setElapsed] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [expanded, setExpanded] = useState(false);
const [historyLoaded, setHistoryLoaded] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
// 마운트 시 Redis에서 대화 히스토리 로드
useEffect(() => {
if (historyLoaded) return;
fetch(`${AI_CHAT_URL}/history?user_id=${encodeURIComponent(userId)}`)
.then(res => res.ok ? res.json() : [])
.then((history: { role: string; content: string }[]) => {
if (history.length > 0) {
setMessages(history.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
timestamp: Date.now(),
})));
}
})
.catch(() => { /* Redis 미연결 시 무시 */ })
.finally(() => setHistoryLoaded(true));
}, [userId, historyLoaded]);
useEffect(() => {
if (isLoading) {
setElapsed(0);
timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000);
} else if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [isLoading]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@ -88,166 +84,72 @@ export function AiChatPanel() {
setInput('');
setIsLoading(true);
// 스트리밍 placeholder 추가
const streamingMsg: ChatMessage = { role: 'assistant', content: '', timestamp: Date.now(), isStreaming: true };
setMessages(prev => [...prev, streamingMsg]);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(AI_CHAT_URL, {
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({
message: userMsg.content,
user_id: userId,
stream: true,
model: 'qwen2.5:7b',
messages: apiMessages,
stream: false,
options: { temperature: 0.3, num_predict: 1024 },
}),
signal: controller.signal,
});
if (!res.ok) throw new Error(`서버 오류: ${res.status}`);
if (!res.body) throw new Error('스트리밍 미지원');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let accumulated = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const chunk = JSON.parse(data) as { content: string; done: boolean };
accumulated += chunk.content;
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: accumulated,
};
return updated;
});
if (chunk.done) break;
} catch {
// JSON 파싱 실패 무시
}
}
}
// 스트리밍 완료
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
isStreaming: false,
};
return updated;
});
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) {
if ((err as Error).name === 'AbortError') return;
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}`,
timestamp: Date.now(),
isStreaming: false,
};
return updated;
});
setMessages(prev => [...prev, {
role: 'assistant',
content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}. Ollama가 실행 중인지 확인하세요.`,
timestamp: Date.now(),
}]);
} finally {
setIsLoading(false);
abortRef.current = null;
}
}, [input, isLoading, userId]);
const clearHistory = useCallback(async () => {
setMessages([]);
try {
await fetch(`${AI_CHAT_URL}/history?user_id=${encodeURIComponent(userId)}`, { method: 'DELETE' });
} catch { /* 무시 */ }
}, [userId]);
}, [input, isLoading, messages, ships, koreanShipCount, chineseShipCount, totalShipCount]);
const quickQuestions = [
'현재 해양 상황을 요약해줘',
'중국어선 불법조업 의심 분석해줘',
'위험 선박 상위 10척 알려줘',
'서해 위험도를 평가해줘',
'다크베셀 현황 분석해줘',
];
return (
<div style={{
...(expanded ? {
position: 'fixed' as const,
bottom: 16,
right: 16,
width: 520,
height: 600,
zIndex: 9999,
borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
display: 'flex',
flexDirection: 'column' as const,
background: 'rgba(12,24,37,0.97)',
border: '1px solid rgba(168,85,247,0.3)',
} : {
borderTop: '1px solid rgba(168,85,247,0.2)',
marginTop: 8,
}),
borderTop: '1px solid rgba(168,85,247,0.2)',
marginTop: 8,
}}>
{/* Toggle header */}
<div
onClick={() => {
if (!isOpen) { setIsOpen(true); return; }
if (expanded) { setExpanded(false); return; }
setExpanded(true);
}}
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: expanded ? '8px 8px 0 0' : 4,
borderLeft: expanded ? 'none' : '2px solid rgba(168,85,247,0.5)',
flexShrink: 0,
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 }}>
Qwen3 14B
</span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4, alignItems: 'center' }}>
{isOpen && (
<button
onClick={e => { e.stopPropagation(); setIsOpen(false); setExpanded(false); }}
title="접기"
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 8, color: '#8b5cf6',
padding: '8px 10px', margin: '-8px -8px -8px -6px',
lineHeight: 1,
}}
>
{expanded ? '⊖' : '▼'}
</button>
)}
{!isOpen && (
<span style={{ fontSize: 8, color: '#8b5cf6' }}></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>
@ -255,11 +157,10 @@ export function AiChatPanel() {
{isOpen && (
<div style={{
display: 'flex', flexDirection: 'column',
...(expanded ? { flex: 1 } : { height: 360 }),
background: expanded ? 'transparent' : 'rgba(88,28,135,0.08)',
height: 360, background: 'rgba(88,28,135,0.08)',
borderRadius: '0 0 6px 6px', overflow: 'hidden',
borderLeft: expanded ? 'none' : '2px solid rgba(168,85,247,0.3)',
borderBottom: expanded ? 'none' : '1px solid rgba(168,85,247,0.15)',
borderLeft: '2px solid rgba(168,85,247,0.3)',
borderBottom: '1px solid rgba(168,85,247,0.15)',
}}>
{/* Messages */}
<div style={{
@ -290,79 +191,34 @@ export function AiChatPanel() {
</div>
</div>
)}
{messages.map((msg, i) => {
const isAssistant = msg.role === 'assistant';
const { thinking, answer } = isAssistant ? splitThinking(msg.content) : { thinking: '', answer: msg.content };
const displayText = isAssistant ? (answer || (thinking && !msg.isStreaming ? '' : msg.content)) : msg.content;
return (
<div
key={i}
style={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
}}
>
{/* thinking 접기 블록 */}
{isAssistant && thinking && (
<details style={{
background: 'rgba(100,116,139,0.1)',
borderRadius: '6px 6px 0 0',
padding: '4px 8px',
fontSize: 9,
color: '#64748b',
cursor: 'pointer',
borderLeft: '2px solid rgba(139,92,246,0.3)',
}}>
<summary style={{ userSelect: 'none', outline: 'none' }}> </summary>
<pre style={{
margin: '4px 0 0', padding: '4px',
fontSize: 8, color: '#94a3b8',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
background: 'rgba(0,0,0,0.2)', borderRadius: 3,
maxHeight: 120, overflowY: 'auto',
}}>{thinking}</pre>
</details>
)}
{/* 메시지 본문 */}
<div style={{
background: msg.role === 'user'
? 'rgba(139,92,246,0.25)'
: 'rgba(168,85,247,0.08)',
borderRadius: thinking
? '0 0 8px 8px'
: (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',
}}>
{displayText}
{msg.isStreaming && msg.content && (
<span style={{ color: '#a78bfa' }}>
<span style={{ animation: 'pulse 1s infinite' }}> </span>
<span style={{ fontSize: 8, color: '#64748b', marginLeft: 4, fontVariantNumeric: 'tabular-nums' }}>
{String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}
</span>
</span>
)}
</div>
</div>
);
})}
{isLoading && !messages[messages.length - 1]?.content && (
{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',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ animation: 'pulse 1.5s ease-in-out infinite' }}> </span>
<span style={{ color: '#64748b', fontVariantNumeric: 'tabular-nums' }}>
{String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}
</span>
...
</div>
)}
<div ref={messagesEndRef} />
@ -374,24 +230,11 @@ export function AiChatPanel() {
borderTop: '1px solid rgba(255,255,255,0.06)',
background: 'rgba(0,0,0,0.15)',
}}>
{messages.length > 0 && (
<button
onClick={clearHistory}
title="대화 초기화"
style={{
background: 'none', border: 'none',
color: '#64748b', fontSize: 12, cursor: 'pointer',
padding: '0 4px', flexShrink: 0,
}}
>
</button>
)}
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void sendMessage(); } }}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
placeholder="해양 상황 질문..."
disabled={isLoading}
style={{
@ -402,7 +245,7 @@ export function AiChatPanel() {
}}
/>
<button
onClick={() => { void sendMessage(); }}
onClick={sendMessage}
disabled={isLoading || !input.trim()}
style={{
background: isLoading || !input.trim() ? '#334155' : '#7c3aed',

파일 보기

@ -1,17 +1,8 @@
import { useState, useMemo, useEffect } from 'react';
import type { VesselAnalysisDto, Ship } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { fetchVesselTrack } from '../../services/vesselTrack';
import {
type AlertLevel,
ALERT_COLOR,
ALERT_EMOJI,
ALERT_LEVELS,
STATS_KEY_MAP,
RISK_TO_ALERT,
} from '../../constants/riskMapping';
interface Props {
stats: AnalysisStats;
@ -19,6 +10,7 @@ interface Props {
isLoading: boolean;
analysisMap: Map<string, VesselAnalysisDto>;
ships: Ship[];
allShips?: Ship[];
onShipSelect?: (mmsi: string) => void;
onTrackLoad?: (mmsi: string, coords: [number, number][]) => void;
onExpandedChange?: (expanded: boolean) => void;
@ -40,6 +32,22 @@ function formatTime(ms: number): string {
return `${hh}:${mm}`;
}
const RISK_COLOR: Record<RiskLevel, string> = {
CRITICAL: '#ef4444',
HIGH: '#f97316',
MEDIUM: '#eab308',
LOW: '#22c55e',
};
const RISK_EMOJI: Record<RiskLevel, string> = {
CRITICAL: '🔴',
HIGH: '🟠',
MEDIUM: '🟡',
LOW: '🟢',
};
const RISK_LEVELS: RiskLevel[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
const LEGEND_LINES = [
'위험도 점수 기준 (0~100)',
'',
@ -57,15 +65,15 @@ const LEGEND_LINES = [
'■ 허가 이력 (최대 20점)',
' 미허가 어선: 20',
'',
'CRITICAL ≥70 / WATCH ≥50',
'MONITOR ≥30 / NORMAL <30',
'CRITICAL ≥70 / HIGH ≥50',
'MEDIUM ≥30 / LOW <30',
'',
'UCAF: 어구별 조업속도 매칭 비율',
'UCFT: 조업-항행 구분 신뢰도',
'스푸핑: 순간이동+SOG급변+BD09 종합',
];
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect, onTrackLoad, onExpandedChange }: Props) {
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad, onExpandedChange }: Props) {
const [expanded, setExpanded] = useLocalStorage('analysisPanelExpanded', false);
const toggleExpanded = () => {
const next = !expanded;
@ -75,24 +83,44 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
// 마운트 시 저장된 상태를 부모에 동기화
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { onExpandedChange?.(expanded); }, []);
const [selectedLevel, setSelectedLevel] = useState<AlertLevel | null>(null);
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [showLegend, setShowLegend] = useState(false);
const isEmpty = useMemo(() => stats.total === 0, [stats.total]);
const gearStats = useMemo(() => {
const source = allShips ?? ships;
const gearPattern = /^(.+?)_\d+_\d+_?$/;
const STALE_MS = 60 * 60_000; // 60분 이내만
const now = Date.now();
const parentMap = new Map<string, number>();
for (const s of source) {
if (now - s.lastSeen > STALE_MS) continue;
const m = (s.name || '').match(gearPattern);
if (m) {
const parent = m[1].trim();
parentMap.set(parent, (parentMap.get(parent) || 0) + 1);
}
}
return {
groups: parentMap.size,
count: Array.from(parentMap.values()).reduce((a, b) => a + b, 0),
};
}, [allShips, ships]);
const vesselList = useMemo((): VesselListItem[] => {
if (!selectedLevel) return [];
const list: VesselListItem[] = [];
for (const [mmsi, dto] of analysisMap) {
if (RISK_TO_ALERT[dto.algorithms.riskScore.level] !== selectedLevel) continue;
if (dto.algorithms.riskScore.level !== selectedLevel) continue;
const ship = ships.find(s => s.mmsi === mmsi);
list.push({ mmsi, name: ship?.name || mmsi, score: dto.algorithms.riskScore.score, dto });
}
return list.sort((a, b) => b.score - a.score).slice(0, 50);
}, [selectedLevel, analysisMap, ships]);
const handleLevelClick = (level: AlertLevel) => {
const handleLevelClick = (level: RiskLevel) => {
setSelectedLevel(prev => (prev === level ? null : level));
setSelectedMmsi(null);
};
@ -115,7 +143,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8,
color: '#e2e8f0',
fontFamily: FONT_MONO,
fontFamily: 'monospace, sans-serif',
fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
@ -247,15 +275,15 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
</div>
{stats.gearGroups > 0 && (
{gearStats.groups > 0 && (
<>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{stats.gearGroups}</span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.groups}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{stats.gearCount}</span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.count}</span>
</div>
</>
)}
@ -264,9 +292,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
{/* 위험도 카운트 행 — 클릭 가능 */}
<div style={riskRowStyle}>
{ALERT_LEVELS.map(level => {
const count = stats[STATS_KEY_MAP[level]];
const color = ALERT_COLOR[level];
{RISK_LEVELS.map(level => {
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
const isActive = selectedLevel === level;
return (
<button
@ -277,18 +304,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
display: 'flex',
alignItems: 'center',
gap: 2,
background: isActive ? `${color}22` : 'none',
border: isActive ? `1px solid ${color}88` : '1px solid transparent',
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
borderRadius: 4,
color: '#cbd5e1',
fontSize: 10,
cursor: 'pointer',
padding: '2px 4px',
fontFamily: FONT_MONO,
fontFamily: 'monospace, sans-serif',
}}
>
<span>{ALERT_EMOJI[level]}</span>
<span style={{ color, fontWeight: 700 }}>{count}</span>
<span>{RISK_EMOJI[level]}</span>
<span style={{ color: RISK_COLOR[level], fontWeight: 700 }}>{count}</span>
</button>
);
})}
@ -299,12 +326,12 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<>
<div style={{ ...dividerStyle, marginTop: 8 }} />
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
{ALERT_EMOJI[selectedLevel]} {selectedLevel} {vesselList.length}
{RISK_EMOJI[selectedLevel]} {selectedLevel} {vesselList.length}
</div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{vesselList.map(item => {
const isExpanded = selectedMmsi === item.mmsi;
const color = ALERT_COLOR[selectedLevel];
const color = RISK_COLOR[selectedLevel];
const { dto } = item;
return (
<div key={item.mmsi}>

파일 보기

@ -1,460 +0,0 @@
import { useState, useMemo, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
import type { MemberInfo } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { FONT_MONO } from '../../styles/fonts';
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useTranslation } from 'react-i18next';
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
interface CorrelationPanelProps {
selectedGearGroup: string;
memberCount: number;
groupPolygons: UseGroupPolygonsResult | undefined;
correlationByModel: Map<string, GearCorrelationItem[]>;
availableModels: { name: string; count: number; isDefault: boolean }[];
enabledModels: Set<string>;
enabledVessels: Set<string>;
correlationLoading: boolean;
hoveredTarget: { mmsi: string; model: string } | null;
hasRightReviewPanel?: boolean;
reviewDriven?: boolean;
onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void;
}
// Ensure MODEL_ORDER is treated as string array for Record lookups
const _MODEL_ORDER: string[] = MODEL_ORDER as unknown as string[];
const CorrelationPanel = ({
selectedGearGroup,
memberCount,
groupPolygons,
correlationByModel,
availableModels,
enabledModels,
enabledVessels,
correlationLoading,
hoveredTarget,
hasRightReviewPanel = false,
reviewDriven = false,
onEnabledModelsChange,
onEnabledVesselsChange,
onHoveredTargetChange,
}: CorrelationPanelProps) => {
const { t } = useTranslation();
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
const layout = useReplayCenterPanelLayout({
minWidth: 252,
maxWidth: 966,
hasRightReviewPanel,
});
// Local tooltip state
const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null);
const [pinnedModelTip, setPinnedModelTip] = useState<string | null>(null);
const activeModelTip = pinnedModelTip ?? hoveredModelTip;
// Card expand state
const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
// Card ref map for tooltip positioning
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const setCardRef = useCallback((model: string, el: HTMLDivElement | null) => {
if (el) cardRefs.current.set(model, el);
else cardRefs.current.delete(model);
}, []);
const toggleCardExpand = (model: string) => {
setExpandedCards(prev => {
const next = new Set(prev);
if (next.has(model)) next.delete(model); else next.add(model);
return next;
});
};
// Identity 목록: 리플레이 활성 시 전체 구간 멤버, 아닐 때 현재 스냅샷 멤버
const allHistoryMembers = useGearReplayStore(s => s.allHistoryMembers);
const { identityVessels, identityGear } = useMemo(() => {
if (historyActive && allHistoryMembers.length > 0) {
return {
identityVessels: allHistoryMembers.filter(m => m.isParent),
identityGear: allHistoryMembers.filter(m => !m.isParent),
};
}
if (!groupPolygons || !selectedGearGroup) return { identityVessels: [] as MemberInfo[], identityGear: [] as MemberInfo[] };
const allGear = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const matches = allGear.filter(g => g.groupKey === selectedGearGroup);
const seen = new Set<string>();
const members: MemberInfo[] = [];
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); } }
return {
identityVessels: members.filter(m => m.isParent),
identityGear: members.filter(m => !m.isParent),
};
}, [historyActive, allHistoryMembers, groupPolygons, selectedGearGroup]);
// Suppress unused MODEL_ORDER warning — used for ordering checks
void _MODEL_ORDER;
// Common card styles
const CARD_WIDTH = 180;
const cardStyle: React.CSSProperties = {
background: 'rgba(12,24,37,0.95)',
borderRadius: 6,
width: CARD_WIDTH,
minWidth: CARD_WIDTH,
flexShrink: 0,
border: '1px solid rgba(255,255,255,0.08)',
position: 'relative',
};
const CARD_COLLAPSED_H = 200;
const CARD_EXPANDED_H = 500;
const cardFooterStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 8px 6px',
cursor: 'pointer', userSelect: 'none',
borderTop: '1px solid rgba(255,255,255,0.06)',
};
const getCardBodyStyle = (model: string): React.CSSProperties => ({
padding: '6px 8px 4px',
maxHeight: expandedCards.has(model) ? CARD_EXPANDED_H : CARD_COLLAPSED_H,
overflowY: 'auto',
transition: 'max-height 0.2s ease',
});
// Model title tooltip: hover → show, right-click → pin
const handleTipHover = (model: string) => {
if (!pinnedModelTip) setHoveredModelTip(model);
};
const handleTipLeave = () => {
if (!pinnedModelTip) setHoveredModelTip(null);
};
const handleTipContextMenu = (e: React.MouseEvent, model: string) => {
e.preventDefault();
setPinnedModelTip(prev => prev === model ? null : model);
setHoveredModelTip(null);
};
// 툴팁은 카드 밖에서 fixed로 렌더 (overflow 영향 안 받음)
const renderFloatingTip = () => {
if (!activeModelTip) return null;
const desc = MODEL_DESC[activeModelTip];
if (!desc) return null;
const el = cardRefs.current.get(activeModelTip);
if (!el) return null;
const rect = el.getBoundingClientRect();
const color = MODEL_COLORS[activeModelTip] ?? '#94a3b8';
return (
<div style={{
position: 'fixed',
left: rect.left,
top: rect.top - 4,
transform: 'translateY(-100%)',
padding: '6px 10px',
background: 'rgba(15,23,42,0.97)',
border: `1px solid ${color}66`,
borderRadius: 5,
fontSize: 9,
color: '#e2e8f0',
zIndex: 50,
whiteSpace: 'nowrap',
boxShadow: '0 2px 12px rgba(0,0,0,0.6)',
pointerEvents: pinnedModelTip ? 'auto' : 'none',
fontFamily: FONT_MONO,
}}>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{desc.summary}</div>
{desc.details.map((line, i) => (
<div key={i} style={{ color: '#94a3b8', lineHeight: 1.5 }}>{line}</div>
))}
{pinnedModelTip && (
<div style={{
color: '#64748b', fontSize: 8, marginTop: 4,
borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 3,
}}>
</div>
)}
</div>
);
};
// Common row renderer (correlation target — with score bar, model-independent hover)
const toggleVessel = (mmsi: string) => {
onEnabledVesselsChange(prev => {
const next = new Set(prev);
if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi);
return next;
});
};
const renderRow = (c: GearCorrelationItem, color: string, modelName: string) => {
const pct = (c.score * 100).toFixed(0);
const barW = Math.max(2, c.score * 30);
const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8';
const isVessel = c.targetType === 'VESSEL';
const isEnabled = enabledVessels.has(c.targetMmsi);
const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName;
return (
<div
key={`${modelName}-${c.targetMmsi}`}
style={{
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
padding: '1px 2px', borderRadius: 2, cursor: reviewDriven ? 'default' : 'pointer',
background: isHovered ? `${color}22` : 'transparent',
opacity: reviewDriven ? 1 : isEnabled ? 1 : 0.5,
}}
onClick={reviewDriven ? undefined : () => toggleVessel(c.targetMmsi)}
onMouseEnter={reviewDriven ? undefined : () => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
onMouseLeave={reviewDriven ? undefined : () => onHoveredTargetChange(null)}
>
{reviewDriven ? (
<span
title={t('parentInference.reference.reviewDriven')}
style={{
width: 9,
height: 9,
borderRadius: 999,
background: color,
flexShrink: 0,
opacity: 0.9,
}}
/>
) : (
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
)}
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
{isVessel ? '⛴' : '◆'}
</span>
<span style={{ color: '#cbd5e1', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.targetName || c.targetMmsi}
</span>
<div style={{ width: 50, display: 'flex', alignItems: 'center', gap: 2, flexShrink: 0 }}>
<div style={{ width: 24, height: 3, background: 'rgba(255,255,255,0.08)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ width: barW, height: '100%', background: barColor, borderRadius: 2 }} />
</div>
<span style={{ color: barColor, fontSize: 8, minWidth: 20, textAlign: 'right' }}>{pct}%</span>
</div>
</div>
);
};
const visibleModelNames = useMemo(() => {
if (reviewDriven) {
return availableModels
.filter(model => (correlationByModel.get(model.name) ?? []).length > 0)
.map(model => model.name);
}
return availableModels.filter(model => enabledModels.has(model.name)).map(model => model.name);
}, [availableModels, correlationByModel, enabledModels, reviewDriven]);
// Member row renderer (identity model — no score, independent hover)
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => {
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
return (
<div
key={`${keyPrefix}-${m.mmsi}`}
style={{
fontSize: 9,
marginBottom: 1,
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '1px 2px',
borderRadius: 2,
cursor: 'default',
background: isHovered ? 'rgba(249,115,22,0.15)' : 'transparent',
}}
onMouseEnter={() => onHoveredTargetChange({ mmsi: m.mmsi, model: 'identity' })}
onMouseLeave={() => onHoveredTargetChange(null)}
>
<span style={{ color: iconColor, width: 10, textAlign: 'center', flexShrink: 0 }}>{icon}</span>
<span style={{ color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{m.name || m.mmsi}
</span>
</div>
);
};
return (
<div style={{
position: 'absolute',
bottom: historyActive ? 120 : 20,
left: `${layout.left}px`,
width: `${layout.width}px`,
display: 'flex',
gap: 6,
alignItems: 'flex-end',
zIndex: 21,
fontFamily: FONT_MONO,
fontSize: 10,
color: '#e2e8f0',
pointerEvents: 'auto',
}}>
{/* 고정: 토글 패널 (스크롤 밖) */}
<div style={{
background: 'rgba(12,24,37,0.95)',
border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8,
padding: '8px 10px',
width: 165,
minWidth: 165,
flexShrink: 0,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<span style={{ fontWeight: 700, color: '#f97316', fontSize: 11 }}>{selectedGearGroup}</span>
<span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}</span>
</div>
<div style={{
marginBottom: 7,
padding: '6px 7px',
borderRadius: 6,
background: 'rgba(15,23,42,0.72)',
border: '1px solid rgba(249,115,22,0.14)',
color: '#cbd5e1',
fontSize: 8,
lineHeight: 1.45,
whiteSpace: 'normal',
wordBreak: 'keep-all',
}}>
{reviewDriven
? t('parentInference.reference.reviewDriven')
: t('parentInference.reference.shipOnly')}
</div>
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 4 }}> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
<input
type="checkbox"
checked={true}
disabled
style={{ accentColor: '#f97316', width: 11, height: 11, opacity: 0.6 }}
title="이름 기반 (항상 ON)"
/>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ color: '#94a3b8' }}> ()</span>
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{memberCount}</span>
</label>
{correlationLoading && <div style={{ fontSize: 8, color: '#64748b' }}>...</div>}
{_MODEL_ORDER.filter(mn => mn !== 'identity').map(mn => {
const color = MODEL_COLORS[mn] ?? '#94a3b8';
const modelItems = correlationByModel.get(mn) ?? [];
const hasData = modelItems.length > 0;
const vc = modelItems.filter(c => c.targetType === 'VESSEL').length;
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
const am = availableModels.find(m => m.name === mn);
return (
<label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: reviewDriven ? 'default' : hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}>
{reviewDriven ? (
<span style={{ width: 11, height: 11, borderRadius: 999, background: hasData ? color : 'rgba(148,163,184,0.2)', flexShrink: 0 }} />
) : (
<input type="checkbox" checked={enabledModels.has(mn)}
disabled={!hasData}
onChange={() => onEnabledModelsChange(prev => {
const next = new Set(prev);
if (next.has(mn)) next.delete(mn); else next.add(mn);
return next;
})}
style={{ accentColor: color, width: 11, height: 11 }} title={mn} />
)}
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, opacity: hasData ? 1 : 0.3 }} />
<span style={{ color: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span>
<span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}${gc}` : '—'}</span>
</label>
);
})}
</div>
{/* 스크롤 영역: 모델 카드들 */}
<div style={{
display: 'flex', gap: 6, alignItems: 'flex-end',
overflowX: 'auto', overflowY: 'visible', flex: 1, minWidth: 0,
}}>
{/* 이름 기반 카드 (체크 시) */}
{(reviewDriven || enabledModels.has('identity')) && (identityVessels.length > 0 || identityGear.length > 0) && (
<div ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}>
<div style={getCardBodyStyle('identity')}>
{identityVessels.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({identityVessels.length})</div>
{identityVessels.map(m => renderMemberRow(m, '⛴', '#60a5fa'))}
</>
)}
{identityGear.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
{t('parentInference.reference.referenceGear')} ({identityGear.length})
</div>
{identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))}
</>
)}
</div>
<div
style={cardFooterStyle}
onClick={() => toggleCardExpand('identity')}
onMouseEnter={() => handleTipHover('identity')}
onMouseLeave={handleTipLeave}
onContextMenu={(e) => handleTipContextMenu(e, 'identity')}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color: '#f97316', flex: 1 }}> </span>
<span style={{ fontSize: 8, color: '#64748b' }}>{expandedCards.has('identity') ? '▾' : '▴'}</span>
</div>
</div>
)}
{/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */}
{visibleModelNames.map(modelName => {
const m = availableModels.find(model => model.name === modelName);
if (!m) return null;
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
const items = correlationByModel.get(m.name) ?? [];
const vessels = items.filter(c => c.targetType === 'VESSEL');
const gears = items.filter(c => c.targetType !== 'VESSEL');
if (vessels.length === 0 && gears.length === 0) return null;
return (
<div key={m.name} ref={(el) => setCardRef(m.name, el)} style={{ ...cardStyle, borderColor: `${color}40`, position: 'relative' }}>
<div style={getCardBodyStyle(m.name)}>
{vessels.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({vessels.length})</div>
{vessels.map(c => renderRow(c, color, m.name))}
</>
)}
{gears.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
{t('parentInference.reference.referenceGear')} ({gears.length})
</div>
{gears.map(c => renderRow(c, color, m.name))}
</>
)}
</div>
<div
style={cardFooterStyle}
onClick={() => toggleCardExpand(m.name)}
onMouseEnter={() => handleTipHover(m.name)}
onMouseLeave={handleTipLeave}
onContextMenu={(e) => handleTipContextMenu(e, m.name)}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color, flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
<span style={{ fontSize: 8, color: '#64748b' }}>{expandedCards.has(m.name) ? '▾' : '▴'}</span>
</div>
</div>
);
})}
</div>{/* 스크롤 영역 끝 */}
{renderFloatingTip() && createPortal(renderFloatingTip(), document.body)}
</div>
);
};
export default CorrelationPanel;

파일 보기

@ -1,12 +1,9 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto, RiskLevel } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { lookupPermittedShip } from '../../services/chnPrmShip';
import { fetchVesselTrack } from '../../services/vesselTrack';
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
import { RISK_TO_ALERT } from '../../constants/riskMapping';
import { Map as MapGL, Source, Layer, Marker } from 'react-map-gl/maplibre';
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
const mtPhotoCache = new Map<string, string | null>();
@ -59,17 +56,22 @@ const C = {
border2: '#0E2035',
} as const;
const MINIMAP_STYLE = {
version: 8 as const,
sources: {
'carto-dark': {
type: 'raster' as const,
tiles: ['https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png'],
tileSize: 256,
},
},
layers: [{ id: 'carto-dark', type: 'raster' as const, source: 'carto-dark' }],
};
// AIS 수신 기준 선박 상태 분류 (Python 결과 없을 때 fallback)
function classifyStateFallback(ship: Ship): string {
const ageMins = (Date.now() - ship.lastSeen) / 60000;
if (ageMins > 20) return 'AIS_LOSS';
if (ship.speed <= 0.5) return 'STATIONARY';
if (ship.speed >= 5.0) return 'SAILING';
return 'FISHING';
}
// Python RiskLevel → 경보 등급 매핑
function riskToAlert(level: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' {
if (level === 'CRITICAL') return 'CRITICAL';
if (level === 'HIGH') return 'WATCH';
if (level === 'MEDIUM') return 'MONITOR';
return 'NORMAL';
}
function stateLabel(s: string): string {
const map: Record<string, string> = {
@ -107,7 +109,7 @@ interface Props {
ships: Ship[];
vesselAnalysis?: UseVesselAnalysisResult;
onClose: () => void;
onShowReport?: () => void;
onReport?: () => void;
}
const PIPE_STEPS = [
@ -122,14 +124,14 @@ const PIPE_STEPS = [
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowReport }: 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');
const [search, setSearch] = useState('');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
// pipeStep 제거 — 파이프라인 상태는 analysisMap 존재 여부로 판단
const [pipeStep, setPipeStep] = useState(0);
const [tick, setTick] = useState(0);
// 중국 어선만 필터
@ -139,20 +141,35 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
return cat === 'fishing' || s.category === 'fishing';
}), [ships]);
// 선박 데이터 처리 — Python 분석 결과 기반 (경량 분석 포함)
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
const processed = useMemo((): ProcessedVessel[] => {
return cnFishing
.filter(ship => analysisMap.has(ship.mmsi))
.map(ship => {
const dto = analysisMap.get(ship.mmsi)!;
const zone = dto.algorithms.location.zone;
const state = dto.algorithms.activity.state;
const alert = RISK_TO_ALERT[dto.algorithms.riskScore.level as RiskLevel];
const vtype = dto.classification.vesselType ?? 'UNKNOWN';
const clusterId = dto.algorithms.cluster.clusterId ?? -1;
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
return { ship, zone, state, alert, vtype, cluster };
});
return cnFishing.map(ship => {
const dto = analysisMap.get(ship.mmsi);
// 수역: Python → GeoJSON 폴리곤 fallback
let zone: string;
if (dto) {
zone = dto.algorithms.location.zone;
} else {
const zoneInfo = classifyFishingZone(ship.lat, ship.lng);
zone = zoneInfo.zone === 'OUTSIDE' ? 'EEZ_OR_BEYOND' : zoneInfo.zone;
}
// 행동 상태: Python → AIS fallback
const state = dto?.algorithms.activity.state ?? classifyStateFallback(ship);
// 경보 등급: Python 위험도 직접 사용
const alert = dto ? riskToAlert(dto.algorithms.riskScore.level) : 'NORMAL';
// 어구 분류: Python classification
const vtype = dto?.classification.vesselType ?? 'UNKNOWN';
// 클러스터: Python cluster ID
const clusterId = dto?.algorithms.cluster.clusterId ?? -1;
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
return { ship, zone, state, alert, vtype, cluster };
});
}, [cnFishing, analysisMap]);
// 필터 + 정렬
@ -172,13 +189,9 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
// 통계 — Python 분석 결과 기반
const stats = useMemo(() => {
let gpsAnomaly = 0;
let bd09Detected = 0;
for (const v of processed) {
const dto = analysisMap.get(v.ship.mmsi);
if (dto) {
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
if (dto.algorithms.gpsSpoofing.bd09OffsetM > 100) bd09Detected++;
}
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
}
return {
total: processed.length,
@ -186,7 +199,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
fishing: processed.filter(v => v.state === 'FISHING').length,
aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length,
gpsAnomaly,
bd09Detected,
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
trawl: processed.filter(v => v.vtype === 'TRAWL').length,
purse: processed.filter(v => v.vtype === 'PURSE').length,
@ -219,6 +231,12 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// AI 파이프라인 애니메이션
useEffect(() => {
const t = setInterval(() => setPipeStep(s => s + 1), 1200);
return () => clearInterval(t);
}, []);
// 시계 tick
useEffect(() => {
const t = setInterval(() => setTick(s => s + 1), 1000);
@ -238,14 +256,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
[selectedMmsi, processed],
);
// 항적 미니맵
const [trackCoords, setTrackCoords] = useState<[number, number][]>([]);
useEffect(() => {
if (!selectedVessel) { setTrackCoords([]); return; }
fetchVesselTrack(selectedVessel.ship.mmsi, 72).then(setTrackCoords);
}, [selectedVessel]);
// 허가 정보
const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle');
const [permitData, setPermitData] = useState<ChnPrmShipInfo | null>(null);
@ -321,7 +331,7 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
position: 'absolute', inset: 0, zIndex: 2000,
background: 'rgba(2,6,14,0.96)',
display: 'flex', flexDirection: 'column',
fontFamily: FONT_MONO,
fontFamily: "'IBM Plex Mono', 'Noto Sans KR', monospace",
}}>
{/* ── 헤더 */}
<div style={{
@ -339,14 +349,14 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
LIVE
</span>
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
{onShowReport && (
{onReport && (
<button
type="button"
onClick={onShowReport}
onClick={onReport}
style={{
background: 'rgba(99,179,237,0.1)', border: '1px solid rgba(99,179,237,0.4)',
color: '#63b3ed', padding: '4px 14px', cursor: 'pointer',
fontSize: 11, borderRadius: 2, fontFamily: 'inherit',
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,
}}
>
📋
@ -433,46 +443,38 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
AI
<span style={{ float: 'right', color: analysisMap.size > 0 ? C.green : C.red, fontSize: 8 }}></span>
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span>
</div>
{PIPE_STEPS.map((step) => {
const connected = analysisMap.size > 0;
{PIPE_STEPS.map((step, idx) => {
const isRunning = idx === pipeStep % PIPE_STEPS.length;
return (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: connected ? 'rgba(0,230,118,0.1)' : 'rgba(255,82,82,0.1)',
border: `1px solid ${connected ? C.green : C.red}`,
color: connected ? C.green : C.red,
fontWeight: 400,
background: isRunning ? 'rgba(0,230,118,0.15)' : 'rgba(0,230,118,0.06)',
border: `1px solid ${isRunning ? C.green : C.border}`,
color: isRunning ? C.green : C.ink3,
fontWeight: isRunning ? 700 : 400,
}}>
{connected ? 'ON' : 'OFF'}
{isRunning ? 'PROC' : 'OK'}
</span>
</div>
);
})}
{[
{
num: 'GPS', name: 'BD-09 변환',
status: stats.bd09Detected > 0 ? `${stats.bd09Detected}척 탐지` : 'CLEAR',
color: stats.bd09Detected > 0 ? C.amber : C.green,
active: stats.bd09Detected > 0,
},
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3, active: false },
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
].map(step => (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: step.active ? 'rgba(255,215,64,0.12)' : 'rgba(24,255,255,0.08)',
border: `1px solid ${step.active ? C.amber : C.border}`,
color: step.color,
fontWeight: step.active ? 700 : 400,
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: step.color,
}}>
{step.status}
</span>
@ -496,35 +498,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
<span style={{ fontSize: 9, color }}>{val}</span>
</div>
))}
{/* 위험도 점수 기준 */}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
</div>
<div style={{ fontSize: 9, color: C.ink3, lineHeight: 1.8 }}>
{[
{ title: '■ 위치 (최대 40점)', items: ['영해 내: 40 / 접속수역: 10'] },
{ title: '■ 조업 행위 (최대 30점)', items: ['영해 내 조업: 20 / 기타 조업: 5', 'U-turn 패턴: 10'] },
{ title: '■ AIS 조작 (최대 35점)', items: ['순간이동: 20 / 장시간 갭: 15', '단시간 갭: 5'] },
{ title: '■ 허가 이력 (최대 20점)', items: ['미허가 어선: 20'] },
].map(({ title, items }) => (
<div key={title} style={{ marginBottom: 6 }}>
<div style={{ color: C.ink2 }}>{title}</div>
{items.map(item => <div key={item} style={{ paddingLeft: 8 }}>{item}</div>)}
</div>
))}
<div style={{ marginTop: 6, borderTop: `1px solid ${C.border}`, paddingTop: 6, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 8px' }}>
<span style={{ color: C.red }}>CRITICAL 70</span>
<span style={{ color: C.amber }}>WATCH 50</span>
<span style={{ color: C.cyan }}>MONITOR 30</span>
<span style={{ color: C.green }}>NORMAL {'<'}30</span>
</div>
<div style={{ marginTop: 6, color: C.ink3 }}>
UCAF: 어구별 <br />
UCFT: 조업- <br />
스푸핑: 순간이동+SOG급변+BD09
</div>
</div>
</div>
{/* ── 중앙 패널: 선박 테이블 */}
@ -859,38 +832,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
</div>
)}
</div>
{/* ── 항적 미니맵 */}
<div style={{ borderTop: `1px solid ${C.border}`, padding: '8px 12px 12px' }}>
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 6 }}> </div>
<div style={{ height: 180, borderRadius: 4, overflow: 'hidden', border: `1px solid ${C.border}` }}>
<MapGL
key={selectedVessel.ship.mmsi}
initialViewState={{ longitude: selectedVessel.ship.lng, latitude: selectedVessel.ship.lat, zoom: 3 }}
style={{ width: '100%', height: '100%' }}
mapStyle={MINIMAP_STYLE}
attributionControl={false}
interactive={false}
>
{trackCoords.length > 1 && (
<Source id="minimap-track" type="geojson" data={{
type: 'Feature', properties: {},
geometry: { type: 'LineString', coordinates: trackCoords },
}}>
<Layer id="minimap-track-line" type="line" paint={{
'line-color': '#fbbf24', 'line-width': 2, 'line-opacity': 0.8,
}} />
</Source>
)}
<Marker longitude={selectedVessel.ship.lng} latitude={selectedVessel.ship.lat}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: C.red, border: '2px solid #fff' }} />
</Marker>
</MapGL>
</div>
<div style={{ fontSize: 8, color: C.ink3, marginTop: 4 }}>
72 · {trackCoords.length}
</div>
</div>
</>
) : (
<div style={{ padding: '24px 0', textAlign: 'center', color: C.ink3, fontSize: 10 }}>

Some files were not shown because too many files have changed in this diff Show More