Compare commits

..

No commits in common. "main" and "feat/layer-panel-tree-svg" have entirely different histories.

196개의 변경된 파일1882개의 추가작업 그리고 28194개의 파일을 삭제

파일 보기

@ -148,10 +148,75 @@ jobs:
sleep 10
done
# ═══ Prediction (FastAPI) — CI/CD 제외, 수동 배포 ═══
# ssh redis-211 에서 수동 배포:
# scp prediction/*.py redis-211:/home/apps/kcg-prediction/
# ssh redis-211 "sudo systemctl restart kcg-prediction"
# ═══ Prediction (FastAPI → redis-211) ═══
- name: Deploy prediction via SSH
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
PRED_HOST: 192.168.1.18
PRED_PORT: 32023
run: |
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
SSH_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -p $PRED_PORT"
SCP_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -P $PRED_PORT"
REMOTE_DIR=/home/apps/kcg-prediction
# 코드 전송 (rsync 대체: tar + scp)
tar czf /tmp/prediction.tar.gz -C prediction --exclude='__pycache__' --exclude='venv' --exclude='.env' .
for attempt in 1 2 3; do
echo "SCP prediction attempt $attempt/3..."
if scp $SCP_OPTS /tmp/prediction.tar.gz root@$PRED_HOST:/tmp/prediction.tar.gz; then break; fi
[ "$attempt" -eq 3 ] && { echo "ERROR: SCP failed"; exit 1; }
sleep 10
done
# systemd 서비스 파일 전송
scp $SCP_OPTS deploy/kcg-prediction.service root@$PRED_HOST:/tmp/kcg-prediction.service
# 원격 설치 + 재시작 (단일 SSH — tar.gz는 SCP에서 이미 전송됨)
ssh $SSH_OPTS root@$PRED_HOST bash -s << 'SCRIPT'
set -e
REMOTE_DIR=/home/apps/kcg-prediction
mkdir -p $REMOTE_DIR
cd $REMOTE_DIR
# 코드 배포
tar xzf /tmp/prediction.tar.gz -C $REMOTE_DIR
# venv + 의존성
python3 -m venv venv 2>/dev/null || true
venv/bin/pip install -r requirements.txt -q
# SELinux 컨텍스트 (Rocky Linux)
chcon -R -t bin_t venv/bin/ 2>/dev/null || true
# systemd 서비스 갱신
if ! diff -q /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service >/dev/null 2>&1; then
cp /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service
systemctl daemon-reload
systemctl enable kcg-prediction
fi
# 재시작
systemctl restart kcg-prediction
# health 확인 (60초 — 초기 로드에 ~30초 소요)
for i in $(seq 1 12); do
if curl -sf http://localhost:8001/health > /dev/null 2>&1; then
echo "Prediction healthy (attempt ${i})"
rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service
exit 0
fi
sleep 5
done
echo "WARNING: Prediction health timeout (서비스는 시작됨, 초기 로드 진행 중)"
systemctl is-active kcg-prediction && echo "Service is active"
rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service
SCRIPT
echo "Prediction deployment completed"
- name: Cleanup
if: always()

4
.gitignore vendored
파일 보기

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

197
CLAUDE.md
파일 보기

