GhiVideo 소스 복제 — v4 작업 시작 기준

기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제.
(node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:32:51 +09:00
co-authored by Claude Fable 5
commit d38b842e8d
362 changed files with 46358 additions and 0 deletions
@@ -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>
);
}
+22
View File
@@ -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;
}
}
+40
View File
@@ -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>
);
}
+267
View File
@@ -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>
);
}
+147
View File
@@ -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)}%)
&nbsp; {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>
);
}
+69
View File
@@ -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">&#9654;</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' : ''}`}>&#9656;</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>
);
}