feat: 어구 연관성 프론트엔드 표시 — Backend API + 팝업 UI

- Backend: GET /api/vessel-analysis/groups/{groupKey}/correlations 엔드포인트
- GroupPolygonService: gear_correlation_scores JOIN correlation_param_models 쿼리
- Frontend: fetchGroupCorrelations API 클라이언트 + GearCorrelationItem 타입
- FleetClusterLayer: 어구 그룹 선택 시 연관 선박/어구 목록 팝업에 표시
  - default 모델 기준 일치율 % + 바 그래프
  - 선박(⛴)/어구(◆) 유형 구분, 상위 8건 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-30 11:33:56 +09:00
부모 812a78f636
커밋 d025809793
4개의 변경된 파일151개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

@ -48,4 +48,19 @@ 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
));
}
}

파일 보기

@ -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<Map<String, Object>> getGroupCorrelations(String groupKey, double minScore) {
try {
return jdbcTemplate.query(GROUP_CORRELATIONS_SQL, (rs, rowNum) -> {
Map<String, Object> 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분 캐시).
*/

파일 보기

@ -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<string | null>(null);
// 어구 연관성 데이터
const [correlationData, setCorrelationData] = useState<GearCorrelationItem[]>([]);
const [correlationLoading, setCorrelationLoading] = useState(false);
// 히스토리 애니메이션 — 12시간 실시간 타임라인
const [historyData, setHistoryData] = useState<HistoryFrame[] | null>(null);
const [, setHistoryGroupKey] = useState<string | null>(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}
</div>
))}
{/* 연관 선박/어구 (선택된 그룹만) */}
{selectedGearGroup === name && correlationData.length > 0 && (
<div style={{ marginTop: 5, borderTop: '1px solid rgba(255,255,255,0.15)', paddingTop: 4 }}>
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 2 }}>
{correlationData.length}
</div>
{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 (
<div key={c.targetMmsi} style={{ fontSize: 9, marginTop: 2, display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: c.targetType === 'VESSEL' ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center' }}>
{c.targetType === 'VESSEL' ? '⛴' : '◆'}
</span>
<span style={{ color: '#cbd5e1', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 100 }}>
{c.targetName || c.targetMmsi}
</span>
<div style={{ width: 62, display: 'flex', alignItems: 'center', gap: 2 }}>
<div style={{ width: 40, height: 4, background: 'rgba(255,255,255,0.1)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ width: barW, height: '100%', background: barColor, borderRadius: 2 }} />
</div>
<span style={{ color: barColor, fontSize: 8, minWidth: 22, textAlign: 'right' }}>{pct}%</span>
</div>
</div>
);
})}
{correlationData.length > 8 && (
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 2 }}>+{correlationData.length - 8} </div>
)}
</div>
)}
{selectedGearGroup === name && correlationLoading && (
<div style={{ fontSize: 8, color: '#64748b', marginTop: 3 }}> ...</div>
)}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> /</div>
</div>
</Popup>

파일 보기

@ -95,6 +95,42 @@ export async function fetchGroupHistory(groupKey: string, hours = 24): Promise<G
return res.json();
}
/* ── Gear Correlation Types ──────────────────────────────────── */
export interface GearCorrelationItem {
targetMmsi: string;
targetType: 'VESSEL' | 'GEAR_BUOY' | 'GEAR_GROUP';
targetName: string;
score: number;
streak: number;
observations: number;
freezeState: string;
proximityRatio: number | null;
visitScore: number | null;
headingCoherence: number | null;
modelId: number;
modelName: string;
isDefault: boolean;
}
export interface GearCorrelationResponse {
groupKey: string;
count: number;
items: GearCorrelationItem[];
}
export async function fetchGroupCorrelations(
groupKey: string,
minScore = 0.3,
): Promise<GearCorrelationResponse> {
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회 로드)