GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap"
|
||||
/>
|
||||
<title>abcvideo — 대용량 동영상 플레이어</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@abcvideo/client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abcvideo/shared": "*",
|
||||
"@types/proj4": "^2.5.6",
|
||||
"@videojs/http-streaming": "^3.17.4",
|
||||
"fflate": "^0.8.3",
|
||||
"hls.js": "^1.6.15",
|
||||
"interactjs": "^1.10.27",
|
||||
"proj4": "^2.20.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"subsrt-ts": "^2.1.2",
|
||||
"uuid": "14.0.0",
|
||||
"video.js": "^8.23.7",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.73",
|
||||
"@types/react-dom": "^18.2.23",
|
||||
"@types/video.js": "^7.3.58",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"sass": "^1.101.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 13 MiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 718 B |
|
After Width: | Height: | Size: 957 B |
|
After Width: | Height: | Size: 961 B |
|
After Width: | Height: | Size: 208 B |
|
After Width: | Height: | Size: 526 B |
|
After Width: | Height: | Size: 505 B |
|
After Width: | Height: | Size: 649 B |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 898 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 946 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 515 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 466 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 466 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 557 KiB |
@@ -0,0 +1,187 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import VideoPlayer, { type VideoPlayerHandle } from './components/player/VideoPlayer';
|
||||
import VideoList from './components/sidebar/VideoList';
|
||||
import CaptureList from './components/sidebar/CaptureList';
|
||||
import AnnotationPanel from './components/sidebar/AnnotationPanel';
|
||||
import MemoOverlay from './components/overlay/MemoOverlay';
|
||||
import AddAnnotationModal from './components/AddAnnotationModal';
|
||||
import HelpOverlay from './components/HelpOverlay';
|
||||
import GeoSearch from './components/geo/GeoSearch';
|
||||
import StationVerify from './components/geo/StationVerify';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { useAnnotations } from './hooks/useAnnotations';
|
||||
|
||||
export default function App() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalTime, setModalTime] = useState(0);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [memoTime, setMemoTime] = useState(0);
|
||||
const [rightTab, setRightTab] = useState<'annotation' | 'geo' | 'station'>('annotation');
|
||||
// 좌/우 패널: UI 에서 숨김(코드 보존). 다시 표시하려면 true 로.
|
||||
const SHOW_LEFT_PANEL = false;
|
||||
const SHOW_RIGHT_PANEL = false;
|
||||
const playerRef = useRef<VideoPlayerHandle>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
// RAF loop for MemoOverlay — avoids timeupdate (~4fps) imprecision
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const video = playerRef.current?.getVideoElement();
|
||||
if (video) setMemoTime(video.currentTime);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, []);
|
||||
|
||||
const { source, currentTime, fps } = usePlayerStore();
|
||||
const currentFrame = Math.round(currentTime * (fps || 30));
|
||||
|
||||
const videoId =
|
||||
source?.kind === 'server'
|
||||
? source.videoId
|
||||
: source?.kind === 'local'
|
||||
? source.file.name
|
||||
: null;
|
||||
|
||||
const { annotations, create, update, remove } = useAnnotations(videoId);
|
||||
|
||||
const handleAddMemo = useCallback((time: number) => {
|
||||
setModalTime(time);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const handleAnnotationCreate = async (
|
||||
type: 'subtitle' | 'memo',
|
||||
text: string,
|
||||
timeStart: number,
|
||||
timeEnd: number
|
||||
) => {
|
||||
await create({
|
||||
type,
|
||||
text,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
position: { x: 10, y: 10 },
|
||||
size: { width: 30, height: 10 },
|
||||
style: { fontSize: 14, color: '#ffffff', backgroundColor: 'rgba(0,0,0,0.75)' },
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = (format: string) => {
|
||||
if (!videoId) return;
|
||||
window.open(`/api/annotations/${videoId}/export?format=${format}`, '_blank');
|
||||
};
|
||||
|
||||
const handleSeek = (time: number) => {
|
||||
playerRef.current?.seekTo(time);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-950 text-white overflow-hidden">
|
||||
{/* Left sidebar — top: video list, bottom: captures (UI 숨김, 코드 보존) */}
|
||||
{SHOW_LEFT_PANEL && (
|
||||
<div className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-gray-800">
|
||||
<h1 className="text-sm font-bold text-white">abcvideo</h1>
|
||||
<p className="text-xs text-gray-500">대용량 영상 플레이어</p>
|
||||
<p className="text-xs text-gray-600 mt-0.5">v1.0.0 · {new Date(__BUILD_TIME__).toLocaleString('ko-KR', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' })}</p>
|
||||
</div>
|
||||
{/* 상단: 서버 영상 목록 */}
|
||||
<div className="flex-1 overflow-y-auto border-b border-gray-700 min-h-0">
|
||||
<div className="px-4 py-2 text-xs text-gray-500 uppercase tracking-wide">서버 영상</div>
|
||||
<VideoList
|
||||
onSelect={(id, name) => playerRef.current?.loadServerStream(id, name)}
|
||||
/>
|
||||
</div>
|
||||
{/* 하단: 캡처 프레임 미리보기 */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="px-4 py-2 text-xs text-gray-500 uppercase tracking-wide">캡처 프레임</div>
|
||||
<CaptureList onSeek={handleSeek} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main — player + memo overlay */}
|
||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
||||
<VideoPlayer
|
||||
ref={playerRef}
|
||||
onAddMemo={handleAddMemo}
|
||||
onToggleHelp={() => setShowHelp(h => !h)}
|
||||
/>
|
||||
|
||||
{/* Memo overlay — positioned absolute over the player area */}
|
||||
<MemoOverlay
|
||||
annotations={annotations}
|
||||
currentTime={memoTime}
|
||||
onUpdate={(id, pos) => update(id, { position: pos })}
|
||||
onDelete={remove}
|
||||
containerRef={{ current: null }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right sidebar — annotation + geo (UI 숨김, 코드 보존) */}
|
||||
{SHOW_RIGHT_PANEL && (
|
||||
<div className="w-64 flex-shrink-0 bg-gray-900 border-l border-gray-800 flex flex-col">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="flex border-b border-gray-800 flex-shrink-0">
|
||||
<button
|
||||
className={`flex-1 py-2.5 text-xs font-semibold transition-colors ${rightTab === 'annotation' ? 'text-white border-b-2 border-blue-500' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
onClick={() => setRightTab('annotation')}
|
||||
>
|
||||
주석
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-2.5 text-xs font-semibold transition-colors ${rightTab === 'geo' ? 'text-white border-b-2 border-blue-500' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
onClick={() => setRightTab('geo')}
|
||||
>
|
||||
지리정보
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-2.5 text-xs font-semibold transition-colors ${rightTab === 'station' ? 'text-white border-b-2 border-yellow-500' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
onClick={() => setRightTab('station')}
|
||||
>
|
||||
측점
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{rightTab === 'annotation' && (
|
||||
<AnnotationPanel
|
||||
annotations={annotations}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
onDelete={remove}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
)}
|
||||
{rightTab === 'geo' && (
|
||||
<GeoSearch
|
||||
currentFrame={currentFrame}
|
||||
fps={fps}
|
||||
onSeekToFrame={(frame) => handleSeek(frame / fps)}
|
||||
/>
|
||||
)}
|
||||
{rightTab === 'station' && (
|
||||
<StationVerify
|
||||
fps={fps}
|
||||
onSeekToFrame={(frame) => handleSeek(frame / (fps || 30))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add annotation modal */}
|
||||
{showModal && (
|
||||
<AddAnnotationModal
|
||||
currentTime={modalTime}
|
||||
onAdd={handleAnnotationCreate}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Help overlay */}
|
||||
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useAnnotationStore } from '../store/annotationStore';
|
||||
import type { Annotation, CreateAnnotationInput, UpdateAnnotationInput } from '@abcvideo/shared';
|
||||
|
||||
export function useAnnotations(videoId: string | null) {
|
||||
const { annotations, setAnnotations, addAnnotation, updateAnnotation, removeAnnotation } =
|
||||
useAnnotationStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoId) { setAnnotations([]); return; }
|
||||
fetch(`/api/annotations/${videoId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: Annotation[]) => setAnnotations(data))
|
||||
.catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoId]);
|
||||
|
||||
const create = useCallback(
|
||||
async (input: Omit<CreateAnnotationInput, 'videoId'>) => {
|
||||
if (!videoId) return;
|
||||
const res = await fetch(`/api/annotations/${videoId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...input, videoId }),
|
||||
});
|
||||
const annotation: Annotation = await res.json();
|
||||
addAnnotation(annotation);
|
||||
return annotation;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[videoId]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, input: UpdateAnnotationInput) => {
|
||||
if (!videoId) return;
|
||||
const res = await fetch(`/api/annotations/${videoId}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const annotation: Annotation = await res.json();
|
||||
updateAnnotation(id, annotation);
|
||||
return annotation;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[videoId]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
if (!videoId) return;
|
||||
await fetch(`/api/annotations/${videoId}/${id}`, { method: 'DELETE' });
|
||||
removeAnnotation(id);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[videoId]
|
||||
);
|
||||
|
||||
return { annotations, create, update, remove };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type Player from 'video.js/dist/types/player';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
export function useFrameStep(playerRef: React.RefObject<Player | null>) {
|
||||
const detectedFps = useRef(30);
|
||||
// 안정적인 fps: 재생 중 연속 2회 이상 동일 값이 나와야 확정
|
||||
const [stableFps, setStableFps] = useState(30);
|
||||
const lastMeasured = useRef(30);
|
||||
const stableCount = useRef(0);
|
||||
const store = usePlayerStore();
|
||||
|
||||
useEffect(() => {
|
||||
const video = playerRef.current?.tech(true)?.el() as HTMLVideoElement | null;
|
||||
if (!video || !('requestVideoFrameCallback' in video)) return;
|
||||
|
||||
let frameCount = 0;
|
||||
let startTime: number | null = null;
|
||||
let handle = 0;
|
||||
|
||||
const measure = (_now: number, meta: { mediaTime: number }) => {
|
||||
if (startTime === null) {
|
||||
// 첫 호출은 시작점만 기록하고 카운트하지 않음.
|
||||
// 카운트에 포함하면 elapsed ≈ 1.001s에 frameCount=31이 되어 31fps로 오감지됨.
|
||||
startTime = meta.mediaTime;
|
||||
handle = (video as any).requestVideoFrameCallback(measure);
|
||||
return;
|
||||
}
|
||||
frameCount++;
|
||||
const elapsed = meta.mediaTime - startTime;
|
||||
if (elapsed >= 1.0) {
|
||||
const fps = Math.round(frameCount / elapsed);
|
||||
if (fps >= 24 && fps <= 120) {
|
||||
// 같은 값이 연속 2회 이상 나와야 안정된 fps로 확정
|
||||
if (fps === lastMeasured.current) {
|
||||
stableCount.current++;
|
||||
if (stableCount.current >= 2) {
|
||||
detectedFps.current = fps;
|
||||
setStableFps(fps);
|
||||
store.setFps(fps);
|
||||
}
|
||||
} else {
|
||||
lastMeasured.current = fps;
|
||||
stableCount.current = 1;
|
||||
}
|
||||
}
|
||||
frameCount = 0;
|
||||
startTime = null;
|
||||
}
|
||||
handle = (video as any).requestVideoFrameCallback(measure);
|
||||
};
|
||||
|
||||
handle = (video as any).requestVideoFrameCallback(measure);
|
||||
return () => { (video as any).cancelVideoFrameCallback(handle); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playerRef.current]);
|
||||
|
||||
const stepForward = () => {
|
||||
const player = playerRef.current;
|
||||
if (!player || player.paused() === false) return;
|
||||
const step = 1 / detectedFps.current;
|
||||
player.currentTime((player.currentTime() ?? 0) + step);
|
||||
};
|
||||
|
||||
const stepBackward = () => {
|
||||
const player = playerRef.current;
|
||||
if (!player || player.paused() === false) return;
|
||||
const step = 1 / detectedFps.current;
|
||||
player.currentTime(Math.max(0, (player.currentTime() ?? 0) - step));
|
||||
};
|
||||
|
||||
return { stepForward, stepBackward, fps: stableFps };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import type Player from 'video.js/dist/types/player';
|
||||
|
||||
interface KeyboardOptions {
|
||||
playerRef: React.RefObject<Player | null>;
|
||||
onStepForward: () => void;
|
||||
onStepBackward: () => void;
|
||||
onCaptureFrame: () => void;
|
||||
onAddMemo: () => void;
|
||||
onToggleHelp?: () => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function useKeyboard({
|
||||
playerRef,
|
||||
onStepForward,
|
||||
onStepBackward,
|
||||
onCaptureFrame,
|
||||
onAddMemo,
|
||||
onToggleHelp,
|
||||
containerRef,
|
||||
}: KeyboardOptions) {
|
||||
const handleKey = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Don't fire when typing in an input
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
||||
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
const ct = player.currentTime() ?? 0;
|
||||
const dur = player.duration() ?? 0;
|
||||
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
e.preventDefault();
|
||||
player.paused() ? player.play() : player.pause();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
player.currentTime(Math.max(0, ct - 5));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
player.currentTime(Math.min(dur, ct + 5));
|
||||
break;
|
||||
case 'KeyJ':
|
||||
player.currentTime(Math.max(0, ct - 10));
|
||||
break;
|
||||
case 'KeyL':
|
||||
player.currentTime(Math.min(dur, ct + 10));
|
||||
break;
|
||||
case 'Comma':
|
||||
e.preventDefault();
|
||||
onStepBackward();
|
||||
break;
|
||||
case 'Period':
|
||||
e.preventDefault();
|
||||
onStepForward();
|
||||
break;
|
||||
case 'BracketLeft':
|
||||
player.currentTime(Math.max(0, ct - 30));
|
||||
break;
|
||||
case 'BracketRight':
|
||||
player.currentTime(Math.min(dur, ct + 30));
|
||||
break;
|
||||
case 'KeyF':
|
||||
if (containerRef.current) {
|
||||
if (!document.fullscreenElement) {
|
||||
containerRef.current.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'KeyM':
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onAddMemo();
|
||||
} else {
|
||||
player.muted(!player.muted());
|
||||
}
|
||||
break;
|
||||
case 'Equal':
|
||||
case 'NumpadAdd':
|
||||
player.playbackRate(Math.min(4, (player.playbackRate() ?? 1) + 0.25));
|
||||
break;
|
||||
case 'Minus':
|
||||
case 'NumpadSubtract':
|
||||
player.playbackRate(Math.max(0.25, (player.playbackRate() ?? 1) - 0.25));
|
||||
break;
|
||||
case 'KeyS':
|
||||
if (e.shiftKey) { e.preventDefault(); onCaptureFrame(); }
|
||||
break;
|
||||
case 'Slash':
|
||||
if (e.shiftKey) { e.preventDefault(); onToggleHelp?.(); }
|
||||
break;
|
||||
default:
|
||||
if (e.code >= 'Digit0' && e.code <= 'Digit9') {
|
||||
const pct = parseInt(e.code.replace('Digit', ''), 10) / 10;
|
||||
player.currentTime(dur * pct);
|
||||
}
|
||||
}
|
||||
},
|
||||
[playerRef, onStepForward, onStepBackward, onCaptureFrame, onAddMemo, onToggleHelp, containerRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [handleKey]);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import videojs from 'video.js';
|
||||
import type Player from 'video.js/dist/types/player';
|
||||
import Hls from 'hls.js';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
const HLS_CONFIG = {
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 600,
|
||||
maxBufferSize: 60 * 1024 * 1024,
|
||||
backBufferLength: 30,
|
||||
enableWorker: true,
|
||||
};
|
||||
|
||||
// PC 브라우저(Chrome/Firefox/Edge)가 HTML5 video로 직접 디코딩 못 하는 코덱.
|
||||
// 이런 영상은 원본 대신 (트랜스코딩된) HLS로 재생해야 한다.
|
||||
const UNSUPPORTED_CODECS = new Set(['hevc', 'h265', 'hvc1', 'hev1']);
|
||||
|
||||
export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
// 현재 로컬 재생 중인 blob URL. 소스 교체/해제 시점을 결정적으로 관리(이벤트 기반 해제는 교체 시
|
||||
// 새 URL까지 조기 revoke 되는 race 가 있어 ref 로 직접 추적한다).
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const store = usePlayerStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || playerRef.current) return;
|
||||
|
||||
const videoEl = document.createElement('video-js');
|
||||
// fill: 컨테이너를 꽉 채움(사이니지처럼 화면 가득). object-fit:cover 로 비율 유지 크롭.
|
||||
videoEl.classList.add('vjs-big-play-centered', 'vjs-fill');
|
||||
containerRef.current.appendChild(videoEl);
|
||||
|
||||
const player = videojs(videoEl, {
|
||||
// 하단 시간 스크러버는 측점 기반 StationBar 로 대체하므로 Video.js 기본 컨트롤바 숨김
|
||||
controls: false,
|
||||
fill: true,
|
||||
responsive: true,
|
||||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4],
|
||||
html5: { vhs: { overrideNative: true } },
|
||||
});
|
||||
|
||||
player.on('play', () => store.setPlaying(true));
|
||||
player.on('pause', () => store.setPlaying(false));
|
||||
// 영상 첫 프레임 표시 가능 시점 — 오버레이(라벨/선)를 영상보다 먼저 그리지 않도록 게이트.
|
||||
player.on('loadstart', () => store.setVideoReady(false));
|
||||
player.on('loadeddata', () => store.setVideoReady(true));
|
||||
player.on('playing', () => store.setVideoReady(true));
|
||||
// 영상 원본 해상도 — object-fit:cover 크롭 영역 계산용(오버레이 정렬). 메타 로드 시 확정.
|
||||
const reportSize = () => store.setVideoSize(player.videoWidth() ?? 0, player.videoHeight() ?? 0);
|
||||
player.on('loadedmetadata', reportSize);
|
||||
player.on('loadeddata', reportSize);
|
||||
player.on('timeupdate', () => store.setCurrentTime(player.currentTime() ?? 0));
|
||||
player.on('durationchange', () => store.setDuration(player.duration() ?? 0));
|
||||
player.on('volumechange', () => {
|
||||
store.setVolume(player.volume() ?? 1);
|
||||
store.setMuted(player.muted() ?? false);
|
||||
});
|
||||
player.on('ratechange', () => store.setPlaybackRate(player.playbackRate() ?? 1));
|
||||
|
||||
playerRef.current = player;
|
||||
|
||||
return () => {
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = null;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
if (playerRef.current && !playerRef.current.isDisposed()) {
|
||||
playerRef.current.dispose();
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadLocalFile = useCallback((file: File) => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
// Clean up previous hls
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = null;
|
||||
|
||||
// 재생 중 폴더 교체 지원: 새 src 설정 후 이전 blob URL 을 해제한다.
|
||||
// (player.src() 가 새 소스를 채택한 뒤이므로 prevUrl 은 더 이상 참조되지 않아 안전)
|
||||
const prevUrl = objectUrlRef.current;
|
||||
// 교체 직전 재생 중이었으면(=드롭 교체) 새 영상도 이어서 재생. 최초 로드는 prevUrl 없음 → 자동재생 안 함.
|
||||
const wasPlaying = prevUrl != null && !player.paused();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
objectUrlRef.current = objectUrl;
|
||||
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
|
||||
store.setSource({ kind: 'local', file, objectUrl });
|
||||
store.setHlsReady(false);
|
||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||
if (wasPlaying) {
|
||||
// 드롭은 사용자 제스처라 자동재생 정책에 막히지 않음. 준비되면 재생(실패는 무시).
|
||||
const p = player.play();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadServerStream = useCallback(async (videoId: string, filename: string) => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = null;
|
||||
// 로컬 → 서버 전환 시 이전 blob URL 해제.
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
|
||||
store.setSource({ kind: 'server', videoId, filename });
|
||||
store.setHlsReady(false);
|
||||
const playRaw = () => player.src({ src: `/api/stream/${videoId}`, type: 'video/mp4' });
|
||||
|
||||
// 코덱 확인 — 브라우저가 직접 못 푸는 코덱(HEVC 등)이면 HLS로 자동 재생
|
||||
let needsHls = false;
|
||||
try {
|
||||
const meta = await fetch(`/api/meta/${videoId}`).then((r) => r.json());
|
||||
needsHls = UNSUPPORTED_CODECS.has(String(meta?.codec ?? '').toLowerCase());
|
||||
} catch {
|
||||
// meta 조회 실패 시 일단 원본으로 시도
|
||||
}
|
||||
|
||||
// await 사이에 사용자가 다른 영상을 선택했으면 중단
|
||||
const stillCurrent = () => {
|
||||
const s = usePlayerStore.getState().source;
|
||||
return s?.kind === 'server' && s.videoId === videoId;
|
||||
};
|
||||
|
||||
if (needsHls) {
|
||||
if (!stillCurrent()) return;
|
||||
const hlsId = videoId.replace(/\.[^.]+$/, '');
|
||||
const ready = await fetch(`/api/hls/${hlsId}/index.m3u8`, { method: 'HEAD' })
|
||||
.then((r) => r.ok)
|
||||
.catch(() => false);
|
||||
if (!stillCurrent()) return;
|
||||
if (ready) {
|
||||
switchToHls(videoId, 0);
|
||||
return;
|
||||
}
|
||||
// HLS 미생성 상태: 원본을 시도(에러 표시)하고, 사용자가 'HLS 변환' 버튼으로 생성하도록 유도
|
||||
}
|
||||
|
||||
// 지원 코덱이거나 HLS가 아직 없으면 원본 즉시 재생 (Range Request)
|
||||
playRaw();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const switchToHls = useCallback((videoId: string, seekTo?: number) => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
const hlsId = videoId.replace(/\.[^.]+$/, '');
|
||||
const hlsUrl = `/api/hls/${hlsId}/index.m3u8`;
|
||||
const savedTime = seekTo ?? player.currentTime() ?? 0;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls(HLS_CONFIG);
|
||||
hls.loadSource(hlsUrl);
|
||||
const videoEl = player.tech(true)?.el() as HTMLVideoElement;
|
||||
hls.attachMedia(videoEl);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
player.currentTime(savedTime);
|
||||
hlsRef.current = hls;
|
||||
store.setHlsReady(true);
|
||||
});
|
||||
} else {
|
||||
player.src({ src: hlsUrl, type: 'application/x-mpegURL' });
|
||||
player.currentTime(savedTime);
|
||||
store.setHlsReady(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getVideoElement = useCallback((): HTMLVideoElement | null => {
|
||||
return (playerRef.current?.tech(true)?.el() as HTMLVideoElement | null) ?? null;
|
||||
}, []);
|
||||
|
||||
return { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #030712;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* 영상이 컨테이너를 비율 유지하며 꽉 채움(사이니지 fill, 넘침만 크롭) */
|
||||
.video-js.vjs-fill,
|
||||
.video-js .vjs-tech {
|
||||
object-fit: cover;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
/* abcVideo 임베드용 래퍼.
|
||||
videoplayer-main 은 1920×1080 풀스크린 스테이지를 window 폭으로 스케일했지만,
|
||||
여기서는 플레이어 컨테이너 폭(clientWidth)에 맞춰 균등 스케일한다.
|
||||
바 본체(.bottomBar)는 1080 레이어의 하단 100px 에 위치(top:980)하며,
|
||||
레이어를 컨테이너 하단에 앵커(bottom:0)해 바가 래퍼 하단에 정확히 붙는다.
|
||||
커서 배지는 바 위(영상 쪽)로 넘쳐야 하므로 overflow:visible. */
|
||||
.wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100px * var(--bar-scale));
|
||||
overflow: visible;
|
||||
font-family: var(--font-ui);
|
||||
user-select: none;
|
||||
background: linear-gradient(180deg, rgb(62, 53, 35) 0%, rgb(29, 24, 16) 100%);
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
/* transform: scale() 는 텍스트를 비트맵으로 축소해 흐려진다. zoom 은 축소된 크기로 '다시 렌더'
|
||||
→ 글자가 네이티브 해상도로 또렷. 내부 px 좌표/커서/seek 계산은 그대로 동작.
|
||||
(지원: Chrome·Edge·Safari·Firefox 126+) */
|
||||
zoom: var(--bar-scale);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* videoplayer VideoPlayer.module.scss .bottomBar 와 동일 좌표/스타일 */
|
||||
.bottomBar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 980px;
|
||||
width: 1920px;
|
||||
height: 100px;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(180deg, rgb(62, 53, 35) 0%, rgb(29, 24, 16) 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.15),
|
||||
0 -1px 4px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||||
import type { DroneFrameBasic } from '../utils/geoProjection';
|
||||
import { useGeoStore } from '../store/geoStore';
|
||||
import { useSettingsStore, isGradeVisible } from '../store/settingsStore';
|
||||
import {
|
||||
STAGE_WIDTH,
|
||||
TRACK_END_PX,
|
||||
TRACK_START_PX,
|
||||
TRACK_WIDTH_PX,
|
||||
renderX,
|
||||
trackXFromRender,
|
||||
} from './mocks/route';
|
||||
import { cssVars } from './utils/cssVars';
|
||||
import { formatMileage } from './utils/mileage';
|
||||
import { PlaybackControls } from './components/PlaybackControls/PlaybackControls';
|
||||
import { Timeline } from './components/Timeline/Timeline';
|
||||
import { TimelineCursor } from './components/TimelineCursor/TimelineCursor';
|
||||
import styles from './StationBar.module.scss';
|
||||
import './tokens.css';
|
||||
|
||||
/** 영상 실제 fps (RoutePanel·StationOverlay 와 동일 기준). */
|
||||
const VIDEO_FPS = 30000 / 1001;
|
||||
/** 프레임→현재측점 precompute 시 프레임 샘플 간격(영상 step). */
|
||||
const FRAME_STEP = 10;
|
||||
|
||||
interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지. */
|
||||
interface ViewedPoint {
|
||||
frameNum: number;
|
||||
km: number;
|
||||
chain: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
/** 측점 폴리라인(평면 투영). 연속 체이니지 계산용. */
|
||||
interface StationLine {
|
||||
pts: { x: number; y: number; km: number }[];
|
||||
k: number; // 경도→m 환산 (cos(lat0)*111000)
|
||||
}
|
||||
|
||||
/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m).
|
||||
* 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치. */
|
||||
function projectChainage(lat: number, lon: number, line: StationLine): number {
|
||||
const px = lon * line.k;
|
||||
const py = lat * 111000;
|
||||
const pts = line.pts;
|
||||
let bestD = Infinity;
|
||||
let bestKm = pts.length ? pts[0].km : -1;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[i + 1];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const L2 = dx * dx + dy * dy;
|
||||
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / L2));
|
||||
const cx = a.x + dx * t;
|
||||
const cy = a.y + dy * t;
|
||||
const d = (px - cx) ** 2 + (py - cy) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
bestKm = a.km + (b.km - a.km) * t;
|
||||
}
|
||||
}
|
||||
return bestKm;
|
||||
}
|
||||
|
||||
/** 데이터 기반 트랙 구간: km 증가(dir 1, 주황) / 감소(dir -1, 하늘색). px는 시간축. */
|
||||
export interface BarSegment {
|
||||
startPx: number;
|
||||
endPx: number;
|
||||
dir: 1 | -1;
|
||||
}
|
||||
/** 측점값 라벨 (방향 전환점·시종점). px는 시간축. */
|
||||
export interface KmLabel {
|
||||
px: number;
|
||||
text: string;
|
||||
}
|
||||
/** 구조물 마크 (교량/터널/역사). px는 통과 시점(시간축), km은 측점값(연속 체이니지, m). */
|
||||
export interface StructMark {
|
||||
px: number;
|
||||
title: string;
|
||||
category: string;
|
||||
km: number;
|
||||
/** 원본 속성(KMZ/CSV) — 마커 클릭 팝업 표시용. */
|
||||
props?: { k: string; v: string }[];
|
||||
/** 종점역인데 영상이 도착하지 못함 → 끝에 '미도착' 스타일(속 빈 링)로 표시. */
|
||||
unreached?: boolean;
|
||||
/** 표시 측점값이 그 위치에 실제로 존재하는가. false=좌표 반경만으로 잡힌 통과(값 라벨·검색 제외). */
|
||||
kmExists?: boolean;
|
||||
}
|
||||
|
||||
interface StationBarProps {
|
||||
currentTime: number;
|
||||
/** 라이브 재생시간 ref (매 프레임 갱신). 커서/진행바를 React 리렌더 없이 직접 이동. */
|
||||
timeRef?: RefObject<number>;
|
||||
duration: number;
|
||||
playing: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onStop: () => void;
|
||||
onCapture: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
showStations: boolean;
|
||||
onToggleStations: () => void;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number): number =>
|
||||
Math.min(hi, Math.max(lo, v));
|
||||
|
||||
function stationKm(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
||||
}
|
||||
|
||||
/** 드론 GPS 최근접 측점 km (FOV 안에 측점이 없을 때 폴백). */
|
||||
function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number {
|
||||
let best = -1;
|
||||
let bd = Infinity;
|
||||
for (const st of stations) {
|
||||
const d = (f.lat - st.lat) ** 2 + (f.lon - st.lon) ** 2;
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = stationKm(st.title);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** station 값(숫자=미터, "158k400" 문자열) → 미터. 실패 시 -1. */
|
||||
function mileageToMeters(v: number | string): number {
|
||||
if (typeof v === 'number') return v;
|
||||
const m = String(v).match(/(\d+)\s*[kK]\s*(\d+)/);
|
||||
if (m) return parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
|
||||
const n = parseInt(String(v).replace(/[^0-9]/g, ''), 10);
|
||||
return Number.isFinite(n) ? n : -1;
|
||||
}
|
||||
|
||||
/** 미터값 → "158k710" (10m 단위 반올림). 커서 배지용 십단위 표시. */
|
||||
function formatMileage10(m: number): string {
|
||||
const r = Math.round(m / 10) * 10;
|
||||
const km = Math.floor(r / 1000);
|
||||
const mm = r % 1000;
|
||||
return `${km}k${String(mm).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* abcVideo 실제 영상 데이터 기반 측점 바.
|
||||
* - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색).
|
||||
* - 측점값 라벨·구조물(교량/터널/역사) = 재생 파일의 측점/POI 데이터로 배치.
|
||||
* - 커서 = 프레임 진행, 배지 = 드론 GPS 최근접 측점.
|
||||
* - 클릭/드래그/측점입력 seek = 시간(프레임) 기준 이동.
|
||||
*/
|
||||
export function StationBar({
|
||||
currentTime,
|
||||
timeRef,
|
||||
duration,
|
||||
playing,
|
||||
onTogglePlay,
|
||||
onStop,
|
||||
onCapture,
|
||||
onSeek,
|
||||
showStations,
|
||||
onToggleStations,
|
||||
}: StationBarProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(0.5);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
// 측점/POI/드론프레임은 폴더 선택으로 채워지는 geoStore에서 읽는다 (서버 /api/geo fetch 대체).
|
||||
const storePois = useGeoStore((s) => s.pois);
|
||||
const storeStations = useGeoStore((s) => s.stations);
|
||||
const pois = useMemo<GeoPoint[]>(
|
||||
() => [...storeStations, ...storePois],
|
||||
[storeStations, storePois],
|
||||
);
|
||||
const storeFrames = useGeoStore((s) => s.frames);
|
||||
const routeMeta = useGeoStore((s) => s.routeMeta);
|
||||
const directionChanges = useGeoStore((s) => s.directionChanges);
|
||||
// v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용.
|
||||
const storeStructuresRaw = useGeoStore((s) => s.structures);
|
||||
// 시설종별 필터(설정 스토어) — 체크 변경 시 즉시 반영(구독).
|
||||
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
|
||||
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
|
||||
const storeStructures = useMemo(
|
||||
() => storeStructuresRaw.filter((s) => isGradeVisible(s.grade, gradeFilter)),
|
||||
[storeStructuresRaw, gradeFilter],
|
||||
);
|
||||
const viewedRef = useRef<ViewedPoint[]>([]);
|
||||
// 이동거리축: time[i] ↔ frac[i](누적 이동거리 비율 0~1, 단조 비감소).
|
||||
// 드론의 '실제 이동거리'를 옆으로 펴서 배치 → 같은 측점이라도 이동량만큼 떨어져 보인다.
|
||||
// 호버(드론 이동 無) 구간은 frac 정체 → 커서 정지. 방향(전진/후진)은 barSegments 색 리본으로 구분.
|
||||
// 이동거리(누적) 축: time[i]↔frac[i](누적 이동거리 비율 0~1, 단조 비감소) → 커서 항상 우측 이동.
|
||||
// depChain=출발측점, arrChain=도착측점 → 방향색 기준(도착방향=주황/반대=파랑).
|
||||
const chainRef = useRef<{ time: Float64Array; frac: Float64Array; depChain: number; arrChain: number } | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const stations = useMemo(
|
||||
() => pois.filter((p) => p.type === 'station' && stationKm(p.title) >= 0),
|
||||
[pois],
|
||||
);
|
||||
|
||||
// 측점 폴리라인(평면 투영) — 연속 체이니지 계산용.
|
||||
const stationLine = useMemo<StationLine | null>(() => {
|
||||
if (!stations.length) return null;
|
||||
const sorted = [...stations].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;
|
||||
return {
|
||||
pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: stationKm(p.title) })),
|
||||
k,
|
||||
};
|
||||
}, [stations]);
|
||||
|
||||
// 프레임별 precompute (frames·stations 준비되면 1회).
|
||||
// - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일).
|
||||
// - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
|
||||
// FPS는 고정값(29.97)이 아니라 실데이터(마지막 프레임 번호 / 재생시간)에서 유도 → 영상별 자동 적응.
|
||||
// 데이터 미비 시에만 VIDEO_FPS(29.97) 폴백. (frame/fps = 절대시간)
|
||||
const videoFps = useMemo<number>(() => {
|
||||
const last = storeFrames.length ? storeFrames[storeFrames.length - 1].frame : 0;
|
||||
return last > 0 && duration > 0 ? last / duration : VIDEO_FPS;
|
||||
}, [storeFrames, duration]);
|
||||
|
||||
useEffect(() => {
|
||||
const frames = storeFrames;
|
||||
if (!frames.length || !stations.length || !stationLine) { setReady(false); return; }
|
||||
const n = frames.length;
|
||||
const out: ViewedPoint[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const f = frames[i];
|
||||
out[i] = {
|
||||
frameNum: f.frame,
|
||||
km: nearestStationKm(f, stations),
|
||||
chain: projectChainage(f.lat, f.lon, stationLine),
|
||||
time: f.frame / videoFps,
|
||||
};
|
||||
}
|
||||
// 이동거리 축: 평활 측점값(±W 이동평균)의 프레임간 |Δ| 누적 = 실제 이동량 → 전체로 정규화(frac).
|
||||
// 호버(이동 無)면 frac 정체 → 커서 정지. 이동하면(전진/후진 무관) frac 증가 → 커서 항상 우측.
|
||||
// depChain/arrChain(앞·뒤 5% 평균)은 방향색 기준.
|
||||
const W = 8; // 평활 반폭(프레임)
|
||||
const pre = new Float64Array(n + 1);
|
||||
for (let i = 0; i < n; i++) pre[i + 1] = pre[i] + out[i].chain;
|
||||
const time = new Float64Array(n);
|
||||
const sm = new Float64Array(n);
|
||||
const frac = new Float64Array(n);
|
||||
let cum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
|
||||
sm[i] = (pre[hi] - pre[lo]) / (hi - lo);
|
||||
if (i > 0) cum += Math.abs(sm[i] - sm[i - 1]);
|
||||
time[i] = out[i].time;
|
||||
frac[i] = cum;
|
||||
}
|
||||
const total = cum > 0 ? cum : 1;
|
||||
for (let i = 0; i < n; i++) frac[i] /= total;
|
||||
const seg = Math.max(1, Math.floor(n * 0.05));
|
||||
let dep = 0, arr = 0;
|
||||
for (let i = 0; i < seg; i++) { dep += sm[i]; arr += sm[n - 1 - i]; }
|
||||
dep /= seg; arr /= seg;
|
||||
if (Math.abs(arr - dep) < 1) arr = dep + 1; // 퇴화 방지
|
||||
chainRef.current = { time, frac, depChain: dep, arrChain: arr };
|
||||
viewedRef.current = out;
|
||||
setReady(true);
|
||||
}, [stations, stationLine, storeFrames, videoFps]);
|
||||
|
||||
/** 현재 시간에 가장 가까운 precompute 인덱스. */
|
||||
const viewedIdxAtTime = useCallback((t: number): number => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length) return -1;
|
||||
const target = t * videoFps;
|
||||
let best = 0;
|
||||
let bd = Math.abs(arr[0].frameNum - target);
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
const d = Math.abs(arr[i].frameNum - target);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}, [videoFps]);
|
||||
|
||||
// 현재 보는 측점값 — 연속 체이니지(chain)로 10m 해상도 (km 필드는 100m 양자화).
|
||||
const realChain = useMemo<number | null>(() => {
|
||||
if (!ready) return null;
|
||||
const idx = viewedIdxAtTime(currentTime);
|
||||
return idx >= 0 ? viewedRef.current[idx].chain : null;
|
||||
}, [currentTime, ready, viewedIdxAtTime]);
|
||||
|
||||
// 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다.
|
||||
// 영상 타임라인은 [START … END−endGapPx] 에만 매핑되고, 종점역 마커는 END(미도착)에 둔다.
|
||||
// 폭 = 미도착거리(m) / 노선길이(m) × 트랙폭 (옵션 2: 실제 비례). 안전상 15%로 상한.
|
||||
const endGapPx = useMemo<number>(() => {
|
||||
const gapM = routeMeta?.routeInfo?.endStationGapMeters ?? 0;
|
||||
const lenM = (routeMeta?.routeInfo?.lengthKm ?? 0) * 1000;
|
||||
if (gapM <= 0 || lenM <= 0) return 0;
|
||||
return Math.min(TRACK_WIDTH_PX * 0.15, (gapM / lenM) * TRACK_WIDTH_PX);
|
||||
}, [routeMeta]);
|
||||
// 시간→px 변환에 쓰는 유효 트랙폭 (미도착폭 제외).
|
||||
const timeTrackWidth = TRACK_WIDTH_PX - endGapPx;
|
||||
|
||||
// 노선 진행방향 '상'|'하' — 동명 시설물의 (상)/(하) 변형 중 어느 쪽을 바에 표출할지 결정.
|
||||
// 1순위 route.json direction, 없으면 directionChanges(상행↔하행 전환점)에서 가장 오래 지속된 방향.
|
||||
const routeDirCh = useMemo<'상' | '하' | null>(() => {
|
||||
const d = routeMeta?.routeInfo?.direction ?? '';
|
||||
const up = d.includes('상'), down = d.includes('하');
|
||||
if (up && !down) return '상';
|
||||
if (down && !up) return '하';
|
||||
const dc = directionChanges;
|
||||
if (dc && dc.length && duration > 0) {
|
||||
const acc: Record<string, number> = {};
|
||||
let prevT = 0;
|
||||
let cur = dc[0].from;
|
||||
for (const c of dc) {
|
||||
acc[cur] = (acc[cur] ?? 0) + Math.max(0, c.atSeconds - prevT);
|
||||
prevT = c.atSeconds;
|
||||
cur = c.to;
|
||||
}
|
||||
acc[cur] = (acc[cur] ?? 0) + Math.max(0, duration - prevT);
|
||||
let best = '', bv = -1;
|
||||
for (const k in acc) if (acc[k] > bv) { bv = acc[k]; best = k; }
|
||||
if (best.includes('상') && !best.includes('하')) return '상';
|
||||
if (best.includes('하') && !best.includes('상')) return '하';
|
||||
}
|
||||
return null;
|
||||
}, [routeMeta, directionChanges, duration]);
|
||||
|
||||
// 진행도(이동거리축): 시간 t → 누적 이동거리 비율(frac, 0~1). chainRef 미준비 시 시간선형 폴백.
|
||||
const cumFracAtTime = useCallback(
|
||||
(t: number): number => {
|
||||
const c = chainRef.current;
|
||||
if (!c || !ready || duration <= 0)
|
||||
return duration > 0 ? clamp(t / duration, 0, 1) : 0;
|
||||
const { time, frac } = c;
|
||||
const n = time.length;
|
||||
if (n === 0) return 0;
|
||||
if (t <= time[0]) return frac[0];
|
||||
if (t >= time[n - 1]) return frac[n - 1];
|
||||
let lo = 0, hi = n - 1;
|
||||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (time[m] <= t) lo = m; else hi = m; }
|
||||
const span = time[hi] - time[lo];
|
||||
const r = span > 0 ? (t - time[lo]) / span : 0;
|
||||
return frac[lo] + (frac[hi] - frac[lo]) * r;
|
||||
},
|
||||
[ready, duration],
|
||||
);
|
||||
// 역변환(이동거리 비율 → 시간): 바 클릭 seek 용. frac 단조 비감소 → 이진 탐색.
|
||||
const timeAtFrac = useCallback(
|
||||
(f: number): number => {
|
||||
const c = chainRef.current;
|
||||
if (!c || !ready || duration <= 0) return clamp(f, 0, 1) * duration;
|
||||
const { time, frac } = c;
|
||||
const n = frac.length;
|
||||
if (n === 0) return 0;
|
||||
const ff = clamp(f, 0, 1);
|
||||
if (ff <= frac[0]) return time[0];
|
||||
if (ff >= frac[n - 1]) return time[n - 1];
|
||||
let lo = 0, hi = n - 1;
|
||||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (frac[m] <= ff) lo = m; else hi = m; }
|
||||
const span = frac[hi] - frac[lo];
|
||||
const r = span > 0 ? (ff - frac[lo]) / span : 0;
|
||||
return time[lo] + (time[hi] - time[lo]) * r;
|
||||
},
|
||||
[ready, duration],
|
||||
);
|
||||
|
||||
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
|
||||
// 시간(프레임) → 트랙 px (이동거리축). 커서·구간색·구조물 마커가 이 매핑을 공유한다.
|
||||
const pxAtTime = useCallback(
|
||||
(t: number): number =>
|
||||
TRACK_START_PX + clamp(cumFracAtTime(t), 0, 1) * timeTrackWidth,
|
||||
[cumFracAtTime, timeTrackWidth],
|
||||
);
|
||||
|
||||
// 커서 위치 = 이동거리축 진행도 → 전진/후진 무관하게 항상 우측으로 이동. 색만 방향에 따라 바뀜.
|
||||
const progressPx = pxAtTime(currentTime);
|
||||
const cursorPx = progressPx;
|
||||
|
||||
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열.
|
||||
const cursorText =
|
||||
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
|
||||
|
||||
// viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
|
||||
// km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
|
||||
const { barSegments, kmLabels } = useMemo<{
|
||||
barSegments: BarSegment[];
|
||||
kmLabels: KmLabel[];
|
||||
}>(() => {
|
||||
const arr = viewedRef.current;
|
||||
const n = arr.length;
|
||||
if (!ready || !n || duration <= 0)
|
||||
return { barSegments: [], kmLabels: [] };
|
||||
// 측점값(chain)을 ±W 이동평균으로 평활 → GPS 지터 제거. 평활했으므로 HYST를 작게 잡아
|
||||
// '목적지에 가까워짐/멀어짐'의 작은 추세 전환(20m+)도 구간으로 잡는다. (커서 배지 = 그 구간 색)
|
||||
const W = 8;
|
||||
const pre = new Float64Array(n + 1);
|
||||
for (let i = 0; i < n; i++) pre[i + 1] = pre[i] + arr[i].chain;
|
||||
const sm = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
|
||||
sm[i] = (pre[hi] - pre[lo]) / (hi - lo);
|
||||
}
|
||||
const HYST = 10; // m — 평활 후 기준. 이 이상 반대로 움직이면 방향(가까워짐↔멀어짐) 전환.
|
||||
const segs: BarSegment[] = [];
|
||||
const bounds: { chain: number; time: number; turn: boolean }[] = [
|
||||
{ chain: sm[0], time: arr[0].time, turn: false },
|
||||
];
|
||||
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정.
|
||||
let dir: 1 | -1 = 1;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const d = sm[i] - sm[0];
|
||||
if (Math.abs(d) >= HYST) { dir = d > 0 ? 1 : -1; break; }
|
||||
}
|
||||
let extCh = sm[0];
|
||||
let extIdx = 0;
|
||||
let startIdx = 0;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const c = sm[i];
|
||||
if (dir > 0 ? c > extCh : c < extCh) {
|
||||
extCh = c;
|
||||
extIdx = i;
|
||||
} else if (dir > 0 ? extCh - c >= HYST : c - extCh >= HYST) {
|
||||
segs.push({
|
||||
startPx: pxAtTime(arr[startIdx].time),
|
||||
endPx: pxAtTime(arr[extIdx].time),
|
||||
dir,
|
||||
});
|
||||
bounds.push({ chain: extCh, time: arr[extIdx].time, turn: true });
|
||||
dir = dir > 0 ? -1 : 1;
|
||||
startIdx = extIdx;
|
||||
extCh = c;
|
||||
extIdx = i;
|
||||
}
|
||||
}
|
||||
segs.push({
|
||||
startPx: pxAtTime(arr[startIdx].time),
|
||||
endPx: pxAtTime(arr[n - 1].time),
|
||||
dir,
|
||||
});
|
||||
bounds.push({ chain: sm[n - 1], time: arr[n - 1].time, turn: false });
|
||||
// 전환(턴) 지점은 실제 위치를 10m 단위로, 시·종점 등 기본 라벨은 100m 단위로 표시.
|
||||
const labels: KmLabel[] = bounds.map((b) => ({
|
||||
px: pxAtTime(b.time),
|
||||
text: b.turn ? formatMileage10(b.chain) : formatMileage(Math.round(b.chain / 100) * 100),
|
||||
}));
|
||||
return { barSegments: segs, kmLabels: labels };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, duration, storeFrames, pxAtTime]);
|
||||
|
||||
// viewedRef의 km(최근접 측점)가 주어진 mileage(m)에 가장 가까운 프레임 시간 → px.
|
||||
const pxAtMileage = useCallback(
|
||||
(mileage: number): number | null => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length) return null;
|
||||
let best = -1;
|
||||
let bd = Infinity;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const d = Math.abs(arr[i].km - mileage);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best >= 0 ? pxAtTime(arr[best].time) : null;
|
||||
},
|
||||
[pxAtTime],
|
||||
);
|
||||
|
||||
// 구조물(교량/터널/역사) 위치 = 실제 좌표(POI 위경도)에 드론 GPS 가 가장 가까워지는
|
||||
// 프레임의 시점 px. → 영상에 구조물이 실제 지나가는 순간과 일치.
|
||||
// route.json structures 의 이정값은 매칭되는 POI 가 없을 때만 폴백으로 사용.
|
||||
const structureMarks = useMemo<StructMark[]>(() => {
|
||||
const arr = viewedRef.current;
|
||||
const frames = storeFrames;
|
||||
if (!ready || !arr.length || duration <= 0 || !frames.length) return [];
|
||||
|
||||
// 실좌표(lat/lon)에 드론이 가까워지는 '모든 통과 구간'의 px 목록.
|
||||
// (드론이 같은 구조물을 2번 이상 지날 때 각 통과마다 마커를 찍기 위함)
|
||||
const pxPassesTo = (lat: number, lon: number, offsetM = 0): { px: number; km: number }[] => {
|
||||
const cosLat = Math.cos((lat * Math.PI) / 180);
|
||||
const ds: number[] = new Array(frames.length);
|
||||
let gmin = Infinity;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const dy = (frames[i].lat - lat) * 111000;
|
||||
const dx = (frames[i].lon - lon) * 111000 * cosLat;
|
||||
const d = Math.hypot(dx, dy); // m
|
||||
ds[i] = d;
|
||||
if (d < gmin) gmin = d;
|
||||
}
|
||||
if (!isFinite(gmin)) return [];
|
||||
const TH = Math.max(100, gmin * 3); // 통과 인정 거리(m)
|
||||
// 통과 프레임에서 진행방향으로 offsetM 만큼 이동(GPS 누적거리 기준).
|
||||
const shift = (idx: number): number => {
|
||||
if (!offsetM) return idx;
|
||||
const step = offsetM >= 0 ? 1 : -1;
|
||||
const target = Math.abs(offsetM);
|
||||
let fi = idx, acc = 0;
|
||||
while (acc < target) {
|
||||
const n = fi + step;
|
||||
if (n < 0 || n >= frames.length) break;
|
||||
const dy = (frames[n].lat - frames[fi].lat) * 111000;
|
||||
const dx = (frames[n].lon - frames[fi].lon) * 111000 * cosLat;
|
||||
acc += Math.hypot(dx, dy);
|
||||
fi = n;
|
||||
}
|
||||
return fi;
|
||||
};
|
||||
const out: { px: number; km: number }[] = [];
|
||||
let i = 0;
|
||||
while (i < frames.length) {
|
||||
if (ds[i] < TH) {
|
||||
let j = i, bj = i, bd = ds[i];
|
||||
while (j < frames.length && ds[j] < TH) {
|
||||
if (ds[j] < bd) { bd = ds[j]; bj = j; }
|
||||
j++;
|
||||
}
|
||||
const fi = shift(bj);
|
||||
const px = pxAtTime(frames[fi].frame / videoFps);
|
||||
if (px !== null) out.push({ px, km: viewedRef.current[fi]?.chain ?? -1 });
|
||||
i = j;
|
||||
} else i++;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// 측점값(미터)에 해당하는 모든 통과 지점 px (연속 체이니지 기준).
|
||||
const pxPassesAtMileage = (targetM: number): { px: number; km: number }[] => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length) return [];
|
||||
// 측점 인정 범위(m): route.json routeInfo.stationTolerance (폴더별), 기본 20.
|
||||
// 너무 크면 인접 통과가 합쳐져 마커가 밀린다 → 멀리 떨어진 통과만 안 잡힐 때 폴더에서 키울 것.
|
||||
const TH = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
const out: { px: number; km: number }[] = [];
|
||||
let i = 0;
|
||||
while (i < arr.length) {
|
||||
if (Math.abs(arr[i].chain - targetM) < TH) {
|
||||
let j = i, bj = i, bd = Math.abs(arr[i].chain - targetM);
|
||||
while (j < arr.length && Math.abs(arr[j].chain - targetM) < TH) {
|
||||
const d = Math.abs(arr[j].chain - targetM);
|
||||
if (d < bd) { bd = d; bj = j; }
|
||||
j++;
|
||||
}
|
||||
const px = pxAtTime(arr[bj].time);
|
||||
if (px !== null) out.push({ px, km: targetM });
|
||||
i = j;
|
||||
} else i++;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// 구조물 후보 POI(교량/터널/역사) base 이름 → 실좌표.
|
||||
const cats = new Set(['교량', '터널', '역사']);
|
||||
const poiByName = new Map<string, { lat: number; lon: number; category: string }>();
|
||||
for (const p of pois) {
|
||||
if (!cats.has(p.category)) continue;
|
||||
const base = p.title.replace(/\s*[((].*$/, '').trim();
|
||||
if (!poiByName.has(base)) poiByName.set(base, { lat: p.lat, lon: p.lon, category: p.category });
|
||||
}
|
||||
|
||||
// 실제 통과 마커는 '이동/삭제 없이' 직교 통과 위치에 그대로 둔다(드론이 종점 야드에서 162080을
|
||||
// 여러 번 지나면 각 통과 자리에 마커 유지 → 검색과 일치).
|
||||
// 종점(최우측 역사)이 '미도착'(endGapPx>0)이면, 기존 마커를 옮기지 않고 트랙 끝(TRACK_END,
|
||||
// 실제 종점 측점 위치)에 '미도착' 종점 마커를 하나 '추가'한다. → 드론이 못 간 종점 162080도
|
||||
// 끝에 표시되고, 지나간 162080 통과들도 실제 위치에 그대로 남는다.
|
||||
const placeUnreachedTerminal = (marks: StructMark[]): StructMark[] => {
|
||||
if (endGapPx <= 0) return marks;
|
||||
let hiPx = -Infinity;
|
||||
let term: StructMark | null = null;
|
||||
for (const m of marks) {
|
||||
if ((m.category === '역사' || m.category === '역') && m.px > hiPx) { hiPx = m.px; term = m; }
|
||||
}
|
||||
if (term) marks.push({ ...term, px: TRACK_END_PX, unreached: true });
|
||||
return marks;
|
||||
};
|
||||
|
||||
// 경로상 시설물만: 시설물명에 방향표기((상…/(하…)가 있으면 영상 진행방향과 일치하는 것만.
|
||||
// 하행이면 '(상…' 제외, 상행이면 '(하…' 제외. (예: 회덕천교(상)/(상인상)은 하행 영상에서 숨김)
|
||||
// 진행방향(routeDirCh)의 반대 변형은 바에서 제외 → 상행이면 (상), 하행이면 (하) 만 남는다.
|
||||
const oppDirCh = routeDirCh === '하' ? '상' : routeDirCh === '상' ? '하' : '';
|
||||
const isOppositeDir = (name: string): boolean =>
|
||||
!!oppDirCh && new RegExp(`[((]${oppDirCh}`).test(name);
|
||||
|
||||
// v2.0: CSV 유래 구조물(storeStructures) 우선. route.json 보정은 geoData 에서 이미 병합됨.
|
||||
if (storeStructures && storeStructures.length) {
|
||||
const out: StructMark[] = [];
|
||||
for (const s of storeStructures) {
|
||||
// 구교(06)는 영상 오버레이에 표출 → 스테이션바에서는 제외(교량/터널만).
|
||||
if (s.category === '구교') continue;
|
||||
if (isOppositeDir(s.name)) continue; // 반대 방향(다른 선로) 시설물 제외
|
||||
const cat = s.type === 'tunnel' ? '터널' : s.type === 'bridge' ? '교량' : '역사';
|
||||
const sBase = s.name.replace(/\s*[((].*$/, '').trim();
|
||||
// 이름 매칭 POI 실좌표 우선 → 없으면 route.json 이정값 폴백.
|
||||
const match =
|
||||
poiByName.get(sBase) ??
|
||||
[...poiByName.entries()].find(([k]) => k.includes(sBase) || sBase.includes(k))?.[1];
|
||||
// 우선순위: station(측점값) → 좌표를 측점선에 투영한 '측점 기준' 탐지 → 좌표 근접(최후 폴백) → 이정값.
|
||||
// 각 통과 지점마다 마커(드론이 2번 지나면 2개). 동명 시설물은 각자 station 으로 구분.
|
||||
// ※ 좌표 근접(pxPassesTo)은 출발점 부근 등에서 엉뚱한 '조기 통과'를 잡아 마커가 앞쪽(좌측)으로
|
||||
// 잘못 배치될 수 있다 → 좌표를 측점값으로 환산해 station-기준으로 탐지하면 실제 통과순서와 일치.
|
||||
let passes: { px: number; km: number }[] = [];
|
||||
const off = s.offset ?? 0;
|
||||
if (s.station != null) {
|
||||
const sM = mileageToMeters(s.station);
|
||||
if (sM >= 0) passes = pxPassesAtMileage(sM);
|
||||
}
|
||||
if (!passes.length && s.lat != null && s.lon != null && stationLine)
|
||||
passes = pxPassesAtMileage(projectChainage(s.lat, s.lon, stationLine));
|
||||
if (!passes.length && match && stationLine)
|
||||
passes = pxPassesAtMileage(projectChainage(match.lat, match.lon, stationLine));
|
||||
// 역사(역)은 좌표근접(pxPassesTo) 폴백을 쓰지 않는다 → '드론 실제 측점(chain)이 역 측점과
|
||||
// 일치(pxPassesAtMileage)'하는 통과에서만 마커를 찍는다. 그래야:
|
||||
// ① 좌표만 가까운(측점 다른) 조차장 재진입(드론 162k210인데 대전조차장 162k080)은 안 찍히고,
|
||||
// ② 진짜 162k080 통과에는 정상적으로 찍힌다. (교량/터널만 좌표근접 폴백 허용.)
|
||||
if (!passes.length && cat !== '역사' && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
|
||||
if (!passes.length && cat !== '역사' && match) passes = pxPassesTo(match.lat, match.lon, off);
|
||||
if (!passes.length && s.startMileage != null && s.endMileage != null) {
|
||||
const mid = (s.startMileage + s.endMileage) / 2;
|
||||
const px = pxAtMileage(mid);
|
||||
if (px !== null) passes = [{ px, km: mid }];
|
||||
}
|
||||
// 역사 km 라벨 = '역 좌표 자체'의 측점값(드론 최근접 프레임 chain 아님).
|
||||
// 역은 바 양끝에 스냅되므로, 시작/끝 커서값(= 역 위치 측점)과 일치시키기 위함.
|
||||
const stationKmVal = cat === '역사' && s.lat != null && s.lon != null && stationLine
|
||||
? projectChainage(s.lat, s.lon, stationLine) : null;
|
||||
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
for (const p of passes) {
|
||||
// 이 통과들은 pxPassesAtMileage(측점 일치) 또는 이정값 기반이라 이미 '실제 측점 일치' 통과.
|
||||
// (역사는 좌표근접 폴백을 위에서 배제.) kmExists 는 라벨/검색 참고용으로만 유지.
|
||||
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
|
||||
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
|
||||
}
|
||||
}
|
||||
return placeUnreachedTerminal(out);
|
||||
}
|
||||
|
||||
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
|
||||
const out: StructMark[] = [];
|
||||
for (const [base, info] of poiByName) {
|
||||
if (isOppositeDir(base)) continue; // 반대 방향(다른 선로) 시설물 제외
|
||||
const stationKmVal = info.category === '역사' && stationLine
|
||||
? projectChainage(info.lat, info.lon, stationLine) : null;
|
||||
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
// 측점 기준 탐지 우선(통과순서 정확), 측점선 없으면 좌표 근접 폴백.
|
||||
const ps = stationLine
|
||||
? pxPassesAtMileage(projectChainage(info.lat, info.lon, stationLine))
|
||||
: pxPassesTo(info.lat, info.lon);
|
||||
for (const p of ps) {
|
||||
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
|
||||
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists });
|
||||
}
|
||||
}
|
||||
return placeUnreachedTerminal(out);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
|
||||
|
||||
// 여정 정방향(목적지=도착 방향): 측점값이 출발(depChain)→도착(arrChain) 으로 변하는 방향.
|
||||
// 그 방향으로 움직이는 구간=정방향(황색), 반대=역방향(청색).
|
||||
const forwardDir = useMemo<1 | -1>(() => {
|
||||
const c = chainRef.current;
|
||||
if (!ready || !c) return 1;
|
||||
return c.arrChain >= c.depChain ? 1 : -1;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, storeFrames]);
|
||||
|
||||
// 방향 색 트랙 gradient — 이동거리축은 구간 px가 좌→우 단조라 gradient 1장으로 충분(경량).
|
||||
// 정방향(도착방향)=FWD, 역방향=BWD. 구간 경계는 하드 스톱.
|
||||
const buildGradient = useCallback(
|
||||
(FWD: string, BWD: string): string => {
|
||||
const segs = barSegments;
|
||||
if (!segs.length) return '';
|
||||
const col = (d: 1 | -1) => (d === forwardDir ? FWD : BWD);
|
||||
const pct = (px: number) => clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||
const stops: string[] = [`${col(segs[0].dir)} 0%`];
|
||||
for (let i = 1; i < segs.length; i++) {
|
||||
const bp = pct(segs[i].startPx).toFixed(2);
|
||||
stops.push(`${col(segs[i - 1].dir)} ${bp}%`);
|
||||
stops.push(`${col(segs[i].dir)} ${bp}%`);
|
||||
}
|
||||
stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
},
|
||||
[barSegments, forwardDir],
|
||||
);
|
||||
// 통과 음영(구간마다 좌→우 3색). 정방향 주황 / 역방향 청록.
|
||||
const buildShaded = useCallback(
|
||||
(fwd: [string, string, string], bwd: [string, string, string]): string => {
|
||||
const segs = barSegments;
|
||||
if (!segs.length) return '';
|
||||
const cols = (d: 1 | -1) => (d === forwardDir ? fwd : bwd);
|
||||
const pct = (px: number) => clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||
const stops: string[] = [];
|
||||
for (const s of segs) {
|
||||
const a = pct(s.startPx), b = pct(s.endPx), c = cols(s.dir);
|
||||
stops.push(`${c[0]} ${a.toFixed(2)}%`);
|
||||
stops.push(`${c[1]} ${((a + b) / 2).toFixed(2)}%`);
|
||||
stops.push(`${c[2]} ${b.toFixed(2)}%`);
|
||||
}
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
},
|
||||
[barSegments, forwardDir],
|
||||
);
|
||||
// 통과(지나간): 음영. 미통과: 단색(정방향 회색 / 역방향 청회색).
|
||||
const trackGradient = useMemo(
|
||||
() => buildShaded(['#ffc257', '#ff8a25', '#ff7b1b'], ['#5ca887', '#35a7a7', '#06a4c8']),
|
||||
[buildShaded],
|
||||
);
|
||||
const trackGradientIdle = useMemo(() => buildGradient('#7a7a7a', '#637789'), [buildGradient]);
|
||||
// 방향(색)이 바뀌는 전환점 px — 구분선 위치.
|
||||
const dividers = useMemo(() => barSegments.slice(1).map((s) => s.startPx), [barSegments]);
|
||||
|
||||
// 커서 배지 색 = 현재 위치한 '구간' 색과 동일(바 배경과 일치). 역방향(목적지 반대) 구간이면 파란색.
|
||||
// → 배지·바 배경이 항상 같은 색(기존 방식). 구간 색은 forwardDir 기준 정/역방향.
|
||||
const currentReverse = useMemo<boolean>(() => {
|
||||
for (const s of barSegments) {
|
||||
if (cursorPx >= s.startPx && cursorPx <= s.endPx) return s.dir !== forwardDir;
|
||||
}
|
||||
return false;
|
||||
}, [barSegments, cursorPx, forwardDir]);
|
||||
|
||||
// 컨테이너 폭 기준 균등 스케일
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const update = (): void =>
|
||||
setScale((el.clientWidth || STAGE_WIDTH) / STAGE_WIDTH);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// 구조물 아이콘(3-slice PNG) 상태별 이미지를 미리 로드해, passed 전환 시
|
||||
// 새 이미지 로딩으로 인한 깜빡임(잠시 사라짐)을 방지한다.
|
||||
useEffect(() => {
|
||||
const base = import.meta.env.BASE_URL;
|
||||
for (const type of ['bridge', 'tunnel']) {
|
||||
for (const st of ['upcoming', 'passed', 'revisit']) {
|
||||
for (const slice of ['left', 'center', 'right']) {
|
||||
const img = new Image();
|
||||
img.src = `${base}assets/route-segment/${type}/${type}-${st}-${slice}.png`;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const st of ['upcoming', 'passed', 'revisit']) {
|
||||
const img = new Image();
|
||||
img.src = `${base}assets/route-segment/terminal/circle-${st}.png`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 커서/진행바 매끄러운 이동: 라이브 시간(timeRef)을 매 프레임 읽어 CSS 변수만 직접 갱신.
|
||||
// React 리렌더(배지/색)는 currentTime(throttle)으로 별도 처리 → 4K 디코딩 중에도 부드러움.
|
||||
useEffect(() => {
|
||||
if (!timeRef) return;
|
||||
let raf = 0;
|
||||
const tick = (): void => {
|
||||
const el = wrapRef.current;
|
||||
if (el && duration > 0) {
|
||||
const t = timeRef.current ?? 0;
|
||||
const pos = pxAtTime(t); // 이동거리축: 현재 진행도 → px
|
||||
el.style.setProperty('--pos-px', `${pos}px`);
|
||||
el.style.setProperty('--cursor-x', `${renderX(pos)}px`);
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [timeRef, duration, pxAtTime]);
|
||||
|
||||
// 클릭/드래그 트랙 px → 이동거리 비율 → 시간(역변환)으로 seek (이동거리축 일관).
|
||||
const seekToTrackX = useCallback(
|
||||
(trackX: number) => {
|
||||
if (duration <= 0) return;
|
||||
const px = clamp(trackX, TRACK_START_PX, TRACK_END_PX);
|
||||
const targetTime = timeAtFrac((px - TRACK_START_PX) / timeTrackWidth);
|
||||
onSeek(clamp(targetTime, 0, duration));
|
||||
},
|
||||
[duration, onSeek, timeTrackWidth, timeAtFrac],
|
||||
);
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const stageX = ((clientX - rect.left) / rect.width) * STAGE_WIDTH;
|
||||
seekToTrackX(trackXFromRender(stageX));
|
||||
},
|
||||
[seekToTrackX],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const move = (e: globalThis.MouseEvent): void => {
|
||||
if (draggingRef.current) seekFromClientX(e.clientX);
|
||||
};
|
||||
const up = (): void => {
|
||||
draggingRef.current = false;
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
}, [seekFromClientX]);
|
||||
|
||||
const handleSeekDown = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
draggingRef.current = true;
|
||||
seekFromClientX(e.clientX);
|
||||
},
|
||||
[seekFromClientX],
|
||||
);
|
||||
|
||||
// 터미널 역명: route.json routeInfo.startStationName/endStationName 값. 없으면 측점 첫/끝 폴백.
|
||||
const { startStationName, endStationName } = useMemo(() => {
|
||||
const sorted = [...stations].sort(
|
||||
(a, b) => stationKm(a.title) - stationKm(b.title),
|
||||
);
|
||||
const firstTitle = sorted.length ? sorted[0].title : '';
|
||||
const lastTitle = sorted.length ? sorted[sorted.length - 1].title : '';
|
||||
return {
|
||||
startStationName: routeMeta?.routeInfo?.startStationName ?? firstTitle,
|
||||
endStationName: routeMeta?.routeInfo?.endStationName ?? lastTitle,
|
||||
};
|
||||
}, [stations, routeMeta]);
|
||||
|
||||
// 측점입력(예: 158k200) → 그 측점을 보는(최근접) 프레임으로 seek (실데이터 기반).
|
||||
// 해당 영상이 커버하는 측점(체이니지) 범위 밖이면 무시(seek 안 함).
|
||||
// 측점 검색 순환 상태 — 같은 측점값을 연속 Enter 할 때 '직전 점프 위치'에서 다음으로 이어가기 위함.
|
||||
const jumpRef = useRef<{ km: number; time: number } | null>(null);
|
||||
const handleJumpToMileage = useCallback(
|
||||
(km: number) => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length || duration <= 0) return false;
|
||||
// 커버 측점 범위 산출 (구간 밖 입력은 무시).
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const c = arr[i].chain;
|
||||
if (c < lo) lo = c;
|
||||
if (c > hi) hi = c;
|
||||
}
|
||||
const MARGIN = 20;
|
||||
if (km < lo - MARGIN || km > hi + MARGIN) return false;
|
||||
|
||||
// 측점 검색은 '드론의 실제 투영 측점(chain)' 기준으로만 이동한다(마커에 '표시된' 라벨 기준 아님).
|
||||
// → 입력값과 허용오차(stationTolerance 기본 20m) 안인 프레임들을 '연속구간=1통과'로 묶어,
|
||||
// 그 통과 지점들로만 순환 이동. 표시 라벨만 162080이고 실제 위치는 다른(예: 162210 조차장
|
||||
// 좌표근접) 마커로는 절대 튀지 않는다.
|
||||
// 통과가 0개면(그 측점을 실제로 안 지남) 이동하지 않고 false → 입력창에 '측점 없음' 안내.
|
||||
const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
const times: number[] = [];
|
||||
{
|
||||
let i = 0;
|
||||
while (i < arr.length) {
|
||||
if (Math.abs(arr[i].chain - km) < TOL) {
|
||||
let j = i, bj = i, bd = Math.abs(arr[i].chain - km);
|
||||
while (j < arr.length && Math.abs(arr[j].chain - km) < TOL) {
|
||||
const d = Math.abs(arr[j].chain - km);
|
||||
if (d < bd) { bd = d; bj = j; }
|
||||
j++;
|
||||
}
|
||||
times.push(arr[bj].time);
|
||||
i = j;
|
||||
} else i++;
|
||||
}
|
||||
}
|
||||
if (!times.length) return false;
|
||||
// 시간 오름차순 + 근접 중복 제거(같은 통과가 두 경로/마커로 잡힌 경우).
|
||||
times.sort((a, b) => a - b);
|
||||
const passes: number[] = [];
|
||||
for (const t of times) if (!passes.length || t - passes[passes.length - 1] > 0.3) passes.push(t);
|
||||
|
||||
// 기준 시각: 같은 측점을 '직전 점프 위치 그대로'에서 다시 Enter 하면 연속(다음 통과),
|
||||
// 재생/클릭으로 커서가 움직였거나 다른 측점이면 현재 커서(라이브) 시각에서 새로 시작.
|
||||
const liveT = timeRef?.current ?? currentTime;
|
||||
const same = !!jumpRef.current
|
||||
&& Math.abs(jumpRef.current.km - km) < 1e-6
|
||||
&& Math.abs(liveT - jumpRef.current.time) < 0.1;
|
||||
const baseT = same ? jumpRef.current!.time : liveT;
|
||||
// 통과방향(시간 증가)으로 baseT 다음 통과. 끝까지 가면 처음으로 순환.
|
||||
const EPS = 1e-3;
|
||||
const target = passes.find((t) => t > baseT + EPS) ?? passes[0];
|
||||
|
||||
jumpRef.current = { km, time: target };
|
||||
onSeek(clamp(target, 0, duration));
|
||||
return true;
|
||||
},
|
||||
[duration, onSeek, routeMeta, currentTime, timeRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={styles.wrap}
|
||||
style={cssVars({ '--bar-scale': scale })}
|
||||
>
|
||||
<div ref={stageRef} className={styles.layer}>
|
||||
<div className={styles.bottomBar}>
|
||||
<PlaybackControls
|
||||
playing={playing}
|
||||
onTogglePlay={onTogglePlay}
|
||||
onStop={onStop}
|
||||
onCapture={onCapture}
|
||||
onJumpToMileage={handleJumpToMileage}
|
||||
lineOn={showStations}
|
||||
onToggleLine={onToggleStations}
|
||||
/>
|
||||
<Timeline
|
||||
posPx={cursorPx}
|
||||
onSeekDown={handleSeekDown}
|
||||
trackGradient={trackGradient}
|
||||
trackGradientIdle={trackGradientIdle}
|
||||
dividers={dividers}
|
||||
kmLabels={kmLabels}
|
||||
structures={structureMarks}
|
||||
startStationName={startStationName}
|
||||
endStationName={endStationName}
|
||||
endGapPx={endGapPx}
|
||||
routeDir={routeDirCh}
|
||||
overlapExclude={poiOverlapExclude}
|
||||
/>
|
||||
</div>
|
||||
<TimelineCursor
|
||||
mileageText={cursorText}
|
||||
reverse={currentReverse}
|
||||
onSeekDown={handleSeekDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.marker {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 72px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.2px;
|
||||
-webkit-text-stroke-width: 2px;
|
||||
-webkit-text-stroke-color: #4F2000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.selected {
|
||||
text-shadow: 0 0 6px rgba(255, 148, 71, 0.55);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MileageMarkerSpec } from '../../types/timeline';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import styles from './MileageMarker.module.scss';
|
||||
|
||||
interface MileageMarkerProps {
|
||||
marker: MileageMarkerSpec;
|
||||
}
|
||||
|
||||
export function MileageMarker({ marker }: MileageMarkerProps) {
|
||||
const classNames = [styles.marker];
|
||||
if (marker.selected) classNames.push(styles.selected);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(' ')}
|
||||
style={cssVars({ '--x': px(marker.left) })}
|
||||
title={marker.value}
|
||||
>
|
||||
{marker.value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
$transport-gradient: linear-gradient(
|
||||
180deg,
|
||||
rgb(96, 79, 50) 0%,
|
||||
rgb(85, 75, 49) 7%,
|
||||
rgb(64, 51, 39) 11%,
|
||||
rgb(40, 34, 22) 100%
|
||||
);
|
||||
|
||||
$tool-gradient: linear-gradient(
|
||||
180deg,
|
||||
rgb(96, 79, 50) 0%,
|
||||
rgb(59, 52, 34) 7%,
|
||||
rgb(54, 43, 31) 11%,
|
||||
rgb(66, 55, 35) 100%
|
||||
);
|
||||
|
||||
$group-border: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 182, 135, 0.5) 0%,
|
||||
rgba(255, 255, 255, 0) 100%,
|
||||
);
|
||||
|
||||
$default-border: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.25) 25%,
|
||||
rgba(153, 122, 101, 0.63) 63%,
|
||||
rgba(98, 62, 39, 0.81) 81%,
|
||||
rgba(80, 67, 60, 1) 100%
|
||||
);
|
||||
|
||||
$hover-border: linear-gradient(
|
||||
180deg,
|
||||
#ffa812 8%,
|
||||
#ffdfa7 38%,
|
||||
#ffa812 72%,
|
||||
#a96e09 95%
|
||||
);
|
||||
|
||||
// ── 컨트롤 패널 치수 (이 값들만 바꾸면 관련 위치/크기가 자동 계산됨) ──
|
||||
$panel-h: 96.67px; // transportGroup 높이
|
||||
$panel-inset: 2px; // 그룹 가장자리 공통 인셋
|
||||
|
||||
// 재생·정지·측점입력 그룹
|
||||
$transport-group-w: 161.33px;
|
||||
$transport-btn-w: 78px;
|
||||
$transport-btn-h: 55px;
|
||||
$transport-btn-left: 3px; // 첫(재생) 버튼 좌측 인셋
|
||||
$transport-btn-top: 4px;
|
||||
$transport-btn-gap: 1px; // 재생 ↔ 정지 간격
|
||||
|
||||
// 화면캡처·측점선 그룹
|
||||
$tool-group-w: 76px;
|
||||
$tool-group-h: 96px;
|
||||
$tool-btn-h: 47px;
|
||||
$tool-btn-top: 0.5px; // 화면캡처(첫 버튼) top — 47×2가 96 안에 들어가도록 축소
|
||||
$tool-btn-gap: 1px; // 화면캡처 ↔ 측점선 간격 (47px 버튼이 바 안에 들어가도록)
|
||||
|
||||
/* 컨트롤 묶음 — bottomBar flex 자식 (좌측 정렬, 세로 중앙) */
|
||||
.controlsRow {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding-left: 1.33px;
|
||||
align-items: center;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
%control-button {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: 4px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
background: $transport-gradient padding-box, $default-border border-box;
|
||||
box-shadow: 0 0 0 2px rgb(0, 0, 0);
|
||||
transition: box-shadow 150ms ease-out;
|
||||
|
||||
&:hover {
|
||||
background: $transport-gradient padding-box, $hover-border border-box;
|
||||
box-shadow:
|
||||
0 0 0 2px #000,
|
||||
0 0 8px rgba(255, 168, 18, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
/* 플레이·정지·측점 입력 패널 */
|
||||
.transportGroup {
|
||||
position: relative;
|
||||
width: $transport-group-w;
|
||||
height: $panel-h;
|
||||
}
|
||||
|
||||
.leftPanel {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
width: calc(100% + 4px);
|
||||
height: 100%;
|
||||
/* 그라데이션 테두리 두께 = 이 border 폭 (숫자를 키우면 더 굵어짐) */
|
||||
border: 2px solid transparent;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
background:
|
||||
linear-gradient(rgb(38, 29, 13), transparent) padding-box,
|
||||
$group-border border-box;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.transportBtn {
|
||||
@extend %control-button;
|
||||
left: $transport-btn-left;
|
||||
top: $transport-btn-top;
|
||||
width: $transport-btn-w;
|
||||
height: $transport-btn-h;
|
||||
}
|
||||
|
||||
.stopBtn {
|
||||
@extend %control-button;
|
||||
// 재생 버튼 우측 + 간격 → 폭이 바뀌면 자동으로 따라옴
|
||||
left: $transport-btn-left + $transport-btn-w + $transport-btn-gap + 1px;
|
||||
top: $transport-btn-top;
|
||||
width: $transport-btn-w;
|
||||
height: $transport-btn-h;
|
||||
}
|
||||
|
||||
/* 버튼 아이콘: 실제 SVG 파일(그라데이션·그림자·테두리 포함)을 배경으로 사용.
|
||||
호버 시 버튼의 :hover 로 _on 버전으로 교체한다. 박스 크기는 SVG viewBox의 2/3
|
||||
(= 기존 렌더 크기 유지). 새 아이콘은 SVG만 교체하면 되어 유지보수가 쉽다. */
|
||||
%btn-icon {
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playIcon {
|
||||
@extend %btn-icon;
|
||||
width: 40px; // 60 × 2/3
|
||||
height: 40px;
|
||||
background-image: url('/assets/icons/ico_play.png');
|
||||
}
|
||||
|
||||
.transportBtn:hover .playIcon {
|
||||
background-image: url('/assets/icons/ico_play_on.png');
|
||||
}
|
||||
|
||||
.pauseIcon {
|
||||
@extend %btn-icon;
|
||||
width: 33.33px; // 50 × 2/3
|
||||
height: 33.33px;
|
||||
background-image: url('/assets/icons/icon_pause.png');
|
||||
}
|
||||
|
||||
.transportBtn:hover .pauseIcon {
|
||||
background-image: url('/assets/icons/icon_pause_on.png');
|
||||
}
|
||||
|
||||
.stopIcon {
|
||||
@extend %btn-icon;
|
||||
width: 40px; // 60 × 2/3
|
||||
height: 40px;
|
||||
background-image: url('/assets/icons/icon_stop.png');
|
||||
}
|
||||
|
||||
.stopBtn:hover .stopIcon {
|
||||
background-image: url('/assets/icons/icon_stop_on.png');
|
||||
}
|
||||
|
||||
.mileageInput {
|
||||
position: absolute;
|
||||
left: $transport-btn-left;
|
||||
top: 62px;
|
||||
z-index: 4;
|
||||
width: $transport-group-w - $transport-btn-left - $panel-inset;
|
||||
height: 32.67px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
box-shadow:
|
||||
0 0 0 2px rgb(0, 0, 0),
|
||||
0 0.5px 0 0 rgba(255, 255, 255, 0.1);
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
font-family: 'Noto Sans KR', var(--font-ui);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* '측점 없음' 안내 — 입력창 위에 겹쳐 표시(패널 자식이 absolute 라 일반 흐름은 가려짐). */
|
||||
.notFound {
|
||||
position: absolute;
|
||||
left: $transport-btn-left;
|
||||
top: 62px;
|
||||
height: 32.67px;
|
||||
width: $transport-group-w - $transport-btn-left - $panel-inset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
z-index: 6; // mileageInput(z:4) 위
|
||||
border-radius: 4px;
|
||||
background: rgba(20, 0, 0, 0.88);
|
||||
box-shadow: 0 0 0 2px #000;
|
||||
color: #ff6b6b;
|
||||
font-family: 'Noto Sans KR', var(--font-ui);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 화면캡처·측점선 보기 패널 */
|
||||
.toolGroup {
|
||||
position: relative;
|
||||
width: $tool-group-w;
|
||||
height: $tool-group-h;
|
||||
margin-left: 1px; // transportGroup 패널 확장분만큼 1px 우측 이동
|
||||
border-radius: 4px;
|
||||
background: #1a1309;
|
||||
}
|
||||
|
||||
.toolBtn {
|
||||
@extend %control-button;
|
||||
left: $panel-inset;
|
||||
width: $tool-group-w - 2 * $panel-inset; // 좌우 인셋 제외 → 그룹 폭 바뀌면 자동
|
||||
height: $tool-btn-h; // 바 영역을 벗어나지 않도록 축소 + 두 버튼 사이 간격 확보
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
background: $tool-gradient padding-box, $default-border border-box;
|
||||
|
||||
&:hover {
|
||||
background: $tool-gradient padding-box, $hover-border border-box;
|
||||
box-shadow:
|
||||
0 0 0 2px #000,
|
||||
0 0 8px rgba(255, 168, 18, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.captureBtn {
|
||||
top: $tool-btn-top + $tool-btn-top;
|
||||
}
|
||||
|
||||
/* 화면캡처 아래 = 첫 버튼 top + 높이 + 간격 → 높이/간격 바꾸면 자동으로 따라옴 */
|
||||
.lineBtn {
|
||||
top: $tool-btn-top + $tool-btn-h + $tool-btn-gap + $tool-btn-top + 1px;
|
||||
}
|
||||
|
||||
.cameraIcon {
|
||||
@extend %btn-icon;
|
||||
width: 24px; // 확대
|
||||
height: 20px;
|
||||
background-image: url('/assets/icons/icon-camera-body.png');
|
||||
}
|
||||
|
||||
.captureBtn:hover .cameraIcon {
|
||||
background-image: url('/assets/icons/icon-camera-body_on.png');
|
||||
}
|
||||
|
||||
.lineIcon {
|
||||
@extend %btn-icon;
|
||||
width: 19.2px; // 확대
|
||||
height: 20px;
|
||||
background-image: url('/assets/icons/icon-sturn.png');
|
||||
}
|
||||
|
||||
.lineBtn:hover .lineIcon {
|
||||
background-image: url('/assets/icons/icon-sturn_on.png');
|
||||
}
|
||||
|
||||
.toolLabel {
|
||||
font-family: 'Noto Sans KR', var(--font-ui);
|
||||
font-weight: 700;
|
||||
font-size: 13.33px; // 살짝 축소 (기존 14.67)
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.04em;
|
||||
white-space: nowrap;
|
||||
color: rgb(204, 199, 189);
|
||||
text-shadow: 0 0.5px 1px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.toolBtn:hover .toolLabel {
|
||||
color: #ffa812;
|
||||
}
|
||||
|
||||
/* 측점선 보기 토글 ON 상태(클릭): 호버와 동일한 아이콘·라벨 색에,
|
||||
눌린 어두운 그라데이션 배경 + 주황 그라데이션 테두리. 별도 동작 없음.
|
||||
(채움 레이어는 padding-box, 테두리 그라데이션은 border-box) */
|
||||
.lineBtn.active {
|
||||
background:
|
||||
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%) padding-box,
|
||||
linear-gradient(
|
||||
180deg,
|
||||
#211601 5%,
|
||||
#211601 10%,
|
||||
#352509 15%,
|
||||
#352509 85%,
|
||||
#231700 90%,
|
||||
#000 95%
|
||||
) padding-box,
|
||||
$hover-border border-box;
|
||||
box-shadow: 0 0 0 2px #000;
|
||||
}
|
||||
|
||||
.lineBtn.active .lineIcon {
|
||||
background-image: url('/assets/icons/icon-sturn_on.png');
|
||||
}
|
||||
|
||||
.lineBtn.active .toolLabel {
|
||||
color: #ffa812;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { parseMileageQuery } from '../../utils/mileage';
|
||||
import styles from './PlaybackControls.module.scss';
|
||||
|
||||
interface PlaybackControlsProps {
|
||||
playing: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onStop: () => void;
|
||||
onCapture: () => void;
|
||||
/** 측점 검색: 이동했으면 true, 이 영상에 없는 측점이라 이동 못 했으면 false. */
|
||||
onJumpToMileage: (mileage: number) => boolean;
|
||||
/** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */
|
||||
lineOn?: boolean;
|
||||
onToggleLine?: () => void;
|
||||
}
|
||||
|
||||
export function PlaybackControls({
|
||||
playing,
|
||||
onTogglePlay,
|
||||
onStop,
|
||||
onCapture,
|
||||
onJumpToMileage,
|
||||
lineOn: lineOnProp,
|
||||
onToggleLine,
|
||||
}: PlaybackControlsProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [lineOnInternal, setLineOnInternal] = useState(false);
|
||||
const lineOn = lineOnProp ?? lineOnInternal;
|
||||
const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v));
|
||||
|
||||
// '측점 없음' 안내는 잠깐만 표시(1.8초 후 자동 사라짐).
|
||||
useEffect(() => {
|
||||
if (!notFound) return;
|
||||
const t = setTimeout(() => setNotFound(false), 1800);
|
||||
return () => clearTimeout(t);
|
||||
}, [notFound]);
|
||||
|
||||
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setQuery(e.target.value);
|
||||
setNotFound(false); // 다시 입력하면 안내 숨김
|
||||
};
|
||||
|
||||
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const mileage = parseMileageQuery(query);
|
||||
// Enter 후에도 입력값 유지 → 같은 측점 재Enter 시 통과방향 다음 위치로 순환 검색.
|
||||
// 이 영상에 없는 측점(이동 실패) 또는 형식 오류면 '측점 없음' 안내를 잠깐 띄운다.
|
||||
const moved = mileage !== null ? onJumpToMileage(mileage) : false;
|
||||
setNotFound(!moved);
|
||||
};
|
||||
|
||||
// 포커스를 잃으면(다른 곳 클릭 등) 입력값 삭제.
|
||||
const handleQueryBlur = (): void => { setQuery(''); setNotFound(false); };
|
||||
|
||||
return (
|
||||
<div className={styles.controlsRow}>
|
||||
<div className={styles.transportGroup}>
|
||||
<div className={styles.leftPanel} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={onTogglePlay}
|
||||
aria-label={playing ? '일시정지' : '재생'}
|
||||
>
|
||||
{/* 호버 시 _on SVG 로 교체(:hover). 재생/일시정지는 상태로 아이콘 전환 */}
|
||||
<span className={playing ? styles.pauseIcon : styles.playIcon} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stopBtn}
|
||||
onClick={onStop}
|
||||
aria-label="정지"
|
||||
>
|
||||
<span className={styles.stopIcon} />
|
||||
</button>
|
||||
<input
|
||||
className={styles.mileageInput}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onKeyDown={handleQueryKeyDown}
|
||||
onBlur={handleQueryBlur}
|
||||
placeholder="측점입력"
|
||||
/>
|
||||
{notFound && (
|
||||
<span role="alert" className={styles.notFound}>⚠ 측점 없음</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.toolGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toolBtn} ${styles.captureBtn}`}
|
||||
onClick={onCapture}
|
||||
aria-label="화면캡처"
|
||||
>
|
||||
<span className={styles.cameraIcon} />
|
||||
<span className={styles.toolLabel}>화면캡처</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toolBtn} ${styles.lineBtn}${lineOn ? ` ${styles.active}` : ''}`}
|
||||
onClick={toggleLine}
|
||||
aria-pressed={lineOn}
|
||||
aria-label="측점선 보기"
|
||||
>
|
||||
<span className={styles.lineIcon} />
|
||||
<span className={styles.toolLabel}>
|
||||
{lineOn ? '측점선 끄기' : '측점선 보기'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
.segment {
|
||||
position: absolute;
|
||||
left: var(--seg-left);
|
||||
top: var(--seg-top);
|
||||
width: var(--seg-width);
|
||||
height: var(--seg-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.capLeft,
|
||||
.capRight {
|
||||
flex: 0 0 var(--seg-cap-width);
|
||||
width: var(--seg-cap-width);
|
||||
height: var(--seg-height);
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.center {
|
||||
flex: 1;
|
||||
margin: 0 -3px;
|
||||
height: var(--seg-center-height);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.bridge {
|
||||
&.upcoming {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-upcoming-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-upcoming-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-upcoming-right.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.passed {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-passed-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-passed-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-passed-right.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.revisit {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-revisit-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-revisit-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-revisit-right.png');
|
||||
}
|
||||
|
||||
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
.tunnel {
|
||||
&.upcoming {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-right.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.passed {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-passed-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-passed-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-passed-right.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.revisit {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-right.png');
|
||||
}
|
||||
|
||||
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { RouteProgressState, StructureSegment } from '../../types/timeline';
|
||||
import { isStructureAssetState } from '../../constants/routeSegmentAssets';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import styles from './RouteSegment.module.scss';
|
||||
|
||||
interface RouteSegmentProps {
|
||||
segment: StructureSegment;
|
||||
state: RouteProgressState;
|
||||
}
|
||||
|
||||
/** Three-slice structure marker: fixed caps, stretchable center. */
|
||||
export function RouteSegment({ segment, state }: RouteSegmentProps) {
|
||||
const assetState = isStructureAssetState(state) ? state : 'upcoming';
|
||||
const classNames = [styles.segment, styles[segment.type], styles[assetState]];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(' ')}
|
||||
title={segment.label}
|
||||
style={cssVars({
|
||||
'--seg-left': px(segment.left),
|
||||
'--seg-top': px(segment.top),
|
||||
'--seg-width': px(segment.width),
|
||||
'--seg-height': px(segment.height),
|
||||
'--seg-cap-width': px(segment.capWidth),
|
||||
'--seg-center-height': px(segment.centerHeight),
|
||||
})}
|
||||
>
|
||||
<div className={styles.capLeft} />
|
||||
<div className={styles.center} />
|
||||
<div className={styles.capRight} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.marker {
|
||||
position: absolute;
|
||||
left: var(--marker-left);
|
||||
top: var(--marker-top);
|
||||
width: var(--marker-width);
|
||||
height: var(--marker-height);
|
||||
z-index: 7;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { terminalCircleAsset } from '../../constants/routeSegmentAssets';
|
||||
import type { TerminalMarker as TerminalMarkerSpec } from '../../types/timeline';
|
||||
import { resolveTerminalState } from '../../utils/routeProgress';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import styles from './TerminalMarker.module.scss';
|
||||
|
||||
interface TerminalMarkerProps {
|
||||
marker: TerminalMarkerSpec;
|
||||
posPx: number;
|
||||
}
|
||||
|
||||
export function TerminalMarker({ marker, posPx }: TerminalMarkerProps) {
|
||||
const state = resolveTerminalState(posPx, marker);
|
||||
|
||||
return (
|
||||
<img
|
||||
className={styles.marker}
|
||||
src={terminalCircleAsset(state)}
|
||||
alt=""
|
||||
style={cssVars({
|
||||
'--marker-left': px(marker.left),
|
||||
'--marker-top': px(marker.top),
|
||||
'--marker-width': px(marker.width),
|
||||
'--marker-height': px(marker.height),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/* 트랙 본체 가로 압축 래퍼. transform(translateX·scaleX)은 route.ts 단일 소스에서
|
||||
파생되어 Timeline.tsx 에서 인라인으로 적용된다(여기에 하드코딩하지 않음). */
|
||||
.trackWrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1920px;
|
||||
height: 100px;
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trackFade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -23px;
|
||||
width: 1920px;
|
||||
height: 23px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(102, 102, 102, 0) 0%,
|
||||
rgba(41, 41, 41, 0.25) 50%,
|
||||
rgba(0, 0, 0, 0.5) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trackFrame {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 27.5px;
|
||||
width: var(--track-w);
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, rgb(11, 8, 3) 0%, rgb(40, 26, 7) 100%);
|
||||
box-shadow: -1px 1px 1px 0 rgb(71, 61, 39);
|
||||
}
|
||||
|
||||
.trackFrameStripesH {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 27.5px;
|
||||
width: var(--track-w);
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.07) 0,
|
||||
rgba(255, 255, 255, 0.07) 1px,
|
||||
transparent 1px,
|
||||
transparent 33.33%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trackFrameStripesV {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 31.5px;
|
||||
width: var(--track-w);
|
||||
height: 45.5px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(61, 52, 34, 0.55) 0,
|
||||
rgba(61, 52, 34, 0.55) 2px,
|
||||
transparent 2px,
|
||||
transparent 36.65px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trackBase {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 34.5px;
|
||||
width: var(--track-w);
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgb(122, 122, 122);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 역명 레이어: 스테이지 전체를 덮어 자식들의 위치 기준이 된다.
|
||||
--track-start / --track-end (트랙 시작·끝, 단일 소스)을 상속받아
|
||||
아래 역 라벨들이 트랙 가장자리 기준 calc()로 배치된다(시작/끝 바꾸면 자동 이동). */
|
||||
.stationLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 트랙 가장자리 기준 오프셋(좌우 대칭): 텍스트 안쪽끝 40px · 점 32/27px · 점선 23/5px */
|
||||
.stationStart {
|
||||
position: absolute;
|
||||
/* 오른쪽(안쪽) 끝을 '트랙 시작-40px'에 고정 → 대전과 트랙 중앙 기준 좌우 대칭.
|
||||
글자 폭과 무관하게 점선·텍스트 공백이 양쪽 동일(점까지 8px, 트랙까지 50px). */
|
||||
right: calc(100% - var(--track-start) + 40px);
|
||||
top: 31px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-station);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stationEnd {
|
||||
position: absolute;
|
||||
/* 우측 끝(1920) 기준으로 고정 → 긴 역명(대전조차장 등)도 안쪽(좌)으로 펼쳐져 화면 밖으로 안 잘림. */
|
||||
right: 8px;
|
||||
top: 31px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-station);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dotStart,
|
||||
.dotEnd {
|
||||
position: absolute;
|
||||
top: 39px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-station-dot);
|
||||
}
|
||||
|
||||
.dotStart {
|
||||
left: calc(var(--track-start) - 32px);
|
||||
}
|
||||
|
||||
.dotEnd {
|
||||
left: calc(var(--track-end) + 27px);
|
||||
}
|
||||
|
||||
.leaderStart,
|
||||
.leaderEnd {
|
||||
position: absolute;
|
||||
top: 40.5px;
|
||||
height: 2.5px;
|
||||
width: 18px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--color-station-leader) 0 5px,
|
||||
transparent 5px 9px
|
||||
);
|
||||
}
|
||||
|
||||
.leaderStart {
|
||||
left: calc(var(--track-start) - 23px);
|
||||
}
|
||||
|
||||
.leaderEnd {
|
||||
left: calc(var(--track-end) + 5px);
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 34.5px;
|
||||
width: var(--track-w);
|
||||
height: 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.leg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 12px;
|
||||
left: var(--leg-left);
|
||||
width: var(--leg-width);
|
||||
|
||||
&.down {
|
||||
background: var(--color-track-down);
|
||||
}
|
||||
|
||||
&.up {
|
||||
background: var(--color-track-up);
|
||||
}
|
||||
}
|
||||
|
||||
.legPassed {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 12px;
|
||||
width: var(--passed-width);
|
||||
|
||||
.down & {
|
||||
background: var(--gradient-track-down-passed);
|
||||
}
|
||||
|
||||
.up & {
|
||||
background: var(--gradient-track-up-passed);
|
||||
}
|
||||
}
|
||||
|
||||
.turnDivider {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 12px;
|
||||
border-left: 1px dashed rgba(0, 0, 0, 0.7);
|
||||
border-right: 1px dashed rgba(255, 255, 255, 0.3);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mileageRow {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 1920px;
|
||||
height: 13px;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 51px;
|
||||
width: 1920px;
|
||||
height: 26px;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
font-size: 16.5px;
|
||||
font-weight: 800;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.zone {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: -6px;
|
||||
width: var(--zone-width);
|
||||
height: 15.5px;
|
||||
border: 1.5px dashed rgb(218, 203, 185);
|
||||
border-radius: 1px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zoneInner {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: -6px;
|
||||
width: 0;
|
||||
height: 15.5px;
|
||||
border-left: 1.5px dashed rgb(218, 203, 185);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.segmentLabel {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: 2px;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
|
||||
&.box {
|
||||
transform: none;
|
||||
width: var(--label-width);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 텍스트 오른쪽 끝이 left 좌표에 닿음 (시작 라인 왼쪽 배치) */
|
||||
&.anchorEnd {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
/* 텍스트 왼쪽 끝이 left 좌표에서 시작 (종료 라인 오른쪽 배치) */
|
||||
&.anchorStart {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&.endpoint {
|
||||
color: #fff;
|
||||
-webkit-text-stroke-width: 4px;
|
||||
-webkit-text-stroke-color: #4f2000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
&.accent {
|
||||
color: #ffd4b7;
|
||||
-webkit-text-stroke-width: 2px;
|
||||
-webkit-text-stroke-color: #4f2000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
color: #e4e4e4;
|
||||
-webkit-text-stroke-width: 3px;
|
||||
-webkit-text-stroke-color: #0d0d0d;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
}
|
||||
|
||||
/* 구조물명: 줄바꿈은 JS(splitStructLabel)가 위/아래 글자수 균등하게 <br>로 처리.
|
||||
박스는 내용 폭에 맞게(최대 140px) 줄어들어 옆 라벨과 간격을 최대 확보.
|
||||
각 줄은 nowrap(명시적 줄바꿈만), 아이콘 기준 가운데 정렬.
|
||||
폰트 크기는 라벨행 기본(16.5px) 유지 — 작게 줄이지 않음. */
|
||||
.structName {
|
||||
width: max-content;
|
||||
max-width: 140px;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.seekArea {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 27.5px;
|
||||
width: var(--track-w);
|
||||
height: 48px;
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { RouteSegment } from '../RouteSegment/RouteSegment';
|
||||
import type { StructureType, StructureSegment } from '../../types/timeline';
|
||||
import {
|
||||
TRACK_RENDER_END_PX,
|
||||
TRACK_RENDER_START_PX,
|
||||
TRACK_START_PX,
|
||||
TRACK_WIDTH_PX,
|
||||
TRACK_WRAPPER_TRANSFORM,
|
||||
} from '../../mocks/route';
|
||||
import type { KmLabel, StructMark } from '../../StationBar';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import { MileageMarker } from '../MileageMarker/MileageMarker';
|
||||
import styles from './Timeline.module.scss';
|
||||
|
||||
/** 미터값 → "158k160" (10m 단위). 음수면 빈 문자열. */
|
||||
function fmtKm(m: number): string {
|
||||
if (m < 0) return '';
|
||||
const r = Math.round(m / 10) * 10;
|
||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물명 라벨 분할 — 한 줄에 안 들어가면 위/아래 '글자 수'가 최대한 같게 2줄로 나눈다.
|
||||
* (균등 분할 → 가장 넓은 줄 폭 최소화 → 옆 라벨과 간격 최대 확보)
|
||||
* 한글/CJK 1.0, 그 외(영문·괄호·숫자) 0.55폭으로 한 줄 용량(≈140px/16.5px) 추정.
|
||||
* 분할점은 글자수 절반. 단, 절반 근처(±2)에 여는 괄호가 있으면 괄호를 통째로 아랫줄로.
|
||||
*/
|
||||
function splitStructLabel(text: string): string[] {
|
||||
const chars = [...text];
|
||||
const isCJK = (c: string): boolean => /[ -鿿가-힣]/.test(c);
|
||||
const width = chars.reduce((a, c) => a + (isCJK(c) ? 1 : 0.55), 0);
|
||||
if (width <= 8.2) return [text]; // 한 줄에 들어감 → 그대로
|
||||
const len = chars.length;
|
||||
// 기본 분할점 = 글자수 절반(위/아래 글자수 균등).
|
||||
let cut = Math.round(len / 2);
|
||||
// 분할점이 괄호 그룹 "(...)" 내부를 가르면 → 괄호 시작 앞으로 옮겨 통째로 아랫줄.
|
||||
let open = -1;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const c = chars[i];
|
||||
if (c === '(' || c === '(') open = i;
|
||||
else if (c === ')' || c === ')') {
|
||||
if (open >= 0 && open < cut && i >= cut) { cut = open; break; }
|
||||
open = -1;
|
||||
}
|
||||
}
|
||||
return [chars.slice(0, cut).join(''), chars.slice(cut).join('')];
|
||||
}
|
||||
|
||||
interface TimelineProps {
|
||||
posPx: number;
|
||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
/** 통과 구간 트랙 gradient (정방향=주황/역방향=청록). */
|
||||
trackGradient: string;
|
||||
/** 미통과 구간 트랙 gradient (정방향=회색/역방향=청회색). */
|
||||
trackGradientIdle: string;
|
||||
/** 색(방향)이 바뀌는 전환점 px(스테이지 좌표) — 구분선 위치. */
|
||||
dividers: number[];
|
||||
/** 데이터 기반 측점값 라벨 (방향 전환점·시종점). */
|
||||
kmLabels: KmLabel[];
|
||||
/** 데이터 기반 구조물 (교량/터널/역사). */
|
||||
structures: StructMark[];
|
||||
/** 시점 역명 (route.json routeInfo 우선, 없으면 첫 측점 title). */
|
||||
startStationName?: string;
|
||||
/** 종점 역명 (route.json routeInfo 우선, 없으면 끝 측점 title). */
|
||||
endStationName?: string;
|
||||
/** 종점역 미도착 폭(px) — 트랙 우측 이 구간은 '재생 불가'(영상이 도달 못 함)로 별색 표시. */
|
||||
endGapPx?: number;
|
||||
/** 영상 진행 방향('상'|'하'). 평행 상/하행 구조물 겹침 시 해당 방향을 우선 표출. */
|
||||
routeDir?: '상' | '하' | null;
|
||||
/** 겹침제외(POI 옵션). true=겹친 구조물 1개만, false=모두 표시. */
|
||||
overlapExclude?: boolean;
|
||||
}
|
||||
|
||||
export function Timeline({
|
||||
posPx,
|
||||
onSeekDown,
|
||||
trackGradient,
|
||||
trackGradientIdle,
|
||||
dividers,
|
||||
kmLabels,
|
||||
structures,
|
||||
startStationName,
|
||||
endStationName,
|
||||
endGapPx = 0,
|
||||
routeDir = null,
|
||||
overlapExclude = true,
|
||||
}: TimelineProps) {
|
||||
// 하단 스테이션바는 겹쳐도 무방 → 역사(역, KAKAO_RAIL)는 dedup 대상에서 제외하고 항상 표출.
|
||||
// (종점 '대전조차장'이 ~70px 앞 '법동가도교'에 밀려 사라지던 문제 방지.)
|
||||
// 교량/터널만 라벨 텍스트가 길어 130px 겹침 방지 적용. 위치는 실제 통과 px 그대로.
|
||||
// 평행 상/하행 구조물(예: 회덕터널 상/하)은 ~6px 차이라 한쪽만 남는데, 영상 방향(routeDir)에
|
||||
// 해당하는 쪽(하행=‘(하)’, 상행=‘(상)’)을 우선 남긴다. → 드론이 실제 지나는 선로와 일치.
|
||||
const isStation = (s: StructMark): boolean => s.category === '역사' || s.category === '역';
|
||||
// '(상' / '(하' 로 매칭 — 이름이 "법동가도교(상,인상,고속)" 처럼 괄호 안 다중 토큰이어도 인식.
|
||||
// (이전 '(상)' 완전일치는 다중토큰 이름에서 매칭 실패해 잘못된 변형이 선택되던 버그)
|
||||
const dirTag = routeDir === '상' ? '(상' : routeDir === '하' ? '(하' : '';
|
||||
const baseStruct = (t: string): string => t.replace(/\s*[((].*$/, '').trim();
|
||||
const others = structures.filter((s) => !isStation(s));
|
||||
const ordered = dirTag
|
||||
? [...others].sort((a, b) =>
|
||||
(a.title.includes(dirTag) ? 0 : 1) - (b.title.includes(dirTag) ? 0 : 1) || a.px - b.px)
|
||||
: [...others].sort((a, b) => a.px - b.px);
|
||||
// 같은 구조물의 상/하/인상 변형(같은 base = 같은 좌표)은 '겹침제외 옵션과 무관하게' 진행방향 1개만.
|
||||
// (dir-우선 정렬돼 있어 방향 변형이 먼저 = 채택. 예: 법동가도교(하)/(인상) → (하) 1개)
|
||||
const byBase = new Map<string, StructMark>();
|
||||
for (const it of ordered) {
|
||||
const b = baseStruct(it.title);
|
||||
if (!byBase.has(b)) byBase.set(b, it);
|
||||
}
|
||||
const keptOthers = [...byBase.values()];
|
||||
// 라벨은 긴 이름 2줄(아래 splitStructLabel)로 폭을 줄여 표시. 그래도 가까워 겹치면 그대로 둠(엇갈림 없음).
|
||||
void overlapExclude;
|
||||
const structs = [...structures.filter(isStation), ...keptOthers].sort((a, b) => a.px - b.px);
|
||||
|
||||
// 측점값 라벨 겹침 숨김: px 오름차순으로 보며, 직전에 '표시한' 라벨과 시각적으로 겹치면
|
||||
// (중앙 간격 < 두 라벨 평균 폭) 뒤엣것을 숨긴다 → 먼저 나온(좌측) 것만 1개 표시.
|
||||
// 같은/근접 측점을 여러 번 통과해 측점값이 겹쳐 보이던 문제 방지. (아이콘·점선은 그대로 둠)
|
||||
const labelW = (v: string): number => v.length * 7.5 + 4; // 13px bold + 2px stroke 대략 폭(px)
|
||||
const showMileage: boolean[] = new Array(structs.length).fill(false);
|
||||
{
|
||||
let lastPx = -Infinity;
|
||||
let lastW = 0;
|
||||
for (let i = 0; i < structs.length; i++) {
|
||||
const s = structs[i];
|
||||
// 측점값 없음(s.km<0) 또는 '그 위치에 실제 측점값이 존재하지 않음'(kmExists=false,
|
||||
// 좌표 반경만으로 잡힌 통과)은 측점값 라벨을 그리지 않는다. (마커 동그라미는 별도로 유지)
|
||||
if (s.km < 0 || s.kmExists === false) continue;
|
||||
const w = labelW(fmtKm(s.km));
|
||||
if (s.px - lastPx >= (lastW + w) / 2) {
|
||||
showMileage[i] = true;
|
||||
lastPx = s.px;
|
||||
lastW = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 라벨 그룹핑: 같은 항목(동일 title)을 드론이 여러 번 지나면 통과 마커가 흩어져 이름이 반복된다.
|
||||
// → 동명 통과를 거리와 무관하게 하나로 묶어, 각 통과에 점선 드롭 + 양끝을 잇는 하단 수평 점선(브래킷)을
|
||||
// 그리고 묶음 중앙에 라벨 1개만 표시한다. (단일 통과는 브래킷 없이 자기 위치 라벨 1개)
|
||||
// 아이콘·측점값은 통과별로 그대로 표출.
|
||||
const labelGroups: {
|
||||
title: string;
|
||||
km: number;
|
||||
category: string;
|
||||
unreached?: boolean;
|
||||
centerPx: number; // 라벨 위치 및 passed 판정 기준 (묶음 중앙)
|
||||
bracketPxs: number[]; // 길이 ≥2면 브래킷(드롭+수평선) 표시 대상 통과 px (정렬)
|
||||
}[] = [];
|
||||
{
|
||||
const byTitle = new Map<string, StructMark[]>();
|
||||
for (const s of structs) {
|
||||
const a = byTitle.get(s.title);
|
||||
if (a) a.push(s);
|
||||
else byTitle.set(s.title, [s]);
|
||||
}
|
||||
for (const arr of byTitle.values()) {
|
||||
const sorted = [...arr].sort((a, b) => a.px - b.px);
|
||||
// 동명 통과(역사 포함)는 하나로 묶어 브래킷(드롭+수평선) + 묶음 라벨 1개. 단일 통과는 자기 위치 라벨.
|
||||
const lo = sorted[0].px;
|
||||
const hi = sorted[sorted.length - 1].px;
|
||||
labelGroups.push({
|
||||
title: sorted[0].title,
|
||||
km: sorted[0].km,
|
||||
category: sorted[0].category,
|
||||
unreached: sorted.some((m) => m.unreached),
|
||||
centerPx: (lo + hi) / 2,
|
||||
bracketPxs: sorted.length > 1 ? sorted.map((m) => m.px) : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 구조물 마커 클릭 시 속성 팝업(클릭 좌표 기준 fixed — 트랙 래퍼 transform 영향 회피).
|
||||
const [sel, setSel] = useState<{ s: StructMark; x: number; y: number } | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
|
||||
<div className={styles.trackFade} />
|
||||
|
||||
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 시·종점 역명 배치 (route.json/측점 유래) */}
|
||||
<div
|
||||
className={styles.stationLayer}
|
||||
style={cssVars({
|
||||
'--track-start': px(TRACK_RENDER_START_PX),
|
||||
'--track-end': px(TRACK_RENDER_END_PX),
|
||||
})}
|
||||
>
|
||||
{/* 좌측(시점)=역방향 하늘색, 우측(종점)=정방향 주황 */}
|
||||
<div className={styles.stationStart} style={{ color: '#06a4c8' }}>{startStationName ?? ''}</div>
|
||||
<div className={styles.dotStart} style={{ background: '#06a4c8' }} />
|
||||
<div className={styles.leaderStart} style={cssVars({ '--color-station-leader': '#06a4c8' })} />
|
||||
<div className={styles.leaderEnd} style={cssVars({ '--color-station-leader': '#ff8a25' })} />
|
||||
<div className={styles.dotEnd} style={{ background: '#ff8a25' }} />
|
||||
<div className={styles.stationEnd} style={{ color: '#ff8a25' }}>{endStationName ?? ''}</div>
|
||||
</div>
|
||||
|
||||
{/* 트랙 본체 (가로 압축 래퍼) */}
|
||||
<div
|
||||
className={styles.trackWrapper}
|
||||
style={{
|
||||
...cssVars({
|
||||
'--track-x': px(TRACK_START_PX),
|
||||
'--track-w': px(TRACK_WIDTH_PX),
|
||||
}),
|
||||
transform: TRACK_WRAPPER_TRANSFORM,
|
||||
}}
|
||||
>
|
||||
<div className={styles.trackFrame} />
|
||||
<div className={styles.trackFrameStripesH} />
|
||||
<div className={styles.trackFrameStripesV} />
|
||||
<div className={styles.trackBase} />
|
||||
|
||||
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색.
|
||||
전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
|
||||
<div className={styles.track}>
|
||||
{/* 미재생(미통과): 방향별 저톤 미리보기 (전체 폭) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: px(TRACK_WIDTH_PX),
|
||||
background: trackGradientIdle,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체. */}
|
||||
{endGapPx > 0 && (
|
||||
<div
|
||||
title="재생 불가 구간 (종점역 미도착)"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: px(TRACK_WIDTH_PX - endGapPx),
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: px(endGapPx),
|
||||
background: '#54545c',
|
||||
borderRadius: 'inherit',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 재생된 구간: 원래 색 복원 (커서까지 clip, --pos-px CSS변수로 매 프레임 갱신). */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: `max(0px, min(${TRACK_WIDTH_PX}px, calc(var(--pos-px, ${TRACK_START_PX}px) - ${TRACK_START_PX}px)))`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: px(TRACK_WIDTH_PX),
|
||||
background: trackGradient,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 색(방향) 전환점 구분선 — videoplayer leg 솔기와 동일(검정+흰색 점선) */}
|
||||
{dividers.map((dpx, i) => (
|
||||
<div
|
||||
key={`div-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: dpx - TRACK_START_PX,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: '100%',
|
||||
transform: 'translateX(-1px)',
|
||||
borderLeft: '1px dashed rgba(0, 0, 0, 0.7)',
|
||||
borderRight: '1px dashed rgba(255, 255, 255, 0.3)',
|
||||
zIndex: 3,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 측점값 라벨 — 각 시설물 측점값(아이콘 위) */}
|
||||
<div className={styles.mileageRow}>
|
||||
{structs.map((s, i) =>
|
||||
s.km >= 0 && showMileage[i] ? (
|
||||
<MileageMarker
|
||||
key={`stkm-${i}`}
|
||||
marker={{ id: `stkm-${i}`, value: fmtKm(s.km), left: s.px, mileage: 0 }}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 구조물 아이콘 — 재생바 위에 겹쳐 표시 (videoplayer 3-slice) */}
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: 4, pointerEvents: 'none' }}>
|
||||
{structs.map((s, i) => {
|
||||
// 두 상태(무채색 upcoming / 유채색 passed) 아이콘을 겹쳐두고 opacity 로 전환 →
|
||||
// 배경이미지 교체가 없어 전환 순간 깜빡임(사라짐)이 없다.
|
||||
// 역사/터미널 → 원형 아이콘
|
||||
if (s.category === '역사' || s.category === '역') {
|
||||
const D = 18;
|
||||
// 라벨과 동일 트리거(라벨 W=22 → s.px-11). 아이콘·라벨 동시 전환.
|
||||
// 미도착(종점역): 영상이 도달 못 함 → 항상 '예정(무채색)' 상태로 고정.
|
||||
// (불투명 아이콘이 뒤의 재생불가 빗금을 덮도록 opacity 1 유지.)
|
||||
const passed = s.unreached ? false : posPx >= s.px - 11;
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const common = {
|
||||
position: 'absolute' as const,
|
||||
left: s.px - D / 2,
|
||||
top: 40.5 - D / 2,
|
||||
width: D,
|
||||
height: D,
|
||||
transition: 'opacity 0.12s',
|
||||
};
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<img src={`${base}assets/route-segment/terminal/circle-upcoming.png`} alt="" style={{ ...common, opacity: passed ? 0 : 1 }} />
|
||||
<img src={`${base}assets/route-segment/terminal/circle-passed.png`} alt="" style={{ ...common, opacity: passed ? 1 : 0 }} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
const segType: StructureType | null =
|
||||
s.category === '터널' ? 'tunnel' : s.category === '교량' ? 'bridge' : null;
|
||||
if (!segType) return null;
|
||||
// 원본(videoplayer) 치수: 교량 22×16/cap6/top32.5, 터널 24×14/cap10/top33.75
|
||||
const isTunnel = segType === 'tunnel';
|
||||
const W = isTunnel ? 24 : 22;
|
||||
const H = isTunnel ? 14 : 16;
|
||||
const CAP = isTunnel ? 10 : 6;
|
||||
const TOP = isTunnel ? 33.75 : 32.5;
|
||||
const passed = posPx >= s.px - W / 2;
|
||||
const seg: StructureSegment = {
|
||||
id: `st-${i}`,
|
||||
type: segType,
|
||||
label: s.title,
|
||||
startMileage: 0,
|
||||
endMileage: 0,
|
||||
left: s.px - W / 2,
|
||||
top: TOP,
|
||||
width: W,
|
||||
height: H,
|
||||
capWidth: CAP,
|
||||
centerHeight: H,
|
||||
passedAtPx: 0,
|
||||
};
|
||||
const layer = (op: number) =>
|
||||
({ position: 'absolute' as const, inset: 0, opacity: op, transition: 'opacity 0.12s' });
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<div style={layer(passed ? 0 : 1)}><RouteSegment segment={seg} state="upcoming" /></div>
|
||||
<div style={layer(passed ? 1 : 0)}><RouteSegment segment={seg} state="passed" /></div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 구조물 클릭 핫스팟 제거 — 스테이션바 클릭은 속성 팝업을 띄우지 않고 seek 으로만 동작.
|
||||
(POI 정보는 화면 오버레이 라벨 클릭에서 확인) */}
|
||||
|
||||
{/* 동일 항목 묶음 — 각 통과 위치를 수직 점선으로 내려, 라벨 '문자 세로 중앙'을 지나는
|
||||
수평 점선으로 잇는다. 수평선은 라벨 뒤로 지나가 글자 양옆으로만 보인다(두번째 참고 이미지).
|
||||
z-index 미지정(auto) → DOM 상 뒤의 labelRow 가 위에 그려져 글자가 선을 덮는다. */}
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||
{labelGroups
|
||||
.filter((g) => g.bracketPxs.length > 1)
|
||||
.map((g, gi) => {
|
||||
const lo = g.bracketPxs[0];
|
||||
const hi = g.bracketPxs[g.bracketPxs.length - 1];
|
||||
// 라벨: 절대 top 53, 줄높이 18px(structName). 문자 세로 중앙 y = 53 + 줄수*9.
|
||||
const lineCount = splitStructLabel(g.title).length;
|
||||
const lineY = 53 + lineCount * 9;
|
||||
const dash = '1.5px dashed rgba(232, 232, 232, 0.6)';
|
||||
return (
|
||||
<Fragment key={`grp-${gi}`}>
|
||||
{/* 문자 세로 중앙을 지나는 수평 점선 (라벨 뒤) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: lo,
|
||||
top: lineY,
|
||||
width: hi - lo,
|
||||
height: 0,
|
||||
borderTop: dash,
|
||||
}}
|
||||
/>
|
||||
{/* 각 통과 수직 점선 (트랙 하단 → 수평선) */}
|
||||
{g.bracketPxs.map((bp, j) => (
|
||||
<div
|
||||
key={j}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: bp,
|
||||
top: 46,
|
||||
width: 0,
|
||||
height: lineY - 46,
|
||||
borderLeft: dash,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄.
|
||||
같은 항목 다중통과는 labelGroups(클러스터)로 묶여 클러스터당 라벨 1개만 표출. */}
|
||||
<div className={styles.labelRow}>
|
||||
{labelGroups.map((g, i) => {
|
||||
const W = g.category === '터널' ? 24 : 22;
|
||||
// 미도착 종점역은 항상 미통과(neutral) 색 유지.
|
||||
const passed = g.unreached ? false : posPx >= g.centerPx - W / 2;
|
||||
// 폭 처리: 긴 이름은 2줄(균등 분할)로 폭을 줄여 표시. 그래도 가까워 겹치면 그대로 둠.
|
||||
const lines = splitStructLabel(g.title);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.segmentLabel} ${styles.structName} ${passed ? styles.accent : styles.neutral}`}
|
||||
style={cssVars({ '--x': px(g.centerPx) })}
|
||||
title={`${g.category} · ${g.title} (${fmtKm(g.km)})`}
|
||||
>
|
||||
{lines.length === 1 ? lines[0] : <>{lines[0]}<br />{lines[1]}</>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.seekArea} onMouseDown={onSeekDown} />
|
||||
</div>
|
||||
|
||||
{/* 구조물 속성 팝업 — 클릭 좌표 기준 fixed(트랙 래퍼 밖). 배경 클릭 시 닫힘. */}
|
||||
{sel && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={() => setSel(null)}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 999 }}
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: Math.min(sel.x + 4, window.innerWidth - 264),
|
||||
top: Math.max(8, Math.min(sel.y - 8, window.innerHeight - 220)),
|
||||
zIndex: 1000,
|
||||
maxWidth: 260,
|
||||
maxHeight: '45vh',
|
||||
overflowY: 'auto',
|
||||
background: 'rgba(0,0,0,0.92)',
|
||||
border: '1px solid rgba(16,185,129,0.7)',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
|
||||
<b style={{ color: '#6ee7b7', wordBreak: 'break-all' }}>📍 {sel.s.title}</b>
|
||||
<span onMouseDown={() => setSel(null)} style={{ cursor: 'pointer', color: '#9ca3af' }}>✕</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 8, rowGap: 2, fontFamily: 'monospace', fontSize: 10, color: '#d1d5db' }}>
|
||||
{(sel.s.props ?? []).map((p, i) => (
|
||||
<Fragment key={i}>
|
||||
<span style={{ color: '#6b7280', whiteSpace: 'nowrap' }}>{p.k}</span>
|
||||
<span style={{ wordBreak: 'break-all' }}>{p.v}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.cursor {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
transform: translateX(var(--cursor-x, 0px));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 991px;
|
||||
width: 2px;
|
||||
height: 37px;
|
||||
background: var(--color-cursor);
|
||||
z-index: 40;
|
||||
/* 라인을 직접 잡아 드래그 가능하게 */
|
||||
pointer-events: auto;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
/* 패널 상단(스테이지 980) 근처까지 키움 — 위 도구모음과는 안 겹치게 top 은 974 까지만. */
|
||||
top: 974px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 41;
|
||||
/* 뱃지를 직접 잡아 드래그 가능하게 (라인과 동일) */
|
||||
pointer-events: auto;
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
background: linear-gradient(180deg, #ff6a35 0%, #f8430d 45%, #ef3c08 100%);
|
||||
border-radius: 7px;
|
||||
height: 36px;
|
||||
min-width: 112px;
|
||||
padding: 0 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
0 2px 6px rgba(0, 0, 0, 0.45);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -7px;
|
||||
transform: translateX(-50%);
|
||||
border-left: 7px solid transparent;
|
||||
border-right: 7px solid transparent;
|
||||
border-top: 8px solid #ef3c08;
|
||||
}
|
||||
}
|
||||
|
||||
/* 역방향(km 감소, 하늘색 구간) 통과 시 커서도 동일한 파란색으로 표시 */
|
||||
.cursor.reverse {
|
||||
.line {
|
||||
background: #06a4c8;
|
||||
}
|
||||
.badge {
|
||||
background: linear-gradient(180deg, #3fb6d4 0%, #0a9bc0 45%, #067f9e 100%);
|
||||
|
||||
&::after {
|
||||
border-top-color: #067f9e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import styles from './TimelineCursor.module.scss';
|
||||
|
||||
interface TimelineCursorProps {
|
||||
mileageText: string;
|
||||
/** 현재 위치가 역방향(km 감소) 구간이면 커서를 파란색으로. */
|
||||
reverse?: boolean;
|
||||
/** 라인·뱃지를 직접 드래그해 이동시킬 때 호출 (seekArea와 동일 핸들러). */
|
||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
// 커서 x 위치(--cursor-x)는 상위(StationBar wrapRef)에서 rAF로 직접 갱신한다(transform).
|
||||
export function TimelineCursor({ mileageText, reverse, onSeekDown }: TimelineCursorProps) {
|
||||
return (
|
||||
<div className={`${styles.cursor} ${reverse ? styles.reverse : ''}`}>
|
||||
<div className={styles.line} onMouseDown={onSeekDown} />
|
||||
<div className={styles.badge} onMouseDown={onSeekDown}>{mileageText}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { RouteProgressState } from '../types/timeline';
|
||||
import { asset } from '../utils/asset';
|
||||
|
||||
const STRUCTURE_STATES: RouteProgressState[] = ['upcoming', 'passed', 'revisit'];
|
||||
|
||||
// 교량/터널 3분할 이미지는 RouteSegment.module.scss 에서 직접 url() 로 쓴다.
|
||||
|
||||
export function terminalCircleAsset(
|
||||
state: Extract<RouteProgressState, 'upcoming' | 'passed' | 'revisit'>,
|
||||
): string {
|
||||
return asset(`/assets/route-segment/terminal/circle-${state}.png`);
|
||||
}
|
||||
|
||||
export function isStructureAssetState(
|
||||
state: RouteProgressState,
|
||||
): state is Extract<RouteProgressState, 'upcoming' | 'passed' | 'revisit'> {
|
||||
return STRUCTURE_STATES.includes(state);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MileageMarkerSpec } from '../types/timeline';
|
||||
|
||||
/** Mileage figures along the top edge of the timeline bar. */
|
||||
export const MILEAGE_MARKERS: MileageMarkerSpec[] = [
|
||||
{ id: 'm-158k400', value: '158k400', left: 349.5, mileage: 158400 },
|
||||
{ id: 'm-158k000', value: '158k000', left: 412.5, mileage: 158000 },
|
||||
{ id: 'm-163k500', value: '163k500', left: 995, mileage: 163500 },
|
||||
{ id: 'm-161k800', value: '161k800', left: 1186, mileage: 161800 },
|
||||
{ id: 'm-163k100', value: '163k100', left: 1332, mileage: 163100 },
|
||||
{ id: 'm-162k300', value: '162k300', left: 1420.5, mileage: 162300 },
|
||||
{ id: 'm-163k400', value: '163k400', left: 1544, mileage: 163400 },
|
||||
{ id: 'm-162k100-a', value: '162k100', left: 1688.5, mileage: 162100 },
|
||||
{ id: 'm-162k600', value: '162k600', left: 1744, mileage: 162600 },
|
||||
{ id: 'm-162k100-b', value: '162k100', left: 1803, mileage: 162100 },
|
||||
];
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { RouteLeg } from '../types/timeline';
|
||||
|
||||
export const STAGE_WIDTH = 1920;
|
||||
export const STAGE_HEIGHT = 1080;
|
||||
|
||||
/** Horizontal bounds of the seekable track on the design stage. */
|
||||
export const TRACK_START_PX = 297;
|
||||
export const TRACK_END_PX = 1806.5;
|
||||
export const TRACK_WIDTH_PX = TRACK_END_PX - TRACK_START_PX;
|
||||
|
||||
/**
|
||||
* 타임라인 시작/끝 화면 좌표 — 좌표계의 단일 소스(single source of truth).
|
||||
* 여백을 조정하려면 이 두 값만 바꾸면 된다. 압축 변환(scaleX/translateX),
|
||||
* 커서 renderX(), 시킹 역변환, 트랙 래퍼 CSS transform 이 모두 여기서 파생된다.
|
||||
*
|
||||
* 트랙 좌표(297~1806.5)를 화면상 TRACK_RENDER_START~END 로 매핑.
|
||||
* 두 역의 점 중앙 기준으로 좌우 대칭이라 양쪽 공백이 동일하게 유지된다.
|
||||
*/
|
||||
// 좌우 여백 기준 시작/끝점: 왼쪽 여백(컨트롤러→신탄진) 10px, 오른쪽 여백
|
||||
// (대전→화면끝) 16px. 양쪽을 안쪽으로 당긴 만큼 트랙 가로폭이 늘어난다.
|
||||
export const TRACK_RENDER_START_PX = 342;
|
||||
export const TRACK_RENDER_END_PX = 1836;
|
||||
|
||||
/** 압축 비율·오프셋은 위 시작/끝에서 자동 계산 (수동 동기화 불필요). */
|
||||
export const RENDER_SCALE_X =
|
||||
(TRACK_RENDER_END_PX - TRACK_RENDER_START_PX) / TRACK_WIDTH_PX;
|
||||
export const RENDER_TRANSLATE_X =
|
||||
TRACK_RENDER_START_PX - RENDER_SCALE_X * TRACK_START_PX;
|
||||
|
||||
/** 트랙 좌표 → 화면(스테이지) x좌표 변환. */
|
||||
export function renderX(px: number): number {
|
||||
return RENDER_TRANSLATE_X + RENDER_SCALE_X * px;
|
||||
}
|
||||
|
||||
/** 화면(스테이지) x좌표 → 트랙 좌표 역변환 (시킹용). */
|
||||
export function trackXFromRender(stageX: number): number {
|
||||
return (stageX - RENDER_TRANSLATE_X) / RENDER_SCALE_X;
|
||||
}
|
||||
|
||||
/** 트랙 래퍼(.trackWrapper)에 인라인으로 적용할 transform 문자열. */
|
||||
export const TRACK_WRAPPER_TRANSFORM = `translateX(${RENDER_TRANSLATE_X}px) scaleX(${RENDER_SCALE_X})`;
|
||||
|
||||
/** video_player 3840→1920 (×0.5) track chrome */
|
||||
export const TRACK_FRAME_TOP_PX = 17.5;
|
||||
export const TRACK_FRAME_HEIGHT_PX = 40.5;
|
||||
export const TRACK_BAR_TOP_PX = 24.5;
|
||||
export const TRACK_BAR_HEIGHT_PX = 12;
|
||||
export const TRACK_BAR_RADIUS_PX = 6;
|
||||
|
||||
/** Matches the "진행 초기" mockup: cursor at 158k200 (on leg-02). */
|
||||
export const INITIAL_POS_PX = 381.25;
|
||||
|
||||
/** Cursor speed while playing, in design px per second. */
|
||||
export const PLAY_SPEED_PX_PER_SEC = 17;
|
||||
|
||||
/**
|
||||
* Ten driving legs measured from the 4K mockup. Direction alternates at each
|
||||
* turn; mileage anchors are interpolated linearly between calibration points.
|
||||
*/
|
||||
export const ROUTE_LEGS: RouteLeg[] = [
|
||||
{
|
||||
id: 'leg-01',
|
||||
direction: 'down',
|
||||
startPx: 297,
|
||||
endPx: 349.5,
|
||||
anchors: [
|
||||
{ px: 297, mileage: 158700 },
|
||||
{ px: 349.5, mileage: 158400 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leg-02',
|
||||
direction: 'up',
|
||||
startPx: 349.5,
|
||||
endPx: 413,
|
||||
anchors: [
|
||||
{ px: 349.5, mileage: 158400 },
|
||||
{ px: 413, mileage: 158000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leg-03',
|
||||
direction: 'down',
|
||||
startPx: 413,
|
||||
endPx: 996,
|
||||
anchors: [
|
||||
{ px: 413, mileage: 158000 },
|
||||
{ px: 622, mileage: 160100 },
|
||||
{ px: 913.5, mileage: 162100 },
|
||||
{ px: 996, mileage: 162700 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leg-04',
|
||||
direction: 'up',
|
||||
startPx: 996,
|
||||
endPx: 1189,
|
||||
anchors: [
|
||||
{ px: 996, mileage: 163500 },
|
||||
{ px: 1189, mileage: 161800 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leg-05',
|
||||
direction: 'down',
|
||||
startPx: 1189,
|
||||
endPx: 1333,
|
||||
anchors: [
|
||||
{ px: 1189, mileage: 161800 },
|
||||
{ px: 1333, mileage: 163100 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leg-06',
|
||||
direction: 'up',
|
||||
startPx: 1333,
|
||||
endPx: 1423,
|
||||
anchors: [
|
||||
{ px: 1333, mileage: 163100 },
|
||||
{ px: 1423, mileage: 162300 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leg-07',
|
||||
direction: 'down',
|
||||
startPx: 1423,
|
||||
endPx: 1545,
|
||||
anchors: [
|
||||
{ px: 1423, mileage: 162300 },
|
||||
{ px: 1545, mileage: 163400 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leg-08',
|
||||
direction: 'up',
|
||||
startPx: 1545,
|
||||
endPx: 1691,
|
||||
anchors: [
|
||||
{ px: 1545, mileage: 163400 },
|
||||
{ px: 1691, mileage: 162100 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leg-09',
|
||||
direction: 'down',
|
||||
startPx: 1691,
|
||||
endPx: 1746,
|
||||
anchors: [
|
||||
{ px: 1691, mileage: 162100 },
|
||||
{ px: 1746, mileage: 162600 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leg-10',
|
||||
direction: 'up',
|
||||
startPx: 1746,
|
||||
endPx: 1806.5,
|
||||
anchors: [
|
||||
{ px: 1746, mileage: 162600 },
|
||||
{ px: 1806.5, mileage: 162100 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const ROUTE_INFO = {
|
||||
direction: '하 행',
|
||||
routeName: '회덕-대전조차장',
|
||||
lengthKm: '4.25',
|
||||
duration: '11분 12초',
|
||||
} as const;
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { StructureSegment, TerminalMarker } from '../types/timeline';
|
||||
import { mileageAtPx } from '../utils/mileage';
|
||||
|
||||
const BRIDGE_LEFT_PX = 339;
|
||||
const BRIDGE_WIDTH_PX = 22;
|
||||
const TUNNEL_LEFT_PX = 610;
|
||||
const TUNNEL_WIDTH_PX = 24;
|
||||
|
||||
/**
|
||||
* 메인 교량은 시작점(좌측)을 지나면 색이 바뀌고(passed), 우측 끝을 벗어나면
|
||||
* 뒤의 반복 교량과 모양을 교환한다(메인 → revisit, 반복 → passed).
|
||||
*/
|
||||
export const BRIDGE_PASSED_AT_PX = BRIDGE_LEFT_PX; // 339 — 시작점 통과 시 색 변경
|
||||
/**
|
||||
* 아이콘을 완전히 지난 뒤(우측 끝 361), 다음 방향 전환 지점에서 반전한다.
|
||||
* 메인 교량은 leg-02(상행)에 걸쳐 있고, 그 다음 상행→하행 전환점은 leg-03 시작(413).
|
||||
*/
|
||||
export const BRIDGE_SWAP_AT_PX = 413;
|
||||
// 커서가 터널 아이콘 좌측 끝과 교차하는 순간부터 색이 바뀐다.
|
||||
export const TUNNEL_PASSED_AT_PX = TUNNEL_LEFT_PX; // 610
|
||||
|
||||
/** video_player INACTIVE_BOW ×0.5 — 항상 테두리만 있는 회색 교량. */
|
||||
const INACTIVE_BOW_LEFT_PX = 446.5;
|
||||
const INACTIVE_BOW_WIDTH_PX = 22;
|
||||
|
||||
/** 라벨·점선 정렬 기준이 되는 교량 아이콘 가로 중앙 좌표. */
|
||||
export const SINDAE_BRIDGE_CENTER_PX = BRIDGE_LEFT_PX + BRIDGE_WIDTH_PX / 2;
|
||||
export const INACTIVE_BOW_CENTER_PX =
|
||||
INACTIVE_BOW_LEFT_PX + INACTIVE_BOW_WIDTH_PX / 2;
|
||||
|
||||
/** Three-slice bridge/tunnel markers drawn on top of the track. */
|
||||
export const STRUCTURE_SEGMENTS: StructureSegment[] = [
|
||||
{
|
||||
id: 'bridge-sindaecheon',
|
||||
type: 'bridge',
|
||||
label: '신대천과선교',
|
||||
startMileage: mileageAtPx(BRIDGE_LEFT_PX),
|
||||
endMileage: mileageAtPx(BRIDGE_LEFT_PX + BRIDGE_WIDTH_PX),
|
||||
left: BRIDGE_LEFT_PX,
|
||||
top: 32.5, // 높이 16 짝수화 후 중앙(40.5) 유지
|
||||
width: BRIDGE_WIDTH_PX,
|
||||
height: 16,
|
||||
capWidth: 6,
|
||||
centerHeight: 16, // 센터 높이를 양옆 캡(= height 16)과 동일하게
|
||||
passedAtPx: BRIDGE_PASSED_AT_PX,
|
||||
swapAtPx: BRIDGE_SWAP_AT_PX,
|
||||
},
|
||||
{
|
||||
id: 'bridge-inactive',
|
||||
type: 'bridge',
|
||||
label: '미통과 교량',
|
||||
startMileage: mileageAtPx(INACTIVE_BOW_LEFT_PX),
|
||||
endMileage: mileageAtPx(INACTIVE_BOW_LEFT_PX + INACTIVE_BOW_WIDTH_PX),
|
||||
left: INACTIVE_BOW_LEFT_PX,
|
||||
top: 32.5, // 높이 16 짝수화 후 중앙(40.5) 유지
|
||||
width: INACTIVE_BOW_WIDTH_PX,
|
||||
height: 16,
|
||||
capWidth: 6,
|
||||
centerHeight: 16, // 센터 높이를 양옆 캡(= height 16)과 동일하게
|
||||
// 같은 구간을 두 번째로 지나는 교량:
|
||||
// ① 처음엔 반전 아이콘(revisit)
|
||||
// ② 방향 전환점(413)을 지나면 일반 회색(upcoming)
|
||||
// ③ 커서가 좌측 끝(446.5)과 교차하면 주황(passed)
|
||||
appearance: 'revisit',
|
||||
flipAtPx: BRIDGE_SWAP_AT_PX,
|
||||
passedAtPx: INACTIVE_BOW_LEFT_PX,
|
||||
},
|
||||
{
|
||||
id: 'tunnel-hoedeok',
|
||||
type: 'tunnel',
|
||||
label: '회덕터널(하)',
|
||||
startMileage: mileageAtPx(TUNNEL_LEFT_PX),
|
||||
endMileage: mileageAtPx(TUNNEL_LEFT_PX + TUNNEL_WIDTH_PX),
|
||||
left: TUNNEL_LEFT_PX,
|
||||
top: 33.75, // 높이 14 짝수화 후 중앙(40.75) 유지
|
||||
width: TUNNEL_WIDTH_PX,
|
||||
height: 14,
|
||||
capWidth: 10,
|
||||
centerHeight: 14, // 센터 높이를 양옆 캡(= height 14)과 동일하게
|
||||
passedAtPx: TUNNEL_PASSED_AT_PX,
|
||||
},
|
||||
];
|
||||
|
||||
/** Terminal circles. Passed markers keep their orange state permanently. */
|
||||
export const TERMINAL_MARKERS: TerminalMarker[] = [
|
||||
// 시작/종료 원형은 트랙 라인(297 / 1806.5)에 원 중앙이 오도록 배치
|
||||
// 사이즈 짝수화(13×13.5→14×14, 11×11.5→12×12), 원 중앙은 유지(left/top 보정)
|
||||
{ id: 'terminal-01', left: 290, top: 33.75, width: 14, height: 14, state: 'passed' },
|
||||
// 커서가 원 좌측 끝(906.5)과 교차하는 순간부터 passed(주황)로 바뀐다.
|
||||
{ id: 'terminal-02', left: 906.5, top: 33.75, width: 14, height: 14, state: 'passed', passedAtPx: 906.5 },
|
||||
{ id: 'terminal-03', left: 1148, top: 34.25, width: 12, height: 12, state: 'revisit' },
|
||||
{ id: 'terminal-04', left: 1216.5, top: 34.25, width: 12, height: 12, state: 'revisit' },
|
||||
{ id: 'terminal-05', left: 1685, top: 34.25, width: 12, height: 12, state: 'revisit' },
|
||||
{ id: 'terminal-06', left: 1799.5, top: 33.75, width: 14, height: 14, state: 'passed' },
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { SegmentLabel, ZoneRule } from '../types/timeline';
|
||||
import { TRACK_END_PX, TRACK_START_PX } from './route';
|
||||
import {
|
||||
BRIDGE_PASSED_AT_PX,
|
||||
INACTIVE_BOW_CENTER_PX,
|
||||
SINDAE_BRIDGE_CENTER_PX,
|
||||
TERMINAL_MARKERS,
|
||||
TUNNEL_PASSED_AT_PX,
|
||||
} from './segments';
|
||||
|
||||
/** 터미널 원 중앙 x좌표 — 존 점선이 원 중앙에 걸리도록 파생. */
|
||||
function terminalCenter(id: string): number {
|
||||
const marker = TERMINAL_MARKERS.find((m) => m.id === id);
|
||||
if (!marker) throw new Error(`Unknown terminal marker: ${id}`);
|
||||
return marker.left + marker.width / 2;
|
||||
}
|
||||
|
||||
/** Section / structure names below the track. */
|
||||
export const SEGMENT_LABELS: SegmentLabel[] = [
|
||||
// 텍스트 센터를 트랙 시작점에 맞춤 (중앙 정렬)
|
||||
{ text: '회덕', left: TRACK_START_PX, color: 'fixed-accent' },
|
||||
{
|
||||
text: '신대천과선교',
|
||||
left: (SINDAE_BRIDGE_CENTER_PX + INACTIVE_BOW_CENTER_PX) / 2,
|
||||
color: { dynamic: BRIDGE_PASSED_AT_PX },
|
||||
},
|
||||
{ text: '회덕터널(하)', left: 622, color: { dynamic: TUNNEL_PASSED_AT_PX } },
|
||||
{
|
||||
text: '대전조차장',
|
||||
left: 1358,
|
||||
color: { dynamic: terminalCenter('terminal-02') },
|
||||
},
|
||||
{
|
||||
// 텍스트 센터를 트랙 끝점에 맞춤 (중앙 정렬)
|
||||
text: '대전조차장',
|
||||
left: TRACK_END_PX,
|
||||
color: 'fixed-accent',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Dashed zone connectors under the track. 첫/끝 앵커 = 박스 양 끝,
|
||||
* 중간 앵커 = 세로 점선만 내려와 아이콘들이 한 줄로 연결돼 보인다.
|
||||
*/
|
||||
export const LABEL_ZONES: ZoneRule[] = [
|
||||
{ anchors: [SINDAE_BRIDGE_CENTER_PX, INACTIVE_BOW_CENTER_PX] },
|
||||
{
|
||||
anchors: [
|
||||
terminalCenter('terminal-02'),
|
||||
terminalCenter('terminal-03'),
|
||||
terminalCenter('terminal-04'),
|
||||
terminalCenter('terminal-05'),
|
||||
terminalCenter('terminal-06'),
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
/* videoplayer-main globals.scss 의 디자인 토큰(:root)만 이식.
|
||||
html/body 리셋·box-sizing 은 abcVideo(Tailwind preflight)가 이미 제공하므로 제외. */
|
||||
:root {
|
||||
--color-stage-bg: #0d0b08;
|
||||
--color-cursor: #ed4a17;
|
||||
--color-accent: #ff9447;
|
||||
--color-label: #f3f0ea;
|
||||
--color-label-outline: #241409;
|
||||
--color-track-down: #7a7a7a;
|
||||
--color-track-up: #637789;
|
||||
--color-track-divider: #241d12;
|
||||
--color-station: #b7b5b0;
|
||||
--color-station-leader: #8f8c85;
|
||||
--color-station-dot: #98958e;
|
||||
--color-input-text: #d8d2c6;
|
||||
--color-input-placeholder: #878074;
|
||||
--color-projection-line: rgba(46, 221, 255, 0.85);
|
||||
--color-projection-text: #aef4ff;
|
||||
--color-projection-bg: rgba(10, 30, 36, 0.72);
|
||||
--color-projection-border: rgba(46, 221, 255, 0.55);
|
||||
|
||||
--gradient-track-down-passed: linear-gradient(90deg, #ffc257, #ff8a25 45%, #ff7b1b);
|
||||
--gradient-track-up-passed: linear-gradient(90deg, #5ca887, #35a7a7 55%, #06a4c8);
|
||||
|
||||
--font-ui: 'Noto Sans KR', sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
export type Direction = 'down' | 'up';
|
||||
|
||||
export type RouteProgressState = 'upcoming' | 'current' | 'passed' | 'revisit';
|
||||
|
||||
export type SegmentType = 'bridge' | 'tunnel' | 'station' | 'normal' | 'obstacle';
|
||||
|
||||
export type StructureType = Extract<SegmentType, 'bridge' | 'tunnel'>;
|
||||
|
||||
/** Timeline mileage label with interaction states. */
|
||||
export interface MileageMarkerSpec {
|
||||
id: string;
|
||||
value: string;
|
||||
/** Design-stage px position. */
|
||||
left: number;
|
||||
/** Mileage in meters for progress resolution. */
|
||||
mileage: number;
|
||||
active?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export type StructureAppearance = 'dynamic' | 'passed' | 'revisit';
|
||||
|
||||
/** A px↔mileage calibration point on the 1920px design stage. */
|
||||
export interface MileageAnchor {
|
||||
px: number;
|
||||
/** Mileage in meters (158200 → "158k200"). */
|
||||
mileage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One driving leg of the route. Mileage is non-monotonic across legs because
|
||||
* the route contains turns; each leg carries its own piecewise-linear mapping.
|
||||
*/
|
||||
export interface RouteLeg {
|
||||
id: string;
|
||||
direction: Direction;
|
||||
startPx: number;
|
||||
endPx: number;
|
||||
anchors: MileageAnchor[];
|
||||
}
|
||||
|
||||
/** A bridge/tunnel drawn with a three-slice (left cap / stretch / right cap) asset. */
|
||||
export interface StructureSegment {
|
||||
id: string;
|
||||
type: StructureType;
|
||||
label: string;
|
||||
startMileage: number;
|
||||
endMileage: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
capWidth: number;
|
||||
centerHeight: number;
|
||||
/** Cursor px at/after which the structure counts as passed (시작점 통과 시 색 변경). */
|
||||
passedAtPx: number;
|
||||
/**
|
||||
* 커서가 아이콘 우측 끝을 벗어나는 px. 설정 시 반복쌍이 모양을 교환한다:
|
||||
* 메인(dynamic) 아이콘은 이 지점을 지나면 passed → revisit,
|
||||
* 뒤의 반복 아이콘(appearance:'revisit')은 revisit → passed 로 바뀐다.
|
||||
*/
|
||||
swapAtPx?: number;
|
||||
/**
|
||||
* appearance:'revisit' 전용. 방향 전환 지점 px. 이 지점 전에는 반전(revisit)으로
|
||||
* 보이고, 지난 뒤에는 일반 upcoming → (passedAtPx) passed 로 진행한다.
|
||||
*/
|
||||
flipAtPx?: number;
|
||||
/** Fixed visual mode; default is cursor-driven upcoming/passed. */
|
||||
appearance?: StructureAppearance;
|
||||
}
|
||||
|
||||
export interface TerminalMarker {
|
||||
id: string;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
state: Extract<RouteProgressState, 'passed' | 'revisit'>;
|
||||
/** When set, the marker is dynamic: passed at/after this px, upcoming before. */
|
||||
passedAtPx?: number;
|
||||
}
|
||||
|
||||
export type SegmentLabelColor =
|
||||
| 'fixed-accent'
|
||||
| 'fixed-neutral'
|
||||
| { dynamic: number };
|
||||
|
||||
export interface SegmentLabel {
|
||||
text: string;
|
||||
left: number;
|
||||
/** When set, label is centered inside a fixed-width box (video_player Pos pattern). */
|
||||
width?: number;
|
||||
/**
|
||||
* 앵커 기준 정렬. 기본은 left 좌표 중앙 정렬.
|
||||
* 'end' = 텍스트 오른쪽 끝이 left에 닿음, 'start' = 텍스트 왼쪽 끝이 left에서 시작.
|
||||
*/
|
||||
anchor?: 'start' | 'end';
|
||||
/** fixed-* = Figma 고정색, dynamic = 커서 passedAtPx 이후 accent */
|
||||
color: SegmentLabelColor;
|
||||
}
|
||||
|
||||
/** Dashed-border zone box under the track (video_player "zone" pattern). */
|
||||
export interface ZoneRule {
|
||||
/** 연결 지점 x좌표(아이콘 중앙). 첫/끝 = 박스 양 끝, 중간 = 세로 점선. */
|
||||
anchors: number[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 정적 에셋(public) 절대경로에 Vite의 base(`import.meta.env.BASE_URL`)를 붙인다.
|
||||
* GitHub Pages 하위 경로(/videoplayer/) 배포에서도 JS로 만든 경로가 깨지지 않게 한다.
|
||||
* CSS url() 은 Vite가 자동으로 base를 붙이므로 이 헬퍼가 필요 없다.
|
||||
*/
|
||||
export function asset(path: string): string {
|
||||
return import.meta.env.BASE_URL + path.replace(/^\//, '');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* Bridges data-driven values (positions, widths) into SCSS modules as CSS
|
||||
* custom properties, keeping all declarative styling in the stylesheets.
|
||||
*/
|
||||
export function cssVars(vars: Record<`--${string}`, string | number>): CSSProperties {
|
||||
return vars as CSSProperties;
|
||||
}
|
||||
|
||||
export function px(value: number): string {
|
||||
return `${value}px`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ROUTE_LEGS } from '../mocks/route';
|
||||
import type { RouteLeg } from '../types/timeline';
|
||||
|
||||
const MILEAGE_ROUND_M = 10;
|
||||
const METERS_PER_KM = 1000;
|
||||
|
||||
/** Mileage in meters at a cursor px, via piecewise-linear leg anchors. */
|
||||
export function mileageAtPx(px: number, legs: RouteLeg[] = ROUTE_LEGS): number {
|
||||
const leg =
|
||||
legs.find((l) => px >= l.startPx && px <= l.endPx) ?? legs[legs.length - 1];
|
||||
const anchors = leg.anchors;
|
||||
for (let i = 0; i < anchors.length - 1; i++) {
|
||||
const a = anchors[i];
|
||||
const b = anchors[i + 1];
|
||||
if (px <= b.px || i === anchors.length - 2) {
|
||||
const t = Math.min(1, Math.max(0, (px - a.px) / (b.px - a.px)));
|
||||
return a.mileage + (b.mileage - a.mileage) * t;
|
||||
}
|
||||
}
|
||||
return anchors[0].mileage;
|
||||
}
|
||||
|
||||
/**
|
||||
* First px at which the given mileage occurs. Leg start anchors win so a jump
|
||||
* to a turn mileage lands on the start of that leg.
|
||||
*/
|
||||
export function pxForMileage(
|
||||
mileage: number,
|
||||
legs: RouteLeg[] = ROUTE_LEGS,
|
||||
): number | null {
|
||||
for (const leg of legs) {
|
||||
if (leg.anchors[0].mileage === mileage) return leg.startPx;
|
||||
}
|
||||
for (const leg of legs) {
|
||||
const anchors = leg.anchors;
|
||||
for (let i = 0; i < anchors.length - 1; i++) {
|
||||
const a = anchors[i];
|
||||
const b = anchors[i + 1];
|
||||
const lo = Math.min(a.mileage, b.mileage);
|
||||
const hi = Math.max(a.mileage, b.mileage);
|
||||
if (mileage >= lo && mileage <= hi) {
|
||||
return a.px + (b.px - a.px) * ((mileage - a.mileage) / (b.mileage - a.mileage));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 158204 → "158k200". */
|
||||
export function formatMileage(mileage: number): string {
|
||||
const rounded = Math.round(mileage / MILEAGE_ROUND_M) * MILEAGE_ROUND_M;
|
||||
const km = Math.floor(rounded / METERS_PER_KM);
|
||||
const m = rounded % METERS_PER_KM;
|
||||
return `${km}k${String(m).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** Accepts "158k200", "158200", "161800"… Returns meters or null. */
|
||||
export function parseMileageQuery(raw: string): number | null {
|
||||
const digits = raw.toLowerCase().replace(/k/g, '').replace(/[^0-9]/g, '');
|
||||
const mileage = parseInt(digits, 10);
|
||||
return Number.isFinite(mileage) && mileage > 0 ? mileage : null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MileageMarkerSpec } from '../types/timeline';
|
||||
|
||||
const SELECT_THRESHOLD_PX = 12;
|
||||
|
||||
/** Derives active (passed) and selected (near cursor) flags for mileage markers. */
|
||||
export function enrichMileageMarkers(
|
||||
markers: MileageMarkerSpec[],
|
||||
posPx: number,
|
||||
): MileageMarkerSpec[] {
|
||||
let selectedId: string | null = null;
|
||||
let minDist = Infinity;
|
||||
|
||||
for (const marker of markers) {
|
||||
const dist = Math.abs(posPx - marker.left);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
selectedId = marker.id;
|
||||
}
|
||||
}
|
||||
|
||||
return markers.map((marker) => ({
|
||||
...marker,
|
||||
active: posPx >= marker.left,
|
||||
selected: marker.id === selectedId && minDist <= SELECT_THRESHOLD_PX,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { RouteProgressState, StructureSegment, TerminalMarker } from '../types/timeline';
|
||||
|
||||
/** Resolves bridge/tunnel marker state from cursor position and segment metadata. */
|
||||
export function resolveStructureState(
|
||||
posPx: number,
|
||||
segment: StructureSegment,
|
||||
): Extract<RouteProgressState, 'upcoming' | 'passed' | 'revisit'> {
|
||||
if (segment.appearance === 'passed') {
|
||||
return 'passed';
|
||||
}
|
||||
if (segment.appearance === 'revisit') {
|
||||
// 같은 구간을 두 번째로 지나는 아이콘:
|
||||
// 방향 전환점(flipAtPx) 전엔 반전(revisit), 이후엔 일반 upcoming →
|
||||
// 커서가 지나면(passedAtPx) passed.
|
||||
if (segment.flipAtPx !== undefined) {
|
||||
if (posPx < segment.flipAtPx) return 'revisit';
|
||||
return posPx >= segment.passedAtPx ? 'passed' : 'upcoming';
|
||||
}
|
||||
// 반복쌍의 뒤 아이콘: 메인이 교환되는 지점을 지나면 passed, 그 전엔 revisit
|
||||
if (segment.swapAtPx !== undefined) {
|
||||
return posPx >= segment.swapAtPx ? 'passed' : 'revisit';
|
||||
}
|
||||
return posPx >= segment.passedAtPx ? 'revisit' : 'upcoming';
|
||||
}
|
||||
// 메인 아이콘: upcoming → (시작점 통과) passed → (아이콘 벗어남) revisit
|
||||
if (segment.swapAtPx !== undefined && posPx >= segment.swapAtPx) {
|
||||
return 'revisit';
|
||||
}
|
||||
return posPx >= segment.passedAtPx ? 'passed' : 'upcoming';
|
||||
}
|
||||
|
||||
/** Resolves terminal circle state. Passed terminals keep orange permanently. */
|
||||
export function resolveTerminalState(
|
||||
posPx: number,
|
||||
marker: TerminalMarker,
|
||||
): Extract<RouteProgressState, 'upcoming' | 'passed' | 'revisit'> {
|
||||
if (marker.state === 'revisit') return 'revisit';
|
||||
if (marker.state === 'passed' && marker.passedAtPx === undefined) return 'passed';
|
||||
if (marker.passedAtPx !== undefined) {
|
||||
return posPx >= marker.passedAtPx ? 'passed' : 'upcoming';
|
||||
}
|
||||
return marker.state;
|
||||
}
|
||||
|
||||
/** Derives segment progress state from mileage range and current mileage. */
|
||||
export function resolveSegmentStateFromMileage(
|
||||
currentMileage: number,
|
||||
startMileage: number,
|
||||
endMileage: number,
|
||||
appearance: StructureSegment['appearance'] = 'dynamic',
|
||||
): RouteProgressState {
|
||||
const lo = Math.min(startMileage, endMileage);
|
||||
const hi = Math.max(startMileage, endMileage);
|
||||
|
||||
if (appearance === 'passed') return 'passed';
|
||||
if (appearance === 'revisit') {
|
||||
return currentMileage >= lo ? 'revisit' : 'upcoming';
|
||||
}
|
||||
if (currentMileage > hi) return 'passed';
|
||||
if (currentMileage >= lo && currentMileage <= hi) return 'current';
|
||||
return 'upcoming';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { SegmentLabelColor } from '../types/timeline';
|
||||
|
||||
export type SegmentLabelTone = 'endpoint' | 'accent' | 'neutral';
|
||||
|
||||
/** Maps segment label color spec to SCSS tone classes. */
|
||||
export function segmentLabelTone(
|
||||
color: SegmentLabelColor,
|
||||
posPx: number,
|
||||
): SegmentLabelTone {
|
||||
if (color === 'fixed-accent') return 'endpoint';
|
||||
if (color === 'fixed-neutral') return 'neutral';
|
||||
return posPx >= color.dynamic ? 'accent' : 'neutral';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Annotation } from '@abcvideo/shared';
|
||||
|
||||
interface AnnotationStore {
|
||||
annotations: Annotation[];
|
||||
setAnnotations: (a: Annotation[]) => void;
|
||||
addAnnotation: (a: Annotation) => void;
|
||||
updateAnnotation: (id: string, a: Partial<Annotation>) => void;
|
||||
removeAnnotation: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useAnnotationStore = create<AnnotationStore>((set) => ({
|
||||
annotations: [],
|
||||
setAnnotations: (annotations) => set({ annotations }),
|
||||
addAnnotation: (annotation) =>
|
||||
set((s) => ({
|
||||
annotations: [...s.annotations, annotation].sort((a, b) => a.timeStart - b.timeStart),
|
||||
})),
|
||||
updateAnnotation: (id, update) =>
|
||||
set((s) => ({
|
||||
annotations: s.annotations.map((a) => (a.id === id ? { ...a, ...update } : a)),
|
||||
})),
|
||||
removeAnnotation: (id) =>
|
||||
set((s) => ({ annotations: s.annotations.filter((a) => a.id !== id) })),
|
||||
}));
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface CaptureItem {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
time: number;
|
||||
filename: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface CaptureStore {
|
||||
captures: CaptureItem[];
|
||||
addCapture: (item: CaptureItem) => void;
|
||||
removeCapture: (id: string) => void;
|
||||
clearCaptures: () => void;
|
||||
}
|
||||
|
||||
export const useCaptureStore = create<CaptureStore>((set) => ({
|
||||
captures: [],
|
||||
addCapture: (item) => set((s) => ({ captures: [item, ...s.captures] })),
|
||||
removeCapture: (id) => set((s) => ({ captures: s.captures.filter((c) => c.id !== id) })),
|
||||
clearCaptures: () => set({ captures: [] }),
|
||||
}));
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 클라이언트 지리정보 스토어 (Zustand)
|
||||
*
|
||||
* 폴더 선택으로 파싱한 드론 프레임 / POI / 측점 / 중심선 / ENU 원점을 보관한다.
|
||||
* 4개 소비 컴포넌트(GeoSearch / StationVerify / StationOverlay / RoutePanel)가
|
||||
* 이 스토어를 단일 소스로 구독한다(서버 /api/geo/* 대체).
|
||||
*
|
||||
* playerStore.ts 패턴을 따른다.
|
||||
*
|
||||
* POI 위치 보정(poiOverrides): 마우스 드래그로 만든 title→{lat,lon,z} 맵.
|
||||
* basePois(파싱 원본)에 applyPoiOverrides 를 적용한 결과가 pois 다.
|
||||
* 내보내기/가져오기 파일(`<base>_poi_overrides.json`)로 영속화한다.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
DroneFrame,
|
||||
GeoPoint,
|
||||
CenterlinePoint,
|
||||
GeoOrigin,
|
||||
RouteMeta,
|
||||
RouteStructure,
|
||||
DirectionChange,
|
||||
PoiOverride,
|
||||
PoiOverrideMap,
|
||||
} from '../types/geo';
|
||||
import { loadFolderGeoData, applyPoiOverrides } from '../utils/geoData';
|
||||
|
||||
interface GeoStore {
|
||||
loaded: boolean;
|
||||
frames: DroneFrame[];
|
||||
pois: GeoPoint[]; // type==='poi' (poiOverrides 적용 결과)
|
||||
basePois: GeoPoint[]; // 파싱 원본 (보정 전) — 보정 재적용용
|
||||
poiOverrides: PoiOverrideMap; // title → {lat,lon,z}
|
||||
stations: GeoPoint[]; // type==='station' (stationOrder 정렬)
|
||||
centerline: CenterlinePoint[];
|
||||
origin: GeoOrigin | null;
|
||||
baseName: string | null;
|
||||
routeMeta: RouteMeta | null;
|
||||
/** KMZ 원본 구조물(교량/터널/구교/역사) + route.json 보정. */
|
||||
structures: RouteStructure[];
|
||||
/** 측점 비고에서 추출한 방향전환점 목록. */
|
||||
directionChanges: DirectionChange[];
|
||||
/** 마지막 폴더 로드에서 KMZ(POI·구조물 원본)가 누락됐는지. true면 재구축 필요. */
|
||||
kmzMissing: boolean;
|
||||
|
||||
/**
|
||||
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
||||
* 발견한 영상 File 을 반환(없으면 null) — 호출자가 loadLocalFile 로 재생.
|
||||
*/
|
||||
loadFromFolder: (files: FileList | File[]) => Promise<File | null>;
|
||||
/** 단일 POI 보정 설정/갱신 (드래그 종료 시). title 키. */
|
||||
setPoiOverride: (title: string, ov: PoiOverride) => void;
|
||||
/** 단일 POI 보정 해제. */
|
||||
clearPoiOverride: (title: string) => void;
|
||||
/** 보정맵 전체 교체 (가져오기). */
|
||||
setPoiOverrides: (map: PoiOverrideMap) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const EMPTY = {
|
||||
loaded: false,
|
||||
frames: [] as DroneFrame[],
|
||||
pois: [] as GeoPoint[],
|
||||
basePois: [] as GeoPoint[],
|
||||
poiOverrides: {} as PoiOverrideMap,
|
||||
stations: [] as GeoPoint[],
|
||||
centerline: [] as CenterlinePoint[],
|
||||
origin: null as GeoOrigin | null,
|
||||
baseName: null as string | null,
|
||||
routeMeta: null as RouteMeta | null,
|
||||
structures: [] as RouteStructure[],
|
||||
directionChanges: [] as DirectionChange[],
|
||||
kmzMissing: false,
|
||||
};
|
||||
|
||||
export const useGeoStore = create<GeoStore>((set) => ({
|
||||
...EMPTY,
|
||||
|
||||
loadFromFolder: async (files) => {
|
||||
const data = await loadFolderGeoData(files);
|
||||
set({
|
||||
loaded: true,
|
||||
frames: data.frames,
|
||||
basePois: data.pois,
|
||||
poiOverrides: data.poiOverrides,
|
||||
pois: applyPoiOverrides(data.pois, data.poiOverrides),
|
||||
stations: data.stations,
|
||||
centerline: data.centerline,
|
||||
origin: data.origin,
|
||||
baseName: data.baseName,
|
||||
routeMeta: data.routeMeta,
|
||||
structures: data.structures,
|
||||
directionChanges: data.directionChanges,
|
||||
kmzMissing: data.kmzMissing,
|
||||
});
|
||||
return data.videoFile;
|
||||
},
|
||||
|
||||
setPoiOverride: (title, ov) =>
|
||||
set((s) => {
|
||||
const poiOverrides = { ...s.poiOverrides, [title]: ov };
|
||||
return { poiOverrides, pois: applyPoiOverrides(s.basePois, poiOverrides) };
|
||||
}),
|
||||
|
||||
clearPoiOverride: (title) =>
|
||||
set((s) => {
|
||||
const poiOverrides = { ...s.poiOverrides };
|
||||
delete poiOverrides[title];
|
||||
return { poiOverrides, pois: applyPoiOverrides(s.basePois, poiOverrides) };
|
||||
}),
|
||||
|
||||
setPoiOverrides: (map) =>
|
||||
set((s) => ({ poiOverrides: map, pois: applyPoiOverrides(s.basePois, map) })),
|
||||
|
||||
clear: () => set({ ...EMPTY }),
|
||||
}));
|
||||
@@ -0,0 +1,56 @@
|
||||
import { create } from 'zustand';
|
||||
import type { VideoSource } from '../types/player';
|
||||
|
||||
interface PlayerStore {
|
||||
source: VideoSource | null;
|
||||
playing: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
playbackRate: number;
|
||||
hlsReady: boolean;
|
||||
/** 영상 첫 프레임이 표시 가능(loadeddata)해진 후 true. 오버레이를 영상보다 먼저 그리지 않도록 게이트. */
|
||||
videoReady: boolean;
|
||||
/** 영상 원본(intrinsic) 해상도 (px). object-fit:cover 크롭 영역 계산 → 오버레이 정렬용. */
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
setSource: (source: VideoSource | null) => void;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (t: number) => void;
|
||||
setDuration: (d: number) => void;
|
||||
setFps: (fps: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
setMuted: (m: boolean) => void;
|
||||
setPlaybackRate: (r: number) => void;
|
||||
setHlsReady: (ready: boolean) => void;
|
||||
setVideoReady: (ready: boolean) => void;
|
||||
setVideoSize: (w: number, h: number) => void;
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerStore>((set) => ({
|
||||
source: null,
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
fps: 30,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
playbackRate: 1,
|
||||
hlsReady: false,
|
||||
videoReady: false,
|
||||
videoWidth: 0,
|
||||
videoHeight: 0,
|
||||
setSource: (source) => set({ source, hlsReady: false, videoReady: false }),
|
||||
setPlaying: (playing) => set({ playing }),
|
||||
setCurrentTime: (currentTime) => set({ currentTime }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
setFps: (fps) => set({ fps }),
|
||||
setVolume: (volume) => set({ volume }),
|
||||
setMuted: (muted) => set({ muted }),
|
||||
setPlaybackRate: (playbackRate) => set({ playbackRate }),
|
||||
setHlsReady: (hlsReady) => set({ hlsReady }),
|
||||
setVideoReady: (videoReady) => set({ videoReady }),
|
||||
setVideoSize: (videoWidth, videoHeight) => set({ videoWidth, videoHeight }),
|
||||
}));
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 사용자 설정 스토어 (Zustand + localStorage 지속)
|
||||
*
|
||||
* 현재는 구조물 시설종별(1종/2종/3종) 표시 필터만 보관한다.
|
||||
* VideoPlayer 의 체크박스 UI 가 토글하고, StationBar(재생바)/RoutePanel(좌측 목록)
|
||||
* 이 구독해 표시 여부를 결정한다.
|
||||
*
|
||||
* 지속: zustand persist 미들웨어. localStorage 키 'ghivideo.settings'.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
/** 필터에서 관리하는 시설종별 집합. 이 외(예 '인상' 등)는 항상 표시. */
|
||||
export const KNOWN_GRADES = ['1종', '2종', '3종', '기타'] as const;
|
||||
|
||||
interface SettingsStore {
|
||||
/** 시설종별별 표시 여부. 기본 1종☑ 2종☑ 3종☐ 기타☐ (초기 구동 시 1·2종만 체크). */
|
||||
gradeFilter: Record<string, boolean>;
|
||||
/** 특정 시설종별 체크 토글. */
|
||||
setGradeFilter: (grade: string, checked: boolean) => void;
|
||||
/** 화면 POI 겹침제외(겹치면 1개만). true=겹친 것 숨김(기본), false=모든 POI 표시. */
|
||||
poiOverlapExclude: boolean;
|
||||
setPoiOverlapExclude: (v: boolean) => void;
|
||||
/** 좌측 노선 패널(RoutePanel) 표시 여부. '화면표시 옵션' 토글로 제어. 기본 숨김(OFF). */
|
||||
showRoutePanel: boolean;
|
||||
setShowRoutePanel: (v: boolean) => void;
|
||||
/** 측점 진단 HUD(드론 GPS·투영측점) 하단바 표시 여부. 기본 off. */
|
||||
showStationDiag: boolean;
|
||||
setShowStationDiag: (v: boolean) => void;
|
||||
/** 선형(중심선) 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
|
||||
showCenterline: boolean;
|
||||
setShowCenterline: (v: boolean) => void;
|
||||
/** 드론 궤적 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
|
||||
showDronePath: boolean;
|
||||
setShowDronePath: (v: boolean) => void;
|
||||
/** 우측 상단 나침반 타입. 'analog'=눈금 나침반, 'map'=지도(노스업, 현재위치). 기본 map. */
|
||||
compassType: 'analog' | 'map';
|
||||
setCompassType: (v: 'analog' | 'map') => void;
|
||||
/** 지도 나침반 배경. 'street'=OSM 일반, 'sat'=위성(Esri). 기본 street. */
|
||||
compassMapStyle: 'street' | 'sat';
|
||||
setCompassMapStyle: (v: 'street' | 'sat') => void;
|
||||
}
|
||||
|
||||
const DEFAULT_GRADE_FILTER: Record<string, boolean> = {
|
||||
'1종': true,
|
||||
'2종': true,
|
||||
'3종': false,
|
||||
'기타': false,
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
gradeFilter: { ...DEFAULT_GRADE_FILTER },
|
||||
setGradeFilter: (grade, checked) =>
|
||||
set((s) => ({ gradeFilter: { ...s.gradeFilter, [grade]: checked } })),
|
||||
poiOverlapExclude: true,
|
||||
setPoiOverlapExclude: (v) => set({ poiOverlapExclude: v }),
|
||||
showRoutePanel: false,
|
||||
setShowRoutePanel: (v) => set({ showRoutePanel: v }),
|
||||
showStationDiag: false,
|
||||
setShowStationDiag: (v) => set({ showStationDiag: v }),
|
||||
showCenterline: true,
|
||||
setShowCenterline: (v) => set({ showCenterline: v }),
|
||||
showDronePath: true,
|
||||
setShowDronePath: (v) => set({ showDronePath: v }),
|
||||
compassType: 'map',
|
||||
setCompassType: (v) => set({ compassType: v }),
|
||||
compassMapStyle: 'street',
|
||||
setCompassMapStyle: (v) => set({ compassMapStyle: v }),
|
||||
}),
|
||||
{
|
||||
name: 'ghivideo.settings',
|
||||
// 시작 기본값 변경(좌측패널 OFF + 시설등급 1·2종만) 반영 —
|
||||
// 과거 저장본(version 0)은 최초 1회 해당 두 값을 기본으로 강제 재설정한다.
|
||||
version: 1,
|
||||
migrate: (persisted, version) => {
|
||||
const p = (persisted ?? {}) as Partial<SettingsStore>;
|
||||
if (version < 1) {
|
||||
return { ...p, showRoutePanel: false, gradeFilter: { ...DEFAULT_GRADE_FILTER } } as SettingsStore;
|
||||
}
|
||||
return p as SettingsStore;
|
||||
},
|
||||
// 과거 저장본에 누락된 종별 키가 있으면 기본값으로 보충(스키마 진화 대비).
|
||||
merge: (persisted, current) => {
|
||||
const p = (persisted ?? {}) as Partial<SettingsStore>;
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
gradeFilter: { ...DEFAULT_GRADE_FILTER, ...(p.gradeFilter ?? {}) },
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* 구조물 표시 규칙(공유). 단일 소스로 StationBar/RoutePanel 이 동일 규칙을 쓴다.
|
||||
* - grade 없음(구교 등) 또는 미관리 등급('기타' 등) → 항상 표시
|
||||
* - 1/2/3종 → gradeFilter 가 true 일 때만 표시
|
||||
*/
|
||||
export function isGradeVisible(
|
||||
grade: string | undefined,
|
||||
gradeFilter: Record<string, boolean>,
|
||||
): boolean {
|
||||
if (!grade) return true;
|
||||
if (!(KNOWN_GRADES as readonly string[]).includes(grade)) return true;
|
||||
return gradeFilter[grade] === true;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 비표준 DOM 속성 보강
|
||||
*
|
||||
* `<input type="file" webkitdirectory>` 폴더 선택과
|
||||
* `File.webkitRelativePath` 가 TypeScript 에서 타입체크되도록 확장한다.
|
||||
* (lib.dom 에 webkitRelativePath 는 있으나 webkitdirectory/directory 속성은 없음)
|
||||
*/
|
||||
|
||||
import 'react';
|
||||
|
||||
declare module 'react' {
|
||||
interface InputHTMLAttributes<T> {
|
||||
// 폴더 선택용 비표준 속성 (Chrome/Firefox/Edge)
|
||||
webkitdirectory?: string;
|
||||
directory?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLInputElement {
|
||||
webkitdirectory: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 지리정보 공유 타입 (클라이언트)
|
||||
*
|
||||
* 서버 server/src/services/geoMatch.ts 의 타입을 그대로 미러링한다.
|
||||
* 4개 소비 컴포넌트(GeoSearch / StationVerify / StationOverlay / RoutePanel)와
|
||||
* geoStore / geoSearch / geoData 가 공유한다.
|
||||
*
|
||||
* 주의: DroneFrame 은 utils/geoProjection.ts 의 DroneFrameBasic 와 필드가 동일하다
|
||||
* (frame/lat/lon/altitude/yaw/pitch/roll/focalLen). 오버레이 투영 유틸이
|
||||
* DroneFrameBasic 을 받으므로, DroneFrame 은 그 타입에 그대로 대입 가능하다.
|
||||
*/
|
||||
|
||||
/** 드론 프레임 (frame별 위경도/자세) — geoMatch.ts DroneFrame 동치 */
|
||||
export interface DroneFrame {
|
||||
frame: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
altitude: number;
|
||||
yaw: number; // 기수방향 (North=0, 시계방향, degrees)
|
||||
pitch: number; // 카메라 틸트 (음수=아래, degrees)
|
||||
roll: number;
|
||||
focalLen: number; // 35mm 환산 초점거리 (mm)
|
||||
}
|
||||
|
||||
/** POI/측점 지리점 — geoMatch.ts GeoPoint 동치 */
|
||||
export interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number; // 표고 (m)
|
||||
type: 'poi' | 'station';
|
||||
/** 원본 CSV 속성(라벨 클릭 팝업에 표시). 순서 보존 위해 배열. */
|
||||
props?: { k: string; v: string }[];
|
||||
}
|
||||
|
||||
/** 의미상 별칭 (POI = GeoPoint, Station = GeoPoint with type==='station') */
|
||||
export type Poi = GeoPoint;
|
||||
export type Station = GeoPoint;
|
||||
|
||||
/** POI 위치 보정값 (마우스 드래그로 생성). title 키로 매칭. z는 정표고(투영이 geoid 가산). */
|
||||
export interface PoiOverride {
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
}
|
||||
/** title → 보정좌표. `<base>_poi_overrides.json` 으로 내보내기/가져오기. */
|
||||
export type PoiOverrideMap = Record<string, PoiOverride>;
|
||||
|
||||
/** 선로 중심선 점 — geoMatch.ts CenterlinePoint 동치 */
|
||||
export interface CenterlinePoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number; // 타원체고(h)
|
||||
}
|
||||
|
||||
/** 건물/측점명 검색 결과 프레임 — geoMatch.ts FrameMatch 동치 (+ 그룹 메타) */
|
||||
export interface FrameMatch {
|
||||
frame: number;
|
||||
time: number; // 초 단위 (frame / fps)
|
||||
bearingDiff: number; // 수평 각도차 (degrees)
|
||||
elevationDiff: number; // 수직 각도차 (degrees)
|
||||
distance: number; // 수평 거리 (m)
|
||||
pixelX: number; // 0~1 정규화
|
||||
pixelY: number; // 0~1 정규화
|
||||
groupSize?: number; // 연속 구간 프레임 수
|
||||
groupStart?: number; // 구간 시작 프레임
|
||||
groupEnd?: number; // 구간 끝 프레임
|
||||
}
|
||||
|
||||
/** 프레임→POI 역조회 결과 항목 — geoMatch.ts PoiInFrame 동치 */
|
||||
export interface PoiInFrame {
|
||||
poi: GeoPoint;
|
||||
bearingDiff: number;
|
||||
elevationDiff: number;
|
||||
distance: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
}
|
||||
|
||||
/** ENU 월드 원점 (첫 측점 기준) */
|
||||
export interface GeoOrigin {
|
||||
lat: number;
|
||||
lon: number;
|
||||
alt: number;
|
||||
}
|
||||
|
||||
/** 노선 구조물 (교량/터널) — v2.0 CSV(03)교량/04)터널/06)구교) 유래 + route.json 보정 항목.
|
||||
* 구교(category='구교')는 교량 아이콘을 쓰므로 type='bridge' 로 매핑한다. */
|
||||
export interface RouteStructure {
|
||||
id: string;
|
||||
/** bridge=교량(구교 포함), tunnel=터널, station(또는 그 외)=역사. */
|
||||
type: 'bridge' | 'tunnel' | 'station';
|
||||
name: string;
|
||||
/** 선택(최우선): 이 측점값에 시설물을 배치. 숫자(미터, 158400) 또는 "158k400" 문자열.
|
||||
* 동명 시설물은 각자의 station 값으로 구분 표시된다. */
|
||||
station?: number | string;
|
||||
/** 선택: 구조물 실좌표. station 없을 때 이 좌표로 배치(POI 매칭 불필요).
|
||||
* v2.0 CSV 구조물은 lat/lon 을 항상 채운다. */
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
/** 선택: 위치는 POI 실좌표(이름 매칭)로 계산. 이정값은 매칭 실패 시 폴백용. */
|
||||
startMileage?: number;
|
||||
endMileage?: number;
|
||||
/** 선택: POI/좌표 기반 위치를 진행방향으로 ±N미터 미세조정. */
|
||||
offset?: number;
|
||||
/** 선택: 구조물 연장(m). CSV `연장(m)` 컬럼 유래. */
|
||||
lengthM?: number;
|
||||
/** 선택: 분류 라벨(교량/터널/구교 등). 표시/디버깅용. */
|
||||
category?: string;
|
||||
/** 선택: 시설종별 원문(예 '1종'|'2종'|'3종'|'기타'). CSV `시설종별` 컬럼 유래.
|
||||
* 교량/터널만 채워지고 구교(컬럼 없음)는 undefined. 빈 값도 undefined. */
|
||||
grade?: string;
|
||||
/** 원본 CSV 속성(라벨 클릭 팝업 표시). */
|
||||
props?: { k: string; v: string }[];
|
||||
}
|
||||
|
||||
/** 측점 비고(방향전환점) 파싱 결과 — FolderGeoData.directionChanges.
|
||||
* CSV 01)측점 비고 컬럼의 `방향전환점(상행->하행, 02:05)` 형태에서 추출한다. */
|
||||
export interface DirectionChange {
|
||||
/** 전환이 일어나는 측점값 (예 "157K930"). */
|
||||
station: string;
|
||||
/** 전환 전 방향 (예 "상행"). */
|
||||
from: string;
|
||||
/** 전환 후 방향 (예 "하행"). */
|
||||
to: string;
|
||||
/** 전환 시각(초). mm:ss 표기를 초로 변환 (02:05 → 125). */
|
||||
atSeconds: number;
|
||||
}
|
||||
|
||||
/** 노선 기본 정보 — route.json routeInfo */
|
||||
export interface RouteInfo {
|
||||
name?: string;
|
||||
direction?: string;
|
||||
lengthKm?: number;
|
||||
durationSec?: number;
|
||||
startStationName?: string;
|
||||
endStationName?: string;
|
||||
/** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */
|
||||
stationTolerance?: number;
|
||||
/**
|
||||
* 종점역 '미도착' 거리(m). 영상이 종점역에 도착하지 못하고 이 거리만큼 못 미쳐 끝날 때 설정.
|
||||
* 스테이션바는 영상 타임라인을 트랙의 [시작 … 끝−미도착폭]에만 매핑하고,
|
||||
* 종점역 마커는 트랙 끝(미도착 스타일)에 둔다. 미설정/0이면 종점역이 끝에 정확히 붙음(도착).
|
||||
*/
|
||||
endStationGapMeters?: number;
|
||||
}
|
||||
|
||||
/** 폴더 보조 파일 <base>.route.json (없으면 route.json) 파싱 결과 */
|
||||
export interface RouteMeta {
|
||||
routeInfo?: RouteInfo;
|
||||
structures?: RouteStructure[];
|
||||
}
|
||||
|
||||
/** loadFolderGeoData 파싱 결과 (v2.0 데이터 형상) */
|
||||
export interface FolderGeoData {
|
||||
videoFile: File | null;
|
||||
baseName: string | null;
|
||||
frames: DroneFrame[];
|
||||
pois: GeoPoint[]; // type==='poi' (KMZ 원본: 지장물·출입문 등)
|
||||
stations: GeoPoint[]; // type==='station' (측점.csv, stationOrder 정렬)
|
||||
centerline: CenterlinePoint[]; // 측점을 mileage 순으로 이은 폴리라인 (v2.0엔 center.csv 없음)
|
||||
origin: GeoOrigin | null;
|
||||
routeMeta: RouteMeta | null;
|
||||
/** KMZ 원본 구조물(교량/터널/구교/역사) + route.json 보정. */
|
||||
structures: RouteStructure[];
|
||||
/** 측점 비고에서 추출한 방향전환점 목록. */
|
||||
directionChanges: DirectionChange[];
|
||||
/** 폴더의 `<base>_poi_overrides.json` 에서 읽은 POI 위치 보정값 (없으면 {}). */
|
||||
poiOverrides: PoiOverrideMap;
|
||||
/** KMZ(POI·구조물 원본)가 없거나 비어 있음 → 데이터 누락(재구축 필요). 측점·드론은 별개. */
|
||||
kmzMissing: boolean;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type VideoSource =
|
||||
| { kind: 'local'; file: File; objectUrl: string }
|
||||
| { kind: 'server'; videoId: string; filename: string };
|
||||
|
||||
export interface PlayerState {
|
||||
source: VideoSource | null;
|
||||
playing: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
playbackRate: number;
|
||||
fullscreen: boolean;
|
||||
hlsReady: boolean;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/** 측점값(체이니지) 공유 유틸 — 드론 GPS 를 측점 폴리라인에 투영해 측점값/최근접측점 산출.
|
||||
* StationBar(배지)·VideoPlayer(측점 진단 HUD) 가 동일 계산을 공유한다. */
|
||||
|
||||
export interface ChainLine {
|
||||
pts: { x: number; y: number; km: number; lat: number; lon: number; title: string }[];
|
||||
k: number; // 경도→m 환산 (cos(lat0)*111000)
|
||||
}
|
||||
|
||||
/** "157K970" → 157970(m). 실패 시 -1. */
|
||||
export function kmFromTitle(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
||||
}
|
||||
|
||||
/** 미터값 → "157k970" (10m 단위). */
|
||||
export function fmtKm10(m: number): string {
|
||||
const r = Math.round(m / 10) * 10;
|
||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** 측점 POI 목록 → 평면투영 폴리라인(km 오름차순). 측점이 없으면 null. */
|
||||
export function buildChainLine(stations: { title: string; lat: number; lon: number }[]): ChainLine | null {
|
||||
const sts = stations.filter((s) => kmFromTitle(s.title) >= 0);
|
||||
if (!sts.length) return null;
|
||||
const sorted = [...sts].sort((a, b) => kmFromTitle(a.title) - kmFromTitle(b.title));
|
||||
const lat0 = sorted.reduce((s, p) => s + p.lat, 0) / sorted.length;
|
||||
const k = Math.cos((lat0 * Math.PI) / 180) * 111000;
|
||||
return {
|
||||
pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: kmFromTitle(p.title), lat: p.lat, lon: p.lon, title: p.title })),
|
||||
k,
|
||||
};
|
||||
}
|
||||
|
||||
/** 드론 lat/lon 을 폴리라인에 투영 → { km(측점값), offsetM(선로 수직이격) }. */
|
||||
export function projectToChain(lat: number, lon: number, line: ChainLine): { km: number; offsetM: number } {
|
||||
const px = lon * line.k;
|
||||
const py = lat * 111000;
|
||||
const pts = line.pts;
|
||||
let bestD = Infinity;
|
||||
let bestKm = pts.length ? pts[0].km : -1;
|
||||
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, L2 = dx * dx + dy * dy;
|
||||
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / L2));
|
||||
const cx = a.x + dx * t, cy = a.y + dy * t;
|
||||
const d = (px - cx) ** 2 + (py - cy) ** 2;
|
||||
if (d < bestD) { bestD = d; bestKm = a.km + (b.km - a.km) * t; }
|
||||
}
|
||||
return { km: bestKm, offsetM: Math.sqrt(bestD) };
|
||||
}
|
||||
|
||||
/** 드론 lat/lon 의 최근접 측점(점) → { title, km, distM }. */
|
||||
export function nearestChainPoint(lat: number, lon: number, line: ChainLine): { title: string; km: number; distM: number } | null {
|
||||
const pts = line.pts;
|
||||
if (!pts.length) return null;
|
||||
let nIdx = 0, nD = Infinity;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const dx = (lat - pts[i].lat) * 111000;
|
||||
const dy = (lon - pts[i].lon) * line.k;
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < nD) { nD = d; nIdx = i; }
|
||||
}
|
||||
return { title: pts[nIdx].title, km: pts[nIdx].km, distM: Math.sqrt(nD) };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Reusable canvas for frame capture — single canvas instance per CLAUDE.md
|
||||
let captureCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
export function captureFrame(video: HTMLVideoElement): string | null {
|
||||
if (!captureCanvas) {
|
||||
captureCanvas = document.createElement('canvas');
|
||||
}
|
||||
captureCanvas.width = video.videoWidth;
|
||||
captureCanvas.height = video.videoHeight;
|
||||
const ctx = captureCanvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(video, 0, 0);
|
||||
return captureCanvas.toDataURL('image/jpeg', 0.95);
|
||||
}
|
||||
|
||||
export function downloadDataUrl(dataUrl: string, filename: string): void {
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||