From f09186a18727ca19b7656ce4e2f9173d50699554 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 09:01:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=96=B4=EA=B5=AC=20=EB=A6=AC=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=20=EC=84=9C=EB=B8=8C=ED=81=B4=EB=9F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EB=B6=84=EB=A6=AC=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20+=20=EC=9D=BC=EC=B9=98=EC=9C=A8=20=EA=B0=90?= =?UTF-8?q?=EC=87=A0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서브클러스터별 독립 폴리곤/센터/center trail 렌더링 - 반경 밖 이탈 선박 강제 감쇠 (OUT_OF_RANGE) - Backend correlation API에 sub_cluster_id 추가 - 모델 패널 5개 항상 표시, 드롭다운 기본값 70% - DISPLAY_STALE_SEC (time_bucket 기반) 폴리곤 노출 필터 - AIS 수집 bbox 122~132E/31~39N 확장 - historyActive 시 deck.gl 이중 렌더링 수정 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kcg/domain/fleet/GroupPolygonService.java | 6 +- .../src/components/korea/CorrelationPanel.tsx | 23 +- .../components/korea/FleetClusterLayer.tsx | 76 ++++-- .../korea/HistoryReplayController.tsx | 3 +- frontend/src/components/korea/KoreaMap.tsx | 26 +- .../src/components/korea/fleetClusterTypes.ts | 17 +- .../src/components/korea/fleetClusterUtils.ts | 94 ++++++- .../korea/useFleetClusterGeoJson.ts | 55 +++-- .../src/hooks/useFleetClusterDeckLayers.ts | 4 +- frontend/src/hooks/useGearReplayLayers.ts | 233 +++++++++++------- frontend/src/services/vesselAnalysis.ts | 1 + frontend/src/stores/gearReplayPreprocess.ts | 179 ++++++++++---- prediction/algorithms/gear_correlation.py | 30 ++- prediction/algorithms/polygon_builder.py | 49 +++- prediction/cache/vessel_store.py | 1 + prediction/db/snpdb.py | 8 +- 16 files changed, 568 insertions(+), 237 deletions(-) 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 | 일치율