Files
DefVideo/client/src/components/geo/GeoSearch.tsx
b23042 819065a8f5 UI 수정
기획안 반영 및 보완
2026-06-19 14:40:47 +09:00

268 lines
12 KiB
TypeScript

/**
* 드론 지리정보 검색 패널
* - 건물/측점명 → 해당 프레임 탐색
* - 현재 프레임 → 보이는 건물/측점 목록
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useGeoStore } from '../../store/geoStore';
import { findFramesForPoi, findPoisForFrame } from '../../utils/geoSearch';
import type { GeoPoint, FrameMatch, PoiInFrame } from '../../types/geo';
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 [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);
// 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
const loaded = useGeoStore(s => s.loaded);
const frames = useGeoStore(s => s.frames);
const pois = useGeoStore(s => s.pois);
const stations = useGeoStore(s => s.stations);
// POI 목록 (자동완성용): 측점 + 건물 통합 (서버 /api/geo/pois 동치)
const allPois = React.useMemo<GeoPoint[]>(
() => (loaded ? [...stations, ...pois] : []),
[loaded, stations, pois],
);
// 자동완성 필터링
useEffect(() => {
if (!query.trim()) { setSuggestions([]); return; }
const q = query.toLowerCase();
setSuggestions(allPois.filter(p => p.title.toLowerCase().includes(q)).slice(0, 10));
}, [query, allPois]);
// 건물/측점명으로 프레임 검색 (클라이언트 검색 — 서버 /api/geo/search 대체)
const handleSearch = useCallback((q?: string) => {
const searchQ = (q ?? query).trim();
if (!searchQ) return;
setLoading(true);
setError('');
setSuggestions([]);
try {
if (!loaded) { setError('폴더를 먼저 선택하세요'); setSearchResult(null); return; }
const origin = useGeoStore.getState().origin;
const combined = [...stations, ...pois];
const result = findFramesForPoi(frames, combined, searchQ, 1.0, 1500, 0, origin);
if (!result.poi) { setError('일치하는 건물/측점 없음'); setSearchResult(null); return; }
setSearchResult({ poi: result.poi, frames: result.frames });
} finally {
setLoading(false);
}
}, [query, loaded, frames, stations, pois]);
// 현재 프레임 역조회 (클라이언트 검색 — 서버 /api/geo/frame/{n} 대체)
const handleReverse = useCallback(() => {
setLoading(true);
setError('');
try {
if (!loaded) { setReverseResult([]); return; }
const origin = useGeoStore.getState().origin;
const combined = [...stations, ...pois];
const result = findPoisForFrame(frames, combined, currentFrame, 1.0, 0, origin);
setReverseResult(result.pois);
} finally {
setLoading(false);
}
}, [currentFrame, loaded, frames, stations, pois]);
// 탭 전환/프레임 변경/데이터 로드 시 역조회 자동 실행
useEffect(() => {
if (tab === 'reverse') handleReverse();
}, [tab, currentFrame, handleReverse]);
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>
);
}