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..1bfaf4d 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; @@ -24,4 +25,5 @@ public class GroupPolygonDto { private String zoneName; private List> members; private String color; + private String resolution; } 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..4cb954e 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,20 +28,21 @@ 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 + area_sq_nm, member_count, zone_id, zone_name, members, color, resolution FROM kcg.group_polygon_snapshots - WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots) + WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots WHERE resolution = '1h') + AND resolution = '1h' ORDER BY group_type, member_count DESC """; 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 + area_sq_nm, member_count, zone_id, zone_name, members, color, resolution FROM kcg.group_polygon_snapshots WHERE group_key = ? ORDER BY snapshot_time DESC @@ -49,39 +50,47 @@ 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 + area_sq_nm, member_count, zone_id, zone_name, members, color, resolution FROM kcg.group_polygon_snapshots WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL) ORDER BY snapshot_time DESC """; private static final String GROUP_CORRELATIONS_SQL = """ - SELECT s.target_mmsi, s.target_type, s.target_name, - s.current_score, s.streak_count, s.observation_count, - s.freeze_state, s.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.sub_cluster_id, s.target_mmsi) + s.target_mmsi, s.target_type, s.target_name, + s.current_score, s.streak_count, s.observation_count, + s.freeze_state, s.shadow_bonus_total, + s.sub_cluster_id, + m.id AS model_id, m.name AS model_name, m.is_default + FROM kcg.gear_correlation_scores s + JOIN kcg.correlation_param_models m ON s.model_id = m.id + WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE + ORDER BY m.id, s.sub_cluster_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 = """ SELECT COUNT(*) AS gear_groups, COALESCE(SUM(member_count), 0) AS gear_count FROM kcg.group_polygon_snapshots - WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots) + WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots WHERE resolution = '1h') AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE') + AND resolution = '1h' """; /** @@ -114,6 +123,7 @@ public class GroupPolygonService { row.put("observations", rs.getInt("observation_count")); row.put("freezeState", rs.getString("freeze_state")); row.put("shadowBonus", rs.getDouble("shadow_bonus_total")); + row.put("subClusterId", rs.getInt("sub_cluster_id")); row.put("proximityRatio", rs.getObject("proximity_ratio")); row.put("visitScore", rs.getObject("visit_score")); row.put("headingCoherence", rs.getObject("heading_coherence")); @@ -121,7 +131,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 +172,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 +203,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")) @@ -202,6 +214,7 @@ public class GroupPolygonService { .zoneName(rs.getString("zone_name")) .members(members) .color(rs.getString("color")) + .resolution(rs.getString("resolution")) .build(); } } diff --git a/database/migration/011_polygon_resolution.sql b/database/migration/011_polygon_resolution.sql new file mode 100644 index 0000000..4b02d0b --- /dev/null +++ b/database/migration/011_polygon_resolution.sql @@ -0,0 +1,14 @@ +-- 011: group_polygon_snapshots에 resolution 컬럼 추가 (1h/6h 듀얼 폴리곤) +-- 기존 데이터는 DEFAULT '6h'로 취급 + +ALTER TABLE kcg.group_polygon_snapshots + ADD COLUMN IF NOT EXISTS resolution VARCHAR(4) DEFAULT '6h'; + +-- 기존 인덱스 교체: resolution 포함 +DROP INDEX IF EXISTS kcg.idx_gps_type_time; +CREATE INDEX idx_gps_type_res_time + ON kcg.group_polygon_snapshots(group_type, resolution, snapshot_time DESC); + +DROP INDEX IF EXISTS kcg.idx_gps_key_time; +CREATE INDEX idx_gps_key_res_time + ON kcg.group_polygon_snapshots(group_key, resolution, snapshot_time DESC); diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index a289858..4a14389 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,41 @@ ## [Unreleased] +## [2026-04-01] + +### 추가 +- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더) +- 리플레이 컨트롤러 A-B 구간 반복 기능 +- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정) +- 리치 툴팁: 선박/어구 구분 + 모델 소속 컬러 표시 + 멤버 호버 강조 +- 항공기 아이콘 줌레벨 기반 스케일 적용 +- 심볼 크기 조정 패널 (선박/항공기 개별 0.5~2.0x) +- 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore) +- 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers) +- 선박 클릭 팝업 React 오버레이 전환 (ShipPopupOverlay + 드래그 지원) +- 선박 호버 툴팁 (이름, MMSI, 위치, 속도, 수신시각) +- 리플레이 집중 모드 — 주변 라이브 정보 숨김 토글 +- 라벨 클러스터링 (줌 레벨별 그리드, z10+ 전체 표시) +- 어구 서브클러스터 독립 추적 (DB sub_cluster_id + Python group_key 고정) +- 서브클러스터별 독립 center trail (PathLayer 색상 구분) + +### 변경 +- 일치율 후보 탐색: 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 + resolution 컬럼, 인덱스 교체 + ## [2026-03-31] ### 추가 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/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/korea/CorrelationPanel.tsx b/frontend/src/components/korea/CorrelationPanel.tsx index 66ad9e4..413ac59 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 (
- {/* 고정: 토글 패널 */} + {/* 고정: 토글 패널 (스크롤 밖) */}
{memberCount} {correlationLoading &&
로딩...
} - {availableModels.map(m => { - const color = MODEL_COLORS[m.name] ?? '#94a3b8'; - const modelItems = correlationByModel.get(m.name) ?? []; + {_MODEL_ORDER.filter(mn => mn !== 'identity').map(mn => { + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + const modelItems = correlationByModel.get(mn) ?? []; + const hasData = modelItems.length > 0; const vc = modelItems.filter(c => c.targetType === 'VESSEL').length; const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length; + const am = availableModels.find(m => m.name === mn); return ( - ); })}
+ {/* 스크롤 영역: 모델 카드들 */} +
+ {/* 이름 기반 카드 (체크 시) */} {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 +336,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 +362,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..a5d43c8 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1,14 +1,114 @@ -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) { + 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[] }); + } + } + + 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'; @@ -29,10 +129,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 +143,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 +164,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 +179,15 @@ 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. resolution별 분리 → 1h(primary) + 6h(secondary) + const history1h = history.filter(h => h.resolution === '1h'); + const history6h = history.filter(h => h.resolution === '6h'); + // fallback: resolution 필드 없는 기존 데이터는 6h로 취급 + const effective1h = history1h.length > 0 ? history1h : history; + const effective6h = history6h; + + const { frames: filled, subClusterCenters } = splitAndMergeHistory(effective1h); + const { frames: filled6h, subClusterCenters: subClusterCenters6h } = splitAndMergeHistory(effective6h); const corrData = corrRes.items; const corrTracks = trackRes.vessels; @@ -88,10 +195,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const withTrack = corrTracks.filter(v => v.track && v.track.length > 0).length; console.log('[loadHistory] fetch 완료:', { history: history.length, + '1h': history1h.length, + '6h': history6h.length, + 'filled1h': filled.length, + 'filled6h': filled6h.length, corrData: corrData.length, corrTracks: corrTracks.length, withTrack, - sampleTrack: corrTracks[0] ? { mmsi: corrTracks[0].mmsi, trackPts: corrTracks[0].track?.length, score: corrTracks[0].score } : 'none', }); const vessels = new Set(corrTracks.filter(v => v.score >= 0.7).map(v => v.mmsi)); @@ -102,15 +212,39 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS setEnabledVessels(vessels); setCorrelationLoading(false); - // 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작 + // 4. 스토어 초기화 (1h + 6h 모든 데이터 포함) → 재생 시작 const store = useGearReplayStore.getState(); - store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels); + store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels, filled6h); + // 서브클러스터별 독립 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, subClusterCenters6h, 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 +274,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 +283,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS return m; }, [ships]); - dataRef.current = { shipMap, groupPolygons, onFleetZoom }; + // ── 부모 콜백 동기화: 어구 그룹 선택 ── useEffect(() => { @@ -293,11 +293,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 +364,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 +516,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); }} @@ -470,8 +560,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/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..fb81d16 100644 --- a/frontend/src/components/korea/HistoryReplayController.tsx +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -1,31 +1,114 @@ -import { useRef, useEffect } from 'react'; +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { FONT_MONO } from '../../styles/fonts'; import { useGearReplayStore } from '../../stores/gearReplayStore'; +import { MODEL_COLORS } from './fleetClusterConstants'; +import type { HistoryFrame } from './fleetClusterTypes'; +import type { GearCorrelationItem } from '../../services/vesselAnalysis'; interface HistoryReplayControllerProps { onClose: () => void; onFilterByScore: (minPct: number | null) => void; } +const MIN_AB_GAP_MS = 2 * 3600_000; + +// 멤버 정보 + 소속 모델 매핑 +interface TooltipMember { + mmsi: string; + name: string; + isGear: boolean; + isParent: boolean; + sources: { label: string; color: string }[]; // 소속 (1h, 6h, 모델명) +} + +function buildTooltipMembers( + frame1h: HistoryFrame | null, + frame6h: HistoryFrame | null, + correlationByModel: Map, + enabledModels: Set, + enabledVessels: Set, +): TooltipMember[] { + const map = new Map(); + + const addSource = (mmsi: string, name: string, isGear: boolean, isParent: boolean, label: string, color: string) => { + const existing = map.get(mmsi); + if (existing) { + existing.sources.push({ label, color }); + } else { + map.set(mmsi, { mmsi, name, isGear, isParent, sources: [{ label, color }] }); + } + }; + + // 1h 멤버 + if (frame1h) { + for (const m of frame1h.members) { + const isGear = m.role === 'GEAR'; + addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '1h', '#fbbf24'); + } + } + + // 6h 멤버 + if (frame6h) { + for (const m of frame6h.members) { + const isGear = m.role === 'GEAR'; + addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '6h', '#93c5fd'); + } + } + + // 활성 모델의 일치율 대상 + for (const [modelName, items] of correlationByModel) { + if (modelName === 'identity') continue; + if (!enabledModels.has(modelName)) continue; + const color = MODEL_COLORS[modelName] ?? '#94a3b8'; + for (const c of items) { + if (!enabledVessels.has(c.targetMmsi)) continue; + const isGear = c.targetType === 'GEAR_BUOY'; + addSource(c.targetMmsi, c.targetName || c.targetMmsi, isGear, false, modelName, color); + } + } + + return [...map.values()]; +} + const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => { const isPlaying = useGearReplayStore(s => s.isPlaying); const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); - const frameCount = useGearReplayStore(s => s.historyFrames.length); + const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h); + const historyFrames = useGearReplayStore(s => s.historyFrames); + const historyFrames6h = useGearReplayStore(s => s.historyFrames6h); + const frameCount = historyFrames.length; + const frameCount6h = historyFrames6h.length; const showTrails = useGearReplayStore(s => s.showTrails); const showLabels = useGearReplayStore(s => s.showLabels); + const focusMode = useGearReplayStore(s => s.focusMode); + const show1hPolygon = useGearReplayStore(s => s.show1hPolygon); + const show6hPolygon = useGearReplayStore(s => s.show6hPolygon); + const abLoop = useGearReplayStore(s => s.abLoop); + const abA = useGearReplayStore(s => s.abA); + const abB = useGearReplayStore(s => s.abB); + const correlationByModel = useGearReplayStore(s => s.correlationByModel); + const enabledModels = useGearReplayStore(s => s.enabledModels); + const enabledVessels = useGearReplayStore(s => s.enabledVessels); + const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); + const has6hData = frameCount6h > 0; - const progressBarRef = useRef(null); + const [hoveredTooltip, setHoveredTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null); + const [pinnedTooltip, setPinnedTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null); + const [dragging, setDragging] = useState<'A' | 'B' | null>(null); + const trackRef = useRef(null); const progressIndicatorRef = useRef(null); const timeDisplayRef = useRef(null); + const store = useGearReplayStore; + + // currentTime → 진행 인디케이터 useEffect(() => { - const unsub = useGearReplayStore.subscribe( + const unsub = store.subscribe( s => s.currentTime, (currentTime) => { - const { startTime, endTime } = useGearReplayStore.getState(); + const { startTime, endTime } = store.getState(); if (endTime <= startTime) return; const progress = (currentTime - startTime) / (endTime - startTime); - if (progressBarRef.current) progressBarRef.current.value = String(Math.round(progress * 1000)); if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`; if (timeDisplayRef.current) { timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); @@ -33,9 +116,141 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont }, ); return unsub; + }, [store]); + + // 재생 시작 시 고정 툴팁 해제 + useEffect(() => { + if (isPlaying) setPinnedTooltip(null); + }, [isPlaying]); + + const posToProgress = useCallback((clientX: number) => { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect) return 0; + return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); }, []); - const store = useGearReplayStore; + const progressToTime = useCallback((p: number) => { + const { startTime, endTime } = store.getState(); + return startTime + p * (endTime - startTime); + }, [store]); + + // 특정 시간에 가장 가까운 1h/6h 프레임 찾기 + const findClosestFrames = useCallback((t: number) => { + const { startTime, endTime } = store.getState(); + const threshold = (endTime - startTime) * 0.01; + let f1h: HistoryFrame | null = null; + let f6h: HistoryFrame | null = null; + let minD1h = Infinity; + let minD6h = Infinity; + + for (const f of historyFrames) { + const d = Math.abs(new Date(f.snapshotTime).getTime() - t); + if (d < minD1h && d < threshold) { minD1h = d; f1h = f; } + } + for (const f of historyFrames6h) { + const d = Math.abs(new Date(f.snapshotTime).getTime() - t); + if (d < minD6h && d < threshold) { minD6h = d; f6h = f; } + } + return { f1h, f6h }; + }, [store, historyFrames, historyFrames6h]); + + // 트랙 클릭 → seek + 일시정지 + 툴팁 고정/갱신 + const handleTrackClick = useCallback((e: React.MouseEvent) => { + if (dragging) return; + const progress = posToProgress(e.clientX); + const t = progressToTime(progress); + store.getState().pause(); + store.getState().seek(t); + + // 가까운 프레임이 있으면 툴팁 고정 + const { f1h, f6h } = findClosestFrames(t); + if (f1h || f6h) { + setPinnedTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h }); + const mmsis = new Set(); + if (f1h) f1h.members.forEach(m => mmsis.add(m.mmsi)); + if (f6h) f6h.members.forEach(m => mmsis.add(m.mmsi)); + for (const [mn, items] of correlationByModel) { + if (mn === 'identity' || !enabledModels.has(mn)) continue; + for (const c of items) { + if (enabledVessels.has(c.targetMmsi)) mmsis.add(c.targetMmsi); + } + } + store.getState().setPinnedMmsis(mmsis); + } else { + setPinnedTooltip(null); + store.getState().setPinnedMmsis(new Set()); + } + }, [store, posToProgress, progressToTime, findClosestFrames, dragging, correlationByModel, enabledModels, enabledVessels]); + + // 호버 → 1h+6h 프레임 동시 검색 + const handleTrackHover = useCallback((e: React.MouseEvent) => { + if (dragging || pinnedTooltip) return; + const progress = posToProgress(e.clientX); + const t = progressToTime(progress); + const { f1h, f6h } = findClosestFrames(t); + if (f1h || f6h) { + setHoveredTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h }); + } else { + setHoveredTooltip(null); + } + }, [posToProgress, progressToTime, findClosestFrames, dragging, pinnedTooltip]); + + // A-B 드래그 + const handleAbDown = useCallback((marker: 'A' | 'B') => (e: React.MouseEvent) => { + if (isPlaying) return; + e.stopPropagation(); + setDragging(marker); + }, [isPlaying]); + + useEffect(() => { + if (!dragging) return; + const handleMove = (e: MouseEvent) => { + const t = progressToTime(posToProgress(e.clientX)); + const { startTime, endTime } = store.getState(); + const s = store.getState(); + if (dragging === 'A') { + store.getState().setAbA(Math.max(startTime, Math.min(s.abB - MIN_AB_GAP_MS, t))); + } else { + store.getState().setAbB(Math.min(endTime, Math.max(s.abA + MIN_AB_GAP_MS, t))); + } + }; + const handleUp = () => setDragging(null); + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleUp); + return () => { window.removeEventListener('mousemove', handleMove); window.removeEventListener('mouseup', handleUp); }; + }, [dragging, store, posToProgress, progressToTime]); + + const abAPos = useMemo(() => { + if (!abLoop || abA <= 0) return -1; + const { startTime, endTime } = store.getState(); + return endTime > startTime ? (abA - startTime) / (endTime - startTime) : -1; + }, [abLoop, abA, store]); + + const abBPos = useMemo(() => { + if (!abLoop || abB <= 0) return -1; + const { startTime, endTime } = store.getState(); + return endTime > startTime ? (abB - startTime) / (endTime - startTime) : -1; + }, [abLoop, abB, store]); + + // 고정 툴팁 멤버 빌드 + const pinnedMembers = useMemo(() => { + if (!pinnedTooltip) return []; + return buildTooltipMembers(pinnedTooltip.frame1h, pinnedTooltip.frame6h, correlationByModel, enabledModels, enabledVessels); + }, [pinnedTooltip, correlationByModel, enabledModels, enabledVessels]); + + // 호버 리치 멤버 목록 (고정 툴팁과 동일 형식) + const hoveredMembers = useMemo(() => { + if (!hoveredTooltip) return []; + return buildTooltipMembers(hoveredTooltip.frame1h, hoveredTooltip.frame6h, correlationByModel, enabledModels, enabledVessels); + }, [hoveredTooltip, correlationByModel, enabledModels, enabledVessels]); + + // 닫기 핸들러 (고정 해제 포함) + const handleClose = useCallback(() => { + setPinnedTooltip(null); + store.getState().setPinnedMmsis(new Set()); + onClose(); + }, [store, onClose]); + const btnStyle: React.CSSProperties = { background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO, @@ -46,80 +261,238 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont return (
- {/* 프로그레스 바 */} -
- {snapshotRanges.map((pos, i) => ( -
{ if (!pinnedTooltip) setHoveredTooltip(null); }} + > +
+ + {/* A-B 구간 */} + {abLoop && abAPos >= 0 && abBPos >= 0 && ( +
+ )} + + {snapshotRanges6h.map((pos, i) => ( +
))} + {snapshotRanges.map((pos, i) => ( +
+ ))} + + {/* A-B 마커 */} + {abLoop && abAPos >= 0 && ( +
+
+ A +
+ )} + {abLoop && abBPos >= 0 && ( +
+
+ B +
+ )} + + {/* 호버 하이라이트 */} + {hoveredTooltip && !pinnedTooltip && ( +
+ )} + + {/* 고정 마커 */} + {pinnedTooltip && ( +
+ )} + + {/* 진행 인디케이터 */}
+ + {/* 호버 리치 툴팁 (고정 아닌 상태) */} + {hoveredTooltip && !pinnedTooltip && hoveredMembers.length > 0 && ( +
+
+ {new Date(hoveredTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} +
+ {hoveredMembers.map(m => ( +
+ + {m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''} + + + {m.name} + +
+ {m.sources.map((s, si) => ( + + {(s.label === '1h' || s.label === '6h') ? s.label : ''} + + ))} +
+
+ ))} +
+ )} + + {/* 고정 리치 툴팁 */} + {pinnedTooltip && pinnedMembers.length > 0 && ( +
e.stopPropagation()} + style={{ + position: 'absolute', + left: `${Math.min(pinnedTooltip.pos * 100, 85)}%`, + top: -8, + transform: 'translateY(-100%)', + background: 'rgba(10,20,32,0.97)', border: '1px solid rgba(99,179,237,0.4)', + borderRadius: 6, padding: '6px 8px', maxWidth: 320, maxHeight: 200, overflowY: 'auto', + fontSize: 9, zIndex: 40, pointerEvents: 'auto', + }}> + {/* 헤더 */} +
+ + {new Date(pinnedTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + + +
+ + {/* 멤버 목록 (호버 → 지도 강조) */} + {pinnedMembers.map(m => ( +
store.getState().setHoveredMmsi(m.mmsi)} + onMouseLeave={() => store.getState().setHoveredMmsi(null)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, padding: '2px 3px', + borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer', + borderRadius: 2, + background: hoveredMmsi === m.mmsi ? 'rgba(255,255,255,0.08)' : 'transparent', + }} + > + + {m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''} + + + {m.name} + +
+ {m.sources.map((s, si) => ( + + {(s.label === '1h' || s.label === '6h') ? s.label : ''} + + ))} +
+
+ ))} +
+ )}
- {/* 컨트롤 행 1: 재생 + 타임라인 */} -
+ {/* 컨트롤 행 */} +
- --:-- - { - 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="이름">이름 + + | + + + | + + | 일치율 - { 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="일치율 필터"> + + + + {frameCount} + {has6hData && <> / {frameCount6h}} 건 + +
); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 59c8fa8..5d328a1 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -13,7 +13,10 @@ import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers'; import { useGearReplayLayers } from '../../hooks/useGearReplayLayers'; import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers'; import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json'; -import { ShipLayer } from '../layers/ShipLayer'; +import { useGearReplayStore } from '../../stores/gearReplayStore'; +import { useShipDeckLayers } from '../../hooks/useShipDeckLayers'; +import { useShipDeckStore } from '../../stores/shipDeckStore'; +import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay'; import { InfraLayer } from './InfraLayer'; import { SatelliteLayer } from '../layers/SatelliteLayer'; import { AircraftLayer } from '../layers/AircraftLayer'; @@ -215,6 +218,12 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const mapRef = useRef(null); const overlayRef = useRef(null); const replayLayerRef = useRef([]); + const fleetClusterLayerRef = useRef([]); + const requestRenderRef = useRef<(() => void) | null>(null); + const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => { + fleetClusterLayerRef.current = layers; + requestRenderRef.current?.(); + }, []); const [infra, setInfra] = useState([]); const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null); const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState(null); @@ -229,15 +238,18 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF if (z !== zoomRef.current) { zoomRef.current = z; setZoomLevel(z); + useShipDeckStore.getState().setZoomLevel(z); } }, []); const [staticPickInfo, setStaticPickInfo] = useState(null); const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []); const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false); const [activeBadgeFilter, setActiveBadgeFilter] = useState(null); + const replayFocusMode = useGearReplayStore(s => s.focusMode); - // ── deck.gl 리플레이 레이어 (Zustand → imperative setProps, React 렌더 우회) ── + // ── deck.gl 레이어 (Zustand → imperative setProps, React 렌더 우회) ── const reactLayersRef = useRef([]); + const shipLayerRef = useRef([]); type ShipPos = { lng: number; lat: number; course?: number }; const shipsRef = useRef(new globalThis.Map()); // live 선박 위치를 ref에 동기화 (리플레이 fallback용) @@ -248,16 +260,41 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const requestRender = useCallback(() => { if (!overlayRef.current) return; + const focus = useGearReplayStore.getState().focusMode; overlayRef.current.setProps({ - layers: [...reactLayersRef.current, ...replayLayerRef.current], + layers: focus + ? [...replayLayerRef.current] + : [...reactLayersRef.current, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current], }); }, []); + requestRenderRef.current = requestRender; + useShipDeckLayers(shipLayerRef, requestRender); useGearReplayLayers(replayLayerRef, requestRender, shipsRef); useEffect(() => { fetchKoreaInfra().then(setInfra).catch(() => {}); }, []); + // MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제) + const handleMapLoad = useCallback(() => {}, []); + + // ── shipDeckStore 동기화 ── + useEffect(() => { + useShipDeckStore.getState().setShips(allShipsList); + }, [allShipsList]); + + useEffect(() => { + useShipDeckStore.getState().setFilters({ + militaryOnly: layers.militaryOnly, + layerVisible: layers.ships, + hiddenShipCategories, + hiddenNationalities, + }); + }, [layers.militaryOnly, layers.ships, hiddenShipCategories, hiddenNationalities]); + + // Korea 탭에서는 한국선박 강조 OFF (Iran 탭 전용 기능) + // highlightKorean 기본값 false 유지 + useEffect(() => { if (flyToTarget && mapRef.current) { mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom, duration: 1500 }); @@ -289,12 +326,10 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const handleFleetZoom = useCallback((bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => { mapRef.current?.fitBounds( [[bounds.minLng, bounds.minLat], [bounds.maxLng, bounds.maxLat]], - { padding: 60, duration: 1500, maxZoom: 12 }, + { padding: 60, duration: 1500, maxZoom: 10 }, ); }, []); - const anyKoreaFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch || koreaFilters.cnFishing; - // 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향 const zoomScale = useMemo(() => { if (zoomLevel <= 4) return 0.8; @@ -429,7 +464,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF // 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl) const selectedGearLayers = useMemo(() => { - if (!selectedGearData) return []; + if (!selectedGearData || replayFocusMode) return []; const { parent, gears, groupName } = selectedGearData; const layers = []; @@ -507,11 +542,11 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF } return layers; - }, [selectedGearData, zoomScale, fontScale.analysis]); + }, [selectedGearData, zoomScale, fontScale.analysis, replayFocusMode]); // 선택된 선단 소속 선박 강조 레이어 (deck.gl) const selectedFleetLayers = useMemo(() => { - if (!selectedFleetData) return []; + if (!selectedFleetData || replayFocusMode) return []; const { ships: fleetShips, clusterId } = selectedFleetData; if (fleetShips.length === 0) return []; @@ -526,7 +561,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const color: [number, number, number, number] = [r, g, b, 255]; const fillColor: [number, number, number, number] = [r, g, b, 80]; - const result: Layer[] = []; + const result: DeckLayer[] = []; // 소속 선박 — 강조 원형 result.push(new ScatterplotLayer({ @@ -593,7 +628,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF } return result; - }, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis]); + }, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis, replayFocusMode]); // 분석 결과 deck.gl 레이어 const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing' @@ -601,28 +636,16 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF : koreaFilters.cnFishing ? 'cnFishing' : null; - // AI 분석 가상 선박 마커 GeoJSON (분석 대상 선박을 삼각형으로 표시) - const analysisShipMarkersGeoJson = useMemo(() => { - const features: GeoJSON.Feature[] = []; - if (!vesselAnalysis || !analysisActiveFilter) return { type: 'FeatureCollection' as const, features }; - const allS = allShips ?? ships; - for (const s of allS) { - const dto = vesselAnalysis.analysisMap.get(s.mmsi); - if (!dto) continue; - const level = dto.algorithms.riskScore.level; - const color = level === 'CRITICAL' ? '#ef4444' : level === 'HIGH' ? '#f97316' : level === 'MEDIUM' ? '#eab308' : '#22c55e'; - const isGear = /^.+?_\d+_\d+_?$/.test(s.name || '') ? 1 : 0; - features.push({ - type: 'Feature', - properties: { mmsi: s.mmsi, name: s.name || s.mmsi, cog: s.heading ?? 0, color, baseSize: 0.16, isGear }, - geometry: { type: 'Point', coordinates: [s.lng, s.lat] }, - }); - } - return { type: 'FeatureCollection' as const, features }; - }, [vesselAnalysis, analysisActiveFilter, allShips, ships]); + // shipDeckStore에 분석 상태 동기화 + useEffect(() => { + useShipDeckStore.getState().setAnalysis( + vesselAnalysis?.analysisMap ?? null, + analysisActiveFilter, + ); + }, [vesselAnalysis?.analysisMap, analysisActiveFilter]); const analysisDeckLayers = useAnalysisDeckLayers( - vesselAnalysis?.analysisMap ?? new Map(), + vesselAnalysis?.analysisMap ?? (new globalThis.Map() as globalThis.Map), allShips ?? ships, analysisActiveFilter, zoomScale, @@ -635,6 +658,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF style={{ width: '100%', height: '100%' }} mapStyle={MAP_STYLE} onZoom={handleZoom} + onLoad={handleMapLoad} > @@ -702,13 +726,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF /> - {layers.ships && } + {/* ShipLayer → deck.gl (useShipDeckLayers) 전환 완료 */} {/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */} {transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => ( @@ -780,13 +798,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined} clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined} groupPolygons={groupPolygons} + zoomScale={zoomScale} + onDeckLayersChange={handleFleetDeckLayers} onShipSelect={handleAnalysisShipSelect} onFleetZoom={handleFleetZoom} onSelectedGearChange={setSelectedGearData} onSelectedFleetChange={setSelectedFleetData} /> )} - {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( + {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && ( )} - {/* AI 분석 가상 선박 마커 (삼각형 + 방향 + 줌 스케일) */} - {analysisActiveFilter && ( - - - - )} + {/* analysisShipMarkers → deck.gl (useShipDeckLayers) 전환 완료 */} + + {/* 선박 호버 툴팁 + 클릭 팝업 — React 오버레이 (MapLibre Popup 대체) */} + + {/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */} { - const base = [ + const base = replayFocusMode ? [] : [ ...staticDeckLayers, illegalFishingLayer, illegalFishingLabelLayer, @@ -838,9 +833,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ...selectedGearLayers, ...selectedFleetLayers, ...(analysisPanelOpen ? analysisDeckLayers : []), - ].filter(Boolean); + ].filter(Boolean) as DeckLayer[]; reactLayersRef.current = base; - return [...base, ...replayLayerRef.current]; + return [...base, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current]; })()} /> {/* 정적 마커 클릭 Popup — 통합 리치 디자인 */} diff --git a/frontend/src/components/korea/fleetClusterTypes.ts b/frontend/src/components/korea/fleetClusterTypes.ts index 57211c3..0f274ca 100644 --- a/frontend/src/components/korea/fleetClusterTypes.ts +++ b/frontend/src/components/korea/fleetClusterTypes.ts @@ -1,8 +1,21 @@ import type { Ship, VesselAnalysisDto } from '../../types'; -import type { MemberInfo } from '../../services/vesselAnalysis'; +import type { MemberInfo, GroupPolygonDto } from '../../services/vesselAnalysis'; + +// ── 서브클러스터 프레임 ── +export interface SubFrame { + subClusterId: number; // 0=통합, 1,2,...=분리 + centerLon: number; + centerLat: number; + members: MemberInfo[]; + memberCount: number; +} // ── 히스토리 스냅샷 + 보간 플래그 ── -export type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean }; +export type HistoryFrame = GroupPolygonDto & { + _interp?: boolean; + _longGap?: boolean; + subFrames: SubFrame[]; // 항상 1개 이상 +}; // ── 외부 노출 타입 (KoreaMap에서 import) ── export interface SelectedGearGroupData { diff --git a/frontend/src/components/korea/fleetClusterUtils.ts b/frontend/src/components/korea/fleetClusterUtils.ts index 6f281bc..b660fa9 100644 --- a/frontend/src/components/korea/fleetClusterUtils.ts +++ b/frontend/src/components/korea/fleetClusterUtils.ts @@ -1,6 +1,5 @@ -import type { GeoJSON } from 'geojson'; import type { GroupPolygonDto } from '../../services/vesselAnalysis'; -import type { HistoryFrame } from './fleetClusterTypes'; +import type { HistoryFrame, SubFrame } from './fleetClusterTypes'; import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes'; /** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */ @@ -129,13 +128,20 @@ export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Po * 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환. * - gap <= 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동) * - gap > 30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성 + * + * subFrames 보간 규칙: + * - prev/next 양쪽에 동일 subClusterId 존재: 멤버/center 보간 + * - prev에만 존재: 마지막 위치 그대로 frozen + * - next에만 존재: 갭 프레임에서 생략 + * + * top-level members/centerLon/Lat: 전체 subFrames의 union (하위 호환) */ -export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] { +export function fillGapFrames(snapshots: HistoryFrame[]): HistoryFrame[] { if (snapshots.length < 2) return snapshots; const STEP_SHORT_MS = 300_000; const STEP_LONG_MS = 1_800_000; const THRESHOLD_MS = 1_800_000; - const result: GroupPolygonDto[] = []; + const result: HistoryFrame[] = []; for (let i = 0; i < snapshots.length; i++) { result.push(snapshots[i]); @@ -152,25 +158,46 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] { const common = prev.members.filter(m => nextMap.has(m.mmsi)); if (common.length === 0) continue; + const nextSubMap = new Map(next.subFrames.map(sf => [sf.subClusterId, sf])); + if (gap <= THRESHOLD_MS) { for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) { const ratio = (t - t0) / gap; const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio; const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio; + + // prev 기준으로 순회: prev에만 존재(frozen) + 양쪽 존재(center 보간) + // next에만 존재하는 subClusterId는 prev.subFrames에 없으므로 자동 생략 + const subFrames: SubFrame[] = prev.subFrames.map(psf => { + const nsf = nextSubMap.get(psf.subClusterId); + if (!nsf) { + // prev에만 존재 → frozen + return { ...psf }; + } + // 양쪽 존재 → center 보간 + return { + ...psf, + centerLon: psf.centerLon + (nsf.centerLon - psf.centerLon) * ratio, + centerLat: psf.centerLat + (nsf.centerLat - psf.centerLat) * ratio, + }; + }); + result.push({ ...prev, snapshotTime: new Date(t).toISOString(), centerLon: cLon, centerLat: cLat, + subFrames, _interp: true, }); } } else { for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) { const ratio = (t - t0) / gap; - const positions: [number, number][] = []; - const members: typeof prev.members = []; + // top-level members 보간 (하위 호환) + const topPositions: [number, number][] = []; + const topMembers: GroupPolygonDto['members'] = []; for (const pm of common) { const nm = nextMap.get(pm.mmsi)!; const lon = pm.lon + (nm.lon - pm.lon) * ratio; @@ -178,13 +205,53 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] { const dLon = nm.lon - pm.lon; const dLat = nm.lat - pm.lat; const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360; - members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent }); - positions.push([lon, lat]); + topMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent }); + topPositions.push([lon, lat]); } + const cLon = topPositions.reduce((s, p) => s + p[0], 0) / topPositions.length; + const cLat = topPositions.reduce((s, p) => s + p[1], 0) / topPositions.length; + const polygon = buildInterpPolygon(topPositions); - const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length; - const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length; - const polygon = buildInterpPolygon(positions); + // subFrames 보간 + const subFrames: SubFrame[] = prev.subFrames.map(psf => { + const nsf = nextSubMap.get(psf.subClusterId); + if (!nsf) { + // prev에만 존재 → frozen + return { ...psf }; + } + // 양쪽 존재 → 멤버 위치 보간 + 폴리곤 재생성 + const nsfMemberMap = new Map(nsf.members.map(m => [m.mmsi, m])); + const commonSfMembers = psf.members.filter(m => nsfMemberMap.has(m.mmsi)); + const sfPositions: [number, number][] = []; + const sfMembers: SubFrame['members'] = []; + + for (const pm of commonSfMembers) { + const nm = nsfMemberMap.get(pm.mmsi)!; + const lon = pm.lon + (nm.lon - pm.lon) * ratio; + const lat = pm.lat + (nm.lat - pm.lat) * ratio; + const dLon = nm.lon - pm.lon; + const dLat = nm.lat - pm.lat; + const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360; + sfMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent }); + sfPositions.push([lon, lat]); + } + + if (sfPositions.length === 0) { + // 공통 멤버 없으면 frozen + return { ...psf }; + } + + const sfCLon = sfPositions.reduce((s, p) => s + p[0], 0) / sfPositions.length; + const sfCLat = sfPositions.reduce((s, p) => s + p[1], 0) / sfPositions.length; + + return { + subClusterId: psf.subClusterId, + centerLon: sfCLon, + centerLat: sfCLat, + members: sfMembers, + memberCount: sfMembers.length, + }; + }); result.push({ ...prev, @@ -192,8 +259,9 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] { polygon, centerLon: cLon, centerLat: cLat, - memberCount: members.length, - members, + memberCount: topMembers.length, + members: topMembers, + subFrames, _interp: true, _longGap: true, }); diff --git a/frontend/src/components/korea/useFleetClusterGeoJson.ts b/frontend/src/components/korea/useFleetClusterGeoJson.ts index 55fc344..2560bf7 100644 --- a/frontend/src/components/korea/useFleetClusterGeoJson.ts +++ b/frontend/src/components/korea/useFleetClusterGeoJson.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import type { GeoJSON } from 'geojson'; import type { Ship, VesselAnalysisDto } from '../../types'; -import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; +import type { GearCorrelationItem, CorrelationVesselTrack, GroupPolygonDto } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import type { FleetListItem } from './fleetClusterTypes'; import { buildInterpPolygon } from './fleetClusterUtils'; @@ -48,6 +48,26 @@ export interface FleetClusterGeoJsonResult { const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] }; +// 선단 색상: 바다색(짙은파랑)과 대비되는 밝은 파스텔 팔레트 (clusterId 해시) +const FLEET_PALETTE = [ + '#e879f9', '#a78bfa', '#67e8f9', '#34d399', '#fbbf24', + '#fb923c', '#f87171', '#a3e635', '#38bdf8', '#c084fc', +]; + +/** 같은 groupKey의 모든 서브클러스터에서 멤버를 합산 (중복 mmsi 제거) */ +function mergeSubClusterMembers(groups: GroupPolygonDto[], groupKey: string) { + const matches = groups.filter(g => g.groupKey === groupKey); + if (matches.length === 0) return { members: [] as GroupPolygonDto['members'], groups: matches }; + const seen = new Set(); + const members: GroupPolygonDto['members'] = []; + for (const g of matches) { + for (const m of g.members) { + if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); } + } + } + return { members, groups: matches }; +} + export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult { const { ships, @@ -70,9 +90,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl if (!groupPolygons) return { type: 'FeatureCollection', features }; for (const g of groupPolygons.fleetGroups) { if (!g.polygon) continue; + const cid = Number(g.groupKey); + const color = FLEET_PALETTE[cid % FLEET_PALETTE.length]; features.push({ type: 'Feature', - properties: { clusterId: Number(g.groupKey), color: g.color }, + properties: { clusterId: cid, color }, geometry: g.polygon, }); } @@ -93,7 +115,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl type: 'FeatureCollection', features: [{ type: 'Feature', - properties: { clusterId: hoveredFleetId, color: g.color }, + properties: { clusterId: hoveredFleetId, color: FLEET_PALETTE[hoveredFleetId % FLEET_PALETTE.length] }, geometry: g.polygon, }], }; @@ -120,43 +142,58 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl return models; }, [correlationByModel]); - // 오퍼레이셔널 폴리곤 (비재생 정적 연산) + // 오퍼레이셔널 폴리곤 (비재생 정적 연산 — 서브클러스터별 분리, subClusterId 기반) const operationalPolygons = useMemo(() => { if (!selectedGearGroup || !groupPolygons) return []; - const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; - const group = allGroups.find(g => g.groupKey === selectedGearGroup); - if (!group) return []; - const basePts: [number, number][] = group.members.map(m => [m.lon, m.lat]); + // 서브클러스터별 개별 그룹 (allGroups = raw, 서브클러스터 분리 유지) + const rawMatches = groupPolygons.allGroups.filter( + g => g.groupKey === selectedGearGroup && g.groupType !== 'FLEET', + ); + if (rawMatches.length === 0) return []; + + // 서브클러스터별 basePts + const subMap = new Map(); + for (const g of rawMatches) { + const sid = g.subClusterId ?? 0; + subMap.set(sid, g.members.map(m => [m.lon, m.lat])); + } + const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; for (const [mn, items] of correlationByModel) { if (!enabledModels.has(mn)) continue; - const extra: [number, number][] = []; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + + // 연관 선박을 subClusterId로 그룹핑 + const subExtras = new Map(); for (const c of items) { if (c.score < 0.7) continue; const s = ships.find(x => x.mmsi === c.targetMmsi); - if (s) extra.push([s.lng, s.lat]); + if (!s) continue; + const sid = c.subClusterId ?? 0; + const list = subExtras.get(sid) ?? []; + list.push([s.lng, s.lat]); + subExtras.set(sid, list); + } + + const features: GeoJSON.Feature[] = []; + for (const [sid, extraPts] of subExtras) { + if (extraPts.length === 0) continue; + const basePts = subMap.get(sid) ?? subMap.get(0) ?? []; + const polygon = buildInterpPolygon([...basePts, ...extraPts]); + if (polygon) features.push({ type: 'Feature', properties: { modelName: mn, color, subClusterId: sid }, geometry: polygon }); + } + if (features.length > 0) { + result.push({ modelName: mn, color, geojson: { type: 'FeatureCollection', features } }); } - if (extra.length === 0) continue; - const polygon = buildInterpPolygon([...basePts, ...extra]); - if (!polygon) continue; - const color = MODEL_COLORS[mn] ?? '#94a3b8'; - result.push({ - modelName: mn, - color, - geojson: { - type: 'FeatureCollection', - features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }], - }, - }); } return result; }, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]); - // 어구 클러스터 GeoJSON (서버 제공) + // 어구 클러스터 GeoJSON — allGroups에서 직접 (서브클러스터별 개별 폴리곤 유지) const gearClusterGeoJson = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; if (!groupPolygons) return { type: 'FeatureCollection', features }; - for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { + for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) { if (!g.polygon) continue; features.push({ type: 'Feature', @@ -205,7 +242,9 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl }; for (const g of groupPolygons.fleetGroups) { - for (const m of g.members) addMember(m, g.groupKey, 'FLEET', g.color); + const cid = Number(g.groupKey); + const fleetColor = FLEET_PALETTE[cid % FLEET_PALETTE.length]; + for (const m of g.members) addMember(m, g.groupKey, 'FLEET', fleetColor); } for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316'; @@ -231,15 +270,15 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; - const group = allGroups.find(g => g.groupKey === selectedGearGroup); - if (!group?.polygon) return null; + const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon); + if (matches.length === 0) return null; return { type: 'FeatureCollection', - features: [{ - type: 'Feature', - properties: {}, - geometry: group.polygon, - }], + features: matches.map(g => ({ + type: 'Feature' as const, + properties: { subClusterId: g.subClusterId }, + geometry: g.polygon!, + })), }; }, [selectedGearGroup, enabledModels, historyActive, groupPolygons]); @@ -303,7 +342,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl if (enabledModels.has('identity') && groupPolygons) { const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; - const members = all.find(g => g.groupKey === selectedGearGroup)?.members ?? []; + const { members } = mergeSubClusterMembers(all, selectedGearGroup); for (const m of members) { const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set() }; e.lon = m.lon; e.lat = m.lat; e.models.add('identity'); @@ -336,7 +375,8 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC; if (groupPolygons) { const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; - const m = all.find(g => g.groupKey === selectedGearGroup)?.members.find(x => x.mmsi === hoveredMmsi); + const { members: allMembers } = mergeSubClusterMembers(all, selectedGearGroup); + const m = allMembers.find(x => x.mmsi === hoveredMmsi); if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] }; } const s = ships.find(x => x.mmsi === hoveredMmsi); @@ -363,7 +403,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl label: g.groupLabel, memberCount: g.memberCount, areaSqNm: g.areaSqNm, - color: g.color, + color: FLEET_PALETTE[Number(g.groupKey) % FLEET_PALETTE.length], members: g.members, })).sort((a, b) => b.memberCount - a.memberCount); }, [groupPolygons]); 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/components/layers/ShipPopupOverlay.tsx b/frontend/src/components/layers/ShipPopupOverlay.tsx new file mode 100644 index 0000000..3946f43 --- /dev/null +++ b/frontend/src/components/layers/ShipPopupOverlay.tsx @@ -0,0 +1,675 @@ +import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react'; +import { useMap } from 'react-map-gl/maplibre'; +import { useTranslation } from 'react-i18next'; +import type { Ship } from '../../types'; +import { MT_TYPE_COLORS, getMTType, NAVY_COLORS, FLAG_EMOJI, isMilitary } from '../../utils/shipClassification'; +import { useShipDeckStore } from '../../stores/shipDeckStore'; + +// ── Local Korean ship photos ────────────────────────────────────────────────── + +const LOCAL_SHIP_PHOTOS: Record = { + '440034000': '/ships/440034000.jpg', + '440150000': '/ships/440150000.jpg', + '440272000': '/ships/440272000.jpg', + '440274000': '/ships/440274000.jpg', + '440323000': '/ships/440323000.jpg', + '440384000': '/ships/440384000.jpg', + '440880000': '/ships/440880000.jpg', + '441046000': '/ships/441046000.jpg', + '441345000': '/ships/441345000.jpg', + '441353000': '/ships/441353000.jpg', + '441393000': '/ships/441393000.jpg', + '441423000': '/ships/441423000.jpg', + '441548000': '/ships/441548000.jpg', + '441708000': '/ships/441708000.png', + '441866000': '/ships/441866000.jpg', +}; + +interface VesselPhotoData { + url: string; +} + +const vesselPhotoCache = new Map(); + +type PhotoSource = 'spglobal' | 'marinetraffic'; + +interface VesselPhotoProps { + mmsi: string; + imo?: string; + shipImagePath?: string | null; + shipImageCount?: number; +} + +/** + * S&P Global 이미지 목록 API 응답 + * GET /signal-batch/api/v1/shipimg/{imo} + * path에 _1.jpg(썸네일) / _2.jpg(원본) 을 붙여서 사용 + */ +interface SpgImageInfo { + picId: number; + path: string; // e.g. "/shipimg/22738/2273823" + copyright: string; + date: string; +} + +const spgImageCache = new Map(); + +async function fetchSpgImages(imo: string): Promise { + if (spgImageCache.has(imo)) return spgImageCache.get(imo) || []; + try { + const res = await fetch(`/signal-batch/api/v1/shipimg/${imo}`); + if (!res.ok) throw new Error(`${res.status}`); + const data: SpgImageInfo[] = await res.json(); + spgImageCache.set(imo, data); + return data; + } catch { + spgImageCache.set(imo, null); + return []; + } +} + +function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) { + const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; + const hasSPGlobal = !!shipImagePath; + const [activeTab, setActiveTab] = useState(hasSPGlobal ? 'spglobal' : 'marinetraffic'); + const [spgSlideIdx, setSpgSlideIdx] = useState(0); + const [spgErrors, setSpgErrors] = useState>(new Set()); + const [spgImages, setSpgImages] = useState([]); + + useEffect(() => { + setActiveTab(hasSPGlobal ? 'spglobal' : 'marinetraffic'); + setSpgSlideIdx(0); + setSpgErrors(new Set()); + setSpgImages([]); + + if (imo && hasSPGlobal) { + fetchSpgImages(imo).then(setSpgImages); + } else if (shipImagePath) { + setSpgImages([{ picId: 0, path: shipImagePath.replace(/_[12]\.\w+$/, ''), copyright: '', date: '' }]); + } + }, [mmsi, imo, hasSPGlobal, shipImagePath]); + + const spgUrls = useMemo( + () => spgImages.map(img => `${img.path}_2.jpg`), + [spgImages], + ); + const validSpgCount = spgUrls.length; + + const [mtPhoto, setMtPhoto] = useState(() => { + return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined; + }); + + useEffect(() => { + setMtPhoto(vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined); + }, [mmsi]); + + useEffect(() => { + if (activeTab !== 'marinetraffic') return; + if (mtPhoto !== undefined) return; + const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`; + const img = new Image(); + img.onload = () => { + const result = { url: imgUrl }; + vesselPhotoCache.set(mmsi, result); + setMtPhoto(result); + }; + img.onerror = () => { + vesselPhotoCache.set(mmsi, null); + setMtPhoto(null); + }; + img.src = imgUrl; + }, [mmsi, activeTab, mtPhoto]); + + let currentUrl: string | null = null; + if (localUrl) { + currentUrl = localUrl; + } else if (activeTab === 'spglobal' && spgUrls.length > 0 && !spgErrors.has(spgSlideIdx)) { + currentUrl = spgUrls[spgSlideIdx]; + } else if (activeTab === 'marinetraffic' && mtPhoto) { + currentUrl = mtPhoto.url; + } + + if (localUrl) { + return ( +
+
+ Vessel { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+
+ ); + } + + const allSpgFailed = spgUrls.length > 0 && spgUrls.every((_, i) => spgErrors.has(i)); + const noPhoto = (!hasSPGlobal || allSpgFailed) && mtPhoto === null; + + return ( +
+
+ {hasSPGlobal && ( +
setActiveTab('spglobal')} + > + S&P Global +
+ )} +
setActiveTab('marinetraffic')} + > + MarineTraffic +
+
+ +
+ {currentUrl ? ( + Vessel { + if (activeTab === 'spglobal') { + setSpgErrors(prev => new Set(prev).add(spgSlideIdx)); + } + }} + /> + ) : noPhoto ? ( +
+ No photo available +
+ ) : activeTab === 'marinetraffic' && mtPhoto === undefined ? ( +
+ Loading... +
+ ) : ( +
+ No photo available +
+ )} + + {activeTab === 'spglobal' && validSpgCount > 1 && ( + <> + + +
+ {spgUrls.map((_, i) => ( + + ))} +
+ + )} +
+
+ ); +} + +// ── Fleet group type ────────────────────────────────────────────────────────── + +interface FleetMember { + ship: Ship; + role: string; + roleKo: string; +} + +interface FleetGroup { + members: FleetMember[]; + fleetTypeKo: string; +} + +// ── Popup content ───────────────────────────────────────────────────────────── + +const FLEET_ROLE_COLORS: Record = { + pair: '#ef4444', + carrier: '#f97316', + lighting: '#eab308', + mothership: '#dc2626', + subsidiary: '#6b7280', +}; + +interface ShipPopupContentProps { + ship: Ship; + onClose: () => void; + fleetGroup: FleetGroup | null; + isDragging: boolean; + onMouseDown: (e: React.MouseEvent) => void; +} + +const ShipPopupContent = memo(function ShipPopupContent({ + ship, + onClose, + fleetGroup, + isDragging: _isDragging, + onMouseDown, +}: ShipPopupContentProps) { + const { t } = useTranslation('ships'); + const mtType = getMTType(ship); + const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown; + const isMil = isMilitary(ship.category); + const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined; + const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined; + const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : ''; + + return ( +
+ {/* Header — draggable handle */} +
+ {flagEmoji && {flagEmoji}} + {ship.name} + {navyLabel && ( + + {navyLabel} + + )} + +
+ + {/* Photo */} + + + {/* Type tags */} +
+ + {t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })} + + + {t(`categoryLabel.${ship.category}`)} + + {ship.typeDesc && ( + {ship.typeDesc} + )} +
+ + {/* Data grid — paired rows */} +
+
+ MMSI + {ship.mmsi} +
+
+ IMO + {ship.imo || '-'} +
+ + {ship.callSign && ( + <> +
+ {t('popup.callSign')} + {ship.callSign} +
+
+ + )} + +
+ Lat + {ship.lat.toFixed(4)} +
+
+ Lon + {ship.lng.toFixed(4)} +
+ +
+ HDG + {ship.heading.toFixed(1)}° +
+
+ COG + {ship.course.toFixed(1)}° +
+ +
+ SOG + {ship.speed.toFixed(1)} kn +
+
+ Draught + {ship.draught ? `${ship.draught.toFixed(2)}m` : '-'} +
+ +
+ Length + {ship.length ? `${ship.length}m` : '-'} +
+
+ Width + {ship.width ? `${ship.width}m` : '-'} +
+
+ + {/* Long-value fields */} + {ship.status && ( +
+ Status + {ship.status} +
+ )} + {ship.destination && ( +
+ Dest + {ship.destination} +
+ )} + {ship.eta && ( +
+ ETA + {new Date(ship.eta).toLocaleString()} +
+ )} + + {/* Fleet info */} + {fleetGroup && fleetGroup.members.length > 0 && ( +
+
+ {'\uD83D\uDD17'} {fleetGroup.fleetTypeKo} — {fleetGroup.members.length}척 연결 +
+ {fleetGroup.members.slice(0, 5).map(m => ( +
+ + {m.roleKo} + + {m.ship.name || m.ship.mmsi} +
+ ))} + {fleetGroup.members.length > 5 && ( +
...외 {fleetGroup.members.length - 5}척
+ )} +
+ )} + + {/* Footer */} +
+ + {t('popup.lastUpdate')}: {new Date(ship.lastSeen).toLocaleString()} + + + MarineTraffic → + +
+
+ ); +}); + +// ── Position tracking ───────────────────────────────────────────────────────── + +interface ScreenPos { + x: number; + y: number; +} + +// Popup tip/arrow height (CSS triangle pointing downward toward ship) +const POPUP_TIP_HEIGHT = 10; +// Vertical offset above ship icon so popup sits above with some gap +const POPUP_ANCHOR_OFFSET = 16; + +// ── Main overlay component ──────────────────────────────────────────────────── + +export function ShipPopupOverlay() { + const { current: mapRef } = useMap(); + + const selectedMmsi = useShipDeckStore(s => s.selectedMmsi); + const ship = useShipDeckStore(s => + s.selectedMmsi ? s.shipMap.get(s.selectedMmsi) ?? null : null, + ); + const analysisMap = useShipDeckStore(s => s.analysisMap); + const ships = useShipDeckStore(s => s.ships); + + // Compute fleet group from analysis map (same logic as ShipLayer lines 414-455) + const fleetGroup = useMemo((): FleetGroup | null => { + if (!selectedMmsi || !analysisMap) return null; + const dto = analysisMap.get(selectedMmsi); + if (!dto) return null; + const clusterId = dto.algorithms.cluster.clusterId; + if (clusterId < 0) return null; + + const members: FleetMember[] = []; + for (const [mmsi, d] of analysisMap) { + if (d.algorithms.cluster.clusterId !== clusterId) continue; + const memberShip = ships.find(s => s.mmsi === mmsi); + if (!memberShip) continue; + const isLeader = d.algorithms.fleetRole.isLeader; + members.push({ + ship: memberShip, + role: d.algorithms.fleetRole.role, + roleKo: isLeader ? '본선' : '선단원', + }); + } + if (members.length === 0) return null; + return { members, fleetTypeKo: '선단' }; + }, [selectedMmsi, analysisMap, ships]); + + // Screen position of the popup (anchored below ship, updated on map move) + const [screenPos, setScreenPos] = useState(null); + // Once dragged, detach from map tracking and use fixed position + const [draggedPos, setDraggedPos] = useState(null); + + const dragging = useRef(false); + const dragStartOffset = useRef({ x: 0, y: 0 }); + const popupRef = useRef(null); + + // Project ship coordinates to screen, accounting for popup height (anchor bottom) + const projectShipToScreen = useCallback((): ScreenPos | null => { + if (!mapRef || !ship) return null; + const m = mapRef.getMap(); + const point = m.project([ship.lng, ship.lat]); + // Anchor bottom: popup tip points down to ship position + // We want the tip to be at ship pixel, so offset upward by popup height + tip + return { x: point.x, y: point.y }; + }, [mapRef, ship]); + + // Update anchored position on map move / resize (only if not dragged) + useEffect(() => { + if (!mapRef || !ship) { + setScreenPos(null); + return; + } + if (draggedPos !== null) return; // detached, skip + + const update = () => { + setScreenPos(projectShipToScreen()); + }; + + update(); // initial + const m = mapRef.getMap(); + m.on('move', update); + m.on('zoom', update); + return () => { + m.off('move', update); + m.off('zoom', update); + }; + }, [mapRef, ship, draggedPos, projectShipToScreen]); + + // Reset drag state when ship changes + useEffect(() => { + setDraggedPos(null); + setScreenPos(projectShipToScreen()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedMmsi]); + + // Drag handlers + const onMouseDown = useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (!target.closest('.ship-popup-header')) return; + e.preventDefault(); + dragging.current = true; + const el = popupRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + dragStartOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }, []); + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + setDraggedPos({ + x: e.clientX - dragStartOffset.current.x, + y: e.clientY - dragStartOffset.current.y, + }); + }; + const onMouseUp = () => { dragging.current = false; }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }, []); + + const handleClose = useCallback(() => { + useShipDeckStore.getState().setSelectedMmsi(null); + }, []); + + if (!ship) return null; + + // Determine final CSS position + // draggedPos: user dragged → use fixed left/top directly (popup div is positioned inside map container) + // screenPos: anchored to ship → offset upward so tip touches ship + let style: React.CSSProperties; + if (draggedPos !== null) { + style = { + position: 'absolute', + left: draggedPos.x, + top: draggedPos.y, + transform: 'none', + }; + } else if (screenPos !== null) { + // Offset: translate(-50%, -100%) then subtract tip height + anchor gap + // We use transform for centering horizontally and anchoring at bottom + style = { + position: 'absolute', + left: screenPos.x, + top: screenPos.y - POPUP_ANCHOR_OFFSET - POPUP_TIP_HEIGHT, + transform: 'translateX(-50%) translateY(-100%)', + }; + } else { + return null; + } + + return ( +
+ {/* Popup body */} + + {/* CSS triangle arrow pointing down toward ship (only when anchored) */} + {draggedPos === null && ( +
+ )} +
+ ); +} + +// ── Ship Hover Tooltip ─────────────────────────────────────────────────────── + +export function ShipHoverTooltip() { + const hoveredMmsi = useShipDeckStore(s => s.hoveredMmsi); + const hoverScreenPos = useShipDeckStore(s => s.hoverScreenPos); + const ship = useShipDeckStore(s => s.hoveredMmsi ? s.shipMap.get(s.hoveredMmsi) ?? null : null); + const selectedMmsi = useShipDeckStore(s => s.selectedMmsi); + + // 팝업이 열려있으면 툴팁 숨김 + if (!hoveredMmsi || !hoverScreenPos || !ship || selectedMmsi === hoveredMmsi) return null; + + const lastSeen = ship.lastSeen + ? new Date(ship.lastSeen).toLocaleString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : '-'; + + return ( +
+
+
+ {ship.name || 'Unknown'} +
+
MMSI {ship.mmsi}
+
+ {ship.lat.toFixed(4)}, {ship.lng.toFixed(4)} +
+
+ {ship.speed?.toFixed(1) ?? '-'} kn / {ship.heading?.toFixed(0) ?? '-'}° +
+
{lastSeen}
+
+
+ ); +} 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/useFleetClusterDeckLayers.ts b/frontend/src/hooks/useFleetClusterDeckLayers.ts new file mode 100644 index 0000000..1608865 --- /dev/null +++ b/frontend/src/hooks/useFleetClusterDeckLayers.ts @@ -0,0 +1,552 @@ +import { useMemo } from 'react'; +import type { Layer } from '@deck.gl/core'; +import { GeoJsonLayer, IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; +import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg'; +import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants'; +import type { FleetClusterGeoJsonResult } from '../components/korea/useFleetClusterGeoJson'; +import { FONT_MONO } from '../styles/fonts'; +import { clusterLabels } from '../utils/labelCluster'; + +// ── Config ──────────────────────────────────────────────────────────────────── + +export interface FleetClusterDeckConfig { + selectedGearGroup: string | null; + hoveredMmsi: string | null; + hoveredGearGroup: string | null; // gear polygon hover highlight + enabledModels: Set; + historyActive: boolean; + hasCorrelationTracks: boolean; + zoomScale: number; + zoomLevel: number; // integer zoom for label clustering + fontScale?: number; // fontScale.analysis (default 1) + focusMode?: boolean; // 집중 모드 — 라이브 폴리곤/마커 숨김 + onPolygonClick?: (features: PickedPolygonFeature[], coordinate: [number, number]) => void; + onPolygonHover?: (info: { lng: number; lat: number; type: 'fleet' | 'gear'; id: string | number } | null) => void; +} + +export interface PickedPolygonFeature { + type: 'fleet' | 'gear'; + clusterId?: number; + name?: string; + gearCount?: number; + inZone?: boolean; +} + +// ── Hex → RGBA (module-level cache) ────────────────────────────────────────── + +const hexRgbaCache = new Map(); + +function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { + const cacheKey = `${hex}-${alpha}`; + const cached = hexRgbaCache.get(cacheKey); + if (cached) return cached; + const h = hex.replace('#', ''); + let r = parseInt(h.substring(0, 2), 16) || 0; + let g = parseInt(h.substring(2, 4), 16) || 0; + let b = parseInt(h.substring(4, 6), 16) || 0; + // 어두운 색상 밝기 보정 (바다 배경 대비) + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + if (lum < 0.3) { + const boost = 0.3 / Math.max(lum, 0.01); + r = Math.min(255, Math.round(r * boost)); + g = Math.min(255, Math.round(g * boost)); + b = Math.min(255, Math.round(b * boost)); + } + const rgba: [number, number, number, number] = [r, g, b, alpha]; + hexRgbaCache.set(cacheKey, rgba); + return rgba; +} + +// ── Gear cluster color helpers ──────────────────────────────────────────────── + +const GEAR_IN_ZONE_FILL: [number, number, number, number] = [220, 38, 38, 25]; // #dc2626 opacity 0.10 +const GEAR_IN_ZONE_LINE: [number, number, number, number] = [220, 38, 38, 200]; // #dc2626 +const GEAR_OUT_ZONE_FILL: [number, number, number, number] = [249, 115, 22, 25]; // #f97316 opacity 0.10 +const GEAR_OUT_ZONE_LINE: [number, number, number, number] = [249, 115, 22, 200]; // #f97316 + +const ICON_PX = 64; + +// ── Point-in-polygon (ray casting) ────────────────────────────────────────── + +function pointInRing(point: [number, number], ring: number[][]): boolean { + const [px, py] = point; + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i][0], yi = ring[i][1]; + const xj = ring[j][0], yj = ring[j][1]; + if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; +} + +function pointInPolygon(point: [number, number], geometry: GeoJSON.Geometry): boolean { + if (geometry.type === 'Polygon') { + return pointInRing(point, geometry.coordinates[0]); + } + if (geometry.type === 'MultiPolygon') { + return geometry.coordinates.some(poly => pointInRing(point, poly[0])); + } + return false; +} + +/** Find all fleet/gear polygons at a given coordinate */ +function findPolygonsAtPoint( + point: [number, number], + fleetFc: GeoJSON.FeatureCollection, + gearFc: GeoJSON.FeatureCollection, +): PickedPolygonFeature[] { + const results: PickedPolygonFeature[] = []; + for (const f of fleetFc.features) { + if (pointInPolygon(point, f.geometry)) { + results.push({ + type: 'fleet', + clusterId: f.properties?.clusterId, + name: f.properties?.name, + }); + } + } + for (const f of gearFc.features) { + if (pointInPolygon(point, f.geometry)) { + results.push({ + type: 'gear', + name: f.properties?.name, + gearCount: f.properties?.gearCount, + inZone: f.properties?.inZone === 1, + }); + } + } + return results; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +/** + * Converts FleetClusterGeoJsonResult (produced by useFleetClusterGeoJson) into + * deck.gl Layer instances. + * + * Uses useMemo — fleet data changes infrequently (every 5 minutes) and on user + * interaction (hover, select). No Zustand subscribe pattern needed. + */ +export function useFleetClusterDeckLayers( + geo: FleetClusterGeoJsonResult | null, + config: FleetClusterDeckConfig, +): Layer[] { + const { + selectedGearGroup, + hoveredMmsi, + hoveredGearGroup, + enabledModels, + historyActive, + zoomScale, + zoomLevel, + fontScale: fs = 1, + onPolygonClick, + onPolygonHover, + } = config; + + const focusMode = config.focusMode ?? false; + + return useMemo((): Layer[] => { + if (!geo || focusMode) return []; + + const layers: Layer[] = []; + + // ── 1. Fleet polygons (fleetPolygonGeoJSON) ────────────────────────────── + const fleetPoly = geo.fleetPolygonGeoJSON as GeoJSON.FeatureCollection; + if (fleetPoly.features.length > 0) { + layers.push(new GeoJsonLayer({ + id: 'fleet-polygons', + data: fleetPoly, + getFillColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#63b3ed', 25), + getLineColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#63b3ed', 128), + getLineWidth: 1.5, + lineWidthUnits: 'pixels', + filled: true, + stroked: true, + pickable: true, + onHover: (info) => { + if (info.object) { + const f = info.object as GeoJSON.Feature; + const cid = f.properties?.clusterId; + if (cid != null) { + onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'fleet', id: cid }); + } + } else { + onPolygonHover?.(null); + } + }, + onClick: (info) => { + if (!info.object || !info.coordinate || !onPolygonClick) return; + const pt: [number, number] = [info.coordinate[0], info.coordinate[1]]; + onPolygonClick(findPolygonsAtPoint(pt, fleetPoly, gearFc), pt); + }, + updateTriggers: {}, + })); + } + + // ── 2. Hovered fleet highlight (hoveredGeoJSON) ────────────────────────── + const hoveredPoly = geo.hoveredGeoJSON as GeoJSON.FeatureCollection; + if (hoveredPoly.features.length > 0) { + layers.push(new GeoJsonLayer({ + id: 'fleet-hover-highlight', + data: hoveredPoly, + getFillColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#63b3ed', 64), + getLineColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#63b3ed', 200), + getLineWidth: 2, + lineWidthUnits: 'pixels', + filled: true, + stroked: true, + pickable: false, + })); + } + + // ── 3. Fleet 2-ship lines (lineGeoJSON) ────────────────────────────────── + // Currently always empty (server handles 2-ship fleets as Polygon), kept for future + const lineFc = geo.lineGeoJSON as GeoJSON.FeatureCollection; + if (lineFc.features.length > 0) { + layers.push(new GeoJsonLayer({ + id: 'fleet-lines', + data: lineFc, + getLineColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#63b3ed', 180), + getLineWidth: 1.5, + lineWidthUnits: 'pixels', + filled: false, + stroked: true, + pickable: false, + })); + } + + // ── 4. Gear cluster polygons (gearClusterGeoJson) ──────────────────────── + const gearFc = geo.gearClusterGeoJson as GeoJSON.FeatureCollection; + if (gearFc.features.length > 0) { + layers.push(new GeoJsonLayer({ + id: 'gear-cluster-polygons', + data: gearFc, + getFillColor: (f: GeoJSON.Feature) => + f.properties?.inZone === 1 ? GEAR_IN_ZONE_FILL : GEAR_OUT_ZONE_FILL, + getLineColor: (f: GeoJSON.Feature) => + f.properties?.inZone === 1 ? GEAR_IN_ZONE_LINE : GEAR_OUT_ZONE_LINE, + getLineWidth: 1.5, + lineWidthUnits: 'pixels', + filled: true, + stroked: true, + pickable: true, + onHover: (info) => { + if (info.object) { + const f = info.object as GeoJSON.Feature; + const name = f.properties?.name; + if (name) { + onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'gear', id: name }); + } + } else { + onPolygonHover?.(null); + } + }, + onClick: (info) => { + if (!info.object || !info.coordinate || !onPolygonClick) return; + const pt: [number, number] = [info.coordinate[0], info.coordinate[1]]; + onPolygonClick(findPolygonsAtPoint(pt, fleetPoly, gearFc), pt); + }, + })); + } + + // ── 4b. Gear hover highlight ────────────────────────────────────────── + if (hoveredGearGroup && gearFc.features.length > 0) { + const hoveredGearFeatures = gearFc.features.filter( + f => f.properties?.name === hoveredGearGroup, + ); + if (hoveredGearFeatures.length > 0) { + layers.push(new GeoJsonLayer({ + id: 'gear-hover-highlight', + data: { type: 'FeatureCollection' as const, features: hoveredGearFeatures }, + getFillColor: (f: GeoJSON.Feature) => + f.properties?.inZone === 1 ? [220, 38, 38, 64] : [249, 115, 22, 64], + getLineColor: (f: GeoJSON.Feature) => + f.properties?.inZone === 1 ? [220, 38, 38, 255] : [249, 115, 22, 255], + getLineWidth: 2.5, + lineWidthUnits: 'pixels', + filled: true, + stroked: true, + pickable: false, + })); + } + } + + // ── 5. Selected gear highlight (selectedGearHighlightGeoJson) ──────────── + if (geo.selectedGearHighlightGeoJson && geo.selectedGearHighlightGeoJson.features.length > 0) { + layers.push(new GeoJsonLayer({ + id: 'gear-selected-highlight', + data: geo.selectedGearHighlightGeoJson, + getFillColor: [249, 115, 22, 40], + getLineColor: [249, 115, 22, 230], + getLineWidth: 2, + lineWidthUnits: 'pixels', + filled: true, + stroked: true, + pickable: false, + })); + } + + // ── 6. Member markers (memberMarkersGeoJson) — skip when historyActive ─── + if (!historyActive) { + const memberFc = geo.memberMarkersGeoJson as GeoJSON.FeatureCollection; + if (memberFc.features.length > 0) { + layers.push(new IconLayer({ + id: 'fleet-member-icons', + data: memberFc.features, + getPosition: (f: GeoJSON.Feature) => + (f.geometry as GeoJSON.Point).coordinates as [number, number], + getIcon: (f: GeoJSON.Feature) => + f.properties?.isGear === 1 + ? SHIP_ICON_MAPPING['gear-diamond'] + : SHIP_ICON_MAPPING['ship-triangle'], + getSize: (f: GeoJSON.Feature) => + (f.properties?.baseSize ?? 0.14) * zoomScale * ICON_PX, + getAngle: (f: GeoJSON.Feature) => + f.properties?.isGear === 1 ? 0 : -(f.properties?.cog ?? 0), + getColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#9e9e9e'), + sizeUnits: 'pixels', + sizeMinPixels: 3, + billboard: false, + pickable: false, + updateTriggers: { + getSize: [zoomScale, fs], + }, + })); + + const clusteredMembers = clusterLabels( + memberFc.features, + f => (f.geometry as GeoJSON.Point).coordinates as [number, number], + zoomLevel, + ); + layers.push(new TextLayer({ + id: 'fleet-member-labels', + data: clusteredMembers, + getPosition: (f: GeoJSON.Feature) => + (f.geometry as GeoJSON.Point).coordinates as [number, number], + getText: (f: GeoJSON.Feature) => { + const isParent = f.properties?.isParent === 1; + return isParent ? `\u2605 ${f.properties?.name ?? ''}` : (f.properties?.name ?? ''); + }, + getSize: 8 * zoomScale * fs, + getColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#e2e8f0'), + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: FONT_MONO, + background: true, + getBackgroundColor: [0, 0, 0, 200], + backgroundPadding: [3, 1], + billboard: false, + characterSet: 'auto', + updateTriggers: { + getSize: [zoomScale, fs], + }, + })); + } + } + + // ── 7. Picker highlight (pickerHighlightGeoJson) ────────────────────────── + const pickerFc = geo.pickerHighlightGeoJson as GeoJSON.FeatureCollection; + if (pickerFc.features.length > 0) { + layers.push(new GeoJsonLayer({ + id: 'fleet-picker-highlight', + data: pickerFc, + getFillColor: [255, 255, 255, 25], + getLineColor: [255, 255, 255, 200], + getLineWidth: 2, + lineWidthUnits: 'pixels', + filled: true, + stroked: true, + pickable: false, + })); + } + + // ── Correlation layers (only when gear group selected, skip during replay) ─ + if (selectedGearGroup && !historyActive) { + + // ── 8. Operational polygons (per model) ──────────────────────────────── + for (const op of geo.operationalPolygons) { + if (!enabledModels.has(op.modelName)) continue; + if (op.geojson.features.length === 0) continue; + const modelColor = MODEL_COLORS[op.modelName] ?? '#94a3b8'; + layers.push(new GeoJsonLayer({ + id: `fleet-op-polygon-${op.modelName}`, + data: op.geojson, + getFillColor: hexToRgba(modelColor, 30), + getLineColor: hexToRgba(modelColor, 180), + getLineWidth: 1.5, + lineWidthUnits: 'pixels', + filled: true, + stroked: true, + pickable: false, + })); + } + + // ── 9. Correlation trails (correlationTrailGeoJson) ──────────────────── + const trailFc = geo.correlationTrailGeoJson as GeoJSON.FeatureCollection; + if (trailFc.features.length > 0) { + layers.push(new GeoJsonLayer({ + id: 'fleet-correlation-trails', + data: trailFc, + getLineColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#60a5fa', 160), + getLineWidth: 1.5, + lineWidthUnits: 'pixels', + filled: false, + stroked: true, + pickable: false, + })); + } + + // ── 10. Correlation vessels (correlationVesselGeoJson) ───────────────── + const corrVesselFc = geo.correlationVesselGeoJson as GeoJSON.FeatureCollection; + if (corrVesselFc.features.length > 0) { + layers.push(new IconLayer({ + id: 'fleet-correlation-vessel-icons', + data: corrVesselFc.features, + getPosition: (f: GeoJSON.Feature) => + (f.geometry as GeoJSON.Point).coordinates as [number, number], + getIcon: () => SHIP_ICON_MAPPING['ship-triangle'], + getSize: () => + 0.14 * zoomScale * ICON_PX, + getAngle: (f: GeoJSON.Feature) => -(f.properties?.cog ?? 0), + getColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#60a5fa'), + sizeUnits: 'pixels', + sizeMinPixels: 3, + billboard: false, + pickable: false, + updateTriggers: { + getSize: [zoomScale, fs], + }, + })); + + const clusteredCorr = clusterLabels( + corrVesselFc.features, + f => (f.geometry as GeoJSON.Point).coordinates as [number, number], + zoomLevel, + ); + layers.push(new TextLayer({ + id: 'fleet-correlation-vessel-labels', + data: clusteredCorr, + getPosition: (f: GeoJSON.Feature) => + (f.geometry as GeoJSON.Point).coordinates as [number, number], + getText: (f: GeoJSON.Feature) => f.properties?.name ?? '', + getSize: 8 * zoomScale * fs, + getColor: (f: GeoJSON.Feature) => + hexToRgba(f.properties?.color ?? '#60a5fa'), + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: FONT_MONO, + background: true, + getBackgroundColor: [0, 0, 0, 200], + backgroundPadding: [3, 1], + billboard: false, + characterSet: 'auto', + updateTriggers: { + getSize: [zoomScale, fs], + }, + })); + } + + // ── 11. Model badges (modelBadgesGeoJson) ───────────────────────────── + // Rendered as small ScatterplotLayer dots, one layer per active model. + // Position is offset in world coordinates (small lng offset per model index). + // Badge size is intentionally small (4px) as visual indicators only. + const badgeFc = geo.modelBadgesGeoJson as GeoJSON.FeatureCollection; + if (badgeFc.features.length > 0) { + MODEL_ORDER.forEach((modelName, i) => { + if (!enabledModels.has(modelName)) return; + const modelColor = MODEL_COLORS[modelName] ?? '#94a3b8'; + const activeFeatures = badgeFc.features.filter( + (f) => f.properties?.[`m${i}`] === 1, + ); + if (activeFeatures.length === 0) return; + + // Small lng offset per model index to avoid overlap (≈ 300m at z10) + const lngOffset = i * 0.003; + + layers.push(new ScatterplotLayer({ + id: `fleet-model-badge-${modelName}`, + data: activeFeatures, + getPosition: (f: GeoJSON.Feature) => { + const [lng, lat] = (f.geometry as GeoJSON.Point).coordinates; + return [lng + lngOffset, lat] as [number, number]; + }, + getRadius: 4, + getFillColor: hexToRgba(modelColor, 230), + getLineColor: [0, 0, 0, 200], + getLineWidth: 1, + stroked: true, + filled: true, + radiusUnits: 'pixels', + lineWidthUnits: 'pixels', + pickable: false, + })); + }); + } + + // ── 12. Hover highlight (hoverHighlightGeoJson + trail) ─────────────── + if (hoveredMmsi) { + const hoverFc = geo.hoverHighlightGeoJson as GeoJSON.FeatureCollection; + if (hoverFc.features.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'fleet-hover-ring', + data: hoverFc.features, + getPosition: (f: GeoJSON.Feature) => + (f.geometry as GeoJSON.Point).coordinates as [number, number], + getRadius: 18, + getFillColor: [255, 255, 255, 20], + getLineColor: [255, 255, 255, 200], + getLineWidth: 2, + stroked: true, + filled: true, + radiusUnits: 'pixels', + lineWidthUnits: 'pixels', + pickable: false, + })); + } + + const hoverTrailFc = geo.hoverHighlightTrailGeoJson as GeoJSON.FeatureCollection; + if (hoverTrailFc.features.length > 0) { + layers.push(new GeoJsonLayer({ + id: 'fleet-hover-trail', + data: hoverTrailFc, + getLineColor: [255, 255, 255, 150], + getLineWidth: 1.5, + lineWidthUnits: 'pixels', + filled: false, + stroked: true, + pickable: false, + })); + } + } + } + + return layers; + }, [ + geo, + selectedGearGroup, + hoveredMmsi, + hoveredGearGroup, + enabledModels, + historyActive, + zoomScale, + zoomLevel, + fs, + focusMode, + onPolygonClick, + onPolygonHover, + ]); +} diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index d121090..010fd8d 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -3,12 +3,15 @@ import type { Layer } from '@deck.gl/core'; import { TripsLayer } from '@deck.gl/geo-layers'; import { ScatterplotLayer, IconLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers'; import { useGearReplayStore } from '../stores/gearReplayStore'; -import { findFrameAtTime, interpolateMemberPositions } from '../stores/gearReplayPreprocess'; +import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess'; import type { MemberPosition } from '../stores/gearReplayPreprocess'; import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants'; import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; import type { GearCorrelationItem } from '../services/vesselAnalysis'; import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg'; +import { useFontScale } from './useFontScale'; +import { useShipDeckStore } from '../stores/shipDeckStore'; +import { clusterLabels } from '../utils/labelCluster'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -60,6 +63,7 @@ export function useGearReplayLayers( const correlationTripsData = useGearReplayStore(s => s.correlationTripsData); const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments); const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions); + const subClusterCenters = useGearReplayStore(s => s.subClusterCenters); const enabledModels = useGearReplayStore(s => s.enabledModels); const enabledVessels = useGearReplayStore(s => s.enabledVessels); const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); @@ -67,6 +71,17 @@ 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); // ── Refs ───────────────────────────────────────────────────────────────── const cursorRef = useRef(0); // frame cursor for O(1) forward lookup @@ -96,44 +111,105 @@ export function useGearReplayLayers( const layers: Layer[] = []; - // ── 항상 표시: 센터 트레일 + 도트 ────────────────────────────────── + // ── 항상 표시: 센터 트레일 ────────────────────────────────── + // 서브클러스터가 존재하면 서브클러스터별 독립 trail만 표시 (전체 trail 숨김) + const hasSubClusters = subClusterCenters.length > 0 && + subClusterCenters.some(sc => sc.subClusterId > 0); - // Center trail segments (PathLayer) — 항상 ON - for (let i = 0; i < centerTrailSegments.length; i++) { - const seg = centerTrailSegments[i]; - if (seg.path.length < 2) continue; - layers.push(new PathLayer({ - id: `replay-center-trail-${i}`, - data: [{ path: seg.path }], - getPath: (d: { path: [number, number][] }) => d.path, - getColor: seg.isInterpolated - ? [249, 115, 22, 200] - : [251, 191, 36, 180], - widthMinPixels: 2, - })); + const SUB_TRAIL_COLORS: [number, number, number, number][] = [ + [251, 191, 36, 200], // sub=0 (unified) — gold + [96, 165, 250, 200], // sub=1 — blue + [74, 222, 128, 200], // sub=2 — green + [251, 146, 60, 200], // sub=3 — orange + [167, 139, 250, 200], // sub=4 — purple + ]; + + if (hasSubClusters) { + // 서브클러스터별 독립 center trail (sub=0 합산 trail 제외) + for (const sc of subClusterCenters) { + if (sc.subClusterId === 0) continue; // 합산 center는 점프 유발 → 제외 + if (sc.path.length < 2) continue; + const color = SUB_TRAIL_COLORS[sc.subClusterId % SUB_TRAIL_COLORS.length]; + layers.push(new PathLayer({ + id: `replay-sub-center-${sc.subClusterId}`, + data: [{ path: sc.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: color, + widthMinPixels: 2, + })); + } + } else { + // 서브클러스터 없음: 기존 전체 center trail + dots + for (let i = 0; i < centerTrailSegments.length; i++) { + const seg = centerTrailSegments[i]; + if (seg.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-center-trail-${i}`, + data: [{ path: seg.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: seg.isInterpolated + ? [249, 115, 22, 200] + : [251, 191, 36, 180], + widthMinPixels: 2, + })); + } + if (centerDotsPositions.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-center-dots', + data: centerDotsPositions, + getPosition: (d: [number, number]) => d, + getFillColor: [251, 191, 36, 150], + getRadius: 80, + radiusUnits: 'meters', + radiusMinPixels: 2.5, + })); + } } - // Center dots (real data only) — 항상 ON - if (centerDotsPositions.length > 0) { - layers.push(new ScatterplotLayer({ - id: 'replay-center-dots', - data: centerDotsPositions, - getPosition: (d: [number, number]) => d, - getFillColor: [251, 191, 36, 150], - getRadius: 80, - radiusUnits: 'meters', - radiusMinPixels: 2.5, - })); + // ── 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; @@ -142,6 +218,9 @@ export function useGearReplayLayers( const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct); const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]); + // 서브클러스터 프레임 (identity 폴리곤 + operational 폴리곤에서 공유) + const subFrames = frame.subFrames ?? [{ subClusterId: 0, centerLon: frame.centerLon, centerLat: frame.centerLat, members: frame.members, memberCount: frame.memberCount }]; + // ── "항적" 토글: 전체 24h 경로 PathLayer (정적 배경) ───────────── if (showTrails) { // 멤버 전체 항적 (identity — 항상 ON) @@ -224,24 +303,30 @@ export function useGearReplayLayers( billboard: false, })); - // Member labels — showLabels 제어 - if (showLabels) layers.push(new TextLayer({ + // Member labels — showLabels 제어 + 줌 레벨별 클러스터 + if (showLabels) { + const clusteredMembers = clusterLabels(members, d => [d.lon, d.lat], zoomLevel); + layers.push(new TextLayer({ id: 'replay-member-labels', - data: members, + data: clusteredMembers, getPosition: d => [d.lon, d.lat], - getText: d => d.name || d.mmsi, + getText: d => { + const prefix = d.isParent ? '\u2605 ' : ''; + return prefix + (d.name || d.mmsi); + }, getColor: d => d.stale ? [148, 163, 184, 200] : d.isGear ? [226, 232, 240, 255] : [251, 191, 36, 255], - getSize: 10, + getSize: 10 * fs, getPixelOffset: [0, 14], background: true, getBackgroundColor: [0, 0, 0, 200], backgroundPadding: [2, 1], fontFamily: '"Fira Code Variable", monospace', })); + } } // 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback) @@ -368,18 +453,21 @@ export function useGearReplayLayers( billboard: false, })); - if (showLabels) layers.push(new TextLayer({ + if (showLabels) { + const clusteredCorr = clusterLabels(corrPositions, d => [d.lon, d.lat], zoomLevel); + layers.push(new TextLayer({ id: 'replay-corr-labels', - data: corrPositions, + data: clusteredCorr, getPosition: d => [d.lon, d.lat], getText: d => d.name, getColor: d => d.color, - getSize: 8, + getSize: 8 * fs, getPixelOffset: [0, 15], background: true, getBackgroundColor: [0, 0, 0, 200], backgroundPadding: [2, 1], })); + } } // 7. Hover highlight @@ -440,39 +528,121 @@ export function useGearReplayLayers( } } - // 8. Operational polygons (멤버 위치 + enabledVessels ON인 연관 선박으로 폴리곤 생성) + // 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; const color = MODEL_COLORS[mn] ?? '#94a3b8'; const [r, g, b] = hexToRgb(color); - const extraPts: [number, number][] = []; + // 연관 선박을 subClusterId로 그룹핑 + const subExtras = new Map(); for (const c of items as GearCorrelationItem[]) { - // enabledVessels로 개별 on/off 제어 (토글 대응) if (!enabledVessels.has(c.targetMmsi)) continue; const cp = corrPositions.find(p => p.mmsi === c.targetMmsi); - if (cp) extraPts.push([cp.lon, cp.lat]); + if (!cp) continue; + const sid = c.subClusterId ?? 0; + const list = subExtras.get(sid) ?? []; + list.push([cp.lon, cp.lat]); + subExtras.set(sid, list); } - if (extraPts.length === 0) continue; - const basePts = memberPts; // identity 항상 ON - const opPolygon = buildInterpPolygon([...basePts, ...extraPts]); - if (!opPolygon) continue; - - layers.push(new PolygonLayer({ - id: `replay-op-${mn}`, - data: [{ polygon: opPolygon.coordinates }], - getPolygon: (d: { polygon: number[][][] }) => d.polygon, - getFillColor: [r, g, b, 30], - getLineColor: [r, g, b, 200], - getLineWidth: 2, - lineWidthMinPixels: 2, - filled: true, - stroked: true, - })); + for (const [sid, extraPts] of subExtras) { + if (extraPts.length === 0) continue; + // 해당 서브클러스터의 멤버 포인트 + const sf = subFrames.find(s => s.subClusterId === sid); + const basePts: [number, number][] = sf + ? interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sid).map(m => [m.lon, m.lat]) + : memberPts; // fallback: 전체 멤버 + const opPolygon = buildInterpPolygon([...basePts, ...extraPts]); + if (opPolygon) { + layers.push(new PolygonLayer({ + id: `replay-op-${mn}-sub${sid}`, + data: [{ polygon: opPolygon.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: [r, g, b, 30], + getLineColor: [r, g, b, 200], + getLineWidth: 2, lineWidthMinPixels: 2, filled: true, stroked: true, + })); + } + } } - // 8.5. Model center trails + current center point (모델별 폴리곤 중심 경로) + // 8.5. Model center trails + current center point (모델×서브클러스터별 중심 경로) for (const trail of modelCenterTrails) { if (!enabledModels.has(trail.modelName)) continue; if (trail.path.length < 2) continue; @@ -481,7 +651,7 @@ export function useGearReplayLayers( // 중심 경로 (PathLayer, 연한 모델 색상) layers.push(new PathLayer({ - id: `replay-model-trail-${trail.modelName}`, + id: `replay-model-trail-${trail.modelName}-s${trail.subClusterId}`, data: [{ path: trail.path }], getPath: (d: { path: [number, number][] }) => d.path, getColor: [r, g, b, 100], @@ -499,7 +669,7 @@ export function useGearReplayLayers( const centerData = [{ position: [cx, cy] as [number, number] }]; layers.push(new ScatterplotLayer({ - id: `replay-model-center-${trail.modelName}`, + id: `replay-model-center-${trail.modelName}-s${trail.subClusterId}`, data: centerData, getPosition: (d: { position: [number, number] }) => d.position, getFillColor: [r, g, b, 255], @@ -512,12 +682,12 @@ export function useGearReplayLayers( })); if (showLabels) { layers.push(new TextLayer({ - id: `replay-model-center-label-${trail.modelName}`, + id: `replay-model-center-label-${trail.modelName}-s${trail.subClusterId}`, data: centerData, getPosition: (d: { position: [number, number] }) => d.position, getText: () => trail.modelName, getColor: [r, g, b, 255], - getSize: 9, + getSize: 9 * fs, getPixelOffset: [0, -12], background: true, getBackgroundColor: [0, 0, 0, 200], @@ -580,22 +750,52 @@ export function useGearReplayLayers( } } - // ══ Identity 레이어 (최상위 z-index — 항상 ON, 다른 모델 위에 표시) ══ - // 폴리곤 - const identityPolygon = buildInterpPolygon(memberPts); - if (identityPolygon) { - layers.push(new PolygonLayer({ - id: 'replay-identity-polygon', - data: [{ polygon: identityPolygon.coordinates }], - getPolygon: (d: { polygon: number[][][] }) => d.polygon, - getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40], - getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180], - getLineWidth: isStale ? 1 : 2, - lineWidthMinPixels: 1, - filled: true, - stroked: true, - })); + // ══ Identity 레이어 (최상위 z-index — 서브클러스터별 독립 폴리곤) ══ + const SUB_POLY_COLORS: [number, number, number, number][] = [ + [251, 191, 36, 40], // sub0 — gold + [96, 165, 250, 30], // sub1 — blue + [74, 222, 128, 30], // sub2 — green + [251, 146, 60, 30], // sub3 — orange + [167, 139, 250, 30], // sub4 — purple + ]; + const SUB_STROKE_COLORS: [number, number, number, number][] = [ + [251, 191, 36, 180], + [96, 165, 250, 180], + [74, 222, 128, 180], + [251, 146, 60, 180], + [167, 139, 250, 180], + ]; + const SUB_CENTER_COLORS: [number, number, number, number][] = [ + [239, 68, 68, 255], + [96, 165, 250, 255], + [74, 222, 128, 255], + [251, 146, 60, 255], + [167, 139, 250, 255], + ]; + + // ── 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-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 (멤버 트레일) if (memberTripsData.length > 0) { layers.push(new TripsLayer({ @@ -610,27 +810,155 @@ export function useGearReplayLayers( currentTime: ct - st, })); } - // 센터 포인트 - layers.push(new ScatterplotLayer({ - id: 'replay-identity-center', - data: [{ position: [frame.centerLon, frame.centerLat] as [number, number] }], - getPosition: (d: { position: [number, number] }) => d.position, - getFillColor: isStale ? [249, 115, 22, 255] : [239, 68, 68, 255], - getRadius: 200, - radiusUnits: 'meters', - radiusMinPixels: 7, - stroked: true, - getLineColor: [255, 255, 255, 255], - lineWidthMinPixels: 2, - })); + + // 센터 포인트 (서브클러스터별 독립) + for (const sf of subFrames) { + // 다음 프레임의 같은 서브클러스터 센터와 보간 + const nextFrame = frameIdx < state.historyFrames.length - 1 ? state.historyFrames[frameIdx + 1] : null; + const nextSf = nextFrame?.subFrames?.find(s => s.subClusterId === sf.subClusterId); + let cx = sf.centerLon, cy = sf.centerLat; + if (nextSf && nextFrame) { + const t0 = new Date(frame.snapshotTime).getTime(); + const t1 = new Date(nextFrame.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; + } + const ci = sf.subClusterId % SUB_CENTER_COLORS.length; + layers.push(new ScatterplotLayer({ + id: `replay-identity-center-sub${sf.subClusterId}`, + data: [{ position: [cx, cy] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: isStale ? [249, 115, 22, 255] : SUB_CENTER_COLORS[ci], + getRadius: 200, + radiusUnits: 'meters', + radiusMinPixels: 7, + stroked: true, + getLineColor: [255, 255, 255, 255], + lineWidthMinPixels: 2, + })); + } + + } // 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, showTrails, showLabels, + modelCenterTrails, subClusterCenters, showTrails, showLabels, + show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel, replayLayerRef, requestRender, ]); @@ -649,7 +977,12 @@ export function useGearReplayLayers( // ── zustand.subscribe effect (currentTime → renderFrame) ───────────────── useEffect(() => { - if (historyFrames.length === 0) return; + if (historyFrames.length === 0) { + // Reset 시 레이어 클리어 + replayLayerRef.current = []; + requestRender(); + return; + } // Initial render renderFrame(); @@ -680,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/hooks/useGroupPolygons.ts b/frontend/src/hooks/useGroupPolygons.ts index b3cd9af..82dada9 100644 --- a/frontend/src/hooks/useGroupPolygons.ts +++ b/frontend/src/hooks/useGroupPolygons.ts @@ -4,6 +4,33 @@ import type { GroupPolygonDto } from '../services/vesselAnalysis'; const POLL_INTERVAL_MS = 5 * 60_000; // 5분 +/** 같은 groupKey의 서브클러스터를 하나로 합산 (멤버 합산, 가장 큰 폴리곤 사용) */ +function mergeByGroupKey(groups: GroupPolygonDto[]): GroupPolygonDto[] { + const byKey = new Map(); + for (const g of groups) { + const list = byKey.get(g.groupKey) ?? []; + list.push(g); + byKey.set(g.groupKey, list); + } + const result: GroupPolygonDto[] = []; + for (const [, items] of byKey) { + if (items.length === 1) { result.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); + result.push({ + ...biggest, + subClusterId: 0, + members: allMembers, + memberCount: allMembers.length, + }); + } + return result; +} + export interface UseGroupPolygonsResult { fleetGroups: GroupPolygonDto[]; gearInZoneGroups: GroupPolygonDto[]; @@ -49,17 +76,17 @@ export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult { }, [enabled, load]); const fleetGroups = useMemo( - () => allGroups.filter(g => g.groupType === 'FLEET'), + () => mergeByGroupKey(allGroups.filter(g => g.groupType === 'FLEET')), [allGroups], ); const gearInZoneGroups = useMemo( - () => allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE'), + () => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE')), [allGroups], ); const gearOutZoneGroups = useMemo( - () => allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE'), + () => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE')), [allGroups], ); diff --git a/frontend/src/hooks/useShipDeckLayers.ts b/frontend/src/hooks/useShipDeckLayers.ts new file mode 100644 index 0000000..bdf1e88 --- /dev/null +++ b/frontend/src/hooks/useShipDeckLayers.ts @@ -0,0 +1,352 @@ +import { useEffect, useCallback } from 'react'; +import type { Layer } from '@deck.gl/core'; +import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers'; +import { useShipDeckStore } from '../stores/shipDeckStore'; +import { useGearReplayStore } from '../stores/gearReplayStore'; +import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg'; +import { MT_TYPE_HEX, getMTType, SIZE_MAP, isMilitary } from '../utils/shipClassification'; +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 ───────────────────────────────────────────────────────────────── + +/** Zoom level → icon scale multiplier (matches MapLibre interpolate) */ +const ZOOM_SCALE: Record = { + 4: 0.8, 5: 0.9, 6: 1.0, 7: 1.2, 8: 1.5, 9: 1.8, + 10: 2.2, 11: 2.5, 12: 2.8, 13: 3.5, +}; +const ZOOM_SCALE_DEFAULT = 4.2; // z14+ + +export function getZoomScale(zoom: number): number { + if (zoom >= 14) return ZOOM_SCALE_DEFAULT; + return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT; +} + +/** MapLibre icon-size is a multiplier on native icon size (64px SVG). + * deck.gl getSize with sizeUnits='pixels' specifies actual pixel height. + * So: baseSize(0.16) * zoomScale(1.0) * 64 = 10.24px ≈ MapLibre equivalent. */ +const ICON_PX = 64; + +const GEAR_RE = /^.+?_\d+_\d+_?$/; + +// ── Hex → RGBA conversion (cached per session) ────────────────────────────── + +const hexCache = new Map(); + +function hexToRgba(hex: string, alpha = 230): [number, number, number, number] { + const cached = hexCache.get(hex); + if (cached) return cached; + const h = hex.replace('#', ''); + const rgba: [number, number, number, number] = [ + parseInt(h.substring(0, 2), 16), + parseInt(h.substring(2, 4), 16), + parseInt(h.substring(4, 6), 16), + alpha, + ]; + hexCache.set(hex, rgba); + return rgba; +} + +// ── Pre-computed ship render datum (avoids repeated computation in accessors) + +interface ShipRenderDatum { + mmsi: string; + name: string; + lng: number; + lat: number; + heading: number; + isGear: boolean; + isKorean: boolean; + isMil: boolean; + category: string; + color: [number, number, number, number]; + baseSize: number; // SIZE_MAP value +} + +function buildShipRenderData( + ships: Ship[], + militaryOnly: boolean, + hiddenCategories: Set, + hiddenNationalities: Set, +): ShipRenderDatum[] { + const result: ShipRenderDatum[] = []; + for (const ship of ships) { + const mtCategory = getMarineTrafficCategory(ship.typecode, ship.category); + const natGroup = getNationalityGroup(ship.flag); + + // CPU-side filtering + if (militaryOnly && !isMilitary(ship.category)) continue; + if (hiddenCategories.size > 0 && hiddenCategories.has(mtCategory)) continue; + if (hiddenNationalities.size > 0 && hiddenNationalities.has(natGroup)) continue; + + const isGear = GEAR_RE.test(ship.name || ''); + const hex = MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown; + + result.push({ + mmsi: ship.mmsi, + name: ship.name || '', + lng: ship.lng, + lat: ship.lat, + heading: ship.heading, + isGear, + isKorean: ship.flag === 'KR', + isMil: isMilitary(ship.category), + category: ship.category, + color: hexToRgba(hex), + baseSize: (isGear ? 0.8 : 1) * (SIZE_MAP[ship.category] ?? 0.12), + }); + } + return result; +} + +// ── Analysis ship markers ──────────────────────────────────────────────────── + +interface AnalysisRenderDatum { + mmsi: string; + lng: number; + lat: number; + cog: number; + isGear: boolean; + color: [number, number, number, number]; + baseSize: number; +} + +const RISK_COLORS: Record = { + CRITICAL: [239, 68, 68, 255], + HIGH: [249, 115, 22, 255], + MEDIUM: [234, 179, 8, 255], + LOW: [34, 197, 94, 255], +}; + +function buildAnalysisData( + ships: Ship[], + analysisMap: Map, +): AnalysisRenderDatum[] { + const result: AnalysisRenderDatum[] = []; + for (const ship of ships) { + const dto = analysisMap.get(ship.mmsi); + if (!dto) continue; + const level = dto.algorithms.riskScore.level; + const isGear = GEAR_RE.test(ship.name || ''); + result.push({ + mmsi: ship.mmsi, + lng: ship.lng, + lat: ship.lat, + cog: ship.heading ?? 0, + isGear, + color: RISK_COLORS[level] ?? RISK_COLORS.LOW, + baseSize: 0.16, + }); + } + return result; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +/** + * Builds deck.gl layers for live ship rendering. + * + * Uses zustand.subscribe to bypass React re-render cycle. + * Ship data updates (5s polling) and filter/hover/zoom changes + * trigger imperative layer rebuild → overlay.setProps(). + */ +export function useShipDeckLayers( + shipLayerRef: React.MutableRefObject, + requestRender: () => void, +): void { + const { symbolScale } = useSymbolScale(); + const shipSymbolScale = symbolScale.ship; + + const renderFrame = useCallback(() => { + const state = useShipDeckStore.getState(); + const { ships, layerVisible, militaryOnly, hiddenShipCategories, hiddenNationalities, + hoveredMmsi, highlightKorean, zoomLevel, analysisMap, analysisActiveFilter } = state; + + // Layer off or focus mode → clear + const focusMode = useGearReplayStore.getState().focusMode; + if (!layerVisible || ships.length === 0 || focusMode) { + shipLayerRef.current = []; + requestRender(); + return; + } + + const zoomScale = getZoomScale(zoomLevel) * shipSymbolScale; + const layers: Layer[] = []; + + // 1. Build filtered ship render data (~3K ships, <1ms) + const data = buildShipRenderData(ships, militaryOnly, hiddenShipCategories, hiddenNationalities); + + // 2. Main ship icons — IconLayer + layers.push(new IconLayer({ + id: 'ship-icons', + data, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => d.isGear + ? SHIP_ICON_MAPPING['gear-diamond'] + : SHIP_ICON_MAPPING['ship-triangle'], + getSize: (d) => d.baseSize * zoomScale * ICON_PX, + getAngle: (d) => d.isGear ? 0 : -d.heading, + getColor: (d) => d.color, + sizeUnits: 'pixels', + sizeMinPixels: 3, + billboard: false, + pickable: true, + onClick: (info) => { + if (info.object) { + useShipDeckStore.getState().setSelectedMmsi(info.object.mmsi); + } + }, + onHover: (info) => { + useShipDeckStore.getState().setHoveredMmsi( + info.object?.mmsi ?? null, + info.object ? { x: info.x, y: info.y } : undefined, + ); + }, + updateTriggers: { + getSize: [zoomScale], + }, + })); + + // 3. Korean ship rings + labels — only when highlightKorean is active + if (highlightKorean) { + const koreanShips = data.filter(d => d.isKorean); + if (koreanShips.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'korean-ship-rings', + data: koreanShips, + getPosition: (d) => [d.lng, d.lat], + getRadius: 10 * zoomScale, + getFillColor: [0, 229, 255, 20], + getLineColor: [0, 229, 255, 255], + getLineWidth: 2.5, + stroked: true, + filled: true, + radiusUnits: 'pixels', + lineWidthUnits: 'pixels', + updateTriggers: { + getRadius: [zoomScale], + }, + })); + + layers.push(new TextLayer({ + id: 'korean-ship-labels', + data: koreanShips, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name || d.mmsi, + getSize: 11 * zoomScale, + getColor: [0, 229, 255, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 14], + fontFamily: FONT_MONO, + fontSettings: { sdf: true }, + outlineWidth: 3, + outlineColor: [0, 0, 0, 255], + billboard: false, + characterSet: 'auto', + updateTriggers: { getSize: [zoomScale] }, + })); + } + } + + // 4. Hover highlight — ScatterplotLayer (conditional) + if (hoveredMmsi) { + const hoveredShip = data.find(d => d.mmsi === hoveredMmsi); + if (hoveredShip) { + layers.push(new ScatterplotLayer({ + id: 'ship-hover-highlight', + data: [hoveredShip], + getPosition: (d: ShipRenderDatum) => [d.lng, d.lat], + getRadius: 18, + getFillColor: [255, 255, 255, 25], + getLineColor: [255, 255, 255, 230], + getLineWidth: 2, + stroked: true, + filled: true, + radiusUnits: 'pixels', + lineWidthUnits: 'pixels', + })); + } + } + + // 5. Carrier labels — TextLayer (very few ships) + const carriers = data.filter(d => d.category === 'carrier'); + if (carriers.length > 0) { + layers.push(new TextLayer({ + id: 'carrier-labels', + data: carriers, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name, + getSize: 12 * zoomScale, + getColor: (d) => d.color, + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 16], + fontFamily: FONT_MONO, + fontSettings: { sdf: true }, + outlineWidth: 3, + outlineColor: [0, 0, 0, 255], + billboard: false, + characterSet: 'auto', + updateTriggers: { getSize: [zoomScale] }, + })); + } + + // 6. Analysis ship markers — IconLayer (conditional on analysisActiveFilter) + if (analysisMap && analysisActiveFilter) { + const analysisData = buildAnalysisData(ships, analysisMap); + if (analysisData.length > 0) { + layers.push(new IconLayer({ + id: 'analysis-ship-markers', + data: analysisData, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => d.isGear + ? SHIP_ICON_MAPPING['gear-diamond'] + : SHIP_ICON_MAPPING['ship-triangle'], + getSize: (d) => d.baseSize * zoomScale * ICON_PX * 1.3, + getAngle: (d) => d.isGear ? 0 : -d.cog, + getColor: (d) => d.color, + sizeUnits: 'pixels', + sizeMinPixels: 4, + billboard: false, + updateTriggers: { getSize: [zoomScale] }, + })); + } + } + + shipLayerRef.current = layers; + requestRender(); + }, [shipLayerRef, requestRender, shipSymbolScale]); + + // Subscribe to all relevant state changes + useEffect(() => { + renderFrame(); // initial render + + const unsub = useShipDeckStore.subscribe( + (s) => ({ + ships: s.ships, + militaryOnly: s.militaryOnly, + hiddenShipCategories: s.hiddenShipCategories, + hiddenNationalities: s.hiddenNationalities, + layerVisible: s.layerVisible, + hoveredMmsi: s.hoveredMmsi, + highlightKorean: s.highlightKorean, + zoomLevel: s.zoomLevel, + analysisMap: s.analysisMap, + analysisActiveFilter: s.analysisActiveFilter, + }), + () => renderFrame(), + ); + + // focusMode 변경 시에도 레이어 갱신 + const unsubFocus = useGearReplayStore.subscribe( + s => s.focusMode, + () => renderFrame(), + ); + + return () => { unsub(); unsubFocus(); }; + }, [renderFrame]); +} 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); +} diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index 1a6d493..a1c470c 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -62,6 +62,7 @@ export interface GroupPolygonDto { groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE'; groupKey: string; groupLabel: string; + subClusterId: number; // 0=단일/병합, 1,2,...=서브클러스터 snapshotTime: string; polygon: GeoJSON.Polygon | null; centerLat: number; @@ -72,6 +73,7 @@ export interface GroupPolygonDto { zoneName: string | null; members: MemberInfo[]; color: string; + resolution?: '1h' | '6h'; } export async function fetchGroupPolygons(): Promise { @@ -105,6 +107,7 @@ export interface GearCorrelationItem { streak: number; observations: number; freezeState: string; + subClusterId: number; proximityRatio: number | null; visitScore: number | null; headingCoherence: number | null; diff --git a/frontend/src/stores/gearReplayPreprocess.ts b/frontend/src/stores/gearReplayPreprocess.ts index 239c938..d272307 100644 --- a/frontend/src/stores/gearReplayPreprocess.ts +++ b/frontend/src/stores/gearReplayPreprocess.ts @@ -1,4 +1,4 @@ -import type { HistoryFrame } from '../components/korea/fleetClusterTypes'; +import type { HistoryFrame, SubFrame } from '../components/korea/fleetClusterTypes'; import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis'; import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; @@ -235,16 +235,104 @@ export function interpolateMemberPositions( }); } +/** + * interpolateMemberPositions와 동일한 보간 로직이지만, + * 특정 subClusterId에 속한 멤버만 스코프한다. + * subClusterId에 해당하는 SubFrame이 없으면 빈 배열을 반환한다. + */ +export function interpolateSubFrameMembers( + frames: HistoryFrame[], + frameIdx: number, + timeMs: number, + subClusterId: number, +): MemberPosition[] { + if (frameIdx < 0 || frameIdx >= frames.length) return []; + + const frame = frames[frameIdx]; + const subFrame: SubFrame | undefined = frame.subFrames.find(sf => sf.subClusterId === subClusterId); + if (!subFrame) return []; + + const isStale = !!frame._longGap || !!frame._interp; + + const toPosition = ( + m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean }, + lon: number, + lat: number, + cog: number, + ): MemberPosition => ({ + mmsi: m.mmsi, + name: m.name, + lon, + lat, + cog, + role: m.role, + isParent: m.isParent, + isGear: m.role === 'GEAR' || !m.isParent, + stale: isStale, + }); + + // 다음 프레임 없음 — 현재 subFrame 위치 그대로 반환 + if (frameIdx >= frames.length - 1) { + return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog)); + } + + const nextFrame = frames[frameIdx + 1]; + const nextSubFrame: SubFrame | undefined = nextFrame.subFrames.find( + sf => sf.subClusterId === subClusterId, + ); + + // 다음 프레임에 해당 subClusterId 없음 — 현재 위치 그대로 반환 + if (!nextSubFrame) { + return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog)); + } + + const t0 = new Date(frame.snapshotTime).getTime(); + const t1 = new Date(nextFrame.snapshotTime).getTime(); + const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0; + + const nextMap = new Map(nextSubFrame.members.map(m => [m.mmsi, m])); + + return subFrame.members.map(m => { + const nm = nextMap.get(m.mmsi); + if (!nm) { + return toPosition(m, m.lon, m.lat, m.cog); + } + return toPosition( + m, + m.lon + (nm.lon - m.lon) * ratio, + m.lat + (nm.lat - m.lat) * ratio, + nm.cog, + ); + }); +} + /** * 모델별 오퍼레이셔널 폴리곤 중심 경로 사전 계산. * 각 프레임마다 멤버 위치 + 연관 선박 위치(트랙 보간)로 폴리곤을 만들고 중심점을 기록. */ export interface ModelCenterTrail { modelName: string; + subClusterId: number; // 서브클러스터별 독립 trail path: [number, number][]; // [lon, lat][] timestamps: number[]; // relative ms } +/** 트랙 맵에서 특정 시점의 보간 위치 조회 */ +function _interpTrackPos( + track: { ts: number[]; path: [number, number][] }, + t: number, +): [number, number] { + if (t <= track.ts[0]) return track.path[0]; + if (t >= track.ts[track.ts.length - 1]) return track.path[track.path.length - 1]; + let lo = 0, hi = track.ts.length - 1; + while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; } + const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0; + return [ + track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio, + track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio, + ]; +} + export function buildModelCenterTrails( frames: HistoryFrame[], corrTracks: CorrelationVesselTrack[], @@ -252,7 +340,6 @@ export function buildModelCenterTrails( enabledVessels: Set, startTime: number, ): ModelCenterTrail[] { - // 연관 선박 트랙 맵: mmsi → {timestampsMs[], geometry[][]} const trackMap = new Map(); for (const vt of corrTracks) { if (vt.track.length < 1) continue; @@ -268,51 +355,53 @@ export function buildModelCenterTrails( const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi)); if (enabledItems.length === 0) continue; - const path: [number, number][] = []; - const timestamps: number[] = []; - - for (const frame of frames) { - const t = new Date(frame.snapshotTime).getTime(); - const relT = t - startTime; - - // 멤버 위치 - const allPts: [number, number][] = frame.members.map(m => [m.lon, m.lat]); - - // 연관 선박 위치 (트랙 보간 or 마지막 점 clamp) - for (const c of enabledItems) { - const track = trackMap.get(c.targetMmsi); - if (!track || track.path.length === 0) continue; - - let lon: number, lat: number; - if (t <= track.ts[0]) { - lon = track.path[0][0]; lat = track.path[0][1]; - } else if (t >= track.ts[track.ts.length - 1]) { - const last = track.path.length - 1; - lon = track.path[last][0]; lat = track.path[last][1]; - } else { - let lo = 0, hi = track.ts.length - 1; - while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; } - const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0; - lon = track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio; - lat = track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio; - } - allPts.push([lon, lat]); - } - - // 폴리곤 중심 계산 - const poly = buildInterpPolygon(allPts); - if (!poly) continue; - const ring = poly.coordinates[0]; - let cx = 0, cy = 0; - for (const pt of ring) { cx += pt[0]; cy += pt[1]; } - cx /= ring.length; cy /= ring.length; - - path.push([cx, cy]); - timestamps.push(relT); + // subClusterId별 연관 선박 그룹핑 + const subItemsMap = new Map(); + for (const c of enabledItems) { + const sid = c.subClusterId ?? 0; + const list = subItemsMap.get(sid) ?? []; + list.push(c); + subItemsMap.set(sid, list); } - if (path.length >= 2) { - results.push({ modelName: mn, path, timestamps }); + // 서브클러스터별 독립 trail 생성 + for (const [sid, subItems] of subItemsMap) { + const path: [number, number][] = []; + const timestamps: number[] = []; + + for (const frame of frames) { + const t = new Date(frame.snapshotTime).getTime(); + const relT = t - startTime; + + // 해당 서브클러스터의 멤버 위치 + const sf = frame.subFrames?.find(s => s.subClusterId === sid); + const basePts: [number, number][] = sf + ? sf.members.map(m => [m.lon, m.lat]) + : frame.members.map(m => [m.lon, m.lat]); // fallback + + const allPts: [number, number][] = [...basePts]; + + // 연관 선박 위치 (트랙 보간) + for (const c of subItems) { + const track = trackMap.get(c.targetMmsi); + if (!track || track.path.length === 0) continue; + allPts.push(_interpTrackPos(track, t)); + } + + const poly = buildInterpPolygon(allPts); + if (!poly) continue; + const ring = poly.coordinates[0]; + let cx = 0, cy = 0; + for (const pt of ring) { cx += pt[0]; cy += pt[1]; } + cx /= ring.length; cy /= ring.length; + + path.push([cx, cy]); + timestamps.push(relT); + } + + if (path.length >= 2) { + results.push({ modelName: mn, subClusterId: sid, path, timestamps }); + } } } diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts index d32a066..60a70c9 100644 --- a/frontend/src/stores/gearReplayStore.ts +++ b/frontend/src/stores/gearReplayStore.ts @@ -54,17 +54,29 @@ 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[]; centerTrailSegments: CenterTrailSegment[]; centerDotsPositions: [number, number][]; + subClusterCenters: { subClusterId: number; path: [number, number][]; timestamps: number[] }[]; + /** 리플레이 전체 구간에서 등장한 모든 고유 멤버 (identity 목록용) */ + allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[]; snapshotRanges: number[]; modelCenterTrails: ModelCenterTrail[]; @@ -75,6 +87,13 @@ interface GearReplayState { correlationByModel: Map; 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: ( @@ -83,6 +102,7 @@ interface GearReplayState { corrData: GearCorrelationItem[], enabledModels: Set, enabledVessels: Set, + frames6h?: HistoryFrame[], ) => void; play: () => void; pause: () => void; @@ -93,6 +113,13 @@ interface GearReplayState { setHoveredMmsi: (mmsi: string | null) => void; 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; } @@ -113,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; @@ -136,12 +176,21 @@ export const useGearReplayStore = create()( frameTimes: [], selectedGroupKey: null, rawCorrelationTracks: [], + historyFrames6h: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], // Pre-computed layer data memberTripsData: [], correlationTripsData: [], centerTrailSegments: [], centerDotsPositions: [], + subClusterCenters: [], + allHistoryMembers: [], snapshotRanges: [], modelCenterTrails: [], @@ -151,20 +200,34 @@ export const useGearReplayStore = create()( hoveredMmsi: null, 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) ?? []; @@ -176,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, @@ -201,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); @@ -238,6 +307,22 @@ export const useGearReplayStore = create()( setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), 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(); @@ -275,13 +360,22 @@ export const useGearReplayStore = create()( endTime: 0, playbackSpeed: 1, historyFrames: [], + historyFrames6h: [], frameTimes: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], selectedGroupKey: null, rawCorrelationTracks: [], memberTripsData: [], correlationTripsData: [], centerTrailSegments: [], centerDotsPositions: [], + subClusterCenters: [], + allHistoryMembers: [], snapshotRanges: [], modelCenterTrails: [], enabledModels: new Set(), @@ -289,6 +383,13 @@ export const useGearReplayStore = create()( hoveredMmsi: null, 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/frontend/src/stores/shipDeckStore.ts b/frontend/src/stores/shipDeckStore.ts new file mode 100644 index 0000000..f75eceb --- /dev/null +++ b/frontend/src/stores/shipDeckStore.ts @@ -0,0 +1,108 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import type { Ship, VesselAnalysisDto } from '../types'; + +// ── Store interface ─────────────────────────────────────────────── + +interface ShipDeckState { + // Ship data (from 5-second polling) + ships: Ship[]; + shipMap: Map; // mmsi → Ship lookup (for popup, hover) + + // Filter state + militaryOnly: boolean; + hiddenShipCategories: Set; // mtCategory strings like 'cargo', 'tanker' + hiddenNationalities: Set; // natGroup strings like 'KR', 'JP' + layerVisible: boolean; // layers.ships toggle from LayerPanel + + // Interaction state + hoveredMmsi: string | null; + hoverScreenPos: { x: number; y: number } | null; // screen coords for tooltip + selectedMmsi: string | null; // popup target + focusMmsi: string | null; // external focus request (e.g., from analysis panel) + + // Display state + highlightKorean: boolean; // korean ships ring + label toggle + zoomLevel: number; // integer floor of map zoom + + // Analysis state (for analysis ship markers overlay) + analysisMap: Map | null; + analysisActiveFilter: string | null; // 'illegalFishing' | 'darkVessel' | 'cnFishing' | null + + // Actions + setShips: (ships: Ship[]) => void; + setFilters: (patch: { + militaryOnly?: boolean; + hiddenShipCategories?: Set; + hiddenNationalities?: Set; + layerVisible?: boolean; + }) => void; + setHoveredMmsi: (mmsi: string | null, screenPos?: { x: number; y: number }) => void; + setSelectedMmsi: (mmsi: string | null) => void; + setFocusMmsi: (mmsi: string | null) => void; + setHighlightKorean: (hl: boolean) => void; + setZoomLevel: (zoom: number) => void; + setAnalysis: (map: Map | null, filter: string | null) => void; +} + +// ── Store ───────────────────────────────────────────────────────── + +export const useShipDeckStore = create()( + subscribeWithSelector((set) => ({ + // Ship data + ships: [], + shipMap: new Map(), + + // Filter state + militaryOnly: false, + hiddenShipCategories: new Set(), + hiddenNationalities: new Set(), + layerVisible: true, + + // Interaction state + hoveredMmsi: null, + hoverScreenPos: null, + selectedMmsi: null, + focusMmsi: null, + + // Display state + highlightKorean: false, + zoomLevel: 5, + + // Analysis state + analysisMap: null, + analysisActiveFilter: null, + + // ── Actions ──────────────────────────────────────────────── + + setShips: (ships) => { + const shipMap = new Map(); + for (const ship of ships) { + shipMap.set(ship.mmsi, ship); + } + set({ ships, shipMap }); + }, + + setFilters: (patch) => set((state) => ({ + militaryOnly: patch.militaryOnly ?? state.militaryOnly, + hiddenShipCategories: patch.hiddenShipCategories ?? state.hiddenShipCategories, + hiddenNationalities: patch.hiddenNationalities ?? state.hiddenNationalities, + layerVisible: patch.layerVisible ?? state.layerVisible, + })), + + setHoveredMmsi: (mmsi, screenPos) => set({ + hoveredMmsi: mmsi, + hoverScreenPos: mmsi ? (screenPos ?? null) : null, + }), + + setSelectedMmsi: (mmsi) => set({ selectedMmsi: mmsi }), + + setFocusMmsi: (mmsi) => set({ focusMmsi: mmsi }), + + setHighlightKorean: (hl) => set({ highlightKorean: hl }), + + setZoomLevel: (zoom) => set({ zoomLevel: zoom }), + + setAnalysis: (map, filter) => set({ analysisMap: map, analysisActiveFilter: filter }), + })), +); diff --git a/frontend/src/utils/labelCluster.ts b/frontend/src/utils/labelCluster.ts new file mode 100644 index 0000000..46d1fdb --- /dev/null +++ b/frontend/src/utils/labelCluster.ts @@ -0,0 +1,51 @@ +/** + * 픽셀그리드 기반 라벨 클러스터링. + * 줌 레벨별 지리적 그리드 셀을 계산하여 셀당 최대 N개만 표시. + * 정수 줌 레벨이 변경될 때만 재계산 (useMemo deps에 포함). + * z10+ 에서는 클러스터링 없이 전체 표시. + */ + +/** 줌 레벨별 그리드 셀 크기 (도 단위, 약 80~100px 상당) */ +const CELL_SIZE_BY_ZOOM: Record = { + 4: 4.0, + 5: 2.0, + 6: 1.0, + 7: 0.5, + 8: 0.25, + 9: 0.12, +}; + +/** + * 지리적 그리드 기반으로 라벨 데이터를 필터링. + * @param data 원본 데이터 배열 + * @param getCoords 데이터 → [lng, lat] 추출 함수 + * @param zoomLevel 정수 줌 레벨 + * @param maxPerCell 셀당 최대 라벨 수 (기본 1) + */ +export function clusterLabels( + data: T[], + getCoords: (d: T) => [number, number], + zoomLevel: number, + maxPerCell = 1, +): T[] { + // z10 이상이면 모두 표시 + if (zoomLevel >= 10) return data; + + const cellSize = CELL_SIZE_BY_ZOOM[zoomLevel] ?? 0.12; + const grid = new Map(); + const result: T[] = []; + + for (const item of data) { + const [lng, lat] = getCoords(item); + const cx = Math.floor(lng / cellSize); + const cy = Math.floor(lat / cellSize); + const key = `${cx},${cy}`; + const count = grid.get(key) ?? 0; + if (count < maxPerCell) { + grid.set(key, count + 1); + result.push(item); + } + } + + return result; +} diff --git a/prediction/algorithms/gear_correlation.py b/prediction/algorithms/gear_correlation.py index 21c5b95..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: @@ -556,19 +558,34 @@ def run_gear_correlation( score_batch: list[tuple] = [] total_updated = 0 total_raw = 0 + processed_keys: set[tuple] = set() # (model_id, parent_name, sub_cluster_id, target_mmsi) default_params = models[0] for gear_group in gear_groups: parent_name = gear_group['parent_name'] + sub_cluster_id = gear_group.get('sub_cluster_id', 0) members = gear_group['members'] 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) @@ -617,7 +634,7 @@ def run_gear_correlation( # raw 메트릭 배치 수집 raw_batch.append(( - now, parent_name, target_mmsi, target_type, target_name, + now, parent_name, sub_cluster_id, target_mmsi, target_type, target_name, metrics.get('proximity_ratio'), metrics.get('visit_score'), metrics.get('activity_sync'), metrics.get('dtw_similarity'), metrics.get('speed_correlation'), metrics.get('heading_coherence'), @@ -637,7 +654,7 @@ def run_gear_correlation( ) # 사전 로드된 점수에서 조회 (DB 쿼리 없음) - score_key = (model.model_id, parent_name, target_mmsi) + score_key = (model.model_id, parent_name, sub_cluster_id, target_mmsi) prev = all_scores.get(score_key) prev_score = prev['current_score'] if prev else None streak = prev['streak_count'] if prev else 0 @@ -649,15 +666,39 @@ def run_gear_correlation( 0.0, model, ) + processed_keys.add(score_key) + if new_score >= model.track_threshold or prev is not None: score_batch.append(( - model.model_id, parent_name, target_mmsi, + model.model_id, parent_name, sub_cluster_id, target_mmsi, target_type, target_name, round(new_score, 6), new_streak, state, now, now, now, )) total_updated += 1 + # ── 반경 밖 이탈 선박 강제 감쇠 ────────────────────────────────── + # all_scores에 기록이 있지만 이번 사이클 후보에서 빠진 항목: + # 선박이 탐색 반경(group_radius × 3)을 완전히 벗어난 경우. + # Freeze 조건 무시하고 decay_fast 적용 → 빠르게 0으로 수렴. + for score_key, prev in all_scores.items(): + if score_key in processed_keys: + continue + prev_score = prev['current_score'] + if prev_score is None or prev_score <= 0: + continue + model_id, parent_name_s, sub_cluster_id_s, target_mmsi_s = score_key + # 해당 모델의 decay_fast 파라미터 사용 + model_params = next((m for m in models if m.model_id == model_id), default_params) + new_score = max(0.0, prev_score - model_params.decay_fast) + score_batch.append(( + model_id, parent_name_s, sub_cluster_id_s, target_mmsi_s, + prev.get('target_type', 'VESSEL'), prev.get('target_name', ''), + round(new_score, 6), 0, 'OUT_OF_RANGE', + prev.get('last_observed_at', now), now, now, + )) + total_updated += 1 + # 배치 DB 저장 _batch_insert_raw(conn, raw_batch) _batch_upsert_scores(conn, score_batch) @@ -703,21 +744,24 @@ def _load_active_models(conn) -> list[ModelParams]: def _load_all_scores(conn) -> dict[tuple, dict]: - """모든 점수를 사전 로드. {(model_id, group_key, target_mmsi): {...}}""" + """모든 점수를 사전 로드. {(model_id, group_key, sub_cluster_id, target_mmsi): {...}}""" cur = conn.cursor() try: cur.execute( - "SELECT model_id, group_key, target_mmsi, " - "current_score, streak_count, last_observed_at " + "SELECT model_id, group_key, sub_cluster_id, target_mmsi, " + "current_score, streak_count, last_observed_at, " + "target_type, target_name " "FROM kcg.gear_correlation_scores" ) result = {} for row in cur.fetchall(): - key = (row[0], row[1], row[2]) + key = (row[0], row[1], row[2], row[3]) result[key] = { - 'current_score': row[3], - 'streak_count': row[4], - 'last_observed_at': row[5], + 'current_score': row[4], + 'streak_count': row[5], + 'last_observed_at': row[6], + 'target_type': row[7], + 'target_name': row[8], } return result except Exception as e: @@ -737,7 +781,7 @@ def _batch_insert_raw(conn, batch: list[tuple]): execute_values( cur, """INSERT INTO kcg.gear_correlation_raw_metrics - (observed_at, group_key, target_mmsi, target_type, target_name, + (observed_at, group_key, sub_cluster_id, target_mmsi, target_type, target_name, proximity_ratio, visit_score, activity_sync, dtw_similarity, speed_correlation, heading_coherence, drift_similarity, shadow_stay, shadow_return, @@ -762,11 +806,11 @@ def _batch_upsert_scores(conn, batch: list[tuple]): execute_values( cur, """INSERT INTO kcg.gear_correlation_scores - (model_id, group_key, target_mmsi, target_type, target_name, + (model_id, group_key, sub_cluster_id, target_mmsi, target_type, target_name, current_score, streak_count, freeze_state, first_observed_at, last_observed_at, updated_at) VALUES %s - ON CONFLICT (model_id, group_key, target_mmsi) + ON CONFLICT (model_id, group_key, sub_cluster_id, target_mmsi) DO UPDATE SET target_type = EXCLUDED.target_type, target_name = EXCLUDED.target_name, diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 0e592cf..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 @@ -26,11 +29,30 @@ logger = logging.getLogger(__name__) # 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일) GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$') MAX_DIST_DEG = 0.15 # ~10NM -STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버) +STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버) — 그룹 멤버 탐색용 +DISPLAY_STALE_SEC = 3600 # 1시간 — 폴리곤 스냅샷 노출 기준 (프론트엔드 초기 로드 minutes=60과 동기화) + # time_bucket(적재시간) 기반 필터링 — AIS 원본 timestamp는 부표 시계 오류로 부정확할 수 있음 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' @@ -157,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) @@ -171,6 +192,10 @@ def detect_gear_groups( if not m: continue + # 한국 국적 선박(MMSI 440/441)은 어구 AIS 미사용 → 제외 + if mmsi.startswith('440') or mmsi.startswith('441'): + continue + parent_raw = (m.group(1) or name).strip() parent_key = _normalize_parent(parent_raw) # 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태) @@ -183,6 +208,7 @@ def detect_gear_groups( 'lon': pos['lon'], 'sog': pos.get('sog', 0), 'cog': pos.get('cog', 0), + 'timestamp': ts, } raw_groups.setdefault(parent_key, []).append(entry) @@ -256,14 +282,15 @@ def detect_gear_groups( for i in idxs ] - # 서브그룹 이름: 1개면 원본, 2개 이상이면 #1, #2 - sub_name = display_name if len(clusters) == 1 else f'{display_name}#{ci + 1}' + # group_key는 항상 원본명 유지, 서브클러스터는 별도 ID로 구분 + sub_cluster_id = 0 if len(clusters) == 1 else (ci + 1) sub_mmsi = parent_mmsi if has_seed else None results.append({ - 'parent_name': sub_name, + 'parent_name': display_name, 'parent_key': parent_key, 'parent_mmsi': sub_mmsi, + 'sub_cluster_id': sub_cluster_id, 'members': members, }) @@ -294,6 +321,7 @@ def detect_gear_groups( existing_mmsis.add(m['mmsi']) if not big['parent_mmsi'] and small['parent_mmsi']: big['parent_mmsi'] = small['parent_mmsi'] + big['sub_cluster_id'] = 0 # 병합됨 → 단일 클러스터 skip.add(j) del big['parent_key'] merged.append(big) @@ -355,8 +383,12 @@ def build_all_group_snapshots( 'isParent': False, }) - # 2척 미만은 폴리곤 미생성 - if len(points) < 2: + 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 polygon_wkt, center_wkt, area_sq_nm, center_lat, center_lon = build_group_polygon( @@ -378,99 +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 기반 전체 멤버 - # 수역 분류: 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 gear_members: - anchor_lat = gear_members[0]['lat'] - anchor_lon = gear_members[0]['lon'] - - if anchor_lat is None: + if not gear_members: 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 + # ── 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] - # 비허가(수역 외) 어구: 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, - '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/cache/vessel_store.py b/prediction/cache/vessel_store.py index 00b82e7..b79f031 100644 --- a/prediction/cache/vessel_store.py +++ b/prediction/cache/vessel_store.py @@ -345,6 +345,7 @@ class VesselStore: 'sog': float(last.get('sog', 0) or last.get('raw_sog', 0) or 0), 'cog': cog, 'timestamp': last.get('timestamp'), + 'time_bucket': last.get('time_bucket'), 'name': info.get('name', ''), } return result diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py index 90bf5bb..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, 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, ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326), %s, %s, %s, %s, %s::jsonb, %s ) @@ -175,6 +175,8 @@ def save_group_snapshots(snapshots: list[dict]) -> int: s['group_type'], 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'), diff --git a/prediction/db/snpdb.py b/prediction/db/snpdb.py index fda2397..fbd5081 100644 --- a/prediction/db/snpdb.py +++ b/prediction/db/snpdb.py @@ -60,12 +60,13 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame: """한국 해역 전 선박의 궤적 포인트를 조회한다. LineStringM 지오메트리에서 개별 포인트를 추출하며, - 한국 해역(124-132E, 32-39N) 내 최근 N시간 데이터를 반환한다. + 한국 해역(122-132E, 31-39N) 내 최근 N시간 데이터를 반환한다. """ query = f""" SELECT t.mmsi, to_timestamp(ST_M((dp).geom)) as timestamp, + t.time_bucket, ST_Y((dp).geom) as lat, ST_X((dp).geom) as lon, CASE @@ -75,7 +76,7 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame: FROM signal.t_vessel_tracks_5min t, LATERAL ST_DumpPoints(t.track_geom) dp WHERE t.time_bucket >= NOW() - INTERVAL '{hours} hours' - AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326) + AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326) ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom)) """ @@ -104,6 +105,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame: SELECT t.mmsi, to_timestamp(ST_M((dp).geom)) as timestamp, + t.time_bucket, ST_Y((dp).geom) as lat, ST_X((dp).geom) as lon, CASE @@ -113,7 +115,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame: FROM signal.t_vessel_tracks_5min t, LATERAL ST_DumpPoints(t.track_geom) dp WHERE t.time_bucket > %s - AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326) + AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326) ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom)) """