feat: 어구 그룹 1h/6h 듀얼 폴리곤 + 리플레이 컨트롤러 개선

- 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) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-01 11:52:38 +09:00
부모 f09186a187
커밋 71d607e499
11개의 변경된 파일992개의 추가작업 그리고 236개의 파일을 삭제

파일 보기

@ -25,4 +25,5 @@ public class GroupPolygonDto {
private String zoneName; private String zoneName;
private List<Map<String, Object>> members; private List<Map<String, Object>> members;
private String color; private String color;
private String resolution;
} }

파일 보기

@ -31,9 +31,10 @@ public class GroupPolygonService {
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time, SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon) AS polygon_geojson, ST_AsGeoJSON(polygon) AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, 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 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 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, SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon) AS polygon_geojson, ST_AsGeoJSON(polygon) AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, 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 FROM kcg.group_polygon_snapshots
WHERE group_key = ? WHERE group_key = ?
ORDER BY snapshot_time DESC ORDER BY snapshot_time DESC
@ -52,7 +53,7 @@ public class GroupPolygonService {
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time, SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon) AS polygon_geojson, ST_AsGeoJSON(polygon) AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, 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 FROM kcg.group_polygon_snapshots
WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL) WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL)
ORDER BY snapshot_time DESC ORDER BY snapshot_time DESC
@ -87,8 +88,9 @@ public class GroupPolygonService {
SELECT COUNT(*) AS gear_groups, SELECT COUNT(*) AS gear_groups,
COALESCE(SUM(member_count), 0) AS gear_count COALESCE(SUM(member_count), 0) AS gear_count
FROM kcg.group_polygon_snapshots 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 group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')
AND resolution = '1h'
"""; """;
/** /**
@ -212,6 +214,7 @@ public class GroupPolygonService {
.zoneName(rs.getString("zone_name")) .zoneName(rs.getString("zone_name"))
.members(members) .members(members)
.color(rs.getString("color")) .color(rs.getString("color"))
.resolution(rs.getString("resolution"))
.build(); .build();
} }
} }

파일 보기

@ -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);

파일 보기

@ -179,8 +179,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })), fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })),
]); ]);
// 2. 서브클러스터별 분리 → 멤버 합산 프레임 + 서브클러스터별 독립 center // 2. resolution별 분리 → 1h(primary) + 6h(secondary)
const { frames: filled, subClusterCenters } = splitAndMergeHistory(history); 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 corrData = corrRes.items;
const corrTracks = trackRes.vessels; 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; const withTrack = corrTracks.filter(v => v.track && v.track.length > 0).length;
console.log('[loadHistory] fetch 완료:', { console.log('[loadHistory] fetch 완료:', {
history: history.length, history: history.length,
'1h': history1h.length,
'6h': history6h.length,
'filled1h': filled.length,
'filled6h': filled6h.length,
corrData: corrData.length, corrData: corrData.length,
corrTracks: corrTracks.length, corrTracks: corrTracks.length,
withTrack, 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)); 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); setEnabledVessels(vessels);
setCorrelationLoading(false); setCorrelationLoading(false);
// 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작 // 4. 스토어 초기화 (1h + 6h 모든 데이터 포함) → 재생 시작
const store = useGearReplayStore.getState(); const store = useGearReplayStore.getState();
store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels); store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels, filled6h);
// 서브클러스터별 독립 center 궤적 + 전체 구간 고유 멤버 저장 // 서브클러스터별 독립 center 궤적 + 전체 구간 고유 멤버 저장
const seen = new Set<string>(); const seen = new Set<string>();
const allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[] = []; 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(); store.play();
}; };

파일 보기

@ -1,32 +1,114 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
import { useGearReplayStore } from '../../stores/gearReplayStore'; import { useGearReplayStore } from '../../stores/gearReplayStore';
import { MODEL_COLORS } from './fleetClusterConstants';
import type { HistoryFrame } from './fleetClusterTypes';
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
interface HistoryReplayControllerProps { interface HistoryReplayControllerProps {
onClose: () => void; onClose: () => void;
onFilterByScore: (minPct: number | null) => 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<string, GearCorrelationItem[]>,
enabledModels: Set<string>,
enabledVessels: Set<string>,
): TooltipMember[] {
const map = new Map<string, TooltipMember>();
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 HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
const isPlaying = useGearReplayStore(s => s.isPlaying); const isPlaying = useGearReplayStore(s => s.isPlaying);
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); 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 showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); const showLabels = useGearReplayStore(s => s.showLabels);
const focusMode = useGearReplayStore(s => s.focusMode); 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<HTMLInputElement>(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<HTMLDivElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null); const progressIndicatorRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null); const timeDisplayRef = useRef<HTMLSpanElement>(null);
const store = useGearReplayStore;
// currentTime → 진행 인디케이터
useEffect(() => { useEffect(() => {
const unsub = useGearReplayStore.subscribe( const unsub = store.subscribe(
s => s.currentTime, s => s.currentTime,
(currentTime) => { (currentTime) => {
const { startTime, endTime } = useGearReplayStore.getState(); const { startTime, endTime } = store.getState();
if (endTime <= startTime) return; if (endTime <= startTime) return;
const progress = (currentTime - startTime) / (endTime - startTime); 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 (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`;
if (timeDisplayRef.current) { if (timeDisplayRef.current) {
timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); 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; 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<HTMLDivElement>) => {
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<string>();
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<HTMLDivElement>) => {
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 = { const btnStyle: React.CSSProperties = {
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, 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, 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, minWidth: 380, maxWidth: 1320,
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)', 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, 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', boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto',
}}> }}>
{/* 프로그레스 바 */} {/* 프로그레스 트랙 */}
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}> <div
{snapshotRanges.map((pos, i) => ( ref={trackRef}
<div key={i} style={{ style={{ position: 'relative', height: 18, cursor: 'pointer' }}
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%', onClick={handleTrackClick}
background: 'rgba(251,191,36,0.4)', onMouseMove={handleTrackHover}
onMouseLeave={() => { if (!pinnedTooltip) setHoveredTooltip(null); }}
>
<div style={{ position: 'absolute', left: 0, right: 0, top: 5, height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4 }} />
{/* A-B 구간 */}
{abLoop && abAPos >= 0 && abBPos >= 0 && (
<div style={{
position: 'absolute', left: `${abAPos * 100}%`, top: 5,
width: `${(abBPos - abAPos) * 100}%`, height: 8,
background: 'rgba(34,197,94,0.12)', borderRadius: 4, pointerEvents: 'none',
}} /> }} />
)}
{snapshotRanges6h.map((pos, i) => (
<div key={`6h-${i}`} style={{ position: 'absolute', left: `${pos * 100}%`, top: 9, width: 2, height: 4, background: 'rgba(147,197,253,0.4)' }} />
))} ))}
{snapshotRanges.map((pos, i) => (
<div key={`1h-${i}`} style={{ position: 'absolute', left: `${pos * 100}%`, top: 5, width: 2, height: 4, background: 'rgba(251,191,36,0.5)' }} />
))}
{/* A-B 마커 */}
{abLoop && abAPos >= 0 && (
<div onMouseDown={handleAbDown('A')} style={{
position: 'absolute', left: `${abAPos * 100}%`, top: 0, width: 8, height: 18,
transform: 'translateX(-50%)', cursor: isPlaying ? 'default' : 'ew-resize', zIndex: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 2, height: 16, background: 'rgba(34,197,94,0.8)', borderRadius: 1 }} />
<span style={{ position: 'absolute', top: -14, fontSize: 10, color: '#22c55e', fontWeight: 700, pointerEvents: 'none', background: 'rgba(0,0,0,0.7)', borderRadius: 2, padding: '0 3px', lineHeight: '14px' }}>A</span>
</div>
)}
{abLoop && abBPos >= 0 && (
<div onMouseDown={handleAbDown('B')} style={{
position: 'absolute', left: `${abBPos * 100}%`, top: 0, width: 8, height: 18,
transform: 'translateX(-50%)', cursor: isPlaying ? 'default' : 'ew-resize', zIndex: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 2, height: 16, background: 'rgba(34,197,94,0.8)', borderRadius: 1 }} />
<span style={{ position: 'absolute', top: -14, fontSize: 10, color: '#22c55e', fontWeight: 700, pointerEvents: 'none', background: 'rgba(0,0,0,0.7)', borderRadius: 2, padding: '0 3px', lineHeight: '14px' }}>B</span>
</div>
)}
{/* 호버 하이라이트 */}
{hoveredTooltip && !pinnedTooltip && (
<div style={{
position: 'absolute', left: `${hoveredTooltip.pos * 100}%`, top: 3, width: 4, height: 12,
background: 'rgba(255,255,255,0.6)',
borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
}} />
)}
{/* 고정 마커 */}
{pinnedTooltip && (
<div style={{
position: 'absolute', left: `${pinnedTooltip.pos * 100}%`, top: 1, width: 5, height: 16,
background: 'rgba(255,255,255,0.9)', borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
}} />
)}
{/* 진행 인디케이터 */}
<div ref={progressIndicatorRef} style={{ <div ref={progressIndicatorRef} style={{
position: 'absolute', left: '0%', top: -1, width: 3, height: 10, position: 'absolute', left: '0%', top: 3, width: 3, height: 12,
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)', background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
}} /> }} />
{/* 호버 리치 툴팁 (고정 아닌 상태) */}
{hoveredTooltip && !pinnedTooltip && hoveredMembers.length > 0 && (
<div style={{
position: 'absolute',
left: `${Math.min(hoveredTooltip.pos * 100, 85)}%`,
top: -8, transform: 'translateY(-100%)',
background: 'rgba(10,20,32,0.95)', border: '1px solid rgba(99,179,237,0.3)',
borderRadius: 6, padding: '5px 7px', maxWidth: 300, maxHeight: 160, overflowY: 'auto',
fontSize: 9, zIndex: 30, pointerEvents: 'none',
}}>
<div style={{ color: '#fbbf24', fontWeight: 600, marginBottom: 3 }}>
{new Date(hoveredTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
{hoveredMembers.map(m => (
<div key={m.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '1px 0' }}>
<span style={{ color: m.isGear ? '#94a3b8' : '#fbbf24', fontSize: 8, width: 16, flexShrink: 0 }}>
{m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''}
</span>
<span style={{ color: '#e2e8f0', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{m.name}
</span>
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{m.sources.map((s, si) => (
<span key={si} style={{
display: 'inline-block', width: s.label === '1h' || s.label === '6h' ? 'auto' : 6,
height: 6, borderRadius: s.label === '1h' || s.label === '6h' ? 2 : '50%',
background: s.color, fontSize: 7, color: '#000', fontWeight: 700,
padding: s.label === '1h' || s.label === '6h' ? '0 2px' : 0,
lineHeight: '6px', textAlign: 'center',
}}>
{(s.label === '1h' || s.label === '6h') ? s.label : ''}
</span>
))}
</div>
</div>
))}
</div>
)}
{/* 고정 리치 툴팁 */}
{pinnedTooltip && pinnedMembers.length > 0 && (
<div
onClick={e => 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',
}}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: '#fbbf24', fontWeight: 600 }}>
{new Date(pinnedTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<button type="button" onClick={(e) => { e.stopPropagation(); setPinnedTooltip(null); store.getState().setPinnedMmsis(new Set()); }}
style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer', fontSize: 10, padding: 0 }}>
</button>
</div>
{/* 멤버 목록 (호버 → 지도 강조) */}
{pinnedMembers.map(m => (
<div
key={m.mmsi}
onMouseEnter={() => 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',
}}
>
<span style={{ color: m.isGear ? '#94a3b8' : '#fbbf24', fontSize: 8, width: 16, flexShrink: 0 }}>
{m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''}
</span>
<span style={{
color: hoveredMmsi === m.mmsi ? '#ffffff' : '#e2e8f0',
fontWeight: hoveredMmsi === m.mmsi ? 600 : 400,
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{m.name}
</span>
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{m.sources.map((s, si) => (
<span key={si} style={{
display: 'inline-block', width: s.label === '1h' || s.label === '6h' ? 'auto' : 6,
height: 6, borderRadius: s.label === '1h' || s.label === '6h' ? 2 : '50%',
background: s.color, fontSize: 7, color: '#000', fontWeight: 700,
padding: s.label === '1h' || s.label === '6h' ? '0 2px' : 0,
lineHeight: '6px', textAlign: 'center',
}}>
{(s.label === '1h' || s.label === '6h') ? s.label : ''}
</span>
))}
</div>
</div>
))}
</div>
)}
</div> </div>
{/* 컨트롤 행 1: 재생 + 타임라인 */} {/* 컨트롤 행 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }} <button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
style={{ ...btnStyle, fontSize: 12 }}> style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button>
{isPlaying ? '⏸' : '▶'} <span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span>
</button> <span style={{ color: '#475569' }}>|</span>
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}>--:--</span>
<input ref={progressBarRef} type="range" min={0} max={1000} defaultValue={0}
onChange={e => {
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="히스토리 타임라인" />
<span style={{ color: '#64748b', fontSize: 9 }}>{frameCount}</span>
<button type="button" onClick={onClose}
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}>
</button>
</div>
{/* 컨트롤 행 2: 표시 옵션 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 4 }}>
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)} <button type="button" onClick={() => store.getState().setShowTrails(!showTrails)}
style={showTrails ? btnActiveStyle : btnStyle} title="전체 항적 표시"> style={showTrails ? btnActiveStyle : btnStyle} title="항적"></button>
</button>
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)} <button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시"> style={showLabels ? btnActiveStyle : btnStyle} title="이름"></button>
</button>
<button type="button" onClick={() => store.getState().setFocusMode(!focusMode)} <button type="button" onClick={() => store.getState().setFocusMode(!focusMode)}
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171', borderColor: 'rgba(239,68,68,0.4)' } : btnStyle} style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle}
title="집중 모드 — 주변 라이브 정보 숨김"> title="집중 모드"></button>
<span style={{ color: '#475569' }}>|</span>
</button> <button type="button" onClick={() => store.getState().setShow1hPolygon(!show1hPolygon)}
<span style={{ color: '#475569', margin: '0 2px' }}>|</span> style={show1hPolygon ? { ...btnActiveStyle, background: 'rgba(251,191,36,0.15)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.4)' } : btnStyle}
title="1h 폴리곤">1h</button>
<button type="button" onClick={() => store.getState().setShow6hPolygon(!show6hPolygon)}
style={!has6hData ? { ...btnStyle, opacity: 0.3, cursor: 'not-allowed' }
: show6hPolygon ? { ...btnActiveStyle, background: 'rgba(147,197,253,0.15)', color: '#93c5fd', border: '1px solid rgba(147,197,253,0.4)' } : btnStyle}
disabled={!has6hData} title="6h 폴리곤">6h</button>
<span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => store.getState().setAbLoop(!abLoop)}
style={abLoop ? { ...btnStyle, background: 'rgba(34,197,94,0.15)', color: '#22c55e', border: '1px solid rgba(34,197,94,0.4)' } : btnStyle}
title="A-B 구간 반복">A-B</button>
<span style={{ color: '#475569' }}>|</span>
<span style={{ color: '#64748b', fontSize: 9 }}></span> <span style={{ color: '#64748b', fontSize: 9 }}></span>
<select <select defaultValue="70"
defaultValue="70" onChange={e => { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }}
onChange={e => { 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' }}
const val = e.target.value; title="일치율 필터" aria-label="일치율 필터">
onFilterByScore(val === '' ? null : Number(val));
}}
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="일치율 필터"
>
<option value=""> (30%+)</option> <option value=""> (30%+)</option>
<option value="50">50%+</option> <option value="50">50%+</option>
<option value="60">60%+</option> <option value="60">60%+</option>
@ -130,6 +484,15 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
<option value="80">80%+</option> <option value="80">80%+</option>
<option value="90">90%+</option> <option value="90">90%+</option>
</select> </select>
<span style={{ flex: 1 }} />
<span style={{ color: '#64748b', fontSize: 9 }}>
<span style={{ color: '#fbbf24' }}>{frameCount}</span>
{has6hData && <> / <span style={{ color: '#93c5fd' }}>{frameCount6h}</span></>}
</span>
<button type="button" onClick={handleClose}
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}>
</button>
</div> </div>
</div> </div>
); );

파일 보기

@ -71,6 +71,14 @@ export function useGearReplayLayers(
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails); const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
const showTrails = useGearReplayStore(s => s.showTrails); const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); 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 { fontScale } = useFontScale();
const fs = fontScale.analysis; const fs = fontScale.analysis;
const zoomLevel = useShipDeckStore(s => s.zoomLevel); 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) ──────────────────────────── // ── Dynamic layers (depend on currentTime) ────────────────────────────
if (frameIdx < 0) { if (frameIdx >= 0) {
// No valid frame at this time — only show static layers
replayLayerRef.current = layers;
requestRender();
return;
}
const frame = state.historyFrames[frameIdx]; const frame = state.historyFrames[frameIdx];
const isStale = !!frame._longGap || !!frame._interp; 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 기반) // 8. Operational polygons (서브클러스터별 분리 — subClusterId 기반)
for (const [mn, items] of correlationByModel) { for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue; if (!enabledModels.has(mn)) continue;
@ -654,24 +773,27 @@ export function useGearReplayLayers(
[167, 139, 250, 255], [167, 139, 250, 255],
]; ];
for (const sf of subFrames) { // ── 1h 폴리곤 (진한색, 실선) ──
const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId); if (state.show1hPolygon) {
const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); for (const sf of subFrames) {
const poly = buildInterpPolygon(sfPts); const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId);
if (!poly) continue; 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; const ci = sf.subClusterId % SUB_POLY_COLORS.length;
layers.push(new PolygonLayer({ layers.push(new PolygonLayer({
id: `replay-identity-polygon-sub${sf.subClusterId}`, id: `replay-identity-polygon-1h-sub${sf.subClusterId}`,
data: [{ polygon: poly.coordinates }], data: [{ polygon: poly.coordinates }],
getPolygon: (d: { polygon: number[][][] }) => d.polygon, getPolygon: (d: { polygon: number[][][] }) => d.polygon,
getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci], getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci],
getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci], getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci],
getLineWidth: isStale ? 1 : 2, getLineWidth: isStale ? 1 : 2,
lineWidthMinPixels: 1, lineWidthMinPixels: 1,
filled: true, filled: true,
stroked: true, stroked: true,
})); }));
}
} }
// TripsLayer (멤버 트레일) // 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<MemberPosition>({
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<MemberPosition>({
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; replayLayerRef.current = layers;
requestRender(); requestRender();
}, [ }, [
historyFrames, memberTripsData, correlationTripsData, historyFrames, historyFrames6h, memberTripsData, memberTripsData6h, correlationTripsData,
centerTrailSegments, centerDotsPositions, centerTrailSegments, centerDotsPositions,
centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h,
enabledModels, enabledVessels, hoveredMmsi, correlationByModel, enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
modelCenterTrails, subClusterCenters, showTrails, showLabels, fs, zoomLevel, modelCenterTrails, subClusterCenters, showTrails, showLabels,
show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel,
replayLayerRef, requestRender, 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 () => { return () => {
unsub(); unsub();
unsubPolygonToggle();
unsubPinned();
if (pendingRafId) cancelAnimationFrame(pendingRafId); if (pendingRafId) cancelAnimationFrame(pendingRafId);
}; };
}, [historyFrames, renderFrame]); }, [historyFrames, renderFrame]);

파일 보기

@ -73,6 +73,7 @@ export interface GroupPolygonDto {
zoneName: string | null; zoneName: string | null;
members: MemberInfo[]; members: MemberInfo[];
color: string; color: string;
resolution?: '1h' | '6h';
} }
export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> { export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> {

파일 보기

@ -54,12 +54,21 @@ interface GearReplayState {
endTime: number; endTime: number;
playbackSpeed: number; playbackSpeed: number;
// Source data // Source data (1h = primary identity polygon)
historyFrames: HistoryFrame[]; historyFrames: HistoryFrame[];
frameTimes: number[]; frameTimes: number[];
selectedGroupKey: string | null; selectedGroupKey: string | null;
rawCorrelationTracks: CorrelationVesselTrack[]; 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 // Pre-computed layer data
memberTripsData: TripsLayerDatum[]; memberTripsData: TripsLayerDatum[];
correlationTripsData: TripsLayerDatum[]; correlationTripsData: TripsLayerDatum[];
@ -79,6 +88,12 @@ interface GearReplayState {
showTrails: boolean; showTrails: boolean;
showLabels: boolean; showLabels: boolean;
focusMode: 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<string>; // 툴팁 고정 시 강조할 MMSI 세트
// Actions // Actions
loadHistory: ( loadHistory: (
@ -87,6 +102,7 @@ interface GearReplayState {
corrData: GearCorrelationItem[], corrData: GearCorrelationItem[],
enabledModels: Set<string>, enabledModels: Set<string>,
enabledVessels: Set<string>, enabledVessels: Set<string>,
frames6h?: HistoryFrame[],
) => void; ) => void;
play: () => void; play: () => void;
pause: () => void; pause: () => void;
@ -98,6 +114,12 @@ interface GearReplayState {
setShowTrails: (show: boolean) => void; setShowTrails: (show: boolean) => void;
setShowLabels: (show: boolean) => void; setShowLabels: (show: boolean) => void;
setFocusMode: (focus: 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<string>) => void;
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
reset: () => void; reset: () => void;
} }
@ -118,7 +140,20 @@ export const useGearReplayStore = create<GearReplayState>()(
const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed; 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 }); set({ currentTime: state.startTime });
animationFrameId = requestAnimationFrame(animate); animationFrameId = requestAnimationFrame(animate);
return; return;
@ -141,6 +176,13 @@ export const useGearReplayStore = create<GearReplayState>()(
frameTimes: [], frameTimes: [],
selectedGroupKey: null, selectedGroupKey: null,
rawCorrelationTracks: [], rawCorrelationTracks: [],
historyFrames6h: [],
frameTimes6h: [],
memberTripsData6h: [],
centerTrailSegments6h: [],
centerDotsPositions6h: [],
subClusterCenters6h: [],
snapshotRanges6h: [],
// Pre-computed layer data // Pre-computed layer data
memberTripsData: [], memberTripsData: [],
@ -159,20 +201,33 @@ export const useGearReplayStore = create<GearReplayState>()(
showTrails: true, showTrails: true,
showLabels: true, showLabels: true,
focusMode: false, focusMode: false,
show1hPolygon: true,
show6hPolygon: false,
abLoop: false,
abA: 0,
abB: 0,
pinnedMmsis: new Set<string>(),
correlationByModel: new Map<string, GearCorrelationItem[]>(), correlationByModel: new Map<string, GearCorrelationItem[]>(),
// ── Actions ──────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────
loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => { loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => {
const startTime = Date.now() - 12 * 60 * 60 * 1000; const startTime = Date.now() - 12 * 60 * 60 * 1000;
const endTime = Date.now(); const endTime = Date.now();
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); 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 memberTrips = buildMemberTripsData(frames, startTime);
const corrTrips = buildCorrelationTripsData(corrTracks, startTime); const corrTrips = buildCorrelationTripsData(corrTracks, startTime);
const { segments, dots } = buildCenterTrailData(frames); const { segments, dots } = buildCenterTrailData(frames);
const ranges = buildSnapshotRanges(frames, startTime, endTime); 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<string, GearCorrelationItem[]>(); const byModel = new Map<string, GearCorrelationItem[]>();
for (const c of corrData) { for (const c of corrData) {
const list = byModel.get(c.modelName) ?? []; const list = byModel.get(c.modelName) ?? [];
@ -184,7 +239,13 @@ export const useGearReplayStore = create<GearReplayState>()(
set({ set({
historyFrames: frames, historyFrames: frames,
historyFrames6h: f6h,
frameTimes, frameTimes,
frameTimes6h,
memberTripsData6h: memberTrips6h,
centerTrailSegments6h: seg6h,
centerDotsPositions6h: dots6h,
snapshotRanges6h: ranges6h,
startTime, startTime,
endTime, endTime,
currentTime: startTime, currentTime: startTime,
@ -209,9 +270,9 @@ export const useGearReplayStore = create<GearReplayState>()(
lastFrameTime = null; lastFrameTime = null;
if (state.currentTime >= state.endTime) { if (state.currentTime >= state.endTime) {
set({ isPlaying: true, currentTime: state.startTime }); set({ isPlaying: true, currentTime: state.startTime, pinnedMmsis: new Set() });
} else { } else {
set({ isPlaying: true }); set({ isPlaying: true, pinnedMmsis: new Set() });
} }
animationFrameId = requestAnimationFrame(animate); animationFrameId = requestAnimationFrame(animate);
@ -247,6 +308,21 @@ export const useGearReplayStore = create<GearReplayState>()(
setShowTrails: (show) => set({ showTrails: show }), setShowTrails: (show) => set({ showTrails: show }),
setShowLabels: (show) => set({ showLabels: show }), setShowLabels: (show) => set({ showLabels: show }),
setFocusMode: (focus) => set({ focusMode: focus }), 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) => { updateCorrelation: (corrData, corrTracks) => {
const state = get(); const state = get();
@ -284,7 +360,14 @@ export const useGearReplayStore = create<GearReplayState>()(
endTime: 0, endTime: 0,
playbackSpeed: 1, playbackSpeed: 1,
historyFrames: [], historyFrames: [],
historyFrames6h: [],
frameTimes: [], frameTimes: [],
frameTimes6h: [],
memberTripsData6h: [],
centerTrailSegments6h: [],
centerDotsPositions6h: [],
subClusterCenters6h: [],
snapshotRanges6h: [],
selectedGroupKey: null, selectedGroupKey: null,
rawCorrelationTracks: [], rawCorrelationTracks: [],
memberTripsData: [], memberTripsData: [],
@ -301,6 +384,12 @@ export const useGearReplayStore = create<GearReplayState>()(
showTrails: true, showTrails: true,
showLabels: true, showLabels: true,
focusMode: false, focusMode: false,
show1hPolygon: true,
show6hPolygon: false,
abLoop: false,
abA: 0,
abB: 0,
pinnedMmsis: new Set<string>(),
correlationByModel: new Map<string, GearCorrelationItem[]>(), correlationByModel: new Map<string, GearCorrelationItem[]>(),
}); });
}, },

파일 보기

@ -18,6 +18,8 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from algorithms.polygon_builder import _get_time_bucket_age
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -479,7 +481,7 @@ def _compute_gear_active_ratio(
gear_members: list[dict], gear_members: list[dict],
all_positions: dict[str, dict], all_positions: dict[str, dict],
now: datetime, now: datetime,
stale_sec: float = 21600, stale_sec: float = 3600,
) -> float: ) -> float:
"""어구 그룹의 활성 멤버 비율.""" """어구 그룹의 활성 멤버 비율."""
if not gear_members: if not gear_members:
@ -567,10 +569,23 @@ def run_gear_correlation(
if not members: if not members:
continue continue
# 그룹 중심 + 반경 # 1h 활성 멤버 필터 (center/radius 계산용)
center_lat = sum(m['lat'] for m in members) / len(members) display_members = [
center_lon = sum(m['lon'] for m in members) / len(members) m for m in members
group_radius = _compute_group_radius(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) active_ratio = _compute_gear_active_ratio(members, all_positions, now)

파일 보기

@ -11,6 +11,9 @@ import math
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo
import pandas as pd
try: try:
from shapely.geometry import MultiPoint, Point from shapely.geometry import MultiPoint, Point
@ -33,6 +36,23 @@ FLEET_BUFFER_DEG = 0.02
GEAR_BUFFER_DEG = 0.01 GEAR_BUFFER_DEG = 0.01
MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외) 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_IN_ZONE = '#ef4444'
_COLOR_GEAR_OUT_ZONE = '#f97316' _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) last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
else: else:
try: try:
import pandas as pd
last_dt = pd.Timestamp(ts).to_pydatetime() last_dt = pd.Timestamp(ts).to_pydatetime()
if last_dt.tzinfo is None: if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=timezone.utc) last_dt = last_dt.replace(tzinfo=timezone.utc)
@ -344,7 +363,6 @@ def build_all_group_snapshots(
points: list[tuple[float, float]] = [] points: list[tuple[float, float]] = []
members: list[dict] = [] members: list[dict] = []
newest_age = float('inf')
for mmsi in mmsi_list: for mmsi in mmsi_list:
pos = all_positions.get(mmsi) pos = all_positions.get(mmsi)
if not pos: if not pos:
@ -364,22 +382,11 @@ def build_all_group_snapshots(
'role': 'LEADER' if mmsi == mmsi_list[0] else 'MEMBER', 'role': 'LEADER' if mmsi == mmsi_list[0] else 'MEMBER',
'isParent': False, '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 초과 → 폴리곤 미생성 # 2척 미만 또는 최근 적재가 DISPLAY_STALE_SEC 초과 → 폴리곤 미생성
if len(points) < 2 or newest_age > DISPLAY_STALE_SEC: if len(points) < 2 or newest_age > DISPLAY_STALE_SEC:
continue continue
@ -403,124 +410,129 @@ def build_all_group_snapshots(
'color': _cluster_color(company_id), 'color': _cluster_color(company_id),
}) })
# ── GEAR 타입: detect_gear_groups 결과 순회 ─────────────────── # ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ────
gear_groups = detect_gear_groups(vessel_store, now=now) gear_groups = detect_gear_groups(vessel_store, now=now)
for group in gear_groups: for group in gear_groups:
parent_name: str = group['parent_name'] parent_name: str = group['parent_name']
parent_mmsi: Optional[str] = group['parent_mmsi'] 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 이내여야 노출 if not gear_members:
# 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:
continue continue
# 수역 분류: anchor(모선 or 첫 어구) 위치 기준 # ── 1h 활성 멤버 필터 ──
anchor_lat: Optional[float] = None display_members_1h = [
anchor_lon: Optional[float] = None 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: # ── 6h 전체 멤버 노출 조건: 최신 적재가 STALE_SEC 이내 ──
parent_pos = all_positions[parent_mmsi] newest_age_6h = min(
anchor_lat = parent_pos['lat'] (_get_time_bucket_age(gm.get('mmsi'), all_positions, now) for gm in gear_members),
anchor_lon = parent_pos['lon'] default=float('inf'),
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
) )
display_members_6h = gear_members
# members JSONB 구성 # ── resolution별 스냅샷 생성 ──
members_out: list[dict] = [] for resolution, members_for_snap in [('1h', display_members_1h), ('6h', display_members_6h)]:
# 모선 먼저 (근접 시에만) if len(members_for_snap) < 2:
if parent_nearby and parent_mmsi and parent_mmsi in all_positions: continue
parent_pos = all_positions[parent_mmsi] # 6h: 최신 적재가 STALE_SEC(6h) 초과 시 스킵
members_out.append({ if resolution == '6h' and newest_age_6h > STALE_SEC:
'mmsi': parent_mmsi, continue
'name': parent_name,
'lat': parent_pos['lat'], # 수역 분류: anchor(모선 or 첫 멤버) 위치 기준
'lon': parent_pos['lon'], anchor_lat: Optional[float] = None
'sog': parent_pos.get('sog', 0), anchor_lon: Optional[float] = None
'cog': parent_pos.get('cog', 0),
'role': 'PARENT', if parent_mmsi and parent_mmsi in all_positions:
'isParent': True, 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 return snapshots

파일 보기

@ -154,11 +154,11 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
insert_sql = """ insert_sql = """
INSERT INTO kcg.group_polygon_snapshots ( 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, polygon, center_point, area_sq_nm, member_count,
zone_id, zone_name, members, color zone_id, zone_name, members, color
) VALUES ( ) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326),
%s, %s, %s, %s, %s::jsonb, %s %s, %s, %s, %s, %s::jsonb, %s
) )
@ -176,6 +176,7 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
s['group_key'], s['group_key'],
s['group_label'], s['group_label'],
s.get('sub_cluster_id', 0), s.get('sub_cluster_id', 0),
s.get('resolution', '6h'),
s['snapshot_time'], s['snapshot_time'],
s.get('polygon_wkt'), s.get('polygon_wkt'),
s.get('center_wkt'), s.get('center_wkt'),