Compare commits

...

226 커밋

작성자 SHA1 메시지 날짜
6303831df3 Merge pull request 'fix: parent_resolution JOIN 타이밍 갭 허용' (#226) from hotfix/join-timing-tolerance into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m29s
2026-04-06 07:09:17 +09:00
dd9de6739c fix: parent_resolution JOIN 타이밍 갭 허용 — snapshot_time - 10분
5분 사이클에서 폴리곤 저장 → inference 실행 순서로 인해
latest snapshot_time > last_evaluated_at이 될 수 있음.
JOIN 조건에 10분 여유를 두어 이전 사이클 결과도 매칭되도록 수정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 07:08:54 +09:00
ed618f6dd0 Merge pull request 'fix: hotfix 동기화 — history/detail candidate_count 안전 처리' (#225) from hotfix/sync-candidate-count into develop 2026-04-04 11:05:43 +09:00
d37653c1be Merge pull request 'fix: history/detail API 500 오류 — candidate_count 컬럼 부재 시 안전 처리' (#224) from hotfix/history-candidate-count into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m53s
2026-04-04 11:05:14 +09:00
17922bf74c fix: history/detail API 500 오류 — candidate_count 컬럼 부재 시 안전 처리
mapGroupRow에서 candidate_count를 읽을 때 optionalInt로 변경하여
해당 컬럼이 없는 SQL (history, detail)에서도 정상 동작하도록 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:04:41 +09:00
32f9aa897b Merge pull request 'release: 2026-04-04 (31건 커밋)' (#223) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m28s
2026-04-04 10:53:04 +09:00
de11a162b4 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-04)' (#222) from release/2026-04-04 into develop 2026-04-04 10:49:43 +09:00
b14b6c241e docs: 릴리즈 노트 정리 (2026-04-04)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:49:10 +09:00
7ea5b4719a Merge pull request 'fix: vessel_store 타임존 수정 + 모선 추론 이식 + 검토 목록 동기화' (#221) from bugfix/vessel-store-tz-naive into develop 2026-04-04 10:28:41 +09:00
d57f993960 docs: 릴리즈 노트 업데이트 — 모선 검토 동기화 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:27:32 +09:00
0b74831b87 fix: 모선 검토 대기 목록을 폴리곤 폴링 데이터에서 파생하여 동기화 문제 해소
- Backend: LATEST_GROUPS_SQL에 candidateCount CTE 추가 (GroupPolygonDto 확장)
- Frontend: parentInferenceQueue를 별도 API 대신 groupPolygons useMemo 파생으로 전환
- 렌더 루프 수정: refreshParentInferenceQueue deps에서 groupPolygons → polygonRefresh 분리
- 초기 로드 시 자동 그룹 선택 제거, 검토 패널만 표시
- 후보 소스 배지 축약 (CORRELATION→CORR, PREVIOUS_SELECTION→PREV)
- useGroupPolygons에 refresh 콜백 외부 노출

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:21:28 +09:00
83db0f8149 docs: 릴리즈 노트 + 프로젝트 문서 최신화 (세션 마무리)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 09:21:16 +09:00
e7ed536be5 fix: prediction proxy target을 nginx 경유로 변경
로컬 dev에서 192.168.1.18(redis-211 내부 IP) 직접 접근 불가 → timeout.
kcg.gc-si.dev nginx 경유로 변경하여 정상 동작.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 09:19:37 +09:00
1033654c82 fix: 모선 추론 점수 가중치 조정 — 100%는 DIRECT_PARENT_MATCH 전용
문제: china_bonus(15%) + prior(20%) 가산으로 일반 후보 23.6%가 100% 도달
- china_bonus: 0.15 → 0.05, 적용 조건: pre >= 0.30 → 0.50
- episode_prior: 0.10 → 0.05
- lineage_prior: 0.05 → 0.03
- label_prior: 0.10 → 0.07
- total_prior_cap: 0.20 → 0.10

결과: 일반 후보 최대 ~93% (라벨 있으면 ~98%), 100%는 직접 모선 일치만

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 09:09:36 +09:00
15f5f680fd fix: FleetClusterLayer codex 원본 복원 + ESLint suppress
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:31:04 +09:00
2ca6371d87 feat: LoginPage DEV_LOGIN 환경변수 지원 추가
VITE_ENABLE_DEV_LOGIN=true로 프로덕션 빌드에서도 DEV LOGIN 활성화 가능.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:21:03 +09:00
e11caf2767 feat: 어구 모선 추적 흐름도 시각화 (React Flow) 추가
- GearParentFlowViewer: React Flow 기반 인터랙티브 흐름도
- gear-parent-flow.html: standalone entry point
- vite.config.ts: multi-entry 빌드 (main + gearParentFlow)
- App.tsx: FLOW 링크 추가
- @xyflow/react 의존성 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:19:21 +09:00
23828c742e refactor: codex 이식 완료 — 환경변수 동적화 + @Table schema 제거 + import 정리
- Backend: @Table(schema="kcg") 하드코딩 제거 → application.yml default_schema 활용
- Backend: application.yml/prod.yml 환경변수 ${} 패턴 전환
- Backend: WebConfig CORS 5174 포트 추가
- Frontend: tsconfig resolveJsonModule 추가
- Prediction: scheduler/snpdb/vessel_store import 위치 + 주석 codex 동기화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:17:57 +09:00
5432e1f282 fix: codex 이식 누락 파일 보완 — polygon_builder 필터 + qualified_table 정리
- polygon_builder: is_trackable_parent_name 필터 추가 (짧은 이름 어구 제외)
- chat/domain_knowledge, chat/tools, db/partition_manager: qualified_table() 적용
- FleetCompanyController: @Value DB_SCHEMA 동적화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:14:06 +09:00
973b419287 fix: 모선 검토 패널 i18n 번역 키 추가 (ko/en)
parentInference.* 키가 누락되어 UI에 번역 키가 그대로 노출되던 문제.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:06:14 +09:00
8362bc5b6c feat: 어구 모선 추론 UI 통합 — FleetClusterLayer + 리플레이 컴포넌트 이식
ParentReviewPanel 마운트 + 관련 상태 관리를 FleetClusterLayer에 통합.
리플레이 컨트롤러, 어구 그룹 섹션, 일치율 패널 등 11개 컴포넌트
codex Lab 환경에서 검증된 버전으로 교체.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:48:48 +09:00
7dd46f2078 feat: 어구 모선 추론(Gear Parent Inference) 시스템 이식
Codex Lab 환경(iran-airstrike-replay-codex)에서 검증 완료된
어구 모선 자동 추론 + 검토 워크플로우 전체를 이식.

## Python (prediction/)
- gear_parent_inference(1,428줄): 다층 점수 모델 (correlation + name + track + prior bonus)
- gear_parent_episode(631줄): Episode 연속성 (Jaccard + 공간거리)
- gear_name_rules: 모선 이름 정규화 + 4자 미만 필터
- scheduler: 추론 호출 단계 추가 (4.8)
- fleet_tracker/kcgdb: SQL qualified_table() 동적화
- gear_correlation: timestamp 필드 추가

## DB (database/migration/ 012~015)
- 후보 스냅샷, resolution, episode, 라벨 세션, 제외 관리 테이블 9개 + VIEW 2개

## Backend (Java)
- 12개 DTO/Controller (ParentInferenceWorkflowController 등)
- GroupPolygonService: parent_resolution LEFT JOIN + 15개 API 메서드

## Frontend
- ParentReviewPanel: 모선 검토 대시보드
- vesselAnalysis: 10개 신규 API 함수 + 6개 타입

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:42:31 +09:00
2534c9dbca fix: time_bucket 수집 안전 윈도우 도입 — incremental fetch 데이터 누락 방지
snpdb 5분 버킷 데이터가 적재 완료까지 ~12분 소요되는데,
기존 fetch_incremental이 상한 없이 미완성 버킷을 수집하여
_last_bucket이 조기 전진 → 뒤늦게 완성된 행 영구 누락.

- time_bucket.py 신규: safe_bucket(12분 지연) + backfill(3 bucket)
- snpdb.py: fetch_all_tracks/fetch_incremental에 safe 상한 + 백필 하한
- vessel_store.py: merge_incremental sort+keep='last', evict_stale time_bucket 우선
- config.py: SNPDB_SAFE_DELAY_MIN=12, SNPDB_BACKFILL_BUCKETS=3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:11:20 +09:00
67523b475d chore: requirements.txt에 tzdata 추가
ZoneInfo('Asia/Seoul') 사용 시 tzdata 미설치 환경 대비.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 07:13:30 +09:00
b8b60bf314 fix: timestamp fallback에서 UTC→KST 변환 추가
Codex 리뷰 지적: timestamp fallback 분기에서 UTC aware 값을
replace(tzinfo=None)로 tz만 제거하면 KST time_bucket과 9시간 어긋남.
astimezone(KST) 후 tz 제거하도록 수정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 07:05:15 +09:00
d15039ce18 fix: vessel_store _last_bucket 타임존 오류 수정
snpdb time_bucket은 tz-naive KST인데 UTC tzinfo를 강제 부여하여
incremental fetch WHERE time_bucket > %s 비교 시 미래 시간으로 해석,
항상 0 rows 반환 → 1h 어구 그룹이 점진적으로 소멸하는 버그.

tz-naive 그대로 유지하도록 수정 (load_initial, merge_incremental 3곳).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 05:53:14 +09:00
e9ae058017 Merge pull request 'fix: 1h 활성 판정 parent_name 전체 합산 기준' (#220) from bugfix/1h-active-parent-sum into develop 2026-04-01 16:48:08 +09:00
5c85afea22 docs: 릴리즈 노트 업데이트 (1h 활성 판정 수정)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:47:22 +09:00
b04e96c457 fix: 1h 활성 판정을 parent_name 전체 합산 기준으로 변경
서브클러스터 분리 후 개별 서브그룹의 1h 멤버가 2개 미만이더라도,
parent_name 전체(모든 서브클러스터 합산)에서 1h 활성 멤버 >= 2이면
resolution='1h'로 저장하여 라이브 현황에 표시.

결과: 라이브 1h 그룹 5개 → 927개 정상 복구

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:47:01 +09:00
ae70eceb96 Merge pull request 'release: 2026-04-01.2 (6건 커밋)' (#218) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m42s
2026-04-01 15:05:27 +09:00
b320aeb3fd Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-01.2)' (#217) from release/2026-04-01.2 into develop 2026-04-01 15:04:51 +09:00
e9cbeaa0d8 docs: 릴리즈 노트 정리 (2026-04-01.2) 2026-04-01 15:04:34 +09:00
acef08fca9 Merge pull request 'fix: 라이브 어구 현황 fallback 제외 + FLEET resolution' (#216) from bugfix/fleet-resolution-fix into develop 2026-04-01 15:03:11 +09:00
d44837e64a Merge pull request 'feat: 한국 현황 위성지도/ENC 토글 + ENC 스타일 설정' (#215) from feature/enc-map-toggle into develop 2026-04-01 15:02:56 +09:00
fc6f696d1f fix: 라이브 어구 현황 fallback 제외 + FLEET resolution + DB VARCHAR(8)
- 1h 실제 활성 멤버 < 2일 때 resolution='1h-fb' (fallback)로 저장
- LATEST_GROUPS_SQL은 resolution='1h'만 필터 → fallback 자동 제외
- FLEET 타입에 resolution='1h' 추가 (이전 누락)
- DB resolution 컬럼: VARCHAR(4) → VARCHAR(8) 확장
- 프론트 리플레이: '1h' + '1h-fb' 모두 1h 프레임으로 처리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:00:16 +09:00
98d173701e fix: 라이브 어구 현황에서 fallback 그룹 제외
- 1h 실제 활성 멤버 < 2일 때 resolution='1h-fb' (fallback)로 저장
- LATEST_GROUPS_SQL은 resolution='1h'만 필터 → fallback 자동 제외
- 리플레이 history API는 1h-fb 포함 (리플레이/일치율 추적 유지)
- 프론트 리플레이: '1h' + '1h-fb' 모두 1h 프레임으로 처리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:22:59 +09:00
a7eb706839 fix: FLEET 타입에 resolution='1h' 누락 — 라이브 현황 API에서 선단 그룹 미표시
FLEET 스냅샷에 resolution 필드를 설정하지 않아 DB default '6h'로 저장됨.
LATEST_GROUPS_SQL이 resolution='1h' 필터를 사용하므로 FLEET 전부 누락.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:17:27 +09:00
650c027013 feat: 한국 현황 위성지도/ENC 토글 + ENC 스타일 설정
- ENC 전자해도: gcnautical 벡터 타일 연동 (gc-wing-dev 이식)
- 상단 위성/ENC 토글 버튼 + ⚙ 드롭다운 설정 패널
- 12개 심볼 토글 + 8개 색상 수정 + 초기화
- mapMode/encSettings localStorage 영속화
- style.load 대기 패턴으로 스타일 전환 시 설정 자동 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:08:41 +09:00
31f557e54d Merge pull request 'release: 2026-04-01 (55건 커밋)' (#214) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m41s
2026-04-01 12:36:34 +09:00
71a2188273 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-01)' (#213) from release/2026-04-01 into develop 2026-04-01 12:35:53 +09:00
138a1b82de docs: 릴리즈 노트 정리 (2026-04-01) 2026-04-01 12:35:26 +09:00
c59b38f913 Merge pull request 'feat: 어구 1h/6h 듀얼 폴리곤 + 리플레이 컨트롤러 개선 + 심볼 스케일' (#212) from feature/gear-replay-cleanup into develop 2026-04-01 12:33:00 +09:00
9200f45cb2 docs: 릴리즈 노트 업데이트 2026-04-01 12:32:08 +09:00
77efab8652 feat: 항공기 줌 스케일 + 선박/항공기 심볼 크기 조정 패널
- 항공기 아이콘에 정수레벨 줌 기반 스케일 적용 (getZoomScale export)
- 심볼 크기 조정: SymbolScaleContext + SymbolScalePanel (0.5~2.0x)
- LayerPanel에 '심볼 크기' 섹션 추가 (선박/항공기 개별 조정)
- localStorage 영속화 (mapSymbolScale)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:29:22 +09:00
71d607e499 feat: 어구 그룹 1h/6h 듀얼 폴리곤 + 리플레이 컨트롤러 개선
- Python: 1h/6h 듀얼 스냅샷 생성 (polygon_builder), 1h 멤버 기반 일치율 후보 (gear_correlation)
- DB: resolution 컬럼 추가 (011_polygon_resolution.sql)
- Backend: resolution 필드 지원 (DTO/Service/Controller)
- Frontend: 6h identity 레이어 독립 구현 (폴리곤/아이콘/라벨/항적/센터)
- 리플레이 컨트롤러: 프로그레스바 통합, 1h/6h 스냅샷 표시, A-B 구간 반복
- 리치 툴팁: 클릭 고정 + 멤버 호버 강조 + 선박/어구/모델 소속 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:52:38 +09:00
f09186a187 feat: 어구 리플레이 서브클러스터 분리 렌더링 + 일치율 감쇠 개선
- 서브클러스터별 독립 폴리곤/센터/center trail 렌더링
- 반경 밖 이탈 선박 강제 감쇠 (OUT_OF_RANGE)
- Backend correlation API에 sub_cluster_id 추가
- 모델 패널 5개 항상 표시, 드롭다운 기본값 70%
- DISPLAY_STALE_SEC (time_bucket 기반) 폴리곤 노출 필터
- AIS 수집 bbox 122~132E/31~39N 확장
- historyActive 시 deck.gl 이중 렌더링 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:01:03 +09:00
ce4cde56b8 Merge pull request 'feat: MapLibre → deck.gl 전면 전환 + 어구 서브클러스터 구조 개선' (#211) from feature/deckgl-ship-migration into develop 2026-03-31 15:59:06 +09:00
6f8e8cb6cc docs: 릴리즈 노트 업데이트
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:55:58 +09:00
313b5d9af8 chore: .claude/worktrees 제거 (로컬 전용)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:48:16 +09:00
b0ac1590f2 Revert "docs: Backend JAR 배포 경로 정확히 명시"
This reverts commit 9eac614910.
2026-03-31 15:48:03 +09:00
9eac614910 docs: Backend JAR 배포 경로 정확히 명시
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:46:40 +09:00
6f4044ce39 feat: MapLibre → deck.gl 전면 전환 + 어구 서브클러스터 구조 개선
- 실시간 선박 13K: MapLibre symbol → deck.gl IconLayer (useShipDeckLayers + shipDeckStore)
- 선단/어구 폴리곤: MapLibre Source/Layer → deck.gl GeoJsonLayer (useFleetClusterDeckLayers)
- 선박 팝업: MapLibre Popup → React 오버레이 (ShipPopupOverlay + ShipHoverTooltip)
- 리플레이 집중 모드 (focusMode), 라벨 클러스터링, fontScale 연동
- Python: group_key 고정 + sub_cluster_id 분리, 한국 국적 어구 오탐 제외
- DB: sub_cluster_id 컬럼 추가 + 기존 '#N' 데이터 마이그레이션
- Backend: DISTINCT ON CTE로 서브클러스터 중복 제거, subClusterId DTO 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:44:09 +09:00
93ce2092d2 Merge pull request 'release: 2026-03-31 (39건 커밋)' (#210) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m39s
2026-03-31 10:12:09 +09:00
1b14aacd89 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-31)' (#209) from release/2026-03-31 into develop 2026-03-31 10:11:33 +09:00
b88c9c28ee docs: 릴리즈 노트 정리 (2026-03-31) 2026-03-31 10:11:05 +09:00
b14df41da7 Merge pull request 'fix: 이름 기반 레이어 항상 ON + 최상위 z-index' (#208) from feature/identity-layer-fix into develop 2026-03-31 10:09:41 +09:00
867ece0a39 docs: 릴리즈 노트 업데이트 2026-03-31 10:08:46 +09:00
9f3d53d2e8 fix: 이름 기반 레이어 항상 ON + 최상위 z-index
CorrelationPanel:
- identity 체크박스 disabled + '(고정)' 표시
- on/off 토글 불가

useGearReplayLayers z-index 재배치:
1. 정적 항적 PathLayer (배경)
2. Correlation TripsLayer + 아이콘/라벨
3. 모델별 폴리곤 + 중심경로/중심점 + 배지
4. Identity 폴리곤 + TripsLayer + 센터포인트 (최상위)
  → 다른 모델 레이어가 identity 구역을 가리지 않음

모든 enabledModels.has('identity') 체크 제거 (항상 true)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:08:09 +09:00
588e61c941 Merge pull request 'feat: 어구 연관성 멀티모델 + deck.gl 리플레이 전환' (#207) from feature/gear-correlation-tracking into develop 2026-03-31 10:04:08 +09:00
7e8a5bb39a docs: 릴리즈 노트 업데이트 2026-03-31 10:03:21 +09:00
30f1956ca6 feat: 모델 중심점에 모델명 라벨 표시 (showLabels 연동)
이름 토글 ON 시 각 모델 폴리곤 중심점 위에 모델명 TextLayer 표시
모델 색상 + 검정 배경, 중심점 위 12px offset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:01:15 +09:00
89b81bc241 ci: Prediction 자동 배포 제거 — 수동 배포로 전환
prediction/ 변경 시 ssh redis-211으로 수동 scp + restart
CI/CD는 Frontend + Backend만 자동 배포

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:59:33 +09:00
bf412cc897 fix: enabledVessels 토글 시 모델 중심 경로 재계산
- setEnabledVessels: rawCorrelationTracks로 modelCenterTrails 재빌드
- rawCorrelationTracks 필드 추가 (원본 트랙 보존)
- 선박/어구 on/off → 폴리곤 + 중심경로 + 중심점 동시 갱신

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:56:46 +09:00
c97f964f93 feat: 모델별 폴리곤 중심 경로 + 현재 중심점 렌더링
사전계산 (gearReplayPreprocess):
- buildModelCenterTrails(): 각 프레임에서 멤버+연관선박 위치 → 폴리곤 → 중심점
- 모델별 path[]/timestamps[] (PathLayer + 보간용)

스토어 (gearReplayStore):
- modelCenterTrails 필드 추가 (loadHistory/updateCorrelation에서 빌드)

렌더링 (useGearReplayLayers):
- PathLayer: 모델별 폴리곤 중심 경로 (연한 모델 색상, alpha 100)
- ScatterplotLayer: 현재 시간 중심점 (고채도 모델 색상, 흰 테두리)
- 모델 ON 시에만 표시 (enabledModels 체크)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:52:17 +09:00
5afa5d4be9 fix: 모델 패널 위치 상향 — 확장된 재생 컨트롤러와 겹침 해소
bottom: 100 → 120 (재생 활성 시)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:45:36 +09:00
e8187b3a6c fix: 토글 패널 위치 재생 컨트롤러 정렬 + 일치율 필터 전역 적용
패널 위치:
- left: 10 → left: calc(50% - 210px) (재생 컨트롤러 중앙 정렬 근처)

일치율 드롭다운 전역 적용:
- 기존: 현재 ON인 모델의 대상만 필터
- 수정: 모든 모델의 모든 대상에 전역 적용 (모델 on/off 무관)
- 동일 MMSI가 여러 모델에 있을 때 최고 score 기준 판단

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:44:15 +09:00
6789f82e3b fix: 항적 토글 + 패널 레이아웃 + TripsLayer 색상 분리
항적 토글 역할 변경:
- 항상 ON: TripsLayer (애니메이션 트레일) + 센터 트레일 + 도트
- "항적" 토글: 전체 24h 정적 항적 PathLayer (멤버 + 연관 선박)
  ON 시 회색/연파랑 배경 경로 위에 고채도 TripsLayer 애니메이션

색상 계층:
- 정적 항적: 회색 [180,180,180,80] / 연파랑 [100,140,200,60]
- TripsLayer: 고채도 노랑 [255,200,60,220] / 고채도 파랑 [100,180,255,220]

패널 레이아웃:
- 토글 패널: position: sticky left: 0 (항상 좌측 고정)
- 모델 카드: 가로 스크롤 (maxWidth: calc(100vw - 340px))
- 다중 토글 유지, 화면 초과 시 스크롤

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:40:06 +09:00
5002105d18 feat: 재생 컨트롤 확장 — 항적/이름 토글 + 일치율 필터 + 개별 on/off
재생 컨트롤러:
- 항적 on/off → showTrails (TripsLayer + PathLayer + 센터도트)
- 이름 on/off → showLabels (TextLayer)
- 일치율 드롭다운 (50~90%) → enabledVessels 일괄 필터

패널 토글:
- 행 전체 클릭으로 체크박스 토글 (cursor: pointer)
- 체크박스 항상 표시, OFF 시 opacity 0.5
- correlationTracks prop 제거 (미사용)

enabledVessels OFF 효과:
- corrPositions 제외 → 아이콘/라벨/트레일/폴리곤 모두 제외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:26:41 +09:00
21df325010 fix: prediction API 프록시 경로 수정 + nginx 라우팅 추가
vite dev: /api/prediction/ → 192.168.1.18:8001 (rewrite /api/prediction → /api)
nginx 프로덕션: /api/prediction/ → redis-211:8001 (동일 rewrite)
디버그 로그: fetchCorrelationTracks URL/status, loadHistory fetch 결과

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:13:52 +09:00
dddb978dea fix: prediction proxy rewrite 경로 수정
/api/prediction → rewrite: '' → '/api'
기존: /api/prediction/v1/... → /v1/... (404 - FastAPI 라우트 불일치)
수정: /api/prediction/v1/... → /api/v1/... (정상 매칭)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:07:33 +09:00
8631546142 fix: 트랙 API DB 접속 버그 수정 (context manager)
- kcgdb.get_conn()을 with문 없이 사용 → cursor 에러
- with kcgdb.get_conn() as conn: 으로 수정
- 디버그 로그 추가 (rows 수, track 매칭 수, vessel_store 크기)
- 결과: 47 vessels, 47 with track data (25567#1 그룹)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:03:33 +09:00
6ea394d120 fix: 트랙 끝점 clamp + 연관 선박 위치 디버그 강화
- 트랙 시간 범위 밖: 가장 가까운 끝점으로 clamp (기존: skip)
  → 트랙 시작 전 = 첫 점, 트랙 종료 후 = 마지막 점
- 디버그 로그: corrPositions 상세 (track/live 소스별, 모델별 위치확인 수)
- 기존 중복 로그 정리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:55:00 +09:00
8eacbb2c91 feat: 트랙 API 전체 모델 확장 + 개별 선박 on/off → 폴리곤 반영
Prediction API:
- /correlation/{group}/tracks: is_default=TRUE 제거 → 모든 활성 모델 조회
- 응답에 models: {modelName: score} 딕셔너리 추가 (모델별 점수)
- MMSI 기준 중복 제거, 최고 점수 유지

Frontend:
- CorrelationVesselTrack 타입: models 필드 추가, type 필드 추가
- 오퍼레이셔널 폴리곤: enabledVessels 기반 on/off 제어
  (score 임계값 → 개별 체크박스 토글로 전환)
- identity OFF 시 폴리곤 base points에서 멤버 위치 제외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:40:31 +09:00
c4186a327d fix: 연관 선박 위치 live fallback — 트랙 데이터 없을 때 ships 배열 사용
- useGearReplayLayers에 shipsRef 파라미터 추가
- corrPositions 계산: 트랙 보간 우선 → live 선박 위치 fallback
- KoreaMap: allShips → shipsRef에 매 렌더 동기화 (ref로 re-render 방지)
- globalThis.Map으로 react-map-gl Map 타입 충돌 해결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:26:07 +09:00
6ba3db5cee fix: 순차 데이터 로딩 + enabledModels 토글 제어
데이터 로딩:
- loadHistory: Promise.all로 history/correlation/tracks 병렬 fetch
  → 모든 응답 완료 후 store.loadHistory + play() 순차 실행
- 개별 fetch effect는 비재생 모드에서만 실행 (historyActive 가드)
- 타이밍 문제 원천 제거 (race condition 없음)

enabledModels 토글 제어:
- identity OFF → 멤버 폴리곤/마커/트레일 숨김
- 각 모델 OFF → 해당 모델의 연관 선박/트레일 숨김
- 모든 모델 OFF → 센터 트레일/점만 표시
- correlation trails도 활성 모델에 속하는 선박만 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:17:53 +09:00
e68f314093 fix: correlation 동기화 타이밍 수정 + 자동 재생 + 디버그 로그
- loadHistory 완료 후 store.play() 호출 (자동 재생)
- correlation sync effect에 historyActive 의존 추가
  (history 로드 후 이미 도착한 correlation 데이터 재동기화)
- loadHistory 직후 즉시 updateCorrelation 호출 (병렬 로드 대응)
- 디버그 로그: renderFrame 첫 프레임 데이터 상태, correlationByModel 갱신

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:13:52 +09:00
242fdb8034 fix: 리플레이 IconLayer 전환 + 모델 배지 + correlation 동기화
- ScatterplotLayer → IconLayer (ship-triangle/gear-diamond SVG 정적 캐시)
- shipIconSvg.ts: MapLibre와 동일한 삼각형/마름모 SVG + mask 모드
- 선박 COG 회전 반영 (getAngle), 어구는 회전 없음
- 모델별 색상 배지 ScatterplotLayer 추가 (각 모델 offset)
- correlation 데이터 비동기 로드 후 store.updateCorrelation() 동기화
- CorrPosition에 cog 필드 추가 (세그먼트 방향 계산)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:08:39 +09:00
4cf29521a9 fix: 모델 패널 위치 상향 — 재생 컨트롤러와 겹침 해소
CorrelationPanel: historyData prop 제거 → useGearReplayStore 직접 구독
재생 활성 시 bottom: 80→100 (컨트롤러 높이 60px 확보)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:01:23 +09:00
87d1b31ef3 feat: 어구 리플레이 deck.gl + Zustand 전환 완료
Phase 3: DeckGLOverlay에 overlayRef 추가, KoreaMap에서
리플레이 레이어 합성 (imperative setProps → React 렌더 우회)

Phase 4: 기존 MapLibre 리플레이 레이어 → deck.gl 전환
- FleetClusterLayer: 애니메이션 state/ref/timer 제거 → Zustand 스토어
- useFleetClusterGeoJson: 리플레이 useMemo 15개 제거 (618→389줄)
- FleetClusterMapLayers: MapLibre 재생 레이어 6개 제거 (492→397줄)
- HistoryReplayController: React refs → Zustand subscribe 바인딩

성능: React re-render 20회/초 → 0회/초 (재생 중)
      GeoJSON 직렬화 15개/프레임 → 0 (raw 배열 → deck.gl)
      트레일: 매 프레임 재생성 → TripsLayer GPU 셰이더

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:54:50 +09:00
bbbc326e38 refactor: FleetClusterLayer 10파일 분리 + deck.gl 리플레이 기반 구축
FleetClusterLayer.tsx 2357줄 → 10개 파일 분리:
- fleetClusterTypes/Utils/Constants: 타입, 기하 함수, 모델 상수
- useFleetClusterGeoJson: 27개 useMemo GeoJSON 훅
- FleetClusterMapLayers: MapLibre Source/Layer JSX
- CorrelationPanel/HistoryReplayController: 패널 서브컴포넌트
- GearGroupSection/FleetGearListPanel: 좌측 목록 (DRY)
- FleetClusterLayer: 오케스트레이터 524줄

deck.gl + Zustand 리플레이 기반 (Phase 0~2):
- zustand 5.0.12, @deck.gl/geo-layers 9.2.11 설치
- gearReplayStore: Zustand + rAF 애니메이션 루프
- gearReplayPreprocess: TripsLayer 전처리 + cursor O(1) 보간
- useGearReplayLayers: deck.gl 레이어 빌더 (10fps 스로틀)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:44:07 +09:00
2fb0842523 fix: 연관성 패널 historyData 조건 제거 + 재생 컨트롤러 위 배치
- !historyData 조건 제거 — 어구 클릭 시 히스토리 자동 로딩되므로 항상 표시
- 히스토리 모드: bottom 80px (재생 컨트롤러 위), 비히스토리: bottom 20px
- z-index 21 (재생 컨트롤러 20 위)
- 오퍼레이셔널 폴리곤/이름 기반 하이라이트도 히스토리 조건 조정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:56:22 +09:00
dc6070d619 fix: 모델 토글을 호버 팝업에서 하단 고정 패널로 이동
호버 팝업은 마우스 이동 시 사라져서 토글 조작 불가 →
어구 그룹 선택 시 하단 중앙에 고정 패널 배치:
- 좌측: 그룹 정보 + 폴리곤 오버레이 토글 (이름 기반 + 5개 모델)
- 우측: 연관 선박 목록 (default 모델 상위 12건, 스크롤)
- ✕ 버튼으로 선택 해제
- 히스토리 재생 컨트롤러와 동일 위치/스타일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:45:30 +09:00
4c994e277a feat: 어구 그룹 멀티모델 폴리곤 오버레이 + 토글 UI
- 어구 그룹 선택 시 전체 모델(5개) 연관성 데이터 로드
- enabledModels 상태: 'identity'(이름 기반) + 'default' 기본 ON
- 모델별 오퍼레이셔널 폴리곤 클라이언트 생성 (70%+ 연관 대상 합산 convex hull)
- Source+Layer 오버레이: 모델별 고유 색상, 대시 라인 구분
- 팝업 UI: 모델 토글 체크박스 (최대 5개), 색상 인디케이터 + 70%+ 대상 수
- 연관 선박 상위 8건 바 그래프 (default 모델 기준)
- 선택 시 팝업 maxWidth 280px로 확장

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:38:09 +09:00
82fb6fbfff fix: correlations API SQL — raw_metrics LATERAL JOIN으로 세부 메트릭 조회
scores 테이블에는 composite 점수만, 세부 메트릭(proximity/visit/heading)은
raw_metrics에 있으므로 LATERAL JOIN으로 최신 raw 메트릭 결합

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:56:07 +09:00
d025809793 feat: 어구 연관성 프론트엔드 표시 — Backend API + 팝업 UI
- Backend: GET /api/vessel-analysis/groups/{groupKey}/correlations 엔드포인트
- GroupPolygonService: gear_correlation_scores JOIN correlation_param_models 쿼리
- Frontend: fetchGroupCorrelations API 클라이언트 + GearCorrelationItem 타입
- FleetClusterLayer: 어구 그룹 선택 시 연관 선박/어구 목록 팝업에 표시
  - default 모델 기준 일치율 % + 바 그래프
  - 선박(⛴)/어구(◆) 유형 구분, 상위 8건 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:33:56 +09:00
812a78f636 feat: 어구 연관성 멀티모델 패턴 추적 시스템 (Phase 1 Core)
- gear_correlation.py: 적응형 EMA + freeze + shadow + 배치 최적화
- 5개 글로벌 모델 병렬 추적 (default/aggressive/conservative/proximity-heavy/visit-pattern)
- 어구 중심 점수 체계: 어구 비활성 시 FREEZE, 선박 shadow 추적
- 유형별 메트릭: 어구-선박(proximity+visit+activity), 선박-선박(DTW+SOG+COG)
- DB: correlation_param_models + raw_metrics(일별 파티션) + scores + system_config
- partition_manager: 일별 파티션 생성/정리 (system_config hot-reload)
- track_similarity: SOG상관 + COG동조 + 근접비 3개 메트릭 추가
- scheduler Step 4.7 통합, fleet_tracker MMSI 점수 이전
- chat/tools: query_gear_correlation 도구

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:36:43 +09:00
3407d37f9b Merge pull request 'feat(replay): 어구/선단 히스토리 보간 애니메이션 강화' (#206) from feature/gear-replay-marker into develop 2026-03-26 15:53:44 +09:00
54b49551e2 feat(replay): 어구/선단 히스토리 보간 애니메이션 강화
- fillGapFrames: API 응답 로드 시 빈 구간 보간 프레임 삽입
  - gap ≤ 30분: 5분 간격 직선 보간 (중심만, 폴리곤 유지)
  - gap > 30분: 30분 간격 멤버 위치 보간 + convex hull 가상 폴리곤
- _interp/_longGap 플래그로 원본/보간/장기gap 프레임 구분
- 빨간 중심 포인트: 현재 재생 시점 위치 표시
- 가상 구간 회색 렌더링 (폴리곤/아이콘/라벨)
- 중심선: 실데이터 노란색 + 장기gap 주황색 파선
- 재생바: 원본 데이터만 표시 (보간 프레임 제외)
- buildInterpPolygon: Python polygon_builder.py 동일 로직
2026-03-26 15:53:21 +09:00
32dd957f4b Merge pull request 'feat(debug): DEV 전용 디버그 도구 체계 + 좌표 표시 도구' (#205) from feature/debug-coord-tool into develop 2026-03-26 13:34:55 +09:00
7fe9e048bf refactor(debug): DebugTools 허브 패턴 — 단일 lazy import로 통합
- debug/index.tsx: 모든 디버그 도구 조합 export
- KoreaMap: lazy import 1줄 + JSX 1줄만 유지
- 디버그 도구 추가/제거 시 debug/index.tsx만 수정
2026-03-26 10:46:05 +09:00
364a34ce10 refactor(debug): 자체 완결형 DevCoordDebug로 전환 — 프로덕션 번들 완전 제거 보장
- lazy + 동적 import로 DEV에서만 청크 로드
- mapRef 기반 이벤트 등록으로 KoreaMap 코드에 디버그 흔적 없음
- 이전 CoordDebugTool/useCoordDebug 삭제
2026-03-26 10:30:58 +09:00
8f342f70b7 feat(debug): Ctrl+Click 좌표 디버그 도구 + DEV 가드 체계 구축
- CoordDebugTool: Ctrl+Click 다중 좌표 표시 (DD/DMS, WGS84)
- import.meta.env.DEV 가드로 프로덕션 빌드에서 코드 제거
- CLAUDE.md: 디버그 도구 가이드 섹션 추가
2026-03-26 10:17:04 +09:00
8048eb533c Merge pull request 'release: 2026-03-26 (5건 커밋)' (#204) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m54s
2026-03-26 09:10:26 +09:00
8e17febd1b Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-26)' (#203) from release/2026-03-26 into develop 2026-03-26 09:09:44 +09:00
4a4afa3dc8 docs: 릴리즈 노트 정리 (2026-03-26) 2026-03-26 09:09:17 +09:00
2593c776bf Merge pull request 'feat(chat): Ollama Qwen3 기반 AI 해양분석 채팅 구축' (#202) from feature/ai-maritime-chat into develop 2026-03-26 09:05:24 +09:00
421f62ec0a docs: 릴리즈 노트 업데이트 2026-03-26 09:04:44 +09:00
e797beaac6 feat(chat): Ollama Qwen3 기반 AI 해양분석 채팅 구축
- Ollama Docker(14b/32b) + Redis 컨텍스트 캐싱 + 대화 히스토리
- Python SSE 채팅 엔드포인트 + 사전 쿼리 + Tool Calling
- 도메인 지식(해양법/어업협정/알고리즘) + DB 스키마 가이드
- Frontend SSE 스트리밍 + 타이머 + thinking 접기 + 확장 UI
2026-03-26 09:03:05 +09:00
f0094c21d3 Merge pull request 'release: 2026-03-25.2 (5건 커밋)' (#201) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m18s
2026-03-25 12:41:14 +09:00
b0bb0fe33d Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-25)' (#200) from release/2026-03-25.2 into develop 2026-03-25 12:40:52 +09:00
9bdda775c4 docs: 릴리즈 노트 정리 (2026-03-25) 2026-03-25 12:40:39 +09:00
c9411658b2 Merge pull request 'feat: 분석 용어/색상 통일 + 경량 분석 + 항적 미니맵' (#199) from feature/unified-analysis-alerts into develop 2026-03-25 12:40:17 +09:00
7750d11de5 docs: 릴리즈 노트 업데이트 2026-03-25 12:39:55 +09:00
1bf70f46ac feat: 분석 용어/색상 통일 + 경량 분석 + 항적 미니맵
- AI분석/현장분석/보고서 위험도 용어 통일 (HIGH→WATCH, MEDIUM→MONITOR, LOW→NORMAL)
- 공통 riskMapping.ts: ALERT_COLOR/EMOJI/LEVELS, RISK_TO_ALERT, STATS_KEY_MAP
- deck.gl 오버레이 색상 현장분석 팔레트로 통일
- Python 경량 분석: 파이프라인 미통과 412* 선박에 위치 기반 간이 AnalysisResult 생성
- 현장분석 fallback 제거: classifyStateFallback/classifyFishingZone → Python 결과 전용
- 보고서 위험 평가: Python riskCounts 실데이터 기반으로 전면 교체
- 현장분석 우측 패널: 항적 미니맵 (72시간, fetchVesselTrack API)
- 현장분석 좌측 패널: 위험도 점수 기준 섹션 추가
2026-03-25 12:39:22 +09:00
f1f965fcd4 Merge pull request 'release: 2026-03-25.1 (5건 커밋)' (#198) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m55s
2026-03-25 10:47:37 +09:00
3e723752af Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-25)' (#197) from release/2026-03-25.1 into develop 2026-03-25 10:47:14 +09:00
cfef5f7316 docs: 릴리즈 노트 정리 (2026-03-25) 2026-03-25 10:46:55 +09:00
51064212dc Merge pull request 'refactor: 현장분석/보고서 더미 데이터를 실데이터로 전환' (#196) from feature/dummy-to-real-data into develop 2026-03-25 10:45:23 +09:00
672591258d docs: 릴리즈 노트 업데이트 2026-03-25 10:44:58 +09:00
98c81cd548 refactor: 현장분석/보고서 더미 데이터를 실데이터로 전환
- AI 파이프라인 PROC 순환 애니메이션 → analysisMap 기반 ON/OFF 상태
- BD-09 STANDBY → bd09OffsetM 실측 탐지 수 표시
- 보고서 수역별 허가업종: ZONE_ALLOWED 상수 동적 참조
- 건의사항: 월/최대 어구 선단 실데이터 연동
- 보고서 버튼: 헤더 → 현장분석 내부로 이동
2026-03-25 10:44:28 +09:00
ebde2dd4cf Merge pull request 'release: 2026-03-25.2 (50건)' (#195) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m5s
2026-03-25 09:33:40 +09:00
308be14b4f Merge pull request 'feat: 어구 마름모 아이콘 + 리플레이 모선 색상 구분' (#194) from feature/gear-diamond-icon into develop 2026-03-25 09:33:29 +09:00
caaedfa5e2 feat: 어구/어망 마름모 아이콘 분리 + 리플레이 모선 색상 구분
- gear-diamond SDF 이미지 등록 (ShipLayer.tsx)
- 라이브/가상/히스토리 전 레이어에서 어구 패턴 → 마름모, 회전 없음
- 모선/선단 선박은 삼각형 유지 (isGear 속성 기반 분기)
- 어구 아이콘 크기 80% 축소 (baseSize 0.14→0.11, 히스토리 0.7→0.55)
- 리플레이 시 모선 아이콘/라벨 노란색(#fbbf24) 구분

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:33:10 +09:00
a556e5f434 Merge pull request 'release: 2026-03-25.1 (halo fix)' (#193) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m53s
2026-03-25 07:43:56 +09:00
25d446731f Merge pull request 'fix: 가상선박 아이콘 SDF halo 아티팩트 제거' (#192) from release/2026-03-25 into develop 2026-03-25 07:43:45 +09:00
a6e91a8e81 fix: 가상선박 아이콘 SDF halo 아티팩트 제거
- 모선 icon-halo-color 노란색 제거 (SDF 바운딩박스 채움 현상)
- halo 통일: rgba(0,0,0,0.6) / width 0.5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 07:43:24 +09:00
2fc8b1d785 Merge pull request 'release: 2026-03-25 (46건 커밋)' (#191) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m55s
2026-03-25 07:38:53 +09:00
29f3bc3186 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-25)' (#190) from release/2026-03-25 into develop 2026-03-25 07:38:17 +09:00
d37a7dfa78 docs: 릴리즈 노트 정리 (2026-03-25)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 07:37:59 +09:00
be9fd3c6cb Merge pull request 'fix: 분석 파이프라인 정확도 개선 + 폴리곤 히스토리 애니메이션' (#189) from fix/analysis-pipeline-accuracy into develop 2026-03-25 07:37:26 +09:00
8f9b347e1f feat: 폴리곤 히스토리 애니메이션 + 어구 추적 안정화
- FleetClusterLayer: 12시간 타임라인 기반 폴리곤 재생 애니메이션
  - 중심 이동 궤적 (점선) + 어구별 개별 궤적 (실선)
  - 가상 어구/선박 아이콘 COG 회전 + 스냅샷 동기화
  - 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭)
  - 신호없음 구간: 마지막 유효 스냅샷 유지 + 회색 점선 표시
  - 히스토리 모드 시 현재 강조 레이어 (deck.gl + MapLibre) 숨김
  - ESC 키: 히스토리 닫기 + 선택 해제
- polygon_builder: STALE_SEC 3600→21600 (6시간, 어구 P75 갭 3.5h 커버)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 07:33:48 +09:00
7573c84e91 fix: 분석 파이프라인 정확도 개선 + 캐시 증분 갱신 + TTS 프록시
- MIN_TRAJ_POINTS 100→20 (16척→684척, 파이프라인 병목 해소)
- risk.py: SOG 급변 count를 위험도 점수에 반영 (+5/+10)
- spoofing.py: BD09 오프셋 중국 MMSI(412*) 예외 (좌표계 노이즈 제거)
- fishing_pattern.py: 마지막 조업 세그먼트 누락 버그 수정
- VesselAnalysisService: 인메모리 캐시 + 증분 갱신 (warmup 2h → incremental)
- nginx: /api/gtts 프록시 추가 (Google TTS CORS 우회)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:48:27 +09:00
e30dcb74ad Merge pull request 'release: 2026-03-24.4 (41건 커밋)' (#188) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m51s
2026-03-24 16:27:01 +09:00
eb6e51c8a0 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-24.4)' (#187) from release/2026-03-24.4 into develop 2026-03-24 16:26:35 +09:00
8f77b68bef docs: 릴리즈 노트 정리 (2026-03-24.4)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:26:12 +09:00
69c18e0237 Merge pull request 'feat: 작전가이드/보고서 모달 병합 + 어구 그룹 섹션 자동 전환' (#186) from merge/korea-layers-enhancement into develop 2026-03-24 16:25:19 +09:00
a3ba0d070b feat: 작전가이드/보고서 모달 병합 + 어구 그룹 섹션 자동 전환
- feature/korea-layers-enhancement 브랜치 기능 이식 (develop 구조 적용)
- OpsGuideModal: 경비함정 작전가이드 3탭 + 임검침로 해상 루트 + TTS
- ReportModal: 중국어선 감시현황 자동 보고서 생성
- KoreaMap: buildSeaRoute (육지 우회) + externalFlyTo + opsRoute 렌더링
- KoreaDashboard: 작전가이드/보고서 버튼 + 모달 상태 관리
- vite.config: /api/gtts 프록시 추가 (Google TTS CORS 우회)
- FleetClusterLayer: 지도 어구 클릭 시 해당 섹션 자동 오픈 + 스크롤

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:24:38 +09:00
07d47c999e Merge pull request 'release: 2026-03-24.3 (37건 커밋)' (#185) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m51s
2026-03-24 15:57:20 +09:00
686f259a72 Merge pull request 'feat: AI 분석 통계 서버사이드 전환 + 어구/선단 UI 개선' (#184) from feature/korea-server-analysis-integration into develop 2026-03-24 15:55:55 +09:00
433141a3e8 feat: AI 분석 통계 서버사이드 전환 + 어구/선단 UI 개선
- Backend: /api/vessel-analysis 응답에 stats 집계 필드 추가
- Backend: GroupPolygonService.getGearStats() 어구 SQL 집계
- Frontend: 클라이언트 사이드 stats/gearStats 계산 로직 완전 제거
- Frontend: 가상 선박 마커, 어구 겹침 팝업, 패널 아코디언
- Frontend: cnFishingSuspects에 모선 포함
- Python: vessel_store COG bearing 계산 (마지막 2점 기반)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:55:15 +09:00
1029e07432 Merge pull request 'release: 2026-03-24.4 (캐시 TTL 수정)' (#183) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m56s
2026-03-24 14:21:16 +09:00
f5ffb4c079 Merge pull request 'fix(backend): 그룹 폴리곤 캐시 TTL 5분 제한' (#182) from fix/group-polygon-cache-ttl into develop 2026-03-24 14:21:15 +09:00
205de674bb fix(backend): 그룹 폴리곤 캐시 TTL 5분 제한
- Caffeine 전역 TTL(2일) 대신 서비스 내 수동 5분 TTL 체크
- 5분마다 DB에서 최신 스냅샷 재조회 보장
2026-03-24 14:20:53 +09:00
89786f1ec3 Merge pull request 'release: 2026-03-24.3 (어구그룹 탐지 수정)' (#181) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m48s
2026-03-24 14:18:24 +09:00
7d9cfe4456 Merge pull request 'fix(prediction): 어구그룹 탐지 — 전체 AIS 선박 대상으로 확장' (#180) from fix/gear-group-detection into develop 2026-03-24 14:18:13 +09:00
4c22d5f1f9 fix(prediction): 어구그룹 탐지 — 전체 AIS 선박 대상으로 확장
- detect_gear_groups: vessel_dfs(분류 대상만) → vessel_store.get_all_latest_positions()(전체 14K선박)
- build_all_group_snapshots: 동일하게 all_positions 기반으로 전환
- vessel_store: get_all_latest_positions() 메서드 추가
- 결과: 0 gear groups → 210 gear groups (GEAR_IN_ZONE 57, GEAR_OUT_ZONE 45)
2026-03-24 14:17:44 +09:00
03747d3c63 Merge pull request 'release: 2026-03-24.2 (폴리곤 서버사이드 이관)' (#179) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m55s
2026-03-24 14:09:37 +09:00
bbe8bf3570 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-24.2)' (#178) from release/2026-03-24.2 into develop 2026-03-24 14:09:20 +09:00
f5654477b2 docs: 릴리즈 노트 정리 (2026-03-24.2) 2026-03-24 14:09:03 +09:00
020cc12197 Merge pull request 'feat: 선단/어구그룹 폴리곤 서버사이드 이관 + 히스토리 추적' (#177) from feature/server-side-polygons into develop 2026-03-24 14:08:37 +09:00
f24eedbd9e docs: 릴리즈 노트 업데이트 2026-03-24 14:08:07 +09:00
00067fa165 fix: 불법어선 탭 복원 (임시 숨김 해제) 2026-03-24 14:05:50 +09:00
9cad89113d feat(frontend): FleetClusterLayer 서버사이드 폴리곤 전환
- vesselAnalysis.ts: GroupPolygonDto 타입 + fetchGroupPolygons/Detail/History
- useGroupPolygons.ts: 5분 폴링 훅 (fleetGroups/gearInZone/gearOutZone)
- FleetClusterLayer: 클라이언트 convexHull/padPolygon 제거 → API GeoJSON 렌더링
- KoreaDashboard/KoreaMap: groupPolygons 훅 연결 + props 전달
2026-03-24 13:42:14 +09:00
b0fafca8c9 feat(backend): 그룹 폴리곤 API — 목록/상세/히스토리 엔드포인트
- GroupPolygonController: GET /api/vessel-analysis/groups (목록, 상세, 히스토리)
- GroupPolygonService: JdbcTemplate + ST_AsGeoJSON + Caffeine 5분 캐시
- GroupPolygonDto: GeoJSON polygon + members JSONB 응답 구조
- CacheConfig: GROUP_POLYGONS 캐시 키 추가
2026-03-24 13:32:36 +09:00
2441e3068a feat(prediction): 선단/어구그룹 폴리곤 서버사이드 생성 + PostGIS 저장
- DB migration 009: group_polygon_snapshots 테이블 (PostGIS geometry)
- polygon_builder.py: Shapely 기반 convex hull + buffer 폴리곤 생성
- scheduler.py: 5분 주기 분석 사이클에 폴리곤 생성 Step 4.5 통합
- fleet_tracker.py: get_company_vessels() 메서드 추가
- kcgdb.py: save_group_snapshots(), cleanup_group_snapshots() 추가
- requirements.txt: shapely>=2.0 추가
2026-03-24 13:30:31 +09:00
5384092b21 Merge pull request 'release: 2026-03-24.1 (5건 커밋)' (#176) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m0s
2026-03-24 10:18:04 +09:00
053ab25ae7 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-24)' (#175) from release/2026-03-24.1 into develop 2026-03-24 10:16:57 +09:00
360ca0e382 docs: 릴리즈 노트 정리 (2026-03-24) 2026-03-24 10:16:34 +09:00
6765c0bc5f Merge pull request 'feat: 웹폰트 내장 + 이란 시설물 색상/가독성 개선' (#174) from feature/embed-fonts into develop 2026-03-24 10:13:59 +09:00
72291b2060 docs: 릴리즈 노트 업데이트 2026-03-24 10:13:23 +09:00
3f2052a46e feat: 웹폰트 내장 + 이란 시설물 색상/가독성 개선
- @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅
- 전체 font-family 통일 (CSS, deck.gl, 인라인 스타일)
- 이란 시설물 색상 사막 대비 고채도 팔레트로 교체
- 이란 라벨 fontWeight 600→700, alpha 200→255
- 접힘 패널 상하 패딩 균일화
2026-03-24 10:11:59 +09:00
a404d81173 Merge pull request 'release: 2026-03-24.2' (#173) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m51s
2026-03-24 09:37:13 +09:00
ea739ab03a Merge pull request 'style: 폰트 외곽 테두리 축소 (outlineWidth 8→3)' (#172) from fix/outline-width-thin into develop 2026-03-24 09:36:37 +09:00
7ce71209cc style: 폰트 외곽 테두리 최소 두께로 축소 (outlineWidth 8→3) 2026-03-24 09:36:22 +09:00
90d1fc249d Merge pull request 'release: 2026-03-24.1 (불법어선 탭 숨김)' (#171) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m4s
2026-03-24 09:34:33 +09:00
4b165afcb8 Merge pull request 'fix: 불법어선 탭 임시 숨김' (#170) from fix/hide-illegal-fishing-tab into develop 2026-03-24 09:34:23 +09:00
c9d504a28f fix: 불법어선 탭 임시 숨김 (준비 중) 2026-03-24 09:34:09 +09:00
a3a933f096 Merge pull request 'release: 2026-03-24 (14건 커밋)' (#169) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m14s
2026-03-24 09:29:49 +09:00
684c74b6eb Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-24)' (#168) from release/2026-03-24 into develop 2026-03-24 09:29:24 +09:00
911a7473be docs: 릴리즈 노트 정리 (2026-03-24) 2026-03-24 09:29:02 +09:00
872cd82cc0 Merge pull request 'feat: LayerPanel 트리 + 이란 리플레이 실데이터 + 글꼴 커스텀' (#167) from feat/iran-replay-realdata into develop 2026-03-24 09:28:43 +09:00
6c91655dfb docs: 릴리즈 노트 업데이트 2026-03-24 09:28:08 +09:00
44aa449b03 feat: 지도 글꼴 크기 커스텀 시스템 (4개 그룹 슬라이더)
- FontScaleContext + FontScalePanel: 시설/선박/분석/지역 4그룹 × 0.5~2.0 범위
- LAYERS 패널 하단 슬라이더 UI, localStorage 영속화
- Korea static 14개 + Iran 4개 + 분석 3개 + KoreaMap 5개 TextLayer 적용
- MapLibre 선박 라벨/국가명 실시간 반영
- 모든 useMemo deps + updateTriggers에 fontScale 포함
2026-03-24 09:27:11 +09:00
6d4ac4d3fe feat(frontend): 이란 리플레이 실데이터 전환 + 피격선박 이벤트 통합
- GeoEvent.type에 'sea_attack' 추가 + SEA ATK 배지 (#0ea5e9)
- damagedShips → GeoEvent 변환, mergedEvents에 합류
- 더미↔API 토글 UI (ReplayControls 배속 우측)
- useIranData: dataSource 분기 (dummy=sampleData, api=Backend DB)
- API 모드: events/aircraft/osint 시점 범위 조회 (3월1일~오늘)
- 중복 방지: API 모드에서 damageEvents 프론트 병합 건너뜀
- fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수
2026-03-24 07:52:22 +09:00
9e1b3730ff feat(backend): 이란 리플레이 시점 조회 API + Events CRUD
- Aircraft/OSINT Controller: from/to Instant 파라미터 추가 (기존 캐시 조회와 공존)
- AircraftService.getByDateRange(): DB에서 icao24별 최신 위치 조회
- OsintService.getByDateRange(): 날짜 범위 OSINT 조회
- Event 패키지 신규: Entity, Dto, Repository, Service, Controller
  - GET /api/events?from=&to= (인증 예외)
  - POST /api/events/import (벌크 import)
- AuthFilter: /api/events 인증 예외 추가
2026-03-24 07:52:06 +09:00
Nan Kyung Lee
81bced4367 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-24 06:34:51 +09:00
dc8a30a58b feat: LayerPanel 공통 트리 구조 + SVG 아이콘 전수 전환
- LayerTreeNode 공통 인터페이스 + LayerTreeRenderer 재귀 컴포넌트
- 한국/이란 양쪽 트리 데이터 정의 + batchToggle 캐스케이드
- 위험시설/해외시설 emoji→SVG IconLayer 전환 (12 SVG 함수, 3 IconLayer)
- 부모 토글→하위 전체 ON/OFF, 카운트 합산 동기화
- 대시보드 탭 localStorage 영속화
2026-03-24 06:34:42 +09:00
ed77005619 Merge pull request 'release: 2026-03-23.6 (5건 커밋)' (#166) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m22s
2026-03-23 15:30:50 +09:00
13bdebb924 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-23.6)' (#165) from release/2026-03-23.6 into develop 2026-03-23 15:30:16 +09:00
4a366b320a docs: 릴리즈 노트 정리 (2026-03-23.6) 2026-03-23 15:30:02 +09:00
faf43fc7db Merge pull request 'fix: LIVE 모드 렌더링 최적화 + 특정어업수역 폴리곤 수정' (#164) from fix/korea-rendering-perf into develop 2026-03-23 15:28:31 +09:00
786092edc1 docs: 릴리즈 노트 업데이트 2026-03-23 15:27:51 +09:00
e2b531d9c5 fix: LIVE 모드 렌더링 최적화 + 특정어업수역 폴리곤 수정
- useMonitor 1초 setInterval 제거 (LIVE 60배 과잉 재계산 해소)
- useKoreaFilters currentTime 의존성 제거 (5분 polling 시에만 재계산)
- useKoreaData aircraft/satellite LIVE/REPLAY 분리
- 특정어업수역 실제 폴리곤 좌표 적용 (bbox→원본 GeoJSON 변환)
- FishingZoneLayer zone 속성 매칭 수정
- 선박/분석 라벨 폰트 크기 80% 축소
2026-03-23 15:26:41 +09:00
bc355ff521 Merge pull request 'release: 2026-03-23.5 (2건 커밋)' (#163) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m2s
2026-03-23 14:52:26 +09:00
66f792724b docs: 릴리즈 노트 정리 (2026-03-23.5) (#162) 2026-03-23 14:52:11 +09:00
498c198336 feat: 이란 시설 deck.gl SVG 전환 + 아이콘 품질 통합 + AI 챗 (#161) 2026-03-23 14:51:14 +09:00
a1ba74697a Merge pull request 'release: 2026-03-23.4 (2건 커밋)' (#160) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m50s
2026-03-23 13:20:00 +09:00
f55cbe8426 docs: 릴리즈 노트 정리 (2026-03-23.4) (#159) 2026-03-23 13:19:26 +09:00
2511a33444 perf: 렌더링 성능 최적화 + 환적 Python 이관 + 중국어선감시 통합 (#158) 2026-03-23 13:16:24 +09:00
a1c917108c Merge pull request 'release: 2026-03-23.3 (리팩토링)' (#157) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m53s
2026-03-23 11:15:41 +09:00
f4ee67a71a docs: 릴리즈 노트 정리 (2026-03-23.3) (#156) 2026-03-23 11:15:30 +09:00
2c566041ca refactor: 프론트엔드 구조 리팩토링 Phase 1~6 (#155) 2026-03-23 11:14:49 +09:00
b0dfa7f6a7 Merge pull request 'release: 2026-03-23.2 (2건 커밋)' (#154) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m52s
2026-03-23 09:32:35 +09:00
f36e1b297b Merge pull request 'release: 2026-03-23 (4건 커밋)' (#151) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m58s
2026-03-23 08:25:33 +09:00
9f0f60159f Merge pull request 'release: 2026-03-20.3 (deck.gl 전면 전환)' (#144) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m56s
2026-03-20 21:22:39 +09:00
f98eca0aec Merge pull request 'release: 어구그룹 하이라이트' (#141) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m0s
2026-03-20 19:08:15 +09:00
db352946ae Merge pull request 'release: 어구 거리제한' (#139) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m58s
2026-03-20 18:54:18 +09:00
cc32ba6290 Merge pull request 'release: 어구 그룹핑 조건 추가' (#137) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m54s
2026-03-20 18:50:38 +09:00
a6de14ecef Merge pull request 'release: 비허가 어구 클러스터' (#135) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m54s
2026-03-20 18:44:16 +09:00
3a31b90a96 Merge pull request 'release: 선단 클러스터 UI' (#133) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m0s
2026-03-20 18:19:56 +09:00
9cf2dbe58c Merge pull request 'release: 선단 등록 DB + 어구 추적' (#131) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m3s
2026-03-20 18:07:46 +09:00
56b92e408f Merge pull request 'release: 선단 패턴 매칭 + 수역 위험도' (#129) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m52s
2026-03-20 17:47:08 +09:00
d35cafb6c5 Merge pull request 'release: 위험도 수역 가산 + 클러스터 그리드 셀' (#127) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m6s
2026-03-20 17:39:16 +09:00
93ddb7d1b6 Merge pull request 'release: 선단 Python 전환 + 성능 복원' (#125) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m59s
2026-03-20 17:28:26 +09:00
fcf1ff5363 Merge pull request 'release: 선단 그룹핑 재설계' (#123) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m53s
2026-03-20 17:13:18 +09:00
15b68bb634 Merge pull request 'release: 점수표시 + 마커위치 + 클러스터 수정' (#121) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m50s
2026-03-20 16:19:56 +09:00
7b31f93d86 Merge pull request 'release: AI 분석 패널 개선' (#119) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m49s
2026-03-20 15:42:57 +09:00
318cfa94ad Merge pull request 'release: AI 분석 패널 인터랙티브' (#117) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m56s
2026-03-20 15:22:31 +09:00
d6aac611d0 Merge pull request 'release: 분석 오버레이 라이브 위치' (#115) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m49s
2026-03-20 15:17:09 +09:00
b24d43e4a1 Merge pull request 'release: 불법어선 수역 필터 + AI 패널 + 마커' (#113) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m49s
2026-03-20 14:17:04 +09:00
be38983cc5 Merge pull request 'release: 수역 폴리곤 오버레이 + 마커 가시성' (#111) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m45s
2026-03-20 14:05:54 +09:00
6e12883768 Merge pull request 'release: vessel-analysis API + 불법어선 필터 수정' (#109) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m17s
2026-03-20 13:58:24 +09:00
d09b8de765 Merge pull request 'release: 불법어선 필터 수정' (#107) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m53s
2026-03-20 13:53:14 +09:00
e0f9b5cf64 Merge pull request 'release: numpy float DB INSERT 수정' (#105) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m46s
2026-03-20 13:40:36 +09:00
d99e356a5d Merge pull request 'release: CacheConfig 빌드 수정' (#103) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m49s
2026-03-20 13:34:09 +09:00
7d27d5fc83 Merge pull request 'release: 2026-03-20.2 (Python 분석 결과 오버레이)' (#101) from develop into main
Some checks failed
Deploy KCG / deploy (push) Failing after 1m7s
2026-03-20 13:31:15 +09:00
fb15b4c89b Merge pull request 'release: 2026-03-20 (특정어업수역 폴리곤 수역 분류)' (#98) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m44s
2026-03-20 12:53:16 +09:00
4cf54a0b4e Merge pull request 'release: 중국어선감시 연결선 폭발 수정' (#95) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m49s
2026-03-20 12:30:51 +09:00
4b33d1792b Merge pull request 'release: prediction 배포 스크립트 수정' (#93) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m51s
2026-03-20 12:21:18 +09:00
51a0ff933a Merge pull request 'release: deploy 키 갱신 재배포' (#91) from develop into main
Some checks failed
Deploy KCG / deploy (push) Failing after 2m13s
2026-03-20 12:17:46 +09:00
635753f636 Merge pull request 'release: Python 어선 분류기 + 배포 설정 + 모니터링 프록시' (#89) from develop into main
Some checks failed
Deploy KCG / deploy (push) Failing after 1m47s
2026-03-20 12:10:52 +09:00
d9d5a9483e Merge pull request 'release: 중국어선 조업분석, 이란 시설, 레이어 재구성 + OSINT 중복 수정' (#86) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m24s
2026-03-20 08:52:32 +09:00
8035692dfc Merge pull request 'release: OSINT 중복 저장 최종 수정' (#83) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m22s
2026-03-19 13:07:42 +09:00
3967d77d65 Merge pull request 'release: OSINT 중복 체크 핫픽스' (#81) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m21s
2026-03-19 11:50:01 +09:00
4fb16678f8 Merge pull request 'release: CI/CD OpenSky 크레덴셜 환경변수' (#79) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m20s
2026-03-19 11:02:54 +09:00
962f2df683 Merge pull request 'release: GDELT URL 인코딩 핫픽스' (#77) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m17s
2026-03-19 10:56:46 +09:00
e052795ef5 Merge pull request 'release: 2026-03-19.2 (5건 커밋)' (#75) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m23s
2026-03-19 10:45:38 +09:00
a96103e639 Merge pull request 'release: 2026-03-19 (5건 커밋)' (#72) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m20s
2026-03-19 10:24:59 +09:00
5ff400f982 Merge pull request 'refactor: 인라인 CSS 정리' (#69) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m25s
2026-03-18 14:23:47 +09:00
f735a3ce7f Merge pull request 'fix: 선박 클릭 지도 이동 + 모달' (#67) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m17s
2026-03-18 12:28:28 +09:00
0604887c75 Merge pull request 'fix: LIVE 모드 더미 피격선박 제거' (#65) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m29s
2026-03-18 12:12:21 +09:00
9c091d1052 Merge pull request 'fix: 선박 분류 오류 수정 + 배지 색상 통일' (#63) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m21s
2026-03-18 11:58:56 +09:00
5e85e80142 Merge pull request 'release: 2026-03-18.5 (5건 커밋)' (#61) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m21s
2026-03-18 11:04:28 +09:00
5ce172eb82 Merge pull request 'fix(deploy): SSH 연결 재시도 로직' (#58) from develop into main
Some checks failed
Deploy KCG / deploy (push) Failing after 1m9s
2026-03-18 10:00:19 +09:00
278c20968e Merge pull request 'release: 2026-03-18.4 (5건 커밋)' (#56) from develop into main
Some checks failed
Deploy KCG / deploy (push) Failing after 1m11s
2026-03-18 09:55:50 +09:00
d0c8b3d1bd Merge pull request 'release: 2026-03-18.3 (10건 커밋)' (#53) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m16s
2026-03-18 09:37:50 +09:00
217개의 변경된 파일34486개의 추가작업 그리고 4758개의 파일을 삭제

파일 보기

@ -148,75 +148,10 @@ jobs:
sleep 10
done
# ═══ 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"
# ═══ Prediction (FastAPI) — CI/CD 제외, 수동 배포 ═══
# ssh redis-211 에서 수동 배포:
# scp prediction/*.py redis-211:/home/apps/kcg-prediction/
# ssh redis-211 "sudo systemctl restart kcg-prediction"
- name: Cleanup
if: always()

4
.gitignore vendored
파일 보기

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

197
CLAUDE.md
파일 보기

@ -9,10 +9,11 @@
| 패키지 | 스택 | 비고 |
|--------|------|------|
| **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) | |
| **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 궤적 |
| **CI/CD** | Gitea Actions → nginx + systemd | main merge 시 자동 배포 |
## 빌드 및 실행
@ -29,55 +30,135 @@ npm run lint # ESLint 검증
### Backend
```bash
cd backend
# 최초: application-local.yml 설정 필요
cp src/main/resources/application-local.yml.example src/main/resources/application-local.yml
sdk use java 21.0.9-amzn # JDK 21 필수
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
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
# kcgdb (분석 결과)
psql -h 211.208.115.83 -U kcg_app -d kcgdb -f database/migration/009_group_polygons.sql
# snpdb (궤적 원본) — 읽기 전용, 별도 관리
```
## 프로젝트 구조
```
frontend/ # React 19 + Vite 7 + Tailwind CSS 4
frontend/ # React 19 + Vite 7 + Tailwind CSS 4 + deck.gl
├── src/
│ ├── 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
│ ├── 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)
├── package.json
└── vite.config.ts # Vite + 프록시 (/api/kcg → backend, /signal-batch → wing)
└── vite.config.ts # Vite + 프록시 (/api/kcg → backend)
backend/ # Spring Boot 3.2 + Java 17
backend/ # Spring Boot 3.2 + Java 21
├── src/main/java/gc/mda/kcg/
│ ├── 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
│ ├── 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)
└── pom.xml
database/ # PostgreSQL
├── init.sql # CREATE SCHEMA kcg
└── migration/001_initial_schema.sql # events, news, osint, users, login_history
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
prediction/ # FastAPI placeholder
deploy/ # systemd + nginx 배포 설정
.gitea/workflows/deploy.yml # CI/CD (main merge → 자동 빌드/배포)
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 → 자동 빌드/배포)
```
## 주요 기능
### 한국 현황 대시보드
- **선박 모니터링**: 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 상태 영속화
## 팀 스킬 사용 지침
### 중요: 스킬 실행 시 반드시 따라야 할 규칙
@ -94,6 +175,11 @@ deploy/ # systemd + nginx 배포 설정
3. **API 응답 검증**: MR 생성, 봇 승인, 머지 API 호출 후 반드시 상태를 확인.
실패 시 사용자에게 알리고 중단.
**MR/머지는 사용자 명시적 요청 없이 절대 진행 금지:**
- `git push` 완료 후에는 "푸시 완료" 보고만 하고 **중단**
- MR 생성은 사용자가 `/mr`, `/release` 호출 또는 "MR 해줘" 등 명시적 요청 시에만
- **스킬 없이 Gitea API를 직접 호출하여 MR/머지를 진행하지 말 것** — 스킬 절차(사용자 확인 단계)를 우회하는 것과 동일
### 스킬 목록
- `/push` — 커밋 + 푸시 (해시 비교 → 커밋 메시지 확인 → 푸시)
- `/mr` — 커밋 + 푸시 + MR 생성 (릴리즈 노트 갱신 포함)
@ -108,12 +194,47 @@ deploy/ # systemd + nginx 배포 설정
## 배포
- **도메인**: 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)
| 서버 | 역할 | 경로/설정 |
|------|------|----------|
| **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, 다중 포인트) |
### 디버그 도구 분류 기준
다음에 해당하면 디버그 도구로 분류하고, 불확실하면 사용자에게 확인:
- 개발/검증 목적의 좌표/데이터 표시 도구
- 프로덕션 사용자에게 불필요한 진단 정보
- 임시 데이터 시각화, 성능 프로파일링
- 특정 조건에서만 활성화되는 테스트 기능
## 팀 규칙

파일 보기

@ -26,6 +26,7 @@ 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;
@ -37,7 +38,8 @@ 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(FLEET_PATH_PREFIX)
|| path.startsWith(EVENTS_PATH_PREFIX);
}
@Override

파일 보기

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

파일 보기

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

파일 보기

@ -21,6 +21,7 @@ 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() {
@ -29,7 +30,8 @@ public class CacheConfig {
OSINT_IRAN, OSINT_KOREA,
SATELLITES,
SEISMIC, PRESSURE,
VESSEL_ANALYSIS
VESSEL_ANALYSIS,
GROUP_POLYGONS
);
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:5173}")
@Value("${app.cors.allowed-origins:http://localhost:5174,http://localhost:5173}")
private List<String> allowedOrigins;
@Bean

파일 보기

@ -2,12 +2,14 @@ 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;
@ -20,16 +22,24 @@ 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(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) {
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", schema = "kcg")
@Table(name = "aircraft_positions")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -1,6 +1,17 @@
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);
}

파일 보기

@ -0,0 +1,51 @@
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,7 +7,6 @@ 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
@ -24,12 +23,6 @@ public class VesselAnalysisController {
@GetMapping
public ResponseEntity<Map<String, Object>> getVesselAnalysis(
@RequestParam(required = false) String region) {
List<VesselAnalysisDto> results = vesselAnalysisService.getLatestResults();
return ResponseEntity.ok(Map.of(
"count", results.size(),
"items", results
));
return ResponseEntity.ok(vesselAnalysisService.getLatestResultsWithStats());
}
}

파일 보기

@ -39,6 +39,7 @@ public class VesselAnalysisDto {
private ClusterInfo cluster;
private FleetRoleInfo fleetRole;
private RiskScoreInfo riskScore;
private TransshipInfo transship;
}
@Getter
@ -99,6 +100,15 @@ 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())
@ -141,6 +151,11 @@ 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", schema = "kcg")
@Table(name = "vessel_analysis_results")
@Getter
@Setter
@NoArgsConstructor
@ -76,6 +76,14 @@ 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;
@ -94,5 +102,8 @@ public class VesselAnalysisResult {
if (isLeader == null) {
isLeader = false;
}
if (isTransshipSuspect == null) {
isTransshipSuspect = false;
}
}
}

파일 보기

@ -1,56 +1,115 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.CacheConfig;
import gc.mda.kcg.domain.fleet.GroupPolygonService;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
@RequiredArgsConstructor
public class VesselAnalysisService {
private final VesselAnalysisResultRepository repository;
private final CacheManager cacheManager;
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;
/**
* 최근 1시간 분석 결과를 반환한다. mmsi별 최신 1건만.
* Caffeine 캐시(TTL 5분) 적용.
* 최근 2시간 분석 결과 + 집계 통계.
* - 호출(warmup): 2시간 전체 조회 캐시 구축
* - 이후: lastFetchTime 이후 증분만 조회 캐시 병합
* - 2시간 초과 항목은 evict
* - 갱신 TTL 타이머 초기화
*/
@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();
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());
}
}
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, Integer> gearStats = groupPolygonService.getGearStats();
List<VesselAnalysisDto> results = latest.values().stream()
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()
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
.map(VesselAnalysisDto::from)
.toList();
if (cache != null) {
cache.put("data", results);
}
return results;
return Map.of(
"count", results.size(),
"items", results,
"stats", stats
);
}
}

파일 보기

@ -0,0 +1,42 @@
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;
}

파일 보기

@ -0,0 +1,34 @@
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()));
}
}

파일 보기

@ -0,0 +1,49 @@
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;
}
}

파일 보기

@ -0,0 +1,13 @@
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);
}

파일 보기

@ -0,0 +1,43 @@
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,6 +1,7 @@
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;
@ -17,10 +18,14 @@ 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 kcg.fleet_companies ORDER BY id"
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM "
+ dbSchema + ".fleet_companies ORDER BY id"
);
return ResponseEntity.ok(results);
}

파일 보기

@ -0,0 +1,12 @@
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;
}

파일 보기

@ -0,0 +1,13 @@
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;
}

파일 보기

@ -0,0 +1,26 @@
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;
}

파일 보기

@ -0,0 +1,13 @@
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;
}

파일 보기

@ -0,0 +1,13 @@
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;
}

파일 보기

@ -0,0 +1,123 @@
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()));
}
}
}

파일 보기

@ -0,0 +1,31 @@
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

파일 보기

@ -0,0 +1,28 @@
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;
}

파일 보기

@ -0,0 +1,30 @@
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;
}

파일 보기

@ -0,0 +1,22 @@
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;
}

파일 보기

@ -0,0 +1,95 @@
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
));
}
}

파일 보기

@ -0,0 +1,31 @@
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;
}

파일 보기

@ -0,0 +1,31 @@
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;
}

파일 보기

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

파일 보기

@ -4,12 +4,14 @@ 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;
@ -23,18 +25,26 @@ 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(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) {
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,7 +9,6 @@ import java.time.Instant;
@Entity
@Table(
name = "osint_feeds",
schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"})
)
@Getter

파일 보기

@ -12,4 +12,6 @@ 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);
}

파일 보기

@ -0,0 +1,25 @@
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", schema = "kcg")
@Table(name = "satellite_tle")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -8,7 +8,6 @@ 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", schema = "kcg")
@Table(name = "seismic_events")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -1,16 +1,19 @@
spring:
datasource:
url: jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg
username: kcg_user
password: kcg_pass
url: ${DB_URL:jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg,public}
username: ${DB_USERNAME:kcg_user}
password: ${DB_PASSWORD:kcg_pass}
app:
jwt:
secret: local-dev-secret-key-32chars-minimum!!
expiration-ms: 86400000
secret: ${JWT_SECRET:local-dev-secret-key-32chars-minimum!!}
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
google:
client-id: YOUR_GOOGLE_CLIENT_ID
client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID}
auth:
allowed-domain: gcsc.co.kr
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
collector:
open-sky-client-id: YOUR_OPENSKY_CLIENT_ID
open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET
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}

파일 보기

@ -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: http://localhost:5173,https://kcg.gc-si.dev
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173,https://kcg.gc-si.dev}

파일 보기

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

파일 보기

@ -0,0 +1,7 @@
-- 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;

파일 보기

@ -0,0 +1,49 @@
-- 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);

파일 보기

@ -0,0 +1,146 @@
-- 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;

파일 보기

@ -0,0 +1,14 @@
-- 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);

파일 보기

@ -0,0 +1,176 @@
-- 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
);

파일 보기

@ -0,0 +1,23 @@
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;

파일 보기

@ -0,0 +1,125 @@
-- 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();

파일 보기

@ -0,0 +1,111 @@
-- 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);

파일 보기

@ -0,0 +1,21 @@
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,6 +20,21 @@ 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/;
@ -94,6 +109,16 @@ 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;

파일 보기

@ -0,0 +1,514 @@
# 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)

파일 보기

@ -0,0 +1,677 @@
# 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`

파일 보기

@ -0,0 +1,706 @@
# 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 버튼 교체와 최소 조회 화면
이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.

파일 보기

@ -0,0 +1,693 @@
# 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,42 +4,190 @@
## [Unreleased]
## [2026-03-23.2]
## [2026-04-04]
### 추가
- 중국어선감시 탭: CN 어선 + 어구 패턴 선박 필터링
- 중국어선감시 탭: 조업수역 ~Ⅳ 폴리곤 동시 표시
- 어구 그룹 수역 내/외 분류 (조업구역내 붉은색, 비허가 오렌지)
- 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구)
- 폴리곤 클릭·zoom 시 어구 행 자동 스크롤
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
- AI 분석 닫힘 시 위험도 마커 off
### 변경
- AI 분석 패널 위치 조정 (줌 버튼 간격 확보)
- 백엔드 vessel-analysis 조회 윈도우 1h → 2h
- 어구 모선 추론(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)
### 수정
- FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거)
- 모선 검토 대기 목록을 폴리곤 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)
## [2026-03-23]
### 추가
- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설
- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드)
- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴)
- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보)
- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기
- 이란 시설 deck.gl SVG 전환: 26개 고유 SVG 아이콘 (IconLayer + TextLayer)
- 중동 에너지/위험시설 데이터 84개 (meEnergyHazardFacilities)
- 환적탐지 Python 이관: 서버사이드 그리드 공간인덱스 O(n log n)
- 중국어선감시 탭: CN 어선 + 어구 패턴 필터링, 조업수역 폴리곤
- AI 해양분석 챗 UI (AiChatPanel, placeholder)
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
### 변경
- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함)
- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x)
- 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)
### 수정
- LIVE 모드 렌더링 최적화: useMonitor 1초 setInterval 제거
- 특정어업수역 실제 폴리곤 좌표 적용 (EPSG:3857→WGS84 변환)
- DB migration 008 적용 (AI 분석 API 500 에러 해결)
- 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정
- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소)
- deck.gl 레이어 호버 시 pointer 커서 표시
- prediction 증분 수집 버그 수정 (vessel_store.py)
- prediction 증분 수집 버그 수정
## [2026-03-20]

1
frontend/.gitignore vendored Normal file
파일 보기

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

파일 보기

@ -0,0 +1,13 @@
<!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,12 +11,17 @@
},
"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",
@ -29,7 +34,8 @@
"react-map-gl": "^8.1.0",
"recharts": "^3.8.0",
"satellite.js": "^6.0.2",
"tailwindcss": "^4.2.1"
"tailwindcss": "^4.2.1",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

파일 보기

@ -20,7 +20,7 @@
font-size: 14px;
font-weight: 700;
letter-spacing: 1.5px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
/* Map mode toggle */
@ -47,7 +47,7 @@
color: var(--kcg-dim);
cursor: pointer;
transition: all 0.15s;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.map-mode-btn:hover {
@ -79,7 +79,7 @@
.count-item {
font-size: 11px;
font-weight: 700;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', 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: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', 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: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.status-dot {
@ -240,7 +240,7 @@
letter-spacing: 1.5px;
color: var(--text-secondary);
margin-bottom: 8px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.layer-items {
@ -299,7 +299,7 @@
align-items: center;
padding: 2px 8px;
font-size: 10px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.stat-cat {
@ -318,7 +318,7 @@
letter-spacing: 1px;
color: var(--text-secondary);
padding: 2px 8px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
/* Layer tree */
@ -434,7 +434,7 @@
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', 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: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.event-list {
@ -504,7 +504,7 @@
white-space: nowrap;
height: fit-content;
margin-top: 2px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.event-content {
@ -521,7 +521,7 @@
font-size: 10px;
color: var(--text-secondary);
margin-top: 1px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.event-desc {
@ -559,7 +559,7 @@
background: var(--kcg-danger);
padding: 2px 6px;
border-radius: 2px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', 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: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.breaking-news-list {
@ -616,7 +616,7 @@
.breaking-news-time {
font-size: 9px;
color: var(--kcg-dim);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.breaking-news-headline {
@ -658,13 +658,13 @@
font-weight: 700;
letter-spacing: 1.5px;
color: var(--kcg-danger);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.osint-count {
margin-left: auto;
font-size: 10px;
color: var(--kcg-muted);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.osint-loading {
margin-left: auto;
@ -722,7 +722,7 @@
font-size: 9px;
color: var(--kcg-dim);
white-space: nowrap;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.osint-item-title {
font-size: 11px;
@ -757,10 +757,17 @@
.area-ship-header {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
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 {
@ -768,7 +775,7 @@
padding: 1px 8px;
font-size: 9px;
font-weight: 700;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
border-radius: 3px;
border: 1px solid var(--kcg-border);
background: transparent;
@ -793,7 +800,11 @@
font-weight: 700;
letter-spacing: 0.5px;
color: var(--text-secondary);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.area-ship-total {
@ -801,7 +812,9 @@
font-size: 16px;
font-weight: 700;
color: #fb923c;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
white-space: nowrap;
flex-shrink: 0;
}
.kr-ship-header {
@ -820,7 +833,7 @@
font-weight: 700;
letter-spacing: 0.5px;
color: var(--text-primary);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.kr-total {
@ -828,7 +841,7 @@
font-size: 14px;
font-weight: 700;
color: var(--kcg-accent);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.kr-ship-breakdown {
@ -859,7 +872,7 @@
flex: 1;
font-size: 10px;
color: var(--text-secondary);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.kr-count {
@ -867,7 +880,7 @@
font-weight: 700;
color: var(--text-primary);
text-align: right;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.kr-ship-list {
@ -881,7 +894,7 @@
gap: 6px;
padding: 2px 0;
font-size: 9px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.kr-ship-name {
@ -1026,7 +1039,7 @@
.korea-stat-num {
font-size: 20px;
font-weight: 800;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.korea-stat-card.total .korea-stat-num { color: var(--kcg-accent); }
.korea-stat-card.anchored .korea-stat-num { color: var(--kcg-danger); }
@ -1049,7 +1062,7 @@
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.korea-ship-section {
flex: 1;
@ -1067,7 +1080,7 @@
color: var(--kcg-muted);
border-bottom: 1px solid var(--kcg-border);
flex-shrink: 0;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.korea-ship-section-count {
color: var(--kcg-accent);
@ -1131,7 +1144,7 @@
.korea-ship-card-speed {
margin-left: auto;
color: var(--kcg-muted);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.korea-ship-card-dest {
font-size: 9px;
@ -1161,7 +1174,7 @@
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-secondary);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.chart-grid {
@ -1177,7 +1190,7 @@
color: var(--text-secondary);
margin-bottom: 0;
padding-left: 4px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.chart-demo-label {
@ -1193,7 +1206,7 @@
.ship-popup-body {
width: 300px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-size: 11px;
line-height: 1.4;
}
@ -1347,12 +1360,12 @@
}
.popup-body {
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-size: 12px;
}
.popup-body-sm {
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-size: 11px;
min-width: 220px;
}
@ -1431,6 +1444,10 @@
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;
@ -1442,7 +1459,7 @@
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.speed-btn:hover {
@ -1481,7 +1498,7 @@
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.range-btn:hover {
@ -1533,7 +1550,7 @@
font-weight: 700;
letter-spacing: 1px;
color: var(--text-secondary);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.range-picker input[type="datetime-local"] {
@ -1543,7 +1560,7 @@
color: var(--text-primary);
padding: 4px 8px;
font-size: 11px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
outline: none;
transition: border-color 0.15s;
}
@ -1567,7 +1584,7 @@
background: rgba(34, 197, 94, 0.15);
color: var(--kcg-success);
cursor: pointer;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
transition: all 0.15s;
white-space: nowrap;
}
@ -1587,7 +1604,7 @@
font-size: 10px;
color: var(--text-secondary);
margin-bottom: 2px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.timeline-current {
@ -1723,7 +1740,7 @@
font-size: 9px;
font-weight: 700;
color: var(--kcg-dim);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
flex-shrink: 0;
}
@ -1770,7 +1787,7 @@
color: var(--text-secondary);
margin-bottom: 2px;
padding-left: 8px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
/* Aircraft, Satellite, Ship & Oil Tooltips - override Leaflet */
@ -1784,7 +1801,7 @@
border-radius: 3px !important;
padding: 2px 6px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important;
font-family: 'Courier New', monospace !important;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace !important;
}
.aircraft-tooltip::before,
@ -1858,7 +1875,7 @@
border-radius: 3px !important;
padding: 2px 6px !important;
box-shadow: 0 2px 12px rgba(255, 0, 0, 0.4) !important;
font-family: 'Courier New', monospace !important;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace !important;
}
.impact-tooltip::before {
@ -1920,7 +1937,7 @@
pointer-events: none;
font-weight: 600;
font-size: 11px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
text-shadow: var(--kcg-map-label-shadow);
background: var(--kcg-glass);
border: 1px solid var(--kcg-border);
@ -1938,7 +1955,7 @@
color: var(--kcg-event-impact);
font-weight: 700;
font-size: 10px;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
text-shadow: var(--kcg-map-impact-shadow);
background: rgba(40, 0, 0, 0.85);
border: 1px solid var(--kcg-event-impact);
@ -1991,17 +2008,34 @@
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;
@ -2017,7 +2051,7 @@
color: var(--kcg-dim);
cursor: pointer;
transition: all 0.15s;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.mode-btn:hover {
@ -2079,14 +2113,14 @@
font-weight: 700;
letter-spacing: 2px;
color: var(--kcg-danger);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.live-clock {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
letter-spacing: 1px;
padding: 4px 12px;
background: var(--kcg-hover);
@ -2105,7 +2139,7 @@
font-weight: 700;
letter-spacing: 1.5px;
color: var(--text-secondary);
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.history-presets {
@ -2123,7 +2157,7 @@
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
}
.history-btn:hover {
@ -2148,7 +2182,7 @@
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
font-family: 'Courier New', monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
border: none;
background: transparent;
color: var(--text-secondary);
@ -2314,7 +2348,7 @@
max-height: 80vh;
overflow: auto;
color: var(--kcg-text, #e2e8f0);
font-family: monospace;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-size: 13px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
}
@ -2441,3 +2475,42 @@
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,44 +1,18 @@
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 { 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 { useLocalStorage } from './hooks/useLocalStorage';
import type { 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 { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
import { EAST_ASIA_PORTS } from './data/ports';
import { KOREAN_AIRPORTS } from './services/airports';
import { MILITARY_BASES } from './data/militaryBases';
import { GOV_BUILDINGS } from './data/govBuildings';
import { KOREA_WIND_FARMS } from './data/windFarms';
import { NK_LAUNCH_SITES } from './data/nkLaunchSites';
import { NK_MISSILE_EVENTS } from './data/nkMissileEvents';
import { COAST_GUARD_FACILITIES } from './services/coastGuard';
import { NAV_WARNINGS } from './services/navWarning';
import { PIRACY_ZONES } from './services/piracy';
import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
import { HAZARD_FACILITIES } from './data/hazardFacilities';
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities';
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities';
import { 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 './App.css';
function App() {
@ -69,133 +43,18 @@ 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 [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,
overseasUK: false,
overseasIran: false,
overseasUAE: false,
overseasSaudi: false,
overseasOman: false,
overseasQatar: false,
overseasKuwait: false,
overseasIraq: false,
overseasBahrain: false,
});
// Korea tab layer visibility (lifted from KoreaMap)
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);
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
// 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 [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();
@ -205,568 +64,115 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
}, [i18n]);
const isLive = appMode === 'live';
// 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 });
}, []);
const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime;
return (
<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' && (
<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>
<FontScaleProvider>
<SymbolScaleProvider>
<SharedFilterProvider>
<div className={`app ${isLive ? 'app-live' : ''}`}>
<header className="app-header">
{/* Dashboard Tabs */}
<div className="dash-tabs">
<button
type="button"
className={`mode-btn ${appMode === 'live' ? 'active live' : ''}`}
onClick={() => setAppMode('live')}
className={`dash-tab ${dashboardTab === 'iran' ? 'active' : ''}`}
onClick={() => setDashboardTab('iran')}
>
<span className="mode-dot-icon" />
{t('mode.live')}
<span className="dash-tab-flag">🇮🇷</span>
{t('tabs.iran')}
</button>
<button
type="button"
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
onClick={() => setAppMode('replay')}
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
onClick={() => setDashboardTab('korea')}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
{t('mode.replay')}
<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>
{dashboardTab === 'iran' && (
<IranDashboard
currentTime={currentTime}
isLive={isLive}
refreshKey={refreshKey}
replay={replay}
monitor={monitor}
timeZone={timeZone}
onTimeZoneChange={setTimeZone}
appMode={appMode}
onAppModeChange={setAppMode}
/>
)}
{dashboardTab === 'korea' && (
<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>
</div>
<KoreaDashboard
currentTime={currentTime}
isLive={isLive}
refreshKey={refreshKey}
replay={replay}
monitor={monitor}
timeZone={timeZone}
onTimeZoneChange={setTimeZone}
/>
)}
{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>
{showCollectorMonitor && (
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
)}
<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={[
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' },
{ key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' },
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' },
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' },
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' },
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' },
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' },
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' },
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' },
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' },
]}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
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)} />
)}
<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}
/>
<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>
</div>
</SharedFilterProvider>
</SymbolScaleProvider>
</FontScaleProvider>
);
}

파일 보기

@ -8,6 +8,7 @@ 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);
@ -136,7 +137,7 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
)}
{/* Dev Login */}
{IS_DEV && (
{DEV_LOGIN_ENABLED && (
<>
<div className="w-full border-t border-kcg-border pt-4 text-center">
<span className="text-xs font-mono tracking-wider text-kcg-dim">

파일 보기

@ -6,6 +6,7 @@ import { getDisasterNews, getDisasterCatIcon, getDisasterCatColor } from '../../
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis';
import type { FishingGearType } from '../../utils/fishingAnalysis';
import { AiChatPanel } from '../korea/AiChatPanel';
type DashboardTab = 'iran' | 'korea';
@ -259,6 +260,7 @@ const TYPE_LABELS: Record<GeoEvent['type'], string> = {
alert: 'ALERT',
impact: 'IMPACT',
osint: 'OSINT',
sea_attack: 'SEA ATK',
};
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
@ -269,6 +271,7 @@ 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
@ -349,7 +352,7 @@ function useTimeAgo() {
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS, highlightKoreanShips = false, onToggleHighlightKorean, onShipHover, onShipClick }: Props) {
const { t } = useTranslation(['common', 'events', 'ships']);
const timeAgo = useTimeAgo();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships']));
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships', 'cn-fishing']));
const toggleCollapse = useCallback((key: string) => {
setCollapsed(prev => {
const next = new Set(prev);
@ -879,6 +882,11 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
)}
</>
)}
{/* AI 해양분석 챗 — 한국 탭 전용 */}
{dashboardTab === 'korea' && (
<AiChatPanel />
)}
</div>
);
}

파일 보기

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

파일 보기

@ -0,0 +1,45 @@
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,6 +1,8 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export type DataSource = 'dummy' | 'api';
interface Props {
isPlaying: boolean;
speed: number;
@ -11,6 +13,8 @@ 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];
@ -51,6 +55,8 @@ export function ReplayControls({
onReset,
onSpeedChange,
onRangeChange,
dataSource,
onDataSourceChange,
}: Props) {
const { t } = useTranslation();
const [showPicker, setShowPicker] = useState(false);
@ -110,6 +116,24 @@ 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" />

파일 보기

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

파일 보기

@ -0,0 +1,152 @@
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',
}),
];
}

파일 보기

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

파일 보기

@ -0,0 +1,356 @@
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 };

파일 보기

@ -0,0 +1,222 @@
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_ENERGY_HAZARD_FACILITIES,
SUB_TYPE_META,
layerKeyToSubType,
layerKeyToCountry,
type EnergyHazardFacility,
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),
);
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];
}

파일 보기

@ -0,0 +1,180 @@
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',
}),
];
}

파일 보기

@ -1,80 +0,0 @@
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>
);
})()}
</>
);
});

파일 보기

@ -0,0 +1,253 @@
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',
}),
];
}

파일 보기

@ -1,320 +0,0 @@
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 } from 'react';
import { useEffect, useMemo, useState, useRef, useCallback } 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,11 +7,17 @@ import { SatelliteLayer } from '../layers/SatelliteLayer';
import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker';
import { OilFacilityLayer } from './OilFacilityLayer';
import { AirportLayer } from './AirportLayer';
import { MEFacilityLayer } from './MEFacilityLayer';
import { iranOilFacilities } from '../../data/oilFacilities';
import { middleEastAirports } from '../../data/airports';
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 type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import 'maplibre-gl/dist/maplibre-gl.css';
@ -86,6 +92,7 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
const SOURCE_COLORS: Record<string, string> = {
@ -111,10 +118,49 @@ 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) {
@ -187,6 +233,7 @@ 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" />
@ -197,7 +244,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 15,
'text-size': 15 * fontScale.area,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -216,7 +263,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-size': 12,
'text-size': 12 * fontScale.area,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -236,7 +283,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
minzoom={5}
layout={{
'text-field': ['get', 'name'],
'text-size': 10,
'text-size': 10 * fontScale.area,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -432,9 +479,158 @@ 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} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
{layers.meFacilities && <MEFacilityLayer />}
<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;
})()}
</Map>
);
}

파일 보기

@ -1,4 +1,4 @@
import { useMemo, useState, useRef, useEffect } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } 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,11 +7,17 @@ import { SatelliteLayer } from '../layers/SatelliteLayer';
import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker';
import { OilFacilityLayer } from './OilFacilityLayer';
import { AirportLayer } from './AirportLayer';
import { MEFacilityLayer } from './MEFacilityLayer';
import { iranOilFacilities } from '../../data/oilFacilities';
import { middleEastAirports } from '../../data/airports';
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 type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import maplibregl from 'maplibre-gl';
@ -69,6 +75,7 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
const SOURCE_COLORS: Record<string, string> = {
@ -94,10 +101,49 @@ 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) {
@ -133,8 +179,53 @@ 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}>
@ -145,7 +236,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 15,
'text-size': 15 * fontScale.area,
'text-allow-overlap': false,
'text-ignore-placement': false,
}}
@ -162,7 +253,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 12,
'text-size': 12 * fontScale.area,
'text-allow-overlap': false,
}}
paint={{
@ -179,7 +270,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 10,
'text-size': 10 * fontScale.area,
'text-allow-overlap': false,
}}
paint={{
@ -264,15 +355,119 @@ 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 />}
</Map>
);
}

파일 보기

@ -0,0 +1,106 @@
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];
}

파일 보기

@ -0,0 +1,156 @@
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];
}

파일 보기

@ -0,0 +1,151 @@
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];
}

파일 보기

@ -0,0 +1,421 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useAuth } from '../../hooks/useAuth';
interface ChatMessage {
role: 'user' | 'assistant';
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 };
}
export function AiChatPanel() {
const { user } = useAuth();
const userId = user?.email ?? 'anonymous';
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' });
}, [messages]);
useEffect(() => {
if (isOpen) inputRef.current?.focus();
}, [isOpen]);
const sendMessage = useCallback(async () => {
if (!input.trim() || isLoading) return;
const userMsg: ChatMessage = { role: 'user', content: input.trim(), timestamp: Date.now() };
setMessages(prev => [...prev, userMsg]);
setInput('');
setIsLoading(true);
// 스트리밍 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, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: userMsg.content,
user_id: userId,
stream: true,
}),
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;
});
} 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;
});
} 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]);
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,
}),
}}>
{/* Toggle header */}
<div
onClick={() => {
if (!isOpen) { setIsOpen(true); return; }
if (expanded) { setExpanded(false); return; }
setExpanded(true);
}}
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,
}}
>
<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>
</div>
{/* Chat body */}
{isOpen && (
<div style={{
display: 'flex', flexDirection: 'column',
...(expanded ? { flex: 1 } : { height: 360 }),
background: expanded ? 'transparent' : '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)',
}}>
{/* Messages */}
<div style={{
flex: 1, overflowY: 'auto', padding: '6px 8px',
display: 'flex', flexDirection: 'column', gap: 6,
}}>
{messages.length === 0 && (
<div style={{ padding: '12px 0', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#a78bfa', marginBottom: 8 }}>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{quickQuestions.map((q, i) => (
<button
key={i}
onClick={() => { setInput(q); }}
style={{
background: 'rgba(139,92,246,0.08)',
border: '1px solid rgba(139,92,246,0.25)',
borderRadius: 4, padding: '4px 8px',
fontSize: 9, color: '#a78bfa',
cursor: 'pointer', textAlign: 'left',
}}
>
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => {
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 && (
<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} />
</div>
{/* Input */}
<div style={{
display: 'flex', gap: 4, padding: '6px 8px',
borderTop: '1px solid rgba(255,255,255,0.06)',
background: 'rgba(0,0,0,0.15)',
}}>
{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(); } }}
placeholder="해양 상황 질문..."
disabled={isLoading}
style={{
flex: 1, background: 'rgba(139,92,246,0.06)',
border: '1px solid rgba(139,92,246,0.2)',
borderRadius: 4, padding: '5px 8px',
fontSize: 10, color: '#e2e8f0', outline: 'none',
}}
/>
<button
onClick={() => { void sendMessage(); }}
disabled={isLoading || !input.trim()}
style={{
background: isLoading || !input.trim() ? '#334155' : '#7c3aed',
border: 'none', borderRadius: 4,
padding: '4px 10px', fontSize: 10, fontWeight: 700,
color: '#fff', cursor: isLoading ? 'not-allowed' : 'pointer',
}}
>
</button>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -1,8 +1,17 @@
import { useState, useMemo, useEffect } from 'react';
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
import type { VesselAnalysisDto, Ship } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
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;
@ -10,7 +19,6 @@ 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;
@ -32,22 +40,6 @@ 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)',
'',
@ -65,15 +57,15 @@ const LEGEND_LINES = [
'■ 허가 이력 (최대 20점)',
' 미허가 어선: 20',
'',
'CRITICAL ≥70 / HIGH ≥50',
'MEDIUM ≥30 / LOW <30',
'CRITICAL ≥70 / WATCH ≥50',
'MONITOR ≥30 / NORMAL <30',
'',
'UCAF: 어구별 조업속도 매칭 비율',
'UCFT: 조업-항행 구분 신뢰도',
'스푸핑: 순간이동+SOG급변+BD09 종합',
];
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad, onExpandedChange }: Props) {
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect, onTrackLoad, onExpandedChange }: Props) {
const [expanded, setExpanded] = useLocalStorage('analysisPanelExpanded', false);
const toggleExpanded = () => {
const next = !expanded;
@ -83,44 +75,24 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
// 마운트 시 저장된 상태를 부모에 동기화
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { onExpandedChange?.(expanded); }, []);
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
const [selectedLevel, setSelectedLevel] = useState<AlertLevel | 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 (dto.algorithms.riskScore.level !== selectedLevel) continue;
if (RISK_TO_ALERT[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: RiskLevel) => {
const handleLevelClick = (level: AlertLevel) => {
setSelectedLevel(prev => (prev === level ? null : level));
setSelectedMmsi(null);
};
@ -143,7 +115,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8,
color: '#e2e8f0',
fontFamily: 'monospace, sans-serif',
fontFamily: FONT_MONO,
fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
@ -275,15 +247,15 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
</div>
{gearStats.groups > 0 && (
{stats.gearGroups > 0 && (
<>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.groups}</span>
<span style={{ ...valueStyle, color: '#f97316' }}>{stats.gearGroups}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.count}</span>
<span style={{ ...valueStyle, color: '#f97316' }}>{stats.gearCount}</span>
</div>
</>
)}
@ -292,8 +264,9 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
{/* 위험도 카운트 행 — 클릭 가능 */}
<div style={riskRowStyle}>
{RISK_LEVELS.map(level => {
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
{ALERT_LEVELS.map(level => {
const count = stats[STATS_KEY_MAP[level]];
const color = ALERT_COLOR[level];
const isActive = selectedLevel === level;
return (
<button
@ -304,18 +277,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
display: 'flex',
alignItems: 'center',
gap: 2,
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
background: isActive ? `${color}22` : 'none',
border: isActive ? `1px solid ${color}88` : '1px solid transparent',
borderRadius: 4,
color: '#cbd5e1',
fontSize: 10,
cursor: 'pointer',
padding: '2px 4px',
fontFamily: 'monospace, sans-serif',
fontFamily: FONT_MONO,
}}
>
<span>{RISK_EMOJI[level]}</span>
<span style={{ color: RISK_COLOR[level], fontWeight: 700 }}>{count}</span>
<span>{ALERT_EMOJI[level]}</span>
<span style={{ color, fontWeight: 700 }}>{count}</span>
</button>
);
})}
@ -326,12 +299,12 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<>
<div style={{ ...dividerStyle, marginTop: 8 }} />
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
{RISK_EMOJI[selectedLevel]} {selectedLevel} {vesselList.length}
{ALERT_EMOJI[selectedLevel]} {selectedLevel} {vesselList.length}
</div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{vesselList.map(item => {
const isExpanded = selectedMmsi === item.mmsi;
const color = RISK_COLOR[selectedLevel];
const color = ALERT_COLOR[selectedLevel];
const { dto } = item;
return (
<div key={item.mmsi}>

파일 보기

@ -0,0 +1,460 @@
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,9 +1,12 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto, RiskLevel } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
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>();
@ -56,22 +59,17 @@ const C = {
border2: '#0E2035',
} as const;
// AIS 수신 기준 선박 상태 분류 (Python 결과 없을 때 fallback)
function classifyStateFallback(ship: Ship): string {
const ageMins = (Date.now() - ship.lastSeen) / 60000;
if (ageMins > 20) return 'AIS_LOSS';
if (ship.speed <= 0.5) return 'STATIONARY';
if (ship.speed >= 5.0) return 'SAILING';
return 'FISHING';
}
// Python RiskLevel → 경보 등급 매핑
function riskToAlert(level: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' {
if (level === 'CRITICAL') return 'CRITICAL';
if (level === 'HIGH') return 'WATCH';
if (level === 'MEDIUM') return 'MONITOR';
return 'NORMAL';
}
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' }],
};
function stateLabel(s: string): string {
const map: Record<string, string> = {
@ -109,6 +107,7 @@ interface Props {
ships: Ship[];
vesselAnalysis?: UseVesselAnalysisResult;
onClose: () => void;
onShowReport?: () => void;
}
const PIPE_STEPS = [
@ -123,14 +122,14 @@ const PIPE_STEPS = [
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowReport }: Props) {
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
const [activeFilter, setActiveFilter] = useState('ALL');
const [search, setSearch] = useState('');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [pipeStep, setPipeStep] = useState(0);
// pipeStep 제거 — 파이프라인 상태는 analysisMap 존재 여부로 판단
const [tick, setTick] = useState(0);
// 중국 어선만 필터
@ -140,35 +139,20 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
return cat === 'fishing' || s.category === 'fishing';
}), [ships]);
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
// 선박 데이터 처리 — Python 분석 결과 기반 (경량 분석 포함)
const processed = useMemo((): ProcessedVessel[] => {
return cnFishing.map(ship => {
const dto = analysisMap.get(ship.mmsi);
// 수역: Python → GeoJSON 폴리곤 fallback
let zone: string;
if (dto) {
zone = dto.algorithms.location.zone;
} else {
const zoneInfo = classifyFishingZone(ship.lat, ship.lng);
zone = zoneInfo.zone === 'OUTSIDE' ? 'EEZ_OR_BEYOND' : zoneInfo.zone;
}
// 행동 상태: Python → AIS fallback
const state = dto?.algorithms.activity.state ?? classifyStateFallback(ship);
// 경보 등급: Python 위험도 직접 사용
const alert = dto ? riskToAlert(dto.algorithms.riskScore.level) : 'NORMAL';
// 어구 분류: Python classification
const vtype = dto?.classification.vesselType ?? 'UNKNOWN';
// 클러스터: Python cluster ID
const clusterId = dto?.algorithms.cluster.clusterId ?? -1;
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
return { ship, zone, state, alert, vtype, cluster };
});
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 };
});
}, [cnFishing, analysisMap]);
// 필터 + 정렬
@ -188,9 +172,13 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
// 통계 — Python 분석 결과 기반
const stats = useMemo(() => {
let gpsAnomaly = 0;
let bd09Detected = 0;
for (const v of processed) {
const dto = analysisMap.get(v.ship.mmsi);
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
if (dto) {
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
if (dto.algorithms.gpsSpoofing.bd09OffsetM > 100) bd09Detected++;
}
}
return {
total: processed.length,
@ -198,6 +186,7 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
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,
@ -230,12 +219,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
// 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);
@ -255,6 +238,14 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
[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);
@ -330,7 +321,7 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
position: 'absolute', inset: 0, zIndex: 2000,
background: 'rgba(2,6,14,0.96)',
display: 'flex', flexDirection: 'column',
fontFamily: "'IBM Plex Mono', 'Noto Sans KR', monospace",
fontFamily: FONT_MONO,
}}>
{/* ── 헤더 */}
<div style={{
@ -348,6 +339,19 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
LIVE
</span>
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
{onShowReport && (
<button
type="button"
onClick={onShowReport}
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',
}}
>
📋
</button>
)}
<button
type="button"
onClick={onClose}
@ -429,38 +433,46 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
AI
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span>
<span style={{ float: 'right', color: analysisMap.size > 0 ? C.green : C.red, fontSize: 8 }}></span>
</div>
{PIPE_STEPS.map((step, idx) => {
const isRunning = idx === pipeStep % PIPE_STEPS.length;
{PIPE_STEPS.map((step) => {
const connected = analysisMap.size > 0;
return (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: isRunning ? 'rgba(0,230,118,0.15)' : 'rgba(0,230,118,0.06)',
border: `1px solid ${isRunning ? C.green : C.border}`,
color: isRunning ? C.green : C.ink3,
fontWeight: isRunning ? 700 : 400,
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,
}}>
{isRunning ? 'PROC' : 'OK'}
{connected ? 'ON' : 'OFF'}
</span>
</div>
);
})}
{[
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
{
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 },
].map(step => (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: step.color,
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,
}}>
{step.status}
</span>
@ -484,6 +496,35 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
<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>
{/* ── 중앙 패널: 선박 테이블 */}
@ -818,6 +859,38 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
</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 }}>

파일 보기

@ -2,35 +2,34 @@ import { Source, Layer } from 'react-map-gl/maplibre';
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
const ZONE_FILL: Record<string, string> = {
ZONE_I: 'rgba(59, 130, 246, 0.15)',
ZONE_II: 'rgba(16, 185, 129, 0.15)',
ZONE_III: 'rgba(245, 158, 11, 0.15)',
ZONE_IV: 'rgba(239, 68, 68, 0.15)',
I: 'rgba(59, 130, 246, 0.15)',
II: 'rgba(16, 185, 129, 0.15)',
III: 'rgba(245, 158, 11, 0.15)',
IV: 'rgba(239, 68, 68, 0.15)',
};
const ZONE_LINE: Record<string, string> = {
ZONE_I: 'rgba(59, 130, 246, 0.6)',
ZONE_II: 'rgba(16, 185, 129, 0.6)',
ZONE_III: 'rgba(245, 158, 11, 0.6)',
ZONE_IV: 'rgba(239, 68, 68, 0.6)',
I: 'rgba(59, 130, 246, 0.6)',
II: 'rgba(16, 185, 129, 0.6)',
III: 'rgba(245, 158, 11, 0.6)',
IV: 'rgba(239, 68, 68, 0.6)',
};
const fillColor = [
'match', ['get', 'id'],
'ZONE_I', ZONE_FILL.ZONE_I,
'ZONE_II', ZONE_FILL.ZONE_II,
'ZONE_III', ZONE_FILL.ZONE_III,
'ZONE_IV', ZONE_FILL.ZONE_IV,
'match', ['get', 'zone'],
'I', ZONE_FILL.I,
'II', ZONE_FILL.II,
'III', ZONE_FILL.III,
'IV', ZONE_FILL.IV,
'rgba(0,0,0,0)',
] as maplibregl.ExpressionSpecification;
const lineColor = [
'match', ['get', 'id'],
'ZONE_I', ZONE_LINE.ZONE_I,
'ZONE_II', ZONE_LINE.ZONE_II,
'ZONE_III', ZONE_LINE.ZONE_III,
'ZONE_IV', ZONE_LINE.ZONE_IV,
'match', ['get', 'zone'],
'I', ZONE_LINE.I,
'II', ZONE_LINE.II,
'III', ZONE_LINE.III,
'IV', ZONE_LINE.IV,
'rgba(0,0,0,0)',
] as maplibregl.ExpressionSpecification;

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

파일 보기

@ -0,0 +1,152 @@
import { Popup } from 'react-map-gl/maplibre';
import { FONT_MONO } from '../../styles/fonts';
import type { FleetCompany } from '../../services/vesselAnalysis';
import type { VesselAnalysisDto } from '../../types';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type {
HoverTooltipState,
GearPickerPopupState,
PickerCandidate,
} from './fleetClusterTypes';
interface FleetClusterMapLayersProps {
selectedGearGroup: string | null;
expandedFleet: number | null;
// Popup/tooltip state
hoverTooltip: HoverTooltipState | null;
gearPickerPopup: GearPickerPopupState | null;
pickerHoveredGroup: string | null;
// Data for tooltip rendering
groupPolygons: UseGroupPolygonsResult | undefined;
companies: Map<number, FleetCompany>;
analysisMap: Map<string, VesselAnalysisDto>;
// Callbacks
onPickerHover: (group: string | null) => void;
onPickerSelect: (candidate: PickerCandidate) => void;
onPickerClose: () => void;
}
/**
* FleetCluster overlay popups/tooltips.
* All MapLibre Source/Layer rendering has been moved to useFleetClusterDeckLayers (deck.gl).
* This component only renders MapLibre Popup-based overlays (tooltips, picker).
*/
const FleetClusterMapLayers = ({
selectedGearGroup,
expandedFleet,
hoverTooltip,
gearPickerPopup,
pickerHoveredGroup,
groupPolygons,
companies,
analysisMap,
onPickerHover,
onPickerSelect,
onPickerClose,
}: FleetClusterMapLayersProps) => {
return (
<>
{/* 어구 다중 선택 팝업 */}
{gearPickerPopup && (
<Popup longitude={gearPickerPopup.lng} latitude={gearPickerPopup.lat}
onClose={() => { onPickerClose(); }}
closeOnClick={false} className="gl-popup" maxWidth="220px">
<div style={{ fontSize: 10, fontFamily: FONT_MONO, padding: '4px 0' }}>
<div style={{ fontWeight: 700, marginBottom: 4, color: '#e2e8f0', padding: '0 6px' }}>
({gearPickerPopup.candidates.length})
</div>
{gearPickerPopup.candidates.map(c => (
<div key={c.isFleet ? `fleet-${c.clusterId}` : c.name}
onMouseEnter={() => onPickerHover(c.isFleet ? String(c.clusterId) : c.name)}
onMouseLeave={() => onPickerHover(null)}
onClick={() => {
onPickerSelect(c);
onPickerClose();
}}
style={{
cursor: 'pointer', padding: '3px 6px',
borderLeft: `3px solid ${c.isFleet ? '#63b3ed' : c.inZone ? '#dc2626' : '#f97316'}`,
marginBottom: 2, borderRadius: 2,
backgroundColor: pickerHoveredGroup === (c.isFleet ? String(c.clusterId) : c.name) ? 'rgba(255,255,255,0.1)' : 'transparent',
}}>
<span style={{ color: c.isFleet ? '#63b3ed' : '#e2e8f0', fontSize: 9 }}>{c.isFleet ? '\u2693 ' : ''}{c.name}</span>
<span style={{ color: '#64748b', marginLeft: 4 }}>({c.count}{c.isFleet ? '척' : '개'})</span>
</div>
))}
</div>
</Popup>
)}
{/* 폴리곤 호버 툴팁 */}
{hoverTooltip && (() => {
if (hoverTooltip.type === 'fleet') {
const cid = hoverTooltip.id as number;
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid);
const company = companies.get(cid);
const memberCount = group?.memberCount ?? 0;
return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth="220px"
>
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: group?.color ?? '#63b3ed', marginBottom: 3 }}>
{company?.nameCn || group?.groupLabel || `선단 #${cid}`}
</div>
<div style={{ color: '#94a3b8' }}> {memberCount}</div>
{expandedFleet === cid && group?.members.slice(0, 5).map(m => {
const dto = analysisMap.get(m.mmsi);
const role = dto?.algorithms.fleetRole.role ?? m.role;
return (
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
{role === 'LEADER' ? '\u2605' : '\u00B7'} {m.name || m.mmsi} <span style={{ color: '#4a6b82' }}>{m.sog.toFixed(1)}kt</span>
</div>
);
})}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> </div>
</div>
</Popup>
);
}
if (hoverTooltip.type === 'gear') {
const name = hoverTooltip.id as string;
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const matches = allGroups.filter(g => g.groupKey === name);
if (matches.length === 0) return null;
const seen = new Set<string>();
const mergedMembers: typeof matches[0]['members'] = [];
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); mergedMembers.push(m); } }
const parentMember = mergedMembers.find(m => m.isParent);
const gearMembers = mergedMembers.filter(m => !m.isParent);
return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth={selectedGearGroup === name ? '280px' : '220px'}
>
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}> {gearMembers.length}</span>
</div>
{parentMember && (
<div style={{ fontSize: 9, color: '#fbbf24' }}>: {parentMember.name || parentMember.mmsi}</div>
)}
{selectedGearGroup === name && gearMembers.slice(0, 5).map(m => (
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
· {m.name || m.mmsi}
</div>
))}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> /</div>
</div>
</Popup>
);
}
return null;
})()}
</>
);
};
export default FleetClusterMapLayers;

