From 54b49551e28ea447dc4acd413b2ec01706cfbfa2 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 26 Mar 2026 15:53:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(replay):=20=EC=96=B4=EA=B5=AC/=EC=84=A0?= =?UTF-8?q?=EB=8B=A8=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=84=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fillGapFrames: API 응답 로드 시 빈 구간 보간 프레임 삽입 - gap ≤ 30분: 5분 간격 직선 보간 (중심만, 폴리곤 유지) - gap > 30분: 30분 간격 멤버 위치 보간 + convex hull 가상 폴리곤 - _interp/_longGap 플래그로 원본/보간/장기gap 프레임 구분 - 빨간 중심 포인트: 현재 재생 시점 위치 표시 - 가상 구간 회색 렌더링 (폴리곤/아이콘/라벨) - 중심선: 실데이터 노란색 + 장기gap 주황색 파선 - 재생바: 원본 데이터만 표시 (보간 프레임 제외) - buildInterpPolygon: Python polygon_builder.py 동일 로직 --- .../components/korea/FleetClusterLayer.tsx | 335 ++++++++++++++++-- 1 file changed, 297 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index ffe7413..83e3c0e 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -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; @@ -51,7 +242,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS } | null>(null); const [pickerHoveredGroup, setPickerHoveredGroup] = useState(null); // 히스토리 애니메이션 — 12시간 실시간 타임라인 - const [historyData, setHistoryData] = useState(null); + const [historyData, setHistoryData] = useState(null); const [, setHistoryGroupKey] = useState(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(); @@ -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 && ( + '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']} /> )} + {/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */} + {/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */} + {historyData && effectiveSnapIdx >= 0 && ( + + + + )} {historyData && ( @@ -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, + ], }} /> @@ -993,7 +1253,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS {timeStr} - {!hasSnap && 신호없음} { setIsPlaying(false); setTimelinePos(Number(e.target.value) / 1000); }} style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}