From 6f4044ce394f375b29b0e9ccbddc3a7fb18a4618 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 31 Mar 2026 15:43:42 +0900 Subject: [PATCH 01/10] =?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="이름 표시"> 이름 + | 일치율 + onEnabledModelsChange(prev => { const next = new Set(prev); - if (next.has(m.name)) next.delete(m.name); else next.add(m.name); + if (next.has(mn)) next.delete(mn); else next.add(mn); return next; })} - style={{ accentColor: color, width: 11, height: 11 }} title={m.name} /> - - {m.name}{m.isDefault ? '*' : ''} - {vc}⛴{gc}◆ + style={{ accentColor: color, width: 11, height: 11 }} title={mn} /> + + {mn}{am?.isDefault ? '*' : ''} + {hasData ? `${vc}⛴${gc}◆` : '—'} ); })} 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 | 일치율 { - const { startTime, endTime } = store.getState(); - const progress = Number(e.target.value) / 1000; - store.getState().pause(); - store.getState().seek(startTime + progress * (endTime - startTime)); - }} - style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} - title="히스토리 타임라인" aria-label="히스토리 타임라인" /> - {frameCount}건 - -
- - {/* 컨트롤 행 2: 표시 옵션 */} -
+ style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'} + --:-- + | + style={showTrails ? btnActiveStyle : btnStyle} title="항적">항적 + style={showLabels ? btnActiveStyle : btnStyle} title="이름">이름 - | + style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle} + title="집중 모드">집중 + | + + + | + + | 일치율 - { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }} + style={{ background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO, padding: '1px 4px', cursor: 'pointer' }} + title="일치율 필터" aria-label="일치율 필터"> @@ -130,6 +484,15 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont + + + {frameCount} + {has6hData && <> / {frameCount6h}} 건 + +
); diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index 503de03..010fd8d 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -71,6 +71,14 @@ export function useGearReplayLayers( const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails); const showTrails = useGearReplayStore(s => s.showTrails); const showLabels = useGearReplayStore(s => s.showLabels); + const show1hPolygon = useGearReplayStore(s => s.show1hPolygon); + const show6hPolygon = useGearReplayStore(s => s.show6hPolygon); + const historyFrames6h = useGearReplayStore(s => s.historyFrames6h); + const memberTripsData6h = useGearReplayStore(s => s.memberTripsData6h); + const centerTrailSegments6h = useGearReplayStore(s => s.centerTrailSegments6h); + const centerDotsPositions6h = useGearReplayStore(s => s.centerDotsPositions6h); + const subClusterCenters6h = useGearReplayStore(s => s.subClusterCenters6h); + const pinnedMmsis = useGearReplayStore(s => s.pinnedMmsis); const { fontScale } = useFontScale(); const fs = fontScale.analysis; const zoomLevel = useShipDeckStore(s => s.zoomLevel); @@ -158,14 +166,50 @@ export function useGearReplayLayers( } } + // ── 6h 센터 트레일 (정적, frameIdx와 무관) ─────────────────────────── + if (state.show6hPolygon) { + const hasSub6h = subClusterCenters6h.length > 0 && subClusterCenters6h.some(sc => sc.subClusterId > 0); + if (hasSub6h) { + for (const sc of subClusterCenters6h) { + if (sc.subClusterId === 0) continue; + if (sc.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-6h-sub-center-${sc.subClusterId}`, + data: [{ path: sc.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [147, 197, 253, 120] as [number, number, number, number], + widthMinPixels: 1.5, + })); + } + } else { + for (let i = 0; i < centerTrailSegments6h.length; i++) { + const seg = centerTrailSegments6h[i]; + if (seg.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-6h-center-trail-${i}`, + data: [{ path: seg.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [147, 197, 253, seg.isInterpolated ? 80 : 120] as [number, number, number, number], + widthMinPixels: 1.5, + })); + } + if (centerDotsPositions6h.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-6h-center-dots', + data: centerDotsPositions6h, + getPosition: (d: [number, number]) => d, + getFillColor: [147, 197, 253, 120] as [number, number, number, number], + getRadius: 80, + radiusUnits: 'meters', + radiusMinPixels: 2, + })); + } + } + } + // ── Dynamic layers (depend on currentTime) ──────────────────────────── - if (frameIdx < 0) { - // No valid frame at this time — only show static layers - replayLayerRef.current = layers; - requestRender(); - return; - } + if (frameIdx >= 0) { const frame = state.historyFrames[frameIdx]; const isStale = !!frame._longGap || !!frame._interp; @@ -484,6 +528,81 @@ export function useGearReplayLayers( } } + // 7b. Pinned highlight (툴팁 고정 시 해당 MMSI 강조) + if (state.pinnedMmsis.size > 0) { + const pinnedPositions: { position: [number, number] }[] = []; + for (const m of members) { + if (state.pinnedMmsis.has(m.mmsi)) pinnedPositions.push({ position: [m.lon, m.lat] }); + } + for (const c of corrPositions) { + if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] }); + } + if (pinnedPositions.length > 0) { + // glow + layers.push(new ScatterplotLayer({ + id: 'replay-pinned-glow', + data: pinnedPositions, + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [255, 255, 255, 40], + getRadius: 350, + radiusUnits: 'meters', + radiusMinPixels: 12, + })); + // ring + layers.push(new ScatterplotLayer({ + id: 'replay-pinned-ring', + data: pinnedPositions, + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [0, 0, 0, 0], + getRadius: 200, + radiusUnits: 'meters', + radiusMinPixels: 6, + stroked: true, + getLineColor: [255, 255, 255, 200], + lineWidthMinPixels: 1.5, + })); + } + + // pinned trails (correlation tracks) + const relTime = ct - st; + for (const trip of correlationTripsData) { + if (!state.pinnedMmsis.has(trip.id)) continue; + let clipIdx = trip.timestamps.length; + for (let i = 0; i < trip.timestamps.length; i++) { + if (trip.timestamps[i] > relTime) { clipIdx = i; break; } + } + const clippedPath = trip.path.slice(0, clipIdx); + if (clippedPath.length >= 2) { + layers.push(new PathLayer({ + id: `replay-pinned-trail-${trip.id}`, + data: [{ path: clippedPath }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [255, 255, 255, 150], + widthMinPixels: 2.5, + })); + } + } + + // pinned member trails (identity tracks) + for (const trip of memberTripsData) { + if (!state.pinnedMmsis.has(trip.id)) continue; + let clipIdx = trip.timestamps.length; + for (let i = 0; i < trip.timestamps.length; i++) { + if (trip.timestamps[i] > relTime) { clipIdx = i; break; } + } + const clippedPath = trip.path.slice(0, clipIdx); + if (clippedPath.length >= 2) { + layers.push(new PathLayer({ + id: `replay-pinned-mtrail-${trip.id}`, + data: [{ path: clippedPath }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [255, 200, 60, 180], + widthMinPixels: 2.5, + })); + } + } + } + // 8. Operational polygons (서브클러스터별 분리 — subClusterId 기반) for (const [mn, items] of correlationByModel) { if (!enabledModels.has(mn)) continue; @@ -654,24 +773,27 @@ export function useGearReplayLayers( [167, 139, 250, 255], ]; - for (const sf of subFrames) { - const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId); - const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); - const poly = buildInterpPolygon(sfPts); - if (!poly) continue; + // ── 1h 폴리곤 (진한색, 실선) ── + if (state.show1hPolygon) { + for (const sf of subFrames) { + const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId); + const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); + const poly = buildInterpPolygon(sfPts); + if (!poly) continue; - const ci = sf.subClusterId % SUB_POLY_COLORS.length; - layers.push(new PolygonLayer({ - id: `replay-identity-polygon-sub${sf.subClusterId}`, - data: [{ polygon: poly.coordinates }], - getPolygon: (d: { polygon: number[][][] }) => d.polygon, - getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci], - getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci], - getLineWidth: isStale ? 1 : 2, - lineWidthMinPixels: 1, - filled: true, - stroked: true, - })); + const ci = sf.subClusterId % SUB_POLY_COLORS.length; + layers.push(new PolygonLayer({ + id: `replay-identity-polygon-1h-sub${sf.subClusterId}`, + data: [{ polygon: poly.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci], + getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci], + getLineWidth: isStale ? 1 : 2, + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } } // TripsLayer (멤버 트레일) @@ -717,13 +839,126 @@ export function useGearReplayLayers( })); } + } // end if (frameIdx >= 0) + + // ══ 6h Identity 레이어 (독립 — 1h/모델과 무관) ══ + if (state.show6hPolygon && state.historyFrames6h.length > 0) { + const { index: frameIdx6h } = findFrameAtTime(state.frameTimes6h, ct, 0); + if (frameIdx6h >= 0) { + const frame6h = state.historyFrames6h[frameIdx6h]; + const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }]; + const members6h = interpolateMemberPositions(state.historyFrames6h, frameIdx6h, ct); + + // 6h 폴리곤 + for (const sf of subFrames6h) { + const sfMembers = interpolateSubFrameMembers(state.historyFrames6h, frameIdx6h, ct, sf.subClusterId); + const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); + const poly = buildInterpPolygon(sfPts); + if (!poly) continue; + layers.push(new PolygonLayer({ + id: `replay-6h-polygon-sub${sf.subClusterId}`, + data: [{ polygon: poly.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: [147, 197, 253, 25] as [number, number, number, number], + getLineColor: [147, 197, 253, 160] as [number, number, number, number], + getLineWidth: 1, + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } + + // 6h 멤버 아이콘 + if (members6h.length > 0) { + layers.push(new IconLayer({ + id: 'replay-6h-members', + data: members6h, + getPosition: d => [d.lon, d.lat], + getIcon: d => d.isGear ? SHIP_ICON_MAPPING['gear-diamond'] : SHIP_ICON_MAPPING['ship-triangle'], + getSize: d => d.isParent ? 24 : d.isGear ? 14 : 18, + getAngle: d => d.isGear ? 0 : -(d.cog || 0), + getColor: d => { + if (d.stale) return [100, 116, 139, 150]; + return [147, 197, 253, 200]; + }, + sizeUnits: 'pixels', + billboard: false, + })); + + // 6h 멤버 라벨 + if (showLabels) { + const clustered6h = clusterLabels(members6h, d => [d.lon, d.lat], zoomLevel); + layers.push(new TextLayer({ + id: 'replay-6h-member-labels', + data: clustered6h, + getPosition: d => [d.lon, d.lat], + getText: d => { + const prefix = d.isParent ? '\u2605 ' : ''; + return prefix + (d.name || d.mmsi); + }, + getColor: [147, 197, 253, 230] as [number, number, number, number], + getSize: 10 * fs, + getPixelOffset: [0, 14], + background: true, + getBackgroundColor: [0, 0, 0, 200] as [number, number, number, number], + backgroundPadding: [2, 1], + fontFamily: '"Fira Code Variable", monospace', + })); + } + } + + // 6h TripsLayer (항적 애니메이션) + if (memberTripsData6h.length > 0) { + layers.push(new TripsLayer({ + id: 'replay-6h-identity-trails', + data: memberTripsData6h, + getPath: d => d.path, + getTimestamps: d => d.timestamps, + getColor: [147, 197, 253, 180] as [number, number, number, number], + widthMinPixels: 2, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + + // 6h 센터 포인트 (서브클러스터별 보간) + for (const sf of subFrames6h) { + const nextFrame6h = frameIdx6h < state.historyFrames6h.length - 1 ? state.historyFrames6h[frameIdx6h + 1] : null; + const nextSf = nextFrame6h?.subFrames?.find(s => s.subClusterId === sf.subClusterId); + let cx = sf.centerLon, cy = sf.centerLat; + if (nextSf && nextFrame6h) { + const t0 = new Date(frame6h.snapshotTime).getTime(); + const t1 = new Date(nextFrame6h.snapshotTime).getTime(); + const r = t1 > t0 ? Math.max(0, Math.min(1, (ct - t0) / (t1 - t0))) : 0; + cx = sf.centerLon + (nextSf.centerLon - sf.centerLon) * r; + cy = sf.centerLat + (nextSf.centerLat - sf.centerLat) * r; + } + layers.push(new ScatterplotLayer({ + id: `replay-6h-center-sub${sf.subClusterId}`, + data: [{ position: [cx, cy] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [147, 197, 253, 200] as [number, number, number, number], + getRadius: 150, + radiusUnits: 'meters', + radiusMinPixels: 5, + stroked: true, + getLineColor: [255, 255, 255, 200] as [number, number, number, number], + lineWidthMinPixels: 1.5, + })); + } + } + } + replayLayerRef.current = layers; requestRender(); }, [ - historyFrames, memberTripsData, correlationTripsData, + historyFrames, historyFrames6h, memberTripsData, memberTripsData6h, correlationTripsData, centerTrailSegments, centerDotsPositions, + centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h, enabledModels, enabledVessels, hoveredMmsi, correlationByModel, - modelCenterTrails, subClusterCenters, showTrails, showLabels, fs, zoomLevel, + modelCenterTrails, subClusterCenters, showTrails, showLabels, + show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel, replayLayerRef, requestRender, ]); @@ -778,8 +1013,20 @@ export function useGearReplayLayers( }, ); + // 1h/6h 토글 + pinnedMmsis 변경 시 즉시 렌더 + const unsubPolygonToggle = useGearReplayStore.subscribe( + s => [s.show1hPolygon, s.show6hPolygon] as const, + () => { debugLoggedRef.current = false; renderFrame(); }, + ); + const unsubPinned = useGearReplayStore.subscribe( + s => s.pinnedMmsis, + () => renderFrame(), + ); + return () => { unsub(); + unsubPolygonToggle(); + unsubPinned(); if (pendingRafId) cancelAnimationFrame(pendingRafId); }; }, [historyFrames, renderFrame]); diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index 57350dc..a1c470c 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -73,6 +73,7 @@ export interface GroupPolygonDto { zoneName: string | null; members: MemberInfo[]; color: string; + resolution?: '1h' | '6h'; } export async function fetchGroupPolygons(): Promise { diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts index 94edf83..60a70c9 100644 --- a/frontend/src/stores/gearReplayStore.ts +++ b/frontend/src/stores/gearReplayStore.ts @@ -54,12 +54,21 @@ interface GearReplayState { endTime: number; playbackSpeed: number; - // Source data + // Source data (1h = primary identity polygon) historyFrames: HistoryFrame[]; frameTimes: number[]; selectedGroupKey: string | null; rawCorrelationTracks: CorrelationVesselTrack[]; + // 6h identity (독립 레이어 — 1h/모델과 무관) + historyFrames6h: HistoryFrame[]; + frameTimes6h: number[]; + memberTripsData6h: TripsLayerDatum[]; + centerTrailSegments6h: CenterTrailSegment[]; + centerDotsPositions6h: [number, number][]; + subClusterCenters6h: { subClusterId: number; path: [number, number][]; timestamps: number[] }[]; + snapshotRanges6h: number[]; + // Pre-computed layer data memberTripsData: TripsLayerDatum[]; correlationTripsData: TripsLayerDatum[]; @@ -79,6 +88,12 @@ interface GearReplayState { showTrails: boolean; showLabels: boolean; focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김 + show1hPolygon: boolean; // 1h 폴리곤 표시 (진한색/실선) + show6hPolygon: boolean; // 6h 폴리곤 표시 (옅은색/점선) + abLoop: boolean; // A-B 구간 반복 활성화 + abA: number; // A 지점 (epoch ms, 0 = 미설정) + abB: number; // B 지점 (epoch ms, 0 = 미설정) + pinnedMmsis: Set; // 툴팁 고정 시 강조할 MMSI 세트 // Actions loadHistory: ( @@ -87,6 +102,7 @@ interface GearReplayState { corrData: GearCorrelationItem[], enabledModels: Set, enabledVessels: Set, + frames6h?: HistoryFrame[], ) => void; play: () => void; pause: () => void; @@ -98,6 +114,12 @@ interface GearReplayState { setShowTrails: (show: boolean) => void; setShowLabels: (show: boolean) => void; setFocusMode: (focus: boolean) => void; + setShow1hPolygon: (show: boolean) => void; + setShow6hPolygon: (show: boolean) => void; + setAbLoop: (on: boolean) => void; + setAbA: (t: number) => void; + setAbB: (t: number) => void; + setPinnedMmsis: (mmsis: Set) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; reset: () => void; } @@ -118,7 +140,20 @@ export const useGearReplayStore = create()( const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed; - if (newTime >= state.endTime) { + // A-B 구간 반복 + if (state.abLoop && state.abA > 0 && state.abB > state.abA) { + if (newTime >= state.abB) { + set({ currentTime: state.abA }); + animationFrameId = requestAnimationFrame(animate); + return; + } + // A 이전이면 A로 점프 + if (newTime < state.abA) { + set({ currentTime: state.abA }); + animationFrameId = requestAnimationFrame(animate); + return; + } + } else if (newTime >= state.endTime) { set({ currentTime: state.startTime }); animationFrameId = requestAnimationFrame(animate); return; @@ -141,6 +176,13 @@ export const useGearReplayStore = create()( frameTimes: [], selectedGroupKey: null, rawCorrelationTracks: [], + historyFrames6h: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], // Pre-computed layer data memberTripsData: [], @@ -159,20 +201,33 @@ export const useGearReplayStore = create()( showTrails: true, showLabels: true, focusMode: false, + show1hPolygon: true, + show6hPolygon: false, + abLoop: false, + abA: 0, + abB: 0, + pinnedMmsis: new Set(), correlationByModel: new Map(), // ── Actions ──────────────────────────────────────────────── - loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => { + loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => { const startTime = Date.now() - 12 * 60 * 60 * 1000; const endTime = Date.now(); const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); + const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime()); const memberTrips = buildMemberTripsData(frames, startTime); const corrTrips = buildCorrelationTripsData(corrTracks, startTime); const { segments, dots } = buildCenterTrailData(frames); const ranges = buildSnapshotRanges(frames, startTime, endTime); + // 6h 전처리 (동일한 빌드 함수) + const f6h = frames6h ?? []; + const memberTrips6h = f6h.length > 0 ? buildMemberTripsData(f6h, startTime) : []; + const { segments: seg6h, dots: dots6h } = f6h.length > 0 ? buildCenterTrailData(f6h) : { segments: [], dots: [] }; + const ranges6h = f6h.length > 0 ? buildSnapshotRanges(f6h, startTime, endTime) : []; + const byModel = new Map(); for (const c of corrData) { const list = byModel.get(c.modelName) ?? []; @@ -184,7 +239,13 @@ export const useGearReplayStore = create()( set({ historyFrames: frames, + historyFrames6h: f6h, frameTimes, + frameTimes6h, + memberTripsData6h: memberTrips6h, + centerTrailSegments6h: seg6h, + centerDotsPositions6h: dots6h, + snapshotRanges6h: ranges6h, startTime, endTime, currentTime: startTime, @@ -209,9 +270,9 @@ export const useGearReplayStore = create()( lastFrameTime = null; if (state.currentTime >= state.endTime) { - set({ isPlaying: true, currentTime: state.startTime }); + set({ isPlaying: true, currentTime: state.startTime, pinnedMmsis: new Set() }); } else { - set({ isPlaying: true }); + set({ isPlaying: true, pinnedMmsis: new Set() }); } animationFrameId = requestAnimationFrame(animate); @@ -247,6 +308,21 @@ export const useGearReplayStore = create()( setShowTrails: (show) => set({ showTrails: show }), setShowLabels: (show) => set({ showLabels: show }), setFocusMode: (focus) => set({ focusMode: focus }), + setShow1hPolygon: (show) => set({ show1hPolygon: show }), + setShow6hPolygon: (show) => set({ show6hPolygon: show }), + setAbLoop: (on) => { + const { startTime, endTime } = get(); + if (on && startTime > 0) { + // 기본 A-B: 전체 구간의 마지막 4시간 + const dur = endTime - startTime; + set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime }); + } else { + set({ abLoop: false, abA: 0, abB: 0 }); + } + }, + setAbA: (t) => set({ abA: t }), + setAbB: (t) => set({ abB: t }), + setPinnedMmsis: (mmsis) => set({ pinnedMmsis: mmsis }), updateCorrelation: (corrData, corrTracks) => { const state = get(); @@ -284,7 +360,14 @@ export const useGearReplayStore = create()( endTime: 0, playbackSpeed: 1, historyFrames: [], + historyFrames6h: [], frameTimes: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], selectedGroupKey: null, rawCorrelationTracks: [], memberTripsData: [], @@ -301,6 +384,12 @@ export const useGearReplayStore = create()( showTrails: true, showLabels: true, focusMode: false, + show1hPolygon: true, + show6hPolygon: false, + abLoop: false, + abA: 0, + abB: 0, + pinnedMmsis: new Set(), correlationByModel: new Map(), }); }, diff --git a/prediction/algorithms/gear_correlation.py b/prediction/algorithms/gear_correlation.py index e690c83..8de28e6 100644 --- a/prediction/algorithms/gear_correlation.py +++ b/prediction/algorithms/gear_correlation.py @@ -18,6 +18,8 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional +from algorithms.polygon_builder import _get_time_bucket_age + logger = logging.getLogger(__name__) @@ -479,7 +481,7 @@ def _compute_gear_active_ratio( gear_members: list[dict], all_positions: dict[str, dict], now: datetime, - stale_sec: float = 21600, + stale_sec: float = 3600, ) -> float: """어구 그룹의 활성 멤버 비율.""" if not gear_members: @@ -567,10 +569,23 @@ def run_gear_correlation( if not members: continue - # 그룹 중심 + 반경 - center_lat = sum(m['lat'] for m in members) / len(members) - center_lon = sum(m['lon'] for m in members) / len(members) - group_radius = _compute_group_radius(members) + # 1h 활성 멤버 필터 (center/radius 계산용) + display_members = [ + m for m in members + if _get_time_bucket_age(m.get('mmsi'), all_positions, now) <= 3600 + ] + # fallback: < 2이면 time_bucket 최신 2개 유지 + if len(display_members) < 2 and len(members) >= 2: + display_members = sorted( + members, + key=lambda m: _get_time_bucket_age(m.get('mmsi'), all_positions, now), + )[:2] + active_members = display_members if len(display_members) >= 2 else members + + # 그룹 중심 + 반경 (1h 활성 멤버 기반) + center_lat = sum(m['lat'] for m in active_members) / len(active_members) + center_lon = sum(m['lon'] for m in active_members) / len(active_members) + group_radius = _compute_group_radius(active_members) # 어구 활성도 active_ratio = _compute_gear_active_ratio(members, all_positions, now) diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 31fa738..78339fb 100644 --- a/prediction/algorithms/polygon_builder.py +++ b/prediction/algorithms/polygon_builder.py @@ -11,6 +11,9 @@ import math import re from datetime import datetime, timezone from typing import Optional +from zoneinfo import ZoneInfo + +import pandas as pd try: from shapely.geometry import MultiPoint, Point @@ -33,6 +36,23 @@ FLEET_BUFFER_DEG = 0.02 GEAR_BUFFER_DEG = 0.01 MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외) +_KST = ZoneInfo('Asia/Seoul') + + +def _get_time_bucket_age(mmsi: str, all_positions: dict, now: datetime) -> float: + """MMSI의 time_bucket 기반 age(초) 반환. 실패 시 inf.""" + pos = all_positions.get(mmsi) + tb = pos.get('time_bucket') if pos else None + if tb is None: + return float('inf') + try: + tb_dt = pd.Timestamp(tb) + if tb_dt.tzinfo is None: + tb_dt = tb_dt.tz_localize(_KST).tz_convert(timezone.utc) + return (now - tb_dt.to_pydatetime()).total_seconds() + except Exception: + return float('inf') + # 수역 내 어구 색상, 수역 외 어구 색상 _COLOR_GEAR_IN_ZONE = '#ef4444' _COLOR_GEAR_OUT_ZONE = '#f97316' @@ -159,7 +179,6 @@ def detect_gear_groups( last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc) else: try: - import pandas as pd last_dt = pd.Timestamp(ts).to_pydatetime() if last_dt.tzinfo is None: last_dt = last_dt.replace(tzinfo=timezone.utc) @@ -344,7 +363,6 @@ def build_all_group_snapshots( points: list[tuple[float, float]] = [] members: list[dict] = [] - newest_age = float('inf') for mmsi in mmsi_list: pos = all_positions.get(mmsi) if not pos: @@ -364,22 +382,11 @@ def build_all_group_snapshots( 'role': 'LEADER' if mmsi == mmsi_list[0] else 'MEMBER', 'isParent': False, }) - # 멤버 중 가장 최근 적재시간(time_bucket) 추적 - tb = pos.get('time_bucket') - if tb is not None: - try: - import pandas as pd - tb_dt = pd.Timestamp(tb) - if tb_dt.tzinfo is None: - from zoneinfo import ZoneInfo - tb_dt = tb_dt.tz_localize(ZoneInfo('Asia/Seoul')).tz_convert(timezone.utc) - tb_dt = tb_dt.to_pydatetime() - age = (now - tb_dt).total_seconds() - if age < newest_age: - newest_age = age - except Exception: - pass + newest_age = min( + (_get_time_bucket_age(m['mmsi'], all_positions, now) for m in members), + default=float('inf'), + ) # 2척 미만 또는 최근 적재가 DISPLAY_STALE_SEC 초과 → 폴리곤 미생성 if len(points) < 2 or newest_age > DISPLAY_STALE_SEC: continue @@ -403,124 +410,129 @@ def build_all_group_snapshots( 'color': _cluster_color(company_id), }) - # ── GEAR 타입: detect_gear_groups 결과 순회 ─────────────────── + # ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ──── gear_groups = detect_gear_groups(vessel_store, now=now) for group in gear_groups: parent_name: str = group['parent_name'] parent_mmsi: Optional[str] = group['parent_mmsi'] - gear_members: list[dict] = group['members'] + gear_members: list[dict] = group['members'] # 6h STALE 기반 전체 멤버 - # 표시 기준: 그룹 멤버 중 가장 최근 적재(time_bucket)가 DISPLAY_STALE_SEC 이내여야 노출 - # time_bucket은 KST naive이므로 UTC로 변환 후 비교 - newest_age = float('inf') - for gm in gear_members: - gm_mmsi = gm.get('mmsi') - gm_pos = all_positions.get(gm_mmsi) if gm_mmsi else None - gm_tb = gm_pos.get('time_bucket') if gm_pos else None - if gm_tb is not None: - try: - import pandas as pd - tb_dt = pd.Timestamp(gm_tb) - if tb_dt.tzinfo is None: - # time_bucket은 KST (Asia/Seoul, UTC+9) - from zoneinfo import ZoneInfo - tb_dt = tb_dt.tz_localize(ZoneInfo('Asia/Seoul')).tz_convert(timezone.utc) - tb_dt = tb_dt.to_pydatetime() - except Exception: - continue - age = (now - tb_dt).total_seconds() - if age < newest_age: - newest_age = age - if newest_age > DISPLAY_STALE_SEC: + if not gear_members: continue - # 수역 분류: anchor(모선 or 첫 어구) 위치 기준 - anchor_lat: Optional[float] = None - anchor_lon: Optional[float] = None + # ── 1h 활성 멤버 필터 ── + display_members_1h = [ + gm for gm in gear_members + if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC + ] + # fallback: 1h < 2이면 time_bucket 최신 2개 유지 (폴리곤 형태 보존) + if len(display_members_1h) < 2 and len(gear_members) >= 2: + sorted_by_age = sorted( + gear_members, + key=lambda gm: _get_time_bucket_age(gm.get('mmsi'), all_positions, now), + ) + display_members_1h = sorted_by_age[:2] - if parent_mmsi and parent_mmsi in all_positions: - parent_pos = all_positions[parent_mmsi] - anchor_lat = parent_pos['lat'] - anchor_lon = parent_pos['lon'] - - if anchor_lat is None and gear_members: - anchor_lat = gear_members[0]['lat'] - anchor_lon = gear_members[0]['lon'] - - if anchor_lat is None: - continue - - zone_info = classify_zone(float(anchor_lat), float(anchor_lon)) - in_zone = _is_in_zone(zone_info) - zone_id = zone_info.get('zone') if in_zone else None - zone_name = zone_info.get('zone_name') if in_zone else None - - # 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외 - if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE: - continue - - # 폴리곤 points: 어구 좌표 + 모선 좌표 (근접 시에만) - points = [(g['lon'], g['lat']) for g in gear_members] - parent_nearby = False - if parent_mmsi and parent_mmsi in all_positions: - parent_pos = all_positions[parent_mmsi] - p_lon, p_lat = parent_pos['lon'], parent_pos['lat'] - # 모선이 어구 클러스터 내 최소 1개와 MAX_DIST_DEG*2 이내일 때만 포함 - if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2 - and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in gear_members): - if (p_lon, p_lat) not in points: - points.append((p_lon, p_lat)) - parent_nearby = True - - polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon( - points, GEAR_BUFFER_DEG + # ── 6h 전체 멤버 노출 조건: 최신 적재가 STALE_SEC 이내 ── + newest_age_6h = min( + (_get_time_bucket_age(gm.get('mmsi'), all_positions, now) for gm in gear_members), + default=float('inf'), ) + display_members_6h = gear_members - # members JSONB 구성 - members_out: list[dict] = [] - # 모선 먼저 (근접 시에만) - if parent_nearby and parent_mmsi and parent_mmsi in all_positions: - parent_pos = all_positions[parent_mmsi] - members_out.append({ - 'mmsi': parent_mmsi, - 'name': parent_name, - 'lat': parent_pos['lat'], - 'lon': parent_pos['lon'], - 'sog': parent_pos.get('sog', 0), - 'cog': parent_pos.get('cog', 0), - 'role': 'PARENT', - 'isParent': True, + # ── resolution별 스냅샷 생성 ── + for resolution, members_for_snap in [('1h', display_members_1h), ('6h', display_members_6h)]: + if len(members_for_snap) < 2: + continue + # 6h: 최신 적재가 STALE_SEC(6h) 초과 시 스킵 + if resolution == '6h' and newest_age_6h > STALE_SEC: + continue + + # 수역 분류: anchor(모선 or 첫 멤버) 위치 기준 + anchor_lat: Optional[float] = None + anchor_lon: Optional[float] = None + + if parent_mmsi and parent_mmsi in all_positions: + parent_pos = all_positions[parent_mmsi] + anchor_lat = parent_pos['lat'] + anchor_lon = parent_pos['lon'] + + if anchor_lat is None and members_for_snap: + anchor_lat = members_for_snap[0]['lat'] + anchor_lon = members_for_snap[0]['lon'] + + if anchor_lat is None: + continue + + zone_info = classify_zone(float(anchor_lat), float(anchor_lon)) + in_zone = _is_in_zone(zone_info) + zone_id = zone_info.get('zone') if in_zone else None + zone_name = zone_info.get('zone_name') if in_zone else None + + # 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외 + if not in_zone and len(members_for_snap) < MIN_GEAR_GROUP_SIZE: + continue + + # 폴리곤 points: 멤버 좌표 + 모선 좌표 (근접 시에만) + points = [(g['lon'], g['lat']) for g in members_for_snap] + parent_nearby = False + if parent_mmsi and parent_mmsi in all_positions: + parent_pos = all_positions[parent_mmsi] + p_lon, p_lat = parent_pos['lon'], parent_pos['lat'] + if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2 + and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in members_for_snap): + if (p_lon, p_lat) not in points: + points.append((p_lon, p_lat)) + parent_nearby = True + + polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon( + points, GEAR_BUFFER_DEG + ) + + # members JSONB 구성 + members_out: list[dict] = [] + if parent_nearby and parent_mmsi and parent_mmsi in all_positions: + parent_pos = all_positions[parent_mmsi] + members_out.append({ + 'mmsi': parent_mmsi, + 'name': parent_name, + 'lat': parent_pos['lat'], + 'lon': parent_pos['lon'], + 'sog': parent_pos.get('sog', 0), + 'cog': parent_pos.get('cog', 0), + 'role': 'PARENT', + 'isParent': True, + }) + for g in members_for_snap: + members_out.append({ + 'mmsi': g['mmsi'], + 'name': g['name'], + 'lat': g['lat'], + 'lon': g['lon'], + 'sog': g['sog'], + 'cog': g['cog'], + 'role': 'GEAR', + 'isParent': False, + }) + + color = _COLOR_GEAR_IN_ZONE if in_zone else _COLOR_GEAR_OUT_ZONE + + snapshots.append({ + 'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE', + 'group_key': parent_name, + 'group_label': parent_name, + 'sub_cluster_id': group.get('sub_cluster_id', 0), + 'resolution': resolution, + 'snapshot_time': now, + 'polygon_wkt': polygon_wkt, + 'center_wkt': center_wkt, + 'area_sq_nm': area_sq_nm, + 'member_count': len(members_out), + 'zone_id': zone_id, + 'zone_name': zone_name, + 'members': members_out, + 'color': color, }) - # 어구 목록 - for g in gear_members: - members_out.append({ - 'mmsi': g['mmsi'], - 'name': g['name'], - 'lat': g['lat'], - 'lon': g['lon'], - 'sog': g['sog'], - 'cog': g['cog'], - 'role': 'GEAR', - 'isParent': False, - }) - - color = _COLOR_GEAR_IN_ZONE if in_zone else _COLOR_GEAR_OUT_ZONE - - snapshots.append({ - 'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE', - 'group_key': parent_name, - 'group_label': parent_name, - 'sub_cluster_id': group.get('sub_cluster_id', 0), - 'snapshot_time': now, - 'polygon_wkt': polygon_wkt, - 'center_wkt': center_wkt, - 'area_sq_nm': area_sq_nm, - 'member_count': len(members_out), - 'zone_id': zone_id, - 'zone_name': zone_name, - 'members': members_out, - 'color': color, - }) return snapshots diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py index 8297042..db55152 100644 --- a/prediction/db/kcgdb.py +++ b/prediction/db/kcgdb.py @@ -154,11 +154,11 @@ def save_group_snapshots(snapshots: list[dict]) -> int: insert_sql = """ INSERT INTO kcg.group_polygon_snapshots ( - group_type, group_key, group_label, sub_cluster_id, snapshot_time, + group_type, group_key, group_label, sub_cluster_id, resolution, snapshot_time, polygon, center_point, area_sq_nm, member_count, zone_id, zone_name, members, color ) VALUES ( - %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326), %s, %s, %s, %s, %s::jsonb, %s ) @@ -176,6 +176,7 @@ def save_group_snapshots(snapshots: list[dict]) -> int: s['group_key'], s['group_label'], s.get('sub_cluster_id', 0), + s.get('resolution', '6h'), s['snapshot_time'], s.get('polygon_wkt'), s.get('center_wkt'), -- 2.45.2 From 77efab8652800ab1eda65a8ae0f4d9ddbf3482de Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 12:29:22 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=ED=95=AD=EA=B3=B5=EA=B8=B0=20?= =?UTF-8?q?=EC=A4=8C=20=EC=8A=A4=EC=BC=80=EC=9D=BC=20+=20=EC=84=A0?= =?UTF-8?q?=EB=B0=95/=ED=95=AD=EA=B3=B5=EA=B8=B0=20=EC=8B=AC=EB=B3=BC=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=95=20=ED=8C=A8=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 항공기 아이콘에 정수레벨 줌 기반 스케일 적용 (getZoomScale export) - 심볼 크기 조정: SymbolScaleContext + SymbolScalePanel (0.5~2.0x) - LayerPanel에 '심볼 크기' 섹션 추가 (선박/항공기 개별 조정) - localStorage 영속화 (mapSymbolScale) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 3 ++ frontend/src/components/common/LayerPanel.tsx | 2 + .../components/common/SymbolScalePanel.tsx | 43 +++++++++++++++++++ .../src/components/layers/AircraftLayer.tsx | 8 +++- frontend/src/contexts/SymbolScaleContext.tsx | 10 +++++ frontend/src/contexts/symbolScaleState.ts | 12 ++++++ frontend/src/hooks/useShipDeckLayers.ts | 9 ++-- frontend/src/hooks/useSymbolScale.ts | 6 +++ 8 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/common/SymbolScalePanel.tsx create mode 100644 frontend/src/contexts/SymbolScaleContext.tsx create mode 100644 frontend/src/contexts/symbolScaleState.ts create mode 100644 frontend/src/hooks/useSymbolScale.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bddd0be..ec2334b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import LoginPage from './components/auth/LoginPage'; import CollectorMonitor from './components/common/CollectorMonitor'; import { SharedFilterProvider } from './contexts/SharedFilterContext'; import { FontScaleProvider } from './contexts/FontScaleContext'; +import { SymbolScaleProvider } from './contexts/SymbolScaleContext'; import { IranDashboard } from './components/iran/IranDashboard'; import { KoreaDashboard } from './components/korea/KoreaDashboard'; import './App.css'; @@ -67,6 +68,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { return ( +
@@ -160,6 +162,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { )}
+
); } diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index d9e9f2c..2fb3345 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocalStorageSet } from '../../hooks/useLocalStorage'; import { FontScalePanel } from './FontScalePanel'; +import { SymbolScalePanel } from './SymbolScalePanel'; // Aircraft category colors (matches AircraftLayer military fixed colors) const AC_CAT_COLORS: Record = { @@ -897,6 +898,7 @@ export function LayerPanel({ )}
+
); } diff --git a/frontend/src/components/common/SymbolScalePanel.tsx b/frontend/src/components/common/SymbolScalePanel.tsx new file mode 100644 index 0000000..4ec6021 --- /dev/null +++ b/frontend/src/components/common/SymbolScalePanel.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { useSymbolScale } from '../../hooks/useSymbolScale'; +import type { SymbolScaleConfig } from '../../contexts/symbolScaleState'; + +const LABELS: Record = { + ship: '선박 심볼', + aircraft: '항공기 심볼', +}; + +export function SymbolScalePanel() { + const { symbolScale, setSymbolScale } = useSymbolScale(); + const [open, setOpen] = useState(false); + + const update = (key: keyof SymbolScaleConfig, val: number) => { + setSymbolScale({ ...symbolScale, [key]: Math.round(val * 10) / 10 }); + }; + + return ( +
+ + {open && ( +
+ {(Object.keys(LABELS) as (keyof SymbolScaleConfig)[]).map(key => ( +
+ + update(key, parseFloat(e.target.value))} /> + {symbolScale[key].toFixed(1)} +
+ ))} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/layers/AircraftLayer.tsx b/frontend/src/components/layers/AircraftLayer.tsx index 874852d..13230d2 100644 --- a/frontend/src/components/layers/AircraftLayer.tsx +++ b/frontend/src/components/layers/AircraftLayer.tsx @@ -2,6 +2,9 @@ import { memo, useMemo, useState, useEffect } from 'react'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import type { Aircraft, AircraftCategory } from '../../types'; +import { useShipDeckStore } from '../../stores/shipDeckStore'; +import { getZoomScale } from '../../hooks/useShipDeckLayers'; +import { useSymbolScale } from '../../hooks/useSymbolScale'; interface Props { aircraft: Aircraft[]; @@ -187,9 +190,12 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) { const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) { const { t } = useTranslation('ships'); const [showPopup, setShowPopup] = useState(false); + const zoomLevel = useShipDeckStore(s => s.zoomLevel); + const { symbolScale } = useSymbolScale(); const color = getAircraftColor(ac); const shape = getShape(ac); - const size = shape.w; + const zs = getZoomScale(zoomLevel); + const size = Math.round(shape.w * zs * symbolScale.aircraft / 0.8); const showLabel = ac.category === 'fighter' || ac.category === 'surveillance'; const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8; diff --git a/frontend/src/contexts/SymbolScaleContext.tsx b/frontend/src/contexts/SymbolScaleContext.tsx new file mode 100644 index 0000000..809e38a --- /dev/null +++ b/frontend/src/contexts/SymbolScaleContext.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { SymbolScaleCtx, DEFAULT_SYMBOL_SCALE } from './symbolScaleState'; + +export type { SymbolScaleConfig } from './symbolScaleState'; + +export function SymbolScaleProvider({ children }: { children: ReactNode }) { + const [symbolScale, setSymbolScale] = useLocalStorage('mapSymbolScale', DEFAULT_SYMBOL_SCALE); + return {children}; +} diff --git a/frontend/src/contexts/symbolScaleState.ts b/frontend/src/contexts/symbolScaleState.ts new file mode 100644 index 0000000..d5aa5bf --- /dev/null +++ b/frontend/src/contexts/symbolScaleState.ts @@ -0,0 +1,12 @@ +import { createContext } from 'react'; + +export interface SymbolScaleConfig { + ship: number; + aircraft: number; +} + +export const DEFAULT_SYMBOL_SCALE: SymbolScaleConfig = { ship: 1.0, aircraft: 1.0 }; + +export const SymbolScaleCtx = createContext<{ symbolScale: SymbolScaleConfig; setSymbolScale: (c: SymbolScaleConfig) => void }>({ + symbolScale: DEFAULT_SYMBOL_SCALE, setSymbolScale: () => {}, +}); diff --git a/frontend/src/hooks/useShipDeckLayers.ts b/frontend/src/hooks/useShipDeckLayers.ts index c96ec5a..bdf1e88 100644 --- a/frontend/src/hooks/useShipDeckLayers.ts +++ b/frontend/src/hooks/useShipDeckLayers.ts @@ -9,6 +9,7 @@ import { getMarineTrafficCategory } from '../utils/marineTraffic'; import { getNationalityGroup } from './useKoreaData'; import { FONT_MONO } from '../styles/fonts'; import type { Ship, VesselAnalysisDto } from '../types'; +import { useSymbolScale } from './useSymbolScale'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -19,7 +20,7 @@ const ZOOM_SCALE: Record = { }; const ZOOM_SCALE_DEFAULT = 4.2; // z14+ -function getZoomScale(zoom: number): number { +export function getZoomScale(zoom: number): number { if (zoom >= 14) return ZOOM_SCALE_DEFAULT; return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT; } @@ -156,6 +157,8 @@ export function useShipDeckLayers( shipLayerRef: React.MutableRefObject, requestRender: () => void, ): void { + const { symbolScale } = useSymbolScale(); + const shipSymbolScale = symbolScale.ship; const renderFrame = useCallback(() => { const state = useShipDeckStore.getState(); @@ -170,7 +173,7 @@ export function useShipDeckLayers( return; } - const zoomScale = getZoomScale(zoomLevel); + const zoomScale = getZoomScale(zoomLevel) * shipSymbolScale; const layers: Layer[] = []; // 1. Build filtered ship render data (~3K ships, <1ms) @@ -316,7 +319,7 @@ export function useShipDeckLayers( shipLayerRef.current = layers; requestRender(); - }, [shipLayerRef, requestRender]); + }, [shipLayerRef, requestRender, shipSymbolScale]); // Subscribe to all relevant state changes useEffect(() => { diff --git a/frontend/src/hooks/useSymbolScale.ts b/frontend/src/hooks/useSymbolScale.ts new file mode 100644 index 0000000..d018b13 --- /dev/null +++ b/frontend/src/hooks/useSymbolScale.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { SymbolScaleCtx } from '../contexts/symbolScaleState'; + +export function useSymbolScale() { + return useContext(SymbolScaleCtx); +} -- 2.45.2 From 9200f45cb2f026b7daf4ac6436635671530f6750 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 12:32:08 +0900 Subject: [PATCH 09/10] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 365cd0d..2bbb43a 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,19 @@ ## [Unreleased] +### 추가 +- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더) +- 리플레이 컨트롤러 A-B 구간 반복 기능 +- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정) +- 리치 툴팁: 선박/어구 구분 + 모델 소속 컬러 표시 + 멤버 호버 강조 +- 항공기 아이콘 줌레벨 기반 스케일 적용 +- 심볼 크기 조정 패널 (선박/항공기 개별 0.5~2.0x) + +### 변경 +- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius +- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용 +- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링 + ### 추가 - 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore) - 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers) -- 2.45.2 From 138a1b82dec2c3fa0508d6cdc639ac9045534101 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 12:35:26 +0900 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-04-01)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 2bbb43a..4a14389 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-04-01] + ### 추가 - 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더) - 리플레이 컨트롤러 A-B 구간 반복 기능 @@ -11,13 +13,6 @@ - 리치 툴팁: 선박/어구 구분 + 모델 소속 컬러 표시 + 멤버 호버 강조 - 항공기 아이콘 줌레벨 기반 스케일 적용 - 심볼 크기 조정 패널 (선박/항공기 개별 0.5~2.0x) - -### 변경 -- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius -- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용 -- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링 - -### 추가 - 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore) - 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers) - 선박 클릭 팝업 React 오버레이 전환 (ShipPopupOverlay + 드래그 지원) @@ -26,23 +21,23 @@ - 라벨 클러스터링 (줌 레벨별 그리드, z10+ 전체 표시) - 어구 서브클러스터 독립 추적 (DB sub_cluster_id + Python group_key 고정) - 서브클러스터별 독립 center trail (PathLayer 색상 구분) -- 리플레이 전체 구간 멤버 목록 (allHistoryMembers) ### 변경 +- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius +- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용 +- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링 - 선단 폴리곤 색상: API 기본색 → 밝은 파스텔 팔레트 (바다 배경 대비) - 멤버/연관 라벨: SDF outline → 검정 배경 블록 + fontScale.analysis 연동 - 모델 패널: 헤더→푸터 구조, 개별 확장/축소, 우클릭 툴팁 고정 -- 모델 패널/재생 컨트롤러 레이아웃: 좌측 패널~우측 패널 사이 중앙 배치 ### 수정 - 어구 group_key 변동 → 이력 불연속 문제 해결 (sub_cluster_id 구조 전환) - 한국 국적 선박(440/441) 어구 오탐 제외 - Backend correlation API 서브클러스터 중복 제거 (DISTINCT ON CTE) - 리플레이 종료/탭 off 시 deck.gl 레이어 + gearReplayStore 완전 초기화 -- 어구 폴리곤 호버 하이라이트 추가 ### 기타 -- DB 마이그레이션: sub_cluster_id 컬럼 추가 + '#N' 데이터 변환 + UNIQUE 제약 변경 +- DB 마이그레이션: sub_cluster_id + resolution 컬럼, 인덱스 교체 ## [2026-03-31] -- 2.45.2