초기 커밋: 스테이션(측점) 기반 주행영상 플레이어
- 클라이언트(React/Vite): Video.js 플레이어, 하단 스테이션바, POI/구조물 영상 오버레이, 카메라 파라미터 보정, 미니맵/RoutePanel - 서버(Express): Range 스트리밍, HLS 변환, 프레임 추출, tus 업로드, 주석 API - 최근 작업: 스테이션바 종점역 표출/양끝 정렬/미도착(재생불가) 표시, POI 라벨 '구분' 우선·컴팩트 팝업·겹침제외 토글·동일좌표 다중행, 진행방향(상/하) 우선 표출, 커서 배지 크기·픽셀 떨림 개선 Co-Authored-By: Claude Opus 4.8 (1M context) <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,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,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,363 @@
|
||||
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 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;
|
||||
|
||||
// 교량/터널만 표시 + 시설종별 필터(설정 스토어, 재생바와 동일 규칙).
|
||||
const filteredPois = structures
|
||||
.filter(s => (s.type === 'bridge' || s.type === 'tunnel') && typeof s.lat === 'number' && typeof s.lon === 'number' && isGradeVisible(s.grade, gradeFilter))
|
||||
.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 }));
|
||||
|
||||
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;
|
||||
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,458 @@
|
||||
import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect } 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';
|
||||
|
||||
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, fps } = 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);
|
||||
// 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어.
|
||||
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
|
||||
const setGradeFilter = useSettingsStore((s) => s.setGradeFilter);
|
||||
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
|
||||
const setPoiOverlapExclude = useSettingsStore((s) => s.setPoiOverlapExclude);
|
||||
// 하단 도구 패널: 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)이 없습니다 — 지리정보만 로드');
|
||||
} 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,
|
||||
});
|
||||
|
||||
// Drag and drop local video file
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file?.type.startsWith('video/')) loadLocalFile(file);
|
||||
};
|
||||
|
||||
// VIDEO_FPS: 영상 실제 fps (29.97 = 30000/1001). Python SRT FrameCnt 기준과 일치.
|
||||
// stableFps(VFC 감지)는 31fps 오감지가 있어 프레임 번호 표시에는 사용하지 않음.
|
||||
const VIDEO_FPS = 30000 / 1001;
|
||||
const frame = secondsToFrame(currentTime, VIDEO_FPS);
|
||||
const videoId = source?.kind === 'server' ? source.videoId : null;
|
||||
|
||||
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 flex-col items-start gap-2"
|
||||
style={{ bottom: (barHeight || 130) + 8 }}
|
||||
>
|
||||
{/* 재생 배속 (영상제어 ON) */}
|
||||
{showVideoControls && (
|
||||
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded pointer-events-auto">
|
||||
<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 font-semibold ${
|
||||
Math.abs(playbackRate - r) < 0.01
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{r}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 한 줄(좌→우): 영상제어 토글 → 프레임표시 패널 → POI옵션 패널 → 시설등급 패널 */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVideoControls((v) => !v)}
|
||||
className={`text-xs px-2 py-1 rounded border font-semibold ${showVideoControls ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>영상제어 {showVideoControls ? 'ON' : 'OFF'}</button>
|
||||
{showVideoControls && (
|
||||
<>
|
||||
{/* 영상 프레임 표시 패널 */}
|
||||
<span className="bg-black/70 text-white text-xs px-2 py-1 rounded font-mono">
|
||||
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
|
||||
</span>
|
||||
{geoLoaded && (
|
||||
<>
|
||||
{/* POI 옵션 패널 (시설등급 왼쪽) — 겹침제외 토글 */}
|
||||
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
|
||||
<span className="text-gray-400 text-xs">POI 옵션</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPoiOverlapExclude(!poiOverlapExclude)}
|
||||
title="ON=겹친 POI 숨김 / OFF=모든 POI 표시"
|
||||
className={`text-xs px-2 py-0.5 rounded border font-semibold ${poiOverlapExclude ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>겹침제외 {poiOverlapExclude ? 'ON' : 'OFF'}</button>
|
||||
</div>
|
||||
{/* 시설등급 패널 (프레임표시 우측) */}
|
||||
<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-blue-500"
|
||||
checked={gradeFilter[g] ?? false}
|
||||
onChange={(e) => setGradeFilter(g, e.target.checked)}
|
||||
/>
|
||||
{g}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */}
|
||||
<RoutePanel
|
||||
currentTime={currentTime}
|
||||
visible={showStations}
|
||||
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={fps}
|
||||
visible={showStations}
|
||||
videoReady={videoReady}
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
showPanel={showVideoControls && !!source}
|
||||
topPx={paramTop}
|
||||
/>
|
||||
|
||||
{/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */}
|
||||
{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 / VIDEO_FPS);
|
||||
}
|
||||
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,168 @@
|
||||
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);
|
||||
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 (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;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
|
||||
store.setSource({ kind: 'local', file, objectUrl });
|
||||
store.setHlsReady(false);
|
||||
|
||||
// Revoke objectUrl when video element is reset
|
||||
player.one('emptied', () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
// 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;
|
||||
|
||||
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,43 @@
|
||||
/* 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(var(--bar-scale));
|
||||
transform-origin: bottom left;
|
||||
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,782 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
// v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용.
|
||||
const storeStructuresRaw = useGeoStore((s) => s.structures);
|
||||
// 시설종별 필터(설정 스토어) — 체크 변경 시 즉시 반영(구독).
|
||||
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
|
||||
const storeStructures = useMemo(
|
||||
() => storeStructuresRaw.filter((s) => isGradeVisible(s.grade, gradeFilter)),
|
||||
[storeStructuresRaw, gradeFilter],
|
||||
);
|
||||
const viewedRef = useRef<ViewedPoint[]>([]);
|
||||
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 out: ViewedPoint[] = new Array(frames.length);
|
||||
for (let i = 0; i < frames.length; 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,
|
||||
};
|
||||
}
|
||||
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;
|
||||
|
||||
// 프레임(시간) 진행률 px — 커서의 단조 이동 기준.
|
||||
// 영상은 노선을 시간순으로 한 번 통과(leg 01→10)하므로, 시간 진행률이 곧
|
||||
// 트랙을 좌→우로 지나는 경로 진행률이다.
|
||||
const progressPx =
|
||||
duration > 0
|
||||
? TRACK_START_PX + clamp(currentTime / duration, 0, 1) * timeTrackWidth
|
||||
: TRACK_START_PX;
|
||||
|
||||
// 커서 위치 = 프레임 진행률(progressPx)로만 단조 이동.
|
||||
// 측점 km은 노선에서 여러 번 반복(증가↔감소)되어, km으로 px를 역산하면
|
||||
// 같은 km의 여러 후보 중 하나로 스냅되며 구간 점프가 생긴다(사용자 보고).
|
||||
// → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다.
|
||||
const cursorPx = progressPx;
|
||||
|
||||
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시.
|
||||
// mock ROUTE_LEGS(mileageAtPx) 의존 제거. 데이터 없으면 빈 문자열.
|
||||
const cursorText =
|
||||
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
|
||||
|
||||
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
|
||||
// 시간(프레임) 진행을 트랙 px로 선형 변환.
|
||||
const pxAtTime = useCallback(
|
||||
(t: number): number =>
|
||||
duration > 0
|
||||
? TRACK_START_PX + clamp(t / duration, 0, 1) * timeTrackWidth
|
||||
: TRACK_START_PX,
|
||||
[duration, timeTrackWidth],
|
||||
);
|
||||
|
||||
// viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
|
||||
// km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
|
||||
const { barSegments, kmLabels } = useMemo<{
|
||||
barSegments: BarSegment[];
|
||||
kmLabels: KmLabel[];
|
||||
}>(() => {
|
||||
const arr = viewedRef.current;
|
||||
if (!ready || !arr.length || duration <= 0)
|
||||
return { barSegments: [], kmLabels: [] };
|
||||
// 방향은 연속 체이니지(chain)로 판정 — 100m 양자화 km은 전환을 ~13s 빨리 잡아 영상과 어긋남.
|
||||
const HYST = 100; // m — 이 이상 반대로 움직여야 방향 전환으로 인정 (GPS 노이즈 무시)
|
||||
const segs: BarSegment[] = [];
|
||||
const bounds: { chain: number; time: number; turn: boolean }[] = [
|
||||
{ chain: arr[0].chain, time: arr[0].time, turn: false },
|
||||
];
|
||||
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정. 기본 증가 가정이 틀리면 첫 구간 색이 반대로 나옴.
|
||||
let dir: 1 | -1 = 1;
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
const d = arr[i].chain - arr[0].chain;
|
||||
if (Math.abs(d) >= HYST) { dir = d > 0 ? 1 : -1; break; }
|
||||
}
|
||||
let extCh = arr[0].chain;
|
||||
let extIdx = 0;
|
||||
let startIdx = 0;
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
const c = arr[i].chain;
|
||||
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[arr.length - 1].time),
|
||||
dir,
|
||||
});
|
||||
bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 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 });
|
||||
}
|
||||
|
||||
// 첫·마지막 스테이션(역사)을 트랙 양끝에 고정 표출.
|
||||
// 역사 마커 중 최좌측 → 트랙 시작(TRACK_START_PX), 최우측 → 트랙 끝(TRACK_END_PX)으로 스냅.
|
||||
// (드론 항로가 역사에서 떨어져 지나가도 첫/끝 역이 바 양끝에 앵커되도록.)
|
||||
const snapStationEnds = (marks: StructMark[]): StructMark[] => {
|
||||
const st = marks
|
||||
.map((m, i) => ({ i, px: m.px }))
|
||||
.filter((_, i) => marks[i].category === '역사' || marks[i].category === '역');
|
||||
if (st.length < 2) return marks;
|
||||
let lo = st[0], hi = st[0];
|
||||
for (const x of st) {
|
||||
if (x.px < lo.px) lo = x;
|
||||
if (x.px > hi.px) hi = x;
|
||||
}
|
||||
if (lo.i !== hi.i) {
|
||||
marks[lo.i] = { ...marks[lo.i], px: TRACK_START_PX };
|
||||
// 종점역: 끝(END)에 두되, 미도착폭이 설정돼 있으면 '미도착'으로 표시
|
||||
// (영상 진행바·커서는 END−endGapPx 까지만 도달 → 끝의 회색 구간 = 미도착).
|
||||
marks[hi.i] = { ...marks[hi.i], px: TRACK_END_PX, unreached: endGapPx > 0 };
|
||||
}
|
||||
return marks;
|
||||
};
|
||||
|
||||
// v2.0: CSV 유래 구조물(storeStructures) 우선. route.json 보정은 geoData 에서 이미 병합됨.
|
||||
if (storeStructures && storeStructures.length) {
|
||||
const out: StructMark[] = [];
|
||||
for (const s of storeStructures) {
|
||||
// 구교(06)는 영상 오버레이에 표출 → 스테이션바에서는 제외(교량/터널만).
|
||||
if (s.category === '구교') 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(측점값) → 직접 좌표 → POI 이름매칭 → 이정값 폴백.
|
||||
// 각 통과 지점마다 마커(드론이 2번 지나면 2개). 동명 시설물은 각자 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) passes = pxPassesTo(s.lat, s.lon, off);
|
||||
if (!passes.length && 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;
|
||||
for (const p of passes) out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, props: s.props });
|
||||
}
|
||||
return snapStationEnds(out);
|
||||
}
|
||||
|
||||
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
|
||||
const out: StructMark[] = [];
|
||||
for (const [base, info] of poiByName) {
|
||||
const stationKmVal = info.category === '역사' && stationLine
|
||||
? projectChainage(info.lat, info.lon, stationLine) : null;
|
||||
for (const p of pxPassesTo(info.lat, info.lon)) {
|
||||
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km });
|
||||
}
|
||||
}
|
||||
return snapStationEnds(out);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
|
||||
|
||||
// 방향 색 트랙 gradient 빌더(전진/후진 색을 받아 구성). 구간 경계는 부드럽게 섞는다.
|
||||
const buildGradient = useCallback(
|
||||
(FWD: string, BWD: string): string => {
|
||||
const segs = barSegments;
|
||||
if (!segs.length) return '';
|
||||
const col = (d: 1 | -1) => (d > 0 ? 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, duration],
|
||||
);
|
||||
// 통과 구간 음영 그라데이션(videoplayer 원본): 구간마다 좌→우 3색 셰이딩, 구간 경계는 하드.
|
||||
// 전진 주황: #ffc257 → #ff8a25 → #ff7b1b / 역방향 청록: #5ca887 → #35a7a7 → #06a4c8
|
||||
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 > 0 ? 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);
|
||||
const b = pct(s.endPx);
|
||||
const 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],
|
||||
);
|
||||
// 통과(지나간): 구간별 음영 그라데이션. 미통과: 단색(전진 회색 / 역방향 청회색).
|
||||
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]);
|
||||
|
||||
// 현재 위치가 역방향(km 감소, 하늘색) 구간이면 커서도 파란색.
|
||||
const currentReverse = useMemo<boolean>(() => {
|
||||
for (const s of barSegments) {
|
||||
if (cursorPx >= s.startPx && cursorPx <= s.endPx) return s.dir < 0;
|
||||
}
|
||||
return false;
|
||||
}, [barSegments, cursorPx]);
|
||||
|
||||
// 컨테이너 폭 기준 균등 스케일
|
||||
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 = TRACK_START_PX + clamp(t / duration, 0, 1) * timeTrackWidth;
|
||||
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, timeTrackWidth]);
|
||||
|
||||
// 클릭/드래그 트랙 px → 시간(프레임) 선형 변환으로 seek (시간축 일관).
|
||||
const seekToTrackX = useCallback(
|
||||
(trackX: number) => {
|
||||
if (duration <= 0) return;
|
||||
const px = clamp(trackX, TRACK_START_PX, TRACK_END_PX);
|
||||
// 미도착 구간(END−endGapPx 우측)을 클릭하면 영상 끝(duration)으로 클램프.
|
||||
const targetTime = ((px - TRACK_START_PX) / timeTrackWidth) * duration;
|
||||
onSeek(clamp(targetTime, 0, duration));
|
||||
},
|
||||
[duration, onSeek, timeTrackWidth],
|
||||
);
|
||||
|
||||
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 안 함).
|
||||
const handleJumpToMileage = useCallback(
|
||||
(km: number) => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length || duration <= 0) return;
|
||||
// 연속 체이니지(chain) 기준으로 입력 측점에 가장 가까운 프레임 탐색 + 커버 범위 산출.
|
||||
let best = -1;
|
||||
let bd = Infinity;
|
||||
let lo = Infinity;
|
||||
let 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 d = Math.abs(c - km);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
// 구간 밖(커버 측점 범위 ± 여유 20m)이면 무시.
|
||||
const MARGIN = 20;
|
||||
if (km < lo - MARGIN || km > hi + MARGIN) return;
|
||||
if (best >= 0) onSeek(clamp(arr[best].time, 0, duration));
|
||||
},
|
||||
[duration, onSeek],
|
||||
);
|
||||
|
||||
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={
|
||||
routeMeta?.routeInfo?.direction?.includes('하') ? '하'
|
||||
: routeMeta?.routeInfo?.direction?.includes('상') ? '상'
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</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,307 @@
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 화면캡처·측점선 보기 패널 */
|
||||
.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,98 @@
|
||||
import { 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;
|
||||
onJumpToMileage: (mileage: number) => void;
|
||||
/** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */
|
||||
lineOn?: boolean;
|
||||
onToggleLine?: () => void;
|
||||
}
|
||||
|
||||
export function PlaybackControls({
|
||||
playing,
|
||||
onTogglePlay,
|
||||
onStop,
|
||||
onCapture,
|
||||
onJumpToMileage,
|
||||
lineOn: lineOnProp,
|
||||
onToggleLine,
|
||||
}: PlaybackControlsProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [lineOnInternal, setLineOnInternal] = useState(false);
|
||||
const lineOn = lineOnProp ?? lineOnInternal;
|
||||
const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v));
|
||||
|
||||
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setQuery(e.target.value);
|
||||
};
|
||||
|
||||
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const mileage = parseMileageQuery(query);
|
||||
if (mileage !== null) {
|
||||
onJumpToMileage(mileage);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
placeholder="측점입력"
|
||||
/>
|
||||
</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,378 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function Timeline({
|
||||
posPx,
|
||||
onSeekDown,
|
||||
trackGradient,
|
||||
trackGradientIdle,
|
||||
dividers,
|
||||
kmLabels,
|
||||
structures,
|
||||
startStationName,
|
||||
endStationName,
|
||||
endGapPx = 0,
|
||||
routeDir = null,
|
||||
}: TimelineProps) {
|
||||
// 하단 스테이션바는 겹쳐도 무방 → 역사(역, KAKAO_RAIL)는 dedup 대상에서 제외하고 항상 표출.
|
||||
// (종점 '대전조차장'이 ~70px 앞 '법동가도교'에 밀려 사라지던 문제 방지.)
|
||||
// 교량/터널만 라벨 텍스트가 길어 130px 겹침 방지 적용. 위치는 실제 통과 px 그대로.
|
||||
// 평행 상/하행 구조물(예: 회덕터널 상/하)은 ~6px 차이라 한쪽만 남는데, 영상 방향(routeDir)에
|
||||
// 해당하는 쪽(하행=‘(하)’, 상행=‘(상)’)을 우선 남긴다. → 드론이 실제 지나는 선로와 일치.
|
||||
const isStation = (s: StructMark): boolean => s.category === '역사' || s.category === '역';
|
||||
const dirTag = routeDir === '상' ? '(상)' : routeDir === '하' ? '(하)' : '';
|
||||
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);
|
||||
const keptOthers: StructMark[] = [];
|
||||
for (const it of ordered) {
|
||||
if (keptOthers.every((k) => Math.abs(k.px - it.px) >= 130)) keptOthers.push(it);
|
||||
}
|
||||
const structs = [...structures.filter(isStation), ...keptOthers].sort((a, b) => a.px - b.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,
|
||||
opacity: 1,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체.
|
||||
재생영역(회색 idle)보다 약간 밝은 단색 배경으로만 구분(빗금 없음). */}
|
||||
{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). 폭은 CSS 변수(--pos-px)로 매 프레임
|
||||
직접 갱신되어 React 리렌더 없이 부드럽게 늘어난다. */}
|
||||
<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 ? (
|
||||
<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>
|
||||
|
||||
{/* 구조물 클릭 핫스팟 — 클릭 시 속성 팝업(회덕화물역 등 역사 포함). seekArea(z20) 위로. */}
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: 21, pointerEvents: 'none' }}>
|
||||
{structs.map((s, i) =>
|
||||
s.props && s.props.length ? (
|
||||
<div
|
||||
key={`hit-${i}`}
|
||||
title={s.title}
|
||||
onMouseDown={(e) => { e.stopPropagation(); setSel({ s, x: e.clientX, y: e.clientY }); }}
|
||||
style={{ position: 'absolute', left: s.px - 12, top: 28, width: 24, height: 28, cursor: 'pointer', pointerEvents: 'auto' }}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄. */}
|
||||
<div className={styles.labelRow}>
|
||||
{structs.map((s, i) => {
|
||||
const W = s.category === '터널' ? 24 : 22;
|
||||
// 미도착 종점역은 항상 미통과(neutral) 색 유지.
|
||||
const passed = s.unreached ? false : posPx >= s.px - W / 2;
|
||||
const lines = splitStructLabel(s.title);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.segmentLabel} ${styles.structName} ${passed ? styles.accent : styles.neutral}`}
|
||||
style={cssVars({ '--x': px(s.px) })}
|
||||
title={`${s.category} · ${s.title} (${fmtKm(s.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,113 @@
|
||||
/**
|
||||
* 클라이언트 지리정보 스토어 (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;
|
||||
/** v2.0 CSV(03)교량/04)터널/06)구교) 유래 구조물 + route.json 보정. */
|
||||
structures: RouteStructure[];
|
||||
/** 측점 비고에서 추출한 방향전환점 목록. */
|
||||
directionChanges: DirectionChange[];
|
||||
|
||||
/**
|
||||
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
||||
* 발견한 영상 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[],
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
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,70 @@
|
||||
/**
|
||||
* 사용자 설정 스토어 (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종☐. */
|
||||
gradeFilter: Record<string, boolean>;
|
||||
/** 특정 시설종별 체크 토글. */
|
||||
setGradeFilter: (grade: string, checked: boolean) => void;
|
||||
/** 화면 POI 겹침제외(겹치면 1개만). true=겹친 것 숨김(기본), false=모든 POI 표시. */
|
||||
poiOverlapExclude: boolean;
|
||||
setPoiOverlapExclude: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_GRADE_FILTER: Record<string, boolean> = {
|
||||
'1종': true,
|
||||
'2종': true,
|
||||
'3종': false,
|
||||
'기타': true,
|
||||
};
|
||||
|
||||
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 }),
|
||||
}),
|
||||
{
|
||||
name: 'ghivideo.settings',
|
||||
// 과거 저장본에 누락된 종별 키가 있으면 기본값으로 보충(스키마 진화 대비).
|
||||
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,171 @@
|
||||
/**
|
||||
* 지리정보 공유 타입 (클라이언트)
|
||||
*
|
||||
* 서버 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' (<base>_POI.csv + building/02)지장물.csv)
|
||||
stations: GeoPoint[]; // type==='station' (building/01)측점.csv, stationOrder 정렬)
|
||||
centerline: CenterlinePoint[]; // 측점을 mileage 순으로 이은 폴리라인 (v2.0엔 center.csv 없음)
|
||||
origin: GeoOrigin | null;
|
||||
routeMeta: RouteMeta | null;
|
||||
/** v2.0 CSV(03)교량/04)터널/06)구교) 유래 구조물 + route.json 보정. */
|
||||
structures: RouteStructure[];
|
||||
/** 측점 비고에서 추출한 방향전환점 목록. */
|
||||
directionChanges: DirectionChange[];
|
||||
/** 폴더의 `<base>_poi_overrides.json` 에서 읽은 POI 위치 보정값 (없으면 {}). */
|
||||
poiOverrides: PoiOverrideMap;
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
/**
|
||||
* 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱 (v2.0 데이터 형상 전용)
|
||||
*
|
||||
* `<input type="file" webkitdirectory>` 로 선택된 File[] 에서 v2.0 폴더 구조를
|
||||
* 식별해 영상 / 드론 CSV / 측점·POI·구조물 CSV 를 파싱한다.
|
||||
*
|
||||
* v2.0 폴더 구조 (영상 base = 영상파일명에서 확장자 제거):
|
||||
* <base>.MP4 영상
|
||||
* <base>.csv 드론 프레임 (frame_cnt,latitude,...)
|
||||
* <base>_POI.csv POI (source,query_label,title,category_clean,...,lat,lon,...)
|
||||
* building/01)측점.csv 측점 (측점,X좌표,...,비고,lat,lon,...)
|
||||
* building/02)지장물.csv POI (명칭,...,lat,lon)
|
||||
* building/03)교량.csv 구조물 bridge (구분,...,연장(m),...,lat,lon,...)
|
||||
* building/04)터널.csv 구조물 tunnel (구분,...,연장(m),...,lat,lon,...)
|
||||
* building/05)출입문번호.csv 출입문 (기본 표시 제외 — 아래 참고)
|
||||
* building/06)구교.csv 구조물 bridge (역구간,시설물명,...,연장(m),lat,lon,...)
|
||||
*
|
||||
* v2.0엔 center.csv 가 없다 → 측점(01)측점)을 측점값 순으로 이어 중심선을 생성한다.
|
||||
*
|
||||
* 인코딩(혼재 — 자동 감지): ArrayBuffer 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8,
|
||||
* 아니면 EUC-KR. 디코딩 후 잔여 BOM(U+FEFF)을 제거한다. 파일별 하드코딩 없음.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DroneFrame,
|
||||
GeoPoint,
|
||||
CenterlinePoint,
|
||||
GeoOrigin,
|
||||
FolderGeoData,
|
||||
RouteMeta,
|
||||
RouteStructure,
|
||||
DirectionChange,
|
||||
PoiOverrideMap,
|
||||
} from '../types/geo';
|
||||
import { stationOrder, getWorldOrigin } from './geoSearch';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
|
||||
// ── CSV 파싱 헬퍼 ─────────────────────────────────────────────────────
|
||||
|
||||
export function parseCsvLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (const ch of line) {
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayBuffer → 문자열. 인코딩 자동 감지:
|
||||
* 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8, 아니면 EUC-KR.
|
||||
* 디코딩 후 남을 수 있는 BOM(U+FEFF)을 제거한다.
|
||||
*/
|
||||
export function decodeBytes(buf: ArrayBuffer): string {
|
||||
const head = new Uint8Array(buf, 0, Math.min(3, buf.byteLength));
|
||||
const isUtf8Bom = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf;
|
||||
const encoding = isUtf8Bom ? 'utf-8' : 'euc-kr';
|
||||
const text = new TextDecoder(encoding).decode(buf);
|
||||
// 디코딩 후 잔여 BOM(U+FEFF) 제거
|
||||
return text.replace(/^/, '');
|
||||
}
|
||||
|
||||
/** File → 파싱된 행 배열. 인코딩은 BOM 기반 자동 감지. */
|
||||
export async function readCsv(file: File): Promise<string[][]> {
|
||||
const buf = await file.arrayBuffer();
|
||||
const text = decodeBytes(buf);
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(parseCsvLine);
|
||||
}
|
||||
|
||||
/** 헤더 행 → (헤더명|위치인덱스) → 값 추출기. 헤더가 깨지면 위치 인덱스로 폴백. */
|
||||
function makeFieldIndexer(header: string[]): (name: string, fallback?: number) => number {
|
||||
const cleaned = header.map((h) => h.trim().replace(/^/, ''));
|
||||
return (name: string, fallback?: number): number => {
|
||||
const i = cleaned.indexOf(name);
|
||||
if (i >= 0) return i;
|
||||
return fallback ?? -1;
|
||||
};
|
||||
}
|
||||
|
||||
/** 안전한 셀 접근 (인덱스 음수/범위 밖이면 ''). */
|
||||
function cell(row: string[], i: number): string {
|
||||
return i >= 0 && i < row.length ? row[i] : '';
|
||||
}
|
||||
|
||||
/** 헤더행 정규화(트림+BOM 제거). props/팝업 표시용. */
|
||||
function cleanHeader(header: string[]): string[] {
|
||||
return header.map((h) => h.trim().replace(/^/, ''));
|
||||
}
|
||||
|
||||
/** (정규화된)헤더 + 데이터행 → 비어있지 않은 속성 배열(순서 보존). 라벨 클릭 팝업 표시용. */
|
||||
function rowProps(header: string[], row: string[]): { k: string; v: string }[] {
|
||||
return header.map((k, i) => ({ k, v: cell(row, i) })).filter((p) => p.k && p.v !== '');
|
||||
}
|
||||
|
||||
// ── 파일 식별 헬퍼 ────────────────────────────────────────────────────
|
||||
|
||||
/** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */
|
||||
function relPath(f: File): string {
|
||||
return f.webkitRelativePath || f.name;
|
||||
}
|
||||
|
||||
/** 경로의 마지막 세그먼트(파일명). */
|
||||
function baseNameOf(p: string): string {
|
||||
const parts = p.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/** building/ 하위 파일 여부 (마지막 디렉토리 세그먼트가 building). */
|
||||
function isInBuilding(f: File): boolean {
|
||||
const parts = relPath(f).split('/');
|
||||
return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building';
|
||||
}
|
||||
|
||||
/** building/ 하위에서 파일명이 키워드를 포함하는 첫 파일. (예 '01)측점', '03)교량') */
|
||||
function findBuildingFile(files: File[], keyword: string): File | null {
|
||||
return files.find((f) => isInBuilding(f) && baseNameOf(relPath(f)).includes(keyword)) ?? null;
|
||||
}
|
||||
|
||||
const VIDEO_EXT = /\.(mp4|webm)$/i;
|
||||
|
||||
/** 영상 파일 찾기 (mp4/webm, building 제외). */
|
||||
export function findVideoFile(files: File[]): File | null {
|
||||
return (
|
||||
files.find((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** 영상 파일명에서 base(확장자 제거) 추출. */
|
||||
export function deriveBaseName(videoFile: File | null): string | null {
|
||||
if (!videoFile) return null;
|
||||
return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, '');
|
||||
}
|
||||
|
||||
// ── 파서: 드론 프레임 (변경 없음) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스).
|
||||
* 헤더: frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len
|
||||
* 루트(building 제외)의 <base>.csv 를 식별한다. base 가 없으면 루트 .csv 중
|
||||
* POI 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백).
|
||||
*/
|
||||
export async function parseDroneFrames(
|
||||
files: File[],
|
||||
baseName: string | null,
|
||||
): Promise<DroneFrame[]> {
|
||||
const rootCsv = files.filter((f) => {
|
||||
if (isInBuilding(f)) return false;
|
||||
const name = baseNameOf(relPath(f));
|
||||
if (!/\.csv$/i.test(name)) return false;
|
||||
if (/POI/i.test(name)) return false;
|
||||
return true;
|
||||
});
|
||||
if (!rootCsv.length) return [];
|
||||
|
||||
// base 일치 우선, 없으면 첫 루트 csv
|
||||
const droneFile =
|
||||
(baseName && rootCsv.find((f) => baseNameOf(relPath(f)) === `${baseName}.csv`)) ||
|
||||
rootCsv[0];
|
||||
|
||||
const rows = await readCsv(droneFile);
|
||||
if (rows.length < 2) return [];
|
||||
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
|
||||
return rows
|
||||
.slice(1)
|
||||
.map((r) => ({
|
||||
frame: parseInt(cell(r, fi('frame_cnt', 0)), 10),
|
||||
lat: parseFloat(cell(r, fi('latitude', 1))),
|
||||
lon: parseFloat(cell(r, fi('longitude', 2))),
|
||||
altitude: parseFloat(cell(r, fi('altitude', 3))),
|
||||
yaw: parseFloat(cell(r, fi('yaw', 4))),
|
||||
pitch: parseFloat(cell(r, fi('pitch', 5))),
|
||||
roll: parseFloat(cell(r, fi('roll', 6))),
|
||||
focalLen: parseFloat(cell(r, fi('focal_len', 7))),
|
||||
}))
|
||||
.filter((f) => !isNaN(f.lat));
|
||||
}
|
||||
|
||||
// ── 파서: 측점 + 방향전환점 ───────────────────────────────────────────
|
||||
|
||||
/** "02:05" → 125 (초). 파싱 실패 시 NaN. */
|
||||
function mmssToSeconds(s: string): number {
|
||||
const m = s.match(/(\d+):(\d+)/);
|
||||
if (!m) return NaN;
|
||||
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 CSV 파싱 (building/01)측점.csv, EUC-KR).
|
||||
* 헤더: 측점,X좌표,Y좌표,Z좌표,비고,lat,lon,...
|
||||
* title ← 측점, lat ← lat, lon ← lon, z ← Z좌표, category='측점', type='station'
|
||||
* 비고(방향전환점) 도 함께 추출한다: `방향전환점(상행->하행, 02:05)`.
|
||||
*/
|
||||
export async function parseStations(files: File[]): Promise<{
|
||||
stations: GeoPoint[];
|
||||
directionChanges: DirectionChange[];
|
||||
}> {
|
||||
// 측점 파일 — building/ 또는 root(영상 옆) 어디든 허용(building 폴더 삭제 대비).
|
||||
const file =
|
||||
findBuildingFile(files, '01)측점') ??
|
||||
files.find((f) => baseNameOf(relPath(f)).includes('01)측점')) ??
|
||||
files.find((f) => baseNameOf(relPath(f)).includes('측점'));
|
||||
if (!file) return { stations: [], directionChanges: [] };
|
||||
|
||||
const rows = await readCsv(file);
|
||||
if (rows.length < 2) return { stations: [], directionChanges: [] };
|
||||
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iTitle = fi('측점', 0);
|
||||
// Z좌표(col 3)는 로컬좌표(≈0). 실제 표고는 Z좌표_한국(EPSG:5186, 정표고)에 있다.
|
||||
// 없으면 Z좌표로 폴백.
|
||||
const iZKorea = fi('Z좌표_한국');
|
||||
const iZ = iZKorea >= 0 ? iZKorea : fi('Z좌표', 3);
|
||||
const iNote = fi('비고', 4);
|
||||
const iLat = fi('lat', 5);
|
||||
const iLon = fi('lon', 6);
|
||||
|
||||
const stations: GeoPoint[] = [];
|
||||
const directionChanges: DirectionChange[] = [];
|
||||
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
const title = cell(r, iTitle);
|
||||
if (isNaN(lat) || isNaN(lon)) continue;
|
||||
|
||||
stations.push({
|
||||
title,
|
||||
category: '측점',
|
||||
lat,
|
||||
lon,
|
||||
z: parseFloat(cell(r, iZ)) || 0,
|
||||
type: 'station',
|
||||
});
|
||||
|
||||
// 비고: 방향전환점(상행->하행, 02:05)
|
||||
const note = cell(r, iNote);
|
||||
const m = note.match(/방향전환점\s*\(\s*([^->]+?)\s*->\s*([^,)]+?)\s*,\s*([\d:]+)\s*\)/);
|
||||
if (m) {
|
||||
const atSeconds = mmssToSeconds(m[3]);
|
||||
directionChanges.push({
|
||||
station: title,
|
||||
from: m[1].trim(),
|
||||
to: m[2].trim(),
|
||||
atSeconds: isNaN(atSeconds) ? -1 : atSeconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { stations, directionChanges };
|
||||
}
|
||||
|
||||
// ── 파서: POI (2개 소스 병합) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POI 파싱 — 2개 소스를 병합한다(둘 다 type='poi').
|
||||
* a. 루트 <base>_POI.csv (UTF-8): title←title, category←category_clean, lat/lon, z=0
|
||||
* b. building/02)지장물.csv (EUC-KR): title←명칭, category='지장물', lat/lon, z←Z좌표
|
||||
*/
|
||||
export async function parsePois(files: File[], baseName: string | null): Promise<GeoPoint[]> {
|
||||
const result: GeoPoint[] = [];
|
||||
|
||||
// a. <base>_POI.csv (없으면 루트의 *_POI.csv 폴백)
|
||||
const poiFile =
|
||||
(baseName &&
|
||||
files.find(
|
||||
(f) => !isInBuilding(f) && baseNameOf(relPath(f)) === `${baseName}_POI.csv`,
|
||||
)) ||
|
||||
files.find((f) => !isInBuilding(f) && /_POI\.csv$/i.test(baseNameOf(relPath(f)))) ||
|
||||
null;
|
||||
|
||||
if (poiFile) {
|
||||
const rows = await readCsv(poiFile);
|
||||
if (rows.length >= 2) {
|
||||
const header = cleanHeader(rows[0]);
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iTitle = fi('title', 2);
|
||||
const iCat = fi('category_clean', 3);
|
||||
const iLat = fi('lat', 7);
|
||||
const iLon = fi('lon', 8);
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
if (isNaN(lat) || isNaN(lon)) continue;
|
||||
result.push({
|
||||
title: cell(r, iTitle),
|
||||
category: cell(r, iCat) || '건물',
|
||||
lat,
|
||||
lon,
|
||||
z: 0,
|
||||
type: 'poi',
|
||||
props: rowProps(header, r),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// b. building/02)지장물.csv (단, '02)지장물_역사.csv'는 역사 전용 → 제외)
|
||||
const isObstacle = (f: File) => {
|
||||
const n = baseNameOf(relPath(f));
|
||||
return isInBuilding(f) && n.includes('지장물') && !n.includes('역사');
|
||||
};
|
||||
const obFile = files.find((f) => isObstacle(f) && baseNameOf(relPath(f)).includes('02)')) ??
|
||||
files.find(isObstacle) ?? null;
|
||||
if (obFile) {
|
||||
const rows = await readCsv(obFile);
|
||||
if (rows.length >= 2) {
|
||||
const header = cleanHeader(rows[0]);
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iTitle = fi('명칭', 0);
|
||||
const iZ = fi('Z좌표', 5);
|
||||
const iLat = fi('lat', 6);
|
||||
const iLon = fi('lon', 7);
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
if (isNaN(lat) || isNaN(lon)) continue;
|
||||
result.push({
|
||||
title: cell(r, iTitle),
|
||||
category: '지장물',
|
||||
lat,
|
||||
lon,
|
||||
z: parseFloat(cell(r, iZ)) || 0,
|
||||
type: 'poi',
|
||||
props: rowProps(header, r),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 파서: 구조물 (교량/터널/구교) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 구조물 파싱 — 3개 CSV 를 RouteStructure[] 로 병합한다.
|
||||
* a. building/03)교량.csv: name←구분, type='bridge', lengthM←연장(m), grade←시설종별, category='교량'
|
||||
* b. building/04)터널.csv: name←구분, type='tunnel', lengthM←연장(m), grade←시설종별, category='터널'
|
||||
* c. building/06)구교.csv: name←시설물명, type='bridge'(구교 아이콘=교량), lengthM←연장(m), category='구교'(시설종별 컬럼 없음→grade undefined)
|
||||
* lat/lon 이 유효하지 않은 행은 건너뛴다(깨진 좌표 방어).
|
||||
*/
|
||||
export async function parseStructures(files: File[]): Promise<RouteStructure[]> {
|
||||
const out: RouteStructure[] = [];
|
||||
|
||||
const pushFrom = async (
|
||||
file: File | null,
|
||||
spec: {
|
||||
type: 'bridge' | 'tunnel';
|
||||
category: string;
|
||||
nameKey: string;
|
||||
nameFallback: number;
|
||||
lengthKey: string;
|
||||
lengthFallback: number;
|
||||
latFallback: number;
|
||||
lonFallback: number;
|
||||
/** 시설종별 컬럼 인덱스 폴백. 컬럼이 없는 파일은 생략(undefined). */
|
||||
gradeFallback?: number;
|
||||
},
|
||||
): Promise<void> => {
|
||||
if (!file) return;
|
||||
const rows = await readCsv(file);
|
||||
if (rows.length < 2) return;
|
||||
const header = cleanHeader(rows[0]);
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iName = fi(spec.nameKey, spec.nameFallback);
|
||||
const iLen = fi(spec.lengthKey, spec.lengthFallback);
|
||||
const iLat = fi('lat', spec.latFallback);
|
||||
const iLon = fi('lon', spec.lonFallback);
|
||||
// 시설종별: 헤더에 있으면 그 인덱스, 없으면 폴백(지정 시) 사용. 둘 다 없으면 -1.
|
||||
const iGrade = spec.gradeFallback != null ? fi('시설종별', spec.gradeFallback) : fi('시설종별');
|
||||
let n = 0;
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
// 위경도 유효성: 대한민국 범위(대략) 밖이면 깨진 행으로 간주.
|
||||
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) continue;
|
||||
const name = cell(r, iName);
|
||||
if (!name) continue;
|
||||
const lengthM = parseFloat(cell(r, iLen));
|
||||
// 시설종별 원문(trim). 빈 문자열이면 undefined.
|
||||
const grade = cell(r, iGrade).trim();
|
||||
out.push({
|
||||
id: `${spec.category}-${n++}`,
|
||||
type: spec.type,
|
||||
name,
|
||||
category: spec.category,
|
||||
lat,
|
||||
lon,
|
||||
...(isNaN(lengthM) ? {} : { lengthM }),
|
||||
...(grade ? { grade } : {}),
|
||||
props: rowProps(header, r),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// a. 03)교량: 구분,시설종별,...,연장(m),...,X좌표,Y좌표,Z좌표,lat,lon,category_clean
|
||||
await pushFrom(findBuildingFile(files, '03)교량') ?? findBuildingFile(files, '교량'), {
|
||||
type: 'bridge',
|
||||
category: '교량',
|
||||
nameKey: '구분',
|
||||
nameFallback: 0,
|
||||
lengthKey: '연장(m)',
|
||||
lengthFallback: 5,
|
||||
latFallback: 10,
|
||||
lonFallback: 11,
|
||||
gradeFallback: 1,
|
||||
});
|
||||
|
||||
// b. 04)터널: 구분,...,연장(m),시설종별,X좌표,Y좌표,Z좌표,lat,lon,category_clean
|
||||
await pushFrom(findBuildingFile(files, '04)터널') ?? findBuildingFile(files, '터널'), {
|
||||
type: 'tunnel',
|
||||
category: '터널',
|
||||
nameKey: '구분',
|
||||
nameFallback: 0,
|
||||
lengthKey: '연장(m)',
|
||||
lengthFallback: 4,
|
||||
latFallback: 9,
|
||||
lonFallback: 10,
|
||||
gradeFallback: 5,
|
||||
});
|
||||
|
||||
// c. 06)구교: 역구간,시설물명,...,연장(m),lat,lon,z,...
|
||||
await pushFrom(findBuildingFile(files, '06)구교') ?? findBuildingFile(files, '구교'), {
|
||||
type: 'bridge',
|
||||
category: '구교',
|
||||
nameKey: '시설물명',
|
||||
nameFallback: 1,
|
||||
lengthKey: '연장(m)',
|
||||
lengthFallback: 5,
|
||||
latFallback: 6,
|
||||
lonFallback: 7,
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 역사(철도역) 파싱 — building/02)지장물_역사.csv (EUC-KR).
|
||||
* 헤더: 명칭,주소(도로명),주소(지번),X좌표,Y좌표,Z좌표,lat,lon
|
||||
* → RouteStructure(type='station', category='역사')로 변환. 하단 스테이션바에 원형 마커로 표출.
|
||||
* (영상 오버레이는 category '구교'만 표시하므로 역사는 영상에 안 뜸.)
|
||||
*/
|
||||
export async function parseHistoricStations(files: File[]): Promise<RouteStructure[]> {
|
||||
// 역사 파일 — building/ 또는 root 어디든(building 폴더 삭제 대비).
|
||||
const file = files.find((f) => baseNameOf(relPath(f)).includes('지장물_역사'));
|
||||
if (!file) return [];
|
||||
const rows = await readCsv(file);
|
||||
if (rows.length < 2) return [];
|
||||
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iName = fi('명칭', 0);
|
||||
const iLat = fi('lat', 6);
|
||||
const iLon = fi('lon', 7);
|
||||
|
||||
const out: RouteStructure[] = [];
|
||||
let n = 0;
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) continue;
|
||||
const name = cell(r, iName);
|
||||
if (!name) continue;
|
||||
out.push({ id: `역사-${n++}`, type: 'station', name, category: '역사', lat, lon });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 출입문 파싱 — building/05)출입문번호.csv (UTF-8 BOM).
|
||||
* 헤더: 본부,선별,역구간,latitude,longitude,시설팀,출입문번호,...,z,측점
|
||||
* → GeoPoint{ title=출입문번호, category='출입문', lat/lon, z, type='poi' } 로 영상에 POI 처럼 표출.
|
||||
* 위치 인덱스 폴백: latitude=3, longitude=4, 출입문번호=6, z=12.
|
||||
*/
|
||||
export async function parseAccessDoors(files: File[]): Promise<GeoPoint[]> {
|
||||
const file = findBuildingFile(files, '05)출입문') ?? findBuildingFile(files, '출입문');
|
||||
if (!file) return [];
|
||||
const rows = await readCsv(file);
|
||||
if (rows.length < 2) return [];
|
||||
|
||||
const header = cleanHeader(rows[0]);
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iTitle = fi('출입문번호', 6);
|
||||
const iLat = fi('latitude', 3);
|
||||
const iLon = fi('longitude', 4);
|
||||
const iZ = fi('z', 12);
|
||||
|
||||
const out: GeoPoint[] = [];
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) continue;
|
||||
const title = cell(r, iTitle);
|
||||
if (!title) continue;
|
||||
// 원본 CSV 속성 전부(비어있지 않은 것) → 팝업에 표시(KMZ 정보표와 동일 항목).
|
||||
out.push({ title, category: '출입문', lat, lon, z: parseFloat(cell(r, iZ)) || 0, type: 'poi', props: rowProps(header, r) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 파서: KMZ (원본) ─────────────────────────────────────────────────
|
||||
//
|
||||
// KMZ(=doc.kml zip)는 building CSV의 원본이다. 폴더가 02)지장물·03)교량·04)터널·05)출입문번호·
|
||||
// 06)구교 로 1:1 대응하고, 각 Placemark 가 좌표 + description HTML표(속성)를 담는다.
|
||||
// → KMZ 에서 직접 POI/구조물을 추출하면 CSV 추출 중복이 사라진다. (측점(01)은 KMZ에 없음 → CSV 유지)
|
||||
|
||||
/** HTML 태그 제거 + 엔티티 디코드 + 트림. */
|
||||
function stripHtml(s: string): string {
|
||||
return s
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&')
|
||||
.replace(/ /g, ' ').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* description CDATA(HTML표) → 속성 배열. 행/셀 단위로 견고하게 파싱.
|
||||
* 구글어스 재저장본은 `<tbody>`, `</b>` 뒤 `<br>`, `>` 엔티티 등 변형이 있어
|
||||
* `<tr>` 안의 두 `<td>` 셀에서 태그를 벗겨 (키, 값)으로 만든다(옛/새 형식 모두 처리).
|
||||
*/
|
||||
function parseKmlDescProps(descHtml: string): { k: string; v: string }[] {
|
||||
const out: { k: string; v: string }[] = [];
|
||||
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||
let tr: RegExpExecArray | null;
|
||||
while ((tr = trRe.exec(descHtml))) {
|
||||
const tds = [...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((m) => stripHtml(m[1]));
|
||||
if (tds.length >= 2 && tds[0]) out.push({ k: tds[0], v: tds[1] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 폴더 내 직계 자식 중 localName 일치 첫 요소. */
|
||||
function childLocal(el: Element, local: string): Element | null {
|
||||
for (const c of Array.from(el.children)) if (c.localName === local) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** KMZ/KML 파싱 — 있으면 { pois, structures }, 없거나 실패 시 null.
|
||||
* bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제. */
|
||||
export async function parseKmz(
|
||||
files: File[],
|
||||
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[] } | null> {
|
||||
const kmlFile = files.find(
|
||||
(f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))),
|
||||
);
|
||||
const kmzFile = files.find(
|
||||
(f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))),
|
||||
);
|
||||
if (!kmlFile && !kmzFile) return null;
|
||||
|
||||
let kmlText: string;
|
||||
try {
|
||||
if (kmlFile) {
|
||||
kmlText = await kmlFile.text(); // 구글 KML 은 UTF-8
|
||||
} else {
|
||||
const buf = new Uint8Array(await kmzFile!.arrayBuffer());
|
||||
const entries = unzipSync(buf);
|
||||
const kmlName =
|
||||
Object.keys(entries).find((n) => /(^|\/)doc\.kml$/i.test(n)) ??
|
||||
Object.keys(entries).find((n) => /\.kml$/i.test(n));
|
||||
if (!kmlName || !entries[kmlName]) return null;
|
||||
kmlText = strFromU8(entries[kmlName]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[KMZ/KML] 읽기 실패 → CSV 폴백', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = new DOMParser().parseFromString(kmlText, 'application/xml');
|
||||
if (doc.getElementsByTagName('parsererror').length) return null;
|
||||
|
||||
const pois: GeoPoint[] = [];
|
||||
const structures: RouteStructure[] = [];
|
||||
let nStruct = 0;
|
||||
|
||||
const handlePlacemark = (pm: Element, folder: string): void => {
|
||||
const name = childLocal(pm, 'name')?.textContent?.trim() ?? '';
|
||||
const desc = childLocal(pm, 'description')?.textContent ?? '';
|
||||
const props = parseKmlDescProps(desc);
|
||||
const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v;
|
||||
|
||||
// 좌표: <coordinates> lon,lat[,alt] 우선, 없으면 속성 lat/lon.
|
||||
let lat = NaN, lon = NaN;
|
||||
const coordEl = pm.getElementsByTagName('coordinates')[0];
|
||||
if (coordEl?.textContent) {
|
||||
const p = coordEl.textContent.trim().split(/[\s,]+/).map(Number);
|
||||
if (p.length >= 2 && isFinite(p[0]) && isFinite(p[1])) { lon = p[0]; lat = p[1]; }
|
||||
}
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
lat = parseFloat(pget('lat') ?? pget('latitude') ?? '');
|
||||
lon = parseFloat(pget('lon') ?? pget('longitude') ?? '');
|
||||
}
|
||||
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) return;
|
||||
|
||||
const lenNum = parseFloat(pget('연장(m)') ?? '');
|
||||
if (folder.includes('교량')) {
|
||||
structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props });
|
||||
} else if (folder.includes('터널')) {
|
||||
structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props });
|
||||
} else if (folder.includes('구교')) {
|
||||
structures.push({ id: `구교-${nStruct++}`, type: 'bridge', category: '구교', name: pget('시설물명') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), props });
|
||||
} else if (folder.includes('출입문')) {
|
||||
const title = pget('출입문번호') || name;
|
||||
if (title) pois.push({ title, category: '출입문', lat, lon, z: parseFloat(pget('z') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props });
|
||||
} else {
|
||||
// 02)지장물 등 = 지오코딩 POI. 모든 POI 는 영상에 표출(철도역 포함).
|
||||
if (!name) return;
|
||||
const src = pget('source');
|
||||
const cat = pget('category_clean') || '지장물';
|
||||
// 라벨: KML '구분' → 'title' → (없으면) placemark name 순.
|
||||
const label = pget('구분') || pget('title') || name;
|
||||
pois.push({ title: label, category: cat, lat, lon, z: parseFloat(pget('z') ?? pget('Z좌표') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props });
|
||||
// 철도역(KAKAO_RAIL)은 영상 POI 에 더해 '역사'로 하단 스테이션바에도 표출(lat/lon 으로 배치).
|
||||
if (src === 'KAKAO_RAIL' || cat === '철도역' || cat === '역사') {
|
||||
structures.push({ id: `역사-${nStruct++}`, type: 'station', category: '역사', name: label, lat, lon, props });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 폴더 트리 재귀 — Placemark 가 직접 속한 (가장 안쪽) 폴더명을 folder 로 전달.
|
||||
const walk = (el: Element, folder: string): void => {
|
||||
for (const c of Array.from(el.children)) {
|
||||
if (c.localName === 'Folder') {
|
||||
walk(c, childLocal(c, 'name')?.textContent?.trim() ?? folder);
|
||||
} else if (c.localName === 'Document') {
|
||||
walk(c, folder);
|
||||
} else if (c.localName === 'Placemark') {
|
||||
handlePlacemark(c, folder);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (doc.documentElement) walk(doc.documentElement, '');
|
||||
|
||||
return { pois, structures };
|
||||
}
|
||||
|
||||
// ── route.json 보정 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 폴더 보조 파일 route.json 파싱 (UTF-8, JSON).
|
||||
* `<base>.route.json` 우선(case-insensitive), 없으면 `route.json`.
|
||||
* building/ 제외, 루트 파일만. 파싱 실패 시 null.
|
||||
*/
|
||||
export async function parseRouteMeta(
|
||||
files: File[],
|
||||
baseName: string | null,
|
||||
): Promise<RouteMeta | null> {
|
||||
const rootJson = files.filter(
|
||||
(f) => !isInBuilding(f) && /\.json$/i.test(baseNameOf(relPath(f))),
|
||||
);
|
||||
if (!rootJson.length) return null;
|
||||
|
||||
const wantBase = baseName ? `${baseName}.route.json`.toLowerCase() : null;
|
||||
const file =
|
||||
(wantBase &&
|
||||
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
|
||||
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json');
|
||||
if (!file) return null;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
return JSON.parse(text) as RouteMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 이름 정규화(괄호 이하 제거) — route.json ↔ CSV 구조물 매칭용. */
|
||||
function structBaseName(name: string): string {
|
||||
return name.replace(/\s*[((].*$/, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 유래 구조물에 route.json structures 의 offset/station/이정 등을 보정(augment)한다.
|
||||
* name 이 일치(괄호 이하 제거 후 부분일치)하는 항목의 값으로 override 하고,
|
||||
* CSV 에 없는 구조물(route.json 전용)은 그대로 추가한다.
|
||||
*/
|
||||
export function mergeStructures(
|
||||
csvStructures: RouteStructure[],
|
||||
meta: RouteMeta | null,
|
||||
): RouteStructure[] {
|
||||
const metaList = meta?.structures;
|
||||
if (!metaList || !metaList.length) return csvStructures;
|
||||
|
||||
const result = csvStructures.map((s) => ({ ...s }));
|
||||
const used = new Set<number>();
|
||||
|
||||
for (const m of metaList) {
|
||||
const mBase = structBaseName(m.name);
|
||||
const idx = result.findIndex((s) => {
|
||||
const sBase = structBaseName(s.name);
|
||||
return sBase === mBase || sBase.includes(mBase) || mBase.includes(sBase);
|
||||
});
|
||||
if (idx >= 0) {
|
||||
// 보정: route.json 에 명시된 필드만 override (CSV 좌표/연장은 유지).
|
||||
const tgt = result[idx];
|
||||
if (m.station != null) tgt.station = m.station;
|
||||
if (m.offset != null) tgt.offset = m.offset;
|
||||
if (m.startMileage != null) tgt.startMileage = m.startMileage;
|
||||
if (m.endMileage != null) tgt.endMileage = m.endMileage;
|
||||
if (m.lat != null) tgt.lat = m.lat;
|
||||
if (m.lon != null) tgt.lon = m.lon;
|
||||
used.add(idx);
|
||||
} else {
|
||||
// CSV 에 없는 route.json 전용 구조물 → 그대로 추가.
|
||||
result.push({ ...m });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── POI 위치 보정 (마우스 드래그 저장값) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* 폴더에서 POI 보정 파일을 읽는다. `<base>_poi_overrides.json` 우선,
|
||||
* 없으면 `poi_overrides.json`. building/ 제외, 루트만. 파싱 실패 시 {}.
|
||||
* 형식: { baseName?, overrides: { "<title>": {lat,lon,z}, ... } } (또는 평면 맵).
|
||||
*/
|
||||
export async function parsePoiOverrides(
|
||||
files: File[],
|
||||
baseName: string | null,
|
||||
): Promise<PoiOverrideMap> {
|
||||
const rootJson = files.filter(
|
||||
(f) => !isInBuilding(f) && /_poi_overrides\.json$/i.test(baseNameOf(relPath(f))),
|
||||
);
|
||||
if (!rootJson.length) return {};
|
||||
const wantBase = baseName ? `${baseName}_poi_overrides.json`.toLowerCase() : null;
|
||||
const file =
|
||||
(wantBase && rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
|
||||
rootJson[0];
|
||||
try {
|
||||
const obj = JSON.parse(await file.text());
|
||||
const map = (obj && typeof obj === 'object' && obj.overrides) ? obj.overrides : obj;
|
||||
const out: PoiOverrideMap = {};
|
||||
for (const [title, v] of Object.entries(map as Record<string, unknown>)) {
|
||||
const o = v as { lat?: number; lon?: number; z?: number };
|
||||
if (o && isFinite(o.lat as number) && isFinite(o.lon as number)) {
|
||||
out[title] = { lat: o.lat as number, lon: o.lon as number, z: Number(o.z) || 0 };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 보정맵을 POI 배열에 적용 (title 일치 시 lat/lon/z 교체). 원본 불변. */
|
||||
export function applyPoiOverrides(pois: GeoPoint[], overrides: PoiOverrideMap): GeoPoint[] {
|
||||
if (!overrides || !Object.keys(overrides).length) return pois;
|
||||
return pois.map((p) => {
|
||||
const o = overrides[p.title];
|
||||
return o ? { ...p, lat: o.lat, lon: o.lon, z: o.z } : p;
|
||||
});
|
||||
}
|
||||
|
||||
// ── 중심선 생성 (v2.0엔 center.csv 없음) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* 측점을 측점값(mileage) 순으로 정렬한 폴리라인을 중심선으로 생성한다.
|
||||
* v2.0엔 center.csv 가 없으므로 측점(01)측점)이 중심선의 소스다.
|
||||
* 이 중심선이 체이니지 투영/RoutePanel 에 쓰인다.
|
||||
*/
|
||||
export function buildCenterlineFromStations(stations: GeoPoint[]): CenterlinePoint[] {
|
||||
return [...stations]
|
||||
.filter((s) => !isNaN(s.lat) && !isNaN(s.lon))
|
||||
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title))
|
||||
.map((s) => ({ lat: s.lat, lon: s.lon, z: s.z }));
|
||||
}
|
||||
|
||||
// ── 통합 로더 ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 폴더 내 파일에서 영상 + 지리정보(v2.0 형상)를 모두 파싱한다.
|
||||
* 반환값을 geoStore.loadFromFolder 가 스토어에 적재한다.
|
||||
*/
|
||||
export async function loadFolderGeoData(
|
||||
input: FileList | File[],
|
||||
): Promise<FolderGeoData> {
|
||||
const files = Array.from(input);
|
||||
|
||||
const videoFile = findVideoFile(files);
|
||||
const baseName = deriveBaseName(videoFile);
|
||||
|
||||
const [frames, stationResult, poisRaw, csvStructures, routeMeta, poiOverrides, historicStations, accessDoors, kmz] = await Promise.all([
|
||||
parseDroneFrames(files, baseName),
|
||||
parseStations(files),
|
||||
parsePois(files, baseName),
|
||||
parseStructures(files),
|
||||
parseRouteMeta(files, baseName),
|
||||
parsePoiOverrides(files, baseName),
|
||||
parseHistoricStations(files),
|
||||
parseAccessDoors(files),
|
||||
parseKmz(files),
|
||||
]);
|
||||
|
||||
// POI/구조물 출처: KMZ(원본)가 있으면 그걸 사용(지장물·출입문·교량/터널/구교) → CSV 추출 중복 제거.
|
||||
// KMZ 없거나 파싱 실패 시 CSV 폴백(지오코딩 _POI + 지장물 + 출입문 / 구조물). 측점은 항상 CSV.
|
||||
const useKmz = !!kmz && (kmz.pois.length > 0 || kmz.structures.length > 0);
|
||||
const pois = useKmz ? kmz!.pois : poisRaw.concat(accessDoors);
|
||||
const baseStructures = useKmz ? kmz!.structures : csvStructures;
|
||||
if (useKmz) console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} 로드(원본) → CSV 미사용`);
|
||||
|
||||
const stations = stationResult.stations.sort(
|
||||
(a, b) => stationOrder(a.title) - stationOrder(b.title),
|
||||
);
|
||||
const directionChanges = stationResult.directionChanges;
|
||||
|
||||
// route.json 은 선택적 보정 레이어 — 있으면 구조물에 override/augment.
|
||||
// 역사(02)지장물_역사)는 구조물로 합류 → 스테이션바에 표출(영상엔 미표시).
|
||||
const structures = mergeStructures(baseStructures, routeMeta).concat(historicStations);
|
||||
|
||||
// v2.0엔 center.csv 가 없다 → 측점 폴리라인으로 중심선 생성.
|
||||
const centerline = buildCenterlineFromStations(stations);
|
||||
|
||||
let origin: GeoOrigin | null = null;
|
||||
if (stations.length || frames.length) {
|
||||
origin = getWorldOrigin(frames, [...stations, ...pois]);
|
||||
} else if (centerline.length) {
|
||||
origin = { lat: centerline[0].lat, lon: centerline[0].lon, alt: centerline[0].z };
|
||||
}
|
||||
|
||||
return {
|
||||
videoFile,
|
||||
baseName,
|
||||
frames,
|
||||
pois,
|
||||
stations,
|
||||
centerline,
|
||||
origin,
|
||||
routeMeta,
|
||||
structures,
|
||||
directionChanges,
|
||||
poiOverrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* 클라이언트 사이드 3D 좌표 변환 투영
|
||||
*
|
||||
* Python advanced_tuner_v2.py 와 동일한 알고리즘:
|
||||
* R_b2w = Rz(-yaw) * Rx(pitch) * Ry(roll)
|
||||
* R_align = [[1,0,0],[0,0,-1],[0,1,0]]
|
||||
* R_w2c = R_align @ R_b2w.T
|
||||
*
|
||||
* 좌표계: EPSG:5186 TM [East(m), North(m), Up(m)]
|
||||
* Python swap_xy=ON 과 동일: easting=X, northing=Y
|
||||
* sensorH 기본값 20.25mm = 36 × (9/16), 16:9 동영상 기준
|
||||
*/
|
||||
|
||||
import proj4 from 'proj4';
|
||||
|
||||
export interface DroneFrameBasic {
|
||||
frame: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
altitude: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
roll: number;
|
||||
focalLen: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카메라 파라미터 — Python advanced_tuner_v2.py 기본값 기준
|
||||
*
|
||||
* yaw / pitch / roll 은 모두 SRT 프레임값에 더하는 오프셋 (기본 0).
|
||||
* Python: pitch = radians(meta['pitch'] + spn_pitch.value()) ← spn_pitch 기본 0
|
||||
* focalLen / sensorW / sensorH 는 Python spn_focal(24) / spn_sensor(36) 기본값.
|
||||
* offX/offY/offZ: 드론 위치 보정 — Python off_x/y/z 동치.
|
||||
*/
|
||||
export interface CameraParams {
|
||||
yawOffset: number; // yaw 오프셋 (degrees, per-frame SRT yaw에 더함)
|
||||
pitch: number; // pitch 오프셋 (degrees, per-frame SRT pitch에 더함, 기본 0)
|
||||
roll: number; // roll 오프셋 (degrees, per-frame SRT roll에 더함, 기본 0)
|
||||
focalLen: number; // 초점거리 (35mm 환산 mm, 기본 24)
|
||||
cx0: number; // 주점 X 오프셋 (정규화)
|
||||
cy0: number; // 주점 Y 오프셋 (정규화)
|
||||
offX: number; // 드론 위치 East 보정 (m, 기본 0)
|
||||
offY: number; // 드론 위치 North 보정 (m, 기본 0)
|
||||
offZ: number; // 드론 위치 Up 보정 (m, 기본 0)
|
||||
geoidOffset: number; // 지오이드고 (m) — 대상 정표고(EL)→타원체고 변환. 드론 abs_alt(타원체고)와 datum 일치용. 대전≈25.8
|
||||
poiZOffset: number; // POI 전용 표고 보정 (m) — POI/구조물 마커만 들어올림. 측점·중심선엔 미적용.
|
||||
// POI는 실제 표고 데이터가 없어 선로 지면 높이로 가정됨 → 건물/지형이 선로보다 높을 때 보정.
|
||||
sensorW: number; // 센서 폭 (mm, 기본 36)
|
||||
sensorH: number; // 센서 높이 (mm, 기본 20.25 = 36×9/16, 16:9 영상)
|
||||
}
|
||||
|
||||
/** Python advanced_tuner_v2.py 기본값 */
|
||||
export const DEFAULT_CAMERA_PARAMS: CameraParams = {
|
||||
yawOffset: 0,
|
||||
pitch: 0, // offset, Python spn_pitch 기본값 0
|
||||
roll: 0, // offset, Python spn_roll 기본값 0
|
||||
focalLen: 24, // Python spn_focal 기본값 24
|
||||
cx0: 0,
|
||||
cy0: 0,
|
||||
offX: 0,
|
||||
offY: 0,
|
||||
offZ: 0,
|
||||
geoidOffset: 25.8, // 대전 KNGeoid18 ≈ 25.8m (정표고 → 타원체고)
|
||||
poiZOffset: 0, // POI 표고 보정 기본 0 (건물 높이/지형차에 맞춰 패널에서 조정)
|
||||
sensorW: 36, // Python spn_sensor 기본값 36
|
||||
sensorH: 20.25,
|
||||
};
|
||||
|
||||
/** 항상 DEFAULT_CAMERA_PARAMS 반환 (Python 방식: SRT 값은 per-frame으로 자동 적용됨) */
|
||||
export function paramsFromFrame(_frame: DroneFrameBasic): CameraParams {
|
||||
return { ...DEFAULT_CAMERA_PARAMS };
|
||||
}
|
||||
|
||||
// EPSG:5186 Korean TM 정의 (Python pyproj와 동일)
|
||||
proj4.defs('EPSG:5186',
|
||||
'+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs'
|
||||
);
|
||||
const _toTM = proj4('EPSG:4326', 'EPSG:5186');
|
||||
|
||||
/** 위경도 → EPSG:5186 TM [easting(m), northing(m)] */
|
||||
function latLonToTM(lat: number, lon: number): [number, number] {
|
||||
// proj4: forward(lon, lat) → [easting, northing]
|
||||
const [e, n] = _toTM.forward([lon, lat]);
|
||||
return [e, n];
|
||||
}
|
||||
|
||||
function toRad(d: number) { return d * Math.PI / 180; }
|
||||
|
||||
/** 위경도+표고 → 월드 [East, North, Up] (m).
|
||||
* Python swap_xy=ON 방식: EPSG:5186 TM easting/northing + altitude */
|
||||
function geoToEnu(
|
||||
lat: number, lon: number, alt: number,
|
||||
_refLat: number, _refLon: number, refAlt: number,
|
||||
): [number, number, number] {
|
||||
const [e, n] = latLonToTM(lat, lon);
|
||||
return [e, n, alt - refAlt];
|
||||
}
|
||||
|
||||
export interface ProjectResult {
|
||||
px: number; // 0~1, 0=왼쪽 (클램프됨)
|
||||
py: number; // 0~1, 0=위 (클램프됨)
|
||||
pxRaw: number; // 클램프 없는 원본
|
||||
pyRaw: number;
|
||||
dist: number; // 수평 거리 (m)
|
||||
h: number; // 수평각 (degrees)
|
||||
v: number; // 수직각 (degrees)
|
||||
inFov: boolean;
|
||||
}
|
||||
|
||||
type Vec3 = [number, number, number];
|
||||
|
||||
/** 카메라 좌표 (Zc 부호 체크 없음 — 근거리 클리핑은 호출자가 처리) */
|
||||
export interface CameraCoords {
|
||||
Xc: number;
|
||||
Yc: number;
|
||||
Zc: number;
|
||||
/** 수평(평면) 거리 (m) — 고도차 무시한 동·북 거리. POI 거리필터용. toCameraCoords가 채움. */
|
||||
distH?: number;
|
||||
/** 진행방향(yaw) 기준 앞쪽 수평거리 (m, +앞). 앞/옆 비등방 거리필터용. */
|
||||
fwd?: number;
|
||||
/** 진행방향 기준 옆쪽(좌우) 수평거리 (m, 부호=좌우). 필터는 |side| 사용. */
|
||||
side?: number;
|
||||
}
|
||||
|
||||
/** 카메라 좌표 → 정규화 픽셀 (Zc > 0 보장 후 호출) */
|
||||
export function pixelFromCamera(
|
||||
cc: CameraCoords,
|
||||
params: CameraParams,
|
||||
): { pxRaw: number; pyRaw: number } {
|
||||
const f = params.focalLen;
|
||||
const sW = params.sensorW ?? 36;
|
||||
const sH = params.sensorH ?? 20.25;
|
||||
return {
|
||||
pxRaw: (0.5 + params.cx0) + (cc.Xc / cc.Zc) * (f / sW),
|
||||
pyRaw: (0.5 + params.cy0) + (cc.Yc / cc.Zc) * (f / sH),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 역투영 — 화면 정규픽셀(px,py) + 현재 카메라에서 월드 좌표(lat/lon/z)를 복원.
|
||||
* 2D→3D는 깊이가 모호하므로 **슬랜트 거리(range)를 유지**한다. 한 번의 드래그로
|
||||
* 수평(lat/lon)과 수직(z)이 함께 바뀐다(POI 마우스 보정용).
|
||||
*
|
||||
* px = 0.5 + (Xc/Zc)·(f/sW), py = 0.5 + (Yc/Zc)·(f/sH) 의 역:
|
||||
* 카메라방향 dir ∝ ((px-0.5)·sW/f, (py-0.5)·sH/f, 1) → range로 정규화 → (Xc,Yc,Zc)
|
||||
* rel = R_w2cᵀ·(Xc,Yc,Zc), stEnu = drEnuAdj + rel
|
||||
* z(정표고) = camera.alt + offZ + rel_up − geoid (투영이 다시 geoid를 더함)
|
||||
*/
|
||||
export function worldFromPixel(
|
||||
camera: DroneFrameBasic,
|
||||
px: number, py: number, range: number,
|
||||
params: CameraParams,
|
||||
ref?: { lat: number; lon: number; alt: number },
|
||||
): { lat: number; lon: number; z: number } {
|
||||
const f = params.focalLen, sW = params.sensorW ?? 36, sH = params.sensorH ?? 20.25;
|
||||
// 1) 카메라 프레임 방향 → range 로 스케일
|
||||
const dx = ((px - 0.5) - (params.cx0 ?? 0)) * sW / f;
|
||||
const dy = ((py - 0.5) - (params.cy0 ?? 0)) * sH / f;
|
||||
const dz = 1;
|
||||
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
const Xc = dx / len * range, Yc = dy / len * range, Zc = dz / len * range;
|
||||
// 2) rel = R_w2cᵀ · cam. (R_w2c row0=col0(b2w), row1=-col2(b2w), row2=col1(b2w))
|
||||
const b2w = buildRotation(camera, params);
|
||||
const w2c: [Vec3, Vec3, Vec3] = [
|
||||
[b2w[0][0], b2w[1][0], b2w[2][0]],
|
||||
[-b2w[0][2], -b2w[1][2], -b2w[2][2]],
|
||||
[b2w[0][1], b2w[1][1], b2w[2][1]],
|
||||
];
|
||||
const cam: Vec3 = [Xc, Yc, Zc];
|
||||
const rel: Vec3 = [
|
||||
w2c[0][0] * cam[0] + w2c[1][0] * cam[1] + w2c[2][0] * cam[2],
|
||||
w2c[0][1] * cam[0] + w2c[1][1] * cam[1] + w2c[2][1] * cam[2],
|
||||
w2c[0][2] * cam[0] + w2c[1][2] * cam[1] + w2c[2][2] * cam[2],
|
||||
];
|
||||
// 3) 월드 ENU. drEnu[0],[1] = 절대 TM easting/northing
|
||||
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
|
||||
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
|
||||
const E = drEnu[0] + (params.offX ?? 0) + rel[0];
|
||||
const N = drEnu[1] + (params.offY ?? 0) + rel[1];
|
||||
const z = camera.altitude + (params.offZ ?? 0) + rel[2] - (params.geoidOffset ?? 0);
|
||||
const [lon, lat] = _toTM.inverse([E, N]);
|
||||
return { lat, lon, z };
|
||||
}
|
||||
|
||||
/**
|
||||
* 표고 역산 — POI의 lat/lon은 고정하고, 화면 세로위치(pyTarget)에 맞는 z(정표고)를 구한다.
|
||||
* "POI가 진행방향으로 앞에 밀려 보이는(표고 과대) 슬라이드" 보정용: 드래그로 실제 지면에
|
||||
* 내려놓으면 그 z가 저장되어 전 거리에서 정렬된다.
|
||||
*
|
||||
* py = 0.5 + cy0 + (Yc/Zc)·(f/sH). Yc,Zc 는 z에 1차(rel_up 가 z에 비례) →
|
||||
* Yc=Yc0+dYc·u, Zc=Zc0+dZc·u (u=z−z0), R=(py−0.5−cy0)·sH/f=Yc/Zc 풀이:
|
||||
* u = (R·Zc0 − Yc0)/(dYc − R·dZc)
|
||||
*/
|
||||
export function solveZForPixelY(
|
||||
camera: DroneFrameBasic,
|
||||
lat: number, lon: number, z0: number, pyTarget: number,
|
||||
params: CameraParams,
|
||||
ref?: { lat: number; lon: number; alt: number },
|
||||
): number {
|
||||
const cc0 = toCameraCoords(camera, lat, lon, z0, params, ref);
|
||||
const b2w = buildRotation(camera, params);
|
||||
const dXc = b2w[2][0]; // col0 Up 성분
|
||||
const dYc = -b2w[2][2]; // -col2 Up
|
||||
const dZc = b2w[2][1]; // col1 Up
|
||||
void dXc;
|
||||
const f = params.focalLen, sH = params.sensorH ?? 20.25;
|
||||
const R = (pyTarget - 0.5 - (params.cy0 ?? 0)) * sH / f;
|
||||
const denom = dYc - R * dZc;
|
||||
if (Math.abs(denom) < 1e-9) return z0;
|
||||
return z0 + (R * cc0.Zc - cc0.Yc) / denom;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면점 → 지면 좌표(lat/lon). 카메라 광선을 표고 zGround 의 수평면과 교차.
|
||||
* POI 좌표(지오코딩) 오차 보정용: 드래그한 지면 지점의 lat/lon 을 돌려줌(z는 유지).
|
||||
* 가까운/내려보는 프레임(가파른 광선)에서 정확. 먼 프레임(얕은 광선)은 민감 → 부정확.
|
||||
*/
|
||||
export function groundPointFromPixel(
|
||||
camera: DroneFrameBasic,
|
||||
px: number, py: number, zGround: number,
|
||||
params: CameraParams,
|
||||
ref?: { lat: number; lon: number; alt: number },
|
||||
): { lat: number; lon: number } | null {
|
||||
const f = params.focalLen, sW = params.sensorW ?? 36, sH = params.sensorH ?? 20.25;
|
||||
const dxc = ((px - 0.5) - (params.cx0 ?? 0)) * sW / f;
|
||||
const dyc = ((py - 0.5) - (params.cy0 ?? 0)) * sH / f;
|
||||
const cam: Vec3 = [dxc, dyc, 1];
|
||||
const b2w = buildRotation(camera, params);
|
||||
const w2c: [Vec3, Vec3, Vec3] = [
|
||||
[b2w[0][0], b2w[1][0], b2w[2][0]],
|
||||
[-b2w[0][2], -b2w[1][2], -b2w[2][2]],
|
||||
[b2w[0][1], b2w[1][1], b2w[2][1]],
|
||||
];
|
||||
// 월드 ENU 방향 dir = R_w2cᵀ · cam
|
||||
const dir: Vec3 = [
|
||||
w2c[0][0] * cam[0] + w2c[1][0] * cam[1] + w2c[2][0] * cam[2],
|
||||
w2c[0][1] * cam[0] + w2c[1][1] * cam[1] + w2c[2][1] * cam[2],
|
||||
w2c[0][2] * cam[0] + w2c[1][2] * cam[1] + w2c[2][2] * cam[2],
|
||||
];
|
||||
if (Math.abs(dir[2]) < 1e-9) return null;
|
||||
// 광선 rel = t·dir, 목표면 rel_up = (zGround+geoid) − droneAlt − offZ
|
||||
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
|
||||
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
|
||||
const relUpTarget = (zGround + (params.geoidOffset ?? 0)) - camera.altitude - (params.offZ ?? 0);
|
||||
const t = relUpTarget / dir[2];
|
||||
if (t <= 0) return null; // 카메라 뒤/위
|
||||
const E = drEnu[0] + (params.offX ?? 0) + t * dir[0];
|
||||
const N = drEnu[1] + (params.offY ?? 0) + t * dir[1];
|
||||
const [lon, lat] = _toTM.inverse([E, N]);
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
// ── 공통 내부 계산 ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRelEnu(
|
||||
camera: DroneFrameBasic,
|
||||
targetLat: number, targetLon: number, targetAlt: number,
|
||||
params: CameraParams,
|
||||
ref?: { lat: number; lon: number; alt: number },
|
||||
): { relEnu: Vec3; dist: number } {
|
||||
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
|
||||
// 대상 정표고(EL) + 지오이드고 → 타원체고. 드론 abs_alt(타원체고)와 datum 일치.
|
||||
const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), refPt.lat, refPt.lon, refPt.alt);
|
||||
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
|
||||
const drEnuAdj: Vec3 = [
|
||||
drEnu[0] + (params.offX ?? 0),
|
||||
drEnu[1] + (params.offY ?? 0),
|
||||
drEnu[2] + (params.offZ ?? 0),
|
||||
];
|
||||
const relEnu: Vec3 = [stEnu[0] - drEnuAdj[0], stEnu[1] - drEnuAdj[1], stEnu[2] - drEnuAdj[2]];
|
||||
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2);
|
||||
return { relEnu, dist };
|
||||
}
|
||||
|
||||
function buildRotation(camera: DroneFrameBasic, params: CameraParams): [Vec3, Vec3, Vec3] {
|
||||
const yaw = toRad(camera.yaw + params.yawOffset);
|
||||
const pitch = toRad(camera.pitch + params.pitch);
|
||||
const roll = toRad(camera.roll + params.roll);
|
||||
const cy = Math.cos(yaw), sy = Math.sin(yaw);
|
||||
const cp = Math.cos(pitch), sp = Math.sin(pitch);
|
||||
const cr = Math.cos(roll), sr = Math.sin(roll);
|
||||
return [
|
||||
[ cy*cr + sy*sp*sr, sy*cp, cy*sr - sy*sp*cr],
|
||||
[-sy*cr + cy*sp*sr, cy*cp, -sy*sr - cy*sp*cr],
|
||||
[ -cp*sr, sp, cp*cr ],
|
||||
];
|
||||
}
|
||||
|
||||
function applyRw2c(b2w: [Vec3, Vec3, Vec3], rel: Vec3): CameraCoords {
|
||||
return {
|
||||
Xc: b2w[0][0]*rel[0] + b2w[1][0]*rel[1] + b2w[2][0]*rel[2],
|
||||
Yc: -(b2w[0][2]*rel[0] + b2w[1][2]*rel[1] + b2w[2][2]*rel[2]),
|
||||
Zc: b2w[0][1]*rel[0] + b2w[1][1]*rel[1] + b2w[2][1]*rel[2],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 카메라 좌표만 반환 (Zc 체크 없음).
|
||||
* 선로 중심선 근거리 클리핑(Python 방식)에 사용.
|
||||
*/
|
||||
export function toCameraCoords(
|
||||
camera: DroneFrameBasic,
|
||||
targetLat: number, targetLon: number, targetAlt: number,
|
||||
params: CameraParams,
|
||||
ref?: { lat: number; lon: number; alt: number },
|
||||
): CameraCoords {
|
||||
const { relEnu, dist } = buildRelEnu(camera, targetLat, targetLon, targetAlt, params, ref);
|
||||
const b2w = buildRotation(camera, params);
|
||||
const cc = applyRw2c(b2w, relEnu);
|
||||
cc.distH = dist; // 수평(평면) 거리 — 고도차 무시
|
||||
// 진행방향(yaw, 북=0 시계방향) 기준 앞/옆 수평성분. heading=(sinθ[E],cosθ[N]).
|
||||
const yaw = toRad(camera.yaw + params.yawOffset);
|
||||
const sy = Math.sin(yaw), cy = Math.cos(yaw);
|
||||
cc.fwd = relEnu[0] * sy + relEnu[1] * cy; // +앞
|
||||
cc.side = relEnu[0] * cy - relEnu[1] * sy; // 우(+)/좌(-)
|
||||
return cc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Python advanced_tuner_v2.py 와 동일한 투영 공식
|
||||
*
|
||||
* 회전 행렬:
|
||||
* R_b2w = Rz(-yaw) × Rx(pitch) × Ry(roll)
|
||||
* R_align = [[1,0,0],[0,0,-1],[0,1,0]] (body→camera 축 변환)
|
||||
* R_w2c = R_align × R_b2w.T
|
||||
*
|
||||
* 투영:
|
||||
* pts_cam = R_w2c × rel_enu
|
||||
* u_norm = 0.5 + (Xc/Zc) × (f/sensorW)
|
||||
* v_norm = 0.5 + (Yc/Zc) × (f/sensorH)
|
||||
*
|
||||
* 드론 위치 오프셋 (off_x/y/z): 카메라 위치를 ENU 공간에서 보정
|
||||
*/
|
||||
export function projectPoint(
|
||||
camera: DroneFrameBasic,
|
||||
targetLat: number,
|
||||
targetLon: number,
|
||||
targetAlt: number,
|
||||
params: CameraParams,
|
||||
ref?: { lat: number; lon: number; alt: number },
|
||||
): ProjectResult | null {
|
||||
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
|
||||
|
||||
// 1. 월드 ENU (m) — 대상 정표고 + 지오이드고 → 타원체고 (드론 abs_alt와 datum 일치)
|
||||
const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), refPt.lat, refPt.lon, refPt.alt);
|
||||
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
|
||||
|
||||
// 드론 위치 보정 적용 (Python: drone_pos = [dx+off_x, dy+off_y, alt+off_z])
|
||||
const drEnuAdj: Vec3 = [
|
||||
drEnu[0] + (params.offX ?? 0),
|
||||
drEnu[1] + (params.offY ?? 0),
|
||||
drEnu[2] + (params.offZ ?? 0),
|
||||
];
|
||||
|
||||
const relEnu: Vec3 = [
|
||||
stEnu[0] - drEnuAdj[0],
|
||||
stEnu[1] - drEnuAdj[1],
|
||||
stEnu[2] - drEnuAdj[2],
|
||||
];
|
||||
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2);
|
||||
|
||||
// 2. 회전 행렬 (Python 방식: Rz(-yaw)*Rx(pitch)*Ry(roll), 모두 라디안)
|
||||
// Python: yaw=radians(meta['yaw']+off_yaw), pitch=radians(meta['pitch']+off_pitch), ...
|
||||
const yaw = toRad(camera.yaw + params.yawOffset);
|
||||
const pitch = toRad(camera.pitch + params.pitch); // SRT per-frame + offset
|
||||
const roll = toRad(camera.roll + params.roll); // SRT per-frame + offset
|
||||
|
||||
const cy = Math.cos(yaw), sy = Math.sin(yaw);
|
||||
const cp = Math.cos(pitch), sp = Math.sin(pitch);
|
||||
const cr = Math.cos(roll), sr = Math.sin(roll);
|
||||
|
||||
// Rz(-yaw): rotation around Z by -yaw
|
||||
// [[cy, sy, 0], [-sy, cy, 0], [0, 0, 1]]
|
||||
// Rx(pitch): rotation around X by pitch
|
||||
// [[1, 0, 0], [0, cp, -sp], [0, sp, cp]]
|
||||
// Ry(roll): rotation around Y by roll
|
||||
// [[cr, 0, sr], [0, 1, 0], [-sr, 0, cr]]
|
||||
//
|
||||
// R_b2w = Rz(-yaw) * Rx(pitch) * Ry(roll)
|
||||
// Computed element by element:
|
||||
|
||||
const b2w: [Vec3, Vec3, Vec3] = [
|
||||
[
|
||||
cy*cr + sy*sp*sr, sy*cp, cy*sr - sy*sp*cr,
|
||||
],
|
||||
[
|
||||
-sy*cr + cy*sp*sr, cy*cp, -sy*sr - cy*sp*cr,
|
||||
],
|
||||
[
|
||||
-cp*sr, sp, cp*cr,
|
||||
],
|
||||
];
|
||||
|
||||
// R_w2c = R_align @ R_b2w.T (R_align = [[1,0,0],[0,0,-1],[0,1,0]])
|
||||
//
|
||||
// R_w2c rows are derived from columns of R_b2w:
|
||||
// R_w2c row 0 = col 0 of R_b2w (R_align row 0 = [1,0,0])
|
||||
// R_w2c row 1 = -(col 2 of R_b2w) (R_align row 1 = [0,0,-1])
|
||||
// R_w2c row 2 = col 1 of R_b2w (R_align row 2 = [0,1,0])
|
||||
//
|
||||
// p_cam = R_w2c @ relEnu → access b2w columns (swap first index across rows)
|
||||
|
||||
const Xc = b2w[0][0]*relEnu[0] + b2w[1][0]*relEnu[1] + b2w[2][0]*relEnu[2]; // col 0
|
||||
const Yc = -(b2w[0][2]*relEnu[0] + b2w[1][2]*relEnu[1] + b2w[2][2]*relEnu[2]); // -col 2
|
||||
const Zc = b2w[0][1]*relEnu[0] + b2w[1][1]*relEnu[1] + b2w[2][1]*relEnu[2]; // col 1
|
||||
|
||||
if (Zc <= 0) return null;
|
||||
|
||||
// 3. 핀홀 투영 (Python: u=f_px*(Xc/Zc)+w/2, v=f_px*(Yc/Zc)+h/2)
|
||||
const f = params.focalLen;
|
||||
const sW = params.sensorW ?? 36;
|
||||
const sH = params.sensorH ?? 20.25;
|
||||
|
||||
const pxRaw = (0.5 + params.cx0) + (Xc / Zc) * (f / sW);
|
||||
const pyRaw = (0.5 + params.cy0) + (Yc / Zc) * (f / sH);
|
||||
|
||||
return {
|
||||
px: Math.max(0, Math.min(1, pxRaw)),
|
||||
py: Math.max(0, Math.min(1, pyRaw)),
|
||||
pxRaw,
|
||||
pyRaw,
|
||||
dist,
|
||||
h: Math.atan2(Xc, Zc) * (180 / Math.PI),
|
||||
v: Math.atan2(-Yc, Zc) * (180 / Math.PI),
|
||||
inFov: pxRaw >= 0 && pxRaw <= 1 && pyRaw >= 0 && pyRaw <= 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 클라이언트 지리정보 검색 알고리즘
|
||||
*
|
||||
* 서버 server/src/services/geoMatch.ts 의 검색/투영 로직을 그대로 포팅한다.
|
||||
* (검색 UI 결과를 서버 응답과 동일하게 유지하기 위해, 오버레이용 proj4 기반
|
||||
* geoProjection.ts 와는 별개의 단순 ENU(cos-lat 근사) 투영을 사용한다.)
|
||||
*
|
||||
* 싱글턴 캐시(loadFrames/loadPois) 대신 데이터를 인자로 받는 순수 함수로 변경했다.
|
||||
* 입력 데이터는 geoStore 에서 읽어 넘긴다.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DroneFrame,
|
||||
GeoPoint,
|
||||
FrameMatch,
|
||||
PoiInFrame,
|
||||
GeoOrigin,
|
||||
} from '../types/geo';
|
||||
|
||||
// ── 카메라/지구 상수 (geoMatch.ts 동일) ──────────────────────────────
|
||||
const SENSOR_W_MM = 36;
|
||||
const SENSOR_H_MM = 24;
|
||||
const R_EARTH = 6371000;
|
||||
const DEFAULT_FPS = 30;
|
||||
const GAP = 30;
|
||||
|
||||
export function toRad(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** 위경도+표고 → 월드 ENU (m). refLat/refLon/refAlt = 원점. geoMatch.ts:78-87 */
|
||||
export function geoToEnu(
|
||||
lat: number, lon: number, alt: number,
|
||||
refLat: number, refLon: number, refAlt: number,
|
||||
): [number, number, number] {
|
||||
const cosRef = Math.cos(toRad(refLat));
|
||||
const e = toRad(lon - refLon) * cosRef * R_EARTH; // East (m)
|
||||
const n = toRad(lat - refLat) * R_EARTH; // North (m)
|
||||
const u = alt - refAlt; // Up (m)
|
||||
return [e, n, u];
|
||||
}
|
||||
|
||||
/** 월드 ENU 벡터 + 카메라 자세 → 정규화 픽셀. geoMatch.ts:90-124 */
|
||||
export function projectEnu(
|
||||
relEnu: [number, number, number],
|
||||
yawDeg: number,
|
||||
pitchDeg: number,
|
||||
focalMm: number,
|
||||
yawOffset = 0,
|
||||
): { px: number; py: number; cx: number; cy: number; cz: number } | null {
|
||||
const yaw = toRad(yawDeg + yawOffset);
|
||||
const pitch = toRad(pitchDeg);
|
||||
const cosY = Math.cos(yaw), sinY = Math.sin(yaw);
|
||||
const cosP = Math.cos(pitch), sinP = Math.sin(pitch);
|
||||
|
||||
const fwd: readonly [number, number, number] = [sinY * cosP, cosY * cosP, sinP];
|
||||
const right: readonly [number, number, number] = [cosY, -sinY, 0];
|
||||
const up: readonly [number, number, number] = [
|
||||
right[1] * fwd[2] - right[2] * fwd[1],
|
||||
right[2] * fwd[0] - right[0] * fwd[2],
|
||||
right[0] * fwd[1] - right[1] * fwd[0],
|
||||
];
|
||||
|
||||
const dot = (a: readonly [number, number, number], b: readonly [number, number, number]) =>
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
|
||||
const cx = dot(relEnu, right);
|
||||
const cy = dot(relEnu, up);
|
||||
const cz = dot(relEnu, fwd);
|
||||
|
||||
if (cz <= 0) return null;
|
||||
|
||||
const f = focalMm || 24;
|
||||
const px = 0.5 + (cx / cz) * (f / SENSOR_W_MM);
|
||||
const py = 0.5 - (cy / cz) * (f / SENSOR_H_MM);
|
||||
return { px, py, cx, cy, cz };
|
||||
}
|
||||
|
||||
/** DroneFrame + POI + 공통 기준점 → 픽셀. geoMatch.ts:127-156 */
|
||||
export function project3D(
|
||||
drone: DroneFrame,
|
||||
poi: { lat: number; lon: number; z: number },
|
||||
yawOffset = 0,
|
||||
origin?: GeoOrigin,
|
||||
): { px: number; py: number; dist: number; h: number; v: number; inFov: boolean } | null {
|
||||
const ref = origin ?? { lat: drone.lat, lon: drone.lon, alt: drone.altitude };
|
||||
|
||||
const stEnu = geoToEnu(poi.lat, poi.lon, poi.z, ref.lat, ref.lon, ref.alt);
|
||||
const drEnu = geoToEnu(drone.lat, drone.lon, drone.altitude, ref.lat, ref.lon, ref.alt);
|
||||
const relEnu: [number, number, number] = [
|
||||
stEnu[0] - drEnu[0],
|
||||
stEnu[1] - drEnu[1],
|
||||
stEnu[2] - drEnu[2],
|
||||
];
|
||||
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2);
|
||||
|
||||
const res = projectEnu(relEnu, drone.yaw, drone.pitch, drone.focalLen || 24, yawOffset);
|
||||
if (!res) return null;
|
||||
|
||||
const h = Math.atan2(res.cx, res.cz) * (180 / Math.PI);
|
||||
const v = Math.atan2(res.cy, res.cz) * (180 / Math.PI);
|
||||
const inFov = res.px >= 0 && res.px <= 1 && res.py >= 0 && res.py <= 1;
|
||||
return {
|
||||
px: Math.max(0, Math.min(1, res.px)),
|
||||
py: Math.max(0, Math.min(1, res.py)),
|
||||
dist, h, v, inFov,
|
||||
};
|
||||
}
|
||||
|
||||
/** 측점 km 정렬 키. geoMatch.ts:371-375 */
|
||||
export function stationOrder(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
if (!m) return 0;
|
||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||
}
|
||||
|
||||
/** 첫 번째 측점 위치를 ENU 원점으로 반환. geoMatch.ts:378-387 */
|
||||
export function getWorldOrigin(frames: DroneFrame[], pois: GeoPoint[]): GeoOrigin {
|
||||
const stations = pois
|
||||
.filter((p) => p.type === 'station')
|
||||
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
|
||||
if (stations.length) {
|
||||
return { lat: stations[0].lat, lon: stations[0].lon, alt: stations[0].z };
|
||||
}
|
||||
return frames[0]
|
||||
? { lat: frames[0].lat, lon: frames[0].lon, alt: frames[0].altitude }
|
||||
: { lat: 0, lon: 0, alt: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 건물/측점명 검색 → 매칭 POI + 카메라 시야 프레임 목록. geoMatch.ts:399-468
|
||||
* pois 는 POI+측점 통합 배열을 넘긴다(검색 대상 전체).
|
||||
*/
|
||||
export function findFramesForPoi(
|
||||
frames: DroneFrame[],
|
||||
pois: GeoPoint[],
|
||||
query: string,
|
||||
marginFactor = 1.0,
|
||||
maxDist = 2000,
|
||||
yawOffset = 0,
|
||||
origin?: GeoOrigin | null,
|
||||
): { poi: GeoPoint | null; frames: FrameMatch[] } {
|
||||
const q = query.trim().toLowerCase();
|
||||
const poi = pois.find((p) => p.title.toLowerCase().includes(q));
|
||||
if (!poi) return { poi: null, frames: [] };
|
||||
|
||||
const matches: FrameMatch[] = [];
|
||||
const ref = origin ?? getWorldOrigin(frames, pois);
|
||||
|
||||
for (const f of frames) {
|
||||
const res = project3D(f, poi, yawOffset, ref);
|
||||
if (!res) continue;
|
||||
if (res.dist > maxDist) continue;
|
||||
|
||||
const halfW = 0.5 * marginFactor;
|
||||
const halfH = 0.5 * marginFactor;
|
||||
const rawPx = 0.5 + (res.px - 0.5);
|
||||
const rawPy = 0.5 + (res.py - 0.5);
|
||||
if (Math.abs(rawPx - 0.5) > halfW || Math.abs(rawPy - 0.5) > halfH) continue;
|
||||
|
||||
matches.push({
|
||||
frame: f.frame,
|
||||
time: f.frame / DEFAULT_FPS,
|
||||
bearingDiff: res.h,
|
||||
elevationDiff: res.v,
|
||||
distance: res.dist,
|
||||
pixelX: res.px,
|
||||
pixelY: res.py,
|
||||
});
|
||||
}
|
||||
|
||||
matches.sort((a, b) => a.frame - b.frame);
|
||||
|
||||
const groups: FrameMatch[][] = [];
|
||||
let group: FrameMatch[] = [];
|
||||
for (const m of matches) {
|
||||
if (group.length === 0 || m.frame - group[group.length - 1].frame <= GAP) {
|
||||
group.push(m);
|
||||
} else {
|
||||
groups.push(group);
|
||||
group = [m];
|
||||
}
|
||||
}
|
||||
if (group.length > 0) groups.push(group);
|
||||
|
||||
const best = groups.map((g) => {
|
||||
const groupStart = g[0].frame;
|
||||
const groupEnd = g[g.length - 1].frame;
|
||||
g.sort(
|
||||
(a, b) =>
|
||||
a.bearingDiff ** 2 + a.elevationDiff ** 2 - (b.bearingDiff ** 2 + b.elevationDiff ** 2),
|
||||
);
|
||||
return { ...g[0], groupSize: g.length, groupStart, groupEnd };
|
||||
});
|
||||
|
||||
best.sort(
|
||||
(a, b) =>
|
||||
a.bearingDiff ** 2 + a.elevationDiff ** 2 - (b.bearingDiff ** 2 + b.elevationDiff ** 2),
|
||||
);
|
||||
|
||||
return { poi, frames: best };
|
||||
}
|
||||
|
||||
/**
|
||||
* 프레임 번호 → 해당 프레임에서 카메라 시야에 들어오는 POI/측점 목록. geoMatch.ts:473-512
|
||||
*/
|
||||
export function findPoisForFrame(
|
||||
frames: DroneFrame[],
|
||||
pois: GeoPoint[],
|
||||
frameNum: number,
|
||||
marginFactor = 1.0,
|
||||
yawOffset = 0,
|
||||
origin?: GeoOrigin | null,
|
||||
): { droneFrame: DroneFrame | null; pois: PoiInFrame[] } {
|
||||
const drone =
|
||||
frames.find((f) => f.frame === frameNum) ??
|
||||
(() => {
|
||||
let best = frames[0];
|
||||
let bestD = Math.abs((best?.frame ?? 0) - frameNum);
|
||||
for (const f of frames) {
|
||||
const d = Math.abs(f.frame - frameNum);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = f;
|
||||
}
|
||||
if (d === 0) break;
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
if (!drone) return { droneFrame: null, pois: [] };
|
||||
|
||||
const result: PoiInFrame[] = [];
|
||||
const ref = origin ?? getWorldOrigin(frames, pois);
|
||||
|
||||
for (const poi of pois) {
|
||||
const res = project3D(drone, poi, yawOffset, ref);
|
||||
if (!res) continue;
|
||||
|
||||
const halfW = 0.5 * marginFactor;
|
||||
const halfH = 0.5 * marginFactor;
|
||||
if (Math.abs(res.px - 0.5) > halfW || Math.abs(res.py - 0.5) > halfH) continue;
|
||||
|
||||
result.push({
|
||||
poi,
|
||||
bearingDiff: res.h,
|
||||
elevationDiff: res.v,
|
||||
distance: res.dist,
|
||||
pixelX: res.px,
|
||||
pixelY: res.py,
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.distance - b.distance);
|
||||
return { droneFrame: drone, pois: result };
|
||||
}
|
||||