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 160b885..0012c79 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
@@ -60,15 +60,16 @@ public class GroupPolygonService {
private static final String GROUP_CORRELATIONS_SQL = """
WITH best_scores AS (
- SELECT DISTINCT ON (m.id, s.target_mmsi)
+ SELECT DISTINCT ON (m.id, s.sub_cluster_id, s.target_mmsi)
s.target_mmsi, s.target_type, s.target_name,
s.current_score, s.streak_count, s.observation_count,
s.freeze_state, s.shadow_bonus_total,
+ s.sub_cluster_id,
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.id, s.target_mmsi, s.current_score DESC
+ ORDER BY m.id, s.sub_cluster_id, s.target_mmsi, s.current_score DESC
)
SELECT bs.*,
r.proximity_ratio, r.visit_score, r.heading_coherence
@@ -120,6 +121,7 @@ public class GroupPolygonService {
row.put("observations", rs.getInt("observation_count"));
row.put("freezeState", rs.getString("freeze_state"));
row.put("shadowBonus", rs.getDouble("shadow_bonus_total"));
+ row.put("subClusterId", rs.getInt("sub_cluster_id"));
row.put("proximityRatio", rs.getObject("proximity_ratio"));
row.put("visitScore", rs.getObject("visit_score"));
row.put("headingCoherence", rs.getObject("heading_coherence"));
diff --git a/frontend/src/components/korea/CorrelationPanel.tsx b/frontend/src/components/korea/CorrelationPanel.tsx
index 0dfd5be..413ac59 100644
--- a/frontend/src/components/korea/CorrelationPanel.tsx
+++ b/frontend/src/components/korea/CorrelationPanel.tsx
@@ -292,23 +292,26 @@ const CorrelationPanel = ({
{memberCount}
{correlationLoading &&
로딩...
}
- {availableModels.map(m => {
- const color = MODEL_COLORS[m.name] ?? '#94a3b8';
- const modelItems = correlationByModel.get(m.name) ?? [];
+ {_MODEL_ORDER.filter(mn => mn !== 'identity').map(mn => {
+ const color = MODEL_COLORS[mn] ?? '#94a3b8';
+ const modelItems = correlationByModel.get(mn) ?? [];
+ const hasData = modelItems.length > 0;
const vc = modelItems.filter(c => c.targetType === 'VESSEL').length;
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
+ const am = availableModels.find(m => m.name === mn);
return (
-
);
})}
diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx
index 3cd16e8..7b00ef5 100644
--- a/frontend/src/components/korea/FleetClusterLayer.tsx
+++ b/frontend/src/components/korea/FleetClusterLayer.tsx
@@ -42,7 +42,7 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) {
([subClusterId, data]) => ({ subClusterId, ...data }),
);
- // 2. 시간별 멤버 합산 프레임 (기존 리플레이 호환)
+ // 2. 시간별 그룹핑 후 서브클러스터 보존
const byTime = new Map();
for (const h of sorted) {
const list = byTime.get(h.snapshotTime) ?? [];
@@ -52,34 +52,63 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) {
const frames: GroupPolygonDto[] = [];
for (const [, items] of byTime) {
- if (items.length === 1) {
- frames.push(items[0]);
- continue;
- }
- const seen = new Set();
- const allMembers: GroupPolygonDto['members'] = [];
- for (const item of items) {
- for (const m of item.members) {
- if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
+ const allSameId = items.every(item => (item.subClusterId ?? 0) === 0);
+
+ if (items.length === 1 || allSameId) {
+ // 단일 아이템 또는 모두 subClusterId=0: 통합 서브프레임 1개
+ const base = items.length === 1 ? items[0] : (() => {
+ const seen = new Set();
+ const allMembers: GroupPolygonDto['members'] = [];
+ for (const item of items) {
+ for (const m of item.members) {
+ if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
+ }
+ }
+ const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
+ return { ...biggest, subClusterId: 0, members: allMembers, memberCount: allMembers.length };
+ })();
+ const subFrames: SubFrame[] = [{
+ subClusterId: 0,
+ centerLon: base.centerLon,
+ centerLat: base.centerLat,
+ members: base.members,
+ memberCount: base.memberCount,
+ }];
+ frames.push({ ...base, subFrames } as GroupPolygonDto & { subFrames: SubFrame[] });
+ } else {
+ // 서로 다른 subClusterId: 각 아이템을 개별 서브프레임으로 보존
+ const subFrames: SubFrame[] = items.map(item => ({
+ subClusterId: item.subClusterId ?? 0,
+ centerLon: item.centerLon,
+ centerLat: item.centerLat,
+ members: item.members,
+ memberCount: item.memberCount,
+ }));
+ const seen = new Set();
+ const allMembers: GroupPolygonDto['members'] = [];
+ for (const sf of subFrames) {
+ for (const m of sf.members) {
+ if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
+ }
}
+ const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
+ frames.push({
+ ...biggest,
+ subClusterId: 0,
+ members: allMembers,
+ memberCount: allMembers.length,
+ centerLat: biggest.centerLat,
+ centerLon: biggest.centerLon,
+ subFrames,
+ } as GroupPolygonDto & { subFrames: SubFrame[] });
}
- const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
- frames.push({
- ...biggest,
- subClusterId: 0,
- members: allMembers,
- memberCount: allMembers.length,
- // 가장 큰 서브클러스터의 center 사용 (가중 평균 아닌 대표 center)
- centerLat: biggest.centerLat,
- centerLon: biggest.centerLon,
- });
}
- return { frames: fillGapFrames(frames), subClusterCenters };
+ return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters };
}
// ── 분리된 모듈 ──
-import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes';
+import type { PickerCandidate, HoverTooltipState, GearPickerPopupState, SubFrame, HistoryFrame } from './fleetClusterTypes';
import { EMPTY_ANALYSIS } from './fleetClusterTypes';
import { fillGapFrames } from './fleetClusterUtils';
import { useFleetClusterGeoJson } from './useFleetClusterGeoJson';
@@ -521,8 +550,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
onClose={closeHistory}
onFilterByScore={(minPct) => {
// 전역: 모든 모델의 모든 대상에 적용 (모델 on/off 무관)
+ // null(전체) = 30% 이상 전부 ON (API minScore=0.3 기준)
if (minPct === null) {
- setEnabledVessels(new Set(correlationTracks.map(v => v.mmsi)));
+ setEnabledVessels(new Set(correlationTracks.filter(v => v.score >= 0.3).map(v => v.mmsi)));
} else {
const threshold = minPct / 100;
const filtered = new Set();
diff --git a/frontend/src/components/korea/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx
index 04662f7..2ebbfc9 100644
--- a/frontend/src/components/korea/HistoryReplayController.tsx
+++ b/frontend/src/components/korea/HistoryReplayController.tsx
@@ -111,6 +111,7 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
일치율