Files
GhiVideo_v4/client/src/components/geo/StationVerify.tsx
T
b23042andClaude Fable 5 d38b842e8d GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제.
(node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:32:51 +09:00

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)}%)
&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>
);
}