@ -9,11 +9,10 @@
| 패키지 | 스택 | 비고 |
|--------|------|------|
| **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 궤적 |
| **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) | |
| **CI/CD** | Gitea Actions → nginx + systemd | main merge 시 자동 배포 |
## 빌드 및 실행
@ -30,135 +29,55 @@ npm run lint # ESLint 검증
### Backend
```bash
cd backend
sdk use java 21.0.9-amzn # JDK 21 필수
# 최초: application-local.yml 설정 필요
cp src/main/resources/application-local.yml.example src/main/resources/application-local.yml
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
# kcgdb (분석 결과)
psql -h 211.208.115.83 -U kcg_app -d kcgdb -f database/migration/009_group_polygons.sql
# snpdb (궤적 원본) — 읽기 전용, 별도 관리
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
```
## 프로젝트 구조
```
frontend/ # React 19 + Vite 7 + Tailwind CSS 4 + deck.gl
frontend/ # React 19 + Vite 7 + Tailwind CSS 4
├── src/
│ ├── 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)
│ ├── 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
├── package.json
└── vite.config.ts # Vite + 프록시 (/api/kcg → backend)
└── vite.config.ts # Vite + 프록시 (/api/kcg → backend, /signal-batch → wing)
backend/ # Spring Boot 3.2 + Java 21
backend/ # Spring Boot 3.2 + Java 17
├── src/main/java/gc/mda/kcg/
│ ├── 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)
│ ├── 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
└── pom.xml
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 # CREATE SCHEMA kcg
└── migration/001_initial_schema.sql # events, news, osint, users, login_history
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 → 자동 빌드/배포)
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 상태 영속화
## 팀 스킬 사용 지침
### 중요: 스킬 실행 시 반드시 따라야 할 규칙
@ -175,11 +94,6 @@ deploy/ # systemd + nginx 배포 설정
3. **API 응답 검증**: MR 생성, 봇 승인, 머지 API 호출 후 반드시 상태를 확인.
실패 시 사용자에게 알리고 중단.
**MR/머지는 사용자 명시적 요청 없이 절대 진행 금지:**
- `git push` 완료 후에는 "푸시 완료" 보고만 하고 **중단**
- MR 생성은 사용자가 `/mr`, `/release` 호출 또는 "MR 해줘" 등 명시적 요청 시에만
- **스킬 없이 Gitea API를 직접 호출하여 MR/머지를 진행하지 말 것** — 스킬 절차(사용자 확인 단계)를 우회하는 것과 동일
### 스킬 목록
- `/push` — 커밋 + 푸시 (해시 비교 → 커밋 메시지 확인 → 푸시)
- `/mr` — 커밋 + 푸시 + MR 생성 (릴리즈 노트 갱신 포함)
@ -194,47 +108,12 @@ deploy/ # systemd + nginx 배포 설정
## 배포
| 서버 | 역할 | 경로/설정 |
|------|------|----------|
| **rocky-211** (192.168.1.20) | Frontend + Backend | `/devdata/services/kcg/` |
| | Frontend | nginx 정적 파일 (`/deploy/kcg/`) |
| | Backend | systemd `kcg-backend` (JDK 21, 2~4GB 힙) |
| | CI/CD | act runner Docker (node:24) |
| **redis-211** (192.168.1.18:32023) | Prediction | `/home/apps/kcg-prediction/` |
| | | systemd `kcg-prediction` (uvicorn 8001, venv) |
| **도메인** | | https://kcg.gc-si.dev |
| **nginx** | | `/etc/nginx/conf.d/kcg.conf` (SSL + SPA + API 프록시) |
| **DB** | kcgdb | 211.208.115.83:5432/kcgdb (유저: kcg_app, pw: Kcg2026monitor) |
| **DB** | snpdb | 211.208.115.83:5432/snpdb (유저: snp, pw: snp#8932, 읽기 전용) |
## 디버그 도구 가이드
### 원칙
- 디버그/개발 전용 기능은 `import.meta.env.DEV` 가드로 감싸서 **프로덕션 빌드에서 코드 자체가 제거**되도록 구현
- Vite production 빌드 시 `import.meta.env.DEV = false` → dead code elimination → 번들 미포함
- 무거운 DB 조회, 통계 계산 등도 DEV 가드 안이면 프로덕션에 영향 없음
### 파일 구조
- 디버그 컴포넌트: `frontend/src/components/{도메인}/debug/` 디렉토리에 분리
- 메인 컴포넌트에서는 import + DEV 가드로만 연결:
```tsx
import { DebugTool } from './debug/DebugTool';
const debug = import.meta.env.DEV ? useDebugHook() : null;
// JSX:
{debug && <DebugTool ... />}
```
### 기존 디버그 도구
| 도구 | 위치 | 기능 |
|------|------|------|
| CoordDebugTool | `korea/debug/CoordDebugTool.tsx` | Ctrl+Click 좌표 표시 (DD/DMS, 다중 포인트) |
### 디버그 도구 분류 기준
다음에 해당하면 디버그 도구로 분류하고, 불확실하면 사용자에게 확인:
- 개발/검증 목적의 좌표/데이터 표시 도구
- 프로덕션 사용자에게 불필요한 진단 정보
- 임시 데이터 시각화, 성능 프로파일링
- 특정 조건에서만 활성화되는 테스트 기능
- **도메인**: 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)
## 팀 규칙

파일 보기

@ -26,7 +26,6 @@ public class AuthFilter extends OncePerRequestFilter {
private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis";
private static final String PREDICTION_PATH_PREFIX = "/api/prediction/";
private static final String FLEET_PATH_PREFIX = "/api/fleet-";
private static final String EVENTS_PATH_PREFIX = "/api/events";
private final JwtProvider jwtProvider;
@ -38,8 +37,7 @@ public class AuthFilter extends OncePerRequestFilter {
|| path.startsWith(CCTV_PATH_PREFIX)
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX)
|| path.startsWith(PREDICTION_PATH_PREFIX)
|| path.startsWith(FLEET_PATH_PREFIX)
|| path.startsWith(EVENTS_PATH_PREFIX);
|| path.startsWith(FLEET_PATH_PREFIX);
}
@Override

파일 보기

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

파일 보기

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

파일 보기

@ -21,7 +21,6 @@ public class CacheConfig {
public static final String SEISMIC = "seismic";
public static final String PRESSURE = "pressure";
public static final String VESSEL_ANALYSIS = "vessel-analysis";
public static final String GROUP_POLYGONS = "group-polygons";
@Bean
public CacheManager cacheManager() {
@ -30,8 +29,7 @@ public class CacheConfig {
OSINT_IRAN, OSINT_KOREA,
SATELLITES,
SEISMIC, PRESSURE,
VESSEL_ANALYSIS,
GROUP_POLYGONS
VESSEL_ANALYSIS
);
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(2, TimeUnit.DAYS)

파일 보기

@ -14,7 +14,7 @@ import java.util.List;
@Configuration
public class WebConfig {
@Value("${app.cors.allowed-origins:http://localhost:5174,http://localhost:5173}")
@Value("${app.cors.allowed-origins:http://localhost:5173}")
private List<String> allowedOrigins;
@Bean

파일 보기

@ -2,14 +2,12 @@ package gc.mda.kcg.domain.aircraft;
import gc.mda.kcg.collector.aircraft.AircraftCacheStore;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -22,24 +20,16 @@ public class AircraftController {
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
private final AircraftCacheStore cacheStore;
private final AircraftService aircraftService;
@GetMapping
public ResponseEntity<Map<String, Object>> getAircraft(
@RequestParam(defaultValue = "iran") String region,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
@RequestParam(defaultValue = "iran") String region) {
if (!VALID_REGIONS.contains(region)) {
return ResponseEntity.badRequest()
.body(Map.of("error", "유효하지 않은 region: " + region));
}
if (from != null && to != null) {
List<AircraftDto> results = aircraftService.getByDateRange(region, from, to);
return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results));
}
List<AircraftDto> aircraft = cacheStore.get(region);
long lastUpdated = cacheStore.getLastUpdated(region);

파일 보기

@ -7,7 +7,7 @@ import org.locationtech.jts.geom.Point;
import java.time.Instant;
@Entity
@Table(name = "aircraft_positions")
@Table(name = "aircraft_positions", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -1,17 +1,6 @@
package gc.mda.kcg.domain.aircraft;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.List;
public interface AircraftPositionRepository extends JpaRepository<AircraftPosition, Long> {
@Query("SELECT a FROM AircraftPosition a WHERE a.region = :region AND a.collectedAt BETWEEN :from AND :to ORDER BY a.collectedAt DESC")
List<AircraftPosition> findByRegionAndDateRange(
@Param("region") String region,
@Param("from") Instant from,
@Param("to") Instant to);
}

파일 보기

@ -1,51 +0,0 @@
package gc.mda.kcg.domain.aircraft;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AircraftService {
private final AircraftPositionRepository repository;
/**
* 시간 범위 항공기 위치를 조회하고 icao24 기준 최신 위치로 중복 제거하여 반환.
*/
public List<AircraftDto> getByDateRange(String region, Instant from, Instant to) {
List<AircraftPosition> positions = repository.findByRegionAndDateRange(region, from, to);
Map<String, AircraftDto> deduplicated = new LinkedHashMap<>();
for (AircraftPosition p : positions) {
deduplicated.putIfAbsent(p.getIcao24(), toDto(p));
}
return List.copyOf(deduplicated.values());
}
private AircraftDto toDto(AircraftPosition p) {
return AircraftDto.builder()
.icao24(p.getIcao24())
.callsign(p.getCallsign())
.lat(p.getPosition() != null ? p.getPosition().getY() : 0.0)
.lng(p.getPosition() != null ? p.getPosition().getX() : 0.0)
.altitude(p.getAltitude() != null ? p.getAltitude() : 0.0)
.velocity(p.getVelocity() != null ? p.getVelocity() : 0.0)
.heading(p.getHeading() != null ? p.getHeading() : 0.0)
.verticalRate(p.getVerticalRate() != null ? p.getVerticalRate() : 0.0)
.onGround(p.getOnGround() != null && p.getOnGround())
.category(p.getCategory())
.typecode(p.getTypecode())
.typeDesc(p.getTypeDesc())
.registration(p.getRegistration())
.operator(p.getOperator())
.squawk(p.getSquawk())
.lastSeen(p.getLastSeen() != null ? p.getLastSeen().toEpochMilli()
: p.getCollectedAt().toEpochMilli())
.build();
}
}

파일 보기

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

파일 보기

@ -9,7 +9,7 @@ import java.time.Instant;
import java.util.Map;
@Entity
@Table(name = "vessel_analysis_results")
@Table(name = "vessel_analysis_results", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -1,115 +1,56 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.domain.fleet.GroupPolygonService;
import gc.mda.kcg.config.CacheConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class VesselAnalysisService {
private final VesselAnalysisResultRepository repository;
private final GroupPolygonService groupPolygonService;
private static final long CACHE_TTL_MS = 2 * 60 * 60_000L; // 2시간
/** mmsi → 최신 분석 결과 (인메모리 캐시) */
private final Map<String, VesselAnalysisResult> cache = new ConcurrentHashMap<>();
private volatile Instant lastFetchTime = null;
private volatile long lastUpdatedAt = 0;
private final CacheManager cacheManager;
/**
* 최근 2시간 분석 결과 + 집계 통계.
* - 호출(warmup): 2시간 전체 조회 캐시 구축
* - 이후: lastFetchTime 이후 증분만 조회 캐시 병합
* - 2시간 초과 항목은 evict
* - 갱신 TTL 타이머 초기화
* 최근 1시간 분석 결과를 반환한다. mmsi별 최신 1건만.
* Caffeine 캐시(TTL 5분) 적용.
*/
public Map<String, Object> getLatestResultsWithStats() {
Instant now = Instant.now();
if (lastFetchTime == null || (System.currentTimeMillis() - lastUpdatedAt) > CACHE_TTL_MS) {
// warmup: 2시간 전체 조회
Instant since = now.minus(2, ChronoUnit.HOURS);
List<VesselAnalysisResult> rows = repository.findByAnalyzedAtAfter(since);
cache.clear();
for (VesselAnalysisResult r : rows) {
cache.merge(r.getMmsi(), r, (old, cur) ->
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
}
lastFetchTime = now;
lastUpdatedAt = System.currentTimeMillis();
log.info("vessel analysis cache warmup: {} vessels from DB", cache.size());
} else {
// 증분: lastFetchTime 이후만 조회
List<VesselAnalysisResult> rows = repository.findByAnalyzedAtAfter(lastFetchTime);
if (!rows.isEmpty()) {
for (VesselAnalysisResult r : rows) {
cache.merge(r.getMmsi(), r, (old, cur) ->
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
}
lastUpdatedAt = System.currentTimeMillis();
log.debug("vessel analysis incremental merge: {} new rows", rows.size());
}
lastFetchTime = now;
}
// 2시간 초과 항목 evict
Instant cutoff = now.minus(2, ChronoUnit.HOURS);
cache.entrySet().removeIf(e -> e.getValue().getAnalyzedAt().isBefore(cutoff));
// 집계 통계
int dark = 0, spoofing = 0, critical = 0, high = 0, medium = 0, low = 0;
Set<Integer> clusterIds = new HashSet<>();
for (VesselAnalysisResult r : cache.values()) {
if (Boolean.TRUE.equals(r.getIsDark())) dark++;
if (r.getSpoofingScore() != null && r.getSpoofingScore() > 0.5) spoofing++;
String level = r.getRiskLevel();
if (level != null) {
switch (level) {
case "CRITICAL" -> critical++;
case "HIGH" -> high++;
case "MEDIUM" -> medium++;
default -> low++;
}
} else {
low++;
}
if (r.getClusterId() != null && r.getClusterId() >= 0) {
clusterIds.add(r.getClusterId());
@SuppressWarnings("unchecked")
public List<VesselAnalysisDto> getLatestResults() {
Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS);
if (cache != null) {
Cache.ValueWrapper wrapper = cache.get("data");
if (wrapper != null) {
return (List<VesselAnalysisDto>) wrapper.get();
}
}
Map<String, Integer> gearStats = groupPolygonService.getGearStats();
Instant since = Instant.now().minus(2, ChronoUnit.HOURS);
// mmsi별 최신 analyzed_at 1건만 유지
Map<String, VesselAnalysisResult> latest = new LinkedHashMap<>();
for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) {
latest.merge(r.getMmsi(), r, (old, cur) ->
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
}
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("total", cache.size());
stats.put("dark", dark);
stats.put("spoofing", spoofing);
stats.put("critical", critical);
stats.put("high", high);
stats.put("medium", medium);
stats.put("low", low);
stats.put("clusterCount", clusterIds.size());
stats.put("gearGroups", gearStats.get("gearGroups"));
stats.put("gearCount", gearStats.get("gearCount"));
List<VesselAnalysisDto> results = cache.values().stream()
List<VesselAnalysisDto> results = latest.values().stream()
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
.map(VesselAnalysisDto::from)
.toList();
return Map.of(
"count", results.size(),
"items", results,
"stats", stats
);
if (cache != null) {
cache.put("data", results);
}
return results;
}
}

파일 보기

@ -1,42 +0,0 @@
package gc.mda.kcg.domain.event;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Map;
@Entity
@Table(name = "events")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_id", unique = true)
private String eventId;
private String title;
private String description;
private String source;
@Column(name = "latitude")
private Double latitude;
@Column(name = "longitude")
private Double longitude;
private Instant timestamp;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Object> rawData;
}

파일 보기

@ -1,34 +0,0 @@
package gc.mda.kcg.domain.event;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/events")
@RequiredArgsConstructor
public class EventController {
private final EventService eventService;
@GetMapping
public ResponseEntity<Map<String, Object>> getEvents(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
Instant f = from != null ? from : Instant.parse("2026-03-01T00:00:00Z");
Instant t = to != null ? to : Instant.now();
List<EventDto> results = eventService.getByDateRange(f, t);
return ResponseEntity.ok(Map.of("count", results.size(), "items", results));
}
@PostMapping("/import")
public ResponseEntity<Map<String, Object>> importEvents(@RequestBody List<EventDto> events) {
int imported = eventService.importEvents(events);
return ResponseEntity.ok(Map.of("imported", imported, "total", events.size()));
}
}

파일 보기

@ -1,49 +0,0 @@
package gc.mda.kcg.domain.event;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class EventDto {
private String id;
private long timestamp;
private Double lat;
private Double lng;
private String type;
private String source;
private String label;
private String description;
private Integer intensity;
public static EventDto from(Event e) {
return EventDto.builder()
.id(e.getEventId())
.timestamp(e.getTimestamp() != null ? e.getTimestamp().toEpochMilli() : 0)
.lat(e.getLatitude())
.lng(e.getLongitude())
.type(extractType(e))
.source(e.getSource())
.label(e.getTitle())
.description(e.getDescription())
.intensity(extractIntensity(e))
.build();
}
private static String extractType(Event e) {
if (e.getRawData() != null && e.getRawData().containsKey("type")) {
return String.valueOf(e.getRawData().get("type"));
}
return "alert";
}
private static Integer extractIntensity(Event e) {
if (e.getRawData() != null && e.getRawData().containsKey("intensity")) {
return ((Number) e.getRawData().get("intensity")).intValue();
}
return 50;
}
}

파일 보기

@ -1,13 +0,0 @@
package gc.mda.kcg.domain.event;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;
import java.util.List;
public interface EventRepository extends JpaRepository<Event, Long> {
List<Event> findByTimestampBetweenOrderByTimestampAsc(Instant from, Instant to);
boolean existsByEventId(String eventId);
}

파일 보기

@ -1,43 +0,0 @@
package gc.mda.kcg.domain.event;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class EventService {
private final EventRepository repository;
public List<EventDto> getByDateRange(Instant from, Instant to) {
return repository.findByTimestampBetweenOrderByTimestampAsc(from, to)
.stream().map(EventDto::from).toList();
}
public int importEvents(List<EventDto> dtos) {
int count = 0;
for (EventDto dto : dtos) {
if (dto.getId() != null && repository.existsByEventId(dto.getId())) continue;
Event e = Event.builder()
.eventId(dto.getId())
.title(dto.getLabel())
.description(dto.getDescription())
.source(dto.getSource())
.latitude(dto.getLat())
.longitude(dto.getLng())
.timestamp(Instant.ofEpochMilli(dto.getTimestamp()))
.rawData(Map.of(
"type", dto.getType() != null ? dto.getType() : "alert",
"intensity", dto.getIntensity() != null ? dto.getIntensity() : 50
))
.build();
repository.save(e);
count++;
}
return count;
}
}

파일 보기

@ -1,7 +1,6 @@
package gc.mda.kcg.domain.fleet;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
@ -18,14 +17,10 @@ public class FleetCompanyController {
private final JdbcTemplate jdbcTemplate;
@Value("${DB_SCHEMA:kcg}")
private String dbSchema;
@GetMapping
public ResponseEntity<List<Map<String, Object>>> getFleetCompanies() {
List<Map<String, Object>> results = jdbcTemplate.queryForList(
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM "
+ dbSchema + ".fleet_companies ORDER BY id"
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM kcg.fleet_companies ORDER BY id"
);
return ResponseEntity.ok(results);
}

파일 보기

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

파일 보기

@ -1,13 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GroupParentCandidateExclusionRequest {
private String candidateMmsi;
private Integer durationDays;
private String actor;
private String comment;
}

파일 보기

@ -1,26 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupParentInferenceDto {
private String groupType;
private String groupKey;
private String groupLabel;
private int subClusterId;
private String snapshotTime;
private String zoneName;
private Integer memberCount;
private String resolution;
private Integer candidateCount;
private ParentInferenceSummaryDto parentInference;
private List<ParentInferenceCandidateDto> candidates;
private Map<String, Object> evidenceSummary;
}

파일 보기

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

파일 보기

@ -1,13 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GroupParentLabelSessionRequest {
private String selectedParentMmsi;
private Integer durationDays;
private String actor;
private String comment;
}

파일 보기

@ -1,123 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vessel-analysis/groups")
@RequiredArgsConstructor
public class GroupPolygonController {
private final GroupPolygonService groupPolygonService;
/**
* 전체 그룹 폴리곤 목록 (최신 스냅샷, 5분 캐시)
*/
@GetMapping
public ResponseEntity<Map<String, Object>> getGroups() {
List<GroupPolygonDto> groups = groupPolygonService.getLatestGroups();
return ResponseEntity.ok(Map.of(
"count", groups.size(),
"items", groups
));
}
/**
* 특정 그룹 상세 (멤버, 면적, 폴리곤 생성 근거)
*/
@GetMapping("/{groupKey}/detail")
public ResponseEntity<GroupPolygonDto> getGroupDetail(@PathVariable String groupKey) {
GroupPolygonDto detail = groupPolygonService.getGroupDetail(groupKey);
if (detail == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(detail);
}
/**
* 특정 그룹 히스토리 (시간별 폴리곤 변화)
*/
@GetMapping("/{groupKey}/history")
public ResponseEntity<List<GroupPolygonDto>> getGroupHistory(
@PathVariable String groupKey,
@RequestParam(defaultValue = "24") int hours) {
List<GroupPolygonDto> history = groupPolygonService.getGroupHistory(groupKey, hours);
return ResponseEntity.ok(history);
}
/**
* 특정 어구 그룹의 연관성 점수 (멀티모델)
*/
@GetMapping("/{groupKey}/correlations")
public ResponseEntity<Map<String, Object>> getGroupCorrelations(
@PathVariable String groupKey,
@RequestParam(defaultValue = "0.3") double minScore) {
List<Map<String, Object>> correlations = groupPolygonService.getGroupCorrelations(groupKey, minScore);
return ResponseEntity.ok(Map.of(
"groupKey", groupKey,
"count", correlations.size(),
"items", correlations
));
}
@GetMapping("/parent-inference/review")
public ResponseEntity<Map<String, Object>> getParentInferenceReview(
@RequestParam(defaultValue = "REVIEW_REQUIRED") String status,
@RequestParam(defaultValue = "100") int limit) {
List<GroupParentInferenceDto> items = groupPolygonService.getParentInferenceReview(status, limit);
return ResponseEntity.ok(Map.of(
"count", items.size(),
"items", items
));
}
@GetMapping("/{groupKey}/parent-inference")
public ResponseEntity<Map<String, Object>> getGroupParentInference(@PathVariable String groupKey) {
List<GroupParentInferenceDto> items = groupPolygonService.getGroupParentInference(groupKey);
return ResponseEntity.ok(Map.of(
"groupKey", groupKey,
"count", items.size(),
"items", items
));
}
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/review")
public ResponseEntity<?> reviewGroupParentInference(
@PathVariable String groupKey,
@PathVariable int subClusterId,
@RequestBody GroupParentInferenceReviewRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.reviewParentInference(groupKey, subClusterId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/label-sessions")
public ResponseEntity<?> createGroupParentLabelSession(
@PathVariable String groupKey,
@PathVariable int subClusterId,
@RequestBody GroupParentLabelSessionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.createGroupParentLabelSession(groupKey, subClusterId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions")
public ResponseEntity<?> createGroupCandidateExclusion(
@PathVariable String groupKey,
@PathVariable int subClusterId,
@RequestBody GroupParentCandidateExclusionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.createGroupCandidateExclusion(groupKey, subClusterId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}

파일 보기

@ -1,31 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class GroupPolygonDto {
private String groupType;
private String groupKey;
private String groupLabel;
private int subClusterId;
private String snapshotTime;
private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON)
private double centerLat;
private double centerLon;
private double areaSqNm;
private int memberCount;
private String zoneId;
private String zoneName;
private List<Map<String, Object>> members;
private String color;
private String resolution;
private Integer candidateCount;
private ParentInferenceSummaryDto parentInference;
}

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

파일 보기

@ -1,28 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentCandidateExclusionDto {
private Long id;
private String scopeType;
private String groupKey;
private Integer subClusterId;
private String candidateMmsi;
private String reasonType;
private Integer durationDays;
private String activeFrom;
private String activeUntil;
private String releasedAt;
private String releasedBy;
private String actor;
private String comment;
private Boolean active;
private Map<String, Object> metadata;
}

파일 보기

@ -1,30 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentInferenceCandidateDto {
private String candidateMmsi;
private String candidateName;
private Integer candidateVesselId;
private Integer rank;
private String candidateSource;
private Double finalScore;
private Double baseCorrScore;
private Double nameMatchScore;
private Double trackSimilarityScore;
private Double visitScore6h;
private Double proximityScore6h;
private Double activitySyncScore6h;
private Double stabilityScore;
private Double registryBonus;
private Double marginFromTop;
private Boolean trackAvailable;
private Map<String, Object> evidence;
}

파일 보기

@ -1,22 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentInferenceSummaryDto {
private String status;
private String normalizedParentName;
private String selectedParentMmsi;
private String selectedParentName;
private Double confidence;
private String decisionSource;
private Double topScore;
private Double scoreMargin;
private Integer stableCycles;
private String skipReason;
private String statusReason;
}

파일 보기

@ -1,95 +0,0 @@
package gc.mda.kcg.domain.fleet;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vessel-analysis/parent-inference")
@RequiredArgsConstructor
public class ParentInferenceWorkflowController {
private final GroupPolygonService groupPolygonService;
@GetMapping("/candidate-exclusions")
public ResponseEntity<Map<String, Object>> getCandidateExclusions(
@RequestParam(required = false) String scopeType,
@RequestParam(required = false) String groupKey,
@RequestParam(required = false) Integer subClusterId,
@RequestParam(required = false) String candidateMmsi,
@RequestParam(defaultValue = "true") boolean activeOnly,
@RequestParam(defaultValue = "100") int limit) {
List<ParentCandidateExclusionDto> items = groupPolygonService.getCandidateExclusions(
scopeType,
groupKey,
subClusterId,
candidateMmsi,
activeOnly,
limit
);
return ResponseEntity.ok(Map.of("count", items.size(), "items", items));
}
@PostMapping("/candidate-exclusions/global")
public ResponseEntity<?> createGlobalCandidateExclusion(@RequestBody GlobalParentCandidateExclusionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.createGlobalCandidateExclusion(request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/candidate-exclusions/{exclusionId}/release")
public ResponseEntity<?> releaseCandidateExclusion(
@PathVariable long exclusionId,
@RequestBody ParentWorkflowActionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.releaseCandidateExclusion(exclusionId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/label-sessions")
public ResponseEntity<Map<String, Object>> getLabelSessions(
@RequestParam(required = false) String groupKey,
@RequestParam(required = false) Integer subClusterId,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "true") boolean activeOnly,
@RequestParam(defaultValue = "100") int limit) {
List<ParentLabelSessionDto> items = groupPolygonService.getLabelSessions(
groupKey,
subClusterId,
status,
activeOnly,
limit
);
return ResponseEntity.ok(Map.of("count", items.size(), "items", items));
}
@PostMapping("/label-sessions/{labelSessionId}/cancel")
public ResponseEntity<?> cancelLabelSession(
@PathVariable long labelSessionId,
@RequestBody ParentWorkflowActionRequest request) {
try {
return ResponseEntity.ok(groupPolygonService.cancelLabelSession(labelSessionId, request));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/label-sessions/{labelSessionId}/tracking")
public ResponseEntity<Map<String, Object>> getLabelSessionTracking(
@PathVariable long labelSessionId,
@RequestParam(defaultValue = "200") int limit) {
List<ParentLabelTrackingCycleDto> items = groupPolygonService.getLabelSessionTracking(labelSessionId, limit);
return ResponseEntity.ok(Map.of(
"labelSessionId", labelSessionId,
"count", items.size(),
"items", items
));
}
}

파일 보기

@ -1,31 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentLabelSessionDto {
private Long id;
private String groupKey;
private Integer subClusterId;
private String labelParentMmsi;
private String labelParentName;
private Integer labelParentVesselId;
private Integer durationDays;
private String status;
private String activeFrom;
private String activeUntil;
private String actor;
private String comment;
private String anchorSnapshotTime;
private Double anchorCenterLat;
private Double anchorCenterLon;
private Integer anchorMemberCount;
private Boolean active;
private Map<String, Object> metadata;
}

파일 보기

@ -1,31 +0,0 @@
package gc.mda.kcg.domain.fleet;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ParentLabelTrackingCycleDto {
private Long id;
private Long labelSessionId;
private String observedAt;
private String candidateSnapshotObservedAt;
private String autoStatus;
private String topCandidateMmsi;
private String topCandidateName;
private Double topCandidateScore;
private Double topCandidateMargin;
private Integer candidateCount;
private Boolean labeledCandidatePresent;
private Integer labeledCandidateRank;
private Double labeledCandidateScore;
private Double labeledCandidatePreBonusScore;
private Double labeledCandidateMarginFromTop;
private Boolean matchedTop1;
private Boolean matchedTop3;
private Map<String, Object> evidenceSummary;
}

파일 보기

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

파일 보기

@ -4,14 +4,12 @@ import gc.mda.kcg.config.CacheConfig;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -25,26 +23,18 @@ public class OsintController {
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
private final CacheManager cacheManager;
private final OsintService osintService;
private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>();
@GetMapping
public ResponseEntity<Map<String, Object>> getOsint(
@RequestParam(defaultValue = "iran") String region,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
@RequestParam(defaultValue = "iran") String region) {
if (!VALID_REGIONS.contains(region)) {
return ResponseEntity.badRequest()
.body(Map.of("error", "유효하지 않은 region: " + region));
}
if (from != null && to != null) {
List<OsintDto> results = osintService.getByDateRange(region, from, to);
return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results));
}
String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA;
List<OsintDto> items = getCachedItems(cacheName);
long updatedAt = lastUpdated.getOrDefault(region, 0L);

파일 보기

@ -9,6 +9,7 @@ import java.time.Instant;
@Entity
@Table(
name = "osint_feeds",
schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"})
)
@Getter

파일 보기

@ -12,6 +12,4 @@ public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
boolean existsByTitle(String title);
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since);
List<OsintFeed> findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(String region, Instant from, Instant to);
}

파일 보기

@ -1,25 +0,0 @@
package gc.mda.kcg.domain.osint;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
@Service
@RequiredArgsConstructor
public class OsintService {
private final OsintFeedRepository repository;
/**
* 시간 범위 OSINT 피드를 조회하여 반환.
* focus(region) 필드 기준 필터링, publishedAt 기준 정렬.
*/
public List<OsintDto> getByDateRange(String region, Instant from, Instant to) {
return repository.findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(region, from, to)
.stream()
.map(OsintDto::from)
.toList();
}
}

파일 보기

@ -6,7 +6,7 @@ import lombok.*;
import java.time.Instant;
@Entity
@Table(name = "satellite_tle")
@Table(name = "satellite_tle", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -8,6 +8,7 @@ import java.time.Instant;
@Entity
@Table(
name = "pressure_readings",
schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"})
)
@Getter

파일 보기

@ -6,7 +6,7 @@ import lombok.*;
import java.time.Instant;
@Entity
@Table(name = "seismic_events")
@Table(name = "seismic_events", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor

파일 보기

@ -1,19 +1,16 @@
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg,public}
username: ${DB_USERNAME:kcg_user}
password: ${DB_PASSWORD:kcg_pass}
url: jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg
username: kcg_user
password: kcg_pass
app:
jwt:
secret: ${JWT_SECRET:local-dev-secret-key-32chars-minimum!!}
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
secret: local-dev-secret-key-32chars-minimum!!
expiration-ms: 86400000
google:
client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID}
client-id: YOUR_GOOGLE_CLIENT_ID
auth:
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
allowed-domain: gcsc.co.kr
collector:
open-sky-client-id: ${OPENSKY_CLIENT_ID:YOUR_OPENSKY_CLIENT_ID}
open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:YOUR_OPENSKY_CLIENT_SECRET}
prediction-base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
cors:
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173}
open-sky-client-id: YOUR_OPENSKY_CLIENT_ID
open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET

파일 보기

@ -16,4 +16,4 @@ app:
open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:}
prediction-base-url: ${PREDICTION_BASE_URL:http://192.168.1.18:8001}
cors:
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173,https://kcg.gc-si.dev}
allowed-origins: http://localhost:5173,https://kcg.gc-si.dev

파일 보기

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

파일 보기

@ -1,49 +0,0 @@
-- 009: 선단/어구그룹 폴리곤 스냅샷 테이블
-- 5분 주기 APPEND, 7일 보존
SET search_path TO kcg, public;
CREATE TABLE IF NOT EXISTS kcg.group_polygon_snapshots (
id BIGSERIAL PRIMARY KEY,
-- 그룹 식별
group_type VARCHAR(20) NOT NULL, -- FLEET | GEAR_IN_ZONE | GEAR_OUT_ZONE
group_key VARCHAR(100) NOT NULL, -- fleet: company_id, gear: parent_name
group_label TEXT, -- 표시명 (회사명 또는 모선명)
-- 스냅샷 시각
snapshot_time TIMESTAMPTZ NOT NULL,
-- PostGIS geometry
polygon geometry(Polygon, 4326), -- convex hull + buffer (3점 미만 시 NULL)
center_point geometry(Point, 4326), -- 중심점
-- 지표
area_sq_nm DOUBLE PRECISION DEFAULT 0, -- 면적 (제곱 해리)
member_count INT NOT NULL DEFAULT 0, -- 소속 선박/어구 수
-- 수역 분류 (어구그룹용)
zone_id VARCHAR(20), -- ZONE_I ~ ZONE_IV | OUTSIDE
zone_name TEXT,
-- 멤버 상세 (JSONB 배열)
members JSONB NOT NULL DEFAULT '[]',
-- [{mmsi, name, lat, lon, sog, cog, role, isParent}]
-- 색상 힌트
color VARCHAR(20),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 조회 성능 인덱스
CREATE INDEX IF NOT EXISTS idx_gps_type_time
ON kcg.group_polygon_snapshots(group_type, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_key_time
ON kcg.group_polygon_snapshots(group_key, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_snapshot_time
ON kcg.group_polygon_snapshots(snapshot_time DESC);
-- 공간 인덱스
CREATE INDEX IF NOT EXISTS idx_gps_polygon_gist
ON kcg.group_polygon_snapshots USING GIST(polygon);

파일 보기

@ -1,146 +0,0 @@
-- 010: 어구 연관성 추적 시스템
-- - correlation_param_models: 파라미터 모델 마스터
-- - gear_correlation_raw_metrics: raw 메트릭 (타임스탬프 파티셔닝, 7일 보존)
-- - gear_correlation_scores: 모델별 어피니티 점수 (상태 테이블)
-- - system_config: 런타임 설정 (파티션 보관기간 등)
SET search_path TO kcg, public;
-- ── 파라미터 모델 ──
CREATE TABLE IF NOT EXISTS kcg.correlation_param_models (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
params JSONB NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- default 모델 삽입
INSERT INTO kcg.correlation_param_models (name, is_default, is_active, params, description)
VALUES ('default', TRUE, TRUE,
'{"alpha_base":0.30,"alpha_min":0.08,"alpha_decay_per_streak":0.005,"track_threshold":0.50,"polygon_threshold":0.70,"w_proximity":0.45,"w_visit":0.35,"w_activity":0.20,"w_dtw":0.30,"w_sog_corr":0.20,"w_heading":0.25,"w_prox_vv":0.25,"w_prox_persist":0.50,"w_drift":0.30,"w_signal_sync":0.20,"group_quiet_ratio":0.30,"normal_gap_hours":1.0,"decay_slow":0.015,"decay_fast":0.08,"stale_hours":6.0,"shadow_stay_bonus":0.10,"shadow_return_bonus":0.15,"candidate_radius_factor":3.0,"proximity_threshold_nm":5.0,"visit_threshold_nm":5.0,"night_bonus":1.3,"long_decay_days":7.0}',
'기본 추적 모델')
ON CONFLICT (name) DO NOTHING;
-- ── Raw 메트릭 (모델 독립, 5분마다 기록, 타임스탬프 파티셔닝) ──
CREATE TABLE IF NOT EXISTS kcg.gear_correlation_raw_metrics (
id BIGSERIAL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
group_key VARCHAR(100) NOT NULL,
target_mmsi VARCHAR(20) NOT NULL,
target_type VARCHAR(10) NOT NULL,
target_name VARCHAR(200),
-- Raw 메트릭 (모든 모델이 공유)
proximity_ratio DOUBLE PRECISION,
visit_score DOUBLE PRECISION,
activity_sync DOUBLE PRECISION,
dtw_similarity DOUBLE PRECISION,
speed_correlation DOUBLE PRECISION,
heading_coherence DOUBLE PRECISION,
drift_similarity DOUBLE PRECISION,
-- Shadow
shadow_stay BOOLEAN DEFAULT FALSE,
shadow_return BOOLEAN DEFAULT FALSE,
-- 상태
gear_group_active_ratio DOUBLE PRECISION,
PRIMARY KEY (id, observed_at)
) PARTITION BY RANGE (observed_at);
-- 일별 파티션 생성 함수
CREATE OR REPLACE FUNCTION kcg.create_raw_metric_partitions(days_ahead INT DEFAULT 3)
RETURNS void AS $$
DECLARE
d DATE;
partition_name TEXT;
BEGIN
FOR i IN 0..days_ahead LOOP
d := CURRENT_DATE + i;
partition_name := 'gear_correlation_raw_metrics_' || TO_CHAR(d, 'YYYYMMDD');
IF NOT EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = partition_name AND n.nspname = 'kcg'
) THEN
EXECUTE format(
'CREATE TABLE IF NOT EXISTS kcg.%I PARTITION OF kcg.gear_correlation_raw_metrics
FOR VALUES FROM (%L) TO (%L)',
partition_name, d, d + 1
);
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- 초기 파티션 생성 (오늘 + 3일)
SELECT kcg.create_raw_metric_partitions(3);
-- raw_metrics 인덱스
CREATE INDEX IF NOT EXISTS idx_raw_metrics_group_time
ON kcg.gear_correlation_raw_metrics (group_key, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_raw_metrics_target
ON kcg.gear_correlation_raw_metrics (target_mmsi, observed_at DESC);
-- ── 어피니티 점수 (모델별 독립, 상태 테이블) ──
CREATE TABLE IF NOT EXISTS kcg.gear_correlation_scores (
id BIGSERIAL PRIMARY KEY,
model_id INT NOT NULL REFERENCES kcg.correlation_param_models(id) ON DELETE CASCADE,
group_key VARCHAR(100) NOT NULL,
target_mmsi VARCHAR(20) NOT NULL,
target_type VARCHAR(10) NOT NULL,
target_name VARCHAR(200),
-- 모델별 점수 (EMA 결과)
current_score DOUBLE PRECISION DEFAULT 0,
streak_count INT DEFAULT 0,
observation_count INT DEFAULT 0,
-- Shadow 축적
shadow_bonus_total DOUBLE PRECISION DEFAULT 0,
shadow_stay_count INT DEFAULT 0,
shadow_return_count INT DEFAULT 0,
-- 상태
freeze_state VARCHAR(20) DEFAULT 'ACTIVE',
-- 시간
first_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (model_id, group_key, target_mmsi)
);
CREATE INDEX IF NOT EXISTS idx_gc_model_group
ON kcg.gear_correlation_scores (model_id, group_key, current_score DESC);
CREATE INDEX IF NOT EXISTS idx_gc_active
ON kcg.gear_correlation_scores (current_score DESC)
WHERE current_score >= 0.5;
-- ── 시스템 설정 (런타임 변경 가능, 재시작 불필요) ──
CREATE TABLE IF NOT EXISTS kcg.system_config (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by VARCHAR(100) DEFAULT 'system'
);
INSERT INTO kcg.system_config (key, value, description) VALUES
('partition.raw_metrics.retention_days', '7',
'raw_metrics 파티션 보관 기간 (일). 초과 시 파티션 DROP.'),
('partition.raw_metrics.create_ahead_days', '3',
'미래 파티션 미리 생성 일수.'),
('partition.scores.cleanup_days', '30',
'미관측 점수 레코드 정리 기간 (일).'),
('correlation.max_active_models', '5',
'동시 활성 모델 최대 수.')
ON CONFLICT (key) DO NOTHING;

파일 보기

@ -1,14 +0,0 @@
-- 011: group_polygon_snapshots에 resolution 컬럼 추가 (1h/6h 듀얼 폴리곤)
-- 기존 데이터는 DEFAULT '6h'로 취급
ALTER TABLE kcg.group_polygon_snapshots
ADD COLUMN IF NOT EXISTS resolution VARCHAR(8) DEFAULT '6h';
-- 기존 인덱스 교체: resolution 포함
DROP INDEX IF EXISTS kcg.idx_gps_type_time;
CREATE INDEX idx_gps_type_res_time
ON kcg.group_polygon_snapshots(group_type, resolution, snapshot_time DESC);
DROP INDEX IF EXISTS kcg.idx_gps_key_time;
CREATE INDEX idx_gps_key_res_time
ON kcg.group_polygon_snapshots(group_key, resolution, snapshot_time DESC);

파일 보기

@ -1,176 +0,0 @@
-- 012: 어구 그룹 모선 추론 저장소 + sub_cluster/resolution 스키마 정합성
SET search_path TO kcg, public;
-- ── live lab과 repo 마이그레이션 정합성 맞추기 ─────────────────────
ALTER TABLE kcg.group_polygon_snapshots
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE kcg.group_polygon_snapshots
ADD COLUMN IF NOT EXISTS resolution VARCHAR(20) NOT NULL DEFAULT '6h';
CREATE INDEX IF NOT EXISTS idx_gps_type_res_time
ON kcg.group_polygon_snapshots(group_type, resolution, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_key_res_time
ON kcg.group_polygon_snapshots(group_key, resolution, snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gps_key_sub_time
ON kcg.group_polygon_snapshots(group_key, sub_cluster_id, snapshot_time DESC);
ALTER TABLE kcg.gear_correlation_raw_metrics
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_raw_metrics_group_sub_time
ON kcg.gear_correlation_raw_metrics(group_key, sub_cluster_id, observed_at DESC);
ALTER TABLE kcg.gear_correlation_scores
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE kcg.gear_correlation_scores
DROP CONSTRAINT IF EXISTS gear_correlation_scores_model_id_group_key_target_mmsi_key;
DROP INDEX IF EXISTS kcg.gear_correlation_scores_model_id_group_key_target_mmsi_key;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE connamespace = 'kcg'::regnamespace
AND conrelid = 'kcg.gear_correlation_scores'::regclass
AND conname = 'gear_correlation_scores_unique'
) THEN
ALTER TABLE kcg.gear_correlation_scores
ADD CONSTRAINT gear_correlation_scores_unique
UNIQUE (model_id, group_key, sub_cluster_id, target_mmsi);
END IF;
END;
$$ LANGUAGE plpgsql;
CREATE INDEX IF NOT EXISTS idx_gc_model_group_sub
ON kcg.gear_correlation_scores(model_id, group_key, sub_cluster_id, current_score DESC);
-- ── 그룹 단위 모선 추론 저장소 ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_candidate_snapshots (
id BIGSERIAL PRIMARY KEY,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
parent_name TEXT NOT NULL,
candidate_mmsi VARCHAR(20) NOT NULL,
candidate_name VARCHAR(200),
candidate_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
rank INT NOT NULL,
candidate_source VARCHAR(100) NOT NULL,
model_id INT REFERENCES kcg.correlation_param_models(id) ON DELETE SET NULL,
model_name VARCHAR(100),
base_corr_score DOUBLE PRECISION DEFAULT 0,
name_match_score DOUBLE PRECISION DEFAULT 0,
track_similarity_score DOUBLE PRECISION DEFAULT 0,
visit_score_6h DOUBLE PRECISION DEFAULT 0,
proximity_score_6h DOUBLE PRECISION DEFAULT 0,
activity_sync_score_6h DOUBLE PRECISION DEFAULT 0,
stability_score DOUBLE PRECISION DEFAULT 0,
registry_bonus DOUBLE PRECISION DEFAULT 0,
final_score DOUBLE PRECISION DEFAULT 0,
margin_from_top DOUBLE PRECISION DEFAULT 0,
evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (observed_at, group_key, sub_cluster_id, candidate_mmsi)
);
CREATE INDEX IF NOT EXISTS idx_ggpcs_group_time
ON kcg.gear_group_parent_candidate_snapshots(group_key, sub_cluster_id, observed_at DESC, rank ASC);
CREATE INDEX IF NOT EXISTS idx_ggpcs_candidate
ON kcg.gear_group_parent_candidate_snapshots(candidate_mmsi, observed_at DESC);
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_resolution (
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
parent_name TEXT NOT NULL,
normalized_parent_name VARCHAR(200),
status VARCHAR(40) NOT NULL,
selected_parent_mmsi VARCHAR(20),
selected_parent_name VARCHAR(200),
selected_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
confidence DOUBLE PRECISION,
decision_source VARCHAR(40),
top_score DOUBLE PRECISION DEFAULT 0,
second_score DOUBLE PRECISION DEFAULT 0,
score_margin DOUBLE PRECISION DEFAULT 0,
stable_cycles INT DEFAULT 0,
last_evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_promoted_at TIMESTAMPTZ,
approved_by VARCHAR(100),
approved_at TIMESTAMPTZ,
manual_comment TEXT,
rejected_candidate_mmsi VARCHAR(20),
rejected_at TIMESTAMPTZ,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_key, sub_cluster_id)
);
CREATE INDEX IF NOT EXISTS idx_ggpr_status
ON kcg.gear_group_parent_resolution(status, last_evaluated_at DESC);
CREATE INDEX IF NOT EXISTS idx_ggpr_parent
ON kcg.gear_group_parent_resolution(selected_parent_mmsi);
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_review_log (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
action VARCHAR(20) NOT NULL,
selected_parent_mmsi VARCHAR(20),
actor VARCHAR(100) NOT NULL,
comment TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ggprl_group_time
ON kcg.gear_group_parent_review_log(group_key, sub_cluster_id, created_at DESC);
-- ── copied schema 환경의 sequence 정렬 ─────────────────────────────
SELECT setval(
pg_get_serial_sequence('kcg.fleet_companies', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.fleet_companies), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.fleet_vessels', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.fleet_vessels), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.gear_identity_log', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.gear_identity_log), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.fleet_tracking_snapshot', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.fleet_tracking_snapshot), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.group_polygon_snapshots', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.group_polygon_snapshots), 1),
TRUE
);
SELECT setval(
pg_get_serial_sequence('kcg.gear_correlation_scores', 'id'),
COALESCE((SELECT MAX(id) FROM kcg.gear_correlation_scores), 1),
TRUE
);

파일 보기

@ -1,23 +0,0 @@
SET search_path TO kcg, public;
DELETE FROM kcg.gear_group_parent_candidate_snapshots
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_group_parent_review_log
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_group_parent_resolution
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_correlation_raw_metrics
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_correlation_scores
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.group_polygon_snapshots
WHERE group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')
AND LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
DELETE FROM kcg.gear_identity_log
WHERE LENGTH(REGEXP_REPLACE(UPPER(COALESCE(parent_name, name)), '[ _%\\-]', '', 'g')) < 4;

파일 보기

@ -1,125 +0,0 @@
-- 014: 어구 모선 검토 워크플로우 v2 phase 1
SET search_path TO kcg, public;
-- ── 그룹/전역 후보 제외 ───────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL,
group_key VARCHAR(100),
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL,
duration_days INT,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ,
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpce_scope CHECK (scope_type IN ('GROUP', 'GLOBAL')),
CONSTRAINT chk_gpce_reason CHECK (reason_type IN ('GROUP_WRONG_PARENT', 'GLOBAL_NOT_PARENT_TARGET')),
CONSTRAINT chk_gpce_group_scope CHECK (
(scope_type = 'GROUP' AND group_key IS NOT NULL AND sub_cluster_id IS NOT NULL AND duration_days IN (1, 3, 5) AND active_until IS NOT NULL)
OR
(scope_type = 'GLOBAL' AND duration_days IS NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_gpce_scope_mmsi_active
ON kcg.gear_parent_candidate_exclusions(scope_type, candidate_mmsi, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_group_active
ON kcg.gear_parent_candidate_exclusions(group_key, sub_cluster_id, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_active_until
ON kcg.gear_parent_candidate_exclusions(active_until);
-- ── 기간형 정답 라벨 세션 ────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpls_duration CHECK (duration_days IN (1, 3, 5)),
CONSTRAINT chk_gpls_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'CANCELLED'))
);
CREATE INDEX IF NOT EXISTS idx_gpls_group_active
ON kcg.gear_parent_label_sessions(group_key, sub_cluster_id, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_mmsi_active
ON kcg.gear_parent_label_sessions(label_parent_mmsi, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_active_until
ON kcg.gear_parent_label_sessions(active_until);
-- ── 라벨 세션 기간 중 cycle별 자동 추론 기록 ─────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gpltc_session_observed UNIQUE (label_session_id, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_gpltc_session_observed
ON kcg.gear_parent_label_tracking_cycles(label_session_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gpltc_top_candidate
ON kcg.gear_parent_label_tracking_cycles(top_candidate_mmsi);
-- ── active view ────────────────────────────────────────────────
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_candidate_exclusions AS
SELECT *
FROM kcg.gear_parent_candidate_exclusions
WHERE released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW());
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_label_sessions AS
SELECT *
FROM kcg.gear_parent_label_sessions
WHERE status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW();

파일 보기

@ -1,111 +0,0 @@
-- 015: 어구 모선 추론 episode continuity + prior bonus
SET search_path TO kcg, public;
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS normalized_parent_name VARCHAR(200);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS episode_id VARCHAR(64);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS episode_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS lineage_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ADD COLUMN IF NOT EXISTS label_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
UPDATE kcg.gear_group_parent_candidate_snapshots
SET normalized_parent_name = regexp_replace(upper(COALESCE(parent_name, '')), '[[:space:]_%-]+', '', 'g')
WHERE normalized_parent_name IS NULL;
CREATE INDEX IF NOT EXISTS idx_ggpcs_episode_time
ON kcg.gear_group_parent_candidate_snapshots(episode_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_ggpcs_lineage_time
ON kcg.gear_group_parent_candidate_snapshots(normalized_parent_name, observed_at DESC);
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS episode_id VARCHAR(64);
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS continuity_source VARCHAR(32);
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS continuity_score DOUBLE PRECISION;
ALTER TABLE kcg.gear_group_parent_resolution
ADD COLUMN IF NOT EXISTS prior_bonus_total DOUBLE PRECISION NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_ggpr_episode
ON kcg.gear_group_parent_resolution(episode_id);
ALTER TABLE kcg.gear_parent_label_sessions
ADD COLUMN IF NOT EXISTS normalized_parent_name VARCHAR(200);
UPDATE kcg.gear_parent_label_sessions
SET normalized_parent_name = regexp_replace(upper(COALESCE(group_key, '')), '[[:space:]_%-]+', '', 'g')
WHERE normalized_parent_name IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpls_lineage_active
ON kcg.gear_parent_label_sessions(normalized_parent_name, active_from DESC);
CREATE TABLE IF NOT EXISTS kcg.gear_group_episodes (
episode_id VARCHAR(64) PRIMARY KEY,
lineage_key VARCHAR(200) NOT NULL,
group_key VARCHAR(100) NOT NULL,
normalized_parent_name VARCHAR(200) NOT NULL,
current_sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
continuity_source VARCHAR(32) NOT NULL DEFAULT 'NEW',
continuity_score DOUBLE PRECISION,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_snapshot_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
current_member_count INT NOT NULL DEFAULT 0,
current_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
current_center_point geometry(Point, 4326),
split_from_episode_id VARCHAR(64),
merged_from_episode_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
merged_into_episode_id VARCHAR(64),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gge_status CHECK (status IN ('ACTIVE', 'MERGED', 'EXPIRED')),
CONSTRAINT chk_gge_continuity CHECK (continuity_source IN ('NEW', 'CONTINUED', 'SPLIT_CONTINUE', 'SPLIT_NEW', 'MERGE_NEW', 'DIRECT_PARENT_MATCH'))
);
CREATE INDEX IF NOT EXISTS idx_gge_lineage_status_time
ON kcg.gear_group_episodes(lineage_key, status, last_snapshot_time DESC);
CREATE INDEX IF NOT EXISTS idx_gge_group_time
ON kcg.gear_group_episodes(group_key, current_sub_cluster_id, last_snapshot_time DESC);
CREATE TABLE IF NOT EXISTS kcg.gear_group_episode_snapshots (
id BIGSERIAL PRIMARY KEY,
episode_id VARCHAR(64) NOT NULL REFERENCES kcg.gear_group_episodes(episode_id) ON DELETE CASCADE,
lineage_key VARCHAR(200) NOT NULL,
group_key VARCHAR(100) NOT NULL,
normalized_parent_name VARCHAR(200) NOT NULL,
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
member_count INT NOT NULL DEFAULT 0,
member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
center_point geometry(Point, 4326),
continuity_source VARCHAR(32) NOT NULL,
continuity_score DOUBLE PRECISION,
parent_episode_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
top_candidate_mmsi VARCHAR(20),
top_candidate_score DOUBLE PRECISION,
resolution_status VARCHAR(40),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gges_episode_observed UNIQUE (episode_id, observed_at)
);
CREATE INDEX IF NOT EXISTS idx_gges_lineage_observed
ON kcg.gear_group_episode_snapshots(lineage_key, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gges_group_observed
ON kcg.gear_group_episode_snapshots(group_key, sub_cluster_id, observed_at DESC);

파일 보기

@ -1,21 +0,0 @@
services:
ollama:
image: ollama/ollama:latest
container_name: kcg-ollama
restart: unless-stopped
ports:
- "11434:11434"
volumes:
- /home/kcg-ollama/data:/root/.ollama
deploy:
resources:
limits:
memory: 64G
reservations:
memory: 40G
environment:
- OLLAMA_NUM_PARALLEL=4
- OLLAMA_MAX_LOADED_MODELS=1
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_FLASH_ATTENTION=1
- OLLAMA_NUM_THREADS=48

파일 보기

@ -20,21 +20,6 @@ server {
try_files $uri $uri/ /index.html;
}
# ── AI Chat (SSE → Python prediction on redis-211) ──
location /api/prediction-chat {
rewrite ^/api/prediction-chat(.*)$ /api/v1/chat$1 break;
proxy_pass http://192.168.1.18:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 120s;
proxy_set_header Connection '';
chunked_transfer_encoding off;
}
# ── Backend API (direct) ──
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
@ -109,16 +94,6 @@ server {
proxy_ssl_server_name on;
}
# ── Google TTS 프록시 (중국어 경고문 음성) ──
location /api/gtts {
rewrite ^/api/gtts(.*)$ /translate_tts$1 break;
proxy_pass https://translate.google.com;
proxy_set_header Host translate.google.com;
proxy_set_header Referer "https://translate.google.com/";
proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
proxy_ssl_server_name on;
}
# gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

파일 보기

@ -1,514 +0,0 @@
# Gear Parent Inference Algorithm Spec
## 문서 목적
이 문서는 현재 구현된 어구 모선 추적 알고리즘을 모듈, 메서드, 파라미터, 판단 기준, 저장소, 엔드포인트, 영향 관계 기준으로 정리한 구현 명세다. `GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md`가 서술형 통합 문서라면, 이 문서는 구현과 후속 변경 작업에 바로 연결할 수 있는 참조 스펙이다.
## 1. 시스템 요약
### 1.1 현재 목적
- 최근 24시간 한국 수역 AIS를 캐시에 유지한다.
- 어구 이름 패턴과 위치를 기준으로 어구 그룹을 만든다.
- 주변 선박/오분류 어구를 correlation 후보로 평가한다.
- 후보 중 대표 모선 가능성이 높은 선박을 추론한다.
- 사람의 라벨/제외를 별도 저장소에 남겨 향후 모델 평가와 자동화 전환에 활용한다.
### 1.2 현재 점수 구조의 역할 구분
- `gear_correlation_scores.current_score`
- 후보 스크리닝용 correlation score
- EMA 기반 단기 메모리
- `gear_group_parent_candidate_snapshots.final_score`
- 모선 추론용 최종 후보 점수
- coverage-aware 보정과 이름/안정성/episode/lineage/label prior 반영
- `gear_group_parent_resolution`
- 그룹별 현재 추론 상태
- `gear_group_episodes`, `gear_group_episode_snapshots`
- `sub_cluster_id`와 분리된 continuity memory
- `gear_parent_label_tracking_cycles`
- 라벨 세션 동안의 자동 추론 성능 추적
## 2. 현재 DB 저장소와 유지 기간
| 저장소 | 역할 | 현재 유지 규칙 |
| --- | --- | --- |
| `group_polygon_snapshots` | `1h/1h-fb/6h` 그룹 스냅샷 | `7일` cleanup |
| `gear_correlation_raw_metrics` | correlation raw metric 시계열 | `7일` retention partition |
| `gear_correlation_scores` | correlation EMA score 현재 상태 | `30일` 미관측 시 cleanup |
| `gear_group_parent_candidate_snapshots` | cycle별 parent candidate snapshot | 현재 자동 cleanup 없음 |
| `gear_group_parent_resolution` | 그룹별 현재 추론 상태 1행 | 현재 자동 cleanup 없음 |
| `gear_group_episodes` | active/merged/expired episode 현재 상태 | 현재 자동 cleanup 없음 |
| `gear_group_episode_snapshots` | cycle별 episode continuity 스냅샷 | 현재 자동 cleanup 없음 |
| `gear_parent_candidate_exclusions` | 그룹/전역 후보 제외 | 기간 종료 또는 수동 해제까지 |
| `gear_parent_label_sessions` | 정답 라벨 세션 | 만료 시 `EXPIRED`, row는 유지 |
| `gear_parent_label_tracking_cycles` | 라벨 세션 cycle별 추적 | 현재 자동 cleanup 없음 |
## 3. 모듈 인덱스
### 3.1 시간/원천 적재
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/time_bucket.py` | `compute_safe_bucket()` | DB 적재 완료 전 bucket 차단 |
| `prediction/time_bucket.py` | `compute_initial_window_start()` | 초기 24h window 시작점 |
| `prediction/time_bucket.py` | `compute_incremental_window_start()` | overlap backfill 시작점 |
| `prediction/db/snpdb.py` | `fetch_all_tracks()` | safe bucket까지 초기 bulk 적재 |
| `prediction/db/snpdb.py` | `fetch_incremental()` | backfill 포함 증분 적재 |
| `prediction/cache/vessel_store.py` | `load_initial()` | 초기 메모리 캐시 구성 |
| `prediction/cache/vessel_store.py` | `merge_incremental()` | 증분 merge + dedupe |
| `prediction/cache/vessel_store.py` | `evict_stale()` | 24h sliding window 유지 |
### 3.2 어구 identity / 그룹
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/fleet_tracker.py` | `track_gear_identity()` | 어구 이름 파싱, identity log 관리 |
| `prediction/algorithms/gear_name_rules.py` | `normalize_parent_name()` | 모선명 정규화 |
| `prediction/algorithms/gear_name_rules.py` | `is_trackable_parent_name()` | 짧은 이름 제외 |
| `prediction/algorithms/polygon_builder.py` | `detect_gear_groups()` | 어구 그룹 및 서브클러스터 생성 |
| `prediction/algorithms/polygon_builder.py` | `build_all_group_snapshots()` | `1h/1h-fb/6h` 스냅샷 저장용 생성 |
### 3.3 correlation / parent inference
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/algorithms/gear_correlation.py` | `run_gear_correlation()` | raw metric + EMA score 계산 |
| `prediction/algorithms/gear_correlation.py` | `_compute_gear_vessel_metrics()` | proximity/visit/activity 계산 |
| `prediction/algorithms/gear_correlation.py` | `update_score()` | EMA + freeze/decay 상태 전이 |
| `prediction/algorithms/gear_parent_episode.py` | `build_episode_plan()` | continuity source와 episode assignment 계산 |
| `prediction/algorithms/gear_parent_episode.py` | `compute_prior_bonus_components()` | episode/lineage/label prior bonus 계산 |
| `prediction/algorithms/gear_parent_episode.py` | `sync_episode_states()` | `gear_group_episodes` upsert |
| `prediction/algorithms/gear_parent_episode.py` | `insert_episode_snapshots()` | episode snapshot append |
| `prediction/algorithms/gear_parent_inference.py` | `run_gear_parent_inference()` | 최종 모선 추론 실행 |
| `prediction/algorithms/gear_parent_inference.py` | `_build_candidate_scores()` | 후보별 상세 점수 계산 |
| `prediction/algorithms/gear_parent_inference.py` | `_name_match_score()` | 이름 점수 규칙 |
| `prediction/algorithms/gear_parent_inference.py` | `_build_track_coverage_metrics()` | coverage-aware evidence 계산 |
| `prediction/algorithms/gear_parent_inference.py` | `_select_status()` | 상태 전이 규칙 |
### 3.4 backend read model / workflow
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `GroupPolygonService.java` | group list/review/detail SQL | 최신 `1h` live + stale suppression read model |
| `ParentInferenceWorkflowController.java` | exclusion/label API | 사람 판단 저장소 API |
## 4. 메서드 상세
## 4.1 `prediction/time_bucket.py`
### `compute_safe_bucket(now: datetime | None = None) -> datetime`
- 입력:
- 현재 시각
- 출력:
- `safe_delay`를 뺀 뒤 5분 단위로 내림한 KST naive bucket
- 기준:
- `SNPDB_SAFE_DELAY_MIN`
- 영향:
- 초기 적재, 증분 적재, eviction 기준점
### `compute_incremental_window_start(last_bucket: datetime) -> datetime`
- 입력:
- 현재 캐시의 마지막 처리 bucket
- 출력:
- `last_bucket - SNPDB_BACKFILL_BUCKETS * 5m`
- 의미:
- 늦게 들어온 같은 bucket row 재흡수
## 4.2 `prediction/db/snpdb.py`
### `fetch_all_tracks(hours: int = 24) -> pd.DataFrame`
- 역할:
- safe bucket까지 최근 `N`시간 full load
- 핵심 쿼리 조건:
- bbox: `122,31,132,39`
- `time_bucket > window_start`
- `time_bucket <= safe_bucket`
- 출력 컬럼:
- `mmsi`, `timestamp`, `time_bucket`, `lat`, `lon`, `raw_sog`
### `fetch_incremental(last_bucket: datetime) -> pd.DataFrame`
- 역할:
- overlap backfill 포함 증분 load
- 핵심 쿼리 조건:
- `time_bucket > from_bucket`
- `time_bucket <= safe_bucket`
- 주의:
- 이미 본 bucket도 일부 다시 읽는 구조다
## 4.3 `prediction/cache/vessel_store.py`
### `load_initial(hours: int = 24) -> None`
- 역할:
- 초기 bulk DataFrame을 MMSI별 track cache로 구성
- 파생 효과:
- `_last_bucket` 갱신
- static info refresh
- permit registry refresh
### `merge_incremental(df_new: pd.DataFrame) -> None`
- 역할:
- 증분 batch merge
- 기준:
- `timestamp`, `time_bucket` 정렬
- `timestamp` 기준 dedupe
- 영향:
- 같은 bucket overlap backfill에서도 최종 row만 유지
### `evict_stale(hours: int = 24) -> None`
- 역할:
- sliding 24h 유지
- 기준:
- `time_bucket` 있으면 bucket 기준
- 없으면 timestamp fallback
## 4.4 `prediction/fleet_tracker.py`
### `track_gear_identity(gear_signals, conn) -> None`
- 역할:
- 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2` 추출
- `gear_identity_log` insert/update
- 입력:
- gear signal list
- 주요 기준:
- 정규화 길이 `< 4`면 건너뜀
- 같은 이름, 다른 MMSI는 identity migration 처리
- 영향:
- `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전 가능
## 4.5 `prediction/algorithms/polygon_builder.py`
### `detect_gear_groups(vessel_store) -> list[dict]`
- 역할:
- 어구 이름 기반 raw group 생성
- 거리 기반 서브클러스터 분리
- 근접 병합
- 입력:
- `all_positions`
- 주요 기준:
- 어구 패턴 매칭
- `440/441` 제외
- `is_trackable_parent_name()`
- `MAX_DIST_DEG = 0.15`
- 출력:
- `parent_name`, `parent_mmsi`, `sub_cluster_id`, `members`
### `build_all_group_snapshots(vessel_store, company_vessels, companies) -> list[dict]`
- 역할:
- `FLEET`, `GEAR_IN_ZONE`, `GEAR_OUT_ZONE``1h/1h-fb/6h` snapshot 생성
- 주요 기준:
- 같은 `parent_name` 전체 기준 1h active member 수
- `GEAR_OUT_ZONE` 최소 멤버 수
- parent nearby 시 `isParent=true`
## 4.6 `prediction/algorithms/gear_correlation.py`
### `run_gear_correlation(vessel_store, gear_groups, conn) -> dict`
- 역할:
- 그룹당 후보 탐색
- raw metric 저장
- EMA score 갱신
- 입력:
- `gear_groups`
- 출력:
- `updated`, `models`, `raw_inserted`
### `_compute_gear_vessel_metrics(gear_center_lat, gear_center_lon, gear_radius_nm, vessel_track, params) -> dict`
- 출력 metric:
- `proximity_ratio`
- `visit_score`
- `activity_sync`
- `composite`
- 한계:
- raw metric은 짧은 항적에 과대 우호적일 수 있음
- 이 문제는 parent inference 단계의 coverage-aware 보정에서 완화
### `update_score(prev_score, raw_score, streak, last_observed, now, gear_group_active_ratio, shadow_bonus, params) -> tuple`
- 상태:
- `ACTIVE`
- `PATTERN_DIVERGE`
- `GROUP_QUIET`
- `NORMAL_GAP`
- `SIGNAL_LOSS`
- 의미:
- correlation score는 장기 기억보다 short-memory EMA에 가깝다
## 4.7 `prediction/algorithms/gear_parent_inference.py`
### `run_gear_parent_inference(vessel_store, gear_groups, conn) -> dict[str, int]`
- 역할:
- direct parent 보강
- active exclusion/label 적용
- 후보 점수 계산
- 상태 전이
- snapshot/resolution/tracking 저장
### `_load_existing_resolution(conn, group_keys) -> dict`
- 역할:
- 현재 그룹의 이전 resolution 상태 로드
- 현재 쓰임:
- `PREVIOUS_SELECTION` 후보 seed
- `stable_cycles`
- `MANUAL_CONFIRMED` 유지
- reject cooldown
### `_build_candidate_scores(...) -> list[CandidateScore]`
- 후보 집합 원천:
- 상위 correlation 후보
- registry name exact bucket
- previous selection
- 제거 규칙:
- global exclusion
- group exclusion
- reject cooldown
- 점수 항목:
- `base_corr_score`
- `name_match_score`
- `track_similarity_score`
- `visit_score_6h`
- `proximity_score_6h`
- `activity_sync_score_6h`
- `stability_score`
- `registry_bonus`
- `china_mmsi_bonus` 후가산
### `_name_match_score(parent_name, candidate_name, registry) -> float`
- 규칙:
- 원문 동일 `1.0`
- 정규화 동일 `0.8`
- prefix/contains `0.5`
- 숫자 제거 후 문자 부분 동일 `0.3`
- else `0.0`
### `_build_track_coverage_metrics(center_track, vessel_track, gear_center_lat, gear_center_lon) -> dict`
- 역할:
- short-track 과대평가 방지용 증거 강도 계산
- 핵심 출력:
- `trackCoverageFactor`
- `visitCoverageFactor`
- `activityCoverageFactor`
- `coverageFactor`
- downstream:
- `track`, `visit`, `proximity`, `activity` raw score에 곱해 effective score 생성
## 4.8 `prediction/algorithms/gear_parent_episode.py`
### `build_episode_plan(groups, previous_by_lineage) -> EpisodePlan`
- 역할:
- 현재 cycle group을 이전 active episode와 매칭
- `NEW`, `CONTINUED`, `SPLIT_CONTINUE`, `SPLIT_NEW`, `MERGE_NEW` 결정
- 입력:
- `GroupEpisodeInput[]`
- 최근 `6h` active `EpisodeState[]`
- continuity score:
- `0.75 * member_jaccard + 0.25 * center_support`
- 기준:
- `member_jaccard`
- 중심점 거리 `12nm`
- continuity score threshold `0.45`
- merge score threshold `0.35`
- 출력:
- assignment map
- expired episode set
- merged target map
### `compute_prior_bonus_components(...) -> dict[str, float]`
- 역할:
- 동일 candidate에 대한 episode/lineage/label prior bonus 계산
- 입력 집계 범위:
- episode prior: `24h`
- lineage prior: `7d`
- label prior: `30d`
- cap:
- `episode <= 0.10`
- `lineage <= 0.05`
- `label <= 0.10`
- `total <= 0.20`
- 출력:
- `episodePriorBonus`
- `lineagePriorBonus`
- `labelPriorBonus`
- `priorBonusTotal`
### `sync_episode_states(conn, observed_at, plan) -> None`
- 역할:
- active/merged/expired episode 상태를 `gear_group_episodes`에 반영
- 기준:
- merge 대상은 `MERGED`
- continuity 없는 old episode는 `EXPIRED`
### `insert_episode_snapshots(conn, observed_at, plan, payloads) -> int`
- 역할:
- cycle별 continuity 결과와 top candidate/result를 `gear_group_episode_snapshots`에 저장
- 기록:
- `episode_id`
- `parent_episode_ids`
- `top_candidate_mmsi`
- `top_candidate_score`
- `resolution_status`
### `_select_status(top_candidate, margin, stable_cycles) -> tuple[str, str]`
- 상태:
- `NO_CANDIDATE`
- `AUTO_PROMOTED`
- `REVIEW_REQUIRED`
- `UNRESOLVED`
- auto promotion 조건:
- `target_type == VESSEL`
- `CORRELATION` source 포함
- `final_score >= 0.72`
- `margin >= 0.15`
- `stable_cycles >= 3`
- review 조건:
- `final_score >= 0.60`
## 5. 현재 엔드포인트 스펙
## 5.1 조회 계열
### `/api/kcg/vessel-analysis/groups/parent-inference/review`
- 역할:
- 최신 전역 `1h` 기준 검토 대기 목록
- 조건:
- stale resolution 숨김
- candidate count는 latest candidate snapshot 기준
### `/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference`
- 역할:
- 특정 그룹의 현재 live sub-cluster 상세
- 주의:
- “현재 최신 전역 `1h`에 실제 존재하는 sub-cluster만” 반환
### `/api/kcg/vessel-analysis/parent-inference/candidate-exclusions`
- 역할:
- 그룹/전역 제외 목록 조회
### `/api/kcg/vessel-analysis/parent-inference/label-sessions`
- 역할:
- active 또는 전체 라벨 세션 조회
## 5.2 액션 계열
### `POST /candidate-exclusions/global`
- 역할:
- 전역 후보 제외 생성
- 영향:
- 다음 cycle부터 모든 그룹에서 해당 MMSI 제거
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/exclude`
- 역할:
- 그룹 단위 후보 제외 생성
- 영향:
- 다음 cycle부터 해당 그룹에서만 제거
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/label`
- 역할:
- 기간형 정답 라벨 세션 생성
- 영향:
- 다음 cycle부터 tracking row 누적
## 6. 현재 기억 구조와 prior bonus
### 6.1 short-memory와 long-memory의 분리
- `gear_correlation_scores`
- EMA short-memory
- 미관측 시 decay
- 현재 후보 seed 역할
- `gear_group_parent_resolution`
- 현재 상태 1행
- same-episode가 아니면 `PREVIOUS_SELECTION` carry를 직접 사용하지 않음
- `gear_group_episodes`
- continuity memory
- `candidate_snapshots`
- bonus 집계 원천
### 6.2 현재 final score의 장기 기억 반영
현재는 과거 점수를 직접 carry하지 않고, 약한 prior bonus만 후가산한다.
```text
final_score =
current_signal_score
+ china_mmsi_bonus
+ prior_bonus_total
```
여기서 `prior_bonus_total`은:
- `episode_prior_bonus`
- `lineage_prior_bonus`
- `label_prior_bonus`
의 합이며 총합 cap은 `0.20`이다.
### 6.3 왜 weak prior인가
과거 점수를 그대로 넘기면:
- 다른 episode로 잘못 관성이 전이될 수 있다
- split/merge 이후 잘못된 top1이 고착될 수 있다
- 오래된 오답이 장기 drift로 남을 수 있다
그래서 현재 구현은 과거 점수를 “현재 score 자체”가 아니라 “작은 bonus”로만 쓴다.
## 7. 현재 continuity / prior 동작
### 7.1 episode continuity
- 같은 lineage 안에서 최근 `6h` active episode를 불러온다.
- continuity score가 높은 이전 episode가 있으면 `CONTINUED`
- 1개 parent episode가 여러 current cluster로 이어지면 `SPLIT_CONTINUE` + `SPLIT_NEW`
- 여러 previous episode가 하나 current cluster로 모이면 `MERGE_NEW`
- 어떤 current와도 연결되지 못한 old episode는 `EXPIRED`
### 7.2 prior 집계
| prior | 참조 범위 | 현재 집계 값 |
| --- | --- | --- |
| episode prior | 최근 동일 episode `24h` | seen_count, top1_count, avg_score, last_seen_at |
| lineage prior | 동일 이름 lineage `7d` | seen_count, top1_count, top3_count, avg_score, last_seen_at |
| label prior | 라벨 세션 `30d` | session_count, last_labeled_at |
### 7.3 구현 시 주의
- 과거 점수를 직접 누적하지 말 것
- prior는 bonus로만 사용하고 cap을 둘 것
- split/merge 이후 parent 후보 관성은 약하게만 상속할 것
- stale live sub-cluster와 vanished old sub-cluster를 혼동하지 않도록, aggregation도 최신 episode anchor를 기준으로 할 것
## 8. 참조 문서
- [GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md)
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2.md)
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md)

