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:
169
client/src/components/geo/StationVerify.tsx
Normal file
169
client/src/components/geo/StationVerify.tsx
Normal 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)}%)
|
||||
수평 {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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user