Compare commits
183 커밋
feature/op
...
main
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 6303831df3 | |||
| dd9de6739c | |||
| ed618f6dd0 | |||
| d37653c1be | |||
| 17922bf74c | |||
| 32f9aa897b | |||
| de11a162b4 | |||
| b14b6c241e | |||
| 7ea5b4719a | |||
| d57f993960 | |||
| 0b74831b87 | |||
| 83db0f8149 | |||
| e7ed536be5 | |||
| 1033654c82 | |||
| 15f5f680fd | |||
| 2ca6371d87 | |||
| e11caf2767 | |||
| 23828c742e | |||
| 5432e1f282 | |||
| 973b419287 | |||
| 8362bc5b6c | |||
| 7dd46f2078 | |||
| 2534c9dbca | |||
| 67523b475d | |||
| b8b60bf314 | |||
| d15039ce18 | |||
| e9ae058017 | |||
| 5c85afea22 | |||
| b04e96c457 | |||
| ae70eceb96 | |||
| b320aeb3fd | |||
| e9cbeaa0d8 | |||
| acef08fca9 | |||
| d44837e64a | |||
| fc6f696d1f | |||
| 98d173701e | |||
| a7eb706839 | |||
| 650c027013 | |||
| 31f557e54d | |||
| 71a2188273 | |||
| 138a1b82de | |||
| c59b38f913 | |||
| 9200f45cb2 | |||
| 77efab8652 | |||
| 71d607e499 | |||
| f09186a187 | |||
| ce4cde56b8 | |||
| 6f8e8cb6cc | |||
| 313b5d9af8 | |||
| b0ac1590f2 | |||
| 9eac614910 | |||
| 6f4044ce39 | |||
| 93ce2092d2 | |||
| 1b14aacd89 | |||
| b88c9c28ee | |||
| b14df41da7 | |||
| 867ece0a39 | |||
| 9f3d53d2e8 | |||
| 588e61c941 | |||
| 7e8a5bb39a | |||
| 30f1956ca6 | |||
| 89b81bc241 | |||
| bf412cc897 | |||
| c97f964f93 | |||
| 5afa5d4be9 | |||
| e8187b3a6c | |||
| 6789f82e3b | |||
| 5002105d18 | |||
| 21df325010 | |||
| dddb978dea | |||
| 8631546142 | |||
| 6ea394d120 | |||
| 8eacbb2c91 | |||
| c4186a327d | |||
| 6ba3db5cee | |||
| e68f314093 | |||
| 242fdb8034 | |||
| 4cf29521a9 | |||
| 87d1b31ef3 | |||
| bbbc326e38 | |||
| 2fb0842523 | |||
| dc6070d619 | |||
| 4c994e277a | |||
| 82fb6fbfff | |||
| d025809793 | |||
| 812a78f636 | |||
| 3407d37f9b | |||
| 54b49551e2 | |||
| 32dd957f4b | |||
| 7fe9e048bf | |||
| 364a34ce10 | |||
| 8f342f70b7 | |||
| 8048eb533c | |||
| 8e17febd1b | |||
| 4a4afa3dc8 | |||
| 2593c776bf | |||
| 421f62ec0a | |||
| e797beaac6 | |||
| f0094c21d3 | |||
| b0bb0fe33d | |||
| 9bdda775c4 | |||
| c9411658b2 | |||
| 7750d11de5 | |||
| 1bf70f46ac | |||
| f1f965fcd4 | |||
| 3e723752af | |||
| cfef5f7316 | |||
| 51064212dc | |||
| 672591258d | |||
| 98c81cd548 | |||
| ebde2dd4cf | |||
| 308be14b4f | |||
| caaedfa5e2 | |||
| a556e5f434 | |||
| 25d446731f | |||
| a6e91a8e81 | |||
| 2fc8b1d785 | |||
| 29f3bc3186 | |||
| d37a7dfa78 | |||
| be9fd3c6cb | |||
| 8f9b347e1f | |||
| 7573c84e91 | |||
| e30dcb74ad | |||
| eb6e51c8a0 | |||
| 8f77b68bef | |||
| 69c18e0237 | |||
| a3ba0d070b | |||
| 07d47c999e | |||
| 1029e07432 | |||
| 89786f1ec3 | |||
| 03747d3c63 | |||
| 5384092b21 | |||
| a404d81173 | |||
| 90d1fc249d | |||
| a3a933f096 | |||
| ed77005619 | |||
| bc355ff521 | |||
| a1ba74697a | |||
| a1c917108c | |||
| b0dfa7f6a7 | |||
| f36e1b297b | |||
| 9f0f60159f | |||
| f98eca0aec | |||
| db352946ae | |||
| cc32ba6290 | |||
| a6de14ecef | |||
| 3a31b90a96 | |||
| 9cf2dbe58c | |||
| 56b92e408f | |||
| d35cafb6c5 | |||
| 93ddb7d1b6 | |||
| fcf1ff5363 | |||
| 15b68bb634 | |||
| 7b31f93d86 | |||
| 318cfa94ad | |||
| d6aac611d0 | |||
| b24d43e4a1 | |||
| be38983cc5 | |||
| 6e12883768 | |||
| d09b8de765 | |||
| e0f9b5cf64 | |||
| d99e356a5d | |||
| 7d27d5fc83 | |||
| fb15b4c89b | |||
| 4cf54a0b4e | |||
| 4b33d1792b | |||
| 51a0ff933a | |||
| 635753f636 | |||
| d9d5a9483e | |||
| 8035692dfc | |||
| 3967d77d65 | |||
| 4fb16678f8 | |||
| 962f2df683 | |||
| e052795ef5 | |||
| a96103e639 | |||
| 5ff400f982 | |||
| f735a3ce7f | |||
| 0604887c75 | |||
| 9c091d1052 | |||
| 5e85e80142 | |||
| 5ce172eb82 | |||
| 278c20968e | |||
| d0c8b3d1bd |
@ -148,75 +148,10 @@ jobs:
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# ═══ 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"
|
||||
# ═══ Prediction (FastAPI) — CI/CD 제외, 수동 배포 ═══
|
||||
# ssh redis-211 에서 수동 배포:
|
||||
# scp prediction/*.py redis-211:/home/apps/kcg-prediction/
|
||||
# ssh redis-211 "sudo systemctl restart kcg-prediction"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -29,6 +29,10 @@ coverage/
|
||||
.prettiercache
|
||||
*.tsbuildinfo
|
||||
|
||||
# === Codex CLI ===
|
||||
AGENTS.md
|
||||
.codex/
|
||||
|
||||
# === Claude Code ===
|
||||
# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함
|
||||
!.claude/
|
||||
|
||||
34
CLAUDE.md
34
CLAUDE.md
@ -175,6 +175,11 @@ deploy/ # systemd + nginx 배포 설정
|
||||
3. **API 응답 검증**: MR 생성, 봇 승인, 머지 API 호출 후 반드시 상태를 확인.
|
||||
실패 시 사용자에게 알리고 중단.
|
||||
|
||||
**MR/머지는 사용자 명시적 요청 없이 절대 진행 금지:**
|
||||
- `git push` 완료 후에는 "푸시 완료" 보고만 하고 **중단**
|
||||
- MR 생성은 사용자가 `/mr`, `/release` 호출 또는 "MR 해줘" 등 명시적 요청 시에만
|
||||
- **스킬 없이 Gitea API를 직접 호출하여 MR/머지를 진행하지 말 것** — 스킬 절차(사용자 확인 단계)를 우회하는 것과 동일
|
||||
|
||||
### 스킬 목록
|
||||
- `/push` — 커밋 + 푸시 (해시 비교 → 커밋 메시지 확인 → 푸시)
|
||||
- `/mr` — 커밋 + 푸시 + MR 생성 (릴리즈 노트 갱신 포함)
|
||||
@ -202,6 +207,35 @@ deploy/ # systemd + nginx 배포 설정
|
||||
| **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, 다중 포인트) |
|
||||
|
||||
### 디버그 도구 분류 기준
|
||||
다음에 해당하면 디버그 도구로 분류하고, 불확실하면 사용자에게 확인:
|
||||
- 개발/검증 목적의 좌표/데이터 표시 도구
|
||||
- 프로덕션 사용자에게 불필요한 진단 정보
|
||||
- 임시 데이터 시각화, 성능 프로파일링
|
||||
- 특정 조건에서만 활성화되는 테스트 기능
|
||||
|
||||
## 팀 규칙
|
||||
|
||||
- 코드 스타일: `.claude/rules/code-style.md`
|
||||
|
||||
@ -17,7 +17,7 @@ import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "login_history", schema = "kcg")
|
||||
@Table(name = "login_history")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -15,7 +15,7 @@ import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users", schema = "kcg")
|
||||
@Table(name = "users")
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
|
||||
@ -14,7 +14,7 @@ import java.util.List;
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
||||
@Value("${app.cors.allowed-origins:http://localhost:5173}")
|
||||
@Value("${app.cors.allowed-origins:http://localhost:5174,http://localhost:5173}")
|
||||
private List<String> allowedOrigins;
|
||||
|
||||
@Bean
|
||||
|
||||
@ -7,7 +7,7 @@ import org.locationtech.jts.geom.Point;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "aircraft_positions", schema = "kcg")
|
||||
@Table(name = "aircraft_positions")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -9,7 +9,7 @@ import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "vessel_analysis_results", schema = "kcg")
|
||||
@Table(name = "vessel_analysis_results")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -1,49 +1,74 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import gc.mda.kcg.domain.fleet.GroupPolygonService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VesselAnalysisService {
|
||||
|
||||
private final VesselAnalysisResultRepository repository;
|
||||
private final CacheManager cacheManager;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 최근 2시간 내 분석 결과 + 집계 통계를 반환한다.
|
||||
* mmsi별 최신 1건만. Caffeine 캐시(TTL 5분) 적용.
|
||||
* 최근 2시간 분석 결과 + 집계 통계.
|
||||
* - 첫 호출(warmup): 2시간 전체 조회 → 캐시 구축
|
||||
* - 이후: lastFetchTime 이후 증분만 조회 → 캐시 병합
|
||||
* - 2시간 초과 항목은 evict
|
||||
* - 값 갱신 시 TTL 타이머 초기화
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getLatestResultsWithStats() {
|
||||
Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS);
|
||||
if (cache != null) {
|
||||
Cache.ValueWrapper wrapper = cache.get("data_with_stats");
|
||||
if (wrapper != null) {
|
||||
return (Map<String, Object>) wrapper.get();
|
||||
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;
|
||||
}
|
||||
|
||||
Instant since = Instant.now().minus(2, ChronoUnit.HOURS);
|
||||
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);
|
||||
}
|
||||
// 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 : latest.values()) {
|
||||
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();
|
||||
@ -62,11 +87,10 @@ public class VesselAnalysisService {
|
||||
}
|
||||
}
|
||||
|
||||
// 어구 통계 — group_polygon_snapshots 기반
|
||||
Map<String, Integer> gearStats = groupPolygonService.getGearStats();
|
||||
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("total", latest.size());
|
||||
stats.put("total", cache.size());
|
||||
stats.put("dark", dark);
|
||||
stats.put("spoofing", spoofing);
|
||||
stats.put("critical", critical);
|
||||
@ -77,21 +101,15 @@ public class VesselAnalysisService {
|
||||
stats.put("gearGroups", gearStats.get("gearGroups"));
|
||||
stats.put("gearCount", gearStats.get("gearCount"));
|
||||
|
||||
List<VesselAnalysisDto> results = latest.values().stream()
|
||||
List<VesselAnalysisDto> results = cache.values().stream()
|
||||
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
|
||||
.map(VesselAnalysisDto::from)
|
||||
.toList();
|
||||
|
||||
Map<String, Object> response = Map.of(
|
||||
return Map.of(
|
||||
"count", results.size(),
|
||||
"items", results,
|
||||
"stats", stats
|
||||
);
|
||||
|
||||
if (cache != null) {
|
||||
cache.put("data_with_stats", response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "events", schema = "kcg")
|
||||
@Table(name = "events")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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;
|
||||
@ -17,10 +18,14 @@ 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 kcg.fleet_companies ORDER BY id"
|
||||
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM "
|
||||
+ dbSchema + ".fleet_companies ORDER BY id"
|
||||
);
|
||||
return ResponseEntity.ok(results);
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
@ -48,4 +48,76 @@ public class GroupPolygonController {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ 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;
|
||||
@ -24,4 +25,7 @@ public class GroupPolygonDto {
|
||||
private String zoneName;
|
||||
private List<Map<String, Object>> members;
|
||||
private String color;
|
||||
private String resolution;
|
||||
private Integer candidateCount;
|
||||
private ParentInferenceSummaryDto parentInference;
|
||||
}
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class ParentWorkflowActionRequest {
|
||||
private String actor;
|
||||
private String comment;
|
||||
}
|
||||
@ -9,7 +9,6 @@ import java.time.Instant;
|
||||
@Entity
|
||||
@Table(
|
||||
name = "osint_feeds",
|
||||
schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"})
|
||||
)
|
||||
@Getter
|
||||
|
||||
@ -6,7 +6,7 @@ import lombok.*;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "satellite_tle", schema = "kcg")
|
||||
@Table(name = "satellite_tle")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -8,7 +8,6 @@ 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", schema = "kcg")
|
||||
@Table(name = "seismic_events")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg
|
||||
username: kcg_user
|
||||
password: kcg_pass
|
||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg,public}
|
||||
username: ${DB_USERNAME:kcg_user}
|
||||
password: ${DB_PASSWORD:kcg_pass}
|
||||
app:
|
||||
jwt:
|
||||
secret: local-dev-secret-key-32chars-minimum!!
|
||||
expiration-ms: 86400000
|
||||
secret: ${JWT_SECRET:local-dev-secret-key-32chars-minimum!!}
|
||||
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
|
||||
google:
|
||||
client-id: YOUR_GOOGLE_CLIENT_ID
|
||||
client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID}
|
||||
auth:
|
||||
allowed-domain: gcsc.co.kr
|
||||
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
|
||||
collector:
|
||||
open-sky-client-id: YOUR_OPENSKY_CLIENT_ID
|
||||
open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET
|
||||
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}
|
||||
|
||||
@ -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: http://localhost:5173,https://kcg.gc-si.dev
|
||||
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173,https://kcg.gc-si.dev}
|
||||
|
||||
@ -6,6 +6,6 @@ spring:
|
||||
ddl-auto: none
|
||||
properties:
|
||||
hibernate:
|
||||
default_schema: kcg
|
||||
default_schema: ${DB_SCHEMA:kcg}
|
||||
server:
|
||||
port: 8080
|
||||
port: ${SERVER_PORT:8080}
|
||||
|
||||
146
database/migration/010_gear_correlation.sql
Normal file
146
database/migration/010_gear_correlation.sql
Normal file
@ -0,0 +1,146 @@
|
||||
-- 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;
|
||||
14
database/migration/011_polygon_resolution.sql
Normal file
14
database/migration/011_polygon_resolution.sql
Normal file
@ -0,0 +1,14 @@
|
||||
-- 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);
|
||||
176
database/migration/012_gear_parent_inference.sql
Normal file
176
database/migration/012_gear_parent_inference.sql
Normal file
@ -0,0 +1,176 @@
|
||||
-- 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
|
||||
);
|
||||
23
database/migration/013_short_parent_name_cleanup.sql
Normal file
23
database/migration/013_short_parent_name_cleanup.sql
Normal file
@ -0,0 +1,23 @@
|
||||
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;
|
||||
125
database/migration/014_gear_parent_workflow_v2_phase1.sql
Normal file
125
database/migration/014_gear_parent_workflow_v2_phase1.sql
Normal file
@ -0,0 +1,125 @@
|
||||
-- 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();
|
||||
111
database/migration/015_gear_parent_episode_tracking.sql
Normal file
111
database/migration/015_gear_parent_episode_tracking.sql
Normal file
@ -0,0 +1,111 @@
|
||||
-- 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);
|
||||
21
deploy/docker-compose-ollama.yml
Normal file
21
deploy/docker-compose-ollama.yml
Normal file
@ -0,0 +1,21 @@
|
||||
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,6 +20,21 @@ 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/;
|
||||
@ -94,6 +109,16 @@ 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;
|
||||
|
||||
514
docs/GEAR-PARENT-INFERENCE-ALGORITHM-SPEC.md
Normal file
514
docs/GEAR-PARENT-INFERENCE-ALGORITHM-SPEC.md
Normal file
@ -0,0 +1,514 @@
|
||||
# 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)
|
||||
677
docs/GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md
Normal file
677
docs/GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md
Normal file
@ -0,0 +1,677 @@
|
||||
# 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`
|
||||
706
docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md
Normal file
706
docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md
Normal file
@ -0,0 +1,706 @@
|
||||
# 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 버튼 교체와 최소 조회 화면
|
||||
|
||||
이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.
|
||||
693
docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2.md
Normal file
693
docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2.md
Normal file
@ -0,0 +1,693 @@
|
||||
# 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,67 +4,162 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-24.3]
|
||||
## [2026-04-04]
|
||||
|
||||
### 추가
|
||||
- 가상 선박 마커: 선단/어구 그룹 멤버를 ship-triangle 아이콘으로 표시 (COG 회전 + zoom interpolate)
|
||||
- 어구 겹침 해결: queryRenderedFeatures → 다중 선택 팝업 + 호버 하이라이트
|
||||
- AI 분석 통계 서버사이드 전환: dark/spoofing/risk/cluster/gear 집계를 Backend에서 계산
|
||||
|
||||
### 변경
|
||||
- cnFishingSuspects에 모선 MMSI 포함 (어구 패턴에서 모선명 추출 → 동일명 선박 추가)
|
||||
- AI 분석 패널: 클라이언트 사이드 stats 계산 로직 완전 제거 (14K+ 선박 순회 useMemo 삭제)
|
||||
- Backend /api/vessel-analysis 응답에 stats 필드 추가 (집계 통계 서버 제공)
|
||||
- GroupPolygonService에 어구 집계 SQL 추가 (gearGroups/gearCount)
|
||||
- FleetClusterLayer: 패널 아코디언 전환 (하나만 열림), 높이 제한 min(45vh, 400px)
|
||||
- vessel_store.py: COG bearing 계산 (마지막 2점 좌표 기반 atan2)
|
||||
- 어구 모선 추론(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)
|
||||
|
||||
### 수정
|
||||
- 어구 줌인 최대 제한 (maxZoom: 12)
|
||||
|
||||
## [2026-03-24.2]
|
||||
|
||||
### 추가
|
||||
- 선단/어구그룹 폴리곤 서버사이드 생성: Shapely convex hull + buffer → PostGIS 저장
|
||||
- DB migration 009: group_polygon_snapshots 테이블 (5분 APPEND, 7일 보존)
|
||||
- Backend API: GET /api/vessel-analysis/groups (목록/상세/히스토리)
|
||||
- useGroupPolygons 훅: 5분 폴링 (fleet/gearInZone/gearOutZone)
|
||||
- 모선 검토 대기 목록을 폴리곤 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 경유로 변경
|
||||
|
||||
### 변경
|
||||
- FleetClusterLayer: 클라이언트 convexHull/padPolygon 제거 → API GeoJSON 직접 렌더링
|
||||
- 프론트 어구그룹 탐지(regex+거리 클러스터링) Python 이관
|
||||
- fleet_tracker: SQL 테이블명 qualified_table() 동적화 + is_trackable_parent_name 필터
|
||||
- gear_correlation: 후보 track에 timestamp 필드 추가
|
||||
- kcgdb: SQL 스키마 하드코딩 → qualified_table() 패턴 전환
|
||||
|
||||
## [2026-04-01]
|
||||
|
||||
### 추가
|
||||
- 한국 현황 위성지도/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 색상 구분)
|
||||
|
||||
### 변경
|
||||
- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius
|
||||
- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용
|
||||
- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링
|
||||
- 선단 폴리곤 색상: API 기본색 → 밝은 파스텔 팔레트 (바다 배경 대비)
|
||||
- 멤버/연관 라벨: SDF outline → 검정 배경 블록 + fontScale.analysis 연동
|
||||
- 모델 패널: 헤더→푸터 구조, 개별 확장/축소, 우클릭 툴팁 고정
|
||||
|
||||
### 수정
|
||||
- 불법어선 탭 복원 (임시 숨김 해제)
|
||||
- 라이브 어구 현황에서 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 완전 초기화
|
||||
|
||||
## [2026-03-24.1]
|
||||
### 기타
|
||||
- DB 마이그레이션: sub_cluster_id + resolution 컬럼, 인덱스 교체
|
||||
|
||||
## [2026-03-31]
|
||||
|
||||
### 추가
|
||||
- 웹폰트 내장: @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅
|
||||
- 폰트 상수 파일 (FONT_MONO, FONT_SANS) + 타입 선언
|
||||
- 어구 연관성 프론트엔드 표시 — Backend API + 모델별 팝업/토글 UI
|
||||
- 어구 그룹 멀티모델 폴리곤 오버레이 + 토글 패널
|
||||
- 어구 리플레이 deck.gl + Zustand 전환 (TripsLayer GPU 트레일 + rAF 10fps)
|
||||
- 리플레이 IconLayer (SVG ship-triangle/gear-diamond, COG 회전)
|
||||
- 재생 컨트롤 확장: 항적/이름 토글, 일치율 드롭다운(50~90%), 개별 on/off
|
||||
- 트랙 API 전체 모델 확장 — 모델별 점수 + 24h 트랙 반환
|
||||
- 모델별 폴리곤 중심 경로 + 현재 중심점 렌더링 (모델명 라벨)
|
||||
|
||||
### 변경
|
||||
- 전체 font-family 통일: CSS 55곳 + deck.gl TextLayer 30곳 + 인라인 스타일 8곳
|
||||
- 이란 시설물 색상 사막 대비 고채도 팔레트 교체 (amber/orange/yellow → rose/sky/cyan/lime)
|
||||
- 이란 시설 라벨 fontWeight 600→700, alpha 200→255 (가독성 개선)
|
||||
- 접힘 패널 상하 패딩 균일화 (area-ship-header :last-child)
|
||||
- FleetClusterLayer 2357줄 → 10파일 리팩토링 (오케스트레이터 + 서브컴포넌트)
|
||||
- 리플레이 렌더링: MapLibre GeoJSON → deck.gl (React re-render 20회/초 → 0회)
|
||||
- 연관 선박 위치: 트랙 보간 우선, live 선박 fallback
|
||||
- 토글 패널 위치 고정 + 모델 카드 가로 스크롤
|
||||
- enabledVessels 토글 시 폴리곤 + 중심경로 동시 재계산
|
||||
|
||||
### 수정
|
||||
- 이름 기반 레이어 항상 ON 고정 + 최상위 z-index (다른 모델에 가려지지 않음)
|
||||
- Prediction API DB 접속 context manager 누락
|
||||
- Prediction proxy rewrite 경로 불일치 (/api/prediction → /api)
|
||||
- nginx prediction API 라우팅 추가
|
||||
|
||||
### 기타
|
||||
- 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 접기 + 확장/축소
|
||||
|
||||
### 변경
|
||||
- AiChatPanel: 클라이언트 프롬프트 → Python 서버사이드 압축 프롬프트
|
||||
- nginx SSE 프록시 + kcgdb 분석 요약 쿼리 추가
|
||||
|
||||
## [2026-03-25]
|
||||
|
||||
### 추가
|
||||
- 현장분석 항적 미니맵: 선박 클릭 시 72시간 항적 + 현재 위치 표시
|
||||
- 현장분석 위험도 점수 기준 섹션
|
||||
- Python 경량 분석: 파이프라인 미통과 412* 선박 간이 위험도
|
||||
- 폴리곤 히스토리 애니메이션: 12시간 타임라인 기반 재생 (중심 궤적 + 어구별 궤적 + 가상 아이콘)
|
||||
- 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭) + 신호없음 구간 표시
|
||||
- nginx /api/gtts 프록시 (Google TTS CORS 우회)
|
||||
|
||||
### 변경
|
||||
- 위험도 용어 통일: 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 커버)
|
||||
|
||||
### 수정
|
||||
- fishing_pattern.py: 마지막 조업 세그먼트 누락 버그 수정
|
||||
- 히스토리 모드 시 현재 강조 레이어 (deck.gl + MapLibre) 정상 숨김
|
||||
|
||||
## [2026-03-24]
|
||||
|
||||
### 추가
|
||||
- LayerPanel 공통 트리 구조: LayerTreeNode 재귀 렌더러 (한국/이란 양쪽 적용)
|
||||
- 위험시설/해외시설 emoji→SVG IconLayer 전환 (12 SVG 함수, hazard/CN/JP 3개 IconLayer)
|
||||
- S&P Global 피격 선박 27척 데이터 (damagedShips.ts)
|
||||
- 이란 리플레이 실데이터 전환: Backend 시점 조회 API + Events CRUD
|
||||
- GeoEvent `sea_attack` 타입 + SEA ATK 배지 (피격 선박 이벤트 로그 통합)
|
||||
- 더미↔API 토글 UI (리플레이 배속 우측)
|
||||
- 대시보드 탭 localStorage 영속화
|
||||
- 지도 글꼴 크기 커스텀: 시설/선박/분석/지역 4그룹 슬라이더 (0.5~2.0x, LAYERS 하단)
|
||||
- 선단/어구그룹 폴리곤 서버사이드 생성: 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 분기
|
||||
|
||||
### 변경
|
||||
- 부모 노드 토글→하위 전체 ON/OFF 캐스케이드 + 카운트 합산
|
||||
- useIranData dataSource 분기 (dummy=sampleData, api=Backend DB 3월1일~오늘)
|
||||
- fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수
|
||||
- FleetClusterLayer: 클라이언트 convexHull 제거 → API GeoJSON 렌더링 + 패널 아코디언 전환
|
||||
- AI 분석 패널: 클라이언트 stats 계산 제거 → 서버 제공 (14K+ 순회 useMemo 삭제)
|
||||
- 프론트 어구그룹 탐지 Python 이관 + 어구 클릭 시 좌측 패널 섹션 자동 전환
|
||||
- 전체 font-family 통일 (CSS 55곳 + deck.gl 30곳) + 이란 시설물 사막 대비 고채도 팔레트
|
||||
- feature/korea-layers-enhancement 브랜치 기능 → develop 아키텍처에 이식
|
||||
|
||||
### 수정
|
||||
- 불법어선 탭 복원 + 어구 줌인 최대 제한 (maxZoom: 12)
|
||||
|
||||
## [2026-03-23]
|
||||
|
||||
|
||||
1
frontend/.gitignore
vendored
Normal file
1
frontend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.claude/worktrees/
|
||||
13
frontend/gear-parent-flow.html
Normal file
13
frontend/gear-parent-flow.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!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>
|
||||
1083
frontend/package-lock.json
generated
1083
frontend/package-lock.json
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -11,6 +11,7 @@
|
||||
},
|
||||
"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",
|
||||
@ -20,6 +21,7 @@
|
||||
"@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",
|
||||
@ -32,7 +34,8 @@
|
||||
"react-map-gl": "^8.1.0",
|
||||
"recharts": "^3.8.0",
|
||||
"satellite.js": "^6.0.2",
|
||||
"tailwindcss": "^4.2.1"
|
||||
"tailwindcss": "^4.2.1",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@ -10,6 +10,7 @@ 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,6 +68,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
|
||||
return (
|
||||
<FontScaleProvider>
|
||||
<SymbolScaleProvider>
|
||||
<SharedFilterProvider>
|
||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||
<header className="app-header">
|
||||
@ -104,6 +106,15 @@ 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>
|
||||
@ -160,6 +171,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
)}
|
||||
</div>
|
||||
</SharedFilterProvider>
|
||||
</SymbolScaleProvider>
|
||||
</FontScaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ 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);
|
||||
@ -136,7 +137,7 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
|
||||
)}
|
||||
|
||||
{/* Dev Login */}
|
||||
{IS_DEV && (
|
||||
{DEV_LOGIN_ENABLED && (
|
||||
<>
|
||||
<div className="w-full border-t border-kcg-border pt-4 text-center">
|
||||
<span className="text-xs font-mono tracking-wider text-kcg-dim">
|
||||
|
||||
@ -885,12 +885,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
|
||||
{/* AI 해양분석 챗 — 한국 탭 전용 */}
|
||||
{dashboardTab === 'korea' && (
|
||||
<AiChatPanel
|
||||
ships={ships}
|
||||
koreanShipCount={koreanShips.length}
|
||||
chineseShipCount={chineseShips.length}
|
||||
totalShipCount={ships.length}
|
||||
/>
|
||||
<AiChatPanel />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ 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,6 +898,7 @@ export function LayerPanel({
|
||||
)}
|
||||
</div>
|
||||
<FontScalePanel />
|
||||
<SymbolScalePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
43
frontend/src/components/common/SymbolScalePanel.tsx
Normal file
43
frontend/src/components/common/SymbolScalePanel.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
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>◆ 심볼 크기</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>
|
||||
);
|
||||
}
|
||||
@ -1,73 +1,76 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import type { Ship } from '../../types';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
koreanShipCount: number;
|
||||
chineseShipCount: number;
|
||||
totalShipCount: number;
|
||||
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 };
|
||||
}
|
||||
|
||||
// TODO: Python FastAPI 기반 해양분석 AI API로 전환 예정
|
||||
const AI_CHAT_URL = '/api/kcg/ai/chat';
|
||||
export function AiChatPanel() {
|
||||
const { user } = useAuth();
|
||||
const userId = user?.email ?? 'anonymous';
|
||||
|
||||
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' });
|
||||
@ -85,72 +88,166 @@ export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShi
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const systemPrompt = buildSystemPrompt({ ships, koreanShipCount, chineseShipCount, totalShipCount });
|
||||
const apiMessages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.filter(m => m.role !== 'system').map(m => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user', content: userMsg.content },
|
||||
];
|
||||
// 스트리밍 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 res = await fetch(AI_CHAT_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'qwen2.5:7b',
|
||||
messages: apiMessages,
|
||||
stream: false,
|
||||
options: { temperature: 0.3, num_predict: 1024 },
|
||||
message: userMsg.content,
|
||||
user_id: userId,
|
||||
stream: true,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
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]);
|
||||
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;
|
||||
});
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}. Ollama가 실행 중인지 확인하세요.`,
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
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;
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [input, isLoading, messages, ships, koreanShipCount, chineseShipCount, totalShipCount]);
|
||||
}, [input, isLoading, userId]);
|
||||
|
||||
const clearHistory = useCallback(async () => {
|
||||
setMessages([]);
|
||||
try {
|
||||
await fetch(`${AI_CHAT_URL}/history?user_id=${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||
} catch { /* 무시 */ }
|
||||
}, [userId]);
|
||||
|
||||
const quickQuestions = [
|
||||
'현재 해양 상황을 요약해줘',
|
||||
'중국어선 불법조업 의심 분석해줘',
|
||||
'서해 위험도를 평가해줘',
|
||||
'위험 선박 상위 10척 알려줘',
|
||||
'다크베셀 현황 분석해줘',
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderTop: '1px solid rgba(168,85,247,0.2)',
|
||||
marginTop: 8,
|
||||
...(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,
|
||||
}),
|
||||
}}>
|
||||
{/* Toggle header */}
|
||||
<div
|
||||
onClick={() => setIsOpen(p => !p)}
|
||||
onClick={() => {
|
||||
if (!isOpen) { setIsOpen(true); return; }
|
||||
if (expanded) { setExpanded(false); return; }
|
||||
setExpanded(true);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 8px', cursor: 'pointer',
|
||||
background: isOpen ? 'rgba(168,85,247,0.12)' : 'rgba(168,85,247,0.04)',
|
||||
borderRadius: 4,
|
||||
borderLeft: '2px solid rgba(168,85,247,0.5)',
|
||||
borderRadius: expanded ? '8px 8px 0 0' : 4,
|
||||
borderLeft: expanded ? 'none' : '2px solid rgba(168,85,247,0.5)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>🤖</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#c4b5fd' }}>AI 해양분석</span>
|
||||
<span style={{ fontSize: 8, color: '#8b5cf6', marginLeft: 2, background: 'rgba(139,92,246,0.15)', padding: '1px 5px', borderRadius: 3 }}>Qwen 2.5</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#8b5cf6' }}>
|
||||
{isOpen ? '▼' : '▶'}
|
||||
<span 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>
|
||||
</div>
|
||||
|
||||
@ -158,10 +255,11 @@ export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShi
|
||||
{isOpen && (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: 360, background: 'rgba(88,28,135,0.08)',
|
||||
...(expanded ? { flex: 1 } : { height: 360 }),
|
||||
background: expanded ? 'transparent' : 'rgba(88,28,135,0.08)',
|
||||
borderRadius: '0 0 6px 6px', overflow: 'hidden',
|
||||
borderLeft: '2px solid rgba(168,85,247,0.3)',
|
||||
borderBottom: '1px solid rgba(168,85,247,0.15)',
|
||||
borderLeft: expanded ? 'none' : '2px solid rgba(168,85,247,0.3)',
|
||||
borderBottom: expanded ? 'none' : '1px solid rgba(168,85,247,0.15)',
|
||||
}}>
|
||||
{/* Messages */}
|
||||
<div style={{
|
||||
@ -192,34 +290,79 @@ export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShi
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
background: msg.role === 'user'
|
||||
? 'rgba(139,92,246,0.25)'
|
||||
: 'rgba(168,85,247,0.08)',
|
||||
borderRadius: msg.role === 'user' ? '8px 8px 2px 8px' : '8px 8px 8px 2px',
|
||||
padding: '6px 8px',
|
||||
fontSize: 10,
|
||||
color: '#e2e8f0',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
{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 && (
|
||||
<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} />
|
||||
@ -231,11 +374,24 @@ export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShi
|
||||
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(); sendMessage(); } }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void sendMessage(); } }}
|
||||
placeholder="해양 상황 질문..."
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
@ -246,7 +402,7 @@ export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShi
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
onClick={() => { void sendMessage(); }}
|
||||
disabled={isLoading || !input.trim()}
|
||||
style={{
|
||||
background: isLoading || !input.trim() ? '#334155' : '#7c3aed',
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
|
||||
import type { VesselAnalysisDto, Ship } from '../../types';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
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;
|
||||
@ -32,22 +40,6 @@ 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)',
|
||||
'',
|
||||
@ -65,8 +57,8 @@ const LEGEND_LINES = [
|
||||
'■ 허가 이력 (최대 20점)',
|
||||
' 미허가 어선: 20',
|
||||
'',
|
||||
'CRITICAL ≥70 / HIGH ≥50',
|
||||
'MEDIUM ≥30 / LOW <30',
|
||||
'CRITICAL ≥70 / WATCH ≥50',
|
||||
'MONITOR ≥30 / NORMAL <30',
|
||||
'',
|
||||
'UCAF: 어구별 조업속도 매칭 비율',
|
||||
'UCFT: 조업-항행 구분 신뢰도',
|
||||
@ -83,7 +75,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
// 마운트 시 저장된 상태를 부모에 동기화
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { onExpandedChange?.(expanded); }, []);
|
||||
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
|
||||
const [selectedLevel, setSelectedLevel] = useState<AlertLevel | null>(null);
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
|
||||
@ -93,14 +85,14 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
if (!selectedLevel) return [];
|
||||
const list: VesselListItem[] = [];
|
||||
for (const [mmsi, dto] of analysisMap) {
|
||||
if (dto.algorithms.riskScore.level !== selectedLevel) continue;
|
||||
if (RISK_TO_ALERT[dto.algorithms.riskScore.level] !== selectedLevel) continue;
|
||||
const ship = ships.find(s => s.mmsi === mmsi);
|
||||
list.push({ mmsi, name: ship?.name || mmsi, score: dto.algorithms.riskScore.score, dto });
|
||||
}
|
||||
return list.sort((a, b) => b.score - a.score).slice(0, 50);
|
||||
}, [selectedLevel, analysisMap, ships]);
|
||||
|
||||
const handleLevelClick = (level: RiskLevel) => {
|
||||
const handleLevelClick = (level: AlertLevel) => {
|
||||
setSelectedLevel(prev => (prev === level ? null : level));
|
||||
setSelectedMmsi(null);
|
||||
};
|
||||
@ -272,8 +264,9 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
|
||||
{/* 위험도 카운트 행 — 클릭 가능 */}
|
||||
<div style={riskRowStyle}>
|
||||
{RISK_LEVELS.map(level => {
|
||||
const count = stats[level.toLowerCase() as 'critical' | 'high' | 'medium' | 'low'];
|
||||
{ALERT_LEVELS.map(level => {
|
||||
const count = stats[STATS_KEY_MAP[level]];
|
||||
const color = ALERT_COLOR[level];
|
||||
const isActive = selectedLevel === level;
|
||||
return (
|
||||
<button
|
||||
@ -284,8 +277,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
background: isActive ? `${RISK_COLOR[level]}22` : 'none',
|
||||
border: isActive ? `1px solid ${RISK_COLOR[level]}88` : '1px solid transparent',
|
||||
background: isActive ? `${color}22` : 'none',
|
||||
border: isActive ? `1px solid ${color}88` : '1px solid transparent',
|
||||
borderRadius: 4,
|
||||
color: '#cbd5e1',
|
||||
fontSize: 10,
|
||||
@ -294,8 +287,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
fontFamily: FONT_MONO,
|
||||
}}
|
||||
>
|
||||
<span>{RISK_EMOJI[level]}</span>
|
||||
<span style={{ color: RISK_COLOR[level], fontWeight: 700 }}>{count}</span>
|
||||
<span>{ALERT_EMOJI[level]}</span>
|
||||
<span style={{ color, fontWeight: 700 }}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -306,12 +299,12 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
<>
|
||||
<div style={{ ...dividerStyle, marginTop: 8 }} />
|
||||
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 4 }}>
|
||||
{RISK_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
|
||||
{ALERT_EMOJI[selectedLevel]} {selectedLevel} — {vesselList.length}척
|
||||
</div>
|
||||
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
||||
{vesselList.map(item => {
|
||||
const isExpanded = selectedMmsi === item.mmsi;
|
||||
const color = RISK_COLOR[selectedLevel];
|
||||
const color = ALERT_COLOR[selectedLevel];
|
||||
const { dto } = item;
|
||||
return (
|
||||
<div key={item.mmsi}>
|
||||
|
||||
460
frontend/src/components/korea/CorrelationPanel.tsx
Normal file
460
frontend/src/components/korea/CorrelationPanel.tsx
Normal file
@ -0,0 +1,460 @@
|
||||
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,10 +1,12 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
|
||||
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto, RiskLevel } from '../../types';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
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>();
|
||||
@ -57,22 +59,17 @@ const C = {
|
||||
border2: '#0E2035',
|
||||
} as const;
|
||||
|
||||
// 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';
|
||||
}
|
||||
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' }],
|
||||
};
|
||||
|
||||
function stateLabel(s: string): string {
|
||||
const map: Record<string, string> = {
|
||||
@ -110,6 +107,7 @@ interface Props {
|
||||
ships: Ship[];
|
||||
vesselAnalysis?: UseVesselAnalysisResult;
|
||||
onClose: () => void;
|
||||
onShowReport?: () => void;
|
||||
}
|
||||
|
||||
const PIPE_STEPS = [
|
||||
@ -124,14 +122,14 @@ const PIPE_STEPS = [
|
||||
|
||||
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
|
||||
|
||||
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowReport }: 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[]>([]);
|
||||
const [pipeStep, setPipeStep] = useState(0);
|
||||
// pipeStep 제거 — 파이프라인 상태는 analysisMap 존재 여부로 판단
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
// 중국 어선만 필터
|
||||
@ -141,35 +139,20 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
return cat === 'fishing' || s.category === 'fishing';
|
||||
}), [ships]);
|
||||
|
||||
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
|
||||
// 선박 데이터 처리 — Python 분석 결과 기반 (경량 분석 포함)
|
||||
const processed = useMemo((): ProcessedVessel[] => {
|
||||
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 };
|
||||
});
|
||||
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 };
|
||||
});
|
||||
}, [cnFishing, analysisMap]);
|
||||
|
||||
// 필터 + 정렬
|
||||
@ -189,9 +172,13 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
// 통계 — Python 분석 결과 기반
|
||||
const stats = useMemo(() => {
|
||||
let gpsAnomaly = 0;
|
||||
let bd09Detected = 0;
|
||||
for (const v of processed) {
|
||||
const dto = analysisMap.get(v.ship.mmsi);
|
||||
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
|
||||
if (dto) {
|
||||
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
|
||||
if (dto.algorithms.gpsSpoofing.bd09OffsetM > 100) bd09Detected++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: processed.length,
|
||||
@ -199,6 +186,7 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
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,
|
||||
@ -231,12 +219,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
// 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);
|
||||
@ -256,6 +238,14 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
[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);
|
||||
@ -349,6 +339,19 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
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}
|
||||
@ -430,38 +433,46 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
|
||||
<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: C.green, fontSize: 8 }}>●</span>
|
||||
<span style={{ float: 'right', color: analysisMap.size > 0 ? C.green : C.red, fontSize: 8 }}>●</span>
|
||||
</div>
|
||||
|
||||
{PIPE_STEPS.map((step, idx) => {
|
||||
const isRunning = idx === pipeStep % PIPE_STEPS.length;
|
||||
{PIPE_STEPS.map((step) => {
|
||||
const connected = analysisMap.size > 0;
|
||||
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: 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,
|
||||
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,
|
||||
}}>
|
||||
{isRunning ? 'PROC' : 'OK'}
|
||||
{connected ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{[
|
||||
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
|
||||
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
|
||||
{
|
||||
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 },
|
||||
].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: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: step.color,
|
||||
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,
|
||||
}}>
|
||||
{step.status}
|
||||
</span>
|
||||
@ -485,6 +496,35 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
<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>
|
||||
|
||||
{/* ── 중앙 패널: 선박 테이블 */}
|
||||
@ -819,6 +859,38 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
</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
152
frontend/src/components/korea/FleetClusterMapLayers.tsx
Normal file
152
frontend/src/components/korea/FleetClusterMapLayers.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
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;
|
||||
175
frontend/src/components/korea/FleetGearListPanel.tsx
Normal file
175
frontend/src/components/korea/FleetGearListPanel.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
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;
|
||||
279
frontend/src/components/korea/GearGroupSection.tsx
Normal file
279
frontend/src/components/korea/GearGroupSection.tsx
Normal file
@ -0,0 +1,279 @@
|
||||
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;
|
||||
570
frontend/src/components/korea/HistoryReplayController.tsx
Normal file
570
frontend/src/components/korea/HistoryReplayController.tsx
Normal file
@ -0,0 +1,570 @@
|
||||
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,8 +2,14 @@ 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';
|
||||
@ -79,8 +85,15 @@ 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();
|
||||
|
||||
@ -164,6 +177,13 @@ 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,
|
||||
koreaData.visibleShips,
|
||||
@ -260,7 +280,25 @@ export const KoreaDashboard = ({
|
||||
return (
|
||||
<>
|
||||
{headerSlot && createPortal(
|
||||
<div className="mode-toggle">
|
||||
<>
|
||||
<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">
|
||||
<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')}
|
||||
@ -293,7 +331,12 @@ export const KoreaDashboard = ({
|
||||
onClick={() => setShowFieldAnalysis(v => !v)} title="현장분석">
|
||||
<span className="text-[11px]">📊</span>현장분석
|
||||
</button>
|
||||
</div>,
|
||||
<button type="button" className={`mode-btn ${showOpsGuide ? 'active' : ''}`}
|
||||
onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드">
|
||||
<span className="text-[11px]">⚓</span>작전가이드
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
headerSlot,
|
||||
)}
|
||||
{countsSlot && createPortal(
|
||||
@ -312,6 +355,18 @@ 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
|
||||
@ -332,6 +387,11 @@ export const KoreaDashboard = ({
|
||||
groupPolygons={groupPolygons}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
hiddenNationalities={hiddenNationalities}
|
||||
externalFlyTo={externalFlyTo}
|
||||
onExternalFlyToDone={() => setExternalFlyTo(null)}
|
||||
opsRoute={opsRoute}
|
||||
mapMode={mapMode}
|
||||
encSettings={encSettings}
|
||||
/>
|
||||
<div className="map-overlay-left">
|
||||
<LayerPanel
|
||||
|
||||
@ -2,15 +2,25 @@ 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 { ShipLayer } from '../layers/ShipLayer';
|
||||
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
||||
import { useShipDeckLayers } from '../../hooks/useShipDeckLayers';
|
||||
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||
import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay';
|
||||
import { InfraLayer } from './InfraLayer';
|
||||
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||
import { AircraftLayer } from '../layers/AircraftLayer';
|
||||
@ -69,6 +79,11 @@ interface Props {
|
||||
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
|
||||
@ -110,6 +125,60 @@ 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;
|
||||
@ -144,9 +213,47 @@ const FILTER_I18N_KEY: Record<string, string> = {
|
||||
cnFishing: 'filters.cnFishingMonitor',
|
||||
};
|
||||
|
||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities }: Props) {
|
||||
// [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) {
|
||||
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);
|
||||
@ -161,17 +268,66 @@ 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 });
|
||||
@ -179,6 +335,13 @@ 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]);
|
||||
@ -196,12 +359,10 @@ 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: 12 },
|
||||
{ padding: 60, duration: 1500, maxZoom: 10 },
|
||||
);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
@ -336,7 +497,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
|
||||
// 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl)
|
||||
const selectedGearLayers = useMemo(() => {
|
||||
if (!selectedGearData) return [];
|
||||
if (!selectedGearData || replayFocusMode) return [];
|
||||
const { parent, gears, groupName } = selectedGearData;
|
||||
const layers = [];
|
||||
|
||||
@ -414,11 +575,11 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}
|
||||
|
||||
return layers;
|
||||
}, [selectedGearData, zoomScale, fontScale.analysis]);
|
||||
}, [selectedGearData, zoomScale, fontScale.analysis, replayFocusMode]);
|
||||
|
||||
// 선택된 선단 소속 선박 강조 레이어 (deck.gl)
|
||||
const selectedFleetLayers = useMemo(() => {
|
||||
if (!selectedFleetData) return [];
|
||||
if (!selectedFleetData || replayFocusMode) return [];
|
||||
const { ships: fleetShips, clusterId } = selectedFleetData;
|
||||
if (fleetShips.length === 0) return [];
|
||||
|
||||
@ -433,7 +594,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: Layer[] = [];
|
||||
const result: DeckLayer[] = [];
|
||||
|
||||
// 소속 선박 — 강조 원형
|
||||
result.push(new ScatterplotLayer({
|
||||
@ -500,7 +661,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis]);
|
||||
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis, replayFocusMode]);
|
||||
|
||||
// 분석 결과 deck.gl 레이어
|
||||
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
|
||||
@ -508,27 +669,16 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
: koreaFilters.cnFishing ? 'cnFishing'
|
||||
: null;
|
||||
|
||||
// AI 분석 가상 선박 마커 GeoJSON (분석 대상 선박을 삼각형으로 표시)
|
||||
const analysisShipMarkersGeoJson = useMemo(() => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (!vesselAnalysis || !analysisActiveFilter) return { type: 'FeatureCollection' as const, features };
|
||||
const allS = allShips ?? ships;
|
||||
for (const s of allS) {
|
||||
const dto = vesselAnalysis.analysisMap.get(s.mmsi);
|
||||
if (!dto) continue;
|
||||
const level = dto.algorithms.riskScore.level;
|
||||
const color = level === 'CRITICAL' ? '#ef4444' : level === 'HIGH' ? '#f97316' : level === 'MEDIUM' ? '#eab308' : '#22c55e';
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { mmsi: s.mmsi, name: s.name || s.mmsi, cog: s.heading ?? 0, color, baseSize: 0.16 },
|
||||
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection' as const, features };
|
||||
}, [vesselAnalysis, analysisActiveFilter, allShips, ships]);
|
||||
// shipDeckStore에 분석 상태 동기화
|
||||
useEffect(() => {
|
||||
useShipDeckStore.getState().setAnalysis(
|
||||
vesselAnalysis?.analysisMap ?? null,
|
||||
analysisActiveFilter,
|
||||
);
|
||||
}, [vesselAnalysis?.analysisMap, analysisActiveFilter]);
|
||||
|
||||
const analysisDeckLayers = useAnalysisDeckLayers(
|
||||
vesselAnalysis?.analysisMap ?? new Map(),
|
||||
vesselAnalysis?.analysisMap ?? (new globalThis.Map() as globalThis.Map<string, import('../../types').VesselAnalysisDto>),
|
||||
allShips ?? ships,
|
||||
analysisActiveFilter,
|
||||
zoomScale,
|
||||
@ -539,11 +689,33 @@ 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={MAP_STYLE}
|
||||
mapStyle={activeMapStyle}
|
||||
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"
|
||||
@ -605,13 +777,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{layers.ships && <ShipLayer
|
||||
ships={anyKoreaFilterOn ? ships : (allShips ?? ships)}
|
||||
militaryOnly={layers.militaryOnly}
|
||||
analysisMap={vesselAnalysis?.analysisMap}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
hiddenNationalities={hiddenNationalities}
|
||||
/>}
|
||||
{/* ShipLayer → deck.gl (useShipDeckLayers) 전환 완료 */}
|
||||
{/* 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">
|
||||
@ -683,13 +849,18 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
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 && (
|
||||
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && (
|
||||
<AnalysisOverlay
|
||||
ships={allShips ?? ships}
|
||||
analysisMap={vesselAnalysis.analysisMap}
|
||||
@ -698,47 +869,29 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI 분석 가상 선박 마커 (삼각형 + 방향 + 줌 스케일) */}
|
||||
{analysisActiveFilter && (
|
||||
<Source id="analysis-ship-markers" type="geojson" data={analysisShipMarkersGeoJson}>
|
||||
<Layer
|
||||
id="analysis-ship-icon"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'icon-image': 'ship-triangle',
|
||||
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||
4, ['*', ['get', 'baseSize'], 1.0],
|
||||
6, ['*', ['get', 'baseSize'], 1.3],
|
||||
8, ['*', ['get', 'baseSize'], 2.0],
|
||||
10, ['*', ['get', 'baseSize'], 2.8],
|
||||
12, ['*', ['get', 'baseSize'], 3.5],
|
||||
13, ['*', ['get', 'baseSize'], 4.2],
|
||||
14, ['*', ['get', 'baseSize'], 5.0],
|
||||
],
|
||||
'icon-rotate': ['get', 'cog'],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
}}
|
||||
paint={{
|
||||
'icon-color': ['get', 'color'],
|
||||
'icon-halo-color': 'rgba(0,0,0,0.6)',
|
||||
'icon-halo-width': 0.5,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
{/* analysisShipMarkers → deck.gl (useShipDeckLayers) 전환 완료 */}
|
||||
|
||||
{/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */}
|
||||
<DeckGLOverlay layers={[
|
||||
...staticDeckLayers,
|
||||
illegalFishingLayer,
|
||||
illegalFishingLabelLayer,
|
||||
zoneLabelsLayer,
|
||||
...selectedGearLayers,
|
||||
...selectedFleetLayers,
|
||||
...(analysisPanelOpen ? analysisDeckLayers : []),
|
||||
].filter(Boolean)} />
|
||||
{/* 선박 호버 툴팁 + 클릭 팝업 — 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];
|
||||
})()}
|
||||
/>
|
||||
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
|
||||
{staticPickInfo && (
|
||||
<StaticFacilityPopup pickInfo={staticPickInfo} onClose={() => setStaticPickInfo(null)} />
|
||||
@ -926,6 +1079,40 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
410
frontend/src/components/korea/OpsGuideModal.tsx
Normal file
410
frontend/src/components/korea/OpsGuideModal.tsx
Normal file
@ -0,0 +1,410 @@
|
||||
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 };
|
||||
1404
frontend/src/components/korea/ParentReviewPanel.tsx
Normal file
1404
frontend/src/components/korea/ParentReviewPanel.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
335
frontend/src/components/korea/ReportModal.tsx
Normal file
335
frontend/src/components/korea/ReportModal.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
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)}%`;
|
||||
}
|
||||
90
frontend/src/components/korea/debug/DevCoordDebug.tsx
Normal file
90
frontend/src/components/korea/debug/DevCoordDebug.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* [DEBUG] 좌표 디버그 — 자체 완결형 래퍼
|
||||
* KoreaMap에서 이 컴포넌트만 동적 import하면 프로덕션 번들에서 완전 제거됨.
|
||||
* import, 훅, 컴포넌트 모두 이 파일 안에서 독립적으로 존재.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
|
||||
interface CoordPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
function toDMS(dd: number, axis: 'lat' | 'lng'): string {
|
||||
const dir = axis === 'lat' ? (dd >= 0 ? 'N' : 'S') : (dd >= 0 ? 'E' : 'W');
|
||||
const abs = Math.abs(dd);
|
||||
const d = Math.floor(abs);
|
||||
const mFull = (abs - d) * 60;
|
||||
const m = Math.floor(mFull);
|
||||
const s = ((mFull - m) * 60).toFixed(2);
|
||||
return `${d}°${String(m).padStart(2, '0')}′${String(s).padStart(5, '0')}″${dir}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mapRef: React.RefObject<MapRef | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자체 완결형 디버그 오버레이.
|
||||
* mapRef를 받아서 직접 click 이벤트를 등록/해제.
|
||||
* KoreaMap의 기존 코드에 어떤 것도 섞이지 않음.
|
||||
*/
|
||||
export default function DevCoordDebug({ mapRef }: Props) {
|
||||
const [points, setPoints] = useState<CoordPoint[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current?.getMap();
|
||||
if (!map) return;
|
||||
|
||||
const handler = (e: maplibregl.MapMouseEvent) => {
|
||||
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
|
||||
e.originalEvent.preventDefault();
|
||||
setPoints(prev => [...prev, { lat: e.lngLat.lat, lng: e.lngLat.lng, id: Date.now() }]);
|
||||
}
|
||||
};
|
||||
|
||||
map.on('click', handler);
|
||||
return () => { map.off('click', handler); };
|
||||
}, [mapRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{points.map(cp => (
|
||||
<div key={cp.id}>
|
||||
<Marker longitude={cp.lng} latitude={cp.lat}>
|
||||
<div style={{
|
||||
width: 12, height: 12, borderRadius: '50%',
|
||||
background: '#f43f5e', border: '2px solid #fff',
|
||||
boxShadow: '0 0 6px rgba(244,63,94,0.8)',
|
||||
}} />
|
||||
</Marker>
|
||||
<Popup
|
||||
longitude={cp.lng}
|
||||
latitude={cp.lat}
|
||||
onClose={() => setPoints(prev => prev.filter(p => p.id !== cp.id))}
|
||||
closeButton={true}
|
||||
closeOnClick={false}
|
||||
anchor="bottom"
|
||||
offset={[0, -10]}
|
||||
style={{ zIndex: 50 }}
|
||||
>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 11, lineHeight: 1.8, padding: '2px 4px', color: '#fff' }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4, borderBottom: '1px solid rgba(255,255,255,0.3)', paddingBottom: 2, color: '#93c5fd' }}>
|
||||
WGS84 (EPSG:4326)
|
||||
</div>
|
||||
<div><b>DD</b></div>
|
||||
<div style={{ paddingLeft: 8 }}>{cp.lat.toFixed(6)}°N</div>
|
||||
<div style={{ paddingLeft: 8 }}>{cp.lng.toFixed(6)}°E</div>
|
||||
<div style={{ marginTop: 2 }}><b>DMS</b></div>
|
||||
<div style={{ paddingLeft: 8 }}>{toDMS(cp.lat, 'lat')}</div>
|
||||
<div style={{ paddingLeft: 8 }}>{toDMS(cp.lng, 'lng')}</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/korea/debug/index.tsx
Normal file
24
frontend/src/components/korea/debug/index.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* [DEBUG] 디버그 도구 허브 — 모든 개발용 도구를 조합하여 export
|
||||
*
|
||||
* 디버그 도구 추가/제거 시 이 파일만 수정하면 됨.
|
||||
* KoreaMap에서는 이 파일만 lazy import.
|
||||
* 프로덕션 빌드에서 이 파일과 하위 도구 전체가 번들에서 제거됨.
|
||||
*/
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import DevCoordDebug from './DevCoordDebug';
|
||||
|
||||
interface Props {
|
||||
mapRef: React.RefObject<MapRef | null>;
|
||||
}
|
||||
|
||||
export default function DebugTools({ mapRef }: Props) {
|
||||
return (
|
||||
<>
|
||||
<DevCoordDebug mapRef={mapRef} />
|
||||
{/* 디버그 도구 추가 시 여기에 한 줄 추가 */}
|
||||
{/* <DevZoneOverlay mapRef={mapRef} /> */}
|
||||
{/* <DevPerformance mapRef={mapRef} /> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
frontend/src/components/korea/fleetClusterConstants.ts
Normal file
116
frontend/src/components/korea/fleetClusterConstants.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
|
||||
// ── 모델 순서/색상/설명 ──
|
||||
export const MODEL_ORDER = ['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'] as const;
|
||||
|
||||
export const MODEL_COLORS: Record<string, string> = {
|
||||
'identity': '#f97316',
|
||||
'default': '#3b82f6',
|
||||
'aggressive': '#22c55e',
|
||||
'conservative': '#a855f7',
|
||||
'proximity-heavy': '#06b6d4',
|
||||
'visit-pattern': '#f43f5e',
|
||||
};
|
||||
|
||||
export const MODEL_DESC: Record<string, { summary: string; details: string[] }> = {
|
||||
'identity': {
|
||||
summary: '이름 패턴매칭 — 동일 모선명 기반 어구 그룹',
|
||||
details: [
|
||||
'패턴: NAME_인덱스 (_ 필수, 공백만은 선박)',
|
||||
'거리제한: ~10NM 이내 연결 클러스터링',
|
||||
'모선연결: 어구와 ~20NM 이내 시 포함',
|
||||
],
|
||||
},
|
||||
'default': {
|
||||
summary: '기본 모델 — 균형 가중치',
|
||||
details: [
|
||||
'어구-선박: 근접도 45% · 방문 35% · 활동동기화 20%',
|
||||
'선박-선박: DTW 30% · SOG 20% · COG 25% · 근접비 25%',
|
||||
'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%',
|
||||
'감쇠: 정상 0.015/5분 · 장기(6h+) 0.08/5분',
|
||||
'근접판정: 5NM · 후보반경: 그룹×3배',
|
||||
],
|
||||
},
|
||||
'aggressive': {
|
||||
summary: '공격적 추적 — 빠른 상승, 약한 감쇠',
|
||||
details: [
|
||||
'어구-선박: 근접도 55% · 방문 25% · 활동동기화 20%',
|
||||
'EMA: α 0.40→0.10 · 추적시작 40% · 폴리곤 60%',
|
||||
'감쇠: 정상 0.010/5분 · 장기(8h+) 0.06/5분',
|
||||
'근접판정: 7NM · 후보반경: 그룹×4배',
|
||||
'야간보너스: ×1.5 · shadow: 체류+0.15 복귀+0.20',
|
||||
],
|
||||
},
|
||||
'conservative': {
|
||||
summary: '보수적 추적 — 높은 임계값, 강한 감쇠',
|
||||
details: [
|
||||
'어구-선박: 근접도 40% · 방문 40% · 활동동기화 20%',
|
||||
'EMA: α 0.20→0.05 · 추적시작 60% · 폴리곤 80%',
|
||||
'감쇠: 정상 0.020/5분 · 장기(4h+) 0.10/5분',
|
||||
'근접판정: 4NM · 후보반경: 그룹×2.5배',
|
||||
'야간보너스: ×1.2 · shadow: 체류+0.08 복귀+0.12',
|
||||
],
|
||||
},
|
||||
'proximity-heavy': {
|
||||
summary: '근접 중심 — 거리 기반 판단 우선',
|
||||
details: [
|
||||
'어구-선박: 근접도 70% · 방문 20% · 활동동기화 10%',
|
||||
'선박-선박: 근접비 50% · DTW 20% · SOG 15% · COG 15%',
|
||||
'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%',
|
||||
'근접판정: 5NM · 후보반경: 그룹×3배',
|
||||
'shadow: 체류+0.12 복귀+0.18',
|
||||
],
|
||||
},
|
||||
'visit-pattern': {
|
||||
summary: '방문 패턴 — 반복 접근 추적',
|
||||
details: [
|
||||
'어구-선박: 근접도 25% · 방문 55% · 활동동기화 20%',
|
||||
'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%',
|
||||
'근접판정: 6NM · 후보반경: 그룹×3.5배',
|
||||
'야간보너스: ×1.4',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── 패널 스타일 상수 ──
|
||||
export const panelStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 10,
|
||||
zIndex: 10,
|
||||
minWidth: 220,
|
||||
maxWidth: 300,
|
||||
backgroundColor: 'rgba(12, 24, 37, 0.92)',
|
||||
border: '1px solid rgba(99, 179, 237, 0.25)',
|
||||
borderRadius: 8,
|
||||
color: '#e2e8f0',
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 11,
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
pointerEvents: 'auto',
|
||||
maxHeight: 'min(45vh, 400px)',
|
||||
};
|
||||
|
||||
export const headerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '6px 10px',
|
||||
borderBottom: 'none',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
export const toggleButtonStyle: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
cursor: 'pointer',
|
||||
fontSize: 10,
|
||||
padding: '0 2px',
|
||||
lineHeight: 1,
|
||||
};
|
||||
74
frontend/src/components/korea/fleetClusterTypes.ts
Normal file
74
frontend/src/components/korea/fleetClusterTypes.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import type { MemberInfo, GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
|
||||
// ── 서브클러스터 프레임 ──
|
||||
export interface SubFrame {
|
||||
subClusterId: number; // 0=통합, 1,2,...=분리
|
||||
centerLon: number;
|
||||
centerLat: number;
|
||||
members: MemberInfo[];
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
// ── 히스토리 스냅샷 + 보간 플래그 ──
|
||||
export type HistoryFrame = GroupPolygonDto & {
|
||||
_interp?: boolean;
|
||||
_longGap?: boolean;
|
||||
subFrames: SubFrame[]; // 항상 1개 이상
|
||||
};
|
||||
|
||||
// ── 외부 노출 타입 (KoreaMap에서 import) ──
|
||||
export interface SelectedGearGroupData {
|
||||
parent: Ship | null;
|
||||
gears: Ship[];
|
||||
groupName: string;
|
||||
}
|
||||
|
||||
export interface SelectedFleetData {
|
||||
clusterId: number;
|
||||
ships: Ship[];
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
// ── 내부 공유 타입 ──
|
||||
export interface HoverTooltipState {
|
||||
lng: number;
|
||||
lat: number;
|
||||
type: 'fleet' | 'gear';
|
||||
id: number | string;
|
||||
groupKey?: string;
|
||||
subClusterId?: number;
|
||||
compositeKey?: string;
|
||||
}
|
||||
|
||||
export interface PickerCandidate {
|
||||
name: string;
|
||||
count: number;
|
||||
inZone: boolean;
|
||||
isFleet: boolean;
|
||||
clusterId?: number;
|
||||
}
|
||||
|
||||
export interface GearPickerPopupState {
|
||||
lng: number;
|
||||
lat: number;
|
||||
candidates: PickerCandidate[];
|
||||
}
|
||||
|
||||
export interface FleetListItem {
|
||||
id: number;
|
||||
mmsiList: string[];
|
||||
label: string;
|
||||
memberCount: number;
|
||||
areaSqNm: number;
|
||||
color: string;
|
||||
members: MemberInfo[];
|
||||
}
|
||||
|
||||
// ── 상수 ──
|
||||
export const GEAR_BUFFER_DEG = 0.01;
|
||||
export const CIRCLE_SEGMENTS = 16;
|
||||
export const TIMELINE_DURATION_MS = 12 * 60 * 60_000; // 12시간
|
||||
export const PLAYBACK_CYCLE_SEC = 30; // 30초에 12시간 전체 재생
|
||||
export const TICK_MS = 50; // 50ms 간격 업데이트
|
||||
export const EMPTY_ANALYSIS = new globalThis.Map<string, VesselAnalysisDto>();
|
||||
272
frontend/src/components/korea/fleetClusterUtils.ts
Normal file
272
frontend/src/components/korea/fleetClusterUtils.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
import type { HistoryFrame, SubFrame } from './fleetClusterTypes';
|
||||
import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes';
|
||||
|
||||
/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */
|
||||
export function interpolateTrackPosition(
|
||||
track: { ts: number; lat: number; lon: number; cog: number }[],
|
||||
timeMs: number,
|
||||
): { lat: number; lon: number; cog: number } | null {
|
||||
if (track.length === 0) return null;
|
||||
if (track.length === 1) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog };
|
||||
if (timeMs <= track[0].ts) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog };
|
||||
if (timeMs >= track[track.length - 1].ts) {
|
||||
const last = track[track.length - 1];
|
||||
return { lat: last.lat, lon: last.lon, cog: last.cog };
|
||||
}
|
||||
// Binary search for surrounding points
|
||||
let lo = 0, hi = track.length - 1;
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (track[mid].ts <= timeMs) lo = mid; else hi = mid;
|
||||
}
|
||||
const a = track[lo], b = track[hi];
|
||||
const t = (timeMs - a.ts) / (b.ts - a.ts);
|
||||
return {
|
||||
lat: a.lat + t * (b.lat - a.lat),
|
||||
lon: a.lon + t * (b.lon - a.lon),
|
||||
cog: b.cog,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convex hull + buffer 기반 폴리곤 생성 (Python polygon_builder.py 동일 로직)
|
||||
* - 1점: 원형 버퍼 (GEAR_BUFFER_DEG=0.01 ~ 1.1km)
|
||||
* - 2점: 두 점 잇는 직선 양쪽 버퍼
|
||||
* - 3점+: convex hull + 버퍼
|
||||
*/
|
||||
export function buildInterpPolygon(points: [number, number][]): GeoJSON.Polygon | null {
|
||||
if (points.length === 0) return null;
|
||||
|
||||
if (points.length === 1) {
|
||||
const [cx, cy] = points[0];
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= CIRCLE_SEGMENTS; i++) {
|
||||
const angle = (2 * Math.PI * i) / CIRCLE_SEGMENTS;
|
||||
ring.push([cx + GEAR_BUFFER_DEG * Math.cos(angle), cy + GEAR_BUFFER_DEG * Math.sin(angle)]);
|
||||
}
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
if (points.length === 2) {
|
||||
const [p1, p2] = points;
|
||||
const dx = p2[0] - p1[0];
|
||||
const dy = p2[1] - p1[1];
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1e-10;
|
||||
const nx = (-dy / len) * GEAR_BUFFER_DEG;
|
||||
const ny = (dx / len) * GEAR_BUFFER_DEG;
|
||||
const ring: [number, number][] = [];
|
||||
const half = CIRCLE_SEGMENTS / 2;
|
||||
ring.push([p1[0] + nx, p1[1] + ny]);
|
||||
ring.push([p2[0] + nx, p2[1] + ny]);
|
||||
const a2 = Math.atan2(ny, nx);
|
||||
for (let i = 0; i <= half; i++) {
|
||||
const angle = a2 - Math.PI * i / half;
|
||||
ring.push([p2[0] + GEAR_BUFFER_DEG * Math.cos(angle), p2[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
|
||||
}
|
||||
ring.push([p1[0] - nx, p1[1] - ny]);
|
||||
const a1 = Math.atan2(-ny, -nx);
|
||||
for (let i = 0; i <= half; i++) {
|
||||
const angle = a1 - Math.PI * i / half;
|
||||
ring.push([p1[0] + GEAR_BUFFER_DEG * Math.cos(angle), p1[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
|
||||
}
|
||||
ring.push(ring[0]);
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
const hull = convexHull(points);
|
||||
return bufferPolygon(hull, GEAR_BUFFER_DEG);
|
||||
}
|
||||
|
||||
/** 단순 convex hull (Graham scan) */
|
||||
export function convexHull(points: [number, number][]): [number, number][] {
|
||||
const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (pts.length <= 2) return pts;
|
||||
|
||||
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
|
||||
const lower: [number, number][] = [];
|
||||
for (const p of pts) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
||||
lower.push(p);
|
||||
}
|
||||
const upper: [number, number][] = [];
|
||||
for (let i = pts.length - 1; i >= 0; i--) {
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pts[i]) <= 0) upper.pop();
|
||||
upper.push(pts[i]);
|
||||
}
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
/** 폴리곤 외곽에 buffer 적용 (각 변의 오프셋 + 꼭짓점 라운드) */
|
||||
export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Polygon {
|
||||
const ring: [number, number][] = [];
|
||||
const n = hull.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = hull[i];
|
||||
const prev = hull[(i - 1 + n) % n];
|
||||
const next = hull[(i + 1) % n];
|
||||
const a1 = Math.atan2(p[1] - prev[1], p[0] - prev[0]) - Math.PI / 2;
|
||||
const a2 = Math.atan2(next[1] - p[1], next[0] - p[0]) - Math.PI / 2;
|
||||
const startA = a1;
|
||||
let endA = a2;
|
||||
if (endA < startA) endA += 2 * Math.PI;
|
||||
const steps = Math.max(2, Math.round((endA - startA) / (Math.PI / 8)));
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const a = startA + (endA - startA) * s / steps;
|
||||
ring.push([p[0] + buf * Math.cos(a), p[1] + buf * Math.sin(a)]);
|
||||
}
|
||||
}
|
||||
ring.push(ring[0]);
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환.
|
||||
* - gap <= 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동)
|
||||
* - gap > 30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성
|
||||
*
|
||||
* subFrames 보간 규칙:
|
||||
* - prev/next 양쪽에 동일 subClusterId 존재: 멤버/center 보간
|
||||
* - prev에만 존재: 마지막 위치 그대로 frozen
|
||||
* - next에만 존재: 갭 프레임에서 생략
|
||||
*
|
||||
* top-level members/centerLon/Lat: 전체 subFrames의 union (하위 호환)
|
||||
*/
|
||||
export function fillGapFrames(snapshots: HistoryFrame[]): HistoryFrame[] {
|
||||
if (snapshots.length < 2) return snapshots;
|
||||
const STEP_SHORT_MS = 300_000;
|
||||
const STEP_LONG_MS = 1_800_000;
|
||||
const THRESHOLD_MS = 1_800_000;
|
||||
const result: HistoryFrame[] = [];
|
||||
|
||||
for (let i = 0; i < snapshots.length; i++) {
|
||||
result.push(snapshots[i]);
|
||||
if (i >= snapshots.length - 1) continue;
|
||||
|
||||
const prev = snapshots[i];
|
||||
const next = snapshots[i + 1];
|
||||
const t0 = new Date(prev.snapshotTime).getTime();
|
||||
const t1 = new Date(next.snapshotTime).getTime();
|
||||
const gap = t1 - t0;
|
||||
if (gap <= STEP_SHORT_MS) continue;
|
||||
|
||||
const nextMap = new Map(next.members.map(m => [m.mmsi, m]));
|
||||
const common = prev.members.filter(m => nextMap.has(m.mmsi));
|
||||
if (common.length === 0) continue;
|
||||
|
||||
const nextSubMap = new Map(next.subFrames.map(sf => [sf.subClusterId, sf]));
|
||||
|
||||
if (gap <= THRESHOLD_MS) {
|
||||
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
|
||||
const ratio = (t - t0) / gap;
|
||||
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio;
|
||||
const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio;
|
||||
|
||||
// prev 기준으로 순회: prev에만 존재(frozen) + 양쪽 존재(center 보간)
|
||||
// next에만 존재하는 subClusterId는 prev.subFrames에 없으므로 자동 생략
|
||||
const subFrames: SubFrame[] = prev.subFrames.map(psf => {
|
||||
const nsf = nextSubMap.get(psf.subClusterId);
|
||||
if (!nsf) {
|
||||
// prev에만 존재 → frozen
|
||||
return { ...psf };
|
||||
}
|
||||
// 양쪽 존재 → center 보간
|
||||
return {
|
||||
...psf,
|
||||
centerLon: psf.centerLon + (nsf.centerLon - psf.centerLon) * ratio,
|
||||
centerLat: psf.centerLat + (nsf.centerLat - psf.centerLat) * ratio,
|
||||
};
|
||||
});
|
||||
|
||||
result.push({
|
||||
...prev,
|
||||
snapshotTime: new Date(t).toISOString(),
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
subFrames,
|
||||
_interp: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
|
||||
const ratio = (t - t0) / gap;
|
||||
|
||||
// top-level members 보간 (하위 호환)
|
||||
const topPositions: [number, number][] = [];
|
||||
const topMembers: GroupPolygonDto['members'] = [];
|
||||
for (const pm of common) {
|
||||
const nm = nextMap.get(pm.mmsi)!;
|
||||
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
|
||||
const lat = pm.lat + (nm.lat - pm.lat) * ratio;
|
||||
const dLon = nm.lon - pm.lon;
|
||||
const dLat = nm.lat - pm.lat;
|
||||
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
|
||||
topMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
||||
topPositions.push([lon, lat]);
|
||||
}
|
||||
const cLon = topPositions.reduce((s, p) => s + p[0], 0) / topPositions.length;
|
||||
const cLat = topPositions.reduce((s, p) => s + p[1], 0) / topPositions.length;
|
||||
const polygon = buildInterpPolygon(topPositions);
|
||||
|
||||
// subFrames 보간
|
||||
const subFrames: SubFrame[] = prev.subFrames.map(psf => {
|
||||
const nsf = nextSubMap.get(psf.subClusterId);
|
||||
if (!nsf) {
|
||||
// prev에만 존재 → frozen
|
||||
return { ...psf };
|
||||
}
|
||||
// 양쪽 존재 → 멤버 위치 보간 + 폴리곤 재생성
|
||||
const nsfMemberMap = new Map(nsf.members.map(m => [m.mmsi, m]));
|
||||
const commonSfMembers = psf.members.filter(m => nsfMemberMap.has(m.mmsi));
|
||||
const sfPositions: [number, number][] = [];
|
||||
const sfMembers: SubFrame['members'] = [];
|
||||
|
||||
for (const pm of commonSfMembers) {
|
||||
const nm = nsfMemberMap.get(pm.mmsi)!;
|
||||
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
|
||||
const lat = pm.lat + (nm.lat - pm.lat) * ratio;
|
||||
const dLon = nm.lon - pm.lon;
|
||||
const dLat = nm.lat - pm.lat;
|
||||
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
|
||||
sfMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
||||
sfPositions.push([lon, lat]);
|
||||
}
|
||||
|
||||
if (sfPositions.length === 0) {
|
||||
// 공통 멤버 없으면 frozen
|
||||
return { ...psf };
|
||||
}
|
||||
|
||||
const sfCLon = sfPositions.reduce((s, p) => s + p[0], 0) / sfPositions.length;
|
||||
const sfCLat = sfPositions.reduce((s, p) => s + p[1], 0) / sfPositions.length;
|
||||
|
||||
return {
|
||||
subClusterId: psf.subClusterId,
|
||||
centerLon: sfCLon,
|
||||
centerLat: sfCLat,
|
||||
members: sfMembers,
|
||||
memberCount: sfMembers.length,
|
||||
};
|
||||
});
|
||||
|
||||
result.push({
|
||||
...prev,
|
||||
snapshotTime: new Date(t).toISOString(),
|
||||
polygon,
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
memberCount: topMembers.length,
|
||||
members: topMembers,
|
||||
subFrames,
|
||||
_interp: true,
|
||||
_longGap: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
15
frontend/src/components/korea/parentInferenceConstants.ts
Normal file
15
frontend/src/components/korea/parentInferenceConstants.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export const MIN_PARENT_REVIEW_SCORE = 0.3;
|
||||
export const MIN_PARENT_REVIEW_SCORE_PCT = 30;
|
||||
export const MIN_PARENT_REVIEW_MEMBER_COUNT = 2;
|
||||
export const REPLAY_COMPARE_PANEL_WIDTH_RATIO = 0.7;
|
||||
export const KOREA_SIDE_PANEL_WIDTH = 300;
|
||||
export const FLEET_LIST_PANEL_MAX_WIDTH = 300;
|
||||
export const FLEET_LIST_PANEL_LEFT_OFFSET = 10;
|
||||
export const ANALYSIS_PANEL_MAX_WIDTH = 280;
|
||||
export const ANALYSIS_PANEL_RIGHT_OFFSET = 50;
|
||||
export const REVIEW_PANEL_MAX_WIDTH = 560;
|
||||
export const REVIEW_PANEL_RIGHT_OFFSET = 16;
|
||||
export const REPLAY_CENTER_SAFE_GAP = 8;
|
||||
export const REPLAY_LEFT_RESERVED_WIDTH = FLEET_LIST_PANEL_LEFT_OFFSET + FLEET_LIST_PANEL_MAX_WIDTH + REPLAY_CENTER_SAFE_GAP;
|
||||
export const REPLAY_ANALYSIS_RESERVED_WIDTH = ANALYSIS_PANEL_MAX_WIDTH + ANALYSIS_PANEL_RIGHT_OFFSET + REPLAY_CENTER_SAFE_GAP;
|
||||
export const REPLAY_REVIEW_RESERVED_WIDTH = REVIEW_PANEL_MAX_WIDTH + REVIEW_PANEL_RIGHT_OFFSET + REPLAY_CENTER_SAFE_GAP;
|
||||
13
frontend/src/components/korea/parentReviewCandidateColors.ts
Normal file
13
frontend/src/components/korea/parentReviewCandidateColors.ts
Normal file
@ -0,0 +1,13 @@
|
||||
const PARENT_REVIEW_CANDIDATE_COLORS = [
|
||||
'#22d3ee',
|
||||
'#f59e0b',
|
||||
'#a78bfa',
|
||||
'#34d399',
|
||||
'#fb7185',
|
||||
'#60a5fa',
|
||||
] as const;
|
||||
|
||||
export function getParentReviewCandidateColor(rank: number): string {
|
||||
const index = Math.max(0, (rank || 1) - 1) % PARENT_REVIEW_CANDIDATE_COLORS.length;
|
||||
return PARENT_REVIEW_CANDIDATE_COLORS[index];
|
||||
}
|
||||
474
frontend/src/components/korea/useFleetClusterGeoJson.ts
Normal file
474
frontend/src/components/korea/useFleetClusterGeoJson.ts
Normal file
@ -0,0 +1,474 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { GeoJSON } from 'geojson';
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import type { GearCorrelationItem, CorrelationVesselTrack, GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import type { FleetListItem } from './fleetClusterTypes';
|
||||
import { buildInterpPolygon } from './fleetClusterUtils';
|
||||
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
|
||||
|
||||
export interface UseFleetClusterGeoJsonParams {
|
||||
ships: Ship[];
|
||||
shipMap: Map<string, Ship>;
|
||||
groupPolygons: UseGroupPolygonsResult | undefined;
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
hoveredFleetId: number | null;
|
||||
hoveredGearCompositeKey?: string | null;
|
||||
visibleGearCompositeKeys?: Set<string> | null;
|
||||
selectedGearGroup: string | null;
|
||||
selectedGearCompositeKey?: string | null;
|
||||
pickerHoveredGroup: string | null;
|
||||
historyActive: boolean;
|
||||
correlationData: GearCorrelationItem[];
|
||||
correlationTracks: CorrelationVesselTrack[];
|
||||
enabledModels: Set<string>;
|
||||
enabledVessels: Set<string>;
|
||||
hoveredMmsi: string | null;
|
||||
}
|
||||
|
||||
export interface FleetClusterGeoJsonResult {
|
||||
// static/base GeoJSON
|
||||
fleetPolygonGeoJSON: GeoJSON;
|
||||
lineGeoJSON: GeoJSON;
|
||||
hoveredGeoJSON: GeoJSON;
|
||||
gearClusterGeoJson: GeoJSON;
|
||||
memberMarkersGeoJson: GeoJSON;
|
||||
pickerHighlightGeoJson: GeoJSON;
|
||||
selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
|
||||
hoveredGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
|
||||
// correlation GeoJSON
|
||||
correlationVesselGeoJson: GeoJSON;
|
||||
correlationTrailGeoJson: GeoJSON;
|
||||
modelBadgesGeoJson: GeoJSON;
|
||||
hoverHighlightGeoJson: GeoJSON;
|
||||
hoverHighlightTrailGeoJson: GeoJSON;
|
||||
// operational polygons (per model)
|
||||
operationalPolygons: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[];
|
||||
// derived values
|
||||
fleetList: FleetListItem[];
|
||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||
availableModels: { name: string; count: number; isDefault: boolean }[];
|
||||
}
|
||||
|
||||
const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
// 선단 색상: 바다색(짙은파랑)과 대비되는 밝은 파스텔 팔레트 (clusterId 해시)
|
||||
const FLEET_PALETTE = [
|
||||
'#e879f9', '#a78bfa', '#67e8f9', '#34d399', '#fbbf24',
|
||||
'#fb923c', '#f87171', '#a3e635', '#38bdf8', '#c084fc',
|
||||
];
|
||||
|
||||
/** 같은 groupKey의 모든 서브클러스터에서 멤버를 합산 (중복 mmsi 제거) */
|
||||
function mergeSubClusterMembers(groups: GroupPolygonDto[], groupKey: string) {
|
||||
const matches = groups.filter(g => g.groupKey === groupKey);
|
||||
if (matches.length === 0) return { members: [] as GroupPolygonDto['members'], groups: matches };
|
||||
const seen = new Set<string>();
|
||||
const members: GroupPolygonDto['members'] = [];
|
||||
for (const g of matches) {
|
||||
for (const m of g.members) {
|
||||
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); }
|
||||
}
|
||||
}
|
||||
return { members, groups: matches };
|
||||
}
|
||||
|
||||
export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult {
|
||||
const {
|
||||
ships,
|
||||
shipMap,
|
||||
groupPolygons,
|
||||
hoveredFleetId,
|
||||
hoveredGearCompositeKey = null,
|
||||
visibleGearCompositeKeys = null,
|
||||
selectedGearGroup,
|
||||
selectedGearCompositeKey = null,
|
||||
pickerHoveredGroup,
|
||||
historyActive,
|
||||
correlationData,
|
||||
correlationTracks,
|
||||
enabledModels,
|
||||
enabledVessels,
|
||||
hoveredMmsi,
|
||||
} = params;
|
||||
|
||||
// ── 선단 폴리곤 GeoJSON (서버 제공) ──
|
||||
const fleetPolygonGeoJSON = useMemo((): GeoJSON => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||
for (const g of groupPolygons.fleetGroups) {
|
||||
if (!g.polygon) continue;
|
||||
const cid = Number(g.groupKey);
|
||||
const color = FLEET_PALETTE[cid % FLEET_PALETTE.length];
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { clusterId: cid, color },
|
||||
geometry: g.polygon,
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [groupPolygons]);
|
||||
|
||||
// 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션
|
||||
const lineGeoJSON = useMemo((): GeoJSON => ({
|
||||
type: 'FeatureCollection', features: [],
|
||||
}), []);
|
||||
|
||||
// 호버 하이라이트용 단일 폴리곤
|
||||
const hoveredGeoJSON = useMemo((): GeoJSON => {
|
||||
if (hoveredFleetId === null || !groupPolygons) return EMPTY_FC;
|
||||
const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId);
|
||||
if (!g?.polygon) return EMPTY_FC;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: { clusterId: hoveredFleetId, color: FLEET_PALETTE[hoveredFleetId % FLEET_PALETTE.length] },
|
||||
geometry: g.polygon,
|
||||
}],
|
||||
};
|
||||
}, [hoveredFleetId, groupPolygons]);
|
||||
|
||||
// 모델별 연관성 데이터 그룹핑
|
||||
const correlationByModel = useMemo(() => {
|
||||
const map = new Map<string, GearCorrelationItem[]>();
|
||||
for (const c of correlationData) {
|
||||
const list = map.get(c.modelName) ?? [];
|
||||
list.push(c);
|
||||
map.set(c.modelName, list);
|
||||
}
|
||||
return map;
|
||||
}, [correlationData]);
|
||||
|
||||
// 사용 가능한 모델 목록 (데이터가 있는 모델만)
|
||||
const availableModels = useMemo(() => {
|
||||
const models: { name: string; count: number; isDefault: boolean }[] = [];
|
||||
for (const [name, items] of correlationByModel) {
|
||||
models.push({ name, count: items.length, isDefault: items[0]?.isDefault ?? false });
|
||||
}
|
||||
models.sort((a, b) => (a.isDefault ? -1 : 0) - (b.isDefault ? -1 : 0));
|
||||
return models;
|
||||
}, [correlationByModel]);
|
||||
|
||||
// 오퍼레이셔널 폴리곤 (비재생 정적 연산 — 서브클러스터별 분리, subClusterId 기반)
|
||||
const operationalPolygons = useMemo(() => {
|
||||
if (!selectedGearGroup || !groupPolygons) return [];
|
||||
// 서브클러스터별 개별 그룹 (allGroups = raw, 서브클러스터 분리 유지)
|
||||
const rawMatches = groupPolygons.allGroups.filter(
|
||||
g => g.groupKey === selectedGearGroup && g.groupType !== 'FLEET',
|
||||
);
|
||||
if (rawMatches.length === 0) return [];
|
||||
|
||||
// 서브클러스터별 basePts
|
||||
const subMap = new Map<number, [number, number][]>();
|
||||
for (const g of rawMatches) {
|
||||
const sid = g.subClusterId ?? 0;
|
||||
subMap.set(sid, g.members.map(m => [m.lon, m.lat]));
|
||||
}
|
||||
|
||||
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
|
||||
// 연관 선박을 subClusterId로 그룹핑
|
||||
const subExtras = new Map<number, [number, number][]>();
|
||||
for (const c of items) {
|
||||
if (c.score < 0.7) continue;
|
||||
const s = ships.find(x => x.mmsi === c.targetMmsi);
|
||||
if (!s) continue;
|
||||
const sid = c.subClusterId ?? 0;
|
||||
const list = subExtras.get(sid) ?? [];
|
||||
list.push([s.lng, s.lat]);
|
||||
subExtras.set(sid, list);
|
||||
}
|
||||
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (const [sid, extraPts] of subExtras) {
|
||||
if (extraPts.length === 0) continue;
|
||||
const basePts = subMap.get(sid) ?? subMap.get(0) ?? [];
|
||||
const polygon = buildInterpPolygon([...basePts, ...extraPts]);
|
||||
if (polygon) features.push({ type: 'Feature', properties: { modelName: mn, color, subClusterId: sid }, geometry: polygon });
|
||||
}
|
||||
if (features.length > 0) {
|
||||
result.push({ modelName: mn, color, geojson: { type: 'FeatureCollection', features } });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
|
||||
|
||||
// 어구 클러스터 GeoJSON — allGroups에서 직접 (서브클러스터별 개별 폴리곤 유지)
|
||||
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||
for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) {
|
||||
if (!g.polygon) continue;
|
||||
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
|
||||
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) continue;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
name: g.groupKey,
|
||||
groupKey: g.groupKey,
|
||||
subClusterId: g.subClusterId ?? 0,
|
||||
compositeKey,
|
||||
gearCount: g.memberCount,
|
||||
inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
|
||||
},
|
||||
geometry: g.polygon,
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [groupPolygons, visibleGearCompositeKeys]);
|
||||
|
||||
// 가상 선박 마커 GeoJSON (API members + shipMap heading 보정)
|
||||
const memberMarkersGeoJson = useMemo((): GeoJSON => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||
|
||||
const addMember = (
|
||||
m: { mmsi: string; name: string; lat: number; lon: number; cog: number; isParent: boolean; role: string },
|
||||
groupKey: string,
|
||||
groupType: string,
|
||||
color: string,
|
||||
) => {
|
||||
const realShip = shipMap.get(m.mmsi);
|
||||
const heading = realShip?.heading ?? m.cog ?? 0;
|
||||
const lat = realShip?.lat ?? m.lat;
|
||||
const lon = realShip?.lng ?? m.lon;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
mmsi: m.mmsi,
|
||||
name: m.name,
|
||||
groupKey,
|
||||
groupType,
|
||||
role: m.role,
|
||||
isParent: m.isParent ? 1 : 0,
|
||||
isGear: (groupType !== 'FLEET' && !m.isParent) ? 1 : 0,
|
||||
color,
|
||||
cog: heading,
|
||||
baseSize: (groupType !== 'FLEET' && !m.isParent) ? 0.11 : m.isParent ? 0.18 : 0.14,
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
});
|
||||
};
|
||||
|
||||
for (const g of groupPolygons.fleetGroups) {
|
||||
const cid = Number(g.groupKey);
|
||||
const fleetColor = FLEET_PALETTE[cid % FLEET_PALETTE.length];
|
||||
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', fleetColor);
|
||||
}
|
||||
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
|
||||
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
|
||||
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
|
||||
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) continue;
|
||||
for (const m of g.members) addMember(m, g.groupKey, g.groupType, color);
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [groupPolygons, shipMap, visibleGearCompositeKeys]);
|
||||
|
||||
// picker 호버 하이라이트 (선단 + 어구 통합)
|
||||
const pickerHighlightGeoJson = useMemo((): GeoJSON => {
|
||||
if (!pickerHoveredGroup || !groupPolygons) return EMPTY_FC;
|
||||
const fleet = groupPolygons.fleetGroups.find(x => String(x.groupKey) === pickerHoveredGroup);
|
||||
if (fleet?.polygon) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: fleet.polygon }] };
|
||||
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const g = all.find(x => x.groupKey === pickerHoveredGroup);
|
||||
if (!g?.polygon) return EMPTY_FC;
|
||||
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] };
|
||||
}, [pickerHoveredGroup, groupPolygons]);
|
||||
|
||||
// 선택된 어구 그룹 하이라이트 폴리곤
|
||||
const selectedGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => {
|
||||
if (!selectedGearGroup || !enabledModels.has('identity') || historyActive) return null;
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const matches = allGroups.filter(g => {
|
||||
if (!g.polygon || g.groupKey !== selectedGearGroup) return false;
|
||||
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
|
||||
if (selectedGearCompositeKey && compositeKey !== selectedGearCompositeKey) return false;
|
||||
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) return false;
|
||||
return true;
|
||||
});
|
||||
if (matches.length === 0) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: matches.map(g => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
subClusterId: g.subClusterId,
|
||||
compositeKey: `${g.groupKey}:${g.subClusterId ?? 0}`,
|
||||
},
|
||||
geometry: g.polygon!,
|
||||
})),
|
||||
};
|
||||
}, [selectedGearGroup, selectedGearCompositeKey, enabledModels, historyActive, groupPolygons, visibleGearCompositeKeys]);
|
||||
|
||||
const hoveredGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => {
|
||||
if (!hoveredGearCompositeKey || !groupPolygons) return null;
|
||||
const group = groupPolygons.allGroups.find(
|
||||
item => item.groupType !== 'FLEET' && `${item.groupKey}:${item.subClusterId ?? 0}` === hoveredGearCompositeKey && item.polygon,
|
||||
);
|
||||
if (!group?.polygon) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
groupKey: group.groupKey,
|
||||
subClusterId: group.subClusterId ?? 0,
|
||||
compositeKey: hoveredGearCompositeKey,
|
||||
inZone: group.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
|
||||
},
|
||||
geometry: group.polygon,
|
||||
}],
|
||||
};
|
||||
}, [groupPolygons, hoveredGearCompositeKey]);
|
||||
|
||||
// ── 연관 대상 마커 (ships[] fallback) ──
|
||||
const correlationVesselGeoJson = useMemo((): GeoJSON => {
|
||||
if (!selectedGearGroup || correlationByModel.size === 0) return EMPTY_FC;
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
for (const c of items) {
|
||||
if (seen.has(c.targetMmsi)) continue;
|
||||
const s = ships.find(x => x.mmsi === c.targetMmsi);
|
||||
if (!s) continue;
|
||||
seen.add(c.targetMmsi);
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
mmsi: c.targetMmsi,
|
||||
name: c.targetName || c.targetMmsi,
|
||||
score: c.score,
|
||||
cog: s.course ?? 0,
|
||||
color,
|
||||
isVessel: c.targetType === 'VESSEL' ? 1 : 0,
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [selectedGearGroup, correlationByModel, enabledModels, ships]);
|
||||
|
||||
// 연관 대상 트레일 (전체 항적)
|
||||
const correlationTrailGeoJson = useMemo((): GeoJSON => {
|
||||
if (correlationTracks.length === 0) return EMPTY_FC;
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
const vesselColor = new Map<string, string>();
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
for (const c of items) {
|
||||
if (!vesselColor.has(c.targetMmsi)) vesselColor.set(c.targetMmsi, MODEL_COLORS[mn] ?? '#60a5fa');
|
||||
}
|
||||
}
|
||||
for (const vt of correlationTracks) {
|
||||
if (!enabledVessels.has(vt.mmsi)) continue;
|
||||
const color = vesselColor.get(vt.mmsi) ?? '#60a5fa';
|
||||
const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]);
|
||||
if (coords.length >= 2) {
|
||||
features.push({ type: 'Feature', properties: { mmsi: vt.mmsi, color }, geometry: { type: 'LineString', coordinates: coords } });
|
||||
}
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [correlationTracks, enabledVessels, correlationByModel, enabledModels]);
|
||||
|
||||
// 모델 배지 GeoJSON (groupPolygons 기반)
|
||||
const modelBadgesGeoJson = useMemo((): GeoJSON => {
|
||||
if (!selectedGearGroup) return EMPTY_FC;
|
||||
const targets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
|
||||
|
||||
if (enabledModels.has('identity') && groupPolygons) {
|
||||
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const { members } = mergeSubClusterMembers(all, selectedGearGroup);
|
||||
for (const m of members) {
|
||||
const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
|
||||
e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
|
||||
targets.set(m.mmsi, e);
|
||||
}
|
||||
}
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
for (const c of items) {
|
||||
if (c.score < 0.3) continue;
|
||||
const s = ships.find(x => x.mmsi === c.targetMmsi);
|
||||
if (!s) continue;
|
||||
const e = targets.get(c.targetMmsi) ?? { lon: s.lng, lat: s.lat, models: new Set<string>() };
|
||||
e.lon = s.lng; e.lat = s.lat; e.models.add(mn);
|
||||
targets.set(c.targetMmsi, e);
|
||||
}
|
||||
}
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (const [mmsi, t] of targets) {
|
||||
if (t.models.size === 0) continue;
|
||||
const props: Record<string, unknown> = { mmsi };
|
||||
for (let i = 0; i < MODEL_ORDER.length; i++) props[`m${i}`] = t.models.has(MODEL_ORDER[i]) ? 1 : 0;
|
||||
features.push({ type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [t.lon, t.lat] } });
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [selectedGearGroup, enabledModels, groupPolygons, correlationByModel, ships]);
|
||||
|
||||
// 호버 하이라이트 — 대상 위치
|
||||
const hoverHighlightGeoJson = useMemo((): GeoJSON => {
|
||||
if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC;
|
||||
if (groupPolygons) {
|
||||
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const { members: allMembers } = mergeSubClusterMembers(all, selectedGearGroup);
|
||||
const m = allMembers.find(x => x.mmsi === hoveredMmsi);
|
||||
if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] };
|
||||
}
|
||||
const s = ships.find(x => x.mmsi === hoveredMmsi);
|
||||
if (s) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } }] };
|
||||
return EMPTY_FC;
|
||||
}, [hoveredMmsi, selectedGearGroup, groupPolygons, ships]);
|
||||
|
||||
// 호버 하이라이트 — 대상 항적
|
||||
const hoverHighlightTrailGeoJson = useMemo((): GeoJSON => {
|
||||
if (!hoveredMmsi) return EMPTY_FC;
|
||||
const vt = correlationTracks.find(v => v.mmsi === hoveredMmsi);
|
||||
if (!vt) return EMPTY_FC;
|
||||
const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]);
|
||||
if (coords.length < 2) return EMPTY_FC;
|
||||
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }] };
|
||||
}, [hoveredMmsi, correlationTracks]);
|
||||
|
||||
// 선단 목록 (멤버 수 내림차순)
|
||||
const fleetList = useMemo((): FleetListItem[] => {
|
||||
if (!groupPolygons) return [];
|
||||
return groupPolygons.fleetGroups.map(g => ({
|
||||
id: Number(g.groupKey),
|
||||
mmsiList: g.members.map(m => m.mmsi),
|
||||
label: g.groupLabel,
|
||||
memberCount: g.memberCount,
|
||||
areaSqNm: g.areaSqNm,
|
||||
color: FLEET_PALETTE[Number(g.groupKey) % FLEET_PALETTE.length],
|
||||
members: g.members,
|
||||
})).sort((a, b) => b.memberCount - a.memberCount);
|
||||
}, [groupPolygons]);
|
||||
|
||||
return {
|
||||
fleetPolygonGeoJSON,
|
||||
lineGeoJSON,
|
||||
hoveredGeoJSON,
|
||||
gearClusterGeoJson,
|
||||
memberMarkersGeoJson,
|
||||
pickerHighlightGeoJson,
|
||||
selectedGearHighlightGeoJson,
|
||||
hoveredGearHighlightGeoJson,
|
||||
correlationVesselGeoJson,
|
||||
correlationTrailGeoJson,
|
||||
modelBadgesGeoJson,
|
||||
hoverHighlightGeoJson,
|
||||
hoverHighlightTrailGeoJson,
|
||||
operationalPolygons,
|
||||
fleetList,
|
||||
correlationByModel,
|
||||
availableModels,
|
||||
};
|
||||
}
|
||||
69
frontend/src/components/korea/useReplayCenterPanelLayout.ts
Normal file
69
frontend/src/components/korea/useReplayCenterPanelLayout.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
KOREA_SIDE_PANEL_WIDTH,
|
||||
REPLAY_ANALYSIS_RESERVED_WIDTH,
|
||||
REPLAY_COMPARE_PANEL_WIDTH_RATIO,
|
||||
REPLAY_LEFT_RESERVED_WIDTH,
|
||||
REPLAY_REVIEW_RESERVED_WIDTH,
|
||||
} from './parentInferenceConstants';
|
||||
|
||||
interface ReplayCenterPanelLayoutOptions {
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
hasRightReviewPanel?: boolean;
|
||||
}
|
||||
|
||||
interface ReplayCenterPanelLayout {
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const FALLBACK_VIEWPORT_WIDTH = 1920;
|
||||
const ABSOLUTE_MIN_WIDTH = 180;
|
||||
|
||||
export function useReplayCenterPanelLayout({
|
||||
minWidth,
|
||||
maxWidth,
|
||||
hasRightReviewPanel = false,
|
||||
}: ReplayCenterPanelLayoutOptions): ReplayCenterPanelLayout {
|
||||
const [viewportWidth, setViewportWidth] = useState(
|
||||
() => (typeof window === 'undefined' ? FALLBACK_VIEWPORT_WIDTH : window.innerWidth),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handleResize = () => {
|
||||
setViewportWidth(window.innerWidth);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
const mapPanelWidth = Math.max(ABSOLUTE_MIN_WIDTH, viewportWidth - KOREA_SIDE_PANEL_WIDTH);
|
||||
const leftReserved = REPLAY_LEFT_RESERVED_WIDTH;
|
||||
const rightReserved = Math.max(
|
||||
REPLAY_ANALYSIS_RESERVED_WIDTH,
|
||||
hasRightReviewPanel ? REPLAY_REVIEW_RESERVED_WIDTH : 0,
|
||||
);
|
||||
const availableWidth = Math.max(ABSOLUTE_MIN_WIDTH, mapPanelWidth - leftReserved - rightReserved);
|
||||
|
||||
let width: number;
|
||||
if (availableWidth >= maxWidth) {
|
||||
width = maxWidth;
|
||||
} else if (availableWidth <= minWidth) {
|
||||
width = Math.max(ABSOLUTE_MIN_WIDTH, availableWidth);
|
||||
} else {
|
||||
width = Math.min(maxWidth, Math.max(minWidth, availableWidth * REPLAY_COMPARE_PANEL_WIDTH_RATIO));
|
||||
}
|
||||
|
||||
const left = leftReserved + Math.max(0, (availableWidth - width) / 2);
|
||||
|
||||
return {
|
||||
left,
|
||||
width,
|
||||
};
|
||||
}, [hasRightReviewPanel, maxWidth, minWidth, viewportWidth]);
|
||||
}
|
||||
@ -2,6 +2,9 @@ import { memo, useMemo, useState, useEffect } from 'react';
|
||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Aircraft, AircraftCategory } from '../../types';
|
||||
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||
import { getZoomScale } from '../../hooks/useShipDeckLayers';
|
||||
import { useSymbolScale } from '../../hooks/useSymbolScale';
|
||||
|
||||
interface Props {
|
||||
aircraft: Aircraft[];
|
||||
@ -187,9 +190,12 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
|
||||
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
||||
const { t } = useTranslation('ships');
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
|
||||
const { symbolScale } = useSymbolScale();
|
||||
const color = getAircraftColor(ac);
|
||||
const shape = getShape(ac);
|
||||
const size = shape.w;
|
||||
const zs = getZoomScale(zoomLevel);
|
||||
const size = Math.round(shape.w * zs * symbolScale.aircraft / 0.8);
|
||||
const showLabel = ac.category === 'fighter' || ac.category === 'surveillance';
|
||||
const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;
|
||||
|
||||
|
||||
@ -1,22 +1,26 @@
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useControl } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
|
||||
interface Props {
|
||||
layers: Layer[];
|
||||
overlayRef?: MutableRefObject<MapboxOverlay | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MapLibre Map 내부에서 deck.gl 레이어를 GPU 렌더링하는 오버레이.
|
||||
* interleaved 모드: MapLibre 레이어와 deck.gl 레이어가 z-order로 혼합됨.
|
||||
* overlayRef: 외부에서 imperative setProps 호출이 필요할 때 전달.
|
||||
*/
|
||||
export function DeckGLOverlay({ layers }: Props) {
|
||||
export function DeckGLOverlay({ layers, overlayRef }: Props) {
|
||||
const overlay = useControl<MapboxOverlay>(
|
||||
() => new MapboxOverlay({
|
||||
interleaved: true,
|
||||
getCursor: ({ isHovering }) => isHovering ? 'pointer' : '',
|
||||
}),
|
||||
);
|
||||
if (overlayRef) overlayRef.current = overlay;
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -270,6 +270,24 @@ function ensureTriangleImage(map: maplibregl.Map) {
|
||||
ctx.fill();
|
||||
const imgData = ctx.getImageData(0, 0, s, s);
|
||||
map.addImage('ship-triangle', { width: s, height: s, data: new Uint8Array(imgData.data.buffer) }, { sdf: true });
|
||||
|
||||
// 어구/어망 마름모 아이콘
|
||||
if (!map.hasImage('gear-diamond')) {
|
||||
const dc = document.createElement('canvas');
|
||||
dc.width = s;
|
||||
dc.height = s;
|
||||
const dx = dc.getContext('2d')!;
|
||||
dx.beginPath();
|
||||
dx.moveTo(s / 2, 4); // top
|
||||
dx.lineTo(s - 4, s / 2); // right
|
||||
dx.lineTo(s / 2, s - 4); // bottom
|
||||
dx.lineTo(4, s / 2); // left
|
||||
dx.closePath();
|
||||
dx.fillStyle = '#ffffff';
|
||||
dx.fill();
|
||||
const dd = dx.getImageData(0, 0, s, s);
|
||||
map.addImage('gear-diamond', { width: s, height: s, data: new Uint8Array(dd.data.buffer) }, { sdf: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main layer (WebGL symbol rendering — triangles) ──
|
||||
@ -311,10 +329,11 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
mmsi: ship.mmsi,
|
||||
name: ship.name,
|
||||
color: getShipHex(ship),
|
||||
size: SIZE_MAP[ship.category] ?? 0.12,
|
||||
size: (/^.+?_\d+_\d+_?$/.test(ship.name || '') ? 0.8 : 1) * (SIZE_MAP[ship.category] ?? 0.12),
|
||||
isMil: isMilitary(ship.category) ? 1 : 0,
|
||||
isKorean: ship.flag === 'KR' ? 1 : 0,
|
||||
isCheonghae: ship.mmsi === '440001981' ? 1 : 0,
|
||||
isGear: /^.+?_\d+_\d+_?$/.test(ship.name || '') ? 1 : 0,
|
||||
heading: ship.heading,
|
||||
mtCategory: getMarineTrafficCategory(ship.typecode, ship.category),
|
||||
natGroup: getNationalityGroup(ship.flag),
|
||||
@ -507,7 +526,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
type="symbol"
|
||||
filter={shipVisibilityFilter}
|
||||
layout={{
|
||||
'icon-image': 'ship-triangle',
|
||||
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
|
||||
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||
4, ['*', ['get', 'size'], 0.8],
|
||||
6, ['*', ['get', 'size'], 1.0],
|
||||
@ -517,7 +536,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
13, ['*', ['get', 'size'], 3.5],
|
||||
14, ['*', ['get', 'size'], 4.2],
|
||||
],
|
||||
'icon-rotate': ['get', 'heading'],
|
||||
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'heading']],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
|
||||
675
frontend/src/components/layers/ShipPopupOverlay.tsx
Normal file
675
frontend/src/components/layers/ShipPopupOverlay.tsx
Normal file
@ -0,0 +1,675 @@
|
||||
import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useMap } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Ship } from '../../types';
|
||||
import { MT_TYPE_COLORS, getMTType, NAVY_COLORS, FLAG_EMOJI, isMilitary } from '../../utils/shipClassification';
|
||||
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||
|
||||
// ── Local Korean ship photos ──────────────────────────────────────────────────
|
||||
|
||||
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
|
||||
'440034000': '/ships/440034000.jpg',
|
||||
'440150000': '/ships/440150000.jpg',
|
||||
'440272000': '/ships/440272000.jpg',
|
||||
'440274000': '/ships/440274000.jpg',
|
||||
'440323000': '/ships/440323000.jpg',
|
||||
'440384000': '/ships/440384000.jpg',
|
||||
'440880000': '/ships/440880000.jpg',
|
||||
'441046000': '/ships/441046000.jpg',
|
||||
'441345000': '/ships/441345000.jpg',
|
||||
'441353000': '/ships/441353000.jpg',
|
||||
'441393000': '/ships/441393000.jpg',
|
||||
'441423000': '/ships/441423000.jpg',
|
||||
'441548000': '/ships/441548000.jpg',
|
||||
'441708000': '/ships/441708000.png',
|
||||
'441866000': '/ships/441866000.jpg',
|
||||
};
|
||||
|
||||
interface VesselPhotoData {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
|
||||
|
||||
type PhotoSource = 'spglobal' | 'marinetraffic';
|
||||
|
||||
interface VesselPhotoProps {
|
||||
mmsi: string;
|
||||
imo?: string;
|
||||
shipImagePath?: string | null;
|
||||
shipImageCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* S&P Global 이미지 목록 API 응답
|
||||
* GET /signal-batch/api/v1/shipimg/{imo}
|
||||
* path에 _1.jpg(썸네일) / _2.jpg(원본) 을 붙여서 사용
|
||||
*/
|
||||
interface SpgImageInfo {
|
||||
picId: number;
|
||||
path: string; // e.g. "/shipimg/22738/2273823"
|
||||
copyright: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
const spgImageCache = new Map<string, SpgImageInfo[] | null>();
|
||||
|
||||
async function fetchSpgImages(imo: string): Promise<SpgImageInfo[]> {
|
||||
if (spgImageCache.has(imo)) return spgImageCache.get(imo) || [];
|
||||
try {
|
||||
const res = await fetch(`/signal-batch/api/v1/shipimg/${imo}`);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data: SpgImageInfo[] = await res.json();
|
||||
spgImageCache.set(imo, data);
|
||||
return data;
|
||||
} catch {
|
||||
spgImageCache.set(imo, null);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) {
|
||||
const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
|
||||
const hasSPGlobal = !!shipImagePath;
|
||||
const [activeTab, setActiveTab] = useState<PhotoSource>(hasSPGlobal ? 'spglobal' : 'marinetraffic');
|
||||
const [spgSlideIdx, setSpgSlideIdx] = useState(0);
|
||||
const [spgErrors, setSpgErrors] = useState<Set<number>>(new Set());
|
||||
const [spgImages, setSpgImages] = useState<SpgImageInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(hasSPGlobal ? 'spglobal' : 'marinetraffic');
|
||||
setSpgSlideIdx(0);
|
||||
setSpgErrors(new Set());
|
||||
setSpgImages([]);
|
||||
|
||||
if (imo && hasSPGlobal) {
|
||||
fetchSpgImages(imo).then(setSpgImages);
|
||||
} else if (shipImagePath) {
|
||||
setSpgImages([{ picId: 0, path: shipImagePath.replace(/_[12]\.\w+$/, ''), copyright: '', date: '' }]);
|
||||
}
|
||||
}, [mmsi, imo, hasSPGlobal, shipImagePath]);
|
||||
|
||||
const spgUrls = useMemo(
|
||||
() => spgImages.map(img => `${img.path}_2.jpg`),
|
||||
[spgImages],
|
||||
);
|
||||
const validSpgCount = spgUrls.length;
|
||||
|
||||
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
|
||||
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMtPhoto(vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined);
|
||||
}, [mmsi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'marinetraffic') return;
|
||||
if (mtPhoto !== undefined) return;
|
||||
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const result = { url: imgUrl };
|
||||
vesselPhotoCache.set(mmsi, result);
|
||||
setMtPhoto(result);
|
||||
};
|
||||
img.onerror = () => {
|
||||
vesselPhotoCache.set(mmsi, null);
|
||||
setMtPhoto(null);
|
||||
};
|
||||
img.src = imgUrl;
|
||||
}, [mmsi, activeTab, mtPhoto]);
|
||||
|
||||
let currentUrl: string | null = null;
|
||||
if (localUrl) {
|
||||
currentUrl = localUrl;
|
||||
} else if (activeTab === 'spglobal' && spgUrls.length > 0 && !spgErrors.has(spgSlideIdx)) {
|
||||
currentUrl = spgUrls[spgSlideIdx];
|
||||
} else if (activeTab === 'marinetraffic' && mtPhoto) {
|
||||
currentUrl = mtPhoto.url;
|
||||
}
|
||||
|
||||
if (localUrl) {
|
||||
return (
|
||||
<div className="mb-1.5">
|
||||
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30">
|
||||
<img
|
||||
src={localUrl}
|
||||
alt="Vessel"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allSpgFailed = spgUrls.length > 0 && spgUrls.every((_, i) => spgErrors.has(i));
|
||||
const noPhoto = (!hasSPGlobal || allSpgFailed) && mtPhoto === null;
|
||||
|
||||
return (
|
||||
<div className="mb-1.5">
|
||||
<div className="flex mb-1">
|
||||
{hasSPGlobal && (
|
||||
<div
|
||||
className={`flex-1 py-1.5 text-center cursor-pointer transition-all text-[12px] font-bold tracking-wide border-b-2 ${
|
||||
activeTab === 'spglobal'
|
||||
? 'border-[#1565c0] text-white bg-white/5'
|
||||
: 'border-transparent text-kcg-muted hover:text-kcg-dim hover:bg-white/[0.03]'
|
||||
}`}
|
||||
onClick={() => setActiveTab('spglobal')}
|
||||
>
|
||||
S&P Global
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex-1 py-1.5 text-center cursor-pointer transition-all text-[12px] font-bold tracking-wide border-b-2 ${
|
||||
activeTab === 'marinetraffic'
|
||||
? 'border-[#1565c0] text-white bg-white/5'
|
||||
: 'border-transparent text-kcg-muted hover:text-kcg-dim hover:bg-white/[0.03]'
|
||||
}`}
|
||||
onClick={() => setActiveTab('marinetraffic')}
|
||||
>
|
||||
MarineTraffic
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30 relative">
|
||||
{currentUrl ? (
|
||||
<img
|
||||
key={currentUrl}
|
||||
src={currentUrl}
|
||||
alt="Vessel"
|
||||
className="w-full h-full object-contain"
|
||||
onError={() => {
|
||||
if (activeTab === 'spglobal') {
|
||||
setSpgErrors(prev => new Set(prev).add(spgSlideIdx));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : noPhoto ? (
|
||||
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
|
||||
No photo available
|
||||
</div>
|
||||
) : activeTab === 'marinetraffic' && mtPhoto === undefined ? (
|
||||
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
|
||||
No photo available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'spglobal' && validSpgCount > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 bg-black/50 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-black/70 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); setSpgSlideIdx(i => (i - 1 + validSpgCount) % validSpgCount); }}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 bg-black/50 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-black/70 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); setSpgSlideIdx(i => (i + 1) % validSpgCount); }}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1">
|
||||
{spgUrls.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`w-1.5 h-1.5 rounded-full ${i === spgSlideIdx ? 'bg-white' : 'bg-white/40'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fleet group type ──────────────────────────────────────────────────────────
|
||||
|
||||
interface FleetMember {
|
||||
ship: Ship;
|
||||
role: string;
|
||||
roleKo: string;
|
||||
}
|
||||
|
||||
interface FleetGroup {
|
||||
members: FleetMember[];
|
||||
fleetTypeKo: string;
|
||||
}
|
||||
|
||||
// ── Popup content ─────────────────────────────────────────────────────────────
|
||||
|
||||
const FLEET_ROLE_COLORS: Record<string, string> = {
|
||||
pair: '#ef4444',
|
||||
carrier: '#f97316',
|
||||
lighting: '#eab308',
|
||||
mothership: '#dc2626',
|
||||
subsidiary: '#6b7280',
|
||||
};
|
||||
|
||||
interface ShipPopupContentProps {
|
||||
ship: Ship;
|
||||
onClose: () => void;
|
||||
fleetGroup: FleetGroup | null;
|
||||
isDragging: boolean;
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const ShipPopupContent = memo(function ShipPopupContent({
|
||||
ship,
|
||||
onClose,
|
||||
fleetGroup,
|
||||
isDragging: _isDragging,
|
||||
onMouseDown,
|
||||
}: ShipPopupContentProps) {
|
||||
const { t } = useTranslation('ships');
|
||||
const mtType = getMTType(ship);
|
||||
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
||||
const isMil = isMilitary(ship.category);
|
||||
const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined;
|
||||
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined;
|
||||
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
|
||||
|
||||
return (
|
||||
<div className="ship-popup-body" onMouseDown={onMouseDown}>
|
||||
{/* Header — draggable handle */}
|
||||
<div
|
||||
className="ship-popup-header"
|
||||
style={{ background: isMil ? '#1a1a2e' : '#1565c0', cursor: 'grab' }}
|
||||
>
|
||||
{flagEmoji && <span className="text-base leading-none">{flagEmoji}</span>}
|
||||
<strong className="ship-popup-name">{ship.name}</strong>
|
||||
{navyLabel && (
|
||||
<span className="ship-popup-navy-badge" style={{ background: navyAccent || color }}>
|
||||
{navyLabel}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto text-white/60 hover:text-white text-sm leading-none flex items-center justify-center"
|
||||
style={{ minWidth: 28, minHeight: 28, padding: '4px 6px' }}
|
||||
onClick={onClose}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Photo */}
|
||||
<VesselPhoto
|
||||
mmsi={ship.mmsi}
|
||||
imo={ship.imo}
|
||||
shipImagePath={ship.shipImagePath}
|
||||
shipImageCount={ship.shipImageCount}
|
||||
/>
|
||||
|
||||
{/* Type tags */}
|
||||
<div className="ship-popup-tags">
|
||||
<span className="ship-tag ship-tag-primary" style={{ background: color }}>
|
||||
{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}
|
||||
</span>
|
||||
<span className="ship-tag ship-tag-secondary">
|
||||
{t(`categoryLabel.${ship.category}`)}
|
||||
</span>
|
||||
{ship.typeDesc && (
|
||||
<span className="ship-tag ship-tag-dim">{ship.typeDesc}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data grid — paired rows */}
|
||||
<div className="ship-popup-grid">
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">MMSI</span>
|
||||
<span className="ship-popup-value">{ship.mmsi}</span>
|
||||
</div>
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">IMO</span>
|
||||
<span className="ship-popup-value">{ship.imo || '-'}</span>
|
||||
</div>
|
||||
|
||||
{ship.callSign && (
|
||||
<>
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">{t('popup.callSign')}</span>
|
||||
<span className="ship-popup-value">{ship.callSign}</span>
|
||||
</div>
|
||||
<div className="ship-popup-row" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">Lat</span>
|
||||
<span className="ship-popup-value">{ship.lat.toFixed(4)}</span>
|
||||
</div>
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">Lon</span>
|
||||
<span className="ship-popup-value">{ship.lng.toFixed(4)}</span>
|
||||
</div>
|
||||
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">HDG</span>
|
||||
<span className="ship-popup-value">{ship.heading.toFixed(1)}°</span>
|
||||
</div>
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">COG</span>
|
||||
<span className="ship-popup-value">{ship.course.toFixed(1)}°</span>
|
||||
</div>
|
||||
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">SOG</span>
|
||||
<span className="ship-popup-value">{ship.speed.toFixed(1)} kn</span>
|
||||
</div>
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">Draught</span>
|
||||
<span className="ship-popup-value">{ship.draught ? `${ship.draught.toFixed(2)}m` : '-'}</span>
|
||||
</div>
|
||||
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">Length</span>
|
||||
<span className="ship-popup-value">{ship.length ? `${ship.length}m` : '-'}</span>
|
||||
</div>
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">Width</span>
|
||||
<span className="ship-popup-value">{ship.width ? `${ship.width}m` : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Long-value fields */}
|
||||
{ship.status && (
|
||||
<div className="ship-popup-full-row">
|
||||
<span className="ship-popup-label">Status</span>
|
||||
<span className="ship-popup-value">{ship.status}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.destination && (
|
||||
<div className="ship-popup-full-row">
|
||||
<span className="ship-popup-label">Dest</span>
|
||||
<span className="ship-popup-value">{ship.destination}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.eta && (
|
||||
<div className="ship-popup-full-row">
|
||||
<span className="ship-popup-label">ETA</span>
|
||||
<span className="ship-popup-value">{new Date(ship.eta).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fleet info */}
|
||||
{fleetGroup && fleetGroup.members.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid #333', marginTop: 6, paddingTop: 6 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#ef4444', marginBottom: 4 }}>
|
||||
{'\uD83D\uDD17'} {fleetGroup.fleetTypeKo} — {fleetGroup.members.length}척 연결
|
||||
</div>
|
||||
{fleetGroup.members.slice(0, 5).map(m => (
|
||||
<div key={m.ship.mmsi} style={{ fontSize: 9, display: 'flex', gap: 4, padding: '2px 0', color: '#ccc' }}>
|
||||
<span style={{ color: FLEET_ROLE_COLORS[m.role] || '#ef4444', fontWeight: 700, minWidth: 55 }}>
|
||||
{m.roleKo}
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>{m.ship.name || m.ship.mmsi}</span>
|
||||
</div>
|
||||
))}
|
||||
{fleetGroup.members.length > 5 && (
|
||||
<div style={{ fontSize: 8, color: '#666' }}>...외 {fleetGroup.members.length - 5}척</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="ship-popup-footer">
|
||||
<span className="ship-popup-timestamp">
|
||||
{t('popup.lastUpdate')}: {new Date(ship.lastSeen).toLocaleString()}
|
||||
</span>
|
||||
<a
|
||||
href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ship-popup-link"
|
||||
>
|
||||
MarineTraffic →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Position tracking ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ScreenPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Popup tip/arrow height (CSS triangle pointing downward toward ship)
|
||||
const POPUP_TIP_HEIGHT = 10;
|
||||
// Vertical offset above ship icon so popup sits above with some gap
|
||||
const POPUP_ANCHOR_OFFSET = 16;
|
||||
|
||||
// ── Main overlay component ────────────────────────────────────────────────────
|
||||
|
||||
export function ShipPopupOverlay() {
|
||||
const { current: mapRef } = useMap();
|
||||
|
||||
const selectedMmsi = useShipDeckStore(s => s.selectedMmsi);
|
||||
const ship = useShipDeckStore(s =>
|
||||
s.selectedMmsi ? s.shipMap.get(s.selectedMmsi) ?? null : null,
|
||||
);
|
||||
const analysisMap = useShipDeckStore(s => s.analysisMap);
|
||||
const ships = useShipDeckStore(s => s.ships);
|
||||
|
||||
// Compute fleet group from analysis map (same logic as ShipLayer lines 414-455)
|
||||
const fleetGroup = useMemo((): FleetGroup | null => {
|
||||
if (!selectedMmsi || !analysisMap) return null;
|
||||
const dto = analysisMap.get(selectedMmsi);
|
||||
if (!dto) return null;
|
||||
const clusterId = dto.algorithms.cluster.clusterId;
|
||||
if (clusterId < 0) return null;
|
||||
|
||||
const members: FleetMember[] = [];
|
||||
for (const [mmsi, d] of analysisMap) {
|
||||
if (d.algorithms.cluster.clusterId !== clusterId) continue;
|
||||
const memberShip = ships.find(s => s.mmsi === mmsi);
|
||||
if (!memberShip) continue;
|
||||
const isLeader = d.algorithms.fleetRole.isLeader;
|
||||
members.push({
|
||||
ship: memberShip,
|
||||
role: d.algorithms.fleetRole.role,
|
||||
roleKo: isLeader ? '본선' : '선단원',
|
||||
});
|
||||
}
|
||||
if (members.length === 0) return null;
|
||||
return { members, fleetTypeKo: '선단' };
|
||||
}, [selectedMmsi, analysisMap, ships]);
|
||||
|
||||
// Screen position of the popup (anchored below ship, updated on map move)
|
||||
const [screenPos, setScreenPos] = useState<ScreenPos | null>(null);
|
||||
// Once dragged, detach from map tracking and use fixed position
|
||||
const [draggedPos, setDraggedPos] = useState<ScreenPos | null>(null);
|
||||
|
||||
const dragging = useRef(false);
|
||||
const dragStartOffset = useRef<ScreenPos>({ x: 0, y: 0 });
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Project ship coordinates to screen, accounting for popup height (anchor bottom)
|
||||
const projectShipToScreen = useCallback((): ScreenPos | null => {
|
||||
if (!mapRef || !ship) return null;
|
||||
const m = mapRef.getMap();
|
||||
const point = m.project([ship.lng, ship.lat]);
|
||||
// Anchor bottom: popup tip points down to ship position
|
||||
// We want the tip to be at ship pixel, so offset upward by popup height + tip
|
||||
return { x: point.x, y: point.y };
|
||||
}, [mapRef, ship]);
|
||||
|
||||
// Update anchored position on map move / resize (only if not dragged)
|
||||
useEffect(() => {
|
||||
if (!mapRef || !ship) {
|
||||
setScreenPos(null);
|
||||
return;
|
||||
}
|
||||
if (draggedPos !== null) return; // detached, skip
|
||||
|
||||
const update = () => {
|
||||
setScreenPos(projectShipToScreen());
|
||||
};
|
||||
|
||||
update(); // initial
|
||||
const m = mapRef.getMap();
|
||||
m.on('move', update);
|
||||
m.on('zoom', update);
|
||||
return () => {
|
||||
m.off('move', update);
|
||||
m.off('zoom', update);
|
||||
};
|
||||
}, [mapRef, ship, draggedPos, projectShipToScreen]);
|
||||
|
||||
// Reset drag state when ship changes
|
||||
useEffect(() => {
|
||||
setDraggedPos(null);
|
||||
setScreenPos(projectShipToScreen());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedMmsi]);
|
||||
|
||||
// Drag handlers
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.ship-popup-header')) return;
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
const el = popupRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
dragStartOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging.current) return;
|
||||
setDraggedPos({
|
||||
x: e.clientX - dragStartOffset.current.x,
|
||||
y: e.clientY - dragStartOffset.current.y,
|
||||
});
|
||||
};
|
||||
const onMouseUp = () => { dragging.current = false; };
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
useShipDeckStore.getState().setSelectedMmsi(null);
|
||||
}, []);
|
||||
|
||||
if (!ship) return null;
|
||||
|
||||
// Determine final CSS position
|
||||
// draggedPos: user dragged → use fixed left/top directly (popup div is positioned inside map container)
|
||||
// screenPos: anchored to ship → offset upward so tip touches ship
|
||||
let style: React.CSSProperties;
|
||||
if (draggedPos !== null) {
|
||||
style = {
|
||||
position: 'absolute',
|
||||
left: draggedPos.x,
|
||||
top: draggedPos.y,
|
||||
transform: 'none',
|
||||
};
|
||||
} else if (screenPos !== null) {
|
||||
// Offset: translate(-50%, -100%) then subtract tip height + anchor gap
|
||||
// We use transform for centering horizontally and anchoring at bottom
|
||||
style = {
|
||||
position: 'absolute',
|
||||
left: screenPos.x,
|
||||
top: screenPos.y - POPUP_ANCHOR_OFFSET - POPUP_TIP_HEIGHT,
|
||||
transform: 'translateX(-50%) translateY(-100%)',
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="z-50 select-none rounded-lg shadow-lg overflow-hidden"
|
||||
style={{ ...style, background: '#1a1a2e', border: '1px solid rgba(255,255,255,0.15)' }}
|
||||
>
|
||||
{/* Popup body */}
|
||||
<ShipPopupContent
|
||||
ship={ship}
|
||||
onClose={handleClose}
|
||||
fleetGroup={fleetGroup}
|
||||
isDragging={dragging.current}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
{/* CSS triangle arrow pointing down toward ship (only when anchored) */}
|
||||
{draggedPos === null && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -POPUP_TIP_HEIGHT,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '8px solid transparent',
|
||||
borderRight: '8px solid transparent',
|
||||
borderTop: `${POPUP_TIP_HEIGHT}px solid rgba(10, 10, 26, 0.96)`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ship Hover Tooltip ───────────────────────────────────────────────────────
|
||||
|
||||
export function ShipHoverTooltip() {
|
||||
const hoveredMmsi = useShipDeckStore(s => s.hoveredMmsi);
|
||||
const hoverScreenPos = useShipDeckStore(s => s.hoverScreenPos);
|
||||
const ship = useShipDeckStore(s => s.hoveredMmsi ? s.shipMap.get(s.hoveredMmsi) ?? null : null);
|
||||
const selectedMmsi = useShipDeckStore(s => s.selectedMmsi);
|
||||
|
||||
// 팝업이 열려있으면 툴팁 숨김
|
||||
if (!hoveredMmsi || !hoverScreenPos || !ship || selectedMmsi === hoveredMmsi) return null;
|
||||
|
||||
const lastSeen = ship.lastSeen
|
||||
? new Date(ship.lastSeen).toLocaleString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
: '-';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none z-40"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: hoverScreenPos.x + 14,
|
||||
top: hoverScreenPos.y - 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded px-2.5 py-1.5 font-mono text-[10px] leading-relaxed whitespace-nowrap shadow-lg"
|
||||
style={{ background: '#1a1a2e', border: '1px solid rgba(255,255,255,0.15)' }}
|
||||
>
|
||||
<div className="text-[11px] font-bold text-white/90 mb-0.5">
|
||||
{ship.name || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-white/50">MMSI {ship.mmsi}</div>
|
||||
<div className="text-white/50">
|
||||
{ship.lat.toFixed(4)}, {ship.lng.toFixed(4)}
|
||||
</div>
|
||||
<div className="text-white/50">
|
||||
{ship.speed?.toFixed(1) ?? '-'} kn / {ship.heading?.toFixed(0) ?? '-'}°
|
||||
</div>
|
||||
<div className="text-white/40">{lastSeen}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/constants/riskMapping.ts
Normal file
35
frontend/src/constants/riskMapping.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { RiskLevel } from '../types';
|
||||
import type { AnalysisStats } from '../services/vesselAnalysis';
|
||||
|
||||
export type AlertLevel = 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL';
|
||||
|
||||
export const RISK_TO_ALERT: Record<RiskLevel, AlertLevel> = {
|
||||
CRITICAL: 'CRITICAL',
|
||||
HIGH: 'WATCH',
|
||||
MEDIUM: 'MONITOR',
|
||||
LOW: 'NORMAL',
|
||||
};
|
||||
|
||||
export const ALERT_COLOR: Record<AlertLevel, string> = {
|
||||
CRITICAL: '#FF5252',
|
||||
WATCH: '#FFD740',
|
||||
MONITOR: '#18FFFF',
|
||||
NORMAL: '#00E676',
|
||||
};
|
||||
|
||||
export const ALERT_EMOJI: Record<AlertLevel, string> = {
|
||||
CRITICAL: '🔴',
|
||||
WATCH: '🟠',
|
||||
MONITOR: '🟡',
|
||||
NORMAL: '🟢',
|
||||
};
|
||||
|
||||
export const ALERT_LEVELS: AlertLevel[] = ['CRITICAL', 'WATCH', 'MONITOR', 'NORMAL'];
|
||||
|
||||
// 서버 stats 키(critical/high/medium/low) → AlertLevel 매핑
|
||||
export const STATS_KEY_MAP: Record<AlertLevel, keyof Pick<AnalysisStats, 'critical' | 'high' | 'medium' | 'low'>> = {
|
||||
CRITICAL: 'critical',
|
||||
WATCH: 'high',
|
||||
MONITOR: 'medium',
|
||||
NORMAL: 'low',
|
||||
};
|
||||
10
frontend/src/contexts/SymbolScaleContext.tsx
Normal file
10
frontend/src/contexts/SymbolScaleContext.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage';
|
||||
import { SymbolScaleCtx, DEFAULT_SYMBOL_SCALE } from './symbolScaleState';
|
||||
|
||||
export type { SymbolScaleConfig } from './symbolScaleState';
|
||||
|
||||
export function SymbolScaleProvider({ children }: { children: ReactNode }) {
|
||||
const [symbolScale, setSymbolScale] = useLocalStorage('mapSymbolScale', DEFAULT_SYMBOL_SCALE);
|
||||
return <SymbolScaleCtx.Provider value={{ symbolScale, setSymbolScale }}>{children}</SymbolScaleCtx.Provider>;
|
||||
}
|
||||
12
frontend/src/contexts/symbolScaleState.ts
Normal file
12
frontend/src/contexts/symbolScaleState.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export interface SymbolScaleConfig {
|
||||
ship: number;
|
||||
aircraft: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SYMBOL_SCALE: SymbolScaleConfig = { ship: 1.0, aircraft: 1.0 };
|
||||
|
||||
export const SymbolScaleCtx = createContext<{ symbolScale: SymbolScaleConfig; setSymbolScale: (c: SymbolScaleConfig) => void }>({
|
||||
symbolScale: DEFAULT_SYMBOL_SCALE, setSymbolScale: () => {},
|
||||
});
|
||||
129
frontend/src/features/encMap/EncMapSettingsPanel.tsx
Normal file
129
frontend/src/features/encMap/EncMapSettingsPanel.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useState } from 'react';
|
||||
import type { EncMapSettings } from './types';
|
||||
import { DEFAULT_ENC_MAP_SETTINGS, ENC_DEPTH_COLOR_TARGETS } from './types';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
|
||||
interface EncMapSettingsPanelProps {
|
||||
value: EncMapSettings;
|
||||
onChange: (next: EncMapSettings) => void;
|
||||
}
|
||||
|
||||
const SYMBOL_TOGGLES: { key: keyof EncMapSettings; label: string }[] = [
|
||||
{ key: 'showBuoys', label: '부표' },
|
||||
{ key: 'showBeacons', label: '비콘' },
|
||||
{ key: 'showLights', label: '등대' },
|
||||
{ key: 'showDangers', label: '위험물' },
|
||||
{ key: 'showLandmarks', label: '랜드마크' },
|
||||
{ key: 'showSoundings', label: '수심' },
|
||||
{ key: 'showPilot', label: '도선소' },
|
||||
{ key: 'showAnchorage', label: '정박지' },
|
||||
{ key: 'showRestricted', label: '제한구역' },
|
||||
{ key: 'showDredged', label: '준설구역' },
|
||||
{ key: 'showTSS', label: '통항분리대' },
|
||||
{ key: 'showContours', label: '등심선' },
|
||||
];
|
||||
|
||||
const AREA_COLOR_INPUTS: { key: keyof EncMapSettings; label: string }[] = [
|
||||
{ key: 'backgroundColor', label: '바다 배경' },
|
||||
{ key: 'landColor', label: '육지' },
|
||||
{ key: 'coastlineColor', label: '해안선' },
|
||||
];
|
||||
|
||||
|
||||
export function EncMapSettingsPanel({ value, onChange }: EncMapSettingsPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const update = <K extends keyof EncMapSettings>(key: K, val: EncMapSettings[K]) => {
|
||||
onChange({ ...value, [key]: val });
|
||||
};
|
||||
|
||||
const isDefault = JSON.stringify(value) === JSON.stringify(DEFAULT_ENC_MAP_SETTINGS);
|
||||
const allChecked = SYMBOL_TOGGLES.every(({ key }) => value[key] as boolean);
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
const next = { ...value };
|
||||
for (const { key } of SYMBOL_TOGGLES) {
|
||||
(next as Record<string, unknown>)[key] = checked;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(p => !p)}
|
||||
title="ENC 스타일 설정"
|
||||
className={`mode-btn${open ? ' active' : ''}`}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, marginTop: 4, width: 240,
|
||||
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
|
||||
borderRadius: 8, padding: '8px 10px', zIndex: 100,
|
||||
fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', maxHeight: 'calc(100vh - 80px)', overflowY: 'auto',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 11 }}>ENC 설정</span>
|
||||
{!isDefault && (
|
||||
<button type="button" onClick={() => onChange(DEFAULT_ENC_MAP_SETTINGS)}
|
||||
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 3, color: '#f87171', cursor: 'pointer', padding: '1px 6px', fontSize: 9, fontFamily: FONT_MONO }}>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 레이어 토글 */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3, color: '#94a3b8', fontSize: 9 }}>
|
||||
<span>레이어 표시</span>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={allChecked} onChange={e => toggleAll(e.target.checked)} />
|
||||
<span>전체</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 8px' }}>
|
||||
{SYMBOL_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={value[key] as boolean}
|
||||
onChange={e => update(key, e.target.checked as never)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 영역 색상 */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ color: '#94a3b8', fontSize: 9, marginBottom: 3 }}>영역 색상</div>
|
||||
{AREA_COLOR_INPUTS.map(({ key, label }) => (
|
||||
<div key={key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1px 0' }}>
|
||||
<span>{label}</span>
|
||||
<input type="color" value={value[key] as string} title={label}
|
||||
onChange={e => update(key, e.target.value as never)}
|
||||
style={{ width: 24, height: 16, border: 'none', cursor: 'pointer', background: 'transparent' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 수심 색상 */}
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 9, marginBottom: 3 }}>수심 색상</div>
|
||||
{ENC_DEPTH_COLOR_TARGETS.map(({ key, label }) => (
|
||||
<div key={key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1px 0' }}>
|
||||
<span>{label}</span>
|
||||
<input type="color" value={value[key] as string} title={label}
|
||||
onChange={e => update(key, e.target.value as never)}
|
||||
style={{ width: 24, height: 16, border: 'none', cursor: 'pointer', background: 'transparent' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
frontend/src/features/encMap/encSettings.ts
Normal file
44
frontend/src/features/encMap/encSettings.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { EncMapSettings } from './types';
|
||||
import { ENC_LAYER_CATEGORIES, ENC_COLOR_TARGETS, ENC_DEPTH_COLOR_TARGETS } from './types';
|
||||
|
||||
export function applyEncVisibility(map: maplibregl.Map, settings: EncMapSettings): void {
|
||||
for (const [key, layerIds] of Object.entries(ENC_LAYER_CATEGORIES)) {
|
||||
const visible = settings[key as keyof EncMapSettings] as boolean;
|
||||
const vis = visible ? 'visible' : 'none';
|
||||
for (const layerId of layerIds) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', vis);
|
||||
}
|
||||
} catch { /* layer may not exist */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEncColors(map: maplibregl.Map, settings: EncMapSettings): void {
|
||||
for (const [layerId, prop, key] of ENC_COLOR_TARGETS) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setPaintProperty(layerId, prop, settings[key] as string);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('background')) {
|
||||
map.setPaintProperty('background', 'background-color', settings.backgroundColor);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const { key, layerIds } of ENC_DEPTH_COLOR_TARGETS) {
|
||||
const color = settings[key] as string;
|
||||
for (const layerId of layerIds) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setPaintProperty(layerId, 'fill-color', color);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/src/features/encMap/encStyle.ts
Normal file
22
frontend/src/features/encMap/encStyle.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
|
||||
const NAUTICAL_STYLE_URL = 'https://tiles.gcnautical.com/styles/nautical.json';
|
||||
|
||||
const SERVER_FONTS = ['Noto Sans CJK KR Regular', 'Noto Sans Regular'];
|
||||
|
||||
export async function fetchEncStyle(signal: AbortSignal): Promise<StyleSpecification> {
|
||||
const res = await fetch(NAUTICAL_STYLE_URL, { signal });
|
||||
if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`);
|
||||
const style = (await res.json()) as StyleSpecification;
|
||||
|
||||
for (const layer of style.layers) {
|
||||
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
||||
if (!layout) continue;
|
||||
const tf = layout['text-font'];
|
||||
if (Array.isArray(tf) && tf.every((v) => typeof v === 'string')) {
|
||||
layout['text-font'] = SERVER_FONTS;
|
||||
}
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
82
frontend/src/features/encMap/types.ts
Normal file
82
frontend/src/features/encMap/types.ts
Normal file
@ -0,0 +1,82 @@
|
||||
export interface EncMapSettings {
|
||||
showBuoys: boolean;
|
||||
showBeacons: boolean;
|
||||
showLights: boolean;
|
||||
showDangers: boolean;
|
||||
showLandmarks: boolean;
|
||||
showSoundings: boolean;
|
||||
showPilot: boolean;
|
||||
showAnchorage: boolean;
|
||||
showRestricted: boolean;
|
||||
showDredged: boolean;
|
||||
showTSS: boolean;
|
||||
showContours: boolean;
|
||||
|
||||
landColor: string;
|
||||
coastlineColor: string;
|
||||
backgroundColor: string;
|
||||
|
||||
depthDrying: string;
|
||||
depthVeryShallow: string;
|
||||
depthSafetyZone: string;
|
||||
depthMedium: string;
|
||||
depthDeep: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_ENC_MAP_SETTINGS: EncMapSettings = {
|
||||
showBuoys: true,
|
||||
showBeacons: true,
|
||||
showLights: true,
|
||||
showDangers: true,
|
||||
showLandmarks: true,
|
||||
showSoundings: true,
|
||||
showPilot: true,
|
||||
showAnchorage: true,
|
||||
showRestricted: true,
|
||||
showDredged: true,
|
||||
showTSS: true,
|
||||
showContours: true,
|
||||
|
||||
landColor: '#BFBE8D',
|
||||
coastlineColor: '#4C5B62',
|
||||
backgroundColor: '#93AEBB',
|
||||
|
||||
depthDrying: '#58AF99',
|
||||
depthVeryShallow: '#61B7FF',
|
||||
depthSafetyZone: '#82CAFF',
|
||||
depthMedium: '#A7D9FA',
|
||||
depthDeep: '#C9EDFD',
|
||||
};
|
||||
|
||||
export const ENC_LAYER_CATEGORIES: Record<string, string[]> = {
|
||||
showBuoys: ['boylat', 'boycar', 'boyisd', 'boysaw', 'boyspp'],
|
||||
showBeacons: ['lndmrk'],
|
||||
showLights: ['lights', 'lights-catlit'],
|
||||
showDangers: ['uwtroc', 'obstrn', 'wrecks'],
|
||||
showLandmarks: ['lndmrk'],
|
||||
showSoundings: ['soundg', 'soundg-critical'],
|
||||
showPilot: ['pilbop'],
|
||||
showAnchorage: ['achare', 'achare-outline'],
|
||||
showRestricted: ['resare-outline', 'resare-symbol', 'mipare'],
|
||||
showDredged: [
|
||||
'drgare-drying', 'drgare-very-shallow', 'drgare-safety-zone',
|
||||
'drgare-medium', 'drgare-deep', 'drgare-pattern', 'drgare-outline', 'drgare-symbol',
|
||||
],
|
||||
showTSS: ['tsslpt', 'tsslpt-outline'],
|
||||
showContours: ['depcnt', 'depare-safety-edge', 'depare-safety-edge-label'],
|
||||
};
|
||||
|
||||
export const ENC_COLOR_TARGETS: [layerId: string, prop: string, settingsKey: keyof EncMapSettings][] = [
|
||||
['lndare', 'fill-color', 'landColor'],
|
||||
['globe-lndare', 'fill-color', 'landColor'],
|
||||
['coalne', 'line-color', 'coastlineColor'],
|
||||
['globe-coalne', 'line-color', 'coastlineColor'],
|
||||
];
|
||||
|
||||
export const ENC_DEPTH_COLOR_TARGETS: { key: keyof EncMapSettings; label: string; layerIds: string[] }[] = [
|
||||
{ key: 'depthDrying', label: '건출 (< 0m)', layerIds: ['depare-drying', 'drgare-drying'] },
|
||||
{ key: 'depthVeryShallow', label: '극천 (0~2m)', layerIds: ['depare-very-shallow', 'drgare-very-shallow'] },
|
||||
{ key: 'depthSafetyZone', label: '안전수심 (2~30m)', layerIds: ['depare-safety-zone', 'drgare-safety-zone'] },
|
||||
{ key: 'depthMedium', label: '중간 (30m~)', layerIds: ['depare-medium', 'drgare-medium'] },
|
||||
{ key: 'depthDeep', label: '심해', layerIds: ['depare-deep', 'drgare-deep'] },
|
||||
];
|
||||
64
frontend/src/features/encMap/useEncMapSettings.ts
Normal file
64
frontend/src/features/encMap/useEncMapSettings.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import { applyEncVisibility, applyEncColors } from './encSettings';
|
||||
import type { EncMapSettings } from './types';
|
||||
|
||||
/**
|
||||
* 스타일 로드 완료를 안정적으로 감지하여 callback 실행.
|
||||
* gc-wing-dev onMapStyleReady 패턴 이식.
|
||||
*/
|
||||
function onStyleReady(map: maplibregl.Map, callback: () => void): () => void {
|
||||
if (map.isStyleLoaded()) {
|
||||
callback();
|
||||
return () => {};
|
||||
}
|
||||
let fired = false;
|
||||
const runOnce = () => {
|
||||
if (fired || !map.isStyleLoaded()) return;
|
||||
fired = true;
|
||||
callback();
|
||||
try { map.off('style.load', runOnce); map.off('styledata', runOnce); } catch { /* ignore */ }
|
||||
};
|
||||
map.on('style.load', runOnce);
|
||||
map.on('styledata', runOnce);
|
||||
return () => {
|
||||
try { map.off('style.load', runOnce); map.off('styledata', runOnce); } catch { /* ignore */ }
|
||||
};
|
||||
}
|
||||
|
||||
export function useEncMapSettings(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
mapMode: 'satellite' | 'enc',
|
||||
settings: EncMapSettings,
|
||||
syncEpoch = 0,
|
||||
) {
|
||||
// settings를 ref로 유지 — style.load 콜백에서 최신값 참조
|
||||
const settingsRef = useRef(settings);
|
||||
settingsRef.current = settings;
|
||||
|
||||
// syncEpoch 변경 = 맵 로드 완료 → 전체 설정 재적용
|
||||
// mapMode 변경 = 위성↔ENC 전환 → style.load 대기 후 적용
|
||||
useEffect(() => {
|
||||
if (mapMode !== 'enc') return;
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const applyAll = () => {
|
||||
const s = settingsRef.current;
|
||||
applyEncVisibility(map, s);
|
||||
applyEncColors(map, s);
|
||||
};
|
||||
|
||||
const stop = onStyleReady(map, applyAll);
|
||||
return stop;
|
||||
}, [mapMode, syncEpoch, mapRef]);
|
||||
|
||||
// settings 변경 시 즉시 적용 (스타일이 이미 로드된 상태에서)
|
||||
useEffect(() => {
|
||||
if (mapMode !== 'enc') return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
applyEncVisibility(map, settings);
|
||||
applyEncColors(map, settings);
|
||||
}, [settings, mapMode, mapRef]);
|
||||
}
|
||||
415
frontend/src/flow/GearParentFlowViewer.css
Normal file
415
frontend/src/flow/GearParentFlowViewer.css
Normal file
@ -0,0 +1,415 @@
|
||||
.gear-flow-app {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(43, 108, 176, 0.16), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(15, 118, 110, 0.14), transparent 24%),
|
||||
#07111f;
|
||||
color: #dce7f3;
|
||||
}
|
||||
|
||||
.gear-flow-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 332px minmax(880px, 1fr) 392px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.gear-flow-sidebar,
|
||||
.gear-flow-detail {
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(7, 17, 31, 0.86);
|
||||
border-color: rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.gear-flow-sidebar {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
|
||||
.gear-flow-detail {
|
||||
border-left-width: 1px;
|
||||
}
|
||||
|
||||
.gear-flow-hero {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.gear-flow-sidebar .gear-flow-hero,
|
||||
.gear-flow-detail .gear-flow-hero {
|
||||
padding-right: 1.75rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
.gear-flow-panel-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gear-flow-panel-kicker {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-panel-title {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.22;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.gear-flow-panel-description {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.72;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-meta-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.64);
|
||||
padding: 1rem 1.05rem;
|
||||
}
|
||||
|
||||
.gear-flow-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-meta-row span {
|
||||
color: #e2e8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gear-flow-input,
|
||||
.gear-flow-select {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.84);
|
||||
padding: 0.72rem 0.9rem;
|
||||
color: #f8fafc;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gear-flow-input:focus,
|
||||
.gear-flow-select:focus {
|
||||
border-color: rgba(96, 165, 250, 0.7);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
|
||||
}
|
||||
|
||||
.gear-flow-node-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.68);
|
||||
overflow: hidden;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.gear-flow-node-card[data-active="true"] {
|
||||
border-color: rgba(96, 165, 250, 0.7);
|
||||
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.4);
|
||||
background: rgba(20, 35, 59, 0.86);
|
||||
}
|
||||
|
||||
.gear-flow-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.42rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gear-flow-chip[data-tone="implemented"] {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.gear-flow-chip[data-tone="proposed"] {
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.gear-flow-chip[data-tone="neutral"] {
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.gear-flow-section {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.gear-flow-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.gear-flow-list-item {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
padding: 0.75rem 0.88rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-canvas {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gear-flow-topbar {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
top: 20px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gear-flow-topbar-card {
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 18px;
|
||||
background: rgba(7, 17, 31, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.gear-flow-topbar-card--wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
max-width: min(1080px, calc(100vw - 840px));
|
||||
}
|
||||
|
||||
.gear-flow-topbar-title {
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gear-flow-topbar-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
padding: 0.34rem 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gear-flow-react-node {
|
||||
overflow: visible !important;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.gear-flow-react-node.is-selected {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.gear-flow-react-node--proposal {
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
.gear-flow-node {
|
||||
--gear-flow-node-text-offset: 0.98rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.02rem;
|
||||
padding: 1.78rem 2.08rem 1.68rem;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gear-flow-node--function,
|
||||
.gear-flow-node--table,
|
||||
.gear-flow-node--component,
|
||||
.gear-flow-node--artifact,
|
||||
.gear-flow-node--proposal {
|
||||
padding-right: 2rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.gear-flow-node__accent {
|
||||
position: absolute;
|
||||
left: 1.18rem;
|
||||
top: 1.18rem;
|
||||
bottom: 1.18rem;
|
||||
width: 5px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--gear-flow-accent) 82%, white 18%);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.gear-flow-node__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.1rem;
|
||||
padding-left: var(--gear-flow-node-text-offset);
|
||||
}
|
||||
|
||||
.gear-flow-node__heading {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.gear-flow-node__stage {
|
||||
font-size: 1.04rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
line-height: 1.2;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gear-flow-node__title {
|
||||
margin-top: 0;
|
||||
font-size: 1.54rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
color: #f8fafc;
|
||||
padding-right: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-node__symbol {
|
||||
font-size: 1.14rem;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: anywhere;
|
||||
padding-right: 0.2rem;
|
||||
padding-left: var(--gear-flow-node-text-offset);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gear-flow-node__role {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.62;
|
||||
color: #94a3b8;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
padding-right: 0.18rem;
|
||||
padding-left: var(--gear-flow-node-text-offset);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gear-flow-summary {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-empty {
|
||||
border: 1px dashed rgba(148, 163, 184, 0.2);
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.gear-flow-detail-card {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
padding: 1.15rem 1.2rem;
|
||||
}
|
||||
|
||||
.gear-flow-detail-title {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.34;
|
||||
color: #f8fafc;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-symbol {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
color: #7dd3fc;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-text {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.78;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-file {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.65;
|
||||
color: #94a3b8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-section-title {
|
||||
margin-bottom: 0.78rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-link {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.gear-flow-link:hover {
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.gear-flow-detail .gear-flow-section {
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.gear-flow-detail .space-y-6 {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1680px) {
|
||||
.gear-flow-shell {
|
||||
grid-template-columns: 304px minmax(760px, 1fr) 348px;
|
||||
}
|
||||
|
||||
.gear-flow-topbar-card--wrap {
|
||||
max-width: min(860px, calc(100vw - 740px));
|
||||
}
|
||||
}
|
||||
565
frontend/src/flow/GearParentFlowViewer.tsx
Normal file
565
frontend/src/flow/GearParentFlowViewer.tsx
Normal file
@ -0,0 +1,565 @@
|
||||
import { useMemo, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
MarkerType,
|
||||
Position,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeMouseHandler,
|
||||
type EdgeMouseHandler,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import manifest from './gearParentFlowManifest.json';
|
||||
import './GearParentFlowViewer.css';
|
||||
|
||||
type FlowStatus = 'implemented' | 'proposed';
|
||||
|
||||
type FlowNodeMeta = {
|
||||
id: string;
|
||||
label: string;
|
||||
stage: string;
|
||||
kind: string;
|
||||
position: { x: number; y: number };
|
||||
file: string;
|
||||
symbol: string;
|
||||
role: string;
|
||||
params: string[];
|
||||
rules: string[];
|
||||
storageReads: string[];
|
||||
storageWrites: string[];
|
||||
outputs: string[];
|
||||
impacts: string[];
|
||||
status: FlowStatus;
|
||||
};
|
||||
|
||||
type FlowEdgeMeta = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type FlowManifest = {
|
||||
meta: {
|
||||
title: string;
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
description: string;
|
||||
};
|
||||
nodes: FlowNodeMeta[];
|
||||
edges: FlowEdgeMeta[];
|
||||
};
|
||||
|
||||
const flowManifest = manifest as FlowManifest;
|
||||
|
||||
const stageColors: Record<string, string> = {
|
||||
'원천': '#38bdf8',
|
||||
'시간 모델': '#60a5fa',
|
||||
'적재': '#818cf8',
|
||||
'캐시': '#a78bfa',
|
||||
'정규화': '#c084fc',
|
||||
'그룹핑': '#f472b6',
|
||||
'후보 추적': '#fb7185',
|
||||
'검토 워크플로우': '#f97316',
|
||||
'최종 추론': '#f59e0b',
|
||||
'조회 계층': '#22c55e',
|
||||
'프론트': '#14b8a6',
|
||||
'문서': '#06b6d4',
|
||||
'미래 설계': '#eab308',
|
||||
};
|
||||
|
||||
const stageOrder = [
|
||||
'원천',
|
||||
'시간 모델',
|
||||
'적재',
|
||||
'캐시',
|
||||
'정규화',
|
||||
'그룹핑',
|
||||
'후보 추적',
|
||||
'검토 워크플로우',
|
||||
'최종 추론',
|
||||
'조회 계층',
|
||||
'프론트',
|
||||
'문서',
|
||||
'미래 설계',
|
||||
] as const;
|
||||
|
||||
const layoutConfig = {
|
||||
startX: 52,
|
||||
startY: 88,
|
||||
columnGap: 816,
|
||||
rowGap: 309,
|
||||
};
|
||||
|
||||
const semanticSlots: Record<string, { col: number; row: number; yOffset?: number }> = {
|
||||
source_tracks: { col: 0, row: 0 },
|
||||
safe_window: { col: 0, row: 1, yOffset: 36 },
|
||||
snpdb_fetch: { col: 0, row: 2, yOffset: 92 },
|
||||
vessel_store: { col: 0, row: 3, yOffset: 156 },
|
||||
gear_identity: { col: 1, row: 0 },
|
||||
detect_groups: { col: 1, row: 1 },
|
||||
group_snapshots: { col: 1, row: 2 },
|
||||
gear_correlation: { col: 1, row: 3 },
|
||||
workflow_exclusions: { col: 1, row: 4 },
|
||||
score_breakdown: { col: 2, row: 0 },
|
||||
parent_inference: { col: 2, row: 1 },
|
||||
backend_read_model: { col: 2, row: 2 },
|
||||
workflow_api: { col: 2, row: 3 },
|
||||
review_ui: { col: 2, row: 4 },
|
||||
future_episode: { col: 2, row: 5 },
|
||||
mermaid_docs: { col: 0, row: 5, yOffset: 240 },
|
||||
react_flow_viewer: { col: 1, row: 5, yOffset: 182 },
|
||||
};
|
||||
|
||||
function summarizeNode(node: FlowNodeMeta): string {
|
||||
return [node.symbol, node.role].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function matchesQuery(node: FlowNodeMeta, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const normalizedQuery = query.replace(/\s+/g, '').toLowerCase();
|
||||
const haystack = [
|
||||
node.label,
|
||||
node.stage,
|
||||
node.kind,
|
||||
node.file,
|
||||
node.symbol,
|
||||
node.role,
|
||||
...(node.params ?? []),
|
||||
...(node.rules ?? []),
|
||||
...(node.outputs ?? []),
|
||||
...(node.impacts ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
}
|
||||
|
||||
function stageTone(stage: string): string {
|
||||
return stageColors[stage] ?? '#94a3b8';
|
||||
}
|
||||
|
||||
function shapeClipPath(kind: string): string | undefined {
|
||||
switch (kind) {
|
||||
case 'function':
|
||||
return 'polygon(4% 0, 100% 0, 96% 100%, 0 100%)';
|
||||
case 'table':
|
||||
return 'polygon(0 6%, 8% 0, 100% 0, 100% 94%, 92% 100%, 0 100%)';
|
||||
case 'component':
|
||||
return 'polygon(7% 0, 93% 0, 100% 16%, 100% 84%, 93% 100%, 7% 100%, 0 84%, 0 16%)';
|
||||
case 'artifact':
|
||||
return 'polygon(0 0, 86% 0, 100% 14%, 100% 100%, 0 100%)';
|
||||
case 'proposal':
|
||||
return 'polygon(7% 0, 93% 0, 100% 20%, 100% 80%, 93% 100%, 7% 100%, 0 80%, 0 20%)';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function compareNodeOrder(a: FlowNodeMeta, b: FlowNodeMeta): number {
|
||||
const stageGap = stageOrder.indexOf(a.stage as (typeof stageOrder)[number])
|
||||
- stageOrder.indexOf(b.stage as (typeof stageOrder)[number]);
|
||||
if (stageGap !== 0) return stageGap;
|
||||
if (a.position.y !== b.position.y) return a.position.y - b.position.y;
|
||||
if (a.position.x !== b.position.x) return a.position.x - b.position.x;
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
function layoutNodeMeta(nodes: FlowNodeMeta[], _edges: FlowEdgeMeta[]): FlowNodeMeta[] {
|
||||
const sortedNodes = [...nodes].sort(compareNodeOrder);
|
||||
const fallbackSlots = new Map<number, number>();
|
||||
const positioned = new Map<string, { x: number; y: number }>();
|
||||
|
||||
for (const node of sortedNodes) {
|
||||
const semantic = semanticSlots[node.id];
|
||||
if (semantic) {
|
||||
positioned.set(node.id, {
|
||||
x: layoutConfig.startX + semantic.col * layoutConfig.columnGap,
|
||||
y: layoutConfig.startY + semantic.row * layoutConfig.rowGap + (semantic.yOffset ?? 0),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackCol = Math.min(3, stageOrder.indexOf(node.stage as (typeof stageOrder)[number]) % 4);
|
||||
const fallbackRow = fallbackSlots.get(fallbackCol) ?? 5;
|
||||
fallbackSlots.set(fallbackCol, fallbackRow + 1);
|
||||
positioned.set(node.id, {
|
||||
x: layoutConfig.startX + fallbackCol * layoutConfig.columnGap,
|
||||
y: layoutConfig.startY + fallbackRow * layoutConfig.rowGap,
|
||||
});
|
||||
}
|
||||
|
||||
return nodes.map((node) => ({ ...node, position: positioned.get(node.id) ?? node.position }));
|
||||
}
|
||||
|
||||
function buildNodes(nodes: FlowNodeMeta[], selectedNodeId: string | null): Node[] {
|
||||
return nodes.map((node) => {
|
||||
const color = stageTone(node.stage);
|
||||
const clipPath = shapeClipPath(node.kind);
|
||||
const style = {
|
||||
'--gear-flow-accent': color,
|
||||
'--gear-flow-node-text-offset': '0.98rem',
|
||||
width: 380,
|
||||
borderRadius: node.kind === 'component' || node.kind === 'proposal' ? 22 : 18,
|
||||
padding: 0,
|
||||
color: '#e2e8f0',
|
||||
border: `2px solid ${selectedNodeId === node.id ? color : `${color}88`}`,
|
||||
background: 'linear-gradient(180deg, rgba(15,23,42,0.98), rgba(15,23,42,0.84))',
|
||||
boxShadow: selectedNodeId === node.id
|
||||
? `0 0 0 4px ${color}33, 0 26px 52px rgba(2, 6, 23, 0.4)`
|
||||
: '0 18px 36px rgba(2, 6, 23, 0.24)',
|
||||
overflow: 'visible',
|
||||
...(clipPath ? { clipPath } : {}),
|
||||
} as CSSProperties;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
className: `gear-flow-react-node gear-flow-react-node--${node.kind}${selectedNodeId === node.id ? ' is-selected' : ''}`,
|
||||
style,
|
||||
data: { label: (
|
||||
<div className={`gear-flow-node gear-flow-node--${node.kind}`}>
|
||||
<div className="gear-flow-node__accent" />
|
||||
<div className="gear-flow-node__header">
|
||||
<div className="gear-flow-node__heading">
|
||||
<div className="gear-flow-node__stage">{node.stage}</div>
|
||||
<div className="gear-flow-node__title">{node.label}</div>
|
||||
</div>
|
||||
<span
|
||||
className="gear-flow-chip shrink-0"
|
||||
data-tone={node.status === 'implemented' ? 'implemented' : 'proposed'}
|
||||
>
|
||||
{node.status === 'implemented' ? '구현됨' : '제안됨'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gear-flow-node__symbol">{node.symbol}</div>
|
||||
<div className="gear-flow-node__role">{node.role}</div>
|
||||
</div>
|
||||
)},
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildEdges(edges: FlowEdgeMeta[], selectedEdgeId: string | null): Edge[] {
|
||||
return edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'smoothstep',
|
||||
pathOptions: { borderRadius: 18, offset: 18 },
|
||||
label: edge.label,
|
||||
animated: selectedEdgeId === edge.id,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: selectedEdgeId === edge.id ? '#f8fafc' : '#94a3b8',
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
style: {
|
||||
stroke: selectedEdgeId === edge.id ? '#f8fafc' : '#94a3b8',
|
||||
strokeWidth: selectedEdgeId === edge.id ? 2.8 : 1.9,
|
||||
},
|
||||
labelStyle: {
|
||||
fill: '#e2e8f0',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: 'rgba(7, 17, 31, 0.94)',
|
||||
fillOpacity: 0.96,
|
||||
stroke: 'rgba(148, 163, 184, 0.24)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
labelBgPadding: [8, 5],
|
||||
labelBgBorderRadius: 12,
|
||||
}));
|
||||
}
|
||||
|
||||
function DetailList({ items }: { items: string[] }) {
|
||||
if (!items.length) {
|
||||
return <div className="text-sm text-slate-500">없음</div>;
|
||||
}
|
||||
return (
|
||||
<div className="gear-flow-list">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="gear-flow-list-item">{item}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearParentFlowViewer() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [stageFilter, setStageFilter] = useState('전체');
|
||||
const [statusFilter, setStatusFilter] = useState<'전체' | FlowStatus>('전체');
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(flowManifest.nodes[0]?.id ?? null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
|
||||
const filteredNodeMeta = useMemo(() => {
|
||||
return flowManifest.nodes.filter((node) => {
|
||||
if (stageFilter !== '전체' && node.stage !== stageFilter) return false;
|
||||
if (statusFilter !== '전체' && node.status !== statusFilter) return false;
|
||||
return matchesQuery(node, search);
|
||||
});
|
||||
}, [search, stageFilter, statusFilter]);
|
||||
|
||||
const visibleNodeIds = useMemo(() => new Set(filteredNodeMeta.map((node) => node.id)), [filteredNodeMeta]);
|
||||
|
||||
const filteredEdgeMeta = useMemo(() => {
|
||||
return flowManifest.edges.filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target));
|
||||
}, [visibleNodeIds]);
|
||||
|
||||
const layoutedNodeMeta = useMemo(
|
||||
() => layoutNodeMeta(filteredNodeMeta, filteredEdgeMeta),
|
||||
[filteredNodeMeta, filteredEdgeMeta],
|
||||
);
|
||||
|
||||
const reactFlowNodes = useMemo(
|
||||
() => buildNodes(layoutedNodeMeta, selectedNodeId),
|
||||
[layoutedNodeMeta, selectedNodeId],
|
||||
);
|
||||
const reactFlowEdges = useMemo(
|
||||
() => buildEdges(filteredEdgeMeta, selectedEdgeId),
|
||||
[filteredEdgeMeta, selectedEdgeId],
|
||||
);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => flowManifest.nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||
[selectedNodeId],
|
||||
);
|
||||
const selectedEdge = useMemo(
|
||||
() => flowManifest.edges.find((edge) => edge.id === selectedEdgeId) ?? null,
|
||||
[selectedEdgeId],
|
||||
);
|
||||
|
||||
const onNodeClick: NodeMouseHandler = (_event, node) => {
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedNodeId(node.id);
|
||||
};
|
||||
|
||||
const onEdgeClick: EdgeMouseHandler = (_event, edge) => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(edge.id);
|
||||
};
|
||||
|
||||
const onNodeMouseEnter: NodeMouseHandler = (_event, node) => {
|
||||
if (!selectedNodeId) setSelectedNodeId(node.id);
|
||||
};
|
||||
|
||||
const stageOptions = useMemo(
|
||||
() => ['전체', ...Array.from(new Set(flowManifest.nodes.map((node) => node.stage)))],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gear-flow-app">
|
||||
<div className="gear-flow-shell">
|
||||
<aside className="gear-flow-sidebar flex flex-col">
|
||||
<div className="gear-flow-hero space-y-4 px-6 py-6">
|
||||
<div className="gear-flow-panel-heading">
|
||||
<div className="gear-flow-panel-kicker">Flow Source</div>
|
||||
<h1 className="gear-flow-panel-title">{flowManifest.meta.title}</h1>
|
||||
<p className="gear-flow-panel-description">{flowManifest.meta.description}</p>
|
||||
</div>
|
||||
<div className="gear-flow-meta-card">
|
||||
<div className="gear-flow-meta-row">버전 <span>{flowManifest.meta.version}</span></div>
|
||||
<div className="gear-flow-meta-row">갱신일 <span>{flowManifest.meta.updatedAt}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">검색</label>
|
||||
<input
|
||||
className="gear-flow-input"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="모듈, 메서드, 규칙, 파일 검색"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">단계</label>
|
||||
<select className="gear-flow-select" value={stageFilter} onChange={(event) => setStageFilter(event.target.value)}>
|
||||
{stageOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">상태</label>
|
||||
<select
|
||||
className="gear-flow-select"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as '전체' | FlowStatus)}
|
||||
>
|
||||
<option value="전체">전체</option>
|
||||
<option value="implemented">구현됨</option>
|
||||
<option value="proposed">제안됨</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="mb-3 flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<span>노드 목록</span>
|
||||
<span>{filteredNodeMeta.length}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{layoutedNodeMeta.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className="gear-flow-node-card w-full p-4 text-left transition"
|
||||
data-active={selectedNodeId === node.id}
|
||||
onClick={() => {
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedNodeId(node.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{node.stage}</div>
|
||||
<div className="mt-1 text-base font-semibold text-slate-50">{node.label}</div>
|
||||
<div className="gear-flow-summary mt-2 text-sm leading-6 text-slate-400">{summarizeNode(node)}</div>
|
||||
</div>
|
||||
<span className="gear-flow-chip shrink-0" data-tone={node.status === 'implemented' ? 'implemented' : 'proposed'}>
|
||||
{node.status === 'implemented' ? '구현됨' : '제안됨'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="gear-flow-canvas">
|
||||
<div className="gear-flow-topbar">
|
||||
<div className="gear-flow-topbar-card gear-flow-topbar-card--wrap px-5 py-3 text-sm text-slate-300">
|
||||
<span className="gear-flow-topbar-title">React Flow Viewer</span>
|
||||
<span className="gear-flow-topbar-pill">노드 클릭 시 상세 스펙</span>
|
||||
<span className="gear-flow-topbar-pill">엣지 클릭 시 전달 의미</span>
|
||||
<span className="gear-flow-topbar-pill">검색/단계/상태 필터</span>
|
||||
</div>
|
||||
</div>
|
||||
<ReactFlow
|
||||
nodes={reactFlowNodes}
|
||||
edges={reactFlowEdges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.03, minZoom: 0.7, maxZoom: 1.2 }}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeMouseEnter={onNodeMouseEnter}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
minZoom={0.35}
|
||||
maxZoom={1.8}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeStrokeColor={(node) => {
|
||||
const meta = flowManifest.nodes.find((item) => item.id === node.id);
|
||||
return meta ? stageTone(meta.stage) : '#94a3b8';
|
||||
}}
|
||||
nodeColor={(node) => {
|
||||
const meta = flowManifest.nodes.find((item) => item.id === node.id);
|
||||
return meta ? `${stageTone(meta.stage)}55` : '#334155';
|
||||
}}
|
||||
maskColor="rgba(7, 17, 31, 0.68)"
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
<Background color="#20324d" gap={24} size={1.2} />
|
||||
</ReactFlow>
|
||||
</main>
|
||||
|
||||
<aside className="gear-flow-detail flex flex-col">
|
||||
<div className="gear-flow-hero px-6 py-6">
|
||||
<div className="gear-flow-panel-heading">
|
||||
<div className="gear-flow-panel-kicker">Detail</div>
|
||||
<h2 className="gear-flow-panel-title">상세 정보</h2>
|
||||
<p className="gear-flow-panel-description">
|
||||
노드를 클릭하면 역할, 파라미터, 판단 기준, 저장소 영향과 downstream 관계를 확인할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
{selectedNode ? (
|
||||
<div className="space-y-6">
|
||||
<div className="gear-flow-detail-card">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="gear-flow-chip" data-tone={selectedNode.status === 'implemented' ? 'implemented' : 'proposed'}>
|
||||
{selectedNode.status === 'implemented' ? '구현됨' : '제안됨'}
|
||||
</span>
|
||||
<span className="gear-flow-chip" data-tone="neutral">{selectedNode.stage}</span>
|
||||
<span className="gear-flow-chip" data-tone="neutral">{selectedNode.kind}</span>
|
||||
</div>
|
||||
<div className="gear-flow-detail-title">{selectedNode.label}</div>
|
||||
<div className="gear-flow-detail-symbol">{selectedNode.symbol}</div>
|
||||
<div className="gear-flow-detail-text">{selectedNode.role}</div>
|
||||
<div className="gear-flow-detail-file">{selectedNode.file}</div>
|
||||
</div>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">파라미터</div>
|
||||
<DetailList items={selectedNode.params} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">판단 기준</div>
|
||||
<DetailList items={selectedNode.rules} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">읽는 저장소</div>
|
||||
<DetailList items={selectedNode.storageReads} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">쓰는 저장소</div>
|
||||
<DetailList items={selectedNode.storageWrites} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">출력</div>
|
||||
<DetailList items={selectedNode.outputs} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">영향 관계</div>
|
||||
<DetailList items={selectedNode.impacts} />
|
||||
</section>
|
||||
</div>
|
||||
) : selectedEdge ? (
|
||||
<div className="space-y-6">
|
||||
<div className="gear-flow-detail-card">
|
||||
<span className="gear-flow-chip" data-tone="neutral">엣지</span>
|
||||
<div className="gear-flow-detail-title">{selectedEdge.label || selectedEdge.id}</div>
|
||||
<div className="gear-flow-detail-text">{selectedEdge.detail || '설명 없음'}</div>
|
||||
<div className="gear-flow-detail-file">{selectedEdge.source} → {selectedEdge.target}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gear-flow-detail-empty px-5 py-6 text-sm leading-7 text-slate-400">
|
||||
노드나 엣지를 선택하면 상세 정보가 여기에 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GearParentFlowViewer;
|
||||
325
frontend/src/flow/gearParentFlowManifest.json
Normal file
325
frontend/src/flow/gearParentFlowManifest.json
Normal file
@ -0,0 +1,325 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "어구 모선 추적 데이터 흐름",
|
||||
"version": "2026-04-03",
|
||||
"updatedAt": "2026-04-03",
|
||||
"description": "snpdb 적재부터 review/label workflow와 episode continuity + prior bonus까지의 전체 흐름"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "source_tracks",
|
||||
"label": "5분 원천 궤적",
|
||||
"stage": "원천",
|
||||
"kind": "table",
|
||||
"position": { "x": 0, "y": 20 },
|
||||
"file": "signal.t_vessel_tracks_5min",
|
||||
"symbol": "signal.t_vessel_tracks_5min",
|
||||
"role": "5분 bucket 단위 AIS 궤적 원천 테이블",
|
||||
"params": ["1 row = 1 MMSI = 5분 linestringM"],
|
||||
"rules": ["bbox 122,31,132,39", "LineStringM dump 후 point timestamp 사용"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["mmsi", "time_bucket", "timestamp", "lat", "lon", "raw_sog"],
|
||||
"impacts": ["모든 그룹/점수 계산의 원천 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "safe_window",
|
||||
"label": "safe watermark",
|
||||
"stage": "시간 모델",
|
||||
"kind": "function",
|
||||
"position": { "x": 260, "y": 20 },
|
||||
"file": "prediction/time_bucket.py",
|
||||
"symbol": "compute_safe_bucket / compute_incremental_window_start",
|
||||
"role": "미완결 bucket 차단과 overlap backfill 시작점 계산",
|
||||
"params": ["SNPDB_SAFE_DELAY_MIN", "SNPDB_BACKFILL_BUCKETS"],
|
||||
"rules": ["safe bucket까지만 조회", "last_bucket보다 과거도 일부 재조회"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["safe_bucket", "window_start", "from_bucket"],
|
||||
"impacts": ["live cache drift 완화", "재기동 spike 억제"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "snpdb_fetch",
|
||||
"label": "snpdb 적재",
|
||||
"stage": "적재",
|
||||
"kind": "module",
|
||||
"position": { "x": 520, "y": 20 },
|
||||
"file": "prediction/db/snpdb.py",
|
||||
"symbol": "fetch_all_tracks / fetch_incremental",
|
||||
"role": "safe bucket까지 초기/증분 궤적 적재",
|
||||
"params": ["hours=24", "last_bucket"],
|
||||
"rules": ["time_bucket > from_bucket", "time_bucket <= safe_bucket"],
|
||||
"storageReads": ["signal.t_vessel_tracks_5min"],
|
||||
"storageWrites": [],
|
||||
"outputs": ["DataFrame of points"],
|
||||
"impacts": ["VesselStore 초기화와 증분 merge 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "vessel_store",
|
||||
"label": "VesselStore 캐시",
|
||||
"stage": "캐시",
|
||||
"kind": "module",
|
||||
"position": { "x": 800, "y": 20 },
|
||||
"file": "prediction/cache/vessel_store.py",
|
||||
"symbol": "load_initial / merge_incremental / evict_stale",
|
||||
"role": "24시간 sliding in-memory cache 유지",
|
||||
"params": ["_tracks", "_last_bucket"],
|
||||
"rules": ["timestamp dedupe", "safe bucket 기준 24h eviction"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["latest positions", "tracks by MMSI"],
|
||||
"impacts": ["identity, grouping, correlation, inference 공통 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "gear_identity",
|
||||
"label": "어구 identity",
|
||||
"stage": "정규화",
|
||||
"kind": "module",
|
||||
"position": { "x": 1080, "y": 20 },
|
||||
"file": "prediction/fleet_tracker.py",
|
||||
"symbol": "track_gear_identity",
|
||||
"role": "어구 이름 패턴 파싱과 gear_identity_log 유지",
|
||||
"params": ["parent_name", "gear_index_1", "gear_index_2"],
|
||||
"rules": ["정규화 길이 4 미만 제외", "같은 이름 다른 MMSI면 identity migration"],
|
||||
"storageReads": ["fleet_vessels"],
|
||||
"storageWrites": ["gear_identity_log", "gear_correlation_scores(target_mmsi transfer)"],
|
||||
"outputs": ["active gear identity rows"],
|
||||
"impacts": ["grouping과 parent_mmsi 보조 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "detect_groups",
|
||||
"label": "어구 그룹 검출",
|
||||
"stage": "그룹핑",
|
||||
"kind": "function",
|
||||
"position": { "x": 260, "y": 220 },
|
||||
"file": "prediction/algorithms/polygon_builder.py",
|
||||
"symbol": "detect_gear_groups",
|
||||
"role": "이름 기반 raw group과 거리 기반 sub-cluster 생성",
|
||||
"params": ["MAX_DIST_DEG=0.15", "STALE_SEC", "is_trackable_parent_name"],
|
||||
"rules": ["440/441 제외", "single cluster면 sc#0", "multi cluster면 sc#1..N", "재병합 시 sc#0"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["gear_groups[]"],
|
||||
"impacts": ["sub_cluster_id는 순간 라벨일 뿐 영구 ID가 아님"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "group_snapshots",
|
||||
"label": "그룹 스냅샷 생성",
|
||||
"stage": "그룹핑",
|
||||
"kind": "function",
|
||||
"position": { "x": 520, "y": 220 },
|
||||
"file": "prediction/algorithms/polygon_builder.py",
|
||||
"symbol": "build_all_group_snapshots",
|
||||
"role": "1h/1h-fb/6h polygon snapshot 생성",
|
||||
"params": ["parent_active_1h", "MIN_GEAR_GROUP_SIZE"],
|
||||
"rules": ["1h 활성<2이면 1h-fb", "수역 외 소수 멤버 제외", "parent nearby면 isParent=true"],
|
||||
"storageReads": [],
|
||||
"storageWrites": ["group_polygon_snapshots"],
|
||||
"outputs": ["group snapshots"],
|
||||
"impacts": ["backend live 현황과 parent inference center track 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "gear_correlation",
|
||||
"label": "correlation 모델",
|
||||
"stage": "후보 추적",
|
||||
"kind": "module",
|
||||
"position": { "x": 800, "y": 220 },
|
||||
"file": "prediction/algorithms/gear_correlation.py",
|
||||
"symbol": "run_gear_correlation",
|
||||
"role": "후보 선박/어구 raw metric과 EMA score 계산",
|
||||
"params": ["active models", "track_threshold", "decay_fast", "candidate max=30"],
|
||||
"rules": ["선박은 track 기반", "어구 후보는 GEAR_BUOY", "후보 이탈 시 fast decay"],
|
||||
"storageReads": ["group snapshots", "vessel_store", "correlation_param_models"],
|
||||
"storageWrites": ["gear_correlation_raw_metrics", "gear_correlation_scores"],
|
||||
"outputs": ["raw metrics", "EMA score rows"],
|
||||
"impacts": ["parent inference 후보 seed"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "workflow_exclusions",
|
||||
"label": "후보 제외 / 라벨",
|
||||
"stage": "검토 워크플로우",
|
||||
"kind": "table",
|
||||
"position": { "x": 1080, "y": 220 },
|
||||
"file": "database/migration/014_gear_parent_workflow_v2_phase1.sql",
|
||||
"symbol": "gear_parent_candidate_exclusions / gear_parent_label_sessions",
|
||||
"role": "사람 판단 데이터를 자동 추론과 분리 저장",
|
||||
"params": ["scope=GROUP|GLOBAL", "duration=1|3|5d"],
|
||||
"rules": ["GLOBAL은 모든 그룹에서 제거", "ACTIVE label session만 tracking"],
|
||||
"storageReads": [],
|
||||
"storageWrites": ["gear_parent_candidate_exclusions", "gear_parent_label_sessions"],
|
||||
"outputs": ["active exclusions", "active label sessions"],
|
||||
"impacts": ["parent inference candidate pruning", "label tracking"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "parent_inference",
|
||||
"label": "모선 추론",
|
||||
"stage": "최종 추론",
|
||||
"kind": "module",
|
||||
"position": { "x": 260, "y": 420 },
|
||||
"file": "prediction/algorithms/gear_parent_inference.py",
|
||||
"symbol": "run_gear_parent_inference",
|
||||
"role": "후보 생성, coverage-aware scoring, 상태 전이, resolution 저장",
|
||||
"params": ["auto score 0.72/0.15/3", "review threshold 0.60", "412/413 bonus +15%"],
|
||||
"rules": ["DIRECT_PARENT_MATCH", "SKIPPED_SHORT_NAME", "NO_CANDIDATE", "AUTO_PROMOTED", "REVIEW_REQUIRED", "UNRESOLVED"],
|
||||
"storageReads": ["gear_correlation_scores", "gear_correlation_raw_metrics", "group_polygon_snapshots", "active exclusions", "active label sessions"],
|
||||
"storageWrites": ["gear_group_parent_candidate_snapshots", "gear_group_parent_resolution", "gear_parent_label_tracking_cycles"],
|
||||
"outputs": ["candidate snapshots", "resolution current state", "label tracking rows"],
|
||||
"impacts": ["review queue", "group detail", "future prior feature source"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "score_breakdown",
|
||||
"label": "점수 보정",
|
||||
"stage": "최종 추론",
|
||||
"kind": "function",
|
||||
"position": { "x": 520, "y": 420 },
|
||||
"file": "prediction/algorithms/gear_parent_inference.py",
|
||||
"symbol": "_build_candidate_scores / _build_track_coverage_metrics",
|
||||
"role": "이름, 궤적, 방문, 근접, 활동, 안정성, bonus를 합산",
|
||||
"params": ["name 1.0/0.8/0.5/0.3", "coverage factors", "registry +0.05", "china prefix +0.15"],
|
||||
"rules": ["raw->effective 보정", "preBonusScore>=0.30일 때만 412/413 bonus"],
|
||||
"storageReads": [],
|
||||
"storageWrites": ["candidate evidence JSON"],
|
||||
"outputs": ["final_score", "coverage metrics", "evidenceConfidence"],
|
||||
"impacts": ["review UI 설명력", "future signal/prior 분리 설계"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "backend_read_model",
|
||||
"label": "backend read model",
|
||||
"stage": "조회 계층",
|
||||
"kind": "module",
|
||||
"position": { "x": 800, "y": 420 },
|
||||
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java",
|
||||
"symbol": "group list / review queue / detail SQL",
|
||||
"role": "최신 전역 1h live snapshot과 fresh inference만 노출",
|
||||
"params": ["snapshot_time max where resolution=1h"],
|
||||
"rules": ["last_evaluated_at >= snapshot_time", "사라진 과거 sub-cluster 숨김"],
|
||||
"storageReads": ["group_polygon_snapshots", "gear_group_parent_resolution", "gear_group_parent_candidate_snapshots"],
|
||||
"storageWrites": [],
|
||||
"outputs": ["GroupPolygonDto", "GroupParentInferenceDto", "review queue rows"],
|
||||
"impacts": ["stale inference 차단", "프론트 live 상세 일관성"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "workflow_api",
|
||||
"label": "workflow API",
|
||||
"stage": "조회 계층",
|
||||
"kind": "module",
|
||||
"position": { "x": 1080, "y": 420 },
|
||||
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java",
|
||||
"symbol": "candidate-exclusions / label-sessions endpoints",
|
||||
"role": "그룹 제외, 전역 제외, 라벨 세션, tracking 조회 API",
|
||||
"params": ["POST/GET workflow actions"],
|
||||
"rules": ["activeOnly query", "release/cancel action"],
|
||||
"storageReads": ["workflow tables"],
|
||||
"storageWrites": ["workflow tables", "review log"],
|
||||
"outputs": ["workflow DTO responses"],
|
||||
"impacts": ["human-in-the-loop 데이터 축적"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "review_ui",
|
||||
"label": "모선 검토 UI",
|
||||
"stage": "프론트",
|
||||
"kind": "component",
|
||||
"position": { "x": 520, "y": 620 },
|
||||
"file": "frontend/src/components/korea/ParentReviewPanel.tsx",
|
||||
"symbol": "ParentReviewPanel",
|
||||
"role": "후보 비교, 필터, 라벨/제외 액션, coverage evidence 표시",
|
||||
"params": ["min score", "min gear count", "search", "spatial filter"],
|
||||
"rules": ["30% 미만 후보 비표시", "검색/범위/어구수 필터 AND", "hover 기반 overlay 강조"],
|
||||
"storageReads": ["review/detail API", "localStorage filters"],
|
||||
"storageWrites": ["workflow API actions", "localStorage filters"],
|
||||
"outputs": ["review decisions", "candidate interpretation"],
|
||||
"impacts": ["사람 판단 백데이터 생성"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "mermaid_docs",
|
||||
"label": "Mermaid 산출물",
|
||||
"stage": "문서",
|
||||
"kind": "artifact",
|
||||
"position": { "x": 800, "y": 620 },
|
||||
"file": "docs/generated/gear-parent-flow-overview.md",
|
||||
"symbol": "generated Mermaid docs",
|
||||
"role": "정적 흐름도와 노드 인덱스 문서",
|
||||
"params": ["manifest JSON"],
|
||||
"rules": ["generator 재실행 시 갱신"],
|
||||
"storageReads": ["flow manifest"],
|
||||
"storageWrites": ["docs/generated/*.md", "docs/generated/*.mmd"],
|
||||
"outputs": ["overview flowchart", "node index"],
|
||||
"impacts": ["정적 문서 기반 리뷰/공유"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "react_flow_viewer",
|
||||
"label": "React Flow viewer",
|
||||
"stage": "문서",
|
||||
"kind": "component",
|
||||
"position": { "x": 1080, "y": 620 },
|
||||
"file": "frontend/src/flow/GearParentFlowViewer.tsx",
|
||||
"symbol": "GearParentFlowViewer",
|
||||
"role": "노드 클릭/검색/필터/상세 패널이 있는 인터랙티브 흐름 뷰어",
|
||||
"params": ["stage filter", "search", "node detail"],
|
||||
"rules": ["별도 HTML entry", "manifest를 단일 source로 사용"],
|
||||
"storageReads": ["flow manifest"],
|
||||
"storageWrites": [],
|
||||
"outputs": ["interactive HTML graph"],
|
||||
"impacts": ["개발/검토/설명 자료"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "future_episode",
|
||||
"label": "episode continuity",
|
||||
"stage": "후보 추적",
|
||||
"kind": "module",
|
||||
"position": { "x": 260, "y": 620 },
|
||||
"file": "prediction/algorithms/gear_parent_episode.py",
|
||||
"symbol": "build_episode_plan / compute_prior_bonus_components",
|
||||
"role": "sub_cluster continuity와 episode/lineage/label prior bonus를 계산하는 계층",
|
||||
"params": ["split", "merge", "expire", "24h/7d/30d prior windows"],
|
||||
"rules": ["small member change는 same episode", "true merge는 new episode", "prior bonus는 weak carry-over + cap 0.20"],
|
||||
"storageReads": ["gear_group_episodes", "gear_group_episode_snapshots", "candidate snapshots", "label history"],
|
||||
"storageWrites": ["gear_group_episodes", "gear_group_episode_snapshots"],
|
||||
"outputs": ["episode assignment", "continuity source/score", "prior bonus components"],
|
||||
"impacts": ["장기 기억 기반 추론", "split/merge 이후 후보 관성 완화"],
|
||||
"status": "implemented"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "e1", "source": "source_tracks", "target": "safe_window", "label": "bucket window", "detail": "원천 5분 bucket에 safe delay와 overlap backfill 적용" },
|
||||
{ "id": "e2", "source": "safe_window", "target": "snpdb_fetch", "label": "fetch bounds", "detail": "safe_bucket, from_bucket, window_start 전달" },
|
||||
{ "id": "e3", "source": "snpdb_fetch", "target": "vessel_store", "label": "points", "detail": "초기/증분 point DataFrame 적재" },
|
||||
{ "id": "e4", "source": "vessel_store", "target": "gear_identity", "label": "latest positions", "detail": "어구 이름 패턴과 parent_name 파싱" },
|
||||
{ "id": "e5", "source": "vessel_store", "target": "detect_groups", "label": "latest positions", "detail": "어구 raw group과 서브클러스터 생성" },
|
||||
{ "id": "e6", "source": "detect_groups", "target": "group_snapshots", "label": "gear_groups", "detail": "1h/1h-fb/6h polygon snapshot 생성" },
|
||||
{ "id": "e7", "source": "vessel_store", "target": "gear_correlation", "label": "tracks", "detail": "후보 선박 6h track과 latest positions 입력" },
|
||||
{ "id": "e8", "source": "detect_groups", "target": "gear_correlation", "label": "groups", "detail": "그룹 중심, 반경, active ratio 계산 입력" },
|
||||
{ "id": "e9", "source": "group_snapshots", "target": "backend_read_model", "label": "snapshots", "detail": "최신 1h live group read model 구성" },
|
||||
{ "id": "e10", "source": "group_snapshots", "target": "parent_inference", "label": "center tracks", "detail": "최근 6h 그룹 중심 이동과 live parent membership 입력" },
|
||||
{ "id": "e11", "source": "gear_correlation", "target": "parent_inference", "label": "scores + raw", "detail": "correlation score와 raw metrics 사용" },
|
||||
{ "id": "e11a", "source": "detect_groups", "target": "future_episode", "label": "current clusters", "detail": "현재 gear group 멤버/중심점으로 episode continuity 계산" },
|
||||
{ "id": "e11b", "source": "workflow_exclusions", "target": "future_episode", "label": "label history", "detail": "label session lineage를 label prior 입력으로 사용" },
|
||||
{ "id": "e11c", "source": "future_episode", "target": "parent_inference", "label": "episode assignment", "detail": "episode_id, continuity source, prior aggregate를 candidate build에 반영" },
|
||||
{ "id": "e12", "source": "workflow_exclusions", "target": "parent_inference", "label": "active gates", "detail": "group/global exclusion과 label session을 candidate build에 반영" },
|
||||
{ "id": "e13", "source": "parent_inference", "target": "score_breakdown", "label": "candidate scoring", "detail": "이름/track/visit/proximity/activity/stability와 bonus 계산" },
|
||||
{ "id": "e13a", "source": "future_episode", "target": "score_breakdown", "label": "prior bonus", "detail": "episode/lineage/label prior bonus를 final score 마지막 단계에 가산" },
|
||||
{ "id": "e14", "source": "score_breakdown", "target": "backend_read_model", "label": "fresh candidate/resolution", "detail": "fresh inference만 group detail과 review queue에 노출" },
|
||||
{ "id": "e15", "source": "workflow_api", "target": "workflow_exclusions", "label": "CRUD", "detail": "exclusion/label 생성, 취소, 조회" },
|
||||
{ "id": "e16", "source": "backend_read_model", "target": "review_ui", "label": "review/detail API", "detail": "모선 검토 UI의 기본 데이터 소스" },
|
||||
{ "id": "e17", "source": "workflow_api", "target": "review_ui", "label": "actions", "detail": "라벨/그룹 제외/전체 제외/해제 액션 처리" },
|
||||
{ "id": "e18", "source": "review_ui", "target": "mermaid_docs", "label": "human-readable spec", "detail": "정적 문서와 UI 해석 흐름 연결" },
|
||||
{ "id": "e19", "source": "review_ui", "target": "react_flow_viewer", "label": "same manifest", "detail": "문서와 viewer가 같은 구조 설명을 공유" },
|
||||
{ "id": "e20", "source": "parent_inference", "target": "future_episode", "label": "episode snapshots", "detail": "current resolution과 top candidate를 episode snapshot/history에 기록" }
|
||||
]
|
||||
}
|
||||
14
frontend/src/gearParentFlowMain.tsx
Normal file
14
frontend/src/gearParentFlowMain.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/noto-sans-kr';
|
||||
import '@fontsource-variable/fira-code';
|
||||
import './styles/tailwind.css';
|
||||
import './index.css';
|
||||
import GearParentFlowViewer from './flow/GearParentFlowViewer';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<GearParentFlowViewer />
|
||||
</StrictMode>,
|
||||
);
|
||||
@ -1,46 +1,51 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import type { Ship, VesselAnalysisDto } from '../types';
|
||||
import type { Ship, VesselAnalysisDto, RiskLevel } from '../types';
|
||||
import { useFontScale } from './useFontScale';
|
||||
import { FONT_MONO } from '../styles/fonts';
|
||||
import { RISK_TO_ALERT, type AlertLevel } from '../constants/riskMapping';
|
||||
|
||||
interface AnalyzedShip {
|
||||
ship: Ship;
|
||||
dto: VesselAnalysisDto;
|
||||
alert: AlertLevel;
|
||||
}
|
||||
|
||||
// RISK_RGBA: [r, g, b, a] 충전색
|
||||
const RISK_RGBA: Record<string, [number, number, number, number]> = {
|
||||
CRITICAL: [239, 68, 68, 60],
|
||||
HIGH: [249, 115, 22, 50],
|
||||
MEDIUM: [234, 179, 8, 40],
|
||||
// AlertLevel 기반 충전색 (현장분석 팔레트 통일)
|
||||
const ALERT_RGBA: Record<AlertLevel, [number, number, number, number]> = {
|
||||
CRITICAL: [255, 82, 82, 60],
|
||||
WATCH: [255, 215, 64, 50],
|
||||
MONITOR: [24, 255, 255, 40],
|
||||
NORMAL: [0, 230, 118, 30],
|
||||
};
|
||||
|
||||
// 테두리색
|
||||
const RISK_RGBA_BORDER: Record<string, [number, number, number, number]> = {
|
||||
CRITICAL: [239, 68, 68, 230],
|
||||
HIGH: [249, 115, 22, 210],
|
||||
MEDIUM: [234, 179, 8, 190],
|
||||
const ALERT_RGBA_BORDER: Record<AlertLevel, [number, number, number, number]> = {
|
||||
CRITICAL: [255, 82, 82, 230],
|
||||
WATCH: [255, 215, 64, 210],
|
||||
MONITOR: [24, 255, 255, 190],
|
||||
NORMAL: [0, 230, 118, 160],
|
||||
};
|
||||
|
||||
// 픽셀 반경
|
||||
const RISK_SIZE: Record<string, number> = {
|
||||
const ALERT_SIZE: Record<AlertLevel, number> = {
|
||||
CRITICAL: 18,
|
||||
HIGH: 14,
|
||||
MEDIUM: 12,
|
||||
WATCH: 14,
|
||||
MONITOR: 12,
|
||||
NORMAL: 10,
|
||||
};
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
const ALERT_LABEL: Record<AlertLevel, string> = {
|
||||
CRITICAL: '긴급',
|
||||
HIGH: '경고',
|
||||
MEDIUM: '주의',
|
||||
WATCH: '경고',
|
||||
MONITOR: '주의',
|
||||
NORMAL: '정상',
|
||||
};
|
||||
|
||||
const RISK_PRIORITY: Record<string, number> = {
|
||||
const ALERT_PRIORITY: Record<AlertLevel, number> = {
|
||||
CRITICAL: 0,
|
||||
HIGH: 1,
|
||||
MEDIUM: 2,
|
||||
WATCH: 1,
|
||||
MONITOR: 2,
|
||||
NORMAL: 3,
|
||||
};
|
||||
|
||||
interface AnalysisData {
|
||||
@ -69,18 +74,14 @@ export function useAnalysisDeckLayers(
|
||||
|
||||
const analyzedShips: AnalyzedShip[] = ships
|
||||
.filter(s => analysisMap.has(s.mmsi))
|
||||
.map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! }));
|
||||
.map(s => {
|
||||
const dto = analysisMap.get(s.mmsi)!;
|
||||
return { ship: s, dto, alert: RISK_TO_ALERT[dto.algorithms.riskScore.level as RiskLevel] };
|
||||
});
|
||||
|
||||
const riskData = analyzedShips
|
||||
.filter(({ dto }) => {
|
||||
const level = dto.algorithms.riskScore.level;
|
||||
return level === 'CRITICAL' || level === 'HIGH' || level === 'MEDIUM';
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const pa = RISK_PRIORITY[a.dto.algorithms.riskScore.level] ?? 99;
|
||||
const pb = RISK_PRIORITY[b.dto.algorithms.riskScore.level] ?? 99;
|
||||
return pa - pb;
|
||||
})
|
||||
.filter(({ alert }) => alert !== 'NORMAL')
|
||||
.sort((a, b) => ALERT_PRIORITY[a.alert] - ALERT_PRIORITY[b.alert])
|
||||
.slice(0, 100);
|
||||
|
||||
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
||||
@ -104,9 +105,9 @@ export function useAnalysisDeckLayers(
|
||||
id: 'risk-markers',
|
||||
data: riskData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getRadius: (d) => (RISK_SIZE[d.dto.algorithms.riskScore.level] ?? 12) * sizeScale,
|
||||
getFillColor: (d) => RISK_RGBA[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 40],
|
||||
getLineColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 200],
|
||||
getRadius: (d) => (ALERT_SIZE[d.alert] ?? 12) * sizeScale,
|
||||
getFillColor: (d) => ALERT_RGBA[d.alert] ?? [100, 100, 100, 40],
|
||||
getLineColor: (d) => ALERT_RGBA_BORDER[d.alert] ?? [100, 100, 100, 200],
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
@ -123,13 +124,13 @@ export function useAnalysisDeckLayers(
|
||||
data: riskData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => {
|
||||
const label = RISK_LABEL[d.dto.algorithms.riskScore.level] ?? d.dto.algorithms.riskScore.level;
|
||||
const label = ALERT_LABEL[d.alert] ?? d.alert;
|
||||
const name = d.ship.name || d.ship.mmsi;
|
||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||
},
|
||||
getSize: 10 * sizeScale * afs,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
||||
getColor: (d) => ALERT_RGBA_BORDER[d.alert] ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 16],
|
||||
|
||||
569
frontend/src/hooks/useFleetClusterDeckLayers.ts
Normal file
569
frontend/src/hooks/useFleetClusterDeckLayers.ts
Normal file
@ -0,0 +1,569 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import { GeoJsonLayer, IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
|
||||
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
|
||||
import type { FleetClusterGeoJsonResult } from '../components/korea/useFleetClusterGeoJson';
|
||||
import { FONT_MONO } from '../styles/fonts';
|
||||
import { clusterLabels } from '../utils/labelCluster';
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FleetClusterDeckConfig {
|
||||
selectedGearGroup: string | null;
|
||||
hoveredMmsi: string | null;
|
||||
hoveredGearCompositeKey: string | null;
|
||||
enabledModels: Set<string>;
|
||||
historyActive: boolean;
|
||||
hasCorrelationTracks: boolean;
|
||||
zoomScale: number;
|
||||
zoomLevel: number; // integer zoom for label clustering
|
||||
fontScale?: number; // fontScale.analysis (default 1)
|
||||
focusMode?: boolean; // 집중 모드 — 라이브 폴리곤/마커 숨김
|
||||
onPolygonClick?: (features: PickedPolygonFeature[], coordinate: [number, number]) => void;
|
||||
onPolygonHover?: (info: {
|
||||
lng: number;
|
||||
lat: number;
|
||||
type: 'fleet' | 'gear';
|
||||
id: string | number;
|
||||
groupKey?: string;
|
||||
subClusterId?: number;
|
||||
compositeKey?: string;
|
||||
} | null) => void;
|
||||
}
|
||||
|
||||
export interface PickedPolygonFeature {
|
||||
type: 'fleet' | 'gear';
|
||||
clusterId?: number;
|
||||
name?: string;
|
||||
groupKey?: string;
|
||||
subClusterId?: number;
|
||||
compositeKey?: string;
|
||||
gearCount?: number;
|
||||
inZone?: boolean;
|
||||
}
|
||||
|
||||
// ── Hex → RGBA (module-level cache) ──────────────────────────────────────────
|
||||
|
||||
const hexRgbaCache = new Map<string, [number, number, number, number]>();
|
||||
|
||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const cacheKey = `${hex}-${alpha}`;
|
||||
const cached = hexRgbaCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const h = hex.replace('#', '');
|
||||
let r = parseInt(h.substring(0, 2), 16) || 0;
|
||||
let g = parseInt(h.substring(2, 4), 16) || 0;
|
||||
let b = parseInt(h.substring(4, 6), 16) || 0;
|
||||
// 어두운 색상 밝기 보정 (바다 배경 대비)
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
if (lum < 0.3) {
|
||||
const boost = 0.3 / Math.max(lum, 0.01);
|
||||
r = Math.min(255, Math.round(r * boost));
|
||||
g = Math.min(255, Math.round(g * boost));
|
||||
b = Math.min(255, Math.round(b * boost));
|
||||
}
|
||||
const rgba: [number, number, number, number] = [r, g, b, alpha];
|
||||
hexRgbaCache.set(cacheKey, rgba);
|
||||
return rgba;
|
||||
}
|
||||
|
||||
// ── Gear cluster color helpers ────────────────────────────────────────────────
|
||||
|
||||
const GEAR_IN_ZONE_FILL: [number, number, number, number] = [220, 38, 38, 25]; // #dc2626 opacity 0.10
|
||||
const GEAR_IN_ZONE_LINE: [number, number, number, number] = [220, 38, 38, 200]; // #dc2626
|
||||
const GEAR_OUT_ZONE_FILL: [number, number, number, number] = [249, 115, 22, 25]; // #f97316 opacity 0.10
|
||||
const GEAR_OUT_ZONE_LINE: [number, number, number, number] = [249, 115, 22, 200]; // #f97316
|
||||
|
||||
const ICON_PX = 64;
|
||||
|
||||
// ── Point-in-polygon (ray casting) ──────────────────────────────────────────
|
||||
|
||||
function pointInRing(point: [number, number], ring: number[][]): boolean {
|
||||
const [px, py] = point;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i][0], yi = ring[i][1];
|
||||
const xj = ring[j][0], yj = ring[j][1];
|
||||
if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function pointInPolygon(point: [number, number], geometry: GeoJSON.Geometry): boolean {
|
||||
if (geometry.type === 'Polygon') {
|
||||
return pointInRing(point, geometry.coordinates[0]);
|
||||
}
|
||||
if (geometry.type === 'MultiPolygon') {
|
||||
return geometry.coordinates.some(poly => pointInRing(point, poly[0]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Find all fleet/gear polygons at a given coordinate */
|
||||
function findPolygonsAtPoint(
|
||||
point: [number, number],
|
||||
fleetFc: GeoJSON.FeatureCollection,
|
||||
gearFc: GeoJSON.FeatureCollection,
|
||||
): PickedPolygonFeature[] {
|
||||
const results: PickedPolygonFeature[] = [];
|
||||
for (const f of fleetFc.features) {
|
||||
if (pointInPolygon(point, f.geometry)) {
|
||||
results.push({
|
||||
type: 'fleet',
|
||||
clusterId: f.properties?.clusterId,
|
||||
name: f.properties?.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const f of gearFc.features) {
|
||||
if (pointInPolygon(point, f.geometry)) {
|
||||
results.push({
|
||||
type: 'gear',
|
||||
name: f.properties?.name,
|
||||
groupKey: f.properties?.groupKey,
|
||||
subClusterId: f.properties?.subClusterId,
|
||||
compositeKey: f.properties?.compositeKey,
|
||||
gearCount: f.properties?.gearCount,
|
||||
inZone: f.properties?.inZone === 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts FleetClusterGeoJsonResult (produced by useFleetClusterGeoJson) into
|
||||
* deck.gl Layer instances.
|
||||
*
|
||||
* Uses useMemo — fleet data changes infrequently (every 5 minutes) and on user
|
||||
* interaction (hover, select). No Zustand subscribe pattern needed.
|
||||
*/
|
||||
export function useFleetClusterDeckLayers(
|
||||
geo: FleetClusterGeoJsonResult | null,
|
||||
config: FleetClusterDeckConfig,
|
||||
): Layer[] {
|
||||
const {
|
||||
selectedGearGroup,
|
||||
hoveredMmsi,
|
||||
hoveredGearCompositeKey,
|
||||
enabledModels,
|
||||
historyActive,
|
||||
zoomScale,
|
||||
zoomLevel,
|
||||
fontScale: fs = 1,
|
||||
onPolygonClick,
|
||||
onPolygonHover,
|
||||
} = config;
|
||||
|
||||
const focusMode = config.focusMode ?? false;
|
||||
|
||||
return useMemo((): Layer[] => {
|
||||
if (!geo || focusMode) return [];
|
||||
|
||||
const layers: Layer[] = [];
|
||||
|
||||
// ── 1. Fleet polygons (fleetPolygonGeoJSON) ──────────────────────────────
|
||||
const fleetPoly = geo.fleetPolygonGeoJSON as GeoJSON.FeatureCollection;
|
||||
if (fleetPoly.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'fleet-polygons',
|
||||
data: fleetPoly,
|
||||
getFillColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#63b3ed', 25),
|
||||
getLineColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#63b3ed', 128),
|
||||
getLineWidth: 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: true,
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
onHover: (info) => {
|
||||
if (info.object) {
|
||||
const f = info.object as GeoJSON.Feature;
|
||||
const cid = f.properties?.clusterId;
|
||||
if (cid != null) {
|
||||
onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'fleet', id: cid });
|
||||
}
|
||||
} else {
|
||||
onPolygonHover?.(null);
|
||||
}
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object || !info.coordinate || !onPolygonClick) return;
|
||||
const pt: [number, number] = [info.coordinate[0], info.coordinate[1]];
|
||||
onPolygonClick(findPolygonsAtPoint(pt, fleetPoly, gearFc), pt);
|
||||
},
|
||||
updateTriggers: {},
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 2. Hovered fleet highlight (hoveredGeoJSON) ──────────────────────────
|
||||
const hoveredPoly = geo.hoveredGeoJSON as GeoJSON.FeatureCollection;
|
||||
if (hoveredPoly.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'fleet-hover-highlight',
|
||||
data: hoveredPoly,
|
||||
getFillColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#63b3ed', 64),
|
||||
getLineColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#63b3ed', 200),
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: true,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 3. Fleet 2-ship lines (lineGeoJSON) ──────────────────────────────────
|
||||
// Currently always empty (server handles 2-ship fleets as Polygon), kept for future
|
||||
const lineFc = geo.lineGeoJSON as GeoJSON.FeatureCollection;
|
||||
if (lineFc.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'fleet-lines',
|
||||
data: lineFc,
|
||||
getLineColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#63b3ed', 180),
|
||||
getLineWidth: 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: false,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 4. Gear cluster polygons (gearClusterGeoJson) ────────────────────────
|
||||
const gearFc = geo.gearClusterGeoJson as GeoJSON.FeatureCollection;
|
||||
if (gearFc.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'gear-cluster-polygons',
|
||||
data: gearFc,
|
||||
getFillColor: (f: GeoJSON.Feature) =>
|
||||
f.properties?.inZone === 1 ? GEAR_IN_ZONE_FILL : GEAR_OUT_ZONE_FILL,
|
||||
getLineColor: (f: GeoJSON.Feature) =>
|
||||
f.properties?.inZone === 1 ? GEAR_IN_ZONE_LINE : GEAR_OUT_ZONE_LINE,
|
||||
getLineWidth: 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: true,
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
onHover: (info) => {
|
||||
if (info.object) {
|
||||
const f = info.object as GeoJSON.Feature;
|
||||
const name = f.properties?.name;
|
||||
if (name) {
|
||||
onPolygonHover?.({
|
||||
lng: info.coordinate![0],
|
||||
lat: info.coordinate![1],
|
||||
type: 'gear',
|
||||
id: f.properties?.groupKey ?? name,
|
||||
groupKey: f.properties?.groupKey ?? name,
|
||||
subClusterId: f.properties?.subClusterId,
|
||||
compositeKey: f.properties?.compositeKey,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
onPolygonHover?.(null);
|
||||
}
|
||||
},
|
||||
onClick: (info) => {
|
||||
if (!info.object || !info.coordinate || !onPolygonClick) return;
|
||||
const pt: [number, number] = [info.coordinate[0], info.coordinate[1]];
|
||||
onPolygonClick(findPolygonsAtPoint(pt, fleetPoly, gearFc), pt);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 4b. Gear hover highlight ──────────────────────────────────────────
|
||||
if (hoveredGearCompositeKey && geo.hoveredGearHighlightGeoJson && geo.hoveredGearHighlightGeoJson.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'gear-hover-highlight',
|
||||
data: geo.hoveredGearHighlightGeoJson,
|
||||
getFillColor: (f: GeoJSON.Feature) =>
|
||||
f.properties?.inZone === 1 ? [220, 38, 38, 72] : [249, 115, 22, 72],
|
||||
getLineColor: (f: GeoJSON.Feature) =>
|
||||
f.properties?.inZone === 1 ? [220, 38, 38, 255] : [249, 115, 22, 255],
|
||||
getLineWidth: 2.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: true,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 5. Selected gear highlight (selectedGearHighlightGeoJson) ────────────
|
||||
if (geo.selectedGearHighlightGeoJson && geo.selectedGearHighlightGeoJson.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'gear-selected-highlight',
|
||||
data: geo.selectedGearHighlightGeoJson,
|
||||
getFillColor: [249, 115, 22, 40],
|
||||
getLineColor: [249, 115, 22, 230],
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: true,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 6. Member markers (memberMarkersGeoJson) — skip when historyActive ───
|
||||
if (!historyActive) {
|
||||
const memberFc = geo.memberMarkersGeoJson as GeoJSON.FeatureCollection;
|
||||
if (memberFc.features.length > 0) {
|
||||
layers.push(new IconLayer<GeoJSON.Feature>({
|
||||
id: 'fleet-member-icons',
|
||||
data: memberFc.features,
|
||||
getPosition: (f: GeoJSON.Feature) =>
|
||||
(f.geometry as GeoJSON.Point).coordinates as [number, number],
|
||||
getIcon: (f: GeoJSON.Feature) =>
|
||||
f.properties?.isGear === 1
|
||||
? SHIP_ICON_MAPPING['gear-diamond']
|
||||
: SHIP_ICON_MAPPING['ship-triangle'],
|
||||
getSize: (f: GeoJSON.Feature) =>
|
||||
(f.properties?.baseSize ?? 0.14) * zoomScale * ICON_PX,
|
||||
getAngle: (f: GeoJSON.Feature) =>
|
||||
f.properties?.isGear === 1 ? 0 : -(f.properties?.cog ?? 0),
|
||||
getColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#9e9e9e'),
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 3,
|
||||
billboard: false,
|
||||
pickable: false,
|
||||
updateTriggers: {
|
||||
getSize: [zoomScale, fs],
|
||||
},
|
||||
}));
|
||||
|
||||
const clusteredMembers = clusterLabels(
|
||||
memberFc.features,
|
||||
f => (f.geometry as GeoJSON.Point).coordinates as [number, number],
|
||||
zoomLevel,
|
||||
);
|
||||
layers.push(new TextLayer<GeoJSON.Feature>({
|
||||
id: 'fleet-member-labels',
|
||||
data: clusteredMembers,
|
||||
getPosition: (f: GeoJSON.Feature) =>
|
||||
(f.geometry as GeoJSON.Point).coordinates as [number, number],
|
||||
getText: (f: GeoJSON.Feature) => {
|
||||
const isParent = f.properties?.isParent === 1;
|
||||
return isParent ? `\u2605 ${f.properties?.name ?? ''}` : (f.properties?.name ?? '');
|
||||
},
|
||||
getSize: 8 * zoomScale * fs,
|
||||
getColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#e2e8f0'),
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: FONT_MONO,
|
||||
background: true,
|
||||
getBackgroundColor: [0, 0, 0, 200],
|
||||
backgroundPadding: [3, 1],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: {
|
||||
getSize: [zoomScale, fs],
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. Picker highlight (pickerHighlightGeoJson) ──────────────────────────
|
||||
const pickerFc = geo.pickerHighlightGeoJson as GeoJSON.FeatureCollection;
|
||||
if (pickerFc.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'fleet-picker-highlight',
|
||||
data: pickerFc,
|
||||
getFillColor: [255, 255, 255, 25],
|
||||
getLineColor: [255, 255, 255, 200],
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: true,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Correlation layers (only when gear group selected, skip during replay) ─
|
||||
if (selectedGearGroup && !historyActive) {
|
||||
|
||||
// ── 8. Operational polygons (per model) ────────────────────────────────
|
||||
for (const op of geo.operationalPolygons) {
|
||||
if (!enabledModels.has(op.modelName)) continue;
|
||||
if (op.geojson.features.length === 0) continue;
|
||||
const modelColor = MODEL_COLORS[op.modelName] ?? '#94a3b8';
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: `fleet-op-polygon-${op.modelName}`,
|
||||
data: op.geojson,
|
||||
getFillColor: hexToRgba(modelColor, 30),
|
||||
getLineColor: hexToRgba(modelColor, 180),
|
||||
getLineWidth: 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: true,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 9. Correlation trails (correlationTrailGeoJson) ────────────────────
|
||||
const trailFc = geo.correlationTrailGeoJson as GeoJSON.FeatureCollection;
|
||||
if (trailFc.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'fleet-correlation-trails',
|
||||
data: trailFc,
|
||||
getLineColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#60a5fa', 160),
|
||||
getLineWidth: 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: false,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 10. Correlation vessels (correlationVesselGeoJson) ─────────────────
|
||||
const corrVesselFc = geo.correlationVesselGeoJson as GeoJSON.FeatureCollection;
|
||||
if (corrVesselFc.features.length > 0) {
|
||||
layers.push(new IconLayer<GeoJSON.Feature>({
|
||||
id: 'fleet-correlation-vessel-icons',
|
||||
data: corrVesselFc.features,
|
||||
getPosition: (f: GeoJSON.Feature) =>
|
||||
(f.geometry as GeoJSON.Point).coordinates as [number, number],
|
||||
getIcon: () => SHIP_ICON_MAPPING['ship-triangle'],
|
||||
getSize: () =>
|
||||
0.14 * zoomScale * ICON_PX,
|
||||
getAngle: (f: GeoJSON.Feature) => -(f.properties?.cog ?? 0),
|
||||
getColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#60a5fa'),
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 3,
|
||||
billboard: false,
|
||||
pickable: false,
|
||||
updateTriggers: {
|
||||
getSize: [zoomScale, fs],
|
||||
},
|
||||
}));
|
||||
|
||||
const clusteredCorr = clusterLabels(
|
||||
corrVesselFc.features,
|
||||
f => (f.geometry as GeoJSON.Point).coordinates as [number, number],
|
||||
zoomLevel,
|
||||
);
|
||||
layers.push(new TextLayer<GeoJSON.Feature>({
|
||||
id: 'fleet-correlation-vessel-labels',
|
||||
data: clusteredCorr,
|
||||
getPosition: (f: GeoJSON.Feature) =>
|
||||
(f.geometry as GeoJSON.Point).coordinates as [number, number],
|
||||
getText: (f: GeoJSON.Feature) => f.properties?.name ?? '',
|
||||
getSize: 8 * zoomScale * fs,
|
||||
getColor: (f: GeoJSON.Feature) =>
|
||||
hexToRgba(f.properties?.color ?? '#60a5fa'),
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: FONT_MONO,
|
||||
background: true,
|
||||
getBackgroundColor: [0, 0, 0, 200],
|
||||
backgroundPadding: [3, 1],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: {
|
||||
getSize: [zoomScale, fs],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 11. Model badges (modelBadgesGeoJson) ─────────────────────────────
|
||||
// Rendered as small ScatterplotLayer dots, one layer per active model.
|
||||
// Position is offset in world coordinates (small lng offset per model index).
|
||||
// Badge size is intentionally small (4px) as visual indicators only.
|
||||
const badgeFc = geo.modelBadgesGeoJson as GeoJSON.FeatureCollection;
|
||||
if (badgeFc.features.length > 0) {
|
||||
MODEL_ORDER.forEach((modelName, i) => {
|
||||
if (!enabledModels.has(modelName)) return;
|
||||
const modelColor = MODEL_COLORS[modelName] ?? '#94a3b8';
|
||||
const activeFeatures = badgeFc.features.filter(
|
||||
(f) => f.properties?.[`m${i}`] === 1,
|
||||
);
|
||||
if (activeFeatures.length === 0) return;
|
||||
|
||||
// Small lng offset per model index to avoid overlap (≈ 300m at z10)
|
||||
const lngOffset = i * 0.003;
|
||||
|
||||
layers.push(new ScatterplotLayer<GeoJSON.Feature>({
|
||||
id: `fleet-model-badge-${modelName}`,
|
||||
data: activeFeatures,
|
||||
getPosition: (f: GeoJSON.Feature) => {
|
||||
const [lng, lat] = (f.geometry as GeoJSON.Point).coordinates;
|
||||
return [lng + lngOffset, lat] as [number, number];
|
||||
},
|
||||
getRadius: 4,
|
||||
getFillColor: hexToRgba(modelColor, 230),
|
||||
getLineColor: [0, 0, 0, 200],
|
||||
getLineWidth: 1,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
pickable: false,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ── 12. Hover highlight (hoverHighlightGeoJson + trail) ───────────────
|
||||
if (hoveredMmsi) {
|
||||
const hoverFc = geo.hoverHighlightGeoJson as GeoJSON.FeatureCollection;
|
||||
if (hoverFc.features.length > 0) {
|
||||
layers.push(new ScatterplotLayer<GeoJSON.Feature>({
|
||||
id: 'fleet-hover-ring',
|
||||
data: hoverFc.features,
|
||||
getPosition: (f: GeoJSON.Feature) =>
|
||||
(f.geometry as GeoJSON.Point).coordinates as [number, number],
|
||||
getRadius: 18,
|
||||
getFillColor: [255, 255, 255, 20],
|
||||
getLineColor: [255, 255, 255, 200],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const hoverTrailFc = geo.hoverHighlightTrailGeoJson as GeoJSON.FeatureCollection;
|
||||
if (hoverTrailFc.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'fleet-hover-trail',
|
||||
data: hoverTrailFc,
|
||||
getLineColor: [255, 255, 255, 150],
|
||||
getLineWidth: 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: false,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layers;
|
||||
}, [
|
||||
geo,
|
||||
selectedGearGroup,
|
||||
hoveredMmsi,
|
||||
hoveredGearCompositeKey,
|
||||
enabledModels,
|
||||
historyActive,
|
||||
zoomScale,
|
||||
zoomLevel,
|
||||
fs,
|
||||
focusMode,
|
||||
onPolygonClick,
|
||||
onPolygonHover,
|
||||
]);
|
||||
}
|
||||
1147
frontend/src/hooks/useGearReplayLayers.ts
Normal file
1147
frontend/src/hooks/useGearReplayLayers.ts
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -4,6 +4,33 @@ import type { GroupPolygonDto } from '../services/vesselAnalysis';
|
||||
|
||||
const POLL_INTERVAL_MS = 5 * 60_000; // 5분
|
||||
|
||||
/** 같은 groupKey의 서브클러스터를 하나로 합산 (멤버 합산, 가장 큰 폴리곤 사용) */
|
||||
function mergeByGroupKey(groups: GroupPolygonDto[]): GroupPolygonDto[] {
|
||||
const byKey = new Map<string, GroupPolygonDto[]>();
|
||||
for (const g of groups) {
|
||||
const list = byKey.get(g.groupKey) ?? [];
|
||||
list.push(g);
|
||||
byKey.set(g.groupKey, list);
|
||||
}
|
||||
const result: GroupPolygonDto[] = [];
|
||||
for (const [, items] of byKey) {
|
||||
if (items.length === 1) { result.push(items[0]); continue; }
|
||||
const seen = new Set<string>();
|
||||
const allMembers: GroupPolygonDto['members'] = [];
|
||||
for (const item of items) for (const m of item.members) {
|
||||
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
|
||||
}
|
||||
const biggest = items.reduce((a, b) => a.memberCount >= b.memberCount ? a : b);
|
||||
result.push({
|
||||
...biggest,
|
||||
subClusterId: 0,
|
||||
members: allMembers,
|
||||
memberCount: allMembers.length,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface UseGroupPolygonsResult {
|
||||
fleetGroups: GroupPolygonDto[];
|
||||
gearInZoneGroups: GroupPolygonDto[];
|
||||
@ -11,8 +38,11 @@ export interface UseGroupPolygonsResult {
|
||||
allGroups: GroupPolygonDto[];
|
||||
isLoading: boolean;
|
||||
lastUpdated: number;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NOOP_REFRESH = async () => {};
|
||||
|
||||
const EMPTY: UseGroupPolygonsResult = {
|
||||
fleetGroups: [],
|
||||
gearInZoneGroups: [],
|
||||
@ -20,13 +50,14 @@ const EMPTY: UseGroupPolygonsResult = {
|
||||
allGroups: [],
|
||||
isLoading: false,
|
||||
lastUpdated: 0,
|
||||
refresh: NOOP_REFRESH,
|
||||
};
|
||||
|
||||
export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
|
||||
const [allGroups, setAllGroups] = useState<GroupPolygonDto[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>();
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@ -49,21 +80,21 @@ export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
|
||||
}, [enabled, load]);
|
||||
|
||||
const fleetGroups = useMemo(
|
||||
() => allGroups.filter(g => g.groupType === 'FLEET'),
|
||||
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'FLEET')),
|
||||
[allGroups],
|
||||
);
|
||||
|
||||
const gearInZoneGroups = useMemo(
|
||||
() => allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE'),
|
||||
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE')),
|
||||
[allGroups],
|
||||
);
|
||||
|
||||
const gearOutZoneGroups = useMemo(
|
||||
() => allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE'),
|
||||
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE')),
|
||||
[allGroups],
|
||||
);
|
||||
|
||||
if (!enabled) return EMPTY;
|
||||
|
||||
return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated };
|
||||
return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated, refresh: load };
|
||||
}
|
||||
|
||||
352
frontend/src/hooks/useShipDeckLayers.ts
Normal file
352
frontend/src/hooks/useShipDeckLayers.ts
Normal file
@ -0,0 +1,352 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { useShipDeckStore } from '../stores/shipDeckStore';
|
||||
import { useGearReplayStore } from '../stores/gearReplayStore';
|
||||
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
|
||||
import { MT_TYPE_HEX, getMTType, SIZE_MAP, isMilitary } from '../utils/shipClassification';
|
||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
||||
import { getNationalityGroup } from './useKoreaData';
|
||||
import { FONT_MONO } from '../styles/fonts';
|
||||
import type { Ship, VesselAnalysisDto } from '../types';
|
||||
import { useSymbolScale } from './useSymbolScale';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Zoom level → icon scale multiplier (matches MapLibre interpolate) */
|
||||
const ZOOM_SCALE: Record<number, number> = {
|
||||
4: 0.8, 5: 0.9, 6: 1.0, 7: 1.2, 8: 1.5, 9: 1.8,
|
||||
10: 2.2, 11: 2.5, 12: 2.8, 13: 3.5,
|
||||
};
|
||||
const ZOOM_SCALE_DEFAULT = 4.2; // z14+
|
||||
|
||||
export function getZoomScale(zoom: number): number {
|
||||
if (zoom >= 14) return ZOOM_SCALE_DEFAULT;
|
||||
return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT;
|
||||
}
|
||||
|
||||
/** MapLibre icon-size is a multiplier on native icon size (64px SVG).
|
||||
* deck.gl getSize with sizeUnits='pixels' specifies actual pixel height.
|
||||
* So: baseSize(0.16) * zoomScale(1.0) * 64 = 10.24px ≈ MapLibre equivalent. */
|
||||
const ICON_PX = 64;
|
||||
|
||||
const GEAR_RE = /^.+?_\d+_\d+_?$/;
|
||||
|
||||
// ── Hex → RGBA conversion (cached per session) ──────────────────────────────
|
||||
|
||||
const hexCache = new Map<string, [number, number, number, number]>();
|
||||
|
||||
function hexToRgba(hex: string, alpha = 230): [number, number, number, number] {
|
||||
const cached = hexCache.get(hex);
|
||||
if (cached) return cached;
|
||||
const h = hex.replace('#', '');
|
||||
const rgba: [number, number, number, number] = [
|
||||
parseInt(h.substring(0, 2), 16),
|
||||
parseInt(h.substring(2, 4), 16),
|
||||
parseInt(h.substring(4, 6), 16),
|
||||
alpha,
|
||||
];
|
||||
hexCache.set(hex, rgba);
|
||||
return rgba;
|
||||
}
|
||||
|
||||
// ── Pre-computed ship render datum (avoids repeated computation in accessors)
|
||||
|
||||
interface ShipRenderDatum {
|
||||
mmsi: string;
|
||||
name: string;
|
||||
lng: number;
|
||||
lat: number;
|
||||
heading: number;
|
||||
isGear: boolean;
|
||||
isKorean: boolean;
|
||||
isMil: boolean;
|
||||
category: string;
|
||||
color: [number, number, number, number];
|
||||
baseSize: number; // SIZE_MAP value
|
||||
}
|
||||
|
||||
function buildShipRenderData(
|
||||
ships: Ship[],
|
||||
militaryOnly: boolean,
|
||||
hiddenCategories: Set<string>,
|
||||
hiddenNationalities: Set<string>,
|
||||
): ShipRenderDatum[] {
|
||||
const result: ShipRenderDatum[] = [];
|
||||
for (const ship of ships) {
|
||||
const mtCategory = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||
const natGroup = getNationalityGroup(ship.flag);
|
||||
|
||||
// CPU-side filtering
|
||||
if (militaryOnly && !isMilitary(ship.category)) continue;
|
||||
if (hiddenCategories.size > 0 && hiddenCategories.has(mtCategory)) continue;
|
||||
if (hiddenNationalities.size > 0 && hiddenNationalities.has(natGroup)) continue;
|
||||
|
||||
const isGear = GEAR_RE.test(ship.name || '');
|
||||
const hex = MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown;
|
||||
|
||||
result.push({
|
||||
mmsi: ship.mmsi,
|
||||
name: ship.name || '',
|
||||
lng: ship.lng,
|
||||
lat: ship.lat,
|
||||
heading: ship.heading,
|
||||
isGear,
|
||||
isKorean: ship.flag === 'KR',
|
||||
isMil: isMilitary(ship.category),
|
||||
category: ship.category,
|
||||
color: hexToRgba(hex),
|
||||
baseSize: (isGear ? 0.8 : 1) * (SIZE_MAP[ship.category] ?? 0.12),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Analysis ship markers ────────────────────────────────────────────────────
|
||||
|
||||
interface AnalysisRenderDatum {
|
||||
mmsi: string;
|
||||
lng: number;
|
||||
lat: number;
|
||||
cog: number;
|
||||
isGear: boolean;
|
||||
color: [number, number, number, number];
|
||||
baseSize: number;
|
||||
}
|
||||
|
||||
const RISK_COLORS: Record<string, [number, number, number, number]> = {
|
||||
CRITICAL: [239, 68, 68, 255],
|
||||
HIGH: [249, 115, 22, 255],
|
||||
MEDIUM: [234, 179, 8, 255],
|
||||
LOW: [34, 197, 94, 255],
|
||||
};
|
||||
|
||||
function buildAnalysisData(
|
||||
ships: Ship[],
|
||||
analysisMap: Map<string, VesselAnalysisDto>,
|
||||
): AnalysisRenderDatum[] {
|
||||
const result: AnalysisRenderDatum[] = [];
|
||||
for (const ship of ships) {
|
||||
const dto = analysisMap.get(ship.mmsi);
|
||||
if (!dto) continue;
|
||||
const level = dto.algorithms.riskScore.level;
|
||||
const isGear = GEAR_RE.test(ship.name || '');
|
||||
result.push({
|
||||
mmsi: ship.mmsi,
|
||||
lng: ship.lng,
|
||||
lat: ship.lat,
|
||||
cog: ship.heading ?? 0,
|
||||
isGear,
|
||||
color: RISK_COLORS[level] ?? RISK_COLORS.LOW,
|
||||
baseSize: 0.16,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Builds deck.gl layers for live ship rendering.
|
||||
*
|
||||
* Uses zustand.subscribe to bypass React re-render cycle.
|
||||
* Ship data updates (5s polling) and filter/hover/zoom changes
|
||||
* trigger imperative layer rebuild → overlay.setProps().
|
||||
*/
|
||||
export function useShipDeckLayers(
|
||||
shipLayerRef: React.MutableRefObject<Layer[]>,
|
||||
requestRender: () => void,
|
||||
): void {
|
||||
const { symbolScale } = useSymbolScale();
|
||||
const shipSymbolScale = symbolScale.ship;
|
||||
|
||||
const renderFrame = useCallback(() => {
|
||||
const state = useShipDeckStore.getState();
|
||||
const { ships, layerVisible, militaryOnly, hiddenShipCategories, hiddenNationalities,
|
||||
hoveredMmsi, highlightKorean, zoomLevel, analysisMap, analysisActiveFilter } = state;
|
||||
|
||||
// Layer off or focus mode → clear
|
||||
const focusMode = useGearReplayStore.getState().focusMode;
|
||||
if (!layerVisible || ships.length === 0 || focusMode) {
|
||||
shipLayerRef.current = [];
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomScale = getZoomScale(zoomLevel) * shipSymbolScale;
|
||||
const layers: Layer[] = [];
|
||||
|
||||
// 1. Build filtered ship render data (~3K ships, <1ms)
|
||||
const data = buildShipRenderData(ships, militaryOnly, hiddenShipCategories, hiddenNationalities);
|
||||
|
||||
// 2. Main ship icons — IconLayer
|
||||
layers.push(new IconLayer<ShipRenderDatum>({
|
||||
id: 'ship-icons',
|
||||
data,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => d.isGear
|
||||
? SHIP_ICON_MAPPING['gear-diamond']
|
||||
: SHIP_ICON_MAPPING['ship-triangle'],
|
||||
getSize: (d) => d.baseSize * zoomScale * ICON_PX,
|
||||
getAngle: (d) => d.isGear ? 0 : -d.heading,
|
||||
getColor: (d) => d.color,
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 3,
|
||||
billboard: false,
|
||||
pickable: true,
|
||||
onClick: (info) => {
|
||||
if (info.object) {
|
||||
useShipDeckStore.getState().setSelectedMmsi(info.object.mmsi);
|
||||
}
|
||||
},
|
||||
onHover: (info) => {
|
||||
useShipDeckStore.getState().setHoveredMmsi(
|
||||
info.object?.mmsi ?? null,
|
||||
info.object ? { x: info.x, y: info.y } : undefined,
|
||||
);
|
||||
},
|
||||
updateTriggers: {
|
||||
getSize: [zoomScale],
|
||||
},
|
||||
}));
|
||||
|
||||
// 3. Korean ship rings + labels — only when highlightKorean is active
|
||||
if (highlightKorean) {
|
||||
const koreanShips = data.filter(d => d.isKorean);
|
||||
if (koreanShips.length > 0) {
|
||||
layers.push(new ScatterplotLayer<ShipRenderDatum>({
|
||||
id: 'korean-ship-rings',
|
||||
data: koreanShips,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 10 * zoomScale,
|
||||
getFillColor: [0, 229, 255, 20],
|
||||
getLineColor: [0, 229, 255, 255],
|
||||
getLineWidth: 2.5,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getRadius: [zoomScale],
|
||||
},
|
||||
}));
|
||||
|
||||
layers.push(new TextLayer<ShipRenderDatum>({
|
||||
id: 'korean-ship-labels',
|
||||
data: koreanShips,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name || d.mmsi,
|
||||
getSize: 11 * zoomScale,
|
||||
getColor: [0, 229, 255, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 14],
|
||||
fontFamily: FONT_MONO,
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 3,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Hover highlight — ScatterplotLayer (conditional)
|
||||
if (hoveredMmsi) {
|
||||
const hoveredShip = data.find(d => d.mmsi === hoveredMmsi);
|
||||
if (hoveredShip) {
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'ship-hover-highlight',
|
||||
data: [hoveredShip],
|
||||
getPosition: (d: ShipRenderDatum) => [d.lng, d.lat],
|
||||
getRadius: 18,
|
||||
getFillColor: [255, 255, 255, 25],
|
||||
getLineColor: [255, 255, 255, 230],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
radiusUnits: 'pixels',
|
||||
lineWidthUnits: 'pixels',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Carrier labels — TextLayer (very few ships)
|
||||
const carriers = data.filter(d => d.category === 'carrier');
|
||||
if (carriers.length > 0) {
|
||||
layers.push(new TextLayer<ShipRenderDatum>({
|
||||
id: 'carrier-labels',
|
||||
data: carriers,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name,
|
||||
getSize: 12 * zoomScale,
|
||||
getColor: (d) => d.color,
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 16],
|
||||
fontFamily: FONT_MONO,
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 3,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
}));
|
||||
}
|
||||
|
||||
// 6. Analysis ship markers — IconLayer (conditional on analysisActiveFilter)
|
||||
if (analysisMap && analysisActiveFilter) {
|
||||
const analysisData = buildAnalysisData(ships, analysisMap);
|
||||
if (analysisData.length > 0) {
|
||||
layers.push(new IconLayer<AnalysisRenderDatum>({
|
||||
id: 'analysis-ship-markers',
|
||||
data: analysisData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => d.isGear
|
||||
? SHIP_ICON_MAPPING['gear-diamond']
|
||||
: SHIP_ICON_MAPPING['ship-triangle'],
|
||||
getSize: (d) => d.baseSize * zoomScale * ICON_PX * 1.3,
|
||||
getAngle: (d) => d.isGear ? 0 : -d.cog,
|
||||
getColor: (d) => d.color,
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 4,
|
||||
billboard: false,
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
shipLayerRef.current = layers;
|
||||
requestRender();
|
||||
}, [shipLayerRef, requestRender, shipSymbolScale]);
|
||||
|
||||
// Subscribe to all relevant state changes
|
||||
useEffect(() => {
|
||||
renderFrame(); // initial render
|
||||
|
||||
const unsub = useShipDeckStore.subscribe(
|
||||
(s) => ({
|
||||
ships: s.ships,
|
||||
militaryOnly: s.militaryOnly,
|
||||
hiddenShipCategories: s.hiddenShipCategories,
|
||||
hiddenNationalities: s.hiddenNationalities,
|
||||
layerVisible: s.layerVisible,
|
||||
hoveredMmsi: s.hoveredMmsi,
|
||||
highlightKorean: s.highlightKorean,
|
||||
zoomLevel: s.zoomLevel,
|
||||
analysisMap: s.analysisMap,
|
||||
analysisActiveFilter: s.analysisActiveFilter,
|
||||
}),
|
||||
() => renderFrame(),
|
||||
);
|
||||
|
||||
// focusMode 변경 시에도 레이어 갱신
|
||||
const unsubFocus = useGearReplayStore.subscribe(
|
||||
s => s.focusMode,
|
||||
() => renderFrame(),
|
||||
);
|
||||
|
||||
return () => { unsub(); unsubFocus(); };
|
||||
}, [renderFrame]);
|
||||
}
|
||||
6
frontend/src/hooks/useSymbolScale.ts
Normal file
6
frontend/src/hooks/useSymbolScale.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { useContext } from 'react';
|
||||
import { SymbolScaleCtx } from '../contexts/symbolScaleState';
|
||||
|
||||
export function useSymbolScale() {
|
||||
return useContext(SymbolScaleCtx);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user