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:
부모
f09186a187
커밋
71d607e499
@ -25,4 +25,5 @@ public class GroupPolygonDto {
|
||||
private String zoneName;
|
||||
private List<Map<String, Object>> members;
|
||||
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,
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
14
database/migration/011_polygon_resolution.sql
Normal file
14
database/migration/011_polygon_resolution.sql
Normal file
@ -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[] })),
|
||||
]);
|
||||
|
||||
// 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<string>();
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@ -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<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 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<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 timeDisplayRef = useRef<HTMLSpanElement>(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<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 = {
|
||||
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',
|
||||
}}>
|
||||
{/* 프로그레스 바 */}
|
||||
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{snapshotRanges.map((pos, i) => (
|
||||
<div key={i} style={{
|
||||
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%',
|
||||
background: 'rgba(251,191,36,0.4)',
|
||||
{/* 프로그레스 트랙 */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
style={{ position: 'relative', height: 18, cursor: 'pointer' }}
|
||||
onClick={handleTrackClick}
|
||||
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={{
|
||||
position: 'absolute', left: '0%', top: -1, width: 3, height: 10,
|
||||
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)',
|
||||
position: 'absolute', left: '0%', top: 3, width: 3, height: 12,
|
||||
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>
|
||||
|
||||
{/* 컨트롤 행 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(); }}
|
||||
style={{ ...btnStyle, fontSize: 12 }}>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
<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 }}>
|
||||
style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button>
|
||||
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span>
|
||||
<span style={{ color: '#475569' }}>|</span>
|
||||
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)}
|
||||
style={showTrails ? btnActiveStyle : btnStyle} title="전체 항적 표시">
|
||||
항적
|
||||
</button>
|
||||
style={showTrails ? btnActiveStyle : btnStyle} title="항적">항적</button>
|
||||
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
|
||||
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시">
|
||||
이름
|
||||
</button>
|
||||
style={showLabels ? btnActiveStyle : btnStyle} title="이름">이름</button>
|
||||
<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}
|
||||
title="집중 모드 — 주변 라이브 정보 숨김">
|
||||
집중
|
||||
</button>
|
||||
<span style={{ color: '#475569', margin: '0 2px' }}>|</span>
|
||||
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle}
|
||||
title="집중 모드">집중</button>
|
||||
<span style={{ color: '#475569' }}>|</span>
|
||||
<button type="button" onClick={() => store.getState().setShow1hPolygon(!show1hPolygon)}
|
||||
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>
|
||||
<select
|
||||
defaultValue="70"
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
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="일치율 필터"
|
||||
>
|
||||
<select defaultValue="70"
|
||||
onChange={e => { 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="일치율 필터">
|
||||
<option value="">전체 (30%+)</option>
|
||||
<option value="50">50%+</option>
|
||||
<option value="60">60%+</option>
|
||||
@ -130,6 +484,15 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
||||
<option value="80">80%+</option>
|
||||
<option value="90">90%+</option>
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -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<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;
|
||||
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]);
|
||||
|
||||
@ -73,6 +73,7 @@ export interface GroupPolygonDto {
|
||||
zoneName: string | null;
|
||||
members: MemberInfo[];
|
||||
color: string;
|
||||
resolution?: '1h' | '6h';
|
||||
}
|
||||
|
||||
export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> {
|
||||
|
||||
@ -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<string>; // 툴팁 고정 시 강조할 MMSI 세트
|
||||
|
||||
// Actions
|
||||
loadHistory: (
|
||||
@ -87,6 +102,7 @@ interface GearReplayState {
|
||||
corrData: GearCorrelationItem[],
|
||||
enabledModels: Set<string>,
|
||||
enabledVessels: Set<string>,
|
||||
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<string>) => void;
|
||||
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
@ -118,7 +140,20 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
|
||||
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<GearReplayState>()(
|
||||
frameTimes: [],
|
||||
selectedGroupKey: null,
|
||||
rawCorrelationTracks: [],
|
||||
historyFrames6h: [],
|
||||
frameTimes6h: [],
|
||||
memberTripsData6h: [],
|
||||
centerTrailSegments6h: [],
|
||||
centerDotsPositions6h: [],
|
||||
subClusterCenters6h: [],
|
||||
snapshotRanges6h: [],
|
||||
|
||||
// Pre-computed layer data
|
||||
memberTripsData: [],
|
||||
@ -159,20 +201,33 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
showTrails: true,
|
||||
showLabels: true,
|
||||
focusMode: false,
|
||||
show1hPolygon: true,
|
||||
show6hPolygon: false,
|
||||
abLoop: false,
|
||||
abA: 0,
|
||||
abB: 0,
|
||||
pinnedMmsis: new Set<string>(),
|
||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||
|
||||
// ── 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<string, GearCorrelationItem[]>();
|
||||
for (const c of corrData) {
|
||||
const list = byModel.get(c.modelName) ?? [];
|
||||
@ -184,7 +239,13 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
|
||||
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<GearReplayState>()(
|
||||
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<GearReplayState>()(
|
||||
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<GearReplayState>()(
|
||||
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<GearReplayState>()(
|
||||
showTrails: true,
|
||||
showLabels: true,
|
||||
focusMode: false,
|
||||
show1hPolygon: true,
|
||||
show6hPolygon: false,
|
||||
abLoop: false,
|
||||
abA: 0,
|
||||
abB: 0,
|
||||
pinnedMmsis: new Set<string>(),
|
||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||
});
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'),
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user