Merge pull request 'feat(replay): 어구/선단 히스토리 보간 애니메이션 강화' (#206) from feature/gear-replay-marker into develop

This commit is contained in:
htlee 2026-03-26 15:53:44 +09:00
커밋 3407d37f9b

파일 보기

@ -8,6 +8,194 @@ import { fetchFleetCompanies, fetchGroupHistory } from '../../services/vesselAna
import type { FleetCompany, GroupPolygonDto } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
/**
* Convex hull + buffer (Python polygon_builder.py )
* - 1점: 원형 (GEAR_BUFFER_DEG=0.01 1.1km)
* - 2점:
* - 3+: convex hull +
*/
const GEAR_BUFFER_DEG = 0.01;
const CIRCLE_SEGMENTS = 16;
function buildInterpPolygon(points: [number, number][]): GeoJSON.Polygon | null {
if (points.length === 0) return null;
if (points.length === 1) {
// Point.buffer → 원형
const [cx, cy] = points[0];
const ring: [number, number][] = [];
for (let i = 0; i <= CIRCLE_SEGMENTS; i++) {
const angle = (2 * Math.PI * i) / CIRCLE_SEGMENTS;
ring.push([cx + GEAR_BUFFER_DEG * Math.cos(angle), cy + GEAR_BUFFER_DEG * Math.sin(angle)]);
}
return { type: 'Polygon', coordinates: [ring] };
}
if (points.length === 2) {
// LineString.buffer → 캡슐 형태
const [p1, p2] = points;
const dx = p2[0] - p1[0];
const dy = p2[1] - p1[1];
const len = Math.sqrt(dx * dx + dy * dy) || 1e-10;
const nx = (-dy / len) * GEAR_BUFFER_DEG;
const ny = (dx / len) * GEAR_BUFFER_DEG;
// 양쪽 오프셋 + 반원 엔드캡
const ring: [number, number][] = [];
const half = CIRCLE_SEGMENTS / 2;
// p1→p2 오른쪽
ring.push([p1[0] + nx, p1[1] + ny]);
ring.push([p2[0] + nx, p2[1] + ny]);
// p2 반원
const a2 = Math.atan2(ny, nx);
for (let i = 0; i <= half; i++) {
const angle = a2 - Math.PI * i / half;
ring.push([p2[0] + GEAR_BUFFER_DEG * Math.cos(angle), p2[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
}
// p2→p1 왼쪽
ring.push([p1[0] - nx, p1[1] - ny]);
// p1 반원
const a1 = Math.atan2(-ny, -nx);
for (let i = 0; i <= half; i++) {
const angle = a1 - Math.PI * i / half;
ring.push([p1[0] + GEAR_BUFFER_DEG * Math.cos(angle), p1[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
}
ring.push(ring[0]); // 닫기
return { type: 'Polygon', coordinates: [ring] };
}
// 3점+: convex hull + buffer
const hull = convexHull(points);
return bufferPolygon(hull, GEAR_BUFFER_DEG);
}
/** 단순 convex hull (Graham scan) */
function convexHull(points: [number, number][]): [number, number][] {
const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (pts.length <= 2) return pts;
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower: [number, number][] = [];
for (const p of pts) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
lower.push(p);
}
const upper: [number, number][] = [];
for (let i = pts.length - 1; i >= 0; i--) {
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pts[i]) <= 0) upper.pop();
upper.push(pts[i]);
}
lower.pop();
upper.pop();
return lower.concat(upper);
}
/** 폴리곤 외곽에 buffer 적용 (각 변의 오프셋 + 꼭짓점 라운드) */
function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Polygon {
const ring: [number, number][] = [];
const n = hull.length;
for (let i = 0; i < n; i++) {
const p = hull[i];
const prev = hull[(i - 1 + n) % n];
const next = hull[(i + 1) % n];
// 이전 변과 다음 변의 외향 법선
const a1 = Math.atan2(p[1] - prev[1], p[0] - prev[0]) - Math.PI / 2;
const a2 = Math.atan2(next[1] - p[1], next[0] - p[0]) - Math.PI / 2;
// 꼭짓점 라운딩 (a1 → a2)
const startA = a1;
let endA = a2;
if (endA < startA) endA += 2 * Math.PI;
const steps = Math.max(2, Math.round((endA - startA) / (Math.PI / 8)));
for (let s = 0; s <= steps; s++) {
const a = startA + (endA - startA) * s / steps;
ring.push([p[0] + buf * Math.cos(a), p[1] + buf * Math.sin(a)]);
}
}
ring.push(ring[0]);
return { type: 'Polygon', coordinates: [ring] };
}
/**
* gap .
* - gap 30분: 5분 ( , )
* - gap > 30분: 30분 +
*/
function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
if (snapshots.length < 2) return snapshots;
const STEP_SHORT_MS = 300_000; // 5분
const STEP_LONG_MS = 1_800_000; // 30분
const THRESHOLD_MS = 1_800_000; // 30분 경계
const result: GroupPolygonDto[] = [];
for (let i = 0; i < snapshots.length; i++) {
result.push(snapshots[i]);
if (i >= snapshots.length - 1) continue;
const prev = snapshots[i];
const next = snapshots[i + 1];
const t0 = new Date(prev.snapshotTime).getTime();
const t1 = new Date(next.snapshotTime).getTime();
const gap = t1 - t0;
if (gap <= STEP_SHORT_MS) continue;
const nextMap = new Map(next.members.map(m => [m.mmsi, m]));
const common = prev.members.filter(m => nextMap.has(m.mmsi));
if (common.length === 0) continue;
if (gap <= THRESHOLD_MS) {
// ≤30분: 5분 간격 직선 보간 (중심만 이동, 폴리곤은 이전 것 유지)
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
const ratio = (t - t0) / gap;
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio;
const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio;
result.push({
...prev,
snapshotTime: new Date(t).toISOString(),
centerLon: cLon,
centerLat: cLat,
_interp: true,
});
}
} else {
// >30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
const ratio = (t - t0) / gap;
const positions: [number, number][] = [];
const members: MemberInfo[] = [];
for (const pm of common) {
const nm = nextMap.get(pm.mmsi)!;
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
const lat = pm.lat + (nm.lat - pm.lat) * ratio;
const dLon = nm.lon - pm.lon;
const dLat = nm.lat - pm.lat;
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
positions.push([lon, lat]);
}
const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length;
const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length;
const polygon = buildInterpPolygon(positions);
result.push({
...prev,
snapshotTime: new Date(t).toISOString(),
polygon,
centerLon: cLon,
centerLat: cLat,
memberCount: members.length,
members,
_interp: true,
_longGap: true,
});
}
}
}
return result;
}
export interface SelectedGearGroupData {
parent: Ship | null;
gears: Ship[];
@ -20,6 +208,9 @@ export interface SelectedFleetData {
companyName: string;
}
/** 히스토리 스냅샷 + 보간 플래그 */
type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean };
interface Props {
ships: Ship[];
analysisMap?: Map<string, VesselAnalysisDto>;
@ -51,7 +242,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
} | null>(null);
const [pickerHoveredGroup, setPickerHoveredGroup] = useState<string | null>(null);
// 히스토리 애니메이션 — 12시간 실시간 타임라인
const [historyData, setHistoryData] = useState<GroupPolygonDto[] | null>(null);
const [historyData, setHistoryData] = useState<HistoryFrame[] | null>(null);
const [, setHistoryGroupKey] = useState<string | null>(null);
const [timelinePos, setTimelinePos] = useState(0); // 0~1 (12시간 내 위치)
const [isPlaying, setIsPlaying] = useState(true);
@ -76,10 +267,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
setIsPlaying(true);
const history = await fetchGroupHistory(groupKey, 12);
const sorted = history.reverse(); // 시간 오름차순
const filled = fillGapFrames(sorted); // 빈 구간 보간 삽입
const now = Date.now();
historyStartRef.current = now - TIMELINE_DURATION_MS;
historyEndRef.current = now;
setHistoryData(sorted);
setHistoryData(filled);
};
const closeHistory = useCallback(() => {
@ -117,17 +309,20 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
const diff = Math.abs(t - currentTimeMs);
if (diff < bestDiff) { bestDiff = diff; best = i; }
}
// 5분(300초) 이내 스냅샷만 유효
return bestDiff < 300_000 ? best : -1;
// 보간 프레임 포함 데이터셋에서 가장 가까운 프레임 매칭 (최대 gap = 30분)
return bestDiff < 1_800_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;
});
return historyData
.filter(h => !h._interp)
.map(h => {
const t = new Date(h.snapshotTime).getTime();
return (t - historyStartRef.current) / TIMELINE_DURATION_MS;
});
}, [historyData]);
useEffect(() => {
@ -477,21 +672,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
// ── 히스토리 애니메이션 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][]>();
@ -521,7 +701,63 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return -1;
}, [historyData, currentSnapIdx, currentTimeMs]);
const isStale = currentSnapIdx < 0 && effectiveSnapIdx >= 0; // 신호없음이지만 이전 데이터 유지
const isStale = currentSnapIdx < 0 && effectiveSnapIdx >= 0;
const currentFrame = historyData && effectiveSnapIdx >= 0 ? historyData[effectiveSnapIdx] : null;
const isLongGap = !!currentFrame?._longGap;
const showGray = isLongGap || (isStale && !currentFrame?._interp);
// center trail: historyData에 이미 보간 프레임 포함 → 전체 좌표 연결
const centerTrailGeoJson = useMemo((): GeoJSON => {
if (!historyData || historyData.length === 0) return EMPTY_HIST_FC;
const features: GeoJSON.Feature[] = [];
// 연속된 같은 타입끼리 세그먼트 분리
let segStart = 0;
for (let i = 1; i <= historyData.length; i++) {
const curInterp = i < historyData.length && !!historyData[i]._longGap;
const startInterp = !!historyData[segStart]._longGap;
if (i < historyData.length && curInterp === startInterp) continue;
const from = segStart > 0 ? segStart - 1 : segStart;
const seg = historyData.slice(from, i);
if (seg.length >= 2) {
features.push({
type: 'Feature',
properties: { interpolated: startInterp ? 1 : 0 },
geometry: { type: 'LineString', coordinates: seg.map(s => [s.centerLon, s.centerLat]) },
});
}
segStart = i;
}
// 실데이터 도트만
for (const h of historyData) {
if (h.color === '#94a3b8') continue;
features.push({
type: 'Feature', properties: { interpolated: 0 },
geometry: { type: 'Point', coordinates: [h.centerLon, h.centerLat] },
});
}
return { type: 'FeatureCollection', features };
}, [historyData]);
// 현재 재생 위치 포인트
const currentCenterGeoJson = useMemo((): GeoJSON => {
if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC;
const snap = historyData[effectiveSnapIdx];
if (!snap) return EMPTY_HIST_FC;
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { interpolated: showGray ? 1 : 0 },
geometry: { type: 'Point', coordinates: [snap.centerLon, snap.centerLat] },
}],
};
}, [historyData, effectiveSnapIdx, showGray]);
const animPolygonGeoJson = useMemo((): GeoJSON => {
if (!historyData || effectiveSnapIdx < 0) return EMPTY_HIST_FC;
@ -529,9 +765,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
if (!snap?.polygon) return EMPTY_HIST_FC;
return {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0 }, geometry: snap.polygon }],
features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 }, geometry: snap.polygon }],
};
}, [historyData, effectiveSnapIdx, isStale]);
}, [historyData, effectiveSnapIdx, isStale, showGray]);
// 현재 프레임의 멤버 위치 (가상 아이콘)
const animMembersGeoJson = useMemo((): GeoJSON => {
@ -542,11 +778,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
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, isGear: m.role === 'GEAR' ? 1 : 0, stale: isStale ? 1 : 0 },
properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, isGear: m.role === 'GEAR' ? 1 : 0, stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 },
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] },
})),
};
}, [historyData, effectiveSnapIdx, isStale]);
}, [historyData, effectiveSnapIdx, isStale, showGray]);
// 선단 목록 (멤버 수 내림차순)
const fleetList = useMemo(() => {
@ -902,23 +1138,38 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
{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,
}} />
'line-color': ['case', ['==', ['get', 'interpolated'], 1], '#f97316', '#fbbf24'],
'line-width': 2,
'line-dasharray': [4, 4],
'line-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.8, 0.7],
}} filter={['==', '$type', 'LineString']} />
<Layer id="history-center-dots" type="circle" paint={{
'circle-radius': 2.5, 'circle-color': '#fbbf24', 'circle-opacity': 0.6,
}} filter={['==', '$type', 'Point']} />
</Source>
)}
{/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */}
{/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */}
{historyData && effectiveSnapIdx >= 0 && (
<Source id="history-current-center" type="geojson" data={currentCenterGeoJson}>
<Layer id="history-current-center-dot" type="circle" paint={{
'circle-radius': 7,
'circle-color': ['case', ['==', ['get', 'interpolated'], 1], '#f97316', '#ef4444'],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
}} />
</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,
'fill-color': ['case', ['==', ['get', 'interpolated'], 1], '#94a3b8', isStale ? '#64748b' : '#fbbf24'],
'fill-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.12, 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-color': ['case', ['==', ['get', 'interpolated'], 1], '#94a3b8', isStale ? '#64748b' : '#fbbf24'],
'line-width': ['case', ['==', ['get', 'interpolated'], 1], 1.5, isStale ? 1 : 2],
'line-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.5, isStale ? 0.4 : 0.7],
'line-dasharray': isStale ? [3, 3] : [1, 0],
}} />
</Source>
@ -934,11 +1185,16 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
'icon-allow-overlap': true,
}} paint={{
'icon-color': ['case',
['==', ['get', 'interpolated'], 1], '#94a3b8',
['==', ['get', 'stale'], 1], '#64748b',
['==', ['get', 'isGear'], 0], '#fbbf24',
'#a8b8c8',
],
'icon-opacity': ['case', ['==', ['get', 'stale'], 1], 0.4, 0.9],
'icon-opacity': ['case',
['==', ['get', 'interpolated'], 1], 0.5,
['==', ['get', 'stale'], 1], 0.4,
0.9,
],
}} />
<Layer id="history-anim-members-label" type="symbol" layout={{
'text-field': ['get', 'name'],
@ -946,7 +1202,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
'text-offset': [0, 1.5],
'text-allow-overlap': false,
}} paint={{
'text-color': ['case', ['==', ['get', 'isGear'], 0], '#fbbf24', '#e2e8f0'],
'text-color': ['case',
['==', ['get', 'interpolated'], 1], '#94a3b8',
['==', ['get', 'isGear'], 0], '#fbbf24',
'#e2e8f0',
],
'text-halo-color': 'rgba(0,0,0,0.8)',
'text-halo-width': 1,
}} />
@ -993,7 +1253,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
<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' }}