GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 측점 검증 패널
|
||||
* - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동
|
||||
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
|
||||
*
|
||||
* 데이터 소스: 클라이언트 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user