feat: 폴리곤 히스토리 애니메이션 + 어구 추적 안정화

- FleetClusterLayer: 12시간 타임라인 기반 폴리곤 재생 애니메이션
  - 중심 이동 궤적 (점선) + 어구별 개별 궤적 (실선)
  - 가상 어구/선박 아이콘 COG 회전 + 스냅샷 동기화
  - 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭)
  - 신호없음 구간: 마지막 유효 스냅샷 유지 + 회색 점선 표시
  - 히스토리 모드 시 현재 강조 레이어 (deck.gl + MapLibre) 숨김
  - ESC 키: 히스토리 닫기 + 선택 해제
- polygon_builder: STALE_SEC 3600→21600 (6시간, 어구 P75 갭 3.5h 커버)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-25 07:33:48 +09:00
부모 7573c84e91
커밋 8f9b347e1f
2개의 변경된 파일293개의 추가작업 그리고 13개의 파일을 삭제

파일 보기

@ -4,8 +4,8 @@ import { FONT_MONO } from '../../styles/fonts';
import type { GeoJSON } from 'geojson';
import type { MapLayerMouseEvent } from 'maplibre-gl';
import type { Ship, VesselAnalysisDto } from '../../types';
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
import type { FleetCompany } from '../../services/vesselAnalysis';
import { fetchFleetCompanies, fetchGroupHistory } from '../../services/vesselAnalysis';
import type { FleetCompany, GroupPolygonDto } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
export interface SelectedGearGroupData {
@ -50,6 +50,14 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[];
} | null>(null);
const [pickerHoveredGroup, setPickerHoveredGroup] = useState<string | null>(null);
// 히스토리 애니메이션 — 12시간 실시간 타임라인
const [historyData, setHistoryData] = useState<GroupPolygonDto[] | null>(null);
const [, setHistoryGroupKey] = useState<string | null>(null);
const [timelinePos, setTimelinePos] = useState(0); // 0~1 (12시간 내 위치)
const [isPlaying, setIsPlaying] = useState(true);
const animTimerRef = useRef<ReturnType<typeof setInterval>>();
const historyStartRef = useRef(0); // 12시간 전 epoch ms
const historyEndRef = useRef(0); // 현재 epoch ms
const { current: mapRef } = useMap();
const registeredRef = useRef(false);
const dataRef = useRef<{ shipMap: Map<string, Ship>; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom });
@ -58,6 +66,83 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
fetchFleetCompanies().then(setCompanies).catch(() => {});
}, []);
const TIMELINE_DURATION_MS = 12 * 60 * 60_000; // 12시간
const PLAYBACK_CYCLE_SEC = 30; // 30초에 12시간 전체 재생
const TICK_MS = 50; // 50ms 간격 업데이트
const loadHistory = async (groupKey: string) => {
setHistoryGroupKey(groupKey);
setTimelinePos(0);
setIsPlaying(true);
const history = await fetchGroupHistory(groupKey, 12);
const sorted = history.reverse(); // 시간 오름차순
const now = Date.now();
historyStartRef.current = now - TIMELINE_DURATION_MS;
historyEndRef.current = now;
setHistoryData(sorted);
};
const closeHistory = useCallback(() => {
setHistoryData(null);
setHistoryGroupKey(null);
setTimelinePos(0);
setIsPlaying(true);
clearInterval(animTimerRef.current);
}, []);
// 재생 타이머 — 50ms마다 timelinePos 진행
useEffect(() => {
if (!historyData || !isPlaying) {
clearInterval(animTimerRef.current);
return;
}
const step = TICK_MS / (PLAYBACK_CYCLE_SEC * 1000); // 1틱당 진행량
animTimerRef.current = setInterval(() => {
setTimelinePos(prev => {
const next = prev + step;
return next >= 1 ? 0 : next; // 순환
});
}, TICK_MS);
return () => clearInterval(animTimerRef.current);
}, [historyData, isPlaying]);
// timelinePos → 현재 시각 + 가장 가까운 스냅샷 인덱스
const currentTimeMs = historyStartRef.current + timelinePos * TIMELINE_DURATION_MS;
const currentSnapIdx = useMemo(() => {
if (!historyData || historyData.length === 0) return -1;
let best = 0;
let bestDiff = Infinity;
for (let i = 0; i < historyData.length; i++) {
const t = new Date(historyData[i].snapshotTime).getTime();
const diff = Math.abs(t - currentTimeMs);
if (diff < bestDiff) { bestDiff = diff; best = i; }
}
// 5분(300초) 이내 스냅샷만 유효
return bestDiff < 300_000 ? best : -1;
}, [historyData, currentTimeMs]);
// 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용)
const snapshotRanges = useMemo(() => {
if (!historyData) return [];
return historyData.map(h => {
const t = new Date(h.snapshotTime).getTime();
return (t - historyStartRef.current) / TIMELINE_DURATION_MS;
});
}, [historyData]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (historyData) closeHistory();
setSelectedGearGroup(null);
setExpandedFleet(null);
setExpandedGearGroup(null);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [historyData, closeHistory]);
// ── 맵 폴리곤 클릭/호버 이벤트 등록
useEffect(() => {
const map = mapRef?.getMap();
@ -98,6 +183,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
loadHistory(String(cid));
};
// 통합 클릭 핸들러: 선단+어구 모든 폴리곤 겹침 판정
@ -179,6 +265,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
loadHistory(name);
};
const onGearClick = onPolygonClick;
@ -220,10 +307,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
// stale closure 방지
dataRef.current = { shipMap, groupPolygons, onFleetZoom };
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용)
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) — 히스토리 모드에서는 null
useEffect(() => {
if (!selectedGearGroup) {
if (!selectedGearGroup || historyData) {
onSelectedGearChange?.(null);
if (historyData) return; // 히스토리 모드: 선택은 유지하되 부모 강조만 숨김
return;
}
const allGroups = groupPolygons
@ -252,12 +340,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
gears: gears.map(toShip),
groupName: selectedGearGroup,
});
}, [selectedGearGroup, groupPolygons, onSelectedGearChange]);
}, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]);
// 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용)
// 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) — 히스토리 모드에서는 null
useEffect(() => {
if (expandedFleet === null) {
if (expandedFleet === null || historyData) {
onSelectedFleetChange?.(null);
if (historyData) return;
return;
}
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet);
@ -282,7 +371,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
ships: fleetShips,
companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}`,
});
}, [expandedFleet, groupPolygons, companies, onSelectedFleetChange]);
}, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyData]);
// API 기반 어구 그룹 분류
const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? [];
@ -385,6 +474,80 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] };
}, [pickerHoveredGroup, groupPolygons]);
// ── 히스토리 애니메이션 GeoJSON ──
const EMPTY_HIST_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
const centerTrailGeoJson = useMemo((): GeoJSON => {
if (!historyData) return EMPTY_HIST_FC;
const coords = historyData.map(h => [h.centerLon, h.centerLat]);
return {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } },
...historyData.map(h => ({
type: 'Feature' as const, properties: {},
geometry: { type: 'Point' as const, coordinates: [h.centerLon, h.centerLat] },
})),
],
};
}, [historyData]);
const memberTrailsGeoJson = useMemo((): GeoJSON => {
if (!historyData) return EMPTY_HIST_FC;
const tracks = new Map<string, [number, number][]>();
for (const snap of historyData) {
for (const m of snap.members) {
const arr = tracks.get(m.mmsi) ?? [];
arr.push([m.lon, m.lat]);
tracks.set(m.mmsi, arr);
}
}
const features: GeoJSON.Feature[] = [];
for (const [, coords] of tracks) {
if (coords.length < 2) continue;
features.push({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } });
}
return { type: 'FeatureCollection', features };
}, [historyData]);
// 현재 또는 마지막 유효 스냅샷 (신호없음 구간에서 이전 데이터 유지)
const effectiveSnapIdx = useMemo(() => {
if (!historyData || historyData.length === 0) return -1;
if (currentSnapIdx >= 0) return currentSnapIdx;
// 현재 시각 이전의 가장 가까운 스냅샷
for (let i = historyData.length - 1; i >= 0; i--) {
if (new Date(historyData[i].snapshotTime).getTime() <= currentTimeMs) return i;
}
return -1;
}, [historyData, currentSnapIdx, currentTimeMs]);
const isStale = currentSnapIdx < 0 && effectiveSnapIdx >= 0; // 신호없음이지만 이전 데이터 유지
const animPolygonGeoJson = useMemo((): GeoJSON => {
if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC;
const snap = historyData[effectiveSnapIdx];
if (!snap?.polygon) return EMPTY_HIST_FC;
return {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0 }, geometry: snap.polygon }],
};
}, [historyData, effectiveSnapIdx, isStale]);
// 현재 프레임의 멤버 위치 (가상 아이콘)
const animMembersGeoJson = useMemo((): GeoJSON => {
if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC;
const snap = historyData[effectiveSnapIdx];
if (!snap) return EMPTY_HIST_FC;
return {
type: 'FeatureCollection',
features: snap.members.map(m => ({
type: 'Feature' as const,
properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, stale: isStale ? 1 : 0 },
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] },
})),
};
}, [historyData, effectiveSnapIdx, isStale]);
// 선단 목록 (멤버 수 내림차순)
const fleetList = useMemo(() => {
if (!groupPolygons) return [];
@ -440,6 +603,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
}
if (minLat === Infinity) return;
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
loadHistory(parentName);
}, [groupPolygons, onFleetZoom]);
// 패널 스타일 (AnalysisStatsPanel 패턴)
@ -534,8 +698,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
/>
</Source>
{/* 선택된 어구 그룹 하이라이트 폴리곤 */}
{selectedGearGroup && (() => {
{/* 선택된 어구 그룹 하이라이트 폴리곤 — 히스토리 모드에서는 숨김 */}
{selectedGearGroup && !historyData && (() => {
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
@ -578,8 +742,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
/>
</Source>
{/* 가상 선박 마커 (API members 기반 — 삼각형 아이콘 + 방향 + 줌 스케일) */}
<Source id="group-member-markers" type="geojson" data={memberMarkersGeoJson}>
{/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */}
<Source id="group-member-markers" type="geojson" data={historyData ? ({ type: 'FeatureCollection', features: [] } as GeoJSON) : memberMarkersGeoJson}>
<Layer
id="group-member-icon"
type="symbol"
@ -727,6 +891,122 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return null;
})()}
{/* ── 히스토리 애니메이션 레이어 (최상위) ── */}
{historyData && (
<Source id="history-member-trails" type="geojson" data={memberTrailsGeoJson}>
<Layer id="history-member-trails-line" type="line" paint={{
'line-color': '#cbd5e1', 'line-width': 1.5, 'line-opacity': 0.65,
}} />
</Source>
)}
{historyData && (
<Source id="history-center-trail" type="geojson" data={centerTrailGeoJson}>
<Layer id="history-center-trail-line" type="line" paint={{
'line-color': '#fbbf24', 'line-width': 2, 'line-dasharray': [4, 4], 'line-opacity': 0.7,
}} />
<Layer id="history-center-dots" type="circle" paint={{
'circle-radius': 2.5, 'circle-color': '#fbbf24', 'circle-opacity': 0.6,
}} filter={['==', '$type', 'Point']} />
</Source>
)}
{historyData && (
<Source id="history-anim-polygon" type="geojson" data={animPolygonGeoJson}>
<Layer id="history-anim-fill" type="fill" paint={{
'fill-color': isStale ? '#64748b' : '#fbbf24',
'fill-opacity': isStale ? 0.08 : 0.15,
}} />
<Layer id="history-anim-line" type="line" paint={{
'line-color': isStale ? '#64748b' : '#fbbf24',
'line-width': isStale ? 1 : 2,
'line-opacity': isStale ? 0.4 : 0.7,
'line-dasharray': isStale ? [3, 3] : [1, 0],
}} />
</Source>
)}
{/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */}
{historyData && (
<Source id="history-anim-members" type="geojson" data={animMembersGeoJson}>
<Layer id="history-anim-members-icon" type="symbol" layout={{
'icon-image': 'ship-triangle',
'icon-size': 0.7,
'icon-rotate': ['get', 'cog'],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
}} paint={{
'icon-color': ['case', ['==', ['get', 'stale'], 1], '#64748b', '#a8b8c8'],
'icon-opacity': ['case', ['==', ['get', 'stale'], 1], 0.4, 0.9],
}} />
<Layer id="history-anim-members-label" type="symbol" layout={{
'text-field': ['get', 'name'],
'text-size': 8,
'text-offset': [0, 1.5],
'text-allow-overlap': false,
}} paint={{
'text-color': '#e2e8f0',
'text-halo-color': 'rgba(0,0,0,0.8)',
'text-halo-width': 1,
}} />
</Source>
)}
{/* 히스토리 재생 컨트롤러 */}
{historyData && (() => {
const curTime = new Date(currentTimeMs);
const timeStr = curTime.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
const hasSnap = currentSnapIdx >= 0;
return (
<div style={{
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)',
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',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', minWidth: 360,
}}>
{/* 프로그레스 바 — 갭 표시 */}
<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 style={{
position: 'absolute', left: `${timelinePos * 100}%`, top: -1, width: 3, height: 10,
background: hasSnap ? '#fbbf24' : '#ef4444', borderRadius: 1,
transform: 'translateX(-50%)',
}} />
</div>
{/* 컨트롤 행 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button type="button" onClick={() => setIsPlaying(p => !p)} style={{
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4,
color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 12, fontFamily: FONT_MONO,
}}>
{isPlaying ? '⏸' : '▶'}
</button>
<span style={{ color: hasSnap ? '#fbbf24' : '#ef4444', minWidth: 40, textAlign: 'center' }}>
{timeStr}
</span>
{!hasSnap && <span style={{ color: '#ef4444', fontSize: 9 }}></span>}
<input type="range" min={0} max={1000} value={Math.round(timelinePos * 1000)}
onChange={e => { setIsPlaying(false); setTimelinePos(Number(e.target.value) / 1000); }}
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
title="히스토리 타임라인" aria-label="히스토리 타임라인"
/>
<span style={{ color: '#64748b', fontSize: 9 }}>
{historyData.length}
</span>
<button type="button" onClick={closeHistory} 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 style={panelStyle}>
{/* ── 선단 현황 섹션 ── */}

파일 보기

@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
# 프론트 FleetClusterLayer.tsx gearGroupMap 패턴과 동일
GEAR_PATTERN = re.compile(r'^(.+?)_\d+_\d+_?$')
MAX_DIST_DEG = 0.15 # ~10NM
STALE_SEC = 3600 # 60분
STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버)
FLEET_BUFFER_DEG = 0.02
GEAR_BUFFER_DEG = 0.01
MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외)