From 71d607e499faa7031383b4f9072b85f8d16b762d Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 11:52:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=96=B4=EA=B5=AC=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=201h/6h=20=EB=93=80=EC=96=BC=20=ED=8F=B4=EB=A6=AC=EA=B3=A4=20+?= =?UTF-8?q?=20=EB=A6=AC=ED=94=8C=EB=A0=88=EC=9D=B4=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Python: 1h/6h 듀얼 스냅샷 생성 (polygon_builder), 1h 멤버 기반 일치율 후보 (gear_correlation) - DB: resolution 컬럼 추가 (011_polygon_resolution.sql) - Backend: resolution 필드 지원 (DTO/Service/Controller) - Frontend: 6h identity 레이어 독립 구현 (폴리곤/아이콘/라벨/항적/센터) - 리플레이 컨트롤러: 프로그레스바 통합, 1h/6h 스냅샷 표시, A-B 구간 반복 - 리치 툴팁: 클릭 고정 + 멤버 호버 강조 + 선박/어구/모델 소속 표시 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mda/kcg/domain/fleet/GroupPolygonDto.java | 1 + .../kcg/domain/fleet/GroupPolygonService.java | 13 +- database/migration/011_polygon_resolution.sql | 14 + .../components/korea/FleetClusterLayer.tsx | 22 +- .../korea/HistoryReplayController.tsx | 491 +++++++++++++++--- frontend/src/hooks/useGearReplayLayers.ts | 297 ++++++++++- frontend/src/services/vesselAnalysis.ts | 1 + frontend/src/stores/gearReplayStore.ts | 99 +++- prediction/algorithms/gear_correlation.py | 25 +- prediction/algorithms/polygon_builder.py | 260 +++++----- prediction/db/kcgdb.py | 5 +- 11 files changed, 992 insertions(+), 236 deletions(-) create mode 100644 database/migration/011_polygon_resolution.sql 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 0012c79..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 @@ -87,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' """; /** @@ -212,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/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 7b00ef5..a5d43c8 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -179,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; @@ -188,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)); @@ -202,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 }[] = []; @@ -216,7 +226,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS } } } - useGearReplayStore.setState({ subClusterCenters, allHistoryMembers }); + useGearReplayStore.setState({ subClusterCenters, subClusterCenters6h, allHistoryMembers }); store.play(); }; diff --git a/frontend/src/components/korea/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx index 2ebbfc9..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,76 +267,216 @@ 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="일치율 필터"> @@ -130,6 +484,15 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont + + + {frameCount} + {has6hData && <> / {frameCount6h}} 건 + +
); diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index 503de03..010fd8d 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -71,6 +71,14 @@ export function useGearReplayLayers( const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails); const showTrails = useGearReplayStore(s => s.showTrails); const showLabels = useGearReplayStore(s => s.showLabels); + const show1hPolygon = useGearReplayStore(s => s.show1hPolygon); + const show6hPolygon = useGearReplayStore(s => s.show6hPolygon); + const historyFrames6h = useGearReplayStore(s => s.historyFrames6h); + const memberTripsData6h = useGearReplayStore(s => s.memberTripsData6h); + const centerTrailSegments6h = useGearReplayStore(s => s.centerTrailSegments6h); + const centerDotsPositions6h = useGearReplayStore(s => s.centerDotsPositions6h); + const subClusterCenters6h = useGearReplayStore(s => s.subClusterCenters6h); + const pinnedMmsis = useGearReplayStore(s => s.pinnedMmsis); const { fontScale } = useFontScale(); const fs = fontScale.analysis; const zoomLevel = useShipDeckStore(s => s.zoomLevel); @@ -158,14 +166,50 @@ export function useGearReplayLayers( } } + // ── 6h 센터 트레일 (정적, frameIdx와 무관) ─────────────────────────── + if (state.show6hPolygon) { + const hasSub6h = subClusterCenters6h.length > 0 && subClusterCenters6h.some(sc => sc.subClusterId > 0); + if (hasSub6h) { + for (const sc of subClusterCenters6h) { + if (sc.subClusterId === 0) continue; + if (sc.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-6h-sub-center-${sc.subClusterId}`, + data: [{ path: sc.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [147, 197, 253, 120] as [number, number, number, number], + widthMinPixels: 1.5, + })); + } + } else { + for (let i = 0; i < centerTrailSegments6h.length; i++) { + const seg = centerTrailSegments6h[i]; + if (seg.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-6h-center-trail-${i}`, + data: [{ path: seg.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [147, 197, 253, seg.isInterpolated ? 80 : 120] as [number, number, number, number], + widthMinPixels: 1.5, + })); + } + if (centerDotsPositions6h.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-6h-center-dots', + data: centerDotsPositions6h, + getPosition: (d: [number, number]) => d, + getFillColor: [147, 197, 253, 120] as [number, number, number, number], + getRadius: 80, + radiusUnits: 'meters', + radiusMinPixels: 2, + })); + } + } + } + // ── Dynamic layers (depend on currentTime) ──────────────────────────── - if (frameIdx < 0) { - // No valid frame at this time — only show static layers - replayLayerRef.current = layers; - requestRender(); - return; - } + if (frameIdx >= 0) { const frame = state.historyFrames[frameIdx]; const isStale = !!frame._longGap || !!frame._interp; @@ -484,6 +528,81 @@ export function useGearReplayLayers( } } + // 7b. Pinned highlight (툴팁 고정 시 해당 MMSI 강조) + if (state.pinnedMmsis.size > 0) { + const pinnedPositions: { position: [number, number] }[] = []; + for (const m of members) { + if (state.pinnedMmsis.has(m.mmsi)) pinnedPositions.push({ position: [m.lon, m.lat] }); + } + for (const c of corrPositions) { + if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] }); + } + if (pinnedPositions.length > 0) { + // glow + layers.push(new ScatterplotLayer({ + id: 'replay-pinned-glow', + data: pinnedPositions, + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [255, 255, 255, 40], + getRadius: 350, + radiusUnits: 'meters', + radiusMinPixels: 12, + })); + // ring + layers.push(new ScatterplotLayer({ + id: 'replay-pinned-ring', + data: pinnedPositions, + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [0, 0, 0, 0], + getRadius: 200, + radiusUnits: 'meters', + radiusMinPixels: 6, + stroked: true, + getLineColor: [255, 255, 255, 200], + lineWidthMinPixels: 1.5, + })); + } + + // pinned trails (correlation tracks) + const relTime = ct - st; + for (const trip of correlationTripsData) { + if (!state.pinnedMmsis.has(trip.id)) continue; + let clipIdx = trip.timestamps.length; + for (let i = 0; i < trip.timestamps.length; i++) { + if (trip.timestamps[i] > relTime) { clipIdx = i; break; } + } + const clippedPath = trip.path.slice(0, clipIdx); + if (clippedPath.length >= 2) { + layers.push(new PathLayer({ + id: `replay-pinned-trail-${trip.id}`, + data: [{ path: clippedPath }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [255, 255, 255, 150], + widthMinPixels: 2.5, + })); + } + } + + // pinned member trails (identity tracks) + for (const trip of memberTripsData) { + if (!state.pinnedMmsis.has(trip.id)) continue; + let clipIdx = trip.timestamps.length; + for (let i = 0; i < trip.timestamps.length; i++) { + if (trip.timestamps[i] > relTime) { clipIdx = i; break; } + } + const clippedPath = trip.path.slice(0, clipIdx); + if (clippedPath.length >= 2) { + layers.push(new PathLayer({ + id: `replay-pinned-mtrail-${trip.id}`, + data: [{ path: clippedPath }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [255, 200, 60, 180], + widthMinPixels: 2.5, + })); + } + } + } + // 8. Operational polygons (서브클러스터별 분리 — subClusterId 기반) for (const [mn, items] of correlationByModel) { if (!enabledModels.has(mn)) continue; @@ -654,24 +773,27 @@ export function useGearReplayLayers( [167, 139, 250, 255], ]; - for (const sf of subFrames) { - const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId); - const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); - const poly = buildInterpPolygon(sfPts); - if (!poly) continue; + // ── 1h 폴리곤 (진한색, 실선) ── + if (state.show1hPolygon) { + for (const sf of subFrames) { + const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId); + const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); + const poly = buildInterpPolygon(sfPts); + if (!poly) continue; - const ci = sf.subClusterId % SUB_POLY_COLORS.length; - layers.push(new PolygonLayer({ - id: `replay-identity-polygon-sub${sf.subClusterId}`, - data: [{ polygon: poly.coordinates }], - getPolygon: (d: { polygon: number[][][] }) => d.polygon, - getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci], - getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci], - getLineWidth: isStale ? 1 : 2, - lineWidthMinPixels: 1, - filled: true, - stroked: true, - })); + const ci = sf.subClusterId % SUB_POLY_COLORS.length; + layers.push(new PolygonLayer({ + id: `replay-identity-polygon-1h-sub${sf.subClusterId}`, + data: [{ polygon: poly.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci], + getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci], + getLineWidth: isStale ? 1 : 2, + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } } // TripsLayer (멤버 트레일) @@ -717,13 +839,126 @@ export function useGearReplayLayers( })); } + } // end if (frameIdx >= 0) + + // ══ 6h Identity 레이어 (독립 — 1h/모델과 무관) ══ + if (state.show6hPolygon && state.historyFrames6h.length > 0) { + const { index: frameIdx6h } = findFrameAtTime(state.frameTimes6h, ct, 0); + if (frameIdx6h >= 0) { + const frame6h = state.historyFrames6h[frameIdx6h]; + const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }]; + const members6h = interpolateMemberPositions(state.historyFrames6h, frameIdx6h, ct); + + // 6h 폴리곤 + for (const sf of subFrames6h) { + const sfMembers = interpolateSubFrameMembers(state.historyFrames6h, frameIdx6h, ct, sf.subClusterId); + const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); + const poly = buildInterpPolygon(sfPts); + if (!poly) continue; + layers.push(new PolygonLayer({ + id: `replay-6h-polygon-sub${sf.subClusterId}`, + data: [{ polygon: poly.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: [147, 197, 253, 25] as [number, number, number, number], + getLineColor: [147, 197, 253, 160] as [number, number, number, number], + getLineWidth: 1, + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } + + // 6h 멤버 아이콘 + if (members6h.length > 0) { + layers.push(new IconLayer({ + id: 'replay-6h-members', + data: members6h, + getPosition: d => [d.lon, d.lat], + getIcon: d => d.isGear ? SHIP_ICON_MAPPING['gear-diamond'] : SHIP_ICON_MAPPING['ship-triangle'], + getSize: d => d.isParent ? 24 : d.isGear ? 14 : 18, + getAngle: d => d.isGear ? 0 : -(d.cog || 0), + getColor: d => { + if (d.stale) return [100, 116, 139, 150]; + return [147, 197, 253, 200]; + }, + sizeUnits: 'pixels', + billboard: false, + })); + + // 6h 멤버 라벨 + if (showLabels) { + const clustered6h = clusterLabels(members6h, d => [d.lon, d.lat], zoomLevel); + layers.push(new TextLayer({ + id: 'replay-6h-member-labels', + data: clustered6h, + getPosition: d => [d.lon, d.lat], + getText: d => { + const prefix = d.isParent ? '\u2605 ' : ''; + return prefix + (d.name || d.mmsi); + }, + getColor: [147, 197, 253, 230] as [number, number, number, number], + getSize: 10 * fs, + getPixelOffset: [0, 14], + background: true, + getBackgroundColor: [0, 0, 0, 200] as [number, number, number, number], + backgroundPadding: [2, 1], + fontFamily: '"Fira Code Variable", monospace', + })); + } + } + + // 6h TripsLayer (항적 애니메이션) + if (memberTripsData6h.length > 0) { + layers.push(new TripsLayer({ + id: 'replay-6h-identity-trails', + data: memberTripsData6h, + getPath: d => d.path, + getTimestamps: d => d.timestamps, + getColor: [147, 197, 253, 180] as [number, number, number, number], + widthMinPixels: 2, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + + // 6h 센터 포인트 (서브클러스터별 보간) + for (const sf of subFrames6h) { + const nextFrame6h = frameIdx6h < state.historyFrames6h.length - 1 ? state.historyFrames6h[frameIdx6h + 1] : null; + const nextSf = nextFrame6h?.subFrames?.find(s => s.subClusterId === sf.subClusterId); + let cx = sf.centerLon, cy = sf.centerLat; + if (nextSf && nextFrame6h) { + const t0 = new Date(frame6h.snapshotTime).getTime(); + const t1 = new Date(nextFrame6h.snapshotTime).getTime(); + const r = t1 > t0 ? Math.max(0, Math.min(1, (ct - t0) / (t1 - t0))) : 0; + cx = sf.centerLon + (nextSf.centerLon - sf.centerLon) * r; + cy = sf.centerLat + (nextSf.centerLat - sf.centerLat) * r; + } + layers.push(new ScatterplotLayer({ + id: `replay-6h-center-sub${sf.subClusterId}`, + data: [{ position: [cx, cy] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [147, 197, 253, 200] as [number, number, number, number], + getRadius: 150, + radiusUnits: 'meters', + radiusMinPixels: 5, + stroked: true, + getLineColor: [255, 255, 255, 200] as [number, number, number, number], + lineWidthMinPixels: 1.5, + })); + } + } + } + replayLayerRef.current = layers; requestRender(); }, [ - historyFrames, memberTripsData, correlationTripsData, + historyFrames, historyFrames6h, memberTripsData, memberTripsData6h, correlationTripsData, centerTrailSegments, centerDotsPositions, + centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h, enabledModels, enabledVessels, hoveredMmsi, correlationByModel, - modelCenterTrails, subClusterCenters, showTrails, showLabels, fs, zoomLevel, + modelCenterTrails, subClusterCenters, showTrails, showLabels, + show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel, replayLayerRef, requestRender, ]); @@ -778,8 +1013,20 @@ export function useGearReplayLayers( }, ); + // 1h/6h 토글 + pinnedMmsis 변경 시 즉시 렌더 + const unsubPolygonToggle = useGearReplayStore.subscribe( + s => [s.show1hPolygon, s.show6hPolygon] as const, + () => { debugLoggedRef.current = false; renderFrame(); }, + ); + const unsubPinned = useGearReplayStore.subscribe( + s => s.pinnedMmsis, + () => renderFrame(), + ); + return () => { unsub(); + unsubPolygonToggle(); + unsubPinned(); if (pendingRafId) cancelAnimationFrame(pendingRafId); }; }, [historyFrames, renderFrame]); diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index 57350dc..a1c470c 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -73,6 +73,7 @@ export interface GroupPolygonDto { zoneName: string | null; members: MemberInfo[]; color: string; + resolution?: '1h' | '6h'; } export async function fetchGroupPolygons(): Promise { diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts index 94edf83..60a70c9 100644 --- a/frontend/src/stores/gearReplayStore.ts +++ b/frontend/src/stores/gearReplayStore.ts @@ -54,12 +54,21 @@ interface GearReplayState { endTime: number; playbackSpeed: number; - // Source data + // Source data (1h = primary identity polygon) historyFrames: HistoryFrame[]; frameTimes: number[]; selectedGroupKey: string | null; rawCorrelationTracks: CorrelationVesselTrack[]; + // 6h identity (독립 레이어 — 1h/모델과 무관) + historyFrames6h: HistoryFrame[]; + frameTimes6h: number[]; + memberTripsData6h: TripsLayerDatum[]; + centerTrailSegments6h: CenterTrailSegment[]; + centerDotsPositions6h: [number, number][]; + subClusterCenters6h: { subClusterId: number; path: [number, number][]; timestamps: number[] }[]; + snapshotRanges6h: number[]; + // Pre-computed layer data memberTripsData: TripsLayerDatum[]; correlationTripsData: TripsLayerDatum[]; @@ -79,6 +88,12 @@ interface GearReplayState { showTrails: boolean; showLabels: boolean; focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김 + show1hPolygon: boolean; // 1h 폴리곤 표시 (진한색/실선) + show6hPolygon: boolean; // 6h 폴리곤 표시 (옅은색/점선) + abLoop: boolean; // A-B 구간 반복 활성화 + abA: number; // A 지점 (epoch ms, 0 = 미설정) + abB: number; // B 지점 (epoch ms, 0 = 미설정) + pinnedMmsis: Set; // 툴팁 고정 시 강조할 MMSI 세트 // Actions loadHistory: ( @@ -87,6 +102,7 @@ interface GearReplayState { corrData: GearCorrelationItem[], enabledModels: Set, enabledVessels: Set, + frames6h?: HistoryFrame[], ) => void; play: () => void; pause: () => void; @@ -98,6 +114,12 @@ interface GearReplayState { setShowTrails: (show: boolean) => void; setShowLabels: (show: boolean) => void; setFocusMode: (focus: boolean) => void; + setShow1hPolygon: (show: boolean) => void; + setShow6hPolygon: (show: boolean) => void; + setAbLoop: (on: boolean) => void; + setAbA: (t: number) => void; + setAbB: (t: number) => void; + setPinnedMmsis: (mmsis: Set) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; reset: () => void; } @@ -118,7 +140,20 @@ export const useGearReplayStore = create()( const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed; - if (newTime >= state.endTime) { + // A-B 구간 반복 + if (state.abLoop && state.abA > 0 && state.abB > state.abA) { + if (newTime >= state.abB) { + set({ currentTime: state.abA }); + animationFrameId = requestAnimationFrame(animate); + return; + } + // A 이전이면 A로 점프 + if (newTime < state.abA) { + set({ currentTime: state.abA }); + animationFrameId = requestAnimationFrame(animate); + return; + } + } else if (newTime >= state.endTime) { set({ currentTime: state.startTime }); animationFrameId = requestAnimationFrame(animate); return; @@ -141,6 +176,13 @@ export const useGearReplayStore = create()( frameTimes: [], selectedGroupKey: null, rawCorrelationTracks: [], + historyFrames6h: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], // Pre-computed layer data memberTripsData: [], @@ -159,20 +201,33 @@ export const useGearReplayStore = create()( showTrails: true, showLabels: true, focusMode: false, + show1hPolygon: true, + show6hPolygon: false, + abLoop: false, + abA: 0, + abB: 0, + pinnedMmsis: new Set(), correlationByModel: new Map(), // ── Actions ──────────────────────────────────────────────── - loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => { + loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => { const startTime = Date.now() - 12 * 60 * 60 * 1000; const endTime = Date.now(); const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); + const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime()); const memberTrips = buildMemberTripsData(frames, startTime); const corrTrips = buildCorrelationTripsData(corrTracks, startTime); const { segments, dots } = buildCenterTrailData(frames); const ranges = buildSnapshotRanges(frames, startTime, endTime); + // 6h 전처리 (동일한 빌드 함수) + const f6h = frames6h ?? []; + const memberTrips6h = f6h.length > 0 ? buildMemberTripsData(f6h, startTime) : []; + const { segments: seg6h, dots: dots6h } = f6h.length > 0 ? buildCenterTrailData(f6h) : { segments: [], dots: [] }; + const ranges6h = f6h.length > 0 ? buildSnapshotRanges(f6h, startTime, endTime) : []; + const byModel = new Map(); for (const c of corrData) { const list = byModel.get(c.modelName) ?? []; @@ -184,7 +239,13 @@ export const useGearReplayStore = create()( set({ historyFrames: frames, + historyFrames6h: f6h, frameTimes, + frameTimes6h, + memberTripsData6h: memberTrips6h, + centerTrailSegments6h: seg6h, + centerDotsPositions6h: dots6h, + snapshotRanges6h: ranges6h, startTime, endTime, currentTime: startTime, @@ -209,9 +270,9 @@ export const useGearReplayStore = create()( lastFrameTime = null; if (state.currentTime >= state.endTime) { - set({ isPlaying: true, currentTime: state.startTime }); + set({ isPlaying: true, currentTime: state.startTime, pinnedMmsis: new Set() }); } else { - set({ isPlaying: true }); + set({ isPlaying: true, pinnedMmsis: new Set() }); } animationFrameId = requestAnimationFrame(animate); @@ -247,6 +308,21 @@ export const useGearReplayStore = create()( setShowTrails: (show) => set({ showTrails: show }), setShowLabels: (show) => set({ showLabels: show }), setFocusMode: (focus) => set({ focusMode: focus }), + setShow1hPolygon: (show) => set({ show1hPolygon: show }), + setShow6hPolygon: (show) => set({ show6hPolygon: show }), + setAbLoop: (on) => { + const { startTime, endTime } = get(); + if (on && startTime > 0) { + // 기본 A-B: 전체 구간의 마지막 4시간 + const dur = endTime - startTime; + set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime }); + } else { + set({ abLoop: false, abA: 0, abB: 0 }); + } + }, + setAbA: (t) => set({ abA: t }), + setAbB: (t) => set({ abB: t }), + setPinnedMmsis: (mmsis) => set({ pinnedMmsis: mmsis }), updateCorrelation: (corrData, corrTracks) => { const state = get(); @@ -284,7 +360,14 @@ export const useGearReplayStore = create()( endTime: 0, playbackSpeed: 1, historyFrames: [], + historyFrames6h: [], frameTimes: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], selectedGroupKey: null, rawCorrelationTracks: [], memberTripsData: [], @@ -301,6 +384,12 @@ export const useGearReplayStore = create()( showTrails: true, showLabels: true, focusMode: false, + show1hPolygon: true, + show6hPolygon: false, + abLoop: false, + abA: 0, + abB: 0, + pinnedMmsis: new Set(), correlationByModel: new Map(), }); }, diff --git a/prediction/algorithms/gear_correlation.py b/prediction/algorithms/gear_correlation.py index e690c83..8de28e6 100644 --- a/prediction/algorithms/gear_correlation.py +++ b/prediction/algorithms/gear_correlation.py @@ -18,6 +18,8 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional +from algorithms.polygon_builder import _get_time_bucket_age + logger = logging.getLogger(__name__) @@ -479,7 +481,7 @@ def _compute_gear_active_ratio( gear_members: list[dict], all_positions: dict[str, dict], now: datetime, - stale_sec: float = 21600, + stale_sec: float = 3600, ) -> float: """어구 그룹의 활성 멤버 비율.""" if not gear_members: @@ -567,10 +569,23 @@ def run_gear_correlation( if not members: continue - # 그룹 중심 + 반경 - center_lat = sum(m['lat'] for m in members) / len(members) - center_lon = sum(m['lon'] for m in members) / len(members) - group_radius = _compute_group_radius(members) + # 1h 활성 멤버 필터 (center/radius 계산용) + display_members = [ + m for m in members + if _get_time_bucket_age(m.get('mmsi'), all_positions, now) <= 3600 + ] + # fallback: < 2이면 time_bucket 최신 2개 유지 + if len(display_members) < 2 and len(members) >= 2: + display_members = sorted( + members, + key=lambda m: _get_time_bucket_age(m.get('mmsi'), all_positions, now), + )[:2] + active_members = display_members if len(display_members) >= 2 else members + + # 그룹 중심 + 반경 (1h 활성 멤버 기반) + center_lat = sum(m['lat'] for m in active_members) / len(active_members) + center_lon = sum(m['lon'] for m in active_members) / len(active_members) + group_radius = _compute_group_radius(active_members) # 어구 활성도 active_ratio = _compute_gear_active_ratio(members, all_positions, now) diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 31fa738..78339fb 100644 --- a/prediction/algorithms/polygon_builder.py +++ b/prediction/algorithms/polygon_builder.py @@ -11,6 +11,9 @@ import math import re from datetime import datetime, timezone from typing import Optional +from zoneinfo import ZoneInfo + +import pandas as pd try: from shapely.geometry import MultiPoint, Point @@ -33,6 +36,23 @@ FLEET_BUFFER_DEG = 0.02 GEAR_BUFFER_DEG = 0.01 MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외) +_KST = ZoneInfo('Asia/Seoul') + + +def _get_time_bucket_age(mmsi: str, all_positions: dict, now: datetime) -> float: + """MMSI의 time_bucket 기반 age(초) 반환. 실패 시 inf.""" + pos = all_positions.get(mmsi) + tb = pos.get('time_bucket') if pos else None + if tb is None: + return float('inf') + try: + tb_dt = pd.Timestamp(tb) + if tb_dt.tzinfo is None: + tb_dt = tb_dt.tz_localize(_KST).tz_convert(timezone.utc) + return (now - tb_dt.to_pydatetime()).total_seconds() + except Exception: + return float('inf') + # 수역 내 어구 색상, 수역 외 어구 색상 _COLOR_GEAR_IN_ZONE = '#ef4444' _COLOR_GEAR_OUT_ZONE = '#f97316' @@ -159,7 +179,6 @@ def detect_gear_groups( last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc) else: try: - import pandas as pd last_dt = pd.Timestamp(ts).to_pydatetime() if last_dt.tzinfo is None: last_dt = last_dt.replace(tzinfo=timezone.utc) @@ -344,7 +363,6 @@ def build_all_group_snapshots( points: list[tuple[float, float]] = [] members: list[dict] = [] - newest_age = float('inf') for mmsi in mmsi_list: pos = all_positions.get(mmsi) if not pos: @@ -364,22 +382,11 @@ def build_all_group_snapshots( 'role': 'LEADER' if mmsi == mmsi_list[0] else 'MEMBER', 'isParent': False, }) - # 멤버 중 가장 최근 적재시간(time_bucket) 추적 - tb = pos.get('time_bucket') - if tb is not None: - try: - import pandas as pd - tb_dt = pd.Timestamp(tb) - if tb_dt.tzinfo is None: - from zoneinfo import ZoneInfo - tb_dt = tb_dt.tz_localize(ZoneInfo('Asia/Seoul')).tz_convert(timezone.utc) - tb_dt = tb_dt.to_pydatetime() - age = (now - tb_dt).total_seconds() - if age < newest_age: - newest_age = age - except Exception: - pass + newest_age = min( + (_get_time_bucket_age(m['mmsi'], all_positions, now) for m in members), + default=float('inf'), + ) # 2척 미만 또는 최근 적재가 DISPLAY_STALE_SEC 초과 → 폴리곤 미생성 if len(points) < 2 or newest_age > DISPLAY_STALE_SEC: continue @@ -403,124 +410,129 @@ def build_all_group_snapshots( 'color': _cluster_color(company_id), }) - # ── GEAR 타입: detect_gear_groups 결과 순회 ─────────────────── + # ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ──── gear_groups = detect_gear_groups(vessel_store, now=now) for group in gear_groups: parent_name: str = group['parent_name'] parent_mmsi: Optional[str] = group['parent_mmsi'] - gear_members: list[dict] = group['members'] + gear_members: list[dict] = group['members'] # 6h STALE 기반 전체 멤버 - # 표시 기준: 그룹 멤버 중 가장 최근 적재(time_bucket)가 DISPLAY_STALE_SEC 이내여야 노출 - # time_bucket은 KST naive이므로 UTC로 변환 후 비교 - newest_age = float('inf') - for gm in gear_members: - gm_mmsi = gm.get('mmsi') - gm_pos = all_positions.get(gm_mmsi) if gm_mmsi else None - gm_tb = gm_pos.get('time_bucket') if gm_pos else None - if gm_tb is not None: - try: - import pandas as pd - tb_dt = pd.Timestamp(gm_tb) - if tb_dt.tzinfo is None: - # time_bucket은 KST (Asia/Seoul, UTC+9) - from zoneinfo import ZoneInfo - tb_dt = tb_dt.tz_localize(ZoneInfo('Asia/Seoul')).tz_convert(timezone.utc) - tb_dt = tb_dt.to_pydatetime() - except Exception: - continue - age = (now - tb_dt).total_seconds() - if age < newest_age: - newest_age = age - if newest_age > DISPLAY_STALE_SEC: + if not gear_members: continue - # 수역 분류: anchor(모선 or 첫 어구) 위치 기준 - anchor_lat: Optional[float] = None - anchor_lon: Optional[float] = None + # ── 1h 활성 멤버 필터 ── + display_members_1h = [ + gm for gm in gear_members + if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC + ] + # fallback: 1h < 2이면 time_bucket 최신 2개 유지 (폴리곤 형태 보존) + if len(display_members_1h) < 2 and len(gear_members) >= 2: + sorted_by_age = sorted( + gear_members, + key=lambda gm: _get_time_bucket_age(gm.get('mmsi'), all_positions, now), + ) + display_members_1h = sorted_by_age[:2] - if parent_mmsi and parent_mmsi in all_positions: - parent_pos = all_positions[parent_mmsi] - anchor_lat = parent_pos['lat'] - anchor_lon = parent_pos['lon'] - - if anchor_lat is None and gear_members: - anchor_lat = gear_members[0]['lat'] - anchor_lon = gear_members[0]['lon'] - - if anchor_lat is None: - continue - - zone_info = classify_zone(float(anchor_lat), float(anchor_lon)) - in_zone = _is_in_zone(zone_info) - zone_id = zone_info.get('zone') if in_zone else None - zone_name = zone_info.get('zone_name') if in_zone else None - - # 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외 - if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE: - continue - - # 폴리곤 points: 어구 좌표 + 모선 좌표 (근접 시에만) - points = [(g['lon'], g['lat']) for g in gear_members] - parent_nearby = False - if parent_mmsi and parent_mmsi in all_positions: - parent_pos = all_positions[parent_mmsi] - p_lon, p_lat = parent_pos['lon'], parent_pos['lat'] - # 모선이 어구 클러스터 내 최소 1개와 MAX_DIST_DEG*2 이내일 때만 포함 - if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2 - and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in gear_members): - if (p_lon, p_lat) not in points: - points.append((p_lon, p_lat)) - parent_nearby = True - - polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon( - points, GEAR_BUFFER_DEG + # ── 6h 전체 멤버 노출 조건: 최신 적재가 STALE_SEC 이내 ── + newest_age_6h = min( + (_get_time_bucket_age(gm.get('mmsi'), all_positions, now) for gm in gear_members), + default=float('inf'), ) + display_members_6h = gear_members - # members JSONB 구성 - members_out: list[dict] = [] - # 모선 먼저 (근접 시에만) - if parent_nearby and parent_mmsi and parent_mmsi in all_positions: - parent_pos = all_positions[parent_mmsi] - members_out.append({ - 'mmsi': parent_mmsi, - 'name': parent_name, - 'lat': parent_pos['lat'], - 'lon': parent_pos['lon'], - 'sog': parent_pos.get('sog', 0), - 'cog': parent_pos.get('cog', 0), - 'role': 'PARENT', - 'isParent': True, + # ── resolution별 스냅샷 생성 ── + for resolution, members_for_snap in [('1h', display_members_1h), ('6h', display_members_6h)]: + if len(members_for_snap) < 2: + continue + # 6h: 최신 적재가 STALE_SEC(6h) 초과 시 스킵 + if resolution == '6h' and newest_age_6h > STALE_SEC: + continue + + # 수역 분류: anchor(모선 or 첫 멤버) 위치 기준 + anchor_lat: Optional[float] = None + anchor_lon: Optional[float] = None + + if parent_mmsi and parent_mmsi in all_positions: + parent_pos = all_positions[parent_mmsi] + anchor_lat = parent_pos['lat'] + anchor_lon = parent_pos['lon'] + + if anchor_lat is None and members_for_snap: + anchor_lat = members_for_snap[0]['lat'] + anchor_lon = members_for_snap[0]['lon'] + + if anchor_lat is None: + continue + + zone_info = classify_zone(float(anchor_lat), float(anchor_lon)) + in_zone = _is_in_zone(zone_info) + zone_id = zone_info.get('zone') if in_zone else None + zone_name = zone_info.get('zone_name') if in_zone else None + + # 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외 + if not in_zone and len(members_for_snap) < MIN_GEAR_GROUP_SIZE: + continue + + # 폴리곤 points: 멤버 좌표 + 모선 좌표 (근접 시에만) + points = [(g['lon'], g['lat']) for g in members_for_snap] + parent_nearby = False + if parent_mmsi and parent_mmsi in all_positions: + parent_pos = all_positions[parent_mmsi] + p_lon, p_lat = parent_pos['lon'], parent_pos['lat'] + if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2 + and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in members_for_snap): + if (p_lon, p_lat) not in points: + points.append((p_lon, p_lat)) + parent_nearby = True + + polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon( + points, GEAR_BUFFER_DEG + ) + + # members JSONB 구성 + members_out: list[dict] = [] + if parent_nearby and parent_mmsi and parent_mmsi in all_positions: + parent_pos = all_positions[parent_mmsi] + members_out.append({ + 'mmsi': parent_mmsi, + 'name': parent_name, + 'lat': parent_pos['lat'], + 'lon': parent_pos['lon'], + 'sog': parent_pos.get('sog', 0), + 'cog': parent_pos.get('cog', 0), + 'role': 'PARENT', + 'isParent': True, + }) + for g in members_for_snap: + members_out.append({ + 'mmsi': g['mmsi'], + 'name': g['name'], + 'lat': g['lat'], + 'lon': g['lon'], + 'sog': g['sog'], + 'cog': g['cog'], + 'role': 'GEAR', + 'isParent': False, + }) + + color = _COLOR_GEAR_IN_ZONE if in_zone else _COLOR_GEAR_OUT_ZONE + + snapshots.append({ + 'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE', + 'group_key': parent_name, + 'group_label': parent_name, + 'sub_cluster_id': group.get('sub_cluster_id', 0), + 'resolution': resolution, + 'snapshot_time': now, + 'polygon_wkt': polygon_wkt, + 'center_wkt': center_wkt, + 'area_sq_nm': area_sq_nm, + 'member_count': len(members_out), + 'zone_id': zone_id, + 'zone_name': zone_name, + 'members': members_out, + 'color': color, }) - # 어구 목록 - for g in gear_members: - members_out.append({ - 'mmsi': g['mmsi'], - 'name': g['name'], - 'lat': g['lat'], - 'lon': g['lon'], - 'sog': g['sog'], - 'cog': g['cog'], - 'role': 'GEAR', - 'isParent': False, - }) - - color = _COLOR_GEAR_IN_ZONE if in_zone else _COLOR_GEAR_OUT_ZONE - - snapshots.append({ - 'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE', - 'group_key': parent_name, - 'group_label': parent_name, - 'sub_cluster_id': group.get('sub_cluster_id', 0), - 'snapshot_time': now, - 'polygon_wkt': polygon_wkt, - 'center_wkt': center_wkt, - 'area_sq_nm': area_sq_nm, - 'member_count': len(members_out), - 'zone_id': zone_id, - 'zone_name': zone_name, - 'members': members_out, - 'color': color, - }) return snapshots diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py index 8297042..db55152 100644 --- a/prediction/db/kcgdb.py +++ b/prediction/db/kcgdb.py @@ -154,11 +154,11 @@ def save_group_snapshots(snapshots: list[dict]) -> int: insert_sql = """ INSERT INTO kcg.group_polygon_snapshots ( - group_type, group_key, group_label, sub_cluster_id, snapshot_time, + group_type, group_key, group_label, sub_cluster_id, resolution, snapshot_time, polygon, center_point, area_sq_nm, member_count, zone_id, zone_name, members, color ) VALUES ( - %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326), %s, %s, %s, %s, %s::jsonb, %s ) @@ -176,6 +176,7 @@ def save_group_snapshots(snapshots: list[dict]) -> int: s['group_key'], s['group_label'], s.get('sub_cluster_id', 0), + s.get('resolution', '6h'), s['snapshot_time'], s.get('polygon_wkt'), s.get('center_wkt'),