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,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { secondsToTimecode } from '../utils/timecode';
|
||||
|
||||
interface Props {
|
||||
currentTime: number;
|
||||
onAdd: (type: 'subtitle' | 'memo', text: string, timeStart: number, timeEnd: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddAnnotationModal({ currentTime, onAdd, onClose }: Props) {
|
||||
const [type, setType] = useState<'subtitle' | 'memo'>('memo');
|
||||
const [text, setText] = useState('');
|
||||
const [duration, setDuration] = useState(3);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
onAdd(type, text.trim(), currentTime, currentTime + duration);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 rounded-lg p-6 w-96 shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-4">주석 추가</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">시작 시간</label>
|
||||
<div className="text-white font-mono">{secondsToTimecode(currentTime)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">유형</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'subtitle' | 'memo')}
|
||||
className="w-full bg-gray-800 text-white rounded px-3 py-2"
|
||||
>
|
||||
<option value="subtitle">자막</option>
|
||||
<option value="memo">메모</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">내용</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-gray-800 text-white rounded px-3 py-2 resize-none"
|
||||
placeholder="주석 내용을 입력하세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">표시 시간 (초)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Number(e.target.value))}
|
||||
min={1}
|
||||
max={300}
|
||||
className="w-full bg-gray-800 text-white rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-400 hover:text-white"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-white"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
interface State { hasError: boolean; error?: Error }
|
||||
export default class ErrorBoundary extends React.Component<React.PropsWithChildren, State> {
|
||||
state: State = { hasError: false };
|
||||
static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; }
|
||||
render() {
|
||||
if (this.state.hasError) return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-950 text-white">
|
||||
<div className="text-center p-8 max-w-lg">
|
||||
<div className="text-5xl mb-4">⚠️</div>
|
||||
<h2 className="text-xl font-bold mb-2">오류가 발생했습니다</h2>
|
||||
<p className="text-gray-400 text-sm mb-4">{this.state.error?.message}</p>
|
||||
<button onClick={() => this.setState({ hasError: false })} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props { onClose: () => void }
|
||||
const shortcuts = [
|
||||
['Space', '재생 / 일시정지'],
|
||||
['← / →', '5초 뒤로 / 앞으로'],
|
||||
['J / L', '10초 뒤로 / 앞으로'],
|
||||
[', / .', '이전 / 다음 프레임 (일시정지 시)'],
|
||||
['[ / ]', '이전 / 다음 장면 (±30초)'],
|
||||
['0 ~ 9', '10% 단위 탐색'],
|
||||
['F', '전체화면 토글'],
|
||||
['M', '음소거 토글'],
|
||||
['+ / -', '재생 속도 증가 / 감소'],
|
||||
['Shift+S', '현재 프레임 캡처'],
|
||||
['Shift+M', '현재 시점에 메모 추가'],
|
||||
['?', '이 도움말 열기 / 닫기'],
|
||||
];
|
||||
export default function HelpOverlay({ onClose }: Props) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-gray-900 rounded-lg p-6 w-96 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">키보드 단축키</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white text-xl">×</button>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{shortcuts.map(([key, desc]) => (
|
||||
<tr key={key} className="py-1">
|
||||
<td className="py-1.5 pr-4 font-mono text-yellow-400 whitespace-nowrap">{key}</td>
|
||||
<td className="py-1.5 text-gray-300">{desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-gray-500 mt-4 text-center">아무 곳이나 클릭하면 닫힙니다</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 드론 지리정보 검색 패널
|
||||
* - 건물/측점명 → 해당 프레임 탐색
|
||||
* - 현재 프레임 → 보이는 건물/측점 목록
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
/**
|
||||
* 지도 기반 나침반 (노스업) — OSM 타일 위에 '현재 위치(중심 고정 점)' + '드론 시야(반투명 역삼각형)'.
|
||||
* - 노스업: 지도는 북쪽 위로 고정. 드론이 움직이면 지도가 팬(현재 위치가 항상 중앙).
|
||||
* - 시야 역삼각형(▽): 중심(드론)에서 진행/촬영 방향으로 벌어지는 반투명 부채꼴. yaw 로 회전.
|
||||
* - 마우스 오버: 위젯 자체가 커져(스케일 아님, 타일 재배치) 더 넓은 영역을 선명하게 표시.
|
||||
* 휠로 줌 인/아웃. 벗어나면 기본 크기·기본 줌으로 복귀.
|
||||
* - poseRef: 부모 RAF 가 매 프레임 { lat, lon, yaw(도, 0=N 시계+) } 갱신 → 내부 RAF 가 읽어
|
||||
* 역삼각형 회전(즉시) + 지도 팬(transform) + 임계 이동/줌·크기 변경 시 타일 재배치.
|
||||
* - 타일은 서버 프록시(/api/tile) 경유(외부 직접 로드는 CSP img-src/COEP 로 차단).
|
||||
*/
|
||||
|
||||
export interface CompassPose { lat: number; lon: number; yaw: number }
|
||||
|
||||
const BASE_D = 200; // 기본 지름(px)
|
||||
const HOVER_D = 400; // 오버 시 지름(px, 2배) — 위젯 확대(스케일 아님 → 더 넓은 영역, 선명)
|
||||
const TILE = 256; // OSM 타일 크기
|
||||
const MARGIN = TILE; // 팬 중 빈틈 방지용 여유 타일 폭
|
||||
const RELAYOUT_PX = 96; // 중심이 이만큼 벗어나면 타일 재배치(팬 리셋)
|
||||
const DEFAULT_ZOOM = 16;
|
||||
const MIN_ZOOM = 12;
|
||||
const MAX_ZOOM = 19;
|
||||
|
||||
/** 위경도 → 전역 픽셀(Web Mercator, zoom z). */
|
||||
function project(lat: number, lon: number, z: number): { x: number; y: number } {
|
||||
const n = 2 ** z;
|
||||
const x = ((lon + 180) / 360) * n * TILE;
|
||||
const s = Math.min(0.9999, Math.max(-0.9999, Math.sin((lat * Math.PI) / 180)));
|
||||
const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n * TILE;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export function MapCompass({ poseRef }: { poseRef: MutableRefObject<CompassPose> }) {
|
||||
const mapStyle = useSettingsStore((s) => s.compassMapStyle); // 'street' | 'sat'
|
||||
const setMapStyle = useSettingsStore((s) => s.setCompassMapStyle);
|
||||
const src = mapStyle === 'sat' ? 'sat' : 'osm';
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [zoom, setZoomState] = useState(DEFAULT_ZOOM);
|
||||
const zoomRef = useRef(zoom);
|
||||
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
||||
const setZoom = (z: number): void => setZoomState(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.round(z))));
|
||||
|
||||
const D = hovered ? HOVER_D : BASE_D;
|
||||
const HALF = D / 2;
|
||||
|
||||
// 타일 배치 기준 중심(이 좌표에 맞춰 타일을 깔고, 현재위치와의 차이는 transform 으로 팬).
|
||||
const [tileCenter, setTileCenter] = useState<{ lat: number; lon: number } | null>(null);
|
||||
const arrowRef = useRef<HTMLDivElement>(null);
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tileCenterRef = useRef<{ lat: number; lon: number } | null>(null);
|
||||
useEffect(() => { tileCenterRef.current = tileCenter; }, [tileCenter]);
|
||||
|
||||
// 줌 변경 시 현재 위치 기준으로 타일 재배치(줌 전환 튐 방지).
|
||||
useEffect(() => {
|
||||
const p = poseRef.current;
|
||||
if (p && isFinite(p.lat) && isFinite(p.lon)) setTileCenter({ lat: p.lat, lon: p.lon });
|
||||
}, [zoom, poseRef]);
|
||||
|
||||
// 휠 줌(페이지 스크롤 대신). native 리스너(passive:false)로 preventDefault.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
e.preventDefault();
|
||||
setZoom(zoomRef.current + (e.deltaY < 0 ? 1 : -1)); // 위로 스크롤 = 확대
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const tick = (): void => {
|
||||
raf = requestAnimationFrame(tick);
|
||||
const p = poseRef.current;
|
||||
if (!p || !isFinite(p.lat) || !isFinite(p.lon)) return;
|
||||
const z = zoomRef.current;
|
||||
if (arrowRef.current) arrowRef.current.style.transform = `rotate(${p.yaw}deg)`;
|
||||
const tc = tileCenterRef.current;
|
||||
if (!tc) { setTileCenter({ lat: p.lat, lon: p.lon }); return; }
|
||||
const cur = project(p.lat, p.lon, z);
|
||||
const base = project(tc.lat, tc.lon, z);
|
||||
const dx = cur.x - base.x, dy = cur.y - base.y;
|
||||
if (layerRef.current) layerRef.current.style.transform = `translate(${-dx}px, ${-dy}px)`;
|
||||
if (Math.hypot(dx, dy) > RELAYOUT_PX) setTileCenter({ lat: p.lat, lon: p.lon });
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [poseRef]);
|
||||
|
||||
// 벗어나면 기본값 복귀(기본 크기 + 기본 줌).
|
||||
const onLeave = (): void => { setHovered(false); setZoomState(DEFAULT_ZOOM); };
|
||||
|
||||
// 타일 배치 — 기준 중심(tileCenter)을 뷰포트 중앙에 두고, 뷰포트+여유를 덮는 타일 나열.
|
||||
const tiles: { key: string; url: string; left: number; top: number }[] = [];
|
||||
if (tileCenter) {
|
||||
const c = project(tileCenter.lat, tileCenter.lon, zoom);
|
||||
const n = 2 ** zoom;
|
||||
const minTx = Math.floor((c.x - HALF - MARGIN) / TILE);
|
||||
const maxTx = Math.floor((c.x + HALF + MARGIN) / TILE);
|
||||
const minTy = Math.floor((c.y - HALF - MARGIN) / TILE);
|
||||
const maxTy = Math.floor((c.y + HALF + MARGIN) / TILE);
|
||||
for (let tx = minTx; tx <= maxTx; tx++) {
|
||||
for (let ty = minTy; ty <= maxTy; ty++) {
|
||||
if (ty < 0 || ty >= n) continue;
|
||||
const wx = ((tx % n) + n) % n; // 경도 래핑
|
||||
tiles.push({
|
||||
key: `${src}_${zoom}_${tx}_${ty}`,
|
||||
url: `/api/tile/${src}/${zoom}/${wx}/${ty}`,
|
||||
left: tx * TILE - c.x + HALF,
|
||||
top: ty * TILE - c.y + HALF,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={() => setMapStyle(mapStyle === 'sat' ? 'street' : 'sat')}
|
||||
title="클릭: 위성 ↔ 일반 전환"
|
||||
style={{ position: 'absolute', top: 14, right: 14, width: D, height: D, zIndex: hovered ? 40 : 30, pointerEvents: 'auto', cursor: 'pointer' }}
|
||||
>
|
||||
{/* 원형 클립 지도 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', inset: 0, borderRadius: '50%', overflow: 'hidden',
|
||||
background: 'rgba(16,22,32,0.55)', border: '3px solid rgba(255,255,255,0.92)', boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* 팬 레이어 — RAF 가 transform 으로 이동. 타일은 tileCenter 기준 배치. */}
|
||||
<div ref={layerRef} style={{ position: 'absolute', inset: 0, willChange: 'transform' }}>
|
||||
{tiles.map((t) => (
|
||||
<img
|
||||
key={t.key} src={t.url} width={TILE} height={TILE} alt="" draggable={false}
|
||||
style={{ position: 'absolute', left: t.left, top: t.top, maxWidth: 'none' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!tileCenter && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)', fontSize: 11 }}>
|
||||
지도 로딩…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 드론 시야(역삼각형 ▽) — 중심(드론)에서 촬영방향으로 벌어짐. yaw 로 회전.
|
||||
주황 그라데이션: 근거리(드론) 진하게 → 멀수록 투명. 보더 없음. */}
|
||||
<div ref={arrowRef} style={{ position: 'absolute', inset: 0, transformOrigin: '50% 50%' }}>
|
||||
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="fovGrad" gradientUnits="userSpaceOnUse" x1="50" y1="50" x2="50" y2="13">
|
||||
<stop offset="0%" stopColor="#ff8800" stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor="#ff8800" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* 꼭짓점이 현재위치 점에 붙음(50). */}
|
||||
<polygon points="50,50 27,13 73,13" fill="url(#fovGrad)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 현재 위치 점(중심) + 상단 N(노스업 고정, 크게·붉은·반투명) — SVG 라 위젯 크기에 맞춰 스케일. */}
|
||||
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
|
||||
{/* 현재 위치 점 — 빨강 단색, 보더 없음. */}
|
||||
<circle cx="50" cy="50" r="3.6" fill="rgba(226,35,26,0.8)" />
|
||||
<text
|
||||
x="50" y="13.5" textAnchor="middle" fontSize="15" fontWeight="800"
|
||||
fill="#e22319" textLength="9" lengthAdjust="spacingAndGlyphs"
|
||||
>N</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import interact from 'interactjs';
|
||||
import type { Annotation } from '@abcvideo/shared';
|
||||
|
||||
interface Props {
|
||||
annotations: Annotation[];
|
||||
currentTime: number;
|
||||
onUpdate: (id: string, pos: { x: number; y: number }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
function isVisible(a: Annotation, t: number): boolean {
|
||||
return a.type === 'memo' && t >= a.timeStart && t <= a.timeEnd;
|
||||
}
|
||||
|
||||
export default function MemoOverlay({
|
||||
annotations,
|
||||
currentTime,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const visible = annotations.filter((a) => isVisible(a, currentTime));
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{visible.map((a) => (
|
||||
<MemoItem
|
||||
key={a.id}
|
||||
annotation={a}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoItem({
|
||||
annotation: a,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
annotation: Annotation;
|
||||
onUpdate: (id: string, pos: { x: number; y: number }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
// Track cumulative pixel offset from initial position
|
||||
const offsetRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Reset pixel offset when annotation changes position externally
|
||||
offsetRef.current = { x: 0, y: 0 };
|
||||
el.style.transform = 'translate(0px, 0px)';
|
||||
|
||||
const interactable = interact(el).draggable({
|
||||
listeners: {
|
||||
move(event) {
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return;
|
||||
const pw = parent.offsetWidth;
|
||||
const ph = parent.offsetHeight;
|
||||
offsetRef.current.x += event.dx;
|
||||
offsetRef.current.y += event.dy;
|
||||
el.style.transform = `translate(${offsetRef.current.x}px, ${offsetRef.current.y}px)`;
|
||||
const newX = Math.max(0, Math.min(100, a.position.x + (offsetRef.current.x / pw) * 100));
|
||||
const newY = Math.max(0, Math.min(100, a.position.y + (offsetRef.current.y / ph) * 100));
|
||||
onUpdate(a.id, { x: newX, y: newY });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => interactable.unset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [a.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elRef}
|
||||
className="absolute pointer-events-auto cursor-move select-none"
|
||||
style={{
|
||||
left: `${a.position.x}%`,
|
||||
top: `${a.position.y}%`,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded px-2 py-1 text-sm max-w-xs shadow-lg"
|
||||
style={{
|
||||
backgroundColor: a.style.backgroundColor ?? 'rgba(0,0,0,0.75)',
|
||||
color: a.style.color ?? '#ffffff',
|
||||
fontSize: `${a.style.fontSize ?? 14}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-1">
|
||||
<span className="flex-1">{a.text}</span>
|
||||
<button
|
||||
onClick={() => onDelete(a.id)}
|
||||
className="text-gray-400 hover:text-white text-xs leading-none ml-1 flex-shrink-0"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
/**
|
||||
* 나침반 (heading-up, 아날로그 눈금형) — 눈금 링 + N + 빨간 바늘이 드론 방위(--rot = -yaw)로 회전.
|
||||
* - 회전 카드(눈금/N/빨간 바늘/빨간 눈금): 부모 RAF 가 `--rot`(누적 언랩) 갱신 → 부드럽게 회전.
|
||||
* - 상단 흰 삼각형 인덱스 + 외곽 링은 고정(위 = 드론 진행방향 = 헤딩).
|
||||
* ref 는 root div — `style.setProperty('--rot', `${-yaw}deg`)`.
|
||||
*/
|
||||
const D = 200; // 나침반 한 변(px) — 지도 나침반(BASE_D)과 동일
|
||||
|
||||
// 눈금: 6° 간격(60개). 0/90/180/270=장, 30°배수=중, 그 외=단.
|
||||
const TICKS = Array.from({ length: 60 }, (_, i) => {
|
||||
const deg = i * 6;
|
||||
const long = deg % 90 === 0;
|
||||
const mid = deg % 30 === 0;
|
||||
const a = (deg * Math.PI) / 180;
|
||||
const r1 = long ? 37 : mid ? 40 : 42; // 안쪽 끝
|
||||
const sin = Math.sin(a), cos = Math.cos(a);
|
||||
return {
|
||||
deg,
|
||||
x1: 50 + r1 * sin, y1: 50 - r1 * cos,
|
||||
x2: 50 + 46 * sin, y2: 50 - 46 * cos,
|
||||
w: long ? 1.7 : mid ? 1.2 : 0.8,
|
||||
color: long ? '#fff' : 'rgba(255,255,255,0.6)',
|
||||
};
|
||||
});
|
||||
|
||||
export const Minimap = forwardRef<HTMLDivElement, { className?: string }>(function Minimap(_props, ref) {
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'absolute', top: 14, right: 14, width: D, height: D, zIndex: 30, pointerEvents: 'none' }}>
|
||||
{/* 고정 — 배경 원 + 외곽 링(두껍게). 프레임만 고정, 그 외 전부 회전. */}
|
||||
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
|
||||
<circle cx="50" cy="50" r="46.5" fill="rgba(16,22,32,0.45)" stroke="rgba(255,255,255,0.92)" strokeWidth="3" />
|
||||
</svg>
|
||||
|
||||
{/* 회전 카드 — 눈금 + N + 빨간 바늘(N) + 흰 삼각형(S) + 빨간 눈금. 전체가 함께 회전. */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width={D}
|
||||
height={D}
|
||||
style={{ position: 'absolute', inset: 0, transform: 'rotate(var(--rot, 0deg))', transformOrigin: '50% 50%' }}
|
||||
>
|
||||
{TICKS.map((t) => (
|
||||
<line key={t.deg} x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={t.color} strokeWidth={t.w} strokeLinecap="round" />
|
||||
))}
|
||||
{/* 빨간 화살표 — N(정북). 끝(tip)이 테두리에 맞닿고, 기단은 중심 쪽. */}
|
||||
<polygon points="50,7 43,32 57,32" fill="#e2231a" />
|
||||
{/* N 문자 — 빨간 화살표 '안쪽'(중심 방향). */}
|
||||
<text
|
||||
x="50"
|
||||
y="45"
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="700"
|
||||
fill="#fff"
|
||||
stroke="rgba(0,0,0,0.55)"
|
||||
strokeWidth="0.7"
|
||||
style={{ paintOrder: 'stroke' }}
|
||||
>
|
||||
N
|
||||
</text>
|
||||
{/* 흰 삼각형 — S방향, 빨간 화살표와 '정반대'(아래로). 끝이 하단 테두리에 맞닿음. */}
|
||||
<polygon points="50,93 43,68 57,68" fill="rgba(255,255,255,0.95)" />
|
||||
{/* 중심 점 */}
|
||||
<circle cx="50" cy="50" r="2.3" fill="#fff" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/* videoplayer/src/components/RouteInfo/RouteInfo.module.scss 1:1 이식 (plain CSS). */
|
||||
.panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 429.5px;
|
||||
height: 65px;
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 429.5px;
|
||||
height: 65px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.direction,
|
||||
.routeName {
|
||||
position: absolute;
|
||||
left: 79.5px;
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans KR', var(--font-ui, sans-serif);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
-webkit-text-stroke: 2.5px rgb(13, 44, 36);
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.direction {
|
||||
top: 8px;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.routeName {
|
||||
top: 35px;
|
||||
font-size: 16.5px;
|
||||
letter-spacing: 0.03em;
|
||||
color: rgb(255, 132, 54);
|
||||
}
|
||||
|
||||
.lengthLabel,
|
||||
.durationLabel {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans KR', var(--font-ui, sans-serif);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 0 4.5px rgb(0, 0, 0),
|
||||
0 0 4.5px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.lengthLabel {
|
||||
left: 261.5px;
|
||||
top: 13px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.lengthValue,
|
||||
.durationValue {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
font-family: var(--font-ui, sans-serif);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.05em;
|
||||
color: rgb(255, 132, 54);
|
||||
text-shadow:
|
||||
0 0 4.5px rgb(0, 0, 0),
|
||||
0 0 4.5px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.lengthValue {
|
||||
left: 296px;
|
||||
top: 8px;
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.lengthUnit {
|
||||
position: absolute;
|
||||
left: 342px;
|
||||
top: 11px;
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans KR', var(--font-ui, sans-serif);
|
||||
font-weight: 700;
|
||||
font-size: 16.5px;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 0 4.5px rgb(0, 0, 0),
|
||||
0 0 4.5px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.durationValue {
|
||||
left: 257.5px;
|
||||
top: 33px;
|
||||
width: 74px;
|
||||
text-align: right;
|
||||
font-size: 19px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.durationLabel {
|
||||
left: 337.5px;
|
||||
top: 35px;
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import styles from './RouteInfo.module.css';
|
||||
|
||||
/** 정적 에셋 경로 (Vite base 반영). */
|
||||
const bgUrl = `${import.meta.env.BASE_URL}assets/title-panel-bg@2x.png`;
|
||||
|
||||
/** 원본 디자인 무대 가로폭(px). 배너는 이 기준으로 만들어졌다. */
|
||||
const STAGE_WIDTH = 1920;
|
||||
|
||||
/** 초 → "M분 S초". */
|
||||
function formatDuration(sec?: number | null): string {
|
||||
if (sec == null || !isFinite(sec) || sec <= 0) return '';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.round(sec % 60);
|
||||
return s > 0 ? `${m}분 ${s}초` : `${m}분`;
|
||||
}
|
||||
|
||||
/** "158k700" → 158700 (m). 매칭 실패 시 -1. */
|
||||
function stationKm(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 좌상단 노선 정보 배너 — videoplayer 의 RouteInfo 디자인 이식.
|
||||
* 값은 모두 선택한 폴더에서 가져온다(하드코딩 없음).
|
||||
* - 연장(lengthKm): route.json 우선 → 측점 CSV 구간(min~max km) 계산 폴백.
|
||||
* - 소요(durationSec): route.json 우선 → 실제 영상 길이 폴백.
|
||||
* - 방향/노선명: CSV에 없는 정보 → route.json(routeInfo).
|
||||
* 표출할 값이 하나도 없으면 렌더하지 않는다.
|
||||
*/
|
||||
export default function RouteInfoOverlay() {
|
||||
const routeInfo = useGeoStore((s) => s.routeMeta?.routeInfo);
|
||||
const stations = useGeoStore((s) => s.stations);
|
||||
const videoDuration = usePlayerStore((s) => s.duration);
|
||||
|
||||
// 원본처럼 영상 폭/1920 비율로 배너를 스케일 (부모=영상 영역 폭 관측).
|
||||
const [scale, setScale] = useState(1);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const setPanelRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
const parent = el?.parentElement;
|
||||
if (!parent) return;
|
||||
const update = () => setScale(parent.clientWidth / STAGE_WIDTH);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(parent);
|
||||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
const direction = routeInfo?.direction;
|
||||
const name = routeInfo?.name;
|
||||
|
||||
// 연장: route.json 우선 → 측점 구간 계산 폴백
|
||||
let lengthKm = routeInfo?.lengthKm ?? null;
|
||||
if (lengthKm == null && stations.length) {
|
||||
const kms = stations.map((s) => stationKm(s.title)).filter((k) => k >= 0);
|
||||
if (kms.length >= 2) {
|
||||
lengthKm = Math.round((Math.max(...kms) - Math.min(...kms)) / 10) / 100; // m→km, 소수2
|
||||
}
|
||||
}
|
||||
|
||||
// 소요시간: route.json 우선 → 실제 영상 길이 폴백
|
||||
const dur = formatDuration(routeInfo?.durationSec ?? videoDuration);
|
||||
|
||||
if (!direction && !name && lengthKm == null && !dur) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setPanelRef}
|
||||
className={styles.panel}
|
||||
style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
||||
>
|
||||
<img className={styles.bg} src={bgUrl} alt="" />
|
||||
{direction && <p className={styles.direction}>{direction}</p>}
|
||||
{name && <p className={styles.routeName}>{name}</p>}
|
||||
{lengthKm != null && (
|
||||
<>
|
||||
<p className={styles.lengthLabel}>연장</p>
|
||||
<p className={styles.lengthValue}>{lengthKm}</p>
|
||||
<p className={styles.lengthUnit}>km</p>
|
||||
</>
|
||||
)}
|
||||
{dur && (
|
||||
<>
|
||||
<p className={styles.durationValue}>{dur}</p>
|
||||
<p className={styles.durationLabel}>소요</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
toCameraCoords,
|
||||
pixelFromCamera,
|
||||
DEFAULT_CAMERA_PARAMS,
|
||||
} from '../../utils/geoProjection';
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import { useSettingsStore, isGradeVisible } from '../../store/settingsStore';
|
||||
import type { GeoPoint } from '../../types/geo';
|
||||
|
||||
interface RoutePanelProps {
|
||||
currentTime: number;
|
||||
visible: boolean;
|
||||
onSeek: (time: number) => void;
|
||||
/** 상단 침범 방지: 컨테이너 top(px). 기본 위쪽(배너/카메라파라미터) 아래. */
|
||||
topPx?: number;
|
||||
/** 하단 침범 방지: 컨테이너 bottom(px). 기본 아래쪽(배속/토글/재생바) 위. */
|
||||
bottomPx?: number;
|
||||
}
|
||||
|
||||
const VIDEO_FPS = 30000 / 1001;
|
||||
|
||||
const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();
|
||||
|
||||
function stationKm(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
if (!m) return -1;
|
||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||
}
|
||||
|
||||
/** 미터값 → "158k160" (10m 단위, 재생바 배지와 동일 형식). */
|
||||
function formatKm10(m: number): string {
|
||||
const r = Math.round(m / 10) * 10;
|
||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
const CATEGORY_EMOJI: Record<string, string> = {
|
||||
'\uD130\uB110': '\uD83D\uDE87',
|
||||
'\uAD50\uB7C9': '\uD83C\uDF09',
|
||||
'\uC5ED\uC0AC': '\uD83D\uDE89',
|
||||
'\uC9C0\uC7A5\uBB3C': '\uD83C\uDFE2',
|
||||
'\uCE21\uC810': '\uD83D\uDCCD',
|
||||
};
|
||||
|
||||
function poiKm(poi: GeoPoint, stations: GeoPoint[]): number {
|
||||
if (!stations.length) return -1;
|
||||
const sorted = [...stations]
|
||||
.map(st => ({
|
||||
st,
|
||||
d: (poi.lat - st.lat) ** 2 + (poi.lon - st.lon) ** 2,
|
||||
}))
|
||||
.sort((a, b) => a.d - b.d);
|
||||
const a = sorted[0], b = sorted[1];
|
||||
if (!b || b.d === 0) return stationKm(a.st.title);
|
||||
const ka = stationKm(a.st.title), kb = stationKm(b.st.title);
|
||||
if (ka < 0 || kb < 0) return ka >= 0 ? ka : kb;
|
||||
const t = a.d / (a.d + b.d);
|
||||
return Math.round(ka + (kb - ka) * t);
|
||||
}
|
||||
|
||||
export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, bottomPx = 200 }: RoutePanelProps) {
|
||||
// 지리정보는 클라이언트 geoStore(폴더 선택 파싱 결과)에서 직접 읽는다.
|
||||
// 서버 /api/geo/* fetch 대체. 폴더 미선택 시 빈 배열 → idle 렌더.
|
||||
const loaded = useGeoStore(s => s.loaded);
|
||||
const stations = useGeoStore(s => s.stations);
|
||||
const pois = useGeoStore(s => s.pois);
|
||||
const structures = useGeoStore(s => s.structures);
|
||||
const gradeFilter = useSettingsStore(s => s.gradeFilter);
|
||||
const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude);
|
||||
const droneFrames = useGeoStore(s => s.frames);
|
||||
const routeMeta = useGeoStore(s => s.routeMeta);
|
||||
const [currentKm, setCurrentKm] = useState(0);
|
||||
const [currentStationTitle, setCurrentStationTitle] = useState('');
|
||||
const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null);
|
||||
const [routeStartTitle, setRouteStartTitle] = useState('');
|
||||
const [routeEndTitle, setRouteEndTitle] = useState('');
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [dragYPct, setDragYPct] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
// POI/측점/드론 프레임은 위 geoStore 셀렉터로 구독한다(폴더 데이터 도착 시 자동 재렌더).
|
||||
|
||||
// 시점/종점: 역사(category=역사) POI 중 km 최소/최대
|
||||
useEffect(() => {
|
||||
if (!stations.length || !pois.length) return;
|
||||
const validSt = stations.filter(s => stationKm(s.title) >= 0);
|
||||
if (!validSt.length) return;
|
||||
const stationPois = pois.filter(p => p.category === '\uC5ED\uC0AC' || p.category === '\uCCA0\uB3C4\uC5ED'); // 역사
|
||||
if (!stationPois.length) return;
|
||||
let minKmPoi = stationPois[0], maxKmPoi = stationPois[0];
|
||||
let minK = poiKm(stationPois[0], validSt), maxK = minK;
|
||||
for (let i = 1; i < stationPois.length; i++) {
|
||||
const k = poiKm(stationPois[i], validSt);
|
||||
if (k >= 0 && k < minK) { minK = k; minKmPoi = stationPois[i]; }
|
||||
if (k >= 0 && k > maxK) { maxK = k; maxKmPoi = stationPois[i]; }
|
||||
}
|
||||
setRouteStartTitle(minKmPoi.title);
|
||||
setRouteEndTitle(maxKmPoi.title);
|
||||
}, [stations, pois]);
|
||||
|
||||
// Update current km and visible range based on currentTime
|
||||
useEffect(() => {
|
||||
const frames = droneFrames;
|
||||
if (!frames.length || !stations.length) return;
|
||||
|
||||
// Find closest frame by time
|
||||
const targetFrame = Math.round(currentTime * VIDEO_FPS);
|
||||
let closest = frames[0];
|
||||
let closestDist = Math.abs(closest.frame - targetFrame);
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
const d = Math.abs(frames[i].frame - targetFrame);
|
||||
if (d < closestDist) {
|
||||
closest = frames[i];
|
||||
closestDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
// Find nearest station to current drone position
|
||||
const validStations = stations.filter(s => stationKm(s.title) >= 0);
|
||||
if (!validStations.length) return;
|
||||
|
||||
let nearestStation = validStations[0];
|
||||
let nearestDist = (closest.lat - nearestStation.lat) ** 2 + (closest.lon - nearestStation.lon) ** 2;
|
||||
for (let i = 1; i < validStations.length; i++) {
|
||||
const d = (closest.lat - validStations[i].lat) ** 2 + (closest.lon - validStations[i].lon) ** 2;
|
||||
if (d < nearestDist) {
|
||||
nearestStation = validStations[i];
|
||||
nearestDist = d;
|
||||
}
|
||||
}
|
||||
setCurrentStationTitle(nearestStation.title);
|
||||
|
||||
// 현재 km = 측점 폴리라인 투영 연속 체이니지 (재생바 StationBar 와 동일 기준).
|
||||
const sorted = [...validStations].sort((a, b) => stationKm(a.title) - stationKm(b.title));
|
||||
const lat0 = sorted.reduce((s, p) => s + p.lat, 0) / sorted.length;
|
||||
const k = Math.cos((lat0 * Math.PI) / 180) * 111000;
|
||||
const pts = sorted.map(p => ({ x: p.lon * k, y: p.lat * 111000, km: stationKm(p.title) }));
|
||||
const cpx = closest.lon * k, cpy = closest.lat * 111000;
|
||||
let bestD = Infinity, bestKm = pts.length ? pts[0].km : 0;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const a = pts[i], b = pts[i + 1];
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const L2 = dx * dx + dy * dy;
|
||||
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((cpx - a.x) * dx + (cpy - a.y) * dy) / L2));
|
||||
const ex = a.x + dx * t, ey = a.y + dy * t;
|
||||
const dd = (cpx - ex) ** 2 + (cpy - ey) ** 2;
|
||||
if (dd < bestD) { bestD = dd; bestKm = a.km + (b.km - a.km) * t; }
|
||||
}
|
||||
setCurrentKm(bestKm);
|
||||
|
||||
// Calculate visible range (green box)
|
||||
const allPoints = [...validStations, ...pois];
|
||||
const visibleKms: number[] = [];
|
||||
for (const pt of allPoints) {
|
||||
const cc = toCameraCoords(closest, pt.lat, pt.lon, pt.z, DEFAULT_CAMERA_PARAMS);
|
||||
if (cc.Zc <= 0) continue;
|
||||
const { pyRaw } = pixelFromCamera(cc, DEFAULT_CAMERA_PARAMS);
|
||||
if (pyRaw >= 0.0 && pyRaw <= 1.0) {
|
||||
const km = pt.type === 'station' ? stationKm(pt.title) : poiKm(pt, validStations);
|
||||
if (km >= 0) visibleKms.push(km);
|
||||
}
|
||||
}
|
||||
if (visibleKms.length >= 2) {
|
||||
setVisibleRange({
|
||||
minKm: Math.min(...visibleKms),
|
||||
maxKm: Math.max(...visibleKms),
|
||||
});
|
||||
} else {
|
||||
setVisibleRange(null);
|
||||
}
|
||||
}, [currentTime, droneFrames, stations, pois]);
|
||||
|
||||
// Drag handling
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!panelRef.current) return;
|
||||
const rect = panelRef.current.getBoundingClientRect();
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
setDragYPct(y * 100);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
setDragging(false);
|
||||
if (!panelRef.current) return;
|
||||
const rect = panelRef.current.getBoundingClientRect();
|
||||
const yPct = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
|
||||
const validStations = stations.filter(s => stationKm(s.title) >= 0);
|
||||
if (validStations.length < 2) return;
|
||||
|
||||
const allKms = [
|
||||
...validStations.map(s => stationKm(s.title)),
|
||||
...pois.map(p => poiKm(p, validStations)).filter(k => k >= 0),
|
||||
];
|
||||
const minK = Math.min(...allKms);
|
||||
const maxK = Math.max(...allKms);
|
||||
const targetKm = maxK - yPct * (maxK - minK);
|
||||
|
||||
// Find closest station to target km
|
||||
let bestStation = validStations[0];
|
||||
let bestDiff = Math.abs(stationKm(bestStation.title) - targetKm);
|
||||
for (let i = 1; i < validStations.length; i++) {
|
||||
const diff = Math.abs(stationKm(validStations[i].title) - targetKm);
|
||||
if (diff < bestDiff) {
|
||||
bestStation = validStations[i];
|
||||
bestDiff = diff;
|
||||
}
|
||||
}
|
||||
|
||||
// Find closest drone frame to that station's lat/lon
|
||||
const frames = droneFrames;
|
||||
if (!frames.length) return;
|
||||
let bestFrame = frames[0];
|
||||
let bestFrameDist = (bestFrame.lat - bestStation.lat) ** 2 + (bestFrame.lon - bestStation.lon) ** 2;
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
const d = (frames[i].lat - bestStation.lat) ** 2 + (frames[i].lon - bestStation.lon) ** 2;
|
||||
if (d < bestFrameDist) {
|
||||
bestFrame = frames[i];
|
||||
bestFrameDist = d;
|
||||
}
|
||||
}
|
||||
onSeek(bestFrame.frame / VIDEO_FPS);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging, stations, pois, droneFrames, onSeek]);
|
||||
|
||||
// Render guard — 폴더 미선택(!loaded) 또는 측점 없음이면 idle(미표시)
|
||||
if (!visible || !loaded || stations.length === 0) return null;
|
||||
const validStations = stations.filter(s => stationKm(s.title) >= 0);
|
||||
if (validStations.length < 2) return null;
|
||||
|
||||
const allKms = [
|
||||
...validStations.map(s => stationKm(s.title)),
|
||||
...pois.map(p => poiKm(p, validStations)).filter(k => k >= 0),
|
||||
];
|
||||
const minKm = Math.min(...allKms);
|
||||
const maxKm = Math.max(...allKms);
|
||||
const kmToY = (km: number) => (1 - (km - minKm) / (maxKm - minKm)) * 100;
|
||||
|
||||
// 교량/터널만 표시 + 시설종별 필터(설정 스토어, 재생바와 동일 규칙).
|
||||
// \uACB9\uCE68\uC81C\uC678 ON \uC2DC \uAC19\uC740 \uC704\uCE58 \uD615\uC81C \uC911 \uC9C4\uD589\uBC29\uD5A5((\uD558)/(\uC0C1))\uC744 \uC6B0\uC120 \uB0A8\uAE30\uB3C4\uB85D dir-\uC6B0\uC120 \uC815\uB82C.
|
||||
const routeDir = routeMeta?.routeInfo?.direction?.includes('\uD558') ? '\uD558'
|
||||
: routeMeta?.routeInfo?.direction?.includes('\uC0C1') ? '\uC0C1' : null;
|
||||
const dirTag = routeDir === '\uC0C1' ? '(\uC0C1)' : routeDir === '\uD558' ? '(\uD558)' : '';
|
||||
// \uACBD\uB85C\uC0C1 \uC2DC\uC124\uBB3C\uB9CC: \uBC29\uD5A5\uD45C\uAE30((\uC0C1\u2026/(\uD558\u2026)\uAC00 \uC788\uC73C\uBA74 \uC601\uC0C1 \uBC29\uD5A5 \uC77C\uCE58\uD558\uB294 \uAC83\uB9CC(\uBC18\uB300 \uBC29\uD5A5=\uB2E4\uB978 \uC120\uB85C \uC81C\uC678).
|
||||
const oppCh = routeDir === '\uD558' ? '\uC0C1' : routeDir === '\uC0C1' ? '\uD558' : '';
|
||||
const isOppositeDir = (name: string): boolean => !!oppCh && new RegExp(`[(\uFF08]${oppCh}`).test(name);
|
||||
const baseStruct = (t: string): string => t.replace(/\s*[(\uFF08].*$/, '').trim();
|
||||
// \uAC19\uC740 \uC88C\uD45C(\uAC19\uC740 base \uBCC0\uD615)\uB294 \uACB9\uCE68\uC81C\uC678 \uC635\uC158\uACFC \uBB34\uAD00\uD558\uAC8C \uC9C4\uD589\uBC29\uD5A5 1\uAC1C\uB9CC(dir-\uC6B0\uC120 \uC815\uB82C \uD6C4 base\uBCC4 \uCCAB\uC9F8).
|
||||
const seenBase = new Set<string>();
|
||||
const filteredPois = structures
|
||||
.filter(s => (s.type === 'bridge' || s.type === 'tunnel') && typeof s.lat === 'number' && typeof s.lon === 'number' && isGradeVisible(s.grade, gradeFilter) && !isOppositeDir(s.name))
|
||||
.map(s => ({ title: s.name, category: s.type === 'tunnel' ? '\uD130\uB110' : '\uAD50\uB7C9', lat: s.lat as number, lon: s.lon as number, z: 0, type: 'poi' as const }))
|
||||
.sort((a, b) => (dirTag && a.title.includes(dirTag) ? 0 : 1) - (dirTag && b.title.includes(dirTag) ? 0 : 1))
|
||||
.filter(p => { const b = baseStruct(p.title); if (seenBase.has(b)) return false; seenBase.add(b); return true; });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute w-28 border border-white/20 rounded-md z-30"
|
||||
style={{ left: 8, top: topPx, bottom: bottomPx, background: 'rgba(0,0,0,0.6)' }}
|
||||
>
|
||||
{/* Center vertical line */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: 38, width: 2, top: 22, bottom: 22, background: 'rgba(255,255,255,0.5)' }}
|
||||
/>
|
||||
|
||||
{/* 높은 km 역명 — 상단 (대전조차장) */}
|
||||
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ top: 4 }}>
|
||||
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
||||
<span className="text-[11px] text-white/90 font-semibold truncate">{routeMeta?.routeInfo?.endStationName || cleanTitle(routeEndTitle)}</span>
|
||||
</div>
|
||||
|
||||
{/* 낮은 km 역명 — 하단 (회덕) */}
|
||||
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ bottom: 4 }}>
|
||||
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
||||
<span className="text-[11px] text-white/90 font-semibold truncate">{routeMeta?.routeInfo?.startStationName || cleanTitle(routeStartTitle)}</span>
|
||||
</div>
|
||||
|
||||
{/* 교량/터널 POIs — 겹침 방지: Y 간격 7% 미만이면 건너뜀 */}
|
||||
{(() => {
|
||||
const MIN_GAP = 9; // %
|
||||
const placed: number[] = [];
|
||||
return filteredPois.map((poi, i) => {
|
||||
const km = poiKm(poi, validStations);
|
||||
if (km < 0) return null;
|
||||
const y = kmToY(km);
|
||||
if (y < 5 || y > 95) return null;
|
||||
// 겹침제외 ON 일 때만 겹친 것(같은 위치 형제 포함) 제외 → 방향 우선(위 dir-정렬)으로 1개만.
|
||||
// OFF 면 모두 표시(겹쳐도). 영상 오버레이 토글과 동일 규칙.
|
||||
if (poiOverlapExclude) {
|
||||
if (placed.some(py => Math.abs(py - y) < MIN_GAP)) return null;
|
||||
placed.push(y);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`poi-${i}`}
|
||||
className="absolute flex items-center pointer-events-none"
|
||||
style={{ top: `${y}%`, transform: 'translateY(-50%)', left: 0, right: 0 }}
|
||||
>
|
||||
<div style={{ position: 'absolute', left: 30, width: 16, display: 'flex', justifyContent: 'center' }}>
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm"
|
||||
style={{ background: poi.category === '\uD130\uB110' ? '#818cf8' : '#38bdf8' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] truncate font-medium"
|
||||
style={{
|
||||
position: 'absolute', left: 48, right: 2,
|
||||
color: poi.category === '\uD130\uB110' ? '#c7d2fe' : '#bae6fd',
|
||||
}}
|
||||
>
|
||||
{cleanTitle(poi.title)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* Green visible range box */}
|
||||
{visibleRange && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: 30,
|
||||
right: 4,
|
||||
top: `${kmToY(visibleRange.maxKm)}%`,
|
||||
bottom: `${100 - kmToY(visibleRange.minKm)}%`,
|
||||
border: '1px solid rgba(74,222,128,0.7)',
|
||||
background: 'rgba(74,222,128,0.08)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Orange current position marker */}
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center cursor-grab z-10"
|
||||
style={{
|
||||
top: `${dragging ? dragYPct : kmToY(currentKm)}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 30,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: '#f97316',
|
||||
border: '2px solid white',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{ position: 'absolute', left: 44 }}
|
||||
className="bg-orange-500 text-white text-[11px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap"
|
||||
>
|
||||
{formatKm10(currentKm)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props { onCapture: () => void; }
|
||||
|
||||
export default function FrameCaptureButton({ onCapture }: Props) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCapture}
|
||||
title="현재 프레임 캡처 (Shift+S)"
|
||||
className="bg-gray-700 hover:bg-gray-600 text-white text-sm px-3 py-1.5 rounded"
|
||||
>
|
||||
캡처
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
videoId: string;
|
||||
onConversionDone: () => void;
|
||||
}
|
||||
|
||||
export default function HlsConversionStatus({ videoId, onConversionDone }: Props) {
|
||||
const [status, setStatus] = useState<'idle' | 'converting' | 'done' | 'error'>('idle');
|
||||
const [percent, setPercent] = useState(0);
|
||||
|
||||
const startConversion = async () => {
|
||||
setStatus('converting');
|
||||
await fetch(`/api/hls/${videoId}/convert`, { method: 'POST' });
|
||||
|
||||
const es = new EventSource(`/api/hls/${videoId}/progress`);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
setPercent(Math.round(data.percent ?? 0));
|
||||
setStatus(data.status);
|
||||
if (data.status === 'done') {
|
||||
es.close();
|
||||
onConversionDone();
|
||||
} else if (data.status === 'error') {
|
||||
es.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setStatus('error');
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{status === 'idle' && (
|
||||
<button
|
||||
onClick={startConversion}
|
||||
className="bg-green-700 hover:bg-green-600 text-white px-3 py-1.5 rounded"
|
||||
>
|
||||
HLS 변환
|
||||
</button>
|
||||
)}
|
||||
{status === 'converting' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-gray-400">{percent}%</span>
|
||||
</div>
|
||||
)}
|
||||
{status === 'done' && <span className="text-green-400">HLS 준비됨</span>}
|
||||
{status === 'error' && <span className="text-red-400">변환 실패</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect, useMemo } from 'react';
|
||||
import StationOverlay from '../overlay/StationOverlay';
|
||||
import RoutePanel from '../overlay/RoutePanel';
|
||||
import RouteInfoOverlay from '../overlay/RouteInfoOverlay';
|
||||
import 'video.js/dist/video-js.css';
|
||||
import { useVideoPlayer } from '../../hooks/useVideoPlayer';
|
||||
import { useFrameStep } from '../../hooks/useFrameStep';
|
||||
import { useKeyboard } from '../../hooks/useKeyboard';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import { useSettingsStore, KNOWN_GRADES } from '../../store/settingsStore';
|
||||
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
|
||||
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
|
||||
import { useCaptureStore } from '../../store/captureStore';
|
||||
import HlsConversionStatus from './HlsConversionStatus';
|
||||
import { StationBar } from '../../stationbar/StationBar';
|
||||
|
||||
/** 드롭된 디렉토리/파일 엔트리에서 모든 파일을 재귀 수집 (폴더 드래그&드롭 지원). */
|
||||
function collectDropEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
(entry as FileSystemFileEntry).file((f) => { out.push(f); resolve(); }, () => resolve());
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const readBatch = () => {
|
||||
reader.readEntries(async (ents) => {
|
||||
if (!ents.length) { resolve(); return; } // 모든 배치 소진
|
||||
await Promise.all(ents.map((en) => collectDropEntry(en, out)));
|
||||
readBatch();
|
||||
}, () => resolve());
|
||||
};
|
||||
readBatch();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface VideoPlayerHandle {
|
||||
loadLocalFile: (file: File) => void;
|
||||
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
|
||||
seekTo: (time: number) => void;
|
||||
getVideoElement: () => HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
interface VideoPlayerProps {
|
||||
onAddMemo: (time: number) => void;
|
||||
onToggleHelp?: () => void;
|
||||
}
|
||||
|
||||
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
function VideoPlayer({ onAddMemo, onToggleHelp }, ref) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement } =
|
||||
useVideoPlayer(containerRef);
|
||||
|
||||
const { stepForward, stepBackward } = useFrameStep(playerRef);
|
||||
const { currentTime, duration, playing, source, playbackRate, videoReady, videoWidth, videoHeight } = usePlayerStore();
|
||||
|
||||
// 커서 매끄러운 이동:
|
||||
// - 라이브 시간(smoothTimeRef): 매 프레임 '단조' 벽시계 보간. StationBar가 ref로 읽어
|
||||
// 커서/진행바를 직접(transform) 갱신 → React 60fps 리렌더 없이 매끄럽게.
|
||||
// - state(smoothTime): 배지 숫자·색 변경용으로만 throttle(≈10fps) 갱신.
|
||||
const [smoothTime, setSmoothTime] = useState(0);
|
||||
const smoothTimeRef = useRef(0);
|
||||
const anchorRef = useRef({ media: 0, wall: 0 });
|
||||
const lastSetRef = useRef(0);
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
// 시크(클릭/드래그 이동) 시 커서를 즉시 그 위치로 재동기화.
|
||||
// (단조 보간은 앵커보다 앞쪽으로의 뒤로가기 시크를 무시하므로 별도 처리 필요)
|
||||
const onSeeked = (): void => {
|
||||
const p = playerRef.current;
|
||||
if (!p || p.isDisposed()) return;
|
||||
const t = p.currentTime() ?? 0;
|
||||
anchorRef.current = { media: t, wall: performance.now() };
|
||||
smoothTimeRef.current = t;
|
||||
lastSetRef.current = t;
|
||||
setSmoothTime(t);
|
||||
};
|
||||
playerRef.current?.on('seeked', onSeeked);
|
||||
const tick = (): void => {
|
||||
const p = playerRef.current;
|
||||
if (p && !p.isDisposed()) {
|
||||
const dur = p.duration() ?? 0;
|
||||
let t: number;
|
||||
if (p.paused()) {
|
||||
t = p.currentTime() ?? 0;
|
||||
anchorRef.current = { media: t, wall: performance.now() };
|
||||
} else {
|
||||
const a = anchorRef.current;
|
||||
const rate = p.playbackRate() ?? 1;
|
||||
let est = a.media + ((performance.now() - a.wall) / 1000) * rate;
|
||||
const real = p.currentTime() ?? 0;
|
||||
// 단조: 작은 역행은 무시(흔들림 방지). 뒤처짐(real이 앞섬) 또는 시크(뒤로)만 재동기화.
|
||||
if (real - est > 0.3 || real < a.media - 0.3) {
|
||||
est = real;
|
||||
anchorRef.current = { media: real, wall: performance.now() };
|
||||
}
|
||||
t = dur > 0 ? Math.min(est, dur) : est;
|
||||
}
|
||||
smoothTimeRef.current = t;
|
||||
if (Math.abs(t - lastSetRef.current) >= 0.1) {
|
||||
lastSetRef.current = t;
|
||||
setSmoothTime(t);
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
playerRef.current?.off('seeked', onSeeked);
|
||||
};
|
||||
}, [playerRef, source]);
|
||||
const loadFromFolder = useGeoStore((s) => s.loadFromFolder);
|
||||
const geoLoaded = useGeoStore((s) => s.loaded);
|
||||
// 드론 정보 — 하단바에 GPS/고도 항상 표시.
|
||||
const storeFrames = useGeoStore((s) => s.frames);
|
||||
// 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어.
|
||||
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
|
||||
const setGradeFilter = useSettingsStore((s) => s.setGradeFilter);
|
||||
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
|
||||
const setPoiOverlapExclude = useSettingsStore((s) => s.setPoiOverlapExclude);
|
||||
// 선형(중심선)·드론 궤적 영상 오버레이 토글 — StationOverlay 와 공유(설정 스토어).
|
||||
const showCenterline = useSettingsStore((s) => s.showCenterline);
|
||||
const setShowCenterline = useSettingsStore((s) => s.setShowCenterline);
|
||||
const showDronePath = useSettingsStore((s) => s.showDronePath);
|
||||
const setShowDronePath = useSettingsStore((s) => s.setShowDronePath);
|
||||
const showRoutePanel = useSettingsStore((s) => s.showRoutePanel);
|
||||
const setShowRoutePanel = useSettingsStore((s) => s.setShowRoutePanel);
|
||||
// 하단 도구 패널: UI에서 숨김(코드는 보존). 다시 표시하려면 true 로.
|
||||
const SHOW_TOOLBAR = false;
|
||||
|
||||
// 폴더 선택: 지리정보 파싱 + 영상 재생
|
||||
const handleSelectFolder = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
try {
|
||||
const videoFile = await loadFromFolder(files);
|
||||
if (videoFile) loadLocalFile(videoFile);
|
||||
else console.warn('[geo] 폴더에 영상 파일(mp4/webm)이 없습니다 — 지리정보만 로드');
|
||||
// KMZ(POI·구조물 원본) 필수 — 측점/드론은 있는데 KMZ만 빠진 경우 = 데이터 누락.
|
||||
const { kmzMissing, stations, frames } = useGeoStore.getState();
|
||||
if (kmzMissing && (stations.length > 0 || frames.length > 0)) {
|
||||
alert(
|
||||
'KMZ(POI·구조물 원본)가 폴더에 없습니다.\n' +
|
||||
'POI·구조물이 표시되지 않습니다. KMZ를 포함해 데이터를 재구축·전달하세요.\n' +
|
||||
'(측점·드론 정보는 정상 로드되었습니다.)',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[geo] 폴더 로드 실패', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadLocalFile,
|
||||
loadServerStream,
|
||||
seekTo: (time: number) => {
|
||||
playerRef.current?.currentTime(time);
|
||||
},
|
||||
getVideoElement,
|
||||
}));
|
||||
|
||||
const addCapture = useCaptureStore((s) => s.addCapture);
|
||||
|
||||
const handleCaptureFrame = () => {
|
||||
const video = getVideoElement();
|
||||
if (!video) return;
|
||||
const dataUrl = captureFrame(video);
|
||||
if (!dataUrl) return;
|
||||
const filename = `frame_${secondsToTimecode(currentTime).replace(/[:.]/g, '-')}.jpg`;
|
||||
downloadDataUrl(dataUrl, filename);
|
||||
addCapture({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
dataUrl,
|
||||
time: currentTime,
|
||||
filename,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddMemo = () => onAddMemo(currentTime);
|
||||
const [showStations, setShowStations] = useState(true);
|
||||
// 영상제어 토글: 카메라 파라미터·프레임상태·배속 표시 on/off
|
||||
const [showVideoControls, setShowVideoControls] = useState(true);
|
||||
// 좌하단 그룹을 재생바 위에 두기 위해 StationBar 높이를 동적 측정.
|
||||
const barWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [barHeight, setBarHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = barWrapRef.current;
|
||||
if (!el) return;
|
||||
const update = (): void => setBarHeight(el.offsetHeight);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [geoLoaded]);
|
||||
// 노선 배너(429.5×65 @1920 비율) 높이만큼 카메라 파라미터를 아래로.
|
||||
const [stageWidth, setStageWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
const update = (): void => setStageWidth(el.clientWidth);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const paramTop = Math.round((stageWidth / 1920) * 65) + 10;
|
||||
const [showUtilBar, setShowUtilBar] = useState(false);
|
||||
|
||||
const handleTogglePlay = (): void => {
|
||||
const p = playerRef.current;
|
||||
if (!p) return;
|
||||
if (playing) p.pause();
|
||||
else void p.play();
|
||||
};
|
||||
const handleStop = (): void => {
|
||||
const p = playerRef.current;
|
||||
if (!p) return;
|
||||
p.pause();
|
||||
p.currentTime(0);
|
||||
};
|
||||
const handleSeek = (t: number): void => {
|
||||
playerRef.current?.currentTime(t);
|
||||
};
|
||||
|
||||
useKeyboard({
|
||||
playerRef,
|
||||
onStepForward: stepForward,
|
||||
onStepBackward: stepBackward,
|
||||
onCaptureFrame: handleCaptureFrame,
|
||||
onAddMemo: handleAddMemo,
|
||||
onToggleHelp,
|
||||
containerRef: wrapperRef,
|
||||
});
|
||||
|
||||
// 드래그&드롭 — 폴더(영상+측점/POI) 또는 단일 영상 파일.
|
||||
// 폴더는 dataTransfer.files 가 비어 있으므로 webkitGetAsEntry 로 디렉토리를 재귀 순회한다.
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
// await 후엔 dataTransfer 가 무효화될 수 있어 동기적으로 먼저 캡처.
|
||||
const entries: FileSystemEntry[] = [];
|
||||
const dt = e.dataTransfer;
|
||||
if (dt.items) {
|
||||
for (let i = 0; i < dt.items.length; i++) {
|
||||
const en = dt.items[i].webkitGetAsEntry?.();
|
||||
if (en) entries.push(en);
|
||||
}
|
||||
}
|
||||
const flatFiles = Array.from(dt.files);
|
||||
void (async () => {
|
||||
if (entries.length) {
|
||||
const out: File[] = [];
|
||||
await Promise.all(entries.map((en) => collectDropEntry(en, out)));
|
||||
if (out.length) { handleSelectFolder(out); return; }
|
||||
}
|
||||
if (flatFiles.length > 1) { handleSelectFolder(flatFiles); return; }
|
||||
const file = flatFiles[0];
|
||||
if (file?.type.startsWith('video/')) loadLocalFile(file);
|
||||
})();
|
||||
};
|
||||
|
||||
// 영상 실제 fps — 데이터(드론 CSV 마지막 프레임 번호) ÷ 영상 길이(초) 로 자동 산출 후
|
||||
// 표준 fps(24/25/29.97/30/50/60 등) 중 가까운 값에 스냅. 영상마다 fps 가 달라도 자동 대응.
|
||||
// (드론 CSV 엔 시간이 없고 frame_cnt 만 있어, 영상 length 와 결합해야 fps 를 알 수 있다.)
|
||||
// 데이터/영상길이 미확보 시 29.97 폴백. VFC 자동감지는 31fps 오감지가 있어 사용 안 함.
|
||||
const effectiveFps = useMemo(() => {
|
||||
const FALLBACK = 30000 / 1001;
|
||||
if (!storeFrames.length || !duration || duration <= 0) return FALLBACK;
|
||||
let maxF = 0;
|
||||
for (const f of storeFrames) if (f.frame > maxF) maxF = f.frame;
|
||||
if (maxF <= 0) return FALLBACK;
|
||||
const raw = maxF / duration; // 마지막 프레임 번호 ÷ 영상 길이(초)
|
||||
const STD = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60];
|
||||
let best = STD[0], bd = Math.abs(raw - STD[0]);
|
||||
for (const s of STD) { const d = Math.abs(raw - s); if (d < bd) { bd = d; best = s; } }
|
||||
return bd <= best * 0.1 ? best : raw; // 표준값 ±10% 이내면 스냅, 아니면 원시값
|
||||
}, [storeFrames, duration]);
|
||||
const frame = secondsToFrame(currentTime, effectiveFps);
|
||||
const videoId = source?.kind === 'server' ? source.videoId : null;
|
||||
|
||||
// 드론 정보(측점진단) — 현재 프레임에 가장 가까운 드론 프레임의 GPS/고도만 표시.
|
||||
// 드론 GPS·고도 HUD 는 항상 표시 → 토글 없이 프레임만 있으면 계산.
|
||||
const stationDiag = useMemo(() => {
|
||||
if (!storeFrames.length) return null;
|
||||
let best = storeFrames[0], bd = Math.abs(storeFrames[0].frame - frame);
|
||||
for (const df of storeFrames) {
|
||||
const d = Math.abs(df.frame - frame);
|
||||
if (d < bd) { bd = d; best = df; }
|
||||
}
|
||||
return { f: best };
|
||||
}, [storeFrames, frame]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative bg-black w-full h-full"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* 영상 영역 — 컨테이너 전체를 채우는 relative 래퍼 (사이니지: 화면 가득) */}
|
||||
<div className="relative w-full h-full">
|
||||
{/* Video.js container — 영상이 영역을 꽉 채우는 베이스 레이어 (object-fit:cover) */}
|
||||
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
|
||||
<div
|
||||
data-vjs-player
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ cursor: source ? 'pointer' : 'default' }}
|
||||
onClick={() => {
|
||||
if (source) handleTogglePlay();
|
||||
}}
|
||||
/>
|
||||
{/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */}
|
||||
<RouteInfoOverlay />
|
||||
{/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */}
|
||||
{source && (
|
||||
<div
|
||||
className="absolute left-2 z-30 flex items-center gap-2 flex-wrap pointer-events-auto"
|
||||
style={{ bottom: (barHeight || 130) + 8 }}
|
||||
>
|
||||
{/* 영상제어 토글 — 버튼만, 고정폭(가장 긴 'OFF' 기준이라 ON/OFF로 폭이 안 변함) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVideoControls((v) => !v)}
|
||||
className={`text-xs px-2 py-1 rounded border font-semibold text-center min-w-[96px] ${showVideoControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>영상제어 {showVideoControls ? 'ON' : 'OFF'}</button>
|
||||
{showVideoControls && (
|
||||
<>
|
||||
{/* 배속 패널 (영상제어 우측) */}
|
||||
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded">
|
||||
<span className="text-gray-400 text-xs">배속</span>
|
||||
{[0.5, 1, 1.5, 2, 3, 4].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => playerRef.current?.playbackRate(r)}
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
Math.abs(playbackRate - r) < 0.01
|
||||
? 'bg-amber-400 text-black'
|
||||
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{r}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */}
|
||||
<span className="bg-black/70 text-gray-200 text-xs px-2 py-1 rounded font-mono whitespace-nowrap">
|
||||
{secondsToTimecode(currentTime)} | F<span className="inline-block text-left" style={{ minWidth: '6ch' }}>{frame}</span> | {effectiveFps.toFixed(2)}fps
|
||||
</span>
|
||||
{geoLoaded && (
|
||||
<>
|
||||
{/* 좌측 패널 토글 — 선형 왼편, 동일 폭(min-w-[96px]) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRoutePanel(!showRoutePanel)}
|
||||
title="좌측 노선 패널 표시/숨김"
|
||||
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showRoutePanel ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>좌측 패널 {showRoutePanel ? 'ON' : 'OFF'}</button>
|
||||
{/* 선형(중심선) 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCenterline(!showCenterline)}
|
||||
title="선형(중심선) 표시/숨김"
|
||||
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showCenterline ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>선형 {showCenterline ? 'ON' : 'OFF'}</button>
|
||||
{/* 드론 궤적 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDronePath(!showDronePath)}
|
||||
title="드론 궤적 표시/숨김"
|
||||
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showDronePath ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>드론궤적 {showDronePath ? 'ON' : 'OFF'}</button>
|
||||
{/* 드론 위치정보 — 드론궤적 버튼 바로 우측에 붙임. GPS·고도 항상 표시. */}
|
||||
{stationDiag && (
|
||||
<div className="bg-black/70 px-2 py-1 rounded font-mono text-xs text-gray-200 whitespace-nowrap">
|
||||
GPS {stationDiag.f.lat.toFixed(6)}, {stationDiag.f.lon.toFixed(6)}
|
||||
<span className="text-gray-400"> · 절대고도 {stationDiag.f.altitude.toFixed(1)}m</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 겹침제외 토글 — UI 숨김(기능/상태는 보존, 기본 ON). 다시 보이려면 주석 해제. */}
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={() => setPoiOverlapExclude(!poiOverlapExclude)}
|
||||
title="ON=겹친 POI 숨김 / OFF=모든 POI 표시"
|
||||
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${poiOverlapExclude ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>겹침제외 {poiOverlapExclude ? 'ON' : 'OFF'}</button> */}
|
||||
{/* 시설등급 패널 */}
|
||||
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
|
||||
<span className="text-gray-400 text-xs">시설등급</span>
|
||||
{KNOWN_GRADES.map((g) => (
|
||||
<label
|
||||
key={g}
|
||||
className="flex items-center gap-1 text-xs text-gray-200 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-amber-400"
|
||||
checked={gradeFilter[g] ?? false}
|
||||
onChange={(e) => setGradeFilter(g, e.target.checked)}
|
||||
/>
|
||||
{g}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */}
|
||||
<RoutePanel
|
||||
currentTime={currentTime}
|
||||
visible={showStations && showRoutePanel}
|
||||
onSeek={(time) => playerRef.current?.currentTime(time)}
|
||||
topPx={paramTop + 40}
|
||||
bottomPx={(barHeight || 130) + 90}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Empty state placeholder — 가운데 반투명 폴더 선택 */}
|
||||
{!source && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none select-none" style={{ minHeight: '240px' }}>
|
||||
<div className="text-5xl mb-3 opacity-60">▶</div>
|
||||
<p className="text-lg">동영상 폴더를 드래그하거나 선택하세요</p>
|
||||
<p className="text-sm mt-1 mb-6">영상 + 측점/POI 데이터가 함께 로드됩니다</p>
|
||||
<label className="pointer-events-auto cursor-pointer bg-emerald-500/20 hover:bg-emerald-500/40 backdrop-blur-sm border border-emerald-300/40 text-white text-base font-medium px-7 py-3 rounded-xl shadow-lg transition-colors">
|
||||
폴더 선택
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files?.length) void handleSelectFolder(Array.from(files));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 측점 오버레이 */}
|
||||
<StationOverlay
|
||||
currentFrame={frame}
|
||||
currentTime={currentTime}
|
||||
timeRef={smoothTimeRef}
|
||||
fps={effectiveFps}
|
||||
visible={showStations}
|
||||
videoReady={videoReady}
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
showPanel={showVideoControls && !!source}
|
||||
topPx={paramTop}
|
||||
barHeight={barHeight}
|
||||
/>
|
||||
|
||||
{/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */}
|
||||
{geoLoaded && (
|
||||
<div ref={barWrapRef} className="absolute bottom-0 left-0 right-0 z-20">
|
||||
<StationBar
|
||||
currentTime={smoothTime}
|
||||
timeRef={smoothTimeRef}
|
||||
duration={duration}
|
||||
playing={playing}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
onStop={handleStop}
|
||||
onCapture={handleCaptureFrame}
|
||||
onSeek={handleSeek}
|
||||
showStations={showStations}
|
||||
onToggleStations={() => setShowStations((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) — UI 숨김(코드 보존, SHOW_TOOLBAR 로 제어) */}
|
||||
{SHOW_TOOLBAR && (
|
||||
<div className="bg-gray-900 border-t border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUtilBar((v) => !v)}
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs text-gray-400 hover:text-white"
|
||||
>
|
||||
<span className={`inline-block transition-transform ${showUtilBar ? 'rotate-90' : ''}`}>▸</span>
|
||||
도구 {showUtilBar ? '접기' : '펼치기'}
|
||||
</button>
|
||||
{showUtilBar && (
|
||||
<div className="flex items-center gap-2 px-2 pb-2 flex-wrap">
|
||||
<label className="cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-sm px-3 py-1.5 rounded">
|
||||
폴더 선택
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files?.length) void handleSelectFolder(Array.from(files));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 프레임 직접 이동 */}
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
const input = (e.currentTarget.elements.namedItem('frameInput') as HTMLInputElement);
|
||||
const frameNum = parseInt(input.value, 10);
|
||||
if (!isNaN(frameNum)) {
|
||||
playerRef.current?.currentTime(frameNum / effectiveFps);
|
||||
}
|
||||
input.blur();
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span className="text-gray-500 text-xs">F</span>
|
||||
<input
|
||||
name="frameInput"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="프레임"
|
||||
className="w-20 bg-black/60 border border-gray-600 rounded px-1.5 py-1 text-xs text-yellow-300 font-mono
|
||||
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="text-xs px-2 py-1 rounded border border-gray-600 bg-gray-800 text-gray-300 hover:text-white"
|
||||
>
|
||||
이동
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{videoId && (
|
||||
<HlsConversionStatus
|
||||
videoId={videoId}
|
||||
onConversionDone={() => switchToHls(videoId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="text-gray-500 text-xs ml-auto hidden sm:inline">
|
||||
Space 재생 | ←/→ 5초 | J/L 10초 | ,/. 프레임 | Shift+S 캡처
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default VideoPlayer;
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Annotation } from '@abcvideo/shared';
|
||||
import { secondsToTimecode } from '../../utils/timecode';
|
||||
|
||||
interface Props {
|
||||
annotations: Annotation[];
|
||||
currentTime: number;
|
||||
onSeek: (time: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onExport: (format: string) => void;
|
||||
}
|
||||
|
||||
export default function AnnotationPanel({
|
||||
annotations,
|
||||
currentTime,
|
||||
onSeek,
|
||||
onDelete,
|
||||
onExport,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState<'subtitle' | 'memo'>('subtitle');
|
||||
const filtered = annotations.filter((a) => a.type === tab);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-700">
|
||||
{(['subtitle', 'memo'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-2 text-sm ${
|
||||
tab === t ? 'text-white border-b-2 border-blue-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{t === 'subtitle' ? '자막' : '메모'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-gray-800">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-gray-500 text-sm text-center py-6">
|
||||
{tab === 'subtitle' ? '자막이 없습니다' : '메모가 없습니다'}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((a) => {
|
||||
const active = currentTime >= a.timeStart && currentTime <= a.timeEnd;
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`px-3 py-2 cursor-pointer hover:bg-gray-800 ${
|
||||
active ? 'bg-gray-800 border-l-2 border-yellow-400' : ''
|
||||
}`}
|
||||
onClick={() => onSeek(a.timeStart)}
|
||||
>
|
||||
<div className="text-xs text-gray-400 font-mono">
|
||||
{secondsToTimecode(a.timeStart)} → {secondsToTimecode(a.timeEnd)}
|
||||
</div>
|
||||
<div className="text-sm text-white mt-0.5 truncate">{a.text}</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(a.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 mt-1"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Export buttons */}
|
||||
<div className="p-2 border-t border-gray-700 flex gap-1 flex-wrap">
|
||||
{['vtt', 'srt', 'json', 'csv'].map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => onExport(f)}
|
||||
className="text-xs bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded"
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { useCaptureStore } from '../../store/captureStore';
|
||||
import { secondsToTimecode } from '../../utils/timecode';
|
||||
|
||||
interface Props {
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
|
||||
export default function CaptureList({ onSeek }: Props) {
|
||||
const { captures, removeCapture, clearCaptures } = useCaptureStore();
|
||||
|
||||
if (captures.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-xs p-4 text-center">
|
||||
캡처된 프레임이 없습니다<br />
|
||||
<span className="text-gray-600">Shift+S로 캡처</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<span className="text-xs text-gray-400">{captures.length}개</span>
|
||||
<button
|
||||
onClick={clearCaptures}
|
||||
className="text-xs text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
전체 삭제
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto space-y-1 px-2 pb-2">
|
||||
{captures.map((cap) => (
|
||||
<div
|
||||
key={cap.id}
|
||||
className="group relative cursor-pointer rounded overflow-hidden border border-gray-700 hover:border-blue-500 transition-colors"
|
||||
onClick={() => onSeek(cap.time)}
|
||||
>
|
||||
<img
|
||||
src={cap.dataUrl}
|
||||
alt={cap.filename}
|
||||
className="w-full h-auto object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/70 px-2 py-0.5 flex items-center justify-between">
|
||||
<span className="text-xs text-white font-mono">
|
||||
{secondsToTimecode(cap.time)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); removeCapture(cap.id); }}
|
||||
className="text-xs text-gray-400 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="삭제"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
|
||||
interface VideoItem { videoId: string; filename: string; }
|
||||
|
||||
interface Props {
|
||||
onSelect: (videoId: string, filename: string) => void;
|
||||
}
|
||||
|
||||
export default function VideoList({ onSelect }: Props) {
|
||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||
const { source } = usePlayerStore();
|
||||
const activeId = source?.kind === 'server' ? source.videoId : null;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/videos')
|
||||
.then((r) => r.json())
|
||||
.then(setVideos)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (videos.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4 text-center">
|
||||
서버에 영상이 없습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{videos.map((v) => (
|
||||
<button
|
||||
key={v.videoId}
|
||||
onClick={() => onSelect(v.videoId, v.filename)}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-800 transition-colors ${
|
||||
activeId === v.videoId ? 'bg-gray-800 border-l-2 border-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm text-white truncate">{v.filename}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{v.videoId}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user