초기 커밋: DefVideo 소스 등록

abcVideo 플레이어 소스 (client / server / shared / pythonsource / docs / .claude).
.gitignore 적용으로 node_modules·storage·samplevideo·미디어 등 대용량 일괄 제외.
103 files, ~964K.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 03:20:27 +00:00
commit 82662d417d
103 changed files with 17213 additions and 0 deletions

View File

@@ -0,0 +1,293 @@
/**
* 드론 지리정보 검색 패널
* - 건물/측점명 → 해당 프레임 탐색
* - 현재 프레임 → 보이는 건물/측점 목록
*/
import React, { useState, useEffect, useCallback, useRef } 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 PoiInFrame {
poi: GeoPoint;
bearingDiff: number;
elevationDiff: number;
distance: number;
pixelX: number;
pixelY: number;
}
interface Props {
currentFrame: number;
fps: number;
onSeekToFrame: (frame: number) => void;
}
type Tab = 'search' | 'reverse';
export default function GeoSearch({ currentFrame, fps, onSeekToFrame }: Props) {
const [tab, setTab] = useState<Tab>('search');
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<GeoPoint[]>([]);
const [allPois, setAllPois] = useState<GeoPoint[]>([]);
const [searchResult, setSearchResult] = useState<{ poi: GeoPoint; frames: FrameMatch[] } | null>(null);
const [reverseResult, setReverseResult] = useState<PoiInFrame[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// POI 목록 로드 (자동완성용)
useEffect(() => {
fetch('/api/geo/pois')
.then(r => r.json())
.then(data => setAllPois(Array.isArray(data) ? data : []))
.catch(() => {});
}, []);
// 자동완성 필터링
useEffect(() => {
if (!query.trim()) { setSuggestions([]); return; }
const q = query.toLowerCase();
setSuggestions(allPois.filter(p => p.title.toLowerCase().includes(q)).slice(0, 10));
}, [query, allPois]);
// 건물/측점명으로 프레임 검색
const handleSearch = useCallback(async (q?: string) => {
const searchQ = (q ?? query).trim();
if (!searchQ) return;
setLoading(true);
setError('');
setSuggestions([]);
try {
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(searchQ)}&margin=1.0&maxDist=1500`);
const data = await res.json();
if (!res.ok) { setError(data.error || '검색 실패'); setSearchResult(null); return; }
setSearchResult(data);
} catch {
setError('서버 연결 실패');
} finally {
setLoading(false);
}
}, [query]);
// 현재 프레임 역조회
const handleReverse = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await fetch(`/api/geo/frame/${currentFrame}?margin=1.0`);
const data = await res.json();
if (!res.ok) { setError(data.error || '조회 실패'); setReverseResult(null); return; }
setReverseResult(data.pois ?? []);
} catch {
setError('서버 연결 실패');
} finally {
setLoading(false);
}
}, [currentFrame]);
// 탭 전환 시 역조회 자동 실행
useEffect(() => {
if (tab === 'reverse') handleReverse();
}, [tab, currentFrame]);
const formatDist = (m: number) =>
m >= 1000 ? `${(m / 1000).toFixed(2)}km` : `${Math.round(m)}m`;
const formatAngle = (deg: number) =>
`${deg >= 0 ? '+' : ''}${deg.toFixed(1)}°`;
return (
<div className="flex flex-col h-full text-sm">
{/* 탭 */}
<div className="flex border-b border-gray-700 flex-shrink-0">
<button
className={`flex-1 py-2 text-xs font-medium transition-colors ${tab === 'search' ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setTab('search')}
>
</button>
<button
className={`flex-1 py-2 text-xs font-medium transition-colors ${tab === 'reverse' ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setTab('reverse')}
>
</button>
</div>
{/* 검색 탭 */}
{tab === 'search' && (
<div className="flex flex-col h-full min-h-0">
<div className="p-2 flex-shrink-0 relative">
<div className="flex gap-1">
<input
className="flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
placeholder="건물명 또는 측점번호..."
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
/>
<button
onClick={() => handleSearch()}
disabled={loading}
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 rounded text-xs transition-colors"
>
{loading ? '…' : '검색'}
</button>
</div>
{/* 자동완성 */}
{suggestions.length > 0 && (
<div className="absolute left-2 right-2 mt-0.5 bg-gray-800 border border-gray-600 rounded shadow-lg z-50 max-h-48 overflow-y-auto">
{suggestions.map((s, i) => (
<button
key={i}
className="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-xs text-white flex items-center gap-2"
onClick={() => { setQuery(s.title); handleSearch(s.title); }}
>
<span className={`text-xs px-1 rounded ${s.type === 'station' ? 'bg-green-800 text-green-300' : 'bg-blue-900 text-blue-300'}`}>
{s.type === 'station' ? '측점' : s.category || 'POI'}
</span>
{s.title}
</button>
))}
</div>
)}
</div>
{error && <div className="px-3 py-1 text-xs text-red-400">{error}</div>}
{searchResult && (
<div className="flex-1 overflow-y-auto min-h-0">
{/* POI 정보 */}
<div className="px-3 py-2 bg-gray-800/50 border-b border-gray-700 flex-shrink-0">
<div className="text-xs font-semibold text-white">{searchResult.poi.title}</div>
<div className="text-xs text-gray-400 mt-0.5">
{searchResult.poi.category} · {searchResult.poi.z.toFixed(1)}m
</div>
<div className="text-xs text-gray-500">
{searchResult.poi.lat.toFixed(6)}, {searchResult.poi.lon.toFixed(6)}
</div>
<div className="text-xs text-blue-400 mt-1">
{searchResult.frames.length}
</div>
</div>
{searchResult.frames.length === 0 && (
<div className="text-xs text-gray-500 p-3 text-center">
<br />
<button
className="mt-1 text-blue-400 hover:text-blue-300"
onClick={() => handleSearch()}
>
</button>
</div>
)}
<div className="space-y-1 p-1">
{searchResult.frames.map((fm, i) => (
<button
key={fm.frame}
className="w-full text-left px-2 py-2 rounded hover:bg-gray-700 transition-colors border border-gray-700/50"
onClick={() => onSeekToFrame(fm.frame)}
>
<div className="flex items-center justify-between">
<span className="text-xs text-white font-mono font-bold">
#{i + 1} Frame {fm.frame}
</span>
<span className="text-xs text-gray-300">{formatDist(fm.distance)}</span>
</div>
{(fm as any).groupSize > 1 && (
<div className="text-xs text-yellow-600 mt-0.5">
{(fm as any).groupStart}~{(fm as any).groupEnd} ({(fm as any).groupSize})
</div>
)}
<div className="flex gap-2 mt-0.5">
<span className="text-xs text-gray-400"> {formatAngle(fm.bearingDiff)}</span>
<span className="text-xs text-gray-400"> {formatAngle(fm.elevationDiff)}</span>
<span className="text-xs text-gray-600">
({(fm.pixelX * 100).toFixed(0)}%, {(fm.pixelY * 100).toFixed(0)}%)
</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
)}
{/* 역조회 탭 */}
{tab === 'reverse' && (
<div className="flex flex-col h-full min-h-0">
<div className="px-3 py-2 bg-gray-800/50 border-b border-gray-700 flex-shrink-0 flex items-center justify-between">
<div>
<span className="text-xs text-gray-400"> : </span>
<span className="text-xs text-white font-mono">#{currentFrame}</span>
</div>
<button
onClick={handleReverse}
disabled={loading}
className="text-xs text-blue-400 hover:text-blue-300 disabled:text-gray-600"
>
{loading ? '조회 중…' : '새로고침'}
</button>
</div>
{error && <div className="px-3 py-1 text-xs text-red-400">{error}</div>}
<div className="flex-1 overflow-y-auto min-h-0">
{reverseResult && reverseResult.length === 0 && (
<div className="text-xs text-gray-500 p-3 text-center">
/
</div>
)}
{reverseResult && reverseResult.length > 0 && (
<div className="space-y-0.5 p-1">
{reverseResult.map((item, i) => (
<div key={i} className="px-2 py-1.5 rounded bg-gray-800/30 hover:bg-gray-800/60">
<div className="flex items-center justify-between">
<span className="text-xs text-white">{item.poi.title}</span>
<span className="text-xs text-gray-400">{formatDist(item.distance)}</span>
</div>
<div className="flex gap-2 mt-0.5 items-center">
<span className={`text-xs px-1 rounded ${item.poi.type === 'station' ? 'bg-green-800 text-green-300' : 'bg-blue-900 text-blue-300'}`}>
{item.poi.type === 'station' ? '측점' : item.poi.category}
</span>
<span className="text-xs text-gray-500">
{formatAngle(item.bearingDiff)} / {formatAngle(item.elevationDiff)}
</span>
</div>
<div className="text-xs text-gray-600 mt-0.5">
({(item.pixelX * 100).toFixed(0)}%, {(item.pixelY * 100).toFixed(0)}%)
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}