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 4468e94..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 @@ -25,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 160b885..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 @@ -31,9 +31,10 @@ public class GroupPolygonService { 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 """; @@ -41,7 +42,7 @@ public class GroupPolygonService { 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 @@ -52,7 +53,7 @@ public class GroupPolygonService { 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 @@ -60,15 +61,16 @@ public class GroupPolygonService { private static final String GROUP_CORRELATIONS_SQL = """ WITH best_scores AS ( - SELECT DISTINCT ON (m.id, s.target_mmsi) + SELECT DISTINCT ON (m.id, s.sub_cluster_id, s.target_mmsi) s.target_mmsi, s.target_type, s.target_name, s.current_score, s.streak_count, s.observation_count, s.freeze_state, s.shadow_bonus_total, + s.sub_cluster_id, m.id AS model_id, m.name AS model_name, m.is_default FROM kcg.gear_correlation_scores s JOIN kcg.correlation_param_models m ON s.model_id = m.id WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE - ORDER BY m.id, s.target_mmsi, s.current_score DESC + ORDER BY m.id, s.sub_cluster_id, s.target_mmsi, s.current_score DESC ) SELECT bs.*, r.proximity_ratio, r.visit_score, r.heading_coherence @@ -86,8 +88,9 @@ public class GroupPolygonService { 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' """; /** @@ -120,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")); @@ -210,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 365cd0d..2bbb43a 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,19 @@ ## [Unreleased] +### 추가 +- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더) +- 리플레이 컨트롤러 A-B 구간 반복 기능 +- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정) +- 리치 툴팁: 선박/어구 구분 + 모델 소속 컬러 표시 + 멤버 호버 강조 +- 항공기 아이콘 줌레벨 기반 스케일 적용 +- 심볼 크기 조정 패널 (선박/항공기 개별 0.5~2.0x) + +### 변경 +- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius +- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용 +- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링 + ### 추가 - 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore) - 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers) 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 0dfd5be..413ac59 100644 --- a/frontend/src/components/korea/CorrelationPanel.tsx +++ b/frontend/src/components/korea/CorrelationPanel.tsx @@ -292,23 +292,26 @@ const CorrelationPanel = ({ {memberCount} {correlationLoading &&
로딩...
} - {availableModels.map(m => { - const color = MODEL_COLORS[m.name] ?? '#94a3b8'; - const modelItems = correlationByModel.get(m.name) ?? []; + {_MODEL_ORDER.filter(mn => mn !== 'identity').map(mn => { + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + const modelItems = correlationByModel.get(mn) ?? []; + const hasData = modelItems.length > 0; const vc = modelItems.filter(c => c.targetType === 'VESSEL').length; const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length; + const am = availableModels.find(m => m.name === mn); return ( - ); })} diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 3cd16e8..a5d43c8 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -42,7 +42,7 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) { ([subClusterId, data]) => ({ subClusterId, ...data }), ); - // 2. 시간별 멤버 합산 프레임 (기존 리플레이 호환) + // 2. 시간별 그룹핑 후 서브클러스터 보존 const byTime = new Map(); for (const h of sorted) { const list = byTime.get(h.snapshotTime) ?? []; @@ -52,34 +52,63 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) { const frames: GroupPolygonDto[] = []; for (const [, items] of byTime) { - if (items.length === 1) { - frames.push(items[0]); - continue; - } - const seen = new Set(); - const allMembers: GroupPolygonDto['members'] = []; - for (const item of items) { - for (const m of item.members) { - if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } + const allSameId = items.every(item => (item.subClusterId ?? 0) === 0); + + if (items.length === 1 || allSameId) { + // 단일 아이템 또는 모두 subClusterId=0: 통합 서브프레임 1개 + const base = items.length === 1 ? items[0] : (() => { + const seen = new Set(); + const allMembers: GroupPolygonDto['members'] = []; + for (const item of items) { + for (const m of item.members) { + if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } + } + } + const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b)); + return { ...biggest, subClusterId: 0, members: allMembers, memberCount: allMembers.length }; + })(); + const subFrames: SubFrame[] = [{ + subClusterId: 0, + centerLon: base.centerLon, + centerLat: base.centerLat, + members: base.members, + memberCount: base.memberCount, + }]; + frames.push({ ...base, subFrames } as GroupPolygonDto & { subFrames: SubFrame[] }); + } else { + // 서로 다른 subClusterId: 각 아이템을 개별 서브프레임으로 보존 + const subFrames: SubFrame[] = items.map(item => ({ + subClusterId: item.subClusterId ?? 0, + centerLon: item.centerLon, + centerLat: item.centerLat, + members: item.members, + memberCount: item.memberCount, + })); + const seen = new Set(); + const allMembers: GroupPolygonDto['members'] = []; + for (const sf of subFrames) { + for (const m of sf.members) { + if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } + } } + const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b)); + frames.push({ + ...biggest, + subClusterId: 0, + members: allMembers, + memberCount: allMembers.length, + centerLat: biggest.centerLat, + centerLon: biggest.centerLon, + subFrames, + } as GroupPolygonDto & { subFrames: SubFrame[] }); } - const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b)); - frames.push({ - ...biggest, - subClusterId: 0, - members: allMembers, - memberCount: allMembers.length, - // 가장 큰 서브클러스터의 center 사용 (가중 평균 아닌 대표 center) - centerLat: biggest.centerLat, - centerLon: biggest.centerLon, - }); } - return { frames: fillGapFrames(frames), subClusterCenters }; + return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters }; } // ── 분리된 모듈 ── -import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; +import type { PickerCandidate, HoverTooltipState, GearPickerPopupState, SubFrame, HistoryFrame } from './fleetClusterTypes'; import { EMPTY_ANALYSIS } from './fleetClusterTypes'; import { fillGapFrames } from './fleetClusterUtils'; import { useFleetClusterGeoJson } from './useFleetClusterGeoJson'; @@ -150,8 +179,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })), ]); - // 2. 서브클러스터별 분리 → 멤버 합산 프레임 + 서브클러스터별 독립 center - const { frames: filled, subClusterCenters } = splitAndMergeHistory(history); + // 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; @@ -159,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)); @@ -173,9 +212,9 @@ 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 }[] = []; @@ -187,7 +226,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS } } } - useGearReplayStore.setState({ subClusterCenters, allHistoryMembers }); + useGearReplayStore.setState({ subClusterCenters, subClusterCenters6h, allHistoryMembers }); store.play(); }; @@ -521,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/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx index 04662f7..fb81d16 100644 --- a/frontend/src/components/korea/HistoryReplayController.tsx +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -1,32 +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' }); @@ -34,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, @@ -53,82 +267,232 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont minWidth: 380, maxWidth: 1320, background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)', borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4, - zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0', + zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0', boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', }}> - {/* 프로그레스 바 */} -
- {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="이름">이름 - | + style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle} + title="집중 모드">집중 + | + + + | + + | 일치율 - { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }} + style={{ background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO, padding: '1px 4px', cursor: 'pointer' }} + title="일치율 필터" aria-label="일치율 필터"> + + + + {frameCount} + {has6hData && <> / {frameCount6h}} 건 + +
); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 479e6a4..5d328a1 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -17,7 +17,6 @@ import { useGearReplayStore } from '../../stores/gearReplayStore'; import { useShipDeckLayers } from '../../hooks/useShipDeckLayers'; import { useShipDeckStore } from '../../stores/shipDeckStore'; import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay'; -import maplibregl from 'maplibre-gl'; import { InfraLayer } from './InfraLayer'; import { SatelliteLayer } from '../layers/SatelliteLayer'; import { AircraftLayer } from '../layers/AircraftLayer'; @@ -276,29 +275,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF fetchKoreaInfra().then(setInfra).catch(() => {}); }, []); - // MapLibre에 ship-triangle/gear-diamond SDF 이미지 등록 (FleetClusterMapLayers에서 사용) - const handleMapLoad = useCallback(() => { - const m = mapRef.current?.getMap() as maplibregl.Map | undefined; - if (!m) return; - if (!m.hasImage('ship-triangle')) { - const s = 64; - const c = document.createElement('canvas'); c.width = s; c.height = s; - const ctx = c.getContext('2d')!; - ctx.beginPath(); ctx.moveTo(s/2,2); ctx.lineTo(s*0.12,s-2); ctx.lineTo(s/2,s*0.62); ctx.lineTo(s*0.88,s-2); ctx.closePath(); - ctx.fillStyle = '#fff'; ctx.fill(); - const d = ctx.getImageData(0,0,s,s); - m.addImage('ship-triangle', { width: s, height: s, data: new Uint8Array(d.data.buffer) }, { sdf: true }); - } - if (!m.hasImage('gear-diamond')) { - const s = 64; - const c = document.createElement('canvas'); c.width = s; c.height = s; - const ctx = c.getContext('2d')!; - ctx.beginPath(); ctx.moveTo(s/2,4); ctx.lineTo(s-4,s/2); ctx.lineTo(s/2,s-4); ctx.lineTo(4,s/2); ctx.closePath(); - ctx.fillStyle = '#fff'; ctx.fill(); - const d = ctx.getImageData(0,0,s,s); - m.addImage('gear-diamond', { width: s, height: s, data: new Uint8Array(d.data.buffer) }, { sdf: true }); - } - }, []); + // MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제) + const handleMapLoad = useCallback(() => {}, []); // ── shipDeckStore 동기화 ── useEffect(() => { 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 5fbc80b..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'; @@ -142,34 +142,49 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl return models; }, [correlationByModel]); - // 오퍼레이셔널 폴리곤 (비재생 정적 연산) + // 오퍼레이셔널 폴리곤 (비재생 정적 연산 — 서브클러스터별 분리, subClusterId 기반) const operationalPolygons = useMemo(() => { if (!selectedGearGroup || !groupPolygons) return []; - const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; - const { members: mergedMembers } = mergeSubClusterMembers(allGroups, selectedGearGroup); - if (mergedMembers.length === 0) return []; - const basePts: [number, number][] = mergedMembers.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]); diff --git a/frontend/src/components/layers/AircraftLayer.tsx b/frontend/src/components/layers/AircraftLayer.tsx index 874852d..13230d2 100644 --- a/frontend/src/components/layers/AircraftLayer.tsx +++ b/frontend/src/components/layers/AircraftLayer.tsx @@ -2,6 +2,9 @@ import { memo, useMemo, useState, useEffect } from 'react'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import type { Aircraft, AircraftCategory } from '../../types'; +import { useShipDeckStore } from '../../stores/shipDeckStore'; +import { getZoomScale } from '../../hooks/useShipDeckLayers'; +import { useSymbolScale } from '../../hooks/useSymbolScale'; interface Props { aircraft: Aircraft[]; @@ -187,9 +190,12 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) { const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) { const { t } = useTranslation('ships'); const [showPopup, setShowPopup] = useState(false); + const zoomLevel = useShipDeckStore(s => s.zoomLevel); + const { symbolScale } = useSymbolScale(); const color = getAircraftColor(ac); const shape = getShape(ac); - const size = shape.w; + const zs = getZoomScale(zoomLevel); + const size = Math.round(shape.w * zs * symbolScale.aircraft / 0.8); const showLabel = ac.category === 'fighter' || ac.category === 'surveillance'; const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8; diff --git a/frontend/src/contexts/SymbolScaleContext.tsx b/frontend/src/contexts/SymbolScaleContext.tsx new file mode 100644 index 0000000..809e38a --- /dev/null +++ b/frontend/src/contexts/SymbolScaleContext.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { SymbolScaleCtx, DEFAULT_SYMBOL_SCALE } from './symbolScaleState'; + +export type { SymbolScaleConfig } from './symbolScaleState'; + +export function SymbolScaleProvider({ children }: { children: ReactNode }) { + const [symbolScale, setSymbolScale] = useLocalStorage('mapSymbolScale', DEFAULT_SYMBOL_SCALE); + return {children}; +} diff --git a/frontend/src/contexts/symbolScaleState.ts b/frontend/src/contexts/symbolScaleState.ts new file mode 100644 index 0000000..d5aa5bf --- /dev/null +++ b/frontend/src/contexts/symbolScaleState.ts @@ -0,0 +1,12 @@ +import { createContext } from 'react'; + +export interface SymbolScaleConfig { + ship: number; + aircraft: number; +} + +export const DEFAULT_SYMBOL_SCALE: SymbolScaleConfig = { ship: 1.0, aircraft: 1.0 }; + +export const SymbolScaleCtx = createContext<{ symbolScale: SymbolScaleConfig; setSymbolScale: (c: SymbolScaleConfig) => void }>({ + symbolScale: DEFAULT_SYMBOL_SCALE, setSymbolScale: () => {}, +}); diff --git a/frontend/src/hooks/useFleetClusterDeckLayers.ts b/frontend/src/hooks/useFleetClusterDeckLayers.ts index 4451b9e..1608865 100644 --- a/frontend/src/hooks/useFleetClusterDeckLayers.ts +++ b/frontend/src/hooks/useFleetClusterDeckLayers.ts @@ -371,8 +371,8 @@ export function useFleetClusterDeckLayers( })); } - // ── Correlation layers (only when gear group selected) ──────────────────── - if (selectedGearGroup) { + // ── Correlation layers (only when gear group selected, skip during replay) ─ + if (selectedGearGroup && !historyActive) { // ── 8. Operational polygons (per model) ──────────────────────────────── for (const op of geo.operationalPolygons) { diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index 9be8376..010fd8d 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -3,7 +3,7 @@ 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'; @@ -71,6 +71,14 @@ export function useGearReplayLayers( const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails); const showTrails = useGearReplayStore(s => s.showTrails); const showLabels = useGearReplayStore(s => s.showLabels); + const show1hPolygon = useGearReplayStore(s => s.show1hPolygon); + const show6hPolygon = useGearReplayStore(s => s.show6hPolygon); + const historyFrames6h = useGearReplayStore(s => s.historyFrames6h); + const memberTripsData6h = useGearReplayStore(s => s.memberTripsData6h); + const centerTrailSegments6h = useGearReplayStore(s => s.centerTrailSegments6h); + const centerDotsPositions6h = useGearReplayStore(s => s.centerDotsPositions6h); + const subClusterCenters6h = useGearReplayStore(s => s.subClusterCenters6h); + const pinnedMmsis = useGearReplayStore(s => s.pinnedMmsis); const { fontScale } = useFontScale(); const fs = fontScale.analysis; const zoomLevel = useShipDeckStore(s => s.zoomLevel); @@ -103,64 +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, - })); - } - - // 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, - })); - } - - // ── 서브클러스터별 독립 center trail (PathLayer) ───────────────────── - const SUB_COLORS: [number, number, number, number][] = [ - [251, 191, 36, 200], // sub=0 (unified) — 기존 gold + 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 ]; - for (const sc of subClusterCenters) { - if (sc.path.length < 2) continue; - const color = SUB_COLORS[sc.subClusterId % SUB_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, - })); + + 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, + })); + } + } + + // ── 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; @@ -169,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) @@ -476,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; @@ -517,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], @@ -535,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], @@ -548,7 +682,7 @@ 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, @@ -616,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({ @@ -646,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, subClusterCenters, showTrails, showLabels, fs, zoomLevel, + modelCenterTrails, subClusterCenters, showTrails, showLabels, + show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel, replayLayerRef, requestRender, ]); @@ -721,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/useShipDeckLayers.ts b/frontend/src/hooks/useShipDeckLayers.ts index c96ec5a..bdf1e88 100644 --- a/frontend/src/hooks/useShipDeckLayers.ts +++ b/frontend/src/hooks/useShipDeckLayers.ts @@ -9,6 +9,7 @@ import { getMarineTrafficCategory } from '../utils/marineTraffic'; import { getNationalityGroup } from './useKoreaData'; import { FONT_MONO } from '../styles/fonts'; import type { Ship, VesselAnalysisDto } from '../types'; +import { useSymbolScale } from './useSymbolScale'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -19,7 +20,7 @@ const ZOOM_SCALE: Record = { }; const ZOOM_SCALE_DEFAULT = 4.2; // z14+ -function getZoomScale(zoom: number): number { +export function getZoomScale(zoom: number): number { if (zoom >= 14) return ZOOM_SCALE_DEFAULT; return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT; } @@ -156,6 +157,8 @@ export function useShipDeckLayers( shipLayerRef: React.MutableRefObject, requestRender: () => void, ): void { + const { symbolScale } = useSymbolScale(); + const shipSymbolScale = symbolScale.ship; const renderFrame = useCallback(() => { const state = useShipDeckStore.getState(); @@ -170,7 +173,7 @@ export function useShipDeckLayers( return; } - const zoomScale = getZoomScale(zoomLevel); + const zoomScale = getZoomScale(zoomLevel) * shipSymbolScale; const layers: Layer[] = []; // 1. Build filtered ship render data (~3K ships, <1ms) @@ -316,7 +319,7 @@ export function useShipDeckLayers( shipLayerRef.current = layers; requestRender(); - }, [shipLayerRef, requestRender]); + }, [shipLayerRef, requestRender, shipSymbolScale]); // Subscribe to all relevant state changes useEffect(() => { diff --git a/frontend/src/hooks/useSymbolScale.ts b/frontend/src/hooks/useSymbolScale.ts new file mode 100644 index 0000000..d018b13 --- /dev/null +++ b/frontend/src/hooks/useSymbolScale.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { SymbolScaleCtx } from '../contexts/symbolScaleState'; + +export function useSymbolScale() { + return useContext(SymbolScaleCtx); +} diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index d25550d..a1c470c 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -73,6 +73,7 @@ export interface GroupPolygonDto { zoneName: string | null; members: MemberInfo[]; color: string; + resolution?: '1h' | '6h'; } export async function fetchGroupPolygons(): Promise { @@ -106,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 94edf83..60a70c9 100644 --- a/frontend/src/stores/gearReplayStore.ts +++ b/frontend/src/stores/gearReplayStore.ts @@ -54,12 +54,21 @@ interface GearReplayState { endTime: number; playbackSpeed: number; - // Source data + // Source data (1h = primary identity polygon) historyFrames: HistoryFrame[]; frameTimes: number[]; selectedGroupKey: string | null; rawCorrelationTracks: CorrelationVesselTrack[]; + // 6h identity (독립 레이어 — 1h/모델과 무관) + historyFrames6h: HistoryFrame[]; + frameTimes6h: number[]; + memberTripsData6h: TripsLayerDatum[]; + centerTrailSegments6h: CenterTrailSegment[]; + centerDotsPositions6h: [number, number][]; + subClusterCenters6h: { subClusterId: number; path: [number, number][]; timestamps: number[] }[]; + snapshotRanges6h: number[]; + // Pre-computed layer data memberTripsData: TripsLayerDatum[]; correlationTripsData: TripsLayerDatum[]; @@ -79,6 +88,12 @@ interface GearReplayState { showTrails: boolean; showLabels: boolean; focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김 + show1hPolygon: boolean; // 1h 폴리곤 표시 (진한색/실선) + show6hPolygon: boolean; // 6h 폴리곤 표시 (옅은색/점선) + abLoop: boolean; // A-B 구간 반복 활성화 + abA: number; // A 지점 (epoch ms, 0 = 미설정) + abB: number; // B 지점 (epoch ms, 0 = 미설정) + pinnedMmsis: Set; // 툴팁 고정 시 강조할 MMSI 세트 // Actions loadHistory: ( @@ -87,6 +102,7 @@ interface GearReplayState { corrData: GearCorrelationItem[], enabledModels: Set, enabledVessels: Set, + frames6h?: HistoryFrame[], ) => void; play: () => void; pause: () => void; @@ -98,6 +114,12 @@ interface GearReplayState { setShowTrails: (show: boolean) => void; setShowLabels: (show: boolean) => void; setFocusMode: (focus: boolean) => void; + setShow1hPolygon: (show: boolean) => void; + setShow6hPolygon: (show: boolean) => void; + setAbLoop: (on: boolean) => void; + setAbA: (t: number) => void; + setAbB: (t: number) => void; + setPinnedMmsis: (mmsis: Set) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; reset: () => void; } @@ -118,7 +140,20 @@ export const useGearReplayStore = create()( const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed; - if (newTime >= state.endTime) { + // A-B 구간 반복 + if (state.abLoop && state.abA > 0 && state.abB > state.abA) { + if (newTime >= state.abB) { + set({ currentTime: state.abA }); + animationFrameId = requestAnimationFrame(animate); + return; + } + // A 이전이면 A로 점프 + if (newTime < state.abA) { + set({ currentTime: state.abA }); + animationFrameId = requestAnimationFrame(animate); + return; + } + } else if (newTime >= state.endTime) { set({ currentTime: state.startTime }); animationFrameId = requestAnimationFrame(animate); return; @@ -141,6 +176,13 @@ export const useGearReplayStore = create()( frameTimes: [], selectedGroupKey: null, rawCorrelationTracks: [], + historyFrames6h: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], // Pre-computed layer data memberTripsData: [], @@ -159,20 +201,33 @@ export const useGearReplayStore = create()( showTrails: true, showLabels: true, focusMode: false, + show1hPolygon: true, + show6hPolygon: false, + abLoop: false, + abA: 0, + abB: 0, + pinnedMmsis: new Set(), correlationByModel: new Map(), // ── Actions ──────────────────────────────────────────────── - loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => { + loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => { const startTime = Date.now() - 12 * 60 * 60 * 1000; const endTime = Date.now(); const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); + const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime()); const memberTrips = buildMemberTripsData(frames, startTime); const corrTrips = buildCorrelationTripsData(corrTracks, startTime); const { segments, dots } = buildCenterTrailData(frames); const ranges = buildSnapshotRanges(frames, startTime, endTime); + // 6h 전처리 (동일한 빌드 함수) + const f6h = frames6h ?? []; + const memberTrips6h = f6h.length > 0 ? buildMemberTripsData(f6h, startTime) : []; + const { segments: seg6h, dots: dots6h } = f6h.length > 0 ? buildCenterTrailData(f6h) : { segments: [], dots: [] }; + const ranges6h = f6h.length > 0 ? buildSnapshotRanges(f6h, startTime, endTime) : []; + const byModel = new Map(); for (const c of corrData) { const list = byModel.get(c.modelName) ?? []; @@ -184,7 +239,13 @@ export const useGearReplayStore = create()( set({ historyFrames: frames, + historyFrames6h: f6h, frameTimes, + frameTimes6h, + memberTripsData6h: memberTrips6h, + centerTrailSegments6h: seg6h, + centerDotsPositions6h: dots6h, + snapshotRanges6h: ranges6h, startTime, endTime, currentTime: startTime, @@ -209,9 +270,9 @@ export const useGearReplayStore = create()( lastFrameTime = null; if (state.currentTime >= state.endTime) { - set({ isPlaying: true, currentTime: state.startTime }); + set({ isPlaying: true, currentTime: state.startTime, pinnedMmsis: new Set() }); } else { - set({ isPlaying: true }); + set({ isPlaying: true, pinnedMmsis: new Set() }); } animationFrameId = requestAnimationFrame(animate); @@ -247,6 +308,21 @@ export const useGearReplayStore = create()( setShowTrails: (show) => set({ showTrails: show }), setShowLabels: (show) => set({ showLabels: show }), setFocusMode: (focus) => set({ focusMode: focus }), + setShow1hPolygon: (show) => set({ show1hPolygon: show }), + setShow6hPolygon: (show) => set({ show6hPolygon: show }), + setAbLoop: (on) => { + const { startTime, endTime } = get(); + if (on && startTime > 0) { + // 기본 A-B: 전체 구간의 마지막 4시간 + const dur = endTime - startTime; + set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime }); + } else { + set({ abLoop: false, abA: 0, abB: 0 }); + } + }, + setAbA: (t) => set({ abA: t }), + setAbB: (t) => set({ abB: t }), + setPinnedMmsis: (mmsis) => set({ pinnedMmsis: mmsis }), updateCorrelation: (corrData, corrTracks) => { const state = get(); @@ -284,7 +360,14 @@ export const useGearReplayStore = create()( endTime: 0, playbackSpeed: 1, historyFrames: [], + historyFrames6h: [], frameTimes: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], selectedGroupKey: null, rawCorrelationTracks: [], memberTripsData: [], @@ -301,6 +384,12 @@ export const useGearReplayStore = create()( showTrails: true, showLabels: true, focusMode: false, + show1hPolygon: true, + show6hPolygon: false, + abLoop: false, + abA: 0, + abB: 0, + pinnedMmsis: new Set(), correlationByModel: new Map(), }); }, diff --git a/prediction/algorithms/gear_correlation.py b/prediction/algorithms/gear_correlation.py index 2eafca9..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,6 +558,7 @@ 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] @@ -566,10 +569,23 @@ def run_gear_correlation( if not members: continue - # 그룹 중심 + 반경 - center_lat = sum(m['lat'] for m in members) / len(members) - center_lon = sum(m['lon'] for m in members) / len(members) - group_radius = _compute_group_radius(members) + # 1h 활성 멤버 필터 (center/radius 계산용) + display_members = [ + m for m in members + if _get_time_bucket_age(m.get('mmsi'), all_positions, now) <= 3600 + ] + # fallback: < 2이면 time_bucket 최신 2개 유지 + if len(display_members) < 2 and len(members) >= 2: + display_members = sorted( + members, + key=lambda m: _get_time_bucket_age(m.get('mmsi'), all_positions, now), + )[:2] + active_members = display_members if len(display_members) >= 2 else members + + # 그룹 중심 + 반경 (1h 활성 멤버 기반) + center_lat = sum(m['lat'] for m in active_members) / len(active_members) + center_lon = sum(m['lon'] for m in active_members) / len(active_members) + group_radius = _compute_group_radius(active_members) # 어구 활성도 active_ratio = _compute_gear_active_ratio(members, all_positions, now) @@ -650,6 +666,8 @@ 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, sub_cluster_id, target_mmsi, @@ -659,6 +677,28 @@ def run_gear_correlation( )) 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) @@ -709,7 +749,8 @@ def _load_all_scores(conn) -> dict[tuple, dict]: try: cur.execute( "SELECT model_id, group_key, sub_cluster_id, target_mmsi, " - "current_score, streak_count, last_observed_at " + "current_score, streak_count, last_observed_at, " + "target_type, target_name " "FROM kcg.gear_correlation_scores" ) result = {} @@ -719,6 +760,8 @@ def _load_all_scores(conn) -> dict[tuple, dict]: '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: diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 34d78e7..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) @@ -187,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) @@ -361,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( @@ -384,100 +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, - 'sub_cluster_id': group.get('sub_cluster_id', 0), - 'snapshot_time': now, - 'polygon_wkt': polygon_wkt, - 'center_wkt': center_wkt, - 'area_sq_nm': area_sq_nm, - 'member_count': len(members_out), - 'zone_id': zone_id, - 'zone_name': zone_name, - 'members': members_out, - 'color': color, - }) return snapshots diff --git a/prediction/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 8297042..db55152 100644 --- a/prediction/db/kcgdb.py +++ b/prediction/db/kcgdb.py @@ -154,11 +154,11 @@ def save_group_snapshots(snapshots: list[dict]) -> int: insert_sql = """ INSERT INTO kcg.group_polygon_snapshots ( - group_type, group_key, group_label, sub_cluster_id, snapshot_time, + group_type, group_key, group_label, sub_cluster_id, resolution, snapshot_time, polygon, center_point, area_sq_nm, member_count, zone_id, zone_name, members, color ) VALUES ( - %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326), %s, %s, %s, %s, %s::jsonb, %s ) @@ -176,6 +176,7 @@ def save_group_snapshots(snapshots: list[dict]) -> int: s['group_key'], s['group_label'], s.get('sub_cluster_id', 0), + s.get('resolution', '6h'), s['snapshot_time'], s.get('polygon_wkt'), s.get('center_wkt'), 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)) """