파일 보기

@ -1,677 +0,0 @@
# Gear Parent Inference Dataflow Paper
## 초록
이 문서는 `iran-airstrike-replay-codex`의 한국 수역 어구 모선 추적 체계를 코드 기준으로 복원하는 통합 기술 문서다. 범위는 `snpdb` 5분 궤적 적재, 인메모리 캐시 유지, 어구 그룹 검출, 서브클러스터 생성, `1h/1h-fb/6h` 폴리곤 스냅샷 저장, correlation 기반 후보 점수화, coverage-aware parent inference, `episode_id` 기반 연속성 계층, backend read model, review/exclusion/label v2까지 포함한다. 문서의 목적은 “현재 무엇이 구현되어 있고, 각 경우의 수에서 어떤 분기 규칙이 적용되는가”를 한 문서에서 복원 가능하게 만드는 것이다.
## 1. 범위와 전제
### 1.1 구현 기준
- frontend: `frontend/`
- backend: `backend/`
- prediction: `prediction/`
- schema migration: `database/migration/012_gear_parent_inference.sql`, `database/migration/014_gear_parent_workflow_v2_phase1.sql`, `database/migration/015_gear_parent_episode_tracking.sql`
### 1.2 실행 환경
- lab backend: `rocky-211:18083`
- lab prediction: `redis-211:18091`
- lab schema: `kcg_lab`
- 로컬 프론트 진입점: `yarn dev:lab`, `yarn dev:lab:ssh`
### 1.3 문서의 구분
- 구현됨:
- 현재 repo 코드와 lab 배포에 이미 반영된 규칙
- 후속 확장 후보:
- episode continuity 위에서 추가로 올릴 `focus mode`, richer episode lineage API, calibration report
## 2. 문제 정의
이 시스템은 한국 수역에서 AIS 신호를 이용해 아래 문제를 단계적으로 푼다.
1. 최근 24시간의 선박/어구 궤적을 메모리 캐시에 유지한다.
2. 동일한 어구 이름 계열을 공간적으로 묶어 어구 그룹을 만든다.
3. 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 생성한다.
4. 주변 선박 또는 잘못 분류된 어구 AIS를 후보로 수집하고 correlation 점수를 만든다.
5. 후보를 모선 추론 점수로 다시 환산한다.
6. 사람이 라벨/제외를 누적해 모델 정확도 고도화용 데이터셋을 만든다.
핵심 난점은 아래 세 가지다.
- DB 적재 지연 때문에 live incremental cache와 fresh reload가 다를 수 있다.
- 같은 `parent_name` 아래에서도 실제로는 여러 공간 덩어리로 갈라질 수 있다.
- 짧은 항적이 `track/proximity/activity`에서 과대평가될 수 있다.
## 3. 전체 아키텍처 흐름
```mermaid
flowchart LR
A["signal.t_vessel_tracks_5min<br/>5분 bucket linestringM"] --> B["prediction/db/snpdb.py<br/>safe bucket + overlap backfill"]
B --> C["prediction/cache/vessel_store.py<br/>24h in-memory cache"]
C --> D["prediction/fleet_tracker.py<br/>gear_identity_log / snapshot"]
C --> E["prediction/algorithms/polygon_builder.py<br/>gear group detect + sub-cluster + snapshots"]
E --> F["kcg_lab.group_polygon_snapshots"]
C --> G["prediction/algorithms/gear_correlation.py<br/>raw metrics + EMA score"]
G --> H["kcg_lab.gear_correlation_raw_metrics"]
G --> I["kcg_lab.gear_correlation_scores"]
F --> J["prediction/algorithms/gear_parent_inference.py<br/>candidate build + scoring + status"]
H --> J
I --> J
K["v2 exclusions / labels"] --> J
J --> L["kcg_lab.gear_group_parent_candidate_snapshots"]
J --> M["kcg_lab.gear_group_parent_resolution"]
J --> N["kcg_lab.gear_parent_label_tracking_cycles"]
F --> O["backend GroupPolygonService"]
L --> O
M --> O
N --> O
O --> P["frontend ParentReviewPanel"]
```
## 4. 원천 데이터와 시간 모델
### 4.1 원천 데이터 형식
원천은 `signal.t_vessel_tracks_5min`이며, `1 row = 1 MMSI = 5분 구간의 궤적 전체``LineStringM`으로 보관한다. 실제 위치 포인트는 `ST_DumpPoints(track_geom)`로 분해하고, 각 점의 timestamp는 `ST_M((dp).geom)`에서 꺼낸다. 구현 위치는 `prediction/db/snpdb.py`다.
### 4.2 safe watermark
현재 구현은 “DB 적재가 완료된 bucket만 읽는다”는 원칙을 따른다.
- `prediction/time_bucket.py`
- `compute_safe_bucket()`
- `compute_initial_window_start()`
- `compute_incremental_window_start()`
- 기본값:
- `SNPDB_SAFE_DELAY_MIN`
- `SNPDB_BACKFILL_BUCKETS`
핵심 규칙:
1. 초기 적재는 `now - safe_delay`를 5분 내림한 `safe_bucket`까지만 읽는다.
2. 증분 적재는 `last_bucket - backfill_window`부터 `safe_bucket`까지 다시 읽는다.
3. live cache는 `timestamp`가 아니라 `time_bucket` 기준으로 24시간 cutoff를 맞춘다.
### 4.3 왜 safe watermark가 필요한가
`time_bucket > last_bucket`만 사용하면, 늦게 들어온 같은 bucket row를 영구히 놓칠 수 있다. 현재 구현은 overlap backfill과 dedupe로 이 drift를 줄인다.
- 조회: `prediction/db/snpdb.py`
- 병합 dedupe: `prediction/cache/vessel_store.py`
## 5. Stage 1: 캐시 적재와 유지
### 5.1 초기 적재
`prediction/main.py`는 시작 시 `vessel_store.load_initial(24)`를 호출한다.
`prediction/cache/vessel_store.py`의 규칙:
1. `snpdb.fetch_all_tracks(hours)`로 최근 24시간을 safe bucket까지 읽는다.
2. MMSI별 DataFrame으로 `_tracks`를 구성한다.
3. 최대 `time_bucket``_last_bucket`으로 저장한다.
4. static info와 permit registry를 함께 refresh한다.
### 5.2 증분 병합
스케줄러는 `snpdb.fetch_incremental(vessel_store.last_bucket)`로 overlap backfill 구간을 다시 읽는다.
`merge_incremental()` 규칙:
1. 기존 DataFrame과 새 batch를 합친다.
2. `timestamp`, `time_bucket`으로 정렬한다.
3. `timestamp` 기준 중복은 `keep='last'`로 제거한다.
4. batch의 최대 `time_bucket`이 더 크면 `_last_bucket`을 갱신한다.
### 5.3 stale eviction
`evict_stale()`는 safe bucket 기준 24시간 이전 포인트를 제거한다. `time_bucket`이 있으면 bucket 기준, 없으면 timestamp 기준으로 fallback한다.
## 6. Stage 2: 어구 identity 추출
`prediction/fleet_tracker.py`는 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2`를 파싱하고 `gear_identity_log`를 관리한다.
### 6.1 이름 기반 필터
공통 규칙은 `prediction/algorithms/gear_name_rules.py`에 있다.
- 정규화:
- 대문자화
- 공백, `_`, `-`, `%` 제거
- 추적 가능 최소 길이:
- 정규화 길이 `>= 4`
`fleet_tracker.py``polygon_builder.py`는 모두 `is_trackable_parent_name()`을 사용한다. 즉 짧은 이름은 추론 이전, 그룹 생성 이전 단계부터 제외된다.
### 6.2 identity log 동작
`fleet_tracker.py`의 핵심 분기:
1. 같은 MMSI + 같은 이름:
- 기존 활성 row의 `last_seen_at`, 위치만 갱신
2. 같은 MMSI + 다른 이름:
- 기존 row 비활성화
- 새 row insert
3. 다른 MMSI + 같은 이름:
- 기존 row 비활성화
- 새 MMSI로 row insert
- 기존 `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전
## 7. Stage 3: 어구 그룹 생성과 서브클러스터
실제 어구 그룹은 `prediction/algorithms/polygon_builder.py``detect_gear_groups()`가 만든다.
### 7.1 1차 그룹화
규칙:
1. 최신 position 이름이 어구 패턴에 맞아야 한다.
2. `STALE_SEC`를 넘는 오래된 신호는 제외한다.
3. `440`, `441` MMSI는 어구 AIS 미사용으로 간주해 제외한다.
4. `is_trackable_parent_name(parent_raw)`를 만족해야 한다.
5. 같은 `parent_name`은 공백 제거 버전으로 묶는다.
### 7.2 서브클러스터 생성
같은 이름 아래에서도 거리 기반 연결성으로 덩어리를 나눈다.
- 거리 임계치: `MAX_DIST_DEG = 0.15`
- 연결 규칙:
- 각 어구가 클러스터 내 최소 1개와 `MAX_DIST_DEG` 이내면 같은 연결 요소
- 구현:
- Union-Find
모선이 이미 있으면, 모선과 가장 가까운 클러스터를 seed cluster로 간주한다.
### 7.3 `sub_cluster_id` 부여 규칙
현재 구현은 아래와 같다.
1. 클러스터가 1개면 `sub_cluster_id = 0`
2. 클러스터가 여러 개면 `1..N`
3. 이후 동일 `parent_key`의 두 서브그룹이 다시 근접 병합되면 `sub_cluster_id = 0`
`sub_cluster_id`는 영구 식별자가 아니라 “그 시점의 공간 분리 라벨”이다.
### 7.4 병합 규칙
동일 `parent_key`의 두 그룹이 다시 가까워지면:
1. 멤버를 합친다.
2. 부모 MMSI가 없는 큰 그룹에 작은 그룹의 `parent_mmsi`를 승계할 수 있다.
3. `sub_cluster_id = 0`으로 재설정한다.
### 7.5 스냅샷 생성 규칙
`build_all_group_snapshots()`는 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 만든다.
- `1h`
- 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `>= 2`
- `1h-fb`
- 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `< 2`
- 리플레이/일치율 추적용
- 라이브 현황에서 제외
- `6h`
- 6시간 내 stale이 아니어야 함
추가 규칙:
1. 서브클러스터 내 1h 활성 멤버가 2개 미만이면 최신 2개로 fallback display를 만든다.
2. 수역 외(`GEAR_OUT_ZONE`)인데 멤버 수가 `MIN_GEAR_GROUP_SIZE` 미만이면 스킵한다.
3. 모선이 있고, 멤버와 충분히 근접하면 `members[].isParent = true`로 같이 넣는다.
## 8. Stage 4: correlation 모델
`prediction/algorithms/gear_correlation.py`는 어구 그룹별 raw metric과 EMA score를 만든다.
### 8.1 후보 생성
입력:
- group center
- group radius
- active ratio
- group member MMSI set
출력 후보:
- 선박 후보(`VESSEL`)
- 잘못 분류된 어구 후보(`GEAR_BUOY`)
후보 수는 그룹당 최대 `30`개로 제한된다.
### 8.2 raw metric
선박 후보는 최근 6시간 항적 기반으로 아래 값을 만든다.
- `proximity_ratio`
- `visit_score`
- `activity_sync`
- `dtw_similarity`
어구 후보는 단순 거리 기반 `proximity_ratio`만 사용한다.
### 8.3 EMA score
모델 파라미터(`gear_correlation_param_models`)별로 아래를 수행한다.
1. composite score 계산
2. 이전 score와 streak를 읽는다
3. `update_score()`로 EMA 갱신
4. threshold 이상이거나 기존 row가 있으면 upsert
반대로 이번 사이클 후보군에서 빠진 기존 항목은 `OUT_OF_RANGE`로 fast decay된다.
### 8.4 correlation 산출물
- `gear_correlation_raw_metrics`
- `gear_correlation_scores`
여기까지는 “잠재적 모선/근접 대상”의 score이고, 최종 parent inference는 아직 아니다.
## 9. Stage 5: parent inference
`prediction/algorithms/gear_parent_inference.py`가 최종 모선 추론을 수행한다.
전체 진입점은 `run_gear_parent_inference(vessel_store, gear_groups, conn)`이다.
### 9.1 전체 분기 개요
```mermaid
flowchart TD
A["active gear group"] --> B{"direct parent member<br/>exists?"}
B -- yes --> C["DIRECT_PARENT_MATCH<br/>fresh resolution upsert"]
B -- no --> D{"trackable parent name?"}
D -- no --> E["SKIPPED_SHORT_NAME"]
D -- yes --> F["build candidate set"]
F --> G{"candidate exists?"}
G -- no --> H["NO_CANDIDATE"]
G -- yes --> I["score + rank + margin + stable cycles"]
I --> J{"auto promotion rule?"}
J -- yes --> K["AUTO_PROMOTED"]
J -- no --> L{"top score >= 0.60?"}
L -- yes --> M["REVIEW_REQUIRED"]
L -- no --> N["UNRESOLVED"]
```
### 9.1.1 episode continuity 선행 단계
현재 구현에서 `run_gear_parent_inference()`는 후보 점수를 만들기 전에 먼저 `prediction/algorithms/gear_parent_episode.py`를 호출해 active 그룹의 continuity를 계산한다.
입력:
- 현재 cycle `gear_groups`
- 정규화된 `parent_name`
- 최근 `6h` active `gear_group_episodes`
- 최근 `24h` episode prior, `7d` lineage prior, `30d` label prior 집계
핵심 규칙:
1. continuity score는 `0.75 * member_jaccard + 0.25 * center_support`다.
2. 중심점 지원값은 `12nm` 이내일수록 커진다.
3. continuity score가 충분하거나, overlap member가 있고 거리 조건을 만족하면 연결 후보로 본다.
4. 두 개 이상 active episode가 하나의 현재 cluster로 들어오면 `MERGE_NEW`다.
5. 하나의 episode가 여러 현재 cluster로 갈라지면 하나는 `SPLIT_CONTINUE`, 나머지는 `SPLIT_NEW`다.
6. 아무 previous episode와도 연결되지 않으면 `NEW`다.
7. 현재 cycle과 연결되지 못한 active episode는 `EXPIRED` 또는 `MERGED`로 종료한다.
현재 저장되는 continuity 메타데이터:
- `gear_group_parent_candidate_snapshots.episode_id`
- `gear_group_parent_resolution.episode_id`
- `gear_group_parent_resolution.continuity_source`
- `gear_group_parent_resolution.continuity_score`
- `gear_group_parent_resolution.prior_bonus_total`
- `gear_group_episodes`
- `gear_group_episode_snapshots`
### 9.2 direct parent 보강
최신 어구 그룹에 아래 중 하나가 있으면 후보 추론 대신 직접 모선 매칭으로 처리한다.
1. `members[].isParent = true`
2. `group.parent_mmsi` 존재
이 경우:
- `status = DIRECT_PARENT_MATCH`
- `decision_source = DIRECT_PARENT_MATCH`
- `confidence = 1.0`
- `candidateCount = 0`
단, 기존 상태가 `MANUAL_CONFIRMED`면 그 수동 상태를 유지한다.
### 9.3 짧은 이름 스킵
정규화 이름 길이 `< 4`면:
- 후보 생성 자체를 수행하지 않는다.
- `status = SKIPPED_SHORT_NAME`
- `decision_source = AUTO_SKIP`
### 9.4 후보 집합
후보 집합은 아래의 합집합이다.
1. default correlation model 상위 후보
2. registry name exact bucket
3. 기존 resolution의 `selected_parent_mmsi` 또는 이전 top candidate
여기에 아래를 적용한다.
- active global exclusion 제거
- active group exclusion 제거
- 최근 reject cooldown 후보 제거
### 9.5 이름 점수
현재 구현 규칙:
1. 원문 완전일치: `1.0`
2. 정규화 완전일치: `0.8`
3. prefix/contains: `0.5`
4. 숫자를 제거한 순수 문자 부분만 동일: `0.3`
5. 그 외: `0.0`
비교 대상:
- `parent_name`
- 후보 AIS 이름
- registry `name_cn`
- registry `name_en`
### 9.6 coverage-aware evidence
짧은 항적 과대평가를 막기 위해 raw score와 effective score를 분리한다.
evidence에 남는 값:
- `trackPointCount`
- `trackSpanMinutes`
- `overlapPointCount`
- `overlapSpanMinutes`
- `inZonePointCount`
- `inZoneSpanMinutes`
- `trackCoverageFactor`
- `visitCoverageFactor`
- `activityCoverageFactor`
- `coverageFactor`
현재 최종 점수에는 raw가 아니라 adjusted score가 들어간다.
### 9.7 점수 식
가중치 합은 아래다.
- `0.40 * base_corr`
- `0.15 * name_match`
- `0.15 * track_similarity_effective`
- `0.10 * visit_effective`
- `0.05 * proximity_effective`
- `0.05 * activity_effective`
- `0.10 * stability`
- `+ registry_bonus(0.05)`
그 다음 별도 후가산:
- `412/413` MMSI 보너스 `+0.15`
- 단, `preBonusScore >= 0.30`일 때만 적용
- `episode/lineage/label prior bonus`
- 최근 동일 episode `24h`
- 동일 lineage `7d`
- 라벨 세션 `30d`
- 총합 cap `0.20`
### 9.8 상태 전이
분기 조건:
- `NO_CANDIDATE`
- 후보가 하나도 없을 때
- `AUTO_PROMOTED`
- `target_type == VESSEL`
- candidate source에 `CORRELATION` 포함
- `final_score >= auto_promotion_threshold`
- `margin >= auto_promotion_margin`
- `stable_cycles >= auto_promotion_stable_cycles`
- `REVIEW_REQUIRED`
- `final_score >= 0.60`
- `UNRESOLVED`
- 나머지
추가 예외:
- 기존 상태가 `MANUAL_CONFIRMED`면 수동 상태를 유지한다.
- active label session이 있으면 tracking row를 별도로 적재한다.
### 9.9 산출물
- `gear_group_parent_candidate_snapshots`
- `gear_group_parent_resolution`
- `gear_parent_label_tracking_cycles`
- `gear_group_episodes`
- `gear_group_episode_snapshots`
## 10. Stage 6: backend read model
backend의 중심은 `backend/.../GroupPolygonService.java`다.
### 10.1 최신 1h만 라이브로 간주
group list, review queue, detail API는 모두 최신 전역 `1h` 스냅샷만 기준으로 삼는다.
핵심 효과:
1. `1h-fb`는 라이브 현황에서 기본 제외된다.
2. 이미 사라진 과거 sub-cluster는 detail API에서 다시 보이지 않는다.
### 10.2 stale inference 차단
`resolution.last_evaluated_at >= group.snapshot_time`인 경우만 join한다.
즉 최신 group snapshot보다 오래된 candidate/resolution은 detail/review/list에서 숨긴다. 이 규칙이 `ZHEDAIYU02433`, `ZHEDAIYU02394` 유형 stale 표시를 막는다.
### 10.3 detail API 의미
`/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference`
현재 의미:
- 해당 그룹의 최신 전역 `1h` live sub-cluster 집합
- 각 sub-cluster의 fresh resolution
- 각 sub-cluster의 latest candidate snapshot
## 11. Stage 7: review / exclusion / label v2
v2 Phase 1은 “자동 추론 결과”와 “사람 판단 데이터”를 분리하는 구조다.
### 11.1 사람 판단 저장소
- `gear_parent_candidate_exclusions`
- `gear_parent_label_sessions`
- `gear_parent_label_tracking_cycles`
### 11.2 액션 의미
- 그룹 제외:
- 특정 `group_key + sub_cluster_id`에서 특정 후보 MMSI를 일정 기간 제거
- 전체 후보 제외:
- 특정 MMSI를 모든 그룹 후보군에서 제거
- 정답 라벨:
- 특정 그룹에 대해 정답 parent MMSI를 `1/3/5일` 세션으로 지정
- prediction은 이후 cycle마다 top1/top3 여부를 추적
### 11.3 why v2
기존 `MANUAL_CONFIRMED`/`REJECT`는 운영 override 성격이 강했고, “모델 정확도 평가용 백데이터”와 섞였다. v2는 이 둘을 분리해 라벨을 평가 데이터로 쓰도록 한다.
## 12. 실제 경우의 수 분기표
| 경우 | 구현 위치 | 현재 동작 |
| --- | --- | --- |
| 이름 길이 `< 4` | `gear_name_rules.py`, `fleet_tracker.py`, `polygon_builder.py`, `gear_parent_inference.py` | identity/grouping/inference 단계에서 제외 또는 `SKIPPED_SHORT_NAME` |
| 직접 모선 포함 | `polygon_builder.py`, `gear_parent_inference.py` | `DIRECT_PARENT_MATCH` fresh resolution |
| 같은 이름, 멀리 떨어진 어구 | `polygon_builder.py` | 별도 sub-cluster 생성 |
| 두 서브클러스터가 다시 근접 | `polygon_builder.py` | 하나로 병합, `sub_cluster_id = 0` |
| group 전체 1h 활성 멤버 `< 2` | `polygon_builder.py` | `1h-fb` 생성, live 현황 제외 |
| 후보가 하나도 없음 | `gear_parent_inference.py` | `NO_CANDIDATE` |
| 짧은 항적이 우연히 근접 | `gear_parent_inference.py` | coverage-aware 보정으로 effective score 감소 |
| stale old inference가 남아 있음 | `GroupPolygonService.java` | 최신 group snapshot보다 오래되면 숨김 |
| 직접 parent가 이미 있음 | `gear_parent_inference.py` | 후보 계산 대신 direct parent resolution |
## 13. `sub_cluster_id`의 한계
현재 코드에서 `sub_cluster_id`는 영구 identity가 아니다.
이유:
1. 같은 이름 그룹의 공간 분리 수가 cycle마다 달라질 수 있다.
2. 병합되면 `0`으로 재설정된다.
3. 멤버가 추가/이탈해도 기존 번호 의미가 유지된다고 보장할 수 없다.
따라서 `group_key + sub_cluster_id`는 “현재 cycle의 공간 덩어리”를 가리키는 키로는 유효하지만, 장기 연속 추적 키로는 부적합하다.
## 14. Stage 8: `episode_id` continuity + prior bonus
### 14.1 목적
현재 구현의 `episode_id`는 “같은 어구 덩어리의 시간적 연속성”을 추적하는 별도 식별자다. `sub_cluster_id`를 대체하지 않고, 그 위에 얹는 계층이다.
핵심 목적:
- 작은 멤버 변화는 같은 episode로 이어 붙인다.
- 구조적 split/merge는 continuity source로 기록한다.
- long-memory는 `stable_cycles` 직접 승계가 아니라 약한 prior bonus로만 전달한다.
### 14.2 현재 저장소
- `gear_group_episodes`
- active/merged/expired episode 현재 상태
- `gear_group_episode_snapshots`
- cycle별 episode 스냅샷
- `gear_group_parent_candidate_snapshots`
- `episode_id`, `normalized_parent_name`,
`episode_prior_bonus`, `lineage_prior_bonus`, `label_prior_bonus`
- `gear_group_parent_resolution`
- `episode_id`, `continuity_source`, `continuity_score`, `prior_bonus_total`
### 14.3 continuity score
현재 continuity score는 아래다.
```text
continuity_score =
0.75 * member_jaccard
+ 0.25 * center_support
```
- `member_jaccard`
- 현재/이전 episode 멤버 MMSI Jaccard
- `center_support`
- 중심점 거리 `12nm` 이내일수록 높아지는 값
연결 후보 판단:
- continuity score `>= 0.45`
- 또는 overlap member가 있고 거리 조건을 만족하면 연결 후보로 인정
### 14.4 continuity source 규칙
- `NEW`
- 어떤 이전 episode와도 연결되지 않음
- `CONTINUED`
- 1:1 continuity
- `SPLIT_CONTINUE`
- 하나의 이전 episode가 여러 현재 cluster로 갈라졌고, 그중 주 가지
- `SPLIT_NEW`
- split로 새로 생성된 가지
- `MERGE_NEW`
- 2개 이상 active episode가 의미 있게 하나의 현재 cluster로 합쳐짐
- `DIRECT_PARENT_MATCH`
- 직접 모선 포함 그룹이 fresh resolution으로 정리되는 경우의 최종 resolution source
### 14.5 merge / split / expire
현재 구현 규칙:
1. split
- 가장 유사한 현재 cluster 1개는 기존 episode 유지
- 나머지는 새 episode 생성
- 새 episode에는 `split_from_episode_id` 저장
2. merge
- 2개 이상 previous episode가 같은 현재 cluster로 의미 있게 들어오면 새 episode 생성
- 이전 episode들은 `MERGED`, `merged_into_episode_id = 새 episode`
3. expire
- 최근 `6h` active episode가 현재 cycle 어떤 cluster와도 연결되지 않으면 `EXPIRED`
### 14.6 prior bonus 계층
현재 final score에는 signal score 뒤에 아래 prior bonus가 후가산된다.
- `episode_prior_bonus`
- 최근 동일 episode `24h`
- cap `0.10`
- `lineage_prior_bonus`
- 동일 정규화 이름 lineage `7d`
- cap `0.05`
- `label_prior_bonus`
- 동일 lineage 라벨 세션 `30d`
- cap `0.10`
- 총합 cap
- `0.20`
현재 후보가 이미 candidate set에 들어온 경우에만 적용하며, 과거 점수를 직접 carry하는 대신 약한 보너스로만 사용한다.
### 14.7 병합 후 후보 관성
질문 사례처럼 `A` episode 후보 `a`, `B` episode 후보 `b`가 있다가 병합 후 `b`가 더 적합해질 수 있다. 현재 구현은 병합 시 무조건 `A`를 유지하지 않고 새 episode를 생성해 `A/B` 둘 다의 history를 prior bonus 풀에서 재평가한다. 따라서 `b`는 완전 신규 후보처럼 0에서 시작하지 않지만, `A`의 과거 `stable_cycles`가 그대로 지배하지도 않는다.
## 15. 현재 episode 상태 흐름
```mermaid
stateDiagram-v2
[*] --> Active
Active --> Active: "CONTINUED / 소규모 멤버 변동"
Active --> Active: "SPLIT_CONTINUE"
Active --> Active: "MERGE_NEW로 새 episode 생성 후 연결"
Active --> Merged: "merged_into_episode_id 기록"
Active --> Expired: "최근 6h continuity 없음"
Merged --> [*]
Expired --> [*]
```
## 16. 결론
현재 구현은 아래를 모두 포함한다.
- safe watermark + overlap backfill 기반 incremental 안정화
- 짧은 이름 그룹 제거
- 거리 기반 sub-cluster와 `1h/1h-fb/6h` 스냅샷
- correlation + parent inference 분리
- coverage-aware score 보정
- stale inference 차단
- direct parent supplement
- v2 exclusion/label/tracking 저장소
- `episode_id` continuity와 prior bonus
남은 과제는 `episode` 자체보다, 이 continuity 계층을 read model과 시각화에서 더 설명력 있게 노출하는 것이다. 즉 다음 단계의 핵심은 episode 도입이 아니라, `episode lineage API`, calibration report, richer review analytics를 얹는 일이다.
## 17. 참고 코드
- `prediction/main.py`
- `prediction/time_bucket.py`
- `prediction/db/snpdb.py`
- `prediction/cache/vessel_store.py`
- `prediction/fleet_tracker.py`
- `prediction/algorithms/gear_name_rules.py`
- `prediction/algorithms/polygon_builder.py`
- `prediction/algorithms/gear_correlation.py`
- `prediction/algorithms/gear_parent_episode.py`
- `prediction/algorithms/gear_parent_inference.py`
- `backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java`
- `backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java`
- `database/migration/012_gear_parent_inference.sql`
- `database/migration/014_gear_parent_workflow_v2_phase1.sql`
- `database/migration/015_gear_parent_episode_tracking.sql`

파일 보기

@ -1,706 +0,0 @@
# Gear Parent Inference Workflow V2 Phase 1 Spec
## 목적
이 문서는 `GEAR-PARENT-INFERENCE-WORKFLOW-V2.md`의 첫 구현 단계를 바로 개발할 수 있는 수준으로 구체화한 명세다.
Phase 1 범위는 아래로 제한한다.
- DB 마이그레이션
- backend API 계약
- prediction exclusion/label read-write 지점
- 프론트의 최소 계약 변화
이번 단계에서는 실제 자동화/LLM 연결은 다루지 않는다.
## 범위 요약
### 포함
- 그룹 단위 후보 제외 `1/3/5일`
- 전역 후보 제외
- 정답 라벨 세션 `1/3/5일`
- 라벨 세션 기간 동안 cycle별 tracking 기록
- active exclusion을 parent inference 후보 생성에 반영
- exclusion/label 관리 API
### 제외
- 운영 `kcg` 스키마 반영
- 기존 `gear_correlation_scores` 산식 변경
- LLM reviewer
- label session의 anchor 기반 재매칭 보강
- UI 고도화 화면 전부
## 구현 원칙
1. 기존 자동 추론 저장소는 유지한다.
2. 새 사람 판단 데이터는 별도 테이블에 저장한다.
3. Phase 1에서는 `group_key + sub_cluster_id`를 세션 식별 기준으로 고정한다.
4. 기존 `CONFIRM/REJECT/RESET` API는 삭제하지 않지만, 새 UI에서는 사용하지 않는다.
5. 새 API와 prediction 로직은 `kcg_lab` 기준으로만 먼저 구현한다.
## DB 명세
## 1. `gear_parent_candidate_exclusions`
### 목적
- 그룹 단위 후보 제외와 전역 후보 제외를 단일 저장소에서 관리
### DDL 초안
```sql
CREATE TABLE IF NOT EXISTS kcg.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL,
group_key VARCHAR(100),
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL,
duration_days INT,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ,
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpce_scope CHECK (scope_type IN ('GROUP', 'GLOBAL')),
CONSTRAINT chk_gpce_reason CHECK (reason_type IN ('GROUP_WRONG_PARENT', 'GLOBAL_NOT_PARENT_TARGET')),
CONSTRAINT chk_gpce_group_scope CHECK (
(scope_type = 'GROUP' AND group_key IS NOT NULL AND sub_cluster_id IS NOT NULL AND duration_days IN (1, 3, 5) AND active_until IS NOT NULL)
OR
(scope_type = 'GLOBAL' AND duration_days IS NULL)
)
);
```
### 인덱스
```sql
CREATE INDEX IF NOT EXISTS idx_gpce_scope_mmsi_active
ON kcg.gear_parent_candidate_exclusions(scope_type, candidate_mmsi, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_group_active
ON kcg.gear_parent_candidate_exclusions(group_key, sub_cluster_id, active_from DESC)
WHERE released_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gpce_active_until
ON kcg.gear_parent_candidate_exclusions(active_until);
```
### active 판정 규칙
active exclusion은 아래를 만족해야 한다.
```sql
released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW())
```
### 해석 규칙
- `GROUP`
- 특정 그룹에서만 해당 후보 제거
- `GLOBAL`
- 모든 그룹에서 해당 후보 제거
## 2. `gear_parent_label_sessions`
### 목적
- 정답 라벨 세션 저장
### DDL 초안
```sql
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL,
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_gpls_duration CHECK (duration_days IN (1, 3, 5)),
CONSTRAINT chk_gpls_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'CANCELLED'))
);
```
### 인덱스
```sql
CREATE INDEX IF NOT EXISTS idx_gpls_group_active
ON kcg.gear_parent_label_sessions(group_key, sub_cluster_id, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_mmsi_active
ON kcg.gear_parent_label_sessions(label_parent_mmsi, active_from DESC)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_gpls_active_until
ON kcg.gear_parent_label_sessions(active_until);
```
### active 판정 규칙
```sql
status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW()
```
### 만료 처리 규칙
prediction 또는 backend batch에서 아래를 주기적으로 실행한다.
```sql
UPDATE kcg.gear_parent_label_sessions
SET status = 'EXPIRED', updated_at = NOW()
WHERE status = 'ACTIVE'
AND active_until <= NOW();
```
## 3. `gear_parent_label_tracking_cycles`
### 목적
- 활성 정답 라벨 세션 동안 cycle별 자동 추론 결과 저장
### DDL 초안
```sql
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_gpltc_session_observed UNIQUE (label_session_id, observed_at)
);
```
### 인덱스
```sql
CREATE INDEX IF NOT EXISTS idx_gpltc_session_observed
ON kcg.gear_parent_label_tracking_cycles(label_session_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_gpltc_top_candidate
ON kcg.gear_parent_label_tracking_cycles(top_candidate_mmsi);
```
## 4. 기존 `gear_group_parent_review_log` action 확장
### 새 action 목록
- `LABEL_PARENT`
- `EXCLUDE_GROUP`
- `EXCLUDE_GLOBAL`
- `RELEASE_EXCLUSION`
- `CANCEL_LABEL`
기존 action과 공존한다.
## migration 파일 제안
- `014_gear_parent_workflow_v2_phase1.sql`
구성 순서:
1. 새 테이블 3개 생성
2. 인덱스 생성
3. review log action 확장은 schema 변경 불필요
4. optional helper view 추가
## optional view 제안
### `vw_active_gear_parent_candidate_exclusions`
```sql
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_candidate_exclusions AS
SELECT *
FROM kcg.gear_parent_candidate_exclusions
WHERE released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW());
```
### `vw_active_gear_parent_label_sessions`
```sql
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_label_sessions AS
SELECT *
FROM kcg.gear_parent_label_sessions
WHERE status = 'ACTIVE'
AND active_from <= NOW()
AND active_until > NOW();
```
## backend API 명세
## 공통 정책
- 모든 write API는 `actor` 필수
- `group_key`, `sub_cluster_id`, `candidate_mmsi`, `selected_parent_mmsi`는 trim 후 저장
- 잘못된 기간은 `400 Bad Request`
- 중복 active session/exclusion 생성 시 `409 Conflict` 대신 동일 active row를 반환해도 됨
- Phase 1에서는 멱등성을 우선한다
## 1. 정답 라벨 세션 생성
### endpoint
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/label-sessions`
### request
```json
{
"selectedParentMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "수동 검토 확정"
}
```
### validation
- `selectedParentMmsi` 필수
- `durationDays in (1,3,5)`
- 동일 `groupKey + subClusterId`에 active label session이 이미 있으면 새 row 생성 금지
### response
```json
{
"groupKey": "58399",
"subClusterId": 0,
"action": "LABEL_PARENT",
"labelSession": {
"id": 12,
"status": "ACTIVE",
"labelParentMmsi": "412333326",
"labelParentName": "UWEIJINGYU51015",
"durationDays": 3,
"activeFrom": "2026-04-03T10:00:00+09:00",
"activeUntil": "2026-04-06T10:00:00+09:00",
"actor": "analyst-01",
"comment": "수동 검토 확정"
}
}
```
## 2. 그룹 후보 제외 생성
### endpoint
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions`
### request
```json
{
"candidateMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "이 그룹에서는 오답"
}
```
### 생성 규칙
- 내부적으로 `scopeType='GROUP'`
- `reasonType='GROUP_WRONG_PARENT'`
- 동일 `groupKey + subClusterId + candidateMmsi` active row가 있으면 재사용
### response
```json
{
"groupKey": "58399",
"subClusterId": 0,
"action": "EXCLUDE_GROUP",
"exclusion": {
"id": 33,
"scopeType": "GROUP",
"candidateMmsi": "412333326",
"durationDays": 3,
"activeFrom": "2026-04-03T10:00:00+09:00",
"activeUntil": "2026-04-06T10:00:00+09:00"
}
}
```
## 3. 전역 후보 제외 생성
### endpoint
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/global`
### request
```json
{
"candidateMmsi": "412333326",
"actor": "analyst-01",
"comment": "모든 어구에서 후보 제외"
}
```
### 생성 규칙
- `scopeType='GLOBAL'`
- `reasonType='GLOBAL_NOT_PARENT_TARGET'`
- `activeUntil = NULL`
- 동일 candidate active global exclusion이 있으면 재사용
## 4. exclusion 해제
### endpoint
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/{id}/release`
### request
```json
{
"actor": "analyst-01",
"comment": "해제"
}
```
### 동작
- `released_at = NOW()`
- `released_by = actor`
- `updated_at = NOW()`
## 5. label session 종료
### endpoint
`POST /api/vessel-analysis/parent-inference/label-sessions/{id}/cancel`
### request
```json
{
"actor": "analyst-01",
"comment": "조기 종료"
}
```
### 동작
- `status='CANCELLED'`
- `updated_at = NOW()`
## 6. active exclusion 조회
### endpoint
`GET /api/vessel-analysis/parent-inference/candidate-exclusions?status=ACTIVE&scopeType=GROUP|GLOBAL&candidateMmsi=...&groupKey=...`
### response 필드
- `id`
- `scopeType`
- `groupKey`
- `subClusterId`
- `candidateMmsi`
- `reasonType`
- `durationDays`
- `activeFrom`
- `activeUntil`
- `releasedAt`
- `actor`
- `comment`
- `isActive`
## 7. label session 목록
### endpoint
`GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE|EXPIRED|CANCELLED&groupKey=...`
### response 필드
- `id`
- `groupKey`
- `subClusterId`
- `labelParentMmsi`
- `labelParentName`
- `durationDays`
- `activeFrom`
- `activeUntil`
- `status`
- `actor`
- `comment`
- `latestTrackingSummary`
## 8. label tracking 상세
### endpoint
`GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking`
### response 필드
- `session`
- `count`
- `items[]`
- `observedAt`
- `autoStatus`
- `topCandidateMmsi`
- `topCandidateScore`
- `topCandidateMargin`
- `candidateCount`
- `labeledCandidatePresent`
- `labeledCandidateRank`
- `labeledCandidateScore`
- `labeledCandidatePreBonusScore`
- `matchedTop1`
- `matchedTop3`
## backend 구현 위치
### 새 DTO/Request 제안
- `GroupParentLabelSessionRequest`
- `GroupParentCandidateExclusionRequest`
- `ReleaseParentCandidateExclusionRequest`
- `CancelParentLabelSessionRequest`
- `ParentCandidateExclusionDto`
- `ParentLabelSessionDto`
- `ParentLabelTrackingCycleDto`
### service 추가 메서드 제안
- `createGroupCandidateExclusion(...)`
- `createGlobalCandidateExclusion(...)`
- `releaseCandidateExclusion(...)`
- `createLabelSession(...)`
- `cancelLabelSession(...)`
- `listCandidateExclusions(...)`
- `listLabelSessions(...)`
- `getLabelSessionTracking(...)`
## prediction 명세
## 적용 함수
중심 파일은 [prediction/algorithms/gear_parent_inference.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/gear_parent_inference.py)다.
### 새 load 함수
- `_load_active_candidate_exclusions(conn, group_keys)`
- `_load_active_label_sessions(conn, group_keys)`
### 반환 구조
`_load_active_candidate_exclusions`
```python
{
"global": {"412333326", "413000111"},
"group": {("58399", 0): {"412333326"}}
}
```
`_load_active_label_sessions`
```python
{
("58399", 0): {
"id": 12,
"label_parent_mmsi": "412333326",
"active_until": ...,
...
}
}
```
### 후보 pruning 순서
1. 기존 candidate union 생성
2. `GLOBAL` exclusion 제거
3. 해당 그룹의 `GROUP` exclusion 제거
4. 남은 후보만 scoring
### tracking row write 규칙
각 그룹 처리 후:
- active label session이 없으면 skip
- 있으면 현재 cycle 결과를 `gear_parent_label_tracking_cycles`에 upsert-like insert
필수 기록값:
- `label_session_id`
- `observed_at`
- `candidate_snapshot_observed_at`
- `auto_status`
- `top_candidate_mmsi`
- `top_candidate_score`
- `top_candidate_margin`
- `candidate_count`
- `labeled_candidate_present`
- `labeled_candidate_rank`
- `labeled_candidate_score`
- `labeled_candidate_pre_bonus_score`
- `matched_top1`
- `matched_top3`
### pre-bonus score 취득
현재 candidate evidence에 이미 아래가 있다.
- `evidence.scoreBreakdown.preBonusScore`
tracking row에서는 이 값을 직접 읽어 저장한다.
### resolution 처리 원칙
Phase 1에서는 다음을 적용한다.
- `LABEL_PARENT`, `EXCLUDE_GROUP`, `EXCLUDE_GLOBAL``gear_group_parent_resolution` 상태를 바꾸지 않는다.
- 자동 추론은 기존 상태 전이를 그대로 사용한다.
- legacy `MANUAL_CONFIRMED` 로직은 남겨두되, 새 UI에서는 호출하지 않는다.
## 프론트 최소 계약
## 기존 패널 액션 치환
현재:
- `확정`
- `24시간 제외`
Phase 1 새 기본 액션:
- `정답 라벨`
- `이 그룹에서 제외`
- `전체 후보 제외`
### 기간 선택 UI
- `정답 라벨`: `1일`, `3일`, `5일`
- `이 그룹에서 제외`: `1일`, `3일`, `5일`
- `전체 후보 제외`: 기간 없음
### 표시 정보
후보 card badge:
- `이 그룹 제외 중`
- `전체 후보 제외 중`
- `정답 라벨 대상`
그룹 summary box:
- active label session 여부
- active group exclusion count
## API 에러 규약
### 400
- 잘못된 duration
- 필수 필드 누락
- groupKey/subClusterId 없음
### 404
- 대상 group 없음
- exclusion/session id 없음
### 409
- active label session 중복 생성
단, Phase 1에서는 backend에서 충돌 시 기존 active row를 그대로 반환하는 방식도 허용한다.
## 테스트 기준
## DB
- GROUP exclusion active query가 정확히 동작
- GLOBAL exclusion active query가 정확히 동작
- label session 만료 시 `EXPIRED` 전환
## backend
- create/release exclusion API
- create/cancel label session API
- list APIs 필터 조건
## prediction
- active exclusion candidate pruning
- global/group exclusion 우선 적용
- label session tracking row 생성
- labeled candidate absent/present/top1/top3 케이스
## 수용 기준
1. 특정 그룹에서 후보 제외를 걸면 다음 cycle부터 그 그룹 후보 목록에서만 빠진다.
2. 전역 후보 제외를 걸면 모든 그룹 후보 목록에서 빠진다.
3. 정답 라벨 세션 생성 후 다음 cycle부터 tracking row가 쌓인다.
4. 자동 resolution은 계속 자동 상태를 유지한다.
5. 기존 manual override API를 쓰지 않아도 review/label/exclusion 흐름이 독립적으로 동작한다.
## Phase 1 이후 바로 이어질 일
### Phase 2
- 라벨 추적 대시보드
- exclusion 관리 화면
- 지표 요약 endpoint
- episode continuity read model 노출
- prior bonus calibration report
### Phase 3
- label session anchor 기반 재매칭
- group case/episode lineage API 확장
- calibration report
## 권장 구현 순서
1. `014_gear_parent_workflow_v2_phase1.sql`
2. backend DTO + controller/service
3. prediction active exclusion/load + tracking write
4. frontend 버튼 교체와 최소 조회 화면
이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.

