feat: StationOverlay 렌더링 최적화 및 스무딩 적용 close #1

- 텍스트(측점/POI) 전 프레임 사전 계산 Map (requestIdleCallback 백그라운드)
- 드론 데이터 이동 평균 스무딩 (smoothFrame ±N프레임)
- 30fps→60fps 프레임 간 선형 보간 (performance.now() 기반)
- EMA(지수이동평균) 표시 위치 스무딩 (α=0.01 기본값)
- 글씨 2배 크기, bold, strokeText 테두리, 배경 박스 제거
- 카메라 파라미터 패널에 smooth/EMA α 슬라이더 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-01 15:11:39 +09:00
commit 2aae3d1c0d
89 changed files with 15739 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
/**
* 측점 검증 패널
* - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
*/
import React, { useState, useEffect } from 'react';
interface GeoPoint {
title: string;
category: string;
lat: number;
lon: number;
z: number;
type: 'poi' | 'station';
}
interface FrameMatch {
frame: number;
time: number;
bearingDiff: number;
elevationDiff: number;
distance: number;
pixelX: number;
pixelY: number;
groupSize?: number;
groupStart?: number;
groupEnd?: number;
}
interface StationResult {
frames: FrameMatch[];
poi: GeoPoint;
}
interface Props {
fps: number;
onSeekToFrame: (frame: number) => void;
}
function stationOrder(title: string): number {
const m = title.match(/(\d+)[Kk](\d+)/);
if (!m) return 0;
return parseInt(m[1]) * 1000 + parseInt(m[2]);
}
export default function StationVerify({ fps, onSeekToFrame }: Props) {
const [stations, setStations] = useState<GeoPoint[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [result, setResult] = useState<StationResult | null>(null);
const [loading, setLoading] = useState(false);
const [seekedFrame, setSeekedFrame] = useState<number | null>(null);
useEffect(() => {
fetch('/api/geo/pois')
.then(r => r.json())
.then((data: GeoPoint[]) => {
const s = Array.isArray(data)
? data.filter(p => p.type === 'station').sort((a, b) => stationOrder(a.title) - stationOrder(b.title))
: [];
setStations(s);
})
.catch(() => {});
}, []);
const handleClick = async (station: GeoPoint) => {
setSelected(station.title);
setResult(null);
setLoading(true);
setSeekedFrame(null);
try {
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(station.title)}&margin=1.2&maxDist=2000`);
const data = await res.json();
if (!res.ok || !data.frames?.length) {
setResult({ frames: [], poi: data.poi ?? station });
return;
}
setResult(data);
// 가장 중심에 가까운 첫 번째 결과로 이동
const best = data.frames[0];
onSeekToFrame(best.frame);
setSeekedFrame(best.frame);
} catch {
setResult(null);
} finally {
setLoading(false);
}
};
const pixelQuality = (px: number, py: number) => {
const dx = Math.abs(px - 0.5);
const dy = Math.abs(py - 0.5);
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 0.15) return 'text-green-400';
if (dist < 0.35) return 'text-yellow-400';
return 'text-orange-400';
};
return (
<div className="flex flex-col h-full text-sm">
<div className="px-3 py-2 bg-gray-800/50 border-b border-gray-700 flex-shrink-0">
<div className="text-xs text-gray-400"> + </div>
<div className="text-xs text-gray-600 mt-0.5">{stations.length} </div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{/* 선택된 측점 결과 */}
{selected && (
<div className="mx-2 my-2 p-2 bg-gray-800 rounded border border-gray-600 flex-shrink-0">
<div className="text-xs font-bold text-white">{selected}</div>
{loading && <div className="text-xs text-gray-400 mt-1"> </div>}
{!loading && result && result.frames.length === 0 && (
<div className="text-xs text-red-400 mt-1"> </div>
)}
{!loading && result && result.frames.length > 0 && (() => {
const f = result.frames[0];
return (
<>
<div className="text-xs text-gray-300 mt-1">
F{f.frame} · {f.distance >= 1000 ? `${(f.distance/1000).toFixed(2)}km` : `${Math.round(f.distance)}m`}
</div>
<div className={`text-xs mt-0.5 font-mono ${pixelQuality(f.pixelX, f.pixelY)}`}>
({(f.pixelX * 100).toFixed(0)}%, {(f.pixelY * 100).toFixed(0)}%)
&nbsp; {f.bearingDiff >= 0 ? '+' : ''}{f.bearingDiff.toFixed(1)}°
</div>
{result.frames.length > 1 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{result.frames.slice(1).map((fm, i) => (
<button
key={fm.frame}
className="text-[10px] px-1.5 py-0.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
onClick={() => { onSeekToFrame(fm.frame); setSeekedFrame(fm.frame); }}
>
#{i + 2} F{fm.frame}
</button>
))}
</div>
)}
</>
);
})()}
</div>
)}
{/* 측점 목록 */}
<div className="space-y-px px-1 pb-2">
{stations.map(st => (
<button
key={st.title}
onClick={() => handleClick(st)}
className={`w-full text-left px-2 py-2 rounded transition-colors flex items-center justify-between ${
selected === st.title
? 'bg-yellow-500/20 border border-yellow-500/50'
: 'hover:bg-gray-800 border border-transparent'
}`}
>
<span className={`text-xs font-mono font-bold ${selected === st.title ? 'text-yellow-400' : 'text-gray-200'}`}>
{st.title}
</span>
<span className="text-[10px] text-gray-600">
{st.z.toFixed(0)}m
</span>
</button>
))}
</div>
</div>
</div>
);
}