From 6f4044ce394f375b29b0e9ccbddc3a7fb18a4618 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 31 Mar 2026 15:43:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20MapLibre=20=E2=86=92=20deck.gl=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=EC=A0=84=ED=99=98=20+=20=EC=96=B4?= =?UTF-8?q?=EA=B5=AC=20=EC=84=9C=EB=B8=8C=ED=81=B4=EB=9F=AC=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실시간 선박 13K: MapLibre symbol → deck.gl IconLayer (useShipDeckLayers + shipDeckStore) - 선단/어구 폴리곤: MapLibre Source/Layer → deck.gl GeoJsonLayer (useFleetClusterDeckLayers) - 선박 팝업: MapLibre Popup → React 오버레이 (ShipPopupOverlay + ShipHoverTooltip) - 리플레이 집중 모드 (focusMode), 라벨 클러스터링, fontScale 연동 - Python: group_key 고정 + sub_cluster_id 분리, 한국 국적 어구 오탐 제외 - DB: sub_cluster_id 컬럼 추가 + 기존 '#N' 데이터 마이그레이션 - Backend: DISTINCT ON CTE로 서브클러스터 중복 제거, subClusterId DTO 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/worktrees/beautiful-lamarr | 1 + .../mda/kcg/domain/fleet/GroupPolygonDto.java | 1 + .../kcg/domain/fleet/GroupPolygonService.java | 36 +- frontend/.gitignore | 1 + .../src/components/korea/CorrelationPanel.tsx | 205 ++++-- .../components/korea/FleetClusterLayer.tsx | 365 ++++++---- .../korea/FleetClusterMapLayers.tsx | 275 +------ .../korea/HistoryReplayController.tsx | 13 +- frontend/src/components/korea/KoreaMap.tsx | 161 +++-- .../korea/useFleetClusterGeoJson.ts | 61 +- .../components/layers/ShipPopupOverlay.tsx | 675 ++++++++++++++++++ .../src/hooks/useFleetClusterDeckLayers.ts | 552 ++++++++++++++ frontend/src/hooks/useGearReplayLayers.ts | 63 +- frontend/src/hooks/useGroupPolygons.ts | 33 +- frontend/src/hooks/useShipDeckLayers.ts | 349 +++++++++ frontend/src/services/vesselAnalysis.ts | 1 + frontend/src/stores/gearReplayStore.ts | 12 + frontend/src/stores/shipDeckStore.ts | 108 +++ frontend/src/utils/labelCluster.ts | 51 ++ prediction/algorithms/gear_correlation.py | 25 +- prediction/algorithms/polygon_builder.py | 13 +- prediction/db/kcgdb.py | 5 +- 22 files changed, 2373 insertions(+), 633 deletions(-) create mode 160000 .claude/worktrees/beautiful-lamarr create mode 100644 frontend/.gitignore create mode 100644 frontend/src/components/layers/ShipPopupOverlay.tsx create mode 100644 frontend/src/hooks/useFleetClusterDeckLayers.ts create mode 100644 frontend/src/hooks/useShipDeckLayers.ts create mode 100644 frontend/src/stores/shipDeckStore.ts create mode 100644 frontend/src/utils/labelCluster.ts diff --git a/.claude/worktrees/beautiful-lamarr b/.claude/worktrees/beautiful-lamarr new file mode 160000 index 0000000..ef34276 --- /dev/null +++ b/.claude/worktrees/beautiful-lamarr @@ -0,0 +1 @@ +Subproject commit ef342769d461dfe48d0e981eb534d467721a41f5 diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java index dd2fa28..4468e94 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java @@ -14,6 +14,7 @@ public class GroupPolygonDto { private String groupType; private String groupKey; private String groupLabel; + private int subClusterId; private String snapshotTime; private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON) private double centerLat; 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 ccd496d..160b885 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 @@ -28,7 +28,7 @@ public class GroupPolygonService { private volatile long lastCacheTime = 0; private static final String LATEST_GROUPS_SQL = """ - SELECT group_type, group_key, group_label, snapshot_time, + SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time, ST_AsGeoJSON(polygon) AS polygon_geojson, ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, area_sq_nm, member_count, zone_id, zone_name, members, color @@ -38,7 +38,7 @@ public class GroupPolygonService { """; private static final String GROUP_DETAIL_SQL = """ - SELECT group_type, group_key, group_label, snapshot_time, + SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time, ST_AsGeoJSON(polygon) AS polygon_geojson, ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, area_sq_nm, member_count, zone_id, zone_name, members, color @@ -49,7 +49,7 @@ public class GroupPolygonService { """; private static final String GROUP_HISTORY_SQL = """ - SELECT group_type, group_key, group_label, snapshot_time, + SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time, ST_AsGeoJSON(polygon) AS polygon_geojson, ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, area_sq_nm, member_count, zone_id, zone_name, members, color @@ -59,21 +59,27 @@ public class GroupPolygonService { """; 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.shadow_bonus_total, - r.proximity_ratio, r.visit_score, r.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 + WITH best_scores AS ( + SELECT DISTINCT ON (m.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, + 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 + ) + SELECT bs.*, + r.proximity_ratio, r.visit_score, r.heading_coherence + FROM best_scores bs LEFT JOIN LATERAL ( SELECT proximity_ratio, visit_score, heading_coherence FROM kcg.gear_correlation_raw_metrics - WHERE group_key = s.group_key AND target_mmsi = s.target_mmsi + WHERE group_key = ? AND target_mmsi = bs.target_mmsi ORDER BY observed_at DESC LIMIT 1 ) r ON TRUE - WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE - ORDER BY m.is_default DESC, s.current_score DESC + ORDER BY bs.model_id, bs.current_score DESC """; private static final String GEAR_STATS_SQL = """ @@ -121,7 +127,7 @@ public class GroupPolygonService { row.put("modelName", rs.getString("model_name")); row.put("isDefault", rs.getBoolean("is_default")); return row; - }, groupKey, minScore); + }, groupKey, minScore, groupKey); } catch (Exception e) { log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage()); return List.of(); @@ -162,6 +168,7 @@ public class GroupPolygonService { /** * 특정 그룹의 시간별 히스토리. + * sub_cluster_id 포함하여 raw 반환 — 프론트에서 서브클러스터별 독립 center trail 구성. */ public List getGroupHistory(String groupKey, int hours) { return jdbcTemplate.query(GROUP_HISTORY_SQL, this::mapRow, groupKey, String.valueOf(hours)); @@ -192,6 +199,7 @@ public class GroupPolygonService { .groupType(rs.getString("group_type")) .groupKey(rs.getString("group_key")) .groupLabel(rs.getString("group_label")) + .subClusterId(rs.getInt("sub_cluster_id")) .snapshotTime(rs.getString("snapshot_time")) .polygon(polygonObj) .centerLat(rs.getDouble("center_lat")) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..0f9b906 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +.claude/worktrees/ diff --git a/frontend/src/components/korea/CorrelationPanel.tsx b/frontend/src/components/korea/CorrelationPanel.tsx index 66ad9e4..0dfd5be 100644 --- a/frontend/src/components/korea/CorrelationPanel.tsx +++ b/frontend/src/components/korea/CorrelationPanel.tsx @@ -1,5 +1,7 @@ -import { useState } from 'react'; +import { useState, useMemo, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import type { GearCorrelationItem } from '../../services/vesselAnalysis'; +import type { MemberInfo } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import { FONT_MONO } from '../../styles/fonts'; import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants'; @@ -44,80 +46,126 @@ const CorrelationPanel = ({ const [pinnedModelTip, setPinnedModelTip] = useState(null); const activeModelTip = pinnedModelTip ?? hoveredModelTip; - // Compute identity data from groupPolygons - const allGroups = groupPolygons - ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] - : []; - const group = allGroups.find(g => g.groupKey === selectedGearGroup); - const identityVessels = group?.members.filter(m => m.isParent) ?? []; - const identityGear = group?.members.filter(m => !m.isParent) ?? []; + // Card expand state + const [expandedCards, setExpandedCards] = useState>(new Set()); + + // Card ref map for tooltip positioning + const cardRefs = useRef>(new Map()); + const setCardRef = useCallback((model: string, el: HTMLDivElement | null) => { + if (el) cardRefs.current.set(model, el); + else cardRefs.current.delete(model); + }, []); + const toggleCardExpand = (model: string) => { + setExpandedCards(prev => { + const next = new Set(prev); + if (next.has(model)) next.delete(model); else next.add(model); + return next; + }); + }; + + // Identity 목록: 리플레이 활성 시 전체 구간 멤버, 아닐 때 현재 스냅샷 멤버 + const allHistoryMembers = useGearReplayStore(s => s.allHistoryMembers); + const { identityVessels, identityGear } = useMemo(() => { + if (historyActive && allHistoryMembers.length > 0) { + return { + identityVessels: allHistoryMembers.filter(m => m.isParent), + identityGear: allHistoryMembers.filter(m => !m.isParent), + }; + } + if (!groupPolygons || !selectedGearGroup) return { identityVessels: [] as MemberInfo[], identityGear: [] as MemberInfo[] }; + const allGear = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const matches = allGear.filter(g => g.groupKey === selectedGearGroup); + const seen = new Set(); + const members: MemberInfo[] = []; + for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); } } + return { + identityVessels: members.filter(m => m.isParent), + identityGear: members.filter(m => !m.isParent), + }; + }, [historyActive, allHistoryMembers, groupPolygons, selectedGearGroup]); // Suppress unused MODEL_ORDER warning — used for ordering checks void _MODEL_ORDER; // Common card styles + const CARD_WIDTH = 180; const cardStyle: React.CSSProperties = { background: 'rgba(12,24,37,0.95)', borderRadius: 6, - minWidth: 160, - maxWidth: 200, + width: CARD_WIDTH, + minWidth: CARD_WIDTH, flexShrink: 0, border: '1px solid rgba(255,255,255,0.08)', position: 'relative', }; - const cardScrollStyle: React.CSSProperties = { - padding: '6px 8px', - maxHeight: 200, - overflowY: 'auto', + const CARD_COLLAPSED_H = 200; + const CARD_EXPANDED_H = 500; + + const cardFooterStyle: React.CSSProperties = { + display: 'flex', alignItems: 'center', gap: 4, + padding: '4px 8px 6px', + cursor: 'pointer', userSelect: 'none', + borderTop: '1px solid rgba(255,255,255,0.06)', }; - // Model title tooltip hover/click handlers + const getCardBodyStyle = (model: string): React.CSSProperties => ({ + padding: '6px 8px 4px', + maxHeight: expandedCards.has(model) ? CARD_EXPANDED_H : CARD_COLLAPSED_H, + overflowY: 'auto', + transition: 'max-height 0.2s ease', + }); + + // Model title tooltip: hover → show, right-click → pin const handleTipHover = (model: string) => { if (!pinnedModelTip) setHoveredModelTip(model); }; const handleTipLeave = () => { if (!pinnedModelTip) setHoveredModelTip(null); }; - const handleTipClick = (model: string) => { + const handleTipContextMenu = (e: React.MouseEvent, model: string) => { + e.preventDefault(); setPinnedModelTip(prev => prev === model ? null : model); setHoveredModelTip(null); }; - const renderModelTip = (model: string, color: string) => { - if (activeModelTip !== model) return null; - const desc = MODEL_DESC[model]; + // 툴팁은 카드 밖에서 fixed로 렌더 (overflow 영향 안 받음) + const renderFloatingTip = () => { + if (!activeModelTip) return null; + const desc = MODEL_DESC[activeModelTip]; if (!desc) return null; + const el = cardRefs.current.get(activeModelTip); + if (!el) return null; + const rect = el.getBoundingClientRect(); + const color = MODEL_COLORS[activeModelTip] ?? '#94a3b8'; return (
{desc.summary}
{desc.details.map((line, i) => (
{line}
))} - {pinnedModelTip === model && ( + {pinnedModelTip && (
- 클릭하여 닫기 + 우클릭하여 닫기
)}
@@ -142,7 +190,7 @@ const CorrelationPanel = ({ const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName; return (
{ + const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => { const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity'; return (
- {/* 고정: 토글 패널 */} + {/* 고정: 토글 패널 (스크롤 밖) */}
+ {/* 스크롤 영역: 모델 카드들 */} +
+ {/* 이름 기반 카드 (체크 시) */} {enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && ( -
- {renderModelTip('identity', '#f97316')} -
-
handleTipHover('identity')} - onMouseLeave={handleTipLeave} - onClick={() => handleTipClick('identity')} - > - - 이름 기반 -
+
setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}> +
{identityVessels.length > 0 && ( <>
연관 선박 ({identityVessels.length})
@@ -291,13 +333,21 @@ const CorrelationPanel = ({ {identityGear.length > 0 && ( <>
연관 어구 ({identityGear.length})
- {identityGear.slice(0, 12).map(m => renderMemberRow(m, '◆', '#f97316'))} - {identityGear.length > 12 && ( -
+{identityGear.length - 12}개 더
- )} + {identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))} )}
+
toggleCardExpand('identity')} + onMouseEnter={() => handleTipHover('identity')} + onMouseLeave={handleTipLeave} + onContextMenu={(e) => handleTipContextMenu(e, 'identity')} + > + + 이름 기반 + {expandedCards.has('identity') ? '▾' : '▴'} +
)} @@ -309,40 +359,37 @@ const CorrelationPanel = ({ const gears = items.filter(c => c.targetType !== 'VESSEL'); if (vessels.length === 0 && gears.length === 0) return null; return ( -
- {renderModelTip(m.name, color)} -
-
handleTipHover(m.name)} - onMouseLeave={handleTipLeave} - onClick={() => handleTipClick(m.name)} - > - - {m.name}{m.isDefault ? '*' : ''} -
+
setCardRef(m.name, el)} style={{ ...cardStyle, borderColor: `${color}40`, position: 'relative' }}> +
{vessels.length > 0 && ( <>
연관 선박 ({vessels.length})
- {vessels.slice(0, 10).map(c => renderRow(c, color, m.name))} - {vessels.length > 10 && ( -
+{vessels.length - 10}건 더
- )} + {vessels.map(c => renderRow(c, color, m.name))} )} {gears.length > 0 && ( <>
연관 어구 ({gears.length})
- {gears.slice(0, 10).map(c => renderRow(c, color, m.name))} - {gears.length > 10 && ( -
+{gears.length - 10}건 더
- )} + {gears.map(c => renderRow(c, color, m.name))} )}
+
toggleCardExpand(m.name)} + onMouseEnter={() => handleTipHover(m.name)} + onMouseLeave={handleTipLeave} + onContextMenu={(e) => handleTipContextMenu(e, m.name)} + > + + {m.name}{m.isDefault ? '*' : ''} + {expandedCards.has(m.name) ? '▾' : '▴'} +
); })} +
{/* 스크롤 영역 끝 */} + {renderFloatingTip() && createPortal(renderFloatingTip(), document.body)}
); }; diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 2ec108d..3cd16e8 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1,11 +1,82 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { useMap } from 'react-map-gl/maplibre'; -import type { MapLayerMouseEvent } from 'maplibre-gl'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import type { Layer as DeckLayer } from '@deck.gl/core'; import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations, fetchCorrelationTracks } from '../../services/vesselAnalysis'; import type { FleetCompany, GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import { useGearReplayStore } from '../../stores/gearReplayStore'; +import { useFleetClusterDeckLayers } from '../../hooks/useFleetClusterDeckLayers'; +import { useShipDeckStore } from '../../stores/shipDeckStore'; +import type { PickedPolygonFeature } from '../../hooks/useFleetClusterDeckLayers'; +import { useFontScale } from '../../hooks/useFontScale'; +import type { GroupPolygonDto } from '../../services/vesselAnalysis'; + +/** 서브클러스터 center 시계열 (독립 추적용) */ +export interface SubClusterCenter { + subClusterId: number; + path: [number, number][]; // [lon, lat] 시간순 + timestamps: number[]; // epoch ms +} + +/** + * 히스토리를 서브클러스터별로 분리하여: + * 1. frames: 시간별 멤버 합산 프레임 (리플레이 애니메이션용) + * 2. subClusterCenters: 서브클러스터별 독립 center 궤적 (PathLayer용) + */ +function splitAndMergeHistory(history: GroupPolygonDto[]) { + // 시간순 정렬 (오래된 것 먼저) + const sorted = [...history].sort((a, b) => + new Date(a.snapshotTime).getTime() - new Date(b.snapshotTime).getTime(), + ); + + // 1. 서브클러스터별 center 궤적 수집 + const centerMap = new Map(); + for (const h of sorted) { + const sid = h.subClusterId ?? 0; + const entry = centerMap.get(sid) ?? { path: [], timestamps: [] }; + entry.path.push([h.centerLon, h.centerLat]); + entry.timestamps.push(new Date(h.snapshotTime).getTime()); + centerMap.set(sid, entry); + } + const subClusterCenters: SubClusterCenter[] = [...centerMap.entries()].map( + ([subClusterId, data]) => ({ subClusterId, ...data }), + ); + + // 2. 시간별 멤버 합산 프레임 (기존 리플레이 호환) + const byTime = new Map(); + for (const h of sorted) { + const list = byTime.get(h.snapshotTime) ?? []; + list.push(h); + byTime.set(h.snapshotTime, list); + } + + 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 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 }; +} // ── 분리된 모듈 ── import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; @@ -29,10 +100,13 @@ interface Props { onSelectedGearChange?: (data: import('./fleetClusterTypes').SelectedGearGroupData | null) => void; onSelectedFleetChange?: (data: import('./fleetClusterTypes').SelectedFleetData | null) => void; groupPolygons?: UseGroupPolygonsResult; + zoomScale?: number; + onDeckLayersChange?: (layers: DeckLayer[]) => void; } -export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons }: Props) { +export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons, zoomScale = 1, onDeckLayersChange }: Props) { const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS; + const { fontScale } = useFontScale(); // ── 선단/어구 패널 상태 ── const [companies, setCompanies] = useState>(new Map()); @@ -40,6 +114,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const [activeSection, setActiveSection] = useState('fleet'); const toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key); const [hoveredFleetId, setHoveredFleetId] = useState(null); + const [hoveredGearName, setHoveredGearName] = useState(null); const [expandedGearGroup, setExpandedGearGroup] = useState(null); const [selectedGearGroup, setSelectedGearGroup] = useState(null); @@ -60,9 +135,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const historyActive = useGearReplayStore(s => s.historyFrames.length > 0); // ── 맵 + ref ── - const { current: mapRef } = useMap(); - const registeredRef = useRef(false); - const dataRef = useRef<{ shipMap: Map; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom }); // ── 초기 로드 ── useEffect(() => { @@ -78,9 +150,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })), ]); - // 2. 데이터 전처리 - const sorted = history.reverse(); - const filled = fillGapFrames(sorted); + // 2. 서브클러스터별 분리 → 멤버 합산 프레임 + 서브클러스터별 독립 center + const { frames: filled, subClusterCenters } = splitAndMergeHistory(history); const corrData = corrRes.items; const corrTracks = trackRes.vessels; @@ -105,12 +176,36 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS // 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작 const store = useGearReplayStore.getState(); store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels); + // 서브클러스터별 독립 center 궤적 + 전체 구간 고유 멤버 저장 + const seen = new Set(); + const allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[] = []; + for (const f of history) { + for (const m of f.members) { + if (!seen.has(m.mmsi)) { + seen.add(m.mmsi); + allHistoryMembers.push({ mmsi: m.mmsi, name: m.name, isParent: m.isParent }); + } + } + } + useGearReplayStore.setState({ subClusterCenters, allHistoryMembers }); store.play(); }; const closeHistory = useCallback(() => { useGearReplayStore.getState().reset(); setSelectedGearGroup(null); + setCorrelationData([]); + setCorrelationTracks([]); + setEnabledVessels(new Set()); + }, []); + + // ── cnFishing 탭 off (unmount) 시 재생 상태 + deck layers 전체 초기화 ── + useEffect(() => { + return () => { + useGearReplayStore.getState().reset(); + onDeckLayersChange?.([]); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ── @@ -140,141 +235,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS return () => window.removeEventListener('keydown', onKeyDown); }, [historyActive, closeHistory]); - // ── 맵 이벤트 등록 ── - useEffect(() => { - const map = mapRef?.getMap(); - if (!map || registeredRef.current) return; - - const fleetLayers = ['fleet-cluster-fill-layer']; - const gearLayers = ['gear-cluster-fill-layer']; - const allLayers = [...fleetLayers, ...gearLayers]; - const setCursor = (cursor: string) => { map.getCanvas().style.cursor = cursor; }; - - const onFleetEnter = (e: MapLayerMouseEvent) => { - setCursor('pointer'); - const feat = e.features?.[0]; - if (!feat) return; - const cid = feat.properties?.clusterId as number | undefined; - if (cid != null) { - setHoveredFleetId(cid); - setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'fleet', id: cid }); - } - }; - const onFleetLeave = () => { - setCursor(''); - setHoveredFleetId(null); - setHoverTooltip(prev => prev?.type === 'fleet' ? null : prev); - }; - - const handleFleetSelect = (cid: number) => { - const d = dataRef.current; - setExpandedFleet(prev => prev === cid ? null : cid); - setActiveSection('fleet'); - const group = d.groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid); - if (!group || group.members.length === 0) return; - let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const m of group.members) { - if (m.lat < minLat) minLat = m.lat; - if (m.lat > maxLat) maxLat = m.lat; - if (m.lon < minLng) minLng = m.lon; - if (m.lon > maxLng) maxLng = m.lon; - } - if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); - loadHistory(String(cid)); - }; - - const handleGearGroupZoomFromMap = (name: string) => { - const d = dataRef.current; - setSelectedGearGroup(prev => prev === name ? null : name); - setExpandedGearGroup(name); - const isInZone = d.groupPolygons?.gearInZoneGroups.some(g => g.groupKey === name); - setActiveSection(isInZone ? 'inZone' : 'outZone'); - requestAnimationFrame(() => { - setTimeout(() => { - document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, 50); - }); - const allGroups = d.groupPolygons ? [...d.groupPolygons.gearInZoneGroups, ...d.groupPolygons.gearOutZoneGroups] : []; - const group = allGroups.find(g => g.groupKey === name); - if (!group || group.members.length === 0) return; - let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const m of group.members) { - if (m.lat < minLat) minLat = m.lat; - if (m.lat > maxLat) maxLat = m.lat; - if (m.lon < minLng) minLng = m.lon; - if (m.lon > maxLng) maxLng = m.lon; - } - if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); - loadHistory(name); - }; - - const onPolygonClick = (e: MapLayerMouseEvent) => { - const features = map.queryRenderedFeatures(e.point, { layers: allLayers }); - if (features.length === 0) return; - const seen = new Set(); - const candidates: PickerCandidate[] = []; - for (const f of features) { - const cid = f.properties?.clusterId as number | undefined; - const gearName = f.properties?.name as string | undefined; - if (cid != null) { - const key = `fleet-${cid}`; - if (seen.has(key)) continue; - seen.add(key); - const d = dataRef.current; - const g = d.groupPolygons?.fleetGroups.find(x => Number(x.groupKey) === cid); - candidates.push({ name: g?.groupLabel ?? `선단 #${cid}`, count: g?.memberCount ?? 0, inZone: false, isFleet: true, clusterId: cid }); - } else if (gearName) { - if (seen.has(gearName)) continue; - seen.add(gearName); - candidates.push({ name: gearName, count: f.properties?.gearCount ?? 0, inZone: f.properties?.inZone === 1, isFleet: false }); - } - } - if (candidates.length === 1) { - const c = candidates[0]; - if (c.isFleet && c.clusterId != null) handleFleetSelect(c.clusterId); - else handleGearGroupZoomFromMap(c.name); - } else if (candidates.length > 1) { - setGearPickerPopup({ lng: e.lngLat.lng, lat: e.lngLat.lat, candidates }); - } - }; - - const onGearEnter = (e: MapLayerMouseEvent) => { - setCursor('pointer'); - const feat = e.features?.[0]; - if (!feat) return; - const name = feat.properties?.name as string | undefined; - if (name) setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'gear', id: name }); - }; - const onGearLeave = () => { - setCursor(''); - setHoverTooltip(prev => prev?.type === 'gear' ? null : prev); - }; - - const register = () => { - const ready = allLayers.every(id => map.getLayer(id)); - if (!ready) return; - registeredRef.current = true; - for (const id of fleetLayers) { - map.on('mouseenter', id, onFleetEnter); - map.on('mouseleave', id, onFleetLeave); - map.on('click', id, onPolygonClick); - } - for (const id of gearLayers) { - map.on('mouseenter', id, onGearEnter); - map.on('mouseleave', id, onGearLeave); - map.on('click', id, onPolygonClick); - } - }; - - register(); - if (!registeredRef.current) { - const interval = setInterval(() => { - register(); - if (registeredRef.current) clearInterval(interval); - }, 500); - return () => clearInterval(interval); - } - }, [mapRef]); + // 맵 이벤트 → deck.gl 콜백으로 전환 완료 (handleDeckPolygonClick/Hover) // ── ships map ── const shipMap = useMemo(() => { @@ -283,7 +244,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS return m; }, [ships]); - dataRef.current = { shipMap, groupPolygons, onFleetZoom }; + // ── 부모 콜백 동기화: 어구 그룹 선택 ── useEffect(() => { @@ -293,11 +254,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS return; } const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; - const group = allGroups.find(g => g.groupKey === selectedGearGroup); - if (!group) { onSelectedGearChange?.(null); return; } - const parent = group.members.find(m => m.isParent); - const gears = group.members.filter(m => !m.isParent); - const toShip = (m: typeof group.members[0]): Ship => ({ + const matches = allGroups.filter(g => g.groupKey === selectedGearGroup); + if (matches.length === 0) { onSelectedGearChange?.(null); return; } + // 서브클러스터 멤버 합산 + const seen = new Set(); + const allMembers: typeof matches[0]['members'] = []; + for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } } + const parent = allMembers.find(m => m.isParent); + const gears = allMembers.filter(m => !m.isParent); + const toShip = (m: typeof allMembers[0]): Ship => ({ mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon, heading: m.cog, speed: m.sog, course: m.cog, category: 'fishing', lastSeen: Date.now(), @@ -360,6 +325,97 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS enabledModels, enabledVessels, hoveredMmsi, }); + // ── deck.gl 이벤트 콜백 ── + const handleDeckPolygonClick = useCallback((features: PickedPolygonFeature[], coordinate: [number, number]) => { + if (features.length === 0) return; + if (features.length === 1) { + const c = features[0]; + if (c.type === 'fleet' && c.clusterId != null) { + setExpandedFleet(prev => prev === c.clusterId ? null : c.clusterId!); + setActiveSection('fleet'); + const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === c.clusterId); + if (group && group.members.length > 0) { + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const m of group.members) { + if (m.lat < minLat) minLat = m.lat; + if (m.lat > maxLat) maxLat = m.lat; + if (m.lon < minLng) minLng = m.lon; + if (m.lon > maxLng) maxLng = m.lon; + } + if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + } + loadHistory(String(c.clusterId)); + } else if (c.type === 'gear' && c.name) { + setSelectedGearGroup(prev => prev === c.name ? null : c.name!); + setExpandedGearGroup(c.name); + const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === c.name); + setActiveSection(isInZone ? 'inZone' : 'outZone'); + const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; + const group = allGroups.find(g => g.groupKey === c.name); + if (group && group.members.length > 0) { + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const m of group.members) { + if (m.lat < minLat) minLat = m.lat; + if (m.lat > maxLat) maxLat = m.lat; + if (m.lon < minLng) minLng = m.lon; + if (m.lon > maxLng) maxLng = m.lon; + } + if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + } + loadHistory(c.name); + } + } else { + // 겹친 폴리곤 → 피커 팝업 + const candidates: PickerCandidate[] = features.map(f => { + if (f.type === 'fleet') { + const g = groupPolygons?.fleetGroups.find(x => Number(x.groupKey) === f.clusterId); + return { name: g?.groupLabel ?? `선단 #${f.clusterId}`, count: g?.memberCount ?? 0, inZone: false, isFleet: true, clusterId: f.clusterId }; + } + return { name: f.name ?? '', count: f.gearCount ?? 0, inZone: !!f.inZone, isFleet: false }; + }); + setGearPickerPopup({ lng: coordinate[0], lat: coordinate[1], candidates }); + } + }, [groupPolygons, onFleetZoom, loadHistory]); + + const handleDeckPolygonHover = useCallback((info: { lng: number; lat: number; type: 'fleet' | 'gear'; id: string | number } | null) => { + if (info) { + if (info.type === 'fleet') { + setHoveredFleetId(info.id as number); + setHoveredGearName(null); + } else { + setHoveredFleetId(null); + setHoveredGearName(info.id as string); + } + setHoverTooltip({ lng: info.lng, lat: info.lat, type: info.type, id: info.id }); + } else { + setHoveredFleetId(null); + setHoveredGearName(null); + setHoverTooltip(null); + } + }, []); + + // ── deck.gl 레이어 빌드 ── + const focusMode = useGearReplayStore(s => s.focusMode); + const zoomLevel = useShipDeckStore(s => s.zoomLevel); + const fleetDeckLayers = useFleetClusterDeckLayers(geo, { + selectedGearGroup, + hoveredMmsi, + hoveredGearGroup: hoveredGearName, + enabledModels, + historyActive, + hasCorrelationTracks: correlationTracks.length > 0, + zoomScale, + zoomLevel, + fontScale: fontScale.analysis, + focusMode, + onPolygonClick: handleDeckPolygonClick, + onPolygonHover: handleDeckPolygonHover, + }); + + useEffect(() => { + onDeckLayersChange?.(fleetDeckLayers); + }, [fleetDeckLayers, onDeckLayersChange]); + // ── 어구 그룹 데이터 ── const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? []; const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? []; @@ -421,26 +477,21 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const selectedGroupMemberCount = useMemo(() => { if (!selectedGearGroup || !groupPolygons) return 0; const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; - return allGroups.find(g => g.groupKey === selectedGearGroup)?.memberCount ?? 0; + return allGroups.filter(g => g.groupKey === selectedGearGroup).reduce((sum, g) => sum + g.memberCount, 0); }, [selectedGearGroup, groupPolygons]); return ( <> {/* ── 맵 레이어 ── */} 0} onPickerHover={setPickerHoveredGroup} onPickerSelect={handlePickerSelect} onPickerClose={() => { setGearPickerPopup(null); setPickerHoveredGroup(null); }} diff --git a/frontend/src/components/korea/FleetClusterMapLayers.tsx b/frontend/src/components/korea/FleetClusterMapLayers.tsx index b73f5ab..9747b62 100644 --- a/frontend/src/components/korea/FleetClusterMapLayers.tsx +++ b/frontend/src/components/korea/FleetClusterMapLayers.tsx @@ -1,4 +1,4 @@ -import { Source, Layer, Popup } from 'react-map-gl/maplibre'; +import { Popup } from 'react-map-gl/maplibre'; import { FONT_MONO } from '../../styles/fonts'; import type { FleetCompany } from '../../services/vesselAnalysis'; import type { VesselAnalysisDto } from '../../types'; @@ -8,16 +8,10 @@ import type { GearPickerPopupState, PickerCandidate, } from './fleetClusterTypes'; -import type { FleetClusterGeoJsonResult } from './useFleetClusterGeoJson'; -import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants'; interface FleetClusterMapLayersProps { - geo: FleetClusterGeoJsonResult; selectedGearGroup: string | null; - hoveredMmsi: string | null; - enabledModels: Set; expandedFleet: number | null; - historyActive: boolean; // Popup/tooltip state hoverTooltip: HoverTooltipState | null; gearPickerPopup: GearPickerPopupState | null; @@ -26,199 +20,33 @@ interface FleetClusterMapLayersProps { groupPolygons: UseGroupPolygonsResult | undefined; companies: Map; analysisMap: Map; - // Whether any correlation trails exist (drives conditional render) - hasCorrelationTracks: boolean; // Callbacks onPickerHover: (group: string | null) => void; onPickerSelect: (candidate: PickerCandidate) => void; onPickerClose: () => void; } +/** + * FleetCluster overlay popups/tooltips. + * All MapLibre Source/Layer rendering has been moved to useFleetClusterDeckLayers (deck.gl). + * This component only renders MapLibre Popup-based overlays (tooltips, picker). + */ const FleetClusterMapLayers = ({ - geo, selectedGearGroup, - hoveredMmsi, - enabledModels, expandedFleet, - historyActive, hoverTooltip, gearPickerPopup, pickerHoveredGroup, groupPolygons, companies, analysisMap, - hasCorrelationTracks, onPickerHover, onPickerSelect, onPickerClose, }: FleetClusterMapLayersProps) => { - const { - fleetPolygonGeoJSON, - lineGeoJSON, - hoveredGeoJSON, - gearClusterGeoJson, - memberMarkersGeoJson, - pickerHighlightGeoJson, - operationalPolygons, - correlationVesselGeoJson, - correlationTrailGeoJson, - modelBadgesGeoJson, - hoverHighlightGeoJson, - hoverHighlightTrailGeoJson, - } = geo; return ( <> - {/* 선단 폴리곤 레이어 */} - - - - - - {/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */} - - - - - {/* 호버 하이라이트 (별도 Source) */} - - - - - {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} - {selectedGearGroup && enabledModels.has('identity') && !historyActive && (() => { - const allGroups = groupPolygons - ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] - : []; - const group = allGroups.find(g => g.groupKey === selectedGearGroup); - if (!group?.polygon) return null; - const hlGeoJson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: [{ - type: 'Feature', - properties: {}, - geometry: group.polygon, - }], - }; - return ( - - - - - ); - })()} - - {/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */} - {selectedGearGroup && !historyActive && operationalPolygons.map(op => ( - - - - - ))} - - {/* 비허가 어구 클러스터 폴리곤 */} - - - - - - {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (deck.gl 레이어로 대체) */} - - - - - {/* 어구 picker 호버 하이라이트 */} - - - - - {/* 어구 다중 선택 팝업 */} {gearPickerPopup && ( - {c.isFleet ? '⚓ ' : ''}{c.name} + {c.isFleet ? '\u2693 ' : ''}{c.name} ({c.count}{c.isFleet ? '척' : '개'})
))} @@ -272,7 +100,7 @@ const FleetClusterMapLayers = ({ const role = dto?.algorithms.fleetRole.role ?? m.role; return (
- {role === 'LEADER' ? '★' : '·'} {m.name || m.mmsi} {m.sog.toFixed(1)}kt + {role === 'LEADER' ? '\u2605' : '\u00B7'} {m.name || m.mmsi} {m.sog.toFixed(1)}kt
); })} @@ -286,10 +114,13 @@ const FleetClusterMapLayers = ({ const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; - const group = allGroups.find(g => g.groupKey === name); - if (!group) return null; - const parentMember = group.members.find(m => m.isParent); - const gearMembers = group.members.filter(m => !m.isParent); + const matches = allGroups.filter(g => g.groupKey === name); + if (matches.length === 0) return null; + const seen = new Set(); + const mergedMembers: typeof matches[0]['members'] = []; + for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); mergedMembers.push(m); } } + const parentMember = mergedMembers.find(m => m.isParent); + const gearMembers = mergedMembers.filter(m => !m.isParent); return ( - - - )} - {selectedGearGroup && !historyActive && ( - - - - - )} - - {/* ── 모델 배지 (비재생 모드) ── */} - {selectedGearGroup && !historyActive && ( - - {MODEL_ORDER.map((model, i) => ( - enabledModels.has(model) ? ( - - ) : null - ))} - - )} - - {/* ── 호버 하이라이트 (비재생 모드) ── */} - {hoveredMmsi && !historyActive && ( - - - - - )} - {hoveredMmsi && !historyActive && ( - - - - )} ); }; diff --git a/frontend/src/components/korea/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx index 51236cf..04662f7 100644 --- a/frontend/src/components/korea/HistoryReplayController.tsx +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -13,6 +13,7 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont const frameCount = useGearReplayStore(s => s.historyFrames.length); const showTrails = useGearReplayStore(s => s.showTrails); const showLabels = useGearReplayStore(s => s.showLabels); + const focusMode = useGearReplayStore(s => s.focusMode); const progressBarRef = useRef(null); const progressIndicatorRef = useRef(null); @@ -46,11 +47,14 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont return (
{/* 프로그레스 바 */}
@@ -99,6 +103,11 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시"> 이름 + | 일치율