파일 보기

@ -1,693 +0,0 @@
# Gear Parent Inference Workflow V2
## 문서 목적
이 문서는 lab 환경의 어구 모선 추적 워크플로우를 v1 운영 override 중심 구조에서,
`평가 데이터 축적 + 후보 제외 관리 + 기간형 정답 라벨 추적` 중심 구조로 재정의하는 설계서다.
대상 범위는 아래와 같다.
- `kcg_lab` 스키마
- `backend-lab` (`192.168.1.20:18083`)
- `prediction-lab` (`192.168.1.18:18091`)
- 로컬 프론트 `yarn dev:lab`
운영 `kcg` 스키마와 기존 데모 동작은 이번 설계 단계에서 변경하지 않는다.
현재 구현 기준으로는 v2 Phase 1 저장소/API가 이미 lab에 반영되어 있고, 그 위에 `015_gear_parent_episode_tracking.sql``prediction/algorithms/gear_parent_episode.py`를 통해 `episode continuity + prior bonus` 계층이 추가되었다. 이 문서는 여전히 워크플로우 설계서지만, 사람 판단 저장소와 자동 추론 저장소 분리 원칙은 현재 코드의 실제 기준이기도 하다.
## 배경
현재 v1은 자동 추론 결과와 사람 판단이 같은 저장소에 섞여 있다.
- `확정``gear_group_parent_resolution``MANUAL_CONFIRMED`로 덮어쓴다.
- `24시간 제외`는 특정 그룹에서 후보 1개를 24시간 숨긴다.
- 자동 추론은 계속 돌지만, 수동 판단이 최종 상태를 override한다.
이 구조는 단기 운용에는 편하지만, 아래 목적에는 맞지 않는다.
- 사람이 보면서 모델 가중치와 후보 생성 품질을 평가
- 정답/오답 사례를 데이터셋으로 축적
- 충분한 정확도 확보 후 자동화 또는 LLM 연결
따라서 v2에서는 `자동 추론`, `사람 라벨`, `후보 제외`를 분리한다.
## 핵심 목표
1. 자동 추론 상태는 계속 독립적으로 유지한다.
2. 사람 판단은 override가 아니라 별도 라벨/제외 데이터로 저장한다.
3. 그룹 단위 오답 라벨은 `1일 / 3일 / 5일` 기간형 후보 제외로 관리한다.
4. 전역 후보 제외는 모든 어구 그룹에서 동일 MMSI를 후보군에서 제거한다.
5. 정답 라벨은 `1일 / 3일 / 5일` 세션으로 만들고, 활성 기간 동안 자동 추론 결과를 별도 추적 로그로 남긴다.
6. 알고리즘은 DB exclusion/label 정보를 읽어 다음 cycle부터 바로 반영한다.
7. 향후 threshold 튜닝, 가산점 실험, LLM 연결 평가에 쓰일 수 있는 정량 지표를 만든다.
## 용어
- 자동 추론
- `gear_parent_inference`가 계산한 현재 cycle의 후보 점수와 추천 결과
- 그룹 제외
- 특정 `group_key + sub_cluster_id`에서 특정 후보 MMSI를 일정 기간 후보군에서 제거
- 전역 후보 제외
- 특정 MMSI를 모든 어구 그룹의 모선 후보군에서 제거
- 정답 라벨 세션
- 특정 어구 그룹에 대해 “이 MMSI가 정답 모선”이라고 사람이 지정하고, 일정 기간 자동 추론 결과를 추적하는 세션
- 라벨 추적
- 정답 라벨 세션 활성 기간 동안 자동 추론이 정답 후보를 어떻게 rank/score하는지 누적 저장하는 기록
## 현재 v1의 한계
### 1. `확정`이 평가 라벨이 아니라 운영 override다
- 현재 `CONFIRM`은 resolution을 `MANUAL_CONFIRMED`로 덮어쓴다.
- 이 경우 자동 추론의 실제 성능과 사람 판단이 섞여, 나중에 모델 정확도를 평가하기 어렵다.
### 2. `24시간 제외`는 기간과 범위가 너무 좁다
- 현재는 그룹 단위 24시간 mute만 가능하다.
- `1/3/5일`처럼 길이를 다르게 두고 비교할 수 없다.
- “이 MMSI는 아예 모선 후보 대상이 아니다”라는 전역 규칙을 넣을 수 없다.
### 3. 백데이터 축적 구조가 없다
- 현재는 review log는 남지만, “정답 후보가 cycle별로 몇 위였는지”, “점수가 어떻게 변했는지”, “후보군에 들어왔는지”를 체계적으로 저장하지 않는다.
### 4. 장기 세션에 대한 그룹 스코프가 약하다
- 현재 그룹 기준은 `group_key + sub_cluster_id`다.
- 기간형 라벨/제외를 도입하면 subcluster 재편성 리스크를 고려해야 한다.
## v2 설계 원칙
### 1. 자동 추론 저장소는 그대로 유지한다
아래 기존 저장소는 계속 자동 추론 전용으로 유지한다.
- `gear_group_parent_candidate_snapshots`
- `gear_group_parent_resolution`
- `gear_group_parent_review_log`
단, `review_log`의 의미는 “UI action audit”로 바꾸고, 더 이상 최종 라벨 저장소로 보지 않는다.
### 2. 사람 판단은 새 저장소로 분리한다
사람이 내린 판단은 아래 두 축으로 분리한다.
- 제외 축
- 이 그룹에서 제외
- 전체 후보 제외
- 정답 축
- 기간형 정답 라벨 세션
### 3. 제외는 후보 생성 이후의 gating layer로 둔다
전역 후보 제외는 raw correlation이나 원시 선박 분류를 지우지 않는다.
- `gear_correlation_scores`는 계속 쌓는다.
- exclusion은 parent inference candidate set에서만 hard filter로 적용한다.
이렇게 해야 원시 모델 출력과 사람 개입의 차이를 비교할 수 있다.
### 4. 라벨 세션 동안 자동 추론은 계속 돈다
정답 라벨 세션이 활성화되어도 자동 추론은 그대로 수행한다.
- UI의 기본 검토 대기에서는 숨길 수 있다.
- 하지만 prediction은 계속 candidate snapshot과 tracking record를 남긴다.
### 5. lab에서는 override보다 평가를 우선한다
v2 이후 lab에서 사람 버튼은 기본적으로 자동 resolution을 덮어쓰지 않는다.
- 운영 override가 필요해지면 추후 별도 action으로 분리한다.
- lab의 기본 목적은 평가 데이터 생성이다.
## 사용자 액션 재정의
### `정답 라벨`
의미:
- 해당 어구 그룹의 정답 모선으로 특정 MMSI를 지정
- `1일 / 3일 / 5일` 중 하나의 기간 동안 자동 추론 결과를 추적
동작:
1. `gear_parent_label_sessions`에 active session 생성
2. 다음 cycle부터 prediction이 이 그룹에 대한 추적 로그를 `gear_parent_label_tracking_cycles`에 누적
3. 기본 review queue에서는 해당 그룹을 숨기고, 별도 `라벨 추적` 목록으로 이동
4. 세션 종료 후에는 completed label dataset으로 남음
중요:
- 자동 resolution은 계속 자동 상태를 유지
- 점수에 수동 가산점/감점은 넣지 않음
### `이 그룹에서 제외`
의미:
- 해당 어구 그룹에서만 특정 후보 MMSI를 일정 기간 후보군에서 제외
기간:
- `1일`
- `3일`
- `5일`
동작:
1. `gear_parent_candidate_exclusions``scope_type='GROUP'` row 생성
2. 다음 cycle부터 해당 그룹의 candidate set에서 제거
3. 다른 그룹에는 영향 없음
4. 기간이 끝나면 자동으로 inactive 처리
용도:
- 이 후보는 이 어구 그룹의 모선이 아니라고 사람이 판단한 경우
- 단기/중기 관찰을 위해 일정 기간만 빼고 싶을 때
### `전체 후보 제외`
의미:
- 특정 MMSI는 모든 어구 그룹에서 모선 후보 대상이 아님
동작:
1. `gear_parent_candidate_exclusions``scope_type='GLOBAL'` row 생성
2. prediction candidate generation에서 모든 그룹에 대해 hard filter
3. 해제 전까지 계속 적용
초기 정책:
- 전역 후보 제외는 기본적으로 기간 없이 active 상태 유지
- 수동 `해제` 전까지 유지
용도:
- 패턴 분류상 선박으로 들어왔지만 실제 모선 후보가 아니라고 판단한 AIS
- 잘못된 유형의 신호가 반복적으로 후보군에 유입되는 경우
### `해제`
의미:
- 활성 그룹 제외, 전역 제외, 정답 라벨 세션을 조기 종료
동작:
- exclusion/session row에 `released_at`, `released_by` 또는 `status='CANCELLED'`를 기록
- 다음 cycle부터 알고리즘 적용 대상에서 빠짐
## DB 설계
### 1. `gear_parent_candidate_exclusions`
역할:
- 그룹 단위 제외와 전역 후보 제외를 모두 저장
- active list의 단일 진실원
권장 컬럼:
```sql
CREATE TABLE kcg_lab.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(16) NOT NULL, -- GROUP | GLOBAL
group_key VARCHAR(100), -- GROUP scope에서만 사용
sub_cluster_id SMALLINT,
candidate_mmsi VARCHAR(20) NOT NULL,
reason_type VARCHAR(32) NOT NULL, -- GROUP_WRONG_PARENT | GLOBAL_NOT_PARENT_TARGET
duration_days INT, -- GROUP scope는 1|3|5, GLOBAL은 NULL 허용
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ, -- GROUP scope는 필수, GLOBAL은 NULL 가능
released_at TIMESTAMPTZ,
released_by VARCHAR(100),
actor VARCHAR(100) NOT NULL,
comment TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
권장 인덱스:
- `(scope_type, candidate_mmsi)`
- `(group_key, sub_cluster_id, active_from DESC)`
- `(released_at, active_until)`
조회 규칙:
active exclusion은 아래 조건으로 판단한다.
```sql
released_at IS NULL
AND active_from <= NOW()
AND (active_until IS NULL OR active_until > NOW())
```
### 2. `gear_parent_label_sessions`
역할:
- 특정 그룹에 대한 정답 라벨 세션 저장
권장 컬럼:
```sql
CREATE TABLE kcg_lab.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(100) NOT NULL,
sub_cluster_id SMALLINT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
label_parent_name VARCHAR(200),
label_parent_vessel_id INT REFERENCES kcg_lab.fleet_vessels(id) ON DELETE SET NULL,
duration_days INT NOT NULL, -- 1 | 3 | 5
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
active_until TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | EXPIRED | CANCELLED
actor VARCHAR(100) NOT NULL,
comment TEXT,
anchor_snapshot_time TIMESTAMPTZ,
anchor_center_point geometry(Point, 4326),
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
설명:
- `anchor_*` 컬럼은 기간형 라벨 동안 subcluster가 재편성될 가능성에 대비한 보조 식별자다.
- phase 1에서는 실제 매칭은 `group_key + sub_cluster_id`를 기본으로 쓰고, anchor 정보는 저장만 한다.
### 3. `gear_parent_label_tracking_cycles`
역할:
- 활성 정답 라벨 세션 동안 cycle별 자동 추론 결과 저장
- 향후 정확도 지표의 기준 데이터
권장 컬럼:
```sql
CREATE TABLE kcg_lab.gear_parent_label_tracking_cycles (
id BIGSERIAL PRIMARY KEY,
label_session_id BIGINT NOT NULL REFERENCES kcg_lab.gear_parent_label_sessions(id) ON DELETE CASCADE,
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(40),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score DOUBLE PRECISION,
top_candidate_margin DOUBLE PRECISION,
candidate_count INT NOT NULL DEFAULT 0,
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
labeled_candidate_rank INT,
labeled_candidate_score DOUBLE PRECISION,
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
labeled_candidate_margin_from_top DOUBLE PRECISION,
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
설명:
- 전체 후보 상세는 기존 `gear_group_parent_candidate_snapshots`를 그대로 사용한다.
- 여기에는 지표 계산에 직접 필요한 값만 요약 저장한다.
### 4. 기존 `gear_group_parent_review_log` 재사용
새 action 이름 예시:
- `LABEL_PARENT`
- `EXCLUDE_GROUP`
- `EXCLUDE_GLOBAL`
- `RELEASE_EXCLUSION`
- `CANCEL_LABEL`
즉, 별도 audit table를 또 만들기보다 기존 review log를 action log로 재사용한다.
## prediction 변경 설계
### 적용 지점
핵심 변경 지점은 [gear_parent_inference.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/gear_parent_inference.py), [fleet_tracker.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/fleet_tracker.py), [polygon_builder.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/polygon_builder.py) 중 `gear_parent_inference.py`가 중심이다.
### 1. active exclusion load
cycle 시작 시 아래 두 집합을 읽는다.
- `global_excluded_mmsis`
- `group_excluded_mmsis[(group_key, sub_cluster_id)]`
적용 위치:
- `_build_candidate_scores()`에서 candidate union 이후, 실제 scoring 전에 hard filter
규칙:
- GLOBAL exclusion은 모든 그룹에 적용
- GROUP exclusion은 해당 그룹에만 적용
- exclusion된 후보는 candidate snapshot에도 남기지 않음
중요:
- raw correlation score는 그대로 계산/저장
- exclusion은 parent inference candidate set에서만 적용
### 2. active label session load
cycle 시작 시 현재 unresolved/active gear group에 매칭되는 active label session을 읽는다.
phase 1 매칭 기준:
- `group_key`
- `sub_cluster_id`
phase 2 보강 기준:
- member overlap
- center distance
- anchor snapshot similarity
### 3. tracking cycle write
각 그룹의 자동 추론이 끝난 뒤, active label session이 있으면 `gear_parent_label_tracking_cycles`에 1 row를 쓴다.
기록 항목:
- 현재 auto top-1 후보
- auto top-1 점수/격차
- 후보 수
- 라벨 대상 MMSI가 현재 후보군에 존재하는지
- 존재한다면 rank/score/pre-bonus score
- top1/top3 일치 여부
### 4. resolution 저장 원칙 변경
v2 이후 lab에서는 아래를 원칙으로 한다.
- 자동 resolution은 자동 추론만 반영
- 사람 라벨은 resolution을 덮어쓰지 않음
즉 아래 legacy 상태는 새로 만들지 않는다.
- `MANUAL_CONFIRMED`
- `MANUAL_REJECT`
기존 row는 읽기 전용으로 남겨둘 수 있지만, v2 새 액션은 이 상태를 만들지 않는다.
### 5. exclusion이 적용된 경우의 상태 전이
후보 pruning 이후:
- 후보가 남으면 기존 자동 상태 전이 사용
- top1이 제외되어 후보가 비면 `NO_CANDIDATE`
- top1이 제외되어 top2가 승격되면 새 top1 기준으로 `AUTO_PROMOTED / REVIEW_REQUIRED / UNRESOLVED` 재판정
## backend API 설계
### 1. 정답 라벨 세션 생성
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/label-session`
request:
```json
{
"selectedParentMmsi": "412333326",
"durationDays": 3,
"actor": "analyst-01",
"comment": "수동 확인"
}
```
response:
- 생성된 label session
- 현재 active label summary
### 2. 그룹 후보 제외 생성
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions`
request:
```json
{
"candidateMmsi": "412333326",
"scopeType": "GROUP",
"durationDays": 3,
"actor": "analyst-01",
"comment": "이 그룹에서는 오답"
}
```
### 3. 전역 후보 제외 생성
`POST /api/vessel-analysis/parent-inference/candidate-exclusions`
request:
```json
{
"candidateMmsi": "412333326",
"scopeType": "GLOBAL",
"actor": "analyst-01",
"comment": "모든 어구에서 모선 후보 대상 제외"
}
```
### 4. exclusion 해제
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/{id}/release`
### 5. label session 종료
`POST /api/vessel-analysis/parent-inference/label-sessions/{id}/cancel`
### 6. active exclusion 조회
`GET /api/vessel-analysis/parent-inference/candidate-exclusions?status=ACTIVE&scopeType=GLOBAL`
용도:
- “대상 선박이 어느 어구에서 제외중인지” 목록 관리
- 운영자 관리 화면
### 7. active label tracking 조회
`GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE`
`GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking`
### 8. 기존 review/detail API 확장
기존 `GroupParentInferenceDto`에 아래 요약을 추가한다.
- `activeLabelSession`
- `groupExclusionCount`
- `hasGlobalExclusionCandidate`
- `availableActions`
`ParentInferenceCandidateDto`에는 아래를 추가한다.
- `isExcludedInGroup`
- `isExcludedGlobally`
- `activeExclusionIds`
## 프론트엔드 설계
### 버튼 재구성
현재:
- `확정`
- `24시간 제외`
v2:
- `정답 라벨`
- `이 그룹에서 제외`
- `전체 후보 제외`
- `해제`
### 기간 선택
`정답 라벨``이 그룹에서 제외`는 버튼 클릭 후 아래 중 하나를 고르게 한다.
- `1일`
- `3일`
- `5일`
### 우측 모선 검토 패널 변화
- 후보 카드 상단 action area를 아래처럼 재구성
- `정답 라벨`
- `이 그룹에서 제외`
- `전체 후보 제외`
- 현재 후보에 active exclusion이 있으면 badge 표시
- `이 그룹 제외 중`
- `전체 후보 제외 중`
- 현재 그룹에 active label session이 있으면 summary box 표시
- 라벨 MMSI
- 남은 기간
- 최근 top1 일치율
### 새 목록
- `검토 대기`
- active label session이 없는 그룹만 기본 표시
- `라벨 추적`
- active label session이 있는 그룹
- `제외 대상 관리`
- active group/global exclusions
### 지도 표시 원칙
- active label session 그룹은 기본 review 색과 다른 badge 색을 사용
- globally excluded candidate는 raw correlation 패널에서는 참고로 보일 수 있지만, parent-review actionable candidate 목록에서는 숨김
## 지표 설계
정답 라벨 세션을 기반으로 최소 아래 지표를 계산한다.
### 핵심 지표
- top1 exact match rate
- top3 hit rate
- labeled candidate mean rank
- labeled candidate mean score
- time-to-first-top1
- session duration 동안 top1 일치 지속률
### 보정/실험 지표
- `412/413` 가산점 적용 전후 top1/top3 uplift
- pre-bonus score 대비 final score uplift
- global exclusion 적용 전후 오탐 감소량
- group exclusion 이후 대체 top1 품질 변화
### 운영 준비 지표
- auto-promoted 후보 중 라벨과 일치하는 비율
- high-confidence (`>= 0.72`) 구간 calibration
- label session 종료 시점 기준 `실무 참고 가능` threshold
## 단계별 구현 순서
### Phase 1. DB/Backend 계약
- 마이그레이션 추가
- `gear_parent_candidate_exclusions`
- `gear_parent_label_sessions`
- `gear_parent_label_tracking_cycles`
- backend DTO/API 추가
- 기존 `CONFIRM/REJECT/RESET`는 lab UI에서 숨기고 legacy로만 남김
### Phase 2. prediction 연동
- active exclusion load
- candidate pruning
- active label session load
- tracking cycle write
### Phase 3. 프론트 UI 전환
- 버튼 재구성
- 기간 선택 UI
- 라벨 추적 목록
- 제외 대상 관리 화면
### Phase 4. 지표와 리포트
- label session summary endpoint
- exclusion usage summary endpoint
- 실험 리포트 화면 또는 문서 산출
## 마이그레이션 전략
### 기존 v1 상태 처리
- `MANUAL_CONFIRMED`, `MANUAL_REJECT`는 새로 생성하지 않는다.
- 기존 row는 history로 남긴다.
- 필요하면 one-time migration으로 legacy `MANUAL_CONFIRMED``expired label session`으로 변환할 수 있다.
### 운영 영향 제한
- v2는 우선 `kcg_lab`에만 적용
- 운영 `kcg` 반영 전에는 사람이 직접 누르는 흐름과 tracking 지표가 충분히 쌓여야 함
## 수용 기준
### 기능 기준
- 그룹 제외가 다음 cycle부터 해당 그룹에서만 적용된다.
- 전역 후보 제외가 다음 cycle부터 모든 그룹에 적용된다.
- active exclusion list가 DB/API/UI에서 동일하게 보인다.
- 정답 라벨 세션 동안 cycle별 tracking row가 누락 없이 쌓인다.
### 데이터 기준
- label session당 최소 아래 값이 저장된다.
- top1 후보
- labeled candidate rank
- labeled candidate score
- candidate count
- observed_at
- exclusion row에는 scope, duration, actor, comment, active 기간이 남는다.
### 평가 기준
- `412/413` 가산점, threshold, exclusion 정책 변경 전후를 label session 데이터로 비교 가능해야 한다.
- 일정 기간 후 “자동 top1을 운영 참고값으로 써도 되는지”를 정량으로 판단할 수 있어야 한다.
## 열린 이슈
### 1. 그룹 스코프 안정성
`group_key + sub_cluster_id`가 며칠 동안 완전히 안정적인지 추가 확인이 필요하다.
현재 권장:
- phase 1은 기존 키를 그대로 사용
- 대신 `anchor_snapshot_time`, `anchor_center_point`, `anchor_member_mmsis`를 저장
### 2. 전역 후보 제외의 기간 정책
현재 제안은 “수동 해제 전까지 유지”다.
이유:
- 전역 제외는 단기 오답보다 “이 AIS는 parent candidate class가 아님”에 가깝다.
필요 시 추후 `1/3/5일` 옵션을 추가할 수 있다.
### 3. raw correlation UI 노출
전역 제외된 후보를 모델 패널에서 완전히 숨길지, `참고 제외` badge만 붙여 남길지는 사용성 확인이 필요하다.
현재 권장은 아래다.
- parent-review actionable 후보 목록에서는 숨김
- raw model/correlation 참고 패널에서는 badge와 함께 유지
## 권장 결론
v2의 핵심은 `사람 판단을 자동 추론의 override가 아니라 평가 데이터로 축적하는 것`이다.
따라서 다음 구현 우선순위는 아래가 맞다.
1. exclusion/label DB 추가
2. prediction candidate gating + tracking write
3. UI 액션 재정의
4. 지표 산출
그 다음 단계에서만 threshold 자동화, 가산점 조정, LLM 연결을 검토하는 것이 안전하다.

파일 보기

@ -4,190 +4,109 @@
## [Unreleased]
## [2026-04-04]
### 추가
- 어구 모선 추론(Gear Parent Inference) 시스템 — 다층 점수 모델 + Episode 연속성 + 자동 승격/검토 워크플로우
- Python: gear_parent_inference(1,428줄), gear_parent_episode(631줄), gear_name_rules
- Backend: ParentInferenceWorkflowController + GroupPolygonService 15개 API
- Frontend: ParentReviewPanel (모선 검토 대시보드) + React Flow 흐름도 시각화
- DB: migration 012~015 (후보 스냅샷, resolution, episode, 라벨 세션, 제외 관리)
- LoginPage DEV_LOGIN 환경변수 지원 (VITE_ENABLE_DEV_LOGIN)
## [2026-03-23.6]
### 수정
- 모선 검토 대기 목록을 폴리곤 5분 폴링 데이터에서 파생하여 동기화 문제 해소
- 후보 소스 배지 축약 (CORRELATION→CORR, PREVIOUS_SELECTION→PREV 등)
- 1h 활성 판정을 parent_name 전체 합산 기준으로 변경
- vessel_store의 _last_bucket 타임존 오류 수정 (tz-naive KST 유지)
- time_bucket 수집 안전 윈도우 도입 — safe_bucket(12분 지연) + 3 bucket 백필
- 모선 추론 점수 가중치 조정 — 100%는 DIRECT_PARENT_MATCH 전용
- prediction proxy target을 nginx 경유로 변경
- LIVE 모드 렌더링 최적화: useMonitor 1초 setInterval 제거 (60배 과잉 재계산 해소)
- useKoreaFilters currentTime 의존성 제거 (5분 polling 시에만 필터 재계산)
- useKoreaData aircraft/satellite LIVE↔REPLAY 분리 (LIVE에서 불필요한 매초 propagation 제거)
- 특정어업수역 실제 폴리곤 좌표 적용 (bbox 직사각형 → 원본 GeoJSON EPSG:3857→WGS84 변환)
- FishingZoneLayer zone 속성 매칭 수정 (id→zone, 폴리곤 투명 렌더링 해결)
- 선박/분석 라벨 폰트 크기 80% 축소 (가독성 개선)
- DB migration 008 적용 (is_transship_suspect 칼럼 추가 → AI 분석 API 500 에러 해결)
### 변경
- fleet_tracker: SQL 테이블명 qualified_table() 동적화 + is_trackable_parent_name 필터
- gear_correlation: 후보 track에 timestamp 필드 추가
- kcgdb: SQL 스키마 하드코딩 → qualified_table() 패턴 전환
## [2026-04-01]
## [2026-03-23.5]
### 추가
- 한국 현황 위성지도/ENC 토글 (gcnautical 벡터 타일 연동)
- ENC 스타일 설정 패널 (12개 심볼 토글 + 8개 색상 수정 + 초기화)
- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더)
- 리플레이 컨트롤러 A-B 구간 반복 기능
- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정)
- 리치 툴팁: 선박/어구 구분 + 모델 소속 컬러 표시 + 멤버 호버 강조
- 항공기 아이콘 줌레벨 기반 스케일 적용
- 심볼 크기 조정 패널 (선박/항공기 개별 0.5~2.0x)
- 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore)
- 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers)
- 선박 클릭 팝업 React 오버레이 전환 (ShipPopupOverlay + 드래그 지원)
- 선박 호버 툴팁 (이름, MMSI, 위치, 속도, 수신시각)
- 리플레이 집중 모드 — 주변 라이브 정보 숨김 토글
- 라벨 클러스터링 (줌 레벨별 그리드, z10+ 전체 표시)
- 어구 서브클러스터 독립 추적 (DB sub_cluster_id + Python group_key 고정)
- 서브클러스터별 독립 center trail (PathLayer 색상 구분)
- 이란 시설 deck.gl SVG 전환: OilFacility/Airport/MEFacility/MEEnergyHazard → IconLayer(SVG) + TextLayer
- 26개 고유 SVG 아이콘 (배경 원형 + 색상 테두리 + 고유 실루엣)
- 중동 에너지/위험시설 데이터 84개 (meEnergyHazardFacilities)
- 나탄즈-디모나 핵시설 교차공격 리플레이 이벤트 (D+20)
- AI 해양분석 챗 UI (AiChatPanel, API placeholder)
- LayerPanel 해외시설 3단계 트리 (국가→카테고리→하위시설)
### 변경
- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius
- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용
- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링
- 선단 폴리곤 색상: API 기본색 → 밝은 파스텔 팔레트 (바다 배경 대비)
- 멤버/연관 라벨: SDF outline → 검정 배경 블록 + fontScale.analysis 연동
- 모델 패널: 헤더→푸터 구조, 개별 확장/축소, 우클릭 툴팁 고정
- 한국 군사/정부/NK 발사장 아이콘: emoji → SVG IconLayer 업그레이드 (19종)
- 시설 라벨 SDF 테두리 적용 (fontSettings.sdf + outlineWidth:8) — 사막/위성 배경 가독성
- 라벨 폰트 크기 ~1.2배 상향 (이란/한국 공통)
- ReplayMap/SatelliteMap: DeckGLOverlay + 줌 스케일 연동
### 수정
- 라이브 어구 현황에서 fallback 그룹 제외 (1h-fb resolution 분리)
- FLEET 타입 resolution='1h' 누락 수정
- DB resolution 컬럼 VARCHAR(4)→VARCHAR(8) 확장
- 어구 group_key 변동 → 이력 불연속 문제 해결 (sub_cluster_id 구조 전환)
- 한국 국적 선박(440/441) 어구 오탐 제외
- Backend correlation API 서브클러스터 중복 제거 (DISTINCT ON CTE)
- 리플레이 종료/탭 off 시 deck.gl 레이어 + gearReplayStore 완전 초기화
- IranDashboard LayerPanel 카운트 전수 보정 (하드코딩→실제 데이터 기반)
- fishing-zones GeoJSON 좌표 보정
- overseas 국가 키: overseasUK → overseasIsrael
### 기타
- DB 마이그레이션: sub_cluster_id + resolution 컬럼, 인덱스 교체
## [2026-03-31]
## [2026-03-23.4]
### 추가
- 어구 연관성 프론트엔드 표시 — Backend API + 모델별 팝업/토글 UI
- 어구 그룹 멀티모델 폴리곤 오버레이 + 토글 패널
- 어구 리플레이 deck.gl + Zustand 전환 (TripsLayer GPU 트레일 + rAF 10fps)
- 리플레이 IconLayer (SVG ship-triangle/gear-diamond, COG 회전)
- 재생 컨트롤 확장: 항적/이름 토글, 일치율 드롭다운(50~90%), 개별 on/off
- 트랙 API 전체 모델 확장 — 모델별 점수 + 24h 트랙 반환
- 모델별 폴리곤 중심 경로 + 현재 중심점 렌더링 (모델명 라벨)
- 환적탐지 Python 이관: 프론트엔드 O(n²) 근접탐지 → 서버사이드 그리드 공간인덱스 O(n log n)
- 필터 배지 클릭 → 대상 선박 목록 패널 (MMSI/이름/국적/유형/속도) + CSV 다운로드
- 중국어선감시 KoreaFilters 통합: 다른 감시 탭과 동일한 선박 비활성화/배지/카운트 동작
- 중국 어구그룹 감시 배지: 어구그룹 수(고유 모선명) 기준 집계
### 변경
- FleetClusterLayer 2357줄 → 10파일 리팩토링 (오케스트레이터 + 서브컴포넌트)
- 리플레이 렌더링: MapLibre GeoJSON → deck.gl (React re-render 20회/초 → 0회)
- 연관 선박 위치: 트랙 보간 우선, live 선박 fallback
- 토글 패널 위치 고정 + 모델 카드 가로 스크롤
- enabledVessels 토글 시 폴리곤 + 중심경로 동시 재계산
- deck.gl updateTriggers 적용: 줌 변경 시 레이어 accessor 재평가 최소화
- 선박 카테고리/국적 토글: JS-level 배열 필터링 → MapLibre GPU-side filter 표현식
- Ship.mtCategory/natGroup 사전 계산: Set.has() O(1) 필터 룩업 (getMarineTrafficCategory 매번 호출 제거)
- LIVE 모드: currentTime 의존성 분리 → 매초 선박 재계산 제거
- 분석 레이어 데이터/스타일 useMemo 분리: 줌 변경 시 ships 필터링 스킵
- SVG 데이터 URI 모듈 레벨 캐싱
### 수정
- 이름 기반 레이어 항상 ON 고정 + 최상위 z-index (다른 모델에 가려지지 않음)
- Prediction API DB 접속 context manager 누락
- Prediction proxy rewrite 경로 불일치 (/api/prediction → /api)
- nginx prediction API 라우팅 추가
- 비허가 어구 그룹: 2개 이상일 때만 그룹 탐지/폴리곤 생성
- 한국 필터 토글 시 선박 표시 복원 (anyKoreaFilterOn 조건 분기)
- 필터별 개별 탐지 카운트 (합산 → 탭별 분리)
- 헤더 1행 배치 (flex-wrap:nowrap), 이란 mode-toggle 좌측/지도 모드 중앙
- onPick useCallback 안정화 (매 렌더 28개 정적 레이어 재생성 방지)
- 감시 목록 Flag 빈값 표기: '??' → '-'
### 기타
- CI/CD: Prediction 자동 배포 제거 → 수동 배포 전환
- zustand 5.0.12, @deck.gl/geo-layers 9.2.11 의존성 추가
## [2026-03-26]
### 추가
- AI 해양분석 채팅: Ollama Qwen3 14B 로컬 LLM 기반 해양 상황 분석 챗봇
- Ollama Docker 컨테이너 (redis-211, CPU 64코어, 64GB RAM 할당)
- Python SSE 채팅 엔드포인트 + Redis 컨텍스트 캐싱 + 계정별 대화 히스토리
- 도메인 지식 시스템 + 사전 쿼리 패턴 매칭 + LLM Tool Calling (5개 도구)
- 채팅 UI: SSE 스트리밍 + 응답 타이머 + thinking 접기 + 확장/축소
## [2026-03-23.3]
### 변경
- AiChatPanel: 클라이언트 프롬프트 → Python 서버사이드 압축 프롬프트
- nginx SSE 프록시 + kcgdb 분석 요약 쿼리 추가
- App.tsx 분해: IranDashboard + KoreaDashboard 추출 (771줄→163줄)
- useStaticDeckLayers 분할: 레이어별 서브훅 4개 (1,086줄→85줄)
- StaticFacilityPopup 독립 컴포넌트 추출 (KoreaMap -200줄)
- geometry/shipClassification 유틸 추출
- SharedFilterContext + useSharedFilters (카테고리 필터 공유)
- API 클라이언트 래퍼 + usePoll 폴링 유틸 추가
- 줌 이벤트 ref 기반 디바운싱
## [2026-03-25]
## [2026-03-23.2]
### 추가
- 현장분석 항적 미니맵: 선박 클릭 시 72시간 항적 + 현재 위치 표시
- 현장분석 위험도 점수 기준 섹션
- Python 경량 분석: 파이프라인 미통과 412* 선박 간이 위험도
- 폴리곤 히스토리 애니메이션: 12시간 타임라인 기반 재생 (중심 궤적 + 어구별 궤적 + 가상 아이콘)
- 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭) + 신호없음 구간 표시
- nginx /api/gtts 프록시 (Google TTS CORS 우회)
- 중국어선감시 탭: CN 어선 + 어구 패턴 선박 필터링
- 중국어선감시 탭: 조업수역 ~Ⅳ 폴리곤 동시 표시
- 어구 그룹 수역 내/외 분류 (조업구역내 붉은색, 비허가 오렌지)
- 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구)
- 폴리곤 클릭·zoom 시 어구 행 자동 스크롤
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
- AI 분석 닫힘 시 위험도 마커 off
### 변경
- 위험도 용어 통일: HIGH→WATCH, MEDIUM→MONITOR, LOW→NORMAL (전체)
- 현장분석/보고서: 클라이언트 fallback 제거 → Python 분석 결과 전용
- 보고서: Python riskCounts 실데이터 기반 위험 평가
- 현장분석: AI 파이프라인 ON/OFF 실상태 + BD-09 실측 탐지 수
- 보고서 버튼: 현장분석 내부로 이동, 수역별 허가업종 동적 참조
- 분석 파이프라인: MIN_TRAJ_POINTS 100→20 (16척→684척 분석 대상 확대)
- risk.py: SOG 급변 count 위험도 점수 반영
- spoofing.py: BD09 오프셋 중국 MMSI(412*) 예외 처리
- VesselAnalysisService: Caffeine 캐시 → 인메모리 캐시 + 증분 갱신
- polygon_builder: STALE_SEC 3600→21600 (6시간, 어구 갭 P75 커버)
- AI 분석 패널 위치 조정 (줌 버튼 간격 확보)
- 백엔드 vessel-analysis 조회 윈도우 1h → 2h
### 수정
- fishing_pattern.py: 마지막 조업 세그먼트 누락 버그 수정
- 히스토리 모드 시 현재 강조 레이어 (deck.gl + MapLibre) 정상 숨김
## [2026-03-24]
### 추가
- 선단/어구그룹 폴리곤 서버사이드 생성: Shapely convex hull + buffer → PostGIS 저장 (DB migration 009, 5분 APPEND, 7일 보존)
- Backend API: groups 목록/상세/히스토리 + vessel-analysis stats 필드 (집계 통계 서버 제공)
- 가상 선박 마커: ship-triangle 아이콘 (COG 회전 + zoom interpolate) + 어구 겹침 다중 선택 팝업
- AI 분석 통계 서버사이드 전환: dark/spoofing/risk/cluster/gear 집계를 Backend에서 계산
- 경비함정 작전가이드 모달: 3탭 + 임검침로 해상 루트 시각화 + 중국어 TTS
- 중국어선 감시현황 보고서 자동 생성 모달
- 웹폰트 내장: @fontsource-variable Inter/Noto Sans KR/Fira Code + 폰트 상수
- LayerPanel 공통 트리 구조: 재귀 렌더러 + 부모 캐스케이드 ON/OFF
- 위험시설/해외시설 SVG IconLayer 전환 (12 SVG 함수)
- 이란 리플레이 실데이터 전환: Events CRUD + 시점 조회 API + 피격 선박 27척
- 지도 글꼴 크기 커스텀: 4그룹 슬라이더 (0.5~2.0x)
- useGroupPolygons 훅 (5분 폴링) + useIranData dataSource 분기
### 변경
- FleetClusterLayer: 클라이언트 convexHull 제거 → API GeoJSON 렌더링 + 패널 아코디언 전환
- AI 분석 패널: 클라이언트 stats 계산 제거 → 서버 제공 (14K+ 순회 useMemo 삭제)
- 프론트 어구그룹 탐지 Python 이관 + 어구 클릭 시 좌측 패널 섹션 자동 전환
- 전체 font-family 통일 (CSS 55곳 + deck.gl 30곳) + 이란 시설물 사막 대비 고채도 팔레트
- feature/korea-layers-enhancement 브랜치 기능 → develop 아키텍처에 이식
### 수정
- 불법어선 탭 복원 + 어구 줌인 최대 제한 (maxZoom: 12)
- FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거)
## [2026-03-23]
### 추가
- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설
- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드)
- 이란 시설 deck.gl SVG 전환: 26개 고유 SVG 아이콘 (IconLayer + TextLayer)
- 중동 에너지/위험시설 데이터 84개 (meEnergyHazardFacilities)
- 환적탐지 Python 이관: 서버사이드 그리드 공간인덱스 O(n log n)
- 중국어선감시 탭: CN 어선 + 어구 패턴 필터링, 조업수역 폴리곤
- AI 해양분석 챗 UI (AiChatPanel, placeholder)
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴)
- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보)
- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기
### 변경
- App.tsx 분해: IranDashboard + KoreaDashboard 추출 (771줄→163줄)
- useStaticDeckLayers 분할: 레이어별 서브훅 4개
- DOM Marker → deck.gl 전환 + 줌 스케일 연동
- 한국 군사/정부/NK 아이콘: emoji → SVG IconLayer (19종)
- 선박 카테고리/국적 토글: MapLibre GPU-side filter 표현식
- LIVE 모드 currentTime 의존성 분리 → 매초 재계산 제거
- 시설 라벨 SDF 테두리 적용 (fontSettings.sdf + outlineWidth)
- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함)
- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x)
### 수정
- LIVE 모드 렌더링 최적화: useMonitor 1초 setInterval 제거
- 특정어업수역 실제 폴리곤 좌표 적용 (EPSG:3857→WGS84 변환)
- DB migration 008 적용 (AI 분석 API 500 에러 해결)
- 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정
- prediction 증분 수집 버그 수정
- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소)
- deck.gl 레이어 호버 시 pointer 커서 표시
- prediction 증분 수집 버그 수정 (vessel_store.py)
## [2026-03-20]

