268 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|