feat: RoutePanel 미니맵 추가 및 StationOverlay altitude 필드명 수정
- RoutePanel: 세로 미니맵 패널 — 측점/POI 위치 표시, 오렌지 마커 드래그로 seek - VideoPlayer: RoutePanel 통합 (showStations 토글 연동) - StationOverlay: smoothFrame에서 alt → altitude 타입 오류 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
330
client/src/components/overlay/RoutePanel.tsx
Normal file
330
client/src/components/overlay/RoutePanel.tsx
Normal file
@@ -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<string, string> = {
|
||||
'\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<GeoPoint[]>([]);
|
||||
const [pois, setPois] = useState<GeoPoint[]>([]);
|
||||
const [droneFramesLoaded, setDroneFramesLoaded] = useState(false);
|
||||
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
|
||||
const [currentKm, setCurrentKm] = useState(0);
|
||||
const [currentStationTitle, setCurrentStationTitle] = useState('');
|
||||
const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute left-0 top-0 h-full w-24 bg-black/80 border-r border-white/10 z-30"
|
||||
>
|
||||
{/* Center vertical line */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{ left: 36, width: 2, background: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
|
||||
{/* Stations */}
|
||||
{validStations.map((st, i) => {
|
||||
const km = stationKm(st.title);
|
||||
if (km < 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={`st-${i}`}
|
||||
className="absolute flex items-center"
|
||||
style={{ top: `${kmToY(km)}%`, transform: 'translateY(-50%)', left: 0, right: 0 }}
|
||||
>
|
||||
<div className="text-[9px] text-white/70 text-right" style={{ width: 34 }}>
|
||||
{st.title}
|
||||
</div>
|
||||
<div style={{ position: 'absolute', left: 33, width: 8, display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="w-2 h-2 rounded-full bg-white/60" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* POIs */}
|
||||
{pois.map((poi, i) => {
|
||||
const km = poiKm(poi, validStations);
|
||||
if (km < 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={`poi-${i}`}
|
||||
className="absolute flex items-center"
|
||||
style={{ top: `${kmToY(km)}%`, transform: 'translateY(-50%)', left: 0, right: 0 }}
|
||||
>
|
||||
<div className="text-[11px] text-right" style={{ width: 34 }}>
|
||||
{CATEGORY_EMOJI[poi.category] || '\uD83D\uDCCD'}
|
||||
</div>
|
||||
<div style={{ position: 'absolute', left: 33, width: 8, display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-cyan-400/70" />
|
||||
</div>
|
||||
<div
|
||||
className="text-[8px] text-cyan-300/80 truncate"
|
||||
style={{ position: 'absolute', left: 44, right: 2 }}
|
||||
>
|
||||
{poi.title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Green visible range box */}
|
||||
{visibleRange && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: 30,
|
||||
right: 4,
|
||||
top: `${kmToY(visibleRange.minKm)}%`,
|
||||
bottom: `${100 - kmToY(visibleRange.maxKm)}%`,
|
||||
border: '1px solid rgba(74,222,128,0.7)',
|
||||
background: 'rgba(74,222,128,0.08)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Orange current position marker */}
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center cursor-grab z-10"
|
||||
style={{
|
||||
top: `${dragging ? dragYPct : kmToY(currentKm)}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 30,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: '#f97316',
|
||||
border: '2px solid white',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{ position: 'absolute', left: 44 }}
|
||||
className="bg-orange-500 text-white text-[9px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap"
|
||||
>
|
||||
{currentStationTitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,10 +41,19 @@ interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// category → 이모지
|
||||
const CATEGORY_EMOJI: Record<string, string> = {
|
||||
'터널': '🚇',
|
||||
'교량': '🌉',
|
||||
'역사': '🚉',
|
||||
'지장물': '🏢',
|
||||
'측점': '📍',
|
||||
};
|
||||
|
||||
// 텍스트 사전 계산 캐시 (Map<frameNum, LabelCache>)
|
||||
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';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<VideoPlayerHandle, VideoPlayerProps>(
|
||||
visible={showStations}
|
||||
/>
|
||||
|
||||
{/* 루트 패널 미니맵 */}
|
||||
<RoutePanel
|
||||
currentTime={currentTime}
|
||||
visible={showStations}
|
||||
onSeek={(time) => playerRef.current?.currentTime(time)}
|
||||
/>
|
||||
|
||||
{/* Timecode overlay — positioned over video */}
|
||||
{source && (
|
||||
<div className="absolute bottom-16 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded font-mono pointer-events-none z-10">
|
||||
|
||||
Reference in New Issue
Block a user