diff --git a/CLAUDE.md b/CLAUDE.md index 5b6ef7d..ffe7de2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,10 +9,11 @@ | 패키지 | 스택 | 비고 | |--------|------|------| -| **Frontend** | React 19 + TypeScript + Vite 7 + Tailwind CSS 4 | i18n(ko/en), 테마(dark/light) | -| **Backend** | Spring Boot 3.2.5 + Java 17 + PostgreSQL | Google OAuth + JWT 인증 | -| **Prediction** | FastAPI (Python) | 향후 해양 분석 | -| **DB** | PostgreSQL 16 (211.208.115.83:5432/kcgdb, 스키마: kcg) | | +| **Frontend** | React 19 + TypeScript 5.9 + Vite 7 + Tailwind CSS 4 | i18n(ko/en), 테마(dark/light), MapLibre GL + deck.gl GPU | +| **Backend** | Spring Boot 3.2.5 + Java 21 + PostgreSQL + PostGIS | Google OAuth + JWT, Caffeine 캐시 | +| **Prediction** | FastAPI + Python 3.9 + Shapely + APScheduler | 5분 주기 해양 분석 파이프라인 | +| **DB** | PostgreSQL 16 + PostGIS (211.208.115.83:5432/kcgdb, 스키마: kcg) | | +| **궤적 DB** | PostgreSQL (snpdb, 211.208.115.83:5432/snpdb, 스키마: signal) | LineStringM 궤적 | | **CI/CD** | Gitea Actions → nginx + systemd | main merge 시 자동 배포 | ## 빌드 및 실행 @@ -29,55 +30,135 @@ npm run lint # ESLint 검증 ### Backend ```bash cd backend -# 최초: application-local.yml 설정 필요 -cp src/main/resources/application-local.yml.example src/main/resources/application-local.yml +sdk use java 21.0.9-amzn # JDK 21 필수 mvn spring-boot:run -Dspring-boot.run.profiles=local # 개발서버 (포트 8080) mvn compile # 컴파일 검증 mvn package # JAR 빌드 (target/kcg.jar) ``` +### Prediction +```bash +cd prediction +pip install -r requirements.txt # shapely, scikit-learn, apscheduler 등 +uvicorn main:app --host 0.0.0.0 --port 8001 +``` + ### Database ```bash -psql -h 211.208.115.83 -U kcg_app -d kcgdb -f database/init.sql -psql -h 211.208.115.83 -U kcg_app -d kcgdb -f database/migration/001_initial_schema.sql +# kcgdb (분석 결과) +psql -h 211.208.115.83 -U kcg_app -d kcgdb -f database/migration/009_group_polygons.sql +# snpdb (궤적 원본) — 읽기 전용, 별도 관리 ``` ## 프로젝트 구조 ``` -frontend/ # React 19 + Vite 7 + Tailwind CSS 4 +frontend/ # React 19 + Vite 7 + Tailwind CSS 4 + deck.gl ├── src/ -│ ├── components/ # 28개 컴포넌트 (맵 레이어, UI 패널, 인증) -│ ├── hooks/ # useReplay, useMonitor, useTheme, useAuth -│ ├── services/ # API 서비스 (ships, opensky, osint, authApi 등) -│ ├── styles/ # tokens.css (테마 토큰), tailwind.css -│ ├── i18n/ # i18next (ko/en) -│ ├── data/ # 정적 데이터 (공항, 유전시설, 샘플) -│ └── App.tsx # 인증 가드 → LoginPage / AuthenticatedApp +│ ├── App.tsx # 인증가드 → SharedFilterProvider → FontScaleProvider +│ ├── contexts/ # SharedFilterContext, FontScaleContext +│ ├── styles/ +│ │ ├── tokens.css # 테마 토큰 (dark/light), Tailwind @theme +│ │ ├── fonts.ts # FONT_MONO/FONT_SANS 상수 (@fontsource-variable) +│ │ └── tailwind.css +│ ├── components/ +│ │ ├── common/ # LayerPanel, EventLog, FontScalePanel, ReplayControls +│ │ ├── layers/ # DeckGLOverlay, ShipLayer, AircraftLayer, SatelliteLayer +│ │ ├── iran/ # IranDashboard, ReplayMap, SatelliteMap, GlobeMap +│ │ │ # createIranOil/Airport/MEFacility/MEEnergyHazard Layers +│ │ ├── korea/ # KoreaDashboard, KoreaMap + 25개 레이어/패널 +│ │ │ # FleetClusterLayer (API GeoJSON 렌더링) +│ │ │ # AnalysisStatsPanel, FieldAnalysisModal +│ │ └── auth/ # LoginPage +│ ├── hooks/ +│ │ ├── useReplay, useMonitor, useAuth, useTheme +│ │ ├── useIranData (더미↔API 토글), useKoreaData, useKoreaFilters +│ │ ├── useVesselAnalysis (5분 폴링, mmsi별 분석 DTO) +│ │ ├── useGroupPolygons (5분 폴링, 선단/어구 폴리곤) +│ │ ├── useStaticDeckLayers (4개 서브훅 조합) +│ │ ├── useAnalysisDeckLayers (위험도/다크/스푸핑 마커) +│ │ ├── useFontScale, useLocalStorage, usePoll +│ │ └── layers/ # createPort/Navigation/Military/FacilityLayers +│ ├── services/ # API 클라이언트 (ships, aircraft, osint, vesselAnalysis 등) +│ ├── data/ # 정적 데이터 (공항, 유전, 샘플, 어업수역 GeoJSON) +│ └── i18n/ # i18next (ko/en) ├── package.json -└── vite.config.ts # Vite + 프록시 (/api/kcg → backend, /signal-batch → wing) +└── vite.config.ts # Vite + 프록시 (/api/kcg → backend) -backend/ # Spring Boot 3.2 + Java 17 +backend/ # Spring Boot 3.2 + Java 21 ├── src/main/java/gc/mda/kcg/ -│ ├── auth/ # Google OAuth + JWT (gcsc.co.kr 제한) -│ ├── config/ # CORS, Security, AppProperties -│ ├── collector/ # 수집기 placeholder (GDELT, GoogleNews, CENTCOM) -│ └── domain/ # event, news, osint, aircraft (placeholder) -├── src/main/resources/ -│ ├── application.yml # 공통 설정 -│ ├── application-local.yml.example -│ └── application-prod.yml.example +│ ├── auth/ # Google OAuth + JWT (AuthFilter: 인증 예외 경로 관리) +│ ├── config/ # CORS, Security, CacheConfig (Caffeine) +│ ├── domain/ +│ │ ├── analysis/ # VesselAnalysisController/Service/Dto/Repository +│ │ ├── fleet/ # FleetCompanyController, GroupPolygonController/Service/Dto +│ │ ├── event/ # EventController/Service (이란 리플레이) +│ │ ├── aircraft/ # AircraftController/Service (시점 조회) +│ │ └── osint/ # OsintController/Service (시점 조회) +│ └── collector/ # GDELT, GoogleNews, CENTCOM (placeholder) └── pom.xml -database/ # PostgreSQL -├── init.sql # CREATE SCHEMA kcg -└── migration/001_initial_schema.sql # events, news, osint, users, login_history +prediction/ # FastAPI + Python 3.9 + APScheduler +├── main.py # FastAPI app + 스케줄러 초기화 +├── scheduler.py # 5분 주기 분석 사이클 (7단계 파이프라인 + 폴리곤 생성) +├── fleet_tracker.py # 등록 선단 매칭 + 어구 정체성 추적 +├── config.py # Settings (snpdb/kcgdb 접속정보) +├── cache/ +│ └── vessel_store.py # 인메모리 AIS 캐시 (14K선박, 24h 윈도우) +├── algorithms/ +│ ├── polygon_builder.py # Shapely 폴리곤 생성 (선단/어구 그룹) +│ ├── fleet.py # 선단 패턴 탐지 (PT/FC/PS) +│ ├── transshipment.py # 환적 탐지 (그리드 O(n log n)) +│ ├── location.py # 특정어업수역 판정 (Point-in-Polygon) +│ └── ... # dark_vessel, spoofing, risk, fishing_pattern +├── pipeline/ # 7단계 분석 파이프라인 +├── models/ # AnalysisResult +├── db/ +│ ├── snpdb.py # 궤적 DB (읽기 전용) +│ └── kcgdb.py # 분석 결과 DB (UPSERT + 폴리곤 저장) +├── data/zones/ # 특정어업수역 GeoJSON (EPSG:3857) +└── requirements.txt # shapely, scikit-learn, apscheduler, pandas, numpy -prediction/ # FastAPI placeholder -deploy/ # systemd + nginx 배포 설정 -.gitea/workflows/deploy.yml # CI/CD (main merge → 자동 빌드/배포) +database/ # PostgreSQL 마이그레이션 +├── init.sql +└── migration/ + ├── 001_initial_schema.sql # events, news, osint, users + ├── 002_aircraft_positions.sql # PostGIS 활성화 + ├── 005_vessel_analysis.sql # vessel_analysis_results + ├── 007_fleet_registry.sql # fleet_companies, fleet_vessels, gear_identity_log + ├── 008_transshipment.sql # 환적 탐지 칼럼 추가 + └── 009_group_polygons.sql # group_polygon_snapshots (PostGIS Polygon) + +deploy/ # systemd + nginx 배포 설정 +.gitea/workflows/deploy.yml # CI/CD (main merge → 자동 빌드/배포) ``` +## 주요 기능 + +### 한국 현황 대시보드 +- **선박 모니터링**: 13K+ AIS 선박, MapLibre GPU-side filter, 카테고리/국적 토글 +- **감시 탭**: 불법어선, 환적, 다크베셀, 해저케이블, 독도감시, 중국어선감시 +- **선단/어구 폴리곤**: Python 서버사이드 생성 (Shapely + PostGIS) → API GeoJSON 렌더링 + - FLEET 15개, GEAR_IN_ZONE 57개, GEAR_OUT_ZONE 45개 (5분 주기 갱신, 7일 히스토리) + - 가상 선박 마커 (ship-triangle + COG 회전 + zoom interpolate) + - 겹침 해결: queryRenderedFeatures → 다중 선택 팝업 + 호버 하이라이트 +- **AI 분석**: Python 7단계 파이프라인, 위험도/다크베셀/스푸핑 deck.gl 오버레이 +- **현장분석**: FieldAnalysisModal (어구/선단 분석 대시보드) +- **시설 레이어**: deck.gl IconLayer(SVG) + TextLayer, 줌 스케일 연동 + +### 이란 상황 대시보드 +- **공습 리플레이**: 실데이터 Backend DB 기반 (더미↔API 토글) +- **유전/공항/군사시설**: deck.gl SVG 아이콘, 사막 대비 고채도 팔레트 +- **센서 그래프**: 지진, 기압, 소음/방사선 +- **위성지도/평면/3D Globe**: 3개 맵 모드 + +### 공통 +- **웹폰트**: @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅 +- **글꼴 크기 커스텀**: FontScalePanel (시설/선박/분석/지역 4그룹 × 0.5~2.0x) +- **LayerPanel**: 공통 트리 구조 (LayerTreeRenderer 재귀, 부모 캐스케이드) +- **인증**: Google OAuth + DEV LOGIN +- **localStorage**: 13개+ UI 상태 영속화 + ## 팀 스킬 사용 지침 ### 중요: 스킬 실행 시 반드시 따라야 할 규칙 @@ -108,12 +189,18 @@ deploy/ # systemd + nginx 배포 설정 ## 배포 -- **도메인**: https://kcg.gc-si.dev -- **서버**: rocky-211 (SSH 접속, Gitea Actions 러너 = 배포 서버) -- **Frontend**: `/deploy/kcg/` (nginx 정적 파일 서빙) -- **Backend**: `/deploy/kcg-backend/kcg.jar` (systemd `kcg-backend` 서비스, JDK 17, 2~4GB 힙) -- **nginx**: `/etc/nginx/conf.d/kcg.conf` (SSL + SPA + API 프록시) -- **DB**: 211.208.115.83:5432/kcgdb (유저: kcg_app) +| 서버 | 역할 | 경로/설정 | +|------|------|----------| +| **rocky-211** (192.168.1.20) | Frontend + Backend | `/devdata/services/kcg/` | +| | Frontend | nginx 정적 파일 (`/deploy/kcg/`) | +| | Backend | systemd `kcg-backend` (JDK 21, 2~4GB 힙) | +| | CI/CD | act runner Docker (node:24) | +| **redis-211** (192.168.1.18:32023) | Prediction | `/home/apps/kcg-prediction/` | +| | | systemd `kcg-prediction` (uvicorn 8001, venv) | +| **도메인** | | https://kcg.gc-si.dev | +| **nginx** | | `/etc/nginx/conf.d/kcg.conf` (SSL + SPA + API 프록시) | +| **DB** | kcgdb | 211.208.115.83:5432/kcgdb (유저: kcg_app, pw: Kcg2026monitor) | +| **DB** | snpdb | 211.208.115.83:5432/snpdb (유저: snp, pw: snp#8932, 읽기 전용) | ## 팀 규칙 diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java index b6fd0a7..9fd100e 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.List; import java.util.Map; @RestController @@ -24,12 +23,6 @@ public class VesselAnalysisController { @GetMapping public ResponseEntity> getVesselAnalysis( @RequestParam(required = false) String region) { - - List results = vesselAnalysisService.getLatestResults(); - - return ResponseEntity.ok(Map.of( - "count", results.size(), - "items", results - )); + return ResponseEntity.ok(vesselAnalysisService.getLatestResultsWithStats()); } } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java index 0dfb546..57fc091 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -1,6 +1,7 @@ package gc.mda.kcg.domain.analysis; import gc.mda.kcg.config.CacheConfig; +import gc.mda.kcg.domain.fleet.GroupPolygonService; import lombok.RequiredArgsConstructor; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; @@ -8,10 +9,7 @@ import org.springframework.stereotype.Service; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; @Service @RequiredArgsConstructor @@ -19,38 +17,81 @@ public class VesselAnalysisService { private final VesselAnalysisResultRepository repository; private final CacheManager cacheManager; + private final GroupPolygonService groupPolygonService; /** - * 최근 1시간 내 분석 결과를 반환한다. mmsi별 최신 1건만. - * Caffeine 캐시(TTL 5분) 적용. + * 최근 2시간 내 분석 결과 + 집계 통계를 반환한다. + * mmsi별 최신 1건만. Caffeine 캐시(TTL 5분) 적용. */ @SuppressWarnings("unchecked") - public List getLatestResults() { + public Map getLatestResultsWithStats() { Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS); if (cache != null) { - Cache.ValueWrapper wrapper = cache.get("data"); + Cache.ValueWrapper wrapper = cache.get("data_with_stats"); if (wrapper != null) { - return (List) wrapper.get(); + return (Map) wrapper.get(); } } Instant since = Instant.now().minus(2, ChronoUnit.HOURS); - // mmsi별 최신 analyzed_at 1건만 유지 Map latest = new LinkedHashMap<>(); for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) { latest.merge(r.getMmsi(), r, (old, cur) -> cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old); } + // 집계 통계 — 같은 루프에서 계산 + int dark = 0, spoofing = 0, critical = 0, high = 0, medium = 0, low = 0; + Set clusterIds = new HashSet<>(); + for (VesselAnalysisResult r : latest.values()) { + if (Boolean.TRUE.equals(r.getIsDark())) dark++; + if (r.getSpoofingScore() != null && r.getSpoofingScore() > 0.5) spoofing++; + String level = r.getRiskLevel(); + if (level != null) { + switch (level) { + case "CRITICAL" -> critical++; + case "HIGH" -> high++; + case "MEDIUM" -> medium++; + default -> low++; + } + } else { + low++; + } + if (r.getClusterId() != null && r.getClusterId() >= 0) { + clusterIds.add(r.getClusterId()); + } + } + + // 어구 통계 — group_polygon_snapshots 기반 + Map gearStats = groupPolygonService.getGearStats(); + + Map stats = new LinkedHashMap<>(); + stats.put("total", latest.size()); + stats.put("dark", dark); + stats.put("spoofing", spoofing); + stats.put("critical", critical); + stats.put("high", high); + stats.put("medium", medium); + stats.put("low", low); + stats.put("clusterCount", clusterIds.size()); + stats.put("gearGroups", gearStats.get("gearGroups")); + stats.put("gearCount", gearStats.get("gearCount")); + List results = latest.values().stream() .sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed()) .map(VesselAnalysisDto::from) .toList(); + Map response = Map.of( + "count", results.size(), + "items", results, + "stats", stats + ); + if (cache != null) { - cache.put("data", results); + cache.put("data_with_stats", response); } - return results; + return response; } } diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java index 6e3790f..7dcb2c6 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java @@ -58,6 +58,29 @@ public class GroupPolygonService { ORDER BY snapshot_time DESC """; + private static final String GEAR_STATS_SQL = """ + SELECT COUNT(*) AS gear_groups, + COALESCE(SUM(member_count), 0) AS gear_count + FROM kcg.group_polygon_snapshots + WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots) + AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE') + """; + + /** + * 어구 그룹 집계 통계 (최신 스냅샷 기준). + */ + public Map getGearStats() { + try { + return jdbcTemplate.queryForObject(GEAR_STATS_SQL, (rs, rowNum) -> Map.of( + "gearGroups", rs.getInt("gear_groups"), + "gearCount", rs.getInt("gear_count") + )); + } catch (Exception e) { + log.warn("getGearStats failed: {}", e.getMessage()); + return Map.of("gearGroups", 0, "gearCount", 0); + } + } + /** * 최신 스냅샷의 전체 그룹 폴리곤 목록 (5분 캐시). */ diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index cad840f..22e4fb1 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,24 @@ ## [Unreleased] +## [2026-03-24.3] + +### 추가 +- 가상 선박 마커: 선단/어구 그룹 멤버를 ship-triangle 아이콘으로 표시 (COG 회전 + zoom interpolate) +- 어구 겹침 해결: queryRenderedFeatures → 다중 선택 팝업 + 호버 하이라이트 +- AI 분석 통계 서버사이드 전환: dark/spoofing/risk/cluster/gear 집계를 Backend에서 계산 + +### 변경 +- cnFishingSuspects에 모선 MMSI 포함 (어구 패턴에서 모선명 추출 → 동일명 선박 추가) +- AI 분석 패널: 클라이언트 사이드 stats 계산 로직 완전 제거 (14K+ 선박 순회 useMemo 삭제) +- Backend /api/vessel-analysis 응답에 stats 필드 추가 (집계 통계 서버 제공) +- GroupPolygonService에 어구 집계 SQL 추가 (gearGroups/gearCount) +- FleetClusterLayer: 패널 아코디언 전환 (하나만 열림), 높이 제한 min(45vh, 400px) +- vessel_store.py: COG bearing 계산 (마지막 2점 좌표 기반 atan2) + +### 수정 +- 어구 줌인 최대 제한 (maxZoom: 12) + ## [2026-03-24.2] ### 추가 diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index a9b80b9..e8f0368 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -11,7 +11,6 @@ interface Props { isLoading: boolean; analysisMap: Map; ships: Ship[]; - allShips?: Ship[]; onShipSelect?: (mmsi: string) => void; onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; onExpandedChange?: (expanded: boolean) => void; @@ -74,7 +73,7 @@ const LEGEND_LINES = [ '스푸핑: 순간이동+SOG급변+BD09 종합', ]; -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad, onExpandedChange }: Props) { +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect, onTrackLoad, onExpandedChange }: Props) { const [expanded, setExpanded] = useLocalStorage('analysisPanelExpanded', false); const toggleExpanded = () => { const next = !expanded; @@ -90,26 +89,6 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, 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(); - 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[] = []; @@ -276,15 +255,15 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, 선단수 {stats.clusterCount} - {gearStats.groups > 0 && ( + {stats.gearGroups > 0 && ( <>
어구그룹 - {gearStats.groups} + {stats.gearGroups}
어구수 - {gearStats.count} + {stats.gearCount}
)} diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index c5e5a29..bda67b1 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -37,15 +37,19 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS; const [companies, setCompanies] = useState>(new Map()); const [expandedFleet, setExpandedFleet] = useState(null); - const [sectionExpanded, setSectionExpanded] = useState>({ - fleet: true, inZone: true, outZone: true, - }); - const toggleSection = (key: string) => setSectionExpanded(prev => ({ ...prev, [key]: !prev[key] })); + const [activeSection, setActiveSection] = useState('fleet'); + const toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key); const [hoveredFleetId, setHoveredFleetId] = useState(null); const [expandedGearGroup, setExpandedGearGroup] = useState(null); const [selectedGearGroup, setSelectedGearGroup] = useState(null); // 폴리곤 호버 툴팁 const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null); + // 어구 다중 선택 팝업 + const [gearPickerPopup, setGearPickerPopup] = useState<{ + lng: number; lat: number; + candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[]; + } | null>(null); + const [pickerHoveredGroup, setPickerHoveredGroup] = useState(null); const { current: mapRef } = useMap(); const registeredRef = useRef(false); const dataRef = useRef<{ shipMap: Map; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom }); @@ -80,14 +84,10 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS setHoveredFleetId(null); setHoverTooltip(prev => prev?.type === 'fleet' ? null : prev); }; - const onFleetClick = (e: MapLayerMouseEvent) => { - const feat = e.features?.[0]; - if (!feat) return; - const cid = feat.properties?.clusterId as number | undefined; - if (cid == null) return; + const handleFleetSelect = (cid: number) => { const d = dataRef.current; setExpandedFleet(prev => prev === cid ? null : cid); - setSectionExpanded(prev => ({ ...prev, fleet: true })); + setActiveSection('fleet'); const group = d.groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid); if (!group || group.members.length === 0) return; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; @@ -100,6 +100,44 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); }; + // 통합 클릭 핸들러: 선단+어구 모든 폴리곤 겹침 판정 + const onPolygonClick = (e: MapLayerMouseEvent) => { + const features = map.queryRenderedFeatures(e.point, { layers: allLayers }); + if (features.length === 0) return; + + // 후보 수집 (선단 + 어구 통합) + const seen = new Set(); + const candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[] = []; + for (const f of features) { + const cid = f.properties?.clusterId as number | undefined; + const gearName = f.properties?.name as string | undefined; + if (cid != null) { + const key = `fleet-${cid}`; + if (seen.has(key)) continue; + seen.add(key); + const d = dataRef.current; + const g = d.groupPolygons?.fleetGroups.find(x => Number(x.groupKey) === cid); + candidates.push({ name: g?.groupLabel ?? `선단 #${cid}`, count: g?.memberCount ?? 0, inZone: false, isFleet: true, clusterId: cid }); + } else if (gearName) { + if (seen.has(gearName)) continue; + seen.add(gearName); + candidates.push({ name: gearName, count: f.properties?.gearCount ?? 0, inZone: f.properties?.inZone === 1, isFleet: false }); + } + } + + if (candidates.length === 1) { + // 단일 → 바로 선택 + const c = candidates[0]; + if (c.isFleet && c.clusterId != null) handleFleetSelect(c.clusterId); + else handleGearGroupZoomFromMap(c.name); + } else if (candidates.length > 1) { + // 다중 → 선택 팝업 + setGearPickerPopup({ lng: e.lngLat.lng, lat: e.lngLat.lat, candidates }); + } + }; + + const onFleetClick = onPolygonClick; + const onGearEnter = (e: MapLayerMouseEvent) => { setCursor('pointer'); const feat = e.features?.[0]; @@ -113,15 +151,10 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS setCursor(''); setHoverTooltip(prev => prev?.type === 'gear' ? null : prev); }; - const onGearClick = (e: MapLayerMouseEvent) => { - const feat = e.features?.[0]; - if (!feat) return; - const name = feat.properties?.name as string | undefined; - if (!name) return; + const handleGearGroupZoomFromMap = (name: string) => { const d = dataRef.current; setSelectedGearGroup(prev => prev === name ? null : name); setExpandedGearGroup(name); - setSectionExpanded(prev => ({ ...prev, inZone: true, outZone: true })); requestAnimationFrame(() => { document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); @@ -140,6 +173,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); }; + const onGearClick = onPolygonClick; + const register = () => { const ready = allLayers.every(id => map.getLayer(id)); if (!ready) return; @@ -299,6 +334,49 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS return { type: 'FeatureCollection', features }; }, [groupPolygons?.gearInZoneGroups, groupPolygons?.gearOutZoneGroups]); + // 가상 선박 마커 GeoJSON (API members + shipMap heading 보정) + const memberMarkersGeoJson = useMemo((): GeoJSON => { + const features: GeoJSON.Feature[] = []; + if (!groupPolygons) return { type: 'FeatureCollection', features }; + + const addMember = (m: { mmsi: string; name: string; lat: number; lon: number; cog: number; isParent: boolean; role: string }, groupKey: string, groupType: string, color: string) => { + // shipMap에서 실제 heading 조회 (AIS 하드웨어 값, API cog보다 정확) + const realShip = shipMap.get(m.mmsi); + const heading = realShip?.heading ?? m.cog ?? 0; + const lat = realShip?.lat ?? m.lat; + const lon = realShip?.lng ?? m.lon; + features.push({ + type: 'Feature', + properties: { mmsi: m.mmsi, name: m.name, groupKey, groupType, role: m.role, isParent: m.isParent ? 1 : 0, color, cog: heading, baseSize: m.isParent ? 0.18 : 0.14 }, + geometry: { type: 'Point', coordinates: [lon, lat] }, + }); + }; + + // 선단 멤버 + for (const g of groupPolygons.fleetGroups) { + for (const m of g.members) addMember(m, g.groupKey, 'FLEET', g.color); + } + // 어구 멤버 + for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { + const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316'; + for (const m of g.members) addMember(m, g.groupKey, g.groupType, color); + } + return { type: 'FeatureCollection', features }; + }, [groupPolygons, shipMap]); + + // picker 호버 하이라이트 (선단 + 어구 통합) + const pickerHighlightGeoJson = useMemo((): GeoJSON => { + if (!pickerHoveredGroup || !groupPolygons) return { type: 'FeatureCollection', features: [] }; + // 선단에서 찾기 + const fleet = groupPolygons.fleetGroups.find(x => String(x.groupKey) === pickerHoveredGroup); + if (fleet?.polygon) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: fleet.polygon }] }; + // 어구에서 찾기 + const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const g = all.find(x => x.groupKey === pickerHoveredGroup); + if (!g?.polygon) return { type: 'FeatureCollection', features: [] }; + return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] }; + }, [pickerHoveredGroup, groupPolygons]); + // 선단 목록 (멤버 수 내림차순) const fleetList = useMemo(() => { if (!groupPolygons) return []; @@ -368,6 +446,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS display: 'flex', flexDirection: 'column', pointerEvents: 'auto', + maxHeight: 'min(45vh, 400px)', }; const headerStyle: React.CSSProperties = { @@ -484,6 +563,90 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS /> + {/* 가상 선박 마커 (API members 기반 — 삼각형 아이콘 + 방향 + 줌 스케일) */} + + + + + {/* 어구 picker 호버 하이라이트 */} + + + + + + {/* 어구 다중 선택 팝업 */} + {gearPickerPopup && ( + { setGearPickerPopup(null); setPickerHoveredGroup(null); }} + closeOnClick={false} className="gl-popup" maxWidth="220px"> +
+
+ 겹친 그룹 ({gearPickerPopup.candidates.length}) +
+ {gearPickerPopup.candidates.map(c => ( +
setPickerHoveredGroup(c.isFleet ? String(c.clusterId) : c.name)} + onMouseLeave={() => setPickerHoveredGroup(null)} + onClick={() => { + if (c.isFleet && c.clusterId != null) { + setExpandedFleet(prev => prev === c.clusterId! ? null : c.clusterId!); + setActiveSection('fleet'); + handleFleetZoom(c.clusterId); + } else { + handleGearGroupZoom(c.name); + } + setGearPickerPopup(null); + setPickerHoveredGroup(null); + }} + style={{ + cursor: 'pointer', padding: '3px 6px', + borderLeft: `3px solid ${c.isFleet ? '#63b3ed' : c.inZone ? '#dc2626' : '#f97316'}`, + marginBottom: 2, borderRadius: 2, + backgroundColor: pickerHoveredGroup === (c.isFleet ? String(c.clusterId) : c.name) ? 'rgba(255,255,255,0.1)' : 'transparent', + }}> + {c.isFleet ? '⚓ ' : ''}{c.name} + ({c.count}{c.isFleet ? '척' : '개'}) +
+ ))} +
+
+ )} + {/* 폴리곤 호버 툴팁 */} {hoverTooltip && (() => { if (hoverTooltip.type === 'fleet') { @@ -551,18 +714,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS {/* 선단 목록 패널 */}
-
{/* ── 선단 현황 섹션 ── */} -
toggleSection('fleet')}> +
toggleSection('fleet')}> 선단 현황 ({fleetList.length}개)
- {sectionExpanded.fleet && ( -
+ {activeSection === 'fleet' && ( +
{fleetList.length === 0 ? (
선단 데이터 없음 @@ -712,18 +874,16 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS )} {/* ── 조업구역내 어구 그룹 섹션 ── */} - {inZoneGearGroups.length > 0 && ( - <> -
toggleSection('inZone')}> +
toggleSection('inZone')}> 조업구역내 어구 ({inZoneGearGroups.length}개)
- {sectionExpanded.inZone && ( -
+ {activeSection === 'inZone' && ( +
{inZoneGearGroups.map(g => { const name = g.groupKey; const isOpen = expandedGearGroup === name; @@ -741,6 +901,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'} setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zoneName}`}>{name} + {parentMember && } {zoneName} ({gearMembers.length}) @@ -762,22 +923,18 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS })}
)} - - )} {/* ── 비허가 어구 그룹 섹션 ── */} - {outZoneGearGroups.length > 0 && ( - <> -
toggleSection('outZone')}> +
toggleSection('outZone')}> 비허가 어구 ({outZoneGearGroups.length}개)
- {sectionExpanded.outZone && ( -
+ {activeSection === 'outZone' && ( +
{outZoneGearGroups.map(g => { const name = g.groupKey; const isOpen = expandedGearGroup === name; @@ -822,6 +979,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS > {name} + {parentMember && } ({gearMembers.length}개) @@ -896,9 +1054,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS })}
)} - - )} -
); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 62aaba6..45a3cc7 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -196,7 +196,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF 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 }, + { padding: 60, duration: 1500, maxZoom: 12 }, ); }, []); @@ -508,6 +508,25 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF : koreaFilters.cnFishing ? 'cnFishing' : null; + // AI 분석 가상 선박 마커 GeoJSON (분석 대상 선박을 삼각형으로 표시) + const analysisShipMarkersGeoJson = useMemo(() => { + const features: GeoJSON.Feature[] = []; + if (!vesselAnalysis || !analysisActiveFilter) return { type: 'FeatureCollection' as const, features }; + const allS = allShips ?? ships; + for (const s of allS) { + const dto = vesselAnalysis.analysisMap.get(s.mmsi); + if (!dto) continue; + const level = dto.algorithms.riskScore.level; + const color = level === 'CRITICAL' ? '#ef4444' : level === 'HIGH' ? '#f97316' : level === 'MEDIUM' ? '#eab308' : '#22c55e'; + features.push({ + type: 'Feature', + properties: { mmsi: s.mmsi, name: s.name || s.mmsi, cog: s.heading ?? 0, color, baseSize: 0.16 }, + geometry: { type: 'Point', coordinates: [s.lng, s.lat] }, + }); + } + return { type: 'FeatureCollection' as const, features }; + }, [vesselAnalysis, analysisActiveFilter, allShips, ships]); + const analysisDeckLayers = useAnalysisDeckLayers( vesselAnalysis?.analysisMap ?? new Map(), allShips ?? ships, @@ -679,6 +698,37 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF /> )} + {/* AI 분석 가상 선박 마커 (삼각형 + 방향 + 줌 스케일) */} + {analysisActiveFilter && ( + + + + )} + {/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */} { const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]); if (active.length === 0) { if (activeBadgeFilter) setActiveBadgeFilter(null); return null; } - const gearPattern = /^.+?_\d+_\d+_?$/; const all = allShips ?? ships; const getShipsForFilter = (k: string): Ship[] => { switch (k) { @@ -710,7 +759,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF case 'cableWatch': return all.filter(s => cableWatchSuspects.has(s.mmsi)); case 'dokdoWatch': return all.filter(s => dokdoWatchSuspects.has(s.mmsi)); case 'ferryWatch': return all.filter(s => s.mtCategory === 'passenger'); - case 'cnFishing': return all.filter(s => gearPattern.test(s.name || '')); + case 'cnFishing': { + const gearRe = /^(.+?)_\d+_\d+_?$/; + const gears = all.filter(s => gearRe.test(s.name || '')); + const parentNames = new Set(gears.map(s => { const m = (s.name || '').match(gearRe); return m ? m[1].trim() : ''; }).filter(Boolean)); + const parents = all.filter(s => parentNames.has((s.name || '').trim()) && !gearRe.test(s.name || '')); + return [...gears, ...parents]; + } default: return []; } }; @@ -866,7 +921,6 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF isLoading={vesselAnalysis.isLoading} analysisMap={vesselAnalysis.analysisMap} ships={allShips ?? ships} - allShips={allShips ?? ships} onShipSelect={handleAnalysisShipSelect} onTrackLoad={handleTrackLoad} onExpandedChange={setAnalysisPanelOpen} diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index efa97d9..d31ede0 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -256,15 +256,36 @@ export function useKoreaFilters( return result; }, [koreaShips, filters.dokdoWatch]); - // 중국어선 의심 선박 Set + // 중국어선 의심 선박 Set (어구 + 모선 포함) const cnFishingSuspects = useMemo(() => { if (!filters.cnFishing) return new Set(); const result = new Set(); + const gearRe = /^(.+?)_\d+_\d+_?$/; + + // 모선명 → mmsi 맵 (어구가 아닌 선박) + const nameToMmsi = new Map(); + for (const s of koreaShips) { + const nm = (s.name || '').trim(); + if (nm && !gearRe.test(nm)) nameToMmsi.set(nm, s.mmsi); + } + + // 어구 + CN 어선 추가, 모선명 수집 + const parentNames = new Set(); for (const s of koreaShips) { const isCnFishing = s.flag === 'CN' && s.mtCategory === 'fishing'; - const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || ''); - if (isCnFishing || isGearPattern) result.add(s.mmsi); + const m = (s.name || '').match(gearRe); + if (isCnFishing || m) { + result.add(s.mmsi); + if (m) parentNames.add(m[1].trim()); + } } + + // 모선도 포함 + for (const pName of parentNames) { + const mmsi = nameToMmsi.get(pName); + if (mmsi) result.add(mmsi); + } + return result; }, [filters.cnFishing, koreaShips]); diff --git a/frontend/src/hooks/useVesselAnalysis.ts b/frontend/src/hooks/useVesselAnalysis.ts index ea53b32..e33b727 100644 --- a/frontend/src/hooks/useVesselAnalysis.ts +++ b/frontend/src/hooks/useVesselAnalysis.ts @@ -1,21 +1,13 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import type { VesselAnalysisDto, RiskLevel } from '../types'; +import type { VesselAnalysisDto } from '../types'; import { fetchVesselAnalysis } from '../services/vesselAnalysis'; +import type { AnalysisStats } from '../services/vesselAnalysis'; + +export type { AnalysisStats }; 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; stats: AnalysisStats; @@ -27,10 +19,12 @@ export interface UseVesselAnalysisResult { const EMPTY_STATS: AnalysisStats = { total: 0, critical: 0, high: 0, medium: 0, low: 0, dark: 0, spoofing: 0, clusterCount: 0, + gearGroups: 0, gearCount: 0, }; export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult { const mapRef = useRef>(new Map()); + const statsRef = useRef(EMPTY_STATS); const [version, setVersion] = useState(0); const [isLoading, setIsLoading] = useState(false); const [lastUpdated, setLastUpdated] = useState(0); @@ -39,7 +33,7 @@ export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult { if (!enabled) return; setIsLoading(true); try { - const items = await fetchVesselAnalysis(); + const data = await fetchVesselAnalysis(); const now = Date.now(); const map = mapRef.current; @@ -50,10 +44,13 @@ export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult { } // 새 결과 merge - for (const item of items) { + for (const item of data.items) { map.set(item.mmsi, item); } + // 서버에서 계산된 stats 직접 사용 + statsRef.current = data.stats; + setLastUpdated(now); setVersion(v => v + 1); } catch { @@ -71,32 +68,7 @@ export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult { 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(); - - 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]); - + // clusters는 vesselList/마커 렌더링에 필요 — 경량 순회이므로 유지 const clusters = useMemo((): Map => { const result = new Map(); for (const [mmsi, dto] of analysisMap) { @@ -110,5 +82,5 @@ export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult { // eslint-disable-next-line react-hooks/exhaustive-deps }, [version]); - return { analysisMap, stats, clusters, isLoading, lastUpdated }; + return { analysisMap, stats: statsRef.current, clusters, isLoading, lastUpdated }; } diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index 257268b..b3072be 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -2,13 +2,41 @@ import type { VesselAnalysisDto } from '../types'; const API_BASE = '/api/kcg'; -export async function fetchVesselAnalysis(): Promise { +export interface AnalysisStats { + total: number; + critical: number; + high: number; + medium: number; + low: number; + dark: number; + spoofing: number; + clusterCount: number; + gearGroups: number; + gearCount: number; +} + +export interface VesselAnalysisResponse { + count: number; + items: VesselAnalysisDto[]; + stats: AnalysisStats; +} + +const EMPTY_STATS: AnalysisStats = { + total: 0, critical: 0, high: 0, medium: 0, low: 0, + dark: 0, spoofing: 0, clusterCount: 0, gearGroups: 0, gearCount: 0, +}; + +export async function fetchVesselAnalysis(): Promise { 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 ?? []; + if (!res.ok) return { count: 0, items: [], stats: EMPTY_STATS }; + const data = await res.json() as VesselAnalysisResponse; + return { + count: data.count ?? 0, + items: data.items ?? [], + stats: data.stats ?? EMPTY_STATS, + }; } export interface FleetCompany { diff --git a/prediction/cache/vessel_store.py b/prediction/cache/vessel_store.py index f16b711..5e207d6 100644 --- a/prediction/cache/vessel_store.py +++ b/prediction/cache/vessel_store.py @@ -318,18 +318,32 @@ class VesselStore: return self._static_info.get(mmsi, {}) def get_all_latest_positions(self) -> dict[str, dict]: - """모든 선박의 최신 위치 반환. {mmsi: {lat, lon, sog, cog, timestamp, name}}""" + """모든 선박의 최신 위치 반환. {mmsi: {lat, lon, sog, cog, timestamp, name}} + cog는 마지막 2점의 좌표로 bearing 계산.""" + import math result: dict[str, dict] = {} for mmsi, df in self._tracks.items(): if df is None or len(df) == 0: continue last = df.iloc[-1] info = self._static_info.get(mmsi, {}) + + # COG: 마지막 2점으로 bearing 계산 + cog = 0.0 + if len(df) >= 2: + prev = df.iloc[-2] + lat1 = math.radians(float(prev['lat'])) + lat2 = math.radians(float(last['lat'])) + dlon = math.radians(float(last['lon']) - float(prev['lon'])) + x = math.sin(dlon) * math.cos(lat2) + y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon) + cog = (math.degrees(math.atan2(x, y)) + 360) % 360 + result[mmsi] = { 'lat': float(last['lat']), 'lon': float(last['lon']), - 'sog': float(last.get('sog', 0) or 0), - 'cog': float(last.get('cog', 0) or 0), + 'sog': float(last.get('sog', 0) or last.get('raw_sog', 0) or 0), + 'cog': cog, 'timestamp': last.get('timestamp'), 'name': info.get('name', ''), }