- 텍스트(측점/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>
170 lines
5.8 KiB
TypeScript
170 lines
5.8 KiB
TypeScript
/**
|
|
* 측점 검증 패널
|
|
* - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동
|
|
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
|
|
*/
|
|
|
|
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>
|
|
);
|
|
}
|