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);
|
List<GroupPolygonDto> history = groupPolygonService.getGroupHistory(groupKey, hours);
|
||||||
return ResponseEntity.ok(history);
|
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
|
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 = """
|
private static final String GEAR_STATS_SQL = """
|
||||||
SELECT COUNT(*) AS gear_groups,
|
SELECT COUNT(*) AS gear_groups,
|
||||||
COALESCE(SUM(member_count), 0) AS gear_count
|
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분 캐시).
|
* 최신 스냅샷의 전체 그룹 폴리곤 목록 (5분 캐시).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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, fetchGroupHistory } from '../../services/vesselAnalysis';
|
import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations } from '../../services/vesselAnalysis';
|
||||||
import type { FleetCompany, GroupPolygonDto } from '../../services/vesselAnalysis';
|
import type { FleetCompany, GroupPolygonDto, GearCorrelationItem } from '../../services/vesselAnalysis';
|
||||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
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 }[];
|
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);
|
||||||
|
// 어구 연관성 데이터
|
||||||
|
const [correlationData, setCorrelationData] = useState<GearCorrelationItem[]>([]);
|
||||||
|
const [correlationLoading, setCorrelationLoading] = useState(false);
|
||||||
// 히스토리 애니메이션 — 12시간 실시간 타임라인
|
// 히스토리 애니메이션 — 12시간 실시간 타임라인
|
||||||
const [historyData, setHistoryData] = useState<HistoryFrame[] | null>(null);
|
const [historyData, setHistoryData] = useState<HistoryFrame[] | null>(null);
|
||||||
const [, setHistoryGroupKey] = useState<string | null>(null);
|
const [, setHistoryGroupKey] = useState<string | null>(null);
|
||||||
@ -537,6 +540,26 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
});
|
});
|
||||||
}, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]);
|
}, [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
|
// 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) — 히스토리 모드에서는 null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expandedFleet === null || historyData) {
|
if (expandedFleet === null || historyData) {
|
||||||
@ -1119,6 +1142,41 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
· {m.name || m.mmsi}
|
· {m.name || m.mmsi}
|
||||||
</div>
|
</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 style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 선택/해제</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
|
|||||||
@ -95,6 +95,42 @@ export async function fetchGroupHistory(groupKey: string, hours = 24): Promise<G
|
|||||||
return res.json();
|
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 ─────────────────────────────────────────── */
|
/* ── Fleet Companies ─────────────────────────────────────────── */
|
||||||
|
|
||||||
// 캐시 (세션 중 1회 로드)
|
// 캐시 (세션 중 1회 로드)
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user