fix: hotfix 동기화 — history/detail candidate_count 안전 처리 #225
163
CLAUDE.md
163
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, 읽기 전용) |
|
||||
|
||||
## 팀 규칙
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
20
prediction/cache/vessel_store.py
vendored
20
prediction/cache/vessel_store.py
vendored
@ -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', ''),
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user