Merge pull request 'release: 2026-03-25 (46건 커밋)' (#191) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m55s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m55s
This commit is contained in:
커밋
2fc8b1d785
@ -1,49 +1,74 @@
|
|||||||
package gc.mda.kcg.domain.analysis;
|
package gc.mda.kcg.domain.analysis;
|
||||||
|
|
||||||
import gc.mda.kcg.config.CacheConfig;
|
|
||||||
import gc.mda.kcg.domain.fleet.GroupPolygonService;
|
import gc.mda.kcg.domain.fleet.GroupPolygonService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.cache.Cache;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.cache.CacheManager;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class VesselAnalysisService {
|
public class VesselAnalysisService {
|
||||||
|
|
||||||
private final VesselAnalysisResultRepository repository;
|
private final VesselAnalysisResultRepository repository;
|
||||||
private final CacheManager cacheManager;
|
|
||||||
private final GroupPolygonService groupPolygonService;
|
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시간 내 분석 결과 + 집계 통계를 반환한다.
|
* 최근 2시간 분석 결과 + 집계 통계.
|
||||||
* mmsi별 최신 1건만. Caffeine 캐시(TTL 5분) 적용.
|
* - 첫 호출(warmup): 2시간 전체 조회 → 캐시 구축
|
||||||
|
* - 이후: lastFetchTime 이후 증분만 조회 → 캐시 병합
|
||||||
|
* - 2시간 초과 항목은 evict
|
||||||
|
* - 값 갱신 시 TTL 타이머 초기화
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public Map<String, Object> getLatestResultsWithStats() {
|
public Map<String, Object> getLatestResultsWithStats() {
|
||||||
Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS);
|
Instant now = Instant.now();
|
||||||
if (cache != null) {
|
|
||||||
Cache.ValueWrapper wrapper = cache.get("data_with_stats");
|
if (lastFetchTime == null || (System.currentTimeMillis() - lastUpdatedAt) > CACHE_TTL_MS) {
|
||||||
if (wrapper != null) {
|
// warmup: 2시간 전체 조회
|
||||||
return (Map<String, Object>) wrapper.get();
|
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);
|
// 2시간 초과 항목 evict
|
||||||
Map<String, VesselAnalysisResult> latest = new LinkedHashMap<>();
|
Instant cutoff = now.minus(2, ChronoUnit.HOURS);
|
||||||
for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) {
|
cache.entrySet().removeIf(e -> e.getValue().getAnalyzedAt().isBefore(cutoff));
|
||||||
latest.merge(r.getMmsi(), r, (old, cur) ->
|
|
||||||
cur.getAnalyzedAt().isAfter(old.getAnalyzedAt()) ? cur : old);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 집계 통계 — 같은 루프에서 계산
|
// 집계 통계
|
||||||
int dark = 0, spoofing = 0, critical = 0, high = 0, medium = 0, low = 0;
|
int dark = 0, spoofing = 0, critical = 0, high = 0, medium = 0, low = 0;
|
||||||
Set<Integer> clusterIds = new HashSet<>();
|
Set<Integer> clusterIds = new HashSet<>();
|
||||||
for (VesselAnalysisResult r : latest.values()) {
|
for (VesselAnalysisResult r : cache.values()) {
|
||||||
if (Boolean.TRUE.equals(r.getIsDark())) dark++;
|
if (Boolean.TRUE.equals(r.getIsDark())) dark++;
|
||||||
if (r.getSpoofingScore() != null && r.getSpoofingScore() > 0.5) spoofing++;
|
if (r.getSpoofingScore() != null && r.getSpoofingScore() > 0.5) spoofing++;
|
||||||
String level = r.getRiskLevel();
|
String level = r.getRiskLevel();
|
||||||
@ -62,11 +87,10 @@ public class VesselAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 어구 통계 — group_polygon_snapshots 기반
|
|
||||||
Map<String, Integer> gearStats = groupPolygonService.getGearStats();
|
Map<String, Integer> gearStats = groupPolygonService.getGearStats();
|
||||||
|
|
||||||
Map<String, Object> stats = new LinkedHashMap<>();
|
Map<String, Object> stats = new LinkedHashMap<>();
|
||||||
stats.put("total", latest.size());
|
stats.put("total", cache.size());
|
||||||
stats.put("dark", dark);
|
stats.put("dark", dark);
|
||||||
stats.put("spoofing", spoofing);
|
stats.put("spoofing", spoofing);
|
||||||
stats.put("critical", critical);
|
stats.put("critical", critical);
|
||||||
@ -77,21 +101,15 @@ public class VesselAnalysisService {
|
|||||||
stats.put("gearGroups", gearStats.get("gearGroups"));
|
stats.put("gearGroups", gearStats.get("gearGroups"));
|
||||||
stats.put("gearCount", gearStats.get("gearCount"));
|
stats.put("gearCount", gearStats.get("gearCount"));
|
||||||
|
|
||||||
List<VesselAnalysisDto> results = latest.values().stream()
|
List<VesselAnalysisDto> results = cache.values().stream()
|
||||||
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
|
.sorted(Comparator.comparingInt(VesselAnalysisResult::getRiskScore).reversed())
|
||||||
.map(VesselAnalysisDto::from)
|
.map(VesselAnalysisDto::from)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Map<String, Object> response = Map.of(
|
return Map.of(
|
||||||
"count", results.size(),
|
"count", results.size(),
|
||||||
"items", results,
|
"items", results,
|
||||||
"stats", stats
|
"stats", stats
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cache != null) {
|
|
||||||
cache.put("data_with_stats", response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,6 +94,16 @@ server {
|
|||||||
proxy_ssl_server_name on;
|
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
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
|||||||
@ -4,6 +4,24 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-03-25]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 폴리곤 히스토리 애니메이션: 12시간 타임라인 기반 재생 (중심 궤적 + 어구별 궤적 + 가상 아이콘)
|
||||||
|
- 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭) + 신호없음 구간 표시
|
||||||
|
- nginx /api/gtts 프록시 (Google TTS CORS 우회)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 분석 파이프라인: 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.4]
|
## [2026-03-24.4]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import { FONT_MONO } from '../../styles/fonts';
|
|||||||
import type { GeoJSON } from 'geojson';
|
import type { GeoJSON } from 'geojson';
|
||||||
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
||||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||||
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
|
import { fetchFleetCompanies, fetchGroupHistory } from '../../services/vesselAnalysis';
|
||||||
import type { FleetCompany } from '../../services/vesselAnalysis';
|
import type { FleetCompany, GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||||
|
|
||||||
export interface SelectedGearGroupData {
|
export interface SelectedGearGroupData {
|
||||||
@ -50,6 +50,14 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[];
|
candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pickerHoveredGroup, setPickerHoveredGroup] = useState<string | null>(null);
|
const [pickerHoveredGroup, setPickerHoveredGroup] = useState<string | null>(null);
|
||||||
|
// 히스토리 애니메이션 — 12시간 실시간 타임라인
|
||||||
|
const [historyData, setHistoryData] = useState<GroupPolygonDto[] | null>(null);
|
||||||
|
const [, setHistoryGroupKey] = useState<string | null>(null);
|
||||||
|
const [timelinePos, setTimelinePos] = useState(0); // 0~1 (12시간 내 위치)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(true);
|
||||||
|
const animTimerRef = useRef<ReturnType<typeof setInterval>>();
|
||||||
|
const historyStartRef = useRef(0); // 12시간 전 epoch ms
|
||||||
|
const historyEndRef = useRef(0); // 현재 epoch ms
|
||||||
const { current: mapRef } = useMap();
|
const { current: mapRef } = useMap();
|
||||||
const registeredRef = useRef(false);
|
const registeredRef = useRef(false);
|
||||||
const dataRef = useRef<{ shipMap: Map<string, Ship>; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom });
|
const dataRef = useRef<{ shipMap: Map<string, Ship>; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom });
|
||||||
@ -58,6 +66,83 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
fetchFleetCompanies().then(setCompanies).catch(() => {});
|
fetchFleetCompanies().then(setCompanies).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const TIMELINE_DURATION_MS = 12 * 60 * 60_000; // 12시간
|
||||||
|
const PLAYBACK_CYCLE_SEC = 30; // 30초에 12시간 전체 재생
|
||||||
|
const TICK_MS = 50; // 50ms 간격 업데이트
|
||||||
|
|
||||||
|
const loadHistory = async (groupKey: string) => {
|
||||||
|
setHistoryGroupKey(groupKey);
|
||||||
|
setTimelinePos(0);
|
||||||
|
setIsPlaying(true);
|
||||||
|
const history = await fetchGroupHistory(groupKey, 12);
|
||||||
|
const sorted = history.reverse(); // 시간 오름차순
|
||||||
|
const now = Date.now();
|
||||||
|
historyStartRef.current = now - TIMELINE_DURATION_MS;
|
||||||
|
historyEndRef.current = now;
|
||||||
|
setHistoryData(sorted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeHistory = useCallback(() => {
|
||||||
|
setHistoryData(null);
|
||||||
|
setHistoryGroupKey(null);
|
||||||
|
setTimelinePos(0);
|
||||||
|
setIsPlaying(true);
|
||||||
|
clearInterval(animTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 재생 타이머 — 50ms마다 timelinePos 진행
|
||||||
|
useEffect(() => {
|
||||||
|
if (!historyData || !isPlaying) {
|
||||||
|
clearInterval(animTimerRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const step = TICK_MS / (PLAYBACK_CYCLE_SEC * 1000); // 1틱당 진행량
|
||||||
|
animTimerRef.current = setInterval(() => {
|
||||||
|
setTimelinePos(prev => {
|
||||||
|
const next = prev + step;
|
||||||
|
return next >= 1 ? 0 : next; // 순환
|
||||||
|
});
|
||||||
|
}, TICK_MS);
|
||||||
|
return () => clearInterval(animTimerRef.current);
|
||||||
|
}, [historyData, isPlaying]);
|
||||||
|
|
||||||
|
// timelinePos → 현재 시각 + 가장 가까운 스냅샷 인덱스
|
||||||
|
const currentTimeMs = historyStartRef.current + timelinePos * TIMELINE_DURATION_MS;
|
||||||
|
const currentSnapIdx = useMemo(() => {
|
||||||
|
if (!historyData || historyData.length === 0) return -1;
|
||||||
|
let best = 0;
|
||||||
|
let bestDiff = Infinity;
|
||||||
|
for (let i = 0; i < historyData.length; i++) {
|
||||||
|
const t = new Date(historyData[i].snapshotTime).getTime();
|
||||||
|
const diff = Math.abs(t - currentTimeMs);
|
||||||
|
if (diff < bestDiff) { bestDiff = diff; best = i; }
|
||||||
|
}
|
||||||
|
// 5분(300초) 이내 스냅샷만 유효
|
||||||
|
return bestDiff < 300_000 ? best : -1;
|
||||||
|
}, [historyData, currentTimeMs]);
|
||||||
|
|
||||||
|
// 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용)
|
||||||
|
const snapshotRanges = useMemo(() => {
|
||||||
|
if (!historyData) return [];
|
||||||
|
return historyData.map(h => {
|
||||||
|
const t = new Date(h.snapshotTime).getTime();
|
||||||
|
return (t - historyStartRef.current) / TIMELINE_DURATION_MS;
|
||||||
|
});
|
||||||
|
}, [historyData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (historyData) closeHistory();
|
||||||
|
setSelectedGearGroup(null);
|
||||||
|
setExpandedFleet(null);
|
||||||
|
setExpandedGearGroup(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [historyData, closeHistory]);
|
||||||
|
|
||||||
// ── 맵 폴리곤 클릭/호버 이벤트 등록
|
// ── 맵 폴리곤 클릭/호버 이벤트 등록
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef?.getMap();
|
const map = mapRef?.getMap();
|
||||||
@ -98,6 +183,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
if (m.lon > maxLng) maxLng = m.lon;
|
if (m.lon > maxLng) maxLng = m.lon;
|
||||||
}
|
}
|
||||||
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||||
|
loadHistory(String(cid));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 통합 클릭 핸들러: 선단+어구 모든 폴리곤 겹침 판정
|
// 통합 클릭 핸들러: 선단+어구 모든 폴리곤 겹침 판정
|
||||||
@ -179,6 +265,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
if (m.lon > maxLng) maxLng = m.lon;
|
if (m.lon > maxLng) maxLng = m.lon;
|
||||||
}
|
}
|
||||||
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||||
|
loadHistory(name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onGearClick = onPolygonClick;
|
const onGearClick = onPolygonClick;
|
||||||
@ -220,10 +307,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
// stale closure 방지
|
// stale closure 방지
|
||||||
dataRef.current = { shipMap, groupPolygons, onFleetZoom };
|
dataRef.current = { shipMap, groupPolygons, onFleetZoom };
|
||||||
|
|
||||||
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용)
|
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) — 히스토리 모드에서는 null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedGearGroup) {
|
if (!selectedGearGroup || historyData) {
|
||||||
onSelectedGearChange?.(null);
|
onSelectedGearChange?.(null);
|
||||||
|
if (historyData) return; // 히스토리 모드: 선택은 유지하되 부모 강조만 숨김
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const allGroups = groupPolygons
|
const allGroups = groupPolygons
|
||||||
@ -252,12 +340,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
gears: gears.map(toShip),
|
gears: gears.map(toShip),
|
||||||
groupName: selectedGearGroup,
|
groupName: selectedGearGroup,
|
||||||
});
|
});
|
||||||
}, [selectedGearGroup, groupPolygons, onSelectedGearChange]);
|
}, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]);
|
||||||
|
|
||||||
// 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용)
|
// 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) — 히스토리 모드에서는 null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expandedFleet === null) {
|
if (expandedFleet === null || historyData) {
|
||||||
onSelectedFleetChange?.(null);
|
onSelectedFleetChange?.(null);
|
||||||
|
if (historyData) return;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet);
|
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet);
|
||||||
@ -282,7 +371,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
ships: fleetShips,
|
ships: fleetShips,
|
||||||
companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}`,
|
companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}`,
|
||||||
});
|
});
|
||||||
}, [expandedFleet, groupPolygons, companies, onSelectedFleetChange]);
|
}, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyData]);
|
||||||
|
|
||||||
// API 기반 어구 그룹 분류
|
// API 기반 어구 그룹 분류
|
||||||
const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? [];
|
const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? [];
|
||||||
@ -385,6 +474,80 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] };
|
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] };
|
||||||
}, [pickerHoveredGroup, groupPolygons]);
|
}, [pickerHoveredGroup, groupPolygons]);
|
||||||
|
|
||||||
|
// ── 히스토리 애니메이션 GeoJSON ──
|
||||||
|
const EMPTY_HIST_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
|
||||||
|
|
||||||
|
const centerTrailGeoJson = useMemo((): GeoJSON => {
|
||||||
|
if (!historyData) return EMPTY_HIST_FC;
|
||||||
|
const coords = historyData.map(h => [h.centerLon, h.centerLat]);
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } },
|
||||||
|
...historyData.map(h => ({
|
||||||
|
type: 'Feature' as const, properties: {},
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [h.centerLon, h.centerLat] },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [historyData]);
|
||||||
|
|
||||||
|
const memberTrailsGeoJson = useMemo((): GeoJSON => {
|
||||||
|
if (!historyData) return EMPTY_HIST_FC;
|
||||||
|
const tracks = new Map<string, [number, number][]>();
|
||||||
|
for (const snap of historyData) {
|
||||||
|
for (const m of snap.members) {
|
||||||
|
const arr = tracks.get(m.mmsi) ?? [];
|
||||||
|
arr.push([m.lon, m.lat]);
|
||||||
|
tracks.set(m.mmsi, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const features: GeoJSON.Feature[] = [];
|
||||||
|
for (const [, coords] of tracks) {
|
||||||
|
if (coords.length < 2) continue;
|
||||||
|
features.push({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } });
|
||||||
|
}
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}, [historyData]);
|
||||||
|
|
||||||
|
// 현재 또는 마지막 유효 스냅샷 (신호없음 구간에서 이전 데이터 유지)
|
||||||
|
const effectiveSnapIdx = useMemo(() => {
|
||||||
|
if (!historyData || historyData.length === 0) return -1;
|
||||||
|
if (currentSnapIdx >= 0) return currentSnapIdx;
|
||||||
|
// 현재 시각 이전의 가장 가까운 스냅샷
|
||||||
|
for (let i = historyData.length - 1; i >= 0; i--) {
|
||||||
|
if (new Date(historyData[i].snapshotTime).getTime() <= currentTimeMs) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}, [historyData, currentSnapIdx, currentTimeMs]);
|
||||||
|
|
||||||
|
const isStale = currentSnapIdx < 0 && effectiveSnapIdx >= 0; // 신호없음이지만 이전 데이터 유지
|
||||||
|
|
||||||
|
const animPolygonGeoJson = useMemo((): GeoJSON => {
|
||||||
|
if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC;
|
||||||
|
const snap = historyData[effectiveSnapIdx];
|
||||||
|
if (!snap?.polygon) return EMPTY_HIST_FC;
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0 }, geometry: snap.polygon }],
|
||||||
|
};
|
||||||
|
}, [historyData, effectiveSnapIdx, isStale]);
|
||||||
|
|
||||||
|
// 현재 프레임의 멤버 위치 (가상 아이콘)
|
||||||
|
const animMembersGeoJson = useMemo((): GeoJSON => {
|
||||||
|
if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC;
|
||||||
|
const snap = historyData[effectiveSnapIdx];
|
||||||
|
if (!snap) return EMPTY_HIST_FC;
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: snap.members.map(m => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, stale: isStale ? 1 : 0 },
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [historyData, effectiveSnapIdx, isStale]);
|
||||||
|
|
||||||
// 선단 목록 (멤버 수 내림차순)
|
// 선단 목록 (멤버 수 내림차순)
|
||||||
const fleetList = useMemo(() => {
|
const fleetList = useMemo(() => {
|
||||||
if (!groupPolygons) return [];
|
if (!groupPolygons) return [];
|
||||||
@ -440,6 +603,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
}
|
}
|
||||||
if (minLat === Infinity) return;
|
if (minLat === Infinity) return;
|
||||||
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||||
|
loadHistory(parentName);
|
||||||
}, [groupPolygons, onFleetZoom]);
|
}, [groupPolygons, onFleetZoom]);
|
||||||
|
|
||||||
// 패널 스타일 (AnalysisStatsPanel 패턴)
|
// 패널 스타일 (AnalysisStatsPanel 패턴)
|
||||||
@ -534,8 +698,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
{/* 선택된 어구 그룹 하이라이트 폴리곤 */}
|
{/* 선택된 어구 그룹 하이라이트 폴리곤 — 히스토리 모드에서는 숨김 */}
|
||||||
{selectedGearGroup && (() => {
|
{selectedGearGroup && !historyData && (() => {
|
||||||
const allGroups = groupPolygons
|
const allGroups = groupPolygons
|
||||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||||
: [];
|
: [];
|
||||||
@ -578,8 +742,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
{/* 가상 선박 마커 (API members 기반 — 삼각형 아이콘 + 방향 + 줌 스케일) */}
|
{/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */}
|
||||||
<Source id="group-member-markers" type="geojson" data={memberMarkersGeoJson}>
|
<Source id="group-member-markers" type="geojson" data={historyData ? ({ type: 'FeatureCollection', features: [] } as GeoJSON) : memberMarkersGeoJson}>
|
||||||
<Layer
|
<Layer
|
||||||
id="group-member-icon"
|
id="group-member-icon"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
@ -727,6 +891,122 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* ── 히스토리 애니메이션 레이어 (최상위) ── */}
|
||||||
|
{historyData && (
|
||||||
|
<Source id="history-member-trails" type="geojson" data={memberTrailsGeoJson}>
|
||||||
|
<Layer id="history-member-trails-line" type="line" paint={{
|
||||||
|
'line-color': '#cbd5e1', 'line-width': 1.5, 'line-opacity': 0.65,
|
||||||
|
}} />
|
||||||
|
</Source>
|
||||||
|
)}
|
||||||
|
{historyData && (
|
||||||
|
<Source id="history-center-trail" type="geojson" data={centerTrailGeoJson}>
|
||||||
|
<Layer id="history-center-trail-line" type="line" paint={{
|
||||||
|
'line-color': '#fbbf24', 'line-width': 2, 'line-dasharray': [4, 4], 'line-opacity': 0.7,
|
||||||
|
}} />
|
||||||
|
<Layer id="history-center-dots" type="circle" paint={{
|
||||||
|
'circle-radius': 2.5, 'circle-color': '#fbbf24', 'circle-opacity': 0.6,
|
||||||
|
}} filter={['==', '$type', 'Point']} />
|
||||||
|
</Source>
|
||||||
|
)}
|
||||||
|
{historyData && (
|
||||||
|
<Source id="history-anim-polygon" type="geojson" data={animPolygonGeoJson}>
|
||||||
|
<Layer id="history-anim-fill" type="fill" paint={{
|
||||||
|
'fill-color': isStale ? '#64748b' : '#fbbf24',
|
||||||
|
'fill-opacity': isStale ? 0.08 : 0.15,
|
||||||
|
}} />
|
||||||
|
<Layer id="history-anim-line" type="line" paint={{
|
||||||
|
'line-color': isStale ? '#64748b' : '#fbbf24',
|
||||||
|
'line-width': isStale ? 1 : 2,
|
||||||
|
'line-opacity': isStale ? 0.4 : 0.7,
|
||||||
|
'line-dasharray': isStale ? [3, 3] : [1, 0],
|
||||||
|
}} />
|
||||||
|
</Source>
|
||||||
|
)}
|
||||||
|
{/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */}
|
||||||
|
{historyData && (
|
||||||
|
<Source id="history-anim-members" type="geojson" data={animMembersGeoJson}>
|
||||||
|
<Layer id="history-anim-members-icon" type="symbol" layout={{
|
||||||
|
'icon-image': 'ship-triangle',
|
||||||
|
'icon-size': 0.7,
|
||||||
|
'icon-rotate': ['get', 'cog'],
|
||||||
|
'icon-rotation-alignment': 'map',
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
}} paint={{
|
||||||
|
'icon-color': ['case', ['==', ['get', 'stale'], 1], '#64748b', '#a8b8c8'],
|
||||||
|
'icon-opacity': ['case', ['==', ['get', 'stale'], 1], 0.4, 0.9],
|
||||||
|
}} />
|
||||||
|
<Layer id="history-anim-members-label" type="symbol" layout={{
|
||||||
|
'text-field': ['get', 'name'],
|
||||||
|
'text-size': 8,
|
||||||
|
'text-offset': [0, 1.5],
|
||||||
|
'text-allow-overlap': false,
|
||||||
|
}} paint={{
|
||||||
|
'text-color': '#e2e8f0',
|
||||||
|
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||||
|
'text-halo-width': 1,
|
||||||
|
}} />
|
||||||
|
</Source>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 히스토리 재생 컨트롤러 */}
|
||||||
|
{historyData && (() => {
|
||||||
|
const curTime = new Date(currentTimeMs);
|
||||||
|
const timeStr = curTime.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const hasSnap = currentSnapIdx >= 0;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
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: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', minWidth: 360,
|
||||||
|
}}>
|
||||||
|
{/* 프로그레스 바 — 갭 표시 */}
|
||||||
|
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
{/* 스냅샷 존재 구간 표시 */}
|
||||||
|
{snapshotRanges.map((pos, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%',
|
||||||
|
background: 'rgba(251,191,36,0.4)',
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
{/* 현재 위치 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: `${timelinePos * 100}%`, top: -1, width: 3, height: 10,
|
||||||
|
background: hasSnap ? '#fbbf24' : '#ef4444', borderRadius: 1,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
{/* 컨트롤 행 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button type="button" onClick={() => setIsPlaying(p => !p)} style={{
|
||||||
|
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4,
|
||||||
|
color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 12, fontFamily: FONT_MONO,
|
||||||
|
}}>
|
||||||
|
{isPlaying ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
<span style={{ color: hasSnap ? '#fbbf24' : '#ef4444', minWidth: 40, textAlign: 'center' }}>
|
||||||
|
{timeStr}
|
||||||
|
</span>
|
||||||
|
{!hasSnap && <span style={{ color: '#ef4444', fontSize: 9 }}>신호없음</span>}
|
||||||
|
<input type="range" min={0} max={1000} value={Math.round(timelinePos * 1000)}
|
||||||
|
onChange={e => { setIsPlaying(false); setTimelinePos(Number(e.target.value) / 1000); }}
|
||||||
|
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
|
||||||
|
title="히스토리 타임라인" aria-label="히스토리 타임라인"
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#64748b', fontSize: 9 }}>
|
||||||
|
{historyData.length}건
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={closeHistory} 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>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 선단 목록 패널 */}
|
{/* 선단 목록 패널 */}
|
||||||
<div style={panelStyle}>
|
<div style={panelStyle}>
|
||||||
{/* ── 선단 현황 섹션 ── */}
|
{/* ── 선단 현황 섹션 ── */}
|
||||||
|
|||||||
@ -92,6 +92,26 @@ def detect_fishing_segments(df_vessel: pd.DataFrame,
|
|||||||
})
|
})
|
||||||
in_fishing = False
|
in_fishing = False
|
||||||
|
|
||||||
|
# 트랙 끝까지 조업 중이면 마지막 세그먼트 추가
|
||||||
|
if in_fishing and len(records) > seg_start_idx:
|
||||||
|
start_ts = records[seg_start_idx].get('timestamp')
|
||||||
|
end_ts = records[-1].get('timestamp')
|
||||||
|
if start_ts and end_ts:
|
||||||
|
dur_sec = (pd.Timestamp(end_ts) - pd.Timestamp(start_ts)).total_seconds()
|
||||||
|
dur_min = dur_sec / 60
|
||||||
|
if dur_min >= window_min:
|
||||||
|
zone_info = classify_zone(
|
||||||
|
records[seg_start_idx].get('lat', 0),
|
||||||
|
records[seg_start_idx].get('lon', 0),
|
||||||
|
)
|
||||||
|
segments.append({
|
||||||
|
'start_idx': seg_start_idx,
|
||||||
|
'end_idx': len(records) - 1,
|
||||||
|
'duration_min': round(dur_min, 1),
|
||||||
|
'zone': zone_info.get('zone', 'UNKNOWN'),
|
||||||
|
'in_territorial_sea': zone_info.get('zone') == 'TERRITORIAL_SEA',
|
||||||
|
})
|
||||||
|
|
||||||
return segments
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# 프론트 FleetClusterLayer.tsx gearGroupMap 패턴과 동일
|
# 프론트 FleetClusterLayer.tsx gearGroupMap 패턴과 동일
|
||||||
GEAR_PATTERN = re.compile(r'^(.+?)_\d+_\d+_?$')
|
GEAR_PATTERN = re.compile(r'^(.+?)_\d+_\d+_?$')
|
||||||
MAX_DIST_DEG = 0.15 # ~10NM
|
MAX_DIST_DEG = 0.15 # ~10NM
|
||||||
STALE_SEC = 3600 # 60분
|
STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버)
|
||||||
FLEET_BUFFER_DEG = 0.02
|
FLEET_BUFFER_DEG = 0.02
|
||||||
GEAR_BUFFER_DEG = 0.01
|
GEAR_BUFFER_DEG = 0.01
|
||||||
MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외)
|
MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외)
|
||||||
|
|||||||
@ -54,6 +54,13 @@ def compute_vessel_risk_score(
|
|||||||
if teleports:
|
if teleports:
|
||||||
score += 20
|
score += 20
|
||||||
|
|
||||||
|
from algorithms.spoofing import count_speed_jumps
|
||||||
|
jumps = count_speed_jumps(df_vessel)
|
||||||
|
if jumps >= 3:
|
||||||
|
score += 10
|
||||||
|
elif jumps >= 1:
|
||||||
|
score += 5
|
||||||
|
|
||||||
gaps = detect_ais_gaps(df_vessel)
|
gaps = detect_ais_gaps(df_vessel)
|
||||||
critical_gaps = [g for g in gaps if g['gap_min'] >= 60]
|
critical_gaps = [g for g in gaps if g['gap_min'] >= 60]
|
||||||
if critical_gaps:
|
if critical_gaps:
|
||||||
|
|||||||
@ -68,13 +68,15 @@ def compute_spoofing_score(df_vessel: pd.DataFrame) -> float:
|
|||||||
if jumps > 0:
|
if jumps > 0:
|
||||||
score += min(0.3, jumps / n * 5)
|
score += min(0.3, jumps / n * 5)
|
||||||
|
|
||||||
# BD09 오프셋 (중국 좌표 사용 의심)
|
# BD09 오프셋 — 중국 선박(412*)은 좌표계 차이로 항상 ~300m이므로 제외
|
||||||
mid_idx = len(df_vessel) // 2
|
mmsi_str = str(df_vessel.iloc[0].get('mmsi', '')) if 'mmsi' in df_vessel.columns else ''
|
||||||
row = df_vessel.iloc[mid_idx]
|
if not mmsi_str.startswith('412'):
|
||||||
offset = compute_bd09_offset(row['lat'], row['lon'])
|
mid_idx = len(df_vessel) // 2
|
||||||
if offset > 300: # 300m 이상
|
row = df_vessel.iloc[mid_idx]
|
||||||
score += 0.3
|
offset = compute_bd09_offset(row['lat'], row['lon'])
|
||||||
elif offset > 100:
|
if offset > 300:
|
||||||
score += 0.1
|
score += 0.3
|
||||||
|
elif offset > 100:
|
||||||
|
score += 0.1
|
||||||
|
|
||||||
return round(min(score, 1.0), 4)
|
return round(min(score, 1.0), 4)
|
||||||
|
|||||||
@ -18,7 +18,7 @@ MIN_CLUSTER_SIZE = 5
|
|||||||
MMSI_DIGITS = 9
|
MMSI_DIGITS = 9
|
||||||
MAX_VESSEL_LENGTH = 300
|
MAX_VESSEL_LENGTH = 300
|
||||||
MAX_SOG_KNOTS = 30.0
|
MAX_SOG_KNOTS = 30.0
|
||||||
MIN_TRAJ_POINTS = 100
|
MIN_TRAJ_POINTS = 20
|
||||||
|
|
||||||
KR_BOUNDS = {
|
KR_BOUNDS = {
|
||||||
'lat_min': 32.0, 'lat_max': 39.0,
|
'lat_min': 32.0, 'lat_max': 39.0,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user