From 8ddc148ef95a5b2dc6a65fdf0258a80dbbece9dd Mon Sep 17 00:00:00 2001 From: minsung Date: Wed, 1 Apr 2026 15:59:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20RoutePanel=20=EB=AF=B8=EB=8B=88?= =?UTF-8?q?=EB=A7=B5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20StationOverlay=20a?= =?UTF-8?q?ltitude=20=ED=95=84=EB=93=9C=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RoutePanel: 세로 미니맵 패널 — 측점/POI 위치 표시, 오렌지 마커 드래그로 seek - VideoPlayer: RoutePanel 통합 (showStations 토글 연동) - StationOverlay: smoothFrame에서 alt → altitude 타입 오류 수정 Co-Authored-By: Claude Sonnet 4.6 --- client/src/components/overlay/RoutePanel.tsx | 330 ++++++++++++++++++ .../src/components/overlay/StationOverlay.tsx | 28 +- client/src/components/player/VideoPlayer.tsx | 8 + 3 files changed, 357 insertions(+), 9 deletions(-) create mode 100644 client/src/components/overlay/RoutePanel.tsx diff --git a/client/src/components/overlay/RoutePanel.tsx b/client/src/components/overlay/RoutePanel.tsx new file mode 100644 index 0000000..fe19a1d --- /dev/null +++ b/client/src/components/overlay/RoutePanel.tsx @@ -0,0 +1,330 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { + toCameraCoords, + pixelFromCamera, + type DroneFrameBasic, + DEFAULT_CAMERA_PARAMS, +} from '../../utils/geoProjection'; + +interface GeoPoint { + title: string; + category: string; + lat: number; + lon: number; + z: number; + type: string; +} + +interface RoutePanelProps { + currentTime: number; + visible: boolean; + onSeek: (time: number) => void; +} + +const VIDEO_FPS = 30000 / 1001; + +function stationKm(title: string): number { + const m = title.match(/(\d+)[Kk](\d+)/); + if (!m) return -1; + return parseInt(m[1]) * 1000 + parseInt(m[2]); +} + +const CATEGORY_EMOJI: Record = { + '\uD130\uB110': '\uD83D\uDE87', + '\uAD50\uB7C9': '\uD83C\uDF09', + '\uC5ED\uC0AC': '\uD83D\uDE89', + '\uC9C0\uC7A5\uBB3C': '\uD83C\uDFE2', + '\uCE21\uC810': '\uD83D\uDCCD', +}; + +function poiKm(poi: GeoPoint, stations: GeoPoint[]): number { + if (!stations.length) return -1; + const sorted = [...stations] + .map(st => ({ + st, + d: (poi.lat - st.lat) ** 2 + (poi.lon - st.lon) ** 2, + })) + .sort((a, b) => a.d - b.d); + const a = sorted[0], b = sorted[1]; + if (!b || b.d === 0) return stationKm(a.st.title); + const ka = stationKm(a.st.title), kb = stationKm(b.st.title); + if (ka < 0 || kb < 0) return ka >= 0 ? ka : kb; + const t = a.d / (a.d + b.d); + return Math.round(ka + (kb - ka) * t); +} + +export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelProps) { + const [stations, setStations] = useState([]); + const [pois, setPois] = useState([]); + const [droneFramesLoaded, setDroneFramesLoaded] = useState(false); + const allDroneFramesRef = useRef([]); + const [currentKm, setCurrentKm] = useState(0); + const [currentStationTitle, setCurrentStationTitle] = useState(''); + const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null); + const panelRef = useRef(null); + const [dragYPct, setDragYPct] = useState(0); + const [dragging, setDragging] = useState(false); + + // Load POIs and stations + useEffect(() => { + if (!visible) return; + fetch('/api/geo/pois') + .then(r => r.json()) + .then((data: GeoPoint[]) => { + setStations(data.filter(p => p.type === 'station')); + setPois(data.filter(p => p.type === 'poi')); + }) + .catch(() => {}); + }, [visible]); + + // Load drone frames when visible and not yet loaded + useEffect(() => { + if (!visible || droneFramesLoaded) return; + fetch('/api/geo/frames?step=1') + .then(r => r.json()) + .then((data: DroneFrameBasic[]) => { + allDroneFramesRef.current = data; + setDroneFramesLoaded(true); + }) + .catch(() => {}); + }, [visible, droneFramesLoaded]); + + // Update current km and visible range based on currentTime + useEffect(() => { + if (!droneFramesLoaded) return; + const frames = allDroneFramesRef.current; + if (!frames.length || !stations.length) return; + + // Find closest frame by time + const targetFrame = Math.round(currentTime * VIDEO_FPS); + let closest = frames[0]; + let closestDist = Math.abs(closest.frame - targetFrame); + for (let i = 1; i < frames.length; i++) { + const d = Math.abs(frames[i].frame - targetFrame); + if (d < closestDist) { + closest = frames[i]; + closestDist = d; + } + } + + // Find nearest station to current drone position + const validStations = stations.filter(s => stationKm(s.title) >= 0); + if (!validStations.length) return; + + let nearestStation = validStations[0]; + let nearestDist = (closest.lat - nearestStation.lat) ** 2 + (closest.lon - nearestStation.lon) ** 2; + for (let i = 1; i < validStations.length; i++) { + const d = (closest.lat - validStations[i].lat) ** 2 + (closest.lon - validStations[i].lon) ** 2; + if (d < nearestDist) { + nearestStation = validStations[i]; + nearestDist = d; + } + } + setCurrentKm(stationKm(nearestStation.title)); + setCurrentStationTitle(nearestStation.title); + + // Calculate visible range (green box) + const allPoints = [...validStations, ...pois]; + const visibleKms: number[] = []; + for (const pt of allPoints) { + const cc = toCameraCoords(closest, pt.lat, pt.lon, pt.z, DEFAULT_CAMERA_PARAMS); + if (cc.Zc <= 0) continue; + const { pyRaw } = pixelFromCamera(cc, DEFAULT_CAMERA_PARAMS); + if (pyRaw >= 0.0 && pyRaw <= 1.0) { + const km = pt.type === 'station' ? stationKm(pt.title) : poiKm(pt, validStations); + if (km >= 0) visibleKms.push(km); + } + } + if (visibleKms.length >= 2) { + setVisibleRange({ + minKm: Math.min(...visibleKms), + maxKm: Math.max(...visibleKms), + }); + } else { + setVisibleRange(null); + } + }, [currentTime, droneFramesLoaded, stations, pois]); + + // Drag handling + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setDragging(true); + }, []); + + useEffect(() => { + if (!dragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!panelRef.current) return; + const rect = panelRef.current.getBoundingClientRect(); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + setDragYPct(y * 100); + }; + + const handleMouseUp = (e: MouseEvent) => { + setDragging(false); + if (!panelRef.current) return; + const rect = panelRef.current.getBoundingClientRect(); + const yPct = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + + const validStations = stations.filter(s => stationKm(s.title) >= 0); + if (validStations.length < 2) return; + + const allKms = [ + ...validStations.map(s => stationKm(s.title)), + ...pois.map(p => poiKm(p, validStations)).filter(k => k >= 0), + ]; + const minK = Math.min(...allKms); + const maxK = Math.max(...allKms); + const targetKm = minK + yPct * (maxK - minK); + + // Find closest station to target km + let bestStation = validStations[0]; + let bestDiff = Math.abs(stationKm(bestStation.title) - targetKm); + for (let i = 1; i < validStations.length; i++) { + const diff = Math.abs(stationKm(validStations[i].title) - targetKm); + if (diff < bestDiff) { + bestStation = validStations[i]; + bestDiff = diff; + } + } + + // Find closest drone frame to that station's lat/lon + const frames = allDroneFramesRef.current; + if (!frames.length) return; + let bestFrame = frames[0]; + let bestFrameDist = (bestFrame.lat - bestStation.lat) ** 2 + (bestFrame.lon - bestStation.lon) ** 2; + for (let i = 1; i < frames.length; i++) { + const d = (frames[i].lat - bestStation.lat) ** 2 + (frames[i].lon - bestStation.lon) ** 2; + if (d < bestFrameDist) { + bestFrame = frames[i]; + bestFrameDist = d; + } + } + onSeek(bestFrame.frame / VIDEO_FPS); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [dragging, stations, pois, onSeek]); + + // Render guard + if (!visible || stations.length === 0) return null; + const validStations = stations.filter(s => stationKm(s.title) >= 0); + if (validStations.length < 2) return null; + + const allKms = [ + ...validStations.map(s => stationKm(s.title)), + ...pois.map(p => poiKm(p, validStations)).filter(k => k >= 0), + ]; + const minKm = Math.min(...allKms); + const maxKm = Math.max(...allKms); + const kmToY = (km: number) => ((km - minKm) / (maxKm - minKm)) * 100; + + return ( +
+ {/* Center vertical line */} +
+ + {/* Stations */} + {validStations.map((st, i) => { + const km = stationKm(st.title); + if (km < 0) return null; + return ( +
+
+ {st.title} +
+
+
+
+
+ ); + })} + + {/* POIs */} + {pois.map((poi, i) => { + const km = poiKm(poi, validStations); + if (km < 0) return null; + return ( +
+
+ {CATEGORY_EMOJI[poi.category] || '\uD83D\uDCCD'} +
+
+
+
+
+ {poi.title} +
+
+ ); + })} + + {/* Green visible range box */} + {visibleRange && ( +
+ )} + + {/* Orange current position marker */} +
+
+
+ {currentStationTitle} +
+
+
+ ); +} diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx index e89faed..8a64624 100644 --- a/client/src/components/overlay/StationOverlay.tsx +++ b/client/src/components/overlay/StationOverlay.tsx @@ -41,10 +41,19 @@ interface Props { visible: boolean; } +// category → 이모지 +const CATEGORY_EMOJI: Record = { + '터널': '🚇', + '교량': '🌉', + '역사': '🚉', + '지장물': '🏢', + '측점': '📍', +}; + // 텍스트 사전 계산 캐시 (Map) interface LabelCache { stationLabels: { sx: number; sy: number; title: string }[]; - poiMarkers: { x: number; y: number; title: string }[]; + poiMarkers: { x: number; y: number; title: string; category: string }[]; } // 중심선 + 나침반 렌더 캐시 (per-frame, renderCacheRef) @@ -205,17 +214,17 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const lo = Math.max(0, i - halfWin); const hi = Math.min(frames.length - 1, i + halfWin); const n = hi - lo + 1; - let lat = 0, lon = 0, alt = 0, pitch = 0, roll = 0, sinYaw = 0, cosYaw = 0; + let lat = 0, lon = 0, altitude = 0, pitch = 0, roll = 0, sinYaw = 0, cosYaw = 0; for (let k = lo; k <= hi; k++) { const f = frames[k]; - lat += f.lat; lon += f.lon; alt += f.alt; + lat += f.lat; lon += f.lon; altitude += f.altitude; pitch += f.pitch; roll += f.roll; const yr = f.yaw * Math.PI / 180; sinYaw += Math.sin(yr); cosYaw += Math.cos(yr); } return { ...frames[i], - lat: lat / n, lon: lon / n, alt: alt / n, + lat: lat / n, lon: lon / n, altitude: altitude / n, pitch: pitch / n, roll: roll / n, yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI, }; @@ -262,7 +271,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible if (cc.Zc < CLIP_Z) continue; const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams); if (pxRaw < -0.02 || pxRaw > 1.02 || pyRaw < -0.02 || pyRaw > 1.02) continue; - poiMarkers.push({ x: pxRaw, y: pyRaw, title: poi.title }); + poiMarkers.push({ x: pxRaw, y: pyRaw, title: poi.title, category: poi.category }); } newMap.set(drone.frame, { stationLabels, poiMarkers }); @@ -484,15 +493,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible ctx.moveTo(px-r, py); ctx.lineTo(px+r, py); ctx.moveTo(px, py-r); ctx.lineTo(px, py+r); ctx.stroke(); - // 텍스트 테두리 + // 이모지 + 텍스트 + const emoji = CATEGORY_EMOJI[poiA.category] ?? '📌'; + const label = `${emoji} ${poiA.title}`; const lx = Math.max(2, px + 14); ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4; ctx.lineJoin = 'round'; ctx.textBaseline = 'middle'; - ctx.strokeText(poiA.title, lx, py); - // 텍스트 본문 + ctx.strokeText(label, lx, py); ctx.fillStyle = '#64c8ff'; - ctx.fillText(poiA.title, lx, py); + ctx.fillText(label, lx, py); ctx.textBaseline = 'alphabetic'; }); } diff --git a/client/src/components/player/VideoPlayer.tsx b/client/src/components/player/VideoPlayer.tsx index 3d6f35e..f2cfd7a 100644 --- a/client/src/components/player/VideoPlayer.tsx +++ b/client/src/components/player/VideoPlayer.tsx @@ -1,5 +1,6 @@ import React, { useRef, useImperativeHandle, forwardRef, useState } from 'react'; import StationOverlay from '../overlay/StationOverlay'; +import RoutePanel from '../overlay/RoutePanel'; import 'video.js/dist/video-js.css'; import { useVideoPlayer } from '../../hooks/useVideoPlayer'; import { useFrameStep } from '../../hooks/useFrameStep'; @@ -115,6 +116,13 @@ const VideoPlayer = forwardRef( visible={showStations} /> + {/* 루트 패널 미니맵 */} + playerRef.current?.currentTime(time)} + /> + {/* Timecode overlay — positioned over video */} {source && (