파일 보기

@ -0,0 +1,175 @@
import type { FleetCompany } from '../../services/vesselAnalysis';
import type { VesselAnalysisDto } from '../../types';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type { FleetListItem } from './fleetClusterTypes';
import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants';
import GearGroupSection from './GearGroupSection';
import { useTranslation } from 'react-i18next';
interface FleetGearListPanelProps {
fleetList: FleetListItem[];
companies: Map<number, FleetCompany>;
analysisMap: Map<string, VesselAnalysisDto>;
inZoneGearGroups: UseGroupPolygonsResult['gearInZoneGroups'];
outZoneGearGroups: UseGroupPolygonsResult['gearOutZoneGroups'];
activeSection: string | null;
expandedFleet: number | null;
expandedGearGroup: string | null;
hoveredFleetId: number | null;
onToggleSection: (key: string) => void;
onExpandFleet: (id: number | null) => void;
onHoverFleet: (id: number | null) => void;
onFleetZoom: (id: number) => void;
onGearGroupZoom: (name: string) => void;
onExpandGearGroup: (name: string | null) => void;
onShipSelect: (mmsi: string) => void;
}
const FleetGearListPanel = ({
fleetList,
companies,
analysisMap,
inZoneGearGroups,
outZoneGearGroups,
activeSection,
expandedFleet,
expandedGearGroup,
hoveredFleetId,
onToggleSection,
onExpandFleet,
onHoverFleet,
onFleetZoom,
onGearGroupZoom,
onExpandGearGroup,
onShipSelect,
}: FleetGearListPanelProps) => {
const { t } = useTranslation();
return (
<div style={panelStyle}>
{/* ── 선단 현황 섹션 ── */}
<div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => onToggleSection('fleet')}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
{t('fleetGear.fleetSection', { count: fleetList.length })}
</span>
<button type="button" style={toggleButtonStyle} aria-label={t('fleetGear.toggleFleetSection')}>
{activeSection === 'fleet' ? '▲' : '▼'}
</button>
</div>
{activeSection === 'fleet' && (
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
{t('fleetGear.emptyFleet')}
</div>
) : (
fleetList.map(({ id, mmsiList, label, color, members }) => {
const company = companies.get(id);
const companyName = company?.nameCn ?? label ?? t('fleetGear.fleetFallback', { id });
const isOpen = expandedFleet === id;
const isHovered = hoveredFleetId === id;
const mainMembers = members.filter(m => {
const dto = analysisMap.get(m.mmsi);
return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER';
});
const displayMembers = mainMembers.length > 0 ? mainMembers : members;
return (
<div key={id}>
<div
onMouseEnter={() => onHoverFleet(id)}
onMouseLeave={() => onHoverFleet(null)}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
cursor: 'pointer',
backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent',
borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent',
transition: 'background-color 0.1s',
}}
>
<span onClick={() => onExpandFleet(isOpen ? null : id)}
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>
{isOpen ? '▾' : '▸'}
</span>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: color, flexShrink: 0 }} />
<span onClick={() => onExpandFleet(isOpen ? null : id)}
style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}>
{companyName}
</span>
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
{t('fleetGear.vesselCountCompact', { count: mmsiList.length })}
</span>
<button type="button" onClick={e => { e.stopPropagation(); onFleetZoom(id); }}
style={{ background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 3, color: '#63b3ed', fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }}
title={t('fleetGear.moveToFleet')}>
{t('fleetGear.zoom')}
</button>
</div>
{isOpen && (
<div style={{ paddingLeft: 22, paddingRight: 10, paddingBottom: 6, fontSize: 10, color: '#94a3b8', borderLeft: `2px solid ${color}33`, marginLeft: 10 }}>
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>{t('fleetGear.shipList')}:</div>
{displayMembers.map(m => {
const dto = analysisMap.get(m.mmsi);
const role = dto?.algorithms.fleetRole.role ?? m.role;
const displayName = m.name || m.mmsi;
return (
<div key={m.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2 }}>
<span style={{ flex: 1, color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayName}
</span>
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
({role === 'LEADER' ? t('fleetGear.roleMain') : t('fleetGear.roleSub')})
</span>
<button type="button" onClick={() => onShipSelect(m.mmsi)}
style={{ background: 'none', border: 'none', color: '#63b3ed', fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }}
title={t('fleetGear.moveToShip')} aria-label={t('fleetGear.moveToShipItem', { name: displayName })}>
</button>
</div>
);
})}
</div>
)}
</div>
);
})
)}
</div>
)}
{/* ── 조업구역내 어구 ── */}
<GearGroupSection
groups={inZoneGearGroups}
sectionKey="inZone"
sectionLabel={t('fleetGear.inZoneSection', { count: inZoneGearGroups.length })}
accentColor="#dc2626"
hoverBgColor="rgba(220,38,38,0.06)"
isActive={activeSection === 'inZone'}
expandedGroup={expandedGearGroup}
onToggleSection={() => onToggleSection('inZone')}
onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)}
onGroupZoom={onGearGroupZoom}
onShipSelect={onShipSelect}
/>
{/* ── 비허가 어구 ── */}
<GearGroupSection
groups={outZoneGearGroups}
sectionKey="outZone"
sectionLabel={t('fleetGear.outZoneSection', { count: outZoneGearGroups.length })}
accentColor="#f97316"
hoverBgColor="rgba(255,255,255,0.04)"
isActive={activeSection === 'outZone'}
expandedGroup={expandedGearGroup}
onToggleSection={() => onToggleSection('outZone')}
onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)}
onGroupZoom={onGearGroupZoom}
onShipSelect={onShipSelect}
/>
</div>
);
};
export default FleetGearListPanel;