1
frontend/.gitignore vendored
파일 보기

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

파일 보기

@ -1,13 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/kcg.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gear-parent-flow-viewer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/gearParentFlowMain.tsx"></script>
</body>
</html>

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

파일 보기

@ -11,17 +11,12 @@
},
"dependencies": {
"@deck.gl/core": "^9.2.11",
"@deck.gl/geo-layers": "^9.2.11",
"@deck.gl/layers": "^9.2.11",
"@deck.gl/mapbox": "^9.2.11",
"@fontsource-variable/fira-code": "^5.2.7",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/noto-sans-kr": "^5.2.10",
"@tailwindcss/vite": "^4.2.1",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4",
"@types/leaflet": "^1.9.21",
"@xyflow/react": "^12.10.2",
"date-fns": "^4.1.0",
"hls.js": "^1.6.15",
"i18next": "^25.8.18",
@ -34,8 +29,7 @@
"react-map-gl": "^8.1.0",
"recharts": "^3.8.0",
"satellite.js": "^6.0.2",
"tailwindcss": "^4.2.1",
"zustand": "^5.0.12"
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

파일 보기

@ -20,7 +20,7 @@
font-size: 14px;
font-weight: 700;
letter-spacing: 1.5px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
/* Map mode toggle */
@ -47,7 +47,7 @@
color: var(--kcg-dim);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.map-mode-btn:hover {
@ -79,7 +79,7 @@
.count-item {
font-size: 11px;
font-weight: 700;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 3px;
@ -103,7 +103,7 @@
color: var(--kcg-text-secondary);
font-size: 10px;
font-weight: 700;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 4px;
@ -126,7 +126,7 @@
font-weight: 700;
letter-spacing: 1px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.status-dot {
@ -240,7 +240,7 @@
letter-spacing: 1.5px;
color: var(--text-secondary);
margin-bottom: 8px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.layer-items {
@ -299,7 +299,7 @@
align-items: center;
padding: 2px 8px;
font-size: 10px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.stat-cat {
@ -318,7 +318,7 @@
letter-spacing: 1px;
color: var(--text-secondary);
padding: 2px 8px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
/* Layer tree */
@ -434,7 +434,7 @@
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
white-space: nowrap;
}
.dash-tab:hover {
@ -465,7 +465,7 @@
text-transform: uppercase;
color: var(--text-secondary);
border-bottom: 1px solid var(--kcg-border);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.event-list {
@ -504,7 +504,7 @@
white-space: nowrap;
height: fit-content;
margin-top: 2px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.event-content {
@ -521,7 +521,7 @@
font-size: 10px;
color: var(--text-secondary);
margin-top: 1px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.event-desc {
@ -559,7 +559,7 @@
background: var(--kcg-danger);
padding: 2px 6px;
border-radius: 2px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
animation: flash-pulse 1.5s ease-in-out infinite;
}
@ -573,7 +573,7 @@
font-weight: 700;
color: var(--text-secondary);
letter-spacing: 0.5px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.breaking-news-list {
@ -616,7 +616,7 @@
.breaking-news-time {
font-size: 9px;
color: var(--kcg-dim);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.breaking-news-headline {
@ -658,13 +658,13 @@
font-weight: 700;
letter-spacing: 1.5px;
color: var(--kcg-danger);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.osint-count {
margin-left: auto;
font-size: 10px;
color: var(--kcg-muted);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.osint-loading {
margin-left: auto;
@ -722,7 +722,7 @@
font-size: 9px;
color: var(--kcg-dim);
white-space: nowrap;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.osint-item-title {
font-size: 11px;
@ -757,17 +757,10 @@
.area-ship-header {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--kcg-hover);
flex-wrap: nowrap;
}
.area-ship-header:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.korean-highlight-toggle {
@ -775,7 +768,7 @@
padding: 1px 8px;
font-size: 9px;
font-weight: 700;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
border-radius: 3px;
border: 1px solid var(--kcg-border);
background: transparent;
@ -800,11 +793,7 @@
font-weight: 700;
letter-spacing: 0.5px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
font-family: 'Courier New', monospace;
}
.area-ship-total {
@ -812,9 +801,7 @@
font-size: 16px;
font-weight: 700;
color: #fb923c;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
white-space: nowrap;
flex-shrink: 0;
font-family: 'Courier New', monospace;
}
.kr-ship-header {
@ -833,7 +820,7 @@
font-weight: 700;
letter-spacing: 0.5px;
color: var(--text-primary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-total {
@ -841,7 +828,7 @@
font-size: 14px;
font-weight: 700;
color: var(--kcg-accent);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-ship-breakdown {
@ -872,7 +859,7 @@
flex: 1;
font-size: 10px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-count {
@ -880,7 +867,7 @@
font-weight: 700;
color: var(--text-primary);
text-align: right;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-ship-list {
@ -894,7 +881,7 @@
gap: 6px;
padding: 2px 0;
font-size: 9px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.kr-ship-name {
@ -1039,7 +1026,7 @@
.korea-stat-num {
font-size: 20px;
font-weight: 800;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.korea-stat-card.total .korea-stat-num { color: var(--kcg-accent); }
.korea-stat-card.anchored .korea-stat-num { color: var(--kcg-danger); }
@ -1062,7 +1049,7 @@
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.korea-ship-section {
flex: 1;
@ -1080,7 +1067,7 @@
color: var(--kcg-muted);
border-bottom: 1px solid var(--kcg-border);
flex-shrink: 0;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.korea-ship-section-count {
color: var(--kcg-accent);
@ -1144,7 +1131,7 @@
.korea-ship-card-speed {
margin-left: auto;
color: var(--kcg-muted);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.korea-ship-card-dest {
font-size: 9px;
@ -1174,7 +1161,7 @@
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.chart-grid {
@ -1190,7 +1177,7 @@
color: var(--text-secondary);
margin-bottom: 0;
padding-left: 4px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.chart-demo-label {
@ -1206,7 +1193,7 @@
.ship-popup-body {
width: 300px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
font-size: 11px;
line-height: 1.4;
}
@ -1360,12 +1347,12 @@
}
.popup-body {
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.popup-body-sm {
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
font-size: 11px;
min-width: 220px;
}
@ -1444,10 +1431,6 @@
gap: 3px;
margin-left: 8px;
}
.data-source-toggle {
border-left: 1px solid rgba(255,255,255,0.15);
padding-left: 8px;
}
.speed-btn {
padding: 3px 8px;
@ -1459,7 +1442,7 @@
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.speed-btn:hover {
@ -1498,7 +1481,7 @@
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.range-btn:hover {
@ -1550,7 +1533,7 @@
font-weight: 700;
letter-spacing: 1px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.range-picker input[type="datetime-local"] {
@ -1560,7 +1543,7 @@
color: var(--text-primary);
padding: 4px 8px;
font-size: 11px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
outline: none;
transition: border-color 0.15s;
}
@ -1584,7 +1567,7 @@
background: rgba(34, 197, 94, 0.15);
color: var(--kcg-success);
cursor: pointer;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
transition: all 0.15s;
white-space: nowrap;
}
@ -1604,7 +1587,7 @@
font-size: 10px;
color: var(--text-secondary);
margin-bottom: 2px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.timeline-current {
@ -1740,7 +1723,7 @@
font-size: 9px;
font-weight: 700;
color: var(--kcg-dim);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
flex-shrink: 0;
}
@ -1787,7 +1770,7 @@
color: var(--text-secondary);
margin-bottom: 2px;
padding-left: 8px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
/* Aircraft, Satellite, Ship & Oil Tooltips - override Leaflet */
@ -1801,7 +1784,7 @@
border-radius: 3px !important;
padding: 2px 6px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace !important;
font-family: 'Courier New', monospace !important;
}
.aircraft-tooltip::before,
@ -1875,7 +1858,7 @@
border-radius: 3px !important;
padding: 2px 6px !important;
box-shadow: 0 2px 12px rgba(255, 0, 0, 0.4) !important;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace !important;
font-family: 'Courier New', monospace !important;
}
.impact-tooltip::before {
@ -1937,7 +1920,7 @@
pointer-events: none;
font-weight: 600;
font-size: 11px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
text-shadow: var(--kcg-map-label-shadow);
background: var(--kcg-glass);
border: 1px solid var(--kcg-border);
@ -1955,7 +1938,7 @@
color: var(--kcg-event-impact);
font-weight: 700;
font-size: 10px;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
text-shadow: var(--kcg-map-impact-shadow);
background: rgba(40, 0, 0, 0.85);
border: 1px solid var(--kcg-event-impact);
@ -2051,7 +2034,7 @@
color: var(--kcg-dim);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.mode-btn:hover {
@ -2113,14 +2096,14 @@
font-weight: 700;
letter-spacing: 2px;
color: var(--kcg-danger);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.live-clock {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
padding: 4px 12px;
background: var(--kcg-hover);
@ -2139,7 +2122,7 @@
font-weight: 700;
letter-spacing: 1.5px;
color: var(--text-secondary);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.history-presets {
@ -2157,7 +2140,7 @@
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
}
.history-btn:hover {
@ -2182,7 +2165,7 @@
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: 'Courier New', monospace;
border: none;
background: transparent;
color: var(--text-secondary);
@ -2348,7 +2331,7 @@
max-height: 80vh;
overflow: auto;
color: var(--kcg-text, #e2e8f0);
font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-family: monospace;
font-size: 13px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
}
@ -2475,42 +2458,3 @@
text-align: center;
opacity: 0.5;
}
/* ── FontScalePanel ──────────────────────── */
.font-scale-section { margin-top: 4px; }
.font-scale-toggle {
width: 100%;
padding: 4px 8px;
font-size: 10px;
color: var(--kcg-text);
background: transparent;
border: none;
border-top: 1px solid rgba(255,255,255,0.08);
cursor: pointer;
display: flex;
justify-content: space-between;
}
.font-scale-toggle:hover { background: rgba(255,255,255,0.05); }
.font-scale-sliders { padding: 4px 8px; }
.font-scale-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 9px;
color: var(--kcg-dim);
margin-bottom: 3px;
}
.font-scale-row label { width: 60px; flex-shrink: 0; }
.font-scale-row input[type="range"] { flex: 1; height: 12px; accent-color: var(--kcg-primary, #3b82f6); }
.font-scale-row span { width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
.font-scale-reset {
width: 100%;
padding: 2px;
font-size: 9px;
color: var(--kcg-dim);
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 3px;
cursor: pointer;
margin-top: 4px;
}

파일 보기

@ -9,8 +9,6 @@ import { useTranslation } from 'react-i18next';
import LoginPage from './components/auth/LoginPage';
import CollectorMonitor from './components/common/CollectorMonitor';
import { SharedFilterProvider } from './contexts/SharedFilterContext';
import { FontScaleProvider } from './contexts/FontScaleContext';
import { SymbolScaleProvider } from './contexts/SymbolScaleContext';
import { IranDashboard } from './components/iran/IranDashboard';
import { KoreaDashboard } from './components/korea/KoreaDashboard';
import './App.css';
@ -67,8 +65,6 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime;
return (
<FontScaleProvider>
<SymbolScaleProvider>
<SharedFilterProvider>
<div className={`app ${isLive ? 'app-live' : ''}`}>
<header className="app-header">
@ -106,15 +102,6 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
>
MON
</button>
<a
className="header-toggle-btn"
href="/gear-parent-flow.html"
target="_blank"
rel="noreferrer"
title="어구 모선 추적 흐름도"
>
FLOW
</a>
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
{i18n.language === 'ko' ? 'KO' : 'EN'}
</button>
@ -171,8 +158,6 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
)}
</div>
</SharedFilterProvider>
</SymbolScaleProvider>
</FontScaleProvider>
);
}

파일 보기

@ -8,7 +8,6 @@ interface LoginPageProps {
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const IS_DEV = import.meta.env.DEV;
const DEV_LOGIN_ENABLED = IS_DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
function useGoogleIdentity(onCredential: (credential: string) => void) {
const btnRef = useRef<HTMLDivElement>(null);
@ -137,7 +136,7 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
)}
{/* Dev Login */}
{DEV_LOGIN_ENABLED && (
{IS_DEV && (
<>
<div className="w-full border-t border-kcg-border pt-4 text-center">
<span className="text-xs font-mono tracking-wider text-kcg-dim">

파일 보기

@ -260,7 +260,6 @@ const TYPE_LABELS: Record<GeoEvent['type'], string> = {
alert: 'ALERT',
impact: 'IMPACT',
osint: 'OSINT',
sea_attack: 'SEA ATK',
};
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
@ -271,7 +270,6 @@ const TYPE_COLORS: Record<GeoEvent['type'], string> = {
alert: 'var(--kcg-event-alert)',
impact: 'var(--kcg-event-impact)',
osint: 'var(--kcg-event-osint)',
sea_attack: '#0ea5e9',
};
// MarineTraffic-style ship type classification
@ -885,7 +883,12 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{/* AI 해양분석 챗 — 한국 탭 전용 */}
{dashboardTab === 'korea' && (
<AiChatPanel />
<AiChatPanel
ships={ships}
koreanShipCount={koreanShips.length}
chineseShipCount={chineseShips.length}
totalShipCount={ships.length}
/>
)}
</div>
);

파일 보기

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

파일 보기

@ -1,45 +0,0 @@
import { useState } from 'react';
import { useFontScale } from '../../hooks/useFontScale';
import type { FontScaleConfig } from '../../contexts/fontScaleState';
const LABELS: Record<keyof FontScaleConfig, string> = {
facility: '시설 라벨',
ship: '선박 이름',
analysis: '분석 라벨',
area: '지역/국가명',
};
export function FontScalePanel() {
const { fontScale, setFontScale } = useFontScale();
const [open, setOpen] = useState(false);
const update = (key: keyof FontScaleConfig, val: number) => {
setFontScale({ ...fontScale, [key]: Math.round(val * 10) / 10 });
};
return (
<div className="font-scale-section">
<button type="button" className="font-scale-toggle" onClick={() => setOpen(!open)}>
<span>Aa </span>
<span>{open ? '▼' : '▶'}</span>
</button>
{open && (
<div className="font-scale-sliders">
{(Object.keys(LABELS) as (keyof FontScaleConfig)[]).map(key => (
<div key={key} className="font-scale-row">
<label>{LABELS[key]}</label>
<input type="range" min={0.5} max={2.0} step={0.1}
value={fontScale[key]}
onChange={e => update(key, parseFloat(e.target.value))} />
<span>{fontScale[key].toFixed(1)}</span>
</div>
))}
<button type="button" className="font-scale-reset"
onClick={() => setFontScale({ facility: 1.0, ship: 1.0, analysis: 1.0, area: 1.0 })}>
</button>
</div>
)}
</div>
);
}

파일 보기

@ -1,8 +1,6 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
import { FontScalePanel } from './FontScalePanel';
import { SymbolScalePanel } from './SymbolScalePanel';
// Aircraft category colors (matches AircraftLayer military fixed colors)
const AC_CAT_COLORS: Record<string, string> = {
@ -897,8 +895,6 @@ export function LayerPanel({
</>
)}
</div>
<FontScalePanel />
<SymbolScalePanel />
</div>
);
}

파일 보기

@ -1,8 +1,6 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export type DataSource = 'dummy' | 'api';
interface Props {
isPlaying: boolean;
speed: number;
@ -13,8 +11,6 @@ interface Props {
onReset: () => void;
onSpeedChange: (speed: number) => void;
onRangeChange: (start: number, end: number) => void;
dataSource?: DataSource;
onDataSourceChange?: (ds: DataSource) => void;
}
const SPEEDS = [1, 2, 4, 8, 16];
@ -55,8 +51,6 @@ export function ReplayControls({
onReset,
onSpeedChange,
onRangeChange,
dataSource,
onDataSourceChange,
}: Props) {
const { t } = useTranslation();
const [showPicker, setShowPicker] = useState(false);
@ -116,24 +110,6 @@ export function ReplayControls({
))}
</div>
{/* Data source toggle */}
{dataSource && onDataSourceChange && (
<div className="speed-controls data-source-toggle">
<button
className={`speed-btn ${dataSource === 'dummy' ? 'active' : ''}`}
onClick={() => onDataSourceChange('dummy')}
>
</button>
<button
className={`speed-btn ${dataSource === 'api' ? 'active' : ''}`}
onClick={() => onDataSourceChange('api')}
>
API
</button>
</div>
)}
{/* Spacer */}
<div className="flex-1" />

파일 보기

@ -1,43 +0,0 @@
import { useState } from 'react';
import { useSymbolScale } from '../../hooks/useSymbolScale';
import type { SymbolScaleConfig } from '../../contexts/symbolScaleState';
const LABELS: Record<keyof SymbolScaleConfig, string> = {
ship: '선박 심볼',
aircraft: '항공기 심볼',
};
export function SymbolScalePanel() {
const { symbolScale, setSymbolScale } = useSymbolScale();
const [open, setOpen] = useState(false);
const update = (key: keyof SymbolScaleConfig, val: number) => {
setSymbolScale({ ...symbolScale, [key]: Math.round(val * 10) / 10 });
};
return (
<div className="font-scale-section">
<button type="button" className="font-scale-toggle" onClick={() => setOpen(!open)}>
<span>&#9670; </span>
<span>{open ? '▼' : '▶'}</span>
</button>
{open && (
<div className="font-scale-sliders">
{(Object.keys(LABELS) as (keyof SymbolScaleConfig)[]).map(key => (
<div key={key} className="font-scale-row">
<label>{LABELS[key]}</label>
<input type="range" min={0.5} max={2.0} step={0.1}
value={symbolScale[key]}
onChange={e => update(key, parseFloat(e.target.value))} />
<span>{symbolScale[key].toFixed(1)}</span>
</div>
))}
<button type="button" className="font-scale-reset"
onClick={() => setSymbolScale({ ship: 1.0, aircraft: 1.0 })}>
</button>
</div>
)}
</div>
);
}

파일 보기

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

파일 보기

@ -1,7 +1,6 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { middleEastAirports } from '../../data/airports';
import type { Airport } from '../../data/airports';
@ -43,9 +42,9 @@ export const IRAN_AIRPORT_COUNT = DEDUPLICATED_AIRPORTS.length;
// ─── Colors ──────────────────────────────────────────────────────────────────
function getAirportColor(airport: Airport): string {
if (isUSBase(airport)) return '#60a5fa';
if (airport.type === 'military') return '#f87171';
return '#38bdf8';
if (isUSBase(airport)) return '#3b82f6';
if (airport.type === 'military') return '#ef4444';
return '#f59e0b';
}
// ─── SVG generators ──────────────────────────────────────────────────────────
@ -141,7 +140,7 @@ export function createIranAirportLayers(config: AirportLayerConfig): Layer[] {
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontFamily: 'monospace',
fontWeight: 700,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],

파일 보기

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

파일 보기

@ -1,5 +1,4 @@
import { useState, useMemo, useCallback } from 'react';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { createPortal } from 'react-dom';
import { IRAN_OIL_COUNT } from './createIranOilLayers';
import { IRAN_AIRPORT_COUNT } from './createIranAirportLayers';
@ -13,7 +12,7 @@ import { SensorChart } from '../common/SensorChart';
import { EventLog } from '../common/EventLog';
import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel';
import { LiveControls } from '../common/LiveControls';
import { ReplayControls, type DataSource } from '../common/ReplayControls';
import { ReplayControls } from '../common/ReplayControls';
import { TimelineSlider } from '../common/TimelineSlider';
import { useIranData } from '../../hooks/useIranData';
import { useSharedFilters } from '../../hooks/useSharedFilters';
@ -96,7 +95,6 @@ const IranDashboard = ({
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
const [dataSource, setDataSource] = useLocalStorage<DataSource>('iranDataSource', 'dummy');
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
useSharedFilters();
@ -109,7 +107,6 @@ const IranDashboard = ({
hiddenShipCategories,
refreshKey,
dashboardTab: 'iran',
dataSource,
});
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
@ -141,23 +138,23 @@ const IranDashboard = ({
},
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.koreanShips.length },
{ key: 'airports', label: t('layers.airports'), color: '#38bdf8', count: IRAN_AIRPORT_COUNT },
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#fb7185', count: IRAN_OIL_COUNT },
{ key: 'meFacilities', label: '주요시설/군사', color: '#f87171', count: ME_FACILITY_COUNT },
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b', count: IRAN_AIRPORT_COUNT },
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706', count: IRAN_OIL_COUNT },
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: ME_FACILITY_COUNT },
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
{
key: 'overseas', label: '해외시설', color: '#c084fc',
key: 'overseas', label: '해외시설', color: '#f97316',
children: [
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#60a5fa', count: meCountByCountry('us') },
{ key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#ef4444', count: meCountByCountry('il') },
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#34d399', count: meCountByCountry('ir') },
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#38bdf8', count: meCountByCountry('ae') },
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#a3e635', count: meCountByCountry('sa') },
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#fb7185', count: meCountByCountry('om') },
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#a78bfa', count: meCountByCountry('qa') },
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f472b6', count: meCountByCountry('kw') },
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#86efac', count: meCountByCountry('iq') },
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#f87171', count: meCountByCountry('bh') },
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6', count: meCountByCountry('us') },
{ key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#dc2626', count: meCountByCountry('il') },
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e', count: meCountByCountry('ir') },
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b', count: meCountByCountry('ae') },
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16', count: meCountByCountry('sa') },
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48', count: meCountByCountry('om') },
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6', count: meCountByCountry('qa') },
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316', count: meCountByCountry('kw') },
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d', count: meCountByCountry('iq') },
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48', count: meCountByCountry('bh') },
],
},
], [iranData, t, meCountByCountry]);
@ -334,8 +331,6 @@ const IranDashboard = ({
onReset={replay.reset}
onSpeedChange={replay.setSpeed}
onRangeChange={replay.setRange}
dataSource={dataSource}
onDataSourceChange={setDataSource}
/>
<TimelineSlider
currentTime={replay.state.currentTime}

파일 보기

@ -1,7 +1,6 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import {
ME_ENERGY_HAZARD_FACILITIES,
SUB_TYPE_META,
@ -55,7 +54,6 @@ export { layerKeyToSubType, layerKeyToCountry };
export interface MELayerConfig {
layers: Record<string, boolean>;
sc: number;
fs?: number;
onPick: (facility: EnergyHazardFacility) => void;
}
@ -176,7 +174,6 @@ function getIconUrl(subType: FacilitySubType): string {
export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] {
const { layers, sc, onPick } = config;
const fs = config.fs ?? 1;
const visibleFacilities = ME_ENERGY_HAZARD_FACILITIES.filter(f =>
isFacilityVisible(f, layers),
@ -203,16 +200,16 @@ export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] {
data: visibleFacilities,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 12 * sc * fs,
getSize: 12 * sc,
updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color),
getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 12],
fontFamily: FONT_MONO,
fontWeight: 700,
fontFamily: 'monospace',
fontWeight: 600,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineWidth: 8,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',

파일 보기

@ -1,7 +1,6 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { ME_FACILITIES } from '../../data/middleEastFacilities';
import type { MEFacility } from '../../data/middleEastFacilities';
@ -10,12 +9,12 @@ export const ME_FACILITY_COUNT = ME_FACILITIES.length;
// ─── Type colors ──────────────────────────────────────────────────────────────
const TYPE_COLORS: Record<MEFacility['type'], string> = {
naval: '#60a5fa',
military_hq: '#f87171',
missile: '#ef4444',
intelligence: '#a78bfa',
government: '#c084fc',
radar: '#22d3ee',
naval: '#3b82f6',
military_hq: '#ef4444',
missile: '#dc2626',
intelligence: '#8b5cf6',
government: '#f59e0b',
radar: '#06b6d4',
};
// ─── SVG generators ──────────────────────────────────────────────────────────
@ -169,7 +168,7 @@ export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontFamily: 'monospace',
fontWeight: 700,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],

파일 보기

@ -1,7 +1,6 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { iranOilFacilities } from '../../data/oilFacilities';
import type { OilFacility, OilFacilityType } from '../../types';
@ -10,12 +9,12 @@ export const IRAN_OIL_COUNT = iranOilFacilities.length;
// ─── Type colors ──────────────────────────────────────────────────────────────
const TYPE_COLORS: Record<OilFacilityType, string> = {
refinery: '#fb7185',
oilfield: '#34d399',
gasfield: '#818cf8',
terminal: '#c084fc',
petrochemical: '#f472b6',
desalination: '#22d3ee',
refinery: '#f59e0b',
oilfield: '#10b981',
gasfield: '#6366f1',
terminal: '#ec4899',
petrochemical: '#8b5cf6',
desalination: '#06b6d4',
};
// ─── SVG generators ──────────────────────────────────────────────────────────
@ -242,7 +241,7 @@ export function createIranOilLayers(config: OilLayerConfig): Layer[] {
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontFamily: 'monospace',
fontWeight: 700,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],

파일 보기

@ -8,7 +8,6 @@ import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker';
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useFontScale } from '../../hooks/useFontScale';
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
@ -92,7 +91,6 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
const SOURCE_COLORS: Record<string, string> = {
@ -129,7 +127,6 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
const { fontScale } = useFontScale();
const [zoomLevel, setZoomLevel] = useState(5);
const zoomRef = useRef(5);
@ -156,11 +153,11 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
}, [zoomLevel]);
const iranDeckLayers = useMemo(() => [
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }),
], [layers, zoomScale, fontScale.facility]);
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, onPick: setMePickedFacility }),
], [layers, zoomScale]);
useEffect(() => {
if (flyToTarget && mapRef.current) {
@ -244,7 +241,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 15 * fontScale.area,
'text-size': 15,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -263,7 +260,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-size': 12 * fontScale.area,
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -283,7 +280,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
minzoom={5}
layout={{
'text-field': ['get', 'name'],
'text-size': 10 * fontScale.area,
'text-size': 10,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -529,8 +526,8 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const { kind, data } = iranPickedFacility;
if (kind === 'oil') {
const OIL_TYPE_COLORS: Record<string, string> = {
refinery: '#fb7185', oilfield: '#34d399', gasfield: '#818cf8',
terminal: '#c084fc', petrochemical: '#f472b6', desalination: '#22d3ee',
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1',
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
};
const color = OIL_TYPE_COLORS[data.type] ?? '#888';
return (
@ -568,13 +565,13 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
}
if (kind === 'airport') {
const isMil = data.type === 'military';
const color = isMil ? '#f87171' : '#38bdf8';
const color = isMil ? '#ef4444' : '#f59e0b';
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#0369a1', color: '#fff', gap: 6, padding: '6px 10px' }}>
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#92400e', color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
@ -598,7 +595,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
);
}
if (kind === 'meFacility') {
const meta = { naval: { label: 'Naval Base', color: '#60a5fa', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#f87171', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#ef4444', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#a78bfa', icon: '🔍' }, government: { label: 'Government', color: '#c084fc', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#22d3ee', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' };
const meta = { naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' };
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}

파일 보기

@ -8,7 +8,6 @@ import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker';
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useFontScale } from '../../hooks/useFontScale';
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
@ -75,7 +74,6 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
const SOURCE_COLORS: Record<string, string> = {
@ -112,7 +110,6 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
const { fontScale } = useFontScale();
const [zoomLevel, setZoomLevel] = useState(5);
const zoomRef = useRef(5);
@ -139,11 +136,11 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
}, [zoomLevel]);
const iranDeckLayers = useMemo(() => [
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }),
], [layers, zoomScale, fontScale.facility]);
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, onPick: setMePickedFacility }),
], [layers, zoomScale]);
useEffect(() => {
if (flyToTarget && mapRef.current) {
@ -236,7 +233,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 15 * fontScale.area,
'text-size': 15,
'text-allow-overlap': false,
'text-ignore-placement': false,
}}
@ -253,7 +250,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 12 * fontScale.area,
'text-size': 12,
'text-allow-overlap': false,
}}
paint={{
@ -270,7 +267,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 10 * fontScale.area,
'text-size': 10,
'text-allow-overlap': false,
}}
paint={{
@ -359,8 +356,8 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
const { kind, data } = iranPickedFacility;
if (kind === 'oil') {
const OIL_TYPE_COLORS: Record<string, string> = {
refinery: '#fb7185', oilfield: '#34d399', gasfield: '#818cf8',
terminal: '#c084fc', petrochemical: '#f472b6', desalination: '#22d3ee',
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1',
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
};
const color = OIL_TYPE_COLORS[data.type] ?? '#888';
return (
@ -398,13 +395,13 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
}
if (kind === 'airport') {
const isMil = data.type === 'military';
const color = isMil ? '#f87171' : '#38bdf8';
const color = isMil ? '#ef4444' : '#f59e0b';
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#0369a1', color: '#fff', gap: 6, padding: '6px 10px' }}>
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#92400e', color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
@ -428,7 +425,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
);
}
if (kind === 'meFacility') {
const meta = { naval: { label: 'Naval Base', color: '#60a5fa', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#f87171', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#ef4444', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#a78bfa', icon: '🔍' }, government: { label: 'Government', color: '#c084fc', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#22d3ee', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' };
const meta = { naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' };
return (
<Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false}

파일 보기

@ -3,7 +3,6 @@ import type { Layer, PickingInfo } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { middleEastAirports } from '../../data/airports';
import type { Airport } from '../../data/airports';
import { FONT_MONO } from '../../styles/fonts';
export { type Airport };
@ -16,10 +15,10 @@ const US_BASE_ICAOS = new Set([
function getAirportColor(airport: Airport): string {
const isMil = airport.type === 'military';
const isUS = isMil && US_BASE_ICAOS.has(airport.icao);
if (isUS) return '#60a5fa'; // blue-400
if (isMil) return '#f87171'; // red-400
if (airport.type === 'international') return '#38bdf8'; // sky-400 (was amber)
return '#a5b4fc'; // indigo-200 (was gray)
if (isUS) return '#3b82f6';
if (isMil) return '#ef4444';
if (airport.type === 'international') return '#f59e0b';
return '#7c8aaa';
}
function airportSvg(color: string, size: number): string {
@ -52,13 +51,11 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
export interface IranAirportLayerConfig {
visible: boolean;
sc: number;
fs?: number;
onPick: (airport: Airport) => void;
}
export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] {
const { visible, sc, onPick } = config;
const fs = config.fs ?? 1;
if (!visible) return [];
const iconLayer = new IconLayer<Airport>({
@ -87,16 +84,16 @@ export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[]
const nameKo = d.nameKo ?? d.name;
return nameKo.length > 10 ? nameKo.slice(0, 10) + '..' : nameKo;
},
getSize: 11 * sc * fs,
getSize: 11 * sc,
updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(getAirportColor(d)),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontWeight: 700,
fontFamily: 'monospace',
fontWeight: 600,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineWidth: 8,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',

파일 보기

@ -3,19 +3,18 @@ import type { Layer, PickingInfo } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { iranOilFacilities } from '../../data/oilFacilities';
import type { OilFacility, OilFacilityType } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
export { type OilFacility };
export const IRAN_OIL_COUNT = iranOilFacilities.length;
const TYPE_COLORS: Record<OilFacilityType, string> = {
refinery: '#fb7185', // rose-400 (was amber — desert에 묻힘)
oilfield: '#34d399', // emerald-400
gasfield: '#818cf8', // indigo-400
terminal: '#c084fc', // purple-400
petrochemical: '#f472b6', // pink-400
desalination: '#22d3ee', // cyan-400
refinery: '#f59e0b',
oilfield: '#10b981',
gasfield: '#6366f1',
terminal: '#ec4899',
petrochemical: '#8b5cf6',
desalination: '#06b6d4',
};
function refinerySvg(color: string, size: number): string {
@ -109,13 +108,11 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
export interface IranOilLayerConfig {
visible: boolean;
sc: number;
fs?: number;
onPick: (facility: OilFacility) => void;
}
export function createIranOilLayers(config: IranOilLayerConfig): Layer[] {
const { visible, sc, onPick } = config;
const fs = config.fs ?? 1;
if (!visible) return [];
const iconLayer = new IconLayer<OilFacility>({
@ -137,16 +134,16 @@ export function createIranOilLayers(config: IranOilLayerConfig): Layer[] {
data: iranOilFacilities,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo,
getSize: 12 * sc * fs,
getSize: 12 * sc,
updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(TYPE_COLORS[d.type]),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontWeight: 700,
fontFamily: 'monospace',
fontWeight: 600,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineWidth: 8,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',

파일 보기

@ -3,7 +3,6 @@ import type { Layer, PickingInfo } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri';
import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities';
import type { MEFacility } from '../../data/middleEastFacilities';
import { FONT_MONO } from '../../styles/fonts';
export { type MEFacility };
@ -104,13 +103,11 @@ function getIconUrl(type: MEFacilityType): string {
export interface MEFacilityLayerConfig {
visible: boolean;
sc: number;
fs?: number;
onPick: (facility: MEFacility) => void;
}
export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
const { visible, sc, onPick } = config;
const fs = config.fs ?? 1;
if (!visible) return [];
const iconLayer = new IconLayer<MEFacility>({
@ -132,16 +129,16 @@ export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
data: ME_FACILITIES,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 12 * sc * fs,
getSize: 12 * sc,
updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color),
getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 12],
fontFamily: FONT_MONO,
fontWeight: 700,
fontFamily: 'monospace',
fontWeight: 600,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineWidth: 8,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',

파일 보기

@ -1,76 +1,73 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useAuth } from '../../hooks/useAuth';
import type { Ship } from '../../types';
interface ChatMessage {
role: 'user' | 'assistant';
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
isStreaming?: boolean;
}
const AI_CHAT_URL = '/api/prediction-chat';
/** assistant 메시지에서 thinking(JSON tool call, 구분선 등)과 답변을 분리 */
function splitThinking(content: string): { thinking: string; answer: string } {
// 패턴: ```json...``` 블록 + ---\n_데이터 조회 완료..._\n\n 까지가 thinking
const thinkingPattern = /^([\s\S]*?```json[\s\S]*?```[\s\S]*?---\n_[^_]*_\n*)/;
const match = content.match(thinkingPattern);
if (match) {
return { thinking: match[1].trim(), answer: content.slice(match[0].length).trim() };
}
// ```json 블록만 있고 답변이 아직 안 온 경우 (스트리밍 중)
const jsonOnly = /^([\s\S]*```json[\s\S]*?```[\s\S]*)$/;
const m2 = content.match(jsonOnly);
if (m2 && !content.includes('---')) {
return { thinking: m2[1].trim(), answer: '' };
}
return { thinking: '', answer: content };
interface Props {
ships: Ship[];
koreanShipCount: number;
chineseShipCount: number;
totalShipCount: number;
}
export function AiChatPanel() {
const { user } = useAuth();
const userId = user?.email ?? 'anonymous';
// TODO: Python FastAPI 기반 해양분석 AI API로 전환 예정
const AI_CHAT_URL = '/api/kcg/ai/chat';
function buildSystemPrompt(props: Props): string {
const { ships, koreanShipCount, chineseShipCount, totalShipCount } = props;
// 선박 유형별 통계
const byType: Record<string, number> = {};
const byFlag: Record<string, number> = {};
ships.forEach(s => {
byType[s.category || 'unknown'] = (byType[s.category || 'unknown'] || 0) + 1;
byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1;
});
// 중국 어선 통계
const cnFishing = ships.filter(s => s.flag === 'CN' && (s.category === 'fishing' || s.typecode === '30'));
const cnFishingOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 5);
return `당신은 대한민국 해양경찰청의 해양상황 분석 AI 어시스턴트입니다.
.
##
- 선박: ${totalShipCount}
- 선박: ${koreanShipCount}
- 선박: ${chineseShipCount}
- 어선: ${cnFishing.length} ( 추정: ${cnFishingOperating.length})
##
${Object.entries(byType).map(([k, v]) => `- ${k}: ${v}`).join('\n')}
## ()
${Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([k, v]) => `- ${k}: ${v}`).join('\n')}
##
- 906 (PT 323, GN 200, PS 16, OT 1 13, FC 31)
- I~IV에서만
- 휴어기: PT/OT 4/16~10/15, GN 6/2~8/31
- (AIS )
##
-
-
-
-
- `;
}
export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShipCount }: Props) {
const [isOpen, setIsOpen] = useState(true);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [elapsed, setElapsed] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [expanded, setExpanded] = useState(false);
const [historyLoaded, setHistoryLoaded] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
// 마운트 시 Redis에서 대화 히스토리 로드
useEffect(() => {
if (historyLoaded) return;
fetch(`${AI_CHAT_URL}/history?user_id=${encodeURIComponent(userId)}`)
.then(res => res.ok ? res.json() : [])
.then((history: { role: string; content: string }[]) => {
if (history.length > 0) {
setMessages(history.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
timestamp: Date.now(),
})));
}
})
.catch(() => { /* Redis 미연결 시 무시 */ })
.finally(() => setHistoryLoaded(true));
}, [userId, historyLoaded]);
useEffect(() => {
if (isLoading) {
setElapsed(0);
timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000);
} else if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [isLoading]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@ -88,166 +85,72 @@ export function AiChatPanel() {
setInput('');
setIsLoading(true);
// 스트리밍 placeholder 추가
const streamingMsg: ChatMessage = { role: 'assistant', content: '', timestamp: Date.now(), isStreaming: true };
setMessages(prev => [...prev, streamingMsg]);
const controller = new AbortController();
abortRef.current = controller;
try {
const systemPrompt = buildSystemPrompt({ ships, koreanShipCount, chineseShipCount, totalShipCount });
const apiMessages = [
{ role: 'system', content: systemPrompt },
...messages.filter(m => m.role !== 'system').map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: userMsg.content },
];
const res = await fetch(AI_CHAT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: userMsg.content,
user_id: userId,
stream: true,
model: 'qwen2.5:7b',
messages: apiMessages,
stream: false,
options: { temperature: 0.3, num_predict: 1024 },
}),
signal: controller.signal,
});
if (!res.ok) throw new Error(`서버 오류: ${res.status}`);
if (!res.body) throw new Error('스트리밍 미지원');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let accumulated = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const chunk = JSON.parse(data) as { content: string; done: boolean };
accumulated += chunk.content;
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: accumulated,
};
return updated;
});
if (chunk.done) break;
} catch {
// JSON 파싱 실패 무시
}
}
}
// 스트리밍 완료
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
isStreaming: false,
};
return updated;
});
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
const data = await res.json();
const assistantMsg: ChatMessage = {
role: 'assistant',
content: data.message?.content || '응답을 생성할 수 없습니다.',
timestamp: Date.now(),
};
setMessages(prev => [...prev, assistantMsg]);
} catch (err) {
if ((err as Error).name === 'AbortError') return;
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}`,
timestamp: Date.now(),
isStreaming: false,
};
return updated;
});
setMessages(prev => [...prev, {
role: 'assistant',
content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}. Ollama가 실행 중인지 확인하세요.`,
timestamp: Date.now(),
}]);
} finally {
setIsLoading(false);
abortRef.current = null;
}
}, [input, isLoading, userId]);
const clearHistory = useCallback(async () => {
setMessages([]);
try {
await fetch(`${AI_CHAT_URL}/history?user_id=${encodeURIComponent(userId)}`, { method: 'DELETE' });
} catch { /* 무시 */ }
}, [userId]);
}, [input, isLoading, messages, ships, koreanShipCount, chineseShipCount, totalShipCount]);
const quickQuestions = [
'현재 해양 상황을 요약해줘',
'중국어선 불법조업 의심 분석해줘',
'위험 선박 상위 10척 알려줘',
'서해 위험도를 평가해줘',
'다크베셀 현황 분석해줘',
];
return (
<div style={{
...(expanded ? {
position: 'fixed' as const,
bottom: 16,
right: 16,
width: 520,
height: 600,
zIndex: 9999,
borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
display: 'flex',
flexDirection: 'column' as const,
background: 'rgba(12,24,37,0.97)',
border: '1px solid rgba(168,85,247,0.3)',
} : {
borderTop: '1px solid rgba(168,85,247,0.2)',
marginTop: 8,
}),
borderTop: '1px solid rgba(168,85,247,0.2)',
marginTop: 8,
}}>
{/* Toggle header */}
<div
onClick={() => {
if (!isOpen) { setIsOpen(true); return; }
if (expanded) { setExpanded(false); return; }
setExpanded(true);
}}
onClick={() => setIsOpen(p => !p)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 8px', cursor: 'pointer',
background: isOpen ? 'rgba(168,85,247,0.12)' : 'rgba(168,85,247,0.04)',
borderRadius: expanded ? '8px 8px 0 0' : 4,
borderLeft: expanded ? 'none' : '2px solid rgba(168,85,247,0.5)',
flexShrink: 0,
borderRadius: 4,
borderLeft: '2px solid rgba(168,85,247,0.5)',
}}
>
<span style={{ fontSize: 12 }}>🤖</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#c4b5fd' }}>AI </span>
<span style={{ fontSize: 8, color: '#8b5cf6', marginLeft: 2, background: 'rgba(139,92,246,0.15)', padding: '1px 5px', borderRadius: 3 }}>
Qwen3 14B
</span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4, alignItems: 'center' }}>
{isOpen && (
<button
onClick={e => { e.stopPropagation(); setIsOpen(false); setExpanded(false); }}
title="접기"
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 8, color: '#8b5cf6',
padding: '8px 10px', margin: '-8px -8px -8px -6px',
lineHeight: 1,
}}
>
{expanded ? '⊖' : '▼'}
</button>
)}
{!isOpen && (
<span style={{ fontSize: 8, color: '#8b5cf6' }}></span>
)}
<span style={{ fontSize: 8, color: '#8b5cf6', marginLeft: 2, background: 'rgba(139,92,246,0.15)', padding: '1px 5px', borderRadius: 3 }}>Qwen 2.5</span>
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#8b5cf6' }}>
{isOpen ? '▼' : '▶'}
</span>
</div>
@ -255,11 +158,10 @@ export function AiChatPanel() {
{isOpen && (
<div style={{
display: 'flex', flexDirection: 'column',
...(expanded ? { flex: 1 } : { height: 360 }),
background: expanded ? 'transparent' : 'rgba(88,28,135,0.08)',
height: 360, background: 'rgba(88,28,135,0.08)',
borderRadius: '0 0 6px 6px', overflow: 'hidden',
borderLeft: expanded ? 'none' : '2px solid rgba(168,85,247,0.3)',
borderBottom: expanded ? 'none' : '1px solid rgba(168,85,247,0.15)',
borderLeft: '2px solid rgba(168,85,247,0.3)',
borderBottom: '1px solid rgba(168,85,247,0.15)',
}}>
{/* Messages */}
<div style={{
@ -290,79 +192,34 @@ export function AiChatPanel() {
</div>
</div>
)}
{messages.map((msg, i) => {
const isAssistant = msg.role === 'assistant';
const { thinking, answer } = isAssistant ? splitThinking(msg.content) : { thinking: '', answer: msg.content };
const displayText = isAssistant ? (answer || (thinking && !msg.isStreaming ? '' : msg.content)) : msg.content;
return (
<div
key={i}
style={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
}}
>
{/* thinking 접기 블록 */}
{isAssistant && thinking && (
<details style={{
background: 'rgba(100,116,139,0.1)',
borderRadius: '6px 6px 0 0',
padding: '4px 8px',
fontSize: 9,
color: '#64748b',
cursor: 'pointer',
borderLeft: '2px solid rgba(139,92,246,0.3)',
}}>
<summary style={{ userSelect: 'none', outline: 'none' }}> </summary>
<pre style={{
margin: '4px 0 0', padding: '4px',
fontSize: 8, color: '#94a3b8',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
background: 'rgba(0,0,0,0.2)', borderRadius: 3,
maxHeight: 120, overflowY: 'auto',
}}>{thinking}</pre>
</details>
)}
{/* 메시지 본문 */}
<div style={{
background: msg.role === 'user'
? 'rgba(139,92,246,0.25)'
: 'rgba(168,85,247,0.08)',
borderRadius: thinking
? '0 0 8px 8px'
: (msg.role === 'user' ? '8px 8px 2px 8px' : '8px 8px 8px 2px'),
padding: '6px 8px',
fontSize: 10,
color: '#e2e8f0',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{displayText}
{msg.isStreaming && msg.content && (
<span style={{ color: '#a78bfa' }}>
<span style={{ animation: 'pulse 1s infinite' }}> </span>
<span style={{ fontSize: 8, color: '#64748b', marginLeft: 4, fontVariantNumeric: 'tabular-nums' }}>
{String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}
</span>
</span>
)}
</div>
</div>
);
})}
{isLoading && !messages[messages.length - 1]?.content && (
{messages.map((msg, i) => (
<div
key={i}
style={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
background: msg.role === 'user'
? 'rgba(139,92,246,0.25)'
: 'rgba(168,85,247,0.08)',
borderRadius: msg.role === 'user' ? '8px 8px 2px 8px' : '8px 8px 8px 2px',
padding: '6px 8px',
fontSize: 10,
color: '#e2e8f0',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{msg.content}
</div>
))}
{isLoading && (
<div style={{
alignSelf: 'flex-start', padding: '6px 8px',
background: 'rgba(168,85,247,0.08)', borderRadius: 8,
fontSize: 10, color: '#a78bfa',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ animation: 'pulse 1.5s ease-in-out infinite' }}> </span>
<span style={{ color: '#64748b', fontVariantNumeric: 'tabular-nums' }}>
{String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}
</span>
...
</div>
)}
<div ref={messagesEndRef} />
@ -374,24 +231,11 @@ export function AiChatPanel() {
borderTop: '1px solid rgba(255,255,255,0.06)',
background: 'rgba(0,0,0,0.15)',
}}>
{messages.length > 0 && (
<button
onClick={clearHistory}
title="대화 초기화"
style={{
background: 'none', border: 'none',
color: '#64748b', fontSize: 12, cursor: 'pointer',
padding: '0 4px', flexShrink: 0,
}}
>
</button>
)}
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void sendMessage(); } }}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
placeholder="해양 상황 질문..."
disabled={isLoading}
style={{
@ -402,7 +246,7 @@ export function AiChatPanel() {
}}
/>
<button
onClick={() => { void sendMessage(); }}
onClick={sendMessage}
disabled={isLoading || !input.trim()}
style={{
background: isLoading || !input.trim() ? '#334155' : '#7c3aed',

파일 보기

@ -1,17 +1,8 @@
import { useState, useMemo, useEffect } from 'react';
import type { VesselAnalysisDto, Ship } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { fetchVesselTrack } from '../../services/vesselTrack';
import {
type AlertLevel,
ALERT_COLOR,
ALERT_EMOJI,
ALERT_LEVELS,
STATS_KEY_MAP,
RISK_TO_ALERT,
} from '../../constants/riskMapping';
interface Props {
stats: AnalysisStats;
@ -19,6 +10,7 @@ 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;
@ -40,6 +32,22 @@ function formatTime(ms: number): string {
return `${hh}:${mm}`;
}
const RISK_COLOR: Record<RiskLevel, string> = {
CRITICAL: '#ef4444',
HIGH: '#f97316',
MEDIUM: '#eab308',
LOW: '#22c55e',
};
const RISK_EMOJI: Record<RiskLevel, string> = {
CRITICAL: '🔴',
HIGH: '🟠',
MEDIUM: '🟡',
LOW: '🟢',
};
const RISK_LEVELS: RiskLevel[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
const LEGEND_LINES = [
'위험도 점수 기준 (0~100)',
'',
@ -57,15 +65,15 @@ const LEGEND_LINES = [
'■ 허가 이력 (최대 20점)',
' 미허가 어선: 20',
'',
'CRITICAL ≥70 / WATCH ≥50',
'MONITOR ≥30 / NORMAL <30',
'CRITICAL ≥70 / HIGH ≥50',
'MEDIUM ≥30 / LOW <30',
'',
'UCAF: 어구별 조업속도 매칭 비율',
'UCFT: 조업-항행 구분 신뢰도',
'스푸핑: 순간이동+SOG급변+BD09 종합',
];
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect, onTrackLoad, onExpandedChange }: Props) {
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad, onExpandedChange }: Props) {
const [expanded, setExpanded] = useLocalStorage('analysisPanelExpanded', false);
const toggleExpanded = () => {
const next = !expanded;
@ -75,24 +83,44 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
// 마운트 시 저장된 상태를 부모에 동기화
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { onExpandedChange?.(expanded); }, []);
const [selectedLevel, setSelectedLevel] = useState<AlertLevel | null>(null);
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [showLegend, setShowLegend] = useState(false);
const isEmpty = useMemo(() => stats.total === 0, [stats.total]);
const gearStats = useMemo(() => {
const source = allShips ?? ships;
const gearPattern = /^(.+?)_\d+_\d+_?$/;
const STALE_MS = 60 * 60_000; // 60분 이내만
const now = Date.now();
const parentMap = new Map<string, number>();
for (const s of source) {
if (now - s.lastSeen > STALE_MS) continue;
const m = (s.name || '').match(gearPattern);
if (m) {
const parent = m[1].trim();
parentMap.set(parent, (parentMap.get(parent) || 0) + 1);
}
}
return {
groups: parentMap.size,
count: Array.from(parentMap.values()).reduce((a, b) => a + b, 0),
};
}, [allShips, ships]);
const vesselList = useMemo((): VesselListItem[] => {
if (!selectedLevel) return [];
const list: VesselListItem[] = [];
for (const [mmsi, dto] of analysisMap) {
if (RISK_TO_ALERT[dto.algorithms.riskScore.level] !== selectedLevel) continue;
if (dto.algorithms.riskScore.level !== selectedLevel) continue;
const ship = ships.find(s => s.mmsi === mmsi);
list.push({ mmsi, name: ship?.name || mmsi, score: dto.algorithms.riskScore.score, dto });
}
return list.sort((a, b) => b.score - a.score).slice(0, 50);
}, [selectedLevel, analysisMap, ships]);
const handleLevelClick = (level: AlertLevel) => {
const handleLevelClick = (level: RiskLevel) => {
setSelectedLevel(prev => (prev === level ? null : level));
setSelectedMmsi(null);
};
@ -115,7 +143,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8,
color: '#e2e8f0',
fontFamily: FONT_MONO,
fontFamily: 'monospace, sans-serif',
fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
@ -247,15 +275,15 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
</div>
{stats.gearGroups > 0 && (
{gearStats.groups > 0 && (
<>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{stats.gearGroups}</span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.groups}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{stats.gearCount}</span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.count}</span>
</div>
</>
)}
@ -264,9 +292,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
{/* 위험도 카운트 행 — 클릭 가능 */}
<div style={riskRowStyle}>
{ALERT_LEVELS.map(level => {
const count = stats[STATS_KEY_MAP[level]];
const color = ALERT_COLOR[level];
{RISK_LEVELS.map(level => {
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
const isActive = selectedLevel === level;
return (
<button
@ -277,18 +304,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
display: 'flex',
alignItems: 'center',
gap: 2,
background: isActive ? `${color}22` : 'none',
border: isActive ? `1px solid ${color}88` : '1px solid transparent',
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
borderRadius: 4,
color: '#cbd5e1',
fontSize: 10,
cursor: 'pointer',
padding: '2px 4px',
fontFamily: FONT_MONO,
fontFamily: 'monospace, sans-serif',
}}
>
<span>{ALERT_EMOJI[level]}</span>
<span style={{ color, fontWeight: 700 }}>{count}</span>
<span>{RISK_EMOJI[level]}</span>
<span style={{ color: RISK_COLOR[level], fontWeight: 700 }}>{count}</span>
</button>
);
})}
@ -299,12 +326,12 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<>
<div style={{ ...dividerStyle, marginTop: 8 }} />
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
{ALERT_EMOJI[selectedLevel]} {selectedLevel} {vesselList.length}
{RISK_EMOJI[selectedLevel]} {selectedLevel} {vesselList.length}
</div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{vesselList.map(item => {
const isExpanded = selectedMmsi === item.mmsi;
const color = ALERT_COLOR[selectedLevel];
const color = RISK_COLOR[selectedLevel];
const { dto } = item;
return (
<div key={item.mmsi}>

파일 보기

@ -1,460 +0,0 @@
import { useState, useMemo, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
import type { MemberInfo } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { FONT_MONO } from '../../styles/fonts';
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useTranslation } from 'react-i18next';
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
interface CorrelationPanelProps {
selectedGearGroup: string;
memberCount: number;
groupPolygons: UseGroupPolygonsResult | undefined;
correlationByModel: Map<string, GearCorrelationItem[]>;
availableModels: { name: string; count: number; isDefault: boolean }[];
enabledModels: Set<string>;
enabledVessels: Set<string>;
correlationLoading: boolean;
hoveredTarget: { mmsi: string; model: string } | null;
hasRightReviewPanel?: boolean;
reviewDriven?: boolean;
onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void;
}
// Ensure MODEL_ORDER is treated as string array for Record lookups
const _MODEL_ORDER: string[] = MODEL_ORDER as unknown as string[];
const CorrelationPanel = ({
selectedGearGroup,
memberCount,
groupPolygons,
correlationByModel,
availableModels,
enabledModels,
enabledVessels,
correlationLoading,
hoveredTarget,
hasRightReviewPanel = false,
reviewDriven = false,
onEnabledModelsChange,
onEnabledVesselsChange,
onHoveredTargetChange,
}: CorrelationPanelProps) => {
const { t } = useTranslation();
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
const layout = useReplayCenterPanelLayout({
minWidth: 252,
maxWidth: 966,
hasRightReviewPanel,
});
// Local tooltip state
const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null);
const [pinnedModelTip, setPinnedModelTip] = useState<string | null>(null);
const activeModelTip = pinnedModelTip ?? hoveredModelTip;
// Card expand state
const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
// Card ref map for tooltip positioning
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const setCardRef = useCallback((model: string, el: HTMLDivElement | null) => {
if (el) cardRefs.current.set(model, el);
else cardRefs.current.delete(model);
}, []);
const toggleCardExpand = (model: string) => {
setExpandedCards(prev => {
const next = new Set(prev);
if (next.has(model)) next.delete(model); else next.add(model);
return next;
});
};
// Identity 목록: 리플레이 활성 시 전체 구간 멤버, 아닐 때 현재 스냅샷 멤버
const allHistoryMembers = useGearReplayStore(s => s.allHistoryMembers);
const { identityVessels, identityGear } = useMemo(() => {
if (historyActive && allHistoryMembers.length > 0) {
return {
identityVessels: allHistoryMembers.filter(m => m.isParent),
identityGear: allHistoryMembers.filter(m => !m.isParent),
};
}
if (!groupPolygons || !selectedGearGroup) return { identityVessels: [] as MemberInfo[], identityGear: [] as MemberInfo[] };
const allGear = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const matches = allGear.filter(g => g.groupKey === selectedGearGroup);
const seen = new Set<string>();
const members: MemberInfo[] = [];
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); } }
return {
identityVessels: members.filter(m => m.isParent),
identityGear: members.filter(m => !m.isParent),
};
}, [historyActive, allHistoryMembers, groupPolygons, selectedGearGroup]);
// Suppress unused MODEL_ORDER warning — used for ordering checks
void _MODEL_ORDER;
// Common card styles
const CARD_WIDTH = 180;
const cardStyle: React.CSSProperties = {
background: 'rgba(12,24,37,0.95)',
borderRadius: 6,
width: CARD_WIDTH,
minWidth: CARD_WIDTH,
flexShrink: 0,
border: '1px solid rgba(255,255,255,0.08)',
position: 'relative',
};
const CARD_COLLAPSED_H = 200;
const CARD_EXPANDED_H = 500;
const cardFooterStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 8px 6px',
cursor: 'pointer', userSelect: 'none',
borderTop: '1px solid rgba(255,255,255,0.06)',
};
const getCardBodyStyle = (model: string): React.CSSProperties => ({
padding: '6px 8px 4px',
maxHeight: expandedCards.has(model) ? CARD_EXPANDED_H : CARD_COLLAPSED_H,
overflowY: 'auto',
transition: 'max-height 0.2s ease',
});
// Model title tooltip: hover → show, right-click → pin
const handleTipHover = (model: string) => {
if (!pinnedModelTip) setHoveredModelTip(model);
};
const handleTipLeave = () => {
if (!pinnedModelTip) setHoveredModelTip(null);
};
const handleTipContextMenu = (e: React.MouseEvent, model: string) => {
e.preventDefault();
setPinnedModelTip(prev => prev === model ? null : model);
setHoveredModelTip(null);
};
// 툴팁은 카드 밖에서 fixed로 렌더 (overflow 영향 안 받음)
const renderFloatingTip = () => {
if (!activeModelTip) return null;
const desc = MODEL_DESC[activeModelTip];
if (!desc) return null;
const el = cardRefs.current.get(activeModelTip);
if (!el) return null;
const rect = el.getBoundingClientRect();
const color = MODEL_COLORS[activeModelTip] ?? '#94a3b8';
return (
<div style={{
position: 'fixed',
left: rect.left,
top: rect.top - 4,
transform: 'translateY(-100%)',
padding: '6px 10px',
background: 'rgba(15,23,42,0.97)',
border: `1px solid ${color}66`,
borderRadius: 5,
fontSize: 9,
color: '#e2e8f0',
zIndex: 50,
whiteSpace: 'nowrap',
boxShadow: '0 2px 12px rgba(0,0,0,0.6)',
pointerEvents: pinnedModelTip ? 'auto' : 'none',
fontFamily: FONT_MONO,
}}>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{desc.summary}</div>
{desc.details.map((line, i) => (
<div key={i} style={{ color: '#94a3b8', lineHeight: 1.5 }}>{line}</div>
))}
{pinnedModelTip && (
<div style={{
color: '#64748b', fontSize: 8, marginTop: 4,
borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 3,
}}>
</div>
)}
</div>
);
};
// Common row renderer (correlation target — with score bar, model-independent hover)
const toggleVessel = (mmsi: string) => {
onEnabledVesselsChange(prev => {
const next = new Set(prev);
if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi);
return next;
});
};
const renderRow = (c: GearCorrelationItem, color: string, modelName: string) => {
const pct = (c.score * 100).toFixed(0);
const barW = Math.max(2, c.score * 30);
const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8';
const isVessel = c.targetType === 'VESSEL';
const isEnabled = enabledVessels.has(c.targetMmsi);
const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName;
return (
<div
key={`${modelName}-${c.targetMmsi}`}
style={{
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
padding: '1px 2px', borderRadius: 2, cursor: reviewDriven ? 'default' : 'pointer',
background: isHovered ? `${color}22` : 'transparent',
opacity: reviewDriven ? 1 : isEnabled ? 1 : 0.5,
}}
onClick={reviewDriven ? undefined : () => toggleVessel(c.targetMmsi)}
onMouseEnter={reviewDriven ? undefined : () => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
onMouseLeave={reviewDriven ? undefined : () => onHoveredTargetChange(null)}
>
{reviewDriven ? (
<span
title={t('parentInference.reference.reviewDriven')}
style={{
width: 9,
height: 9,
borderRadius: 999,
background: color,
flexShrink: 0,
opacity: 0.9,
}}
/>
) : (
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
)}
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
{isVessel ? '⛴' : '◆'}
</span>
<span style={{ color: '#cbd5e1', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.targetName || c.targetMmsi}
</span>
<div style={{ width: 50, display: 'flex', alignItems: 'center', gap: 2, flexShrink: 0 }}>
<div style={{ width: 24, height: 3, background: 'rgba(255,255,255,0.08)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ width: barW, height: '100%', background: barColor, borderRadius: 2 }} />
</div>
<span style={{ color: barColor, fontSize: 8, minWidth: 20, textAlign: 'right' }}>{pct}%</span>
</div>
</div>
);
};
const visibleModelNames = useMemo(() => {
if (reviewDriven) {
return availableModels
.filter(model => (correlationByModel.get(model.name) ?? []).length > 0)
.map(model => model.name);
}
return availableModels.filter(model => enabledModels.has(model.name)).map(model => model.name);
}, [availableModels, correlationByModel, enabledModels, reviewDriven]);
// Member row renderer (identity model — no score, independent hover)
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => {
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
return (
<div
key={`${keyPrefix}-${m.mmsi}`}
style={{
fontSize: 9,
marginBottom: 1,
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '1px 2px',
borderRadius: 2,
cursor: 'default',
background: isHovered ? 'rgba(249,115,22,0.15)' : 'transparent',
}}
onMouseEnter={() => onHoveredTargetChange({ mmsi: m.mmsi, model: 'identity' })}
onMouseLeave={() => onHoveredTargetChange(null)}
>
<span style={{ color: iconColor, width: 10, textAlign: 'center', flexShrink: 0 }}>{icon}</span>
<span style={{ color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{m.name || m.mmsi}
</span>
</div>
);
};
return (
<div style={{
position: 'absolute',
bottom: historyActive ? 120 : 20,
left: `${layout.left}px`,
width: `${layout.width}px`,
display: 'flex',
gap: 6,
alignItems: 'flex-end',
zIndex: 21,
fontFamily: FONT_MONO,
fontSize: 10,
color: '#e2e8f0',
pointerEvents: 'auto',
}}>
{/* 고정: 토글 패널 (스크롤 밖) */}
<div style={{
background: 'rgba(12,24,37,0.95)',
border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8,
padding: '8px 10px',
width: 165,
minWidth: 165,
flexShrink: 0,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<span style={{ fontWeight: 700, color: '#f97316', fontSize: 11 }}>{selectedGearGroup}</span>
<span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}</span>
</div>
<div style={{
marginBottom: 7,
padding: '6px 7px',
borderRadius: 6,
background: 'rgba(15,23,42,0.72)',
border: '1px solid rgba(249,115,22,0.14)',
color: '#cbd5e1',
fontSize: 8,
lineHeight: 1.45,
whiteSpace: 'normal',
wordBreak: 'keep-all',
}}>
{reviewDriven
? t('parentInference.reference.reviewDriven')
: t('parentInference.reference.shipOnly')}
</div>
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 4 }}> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
<input
type="checkbox"
checked={true}
disabled
style={{ accentColor: '#f97316', width: 11, height: 11, opacity: 0.6 }}
title="이름 기반 (항상 ON)"
/>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ color: '#94a3b8' }}> ()</span>
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{memberCount}</span>
</label>
{correlationLoading && <div style={{ fontSize: 8, color: '#64748b' }}>...</div>}
{_MODEL_ORDER.filter(mn => mn !== 'identity').map(mn => {
const color = MODEL_COLORS[mn] ?? '#94a3b8';
const modelItems = correlationByModel.get(mn) ?? [];
const hasData = modelItems.length > 0;
const vc = modelItems.filter(c => c.targetType === 'VESSEL').length;
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
const am = availableModels.find(m => m.name === mn);
return (
<label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: reviewDriven ? 'default' : hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}>
{reviewDriven ? (
<span style={{ width: 11, height: 11, borderRadius: 999, background: hasData ? color : 'rgba(148,163,184,0.2)', flexShrink: 0 }} />
) : (
<input type="checkbox" checked={enabledModels.has(mn)}
disabled={!hasData}
onChange={() => onEnabledModelsChange(prev => {
const next = new Set(prev);
if (next.has(mn)) next.delete(mn); else next.add(mn);
return next;
})}
style={{ accentColor: color, width: 11, height: 11 }} title={mn} />
)}
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, opacity: hasData ? 1 : 0.3 }} />
<span style={{ color: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span>
<span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}${gc}` : '—'}</span>
</label>
);
})}
</div>
{/* 스크롤 영역: 모델 카드들 */}
<div style={{
display: 'flex', gap: 6, alignItems: 'flex-end',
overflowX: 'auto', overflowY: 'visible', flex: 1, minWidth: 0,
}}>
{/* 이름 기반 카드 (체크 시) */}
{(reviewDriven || enabledModels.has('identity')) && (identityVessels.length > 0 || identityGear.length > 0) && (
<div ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}>
<div style={getCardBodyStyle('identity')}>
{identityVessels.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({identityVessels.length})</div>
{identityVessels.map(m => renderMemberRow(m, '⛴', '#60a5fa'))}
</>
)}
{identityGear.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
{t('parentInference.reference.referenceGear')} ({identityGear.length})
</div>
{identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))}
</>
)}
</div>
<div
style={cardFooterStyle}
onClick={() => toggleCardExpand('identity')}
onMouseEnter={() => handleTipHover('identity')}
onMouseLeave={handleTipLeave}
onContextMenu={(e) => handleTipContextMenu(e, 'identity')}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color: '#f97316', flex: 1 }}> </span>
<span style={{ fontSize: 8, color: '#64748b' }}>{expandedCards.has('identity') ? '▾' : '▴'}</span>
</div>
</div>
)}
{/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */}
{visibleModelNames.map(modelName => {
const m = availableModels.find(model => model.name === modelName);
if (!m) return null;
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
const items = correlationByModel.get(m.name) ?? [];
const vessels = items.filter(c => c.targetType === 'VESSEL');
const gears = items.filter(c => c.targetType !== 'VESSEL');
if (vessels.length === 0 && gears.length === 0) return null;
return (
<div key={m.name} ref={(el) => setCardRef(m.name, el)} style={{ ...cardStyle, borderColor: `${color}40`, position: 'relative' }}>
<div style={getCardBodyStyle(m.name)}>
{vessels.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({vessels.length})</div>
{vessels.map(c => renderRow(c, color, m.name))}
</>
)}
{gears.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
{t('parentInference.reference.referenceGear')} ({gears.length})
</div>
{gears.map(c => renderRow(c, color, m.name))}
</>
)}
</div>
<div
style={cardFooterStyle}
onClick={() => toggleCardExpand(m.name)}
onMouseEnter={() => handleTipHover(m.name)}
onMouseLeave={handleTipLeave}
onContextMenu={(e) => handleTipContextMenu(e, m.name)}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color, flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
<span style={{ fontSize: 8, color: '#64748b' }}>{expandedCards.has(m.name) ? '▾' : '▴'}</span>
</div>
</div>
);
})}
</div>{/* 스크롤 영역 끝 */}
{renderFloatingTip() && createPortal(renderFloatingTip(), document.body)}
</div>
);
};
export default CorrelationPanel;

파일 보기

@ -1,12 +1,9 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto, RiskLevel } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { lookupPermittedShip } from '../../services/chnPrmShip';
import { fetchVesselTrack } from '../../services/vesselTrack';
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
import { RISK_TO_ALERT } from '../../constants/riskMapping';
import { Map as MapGL, Source, Layer, Marker } from 'react-map-gl/maplibre';
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
const mtPhotoCache = new Map<string, string | null>();
@ -59,17 +56,22 @@ const C = {
border2: '#0E2035',
} as const;
const MINIMAP_STYLE = {
version: 8 as const,
sources: {
'carto-dark': {
type: 'raster' as const,
tiles: ['https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png'],
tileSize: 256,
},
},
layers: [{ id: 'carto-dark', type: 'raster' as const, source: 'carto-dark' }],
};
// AIS 수신 기준 선박 상태 분류 (Python 결과 없을 때 fallback)
function classifyStateFallback(ship: Ship): string {
const ageMins = (Date.now() - ship.lastSeen) / 60000;
if (ageMins > 20) return 'AIS_LOSS';
if (ship.speed <= 0.5) return 'STATIONARY';
if (ship.speed >= 5.0) return 'SAILING';
return 'FISHING';
}
// Python RiskLevel → 경보 등급 매핑
function riskToAlert(level: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' {
if (level === 'CRITICAL') return 'CRITICAL';
if (level === 'HIGH') return 'WATCH';
if (level === 'MEDIUM') return 'MONITOR';
return 'NORMAL';
}
function stateLabel(s: string): string {
const map: Record<string, string> = {
@ -107,7 +109,6 @@ interface Props {
ships: Ship[];
vesselAnalysis?: UseVesselAnalysisResult;
onClose: () => void;
onShowReport?: () => void;
}
const PIPE_STEPS = [
@ -122,14 +123,14 @@ const PIPE_STEPS = [
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowReport }: Props) {
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
const [activeFilter, setActiveFilter] = useState('ALL');
const [search, setSearch] = useState('');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
// pipeStep 제거 — 파이프라인 상태는 analysisMap 존재 여부로 판단
const [pipeStep, setPipeStep] = useState(0);
const [tick, setTick] = useState(0);
// 중국 어선만 필터
@ -139,20 +140,35 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
return cat === 'fishing' || s.category === 'fishing';
}), [ships]);
// 선박 데이터 처리 — Python 분석 결과 기반 (경량 분석 포함)
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
const processed = useMemo((): ProcessedVessel[] => {
return cnFishing
.filter(ship => analysisMap.has(ship.mmsi))
.map(ship => {
const dto = analysisMap.get(ship.mmsi)!;
const zone = dto.algorithms.location.zone;
const state = dto.algorithms.activity.state;
const alert = RISK_TO_ALERT[dto.algorithms.riskScore.level as RiskLevel];
const vtype = dto.classification.vesselType ?? 'UNKNOWN';
const clusterId = dto.algorithms.cluster.clusterId ?? -1;
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
return { ship, zone, state, alert, vtype, cluster };
});
return cnFishing.map(ship => {
const dto = analysisMap.get(ship.mmsi);
// 수역: Python → GeoJSON 폴리곤 fallback
let zone: string;
if (dto) {
zone = dto.algorithms.location.zone;
} else {
const zoneInfo = classifyFishingZone(ship.lat, ship.lng);
zone = zoneInfo.zone === 'OUTSIDE' ? 'EEZ_OR_BEYOND' : zoneInfo.zone;
}
// 행동 상태: Python → AIS fallback
const state = dto?.algorithms.activity.state ?? classifyStateFallback(ship);
// 경보 등급: Python 위험도 직접 사용
const alert = dto ? riskToAlert(dto.algorithms.riskScore.level) : 'NORMAL';
// 어구 분류: Python classification
const vtype = dto?.classification.vesselType ?? 'UNKNOWN';
// 클러스터: Python cluster ID
const clusterId = dto?.algorithms.cluster.clusterId ?? -1;
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
return { ship, zone, state, alert, vtype, cluster };
});
}, [cnFishing, analysisMap]);
// 필터 + 정렬
@ -172,13 +188,9 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
// 통계 — Python 분석 결과 기반
const stats = useMemo(() => {
let gpsAnomaly = 0;
let bd09Detected = 0;
for (const v of processed) {
const dto = analysisMap.get(v.ship.mmsi);
if (dto) {
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
if (dto.algorithms.gpsSpoofing.bd09OffsetM > 100) bd09Detected++;
}
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
}
return {
total: processed.length,
@ -186,7 +198,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
fishing: processed.filter(v => v.state === 'FISHING').length,
aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length,
gpsAnomaly,
bd09Detected,
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
trawl: processed.filter(v => v.vtype === 'TRAWL').length,
purse: processed.filter(v => v.vtype === 'PURSE').length,
@ -219,6 +230,12 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// AI 파이프라인 애니메이션
useEffect(() => {
const t = setInterval(() => setPipeStep(s => s + 1), 1200);
return () => clearInterval(t);
}, []);
// 시계 tick
useEffect(() => {
const t = setInterval(() => setTick(s => s + 1), 1000);
@ -238,14 +255,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
[selectedMmsi, processed],
);
// 항적 미니맵
const [trackCoords, setTrackCoords] = useState<[number, number][]>([]);
useEffect(() => {
if (!selectedVessel) { setTrackCoords([]); return; }
fetchVesselTrack(selectedVessel.ship.mmsi, 72).then(setTrackCoords);
}, [selectedVessel]);
// 허가 정보
const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle');
const [permitData, setPermitData] = useState<ChnPrmShipInfo | null>(null);
@ -321,7 +330,7 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
position: 'absolute', inset: 0, zIndex: 2000,
background: 'rgba(2,6,14,0.96)',
display: 'flex', flexDirection: 'column',
fontFamily: FONT_MONO,
fontFamily: "'IBM Plex Mono', 'Noto Sans KR', monospace",
}}>
{/* ── 헤더 */}
<div style={{
@ -339,19 +348,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
LIVE
</span>
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
{onShowReport && (
<button
type="button"
onClick={onShowReport}
style={{
background: 'rgba(99,179,237,0.1)', border: '1px solid rgba(99,179,237,0.4)',
color: '#63b3ed', padding: '4px 14px', cursor: 'pointer',
fontSize: 11, borderRadius: 2, fontFamily: 'inherit',
}}
>
📋
</button>
)}
<button
type="button"
onClick={onClose}
@ -433,46 +429,38 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
AI
<span style={{ float: 'right', color: analysisMap.size > 0 ? C.green : C.red, fontSize: 8 }}></span>
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span>
</div>
{PIPE_STEPS.map((step) => {
const connected = analysisMap.size > 0;
{PIPE_STEPS.map((step, idx) => {
const isRunning = idx === pipeStep % PIPE_STEPS.length;
return (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: connected ? 'rgba(0,230,118,0.1)' : 'rgba(255,82,82,0.1)',
border: `1px solid ${connected ? C.green : C.red}`,
color: connected ? C.green : C.red,
fontWeight: 400,
background: isRunning ? 'rgba(0,230,118,0.15)' : 'rgba(0,230,118,0.06)',
border: `1px solid ${isRunning ? C.green : C.border}`,
color: isRunning ? C.green : C.ink3,
fontWeight: isRunning ? 700 : 400,
}}>
{connected ? 'ON' : 'OFF'}
{isRunning ? 'PROC' : 'OK'}
</span>
</div>
);
})}
{[
{
num: 'GPS', name: 'BD-09 변환',
status: stats.bd09Detected > 0 ? `${stats.bd09Detected}척 탐지` : 'CLEAR',
color: stats.bd09Detected > 0 ? C.amber : C.green,
active: stats.bd09Detected > 0,
},
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3, active: false },
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
].map(step => (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: step.active ? 'rgba(255,215,64,0.12)' : 'rgba(24,255,255,0.08)',
border: `1px solid ${step.active ? C.amber : C.border}`,
color: step.color,
fontWeight: step.active ? 700 : 400,
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: step.color,
}}>
{step.status}
</span>
@ -496,35 +484,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
<span style={{ fontSize: 9, color }}>{val}</span>
</div>
))}
{/* 위험도 점수 기준 */}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
</div>
<div style={{ fontSize: 9, color: C.ink3, lineHeight: 1.8 }}>
{[
{ title: '■ 위치 (최대 40점)', items: ['영해 내: 40 / 접속수역: 10'] },
{ title: '■ 조업 행위 (최대 30점)', items: ['영해 내 조업: 20 / 기타 조업: 5', 'U-turn 패턴: 10'] },
{ title: '■ AIS 조작 (최대 35점)', items: ['순간이동: 20 / 장시간 갭: 15', '단시간 갭: 5'] },
{ title: '■ 허가 이력 (최대 20점)', items: ['미허가 어선: 20'] },
].map(({ title, items }) => (
<div key={title} style={{ marginBottom: 6 }}>
<div style={{ color: C.ink2 }}>{title}</div>
{items.map(item => <div key={item} style={{ paddingLeft: 8 }}>{item}</div>)}
</div>
))}
<div style={{ marginTop: 6, borderTop: `1px solid ${C.border}`, paddingTop: 6, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 8px' }}>
<span style={{ color: C.red }}>CRITICAL 70</span>
<span style={{ color: C.amber }}>WATCH 50</span>
<span style={{ color: C.cyan }}>MONITOR 30</span>
<span style={{ color: C.green }}>NORMAL {'<'}30</span>
</div>
<div style={{ marginTop: 6, color: C.ink3 }}>
UCAF: 어구별 <br />
UCFT: 조업- <br />
스푸핑: 순간이동+SOG급변+BD09
</div>
</div>
</div>
{/* ── 중앙 패널: 선박 테이블 */}
@ -859,38 +818,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowRepor
</div>
)}
</div>
{/* ── 항적 미니맵 */}
<div style={{ borderTop: `1px solid ${C.border}`, padding: '8px 12px 12px' }}>
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 6 }}> </div>
<div style={{ height: 180, borderRadius: 4, overflow: 'hidden', border: `1px solid ${C.border}` }}>
<MapGL
key={selectedVessel.ship.mmsi}
initialViewState={{ longitude: selectedVessel.ship.lng, latitude: selectedVessel.ship.lat, zoom: 3 }}
style={{ width: '100%', height: '100%' }}
mapStyle={MINIMAP_STYLE}
attributionControl={false}
interactive={false}
>
{trackCoords.length > 1 && (
<Source id="minimap-track" type="geojson" data={{
type: 'Feature', properties: {},
geometry: { type: 'LineString', coordinates: trackCoords },
}}>
<Layer id="minimap-track-line" type="line" paint={{
'line-color': '#fbbf24', 'line-width': 2, 'line-opacity': 0.8,
}} />
</Source>
)}
<Marker longitude={selectedVessel.ship.lng} latitude={selectedVessel.ship.lat}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: C.red, border: '2px solid #fff' }} />
</Marker>
</MapGL>
</div>
<div style={{ fontSize: 8, color: C.ink3, marginTop: 4 }}>
72 · {trackCoords.length}
</div>
</div>
</>
) : (
<div style={{ padding: '24px 0', textAlign: 'center', color: C.ink3, fontSize: 10 }}>

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -2,14 +2,8 @@ import { useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage';
import { DEFAULT_ENC_MAP_SETTINGS } from '../../features/encMap/types';
import type { EncMapSettings } from '../../features/encMap/types';
import { EncMapSettingsPanel } from '../../features/encMap/EncMapSettingsPanel';
import { KoreaMap } from './KoreaMap';
import { FieldAnalysisModal } from './FieldAnalysisModal';
import { ReportModal } from './ReportModal';
import { OpsGuideModal } from './OpsGuideModal';
import type { OpsRoute } from './OpsGuideModal';
import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel';
import { EventLog } from '../common/EventLog';
import { LiveControls } from '../common/LiveControls';
@ -17,7 +11,6 @@ import { ReplayControls } from '../common/ReplayControls';
import { TimelineSlider } from '../common/TimelineSlider';
import { useKoreaData } from '../../hooks/useKoreaData';
import { useVesselAnalysis } from '../../hooks/useVesselAnalysis';
import { useGroupPolygons } from '../../hooks/useGroupPolygons';
import { useKoreaFilters } from '../../hooks/useKoreaFilters';
import { useSharedFilters } from '../../hooks/useSharedFilters';
import { EAST_ASIA_PORTS } from '../../data/ports';
@ -85,15 +78,8 @@ export const KoreaDashboard = ({
onTimeZoneChange,
}: KoreaDashboardProps) => {
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
const [showReport, setShowReport] = useState(false);
const [showOpsGuide, setShowOpsGuide] = useState(false);
const [opsRoute, setOpsRoute] = useState<OpsRoute | null>(null);
const [externalFlyTo, setExternalFlyTo] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
const { t } = useTranslation();
const [mapMode, setMapMode] = useLocalStorage<'satellite' | 'enc'>('koreaMapMode', 'satellite');
const [encSettings, setEncSettings] = useLocalStorage<EncMapSettings>('encMapSettings', DEFAULT_ENC_MAP_SETTINGS);
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
useSharedFilters();
@ -175,14 +161,6 @@ export const KoreaDashboard = ({
});
const vesselAnalysis = useVesselAnalysis(true);
const groupPolygons = useGroupPolygons(true);
const largestGearGroup = useMemo(() => {
const gears = groupPolygons.allGroups.filter(g => g.groupType !== 'FLEET');
if (gears.length === 0) return undefined;
const max = gears.reduce((a, b) => a.memberCount > b.memberCount ? a : b);
return { name: max.groupLabel, count: max.memberCount };
}, [groupPolygons.allGroups]);
const koreaFiltersResult = useKoreaFilters(
koreaData.ships,
@ -280,25 +258,7 @@ export const KoreaDashboard = ({
return (
<>
{headerSlot && createPortal(
<>
<div className="map-mode-toggle" style={{ display: 'flex', alignItems: 'center', gap: 2, marginRight: 8, position: 'relative' }}>
<button type="button"
className={`mode-btn${mapMode === 'satellite' ? ' active' : ''}`}
onClick={() => setMapMode('satellite')}
title="위성지도">
🛰
</button>
<button type="button"
className={`mode-btn${mapMode === 'enc' ? ' active' : ''}`}
onClick={() => setMapMode('enc')}
title="전자해도 (ENC)">
🗺 ENC
</button>
{mapMode === 'enc' && (
<EncMapSettingsPanel value={encSettings} onChange={setEncSettings} />
)}
</div>
<div className="mode-toggle">
<div className="mode-toggle">
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)} title={t('filters.illegalFishing')}>
<span className="text-[11px]">🚫🐟</span>{t('filters.illegalFishing')}
@ -331,12 +291,7 @@ export const KoreaDashboard = ({
onClick={() => setShowFieldAnalysis(v => !v)} title="현장분석">
<span className="text-[11px]">📊</span>
</button>
<button type="button" className={`mode-btn ${showOpsGuide ? 'active' : ''}`}
onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드">
<span className="text-[11px]"></span>
</button>
</div>
</>,
</div>,
headerSlot,
)}
{countsSlot && createPortal(
@ -355,18 +310,6 @@ export const KoreaDashboard = ({
ships={koreaData.ships}
vesselAnalysis={vesselAnalysis}
onClose={() => setShowFieldAnalysis(false)}
onShowReport={() => setShowReport(v => !v)}
/>
)}
{showReport && (
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} largestGearGroup={largestGearGroup} analysisMap={vesselAnalysis.analysisMap} />
)}
{showOpsGuide && (
<OpsGuideModal
ships={koreaData.ships}
onClose={() => { setShowOpsGuide(false); setOpsRoute(null); }}
onFlyTo={(lat, lng, zoom) => setExternalFlyTo({ lat, lng, zoom })}
onRouteSelect={setOpsRoute}
/>
)}
<KoreaMap
@ -384,14 +327,8 @@ export const KoreaDashboard = ({
cnFishingSuspects={koreaFiltersResult.cnFishingSuspects}
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
vesselAnalysis={vesselAnalysis}
groupPolygons={groupPolygons}
hiddenShipCategories={hiddenShipCategories}
hiddenNationalities={hiddenNationalities}
externalFlyTo={externalFlyTo}
onExternalFlyToDone={() => setExternalFlyTo(null)}
opsRoute={opsRoute}
mapMode={mapMode}
encSettings={encSettings}
/>
<div className="map-overlay-left">
<LayerPanel

파일 보기

@ -2,25 +2,13 @@ import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import type { StyleSpecification } from 'maplibre-gl';
import { fetchEncStyle } from '../../features/encMap/encStyle';
import { useEncMapSettings } from '../../features/encMap/useEncMapSettings';
import type { EncMapSettings } from '../../features/encMap/types';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { useFontScale } from '../../hooks/useFontScale';
import { FONT_MONO } from '../../styles/fonts';
import type { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer as DeckLayer } from '@deck.gl/core';
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers';
import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
import { useGearReplayLayers } from '../../hooks/useGearReplayLayers';
import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers';
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useShipDeckLayers } from '../../hooks/useShipDeckLayers';
import { useShipDeckStore } from '../../stores/shipDeckStore';
import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay';
import { ShipLayer } from '../layers/ShipLayer';
import { InfraLayer } from './InfraLayer';
import { SatelliteLayer } from '../layers/SatelliteLayer';
import { AircraftLayer } from '../layers/AircraftLayer';
@ -46,7 +34,6 @@ import type { PowerFacility } from '../../services/infra';
import type { Ship, Aircraft, SatellitePosition } from '../../types';
import type { OsintItem } from '../../services/osint';
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import 'maplibre-gl/dist/maplibre-gl.css';
@ -76,14 +63,8 @@ interface Props {
cnFishingSuspects: Set<string>;
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
vesselAnalysis?: UseVesselAnalysisResult;
groupPolygons?: UseGroupPolygonsResult;
hiddenShipCategories?: Set<string>;
hiddenNationalities?: Set<string>;
externalFlyTo?: { lat: number; lng: number; zoom: number } | null;
onExternalFlyToDone?: () => void;
opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null;
mapMode: 'satellite' | 'enc';
encSettings: EncMapSettings;
}
// MarineTraffic-style: satellite + dark ocean + nautical overlay
@ -125,60 +106,6 @@ const MAP_STYLE = {
],
};
// ═══ Sea routing — avoid Korean peninsula land mass ═══
const SEA_WAYPOINTS: [number, number][] = [
[124.5, 37.8], [124.0, 36.5], [124.5, 35.5], [125.0, 34.5],
[126.0, 33.5], [126.5, 33.2], [127.5, 33.0], [128.5, 33.5],
[129.0, 34.5], [129.5, 35.2], [129.8, 36.0], [130.0, 37.0],
[129.5, 37.8], [129.0, 38.5],
];
const LAND_BOXES = [
{ minLng: 125.5, maxLng: 129.5, minLat: 34.3, maxLat: 38.6 },
{ minLng: 126.1, maxLng: 126.9, minLat: 33.2, maxLat: 33.6 },
];
function segmentCrossesLand(lng1: number, lat1: number, lng2: number, lat2: number): boolean {
for (let i = 1; i < 10; i++) {
const t = i / 10;
const lng = lng1 + (lng2 - lng1) * t;
const lat = lat1 + (lat2 - lat1) * t;
for (const box of LAND_BOXES) {
if (lng >= box.minLng && lng <= box.maxLng && lat >= box.minLat && lat <= box.maxLat) return true;
}
}
return false;
}
function buildSeaRoute(from: { lat: number; lng: number }, to: { lat: number; lng: number }): [number, number][] {
if (!segmentCrossesLand(from.lng, from.lat, to.lng, to.lat)) {
return [[from.lng, from.lat], [to.lng, to.lat]];
}
const nearest = (lng: number, lat: number) => {
let best = 0, d = Infinity;
for (let i = 0; i < SEA_WAYPOINTS.length; i++) {
const dd = (SEA_WAYPOINTS[i][0] - lng) ** 2 + (SEA_WAYPOINTS[i][1] - lat) ** 2;
if (dd < d) { d = dd; best = i; }
}
return best;
};
const startWP = nearest(from.lng, from.lat);
const endWP = nearest(to.lng, to.lat);
const n = SEA_WAYPOINTS.length;
const cwPath: [number, number][] = [];
const ccwPath: [number, number][] = [];
for (let i = startWP; ; i = (i + 1) % n) {
cwPath.push(SEA_WAYPOINTS[i]);
if (i === endWP || cwPath.length > n) break;
}
for (let i = startWP; ; i = (i - 1 + n) % n) {
ccwPath.push(SEA_WAYPOINTS[i]);
if (i === endWP || ccwPath.length > n) break;
}
const waypoints = cwPath.length <= ccwPath.length ? cwPath : ccwPath;
return [[from.lng, from.lat], ...waypoints, [to.lng, to.lat]];
}
// Korea-centered view
const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 };
const KOREA_MAP_ZOOM = 6;
@ -213,54 +140,15 @@ const FILTER_I18N_KEY: Record<string, string> = {
cnFishing: 'filters.cnFishingMonitor',
};
// [DEBUG] 개발용 도구 — DEV에서만 동적 로드, 프로덕션 번들에서 완전 제거
import { lazy, Suspense } from 'react';
const DebugTools = import.meta.env.DEV
? lazy(() => import('./debug'))
: null;
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute, mapMode, encSettings }: Props) {
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, hiddenShipCategories, hiddenNationalities }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const maplibreRef = useRef<import('maplibre-gl').Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
// ENC 스타일 사전 로드
const [encStyle, setEncStyle] = useState<StyleSpecification | null>(null);
useEffect(() => {
const ctrl = new AbortController();
fetchEncStyle(ctrl.signal).then(setEncStyle).catch(() => {});
return () => ctrl.abort();
}, []);
const activeMapStyle = mapMode === 'enc' && encStyle ? encStyle : MAP_STYLE;
// ENC 설정 적용을 트리거하는 epoch — 맵 로드/스타일 전환 시 증가
const [encSyncEpoch, setEncSyncEpoch] = useState(0);
// ENC 설정 런타임 적용
useEncMapSettings(maplibreRef, mapMode, encSettings, encSyncEpoch);
const replayLayerRef = useRef<DeckLayer[]>([]);
const fleetClusterLayerRef = useRef<DeckLayer[]>([]);
const fleetMapClickHandlerRef = useRef<((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null>(null);
const fleetMapMoveHandlerRef = useRef<((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null>(null);
const requestRenderRef = useRef<(() => void) | null>(null);
const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => {
fleetClusterLayerRef.current = layers;
requestRenderRef.current?.();
}, []);
const registerFleetMapClickHandler = useCallback((handler: ((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null) => {
fleetMapClickHandlerRef.current = handler;
}, []);
const registerFleetMapMoveHandler = useCallback((handler: ((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null) => {
fleetMapMoveHandlerRef.current = handler;
}, []);
const [infra, setInfra] = useState<PowerFacility[]>([]);
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
const { fontScale } = useFontScale();
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
const zoomRef = useRef(KOREA_MAP_ZOOM);
const handleZoom = useCallback((e: { viewState: { zoom: number } }) => {
@ -268,66 +156,17 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
if (z !== zoomRef.current) {
zoomRef.current = z;
setZoomLevel(z);
useShipDeckStore.getState().setZoomLevel(z);
}
}, []);
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []);
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
const [activeBadgeFilter, setActiveBadgeFilter] = useState<string | null>(null);
const replayFocusMode = useGearReplayStore(s => s.focusMode);
// ── deck.gl 레이어 (Zustand → imperative setProps, React 렌더 우회) ──
const reactLayersRef = useRef<DeckLayer[]>([]);
const shipLayerRef = useRef<DeckLayer[]>([]);
type ShipPos = { lng: number; lat: number; course?: number };
const shipsRef = useRef(new globalThis.Map<string, ShipPos>());
// live 선박 위치를 ref에 동기화 (리플레이 fallback용)
const allShipsList = allShips ?? ships;
const shipPosMap = new globalThis.Map<string, ShipPos>();
for (const s of allShipsList) shipPosMap.set(s.mmsi, { lng: s.lng, lat: s.lat, course: s.course });
shipsRef.current = shipPosMap;
const requestRender = useCallback(() => {
if (!overlayRef.current) return;
const focus = useGearReplayStore.getState().focusMode;
overlayRef.current.setProps({
layers: focus
? [...replayLayerRef.current]
: [...reactLayersRef.current, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current],
});
}, []);
requestRenderRef.current = requestRender;
useShipDeckLayers(shipLayerRef, requestRender);
useGearReplayLayers(replayLayerRef, requestRender, shipsRef);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
}, []);
// MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제)
const handleMapLoad = useCallback(() => {
maplibreRef.current = mapRef.current?.getMap() ?? null;
setEncSyncEpoch(v => v + 1);
}, []);
// ── shipDeckStore 동기화 ──
useEffect(() => {
useShipDeckStore.getState().setShips(allShipsList);
}, [allShipsList]);
useEffect(() => {
useShipDeckStore.getState().setFilters({
militaryOnly: layers.militaryOnly,
layerVisible: layers.ships,
hiddenShipCategories,
hiddenNationalities,
});
}, [layers.militaryOnly, layers.ships, hiddenShipCategories, hiddenNationalities]);
// Korea 탭에서는 한국선박 강조 OFF (Iran 탭 전용 기능)
// highlightKorean 기본값 false 유지
useEffect(() => {
if (flyToTarget && mapRef.current) {
mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom, duration: 1500 });
@ -335,13 +174,6 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
}
}, [flyToTarget]);
useEffect(() => {
if (externalFlyTo && mapRef.current) {
mapRef.current.flyTo({ center: [externalFlyTo.lng, externalFlyTo.lat], zoom: externalFlyTo.zoom, duration: 1500 });
onExternalFlyToDone?.();
}
}, [externalFlyTo, onExternalFlyToDone]);
useEffect(() => {
if (!selectedAnalysisMmsi) setTrackCoords(null);
}, [selectedAnalysisMmsi]);
@ -359,10 +191,12 @@ 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, maxZoom: 10 },
{ padding: 60, duration: 1500 },
);
}, []);
const anyKoreaFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch || koreaFilters.cnFishing;
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
const zoomScale = useMemo(() => {
if (zoomLevel <= 4) return 0.8;
@ -408,19 +242,19 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data: illegalFishingData,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name || d.mmsi,
getSize: 11 * zoomScale * fontScale.analysis,
getSize: 11 * zoomScale,
getColor: [239, 68, 68, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 14],
fontFamily: FONT_MONO,
fontFamily: 'monospace',
fontSettings: { sdf: true },
outlineWidth: 3,
outlineWidth: 8,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
updateTriggers: { getSize: [zoomScale, fontScale.analysis] },
}), [illegalFishingData, zoomScale, fontScale.analysis]);
updateTriggers: { getSize: [zoomScale] },
}), [illegalFishingData, zoomScale]);
// 수역 라벨 TextLayer — illegalFishing 또는 cnFishing 필터 활성 시 표시
const zoneLabelsLayer = useMemo(() => {
@ -447,20 +281,20 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data,
getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat],
getText: (d: { name: string }) => d.name,
getSize: 14 * zoomScale * fontScale.area,
getSize: 14 * zoomScale,
getColor: [255, 255, 255, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: FONT_MONO,
fontFamily: 'monospace',
fontWeight: 700,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineWidth: 8,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
updateTriggers: { getSize: [zoomScale, fontScale.area] },
updateTriggers: { getSize: [zoomScale] },
});
}, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale, fontScale.area]);
}, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale]);
// 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등
const staticDeckLayers = useStaticDeckLayers({
@ -497,7 +331,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
// 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl)
const selectedGearLayers = useMemo(() => {
if (!selectedGearData || replayFocusMode) return [];
if (!selectedGearData) return [];
const { parent, gears, groupName } = selectedGearData;
const layers = [];
@ -523,14 +357,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data: gears,
getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => d.name || d.mmsi,
getSize: 10 * zoomScale * fontScale.analysis,
getSize: 10 * zoomScale,
getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontFamily: 'monospace',
fontSettings: { sdf: true },
outlineWidth: 3,
outlineWidth: 8,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
@ -558,15 +392,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data: [parent],
getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => `${d.name || groupName} (모선)`,
getSize: 11 * zoomScale * fontScale.analysis,
getSize: 11 * zoomScale,
getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 18],
fontFamily: FONT_MONO,
fontFamily: 'monospace',
fontWeight: 700,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineWidth: 8,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
@ -575,11 +409,11 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
}
return layers;
}, [selectedGearData, zoomScale, fontScale.analysis, replayFocusMode]);
}, [selectedGearData, zoomScale]);
// 선택된 선단 소속 선박 강조 레이어 (deck.gl)
const selectedFleetLayers = useMemo(() => {
if (!selectedFleetData || replayFocusMode) return [];
if (!selectedFleetData) return [];
const { ships: fleetShips, clusterId } = selectedFleetData;
if (fleetShips.length === 0) return [];
@ -594,7 +428,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const color: [number, number, number, number] = [r, g, b, 255];
const fillColor: [number, number, number, number] = [r, g, b, 80];
const result: DeckLayer[] = [];
const result: Layer[] = [];
// 소속 선박 — 강조 원형
result.push(new ScatterplotLayer({
@ -623,15 +457,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const prefix = role === 'LEADER' ? '★ ' : '';
return `${prefix}${d.name || d.mmsi}`;
},
getSize: 10 * zoomScale * fontScale.analysis,
getSize: 10 * zoomScale,
getColor: color,
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 12],
fontFamily: FONT_MONO,
fontFamily: 'monospace',
fontWeight: 600,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineWidth: 8,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
@ -661,7 +495,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
}
return result;
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis, replayFocusMode]);
}, [selectedFleetData, zoomScale, vesselAnalysis]);
// 분석 결과 deck.gl 레이어
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
@ -669,16 +503,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
: koreaFilters.cnFishing ? 'cnFishing'
: null;
// shipDeckStore에 분석 상태 동기화
useEffect(() => {
useShipDeckStore.getState().setAnalysis(
vesselAnalysis?.analysisMap ?? null,
analysisActiveFilter,
);
}, [vesselAnalysis?.analysisMap, analysisActiveFilter]);
const analysisDeckLayers = useAnalysisDeckLayers(
vesselAnalysis?.analysisMap ?? (new globalThis.Map() as globalThis.Map<string, import('../../types').VesselAnalysisDto>),
vesselAnalysis?.analysisMap ?? new Map(),
allShips ?? ships,
analysisActiveFilter,
zoomScale,
@ -689,33 +515,11 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
ref={mapRef}
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
style={{ width: '100%', height: '100%' }}
mapStyle={activeMapStyle}
mapStyle={MAP_STYLE}
onZoom={handleZoom}
onLoad={handleMapLoad}
onClick={event => {
const handler = fleetMapClickHandlerRef.current;
if (handler) {
handler({
coordinate: [event.lngLat.lng, event.lngLat.lat],
screen: [event.point.x, event.point.y],
});
}
}}
onMouseMove={event => {
const handler = fleetMapMoveHandlerRef.current;
if (handler) {
handler({
coordinate: [event.lngLat.lng, event.lngLat.lat],
screen: [event.point.x, event.point.y],
});
}
}}
>
<NavigationControl position="top-right" />
{/* [DEBUG] 개발용 도구 — 프로덕션 번들에서 완전 제거 */}
{DebugTools && <Suspense><DebugTools mapRef={mapRef} /></Suspense>}
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer
id="country-label-lg"
@ -723,7 +527,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 15 * fontScale.area,
'text-size': 15,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -742,7 +546,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-size': 12 * fontScale.area,
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -762,7 +566,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
minzoom={5}
layout={{
'text-field': ['get', 'name'],
'text-size': 10 * fontScale.area,
'text-size': 10,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false,
'text-ignore-placement': false,
@ -777,7 +581,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
/>
</Source>
{/* ShipLayer → deck.gl (useShipDeckLayers) 전환 완료 */}
{layers.ships && <ShipLayer
ships={anyKoreaFilterOn ? ships : (allShips ?? ships)}
militaryOnly={layers.militaryOnly}
analysisMap={vesselAnalysis?.analysisMap}
hiddenShipCategories={hiddenShipCategories}
hiddenNationalities={hiddenNationalities}
/>}
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
@ -848,19 +658,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
ships={allShips ?? ships}
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
groupPolygons={groupPolygons}
zoomScale={zoomScale}
onDeckLayersChange={handleFleetDeckLayers}
registerMapClickHandler={registerFleetMapClickHandler}
registerMapMoveHandler={registerFleetMapMoveHandler}
onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom}
onSelectedGearChange={setSelectedGearData}
onSelectedFleetChange={setSelectedFleetData}
autoOpenReviewPanel={koreaFilters.cnFishing}
/>
)}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && (
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
<AnalysisOverlay
ships={allShips ?? ships}
analysisMap={vesselAnalysis.analysisMap}
@ -869,29 +673,16 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
/>
)}
{/* analysisShipMarkers → deck.gl (useShipDeckLayers) 전환 완료 */}
{/* 선박 호버 툴팁 + 클릭 팝업 — React 오버레이 (MapLibre Popup 대체) */}
<ShipHoverTooltip />
<ShipPopupOverlay />
{/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */}
<DeckGLOverlay
overlayRef={overlayRef}
layers={(() => {
const base = replayFocusMode ? [] : [
...staticDeckLayers,
illegalFishingLayer,
illegalFishingLabelLayer,
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean) as DeckLayer[];
reactLayersRef.current = base;
return [...base, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current];
})()}
/>
{/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */}
<DeckGLOverlay layers={[
...staticDeckLayers,
illegalFishingLayer,
illegalFishingLabelLayer,
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean)} />
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
{staticPickInfo && (
<StaticFacilityPopup pickInfo={staticPickInfo} onClose={() => setStaticPickInfo(null)} />
@ -903,6 +694,7 @@ 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) {
@ -912,13 +704,7 @@ 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': {
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];
}
case 'cnFishing': return all.filter(s => gearPattern.test(s.name || ''));
default: return [];
}
};
@ -1074,45 +860,12 @@ 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}
/>
)}
{/* 작전가이드 임검침로 점선 — 해상 루트 (육지 우회) */}
{opsRoute && (() => {
const riskColor = opsRoute.riskLevel === 'CRITICAL' ? '#ef4444' : opsRoute.riskLevel === 'HIGH' ? '#f59e0b' : '#3b82f6';
const coords = buildSeaRoute(opsRoute.from, opsRoute.to);
const routeGeoJson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }],
};
const midIdx = Math.floor(coords.length / 2);
return (
<>
<Source id="ops-route-line" type="geojson" data={routeGeoJson}>
<Layer id="ops-route-dash" type="line" paint={{
'line-color': riskColor, 'line-width': 2.5,
'line-dasharray': [4, 4], 'line-opacity': 0.8,
}} />
</Source>
<Marker longitude={opsRoute.from.lng} latitude={opsRoute.from.lat} anchor="center">
<div style={{ fontSize: 18, filter: 'drop-shadow(0 0 4px rgba(0,0,0,0.8))' }}></div>
</Marker>
<Marker longitude={opsRoute.to.lng} latitude={opsRoute.to.lat} anchor="center">
<div style={{ width: 12, height: 12, borderRadius: '50%', background: riskColor, border: '2px solid #fff', boxShadow: `0 0 8px ${riskColor}` }} />
</Marker>
<Marker longitude={coords[midIdx][0]} latitude={coords[midIdx][1]} anchor="bottom">
<div style={{ background: 'rgba(0,0,0,0.8)', padding: '2px 6px', borderRadius: 3, border: `1px solid ${riskColor}`, fontSize: 9, color: '#fff', fontWeight: 700, whiteSpace: 'nowrap', textAlign: 'center' }}>
{opsRoute.distanceNM.toFixed(1)} NM
<div style={{ fontSize: 7, color: riskColor }}>{opsRoute.from.name} {opsRoute.to.name}</div>
</div>
</Marker>
</>
);
})()}
{/* ENC 설정 패널은 KoreaDashboard 헤더에서 렌더 */}
</Map>
);
}

파일 보기

@ -1,410 +0,0 @@
import { useState, useMemo, useRef, useCallback } from 'react';
import type { Ship } from '../../types';
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../../services/coastGuard';
import type { CoastGuardFacility } from '../../services/coastGuard';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
export interface OpsRoute {
from: { lat: number; lng: number; name: string };
to: { lat: number; lng: number; name: string; mmsi: string };
distanceNM: number;
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM';
}
interface Props {
ships: Ship[];
onClose: () => void;
onFlyTo?: (lat: number, lng: number, zoom: number) => void;
onRouteSelect?: (route: OpsRoute | null) => void;
}
interface SuspectVessel {
ship: Ship;
distance: number;
reasons: string[];
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM';
estimatedType: 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN';
}
function haversineNM(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 3440.065;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
const RISK_COLOR = { CRITICAL: '#ef4444', HIGH: '#f59e0b', MEDIUM: '#3b82f6' };
const RISK_ICON = { CRITICAL: '🔴', HIGH: '🟡', MEDIUM: '🔵' };
type Tab = 'detect' | 'procedure' | 'alert';
// ── 중국어 경고문 ──
const CN_WARNINGS: Record<string, { zh: string; ko: string; usage: string }[]> = {
PT: [
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF Ch.16 + 확성기' },
{ zh: '请出示捕捞许可证', ko: '어업허가증을 제시하시오', usage: '승선 검사 시' },
{ zh: '请出示作业日志', ko: '조업일지를 제시하시오', usage: '어획량 확인' },
{ zh: '你的网目不符合规定', ko: '망목이 규정에 미달합니다', usage: '어구 검사 (54mm 미만)' },
],
GN: [
{ zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀 대응' },
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
{ zh: '你在非许可区域作业', ko: '비허가 구역에서 조업 중입니다', usage: '수역 이탈 시' },
{ zh: '请立即收回渔网', ko: '어망을 즉시 회수하시오', usage: '불법 자망 발견' },
],
PS: [
{ zh: '所有船只立即停止作业', ko: '모든 선박 즉시 조업 중단', usage: '선단 제압 시' },
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
{ zh: '关闭集鱼灯', ko: '집어등을 끄시오', usage: '조명선 대응' },
{ zh: '不要试图逃跑', ko: '도주를 시도하지 마시오', usage: '도주 시' },
],
FC: [
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
{ zh: '请出示货物清单', ko: '화물 목록을 제시하시오', usage: '환적 검사' },
{ zh: '禁止转运渔获物', ko: '어획물 환적을 금지합니다', usage: '환적 현장' },
],
GEAR: [
{ zh: '这些渔具属于非法设置', ko: '이 어구는 불법 설치되었습니다', usage: '어구 수거 시' },
],
UNKNOWN: [
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: '기본 경고' },
{ zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀' },
],
};
function estimateVesselType(ship: Ship): 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN' {
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
const isGear = /[_]\d+[_]|%$/.test(ship.name);
if (isGear) return 'GEAR';
if (cat === 'cargo' || (cat === 'unspecified' && (ship.length || 0) > 50)) return 'FC';
if (cat !== 'fishing' && ship.category !== 'fishing' && ship.typecode !== '30') return 'UNKNOWN';
const spd = ship.speed || 0;
if (spd >= 7) return 'PS';
if (spd < 1.5) return 'GN';
return 'PT';
}
export function OpsGuideModal({ ships, onClose, onFlyTo, onRouteSelect }: Props) {
const [selectedKCG, setSelectedKCG] = useState<CoastGuardFacility | null>(null);
const [searchRadius, setSearchRadius] = useState(30);
const [pos, setPos] = useState({ x: 60, y: 60 });
const [tab, setTab] = useState<Tab>('detect');
const [selectedSuspect, setSelectedSuspect] = useState<SuspectVessel | null>(null);
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const onDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
const onMove = (ev: MouseEvent) => {
if (!dragRef.current) return;
setPos({ x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: dragRef.current.origY + (ev.clientY - dragRef.current.startY) });
};
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [pos]);
const kcgBases = useMemo(() =>
COAST_GUARD_FACILITIES.filter(f => ['hq', 'regional', 'station', 'navy'].includes(f.type)).sort((a, b) => a.name.localeCompare(b.name)),
[]);
const suspects = useMemo<SuspectVessel[]>(() => {
if (!selectedKCG) return [];
const results: SuspectVessel[] = [];
for (const ship of ships) {
if (ship.flag !== 'CN') continue;
const dist = haversineNM(selectedKCG.lat, selectedKCG.lng, ship.lat, ship.lng);
if (dist > searchRadius) continue;
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
const isFishing = cat === 'fishing' || ship.category === 'fishing' || ship.typecode === '30';
const isGear = /[_]\d+[_]|%$/.test(ship.name);
const zone = classifyFishingZone(ship.lat, ship.lng);
const reasons: string[] = [];
let riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' = 'MEDIUM';
if (isFishing && zone.zone === 'OUTSIDE') { reasons.push('비허가 수역 진입'); riskLevel = 'CRITICAL'; }
if (isFishing && zone.zone === 'ZONE_I' && ship.speed >= 2 && ship.speed <= 5) { reasons.push('수역I 저인망 의심'); riskLevel = 'HIGH'; }
if (isFishing && ship.speed === 0 && (!ship.heading || ship.heading === 0)) { reasons.push('다크베셀 의심'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; }
if (isFishing && ship.speed >= 2 && ship.speed <= 6) reasons.push(`조업 추정 (${ship.speed.toFixed(1)}kn)`);
if (isGear) { reasons.push('어구/어망 AIS'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; }
if (!isFishing && (cat === 'cargo' || cat === 'unspecified') && ship.speed < 3) reasons.push('운반선/환적 의심');
if (reasons.length > 0) results.push({ ship, distance: dist, reasons, riskLevel, estimatedType: estimateVesselType(ship) });
}
return results.sort((a, b) => ({ CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[a.riskLevel] - { CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[b.riskLevel]) || a.distance - b.distance);
}, [selectedKCG, ships, searchRadius]);
const criticalCount = suspects.filter(s => s.riskLevel === 'CRITICAL').length;
const highCount = suspects.filter(s => s.riskLevel === 'HIGH').length;
const [speakingIdx, setSpeakingIdx] = useState<number | null>(null);
const copyToClipboard = (text: string, idx: number) => {
navigator.clipboard.writeText(text).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1500); });
};
const audioRef = useRef<HTMLAudioElement | null>(null);
const speakChinese = useCallback((text: string, idx: number) => {
// Stop previous
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
setSpeakingIdx(idx);
const encoded = encodeURIComponent(text);
const url = `/api/gtts?ie=UTF-8&client=tw-ob&tl=zh-CN&q=${encoded}`;
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => setSpeakingIdx(null);
audio.onerror = () => setSpeakingIdx(null);
audio.play().catch(() => setSpeakingIdx(null));
}, []);
const handleSuspectClick = (s: SuspectVessel) => {
setSelectedSuspect(s);
setTab('procedure');
onFlyTo?.(s.ship.lat, s.ship.lng, 10);
if (selectedKCG) {
onRouteSelect?.({ from: { lat: selectedKCG.lat, lng: selectedKCG.lng, name: selectedKCG.name }, to: { lat: s.ship.lat, lng: s.ship.lng, name: s.ship.name || s.ship.mmsi, mmsi: s.ship.mmsi }, distanceNM: s.distance, riskLevel: s.riskLevel });
}
};
const TYPE_LABEL: Record<string, string> = { PT: '저인망(PT)', GN: '유자망(GN)', PS: '위망(PS)', FC: '운반선(FC)', GEAR: '어구/어망', UNKNOWN: '미분류' };
return (
<div style={{ position: 'fixed', left: pos.x, top: pos.y, zIndex: 9999, width: 760, maxHeight: '85vh', overflow: 'hidden', background: '#0a0f1a', borderRadius: 8, border: '1px solid #1e293b', display: 'flex', flexDirection: 'column', boxShadow: '0 8px 32px rgba(0,0,0,0.6)', resize: 'both' }}>
{/* Header */}
<div onMouseDown={onDragStart} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderBottom: '1px solid #1e293b', background: 'rgba(30,58,95,0.5)', cursor: 'grab', userSelect: 'none', flexShrink: 0 }}>
<span style={{ fontSize: 10, color: '#475569', letterSpacing: 2 }}></span>
<span style={{ fontSize: 14 }}></span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}> </span>
<button onClick={onClose} style={{ marginLeft: 'auto', background: 'rgba(255,82,82,0.1)', border: '1px solid rgba(255,82,82,0.4)', color: '#ef4444', padding: '4px 12px', cursor: 'pointer', fontSize: 10, borderRadius: 2 }}></button>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 2, padding: '4px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0 }}>
{([['detect', '🔍 실시간 탐지'], ['procedure', '📋 대응 절차'], ['alert', '🚨 조치 기준']] as [Tab, string][]).map(([k, l]) => (
<button key={k} onClick={() => setTab(k)} style={{
padding: '3px 10px', fontSize: 10, fontWeight: 700, borderRadius: 3, cursor: 'pointer',
background: tab === k ? 'rgba(59,130,246,0.2)' : 'transparent',
border: tab === k ? '1px solid rgba(59,130,246,0.4)' : '1px solid transparent',
color: tab === k ? '#60a5fa' : '#64748b',
}}>{l}</button>
))}
</div>
{/* Controls (detect tab) */}
{tab === 'detect' && (
<div style={{ display: 'flex', gap: 8, padding: '6px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0, alignItems: 'center', flexWrap: 'wrap' }}>
<select value={selectedKCG?.id ?? ''} onChange={e => { const f = kcgBases.find(b => b.id === Number(e.target.value)); setSelectedKCG(f || null); }} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0', minWidth: 160 }}>
<option value=""> </option>
{kcgBases.map(b => <option key={b.id} value={b.id}>[{CG_TYPE_LABEL[b.type]}] {b.name}</option>)}
</select>
<select value={searchRadius} onChange={e => setSearchRadius(Number(e.target.value))} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0' }}>
{[10, 20, 30, 50, 100].map(n => <option key={n} value={n}>{n} NM</option>)}
</select>
{selectedKCG && <div style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9 }}>
<span style={{ color: '#ef4444', fontWeight: 700 }}>🔴 {criticalCount}</span>
<span style={{ color: '#f59e0b', fontWeight: 700 }}>🟡 {highCount}</span>
<span style={{ color: '#3b82f6', fontWeight: 700 }}>🔵 {suspects.length}</span>
</div>}
</div>
)}
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '8px 16px', minHeight: 200 }}>
{/* ── TAB: 실시간 탐지 ── */}
{tab === 'detect' && (<>
{!selectedKCG ? (
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}> · </div>
) : suspects.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px 0', color: '#22c55e', fontSize: 11 }}> {selectedKCG.name} {searchRadius}NM </div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{suspects.map((s, i) => (
<div key={s.ship.mmsi} onClick={() => handleSuspectClick(s)} style={{
background: '#111827', borderRadius: 4, padding: '6px 10px', borderLeft: `3px solid ${RISK_COLOR[s.riskLevel]}`, cursor: 'pointer',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
<span style={{ color: '#475569', minWidth: 18 }}>#{i + 1}</span>
<span>{RISK_ICON[s.riskLevel]}</span>
<span style={{ fontSize: 8, fontWeight: 700, padding: '0 4px', borderRadius: 2, background: RISK_COLOR[s.riskLevel] + '20', color: RISK_COLOR[s.riskLevel] }}>{s.riskLevel}</span>
<span style={{ fontWeight: 700, color: '#e2e8f0' }}>{s.ship.name || s.ship.mmsi}</span>
<span style={{ fontSize: 8, color: '#64748b' }}>[{TYPE_LABEL[s.estimatedType]}]</span>
<span style={{ marginLeft: 'auto', color: '#60a5fa', fontWeight: 700 }}>{s.distance.toFixed(1)} NM</span>
</div>
<div style={{ display: 'flex', gap: 3, marginTop: 2, flexWrap: 'wrap' }}>
{s.reasons.map((r, j) => <span key={j} style={{ fontSize: 7, padding: '0 4px', borderRadius: 2, background: 'rgba(255,255,255,0.05)', color: '#94a3b8' }}>{r}</span>)}
</div>
</div>
))}
</div>
)}
</>)}
{/* ── TAB: 대응 절차 ── */}
{tab === 'procedure' && (<>
{selectedSuspect ? (
<div style={{ fontSize: 10, color: '#e2e8f0', lineHeight: 1.7 }}>
{/* 선박 정보 */}
<div style={{ background: '#111827', borderRadius: 6, padding: '8px 12px', border: `1px solid ${RISK_COLOR[selectedSuspect.riskLevel]}30`, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{RISK_ICON[selectedSuspect.riskLevel]}</span>
<span style={{ fontWeight: 700, fontSize: 12 }}>{selectedSuspect.ship.name || selectedSuspect.ship.mmsi}</span>
<span style={{ fontSize: 9, padding: '1px 6px', borderRadius: 3, background: RISK_COLOR[selectedSuspect.riskLevel] + '20', color: RISK_COLOR[selectedSuspect.riskLevel], fontWeight: 700 }}>{selectedSuspect.riskLevel}</span>
<span style={{ fontSize: 9, color: '#64748b' }}>: {TYPE_LABEL[selectedSuspect.estimatedType]}</span>
</div>
<div style={{ fontSize: 9, color: '#64748b', marginTop: 2 }}>MMSI: {selectedSuspect.ship.mmsi} | SOG: {selectedSuspect.ship.speed?.toFixed(1)}kn | {selectedSuspect.distance.toFixed(1)} NM</div>
</div>
{/* 업종별 대응 절차 */}
<ProcedureSteps type={selectedSuspect.estimatedType} />
{/* 중국어 경고문 */}
<div style={{ marginTop: 12, borderTop: '1px solid #1e293b', paddingTop: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#f59e0b', marginBottom: 6 }}>📢 (클릭: 복사 | 🔊: )</div>
{(CN_WARNINGS[selectedSuspect.estimatedType] || CN_WARNINGS.UNKNOWN).map((w, i) => (
<div key={i} style={{
background: copiedIdx === i ? 'rgba(34,197,94,0.15)' : speakingIdx === i ? 'rgba(251,191,36,0.1)' : '#111827',
border: copiedIdx === i ? '1px solid rgba(34,197,94,0.4)' : speakingIdx === i ? '1px solid rgba(251,191,36,0.4)' : '1px solid #1e293b',
borderRadius: 4, padding: '6px 10px', marginBottom: 4, transition: 'all 0.2s',
display: 'flex', alignItems: 'flex-start', gap: 8,
}}>
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => copyToClipboard(w.zh, i)}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fbbf24' }}>{w.zh}</div>
<div style={{ fontSize: 9, color: '#94a3b8' }}>{w.ko}</div>
<div style={{ fontSize: 8, color: '#475569' }}>
: {w.usage}
{copiedIdx === i && <span style={{ color: '#22c55e', fontWeight: 700, marginLeft: 4 }}> </span>}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); speakChinese(w.zh, i); }}
style={{
background: speakingIdx === i ? 'rgba(251,191,36,0.3)' : 'rgba(251,191,36,0.1)',
border: '1px solid rgba(251,191,36,0.3)',
borderRadius: 4, padding: '4px 8px', cursor: 'pointer',
fontSize: 14, lineHeight: 1, flexShrink: 0,
animation: speakingIdx === i ? 'pulse 1s ease-in-out infinite' : 'none',
}}
title="중국어 음성 재생"
>
{speakingIdx === i ? '🔊' : '🔈'}
</button>
</div>
))}
</div>
</div>
) : (
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}>
<br/>
</div>
)}
</>)}
{/* ── TAB: 조치 기준 ── */}
{tab === 'alert' && (<AlertTable />)}
</div>
{/* Footer */}
<div style={{ padding: '4px 16px', borderTop: '1px solid #1e293b', fontSize: 8, color: '#475569', flexShrink: 0 }}>
GC-KCG-2026-001 | 906 | 수역: Point-in-Polygon |
</div>
</div>
);
}
// ── 업종별 대응 절차 컴포넌트 ──
const step: React.CSSProperties = { background: '#1e293b', borderRadius: 4, padding: '6px 10px', margin: '4px 0' };
const stepN: React.CSSProperties = { display: 'inline-block', background: '#3b82f6', color: '#fff', borderRadius: 3, padding: '0 5px', fontSize: 8, fontWeight: 700, marginRight: 4 };
const warn: React.CSSProperties = { background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, padding: '4px 8px', margin: '4px 0', fontSize: 9, color: '#fca5a5' };
function ProcedureSteps({ type }: { type: string }) {
switch (type) {
case 'PT': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🔴 2 (PT) </div>
<div style={warn}> () </div>
<div style={step}><span style={stepN}>1</span><b>/</b> AIS MMSI DB . · , </div>
<div style={step}><span style={stepN}>2</span><b>/</b> 45° . VHF Ch.16 3. </div>
<div style={step}><span style={stepN}>3</span><b> </b> (C21-xxxxx) ( 100/) (54mm)</div>
<div style={step}><span style={stepN}>4</span><b> </b> (4/16~10/15) | | </div>
<div style={step}><span style={stepN}>5</span><b>/</b> 위반: 목포··· . 경미: 경고 . </div>
</>);
case 'GN': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟡 (GN) </div>
<div style={warn}> ( )</div>
<div style={step}><span style={stepN}>1</span><b> </b> + SAR . 1NM </div>
<div style={step}><span style={stepN}>2</span><b> </b> 90° </div>
<div style={step}><span style={stepN}>3</span><b>AIS </b> "请打开AIS" . MMSI . </div>
<div style={step}><span style={stepN}>4</span><b> </b> (C25-xxxxx) (I발견) (28/) ·</div>
<div style={step}><span style={stepN}>5</span><b> </b> /. . GPS· </div>
</>);
case 'PS': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟣 (PS) </div>
<div style={warn}> , . </div>
<div style={step}><span style={stepN}>1</span><b> /</b> + . 3+ . </div>
<div style={step}><span style={stepN}>2</span><b> </b> EO/. MMSI . </div>
<div style={step}><span style={stepN}>3</span><b> </b> ·· . () </div>
<div style={step}><span style={stepN}>4</span><b> </b> 모선: C23-xxxxx, 1,500/. /조명선: 0톤 </div>
<div style={step}><span style={stepN}>5</span><b>/</b> · . VHF . · </div>
</>);
case 'FC': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟠 (FC) </div>
<div style={step}><span style={stepN}>1</span><b> </b> FC+ 0.5NM + 2kn + 30 HIGH. </div>
<div style={step}><span style={stepN}>2</span><b> </b> / . . MMSI·· </div>
<div style={step}><span style={stepN}>3</span><b> </b> 운반선: 화물··. 조업선: 허가량 </div>
<div style={step}><span style={stepN}>4</span><b>/</b> · . . . </div>
</>);
case 'GEAR': return (<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🪤 </div>
<div style={warn}> / . </div>
<div style={step}><span style={stepN}>1</span><b>/</b> GPS(WGS84), , , , (··)</div>
<div style={step}><span style={stepN}>2</span><b> </b> , · . . </div>
<div style={step}><span style={stepN}>3</span><b> </b> RIB/. . · </div>
<div style={step}><span style={stepN}>4</span><b> </b> . · . </div>
</>);
default: return (<div style={{ color: '#64748b', fontSize: 10 }}> . .</div>);
}
}
function AlertTable() {
const rows = [
{ type: '미등록 선박', criteria: 'MMSI 허가DB 미등록', action: '즉시 정선·나포', level: 'CRITICAL', note: '허가증 불소지 추가 확인' },
{ type: '휴어기 조업', criteria: 'C21·C22: 4/16~10/15\nC25: 6/2~8/31', action: '즉시 나포', level: 'CRITICAL', note: '날짜 자동 판별' },
{ type: '허가 수역 이탈', criteria: '비허가 수역 진입', action: '경고 후 나포', level: 'HIGH', note: 'PT: I·IV이탈 GN: I이탈' },
{ type: 'PT 부속선 분리', criteria: '본선 이격 3NM+', action: '양선 동시 나포', level: 'HIGH→CRIT', note: '311쌍 실시간 모니터링' },
{ type: '환적 현장 포착', criteria: 'FC+조업선 0.5NM+2kn+30분', action: '촬영 후 양선 나포', level: 'HIGH', note: '증거 촬영 최우선' },
{ type: '불법 어구 발견', criteria: '표지 없음/미허가', action: '즉시 수거·기록', level: '자체판단', note: 'GPS 등록, 반복 요주의' },
{ type: '할당량 초과', criteria: '80~100%+ 초과', action: '계량·초과 시 압수', level: 'CRITICAL', note: 'GN 28톤 현장 계량' },
{ type: '다크베셀', criteria: 'AIS 공백 6시간+', action: '접근·임검', level: 'HIGH', note: 'SAR 교차 확인' },
];
const lc = (l: string) => l.includes('CRIT') ? '#ef4444' : l === 'HIGH' ? '#f59e0b' : '#64748b';
return (
<div style={{ fontSize: 10, color: '#e2e8f0' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#60a5fa', marginBottom: 6 }}>🚨 </div>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9 }}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={th}> </th><th style={th}> </th><th style={th}> </th><th style={th}></th><th style={th}></th>
</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}><td style={td}>{r.type}</td><td style={{ ...td, whiteSpace: 'pre-line' }}>{r.criteria}</td><td style={td}>{r.action}</td>
<td style={{ ...td, color: lc(r.level), fontWeight: 700 }}>{r.level}</td><td style={{ ...td, color: '#64748b', fontSize: 8 }}>{r.note}</td></tr>
))}
</tbody>
</table>
<div style={{ marginTop: 12, fontSize: 11, fontWeight: 700, color: '#60a5fa' }}>📅 </div>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9, marginTop: 4 }}>
<thead><tr style={{ background: '#1e293b' }}><th style={th}></th><th style={th}></th><th style={th}></th></tr></thead>
<tbody>
<tr><td style={{ ...td, fontWeight: 700 }}>7~8</td><td style={td}>PS 16 </td><td style={{ ...td, color: '#ef4444' }}>C21·C22·C25 </td></tr>
<tr><td style={{ ...td, fontWeight: 700 }}>5</td><td style={td}>GN만 </td><td style={td}>(C21·C22) </td></tr>
<tr><td style={{ ...td, fontWeight: 700 }}>4·10</td><td style={td}> </td><td style={td}>4/16, 10/16 </td></tr>
<tr><td style={{ ...td, fontWeight: 700 }}>1~3</td><td style={td}> </td><td style={td}>· </td></tr>
</tbody>
</table>
</div>
);
}
const th: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 6px', textAlign: 'left', color: '#e2e8f0', fontSize: 8, fontWeight: 700 };
const td: React.CSSProperties = { border: '1px solid #1e293b', padding: '2px 6px', fontSize: 9 };

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

파일 보기

@ -1,335 +0,0 @@
import { useMemo, useRef } from 'react';
import type { Ship, VesselAnalysisDto } from '../../types';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { aggregateFishingStats, GEAR_LABELS, ZONE_ALLOWED } from '../../utils/fishingAnalysis';
import type { FishingGearType } from '../../utils/fishingAnalysis';
import { ALERT_COLOR } from '../../constants/riskMapping';
interface Props {
ships: Ship[];
onClose: () => void;
largestGearGroup?: { name: string; count: number };
analysisMap?: Map<string, VesselAnalysisDto>;
}
const ALL_GEAR_TYPES = ['PT', 'OT', 'GN', 'PS', 'FC'];
const ZONE_LABELS: Record<string, string> = {
ZONE_I: '수역 I (동해)',
ZONE_II: '수역 II (남해)',
ZONE_III: '수역 III (서남해)',
ZONE_IV: '수역 IV (서해)',
};
const ZONE_EXTRA_NOTES: Record<string, string> = {
ZONE_III: '이어도 해역',
};
function zoneAllowedText(zone: string): string {
const allowed = ZONE_ALLOWED[zone];
if (!allowed || allowed.length === 0) return '-';
if (allowed.length >= ALL_GEAR_TYPES.length) return '전 업종';
return allowed.join(', ') + (allowed.length <= 2 ? '만' : '');
}
function zoneViolationText(zone: string): string {
const allowed = ZONE_ALLOWED[zone];
if (!allowed) return '-';
const violations = ALL_GEAR_TYPES.filter(t => !allowed.includes(t));
if (violations.length === 0) return ZONE_EXTRA_NOTES[zone] || '-';
const extra = ZONE_EXTRA_NOTES[zone] ? ` (${ZONE_EXTRA_NOTES[zone]})` : '';
return `${violations.join('/')} 발견 시 위반${extra}`;
}
function now() {
const d = new Date();
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
export function ReportModal({ ships, onClose, largestGearGroup, analysisMap }: Props) {
const reportRef = useRef<HTMLDivElement>(null);
const timestamp = useMemo(() => now(), []);
// Ship statistics
const stats = useMemo(() => {
const kr = ships.filter(s => s.flag === 'KR');
const cn = ships.filter(s => s.flag === 'CN');
const cnFishing = cn.filter(s => {
const cat = getMarineTrafficCategory(s.typecode, s.category);
return cat === 'fishing' || s.category === 'fishing' || s.typecode === '30';
});
// CN fishing by speed
const cnAnchored = cnFishing.filter(s => s.speed < 1);
const cnLowSpeed = cnFishing.filter(s => s.speed >= 1 && s.speed < 3);
const cnOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 6);
const cnSailing = cnFishing.filter(s => s.speed > 6);
// Gear analysis
const fishingStats = aggregateFishingStats(cn);
// Zone analysis — Python 분석 결과 기반 (현장분석과 동일 기준)
const zoneStats: Record<string, number> = { ZONE_I: 0, ZONE_II: 0, ZONE_III: 0, ZONE_IV: 0, OUTSIDE: 0 };
cnFishing.forEach(s => {
const dto = analysisMap?.get(s.mmsi);
if (dto) {
const zone = dto.algorithms.location.zone;
if (zone.startsWith('ZONE_') && zone in zoneStats) {
zoneStats[zone] = (zoneStats[zone] || 0) + 1;
} else {
zoneStats.OUTSIDE = (zoneStats.OUTSIDE || 0) + 1;
}
}
// dto 없는 선박은 수역 집계에서 제외 (미분석)
});
// Dark vessels — Python 분석 결과 기반 (현장분석과 동일 기준)
const darkSuspect = cnFishing.filter(s =>
analysisMap?.get(s.mmsi)?.algorithms.darkVessel.isDark === true,
);
// Ship types
const byType: Record<string, number> = {};
ships.forEach(s => {
const cat = getMarineTrafficCategory(s.typecode, s.category);
byType[cat] = (byType[cat] || 0) + 1;
});
// By nationality top 10
const byFlag: Record<string, number> = {};
ships.forEach(s => { byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1; });
const topFlags = Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10);
// Python 분석 결과 집계 (현장분석/AI분석과 동일 기준)
let analysisTotal = 0;
const riskCounts = { critical: 0, watch: 0, monitor: 0, normal: 0 };
let spoofingCount = 0;
if (analysisMap) {
cnFishing.forEach(s => {
const dto = analysisMap.get(s.mmsi);
if (!dto) return;
analysisTotal++;
const level = dto.algorithms.riskScore.level;
if (level === 'CRITICAL') riskCounts.critical++;
else if (level === 'HIGH') riskCounts.watch++;
else if (level === 'MEDIUM') riskCounts.monitor++;
else riskCounts.normal++;
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) spoofingCount++;
});
}
return {
total: ships.length, kr, cn, cnFishing, cnAnchored, cnLowSpeed, cnOperating, cnSailing,
fishingStats, zoneStats, darkSuspect, byType, topFlags,
analysisTotal, riskCounts, spoofingCount,
};
}, [ships, analysisMap]);
const handlePrint = () => {
const content = reportRef.current;
if (!content) return;
const win = window.open('', '_blank');
if (!win) return;
win.document.write(`
<html><head><title> - ${timestamp}</title>
<style>
body { font-family: 'Malgun Gothic', sans-serif; padding: 40px; color: #1a1a1a; line-height: 1.8; font-size: 12px; }
h1 { font-size: 20px; border-bottom: 2px solid #1e3a5f; padding-bottom: 8px; color: #1e3a5f; }
h2 { font-size: 15px; color: #1e3a5f; margin-top: 24px; border-left: 4px solid #1e3a5f; padding-left: 8px; }
h3 { font-size: 13px; color: #333; margin-top: 16px; }
table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 11px; }
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; }
th { background: #1e3a5f; color: #fff; font-weight: 700; }
tr:nth-child(even) { background: #f5f7fa; }
.badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; }
.critical { background: #dc2626; color: #fff; }
.high { background: #f59e0b; color: #000; }
.medium { background: #3b82f6; color: #fff; }
.footer { margin-top: 30px; font-size: 9px; color: #888; border-top: 1px solid #ddd; padding-top: 8px; }
@media print { body { padding: 20px; } }
</style></head><body>${content.innerHTML}</body></html>
`);
win.document.close();
win.print();
};
const gearEntries = Object.entries(stats.fishingStats.byGear) as [FishingGearType, number][];
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={onClose}>
<div
style={{
width: '90vw', maxWidth: 900, maxHeight: '90vh', overflow: 'auto',
background: '#0f172a', borderRadius: 8, border: '1px solid rgba(99,179,237,0.3)',
}}
onClick={e => e.stopPropagation()}
>
{/* Toolbar */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(30,58,95,0.5)',
}}>
<span style={{ fontSize: 14 }}>📋</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}> </span>
<span style={{ fontSize: 9, color: '#64748b' }}>{timestamp} </span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
<button onClick={handlePrint} style={{
background: '#3b82f6', border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#fff', cursor: 'pointer',
}}>🖨 / PDF</button>
<button onClick={onClose} style={{
background: '#334155', border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#94a3b8', cursor: 'pointer',
}}> </button>
</div>
</div>
{/* Report Content */}
<div ref={reportRef} style={{ padding: '16px 24px', color: '#cbd5e1', fontSize: 11, lineHeight: 1.7 }}>
<h1 style={{ fontSize: 18, color: '#60a5fa', borderBottom: '2px solid #1e3a5f', paddingBottom: 6 }}>
</h1>
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 12 }}>
문서번호: GC-KCG-RPT-AUTO | : {timestamp} | 작성: KCG AI |
</div>
{/* 1. 전체 현황 */}
<h2 style={{ fontSize: 14, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 16 }}>1. </h2>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 10 }}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}></th><th style={thStyle}></th><th style={thStyle}></th>
</tr></thead>
<tbody>
<tr><td style={tdStyle}> </td><td style={tdBold}>{stats.total.toLocaleString()}</td><td style={tdStyle}>100%</td></tr>
<tr><td style={tdStyle}>🇰🇷 </td><td style={tdBold}>{stats.kr.length.toLocaleString()}</td><td style={tdStyle}>{pct(stats.kr.length, stats.total)}</td></tr>
<tr><td style={tdStyle}>🇨🇳 </td><td style={tdBold}>{stats.cn.length.toLocaleString()}</td><td style={tdStyle}>{pct(stats.cn.length, stats.total)}</td></tr>
<tr style={{ background: '#1e293b' }}><td style={tdStyle}>🇨🇳 </td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnFishing.length.toLocaleString()}</td><td style={tdStyle}>{pct(stats.cnFishing.length, stats.total)}</td></tr>
</tbody>
</table>
{/* 2. 중국어선 상세 */}
<h2 style={h2Style}>2. </h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}> </th><th style={thStyle}></th><th style={thStyle}></th><th style={thStyle}> </th>
</tr></thead>
<tbody>
<tr><td style={tdStyle}> (0~1kn)</td><td style={tdBold}>{stats.cnAnchored.length}</td><td style={tdStyle}>{pct(stats.cnAnchored.length, stats.cnFishing.length)}</td><td style={tdDim}>SOG {'<'} 1 knot</td></tr>
<tr><td style={tdStyle}>🔵 (1~3kn)</td><td style={tdBold}>{stats.cnLowSpeed.length}</td><td style={tdStyle}>{pct(stats.cnLowSpeed.length, stats.cnFishing.length)}</td><td style={tdDim}>· </td></tr>
<tr style={{ background: 'rgba(245,158,11,0.1)' }}><td style={tdStyle}>🟡 (2~6kn)</td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnOperating.length}</td><td style={tdStyle}>{pct(stats.cnOperating.length, stats.cnFishing.length)}</td><td style={tdDim}>/ </td></tr>
<tr><td style={tdStyle}>🟢 (6+kn)</td><td style={tdBold}>{stats.cnSailing.length}</td><td style={tdStyle}>{pct(stats.cnSailing.length, stats.cnFishing.length)}</td><td style={tdDim}>/</td></tr>
</tbody>
</table>
{/* 3. 어구별 분석 */}
<h2 style={h2Style}>3. / </h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}> </th><th style={thStyle}> </th><th style={thStyle}></th><th style={thStyle}> </th>
</tr></thead>
<tbody>
{gearEntries.map(([gear, count]) => {
const meta = GEAR_LABELS[gear];
return (
<tr key={gear}>
<td style={tdStyle}><span style={{ color: meta?.color || '#888' }}>{meta?.icon || '🎣'}</span> {meta?.label || gear}</td>
<td style={tdBold}>{count}</td>
<td style={tdStyle}>{meta?.riskLevel === 'CRITICAL' ? '◉ CRITICAL' : meta?.riskLevel === 'HIGH' ? '⚠ HIGH' : '△ MED'}</td>
<td style={tdStyle}>{meta?.confidence || '-'}</td>
</tr>
);
})}
</tbody>
</table>
{/* 4. 수역별 분포 */}
<h2 style={h2Style}>4. </h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}></th><th style={thStyle}> </th><th style={thStyle}> ({new Date().getMonth() + 1})</th><th style={thStyle}></th>
</tr></thead>
<tbody>
{(['ZONE_I', 'ZONE_II', 'ZONE_III', 'ZONE_IV'] as const).map(zone => (
<tr key={zone}>
<td style={tdStyle}>{ZONE_LABELS[zone]}</td>
<td style={tdBold}>{stats.zoneStats[zone]}</td>
<td style={tdDim}>{zoneAllowedText(zone)}</td>
<td style={tdDim}>{zoneViolationText(zone)}</td>
</tr>
))}
<tr style={{ background: 'rgba(239,68,68,0.1)' }}><td style={tdStyle}> </td><td style={{ ...tdBold, color: '#ef4444' }}>{stats.zoneStats.OUTSIDE}</td><td style={tdDim}>-</td><td style={{ ...tdDim, color: '#ef4444' }}> </td></tr>
</tbody>
</table>
{/* 5. 위험 평가 — Python AI 분석 결과 기반 */}
<h2 style={h2Style}>5. (AI )</h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}></th><th style={thStyle}></th><th style={thStyle}></th>
</tr></thead>
<tbody>
<tr><td style={tdStyle}> </td><td style={tdBold}>{stats.analysisTotal}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: '#475569' }}></span></td></tr>
<tr style={{ background: 'rgba(255,82,82,0.08)' }}><td style={tdStyle}>CRITICAL ()</td><td style={{ ...tdBold, color: ALERT_COLOR.CRITICAL }}>{stats.riskCounts.critical}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: ALERT_COLOR.CRITICAL }}>CRITICAL</span></td></tr>
<tr style={{ background: 'rgba(255,215,64,0.06)' }}><td style={tdStyle}>WATCH ()</td><td style={{ ...tdBold, color: ALERT_COLOR.WATCH }}>{stats.riskCounts.watch}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: ALERT_COLOR.WATCH, color: '#000' }}>WATCH</span></td></tr>
<tr><td style={tdStyle}>MONITOR ()</td><td style={tdBold}>{stats.riskCounts.monitor}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: ALERT_COLOR.MONITOR, color: '#000' }}>MONITOR</span></td></tr>
<tr><td style={tdStyle}>NORMAL ()</td><td style={tdBold}>{stats.riskCounts.normal}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: ALERT_COLOR.NORMAL, color: '#000' }}>NORMAL</span></td></tr>
<tr><td style={tdStyle} colSpan={3} /></tr>
<tr><td style={tdStyle}> </td><td style={tdBold}>{stats.darkSuspect.length}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.darkSuspect.length > 0 ? ALERT_COLOR.WATCH : ALERT_COLOR.NORMAL }}>{stats.darkSuspect.length > 0 ? 'WATCH' : 'NORMAL'}</span></td></tr>
<tr><td style={tdStyle}>GPS </td><td style={tdBold}>{stats.spoofingCount}</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.spoofingCount > 0 ? ALERT_COLOR.WATCH : ALERT_COLOR.NORMAL }}>{stats.spoofingCount > 0 ? 'WATCH' : 'NORMAL'}</span></td></tr>
</tbody>
</table>
{/* 6. 국적별 현황 */}
<h2 style={h2Style}>6. (TOP 10)</h2>
<table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}></th><th style={thStyle}></th><th style={thStyle}></th><th style={thStyle}></th>
</tr></thead>
<tbody>
{stats.topFlags.map(([flag, count], i) => (
<tr key={flag}><td style={tdStyle}>{i + 1}</td><td style={tdStyle}>{flag}</td><td style={tdBold}>{count.toLocaleString()}</td><td style={tdStyle}>{pct(count, stats.total)}</td></tr>
))}
</tbody>
</table>
{/* 7. 건의사항 */}
<h2 style={h2Style}>7. </h2>
<div style={{ fontSize: 10, color: '#94a3b8', paddingLeft: 12 }}>
<p>1. {new Date().getMonth() + 1} , <strong style={{ color: '#f59e0b' }}> - </strong> </p>
<p>2. {stats.darkSuspect.length} <strong style={{ color: '#ef4444' }}>SAR </strong> </p>
<p>3. {stats.zoneStats.OUTSIDE} <strong style={{ color: '#ef4444' }}> </strong> </p>
<p>4. 4/16 <strong> </strong> </p>
{largestGearGroup ? (
<p>5. {largestGearGroup.name} {largestGearGroup.count} <strong> </strong> </p>
) : (
<p>5. <strong> </strong> </p>
)}
</div>
{/* Footer */}
<div style={{ marginTop: 24, paddingTop: 8, borderTop: '1px solid rgba(255,255,255,0.1)', fontSize: 8, color: '#475569' }}>
KCG . | : {timestamp} | 데이터: 실시간 AIS | 분석: AI |
</div>
</div>
</div>
</div>
);
}
// Styles
const h2Style: React.CSSProperties = { fontSize: 13, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 20 };
const tableStyle: React.CSSProperties = { borderCollapse: 'collapse', width: '100%', fontSize: 10, marginTop: 6 };
const thStyle: React.CSSProperties = { border: '1px solid #334155', padding: '4px 8px', textAlign: 'left', color: '#e2e8f0', fontSize: 9, fontWeight: 700 };
const tdStyle: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 8px', fontSize: 10 };
const tdBold: React.CSSProperties = { ...tdStyle, fontWeight: 700, color: '#e2e8f0' };
const tdDim: React.CSSProperties = { ...tdStyle, color: '#64748b', fontSize: 9 };
const badgeStyle: React.CSSProperties = { display: 'inline-block', padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700, color: '#fff' };
function pct(n: number, total: number): string {
if (!total) return '-';
return `${((n / total) * 100).toFixed(1)}%`;
}

파일 보기

@ -1,6 +1,5 @@
import { useState } from 'react';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import { FONT_MONO } from '../../styles/fonts';
import { KOREA_SUBMARINE_CABLES, KOREA_LANDING_POINTS } from '../../services/submarineCable';
import type { SubmarineCable } from '../../services/submarineCable';
@ -91,7 +90,7 @@ export function SubmarineCableLayer() {
<Marker key={`label-${cable.id}`} longitude={mid[0]} latitude={mid[1]} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedCable(cable); setSelectedPoint(null); }}>
<div style={{
fontSize: 7, fontFamily: FONT_MONO, fontWeight: 600,
fontSize: 7, fontFamily: 'monospace', fontWeight: 600,
color: cable.color, cursor: 'pointer',
textShadow: '0 0 3px #000, 0 0 3px #000, 0 0 6px #000',
whiteSpace: 'nowrap', opacity: 0.8,

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