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:
부모
812a78f636
커밋
d025809793
@ -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회 로드)
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user