기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
148 lines
5.7 KiB
TypeScript
148 lines
5.7 KiB
TypeScript
/**
|
|
* 측점 검증 패널
|
|
* - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동
|
|
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
|
|
*
|
|
* 데이터 소스: 클라이언트 geoStore(폴더 선택으로 파싱). 폴더 미선택 시 빈 상태.
|
|
*/
|
|
|
|
import React, { useState } from 'react';
|
|
import { useGeoStore } from '../../store/geoStore';
|
|
import { findFramesForPoi } from '../../utils/geoSearch';
|
|
import type { GeoPoint, FrameMatch } from '../../types/geo';
|
|
|
|
interface StationResult {
|
|
frames: FrameMatch[];
|
|
poi: GeoPoint;
|
|
}
|
|
|
|
interface Props {
|
|
fps: number;
|
|
onSeekToFrame: (frame: number) => void;
|
|
}
|
|
|
|
export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
|
// stations 는 이미 stationOrder 로 정렬되어 있다.
|
|
const stations = useGeoStore(s => s.stations);
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
const [result, setResult] = useState<StationResult | null>(null);
|
|
const [seekedFrame, setSeekedFrame] = useState<number | null>(null);
|
|
|
|
// 드론 GPS 가 측점에 가장 가까운 프레임 (StationBar 배지·실제 위치와 동일 기준).
|
|
const nearestFrameForStation = (st: GeoPoint): number | null => {
|
|
const fr = useGeoStore.getState().frames;
|
|
if (!fr.length) return null;
|
|
let best = fr[0];
|
|
let bd = (fr[0].lat - st.lat) ** 2 + (fr[0].lon - st.lon) ** 2;
|
|
for (const f of fr) {
|
|
const d = (f.lat - st.lat) ** 2 + (f.lon - st.lon) ** 2;
|
|
if (d < bd) { bd = d; best = f; }
|
|
}
|
|
return best.frame;
|
|
};
|
|
|
|
const handleClick = (station: GeoPoint) => {
|
|
setSelected(station.title);
|
|
setResult(null);
|
|
setSeekedFrame(null);
|
|
// 영상은 드론 GPS 가 그 측점에 가장 가까운 프레임으로 이동한다.
|
|
// (카메라 FOV 검색은 앞을 보는 카메라 특성상 ~200m 앞쪽으로 치우쳐 위치가 어긋남)
|
|
const gpsFrame = nearestFrameForStation(station);
|
|
if (gpsFrame != null) {
|
|
onSeekToFrame(gpsFrame);
|
|
setSeekedFrame(gpsFrame);
|
|
}
|
|
// 검증 정보(카메라 시야 프레임/투영)는 참고용으로 표시.
|
|
const { frames, pois, stations: sts, origin } = useGeoStore.getState();
|
|
const { poi, frames: matches } = findFramesForPoi(
|
|
frames,
|
|
[...sts, ...pois],
|
|
station.title,
|
|
1.2,
|
|
2000,
|
|
0,
|
|
origin,
|
|
);
|
|
setResult({ frames: matches, poi: poi ?? station });
|
|
};
|
|
|
|
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>
|
|
{result && result.frames.length === 0 && (
|
|
<div className="text-xs text-red-400 mt-1">카메라 시야에 들어오는 프레임 없음</div>
|
|
)}
|
|
{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>
|
|
);
|
|
}
|