diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java index 97b9e9e..5786155 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java @@ -48,4 +48,19 @@ public class GroupPolygonController { List history = groupPolygonService.getGroupHistory(groupKey, hours); return ResponseEntity.ok(history); } + + /** + * 특정 어구 그룹의 연관성 점수 (멀티모델) + */ + @GetMapping("/{groupKey}/correlations") + public ResponseEntity> getGroupCorrelations( + @PathVariable String groupKey, + @RequestParam(defaultValue = "0.3") double minScore) { + List> correlations = groupPolygonService.getGroupCorrelations(groupKey, minScore); + return ResponseEntity.ok(Map.of( + "groupKey", groupKey, + "count", correlations.size(), + "items", correlations + )); + } } diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java index 7dcb2c6..d232a21 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java @@ -58,6 +58,18 @@ public class GroupPolygonService { ORDER BY snapshot_time DESC """; + private static final String GROUP_CORRELATIONS_SQL = """ + SELECT s.target_mmsi, s.target_type, s.target_name, + s.current_score, s.streak_count, s.observation_count, + s.freeze_state, + s.proximity_ratio, s.visit_score, s.heading_coherence, + m.id AS model_id, m.name AS model_name, m.is_default + FROM kcg.gear_correlation_scores s + JOIN kcg.correlation_param_models m ON s.model_id = m.id + WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE + ORDER BY m.is_default DESC, s.current_score DESC + """; + private static final String GEAR_STATS_SQL = """ SELECT COUNT(*) AS gear_groups, COALESCE(SUM(member_count), 0) AS gear_count @@ -81,6 +93,34 @@ public class GroupPolygonService { } } + /** + * 특정 어구 그룹의 연관성 점수 (멀티모델). + */ + public List> getGroupCorrelations(String groupKey, double minScore) { + try { + return jdbcTemplate.query(GROUP_CORRELATIONS_SQL, (rs, rowNum) -> { + Map row = new java.util.LinkedHashMap<>(); + row.put("targetMmsi", rs.getString("target_mmsi")); + row.put("targetType", rs.getString("target_type")); + row.put("targetName", rs.getString("target_name")); + row.put("score", rs.getDouble("current_score")); + row.put("streak", rs.getInt("streak_count")); + row.put("observations", rs.getInt("observation_count")); + row.put("freezeState", rs.getString("freeze_state")); + row.put("proximityRatio", rs.getObject("proximity_ratio")); + row.put("visitScore", rs.getObject("visit_score")); + row.put("headingCoherence", rs.getObject("heading_coherence")); + row.put("modelId", rs.getInt("model_id")); + row.put("modelName", rs.getString("model_name")); + row.put("isDefault", rs.getBoolean("is_default")); + return row; + }, groupKey, minScore); + } catch (Exception e) { + log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage()); + return List.of(); + } + } + /** * 최신 스냅샷의 전체 그룹 폴리곤 목록 (5분 캐시). */ diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 83e3c0e..cdfde40 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -4,8 +4,8 @@ import { FONT_MONO } from '../../styles/fonts'; import type { GeoJSON } from 'geojson'; import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { Ship, VesselAnalysisDto } from '../../types'; -import { fetchFleetCompanies, fetchGroupHistory } from '../../services/vesselAnalysis'; -import type { FleetCompany, GroupPolygonDto } from '../../services/vesselAnalysis'; +import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations } from '../../services/vesselAnalysis'; +import type { FleetCompany, GroupPolygonDto, GearCorrelationItem } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; /** @@ -241,6 +241,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[]; } | null>(null); const [pickerHoveredGroup, setPickerHoveredGroup] = useState(null); + // 어구 연관성 데이터 + const [correlationData, setCorrelationData] = useState([]); + const [correlationLoading, setCorrelationLoading] = useState(false); // 히스토리 애니메이션 — 12시간 실시간 타임라인 const [historyData, setHistoryData] = useState(null); const [, setHistoryGroupKey] = useState(null); @@ -537,6 +540,26 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS }); }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]); + // 선택된 어구 그룹의 연관성 데이터 로드 + useEffect(() => { + if (!selectedGearGroup) { + setCorrelationData([]); + return; + } + let cancelled = false; + setCorrelationLoading(true); + fetchGroupCorrelations(selectedGearGroup, 0.3) + .then(res => { + if (!cancelled) { + // default 모델 결과만 팝업에 표시 + setCorrelationData(res.items.filter(i => i.isDefault)); + } + }) + .catch(() => { if (!cancelled) setCorrelationData([]); }) + .finally(() => { if (!cancelled) setCorrelationLoading(false); }); + return () => { cancelled = true; }; + }, [selectedGearGroup]); + // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) — 히스토리 모드에서는 null useEffect(() => { if (expandedFleet === null || historyData) { @@ -1119,6 +1142,41 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS · {m.name || m.mmsi} ))} + {/* 연관 선박/어구 (선택된 그룹만) */} + {selectedGearGroup === name && correlationData.length > 0 && ( +
+
+ 연관 {correlationData.length}건 +
+ {correlationData.slice(0, 8).map(c => { + const pct = (c.score * 100).toFixed(0); + const barW = Math.max(2, c.score * 60); + const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; + return ( +
+ + {c.targetType === 'VESSEL' ? '⛴' : '◆'} + + + {c.targetName || c.targetMmsi} + +
+
+
+
+ {pct}% +
+
+ ); + })} + {correlationData.length > 8 && ( +
+{correlationData.length - 8}건 더
+ )} +
+ )} + {selectedGearGroup === name && correlationLoading && ( +
연관 분석 로딩...
+ )}
클릭하여 선택/해제
diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index b3072be..dc66deb 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -95,6 +95,42 @@ export async function fetchGroupHistory(groupKey: string, hours = 24): Promise { + const res = await fetch( + `${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/correlations?minScore=${minScore}`, + { headers: { accept: 'application/json' } }, + ); + if (!res.ok) return { groupKey, count: 0, items: [] }; + return res.json(); +} + /* ── Fleet Companies ─────────────────────────────────────────── */ // 캐시 (세션 중 1회 로드)