feat: AI 분석 통계 서버사이드 전환 + 어구/선단 UI 개선

- Backend: /api/vessel-analysis 응답에 stats 집계 필드 추가
- Backend: GroupPolygonService.getGearStats() 어구 SQL 집계
- Frontend: 클라이언트 사이드 stats/gearStats 계산 로직 완전 제거
- Frontend: 가상 선박 마커, 어구 겹침 팝업, 패널 아코디언
- Frontend: cnFishingSuspects에 모선 포함
- Python: vessel_store COG bearing 계산 (마지막 2점 기반)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-24 15:55:15 +09:00
부모 f5ffb4c079
커밋 433141a3e8
12개의 변경된 파일561개의 추가작업 그리고 176개의 파일을 삭제

159
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
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)
prediction/ # FastAPI placeholder
deploy/ # systemd + nginx 배포 설정
.gitea/workflows/deploy.yml # CI/CD (main merge → 자동 빌드/배포)
```
## 주요 기능
### 한국 현황 대시보드
- **선박 모니터링**: 13K+ AIS 선박, MapLibre GPU-side filter, 카테고리/국적 토글
- **감시 탭**: 불법어선, 환적, 다크베셀, 해저케이블, 독도감시, 중국어선감시
- **선단/어구 폴리곤**: Python 서버사이드 생성 (Shapely + PostGIS) → API GeoJSON 렌더링
- FLEET 15개, GEAR_IN_ZONE 57개, GEAR_OUT_ZONE 45개 (5분 주기 갱신, 7일 히스토리)
- 가상 선박 마커 (ship-triangle + COG 회전 + zoom interpolate)
- 겹침 해결: queryRenderedFeatures → 다중 선택 팝업 + 호버 하이라이트
- **AI 분석**: Python 7단계 파이프라인, 위험도/다크베셀/스푸핑 deck.gl 오버레이
- **현장분석**: FieldAnalysisModal (어구/선단 분석 대시보드)
- **시설 레이어**: deck.gl IconLayer(SVG) + TextLayer, 줌 스케일 연동
### 이란 상황 대시보드
- **공습 리플레이**: 실데이터 Backend DB 기반 (더미↔API 토글)
- **유전/공항/군사시설**: deck.gl SVG 아이콘, 사막 대비 고채도 팔레트
- **센서 그래프**: 지진, 기압, 소음/방사선
- **위성지도/평면/3D Globe**: 3개 맵 모드
### 공통
- **웹폰트**: @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅
- **글꼴 크기 커스텀**: FontScalePanel (시설/선박/분석/지역 4그룹 × 0.5~2.0x)
- **LayerPanel**: 공통 트리 구조 (LayerTreeRenderer 재귀, 부모 캐스케이드)
- **인증**: Google OAuth + DEV LOGIN
- **localStorage**: 13개+ UI 상태 영속화
## 팀 스킬 사용 지침
### 중요: 스킬 실행 시 반드시 따라야 할 규칙
@ -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, 읽기 전용) |
## 팀 규칙

파일 보기

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

파일 보기

@ -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<VesselAnalysisDto> getLatestResults() {
public Map<String, Object> 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<VesselAnalysisDto>) wrapper.get();
return (Map<String, Object>) wrapper.get();
}
}
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);
}
// 집계 통계 같은 루프에서 계산
int dark = 0, spoofing = 0, critical = 0, high = 0, medium = 0, low = 0;
Set<Integer> 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<String, Integer> gearStats = groupPolygonService.getGearStats();
Map<String, Object> 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<VesselAnalysisDto> results = latest.values().stream()
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
.map(VesselAnalysisDto::from)
.toList();
Map<String, Object> 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;
}
}

파일 보기

@ -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<String, Integer> 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분 캐시).
*/

파일 보기

@ -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]
### 추가

파일 보기

@ -11,7 +11,6 @@ interface Props {
isLoading: boolean;
analysisMap: Map<string, VesselAnalysisDto>;
ships: Ship[];
allShips?: Ship[];
onShipSelect?: (mmsi: string) => void;
onTrackLoad?: (mmsi: string, coords: [number, number][]) => void;
onExpandedChange?: (expanded: boolean) => void;
@ -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<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[] = [];
@ -276,15 +255,15 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
</div>
{gearStats.groups > 0 && (
{stats.gearGroups > 0 && (
<>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.groups}</span>
<span style={{ ...valueStyle, color: '#f97316' }}>{stats.gearGroups}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.count}</span>
<span style={{ ...valueStyle, color: '#f97316' }}>{stats.gearCount}</span>
</div>
</>
)}

파일 보기

@ -37,15 +37,19 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS;
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
const [sectionExpanded, setSectionExpanded] = useState<Record<string, boolean>>({
fleet: true, inZone: true, outZone: true,
});
const toggleSection = (key: string) => setSectionExpanded(prev => ({ ...prev, [key]: !prev[key] }));
const [activeSection, setActiveSection] = useState<string | null>('fleet');
const toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key);
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(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<string | null>(null);
const { current: mapRef } = useMap();
const registeredRef = useRef(false);
const dataRef = useRef<{ shipMap: Map<string, Ship>; 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<string>();
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
/>
</Source>
{/* 가상 선박 마커 (API members 기반 — 삼각형 아이콘 + 방향 + 줌 스케일) */}
<Source id="group-member-markers" type="geojson" data={memberMarkersGeoJson}>
<Layer
id="group-member-icon"
type="symbol"
layout={{
'icon-image': 'ship-triangle',
'icon-size': ['interpolate', ['linear'], ['zoom'],
4, ['*', ['get', 'baseSize'], 0.9],
6, ['*', ['get', 'baseSize'], 1.2],
8, ['*', ['get', 'baseSize'], 1.8],
10, ['*', ['get', 'baseSize'], 2.6],
12, ['*', ['get', 'baseSize'], 3.2],
13, ['*', ['get', 'baseSize'], 4.0],
14, ['*', ['get', 'baseSize'], 4.8],
],
'icon-rotate': ['get', 'cog'],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6, 8, 8, 12, 10],
'text-offset': [0, 1.4],
'text-anchor': 'top',
'text-allow-overlap': false,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
}}
paint={{
'icon-color': ['get', 'color'],
'icon-halo-color': ['case', ['==', ['get', 'isParent'], 1], '#fbbf24', 'rgba(0,0,0,0.6)'],
'icon-halo-width': ['case', ['==', ['get', 'isParent'], 1], 2, 0.5],
'text-color': ['get', 'color'],
'text-halo-color': '#000000',
'text-halo-width': 1,
}}
/>
</Source>
{/* 어구 picker 호버 하이라이트 */}
<Source id="gear-picker-highlight" type="geojson" data={pickerHighlightGeoJson}>
<Layer id="gear-picker-highlight-fill" type="fill"
paint={{ 'fill-color': '#ffffff', 'fill-opacity': 0.25 }} />
<Layer id="gear-picker-highlight-line" type="line"
paint={{ 'line-color': '#ffffff', 'line-width': 2, 'line-dasharray': [3, 2] }} />
</Source>
{/* 어구 다중 선택 팝업 */}
{gearPickerPopup && (
<Popup longitude={gearPickerPopup.lng} latitude={gearPickerPopup.lat}
onClose={() => { setGearPickerPopup(null); setPickerHoveredGroup(null); }}
closeOnClick={false} className="gl-popup" maxWidth="220px">
<div style={{ fontSize: 10, fontFamily: FONT_MONO, padding: '4px 0' }}>
<div style={{ fontWeight: 700, marginBottom: 4, color: '#e2e8f0', padding: '0 6px' }}>
({gearPickerPopup.candidates.length})
</div>
{gearPickerPopup.candidates.map(c => (
<div key={c.isFleet ? `fleet-${c.clusterId}` : c.name}
onMouseEnter={() => 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',
}}>
<span style={{ color: c.isFleet ? '#63b3ed' : '#e2e8f0', fontSize: 9 }}>{c.isFleet ? '⚓ ' : ''}{c.name}</span>
<span style={{ color: '#64748b', marginLeft: 4 }}>({c.count}{c.isFleet ? '척' : '개'})</span>
</div>
))}
</div>
</Popup>
)}
{/* 폴리곤 호버 툴팁 */}
{hoverTooltip && (() => {
if (hoverTooltip.type === 'fleet') {
@ -551,18 +714,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
{/* 선단 목록 패널 */}
<div style={panelStyle}>
<div style={{ maxHeight: 500, overflowY: 'auto' }}>
{/* ── 선단 현황 섹션 ── */}
<div style={headerStyle} onClick={() => toggleSection('fleet')}>
<div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => toggleSection('fleet')}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
({fleetList.length})
</span>
<button type="button" style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
{sectionExpanded.fleet ? '▲' : '▼'}
{activeSection === 'fleet' ? '▲' : '▼'}
</button>
</div>
{sectionExpanded.fleet && (
<div style={{ padding: '4px 0' }}>
{activeSection === 'fleet' && (
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
@ -712,18 +874,16 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
)}
{/* ── 조업구역내 어구 그룹 섹션 ── */}
{inZoneGearGroups.length > 0 && (
<>
<div style={{ ...headerStyle, borderTop: '1px solid rgba(220,38,38,0.35)' }} onClick={() => toggleSection('inZone')}>
<div style={{ ...headerStyle, borderTop: '1px solid rgba(220,38,38,0.35)', cursor: 'pointer' }} onClick={() => toggleSection('inZone')}>
<span style={{ fontWeight: 700, color: '#dc2626', letterSpacing: 0.3 }}>
({inZoneGearGroups.length})
</span>
<button style={toggleButtonStyle} aria-label="조업구역내 어구 접기/펴기">
{sectionExpanded.inZone ? '▲' : '▼'}
{activeSection === 'inZone' ? '▲' : '▼'}
</button>
</div>
{sectionExpanded.inZone && (
<div style={{ padding: '4px 0' }}>
{activeSection === 'inZone' && (
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{inZoneGearGroups.map(g => {
const name = g.groupKey;
const isOpen = expandedGearGroup === name;
@ -741,6 +901,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
<span onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'}</span>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: accentColor, flexShrink: 0 }} />
<span onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name}${zoneName}`}>{name}</span>
{parentMember && <span style={{ color: '#fbbf24', fontSize: 8, flexShrink: 0 }} title={`모선: ${parentMember.name}`}></span>}
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({gearMembers.length})</span>
<button type="button" onClick={e => { e.stopPropagation(); handleGearGroupZoom(name); }} style={{ background: 'none', border: `1px solid rgba(220,38,38,0.5)`, borderRadius: 3, color: accentColor, fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }} title="이 어구 그룹으로 지도 이동">zoom</button>
@ -762,22 +923,18 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
})}
</div>
)}
</>
)}
{/* ── 비허가 어구 그룹 섹션 ── */}
{outZoneGearGroups.length > 0 && (
<>
<div style={{ ...headerStyle, borderTop: '1px solid rgba(249,115,22,0.25)' }} onClick={() => toggleSection('outZone')}>
<div style={{ ...headerStyle, borderTop: '1px solid rgba(249,115,22,0.25)', cursor: 'pointer' }} onClick={() => toggleSection('outZone')}>
<span style={{ fontWeight: 700, color: '#f97316', letterSpacing: 0.3 }}>
({outZoneGearGroups.length})
</span>
<button type="button" style={toggleButtonStyle} aria-label="비허가 어구 접기/펴기">
{sectionExpanded.outZone ? '▲' : '▼'}
{activeSection === 'outZone' ? '▲' : '▼'}
</button>
</div>
{sectionExpanded.outZone && (
<div style={{ padding: '4px 0' }}>
{activeSection === 'outZone' && (
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{outZoneGearGroups.map(g => {
const name = g.groupKey;
const isOpen = expandedGearGroup === name;
@ -822,6 +979,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
>
{name}
</span>
{parentMember && <span style={{ color: '#fbbf24', fontSize: 8, flexShrink: 0 }} title={`모선: ${parentMember.name}`}></span>}
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
({gearMembers.length})
</span>
@ -896,9 +1054,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
})}
</div>
)}
</>
)}
</div>
</div>
</>
);

파일 보기

@ -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 && (
<Source id="analysis-ship-markers" type="geojson" data={analysisShipMarkersGeoJson}>
<Layer
id="analysis-ship-icon"
type="symbol"
layout={{
'icon-image': 'ship-triangle',
'icon-size': ['interpolate', ['linear'], ['zoom'],
4, ['*', ['get', 'baseSize'], 1.0],
6, ['*', ['get', 'baseSize'], 1.3],
8, ['*', ['get', 'baseSize'], 2.0],
10, ['*', ['get', 'baseSize'], 2.8],
12, ['*', ['get', 'baseSize'], 3.5],
13, ['*', ['get', 'baseSize'], 4.2],
14, ['*', ['get', 'baseSize'], 5.0],
],
'icon-rotate': ['get', 'cog'],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
}}
paint={{
'icon-color': ['get', 'color'],
'icon-halo-color': 'rgba(0,0,0,0.6)',
'icon-halo-width': 0.5,
}}
/>
</Source>
)}
{/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */}
<DeckGLOverlay layers={[
...staticDeckLayers,
@ -700,7 +750,6 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
{(() => {
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}

파일 보기

@ -256,15 +256,36 @@ export function useKoreaFilters(
return result;
}, [koreaShips, filters.dokdoWatch]);
// 중국어선 의심 선박 Set
// 중국어선 의심 선박 Set (어구 + 모선 포함)
const cnFishingSuspects = useMemo(() => {
if (!filters.cnFishing) return new Set<string>();
const result = new Set<string>();
const gearRe = /^(.+?)_\d+_\d+_?$/;
// 모선명 → mmsi 맵 (어구가 아닌 선박)
const nameToMmsi = new Map<string, string>();
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<string>();
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]);

파일 보기

@ -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<string, VesselAnalysisDto>;
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<Map<string, VesselAnalysisDto>>(new Map());
const statsRef = useRef<AnalysisStats>(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<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]);
// clusters는 vesselList/마커 렌더링에 필요 — 경량 순회이므로 유지
const clusters = useMemo((): Map<number, string[]> => {
const result = new Map<number, string[]>();
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 };
}

파일 보기

@ -2,13 +2,41 @@ import type { VesselAnalysisDto } from '../types';
const API_BASE = '/api/kcg';
export async function fetchVesselAnalysis(): Promise<VesselAnalysisDto[]> {
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<VesselAnalysisResponse> {
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 {

파일 보기

@ -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', ''),
}