파일 보기

@ -0,0 +1,279 @@
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
import { FONT_MONO } from '../../styles/fonts';
import { headerStyle, toggleButtonStyle } from './fleetClusterConstants';
import { useTranslation } from 'react-i18next';
interface GearGroupSectionProps {
groups: GroupPolygonDto[];
sectionKey: string;
sectionLabel: string;
accentColor: string;
hoverBgColor: string;
isActive: boolean;
expandedGroup: string | null;
onToggleSection: () => void;
onToggleGroup: (name: string) => void;
onGroupZoom: (name: string) => void;
onShipSelect: (mmsi: string) => void;
}
const GearGroupSection = ({
groups,
sectionKey,
sectionLabel,
accentColor,
hoverBgColor,
isActive,
expandedGroup,
onToggleSection,
onToggleGroup,
onGroupZoom,
onShipSelect,
}: GearGroupSectionProps) => {
const { t } = useTranslation();
const isInZoneSection = sectionKey === 'inZone';
const getInferenceBadge = (status: string | null | undefined) => {
switch (status) {
case 'AUTO_PROMOTED':
return { label: t('parentInference.badges.AUTO_PROMOTED'), color: '#22c55e' };
case 'MANUAL_CONFIRMED':
return { label: t('parentInference.badges.MANUAL_CONFIRMED'), color: '#38bdf8' };
case 'DIRECT_PARENT_MATCH':
return { label: t('parentInference.badges.DIRECT_PARENT_MATCH'), color: '#2dd4bf' };
case 'REVIEW_REQUIRED':
return { label: t('parentInference.badges.REVIEW_REQUIRED'), color: '#f59e0b' };
case 'SKIPPED_SHORT_NAME':
return { label: t('parentInference.badges.SKIPPED_SHORT_NAME'), color: '#94a3b8' };
case 'NO_CANDIDATE':
return { label: t('parentInference.badges.NO_CANDIDATE'), color: '#c084fc' };
case 'UNRESOLVED':
return { label: t('parentInference.badges.UNRESOLVED'), color: '#64748b' };
default:
return null;
}
};
const getInferenceStatusLabel = (status: string | null | undefined) => {
if (!status) return '';
return t(`parentInference.status.${status}`, { defaultValue: status });
};
const getInferenceReason = (inference: GroupPolygonDto['parentInference']) => {
if (!inference) return '';
switch (inference.status) {
case 'SKIPPED_SHORT_NAME':
return t('parentInference.reasons.shortName');
case 'NO_CANDIDATE':
return t('parentInference.reasons.noCandidate');
default:
return inference.statusReason || inference.skipReason || '';
}
};
return (
<>
<div
style={{
...headerStyle,
borderTop: `1px solid ${accentColor}40`,
cursor: 'pointer',
}}
onClick={onToggleSection}
>
<span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}>
{sectionLabel}
</span>
<button
type="button"
style={toggleButtonStyle}
aria-label={`${sectionLabel} 접기/펴기`}
>
{isActive ? '▲' : '▼'}
</button>
</div>
{isActive && (
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{groups.map(g => {
const name = g.groupKey;
const isOpen = expandedGroup === name;
const parentMember = g.members.find(m => m.isParent);
const gearMembers = g.members.filter(m => !m.isParent);
const zoneName = g.zoneName ?? '';
const inference = g.parentInference ?? null;
const badge = getInferenceBadge(inference?.status);
return (
<div key={name} id={`gear-row-${name}`}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '3px 10px',
cursor: 'pointer',
borderLeft: isOpen ? `2px solid ${accentColor}` : '2px solid transparent',
transition: 'background-color 0.1s',
fontFamily: FONT_MONO,
}}
onMouseEnter={e => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = hoverBgColor;
}}
onMouseLeave={e => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
}}
>
<span
onClick={() => onToggleGroup(name)}
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}
>
{isOpen ? '▾' : '▸'}
</span>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: accentColor,
flexShrink: 0,
}} />
<span
onClick={() => onToggleGroup(name)}
style={{
flex: 1,
color: '#e2e8f0',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
}}
title={isInZoneSection ? `${name}${zoneName}` : name}
>
{name}
</span>
{parentMember && (
<span
style={{ color: '#fbbf24', fontSize: 8, flexShrink: 0 }}
title={`모선: ${parentMember.name}`}
>
</span>
)}
{badge && (
<span
style={{
color: badge.color,
border: `1px solid ${badge.color}55`,
borderRadius: 3,
padding: '0 4px',
fontSize: 8,
flexShrink: 0,
}}
title={
inference?.selectedParentName
? `${getInferenceStatusLabel(inference.status)}: ${inference.selectedParentName}`
: getInferenceReason(inference) || getInferenceStatusLabel(inference?.status) || ''
}
>
{badge.label}
</span>
)}
{isInZoneSection && zoneName && (
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
)}
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
({gearMembers.length}{isInZoneSection ? '' : '개'})
</span>
<button
type="button"
onClick={e => {
e.stopPropagation();
onGroupZoom(name);
}}
style={{
background: 'none',
border: `1px solid ${accentColor}80`,
borderRadius: 3,
color: accentColor,
fontSize: 9,
cursor: 'pointer',
padding: '1px 4px',
flexShrink: 0,
}}
title={t('fleetGear.moveToGroup')}
>
{t('fleetGear.zoom')}
</button>
</div>
{isOpen && (
<div style={{
paddingLeft: 24,
paddingRight: 10,
paddingBottom: 4,
fontSize: 9,
color: '#94a3b8',
borderLeft: `2px solid ${accentColor}40`,
marginLeft: 10,
fontFamily: FONT_MONO,
}}>
{parentMember && (
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
{t('parentInference.summary.recommendedParent')}: {parentMember.name || parentMember.mmsi}
</div>
)}
{inference && (
<div style={{ marginBottom: 4, color: inference.status === 'AUTO_PROMOTED' ? '#22c55e' : '#94a3b8' }}>
{t('parentInference.summary.label')}: {getInferenceStatusLabel(inference.status)}
{inference.selectedParentName ? ` / ${inference.selectedParentName}` : ''}
{getInferenceReason(inference) ? ` / ${getInferenceReason(inference)}` : ''}
</div>
)}
<div style={{ color: '#64748b', marginBottom: 2 }}>{t('fleetGear.gearList')}:</div>
{gearMembers.map(m => (
<div key={m.mmsi} style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 1,
}}>
<span style={{
flex: 1,
color: '#475569',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{m.name || m.mmsi}
</span>
<button
type="button"
onClick={() => onShipSelect(m.mmsi)}
style={{
background: 'none',
border: 'none',
color: accentColor,
fontSize: 10,
cursor: 'pointer',
padding: '0 2px',
flexShrink: 0,
}}
title={t('fleetGear.moveToGear')}
aria-label={t('fleetGear.moveToGearItem', { name: m.name || m.mmsi })}
>
</button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</>
);
};
export default GearGroupSection;

파일 보기

@ -0,0 +1,570 @@
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { FONT_MONO } from '../../styles/fonts';
import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { MODEL_COLORS } from './fleetClusterConstants';
import type { HistoryFrame } from './fleetClusterTypes';
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
interface HistoryReplayControllerProps {
onClose: () => void;
hasRightReviewPanel?: boolean;
}
const MIN_AB_GAP_MS = 2 * 3600_000;
const BASE_PLAYBACK_SPEED = 0.5;
const SPEED_MULTIPLIERS = [1, 2, 5, 10] as const;
interface ReplayUiPrefs {
showTrails: boolean;
showLabels: boolean;
focusMode: boolean;
show1hPolygon: boolean;
show6hPolygon: boolean;
abLoop: boolean;
speedMultiplier: 1 | 2 | 5 | 10;
}
const DEFAULT_REPLAY_UI_PREFS: ReplayUiPrefs = {
showTrails: true,
showLabels: true,
focusMode: false,
show1hPolygon: true,
show6hPolygon: false,
abLoop: false,
speedMultiplier: 1,
};
// 멤버 정보 + 소속 모델 매핑
interface TooltipMember {
mmsi: string;
name: string;
isGear: boolean;
isParent: boolean;
sources: { label: string; color: string }[]; // 소속 (1h, 6h, 모델명)
}
function buildTooltipMembers(
frame1h: HistoryFrame | null,
frame6h: HistoryFrame | null,
correlationByModel: Map<string, GearCorrelationItem[]>,
enabledModels: Set<string>,
enabledVessels: Set<string>,
): TooltipMember[] {
const map = new Map<string, TooltipMember>();
const addSource = (mmsi: string, name: string, isGear: boolean, isParent: boolean, label: string, color: string) => {
const existing = map.get(mmsi);
if (existing) {
existing.sources.push({ label, color });
} else {
map.set(mmsi, { mmsi, name, isGear, isParent, sources: [{ label, color }] });
}
};
// 1h 멤버
if (frame1h) {
for (const m of frame1h.members) {
const isGear = m.role === 'GEAR';
addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '1h', '#fbbf24');
}
}
// 6h 멤버
if (frame6h) {
for (const m of frame6h.members) {
const isGear = m.role === 'GEAR';
addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '6h', '#93c5fd');
}
}
// 활성 모델의 일치율 대상
for (const [modelName, items] of correlationByModel) {
if (modelName === 'identity') continue;
if (!enabledModels.has(modelName)) continue;
const color = MODEL_COLORS[modelName] ?? '#94a3b8';
for (const c of items) {
if (!enabledVessels.has(c.targetMmsi)) continue;
const isGear = c.targetType === 'GEAR_BUOY';
addSource(c.targetMmsi, c.targetName || c.targetMmsi, isGear, false, modelName, color);
}
}
return [...map.values()];
}
const HistoryReplayController = ({ onClose, hasRightReviewPanel = false }: HistoryReplayControllerProps) => {
const isPlaying = useGearReplayStore(s => s.isPlaying);
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h);
const historyFrames = useGearReplayStore(s => s.historyFrames);
const historyFrames6h = useGearReplayStore(s => s.historyFrames6h);
const frameCount = historyFrames.length;
const frameCount6h = historyFrames6h.length;
const dataStartTime = useGearReplayStore(s => s.dataStartTime);
const dataEndTime = useGearReplayStore(s => s.dataEndTime);
const playbackSpeed = useGearReplayStore(s => s.playbackSpeed);
const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels);
const focusMode = useGearReplayStore(s => s.focusMode);
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
const show6hPolygon = useGearReplayStore(s => s.show6hPolygon);
const abLoop = useGearReplayStore(s => s.abLoop);
const abA = useGearReplayStore(s => s.abA);
const abB = useGearReplayStore(s => s.abB);
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
const enabledModels = useGearReplayStore(s => s.enabledModels);
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
const has6hData = frameCount6h > 0;
const [hoveredTooltip, setHoveredTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
const [pinnedTooltip, setPinnedTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
const [dragging, setDragging] = useState<'A' | 'B' | null>(null);
const [replayUiPrefs, setReplayUiPrefs] = useLocalStorage<ReplayUiPrefs>('gearReplayUiPrefs', DEFAULT_REPLAY_UI_PREFS);
const trackRef = useRef<HTMLDivElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null);
const store = useGearReplayStore;
const speedMultiplier = SPEED_MULTIPLIERS.includes(replayUiPrefs.speedMultiplier)
? replayUiPrefs.speedMultiplier
: 1;
// currentTime → 진행 인디케이터
useEffect(() => {
const unsub = store.subscribe(
s => s.currentTime,
(currentTime) => {
const { startTime, endTime } = store.getState();
if (endTime <= startTime) return;
const progress = (currentTime - startTime) / (endTime - startTime);
if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`;
if (timeDisplayRef.current) {
timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
}
},
);
return unsub;
}, [store]);
// 재생 시작 시 고정 툴팁 해제
useEffect(() => {
if (isPlaying) setPinnedTooltip(null);
}, [isPlaying]);
useEffect(() => {
const replayStore = store.getState();
replayStore.setShowTrails(replayUiPrefs.showTrails);
replayStore.setShowLabels(replayUiPrefs.showLabels);
replayStore.setFocusMode(replayUiPrefs.focusMode);
replayStore.setShow1hPolygon(replayUiPrefs.show1hPolygon);
replayStore.setShow6hPolygon(has6hData ? replayUiPrefs.show6hPolygon : false);
}, [
has6hData,
replayUiPrefs.focusMode,
replayUiPrefs.show1hPolygon,
replayUiPrefs.show6hPolygon,
replayUiPrefs.showLabels,
replayUiPrefs.showTrails,
store,
]);
useEffect(() => {
store.getState().setAbLoop(replayUiPrefs.abLoop);
}, [dataEndTime, dataStartTime, replayUiPrefs.abLoop, store]);
useEffect(() => {
const nextSpeed = BASE_PLAYBACK_SPEED * speedMultiplier;
if (Math.abs(playbackSpeed - nextSpeed) > 1e-9) {
store.getState().setPlaybackSpeed(nextSpeed);
}
}, [playbackSpeed, speedMultiplier, store]);
const posToProgress = useCallback((clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return 0;
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}, []);
const progressToTime = useCallback((p: number) => {
const { startTime, endTime } = store.getState();
return startTime + p * (endTime - startTime);
}, [store]);
// 특정 시간에 가장 가까운 1h/6h 프레임 찾기
const findClosestFrames = useCallback((t: number) => {
const { startTime, endTime } = store.getState();
const threshold = (endTime - startTime) * 0.01;
let f1h: HistoryFrame | null = null;
let f6h: HistoryFrame | null = null;
let minD1h = Infinity;
let minD6h = Infinity;
for (const f of historyFrames) {
const d = Math.abs(new Date(f.snapshotTime).getTime() - t);
if (d < minD1h && d < threshold) { minD1h = d; f1h = f; }
}
for (const f of historyFrames6h) {
const d = Math.abs(new Date(f.snapshotTime).getTime() - t);
if (d < minD6h && d < threshold) { minD6h = d; f6h = f; }
}
return { f1h, f6h };
}, [store, historyFrames, historyFrames6h]);
// 트랙 클릭 → seek + 일시정지 + 툴팁 고정/갱신
const handleTrackClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (dragging) return;
const progress = posToProgress(e.clientX);
const t = progressToTime(progress);
store.getState().pause();
store.getState().seek(t);
// 가까운 프레임이 있으면 툴팁 고정
const { f1h, f6h } = findClosestFrames(t);
if (f1h || f6h) {
setPinnedTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h });
const mmsis = new Set<string>();
if (f1h) f1h.members.forEach(m => mmsis.add(m.mmsi));
if (f6h) f6h.members.forEach(m => mmsis.add(m.mmsi));
for (const [mn, items] of correlationByModel) {
if (mn === 'identity' || !enabledModels.has(mn)) continue;
for (const c of items) {
if (enabledVessels.has(c.targetMmsi)) mmsis.add(c.targetMmsi);
}
}
store.getState().setPinnedMmsis(mmsis);
} else {
setPinnedTooltip(null);
store.getState().setPinnedMmsis(new Set());
}
}, [store, posToProgress, progressToTime, findClosestFrames, dragging, correlationByModel, enabledModels, enabledVessels]);
// 호버 → 1h+6h 프레임 동시 검색
const handleTrackHover = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (dragging || pinnedTooltip) return;
const progress = posToProgress(e.clientX);
const t = progressToTime(progress);
const { f1h, f6h } = findClosestFrames(t);
if (f1h || f6h) {
setHoveredTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h });
} else {
setHoveredTooltip(null);
}
}, [posToProgress, progressToTime, findClosestFrames, dragging, pinnedTooltip]);
// A-B 드래그
const handleAbDown = useCallback((marker: 'A' | 'B') => (e: React.MouseEvent) => {
if (isPlaying) return;
e.stopPropagation();
setDragging(marker);
}, [isPlaying]);
useEffect(() => {
if (!dragging) return;
const handleMove = (e: MouseEvent) => {
const t = progressToTime(posToProgress(e.clientX));
const { startTime, endTime } = store.getState();
const s = store.getState();
if (dragging === 'A') {
store.getState().setAbA(Math.max(startTime, Math.min(s.abB - MIN_AB_GAP_MS, t)));
} else {
store.getState().setAbB(Math.min(endTime, Math.max(s.abA + MIN_AB_GAP_MS, t)));
}
};
const handleUp = () => setDragging(null);
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
return () => { window.removeEventListener('mousemove', handleMove); window.removeEventListener('mouseup', handleUp); };
}, [dragging, store, posToProgress, progressToTime]);
const abAPos = useMemo(() => {
if (!abLoop || abA <= 0) return -1;
const { startTime, endTime } = store.getState();
return endTime > startTime ? (abA - startTime) / (endTime - startTime) : -1;
}, [abLoop, abA, store]);
const abBPos = useMemo(() => {
if (!abLoop || abB <= 0) return -1;
const { startTime, endTime } = store.getState();
return endTime > startTime ? (abB - startTime) / (endTime - startTime) : -1;
}, [abLoop, abB, store]);
// 고정 툴팁 멤버 빌드
const pinnedMembers = useMemo(() => {
if (!pinnedTooltip) return [];
return buildTooltipMembers(pinnedTooltip.frame1h, pinnedTooltip.frame6h, correlationByModel, enabledModels, enabledVessels);
}, [pinnedTooltip, correlationByModel, enabledModels, enabledVessels]);
// 호버 리치 멤버 목록 (고정 툴팁과 동일 형식)
const hoveredMembers = useMemo(() => {
if (!hoveredTooltip) return [];
return buildTooltipMembers(hoveredTooltip.frame1h, hoveredTooltip.frame6h, correlationByModel, enabledModels, enabledVessels);
}, [hoveredTooltip, correlationByModel, enabledModels, enabledVessels]);
// 닫기 핸들러 (고정 해제 포함)
const handleClose = useCallback(() => {
setPinnedTooltip(null);
store.getState().setPinnedMmsis(new Set());
onClose();
}, [store, onClose]);
const btnStyle: React.CSSProperties = {
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4,
color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO,
};
const btnActiveStyle: React.CSSProperties = {
...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd',
};
const layout = useReplayCenterPanelLayout({
minWidth: 266,
maxWidth: 966,
hasRightReviewPanel,
});
return (
<div style={{
position: 'absolute', bottom: 20,
left: `${layout.left}px`,
width: `${layout.width}px`,
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto',
}}>
{/* 프로그레스 트랙 */}
<div
ref={trackRef}
style={{ position: 'relative', height: 18, cursor: 'pointer' }}
onClick={handleTrackClick}
onMouseMove={handleTrackHover}
onMouseLeave={() => { if (!pinnedTooltip) setHoveredTooltip(null); }}
>
<div style={{ position: 'absolute', left: 0, right: 0, top: 5, height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4 }} />
{/* A-B 구간 */}
{abLoop && abAPos >= 0 && abBPos >= 0 && (
<div style={{
position: 'absolute', left: `${abAPos * 100}%`, top: 5,
width: `${(abBPos - abAPos) * 100}%`, height: 8,
background: 'rgba(34,197,94,0.12)', borderRadius: 4, pointerEvents: 'none',
}} />
)}
{snapshotRanges6h.map((pos, i) => (
<div key={`6h-${i}`} style={{ position: 'absolute', left: `${pos * 100}%`, top: 9, width: 2, height: 4, background: 'rgba(147,197,253,0.4)' }} />
))}
{snapshotRanges.map((pos, i) => (
<div key={`1h-${i}`} style={{ position: 'absolute', left: `${pos * 100}%`, top: 5, width: 2, height: 4, background: 'rgba(251,191,36,0.5)' }} />
))}
{/* A-B 마커 */}
{abLoop && abAPos >= 0 && (
<div onMouseDown={handleAbDown('A')} style={{
position: 'absolute', left: `${abAPos * 100}%`, top: 0, width: 8, height: 18,
transform: 'translateX(-50%)', cursor: isPlaying ? 'default' : 'ew-resize', zIndex: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 2, height: 16, background: 'rgba(34,197,94,0.8)', borderRadius: 1 }} />
<span style={{ position: 'absolute', top: -14, fontSize: 10, color: '#22c55e', fontWeight: 700, pointerEvents: 'none', background: 'rgba(0,0,0,0.7)', borderRadius: 2, padding: '0 3px', lineHeight: '14px' }}>A</span>
</div>
)}
{abLoop && abBPos >= 0 && (
<div onMouseDown={handleAbDown('B')} style={{
position: 'absolute', left: `${abBPos * 100}%`, top: 0, width: 8, height: 18,
transform: 'translateX(-50%)', cursor: isPlaying ? 'default' : 'ew-resize', zIndex: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 2, height: 16, background: 'rgba(34,197,94,0.8)', borderRadius: 1 }} />
<span style={{ position: 'absolute', top: -14, fontSize: 10, color: '#22c55e', fontWeight: 700, pointerEvents: 'none', background: 'rgba(0,0,0,0.7)', borderRadius: 2, padding: '0 3px', lineHeight: '14px' }}>B</span>
</div>
)}
{/* 호버 하이라이트 */}
{hoveredTooltip && !pinnedTooltip && (
<div style={{
position: 'absolute', left: `${hoveredTooltip.pos * 100}%`, top: 3, width: 4, height: 12,
background: 'rgba(255,255,255,0.6)',
borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
}} />
)}
{/* 고정 마커 */}
{pinnedTooltip && (
<div style={{
position: 'absolute', left: `${pinnedTooltip.pos * 100}%`, top: 1, width: 5, height: 16,
background: 'rgba(255,255,255,0.9)', borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
}} />
)}
{/* 진행 인디케이터 */}
<div ref={progressIndicatorRef} style={{
position: 'absolute', left: '0%', top: 3, width: 3, height: 12,
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
}} />
{/* 호버 리치 툴팁 (고정 아닌 상태) */}
{hoveredTooltip && !pinnedTooltip && hoveredMembers.length > 0 && (
<div style={{
position: 'absolute',
left: `${Math.min(hoveredTooltip.pos * 100, 85)}%`,
top: -8, transform: 'translateY(-100%)',
background: 'rgba(10,20,32,0.95)', border: '1px solid rgba(99,179,237,0.3)',
borderRadius: 6, padding: '5px 7px', maxWidth: 300, maxHeight: 160, overflowY: 'auto',
fontSize: 9, zIndex: 30, pointerEvents: 'none',
}}>
<div style={{ color: '#fbbf24', fontWeight: 600, marginBottom: 3 }}>
{new Date(hoveredTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
{hoveredMembers.map(m => (
<div key={m.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '1px 0' }}>
<span style={{ color: m.isGear ? '#94a3b8' : '#fbbf24', fontSize: 8, width: 16, flexShrink: 0 }}>
{m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''}
</span>
<span style={{ color: '#e2e8f0', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{m.name}
</span>
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{m.sources.map((s, si) => (
<span key={si} style={{
display: 'inline-block', width: s.label === '1h' || s.label === '6h' ? 'auto' : 6,
height: 6, borderRadius: s.label === '1h' || s.label === '6h' ? 2 : '50%',
background: s.color, fontSize: 7, color: '#000', fontWeight: 700,
padding: s.label === '1h' || s.label === '6h' ? '0 2px' : 0,
lineHeight: '6px', textAlign: 'center',
}}>
{(s.label === '1h' || s.label === '6h') ? s.label : ''}
</span>
))}
</div>
</div>
))}
</div>
)}
{/* 고정 리치 툴팁 */}
{pinnedTooltip && pinnedMembers.length > 0 && (
<div
onClick={e => e.stopPropagation()}
style={{
position: 'absolute',
left: `${Math.min(pinnedTooltip.pos * 100, 85)}%`,
top: -8,
transform: 'translateY(-100%)',
background: 'rgba(10,20,32,0.97)', border: '1px solid rgba(99,179,237,0.4)',
borderRadius: 6, padding: '6px 8px', maxWidth: 320, maxHeight: 200, overflowY: 'auto',
fontSize: 9, zIndex: 40, pointerEvents: 'auto',
}}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: '#fbbf24', fontWeight: 600 }}>
{new Date(pinnedTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<button type="button" onClick={(e) => { e.stopPropagation(); setPinnedTooltip(null); store.getState().setPinnedMmsis(new Set()); }}
style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer', fontSize: 10, padding: 0 }}>
</button>
</div>
{/* 멤버 목록 (호버 → 지도 강조) */}
{pinnedMembers.map(m => (
<div
key={m.mmsi}
onMouseEnter={() => store.getState().setHoveredMmsi(m.mmsi)}
onMouseLeave={() => store.getState().setHoveredMmsi(null)}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 3px',
borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer',
borderRadius: 2,
background: hoveredMmsi === m.mmsi ? 'rgba(255,255,255,0.08)' : 'transparent',
}}
>
<span style={{ color: m.isGear ? '#94a3b8' : '#fbbf24', fontSize: 8, width: 16, flexShrink: 0 }}>
{m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''}
</span>
<span style={{
color: hoveredMmsi === m.mmsi ? '#ffffff' : '#e2e8f0',
fontWeight: hoveredMmsi === m.mmsi ? 600 : 400,
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{m.name}
</span>
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{m.sources.map((s, si) => (
<span key={si} style={{
display: 'inline-block', width: s.label === '1h' || s.label === '6h' ? 'auto' : 6,
height: 6, borderRadius: s.label === '1h' || s.label === '6h' ? 2 : '50%',
background: s.color, fontSize: 7, color: '#000', fontWeight: 700,
padding: s.label === '1h' || s.label === '6h' ? '0 2px' : 0,
lineHeight: '6px', textAlign: 'center',
}}>
{(s.label === '1h' || s.label === '6h') ? s.label : ''}
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* 컨트롤 행 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button>
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span>
<span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showTrails: !prev.showTrails }))}
style={showTrails ? btnActiveStyle : btnStyle} title="항적"></button>
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showLabels: !prev.showLabels }))}
style={showLabels ? btnActiveStyle : btnStyle} title="이름"></button>
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, focusMode: !prev.focusMode }))}
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle}
title="집중 모드"></button>
<span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, show1hPolygon: !prev.show1hPolygon }))}
style={show1hPolygon ? { ...btnActiveStyle, background: 'rgba(251,191,36,0.15)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.4)' } : btnStyle}
title="1h 폴리곤">1h</button>
<button type="button" onClick={() => has6hData && setReplayUiPrefs(prev => ({ ...prev, show6hPolygon: !prev.show6hPolygon }))}
style={!has6hData ? { ...btnStyle, opacity: 0.3, cursor: 'not-allowed' }
: show6hPolygon ? { ...btnActiveStyle, background: 'rgba(147,197,253,0.15)', color: '#93c5fd', border: '1px solid rgba(147,197,253,0.4)' } : btnStyle}
disabled={!has6hData} title="6h 폴리곤">6h</button>
<span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, abLoop: !prev.abLoop }))}
style={abLoop ? { ...btnStyle, background: 'rgba(34,197,94,0.15)', color: '#22c55e', border: '1px solid rgba(34,197,94,0.4)' } : btnStyle}
title="A-B 구간 반복">A-B</button>
<span style={{ color: '#475569' }}>|</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{SPEED_MULTIPLIERS.map(multiplier => {
const active = speedMultiplier === multiplier;
return (
<button
key={multiplier}
type="button"
onClick={() => setReplayUiPrefs(prev => ({ ...prev, speedMultiplier: multiplier }))}
style={active
? { ...btnActiveStyle, background: 'rgba(250,204,21,0.16)', color: '#fde68a', border: '1px solid rgba(250,204,21,0.32)' }
: btnStyle}
title={`재생 속도 x${multiplier}`}
>
x{multiplier}
</button>
);
})}
</div>
<span style={{ flex: 1 }} />
<span style={{ color: '#64748b', fontSize: 9 }}>
<span style={{ color: '#fbbf24' }}>{frameCount}</span>
{has6hData && <> / <span style={{ color: '#93c5fd' }}>{frameCount6h}</span></>}
</span>
<button type="button" onClick={handleClose}
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}>
</button>
</div>
</div>
);
};
export default HistoryReplayController;

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