Compare commits

...

90 커밋

작성자 SHA1 메시지 날짜
Nan Kyung Lee
5296e0df19 fix: fishing-zones-wgs84.json id 필드 추가 (ZONE_I~IV) — 런타임 크래시 해결 2026-03-23 16:28:03 +09:00
Nan Kyung Lee
be77d97eb3 feat(korea): AI 해양분석 챗 (Qwen 2.5) + 이란 발전소 29개 확장 + UI 개선
- AI 해양분석 챗패널 추가 (AiChatPanel, Ollama/Qwen 2.5:7b)
- 시스템 프롬프트에 실시간 선박 데이터 자동 주입
- 보라/퍼플 톤 UI 차별화
- Vite 프록시 /ollama 추가
- 이란 발전소 20→29개 확장 (Wikipedia 기반 좌표/용량 보정)
- 선박 현황 폰트 사이즈 축소 (11→9px, 13→10px)
- OSINT LIVE 3개, 재난뉴스 2개 표시 + 스크롤
- 한국/중국 선박현황, 조업분석 기본 접힘
- AI 해양분석 기본 펼침

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:01:27 +09:00
8ca89487e9 docs: 릴리즈 노트 정리 (2026-03-23.2) (#153) 2026-03-23 09:32:14 +09:00
cdc4cb57b1 feat: 중국어선감시 탭 강화 + localStorage 상태 영속화 (#152) 2026-03-23 09:31:38 +09:00
852817d7ff docs: 릴리즈 노트 정리 (2026-03-23) (#150) 2026-03-23 08:24:51 +09:00
5bf3ef8f79 fix: UX 개선 — 줌 스케일 연동 + 호버 커서 (#149) 2026-03-23 08:22:26 +09:00
e26a4db6e0 feat: 시설 Popup 디자인 통합 + LAYERS 카운트 통일 + 해외시설 토글 수정 (#148) 2026-03-23 08:21:59 +09:00
2f0ff22d1b feat: 한국 레이어 핵심 기능 통합 — 해외시설·현장분석·선단강조·버그수정 (#145) 2026-03-23 08:19:54 +09:00
9877b8d7a7 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-20.3)' (#143) from docs/release-notes-2026-03-20-3 into develop 2026-03-20 21:20:26 +09:00
109a2068ab docs: 릴리즈 노트 정리 (2026-03-20.3) 2026-03-20 21:20:02 +09:00
0b24c75a1f Merge pull request 'refactor: deck.gl 전면 전환 — DOM Marker → GPU 렌더링' (#142) from refactor/deck-gl-migration into develop 2026-03-20 21:15:11 +09:00
8bda286975 docs: 릴리즈 노트 업데이트 2026-03-20 21:14:16 +09:00
f0c991c9ec refactor: deck.gl 전면 전환 — DOM Marker → GPU 렌더링
- deck.gl 9.2 설치 + DeckGLOverlay(MapboxOverlay interleaved) 통합
- 정적 마커 11종 → useStaticDeckLayers (IconLayer/TextLayer, SVG DataURI)
- 분석 오버레이 → useAnalysisDeckLayers (ScatterplotLayer/TextLayer)
- 불법어선/어구/수역 라벨 → deck.gl ScatterplotLayer/TextLayer
- 줌 레벨별 스케일 (0~6: 0.6x, 7~9: 1.0x, 10~12: 1.4x, 13+: 1.8x)
- NK 미사일 궤적 PathLayer 추가 + 정적 마커 클릭 Popup
- 해저케이블 날짜변경선(180도) 좌표 보정
- 기존 DOM Marker 제거로 렌더링 성능 대폭 개선

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:11:56 +09:00
8323a248a7 Merge pull request 'feat: 어구그룹 선택 하이라이트 + 모선 마커' (#140) from fix/gear-group-highlight into develop 2026-03-20 19:08:04 +09:00
8c008c69ec feat: 선택 어구그룹 하이라이트 폴리곤 + 모선 강조 마커
- 선택된 어구그룹: 진한 주황 fill(0.25) + 굵은 경계선(3px)
- 모선 존재 시: 28px 주황 원 + glow + 'M' 라벨 + 선박명
- zoom 시 자동 선택 + 펼침

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:07:46 +09:00
83f1e8f387 Merge pull request 'fix: 어구 독립그룹 거리제한 10NM' (#138) from feat/unregistered-gear-clusters into develop 2026-03-20 18:54:08 +09:00
a5dc5bbf35 fix: 비허가 어구 독립그룹에도 거리제한(10NM) 적용 — 동명 원거리 어구 분리
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:53:45 +09:00
23f60a4254 Merge pull request 'fix: 어구 그룹핑 거리+시간 조건' (#136) from feat/unregistered-gear-clusters into develop 2026-03-20 18:50:30 +09:00
befcd12277 fix: 비허가 어구 그룹핑에 거리제한(10NM) + 수신시각(60분) 조건 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:50:12 +09:00
5ba28f54f5 Merge pull request 'feat: 비허가 어구 클러스터 집계 + 폴리곤' (#134) from feat/unregistered-gear-clusters into develop 2026-03-20 18:44:08 +09:00
730872d47e feat: 비허가 어구 클러스터 집계 + 폴리곤 시각화
- AnalysisStatsPanel: 어구그룹/어구수 통계 (주황색)
- FleetClusterLayer: 비허가 어구 ConvexHull 폴리곤 (주황 점선) + 목록 패널
- 허가 선단(HSL 색상) vs 비허가 어구(주황) 별도 시각화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:43:48 +09:00
345a5d5250 Merge pull request 'feat: 선단 클러스터 UI — 폴리곤 + 목록 + hover/zoom' (#132) from feat/fleet-cluster-ui into develop 2026-03-20 18:19:46 +09:00
83bcbf48ab feat: 선단 클러스터 UI — 폴리곤 경계 + 목록 패널 + hover/zoom 인터랙션
- FleetClusterLayer: ConvexHull 폴리곤 + 패딩 + 회사별 색상
- 선단 목록 패널: hover→하이라이트, zoom→fitBounds, 선박/어구 목록
- FleetCompanyController: GET /api/fleet-companies (회사명 조회)
- AuthFilter: /api/fleet-* 인증 예외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:19:27 +09:00
5e359ec296 Merge pull request 'feat: 선단 등록 DB + 어망/어구 정체성 추적' (#130) from fix/risk-scoring-and-cluster into develop 2026-03-20 18:07:38 +09:00
bb99387168 feat: 선단 등록 DB + 어망/어구 정체성 추적 시스템
- DB 007: fleet_companies, fleet_vessels, gear_identity_log, fleet_tracking_snapshot
- 906척 선단 구성 데이터 적재 (497개 회사, 279쌍 PT)
- FleetTracker: 등록 선단 ↔ AIS 매칭(NAME_EXACT) + 어구 정체성 추적
- track_similarity.py: DTW 기반 궤적 유사도 (TRACK_SIMILAR 플래그)
- scheduler: fleet_tracker 통합 (기존 assign_fleet_roles 대체)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:07:15 +09:00
fae116f7bd Merge pull request 'feat: 선단 행동 패턴 매칭 + 수역 위험도 가산' (#128) from fix/risk-scoring-and-cluster into develop 2026-03-20 17:46:55 +09:00
c09429b003 feat: 선단 탐지를 행동 패턴 매칭으로 전환 + 수역 위험도 가산
- fleet.py: DBSCAN/그리드 → PT 저인망(2척 3NM 유사속도방향) / PS 선망(3+척 2NM) / FC 환적(0.5NM 저속) 패턴 매칭
- risk.py: 특정어업수역 + 미허가 = +25점
- scheduler.py: cluster_id를 fleet 패턴 결과로 교체

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 17:46:10 +09:00
be728bc2d5 Merge pull request 'fix: 위험도 수역 가산 + 클러스터 그리드 셀' (#126) from fix/risk-scoring-and-cluster into develop 2026-03-20 17:39:07 +09:00
d13baf302f fix: 위험도 점수 수역 가산 + 클러스터 그리드 셀 방식 전환
- risk.py: 특정어업수역(ZONE_I~IV) 내 미허가 어선 +25점 가산
- fleet.py: DBSCAN → 고정 그리드 셀(5NM) 클러스터링 (체인 효과 차단)
  - max_cluster_size=20으로 거대 클러스터 방지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 17:38:49 +09:00
16805a1cf0 Merge pull request 'fix: 선단을 Python cluster로 전환 — BFS 제거 + 보라선 제거' (#124) from fix/fleet-grouping-from-python into develop 2026-03-20 17:28:10 +09:00
72f0dc4eba fix: 선단 그룹핑을 Python cluster 결과로 전환 — 프론트 BFS 제거
- ShipLayer: buildFleetGroups() 제거 → Python analysisMap cluster_id 기반
- 선박 클릭 시 같은 cluster_id 멤버만 연결선 표시
- AnalysisOverlay: 보라색 100NM+ 클러스터 연결선 제거
- 프론트엔드 전체 순회 제거로 성능 복원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 17:27:51 +09:00
04d128b714 Merge pull request 'feat: 선단 사전 그룹핑 + 동일 그룹 보장' (#122) from fix/score-display-and-fixes into develop 2026-03-20 17:12:59 +09:00
418225c6a7 feat: 선단 그룹핑 재설계 — 사전 클러스터링 + 동일 그룹 보장
- buildFleetGroups(): BFS 3NM 클러스터링으로 전체 중국어선 사전 그룹핑
- mmsi → groupId 맵으로 어느 멤버를 눌러도 같은 그룹 표시
- 그룹별 유형 자동 판별 (trawl_pair/purse_seine/transship/cluster)
- ShipLayer: detectFleet → buildFleetGroups 기반으로 전환

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 17:12:42 +09:00
abefcc2e4c Merge pull request 'fix: 점수 8000→80 + 마커 중앙정렬 + 클러스터 eps 3NM' (#120) from fix/score-display-and-fixes into develop 2026-03-20 16:19:50 +09:00
a009534c35 fix: 점수 표시 8000→80 + 강조마커 위치 중앙정렬 + 클러스터 eps 3NM
- AnalysisStatsPanel: score*100 제거 (이미 0~100 정수)
- KoreaMap: 불법어선 펄스 링 position:relative+absolute로 선박 아이콘 중앙 오버레이
- fleet.py: DBSCAN spatial_eps_nm 10→3 (116척 단일 클러스터 해소)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:19:31 +09:00
4c9eebab50 Merge pull request 'feat: AI 분석 패널 — 항적 API + 범례 + 스크롤 + 중복 제거' (#118) from feat/analysis-panel-interactive into develop 2026-03-20 15:42:35 +09:00
48c15f9c33 feat: AI 분석 패널 개선 — 항적 API + 범례 + 스크롤 + 중복 제거
- Backend: mmsi별 최신 1건만 반환 (중복 제거)
- 항적: signal-batch tracks API 호출 (6시간, 5분 캐시)
- 범례: 위험도 점수 기준 상세 (위치/조업/AIS/허가, 0~100)
- 선박 목록: maxHeight 300px 스크롤 가능
- 선박 클릭 → flyTo + 항적 표시 + 근거 상세

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 15:42:13 +09:00
1ef39c5210 Merge pull request 'feat: AI 분석 패널 인터랙티브 — 선박 목록 + flyTo + 항적' (#116) from feat/analysis-panel-interactive into develop 2026-03-20 15:22:24 +09:00
fe133b142e feat: AI 분석 패널 인터랙티브 — 선박 목록 + flyTo + 근거 상세 + 항적 표시
- 위험도 버튼 클릭 → 해당 레벨 선박 목록 펼침 (최대 50척)
- 선박 행 클릭 → 지도 중심이동(flyTo) + 근거 상세 펼침
- 근거: 위치/활동/다크/GPS/선단 정보 표시
- 선택 선박 항적: trail 데이터를 GeoJSON LineString으로 렌더링
- KoreaMap flyTo 기능 구현 (mapRef.flyTo)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 15:22:06 +09:00
16150ceee1 Merge pull request 'fix: 분석 오버레이 라이브 위치 사용' (#114) from fix/analysis-live-position into develop 2026-03-20 15:17:08 +09:00
1b2f8c65c6 fix: 분석 오버레이 라이브 선박 위치 사용 — allShips prop 전달
- KoreaMap에 allShips(전체 라이브 선박) prop 추가
- AnalysisOverlay: allShips 기반으로 분석 대상 매칭 (필터링 무관)
- 불법어선 마커: allShips에서 라이브 위치 참조 (위치 갭 해소)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 15:16:51 +09:00
7bd2ba3451 Merge pull request 'feat: 불법어선 수역 폴리곤 + AI 패널 수정 + 마커 강조' (#112) from feat/fishing-zone-overlay-ui into develop 2026-03-20 14:17:03 +09:00
9507b0da26 fix: 불법어선 수역 내 한정 + AI 패널 항상 표시 + API 1시간 윈도우
- 불법어선 필터: classifyFishingZone으로 수역 내 비한국 어선만 판별
- 수역 내 어선에 빨간 강조 링+선박명 마커 표시
- AI 분석 패널: 데이터 유무 무관하게 항상 표시
- Backend: analyzed_at 기준 1시간 윈도우로 확대 (10분 → 1시간)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:16:42 +09:00
4cf1c50d05 Merge pull request 'feat: 수역 폴리곤 오버레이 + 마커 가시성 개선' (#110) from feat/fishing-zone-overlay-ui into develop 2026-03-20 14:05:53 +09:00
af02ad12ff feat: 불법어선 필터 시 수역 폴리곤 오버레이 + 선박 마커 가시성 개선
- WGS84 사전 변환 GeoJSON 생성 (런타임 변환 제거)
- FishingZoneLayer: 수역별 색상 fill/line + 이름 라벨
- AnalysisOverlay: 마커 크기 확대, 한글 라벨, 선박명 표시
- fishingAnalysis.ts: EPSG:3857 변환 로직 제거, WGS84 JSON 직접 사용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:05:35 +09:00
95f320c9f3 Merge pull request 'fix: vessel-analysis API 500 + 불법어선 필터 수정' (#108) from fix/vessel-analysis-api-mapping into develop 2026-03-20 13:58:22 +09:00
f2a05f742f fix: vessel-analysis API 500 에러 + 불법어선 필터 기준 수정
- JPA bd09OffsetM → @Column(name="bd09_offset_m") 매핑 추가
- chnPrmShip.ts 복원 (허가어선 조회 서비스 누락)
- 불법어선: 영해/접속수역 침범 + risk HIGH+ 어선만 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:58:04 +09:00
15d4a4513f Merge pull request 'fix: 불법어선 필터 비어선 포함 버그 수정' (#106) from fix/illegal-fishing-filter-scope into develop 2026-03-20 13:53:06 +09:00
4478b70cd8 fix: 불법어선 필터에 비어선 포함되는 버그 — risk 조건을 fishing 카테고리에만 적용
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:52:49 +09:00
597d921441 Merge pull request 'fix: numpy float DB INSERT 오류 수정' (#104) from fix/numpy-float-db-insert into develop 2026-03-20 13:40:34 +09:00
746ddb7111 fix: numpy float → Python native 변환 — DB INSERT 시 np.float64 직렬화 오류 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:40:16 +09:00
c634381c15 Merge pull request 'fix: CacheConfig VESSEL_ANALYSIS 상수 누락 수정' (#102) from fix/cache-config-vessel-analysis into develop 2026-03-20 13:33:58 +09:00
67d817d0ba fix: CacheConfig에 VESSEL_ANALYSIS 상수 누락 — 빌드 실패 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:33:44 +09:00
a461767fc6 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-20.2)' (#100) from docs/release-notes-2026-03-20-2 into develop 2026-03-20 13:30:57 +09:00
5e60c8dba4 docs: 릴리즈 노트 정리 (2026-03-20.2) 2026-03-20 13:30:40 +09:00
5154c67f1b Merge pull request 'feat: Python 분석 결과 오버레이 + 메뉴 연동' (#99) from feat/vessel-analysis-overlay into develop 2026-03-20 13:30:15 +09:00
de36958fa0 docs: 릴리즈 노트 업데이트 2026-03-20 13:29:35 +09:00
e82b2d77e7 feat: Python 분석 결과 오버레이 + 메뉴 연동 — Backend API 복원 + DB 테이블 + 통계패널 + 위험도 마커
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:28:50 +09:00
2a2b5fb111 Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-20)' (#97) from docs/release-notes-2026-03-20 into develop 2026-03-20 12:52:26 +09:00
5a93b4af25 docs: 릴리즈 노트 정리 (2026-03-20) 2026-03-20 12:52:07 +09:00
8b0bbf3d66 Merge pull request 'feat: 특정어업수역 폴리곤 기반 수역 분류 + 연결선 성능 수정' (#96) from fix/fishing-overlay-perf into develop 2026-03-20 12:50:04 +09:00
e21d2a74e5 docs: 릴리즈 노트 업데이트 2026-03-20 12:49:00 +09:00
d4a35f546d feat: 특정어업수역 Ⅰ~Ⅳ 폴리곤 기반 수역 분류 — 경도 하드코딩 → point-in-polygon 교체
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:47:29 +09:00
1351f366f1 Merge pull request 'fix: 중국어선감시 연결선 폭발 수정' (#94) from fix/fishing-overlay-perf into develop 2026-03-20 12:30:47 +09:00
8c5ba0000c fix: 중국어선감시 연결선 폭발 — 부분매칭 제거 + 거리제한 + 마커 상한
- gearLinks: 부분 매칭(startsWith) 제거 → 정확 이름 매칭만
- gearLinks: 거리 제한 0.15도(~10NM) 추가 — 원거리 연결선 차단
- gearLinks: 최대 200개 제한
- operating 마커: 최대 100척
- 역할 라벨: 일반 어선(FV) 제외, 본선/부속/운반만 최대 100개
- parentName 최소 3글자 이상만 매칭

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:30:25 +09:00
5d7dca128d Merge pull request 'fix: prediction 배포 스크립트 수정' (#92) from fix/prediction-deploy-timeout into develop 2026-03-20 12:21:14 +09:00
af088fdcc1 fix: prediction 배포 스크립트 수정 — health timeout 60초 + tar.gz 재시도 수정 2026-03-20 12:20:54 +09:00
2ef1e55927 Merge pull request 'ci: deploy 키 갱신 후 재배포 트리거' (#90) from ci/redeploy-prediction into develop 2026-03-20 12:17:42 +09:00
8df1bb8f0f ci: deploy 키 갱신 후 재배포 트리거 2026-03-20 12:17:23 +09:00
22f58c8473 Merge pull request 'feat: Python 어선 분류기 + 배포 설정 + 모니터링 프록시' (#88) from feat/prediction-service into develop 2026-03-20 12:10:46 +09:00
a68dfb21b2 feat: Python 어선 분류기 + 배포 설정 + 백엔드 모니터링 프록시
- prediction/: FastAPI 7단계 분류 파이프라인 + 6개 탐지 알고리즘
  - snpdb 궤적 조회 → 인메모리 캐시(13K척) → 분류 → kcgdb 저장
  - APScheduler 5분 주기, Python 3.9 호환
  - 버그 수정: @property last_bucket, SQL INTERVAL 바인딩, rollback, None 가드
  - 보안: DB 비밀번호 하드코딩 제거 → env 환경변수 필수
- deploy/kcg-prediction.service: systemd 서비스 (redis-211, 포트 8001)
- deploy.yml: prediction CI/CD 배포 단계 추가 (192.168.1.18:32023)
- backend: PredictionProxyController (health/status/trigger 프록시)
- backend: AppProperties predictionBaseUrl + AuthFilter 인증 예외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:10:21 +09:00
206c6f22a0 Merge pull request 'feat: 중국어선 조업분석, 어구/어망 분류, 이란 시설, 레이어 재구성' (#84) from feature/korea-layers-enhancement into develop 2026-03-20 08:52:15 +09:00
b34efe37de chore: develop 머지 충돌 해결 (RELEASE-NOTES.md) 2026-03-20 08:51:53 +09:00
4a6bc2d9cd Merge pull request 'fix: OSINT 중복 저장 최종 수정 — DB UNIQUE + save try-catch' (#82) from fix/osint-dedup-unique-index into develop 2026-03-19 13:07:38 +09:00
0c6d626b36 fix: OSINT 중복 저장 방지 — 개별 save try-catch + DB UNIQUE(title) 인덱스 2026-03-19 13:06:58 +09:00
088a3e7caa Merge pull request 'fix: OSINT 중복 체크를 title 단독 조건으로 단순화' (#80) from fix/osint-title-dedup-simplify into develop 2026-03-19 11:49:57 +09:00
5a2675a1d5 fix: OSINT 중복 체크를 title 단독 조건으로 단순화 2026-03-19 11:49:38 +09:00
a0b31b99e7 Merge pull request 'ci: deploy.yml에 OpenSky 크레덴셜 환경변수 추가' (#78) from ci/opensky-env-secrets into develop 2026-03-19 11:02:43 +09:00
b1daa36911 ci: deploy.yml에 OpenSky 크레덴셜 환경변수 추가 2026-03-19 11:02:28 +09:00
4a5e29377c Merge pull request 'fix: GDELT 쿼리 URL 인코딩 수정' (#76) from fix/gdelt-url-encoding into develop 2026-03-19 10:56:27 +09:00
712d7c12ff fix: GDELT 쿼리 URL 인코딩 수정 (한글/특수문자 깨짐 해결) 2026-03-19 10:56:11 +09:00
05160605bd Merge pull request 'docs: 릴리즈 노트 정리 (2026-03-19.2)' (#74) from docs/release-notes-2026-03-19-2 into develop 2026-03-19 10:45:22 +09:00
dcff31002d docs: 릴리즈 노트 정리 (2026-03-19.2) 2026-03-19 10:45:10 +09:00
b6456145d5 Merge pull request 'feat: OpenSky OAuth2 인증 + 수집 주기 5분 조정' (#73) from feat/opensky-oauth2-credits into develop 2026-03-19 10:44:46 +09:00
0b3775a251 docs: 릴리즈 노트 업데이트 2026-03-19 10:44:21 +09:00
bf9c0bd346 feat: OpenSky OAuth2 인증 + 수집 주기 5분 조정
- OAuth2 Client Credentials 토큰 관리 (30분 유효, 자동 갱신)
- 수집 주기 60초 → 300초 (일일 크레딧 11,520 → 2,304)
- AppProperties: openSkyClientId/Secret/AuthUrl 설정 추가
- application-prod.yml: 환경변수 참조 (OPENSKY_CLIENT_ID/SECRET)
- 미설정 시 익명 모드 폴백 유지
2026-03-19 10:43:58 +09:00
79개의 변경된 파일7271개의 추가작업 그리고 1628개의 파일을 삭제

파일 보기

@ -53,6 +53,8 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
OPENSKY_CLIENT_ID: ${{ secrets.OPENSKY_CLIENT_ID }}
OPENSKY_CLIENT_SECRET: ${{ secrets.OPENSKY_CLIENT_SECRET }}
run: |
DEPLOY_DIR=/deploy/kcg-backend
mkdir -p $DEPLOY_DIR/backup
@ -68,6 +70,8 @@ jobs:
[ -n "$GOOGLE_CLIENT_ID" ] && echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" >> $DEPLOY_DIR/.env
[ -n "$JWT_SECRET" ] && echo "JWT_SECRET=${JWT_SECRET}" >> $DEPLOY_DIR/.env
[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD=${DB_PASSWORD}" >> $DEPLOY_DIR/.env
[ -n "$OPENSKY_CLIENT_ID" ] && echo "OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}" >> $DEPLOY_DIR/.env
[ -n "$OPENSKY_CLIENT_SECRET" ] && echo "OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}" >> $DEPLOY_DIR/.env
echo "PREDICTION_BASE_URL=http://192.168.1.18:8001" >> $DEPLOY_DIR/.env
# JAR 내부에 application-prod.yml이 있으면 외부 파일 제거
@ -172,50 +176,47 @@ jobs:
# systemd 서비스 파일 전송
scp $SCP_OPTS deploy/kcg-prediction.service root@$PRED_HOST:/tmp/kcg-prediction.service
# 원격 설치 + 재시작
for attempt in 1 2 3; do
echo "SSH deploy attempt $attempt/3..."
if ssh $SSH_OPTS root@$PRED_HOST bash -s << 'SCRIPT'
set -e
REMOTE_DIR=/home/apps/kcg-prediction
mkdir -p $REMOTE_DIR
cd $REMOTE_DIR
# 원격 설치 + 재시작 (단일 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
rm -f /tmp/prediction.tar.gz
# 코드 배포
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
# venv + 의존성
python3 -m venv venv 2>/dev/null || true
venv/bin/pip install -r requirements.txt -q
# 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
# 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
rm -f /tmp/kcg-prediction.service
# 재시작
systemctl restart kcg-prediction
# health 확인 (30초)
for i in $(seq 1 6); do
if curl -sf http://localhost:8001/health > /dev/null 2>&1; then
echo "Prediction healthy (${i})"
exit 0
fi
sleep 5
done
echo "WARNING: Prediction health timeout"
journalctl -u kcg-prediction --no-pager -n 10
exit 1
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
then exit 0; fi
[ "$attempt" -eq 3 ] && { echo "ERROR: SSH failed"; exit 1; }
sleep 10
done
echo "Prediction deployment completed"
- name: Cleanup
if: always()

파일 보기

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

파일 보기

@ -25,6 +25,7 @@ public class AuthFilter extends OncePerRequestFilter {
private static final String CCTV_PATH_PREFIX = "/api/cctv/";
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 final JwtProvider jwtProvider;
@ -35,7 +36,8 @@ public class AuthFilter extends OncePerRequestFilter {
|| path.startsWith(SENSOR_PATH_PREFIX)
|| path.startsWith(CCTV_PATH_PREFIX)
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX)
|| path.startsWith(PREDICTION_PATH_PREFIX);
|| path.startsWith(PREDICTION_PATH_PREFIX)
|| path.startsWith(FLEET_PATH_PREFIX);
}
@Override

파일 보기

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

파일 보기

@ -3,6 +3,7 @@ package gc.mda.kcg.collector.aircraft;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.collector.CollectorStatusTracker;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.domain.aircraft.AircraftDto;
import gc.mda.kcg.domain.aircraft.AircraftPosition;
import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
@ -11,9 +12,15 @@ import lombok.extern.slf4j.Slf4j;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.time.Instant;
@ -24,22 +31,23 @@ import java.util.List;
@RequiredArgsConstructor
public class OpenSkyCollector {
private static final String BASE_URL = "https://opensky-network.org/api";
// 이란/중동 bbox
private static final String IRAN_PARAMS = "lamin=24&lomin=30&lamax=42&lomax=62";
// 한국/동아시아 bbox
private static final String KOREA_PARAMS = "lamin=20&lomin=115&lamax=45&lomax=145";
private static final long TOKEN_REFRESH_MARGIN_SEC = 120;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final AircraftCacheStore cacheStore;
private final AircraftPositionRepository positionRepository;
private final CollectorStatusTracker tracker;
private final AppProperties appProperties;
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
@Scheduled(initialDelay = 30_000, fixedDelay = 60_000)
private String accessToken;
private Instant tokenExpiresAt = Instant.EPOCH;
@Scheduled(initialDelay = 30_000, fixedDelay = 300_000)
public void collectIran() {
List<AircraftDto> aircraft = fetchStates(IRAN_PARAMS);
if (!aircraft.isEmpty()) {
@ -48,12 +56,12 @@ public class OpenSkyCollector {
persistAll(aircraft, "opensky", "iran");
tracker.recordSuccess("opensky-iran", "iran", aircraft.size());
} else {
tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 429");
tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 인증 실패");
}
log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size());
}
@Scheduled(initialDelay = 45_000, fixedDelay = 60_000)
@Scheduled(initialDelay = 180_000, fixedDelay = 300_000)
public void collectKorea() {
List<AircraftDto> aircraft = fetchStates(KOREA_PARAMS);
if (!aircraft.isEmpty()) {
@ -62,15 +70,23 @@ public class OpenSkyCollector {
persistAll(aircraft, "opensky", "korea");
tracker.recordSuccess("opensky-korea", "korea", aircraft.size());
} else {
tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 429");
tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 인증 실패");
}
log.debug("OpenSky 한국 수집 완료: {} 항공기", aircraft.size());
}
private List<AircraftDto> fetchStates(String bboxParams) {
try {
String url = BASE_URL + "/states/all?" + bboxParams;
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
String token = getAccessToken();
String url = appProperties.getCollector().getOpenSkyBaseUrl() + "/states/all?" + bboxParams;
HttpHeaders headers = new HttpHeaders();
if (token != null) {
headers.setBearerAuth(token);
}
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
if (response.getStatusCode().value() == 429) {
log.warn("OpenSky 429 rate limited, 스킵");
return List.of();
@ -83,6 +99,47 @@ public class OpenSkyCollector {
}
}
private synchronized String getAccessToken() {
if (accessToken != null && Instant.now().isBefore(tokenExpiresAt.minusSeconds(TOKEN_REFRESH_MARGIN_SEC))) {
return accessToken;
}
String clientId = appProperties.getCollector().getOpenSkyClientId();
String clientSecret = appProperties.getCollector().getOpenSkyClientSecret();
if (clientId == null || clientSecret == null) {
log.debug("OpenSky OAuth2 미설정, 익명 모드로 동작");
return null;
}
try {
String authUrl = appProperties.getCollector().getOpenSkyAuthUrl();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<String> response = restTemplate.postForEntity(authUrl, request, String.class);
JsonNode json = objectMapper.readTree(response.getBody());
accessToken = json.get("access_token").asText();
int expiresIn = json.get("expires_in").asInt();
tokenExpiresAt = Instant.now().plusSeconds(expiresIn);
log.info("OpenSky OAuth2 토큰 발급 완료 (만료: {}초)", expiresIn);
return accessToken;
} catch (Exception e) {
log.warn("OpenSky OAuth2 토큰 발급 실패: {}, 익명 모드로 폴백", e.getMessage());
accessToken = null;
tokenExpiresAt = Instant.EPOCH;
return null;
}
}
private void persistAll(List<AircraftDto> aircraft, String source, String region) {
try {
Instant now = Instant.now();

파일 보기

@ -58,12 +58,12 @@ public class OsintCollector {
@PostConstruct
public void init() {
Thread.ofVirtual().name("osint-init").start(() -> {
new Thread(() -> {
log.info("OSINT 초기 캐시 로드 시작");
refreshCache("iran");
refreshCache("korea");
log.info("OSINT 초기 캐시 로드 완료");
});
}, "osint-init").start();
}
@Scheduled(initialDelay = 30_000, fixedDelay = 10_000)
@ -118,8 +118,7 @@ public class OsintCollector {
if (articleUrl == null || title == null || title.isBlank()) continue;
if (osintFeedRepository.existsBySourceAndSourceUrl("gdelt", articleUrl)) continue;
if (osintFeedRepository.existsByRegionAndTitleAndCollectedAtAfter(
region, title, Instant.now().minus(24, ChronoUnit.HOURS))) continue;
if (osintFeedRepository.existsByTitle(title)) continue;
String seendate = article.path("seendate").asText(null);
Instant publishedAt = parseGdeltDate(seendate);
@ -140,8 +139,12 @@ public class OsintCollector {
.publishedAt(publishedAt)
.build();
osintFeedRepository.save(feed);
saved++;
try {
osintFeedRepository.save(feed);
saved++;
} catch (Exception ex) {
log.debug("GDELT 중복 스킵: {}", title);
}
}
log.debug("GDELT {} 저장: {}건", region, saved);
return saved;
@ -184,8 +187,7 @@ public class OsintCollector {
if (link == null || title == null || title.isBlank()) continue;
if (osintFeedRepository.existsBySourceAndSourceUrl(sourceName, link)) continue;
if (osintFeedRepository.existsByRegionAndTitleAndCollectedAtAfter(
region, title, Instant.now().minus(24, ChronoUnit.HOURS))) continue;
if (osintFeedRepository.existsByTitle(title)) continue;
Instant publishedAt = parseRssDate(pubDate);
@ -201,8 +203,12 @@ public class OsintCollector {
.publishedAt(publishedAt)
.build();
osintFeedRepository.save(feed);
saved++;
try {
osintFeedRepository.save(feed);
saved++;
} catch (Exception ex) {
log.debug("Google News 중복 스킵: {}", title);
}
}
log.debug("Google News {} ({}) 저장: {}건", region, lang, saved);
return saved;
@ -265,6 +271,6 @@ public class OsintCollector {
}
private String encodeQuery(String query) {
return query.replace(" ", "+");
return java.net.URLEncoder.encode(query, StandardCharsets.UTF_8);
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -40,6 +40,9 @@ public class AppProperties {
public static class Collector {
private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2";
private String openSkyBaseUrl = "https://opensky-network.org/api";
private String openSkyAuthUrl = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
private String openSkyClientId;
private String openSkyClientSecret;
private String gdeltBaseUrl = "https://api.gdeltproject.org/api/v2/doc/doc";
private String googleNewsBaseUrl = "https://news.google.com/rss/search";
private String celestrakBaseUrl = "https://celestrak.org/NORAD/elements/gp.php";

파일 보기

@ -58,6 +58,7 @@ public class VesselAnalysisResult {
private Double spoofingScore;
@Column(name = "bd09_offset_m")
private Double bd09OffsetM;
private Integer speedJumpCount;

파일 보기

@ -7,5 +7,8 @@ import java.util.List;
public interface VesselAnalysisResultRepository extends JpaRepository<VesselAnalysisResult, Long> {
List<VesselAnalysisResult> findByTimestampAfter(Instant since);
List<VesselAnalysisResult> findByAnalyzedAtAfter(Instant since);
/** 가장 최근 analyzed_at 이후 결과 전체 (최신 분석 사이클) */
List<VesselAnalysisResult> findByAnalyzedAtGreaterThanEqual(Instant since);
}

파일 보기

@ -8,19 +8,21 @@ 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;
@Service
@RequiredArgsConstructor
public class VesselAnalysisService {
private static final int RECENT_MINUTES = 10;
private final VesselAnalysisResultRepository repository;
private final CacheManager cacheManager;
/**
* 최근 10분 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용.
* 최근 1시간 분석 결과를 반환한다. mmsi별 최신 1건만.
* Caffeine 캐시(TTL 5분) 적용.
*/
@SuppressWarnings("unchecked")
public List<VesselAnalysisDto> getLatestResults() {
@ -32,9 +34,16 @@ public class VesselAnalysisService {
}
}
Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES);
List<VesselAnalysisDto> results = repository.findByTimestampAfter(since)
.stream()
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);
}
List<VesselAnalysisDto> results = latest.values().stream()
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
.map(VesselAnalysisDto::from)
.toList();

파일 보기

@ -0,0 +1,27 @@
package gc.mda.kcg.domain.fleet;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/fleet-companies")
@RequiredArgsConstructor
public class FleetCompanyController {
private final JdbcTemplate jdbcTemplate;
@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"
);
return ResponseEntity.ok(results);
}
}

파일 보기

@ -9,7 +9,7 @@ public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
boolean existsBySourceAndSourceUrl(String source, String sourceUrl);
boolean existsByRegionAndTitleAndCollectedAtAfter(String region, String title, Instant since);
boolean existsByTitle(String title);
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since);
}

파일 보기

@ -11,3 +11,6 @@ app:
client-id: YOUR_GOOGLE_CLIENT_ID
auth:
allowed-domain: gcsc.co.kr
collector:
open-sky-client-id: YOUR_OPENSKY_CLIENT_ID
open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET

파일 보기

@ -12,6 +12,8 @@ app:
auth:
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
collector:
open-sky-client-id: ${OPENSKY_CLIENT_ID:}
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

파일 보기

@ -0,0 +1,74 @@
-- 선단 등록 + 어망/어구 정체성 추적 시스템
-- 1. 소유자/회사
CREATE TABLE IF NOT EXISTS kcg.fleet_companies (
id SERIAL PRIMARY KEY,
name_cn TEXT NOT NULL,
name_en TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 2. 등록 선박 (906척 참고자료 기반)
CREATE TABLE IF NOT EXISTS kcg.fleet_vessels (
id SERIAL PRIMARY KEY,
company_id INT REFERENCES kcg.fleet_companies(id),
permit_no VARCHAR(20) NOT NULL,
name_cn TEXT NOT NULL,
name_en TEXT,
tonnage INT,
gear_code VARCHAR(10),
fleet_role VARCHAR(20),
pair_vessel_id INT REFERENCES kcg.fleet_vessels(id),
mmsi VARCHAR(15),
match_confidence REAL DEFAULT 0,
match_method VARCHAR(20),
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_mmsi ON kcg.fleet_vessels(mmsi);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_name_en ON kcg.fleet_vessels(name_en);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_name_cn ON kcg.fleet_vessels(name_cn);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_company ON kcg.fleet_vessels(company_id);
-- 3. 어망/어구 정체성 이력
CREATE TABLE IF NOT EXISTS kcg.gear_identity_log (
id BIGSERIAL PRIMARY KEY,
mmsi VARCHAR(15) NOT NULL,
name TEXT NOT NULL,
parent_name TEXT,
parent_mmsi VARCHAR(15),
parent_vessel_id INT REFERENCES kcg.fleet_vessels(id),
gear_index_1 INT,
gear_index_2 INT,
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
match_method VARCHAR(30),
match_confidence REAL DEFAULT 0,
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_gear_identity_mmsi ON kcg.gear_identity_log(mmsi);
CREATE INDEX IF NOT EXISTS idx_gear_identity_parent ON kcg.gear_identity_log(parent_mmsi);
CREATE INDEX IF NOT EXISTS idx_gear_identity_active ON kcg.gear_identity_log(is_active) WHERE is_active = TRUE;
-- 4. 선단 추적 스냅샷 (5분 주기)
CREATE TABLE IF NOT EXISTS kcg.fleet_tracking_snapshot (
id BIGSERIAL PRIMARY KEY,
company_id INT REFERENCES kcg.fleet_companies(id),
snapshot_time TIMESTAMPTZ NOT NULL,
total_vessels INT,
active_vessels INT,
in_zone_vessels INT,
operating_vessels INT,
gear_count INT,
fleet_status VARCHAR(20),
center_lat DOUBLE PRECISION,
center_lon DOUBLE PRECISION,
details JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fleet_snapshot_time ON kcg.fleet_tracking_snapshot(snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_fleet_snapshot_company ON kcg.fleet_tracking_snapshot(company_id);

파일 보기

@ -4,26 +4,67 @@
## [Unreleased]
## [2026-03-23.2]
### 추가
- 중국어선 조업분석: AIS Ship Type 30 + 선박명 패턴 분류, GC-KCG-2026-001/CSSA 기반 안강망 추가
- 중국어선 선단 탐지: 본선-부속선 쌍, 운반선 환적, 선망 선단
- 어구/어망 카테고리 신설 + 모선 연결선 시각화
- 어구 SVG 아이콘 5종 (트롤/자망/안강망/선망/기본)
- 이란 주변국 시설 레이어 (MEFacilityLayer 35개소)
- 사우스파르스 가스전 피격 + 카타르 라스라판 보복 공격 반영
- 한국 해군부대 10개소, 항만, 풍력발전단지, 북한 발사대/미사일 이벤트 레이어
- 정부기관 건물 레이어 (GovBuildingLayer)
- CCTV 프록시 컨트롤러
- 중국어선감시 탭: CN 어선 + 어구 패턴 선박 필터링
- 중국어선감시 탭: 조업수역 ~Ⅳ 폴리곤 동시 표시
- 어구 그룹 수역 내/외 분류 (조업구역내 붉은색, 비허가 오렌지)
- 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구)
- 폴리곤 클릭·zoom 시 어구 행 자동 스크롤
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
- AI 분석 닫힘 시 위험도 마커 off
### 변경
- 레이어 재구성: 선박(최상위) → 항공망 → 해양안전 → 국가기관망
- 오른쪽 패널 접기/펼치기 기능
- 센서차트 기본 숨김
- CCTV 레이어 리팩토링
- AI 분석 패널 위치 조정 (줌 버튼 간격 확보)
- 백엔드 vessel-analysis 조회 윈도우 1h → 2h
### 수정
- FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거)
## [2026-03-23]
### 추가
- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설
- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드)
- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴)
- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보)
- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기
### 변경
- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함)
- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x)
### 수정
- 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정
- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소)
- deck.gl 레이어 호버 시 pointer 커서 표시
- prediction 증분 수집 버그 수정 (vessel_store.py)
## [2026-03-20]
### 변경
- deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL)
- 정적 마커 11종 deck.gl 전환 + 줌 레벨별 스케일
### 추가
- NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조
- Python 분석 결과 오버레이: 위험도 마커 + 다크베셀/GPS 스푸핑 경고
- AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계
- 불법어선/다크베셀/중국어선감시 Python 분석 연동
- Backend vessel-analysis REST API + DB 테이블 복원
- 특정어업수역 ~Ⅳ 실제 폴리곤 기반 수역 분류
### 수정
- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선
## [2026-03-19]
### 추가
- OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신)
### 변경
- OpenSky 수집 주기 60초 → 300초 (일일 크레딧 소비 11,520 → 2,304)
- 인라인 CSS 정리 — 공통 클래스 추출 + Tailwind 전환
### 수정

파일 보기

@ -8,7 +8,12 @@
"name": "kcg-monitoring",
"version": "0.0.0",
"dependencies": {
"@deck.gl/core": "^9.2.11",
"@deck.gl/layers": "^9.2.11",
"@deck.gl/mapbox": "^9.2.11",
"@tailwindcss/vite": "^4.2.1",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4",
"@types/leaflet": "^1.9.21",
"date-fns": "^4.1.0",
"hls.js": "^1.6.15",
@ -291,6 +296,76 @@
"node": ">=6.9.0"
}
},
"node_modules/@deck.gl/core": {
"version": "9.2.11",
"resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.11.tgz",
"integrity": "sha512-lpdxXQuFSkd6ET7M6QxPI8QMhsLRY6vzLyk83sPGFb7JSb4OhrNHYt9sfIhcA/hxJW7bdBSMWWphf2GvQetVuA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@loaders.gl/core": "~4.3.4",
"@loaders.gl/images": "~4.3.4",
"@luma.gl/constants": "~9.2.6",
"@luma.gl/core": "~9.2.6",
"@luma.gl/engine": "~9.2.6",
"@luma.gl/shadertools": "~9.2.6",
"@luma.gl/webgl": "~9.2.6",
"@math.gl/core": "^4.1.0",
"@math.gl/sun": "^4.1.0",
"@math.gl/types": "^4.1.0",
"@math.gl/web-mercator": "^4.1.0",
"@probe.gl/env": "^4.1.1",
"@probe.gl/log": "^4.1.1",
"@probe.gl/stats": "^4.1.1",
"@types/offscreencanvas": "^2019.6.4",
"gl-matrix": "^3.0.0",
"mjolnir.js": "^3.0.0"
}
},
"node_modules/@deck.gl/layers": {
"version": "9.2.11",
"resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.11.tgz",
"integrity": "sha512-2FSb0Qa6YR+Rg6GWhYOGTUug3vtZ4uKcFdnrdiJoVXGyibKJMScKZIsivY0r/yQQZsaBjYqty5QuVJvdtEHxSA==",
"license": "MIT",
"dependencies": {
"@loaders.gl/images": "~4.3.4",
"@loaders.gl/schema": "~4.3.4",
"@luma.gl/shadertools": "~9.2.6",
"@mapbox/tiny-sdf": "^2.0.5",
"@math.gl/core": "^4.1.0",
"@math.gl/polygon": "^4.1.0",
"@math.gl/web-mercator": "^4.1.0",
"earcut": "^2.2.4"
},
"peerDependencies": {
"@deck.gl/core": "~9.2.0",
"@loaders.gl/core": "~4.3.4",
"@luma.gl/core": "~9.2.6",
"@luma.gl/engine": "~9.2.6"
}
},
"node_modules/@deck.gl/layers/node_modules/earcut": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
"license": "ISC"
},
"node_modules/@deck.gl/mapbox": {
"version": "9.2.11",
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.11.tgz",
"integrity": "sha512-5OaFZgjyA4Vq6WjHUdcEdl0Phi8dwj8hSCErej0NetW90mctdbxwMt0gSbqcvWBowwhyj2QAhH0P2FcITjKG/A==",
"license": "MIT",
"dependencies": {
"@luma.gl/constants": "~9.2.6",
"@math.gl/web-mercator": "^4.1.0"
},
"peerDependencies": {
"@deck.gl/core": "~9.2.0",
"@luma.gl/constants": "~9.2.6",
"@luma.gl/core": "~9.2.6",
"@math.gl/web-mercator": "^4.1.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
@ -919,6 +994,133 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@loaders.gl/core": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz",
"integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@loaders.gl/loader-utils": "4.3.4",
"@loaders.gl/schema": "4.3.4",
"@loaders.gl/worker-utils": "4.3.4",
"@probe.gl/log": "^4.0.2"
}
},
"node_modules/@loaders.gl/images": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz",
"integrity": "sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==",
"license": "MIT",
"dependencies": {
"@loaders.gl/loader-utils": "4.3.4"
},
"peerDependencies": {
"@loaders.gl/core": "^4.3.0"
}
},
"node_modules/@loaders.gl/loader-utils": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.4.tgz",
"integrity": "sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==",
"license": "MIT",
"dependencies": {
"@loaders.gl/schema": "4.3.4",
"@loaders.gl/worker-utils": "4.3.4",
"@probe.gl/log": "^4.0.2",
"@probe.gl/stats": "^4.0.2"
},
"peerDependencies": {
"@loaders.gl/core": "^4.3.0"
}
},
"node_modules/@loaders.gl/schema": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz",
"integrity": "sha512-1YTYoatgzr/6JTxqBLwDiD3AVGwQZheYiQwAimWdRBVB0JAzych7s1yBuE0CVEzj4JDPKOzVAz8KnU1TiBvJGw==",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.7"
},
"peerDependencies": {
"@loaders.gl/core": "^4.3.0"
}
},
"node_modules/@loaders.gl/worker-utils": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz",
"integrity": "sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==",
"license": "MIT",
"peerDependencies": {
"@loaders.gl/core": "^4.3.0"
}
},
"node_modules/@luma.gl/constants": {
"version": "9.2.6",
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz",
"integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==",
"license": "MIT"
},
"node_modules/@luma.gl/core": {
"version": "9.2.6",
"resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.2.6.tgz",
"integrity": "sha512-d8KcH8ZZcjDAodSN/G2nueA9YE2X8kMz7Q0OxDGpCww6to1MZXM3Ydate/Jqsb5DDKVgUF6yD6RL8P5jOki9Yw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@math.gl/types": "^4.1.0",
"@probe.gl/env": "^4.0.8",
"@probe.gl/log": "^4.0.8",
"@probe.gl/stats": "^4.0.8",
"@types/offscreencanvas": "^2019.6.4"
}
},
"node_modules/@luma.gl/engine": {
"version": "9.2.6",
"resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.2.6.tgz",
"integrity": "sha512-1AEDs2AUqOWh7Wl4onOhXmQF+Rz1zNdPXF+Kxm4aWl92RQ42Sh2CmTvRt2BJku83VQ91KFIEm/v3qd3Urzf+Uw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@math.gl/core": "^4.1.0",
"@math.gl/types": "^4.1.0",
"@probe.gl/log": "^4.0.8",
"@probe.gl/stats": "^4.0.8"
},
"peerDependencies": {
"@luma.gl/core": "~9.2.0",
"@luma.gl/shadertools": "~9.2.0"
}
},
"node_modules/@luma.gl/shadertools": {
"version": "9.2.6",
"resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz",
"integrity": "sha512-4+uUbynqPUra9d/z1nQChyHmhLgmKfSMjS7kOwLB6exSnhKnpHL3+Hu9fv55qyaX50nGH3oHawhGtJ6RRvu65w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@math.gl/core": "^4.1.0",
"@math.gl/types": "^4.1.0",
"wgsl_reflect": "^1.2.0"
},
"peerDependencies": {
"@luma.gl/core": "~9.2.0"
}
},
"node_modules/@luma.gl/webgl": {
"version": "9.2.6",
"resolved": "https://registry.npmjs.org/@luma.gl/webgl/-/webgl-9.2.6.tgz",
"integrity": "sha512-NGBTdxJMk7j8Ygr1zuTyAvr1Tw+EpupMIQo7RelFjEsZXg6pujFqiDMM+rgxex8voCeuhWBJc7Rs+MoSqd46UQ==",
"license": "MIT",
"dependencies": {
"@luma.gl/constants": "9.2.6",
"@math.gl/types": "^4.1.0",
"@probe.gl/env": "^4.0.8"
},
"peerDependencies": {
"@luma.gl/core": "~9.2.0"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"engines": {
@ -1002,6 +1204,66 @@
"version": "5.0.4",
"license": "ISC"
},
"node_modules/@math.gl/core": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz",
"integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==",
"license": "MIT",
"dependencies": {
"@math.gl/types": "4.1.0"
}
},
"node_modules/@math.gl/polygon": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz",
"integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==",
"license": "MIT",
"dependencies": {
"@math.gl/core": "4.1.0"
}
},
"node_modules/@math.gl/sun": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz",
"integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==",
"license": "MIT"
},
"node_modules/@math.gl/types": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz",
"integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==",
"license": "MIT"
},
"node_modules/@math.gl/web-mercator": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz",
"integrity": "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==",
"license": "MIT",
"dependencies": {
"@math.gl/core": "4.1.0"
}
},
"node_modules/@probe.gl/env": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.1.tgz",
"integrity": "sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==",
"license": "MIT"
},
"node_modules/@probe.gl/log": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.1.tgz",
"integrity": "sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==",
"license": "MIT",
"dependencies": {
"@probe.gl/env": "4.1.1"
}
},
"node_modules/@probe.gl/stats": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.1.tgz",
"integrity": "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==",
"license": "MIT"
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"license": "Hippocratic-2.1",
@ -1628,6 +1890,49 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@turf/boolean-point-in-polygon": {
"version": "7.3.4",
"resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.4.tgz",
"integrity": "sha512-v/4hfyY90Vz9cDgs2GwjQf+Lft8o7mNCLJOTz/iv8SHAIgMMX0czEoIaNVOJr7tBqPqwin1CGwsncrkf5C9n8Q==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.4",
"@turf/invariant": "7.3.4",
"@types/geojson": "^7946.0.10",
"point-in-polygon-hao": "^1.1.0",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/helpers": {
"version": "7.3.4",
"resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz",
"integrity": "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/invariant": {
"version": "7.3.4",
"resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.4.tgz",
"integrity": "sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.4",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"dev": true,
@ -1739,6 +2044,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"devOptional": true,
@ -3448,6 +3759,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mjolnir.js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz",
"integrity": "sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"dev": true,
@ -3579,6 +3896,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/point-in-polygon-hao": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz",
"integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==",
"license": "MIT",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"funding": [
@ -3805,6 +4131,12 @@
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.59.0",
"license": "MIT",
@ -4043,6 +4375,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
@ -4262,6 +4600,12 @@
"node": ">=0.10.0"
}
},
"node_modules/wgsl_reflect": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz",
"integrity": "sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"dev": true,

파일 보기

@ -10,7 +10,12 @@
"preview": "vite preview"
},
"dependencies": {
"@deck.gl/core": "^9.2.11",
"@deck.gl/layers": "^9.2.11",
"@deck.gl/mapbox": "^9.2.11",
"@tailwindcss/vite": "^4.2.1",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4",
"@types/leaflet": "^1.9.21",
"date-fns": "^4.1.0",
"hls.js": "^1.6.15",

파일 보기

@ -1,4 +1,5 @@
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';
@ -15,6 +16,7 @@ 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 { useTheme } from './hooks/useTheme';
import { useAuth } from './hooks/useAuth';
@ -22,6 +24,22 @@ import { useTranslation } from 'react-i18next';
import LoginPage from './components/auth/LoginPage';
import CollectorMonitor from './components/common/CollectorMonitor';
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
import { filterFacilities } from './data/meEnergyHazardFacilities';
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
import { EAST_ASIA_PORTS } from './data/ports';
import { KOREAN_AIRPORTS } from './services/airports';
import { MILITARY_BASES } from './data/militaryBases';
import { GOV_BUILDINGS } from './data/govBuildings';
import { KOREA_WIND_FARMS } from './data/windFarms';
import { NK_LAUNCH_SITES } from './data/nkLaunchSites';
import { NK_MISSILE_EVENTS } from './data/nkMissileEvents';
import { COAST_GUARD_FACILITIES } from './services/coastGuard';
import { NAV_WARNINGS } from './services/navWarning';
import { PIRACY_ZONES } from './services/piracy';
import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
import { HAZARD_FACILITIES } from './data/hazardFacilities';
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities';
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities';
import './App.css';
function App() {
@ -52,9 +70,9 @@ interface AuthenticatedAppProps {
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const [appMode, setAppMode] = useState<AppMode>('live');
const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite');
const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran');
const [layers, setLayers] = useState<LayerVisibility>({
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,
@ -66,7 +84,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
meFacilities: true,
militaryOnly: false,
overseasUS: false,
overseasUK: false,
overseasIsrael: false,
overseasIran: false,
overseasUAE: false,
overseasSaudi: false,
@ -78,7 +96,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
});
// Korea tab layer visibility (lifted from KoreaMap)
const [koreaLayers, setKoreaLayers] = useState<Record<string, boolean>>({
const [koreaLayers, setKoreaLayers] = useLocalStorage<Record<string, boolean>>('koreaLayers', {
ships: true,
aircraft: true,
satellites: true,
@ -118,11 +136,11 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const toggleKoreaLayer = useCallback((key: string) => {
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []);
}, [setKoreaLayers]);
// Category filter state (shared across tabs)
const [hiddenAcCategories, setHiddenAcCategories] = useState<Set<string>>(new Set());
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(new Set());
const [hiddenAcCategories, setHiddenAcCategories] = useLocalStorageSet('hiddenAcCategories', new Set());
const [hiddenShipCategories, setHiddenShipCategories] = useLocalStorageSet('hiddenShipCategories', new Set());
const toggleAcCategory = useCallback((cat: string) => {
setHiddenAcCategories(prev => {
@ -130,7 +148,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
return next;
});
}, []);
}, [setHiddenAcCategories]);
const toggleShipCategory = useCallback((cat: string) => {
setHiddenShipCategories(prev => {
@ -138,27 +156,27 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
return next;
});
}, []);
}, [setHiddenShipCategories]);
// Nationality filter state (Korea tab)
const [hiddenNationalities, setHiddenNationalities] = useState<Set<string>>(new Set());
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] = useState<Set<string>>(new Set());
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);
@ -213,16 +231,21 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
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) => {
@ -280,6 +303,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
{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' : ''}`}
@ -481,18 +513,45 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
]}
overseasItems={[
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' },
{ key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' },
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' },
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' },
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' },
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' },
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' },
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' },
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' },
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' },
]}
overseasItems={(() => {
const fc = (ck: string, st?: string) => filterFacilities(ck, st as never).length;
const energyChildren = (ck: string) => [
{ key: `${ck}Power`, label: '발전소', color: '#a855f7', count: fc(ck, 'power') },
{ key: `${ck}Wind`, label: '풍력단지', color: '#22d3ee', count: fc(ck, 'wind') },
{ key: `${ck}Nuclear`, label: '원자력발전소', color: '#f59e0b', count: fc(ck, 'nuclear') },
{ key: `${ck}Thermal`, label: '화력발전소', color: '#64748b', count: fc(ck, 'thermal') },
];
const hazardChildren = (ck: string) => [
{ key: `${ck}Petrochem`, label: '석유화학단지', color: '#f97316', count: fc(ck, 'petrochem') },
{ key: `${ck}Lng`, label: 'LNG저장기지', color: '#0ea5e9', count: fc(ck, 'lng') },
{ key: `${ck}OilTank`, label: '유류저장탱크', color: '#eab308', count: fc(ck, 'oil_tank') },
{ key: `${ck}HazPort`, label: '위험물항만하역시설', color: '#dc2626', count: fc(ck, 'haz_port') },
];
const fullCountry = (key: string, label: string, color: string, ck: string) => ({
key, label, color, children: [
{ key: `${ck}Energy`, label: '에너지/발전시설', color: '#a855f7', children: energyChildren(ck) },
{ key: `${ck}Hazard`, label: '위험시설', color: '#ef4444', children: hazardChildren(ck) },
],
});
const compactCountry = (key: string, label: string, color: string, ck: string) => ({
key, label, color, children: [
{ key: `${ck}Energy`, label: '에너지/발전시설', color: '#a855f7', count: filterFacilities(ck).filter(f => f.category === 'energy').length },
{ key: `${ck}Hazard`, label: '위험시설', color: '#ef4444', count: filterFacilities(ck).filter(f => f.category === 'hazard').length },
],
});
return [
fullCountry('overseasUS', '🇺🇸 미국', '#3b82f6', 'us'),
fullCountry('overseasIsrael', '🇮🇱 이스라엘', '#0ea5e9', 'il'),
fullCountry('overseasIran', '🇮🇷 이란', '#22c55e', 'ir'),
fullCountry('overseasUAE', '🇦🇪 UAE', '#f59e0b', 'ae'),
fullCountry('overseasSaudi', '🇸🇦 사우디아라비아', '#84cc16', 'sa'),
compactCountry('overseasOman', '🇴🇲 오만', '#e11d48', 'om'),
compactCountry('overseasQatar', '🇶🇦 카타르', '#8b5cf6', 'qa'),
compactCountry('overseasKuwait', '🇰🇼 쿠웨이트', '#f97316', 'kw'),
compactCountry('overseasIraq', '🇮🇶 이라크', '#65a30d', 'iq'),
compactCountry('overseasBahrain', '🇧🇭 바레인', '#e11d48', 'bh'),
];
})()}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
@ -588,10 +647,11 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
<main className="app-main">
<div className="map-panel">
{showFieldAnalysis && (
<FieldAnalysisModal ships={koreaData.ships} onClose={() => setShowFieldAnalysis(false)} />
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} />
)}
<KoreaMap
ships={koreaFiltersResult.filteredShips}
allShips={koreaData.visibleShips}
aircraft={koreaData.visibleAircraft}
satellites={koreaData.satPositions}
layers={koreaLayers}
@ -602,6 +662,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
vesselAnalysis={vesselAnalysis}
/>
<div className="map-overlay-left">
<LayerPanel
@ -614,48 +675,49 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
satelliteCount={koreaData.satPositions.length}
extraLayers={[
// 해양안전
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', group: '해양안전' },
{ key: 'cctv', label: t('layers.cctv'), color: '#ff6b6b', count: 15, group: '해양안전' },
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 56, group: '해양안전' },
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', group: '해양안전' },
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', group: '해양안전' },
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', group: '해양안전' },
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', group: '해양안전' },
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: 4, group: '해양안전' },
{ 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: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 59, group: '국가기관망' },
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: 8, group: '에너지/발전시설' },
{ key: 'ports', label: '항구', color: '#3b82f6', count: 46, group: '국가기관망' },
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: 38, group: '국가기관망' },
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: 32, group: '국가기관망' },
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: 19, group: '해양안전' },
// 위험시설
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: 5, group: '위험시설' },
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: 10, group: '위험시설' },
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: 15, group: '위험시설' },
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: 6, 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: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: 5, group: '에너지/발전시설' },
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: 5, 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: 6, group: '산업공정/제조시설' },
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: 5, group: '산업공정/제조시설' },
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: 5, 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' },
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444' },
{ 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' },
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444' },
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
],
},
]}

파일 보기

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

파일 보기

@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
// Aircraft category colors (matches AircraftLayer military fixed colors)
const AC_CAT_COLORS: Record<string, string> = {
@ -124,9 +125,15 @@ interface OverseasItem {
key: string;
label: string;
color: string;
count?: number;
children?: OverseasItem[];
}
function countOverseasLeaves(item: OverseasItem): number {
if (!item.children?.length) return item.count ?? 0;
return item.children.reduce((sum, c) => sum + countOverseasLeaves(c), 0);
}
interface LayerPanelProps {
layers: Record<string, boolean>;
onToggle: (key: string) => void;
@ -171,7 +178,7 @@ export function LayerPanel({
onFishingNatToggle,
}: LayerPanelProps) {
const { t } = useTranslation(['common', 'ships']);
const [expanded, setExpanded] = useState<Set<string>>(new Set(['ships']));
const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships']));
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((key: string) => {
@ -180,7 +187,7 @@ export function LayerPanel({
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}, []);
}, [setExpanded]);
const toggleLegend = useCallback((key: string) => {
setLegendOpen(prev => {
@ -190,9 +197,10 @@ export function LayerPanel({
});
}, []);
const militaryCount = Object.entries(aircraftByCategory)
const _militaryCount = Object.entries(aircraftByCategory)
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
.reduce((sum, [, c]) => sum + c, 0);
void _militaryCount; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용
return (
<div className="layer-panel">
@ -538,19 +546,23 @@ export function LayerPanel({
<div className="layer-divider" />
{/* 해외시설 */}
{/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */}
<LayerTreeItem
layerKey="militaryOnly"
label={t('layers.militaryOnly')}
count={militaryCount}
layerKey="overseas-section"
label="해외시설"
count={overseasItems?.reduce((sum, item) => {
const parentOn = layers[item.key] ? 1 : 0;
const childrenOn = item.children?.filter(c => layers[c.key]).length ?? 0;
return sum + parentOn + childrenOn;
}, 0) ?? 0}
color="#f97316"
active={layers.militaryOnly ?? false}
active={expanded.has('overseas-section')}
expandable
isExpanded={expanded.has('militaryOnly')}
onToggle={() => onToggle('militaryOnly')}
onExpand={() => toggleExpand('militaryOnly')}
isExpanded={expanded.has('overseas-section')}
onToggle={() => toggleExpand('overseas-section')}
onExpand={() => toggleExpand('overseas-section')}
/>
{expanded.has('militaryOnly') && overseasItems && overseasItems.length > 0 && (
{expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && (
<div className="layer-tree-children">
{overseasItems.map(item => (
<div key={item.key}>
@ -567,14 +579,34 @@ export function LayerPanel({
{item.children?.length && expanded.has(`overseas-${item.key}`) && (
<div className="layer-tree-children">
{item.children.map(child => (
<LayerTreeItem
key={child.key}
layerKey={child.key}
label={child.label}
color={child.color}
active={layers[child.key] ?? false}
onToggle={() => onToggle(child.key)}
/>
<div key={child.key}>
<LayerTreeItem
layerKey={child.key}
label={child.label}
color={child.color}
count={child.count ?? countOverseasLeaves(child)}
active={layers[child.key] ?? false}
expandable={!!child.children?.length}
isExpanded={expanded.has(`overseas-${child.key}`)}
onToggle={() => onToggle(child.key)}
onExpand={child.children?.length ? () => toggleExpand(`overseas-${child.key}`) : undefined}
/>
{child.children?.length && expanded.has(`overseas-${child.key}`) && (
<div className="layer-tree-children">
{child.children.map(gc => (
<LayerTreeItem
key={gc.key}
layerKey={gc.key}
label={gc.label}
color={gc.color}
count={gc.count}
active={layers[gc.key] ?? false}
onToggle={() => onToggle(gc.key)}
/>
))}
</div>
)}
</div>
))}
</div>
)}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { Marker } from 'react-map-gl/maplibre';
import type { VesselAnalysisDto, Ship } from '../../types';
interface Props {
ships: Ship[];
analysisMap: Map<string, VesselAnalysisDto>;
clusters: Map<number, string[]>;
activeFilter: string | null;
}
interface AnalyzedShip {
ship: Ship;
dto: VesselAnalysisDto;
}
/**
* // useAnalysisDeckLayers + DeckGLOverlay로 GPU .
* DOM Marker가 leader .
*/
export function AnalysisOverlay({ ships, analysisMap, clusters: _clusters, activeFilter }: Props) {
const analyzedShips: AnalyzedShip[] = useMemo(() => {
return ships
.filter(s => analysisMap.has(s.mmsi))
.map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! }));
}, [ships, analysisMap]);
// 선단 leader 별 아이콘 (cnFishing 필터 ON)
const leaderShips = useMemo(() => {
if (activeFilter !== 'cnFishing') return [];
return analyzedShips.filter(({ dto }) => dto.algorithms.fleetRole.isLeader);
}, [analyzedShips, activeFilter]);
return (
<>
{/* 선단 leader 별 아이콘 */}
{leaderShips.map(({ ship }) => (
<Marker key={`leader-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
<div style={{
marginBottom: 6,
fontSize: 10,
color: '#f59e0b',
textShadow: '0 0 3px #000',
pointerEvents: 'none',
}}>
</div>
</Marker>
))}
</>
);
}

파일 보기

@ -0,0 +1,439 @@
import { useState, useMemo, useEffect } from 'react';
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { fetchVesselTrack } from '../../services/vesselTrack';
interface Props {
stats: AnalysisStats;
lastUpdated: number;
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;
}
interface VesselListItem {
mmsi: string;
name: string;
score: number;
dto: VesselAnalysisDto;
}
/** unix ms → HH:MM 형식 */
function formatTime(ms: number): string {
if (ms === 0) return '--:--';
const d = new Date(ms);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
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)',
'',
'■ 위치 (최대 40점)',
' 영해 내: 40 / 접속수역: 10',
'',
'■ 조업 행위 (최대 30점)',
' 영해 내 조업: 20 / 기타 조업: 5',
' U-turn 패턴: 10',
'',
'■ AIS 조작 (최대 35점)',
' 순간이동: 20 / 장시간 갭: 15',
' 단시간 갭: 5',
'',
'■ 허가 이력 (최대 20점)',
' 미허가 어선: 20',
'',
'CRITICAL ≥70 / HIGH ≥50',
'MEDIUM ≥30 / LOW <30',
'',
'UCAF: 어구별 조업속도 매칭 비율',
'UCFT: 조업-항행 구분 신뢰도',
'스푸핑: 순간이동+SOG급변+BD09 종합',
];
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad, onExpandedChange }: Props) {
const [expanded, setExpanded] = useLocalStorage('analysisPanelExpanded', false);
const toggleExpanded = () => {
const next = !expanded;
setExpanded(next);
onExpandedChange?.(next);
};
// 마운트 시 저장된 상태를 부모에 동기화
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { onExpandedChange?.(expanded); }, []);
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [showLegend, setShowLegend] = useState(false);
const isEmpty = useMemo(() => stats.total === 0, [stats.total]);
const gearStats = useMemo(() => {
const source = allShips ?? ships;
const gearPattern = /^(.+?)_\d+_\d+_?$/;
const STALE_MS = 60 * 60_000; // 60분 이내만
const now = Date.now();
const parentMap = new Map<string, number>();
for (const s of source) {
if (now - s.lastSeen > STALE_MS) continue;
const m = (s.name || '').match(gearPattern);
if (m) {
const parent = m[1].trim();
parentMap.set(parent, (parentMap.get(parent) || 0) + 1);
}
}
return {
groups: parentMap.size,
count: Array.from(parentMap.values()).reduce((a, b) => a + b, 0),
};
}, [allShips, ships]);
const vesselList = useMemo((): VesselListItem[] => {
if (!selectedLevel) return [];
const list: VesselListItem[] = [];
for (const [mmsi, dto] of analysisMap) {
if (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) => {
setSelectedLevel(prev => (prev === level ? null : level));
setSelectedMmsi(null);
};
const handleVesselClick = async (mmsi: string) => {
setSelectedMmsi(prev => (prev === mmsi ? null : mmsi));
onShipSelect?.(mmsi);
const coords = await fetchVesselTrack(mmsi);
if (coords.length > 0) onTrackLoad?.(mmsi, coords);
};
const panelStyle: React.CSSProperties = {
position: 'absolute',
top: 10,
right: 50,
zIndex: 10,
minWidth: 200,
maxWidth: 280,
backgroundColor: 'rgba(12, 24, 37, 0.92)',
border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8,
color: '#e2e8f0',
fontFamily: 'monospace, sans-serif',
fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
pointerEvents: 'auto',
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 10px',
borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none',
cursor: 'default',
userSelect: 'none',
flexShrink: 0,
};
const toggleButtonStyle: React.CSSProperties = {
background: 'none',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
fontSize: 10,
padding: '0 2px',
lineHeight: 1,
};
const bodyStyle: React.CSSProperties = {
padding: '8px 10px',
overflowY: 'auto',
flex: 1,
};
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 3,
};
const labelStyle: React.CSSProperties = {
color: '#94a3b8',
};
const valueStyle: React.CSSProperties = {
fontWeight: 700,
color: '#e2e8f0',
};
const dividerStyle: React.CSSProperties = {
borderTop: '1px solid rgba(99, 179, 237, 0.15)',
margin: '6px 0',
};
const riskRowStyle: React.CSSProperties = {
display: 'flex',
gap: 4,
justifyContent: 'space-between',
marginTop: 4,
};
const legendDividerStyle: React.CSSProperties = {
...dividerStyle,
marginTop: 8,
};
const legendBodyStyle: React.CSSProperties = {
fontSize: 9,
color: '#475569',
lineHeight: 1.7,
whiteSpace: 'pre',
};
return (
<div style={panelStyle}>
{/* 헤더 */}
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>AI </span>
{isLoading && (
<span style={{ fontSize: 9, color: '#fbbf24' }}>...</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 9, color: '#64748b' }}>{formatTime(lastUpdated)}</span>
<button
style={toggleButtonStyle}
onClick={() => setShowLegend(prev => !prev)}
aria-label="범례 보기"
title="범례"
>
?
</button>
<button
style={toggleButtonStyle}
onClick={toggleExpanded}
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
>
{expanded ? '▲' : '▼'}
</button>
</div>
</div>
{/* 본문 */}
{expanded && (
<div style={bodyStyle}>
{isEmpty ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '6px 0' }}>
</div>
) : (
<>
{/* 요약 행 */}
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={valueStyle}>{stats.total}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#a855f7' }}>{stats.dark}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}>GPS스푸핑</span>
<span style={{ ...valueStyle, color: '#ef4444' }}>{stats.spoofing}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
</div>
{gearStats.groups > 0 && (
<>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.groups}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.count}</span>
</div>
</>
)}
<div style={dividerStyle} />
{/* 위험도 카운트 행 — 클릭 가능 */}
<div style={riskRowStyle}>
{RISK_LEVELS.map(level => {
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
const isActive = selectedLevel === level;
return (
<button
key={level}
type="button"
onClick={() => handleLevelClick(level)}
style={{
display: 'flex',
alignItems: 'center',
gap: 2,
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
borderRadius: 4,
color: '#cbd5e1',
fontSize: 10,
cursor: 'pointer',
padding: '2px 4px',
fontFamily: 'monospace, sans-serif',
}}
>
<span>{RISK_EMOJI[level]}</span>
<span style={{ color: RISK_COLOR[level], fontWeight: 700 }}>{count}</span>
</button>
);
})}
</div>
{/* 선박 목록 */}
{selectedLevel !== null && vesselList.length > 0 && (
<>
<div style={{ ...dividerStyle, marginTop: 8 }} />
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
{RISK_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 { dto } = item;
return (
<div key={item.mmsi}>
{/* 선박 행 */}
<div
onClick={() => { void handleVesselClick(item.mmsi); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '3px 4px',
cursor: 'pointer',
borderRadius: 3,
borderLeft: isExpanded ? `2px solid ${color}` : '2px solid transparent',
backgroundColor: isExpanded ? 'rgba(255,255,255,0.06)' : 'transparent',
transition: 'background-color 0.1s',
}}
onMouseEnter={e => {
if (!isExpanded) (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(255,255,255,0.04)';
}}
onMouseLeave={e => {
if (!isExpanded) (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
}}
>
<span style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.name}
</span>
<span style={{ color: '#64748b', fontSize: 9, flexShrink: 0 }}>
{item.mmsi}
</span>
<span style={{ color, fontWeight: 700, fontSize: 10, flexShrink: 0 }}>
{item.score}
</span>
<span style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0 }}></span>
</div>
{/* 근거 상세 */}
{isExpanded && (
<div style={{
paddingLeft: 12,
paddingBottom: 4,
fontSize: 9,
color: '#64748b',
lineHeight: 1.6,
}}>
<div>
: {dto.algorithms.location.zone}
{' '}( {dto.algorithms.location.distToBaselineNm.toFixed(1)}NM)
</div>
<div>
: {dto.algorithms.activity.state}
{' '}(UCAF {dto.algorithms.activity.ucafScore.toFixed(2)})
</div>
{dto.algorithms.darkVessel.isDark && (
<div>: {dto.algorithms.darkVessel.gapDurationMin} </div>
)}
{dto.algorithms.gpsSpoofing.spoofingScore > 0 && (
<div>
GPS: 스푸핑 {Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}%
</div>
)}
{dto.algorithms.cluster.clusterSize > 1 && (
<div>
: {dto.algorithms.fleetRole.role}
{' '}({dto.algorithms.cluster.clusterSize})
</div>
)}
</div>
)}
</div>
);
})}
</div>
</>
)}
{selectedLevel !== null && vesselList.length === 0 && (
<>
<div style={{ ...dividerStyle, marginTop: 8 }} />
<div style={{ fontSize: 9, color: '#64748b', textAlign: 'center', padding: '4px 0' }}>
</div>
</>
)}
</>
)}
{/* 범례 */}
{showLegend && (
<>
<div style={legendDividerStyle} />
<div style={legendBodyStyle}>
{LEGEND_LINES.map((line, i) => (
<div key={i} style={{ color: line.startsWith('■') ? '#64748b' : '#475569' }}>
{line || '\u00A0'}
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
}

파일 보기

@ -1,57 +1,6 @@
import { useMemo } from 'react';
import { Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { Ship } from '../../types';
import { analyzeFishing, GEAR_LABELS } from '../../utils/fishingAnalysis';
import type { FishingGearType } from '../../utils/fishingAnalysis';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { FishingNetIcon, TrawlNetIcon, GillnetIcon, StowNetIcon, PurseSeineIcon } from '../icons/FishingNetIcon';
/** 어구 아이콘 컴포넌트 매핑 */
function GearIcon({ gear, size = 14 }: { gear: FishingGearType; size?: number }) {
const meta = GEAR_LABELS[gear];
const color = meta?.color || '#888';
switch (gear) {
case 'trawl_pair':
case 'trawl_single':
return <TrawlNetIcon color={color} size={size} />;
case 'gillnet':
return <GillnetIcon color={color} size={size} />;
case 'stow_net':
return <StowNetIcon color={color} size={size} />;
case 'purse_seine':
return <PurseSeineIcon color={color} size={size} />;
default:
return <FishingNetIcon color={color} size={size} />;
}
}
/** 선박 역할 추정 — 속도/크기/카테고리 기반 */
function estimateRole(ship: Ship): { role: string; roleKo: string; color: string } {
const mtCat = getMarineTrafficCategory(ship.typecode, ship.category);
const speed = ship.speed;
const len = ship.length || 0;
// 운반선: 화물선/대형/미분류 + 저속
if (mtCat === 'cargo' || (mtCat === 'unspecified' && len > 50)) {
return { role: 'FC', roleKo: '운반', color: '#f97316' };
}
// 어선 분류
if (mtCat === 'fishing' || ship.category === 'fishing') {
// 대형(>200톤급, 길이 40m+) → 본선
if (len >= 40) {
return { role: 'PT', roleKo: '본선', color: '#ef4444' };
}
// 소형(<30m) + 트롤 속도 → 부속선
if (len > 0 && len < 30 && speed >= 2 && speed <= 5) {
return { role: 'PT-S', roleKo: '부속', color: '#fb923c' };
}
// 기본 어선
return { role: 'FV', roleKo: '어선', color: '#22c55e' };
}
return { role: '', roleKo: '', color: '#6b7280' };
}
import { Source, Layer } from 'react-map-gl/maplibre';
import type { Ship, VesselAnalysisDto } from '../../types';
/**
* /
@ -79,68 +28,45 @@ interface GearToParentLink {
interface Props {
ships: Ship[];
analysisMap?: Map<string, VesselAnalysisDto>;
}
export function ChineseFishingOverlay({ ships }: Props) {
// 중국 어선만 필터링
const chineseFishing = useMemo(() => {
return ships.filter(s => {
if (s.flag !== 'CN') return false;
const cat = getMarineTrafficCategory(s.typecode, s.category);
return cat === 'fishing' || s.category === 'fishing';
});
}, [ships]);
// 조업 분석 결과
const analyzed = useMemo(() => {
return chineseFishing.map(s => ({
ship: s,
analysis: analyzeFishing(s),
role: estimateRole(s),
}));
}, [chineseFishing]);
// 조업 중인 선박만 (어구 아이콘 표시용)
const operating = useMemo(() => analyzed.filter(a => a.analysis.isOperating), [analyzed]);
// 어구/어망 → 모선 연결 탐지
export function ChineseFishingOverlay({ ships, analysisMap: _analysisMap }: Props) {
// 어구/어망 → 모선 연결 탐지 (거리 제한 + 정확 매칭 우선)
const gearLinks: GearToParentLink[] = useMemo(() => {
// 어구/어망 선박 (이름_숫자_ 또는 이름% 패턴)
const gearPattern = /^.+_\d+_\d*$|%$/;
const gearShips = ships.filter(s => gearPattern.test(s.name));
if (gearShips.length === 0) return [];
// 모선 후보 (모든 선박의 이름 → Ship 매핑)
// 모선 후보
const nameMap = new Map<string, Ship>();
for (const s of ships) {
if (!gearPattern.test(s.name) && s.name) {
// 정확한 이름 매핑
nameMap.set(s.name.trim(), s);
}
}
const MAX_DIST_DEG = 0.15; // ~10NM — 이 이상 떨어지면 연결 안 함
const MAX_LINKS = 200; // 브라우저 성능 보호
const links: GearToParentLink[] = [];
for (const gear of gearShips) {
if (links.length >= MAX_LINKS) break;
const parentName = extractParentName(gear.name);
if (!parentName) continue;
if (!parentName || parentName.length < 3) continue; // 너무 짧은 이름 제외
// 정확히 일치하는 모선 찾기
let parent = nameMap.get(parentName);
// 정확 매칭만 (부분 매칭 제거 — 오탐 원인)
const parent = nameMap.get(parentName);
if (!parent) continue;
// 정확 매칭 없으면 부분 매칭 (앞부분이 같은 선박)
if (!parent) {
for (const [name, ship] of nameMap) {
if (name.startsWith(parentName) || parentName.startsWith(name)) {
parent = ship;
break;
}
}
}
// 거리 제한: ~10NM 이내만 연결
const dlat = Math.abs(gear.lat - parent.lat);
const dlng = Math.abs(gear.lng - parent.lng);
if (dlat > MAX_DIST_DEG || dlng > MAX_DIST_DEG) continue;
if (parent) {
links.push({ gear, parent, parentName });
}
links.push({ gear, parent, parentName });
}
return links;
}, [ships]);
@ -161,21 +87,6 @@ export function ChineseFishingOverlay({ ships }: Props) {
})),
}), [gearLinks]);
// 운반선 추정 (중국 화물선 중 어선 근처)
const carriers = useMemo(() => {
return ships.filter(s => {
if (s.flag !== 'CN') return false;
const cat = getMarineTrafficCategory(s.typecode, s.category);
if (cat !== 'cargo' && cat !== 'unspecified') return false;
// 어선 5NM 이내에 있는 화물선
return chineseFishing.some(f => {
const dlat = Math.abs(s.lat - f.lat);
const dlng = Math.abs(s.lng - f.lng);
return dlat < 0.08 && dlng < 0.08; // ~5NM 근사
});
}).slice(0, 50); // 최대 50척
}, [ships, chineseFishing]);
return (
<>
{/* 어구/어망 → 모선 연결선 */}
@ -193,74 +104,6 @@ export function ChineseFishingOverlay({ ships }: Props) {
/>
</Source>
)}
{/* 어구/어망 위치 마커 (모선 연결된 것) — 최대 50개 */}
{gearLinks.slice(0, 50).map(link => (
<Marker key={`gearlink-${link.gear.mmsi}`} longitude={link.gear.lng} latitude={link.gear.lat} anchor="center">
<div className="cn-fishing-no-events">
<div style={{ filter: 'drop-shadow(0 0 3px #f9731688)' }}>
<FishingNetIcon color="#f97316" size={10} />
</div>
<div style={{
fontSize: 5, color: '#f97316', textAlign: 'center',
textShadow: '0 0 2px #000', fontWeight: 700, marginTop: -1,
whiteSpace: 'nowrap',
}}>
{link.parentName}
</div>
</div>
</Marker>
))}
{/* 조업 중 어선 — 어구 아이콘 — 최대 80개 */}
{operating.slice(0, 80).map(({ ship, analysis }) => {
const meta = GEAR_LABELS[analysis.gearType];
return (
<Marker key={`gear-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
<div className="cn-fishing-no-events" style={{
marginBottom: 8,
filter: `drop-shadow(0 0 3px ${meta?.color || '#f97316'}88)`,
opacity: 0.85,
}}>
<GearIcon gear={analysis.gearType} size={12} />
</div>
</Marker>
);
})}
{/* 본선/부속선/어선 역할 라벨 — 최대 100개 */}
{analyzed.filter(a => a.role.role).slice(0, 100).map(({ ship, role }) => (
<Marker key={`role-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="top">
<div className="cn-fishing-no-events" style={{
marginTop: 6,
fontSize: 5,
fontWeight: 700,
color: role.color,
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
}}>
{role.roleKo}
</div>
</Marker>
))}
{/* 운반선 라벨 */}
{carriers.map(s => (
<Marker key={`carrier-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="top">
<div className="cn-fishing-no-events" style={{
marginTop: 6,
fontSize: 5,
fontWeight: 700,
color: '#f97316',
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
}}>
</div>
</Marker>
))}
</>
);
}

파일 보기

@ -1,7 +1,6 @@
import { useState } from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../../services/coastGuard';
import { CG_TYPE_LABEL } from '../../services/coastGuard';
import type { CoastGuardFacility, CoastGuardType } from '../../services/coastGuard';
const TYPE_COLOR: Record<CoastGuardType, string> = {
@ -13,143 +12,52 @@ const TYPE_COLOR: Record<CoastGuardType, string> = {
navy: '#3b82f6',
};
const TYPE_SIZE: Record<CoastGuardType, number> = {
hq: 24,
regional: 20,
station: 16,
substation: 13,
vts: 14,
navy: 18,
};
/** 해경 로고 SVG — 작은 방패+앵커 심볼 */
function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number }) {
const color = TYPE_COLOR[type];
const isVts = type === 'vts';
if (type === 'navy') {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M2 16 Q12 20 22 16 L22 12 Q12 8 2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
<line x1="12" y1="4" x2="12" y2="12" stroke={color} strokeWidth="1.5" />
<circle cx="12" cy="4" r="2" fill={color} />
<line x1="8" y1="12" x2="16" y2="12" stroke={color} strokeWidth="1" />
</svg>
);
}
if (isVts) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
<line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" />
<circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" />
<path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" />
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
</svg>
);
}
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z"
fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" />
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" />
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" />
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" />
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
{(type === 'hq' || type === 'regional') && (
<circle cx="12" cy="9" r="1" fill={color} />
)}
</svg>
);
interface Props {
selected: CoastGuardFacility | null;
onClose: () => void;
}
export function CoastGuardLayer() {
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
export function CoastGuardLayer({ selected, onClose }: Props) {
const { t } = useTranslation();
if (!selected) return null;
return (
<>
{COAST_GUARD_FACILITIES.map(f => {
const size = TYPE_SIZE[f.type];
return (
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
<div style={{
cursor: 'pointer',
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
}} className="flex flex-col items-center">
<CoastGuardIcon type={f.type} size={size} />
{(f.type === 'hq' || f.type === 'regional') && (
<div style={{
fontSize: 6,
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
}} className="mt-px whitespace-nowrap font-bold text-white">
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
</div>
)}
{f.type === 'navy' && (
<div style={{
fontSize: 5,
textShadow: '0 0 3px #3b82f6, 0 0 2px #000',
}} className="whitespace-nowrap font-bold tracking-wider text-[#3b82f6]">
{f.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8)}
</div>
)}
{f.type === 'vts' && (
<div style={{
fontSize: 5,
textShadow: '0 0 3px #da77f2, 0 0 2px #000',
}} className="whitespace-nowrap font-bold tracking-wider text-[#da77f2]">
VTS
</div>
)}
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{
background: TYPE_COLOR[selected.type],
color: selected.type === 'vts' ? '#fff' : '#000',
gap: 6, padding: '6px 10px',
}}>
{selected.type === 'navy' ? (
<span style={{ fontSize: 16 }}></span>
) : selected.type === 'vts' ? (
<span style={{ fontSize: 16 }}>📡</span>
) : (
<span style={{ fontSize: 16 }}>🚔</span>
)}
<strong style={{ fontSize: 13 }}>{selected.name}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: TYPE_COLOR[selected.type],
color: selected.type === 'vts' ? '#fff' : '#000',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{CG_TYPE_LABEL[selected.type]}
</span>
<span style={{
background: '#333', color: '#4dabf7',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{t('coastGuard.agency')}
</span>
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
)}
</>
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{
background: TYPE_COLOR[selected.type],
color: selected.type === 'vts' ? '#fff' : '#000',
gap: 6, padding: '6px 10px',
}}>
{selected.type === 'navy' ? (
<span style={{ fontSize: 16 }}></span>
) : selected.type === 'vts' ? (
<span style={{ fontSize: 16 }}>📡</span>
) : (
<span style={{ fontSize: 16 }}>🚔</span>
)}
<strong style={{ fontSize: 13 }}>{selected.name}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: TYPE_COLOR[selected.type],
color: selected.type === 'vts' ? '#fff' : '#000',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{CG_TYPE_LABEL[selected.type]}
</span>
<span style={{
background: '#333', color: '#4dabf7',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{t('coastGuard.agency')}
</span>
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
);
}

파일 보기

@ -1,8 +1,9 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import type { Ship, ChnPrmShipInfo } from '../../types';
import { analyzeFishing } from '../../utils/fishingAnalysis';
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { lookupPermittedShip } from '../../services/chnPrmShip';
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
const mtPhotoCache = new Map<string, string | null>();
@ -55,16 +56,8 @@ const C = {
border2: '#0E2035',
} as const;
// 황해 위치 기반 수역 분류 (근사값)
function classifyZone(lng: number): string {
if (lng > 124.8) return 'TERRITORIAL';
if (lng > 124.2) return 'CONTIGUOUS';
if (lng > 121.5) return 'EEZ';
return 'BEYOND';
}
// AIS 수신 기준 선박 상태 분류
function classifyState(ship: Ship): string {
// 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';
@ -72,11 +65,11 @@ function classifyState(ship: Ship): string {
return 'FISHING';
}
function getAlertLevel(zone: string, state: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' {
if (zone === 'TERRITORIAL') return 'CRITICAL';
if (state === 'AIS_LOSS') return 'WATCH';
if (zone === 'CONTIGUOUS' && state === 'FISHING') return 'WATCH';
if (zone === 'EEZ' && state === 'FISHING') return 'MONITOR';
// 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';
}
@ -94,29 +87,6 @@ function zoneLabel(z: string): string {
return map[z] ?? z;
}
// 근접 클러스터링 (~5NM 내 2척 이상 집단)
function buildClusters(vessels: ProcessedVessel[]): Map<string, string> {
const result = new Map<string, string>();
let clusterIdx = 0;
for (let i = 0; i < vessels.length; i++) {
if (result.has(vessels[i].ship.mmsi)) continue;
const cluster: string[] = [vessels[i].ship.mmsi];
for (let j = i + 1; j < vessels.length; j++) {
if (result.has(vessels[j].ship.mmsi)) continue;
const dlat = Math.abs(vessels[i].ship.lat - vessels[j].ship.lat);
const dlng = Math.abs(vessels[i].ship.lng - vessels[j].ship.lng);
if (dlat < 0.08 && dlng < 0.08) {
cluster.push(vessels[j].ship.mmsi);
}
}
if (cluster.length >= 2) {
clusterIdx++;
const id = `C-${String(clusterIdx).padStart(2, '0')}`;
cluster.forEach(mmsi => result.set(mmsi, id));
}
}
return result;
}
interface ProcessedVessel {
ship: Ship;
@ -137,6 +107,7 @@ interface LogEntry {
interface Props {
ships: Ship[];
vesselAnalysis?: UseVesselAnalysisResult;
onClose: () => void;
}
@ -152,7 +123,9 @@ const PIPE_STEPS = [
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
export function FieldAnalysisModal({ ships, onClose }: Props) {
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
const [activeFilter, setActiveFilter] = useState('ALL');
const [search, setSearch] = useState('');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
@ -167,25 +140,36 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
return cat === 'fishing' || s.category === 'fishing';
}), [ships]);
// 선박 데이터 처리
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
const processed = useMemo((): ProcessedVessel[] => {
const baseList = cnFishing.map(ship => {
const zone = classifyZone(ship.lng);
const state = classifyState(ship);
const alert = getAlertLevel(zone, state);
const analysis = analyzeFishing(ship);
const gear = analysis.gearType;
const vtype =
(gear === 'trawl_pair' || gear === 'trawl_single') ? 'TRAWL' :
gear === 'purse_seine' ? 'PURSE' :
gear === 'gillnet' ? 'GILLNET' :
gear === 'stow_net' ? 'TRAP' :
'TRAWL';
return { ship, zone, state, alert, vtype, cluster: '' };
return cnFishing.map(ship => {
const dto = analysisMap.get(ship.mmsi);
// 수역: Python → GeoJSON 폴리곤 fallback
let zone: string;
if (dto) {
zone = dto.algorithms.location.zone;
} else {
const zoneInfo = classifyFishingZone(ship.lat, ship.lng);
zone = zoneInfo.zone === 'OUTSIDE' ? 'EEZ_OR_BEYOND' : zoneInfo.zone;
}
// 행동 상태: Python → AIS fallback
const state = dto?.algorithms.activity.state ?? classifyStateFallback(ship);
// 경보 등급: Python 위험도 직접 사용
const alert = dto ? riskToAlert(dto.algorithms.riskScore.level) : 'NORMAL';
// 어구 분류: Python classification
const vtype = dto?.classification.vesselType ?? 'UNKNOWN';
// 클러스터: Python cluster ID
const clusterId = dto?.algorithms.cluster.clusterId ?? -1;
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
return { ship, zone, state, alert, vtype, cluster };
});
const clusterMap = buildClusters(baseList);
return baseList.map(v => ({ ...v, cluster: clusterMap.get(v.ship.mmsi) ?? '—' }));
}, [cnFishing]);
}, [cnFishing, analysisMap]);
// 필터 + 정렬
const displayed = useMemo(() => {
@ -201,24 +185,31 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
.sort((a, b) => ALERT_ORDER[a.alert] - ALERT_ORDER[b.alert]);
}, [processed, activeFilter, search]);
// 통계
const stats = useMemo(() => ({
total: processed.length,
territorial: processed.filter(v => v.zone === 'TERRITORIAL').length,
fishing: processed.filter(v => v.state === 'FISHING').length,
aisLoss: processed.filter(v => v.state === 'AIS_LOSS').length,
gpsAnomaly: 0,
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
trawl: processed.filter(v => v.vtype === 'TRAWL').length,
purse: processed.filter(v => v.vtype === 'PURSE').length,
}), [processed]);
// 통계 — Python 분석 결과 기반
const stats = useMemo(() => {
let gpsAnomaly = 0;
for (const v of processed) {
const dto = analysisMap.get(v.ship.mmsi);
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
}
return {
total: processed.length,
territorial: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length,
fishing: processed.filter(v => v.state === 'FISHING').length,
aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length,
gpsAnomaly,
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
trawl: processed.filter(v => v.vtype === 'TRAWL').length,
purse: processed.filter(v => v.vtype === 'PURSE').length,
};
}, [processed, analysisMap]);
// 구역별 카운트
// 구역별 카운트 — Python zone 분류 기반
const zoneCounts = useMemo(() => ({
terr: processed.filter(v => v.zone === 'TERRITORIAL').length,
cont: processed.filter(v => v.zone === 'CONTIGUOUS').length,
eez: processed.filter(v => v.zone === 'EEZ').length,
beyond: processed.filter(v => v.zone === 'BEYOND').length,
terr: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length,
cont: processed.filter(v => v.zone === 'CONTIGUOUS_ZONE' || v.zone === 'ZONE_II').length,
eez: processed.filter(v => v.zone === 'EEZ_OR_BEYOND' || v.zone === 'ZONE_III' || v.zone === 'ZONE_IV').length,
beyond: processed.filter(v => !['TERRITORIAL_SEA', 'CONTIGUOUS_ZONE', 'EEZ_OR_BEYOND', 'ZONE_I', 'ZONE_II', 'ZONE_III', 'ZONE_IV'].includes(v.zone)).length,
}), [processed]);
// 초기 경보 로그 생성
@ -382,10 +373,10 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
{ label: '영해 침범', val: stats.territorial, color: C.red, sub: '12NM 이내' },
{ label: '조업 중', val: stats.fishing, color: C.amber, sub: 'SOG 0.55.0kt' },
{ label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' },
{ label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 의심' },
{ label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'BIRCH 군집' },
{ label: '트롤어선', val: stats.trawl, color: C.purple, sub: '규칙 기반 분류' },
{ label: '선망어선', val: stats.purse, color: C.cyan, sub: '규칙 기반 분류' },
{ label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 스푸핑 50%↑' },
{ label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'DBSCAN 군집' },
{ label: '트롤어선', val: stats.trawl, color: C.purple, sub: 'Python 분류' },
{ label: '선망어선', val: stats.purse, color: C.cyan, sub: 'Python 분류' },
].map(({ label, val, color, sub }) => (
<div key={label} style={{
flex: 1, background: C.bg2, border: `1px solid ${C.border}`,
@ -485,8 +476,8 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
{ label: '조업 패턴', val: 'UCAF/UCFT SOG', color: C.ink2 },
{ label: 'AIS 소실', val: '>20분 미수신', color: C.amber },
{ label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple },
{ label: '클러스터', val: 'BIRCH 5NM', color: C.ink2 },
{ label: '선종 분류', val: '규칙 기반 (Python 연동 예정)', color: C.ink2 },
{ label: '클러스터', val: 'DBSCAN 3NM (Python)', color: C.ink2 },
{ label: '선종 분류', val: 'Python 7단계 파이프라인', color: C.green },
].map(({ label, val, color }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
@ -680,7 +671,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
</span>
))}
<span style={{ marginLeft: 'auto', color: C.ink3, fontSize: 9 }}>
AIS 4 | Shepperson(2017) | | 5NM
AIS 4 | Python 7 | DBSCAN 3NM | GeoJSON
</span>
</div>
</div>
@ -709,10 +700,20 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
{ label: '위치', val: `${selectedVessel.ship.lat.toFixed(4)}°N ${selectedVessel.ship.lng.toFixed(4)}°E`, color: C.ink },
{ label: '속도 / 침로', val: `${selectedVessel.ship.speed.toFixed(1)}kt ${selectedVessel.ship.course}°`, color: C.amber },
{ label: '행동 상태', val: stateLabel(selectedVessel.state), color: stateColor(selectedVessel.state) },
{ label: '추정 선종', val: selectedVessel.vtype, color: C.ink },
{ label: '선종 (Python)', val: selectedVessel.vtype, color: C.ink },
{ label: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) },
{ label: 'BIRCH 클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 },
{ label: '경보 등급', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) },
{ label: '클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 },
{ label: '위험도', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) },
...(() => {
const dto = analysisMap.get(selectedVessel.ship.mmsi);
if (!dto) return [{ label: 'AI 분석', val: '미분석', color: C.ink3 }];
return [
{ label: '위험 점수', val: `${dto.algorithms.riskScore.score}`, color: alertColor(selectedVessel.alert) },
{ label: 'GPS 스푸핑', val: `${Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, color: dto.algorithms.gpsSpoofing.spoofingScore > 0.5 ? C.red : C.green },
{ label: 'AIS 공백', val: dto.algorithms.darkVessel.isDark ? `${Math.round(dto.algorithms.darkVessel.gapDurationMin)}` : '정상', color: dto.algorithms.darkVessel.isDark ? C.red : C.green },
{ label: '선단 역할', val: dto.algorithms.fleetRole.role, color: dto.algorithms.fleetRole.isLeader ? C.amber : C.ink2 },
];
})(),
].map(({ label, val, color }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: `1px solid ${C.border2}` }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>

파일 보기

@ -0,0 +1,57 @@
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)',
};
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)',
};
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,
'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,
'rgba(0,0,0,0)',
] as maplibregl.ExpressionSpecification;
export function FishingZoneLayer() {
return (
<Source id="fishing-zones" type="geojson" data={fishingZonesData as GeoJSON.FeatureCollection}>
<Layer
id="fishing-zone-fill"
type="fill"
paint={{ 'fill-color': fillColor, 'fill-opacity': 1 }}
/>
<Layer
id="fishing-zone-line"
type="line"
paint={{
'line-color': lineColor,
'line-opacity': 1,
'line-width': 1.5,
'line-dasharray': [4, 2],
}}
/>
</Source>
);
}

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

파일 보기

@ -1,6 +1,4 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { GOV_BUILDINGS } from '../../data/govBuildings';
import { Popup } from 'react-map-gl/maplibre';
import type { GovBuilding } from '../../data/govBuildings';
const COUNTRY_STYLE: Record<string, { color: string; flag: string; label: string }> = {
@ -18,79 +16,48 @@ const TYPE_STYLE: Record<string, { icon: string; label: string; color: string }>
defense: { icon: '🛡️', label: '국방부', color: '#dc2626' },
};
export function GovBuildingLayer() {
const [selected, setSelected] = useState<GovBuilding | null>(null);
interface Props {
selected: GovBuilding | null;
onClose: () => void;
}
export function GovBuildingLayer({ selected, onClose }: Props) {
if (!selected) return null;
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.executive;
return (
<>
{GOV_BUILDINGS.map(g => {
const ts = TYPE_STYLE[g.type] || TYPE_STYLE.executive;
return (
<Marker key={g.id} longitude={g.lng} latitude={g.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(g); }}>
<div
className="flex flex-col items-center cursor-pointer"
style={{ filter: `drop-shadow(0 0 3px ${ts.color}88)` }}
>
<div style={{
width: 16, height: 16, borderRadius: '50%',
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${ts.color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 9,
}}>
{ts.icon}
</div>
<div style={{
fontSize: 5, color: ts.color, marginTop: 0,
textShadow: '0 0 3px #000, 0 0 3px #000',
whiteSpace: 'nowrap', fontWeight: 700,
}}>
{g.nameKo.length > 10 ? g.nameKo.slice(0, 10) + '..' : g.nameKo}
</div>
</div>
</Marker>
);
})}
{selected && (() => {
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.executive;
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: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{cs.flag}</span>
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: ts.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{ts.label}
</span>
<span style={{
background: cs.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{cs.label}
</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)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
);
})()}
</>
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{cs.flag}</span>
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: ts.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{ts.label}
</span>
<span style={{
background: cs.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{cs.label}
</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)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
);
}

파일 보기

@ -1,7 +1,5 @@
import { useState } from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { KOREAN_AIRPORTS } from '../../services/airports';
import type { KoreanAirport } from '../../services/airports';
const COUNTRY_COLOR: Record<string, { intl: string; domestic: string; flag: string; label: string }> = {
@ -12,100 +10,72 @@ const COUNTRY_COLOR: Record<string, { intl: string; domestic: string; flag: stri
TW: { intl: '#10b981', domestic: '#059669', flag: '🇹🇼', label: '대만' },
};
function getColor(ap: KoreanAirport) {
const cc = COUNTRY_COLOR[ap.country || 'KR'] || COUNTRY_COLOR.KR;
function getColor(ap: KoreanAirport): string {
const cc = COUNTRY_COLOR[ap.country ?? 'KR'] ?? COUNTRY_COLOR.KR;
return ap.intl ? cc.intl : cc.domestic;
}
function getCountryInfo(ap: KoreanAirport) {
return COUNTRY_COLOR[ap.country || 'KR'] || COUNTRY_COLOR.KR;
return COUNTRY_COLOR[ap.country ?? 'KR'] ?? COUNTRY_COLOR.KR;
}
export function KoreaAirportLayer() {
const [selected, setSelected] = useState<KoreanAirport | null>(null);
interface Props {
selected: KoreanAirport | null;
onClose: () => void;
}
export function KoreaAirportLayer({ selected, onClose }: Props) {
const { t } = useTranslation();
if (!selected) return null;
const color = getColor(selected);
const info = getCountryInfo(selected);
return (
<>
{KOREAN_AIRPORTS.map(ap => {
const color = getColor(ap);
const size = ap.intl ? 20 : 16;
return (
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
<div style={{
cursor: 'pointer',
filter: `drop-shadow(0 0 3px ${color}88)`,
}} className="flex flex-col items-center">
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="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" strokeWidth="0.3" />
</svg>
<div style={{
fontSize: 6,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
}} className="mt-px whitespace-nowrap font-bold tracking-wide text-white">
{ap.nameKo.replace('국제공항', '').replace('공항', '')}
</div>
</div>
</Marker>
);
})}
{selected && (() => {
const color = getColor(selected);
const info = getCountryInfo(selected);
return (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{ background: color, color: '#000', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{info.flag}</span>
<strong style={{ fontSize: 13 }}>{selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
{selected.intl && (
<span style={{
background: color, color: '#000',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{t('airport.international')}
</span>
)}
{selected.domestic && (
<span style={{
background: '#555', color: '#ccc',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{t('airport.domestic')}
</span>
)}
<span style={{
background: '#333', color: '#ccc',
padding: '2px 8px', borderRadius: 3, fontSize: 10,
}}>
{info.label}
</span>
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
<div><span className="popup-label">IATA : </span><strong>{selected.id}</strong></div>
<div><span className="popup-label">ICAO : </span><strong>{selected.icao}</strong></div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
<a href={`https://www.flightradar24.com/airport/${selected.id.toLowerCase()}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
Flightradar24 &rarr;
</a>
</div>
</div>
</Popup>
);
})()}
</>
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{ background: color, color: '#000', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{info.flag}</span>
<strong style={{ fontSize: 13 }}>{selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
{selected.intl && (
<span style={{
background: color, color: '#000',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{t('airport.international')}
</span>
)}
{selected.domestic && (
<span style={{
background: '#555', color: '#ccc',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{t('airport.domestic')}
</span>
)}
<span style={{
background: '#333', color: '#ccc',
padding: '2px 8px', borderRadius: 3, fontSize: 10,
}}>
{info.label}
</span>
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
<div><span className="popup-label">IATA : </span><strong>{selected.id}</strong></div>
<div><span className="popup-label">ICAO : </span><strong>{selected.icao}</strong></div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
<a href={`https://www.flightradar24.com/airport/${selected.id.toLowerCase()}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
Flightradar24 &rarr;
</a>
</div>
</div>
</Popup>
);
}

파일 보기

@ -1,34 +1,40 @@
import { useRef, useState, useEffect } from 'react';
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
import { Map, NavigationControl, Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers';
import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers';
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
import { ShipLayer } from '../layers/ShipLayer';
import { InfraLayer } from './InfraLayer';
import { SatelliteLayer } from '../layers/SatelliteLayer';
import { AircraftLayer } from '../layers/AircraftLayer';
import { SubmarineCableLayer } from './SubmarineCableLayer';
import { CctvLayer } from './CctvLayer';
import { KoreaAirportLayer } from './KoreaAirportLayer';
import { CoastGuardLayer } from './CoastGuardLayer';
import { NavWarningLayer } from './NavWarningLayer';
// 정적 레이어들은 useStaticDeckLayers로 전환됨
import { OsintMapLayer } from './OsintMapLayer';
import { EezLayer } from './EezLayer';
import { PiracyLayer } from './PiracyLayer';
import { WindFarmLayer } from './WindFarmLayer';
import { PortLayer } from './PortLayer';
import { MilitaryBaseLayer } from './MilitaryBaseLayer';
import { GovBuildingLayer } from './GovBuildingLayer';
import { NKLaunchLayer } from './NKLaunchLayer';
import { NKMissileEventLayer } from './NKMissileEventLayer';
// PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer,
// NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
import { HazardFacilityLayer } from './HazardFacilityLayer';
import { CnFacilityLayer } from './CnFacilityLayer';
import { JpFacilityLayer } from './JpFacilityLayer';
// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨
import { AnalysisOverlay } from './AnalysisOverlay';
import { FleetClusterLayer } from './FleetClusterLayer';
import type { SelectedGearGroupData, SelectedFleetData } from './FleetClusterLayer';
import { FishingZoneLayer } from './FishingZoneLayer';
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
import { fetchKoreaInfra } from '../../services/infra';
import type { PowerFacility } from '../../services/infra';
import type { Ship, Aircraft, SatellitePosition } from '../../types';
import type { OsintItem } from '../../services/osint';
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import 'maplibre-gl/dist/maplibre-gl.css';
export interface KoreaFiltersState {
@ -42,6 +48,7 @@ export interface KoreaFiltersState {
interface Props {
ships: Ship[];
allShips?: Ship[];
aircraft: Aircraft[];
satellites: SatellitePosition[];
layers: Record<string, boolean>;
@ -52,6 +59,7 @@ interface Props {
cableWatchSuspects: Set<string>;
dokdoWatchSuspects: Set<string>;
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
vesselAnalysis?: UseVesselAnalysisResult;
}
// MarineTraffic-style: satellite + dark ocean + nautical overlay
@ -124,21 +132,356 @@ const FILTER_I18N_KEY: Record<string, string> = {
ferryWatch: 'filters.ferryWatchMonitor',
};
export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts }: Props) {
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [infra, setInfra] = useState<PowerFacility[]>([]);
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
}, []);
useEffect(() => {
if (flyToTarget && mapRef.current) {
mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom, duration: 1500 });
setFlyToTarget(null);
}
}, [flyToTarget]);
useEffect(() => {
if (!selectedAnalysisMmsi) setTrackCoords(null);
}, [selectedAnalysisMmsi]);
const handleAnalysisShipSelect = useCallback((mmsi: string) => {
setSelectedAnalysisMmsi(mmsi);
const ship = (allShips ?? ships).find(s => s.mmsi === mmsi);
if (ship) setFlyToTarget({ lng: ship.lng, lat: ship.lat, zoom: 12 });
}, [allShips, ships]);
const handleTrackLoad = useCallback((_mmsi: string, coords: [number, number][]) => {
setTrackCoords(coords);
}, []);
const handleFleetZoom = useCallback((bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => {
mapRef.current?.fitBounds(
[[bounds.minLng, bounds.minLat], [bounds.maxLng, bounds.maxLat]],
{ padding: 60, duration: 1500 },
);
}, []);
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=1.0x 기준, 2단계씩 상향
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
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]);
// 불법어선 강조 — deck.gl ScatterplotLayer + TextLayer
const illegalFishingData = useMemo(() => {
if (!koreaFilters.illegalFishing) return [];
return (allShips ?? ships).filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (mtCat !== 'fishing' || s.flag === 'KR') return false;
return classifyFishingZone(s.lat, s.lng).zone !== 'OUTSIDE';
}).slice(0, 200);
}, [koreaFilters.illegalFishing, allShips, ships]);
const illegalFishingLayer = useMemo(() => new ScatterplotLayer({
id: 'illegal-fishing-highlight',
data: illegalFishingData,
getPosition: (d) => [d.lng, d.lat],
getRadius: 800 * zoomScale,
getFillColor: [239, 68, 68, 40],
getLineColor: [239, 68, 68, 200],
getLineWidth: 2,
stroked: true,
filled: true,
radiusUnits: 'meters',
lineWidthUnits: 'pixels',
}), [illegalFishingData, zoomScale]);
const illegalFishingLabelLayer = useMemo(() => new TextLayer({
id: 'illegal-fishing-labels',
data: illegalFishingData,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name || d.mmsi,
getSize: 10 * zoomScale,
getColor: [239, 68, 68, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 14],
fontFamily: 'monospace',
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}), [illegalFishingData, zoomScale]);
// 수역 라벨 TextLayer — illegalFishing 필터 활성 시만 표시
const zoneLabelsLayer = useMemo(() => {
if (!koreaFilters.illegalFishing) return null;
const data = (fishingZonesData as GeoJSON.FeatureCollection).features.map(f => {
const geom = f.geometry as GeoJSON.MultiPolygon;
let sLng = 0, sLat = 0, n = 0;
for (const poly of geom.coordinates) {
for (const ring of poly) {
for (const [lng, lat] of ring) {
sLng += lng; sLat += lat; n++;
}
}
}
return {
name: (f.properties as { name: string }).name,
lng: n > 0 ? sLng / n : 0,
lat: n > 0 ? sLat / n : 0,
};
});
return new TextLayer({
id: 'fishing-zone-labels',
data,
getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat],
getText: (d: { name: string }) => d.name,
getSize: 12 * zoomScale,
getColor: [255, 255, 255, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'monospace',
fontWeight: 700,
outlineWidth: 3,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
});
}, [koreaFilters.illegalFishing, zoomScale]);
// 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등
const staticDeckLayers = useStaticDeckLayers({
ports: layers.ports ?? false,
coastGuard: layers.coastGuard ?? false,
windFarm: layers.windFarm ?? false,
militaryBases: layers.militaryBases ?? false,
govBuildings: layers.govBuildings ?? false,
airports: layers.airports ?? false,
navWarning: layers.navWarning ?? false,
nkLaunch: layers.nkLaunch ?? false,
nkMissile: layers.nkMissile ?? false,
piracy: layers.piracy ?? false,
infra: layers.infra ?? false,
infraFacilities: infra,
hazardTypes: [
...(layers.hazardPetrochemical ? ['petrochemical' as const] : []),
...(layers.hazardLng ? ['lng' as const] : []),
...(layers.hazardOilTank ? ['oilTank' as const] : []),
...(layers.hazardPort ? ['hazardPort' as const] : []),
...(layers.energyNuclear ? ['nuclear' as const] : []),
...(layers.energyThermal ? ['thermal' as const] : []),
...(layers.industryShipyard ? ['shipyard' as const] : []),
...(layers.industryWastewater ? ['wastewater' as const] : []),
...(layers.industryHeavy ? ['heavyIndustry' as const] : []),
],
cnPower: !!layers.cnPower,
cnMilitary: !!layers.cnMilitary,
jpPower: !!layers.jpPower,
jpMilitary: !!layers.jpMilitary,
onPick: (info) => setStaticPickInfo(info),
sizeScale: zoomScale,
});
// 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl)
const selectedGearLayers = useMemo(() => {
if (!selectedGearData) return [];
const { parent, gears, groupName } = selectedGearData;
const layers = [];
// 어구 위치 — 주황 원형 마커
layers.push(new ScatterplotLayer({
id: 'selected-gear-items',
data: gears,
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 6 * zoomScale,
getFillColor: [249, 115, 22, 180],
getLineColor: [255, 255, 255, 220],
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 1.5,
}));
// 어구 이름 라벨
layers.push(new TextLayer({
id: 'selected-gear-labels',
data: gears,
getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => d.name || d.mmsi,
getSize: 9 * zoomScale,
getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 10],
fontFamily: 'monospace',
outlineWidth: 2,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
}));
// 모선 강조 — 큰 원 + 라벨
if (parent) {
layers.push(new ScatterplotLayer({
id: 'selected-gear-parent',
data: [parent],
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 14 * zoomScale,
getFillColor: [249, 115, 22, 80],
getLineColor: [249, 115, 22, 255],
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 3,
}));
layers.push(new TextLayer({
id: 'selected-gear-parent-label',
data: [parent],
getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => `${d.name || groupName} (모선)`,
getSize: 11 * zoomScale,
getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 18],
fontFamily: 'monospace',
fontWeight: 700,
outlineWidth: 3,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
}));
}
return layers;
}, [selectedGearData, zoomScale]);
// 선택된 선단 소속 선박 강조 레이어 (deck.gl)
const selectedFleetLayers = useMemo(() => {
if (!selectedFleetData) return [];
const { ships: fleetShips, clusterId } = selectedFleetData;
if (fleetShips.length === 0) return [];
// HSL→RGB 인라인 변환 (선단 색상)
const hue = (clusterId * 137) % 360;
const h = hue / 360; const s = 0.7; const l = 0.6;
const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1; if (t > 1) t -= 1; return t < 1/6 ? p + (q-p)*6*t : t < 1/2 ? q : t < 2/3 ? p + (q-p)*(2/3-t)*6 : p; };
const q = l < 0.5 ? l * (1+s) : l + s - l*s; const p = 2*l - q;
const r = Math.round(hue2rgb(p, q, h + 1/3) * 255);
const g = Math.round(hue2rgb(p, q, h) * 255);
const b = Math.round(hue2rgb(p, q, h - 1/3) * 255);
const color: [number, number, number, number] = [r, g, b, 255];
const fillColor: [number, number, number, number] = [r, g, b, 80];
const result: Layer[] = [];
// 소속 선박 — 강조 원형
result.push(new ScatterplotLayer({
id: 'selected-fleet-items',
data: fleetShips,
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 8 * zoomScale,
getFillColor: fillColor,
getLineColor: color,
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 2,
}));
// 소속 선박 이름 라벨
result.push(new TextLayer({
id: 'selected-fleet-labels',
data: fleetShips,
getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => {
const dto = vesselAnalysis?.analysisMap.get(d.mmsi);
const role = dto?.algorithms.fleetRole.role;
const prefix = role === 'LEADER' ? '★ ' : '';
return `${prefix}${d.name || d.mmsi}`;
},
getSize: 9 * zoomScale,
getColor: color,
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 12],
fontFamily: 'monospace',
fontWeight: 600,
outlineWidth: 2,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
}));
// 리더 선박 추가 강조 (큰 외곽 링)
const leaders = fleetShips.filter(s => {
const dto = vesselAnalysis?.analysisMap.get(s.mmsi);
return dto?.algorithms.fleetRole.isLeader;
});
if (leaders.length > 0) {
result.push(new ScatterplotLayer({
id: 'selected-fleet-leaders',
data: leaders,
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 16 * zoomScale,
getFillColor: [0, 0, 0, 0],
getLineColor: color,
stroked: true,
filled: false,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 3,
}));
}
return result;
}, [selectedFleetData, zoomScale, vesselAnalysis]);
// 분석 결과 deck.gl 레이어
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
: koreaFilters.darkVessel ? 'darkVessel'
: layers.cnFishing ? 'cnFishing'
: null;
const analysisDeckLayers = useAnalysisDeckLayers(
vesselAnalysis?.analysisMap ?? new Map(),
allShips ?? ships,
analysisActiveFilter,
zoomScale,
);
return (
<Map
ref={mapRef}
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
onZoom={e => setZoomLevel(Math.floor(e.viewState.zoom))}
>
<NavigationControl position="top-right" />
@ -203,7 +546,7 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
/>
</Source>
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} analysisMap={vesselAnalysis?.analysisMap} />}
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
@ -265,32 +608,240 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
{layers.aircraft && aircraft.length > 0 && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.cables && <SubmarineCableLayer />}
{layers.cctv && <CctvLayer />}
{layers.windFarm && <WindFarmLayer />}
{layers.ports && <PortLayer />}
{layers.militaryBases && <MilitaryBaseLayer />}
{layers.govBuildings && <GovBuildingLayer />}
{layers.nkLaunch && <NKLaunchLayer />}
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} />}
{layers.hazardPetrochemical && <HazardFacilityLayer type="petrochemical" />}
{layers.hazardLng && <HazardFacilityLayer type="lng" />}
{layers.hazardOilTank && <HazardFacilityLayer type="oilTank" />}
{layers.hazardPort && <HazardFacilityLayer type="hazardPort" />}
{layers.energyNuclear && <HazardFacilityLayer type="nuclear" />}
{layers.energyThermal && <HazardFacilityLayer type="thermal" />}
{layers.industryShipyard && <HazardFacilityLayer type="shipyard" />}
{layers.industryWastewater && <HazardFacilityLayer type="wastewater" />}
{layers.industryHeavy && <HazardFacilityLayer type="heavyIndustry" />}
{layers.cnPower && <CnFacilityLayer type="power" />}
{layers.cnMilitary && <CnFacilityLayer type="military" />}
{layers.jpPower && <JpFacilityLayer type="power" />}
{layers.jpMilitary && <JpFacilityLayer type="military" />}
{layers.airports && <KoreaAirportLayer />}
{layers.coastGuard && <CoastGuardLayer />}
{layers.navWarning && <NavWarningLayer />}
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
{(koreaFilters.illegalFishing || layers.cnFishing) && <FishingZoneLayer />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
{/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */}
{layers.cnFishing && (
<FleetClusterLayer
ships={allShips ?? ships}
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom}
onSelectedGearChange={setSelectedGearData}
onSelectedFleetChange={setSelectedFleetData}
/>
)}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
<AnalysisOverlay
ships={allShips ?? ships}
analysisMap={vesselAnalysis.analysisMap}
clusters={vesselAnalysis.clusters}
activeFilter={analysisActiveFilter}
/>
)}
{/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */}
<DeckGLOverlay layers={[
...staticDeckLayers,
illegalFishingLayer,
illegalFishingLabelLayer,
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean)} />
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
{staticPickInfo && (() => {
const obj = staticPickInfo.object;
const kind = staticPickInfo.kind;
const lat = obj.lat ?? obj.launchLat ?? 0;
const lng = obj.lng ?? obj.launchLng ?? 0;
if (!lat || !lng) return null;
// ── kind + subType 기반 메타 결정 ──
const SUB_META: Record<string, Record<string, { icon: string; color: string; label: string }>> = {
hazard: {
petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지' },
lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지' },
oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크' },
hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물항만하역' },
nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소' },
thermal: { icon: '🔥', color: '#64748b', label: '화력발전소' },
shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소' },
wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장' },
heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소' },
},
overseas: {
nuclear: { icon: '☢️', color: '#ef4444', label: '핵발전소' },
thermal: { icon: '🔥', color: '#f97316', label: '화력발전소' },
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
airbase: { icon: '✈️', color: '#22d3ee', label: '공군기지' },
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
shipyard:{ icon: '🚢', color: '#94a3b8', label: '조선소' },
},
militaryBase: {
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
airforce:{ icon: '✈️', color: '#22d3ee', label: '공군기지' },
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
missile: { icon: '🚀', color: '#ef4444', label: '미사일기지' },
joint: { icon: '⭐', color: '#f59e0b', label: '합동사령부' },
},
govBuilding: {
executive: { icon: '🏛️', color: '#f59e0b', label: '행정부' },
legislature: { icon: '🏛️', color: '#3b82f6', label: '입법부' },
military_hq: { icon: '⭐', color: '#ef4444', label: '군사령부' },
intelligence: { icon: '🔍', color: '#8b5cf6', label: '정보기관' },
foreign: { icon: '🌐', color: '#06b6d4', label: '외교부' },
maritime: { icon: '⚓', color: '#0ea5e9', label: '해양기관' },
defense: { icon: '🛡️', color: '#dc2626', label: '국방부' },
},
nkLaunch: {
icbm: { icon: '🚀', color: '#dc2626', label: 'ICBM 발사장' },
irbm: { icon: '🚀', color: '#ef4444', label: 'IRBM/MRBM' },
srbm: { icon: '🎯', color: '#f97316', label: '단거리탄도미사일' },
slbm: { icon: '🔱', color: '#3b82f6', label: 'SLBM 발사' },
cruise: { icon: '✈️', color: '#8b5cf6', label: '순항미사일' },
artillery: { icon: '💥', color: '#eab308', label: '해안포/장사정포' },
mlrs: { icon: '💥', color: '#f59e0b', label: '방사포(MLRS)' },
},
coastGuard: {
hq: { icon: '🏢', color: '#3b82f6', label: '본청' },
regional: { icon: '🏢', color: '#60a5fa', label: '지방청' },
station: { icon: '🚔', color: '#22d3ee', label: '해양경찰서' },
substation: { icon: '🏠', color: '#94a3b8', label: '파출소' },
vts: { icon: '📡', color: '#f59e0b', label: 'VTS센터' },
navy: { icon: '⚓', color: '#3b82f6', label: '해군부대' },
},
airport: {
international: { icon: '✈️', color: '#a78bfa', label: '국제공항' },
domestic: { icon: '🛩️', color: '#60a5fa', label: '국내공항' },
military: { icon: '✈️', color: '#ef4444', label: '군용비행장' },
},
navWarning: {
danger: { icon: '⚠️', color: '#ef4444', label: '위험' },
caution: { icon: '⚠️', color: '#eab308', label: '주의' },
info: { icon: '', color: '#3b82f6', label: '정보' },
},
piracy: {
critical: { icon: '☠️', color: '#ef4444', label: '극고위험' },
high: { icon: '☠️', color: '#f97316', label: '고위험' },
moderate: { icon: '☠️', color: '#eab308', label: '주의' },
},
};
const KIND_DEFAULT: Record<string, { icon: string; color: string; label: string }> = {
port: { icon: '⚓', color: '#3b82f6', label: '항구' },
windFarm: { icon: '🌀', color: '#00bcd4', label: '풍력단지' },
militaryBase: { icon: '🪖', color: '#ef4444', label: '군사시설' },
govBuilding: { icon: '🏛️', color: '#f59e0b', label: '정부기관' },
nkLaunch: { icon: '🚀', color: '#dc2626', label: '북한 발사장' },
nkMissile: { icon: '💣', color: '#ef4444', label: '미사일 낙하' },
coastGuard: { icon: '🚔', color: '#4dabf7', label: '해양경찰' },
airport: { icon: '✈️', color: '#a78bfa', label: '공항' },
navWarning: { icon: '⚠️', color: '#eab308', label: '항행경보' },
piracy: { icon: '☠️', color: '#ef4444', label: '해적위험' },
infra: { icon: '⚡', color: '#ffeb3b', label: '발전/변전' },
hazard: { icon: '⚠️', color: '#ef4444', label: '위험시설' },
cnFacility: { icon: '📍', color: '#ef4444', label: '중국시설' },
jpFacility: { icon: '📍', color: '#f472b6', label: '일본시설' },
};
// subType 키 결정
const subKey = obj.type ?? obj.subType ?? obj.level ?? '';
const subGroup = kind === 'cnFacility' || kind === 'jpFacility' ? 'overseas' : kind;
const meta = SUB_META[subGroup]?.[subKey] ?? KIND_DEFAULT[kind] ?? { icon: '📍', color: '#64748b', label: kind };
// 국가 플래그
const COUNTRY_FLAG: Record<string, string> = { CN: '🇨🇳', JP: '🇯🇵', KP: '🇰🇵', KR: '🇰🇷', TW: '🇹🇼' };
const flag = kind === 'cnFacility' ? '🇨🇳' : kind === 'jpFacility' ? '🇯🇵' : COUNTRY_FLAG[obj.country] ?? '';
const countryName = kind === 'cnFacility' ? '중국' : kind === 'jpFacility' ? '일본'
: { CN: '중국', JP: '일본', KP: '북한', KR: '한국', TW: '대만' }[obj.country] ?? '';
// 이름 결정
const title = obj.nameKo || obj.name || obj.launchNameKo || obj.title || kind;
return (
<Popup longitude={lng} latitude={lat} anchor="bottom"
onClose={() => setStaticPickInfo(null)} closeOnClick={false}
maxWidth="280px" className="gl-popup"
>
<div className="popup-body-sm" style={{ minWidth: 200 }}>
{/* 컬러 헤더 */}
<div className="popup-header" style={{ background: meta.color, color: '#000', gap: 6, padding: '4px 8px' }}>
<span>{meta.icon}</span> {title}
</div>
{/* 배지 행 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: meta.color, color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{meta.label}
</span>
{flag && (
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
{flag} {countryName}
</span>
)}
{kind === 'hazard' && (
<span style={{
background: 'rgba(239,68,68,0.15)', color: '#ef4444',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 600,
border: '1px solid rgba(239,68,68,0.3)',
}}> </span>
)}
{kind === 'port' && (
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
{obj.type === 'major' ? '주요항' : '중소항'}
</span>
)}
{kind === 'airport' && obj.intl && (
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}></span>
)}
</div>
{/* 설명 */}
{obj.description && (
<div style={{ fontSize: 10, color: '#999', marginBottom: 4, lineHeight: 1.5 }}>{obj.description}</div>
)}
{obj.detail && (
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.detail}</div>
)}
{obj.note && (
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.note}</div>
)}
{/* 필드 그리드 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{obj.operator && <div><span className="popup-label">: </span>{obj.operator}</div>}
{obj.capacity && <div><span className="popup-label">: </span><strong>{obj.capacity}</strong></div>}
{obj.output && <div><span className="popup-label">: </span><strong>{obj.output}</strong></div>}
{obj.source && <div><span className="popup-label">: </span>{obj.source}</div>}
{obj.capacityMW && <div><span className="popup-label">: </span><strong>{obj.capacityMW}MW</strong></div>}
{obj.turbines && <div><span className="popup-label">: </span>{obj.turbines}</div>}
{obj.status && <div><span className="popup-label">: </span>{obj.status}</div>}
{obj.year && <div><span className="popup-label">: </span>{obj.year}</div>}
{obj.region && <div><span className="popup-label">: </span>{obj.region}</div>}
{obj.org && <div><span className="popup-label">: </span>{obj.org}</div>}
{obj.area && <div><span className="popup-label">: </span>{obj.area}</div>}
{obj.altitude && <div><span className="popup-label">: </span>{obj.altitude}</div>}
{obj.address && <div><span className="popup-label">: </span>{obj.address}</div>}
{obj.recentUse && <div><span className="popup-label"> : </span>{obj.recentUse}</div>}
{obj.recentIncidents != null && <div><span className="popup-label"> 1: </span><strong>{obj.recentIncidents}</strong></div>}
{obj.icao && <div><span className="popup-label">ICAO: </span>{obj.icao}</div>}
{kind === 'nkMissile' && (
<>
{obj.typeKo && <div><span className="popup-label">: </span>{obj.typeKo}</div>}
{obj.date && <div><span className="popup-label">: </span>{obj.date} {obj.time}</div>}
{obj.distanceKm && <div><span className="popup-label">: </span>{obj.distanceKm}km</div>}
{obj.altitudeKm && <div><span className="popup-label">: </span>{obj.altitudeKm}km</div>}
{obj.flightMin && <div><span className="popup-label">: </span>{obj.flightMin}</div>}
{obj.launchNameKo && <div><span className="popup-label">: </span>{obj.launchNameKo}</div>}
</>
)}
{obj.name && obj.nameKo && obj.name !== obj.nameKo && (
<div><span className="popup-label">: </span>{obj.name}</div>
)}
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
{lat.toFixed(4)}°N, {lng.toFixed(4)}°E
</div>
</div>
</div>
</Popup>
);
})()}
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
{layers.eez && <EezLayer />}
{layers.piracy && <PiracyLayer />}
{/* Filter Status Banner */}
{(() => {
@ -346,6 +897,42 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
))}
</div>
)}
{/* 선택된 분석 선박 항적 — tracks API 응답 기반 */}
{trackCoords && trackCoords.length > 1 && (
<Source id="analysis-trail" type="geojson" data={{
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: trackCoords,
},
}],
}}>
<Layer id="analysis-trail-line" type="line" paint={{
'line-color': '#00e5ff',
'line-width': 2.5,
'line-opacity': 0.8,
}} />
</Source>
)}
{/* AI Analysis Stats Panel — 항상 표시 */}
{vesselAnalysis && (
<AnalysisStatsPanel
stats={vesselAnalysis.stats}
lastUpdated={vesselAnalysis.lastUpdated}
isLoading={vesselAnalysis.isLoading}
analysisMap={vesselAnalysis.analysisMap}
ships={allShips ?? ships}
allShips={allShips ?? ships}
onShipSelect={handleAnalysisShipSelect}
onTrackLoad={handleTrackLoad}
onExpandedChange={setAnalysisPanelOpen}
/>
)}
</Map>
);
}

파일 보기

@ -1,6 +1,4 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { MILITARY_BASES } from '../../data/militaryBases';
import { Popup } from 'react-map-gl/maplibre';
import type { MilitaryBase } from '../../data/militaryBases';
const COUNTRY_STYLE: Record<string, { color: string; flag: string; label: string }> = {
@ -18,91 +16,49 @@ const TYPE_STYLE: Record<string, { icon: string; label: string; color: string }>
joint: { icon: '⭐', label: '합동기지', color: '#a78bfa' },
};
function _MilIcon({ type, size = 16 }: { type: string; size?: number }) {
const ts = TYPE_STYLE[type] || TYPE_STYLE.army;
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<polygon points="12,2 22,8 22,16 12,22 2,16 2,8" fill="rgba(0,0,0,0.6)" stroke={ts.color} strokeWidth="1.5" />
<text x="12" y="14" textAnchor="middle" fontSize="9" fill={ts.color}>{ts.icon}</text>
</svg>
);
interface Props {
selected: MilitaryBase | null;
onClose: () => void;
}
export function MilitaryBaseLayer() {
const [selected, setSelected] = useState<MilitaryBase | null>(null);
export function MilitaryBaseLayer({ selected, onClose }: Props) {
if (!selected) return null;
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.army;
return (
<>
{MILITARY_BASES.map(base => {
const _cs = COUNTRY_STYLE[base.country] || COUNTRY_STYLE.CN;
const ts = TYPE_STYLE[base.type] || TYPE_STYLE.army;
return (
<Marker key={base.id} longitude={base.lng} latitude={base.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(base); }}>
<div
className="flex flex-col items-center cursor-pointer"
style={{ filter: `drop-shadow(0 0 3px ${ts.color}88)` }}
>
<div style={{
width: 18, height: 18, borderRadius: 3,
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${ts.color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10,
}}>
{ts.icon}
</div>
<div style={{
fontSize: 5, color: ts.color, marginTop: 1,
textShadow: '0 0 3px #000, 0 0 3px #000',
whiteSpace: 'nowrap', fontWeight: 700,
}}>
{base.nameKo.length > 12 ? base.nameKo.slice(0, 12) + '..' : base.nameKo}
</div>
</div>
</Marker>
);
})}
{selected && (() => {
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.army;
return (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 220 }}>
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{cs.flag}</span>
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: ts.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{ts.label}
</span>
<span style={{
background: cs.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{cs.label}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--kcg-text)', marginBottom: 6, lineHeight: 1.4 }}>
{selected.description}
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
<div><span className="popup-label"> : </span><strong>{selected.name}</strong></div>
<div><span className="popup-label"> : </span>{ts.label}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
);
})()}
</>
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 220 }}>
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{cs.flag}</span>
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: ts.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{ts.label}
</span>
<span style={{
background: cs.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{cs.label}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--kcg-text)', marginBottom: 6, lineHeight: 1.4 }}>
{selected.description}
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
<div><span className="popup-label"> : </span><strong>{selected.name}</strong></div>
<div><span className="popup-label"> : </span>{ts.label}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
);
}

파일 보기

@ -1,89 +1,53 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites';
import { Popup } from 'react-map-gl/maplibre';
import { NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites';
import type { NKLaunchSite } from '../../data/nkLaunchSites';
export function NKLaunchLayer() {
const [selected, setSelected] = useState<NKLaunchSite | null>(null);
interface Props {
selected: NKLaunchSite | null;
onClose: () => void;
}
export function NKLaunchLayer({ selected, onClose }: Props) {
if (!selected) return null;
const meta = NK_LAUNCH_TYPE_META[selected.type];
return (
<>
{NK_LAUNCH_SITES.map(site => {
const meta = NK_LAUNCH_TYPE_META[site.type];
const isArtillery = site.type === 'artillery' || site.type === 'mlrs';
const size = isArtillery ? 14 : 18;
return (
<Marker key={site.id} longitude={site.lng} latitude={site.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(site); }}>
<div
className="flex flex-col items-center cursor-pointer"
style={{ filter: `drop-shadow(0 0 4px ${meta.color}aa)` }}
>
<div style={{
width: size, height: size,
borderRadius: isArtillery ? '50%' : 4,
background: 'rgba(0,0,0,0.7)',
border: `2px solid ${meta.color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: isArtillery ? 8 : 10,
}}>
{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,
}}>
{site.nameKo.length > 10 ? site.nameKo.slice(0, 10) + '..' : site.nameKo}
</div>
</div>
</Marker>
);
})}
{selected && (() => {
const meta = NK_LAUNCH_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 }}>🇰🇵</span>
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: meta.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{meta.label}
</span>
<span style={{
background: '#f97316', color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
</span>
</div>
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{selected.description}
</div>
{selected.recentUse && (
<div style={{ fontSize: 10, color: '#f87171', marginBottom: 4 }}>
: {selected.recentUse}
</div>
)}
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
{selected.name}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
);
})()}
</>
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={onClose} 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 }}>🇰🇵</span>
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: meta.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{meta.label}
</span>
<span style={{
background: '#f97316', color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
</span>
</div>
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{selected.description}
</div>
{selected.recentUse && (
<div style={{ fontSize: 10, color: '#f87171', marginBottom: 4 }}>
: {selected.recentUse}
</div>
)}
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
{selected.name}
</div>
<div style={{ fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
);
}

파일 보기

@ -1,15 +1,10 @@
import { useState, useMemo } from 'react';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import { useMemo } from 'react';
import { Popup, Source, Layer } from 'react-map-gl/maplibre';
import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents';
import type { NKMissileEvent } from '../../data/nkMissileEvents';
import type { Ship } from '../../types';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
function isToday(dateStr: string): boolean {
const today = new Date().toISOString().slice(0, 10);
return dateStr === today;
}
function getMissileColor(type: string): string {
if (type.includes('ICBM')) return '#dc2626';
if (type.includes('IRBM')) return '#ef4444';
@ -27,11 +22,11 @@ function distKm(lat1: number, lng1: number, lat2: number, lng2: number): number
interface Props {
ships: Ship[];
selected: NKMissileEvent | null;
onClose: () => void;
}
export function NKMissileEventLayer({ ships }: Props) {
const [selected, setSelected] = useState<NKMissileEvent | null>(null);
export function NKMissileEventLayer({ ships, selected, onClose }: Props) {
const lineGeoJSON = useMemo(() => ({
type: 'FeatureCollection' as const,
features: NK_MISSILE_EVENTS.map(ev => ({
@ -51,7 +46,7 @@ export function NKMissileEventLayer({ ships }: Props) {
return (
<>
{/* 궤적 라인 */}
{/* 궤적 라인 — MapLibre Source/Layer 유지 */}
<Source id="nk-missile-lines" type="geojson" data={lineGeoJSON}>
<Layer
id="nk-missile-line-layer"
@ -65,62 +60,12 @@ export function NKMissileEventLayer({ ships }: Props) {
/>
</Source>
{/* 발사 지점 (▲) */}
{NK_MISSILE_EVENTS.map(ev => {
const color = getMissileColor(ev.type);
const today = isToday(ev.date);
return (
<Marker key={`launch-${ev.id}`} longitude={ev.launchLng} latitude={ev.launchLat} anchor="center">
<div style={{ filter: `drop-shadow(0 0 4px ${color}aa)`, opacity: today ? 1 : 0.35 }}>
<svg width={12} height={12} viewBox="0 0 24 24" fill="none">
<polygon points="12,2 22,20 2,20" fill={color} stroke="#fff" strokeWidth="1" />
</svg>
</div>
</Marker>
);
})}
{/* 낙하 지점 (✕ + 정보 라벨) */}
{NK_MISSILE_EVENTS.map(ev => {
const color = getMissileColor(ev.type);
const today = isToday(ev.date);
return (
<Marker key={`impact-${ev.id}`} longitude={ev.impactLng} latitude={ev.impactLat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ev); }}>
<div className="cursor-pointer flex flex-col items-center" style={{
filter: `drop-shadow(0 0 ${today ? '6px' : '3px'} ${color})`,
opacity: today ? 1 : 0.4,
pointerEvents: 'auto',
}}>
<svg width={16} height={16} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.5" />
<line x1="7" y1="7" x2="17" y2="17" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
<line x1="17" y1="7" x2="7" y2="17" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
{today && (
<circle cx="12" cy="12" r="10" fill="none" stroke={color} strokeWidth="1" opacity="0.4">
<animate attributeName="r" values="10;18;10" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.4;0;0.4" dur="2s" repeatCount="indefinite" />
</circle>
)}
</svg>
<div style={{
fontSize: 5, color, fontWeight: 700, marginTop: 1,
textShadow: '0 0 3px #000, 0 0 3px #000',
whiteSpace: 'nowrap',
}}>
{ev.date.slice(5)} {ev.time} {ev.launchNameKo}
</div>
</div>
</Marker>
);
})}
{/* 낙하 지점 팝업 */}
{selected && (() => {
const color = getMissileColor(selected.type);
return (
<Popup longitude={selected.impactLng} latitude={selected.impactLat}
onClose={() => setSelected(null)} closeOnClick={false}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 260 }}>
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>

파일 보기

@ -1,7 +1,6 @@
import { useState } from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../../services/navWarning';
import { NW_LEVEL_LABEL, NW_ORG_LABEL } from '../../services/navWarning';
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../../services/navWarning';
const LEVEL_COLOR: Record<NavWarningLevel, string> = {
@ -19,112 +18,68 @@ const ORG_COLOR: Record<TrainingOrg, string> = {
'국과연': '#eab308',
};
function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: TrainingOrg; size: number }) {
const color = ORG_COLOR[org];
if (level === 'danger') {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M12 3 L22 20 L2 20 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.5" />
<line x1="12" y1="9" x2="12" y2="14" stroke={color} strokeWidth="2" strokeLinecap="round" />
<circle cx="12" cy="17" r="1" fill={color} />
</svg>
);
}
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
<line x1="12" y1="8" x2="12" y2="13" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
<circle cx="12" cy="16" r="1" fill={color} />
</svg>
);
interface Props {
selected: NavWarning | null;
onClose: () => void;
}
export function NavWarningLayer() {
const [selected, setSelected] = useState<NavWarning | null>(null);
export function NavWarningLayer({ selected, onClose }: Props) {
const { t } = useTranslation();
if (!selected) return null;
return (
<>
{NAV_WARNINGS.map(w => {
const color = ORG_COLOR[w.org];
const size = w.level === 'danger' ? 16 : 14;
return (
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
<div style={{
cursor: 'pointer',
filter: `drop-shadow(0 0 4px ${color}88)`,
}} className="flex flex-col items-center">
<WarningIcon level={w.level} org={w.org} size={size} />
<div style={{
fontSize: 5, color,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
}} className="whitespace-nowrap font-bold tracking-wide">
{w.id}
</div>
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div className="font-mono" style={{ minWidth: 240, fontSize: 12 }}>
<div style={{
background: ORG_COLOR[selected.org],
color: '#fff',
padding: '4px 8px',
fontSize: 12,
fontWeight: 700,
margin: '-10px -10px 0',
borderRadius: '5px 5px 0 0',
}}>
{selected.title}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 10, marginBottom: 6 }}>
<span style={{
background: LEVEL_COLOR[selected.level],
color: '#fff',
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
}}>
{NW_LEVEL_LABEL[selected.level]}
</span>
<span style={{
background: ORG_COLOR[selected.org] + '33',
color: ORG_COLOR[selected.org],
border: `1px solid ${ORG_COLOR[selected.org]}44`,
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
}}>
{NW_ORG_LABEL[selected.org]}
</span>
<span style={{
background: 'var(--kcg-card)', color: 'var(--kcg-muted)',
padding: '1px 6px', fontSize: 10, borderRadius: 3,
}}>
{selected.area}
</span>
</div>
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{selected.description}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 9, color: '#666' }}>
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
<div>{t('navWarning.source')}: {selected.source}</div>
</div>
<a
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block', marginTop: 6, fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
>{t('navWarning.khoaLink')}</a>
</div>
</Popup>
)}
</>
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div className="font-mono" style={{ minWidth: 240, fontSize: 12 }}>
<div style={{
background: ORG_COLOR[selected.org],
color: '#fff',
padding: '4px 8px',
fontSize: 12,
fontWeight: 700,
margin: '-10px -10px 0',
borderRadius: '5px 5px 0 0',
}}>
{selected.title}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 10, marginBottom: 6 }}>
<span style={{
background: LEVEL_COLOR[selected.level],
color: '#fff',
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
}}>
{NW_LEVEL_LABEL[selected.level]}
</span>
<span style={{
background: ORG_COLOR[selected.org] + '33',
color: ORG_COLOR[selected.org],
border: `1px solid ${ORG_COLOR[selected.org]}44`,
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
}}>
{NW_ORG_LABEL[selected.org]}
</span>
<span style={{
background: 'var(--kcg-card)', color: 'var(--kcg-muted)',
padding: '1px 6px', fontSize: 10, borderRadius: 3,
}}>
{selected.area}
</span>
</div>
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
{selected.description}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 9, color: '#666' }}>
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
<div>{t('navWarning.source')}: {selected.source}</div>
</div>
<a
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'block', marginTop: 6, fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
>{t('navWarning.khoaLink')}</a>
</div>
</Popup>
);
}

파일 보기

@ -1,95 +1,57 @@
import { useState } from 'react';
import { Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../../services/piracy';
import { PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../../services/piracy';
import type { PiracyZone } from '../../services/piracy';
function SkullIcon({ color, size }: { color: string; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
<line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
<line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
interface Props {
selected: PiracyZone | null;
onClose: () => void;
}
export function PiracyLayer() {
const [selected, setSelected] = useState<PiracyZone | null>(null);
export function PiracyLayer({ selected, onClose }: Props) {
const { t } = useTranslation();
if (!selected) return null;
return (
<>
{PIRACY_ZONES.map(zone => {
const color = PIRACY_LEVEL_COLOR[zone.level];
const size = zone.level === 'critical' ? 28 : zone.level === 'high' ? 24 : 20;
return (
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
<div style={{
cursor: 'pointer',
filter: `drop-shadow(0 0 8px ${color}aa)`,
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
}} className="flex flex-col items-center">
<SkullIcon color={color} size={size} />
<div style={{
fontSize: 7, color,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
}} className="mt-px whitespace-nowrap font-mono font-bold tracking-wide">
{PIRACY_LEVEL_LABEL[zone.level]}
</div>
</div>
</Marker>
);
})}
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div className="min-w-[260px] font-mono text-xs">
<div style={{
background: PIRACY_LEVEL_COLOR[selected.level],
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
<span className="text-sm"></span>
{selected.nameKo}
</div>
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div className="min-w-[260px] font-mono text-xs">
<div style={{
background: PIRACY_LEVEL_COLOR[selected.level],
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
<span className="text-sm"></span>
{selected.nameKo}
</div>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: PIRACY_LEVEL_COLOR[selected.level],
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
{PIRACY_LEVEL_LABEL[selected.level]}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
{selected.name}
</span>
{selected.recentIncidents != null && (
<span style={{
color: PIRACY_LEVEL_COLOR[selected.level],
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
}} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
</span>
)}
</div>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: PIRACY_LEVEL_COLOR[selected.level],
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
{PIRACY_LEVEL_LABEL[selected.level]}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
{selected.name}
</span>
{selected.recentIncidents != null && (
<span style={{
color: PIRACY_LEVEL_COLOR[selected.level],
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
}} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
</span>
)}
</div>
<div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
{selected.description}
</div>
<div className="text-[10px] leading-snug text-[#999]">
{selected.detail}
</div>
<div className="mt-1.5 text-[9px] text-kcg-dim">
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
</div>
</div>
</Popup>
)}
</>
<div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
{selected.description}
</div>
<div className="text-[10px] leading-snug text-[#999]">
{selected.detail}
</div>
<div className="mt-1.5 text-[9px] text-kcg-dim">
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
</div>
</div>
</Popup>
);
}

파일 보기

@ -1,6 +1,4 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { EAST_ASIA_PORTS } from '../../data/ports';
import { Popup } from 'react-map-gl/maplibre';
import type { Port } from '../../data/ports';
const COUNTRY_STYLE: Record<string, { color: string; flag: string; label: string }> = {
@ -15,87 +13,51 @@ function getStyle(p: Port) {
return COUNTRY_STYLE[p.country] || COUNTRY_STYLE.KR;
}
function AnchorIcon({ color, size = 14 }: { color: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="5" r="2.5" stroke={color} strokeWidth="1.5" fill="none" />
<line x1="12" y1="7.5" x2="12" y2="21" stroke={color} strokeWidth="1.5" />
<line x1="7" y1="12" x2="17" y2="12" stroke={color} strokeWidth="1.5" />
<path d="M5 18 Q8 21 12 21 Q16 21 19 18" stroke={color} strokeWidth="1.5" fill="none" />
</svg>
);
interface Props {
selected: Port | null;
onClose: () => void;
}
export function PortLayer() {
const [selected, setSelected] = useState<Port | null>(null);
export function PortLayer({ selected, onClose }: Props) {
if (!selected) return null;
const s = getStyle(selected);
return (
<>
{EAST_ASIA_PORTS.map(p => {
const s = getStyle(p);
const size = p.type === 'major' ? 16 : 12;
return (
<Marker key={p.id} longitude={p.lng} latitude={p.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(p); }}>
<div
className="flex flex-col items-center cursor-pointer"
style={{ filter: `drop-shadow(0 0 2px ${s.color}88)` }}
>
<AnchorIcon color={s.color} size={size} />
<div style={{
fontSize: 5, color: s.color, marginTop: 0,
textShadow: '0 0 3px #000, 0 0 3px #000',
whiteSpace: 'nowrap', fontWeight: 700,
}}>
{p.nameKo.replace('항', '')}
</div>
</div>
</Marker>
);
})}
{selected && (() => {
const s = getStyle(selected);
return (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{ background: s.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{s.flag}</span>
<strong style={{ fontSize: 13 }}> {selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: s.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{selected.type === 'major' ? '주요항만' : '항만'}
</span>
<span style={{
background: '#333', color: '#ccc',
padding: '2px 8px', borderRadius: 3, fontSize: 10,
}}>
{s.label}
</span>
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
<div><span className="popup-label"> : </span><strong>{selected.nameKo}</strong></div>
<div><span className="popup-label"> : </span>{selected.name}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
<a href={`https://www.marinetraffic.com/en/ais/details/ports/${selected.id}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
MarineTraffic &rarr;
</a>
</div>
</div>
</Popup>
);
})()}
</>
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{ background: s.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>{s.flag}</span>
<strong style={{ fontSize: 13 }}> {selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: s.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{selected.type === 'major' ? '주요항만' : '항만'}
</span>
<span style={{
background: '#333', color: '#ccc',
padding: '2px 8px', borderRadius: 3, fontSize: 10,
}}>
{s.label}
</span>
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
<div><span className="popup-label"> : </span><strong>{selected.nameKo}</strong></div>
<div><span className="popup-label"> : </span>{selected.name}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
<a href={`https://www.marinetraffic.com/en/ais/details/ports/${selected.id}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
MarineTraffic &rarr;
</a>
</div>
</div>
</Popup>
);
}

파일 보기

@ -7,6 +7,26 @@ export function SubmarineCableLayer() {
const [selectedCable, setSelectedCable] = useState<SubmarineCable | null>(null);
const [selectedPoint, setSelectedPoint] = useState<{ name: string; lat: number; lng: number; cables: string[] } | null>(null);
// 날짜변경선(180도) 보정: 연속 좌표가 180도를 넘으면 경도를 연속으로 만듦
// 예: [170, lat] → [-170, lat] 를 [170, lat] → [190, lat] 로 변환
function fixDateline(route: number[][]): number[][] {
const fixed: number[][] = [];
for (let i = 0; i < route.length; i++) {
const [lng, lat] = route[i];
if (i === 0) {
fixed.push([lng, lat]);
continue;
}
const prevLng = fixed[i - 1][0];
let newLng = lng;
// 이전 경도와 180도 이상 차이나면 보정
while (newLng - prevLng > 180) newLng -= 360;
while (prevLng - newLng > 180) newLng += 360;
fixed.push([newLng, lat]);
}
return fixed;
}
// Build GeoJSON for all cables
const geojson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
@ -19,7 +39,7 @@ export function SubmarineCableLayer() {
},
geometry: {
type: 'LineString' as const,
coordinates: cable.route,
coordinates: fixDateline(cable.route),
},
})),
};

파일 보기

@ -1,95 +1,60 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { KOREA_WIND_FARMS } from '../../data/windFarms';
import { Popup } from 'react-map-gl/maplibre';
import type { WindFarm } from '../../data/windFarms';
const COLOR = '#00bcd4';
function WindTurbineIcon({ size = 18 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<line x1="12" y1="10" x2="11" y2="23" stroke={COLOR} strokeWidth="1.5" />
<line x1="12" y1="10" x2="13" y2="23" stroke={COLOR} strokeWidth="1.5" />
<circle cx="12" cy="9" r="1.8" fill={COLOR} />
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill={COLOR} opacity="0.9" />
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill={COLOR} opacity="0.9" />
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill={COLOR} opacity="0.9" />
<line x1="8" y1="23" x2="16" y2="23" stroke={COLOR} strokeWidth="1.5" />
</svg>
);
}
const STATUS_COLOR: Record<string, string> = {
'운영중': '#22c55e',
'건설중': '#eab308',
'계획': '#64748b',
};
export function WindFarmLayer() {
const [selected, setSelected] = useState<WindFarm | null>(null);
interface Props {
selected: WindFarm | null;
onClose: () => void;
}
export function WindFarmLayer({ selected, onClose }: Props) {
if (!selected) return null;
return (
<>
{KOREA_WIND_FARMS.map(wf => (
<Marker key={wf.id} longitude={wf.lng} latitude={wf.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(wf); }}>
<div
className="flex flex-col items-center cursor-pointer"
style={{ filter: `drop-shadow(0 0 3px ${COLOR}88)` }}
>
<WindTurbineIcon size={18} />
<div style={{
fontSize: 6, color: COLOR, marginTop: 1,
textShadow: '0 0 3px #000, 0 0 3px #000',
whiteSpace: 'nowrap', fontWeight: 700,
}}>
{wf.name.length > 10 ? wf.name.slice(0, 10) + '..' : wf.name}
</div>
</div>
</Marker>
))}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{ background: COLOR, color: '#000', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>🌀</span>
<strong>{selected.name}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: STATUS_COLOR[selected.status] || '#666', color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{selected.status}
</span>
<span style={{
background: COLOR, color: '#000',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
</span>
<span style={{
background: '#333', color: '#ccc',
padding: '2px 8px', borderRadius: 3, fontSize: 10,
}}>
{selected.region}
</span>
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
<div><span className="popup-label"> : </span><strong>{selected.capacityMW} MW</strong></div>
<div><span className="popup-label"> : </span><strong>{selected.turbines}</strong></div>
{selected.year && <div><span className="popup-label"> : </span><strong>{selected.year}</strong></div>}
<div><span className="popup-label"> : </span>{selected.region}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
)}
</>
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 200 }}>
<div className="popup-header" style={{ background: COLOR, color: '#000', gap: 6, padding: '6px 10px' }}>
<span style={{ fontSize: 16 }}>🌀</span>
<strong>{selected.name}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: STATUS_COLOR[selected.status] || '#666', color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{selected.status}
</span>
<span style={{
background: COLOR, color: '#000',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
</span>
<span style={{
background: '#333', color: '#ccc',
padding: '2px 8px', borderRadius: 3, fontSize: 10,
}}>
{selected.region}
</span>
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
<div><span className="popup-label"> : </span><strong>{selected.capacityMW} MW</strong></div>
<div><span className="popup-label"> : </span><strong>{selected.turbines}</strong></div>
{selected.year && <div><span className="popup-label"> : </span><strong>{selected.year}</strong></div>}
<div><span className="popup-label"> : </span>{selected.region}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
);
}

파일 보기

@ -0,0 +1,22 @@
import { useControl } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer } from '@deck.gl/core';
interface Props {
layers: Layer[];
}
/**
* MapLibre Map deck.gl GPU .
* interleaved 모드: MapLibre deck.gl z-order로 .
*/
export function DeckGLOverlay({ layers }: Props) {
const overlay = useControl<MapboxOverlay>(
() => new MapboxOverlay({
interleaved: true,
getCursor: ({ isHovering }) => isHovering ? 'pointer' : '',
}),
);
overlay.setProps({ layers });
return null;
}

파일 보기

@ -1,10 +1,8 @@
import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { Ship, ShipCategory } from '../../types';
import type { Ship, ShipCategory, VesselAnalysisDto } from '../../types';
import maplibregl from 'maplibre-gl';
import { detectFleet } from '../../utils/fleetDetection';
import type { FleetConnection } from '../../utils/fleetDetection';
interface Props {
ships: Ship[];
@ -13,6 +11,7 @@ interface Props {
hoveredMmsi?: string | null;
focusMmsi?: string | null;
onFocusClear?: () => void;
analysisMap?: Map<string, VesselAnalysisDto>;
}
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
@ -362,7 +361,7 @@ function ensureTriangleImage(map: maplibregl.Map) {
}
// ── Main layer (WebGL symbol rendering — triangles) ──
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear }: Props) {
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap }: Props) {
const { current: map } = useMap();
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [imageReady, setImageReady] = useState(false);
@ -465,35 +464,48 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null;
// 선단 탐지 (중국어선 선택 시 — 성능 최적화: 근처 선박만 전달)
const fleet: FleetConnection | null = useMemo(() => {
if (!selectedShip || selectedShip.flag !== 'CN') return null;
// 0.2도(~12NM) 이내 선박만 필터링하여 전달
const nearby = ships.filter(s =>
Math.abs(s.lat - selectedShip.lat) < 0.2 &&
Math.abs(s.lng - selectedShip.lng) < 0.2
);
return detectFleet(selectedShip, nearby);
}, [selectedShip, ships]);
// Python 분석 결과 기반 선단 그룹 (cluster_id로 그룹핑)
const selectedFleetMembers = useMemo(() => {
if (!selectedMmsi || !analysisMap) return [];
const dto = analysisMap.get(selectedMmsi);
if (!dto) return [];
const clusterId = dto.algorithms.cluster.clusterId;
if (clusterId < 0) return [];
// 선단 연결선 GeoJSON
// 같은 cluster_id를 가진 모든 선박
const members: { ship: Ship; role: string; roleKo: string }[] = [];
for (const [mmsi, d] of analysisMap) {
if (d.algorithms.cluster.clusterId !== clusterId) continue;
const ship = ships.find(s => s.mmsi === mmsi);
if (!ship) continue;
const isLeader = d.algorithms.fleetRole.isLeader;
members.push({
ship,
role: d.algorithms.fleetRole.role,
roleKo: isLeader ? '본선' : '선단원',
});
}
return members;
}, [selectedMmsi, analysisMap, ships]);
// 선단 연결선 GeoJSON — 선택 선박과 같은 cluster 멤버 연결
const fleetLineGeoJson = useMemo(() => {
if (!fleet) return { type: 'FeatureCollection' as const, features: [] };
if (selectedFleetMembers.length < 2) return { type: 'FeatureCollection' as const, features: [] };
// 중심점 계산
const cLat = selectedFleetMembers.reduce((s, m) => s + m.ship.lat, 0) / selectedFleetMembers.length;
const cLng = selectedFleetMembers.reduce((s, m) => s + m.ship.lng, 0) / selectedFleetMembers.length;
return {
type: 'FeatureCollection' as const,
features: fleet.members.map(m => ({
features: selectedFleetMembers.map(m => ({
type: 'Feature' as const,
properties: { role: m.role },
geometry: {
type: 'LineString' as const,
coordinates: [
[fleet.selectedShip.lng, fleet.selectedShip.lat],
[m.ship.lng, m.ship.lat],
],
coordinates: [[cLng, cLat], [m.ship.lng, m.ship.lat]],
},
})),
};
}, [fleet]);
}, [selectedFleetMembers]);
// Carrier labels — only a few, so DOM markers are fine
const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]);
@ -514,16 +526,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
return (
<>
<Source id="ships-source" type="geojson" data={shipGeoJson} promoteId="mmsi">
{/* Hovered ship highlight ring */}
{/* Hovered ship highlight ring — feature-state는 paint에서만 지원 */}
<Layer
id="ships-hover-ring"
type="circle"
filter={['boolean', ['feature-state', 'hovered'], false]}
paint={{
'circle-radius': 18,
'circle-radius': ['case', ['boolean', ['feature-state', 'hovered'], false], 18, 0],
'circle-color': 'rgba(255, 255, 255, 0.1)',
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
'circle-stroke-width': ['case', ['boolean', ['feature-state', 'hovered'], false], 2, 0],
'circle-stroke-opacity': 0.9,
}}
/>
@ -548,7 +559,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
layout={{
'visibility': highlightKorean ? 'visible' : 'none',
'text-field': ['get', 'name'],
'text-size': 9,
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 8, 6, 9, 8, 11, 10, 14, 12, 16, 13, 18, 14, 20],
'text-offset': [0, 2.2],
'text-anchor': 'top',
'text-allow-overlap': false,
@ -566,7 +577,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
type="symbol"
layout={{
'icon-image': 'ship-triangle',
'icon-size': ['get', 'size'],
'icon-size': ['interpolate', ['linear'], ['zoom'],
4, ['*', ['get', 'size'], 0.8],
6, ['*', ['get', 'size'], 1.0],
8, ['*', ['get', 'size'], 1.5],
10, ['*', ['get', 'size'], 2.2],
12, ['*', ['get', 'size'], 2.8],
13, ['*', ['get', 'size'], 3.5],
14, ['*', ['get', 'size'], 4.2],
],
'icon-rotate': ['get', 'heading'],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
@ -598,8 +617,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
</Marker>
))}
{/* Fleet connection lines — 중국어선 클릭 시만 */}
{fleet && fleetLineGeoJson.features.length > 0 && selectedShip?.flag === 'CN' && (
{/* Fleet connection lines — Python cluster 기반, 선박 클릭 시 */}
{selectedFleetMembers.length > 1 && fleetLineGeoJson.features.length > 0 && (
<Source id="fleet-lines" type="geojson" data={fleetLineGeoJson}>
<Layer
id="fleet-line-layer"
@ -614,8 +633,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
</Source>
)}
{/* Fleet member markers — 중국어선 클릭 시만 */}
{fleet && selectedShip?.flag === 'CN' && fleet.members.map(m => (
{/* Fleet member markers — Python cluster 기반 */}
{selectedFleetMembers.length > 1 && selectedFleetMembers.map(m => (
<Marker key={`fleet-${m.ship.mmsi}`} longitude={m.ship.lng} latitude={m.ship.lat} anchor="center">
<div style={{
width: 24, height: 24, borderRadius: '50%',
@ -625,26 +644,27 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
fontSize: 8, color: '#fff', fontWeight: 700,
filter: `drop-shadow(0 0 4px ${FLEET_ROLE_COLORS[m.role] || '#ef4444'})`,
}}>
{m.role === 'pair' ? 'PT' : m.role === 'carrier' ? 'FC' : m.role === 'lighting' ? '灯' : '●'}
{m.role === 'LEADER' ? 'L' : '●'}
</div>
<div style={{
fontSize: 6, color: FLEET_ROLE_COLORS[m.role] || '#888', textAlign: 'center',
textShadow: '0 0 3px #000', fontWeight: 700, marginTop: -2,
}}>
{m.roleKo} {m.distanceNm.toFixed(1)}NM
{m.roleKo}
</div>
</Marker>
))}
{/* Popup for selected ship */}
{selectedShip && (
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} fleet={fleet} />
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} fleetGroup={null} />
)}
</>
);
}
const ShipPopup = memo(function ShipPopup({ ship, onClose, fleet }: { ship: Ship; onClose: () => void; fleet?: FleetConnection | null }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ShipPopup = memo(function ShipPopup({ ship, onClose, fleetGroup }: { ship: Ship; onClose: () => void; fleetGroup?: any }) {
const { t } = useTranslation('ships');
const mtType = getMTType(ship);
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
@ -808,21 +828,20 @@ const ShipPopup = memo(function ShipPopup({ ship, onClose, fleet }: { ship: Ship
</div>
)}
{/* Fleet info (중국어선만) */}
{fleet && fleet.members.length > 0 && (
{/* Fleet info (선단 그룹 소속 시) */}
{fleetGroup && fleetGroup.members.length > 0 && (
<div style={{ borderTop: '1px solid #333', marginTop: 6, paddingTop: 6 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#ef4444', marginBottom: 4 }}>
🔗 {fleet.fleetTypeKo} {fleet.members.length}
🔗 {fleetGroup.fleetTypeKo} {fleetGroup.members.length}
</div>
{fleet.members.slice(0, 5).map(m => (
{fleetGroup.members.slice(0, 5).map(m => (
<div key={m.ship.mmsi} style={{ fontSize: 9, display: 'flex', gap: 4, padding: '2px 0', color: '#ccc' }}>
<span style={{ color: '#ef4444', fontWeight: 700, minWidth: 55 }}>{m.roleKo}</span>
<span style={{ flex: 1 }}>{m.ship.name || m.ship.mmsi}</span>
<span style={{ color: '#f97316' }}>{m.distanceNm.toFixed(1)}NM</span>
</div>
))}
{fleet.members.length > 5 && (
<div style={{ fontSize: 8, color: '#666' }}>... {fleet.members.length - 5}</div>
{fleetGroup.members.length > 5 && (
<div style={{ fontSize: 8, color: '#666' }}>... {fleetGroup.members.length - 5}</div>
)}
</div>
)}

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,37 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "id": "ZONE_I", "zone": "I", "name": "특정어업수역 I (동해)" },
"geometry": {
"type": "Polygon",
"coordinates": [[[128.86, 35.65], [131.67, 35.65], [131.67, 38.25], [128.86, 38.25], [128.86, 35.65]]]
}
},
{
"type": "Feature",
"properties": { "id": "ZONE_II", "zone": "II", "name": "특정어업수역 II (남해 제주남방)" },
"geometry": {
"type": "Polygon",
"coordinates": [[[126.00, 32.18], [128.89, 32.18], [128.89, 34.34], [126.00, 34.34], [126.00, 32.18]]]
}
},
{
"type": "Feature",
"properties": { "id": "ZONE_III", "zone": "III", "name": "특정어업수역 III (서남해 이어도)" },
"geometry": {
"type": "Polygon",
"coordinates": [[[124.01, 32.18], [126.08, 32.18], [126.08, 35.00], [124.01, 35.00], [124.01, 32.18]]]
}
},
{
"type": "Feature",
"properties": { "id": "ZONE_IV", "zone": "IV", "name": "특정어업수역 IV (서해 중간수역)" },
"geometry": {
"type": "Polygon",
"coordinates": [[[124.13, 35.00], [125.85, 35.00], [125.85, 37.00], [124.13, 37.00], [124.13, 35.00]]]
}
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

@ -0,0 +1 @@
{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed4", "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, "features": [{"type": "Feature", "properties": {"fid": 0, "GML_ID": null, "OBJECTID": null, "ZONE_NM": null, "MNCT_NO": null, "MNCT_SCALE": null, "MNCT_NM": null, "RELREGLTN": null, "RELGOAG": null, "REVIYR": null, "ZONE_DESC": null, "PHOTO1_PAT": null, "ID": -2147483647, "CATE_CD": null, "ADR_CD": null, "ADR_KNM": null, "ORIGIN": null, "ORIYR": null, "ORIORG": null, "NAME": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed\u2163", "WARD_NM": null, "WARD_ID": null, "GISID": null, "FID_2": null, "NAME_2": null, "FID_3": null, "NAME_3": null, "GID": null, "NAME_4": null, "FID_4": null, "NAME_5": null, "FID_5": null, "NAME_6": null}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[13859276.603817873, 4232038.462456921], [13859276.603762543, 4321218.244482412], [13859276.603710985, 4404317.064005076], [13840719.645028654, 4439106.786523586], [13884632.712472571, 4439106.787250583], [13884632.712472571, 4439504.084564682], [13940418.269436067, 4439504.375880923], [13969123.924724836, 4439504.525783945], [13968718.329494288, 4438626.439593866], [13962623.599395147, 4425543.915710401], [13960437.31344761, 4420657.3891166765], [13958238.813611617, 4416093.569832627], [13958143.094601436, 4415900.994484875], [13958143.094601437, 4415900.994484875], [13957298.344237303, 4414201.456484755], [13953878.455604602, 4406316.186534493], [13949652.450365951, 4397019.979821594], [13948553.200448176, 4393395.13065616], [13947612.731073817, 4389132.176741289], [13947612.731072996, 4387549.226905922], [13947466.164417507, 4385829.556682826], [13947783.725505754, 4381721.729468383], [13948260.06713652, 4379835.70012994], [13949359.317054221, 4375897.403884492], [13951093.689146286, 4371808.582233328], [13954867.780530114, 4365670.678186072], [13964809.885341855, 4351190.629491161], [13978342.873219142, 4331838.456925102], [13980382.592510404, 4329007.496874151], [13981728.043604897, 4327079.749205159], [13985775.34591557, 4321280.81855131], [13997066.763484716, 4305102.598482491], [13999424.043863578, 4300225.286038025], [14003039.354703771, 4290447.064438686], [14005091.287883686, 4284626.561498255], [14006520.312777169, 4279426.932176922], [14007631.77658257, 4275178.643476352], [14008242.470981453, 4271549.325573796], [14009378.362562515, 4262248.123573576], [14009427.990871342, 4261704.85208626], [14009708.137538105, 4258638.140769343], [14009854.704193696, 4257224.555715567], [14009378.362562606, 4254698.603440943], [14005347.779531531, 4240996.452433007], [14002367.590864772, 4231511.1380338315], [14001280.554835469, 4227266.412716273], [14000486.652116666, 4225212.134400094], [13998047.81589918, 4222926.459154359], [13991387.305576058, 4216684.234498038], [13970721.407121927, 4197120.494488488], [13958654.085803084, 4185745.4565721145], [13956602.15262321, 4184012.5742896623], [13944065.033685392, 4171984.566055202], [13940467.606607554, 4168533.224265296], [13935619.01320107, 4163881.1438622964], [13935718.55954324, 4163976.6556012244], [13817590.293393573, 4163976.6556012244], [13859276.603817873, 4232038.462456921]]]]}}]}

파일 보기

@ -0,0 +1,187 @@
import { useMemo } from 'react';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import type { Layer } from '@deck.gl/core';
import type { Ship, VesselAnalysisDto } from '../types';
interface AnalyzedShip {
ship: Ship;
dto: VesselAnalysisDto;
}
// RISK_RGBA: [r, g, b, a] 충전색
const RISK_RGBA: Record<string, [number, number, number, number]> = {
CRITICAL: [239, 68, 68, 60],
HIGH: [249, 115, 22, 50],
MEDIUM: [234, 179, 8, 40],
};
// 테두리색
const RISK_RGBA_BORDER: Record<string, [number, number, number, number]> = {
CRITICAL: [239, 68, 68, 230],
HIGH: [249, 115, 22, 210],
MEDIUM: [234, 179, 8, 190],
};
// 픽셀 반경
const RISK_SIZE: Record<string, number> = {
CRITICAL: 18,
HIGH: 14,
MEDIUM: 12,
};
const RISK_LABEL: Record<string, string> = {
CRITICAL: '긴급',
HIGH: '경고',
MEDIUM: '주의',
};
const RISK_PRIORITY: Record<string, number> = {
CRITICAL: 0,
HIGH: 1,
MEDIUM: 2,
};
/**
* deck.gl .
* AnalysisOverlay DOM Marker GPU .
*/
export function useAnalysisDeckLayers(
analysisMap: Map<string, VesselAnalysisDto>,
ships: Ship[],
activeFilter: string | null,
sizeScale: number = 1.0,
): Layer[] {
return useMemo(() => {
if (analysisMap.size === 0) return [];
const analyzedShips: AnalyzedShip[] = ships
.filter(s => analysisMap.has(s.mmsi))
.map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! }));
const riskData = analyzedShips
.filter(({ dto }) => {
const level = dto.algorithms.riskScore.level;
return level === 'CRITICAL' || level === 'HIGH' || level === 'MEDIUM';
})
.sort((a, b) => {
const pa = RISK_PRIORITY[a.dto.algorithms.riskScore.level] ?? 99;
const pb = RISK_PRIORITY[b.dto.algorithms.riskScore.level] ?? 99;
return pa - pb;
})
.slice(0, 100);
const layers: Layer[] = [];
// 위험도 원형 마커
layers.push(
new ScatterplotLayer<AnalyzedShip>({
id: 'risk-markers',
data: riskData,
getPosition: (d) => [d.ship.lng, d.ship.lat],
getRadius: (d) => (RISK_SIZE[d.dto.algorithms.riskScore.level] ?? 12) * sizeScale,
getFillColor: (d) => RISK_RGBA[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 40],
getLineColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 200],
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 2,
}),
);
// 위험도 라벨 (선박명 + 위험도 등급)
layers.push(
new TextLayer<AnalyzedShip>({
id: 'risk-labels',
data: riskData,
getPosition: (d) => [d.ship.lng, d.ship.lat],
getText: (d) => {
const label = RISK_LABEL[d.dto.algorithms.riskScore.level] ?? d.dto.algorithms.riskScore.level;
const name = d.ship.name || d.ship.mmsi;
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
},
getSize: 10 * sizeScale,
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 16],
fontFamily: 'monospace',
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
);
// 다크베셀 (activeFilter === 'darkVessel' 일 때만)
if (activeFilter === 'darkVessel') {
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
if (darkData.length > 0) {
layers.push(
new ScatterplotLayer<AnalyzedShip>({
id: 'dark-vessel-markers',
data: darkData,
getPosition: (d) => [d.ship.lng, d.ship.lat],
getRadius: 12 * sizeScale,
getFillColor: [168, 85, 247, 40],
getLineColor: [168, 85, 247, 200],
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 2,
}),
);
// 다크베셀 gap 라벨
layers.push(
new TextLayer<AnalyzedShip>({
id: 'dark-vessel-labels',
data: darkData,
getPosition: (d) => [d.ship.lng, d.ship.lat],
getText: (d) => {
const gap = d.dto.algorithms.darkVessel.gapDurationMin;
return gap > 0 ? `AIS 소실 ${Math.round(gap)}` : 'DARK';
},
getSize: 10 * sizeScale,
getColor: [168, 85, 247, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 14],
fontFamily: 'monospace',
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
);
}
}
// GPS 스푸핑 라벨
const spoofData = analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5);
if (spoofData.length > 0) {
layers.push(
new TextLayer<AnalyzedShip>({
id: 'spoof-labels',
data: spoofData,
getPosition: (d) => [d.ship.lng, d.ship.lat],
getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`,
getSize: 10 * sizeScale,
getColor: [239, 68, 68, 255],
getTextAnchor: 'start',
getPixelOffset: [12, -8],
fontFamily: 'monospace',
fontWeight: 700,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
);
}
return layers;
}, [analysisMap, ships, activeFilter, sizeScale]);
}

파일 보기

@ -1,7 +1,9 @@
import { useState, useMemo, useRef } from 'react';
import { useLocalStorage } from './useLocalStorage';
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
import { getMarineTrafficCategory } from '../utils/marineTraffic';
import type { Ship } from '../types';
import { classifyFishingZone } from '../utils/fishingAnalysis';
import type { Ship, VesselAnalysisDto } from '../types';
interface KoreaFilters {
illegalFishing: boolean;
@ -41,8 +43,10 @@ export function useKoreaFilters(
koreaShips: Ship[],
visibleShips: Ship[],
currentTime: number,
analysisMap?: Map<string, VesselAnalysisDto>,
cnFishingOn = false,
): UseKoreaFiltersResult {
const [filters, setFilters] = useState<KoreaFilters>({
const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', {
illegalFishing: false,
illegalTransship: false,
darkVessel: false,
@ -67,7 +71,8 @@ export function useKoreaFilters(
filters.darkVessel ||
filters.cableWatch ||
filters.dokdoWatch ||
filters.ferryWatch;
filters.ferryWatch ||
cnFishingOn;
// 불법환적 의심 선박 탐지
const transshipSuspects = useMemo(() => {
@ -190,8 +195,17 @@ export function useKoreaFilters(
}
}
// Python 분류 결과 합집합: is_dark=true인 mmsi 추가
if (analysisMap) {
for (const [mmsi, dto] of analysisMap.entries()) {
if (dto.algorithms.darkVessel.isDark) {
result.add(mmsi);
}
}
}
return result;
}, [koreaShips, filters.darkVessel, currentTime]);
}, [koreaShips, filters.darkVessel, currentTime, analysisMap]);
// 해저케이블 감시
const cableWatchSet = useMemo(() => {
@ -297,15 +311,32 @@ export function useKoreaFilters(
if (!anyFilterOn) return visibleShips;
return visibleShips.filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (filters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
if (filters.illegalFishing) {
// 특정어업수역 ~Ⅳ 내 비한국 어선만 불법어선으로 판별
if (mtCat === 'fishing' && s.flag !== 'KR') {
const zoneInfo = classifyFishingZone(s.lat, s.lng);
if (zoneInfo.zone !== 'OUTSIDE') return true;
}
// Python 분석: 영해/접속수역 침범
const analysis = analysisMap?.get(s.mmsi);
if (analysis) {
const zone = analysis.algorithms.location.zone;
if (zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE') return true;
}
}
if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true;
if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true;
if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
if (cnFishingOn) {
const isCnFishing = s.flag === 'CN' && getMarineTrafficCategory(s.typecode, s.category) === 'fishing';
const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || '');
if (isCnFishing || isGearPattern) return true;
}
return false;
});
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingOn]);
return {
filters,

파일 보기

@ -0,0 +1,68 @@
import { useState, useCallback } from 'react';
const PREFIX = 'kcg.';
/**
* localStorage useState JSON / .
* Record defaults와 .
*/
export function useLocalStorage<T>(key: string, defaults: T): [T, (v: T | ((prev: T) => T)) => void] {
const storageKey = PREFIX + key;
const [value, setValueRaw] = useState<T>(() => {
try {
const raw = localStorage.getItem(storageKey);
if (raw === null) return defaults;
const parsed = JSON.parse(raw) as T;
// Record 타입이면 defaults에 있는 키가 저장값에 없을 때 머지
if (defaults !== null && typeof defaults === 'object' && !Array.isArray(defaults)) {
return { ...defaults, ...parsed };
}
return parsed;
} catch {
return defaults;
}
});
const setValue = useCallback((updater: T | ((prev: T) => T)) => {
setValueRaw(prev => {
const next = typeof updater === 'function' ? (updater as (prev: T) => T)(prev) : updater;
try {
localStorage.setItem(storageKey, JSON.stringify(next));
} catch { /* quota exceeded — 무시 */ }
return next;
});
}, [storageKey]);
return [value, setValue];
}
/**
* Set<string> localStorage Array로 .
*/
export function useLocalStorageSet(key: string, defaults: Set<string>): [Set<string>, (v: Set<string> | ((prev: Set<string>) => Set<string>)) => void] {
const storageKey = PREFIX + key;
const [value, setValueRaw] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem(storageKey);
if (raw === null) return defaults;
const arr = JSON.parse(raw);
return Array.isArray(arr) ? new Set(arr) : defaults;
} catch {
return defaults;
}
});
const setValue = useCallback((updater: Set<string> | ((prev: Set<string>) => Set<string>)) => {
setValueRaw(prev => {
const next = typeof updater === 'function' ? updater(prev) : updater;
try {
localStorage.setItem(storageKey, JSON.stringify(Array.from(next)));
} catch { /* quota exceeded */ }
return next;
});
}, [storageKey]);
return [value, setValue];
}

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

파일 보기

@ -0,0 +1,114 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import type { VesselAnalysisDto, RiskLevel } from '../types';
import { fetchVesselAnalysis } from '../services/vesselAnalysis';
const POLL_INTERVAL_MS = 5 * 60_000; // 5분
const STALE_MS = 30 * 60_000; // 30분
export interface AnalysisStats {
total: number;
critical: number;
high: number;
medium: number;
low: number;
dark: number;
spoofing: number;
clusterCount: number;
}
export interface UseVesselAnalysisResult {
analysisMap: Map<string, VesselAnalysisDto>;
stats: AnalysisStats;
clusters: Map<number, string[]>;
isLoading: boolean;
lastUpdated: number;
}
const EMPTY_STATS: AnalysisStats = {
total: 0, critical: 0, high: 0, medium: 0, low: 0,
dark: 0, spoofing: 0, clusterCount: 0,
};
export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult {
const mapRef = useRef<Map<string, VesselAnalysisDto>>(new Map());
const [version, setVersion] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [lastUpdated, setLastUpdated] = useState(0);
const doFetch = useCallback(async () => {
if (!enabled) return;
setIsLoading(true);
try {
const items = await fetchVesselAnalysis();
const now = Date.now();
const map = mapRef.current;
// stale 제거
for (const [mmsi, dto] of map) {
const ts = new Date(dto.timestamp).getTime();
if (now - ts > STALE_MS) map.delete(mmsi);
}
// 새 결과 merge
for (const item of items) {
map.set(item.mmsi, item);
}
setLastUpdated(now);
setVersion(v => v + 1);
} catch {
// 에러 시 기존 데이터 유지 (graceful degradation)
} finally {
setIsLoading(false);
}
}, [enabled]);
useEffect(() => {
doFetch();
const t = setInterval(doFetch, POLL_INTERVAL_MS);
return () => clearInterval(t);
}, [doFetch]);
const analysisMap = mapRef.current;
const stats = useMemo((): AnalysisStats => {
if (analysisMap.size === 0) return EMPTY_STATS;
let critical = 0, high = 0, medium = 0, low = 0, dark = 0, spoofing = 0;
const clusterIds = new Set<number>();
for (const dto of analysisMap.values()) {
const level: RiskLevel = dto.algorithms.riskScore.level;
if (level === 'CRITICAL') critical++;
else if (level === 'HIGH') high++;
else if (level === 'MEDIUM') medium++;
else low++;
if (dto.algorithms.darkVessel.isDark) dark++;
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) spoofing++;
if (dto.algorithms.cluster.clusterId >= 0) {
clusterIds.add(dto.algorithms.cluster.clusterId);
}
}
return {
total: analysisMap.size, critical, high, medium, low,
dark, spoofing, clusterCount: clusterIds.size,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version]);
const clusters = useMemo((): Map<number, string[]> => {
const result = new Map<number, string[]>();
for (const [mmsi, dto] of analysisMap) {
const cid = dto.algorithms.cluster.clusterId;
if (cid < 0) continue;
const arr = result.get(cid);
if (arr) arr.push(mmsi);
else result.set(cid, [mmsi]);
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version]);
return { analysisMap, stats, clusters, isLoading, lastUpdated };
}

파일 보기

@ -0,0 +1,30 @@
import type { VesselAnalysisDto } from '../types';
const API_BASE = '/api/kcg';
export async function fetchVesselAnalysis(): Promise<VesselAnalysisDto[]> {
const res = await fetch(`${API_BASE}/vessel-analysis`, {
headers: { accept: 'application/json' },
});
if (!res.ok) return [];
const data: { count: number; items: VesselAnalysisDto[] } = await res.json();
return data.items ?? [];
}
export interface FleetCompany {
id: number;
nameCn: string;
nameEn: string;
}
// 캐시 (세션 중 1회 로드)
let companyCache: Map<number, FleetCompany> | null = null;
export async function fetchFleetCompanies(): Promise<Map<number, FleetCompany>> {
if (companyCache) return companyCache;
const res = await fetch(`${API_BASE}/fleet-companies`);
if (!res.ok) return new Map();
const items: FleetCompany[] = await res.json();
companyCache = new Map(items.map(c => [c.id, c]));
return companyCache;
}

파일 보기

@ -0,0 +1,39 @@
const SIGNAL_BATCH_BASE = '/signal-batch';
interface TrackResponse {
vesselId: string;
geometry: [number, number][];
speeds: number[];
timestamps: string[];
pointCount: number;
totalDistance: number;
shipName: string;
}
// mmsi별 캐시 (TTL 5분)
const trackCache = new Map<string, { time: number; coords: [number, number][] }>();
const CACHE_TTL = 5 * 60_000;
export async function fetchVesselTrack(mmsi: string, hours: number = 6): Promise<[number, number][]> {
const cached = trackCache.get(mmsi);
if (cached && Date.now() - cached.time < CACHE_TTL) return cached.coords;
const endTime = new Date().toISOString();
const startTime = new Date(Date.now() - hours * 3600_000).toISOString();
try {
const res = await fetch(`${SIGNAL_BATCH_BASE}/api/v2/tracks/vessels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ startTime, endTime, vessels: [mmsi] }),
});
if (!res.ok) return [];
const data: TrackResponse[] = await res.json();
if (!data.length || !data[0].geometry?.length) return [];
const coords = data[0].geometry;
trackCache.set(mmsi, { time: Date.now(), coords });
return coords;
} catch {
return [];
}
}

파일 보기

@ -145,7 +145,8 @@ export interface LayerVisibility {
meFacilities: boolean;
militaryOnly: boolean;
overseasUS: boolean;
overseasUK: boolean;
overseasIsrael: boolean;
[key: string]: boolean;
overseasIran: boolean;
overseasUAE: boolean;
overseasSaudi: boolean;
@ -158,59 +159,30 @@ export interface LayerVisibility {
export type AppMode = 'replay' | 'live';
// ── 중국어선 분석 결과 (Python 분류기 → REST API → Frontend) ──
// Vessel analysis (Python prediction 결과)
export type VesselType = 'TRAWL' | 'PURSE' | 'LONGLINE' | 'TRAP' | 'UNKNOWN';
export type RiskLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
export type ActivityState = 'STATIONARY' | 'FISHING' | 'SAILING' | 'UNKNOWN';
export type FleetRole = 'LEADER' | 'MEMBER' | 'NOISE';
export type VesselType = 'TRAWL' | 'PURSE' | 'LONGLINE' | 'TRAP';
export type RiskLevel = 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL';
export type ActivityState = 'FISHING' | 'SAILING' | 'STATIONARY' | 'AIS_LOSS';
export type ZoneType = 'TERRITORIAL' | 'CONTIGUOUS' | 'EEZ' | 'BEYOND';
export type FleetRole = 'MOTHER' | 'SUB' | 'TRANSPORT' | 'INDEPENDENT';
export interface VesselClassification {
vesselType: VesselType;
confidence: number; // 0~1
fishingPct: number; // 조업 비율 %
clusterId: number; // BIRCH 군집 ID (-1=노이즈)
season: string; // SPRING/SUMMER/FALL/WINTER
}
export interface VesselAlgorithms {
location: { zone: ZoneType; distToBaselineNm: number };
activity: { state: ActivityState; ucafScore: number; ucftScore: number };
darkVessel: { isDark: boolean; gapDurationMin: number };
gpsSpoofing: { spoofingScore: number; bd09OffsetM: number; speedJumpCount: number };
cluster: { clusterId: number; clusterSize: number; centroid?: [number, number] };
fleetRole: { isLeader: boolean; role: FleetRole };
riskScore: { score: number; level: RiskLevel };
}
export interface VesselAnalysisResult {
export interface VesselAnalysisDto {
mmsi: string;
timestamp: string; // ISO 분석 시점
classification: VesselClassification;
algorithms: VesselAlgorithms;
timestamp: string;
classification: {
vesselType: VesselType;
confidence: number;
fishingPct: number;
clusterId: number;
season: string;
};
algorithms: {
location: { zone: string; distToBaselineNm: number };
activity: { state: ActivityState; ucafScore: number; ucftScore: number };
darkVessel: { isDark: boolean; gapDurationMin: number };
gpsSpoofing: { spoofingScore: number; bd09OffsetM: number; speedJumpCount: number };
cluster: { clusterId: number; clusterSize: number };
fleetRole: { isLeader: boolean; role: FleetRole };
riskScore: { score: number; level: RiskLevel };
};
features: Record<string, number>;
}
// 허가어선 정보 (signal-batch /api/v2/vessels/chnprmship)
export interface ChnPrmShipInfo {
mmsi: string;
imo: number;
name: string;
callsign: string;
vesselType: string;
lat: number;
lon: number;
sog: number;
cog: number;
heading: number;
length: number;
width: number;
draught: number;
destination: string;
status: string;
signalKindCode: string;
messageTimestamp: string;
shipImagePath?: string | null;
shipImageCount?: number;
}

파일 보기

@ -2,6 +2,11 @@
// 한중어업협정 허가현황 (2026.01.06, 906척) + GB/T 5147-2003 어구 분류
import type { Ship } from '../types';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { point } from '@turf/helpers';
import type { Feature, MultiPolygon } from 'geojson';
import fishingZonesWgs84 from '../data/zones/fishing-zones-wgs84.json';
/**
* ( )
@ -42,16 +47,45 @@ const GEAR_META: Record<FishingGearType, {
export { GEAR_META as GEAR_LABELS };
/**
* ()
*/
const _FISHING_ZONES = {
I: { name: '수역Ⅰ(동해)', lngMin: 128.86, lngMax: 131.67, latMin: 35.65, latMax: 38.25, allowed: ['PS', 'FC'] },
II: { name: '수역Ⅱ(남해)', lngMin: 126.00, lngMax: 128.89, latMin: 32.18, latMax: 34.34, allowed: ['PT', 'OT', 'GN', 'PS', 'FC'] },
III:{ name: '수역Ⅲ(서남해)', lngMin: 124.01, lngMax: 126.08, latMin: 32.18, latMax: 35.00, allowed: ['PT', 'OT', 'GN', 'PS', 'FC'] },
IV: { name: '수역Ⅳ(서해)', lngMin: 124.13, lngMax: 125.85, latMin: 35.00, latMax: 37.00, allowed: ['GN', 'PS', 'FC'] },
export type FishingZoneId = 'ZONE_I' | 'ZONE_II' | 'ZONE_III' | 'ZONE_IV' | 'OUTSIDE';
export interface FishingZoneInfo {
zone: FishingZoneId;
name: string;
allowed: string[];
}
/** 수역별 허가 업종 */
const ZONE_ALLOWED: Record<string, string[]> = {
ZONE_I: ['PS', 'FC'],
ZONE_II: ['PT', 'OT', 'GN', 'PS', 'FC'],
ZONE_III: ['PT', 'OT', 'GN', 'PS', 'FC'],
ZONE_IV: ['GN', 'PS', 'FC'],
};
/**
* ~ ( WGS84 GeoJSON)
*/
export const ZONE_POLYGONS = fishingZonesWgs84.features.map(f => ({
id: f.properties.id as FishingZoneId,
name: f.properties.name,
allowed: ZONE_ALLOWED[f.properties.id] ?? [],
geojson: f as unknown as Feature<MultiPolygon>,
}));
/**
*
*/
export function classifyFishingZone(lat: number, lng: number): FishingZoneInfo {
const pt = point([lng, lat]);
for (const z of ZONE_POLYGONS) {
if (booleanPointInPolygon(pt, z.geojson)) {
return { zone: z.id, name: z.name, allowed: z.allowed };
}
}
return { zone: 'OUTSIDE', name: '수역 외', allowed: [] };
}
/**
* (/)
*/

파일 보기

@ -20,6 +20,22 @@ export interface FleetConnection {
fleetTypeKo: string;
}
// ── 사전 그룹핑 인터페이스 ──
export interface FleetGroupMember {
ship: Ship;
role: FleetRole;
roleKo: string;
}
export interface FleetGroup {
groupId: number;
fleetType: 'trawl_pair' | 'purse_seine_fleet' | 'transship' | 'cluster';
fleetTypeKo: string;
members: FleetGroupMember[];
center: { lat: number; lng: number };
}
/** 두 지점 사이 거리(NM) */
function distNm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 3440.065; // 지구 반경 (해리)
@ -29,8 +45,167 @@ function distNm(lat1: number, lng1: number, lat2: number, lng2: number): number
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/** 선박이 중국 어선 후보인지 판별 */
function isCnFishingCandidate(ship: Ship): boolean {
if (ship.flag !== 'CN') return false;
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
return cat === 'fishing' || cat === 'unspecified';
}
/** 그룹 내 역할 결정 */
function assignRoles(members: Ship[]): FleetGroupMember[] {
if (members.length === 0) return [];
// 운반선 후보: 이름에 냉동운반선 관련 한자 포함 또는 cargo 카테고리
const isCarrierCandidate = (s: Ship): boolean => {
const cat = getMarineTrafficCategory(s.typecode, s.category);
return cat === 'cargo' || s.name.includes('运') || s.name.includes('冷');
};
// 최대 속도 선박 → mothership
const maxSpeed = Math.max(...members.map(s => s.speed));
const motherIdx = members.findIndex(s => s.speed === maxSpeed);
return members.map((ship, idx) => {
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
if (isCarrierCandidate(ship) && ship.speed <= 2) {
return { ship, role: 'carrier' as FleetRole, roleKo: '운반선 (FC)' };
}
if (ship.speed < 1 && (cat === 'fishing' || cat === 'unspecified')) {
return { ship, role: 'lighting' as FleetRole, roleKo: '조명선' };
}
if (idx === motherIdx && members.length > 1) {
return { ship, role: 'mothership' as FleetRole, roleKo: '모선' };
}
return { ship, role: 'subsidiary' as FleetRole, roleKo: '선단 멤버' };
});
}
/** 그룹 유형 판별 */
function classifyGroup(members: Ship[]): { fleetType: FleetGroup['fleetType']; fleetTypeKo: string } {
if (members.length === 2) {
const [a, b] = members;
const speedDiff = Math.abs(a.speed - b.speed);
let headingDiff = Math.abs(a.heading - b.heading);
if (headingDiff > 180) headingDiff = 360 - headingDiff;
if (
speedDiff < 1 && headingDiff < 20 &&
a.speed >= 2 && a.speed <= 5 &&
b.speed >= 2 && b.speed <= 5
) {
return { fleetType: 'trawl_pair', fleetTypeKo: '2척식 저인망 (본선·부속선)' };
}
}
const hasCarrier = members.some(s => {
const cat = getMarineTrafficCategory(s.typecode, s.category);
return (cat === 'cargo' || s.name.includes('运') || s.name.includes('冷')) && s.speed <= 2;
});
if (hasCarrier) {
return { fleetType: 'transship', fleetTypeKo: '환적 의심 (운반선 접근)' };
}
const hasLighting = members.some(s => s.speed < 1);
if (members.length >= 3 || hasLighting) {
return { fleetType: 'purse_seine_fleet', fleetTypeKo: '위망 선단 (모선·운반·조명)' };
}
return { fleetType: 'cluster', fleetTypeKo: '인근 어선 클러스터' };
}
/**
*
* .
* mmsigroupId .
*
* 알고리즘: 3NM BFS flood fill (O(N²), N>1000 0.3 )
*/
export function buildFleetGroups(ships: Ship[]): {
groups: FleetGroup[];
memberMap: Map<string, number>;
} {
// 중국 어선 후보만 추출
const candidates = ships.filter(isCnFishingCandidate);
// N > 1000이면 0.3도 박스로 사전필터링하여 인접 목록 구성
const USE_BBOX_FILTER = candidates.length > 1000;
const CLUSTER_NM = 3;
const BBOX_DEG = 0.3; // ~18NM
// 미할당 인덱스 집합
const unassigned = new Set<number>(candidates.map((_, i) => i));
const groups: FleetGroup[] = [];
const memberMap = new Map<string, number>();
let groupId = 0;
for (let i = 0; i < candidates.length; i++) {
if (!unassigned.has(i)) continue;
// BFS
const cluster: number[] = [];
const queue: number[] = [i];
unassigned.delete(i);
while (queue.length > 0) {
const cur = queue.shift()!;
cluster.push(cur);
const curShip = candidates[cur];
// 탐색 후보: bbox 필터 또는 전체
const searchPool = USE_BBOX_FILTER
? candidates.reduce<number[]>((acc, s, idx) => {
if (
unassigned.has(idx) &&
Math.abs(s.lat - curShip.lat) < BBOX_DEG &&
Math.abs(s.lng - curShip.lng) < BBOX_DEG
) {
acc.push(idx);
}
return acc;
}, [])
: Array.from(unassigned);
for (const j of searchPool) {
if (!unassigned.has(j)) continue;
const neighbor = candidates[j];
if (distNm(curShip.lat, curShip.lng, neighbor.lat, neighbor.lng) <= CLUSTER_NM) {
unassigned.delete(j);
queue.push(j);
}
}
}
// 2척 미만은 그룹 해제
if (cluster.length < 2) continue;
const memberShips = cluster.map(idx => candidates[idx]);
const { fleetType, fleetTypeKo } = classifyGroup(memberShips);
const memberRoles = assignRoles(memberShips);
// 중심점 계산
const centerLat = memberShips.reduce((sum, s) => sum + s.lat, 0) / memberShips.length;
const centerLng = memberShips.reduce((sum, s) => sum + s.lng, 0) / memberShips.length;
const group: FleetGroup = {
groupId,
fleetType,
fleetTypeKo,
members: memberRoles,
center: { lat: centerLat, lng: centerLng },
};
groups.push(group);
memberShips.forEach(s => memberMap.set(s.mmsi, groupId));
groupId++;
}
return { groups, memberMap };
}
/**
* ( 1 fallback용 )
*
* :
* - PT 2 저인망: 본선+ 3NM , (2~5kn),

파일 보기

@ -0,0 +1,3 @@
export function svgToDataUri(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}

파일 보기

@ -110,6 +110,11 @@ export default defineConfig(({ mode }): UserConfig => ({
changeOrigin: true,
secure: false,
},
'/ollama': {
target: 'http://localhost:11434',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ollama/, ''),
},
},
},
}))

파일 보기

@ -1,152 +1,177 @@
import math
"""선단(Fleet) 패턴 탐지 — 공간+행동 기반.
단순 공간 근접이 아닌, 협조 운항 패턴(유사 속도/방향/역할)으로 선단을 판별.
- PT 저인망: 2, 3NM 이내, 유사 속도(2~5kn) + 유사 방향(20° 이내)
- PS 선망: 3~5, 2NM 이내, 모선(고속)+조명선(정지)+운반선(저속 대형)
- FC 환적: 2, 0.5NM 이내, 양쪽 저속(2kn 이하)
"""
import logging
from typing import Optional
import numpy as np
import pandas as pd
from algorithms.location import haversine_nm, dist_to_baseline, EARTH_RADIUS_NM
from algorithms.location import haversine_nm, dist_to_baseline
logger = logging.getLogger(__name__)
def detect_group_clusters(
vessel_snapshots: list[dict],
spatial_eps_nm: float = 10.0,
time_eps_hours: float = 2.0,
min_vessels: int = 3,
def _heading_diff(h1: float, h2: float) -> float:
"""두 방향 사이 최소 각도차 (0~180)."""
d = abs(h1 - h2) % 360
return d if d <= 180 else 360 - d
def detect_fleet_patterns(
vessel_dfs: dict[str, pd.DataFrame],
) -> dict[int, list[dict]]:
"""DBSCAN 시공간 클러스터링으로 집단 탐지."""
if len(vessel_snapshots) < min_vessels:
return {}
"""행동 패턴 기반 선단 탐지.
try:
from sklearn.cluster import DBSCAN
except ImportError:
logger.warning('sklearn not available for DBSCAN clustering')
return {}
lat_rad = [math.radians(v['lat']) * EARTH_RADIUS_NM for v in vessel_snapshots]
lon_rad = [math.radians(v['lon']) * EARTH_RADIUS_NM for v in vessel_snapshots]
# 시간을 NM 단위로 정규화
timestamps = [pd.Timestamp(v['timestamp']).timestamp() for v in vessel_snapshots]
t_min = min(timestamps)
time_nm = [(t - t_min) / 3600 * 10 / time_eps_hours for t in timestamps]
X = np.array(list(zip(lat_rad, lon_rad, time_nm)))
db = DBSCAN(eps=spatial_eps_nm, min_samples=min_vessels, metric='euclidean').fit(X)
clusters: dict[int, list[dict]] = {}
for idx, label in enumerate(db.labels_):
if label == -1:
Returns: {fleet_id: [{mmsi, lat, lon, sog, cog, role, pattern}, ...]}
"""
# 각 선박의 최신 스냅샷 추출
snapshots: list[dict] = []
for mmsi, df in vessel_dfs.items():
if df is None or len(df) == 0:
continue
clusters.setdefault(int(label), []).append(vessel_snapshots[idx])
last = df.iloc[-1]
snapshots.append({
'mmsi': mmsi,
'lat': float(last['lat']),
'lon': float(last['lon']),
'sog': float(last.get('sog', 0)),
'cog': float(last.get('cog', 0)),
})
return clusters
def identify_lead_vessel(cluster_vessels: list[dict]) -> dict:
"""5기준 스코어링으로 대표선 특정."""
if not cluster_vessels:
if len(snapshots) < 2:
return {}
scores: dict[str, float] = {}
matched: set[str] = set()
fleets: dict[int, list[dict]] = {}
fleet_id = 0
timestamps = [pd.Timestamp(v.get('timestamp', 0)).timestamp() for v in cluster_vessels]
min_ts = min(timestamps) if timestamps else 0
# 1차: PT 저인망 쌍 탐지 (2척, 3NM, 유사 속도/방향)
for i in range(len(snapshots)):
if snapshots[i]['mmsi'] in matched:
continue
a = snapshots[i]
for j in range(i + 1, len(snapshots)):
if snapshots[j]['mmsi'] in matched:
continue
b = snapshots[j]
dist = haversine_nm(a['lat'], a['lon'], b['lat'], b['lon'])
if dist > 3.0:
continue
# 둘 다 조업 속도 (2~5kn)
if not (2.0 <= a['sog'] <= 5.0 and 2.0 <= b['sog'] <= 5.0):
continue
# 유사 속도 (차이 1kn 미만)
if abs(a['sog'] - b['sog']) >= 1.0:
continue
# 유사 방향 (20° 미만)
if _heading_diff(a['cog'], b['cog']) >= 20.0:
continue
lats = [v['lat'] for v in cluster_vessels]
lons = [v['lon'] for v in cluster_vessels]
centroid_lat = float(np.mean(lats))
centroid_lon = float(np.mean(lons))
fleets[fleet_id] = [
{**a, 'role': 'LEADER', 'pattern': 'TRAWL_PAIR'},
{**b, 'role': 'MEMBER', 'pattern': 'TRAWL_PAIR'},
]
matched.add(a['mmsi'])
matched.add(b['mmsi'])
fleet_id += 1
break
for i, v in enumerate(cluster_vessels):
mmsi = v['mmsi']
s = 0.0
# 2차: FC 환적 쌍 탐지 (2척, 0.5NM, 양쪽 저속)
for i in range(len(snapshots)):
if snapshots[i]['mmsi'] in matched:
continue
a = snapshots[i]
for j in range(i + 1, len(snapshots)):
if snapshots[j]['mmsi'] in matched:
continue
b = snapshots[j]
dist = haversine_nm(a['lat'], a['lon'], b['lat'], b['lon'])
if dist > 0.5:
continue
if a['sog'] > 2.0 or b['sog'] > 2.0:
continue
# 기준 1: 최초 시각 (30점)
ts_rank = timestamps[i] - min_ts
s += 30.0 * (1.0 - min(ts_rank, 7200) / 7200)
fleets[fleet_id] = [
{**a, 'role': 'LEADER', 'pattern': 'TRANSSHIP'},
{**b, 'role': 'MEMBER', 'pattern': 'TRANSSHIP'},
]
matched.add(a['mmsi'])
matched.add(b['mmsi'])
fleet_id += 1
break
# 기준 2: 총톤수 (25점) — 외부 DB 연동 전까지 균등 배점
s += 12.5
# 3차: PS 선망 선단 탐지 (3~10척, 2NM 이내 클러스터)
unmatched = [s for s in snapshots if s['mmsi'] not in matched]
for anchor in unmatched:
if anchor['mmsi'] in matched:
continue
nearby = []
for other in unmatched:
if other['mmsi'] == anchor['mmsi'] or other['mmsi'] in matched:
continue
dist = haversine_nm(anchor['lat'], anchor['lon'], other['lat'], other['lon'])
if dist <= 2.0:
nearby.append(other)
# 기준 3: 클러스터 중심 근접성 (20점)
dist_center = haversine_nm(v['lat'], v['lon'], centroid_lat, centroid_lon)
s += 20.0 * (1.0 - min(dist_center, 10) / 10)
if len(nearby) < 2: # 본인 포함 3척 이상
continue
# 기준 4: 기선 최근접 (15점)
dist_base = dist_to_baseline(v['lat'], v['lon'])
s += 15.0 * (1.0 - min(dist_base, 12) / 12)
# 역할 분류: 고속(모선), 정지(조명선), 나머지(멤버)
members = [{**anchor, 'role': 'LEADER', 'pattern': 'PURSE_SEINE'}]
matched.add(anchor['mmsi'])
for n in nearby[:9]: # 최대 10척
if n['sog'] < 0.5:
role = 'LIGHTING'
else:
role = 'MEMBER'
members.append({**n, 'role': role, 'pattern': 'PURSE_SEINE'})
matched.add(n['mmsi'])
# 기준 5: AIS 소실 이력 (10점) — 이력 없으면 만점
s += 10.0
fleets[fleet_id] = members
fleet_id += 1
scores[mmsi] = round(s, 2)
lead_mmsi = max(scores, key=lambda k: scores[k])
score_vals = sorted(scores.values(), reverse=True)
if len(score_vals) > 1 and score_vals[0] - score_vals[1] > 15:
confidence = 'HIGH'
elif len(score_vals) > 1 and score_vals[0] - score_vals[1] > 8:
confidence = 'MED'
else:
confidence = 'LOW'
return {
'lead_mmsi': lead_mmsi,
'lead_score': scores[lead_mmsi],
'all_scores': scores,
'confidence': confidence,
}
logger.info('fleet detection: %d fleets found (%d vessels matched)',
len(fleets), len(matched))
return fleets
def assign_fleet_roles(
vessel_dfs: dict[str, pd.DataFrame],
cluster_map: dict[str, int],
) -> dict[str, dict]:
"""선단 역할 할당: LEADER/MEMBER/NOISE."""
"""선단 역할 할당 — 패턴 매칭 기반.
cluster_map은 파이프라인에서 전달되지만, 여기서는 vessel_dfs로 직접 패턴 탐지.
"""
fleets = detect_fleet_patterns(vessel_dfs)
results: dict[str, dict] = {}
# 클러스터별 그룹핑
clusters: dict[int, list[str]] = {}
for mmsi, cid in cluster_map.items():
clusters.setdefault(cid, []).append(mmsi)
# 매칭된 선박 (fleet_id를 cluster_id로 사용)
fleet_mmsis: set[str] = set()
for fid, members in fleets.items():
for m in members:
fleet_mmsis.add(m['mmsi'])
results[m['mmsi']] = {
'cluster_id': fid,
'cluster_size': len(members),
'is_leader': m['role'] == 'LEADER',
'fleet_role': m['role'],
}
for cid, mmsi_list in clusters.items():
if cid == -1:
for mmsi in mmsi_list:
results[mmsi] = {
'cluster_size': 0,
'is_leader': False,
'fleet_role': 'NOISE',
}
continue
cluster_size = len(mmsi_list)
# 스냅샷 생성 (각 선박의 마지막 포인트)
snapshots: list[dict] = []
for mmsi in mmsi_list:
df = vessel_dfs.get(mmsi)
if df is not None and len(df) > 0:
last = df.iloc[-1]
snapshots.append({
'mmsi': mmsi,
'lat': last['lat'],
'lon': last['lon'],
'timestamp': last.get('timestamp', pd.Timestamp.now()),
})
lead_info = identify_lead_vessel(snapshots) if len(snapshots) >= 2 else {}
lead_mmsi = lead_info.get('lead_mmsi')
for mmsi in mmsi_list:
# 매칭 안 된 선박 → NOISE (cluster_id = -1)
for mmsi in vessel_dfs:
if mmsi not in fleet_mmsis:
results[mmsi] = {
'cluster_size': cluster_size,
'is_leader': mmsi == lead_mmsi,
'fleet_role': 'LEADER' if mmsi == lead_mmsi else 'MEMBER',
'cluster_id': -1,
'cluster_size': 0,
'is_leader': False,
'fleet_role': 'NOISE',
}
return results

파일 보기

@ -10,6 +10,7 @@ TERRITORIAL_SEA_NM = 12.0
CONTIGUOUS_ZONE_NM = 24.0
_baseline_points: Optional[List[Tuple[float, float]]] = None
_zone_polygons: Optional[list] = None
def _load_baseline() -> List[Tuple[float, float]]:
@ -46,10 +47,91 @@ def dist_to_baseline(vessel_lat: float, vessel_lon: float,
return min_dist
def classify_zone(vessel_lat: float, vessel_lon: float) -> dict:
"""선박 위치 수역 분류."""
dist = dist_to_baseline(vessel_lat, vessel_lon)
def _epsg3857_to_wgs84(x: float, y: float) -> Tuple[float, float]:
"""EPSG:3857 (Web Mercator) → WGS84 변환."""
lon = x / (math.pi * 6378137) * 180
lat = math.atan(math.exp(y / 6378137)) * 360 / math.pi - 90
return lat, lon
def _load_zone_polygons() -> list:
"""특정어업수역 ~Ⅳ GeoJSON 로드 + EPSG:3857→WGS84 변환."""
global _zone_polygons
if _zone_polygons is not None:
return _zone_polygons
zone_dir = Path(__file__).parent.parent / 'data' / 'zones'
zones_meta = [
('ZONE_I', '수역Ⅰ(동해)', ['PS', 'FC'], '특정어업수역Ⅰ.json'),
('ZONE_II', '수역Ⅱ(남해)', ['PT', 'OT', 'GN', 'PS', 'FC'], '특정어업수역Ⅱ.json'),
('ZONE_III', '수역Ⅲ(서남해)', ['PT', 'OT', 'GN', 'PS', 'FC'], '특정어업수역Ⅲ.json'),
('ZONE_IV', '수역Ⅳ(서해)', ['GN', 'PS', 'FC'], '특정어업수역Ⅳ.json'),
]
result = []
for zone_id, name, allowed, filename in zones_meta:
filepath = zone_dir / filename
if not filepath.exists():
continue
with open(filepath, 'r') as f:
data = json.load(f)
multi_coords = data['features'][0]['geometry']['coordinates']
wgs84_polys = []
for poly in multi_coords:
wgs84_rings = []
for ring in poly:
wgs84_rings.append([_epsg3857_to_wgs84(x, y) for x, y in ring])
wgs84_polys.append(wgs84_rings)
result.append({
'id': zone_id, 'name': name, 'allowed': allowed,
'polygons': wgs84_polys,
})
_zone_polygons = result
return result
def _point_in_polygon(lat: float, lon: float, ring: list) -> bool:
"""Ray-casting point-in-polygon."""
n = len(ring)
inside = False
j = n - 1
for i in range(n):
yi, xi = ring[i]
yj, xj = ring[j]
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
inside = not inside
j = i
return inside
def _point_in_multipolygon(lat: float, lon: float, polygons: list) -> bool:
"""MultiPolygon 내 포함 여부 (외곽 링 in + 내곽 링 hole 제외)."""
for poly in polygons:
outer = poly[0]
if _point_in_polygon(lat, lon, outer):
for hole in poly[1:]:
if _point_in_polygon(lat, lon, hole):
return False
return True
return False
def classify_zone(vessel_lat: float, vessel_lon: float) -> dict:
"""선박 위치 수역 분류 — 특정어업수역 ~Ⅳ 폴리곤 기반."""
zones = _load_zone_polygons()
for z in zones:
if _point_in_multipolygon(vessel_lat, vessel_lon, z['polygons']):
dist = dist_to_baseline(vessel_lat, vessel_lon)
return {
'zone': z['id'],
'zone_name': z['name'],
'allowed_gears': z['allowed'],
'dist_from_baseline_nm': round(dist, 2),
'violation': False,
'alert_level': 'WATCH',
}
dist = dist_to_baseline(vessel_lat, vessel_lon)
if dist <= TERRITORIAL_SEA_NM:
return {
'zone': 'TERRITORIAL_SEA',

파일 보기

@ -32,6 +32,10 @@ def compute_vessel_risk_score(
score += 40
elif zone == 'CONTIGUOUS_ZONE':
score += 10
elif zone.startswith('ZONE_'):
# 특정어업수역 내 — 무허가면 가산
if is_permitted is not None and not is_permitted:
score += 25
# 2. 조업 행위 (최대 30점)
segs = detect_fishing_segments(df_vessel)

파일 보기

@ -0,0 +1,160 @@
"""궤적 유사도 — DTW(Dynamic Time Warping) 기반."""
import math
_MAX_RESAMPLE_POINTS = 50
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""두 좌표 간 거리 (미터)."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def _resample(track: list[tuple[float, float]], n: int) -> list[tuple[float, float]]:
"""궤적을 n 포인트로 균등 리샘플링 (선형 보간)."""
if len(track) == 0:
return []
if len(track) == 1:
return [track[0]] * n
if len(track) <= n:
return list(track)
# 누적 거리 계산
cumulative = [0.0]
for i in range(1, len(track)):
d = haversine_m(track[i - 1][0], track[i - 1][1], track[i][0], track[i][1])
cumulative.append(cumulative[-1] + d)
total_dist = cumulative[-1]
if total_dist == 0.0:
return [track[0]] * n
step = total_dist / (n - 1)
result: list[tuple[float, float]] = []
seg = 0
for k in range(n):
target = step * k
# 해당 target 거리에 해당하는 선분 찾기
while seg < len(cumulative) - 2 and cumulative[seg + 1] < target:
seg += 1
seg_len = cumulative[seg + 1] - cumulative[seg]
if seg_len == 0.0:
result.append(track[seg])
else:
t = (target - cumulative[seg]) / seg_len
lat = track[seg][0] + t * (track[seg + 1][0] - track[seg][0])
lon = track[seg][1] + t * (track[seg + 1][1] - track[seg][1])
result.append((lat, lon))
return result
def _dtw_distance(
track_a: list[tuple[float, float]],
track_b: list[tuple[float, float]],
) -> float:
"""두 궤적 간 DTW 거리 (미터 단위 평균 거리)."""
n, m = len(track_a), len(track_b)
if n == 0 or m == 0:
return float('inf')
INF = float('inf')
# 1D 롤링 DP (공간 최적화)
prev = [INF] * (m + 1)
prev[0] = 0.0
# 첫 행 초기화
row = [INF] * (m + 1)
row[0] = INF
dp_prev = [INF] * (m + 1)
dp_curr = [INF] * (m + 1)
dp_prev[0] = 0.0
for j in range(1, m + 1):
dp_prev[j] = INF
for i in range(1, n + 1):
dp_curr[0] = INF
for j in range(1, m + 1):
cost = haversine_m(track_a[i - 1][0], track_a[i - 1][1],
track_b[j - 1][0], track_b[j - 1][1])
min_prev = min(dp_curr[j - 1], dp_prev[j], dp_prev[j - 1])
dp_curr[j] = cost + min_prev
dp_prev, dp_curr = dp_curr, [INF] * (m + 1)
# dp_prev는 마지막으로 계산된 행
total = dp_prev[m]
if total == INF:
return INF
return total / (n + m)
def compute_track_similarity(
track_a: list[tuple[float, float]],
track_b: list[tuple[float, float]],
max_dist_m: float = 10000.0,
) -> float:
"""두 궤적의 DTW 거리 기반 유사도 (0~1).
track이 비어있으면 0.0 반환.
유사할수록 1.0 가까움.
"""
if not track_a or not track_b:
return 0.0
a = _resample(track_a, _MAX_RESAMPLE_POINTS)
b = _resample(track_b, _MAX_RESAMPLE_POINTS)
avg_dist = _dtw_distance(a, b)
if avg_dist == float('inf') or max_dist_m <= 0.0:
return 0.0
similarity = 1.0 - (avg_dist / max_dist_m)
return max(0.0, min(1.0, similarity))
def match_gear_by_track(
gear_tracks: dict[str, list[tuple[float, float]]],
vessel_tracks: dict[str, list[tuple[float, float]]],
threshold: float = 0.6,
) -> list[dict]:
"""어구 궤적을 선단 선박 궤적과 비교하여 매칭.
Args:
gear_tracks: mmsi [(lat, lon), ...] 어구 궤적
vessel_tracks: mmsi [(lat, lon), ...] 선박 궤적
threshold: 유사도 하한 (이상이면 매칭)
Returns:
[{gear_mmsi, vessel_mmsi, similarity, match_method: 'TRACK_SIMILAR'}]
"""
results: list[dict] = []
for gear_mmsi, g_track in gear_tracks.items():
if not g_track:
continue
best_mmsi: str | None = None
best_sim = -1.0
for vessel_mmsi, v_track in vessel_tracks.items():
if not v_track:
continue
sim = compute_track_similarity(g_track, v_track)
if sim > best_sim:
best_sim = sim
best_mmsi = vessel_mmsi
if best_mmsi is not None and best_sim >= threshold:
results.append({
'gear_mmsi': gear_mmsi,
'vessel_mmsi': best_mmsi,
'similarity': best_sim,
'match_method': 'TRACK_SIMILAR',
})
return results

파일 보기

@ -113,12 +113,29 @@ class VesselStore:
for mmsi, group in df_all.groupby('mmsi'):
self._tracks[str(mmsi)] = group.reset_index(drop=True)
# last_bucket 설정 — incremental fetch 시작점
if 'time_bucket' in df_all.columns and not df_all['time_bucket'].dropna().empty:
max_bucket = pd.to_datetime(df_all['time_bucket'].dropna()).max()
if hasattr(max_bucket, 'to_pydatetime'):
max_bucket = max_bucket.to_pydatetime()
if isinstance(max_bucket, datetime) and max_bucket.tzinfo is None:
max_bucket = max_bucket.replace(tzinfo=timezone.utc)
self._last_bucket = max_bucket
elif 'timestamp' in df_all.columns and not df_all['timestamp'].dropna().empty:
max_ts = pd.to_datetime(df_all['timestamp'].dropna()).max()
if hasattr(max_ts, 'to_pydatetime'):
max_ts = max_ts.to_pydatetime()
if isinstance(max_ts, datetime) and max_ts.tzinfo is None:
max_ts = max_ts.replace(tzinfo=timezone.utc)
self._last_bucket = max_ts
vessel_count = len(self._tracks)
point_count = sum(len(v) for v in self._tracks.values())
logger.info(
'initial load complete: %d vessels, %d total points',
'initial load complete: %d vessels, %d total points, last_bucket=%s',
vessel_count,
point_count,
self._last_bucket,
)
self.refresh_static_info()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

@ -0,0 +1 @@
{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed4", "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, "features": [{"type": "Feature", "properties": {"fid": 0, "GML_ID": null, "OBJECTID": null, "ZONE_NM": null, "MNCT_NO": null, "MNCT_SCALE": null, "MNCT_NM": null, "RELREGLTN": null, "RELGOAG": null, "REVIYR": null, "ZONE_DESC": null, "PHOTO1_PAT": null, "ID": -2147483647, "CATE_CD": null, "ADR_CD": null, "ADR_KNM": null, "ORIGIN": null, "ORIYR": null, "ORIORG": null, "NAME": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed\u2163", "WARD_NM": null, "WARD_ID": null, "GISID": null, "FID_2": null, "NAME_2": null, "FID_3": null, "NAME_3": null, "GID": null, "NAME_4": null, "FID_4": null, "NAME_5": null, "FID_5": null, "NAME_6": null}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[13859276.603817873, 4232038.462456921], [13859276.603762543, 4321218.244482412], [13859276.603710985, 4404317.064005076], [13840719.645028654, 4439106.786523586], [13884632.712472571, 4439106.787250583], [13884632.712472571, 4439504.084564682], [13940418.269436067, 4439504.375880923], [13969123.924724836, 4439504.525783945], [13968718.329494288, 4438626.439593866], [13962623.599395147, 4425543.915710401], [13960437.31344761, 4420657.3891166765], [13958238.813611617, 4416093.569832627], [13958143.094601436, 4415900.994484875], [13958143.094601437, 4415900.994484875], [13957298.344237303, 4414201.456484755], [13953878.455604602, 4406316.186534493], [13949652.450365951, 4397019.979821594], [13948553.200448176, 4393395.13065616], [13947612.731073817, 4389132.176741289], [13947612.731072996, 4387549.226905922], [13947466.164417507, 4385829.556682826], [13947783.725505754, 4381721.729468383], [13948260.06713652, 4379835.70012994], [13949359.317054221, 4375897.403884492], [13951093.689146286, 4371808.582233328], [13954867.780530114, 4365670.678186072], [13964809.885341855, 4351190.629491161], [13978342.873219142, 4331838.456925102], [13980382.592510404, 4329007.496874151], [13981728.043604897, 4327079.749205159], [13985775.34591557, 4321280.81855131], [13997066.763484716, 4305102.598482491], [13999424.043863578, 4300225.286038025], [14003039.354703771, 4290447.064438686], [14005091.287883686, 4284626.561498255], [14006520.312777169, 4279426.932176922], [14007631.77658257, 4275178.643476352], [14008242.470981453, 4271549.325573796], [14009378.362562515, 4262248.123573576], [14009427.990871342, 4261704.85208626], [14009708.137538105, 4258638.140769343], [14009854.704193696, 4257224.555715567], [14009378.362562606, 4254698.603440943], [14005347.779531531, 4240996.452433007], [14002367.590864772, 4231511.1380338315], [14001280.554835469, 4227266.412716273], [14000486.652116666, 4225212.134400094], [13998047.81589918, 4222926.459154359], [13991387.305576058, 4216684.234498038], [13970721.407121927, 4197120.494488488], [13958654.085803084, 4185745.4565721145], [13956602.15262321, 4184012.5742896623], [13944065.033685392, 4171984.566055202], [13940467.606607554, 4168533.224265296], [13935619.01320107, 4163881.1438622964], [13935718.55954324, 4163976.6556012244], [13817590.293393573, 4163976.6556012244], [13859276.603817873, 4232038.462456921]]]]}}]}

322
prediction/fleet_tracker.py Normal file
파일 보기

@ -0,0 +1,322 @@
"""등록 선단 기반 추적기."""
import logging
import re
import time
from datetime import datetime, timezone
from typing import Optional
import pandas as pd
logger = logging.getLogger(__name__)
# 어구 이름 패턴
GEAR_PATTERN = re.compile(r'^(.+?)_(\d+)_(\d*)$')
GEAR_PATTERN_PCT = re.compile(r'^(.+?)%$')
_REGISTRY_CACHE_SEC = 3600
class FleetTracker:
def __init__(self) -> None:
self._companies: dict[int, dict] = {} # id → {name_cn, name_en}
self._vessels: dict[int, dict] = {} # id → {permit_no, name_cn, ...}
self._name_cn_map: dict[str, int] = {} # name_cn → vessel_id
self._name_en_map: dict[str, int] = {} # name_en(lowercase) → vessel_id
self._mmsi_to_vid: dict[str, int] = {} # mmsi → vessel_id (매칭된 것만)
self._gear_active: dict[str, dict] = {} # mmsi → {name, parent_mmsi, ...}
self._last_registry_load: float = 0.0
def load_registry(self, conn) -> None:
"""DB에서 fleet_companies + fleet_vessels 로드. 1시간 캐시."""
if time.time() - self._last_registry_load < _REGISTRY_CACHE_SEC:
return
cur = conn.cursor()
cur.execute('SELECT id, name_cn, name_en FROM kcg.fleet_companies')
self._companies = {r[0]: {'name_cn': r[1], 'name_en': r[2]} for r in cur.fetchall()}
cur.execute(
"""SELECT id, company_id, permit_no, name_cn, name_en, tonnage,
gear_code, fleet_role, pair_vessel_id, mmsi
FROM kcg.fleet_vessels"""
)
self._vessels = {}
self._name_cn_map = {}
self._name_en_map = {}
self._mmsi_to_vid = {}
for r in cur.fetchall():
vid = r[0]
v: dict = {
'id': vid,
'company_id': r[1],
'permit_no': r[2],
'name_cn': r[3],
'name_en': r[4],
'tonnage': r[5],
'gear_code': r[6],
'fleet_role': r[7],
'pair_vessel_id': r[8],
'mmsi': r[9],
}
self._vessels[vid] = v
if r[3]:
self._name_cn_map[r[3]] = vid
if r[4]:
self._name_en_map[r[4].lower().strip()] = vid
if r[9]:
self._mmsi_to_vid[r[9]] = vid
cur.close()
self._last_registry_load = time.time()
logger.info(
'fleet registry loaded: %d companies, %d vessels',
len(self._companies),
len(self._vessels),
)
def match_ais_to_registry(self, ais_vessels: list[dict], conn) -> None:
"""AIS 선박을 등록 선단에 매칭. DB 업데이트.
ais_vessels: [{mmsi, name, lat, lon, sog, cog}, ...]
"""
cur = conn.cursor()
matched = 0
for v in ais_vessels:
mmsi = v.get('mmsi', '')
name = v.get('name', '')
if not mmsi or not name:
continue
# 이미 매칭됨 → last_seen_at 업데이트
if mmsi in self._mmsi_to_vid:
cur.execute(
'UPDATE kcg.fleet_vessels SET last_seen_at = NOW() WHERE id = %s',
(self._mmsi_to_vid[mmsi],),
)
continue
# NAME_EXACT 매칭
vid: Optional[int] = self._name_cn_map.get(name)
if not vid:
vid = self._name_en_map.get(name.lower().strip())
if vid:
cur.execute(
"""UPDATE kcg.fleet_vessels
SET mmsi = %s, match_confidence = 0.95, match_method = 'NAME_EXACT',
last_seen_at = NOW(), updated_at = NOW()
WHERE id = %s AND (mmsi IS NULL OR mmsi = %s)""",
(mmsi, vid, mmsi),
)
self._mmsi_to_vid[mmsi] = vid
matched += 1
conn.commit()
cur.close()
if matched > 0:
logger.info('AIS→registry matched: %d vessels', matched)
def track_gear_identity(self, gear_signals: list[dict], conn) -> None:
"""어구/어망 정체성 추적.
gear_signals: [{mmsi, name, lat, lon}, ...] 이름이 XXX_숫자_숫자 패턴인 AIS 신호
"""
cur = conn.cursor()
now = datetime.now(timezone.utc)
for g in gear_signals:
mmsi = g['mmsi']
name = g['name']
lat = g.get('lat', 0)
lon = g.get('lon', 0)
# 모선명 + 인덱스 추출
parent_name: Optional[str] = None
idx1: Optional[int] = None
idx2: Optional[int] = None
m = GEAR_PATTERN.match(name)
if m:
parent_name = m.group(1).strip()
idx1 = int(m.group(2))
idx2 = int(m.group(3)) if m.group(3) else None
else:
m2 = GEAR_PATTERN_PCT.match(name)
if m2:
parent_name = m2.group(1).strip()
# 모선 매칭
parent_mmsi: Optional[str] = None
parent_vid: Optional[int] = None
if parent_name:
vid = self._name_cn_map.get(parent_name)
if not vid:
vid = self._name_en_map.get(parent_name.lower())
if vid:
parent_vid = vid
parent_mmsi = self._vessels[vid].get('mmsi')
match_method: Optional[str] = 'NAME_PARENT' if parent_vid else None
confidence = 0.9 if parent_vid else 0.0
# 기존 활성 행 조회
cur.execute(
"""SELECT id, name FROM kcg.gear_identity_log
WHERE mmsi = %s AND is_active = TRUE""",
(mmsi,),
)
existing = cur.fetchone()
if existing:
if existing[1] == name:
# 같은 MMSI + 같은 이름 → 위치/시간 업데이트
cur.execute(
"""UPDATE kcg.gear_identity_log
SET last_seen_at = %s, lat = %s, lon = %s
WHERE id = %s""",
(now, lat, lon, existing[0]),
)
else:
# 같은 MMSI + 다른 이름 → 이전 비활성화 + 새 행
cur.execute(
'UPDATE kcg.gear_identity_log SET is_active = FALSE WHERE id = %s',
(existing[0],),
)
cur.execute(
"""INSERT INTO kcg.gear_identity_log
(mmsi, name, parent_name, parent_mmsi, parent_vessel_id,
gear_index_1, gear_index_2, lat, lon,
match_method, match_confidence, first_seen_at, last_seen_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(mmsi, name, parent_name, parent_mmsi, parent_vid,
idx1, idx2, lat, lon,
match_method, confidence, now, now),
)
else:
# 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인
cur.execute(
"""SELECT id, mmsi FROM kcg.gear_identity_log
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
(name, mmsi),
)
old_mmsi_row = cur.fetchone()
if old_mmsi_row:
# 같은 이름 + 다른 MMSI → MMSI 변경
cur.execute(
'UPDATE kcg.gear_identity_log SET is_active = FALSE WHERE id = %s',
(old_mmsi_row[0],),
)
logger.info('gear MMSI change: %s%s (name=%s)', old_mmsi_row[1], mmsi, name)
cur.execute(
"""INSERT INTO kcg.gear_identity_log
(mmsi, name, parent_name, parent_mmsi, parent_vessel_id,
gear_index_1, gear_index_2, lat, lon,
match_method, match_confidence, first_seen_at, last_seen_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(mmsi, name, parent_name, parent_mmsi, parent_vid,
idx1, idx2, lat, lon,
match_method, confidence, now, now),
)
conn.commit()
cur.close()
def build_fleet_clusters(self, vessel_dfs: dict[str, pd.DataFrame]) -> dict[str, dict]:
"""등록 선단 기준으로 cluster 정보 구성.
Returns: {mmsi {cluster_id, cluster_size, is_leader, fleet_role}}
cluster_id = company_id (등록 선단 기준)
"""
results: dict[str, dict] = {}
# 회사별로 현재 AIS 수신 중인 선박 그룹핑
company_vessels: dict[int, list[str]] = {}
for mmsi, vid in self._mmsi_to_vid.items():
v = self._vessels.get(vid)
if not v or mmsi not in vessel_dfs:
continue
cid = v['company_id']
company_vessels.setdefault(cid, []).append(mmsi)
for cid, mmsis in company_vessels.items():
if len(mmsis) < 2:
# 단독 선박 → NOISE
for mmsi in mmsis:
v = self._vessels.get(self._mmsi_to_vid.get(mmsi, -1), {})
results[mmsi] = {
'cluster_id': -1,
'cluster_size': 1,
'is_leader': False,
'fleet_role': v.get('fleet_role', 'NOISE'),
}
continue
# 2척 이상 → 등록 선단 클러스터
for mmsi in mmsis:
vid = self._mmsi_to_vid[mmsi]
v = self._vessels[vid]
results[mmsi] = {
'cluster_id': cid,
'cluster_size': len(mmsis),
'is_leader': v['fleet_role'] == 'MAIN',
'fleet_role': v['fleet_role'],
}
# 매칭 안 된 선박 → NOISE
for mmsi in vessel_dfs:
if mmsi not in results:
results[mmsi] = {
'cluster_id': -1,
'cluster_size': 0,
'is_leader': False,
'fleet_role': 'NOISE',
}
return results
def save_snapshot(self, vessel_dfs: dict[str, pd.DataFrame], conn) -> None:
"""fleet_tracking_snapshot 저장."""
now = datetime.now(timezone.utc)
cur = conn.cursor()
company_vessels: dict[int, list[str]] = {}
for mmsi, vid in self._mmsi_to_vid.items():
v = self._vessels.get(vid)
if not v or mmsi not in vessel_dfs:
continue
company_vessels.setdefault(v['company_id'], []).append(mmsi)
for cid, mmsis in company_vessels.items():
active = len(mmsis)
total = sum(1 for v in self._vessels.values() if v['company_id'] == cid)
lats: list[float] = []
lons: list[float] = []
for mmsi in mmsis:
df = vessel_dfs.get(mmsi)
if df is not None and len(df) > 0:
last = df.iloc[-1]
lats.append(float(last['lat']))
lons.append(float(last['lon']))
center_lat = sum(lats) / len(lats) if lats else None
center_lon = sum(lons) / len(lons) if lons else None
cur.execute(
"""INSERT INTO kcg.fleet_tracking_snapshot
(company_id, snapshot_time, total_vessels, active_vessels,
center_lat, center_lon)
VALUES (%s, %s, %s, %s, %s, %s)""",
(cid, now, total, active, center_lat, center_lon),
)
conn.commit()
cur.close()
logger.info('fleet snapshot saved: %d companies', len(company_vessels))
# 싱글턴
fleet_tracker = FleetTracker()

파일 보기

@ -56,29 +56,41 @@ class AnalysisResult:
def to_db_tuple(self) -> tuple:
import json
def _f(v: object) -> float:
"""numpy float → Python float 변환."""
return float(v) if v is not None else 0.0
def _i(v: object) -> int:
"""numpy int → Python int 변환."""
return int(v) if v is not None else 0
# features dict 내부 numpy 값도 변환
safe_features = {k: float(v) for k, v in self.features.items()} if self.features else {}
return (
self.mmsi,
str(self.mmsi),
self.timestamp,
self.vessel_type,
self.confidence,
self.fishing_pct,
self.cluster_id,
self.season,
self.zone,
self.dist_to_baseline_nm,
self.activity_state,
self.ucaf_score,
self.ucft_score,
self.is_dark,
self.gap_duration_min,
self.spoofing_score,
self.bd09_offset_m,
self.speed_jump_count,
self.cluster_size,
self.is_leader,
self.fleet_role,
self.risk_score,
self.risk_level,
json.dumps(self.features),
str(self.vessel_type),
_f(self.confidence),
_f(self.fishing_pct),
_i(self.cluster_id),
str(self.season),
str(self.zone),
_f(self.dist_to_baseline_nm),
str(self.activity_state),
_f(self.ucaf_score),
_f(self.ucft_score),
bool(self.is_dark),
_i(self.gap_duration_min),
_f(self.spoofing_score),
_f(self.bd09_offset_m),
_i(self.speed_jump_count),
_i(self.cluster_size),
bool(self.is_leader),
str(self.fleet_role),
_i(self.risk_score),
str(self.risk_level),
json.dumps(safe_features),
self.analyzed_at,
)

파일 보기

@ -25,6 +25,7 @@ def get_last_run() -> dict:
def run_analysis_cycle():
"""5분 주기 분석 사이클 — 인메모리 캐시 기반."""
import re as _re
from cache.vessel_store import vessel_store
from db import snpdb, kcgdb
from pipeline.orchestrator import ChineseFishingVesselPipeline
@ -32,8 +33,8 @@ def run_analysis_cycle():
from algorithms.fishing_pattern import compute_ucaf_score, compute_ucft_score
from algorithms.dark_vessel import is_dark_vessel
from algorithms.spoofing import compute_spoofing_score, count_speed_jumps, compute_bd09_offset
from algorithms.fleet import assign_fleet_roles
from algorithms.risk import compute_vessel_risk_score
from fleet_tracker import fleet_tracker
from models.result import AnalysisResult
start = time.time()
@ -71,9 +72,30 @@ def run_analysis_cycle():
_last_run['vessel_count'] = 0
return
# 4. 선단 역할 분석
cluster_map = {c['mmsi']: c['cluster_id'] for c in classifications}
fleet_roles = assign_fleet_roles(vessel_dfs, cluster_map)
# 4. 등록 선단 기반 fleet 분석
_gear_re = _re.compile(r'^.+_\d+_\d*$|%$')
with kcgdb.get_conn() as kcg_conn:
fleet_tracker.load_registry(kcg_conn)
all_ais = []
for mmsi, df in vessel_dfs.items():
if len(df) > 0:
last = df.iloc[-1]
all_ais.append({
'mmsi': mmsi,
'name': vessel_store.get_vessel_info(mmsi).get('name', ''),
'lat': float(last['lat']),
'lon': float(last['lon']),
})
fleet_tracker.match_ais_to_registry(all_ais, kcg_conn)
gear_signals = [v for v in all_ais if _gear_re.match(v.get('name', ''))]
fleet_tracker.track_gear_identity(gear_signals, kcg_conn)
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)
# 5. 선박별 추가 알고리즘 → AnalysisResult 생성
results = []
@ -116,7 +138,7 @@ def run_analysis_cycle():
vessel_type=c['vessel_type'],
confidence=c['confidence'],
fishing_pct=c['fishing_pct'],
cluster_id=c['cluster_id'],
cluster_id=fleet_info.get('cluster_id', -1),
season=c['season'],
zone=zone_info.get('zone', 'EEZ_OR_BEYOND'),
dist_to_baseline_nm=zone_info.get('dist_from_baseline_nm', 999.0),

파일 보기

@ -0,0 +1,176 @@
"""선단 구성 JSX → kcgdb fleet_companies + fleet_vessels 적재.
Usage: python3 prediction/scripts/load_fleet_registry.py
"""
import json
import re
import sys
from pathlib import Path
import psycopg2
import psycopg2.extras
# JSX 파일에서 D 배열 추출
JSX_PATH = Path(__file__).parent.parent.parent.parent / 'gc-wing-dev' / 'legacy' / '선단구성_906척_어업수역 (1).jsx'
# kcgdb 접속 — prediction/.env 또는 환경변수
DB_HOST = '211.208.115.83'
DB_PORT = 5432
DB_NAME = 'kcgdb'
DB_USER = 'kcg_app'
DB_SCHEMA = 'kcg'
def parse_jsx(path: Path) -> list[list]:
"""JSX 파일에서 D=[ ... ] 배열을 파싱."""
text = path.read_text(encoding='utf-8')
# const D=[ 부터 ]; 까지 추출
m = re.search(r'const\s+D\s*=\s*\[', text)
if not m:
raise ValueError('D 배열을 찾을 수 없습니다')
start = m.end() - 1 # [ 위치
# 중첩 배열을 추적하여 닫는 ] 찾기
depth = 0
end = start
for i in range(start, len(text)):
if text[i] == '[':
depth += 1
elif text[i] == ']':
depth -= 1
if depth == 0:
end = i + 1
break
raw = text[start:end]
# JavaScript → JSON 변환 (trailing comma 제거)
raw = re.sub(r',\s*]', ']', raw)
raw = re.sub(r',\s*}', '}', raw)
return json.loads(raw)
def load_to_db(data: list[list], db_password: str):
"""파싱된 데이터를 DB에 적재."""
conn = psycopg2.connect(
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
user=DB_USER, password=db_password,
options=f'-c search_path={DB_SCHEMA}',
)
conn.autocommit = False
cur = conn.cursor()
try:
# 기존 데이터 초기화
cur.execute('DELETE FROM fleet_vessels')
cur.execute('DELETE FROM fleet_companies')
company_count = 0
vessel_count = 0
pair_links = [] # (vessel_id, pair_vessel_id) 후처리
for row in data:
if len(row) < 7:
continue
name_cn = row[0]
name_en = row[1]
# 회사 INSERT
cur.execute(
'INSERT INTO fleet_companies (name_cn, name_en) VALUES (%s, %s) RETURNING id',
(name_cn, name_en),
)
company_id = cur.fetchone()[0]
company_count += 1
# 인덱스: 0=own, 1=ownEn, 2=pairs, 3=gn, 4=ot, 5=ps, 6=fc, 7=upt, 8=upts
pairs = row[2] if len(row) > 2 and isinstance(row[2], list) else []
gn = row[3] if len(row) > 3 and isinstance(row[3], list) else []
ot = row[4] if len(row) > 4 and isinstance(row[4], list) else []
ps = row[5] if len(row) > 5 and isinstance(row[5], list) else []
fc = row[6] if len(row) > 6 and isinstance(row[6], list) else []
upt = row[7] if len(row) > 7 and isinstance(row[7], list) else []
upts = row[8] if len(row) > 8 and isinstance(row[8], list) else []
def insert_vessel(v, gear_code, role):
nonlocal vessel_count
if not isinstance(v, list) or len(v) < 4:
return None
cur.execute(
'''INSERT INTO fleet_vessels
(company_id, permit_no, name_cn, name_en, tonnage, gear_code, fleet_role)
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id''',
(company_id, v[0], v[1], v[2], v[3], gear_code, role),
)
vessel_count += 1
return cur.fetchone()[0]
# PT 본선쌍 (pairs)
for pair in pairs:
if not isinstance(pair, list) or len(pair) < 2:
continue
main_id = insert_vessel(pair[0], 'C21', 'MAIN')
sub_id = insert_vessel(pair[1], 'C21', 'SUB')
if main_id and sub_id:
pair_links.append((main_id, sub_id))
# GN 유자망
for v in gn:
insert_vessel(v, 'C25', 'GN')
# OT 기타
for v in ot:
insert_vessel(v, 'C22', 'OT')
# PS 선망
for v in ps:
insert_vessel(v, 'C23', 'PS')
# FC 운반선
for v in fc:
insert_vessel(v, 'C40', 'FC')
# UPT 단독 본선
for v in upt:
insert_vessel(v, 'C21', 'MAIN_SOLO')
# UPTS 단독 부속선
for v in upts:
insert_vessel(v, 'C21', 'SUB_SOLO')
# PT 쌍 상호 참조 설정
for main_id, sub_id in pair_links:
cur.execute('UPDATE fleet_vessels SET pair_vessel_id = %s WHERE id = %s', (sub_id, main_id))
cur.execute('UPDATE fleet_vessels SET pair_vessel_id = %s WHERE id = %s', (main_id, sub_id))
conn.commit()
print(f'적재 완료: {company_count}개 회사, {vessel_count}척 선박, {len(pair_links)}쌍 PT')
except Exception as e:
conn.rollback()
print(f'적재 실패: {e}', file=sys.stderr)
raise
finally:
cur.close()
conn.close()
if __name__ == '__main__':
if not JSX_PATH.exists():
print(f'파일을 찾을 수 없습니다: {JSX_PATH}', file=sys.stderr)
sys.exit(1)
# DB 비밀번호 — 환경변수 또는 직접 입력
import os
password = os.environ.get('KCGDB_PASSWORD', 'Kcg2026monitor')
print(f'JSX 파싱: {JSX_PATH}')
data = parse_jsx(JSX_PATH)
print(f'파싱 완료: {len(data)}개 회사')
print('DB 적재 시작...')
load_to_db(data, password)