defVideo 작업분 반영

This commit is contained in:
b23042
2026-06-17 13:57:21 +09:00
parent 82662d417d
commit d0e999b083
82 changed files with 2929 additions and 56 deletions

0
.eslintrc.json Normal file → Executable file
View File

0
.gitignore vendored Normal file → Executable file
View File

0
.prettierrc Normal file → Executable file
View File

0
CLAUDE.md Normal file → Executable file
View File

0
PLAN.md Normal file → Executable file
View File

0
PROGRESS.md Normal file → Executable file
View File

0
README.md Normal file → Executable file
View File

0
VERIFICATION.md Normal file → Executable file
View File

View File

@@ -29,6 +29,7 @@
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"sass": "^1.101.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.6" "vite": "^5.2.6"

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
client/public/assets/minimap.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

View File

@@ -4,7 +4,7 @@
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증 * - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
interface GeoPoint { interface GeoPoint {
title: string; title: string;
@@ -50,6 +50,8 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) {
const [result, setResult] = useState<StationResult | null>(null); const [result, setResult] = useState<StationResult | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [seekedFrame, setSeekedFrame] = useState<number | null>(null); const [seekedFrame, setSeekedFrame] = useState<number | null>(null);
// 드론 프레임(위경도) — 측점 클릭 시 GPS 최근접 프레임 계산용.
const framesRef = useRef<{ frame: number; lat: number; lon: number }[]>([]);
useEffect(() => { useEffect(() => {
fetch('/api/geo/pois') fetch('/api/geo/pois')
@@ -63,23 +65,43 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) {
.catch(() => {}); .catch(() => {});
}, []); }, []);
useEffect(() => {
fetch('/api/geo/frames?step=1')
.then(r => r.json())
.then((d) => { framesRef.current = Array.isArray(d) ? d : []; })
.catch(() => {});
}, []);
// 드론 GPS 가 측점에 가장 가까운 프레임 (StationBar 배지·실제 위치와 동일 기준).
const nearestFrameForStation = (st: GeoPoint): number | null => {
const fr = framesRef.current;
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 = async (station: GeoPoint) => { const handleClick = async (station: GeoPoint) => {
setSelected(station.title); setSelected(station.title);
setResult(null); setResult(null);
setLoading(true); setLoading(true);
setSeekedFrame(null); setSeekedFrame(null);
// 영상은 드론 GPS 가 그 측점에 가장 가까운 프레임으로 이동한다.
// (카메라 FOV 검색은 앞을 보는 카메라 특성상 ~200m 앞쪽으로 치우쳐 위치가 어긋남)
const gpsFrame = nearestFrameForStation(station);
if (gpsFrame != null) {
onSeekToFrame(gpsFrame);
setSeekedFrame(gpsFrame);
}
// 검증 정보(카메라 시야 프레임/투영)는 참고용으로 표시.
try { try {
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(station.title)}&margin=1.2&maxDist=2000`); const res = await fetch(`/api/geo/search?q=${encodeURIComponent(station.title)}&margin=1.2&maxDist=2000`);
const data = await res.json(); const data = await res.json();
if (!res.ok || !data.frames?.length) { setResult(res.ok && data.frames?.length ? data : { frames: [], poi: data.poi ?? station });
setResult({ frames: [], poi: data.poi ?? station });
return;
}
setResult(data);
// 가장 중심에 가까운 첫 번째 결과로 이동
const best = data.frames[0];
onSeekToFrame(best.frame);
setSeekedFrame(best.frame);
} catch { } catch {
setResult(null); setResult(null);
} finally { } finally {

View File

@@ -253,8 +253,8 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
return ( return (
<div <div
ref={panelRef} ref={panelRef}
className="absolute left-0 w-28 border-r border-white/20 z-30" className="absolute w-28 border border-white/20 rounded-md z-30"
style={{ top: '8%', height: '80%', background: 'rgba(0,0,0,0.6)' }} style={{ left: 10, top: '12%', height: '68%', background: 'rgba(0,0,0,0.6)' }}
> >
{/* Center vertical line */} {/* Center vertical line */}
<div <div

View File

@@ -509,9 +509,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
}); });
} }
// 나침반 HUD // 나침반 HUD — 우측 상단 (상단 '카메라 파라미터' 버튼 아래로 띄워 하단 StationBar/배지와 겹침 방지)
{ {
const cx = W-54, cy = H-54-H*0.05, r = 38; const r = 38;
const cx = W-54, cy = 52 + r;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill(); ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
@@ -545,11 +546,13 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
...(cache.clCount > 0 ? [{ color: 'rgba(255,60,60,0.9)', text: `— 선로중심선 (${cache.clCount}점)` }] : []), ...(cache.clCount > 0 ? [{ color: 'rgba(255,60,60,0.9)', text: `— 선로중심선 (${cache.clCount}점)` }] : []),
...(cache.poiCount > 0 ? [{ color: '#64c8ff', text: `+ 지장물 ${cache.poiCount}` }] : []), ...(cache.poiCount > 0 ? [{ color: '#64c8ff', text: `+ 지장물 ${cache.poiCount}` }] : []),
]; ];
// 좌상단 타임코드(HTML) 아래로 배치 (코너 HUD 정보 그룹)
const legendTop = 34;
ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(6, 6, 160, lines.length*15+6); ctx.fillRect(6, legendTop, 160, lines.length*15+6);
lines.forEach(({ color, text }, i) => { lines.forEach(({ color, text }, i) => {
ctx.font = '10px sans-serif'; ctx.fillStyle = color; ctx.font = '10px sans-serif'; ctx.fillStyle = color;
ctx.fillText(text, 12, 20+i*15); ctx.fillText(text, 12, legendTop + 14 + i*15);
}); });
} }
}; };

View File

@@ -9,12 +9,12 @@ import { usePlayerStore } from '../../store/playerStore';
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture'; import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode'; import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
import { useCaptureStore } from '../../store/captureStore'; import { useCaptureStore } from '../../store/captureStore';
import FrameCaptureButton from './FrameCaptureButton';
import HlsConversionStatus from './HlsConversionStatus'; import HlsConversionStatus from './HlsConversionStatus';
import { StationBar } from '../../stationbar/StationBar';
export interface VideoPlayerHandle { export interface VideoPlayerHandle {
loadLocalFile: (file: File) => void; loadLocalFile: (file: File) => void;
loadServerStream: (videoId: string, filename: string) => void; loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
seekTo: (time: number) => void; seekTo: (time: number) => void;
getVideoElement: () => HTMLVideoElement | null; getVideoElement: () => HTMLVideoElement | null;
} }
@@ -33,7 +33,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
useVideoPlayer(containerRef); useVideoPlayer(containerRef);
const { stepForward, stepBackward, fps } = useFrameStep(playerRef); const { stepForward, stepBackward, fps } = useFrameStep(playerRef);
const { currentTime, source } = usePlayerStore(); const { currentTime, duration, playing, source, playbackRate } = usePlayerStore();
// Expose methods to parent via ref // Expose methods to parent via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -66,6 +66,22 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
const handleAddMemo = () => onAddMemo(currentTime); const handleAddMemo = () => onAddMemo(currentTime);
const [showStations, setShowStations] = useState(true); const [showStations, setShowStations] = useState(true);
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({ useKeyboard({
playerRef, playerRef,
onStepForward: stepForward, onStepForward: stepForward,
@@ -96,8 +112,52 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
> >
{/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */} {/* 영상 영역 — 타임코드를 '영상 하단'에 앵커하기 위한 relative 래퍼 */}
<div data-vjs-player ref={containerRef} className="w-full" /> <div className="relative w-full">
{/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */}
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
<div
data-vjs-player
ref={containerRef}
className="w-full"
style={{ cursor: source ? 'pointer' : 'default' }}
onClick={() => {
if (source) handleTogglePlay();
}}
/>
{/* 프레임/타임코드 — 영상 좌상단 코너 HUD (정보 그룹) */}
{source && (
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded font-mono pointer-events-none z-30">
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
</div>
)}
{/* 재생 배속 — 영상 우하단 (항상 보임). 현재 배속은 파랗게 강조 */}
{source && (
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/70 px-2 py-1 rounded z-30">
<span className="text-gray-400 text-xs"></span>
{[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>
)}
{/* 루트 패널 미니맵 — 영상 영역 기준(아래 StationBar 침범 방지) */}
<RoutePanel
currentTime={currentTime}
visible={showStations}
onSeek={(time) => playerRef.current?.currentTime(time)}
/>
</div>
{/* Empty state placeholder */} {/* Empty state placeholder */}
{!source && ( {!source && (
@@ -116,21 +176,20 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
visible={showStations} visible={showStations}
/> />
{/* 루트 패널 미니맵 */} {/* 측점 기반 재생 바 — videoplayer-main 스테이션 바 이식 (시간 스크러버 대체) */}
<RoutePanel <StationBar
currentTime={currentTime} currentTime={currentTime}
visible={showStations} duration={duration}
onSeek={(time) => playerRef.current?.currentTime(time)} playing={playing}
onTogglePlay={handleTogglePlay}
onStop={handleStop}
onCapture={handleCaptureFrame}
onSeek={handleSeek}
showStations={showStations}
onToggleStations={() => setShowStations((v) => !v)}
/> />
{/* Timecode overlay — positioned over video */} {/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) */}
{source && (
<div className="absolute bottom-16 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded font-mono pointer-events-none z-10">
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
</div>
)}
{/* Bottom controls bar */}
<div className="flex items-center gap-2 p-2 bg-gray-900 flex-wrap"> <div className="flex items-center gap-2 p-2 bg-gray-900 flex-wrap">
<label className="cursor-pointer bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 rounded"> <label className="cursor-pointer bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 rounded">
@@ -145,20 +204,6 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
/> />
</label> </label>
<FrameCaptureButton onCapture={handleCaptureFrame} />
<button
onClick={() => setShowStations(v => !v)}
className={`text-xs px-3 py-1.5 rounded border transition-colors ${
showStations
? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'
: 'bg-gray-800 border-gray-600 text-gray-400 hover:text-white'
}`}
title="측점 오버레이 토글"
>
{showStations ? 'ON' : 'OFF'}
</button>
{/* 프레임 직접 이동 */} {/* 프레임 직접 이동 */}
<form <form
onSubmit={e => { onSubmit={e => {

View File

@@ -12,6 +12,10 @@ const HLS_CONFIG = {
enableWorker: true, 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>) { export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | null>) {
const playerRef = useRef<Player | null>(null); const playerRef = useRef<Player | null>(null);
const hlsRef = useRef<Hls | null>(null); const hlsRef = useRef<Hls | null>(null);
@@ -25,7 +29,8 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
containerRef.current.appendChild(videoEl); containerRef.current.appendChild(videoEl);
const player = videojs(videoEl, { const player = videojs(videoEl, {
controls: true, // 하단 시간 스크러버는 측점 기반 StationBar 로 대체하므로 Video.js 기본 컨트롤바 숨김
controls: false,
fluid: true, fluid: true,
responsive: true, responsive: true,
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4], playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4],
@@ -75,28 +80,58 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const loadServerStream = useCallback((videoId: string, filename: string) => { const loadServerStream = useCallback(async (videoId: string, filename: string) => {
const player = playerRef.current; const player = playerRef.current;
if (!player) return; if (!player) return;
hlsRef.current?.destroy(); hlsRef.current?.destroy();
hlsRef.current = null; hlsRef.current = null;
// Immediate playback via Range Request
const streamUrl = `/api/stream/${videoId}`;
player.src({ src: streamUrl, type: 'video/mp4' });
store.setSource({ kind: 'server', videoId, filename }); store.setSource({ kind: 'server', videoId, filename });
store.setHlsReady(false); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const switchToHls = useCallback((videoId: string) => { const switchToHls = useCallback((videoId: string, seekTo?: number) => {
const player = playerRef.current; const player = playerRef.current;
if (!player) return; if (!player) return;
const hlsId = videoId.replace(/\.[^.]+$/, ''); const hlsId = videoId.replace(/\.[^.]+$/, '');
const hlsUrl = `/api/hls/${hlsId}/index.m3u8`; const hlsUrl = `/api/hls/${hlsId}/index.m3u8`;
const savedTime = player.currentTime() ?? 0; const savedTime = seekTo ?? player.currentTime() ?? 0;
if (Hls.isSupported()) { if (Hls.isSupported()) {
const hls = new Hls(HLS_CONFIG); const hls = new Hls(HLS_CONFIG);

View File

@@ -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);
}

View File

@@ -0,0 +1,491 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { DroneFrameBasic } from '../utils/geoProjection';
import {
STAGE_WIDTH,
TRACK_END_PX,
TRACK_START_PX,
TRACK_WIDTH_PX,
trackXFromRender,
} from './mocks/route';
import { cssVars } from './utils/cssVars';
import { formatMileage, mileageAtPx } 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는 통과 시점(시간축). */
export interface StructMark {
px: number;
title: string;
category: string;
}
interface StationBarProps {
currentTime: 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;
}
/**
* abcVideo 실제 영상 데이터 기반 측점 바.
* - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색).
* - 측점값 라벨·구조물(교량/터널/역사) = 재생 파일의 측점/POI 데이터로 배치.
* - 커서 = 프레임 진행, 배지 = 드론 GPS 최근접 측점.
* - 클릭/드래그/측점입력 seek = 시간(프레임) 기준 이동.
*/
export function StationBar({
currentTime,
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);
const [pois, setPois] = useState<GeoPoint[]>([]);
const framesRef = useRef<DroneFrameBasic[]>([]);
const [framesVersion, setFramesVersion] = useState(0);
const viewedRef = useRef<ViewedPoint[]>([]);
const [ready, setReady] = useState(false);
useEffect(() => {
fetch('/api/geo/pois')
.then((r) => r.json())
.then((data: GeoPoint[]) => setPois(Array.isArray(data) ? data : []))
.catch(() => {});
}, []);
useEffect(() => {
fetch(`/api/geo/frames?step=${FRAME_STEP}`)
.then((r) => r.json())
.then((data: DroneFrameBasic[]) => {
framesRef.current = Array.isArray(data) ? data : [];
setFramesVersion((v) => v + 1);
})
.catch(() => {});
}, []);
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: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
useEffect(() => {
const frames = framesRef.current;
if (!frames.length || !stations.length || !stationLine) 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 / VIDEO_FPS,
};
}
viewedRef.current = out;
setReady(true);
}, [stations, stationLine, framesVersion]);
/** 현재 시간에 가장 가까운 precompute 인덱스. */
const viewedIdxAtTime = useCallback((t: number): number => {
const arr = viewedRef.current;
if (!arr.length) return -1;
const target = t * VIDEO_FPS;
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;
}, []);
// 현재 보는 측점 km
const realKm = useMemo<number | null>(() => {
if (!ready) return null;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].km : null;
}, [currentTime, ready, viewedIdxAtTime]);
// 프레임(시간) 진행률 px — 커서의 단조 이동 기준.
// 영상은 노선을 시간순으로 한 번 통과(leg 01→10)하므로, 시간 진행률이 곧
// 트랙을 좌→우로 지나는 경로 진행률이다.
const progressPx =
duration > 0
? TRACK_START_PX + clamp(currentTime / duration, 0, 1) * TRACK_WIDTH_PX
: TRACK_START_PX;
// 커서 위치 = 프레임 진행률(progressPx)로만 단조 이동.
// 측점 km은 노선에서 여러 번 반복(증가↔감소)되어, km으로 px를 역산하면
// 같은 km의 여러 후보 중 하나로 스냅되며 구간 점프가 생긴다(사용자 보고).
// → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다.
const cursorPx = progressPx;
const cursorText =
realKm !== null && realKm >= 0
? formatMileage(realKm)
: formatMileage(mileageAtPx(cursorPx));
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
// 시간(프레임) 진행을 트랙 px로 선형 변환.
const pxAtTime = useCallback(
(t: number): number =>
duration > 0
? TRACK_START_PX + clamp(t / duration, 0, 1) * TRACK_WIDTH_PX
: TRACK_START_PX,
[duration],
);
// 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 }[] = [
{ chain: arr[0].chain, time: arr[0].time },
];
let dir: 1 | -1 = 1;
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 });
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 });
// 라벨은 전환점 체이니지를 최근접 측점(100m)으로 스냅해 표시.
const labels: KmLabel[] = bounds.map((b) => ({
px: pxAtTime(b.time),
text: formatMileage(Math.round(b.chain / 100) * 100),
}));
return { barSegments: segs, kmLabels: labels };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, duration, framesVersion, pxAtTime]);
// 구조물(교량/터널/역사) → 체이니지(최근접 측점 km) → 통과 시점 px.
const structureMarks = useMemo<StructMark[]>(() => {
const arr = viewedRef.current;
if (!ready || !arr.length || duration <= 0 || !stations.length) return [];
const cats = new Set(['교량', '터널', '역사']);
const seen = new Set<string>();
const out: StructMark[] = [];
for (const p of pois) {
if (!cats.has(p.category)) continue;
// (상)/(하)/(인상) 등 변형은 같은 구조물 → base 이름으로 통합(중복 라벨 제거).
const base = p.title.replace(/\s*[(].*$/, '').trim();
if (seen.has(base)) continue;
seen.add(base);
const chain = nearestStationKm(
{ lat: p.lat, lon: p.lon } as DroneFrameBasic,
stations,
);
if (chain < 0) continue;
let best = -1;
let bd = Infinity;
for (let i = 0; i < arr.length; i++) {
const d = Math.abs(arr[i].km - chain);
if (d < bd) {
bd = d;
best = i;
}
}
if (best >= 0)
out.push({ px: pxAtTime(arr[best].time), title: base, category: p.category });
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, duration, framesVersion, pois, stations, pxAtTime]);
// 방향 색 트랙을 CSS linear-gradient 로 구성. 구간 경계(드론 회전, 측점 변화 ~0,
// 약 3~4초)는 딱딱한 경계 대신 부드러운 그라데이션으로 주황↔하늘색이 섞이게 한다.
const trackGradient = useMemo<string>(() => {
const segs = barSegments;
if (!segs.length) return '';
const FWD = '#ff8a25'; // 전진(km 증가) 주황
const BWD = '#06a4c8'; // 후진(km 감소) 하늘색
const col = (d: 1 | -1) => (d > 0 ? FWD : BWD);
// 전환 폭 ≈ 4초 (회전 소요시간). 최소/최대 px 로 가시성 보장.
const transPx = duration > 0 ? clamp((4 / duration) * TRACK_WIDTH_PX, 6, 40) : 10;
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 b = segs[i].startPx;
const w = Math.min(
transPx / 2,
(segs[i - 1].endPx - segs[i - 1].startPx) / 2,
(segs[i].endPx - segs[i].startPx) / 2,
);
stops.push(`${col(segs[i - 1].dir)} ${pct(b - w).toFixed(2)}%`);
stops.push(`${col(segs[i].dir)} ${pct(b + w).toFixed(2)}%`);
}
stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
return `linear-gradient(to right, ${stops.join(', ')})`;
}, [barSegments, duration]);
// 현재 위치가 역방향(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();
}, []);
// 클릭/드래그 트랙 px → 시간(프레임) 선형 변환으로 seek (시간축 일관).
const seekToTrackX = useCallback(
(trackX: number) => {
if (duration <= 0) return;
const px = clamp(trackX, TRACK_START_PX, TRACK_END_PX);
const targetTime = ((px - TRACK_START_PX) / TRACK_WIDTH_PX) * duration;
onSeek(clamp(targetTime, 0, duration));
},
[duration, onSeek],
);
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],
);
// 측점입력(예: 158k200) → 그 측점을 보는(최근접) 프레임으로 seek (실데이터 기반).
const handleJumpToMileage = useCallback(
(km: number) => {
const arr = viewedRef.current;
if (!arr.length || duration <= 0) return;
let best = -1;
let bd = Infinity;
for (let i = 0; i < arr.length; i++) {
const d = Math.abs(arr[i].km - km);
if (d < bd) {
bd = d;
best = i;
}
}
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}
kmLabels={kmLabels}
structures={structureMarks}
/>
</div>
<TimelineCursor
posPx={cursorPx}
mileageText={cursorText}
reverse={currentReverse}
onSeekDown={handleSeekDown}
/>
</div>
</div>
);
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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));
}
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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),
})}
/>
);
}

View File

@@ -0,0 +1,304 @@
/* 트랙 본체 가로 압축 래퍼. 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;
left: calc(var(--track-end) + 40px);
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: 4px;
top: 6px;
width: 1912px;
height: 13px;
white-space: nowrap;
z-index: 3;
}
.labelRow {
position: absolute;
left: 0;
top: 51px;
width: 1920px;
height: 26px;
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;
}
}
.seekArea {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
cursor: pointer;
z-index: 20;
pointer-events: auto;
}

View File

@@ -0,0 +1,147 @@
import type { MouseEvent } from 'react';
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';
interface TimelineProps {
posPx: number;
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
/** 방향 색 트랙 CSS gradient (전진=주황/후진=하늘색, 회전구간 부드러운 전환). */
trackGradient: string;
/** 데이터 기반 측점값 라벨 (방향 전환점·시종점). */
kmLabels: KmLabel[];
/** 데이터 기반 구조물 (교량/터널/역사). */
structures: StructMark[];
}
export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structures }: TimelineProps) {
// 라벨 겹침 방지: x px 순으로 gap px 이내는 1개만 표시.
const dedup = <T extends { px: number }>(items: T[], gap: number): T[] => {
const sorted = [...items].sort((a, b) => a.px - b.px);
const out: T[] = [];
for (const it of sorted) {
if (!out.length || it.px - out[out.length - 1].px >= gap) out.push(it);
}
return out;
};
const labels = dedup(kmLabels, 28);
// 구조물명은 텍스트가 길어 더 넓은 간격으로 (겹침 방지).
const structs = dedup(structures, 90);
return (
<>
{/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
<div className={styles.trackFade} />
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 신탄진·대전 배치 */}
<div
className={styles.stationLayer}
style={cssVars({
'--track-start': px(TRACK_RENDER_START_PX),
'--track-end': px(TRACK_RENDER_END_PX),
})}
>
<div className={styles.stationStart}></div>
<div className={styles.dotStart} />
<div className={styles.leaderStart} />
<div className={styles.leaderEnd} />
<div className={styles.dotEnd} />
<div className={styles.stationEnd}></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: trackGradient,
opacity: 0.18,
borderRadius: 'inherit',
}}
/>
{/* 재생된 구간: 원래 색 복원 (커서까지 clip) */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: px(
Math.max(0, Math.min(TRACK_WIDTH_PX, posPx - TRACK_START_PX)),
),
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: px(TRACK_WIDTH_PX),
background: trackGradient,
borderRadius: 'inherit',
}}
/>
</div>
</div>
{/* 측점값 라벨 (데이터) */}
<div className={styles.mileageRow}>
{labels.map((l, i) => (
<MileageMarker
key={i}
marker={{ id: `km-${i}`, value: l.text, left: l.px, mileage: 0 }}
/>
))}
</div>
{/* 구조물 라벨 (데이터: 교량/터널/역사) */}
<div className={styles.labelRow}>
{structs.map((s, i) => (
<div
key={i}
className={`${styles.segmentLabel} ${styles.neutral}`}
style={cssVars({ '--x': px(s.px) })}
title={`${s.category} · ${s.title}`}
>
{s.title}
</div>
))}
</div>
<div className={styles.seekArea} onMouseDown={onSeekDown} />
</div>
</>
);
}

View File

@@ -0,0 +1,72 @@
.cursor {
position: absolute;
left: var(--cursor-x);
top: 0;
width: 0;
height: 0;
}
.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;
top: 966px;
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: 8px;
height: 40px;
min-width: 130px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22.5px;
font-weight: 700;
letter-spacing: 0.5px;
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: -8px;
transform: translateX(-50%);
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 9px solid #ef3c08;
}
}
/* 역방향(km 감소, 하늘색 구간) 통과 시 커서도 동일한 파란색으로 표시 */
.cursor.reverse {
.line {
background: #06a4c8;
}
.badge {
background: linear-gradient(180deg, #3fb6d4 0%, #0a9bc0 45%, #067f9e 100%);
&::after {
border-top-color: #067f9e;
}
}
}

View File

@@ -0,0 +1,25 @@
import type { MouseEvent } from 'react';
import { renderX } from '../../mocks/route';
import { cssVars, px } from '../../utils/cssVars';
import styles from './TimelineCursor.module.scss';
interface TimelineCursorProps {
posPx: number;
mileageText: string;
/** 현재 위치가 역방향(km 감소) 구간이면 커서를 파란색으로. */
reverse?: boolean;
/** 라인·뱃지를 직접 드래그해 이동시킬 때 호출 (seekArea와 동일 핸들러). */
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
}
export function TimelineCursor({ posPx, mileageText, reverse, onSeekDown }: TimelineCursorProps) {
return (
<div
className={`${styles.cursor} ${reverse ? styles.reverse : ''}`}
style={cssVars({ '--cursor-x': px(renderX(posPx)) })}
>
<div className={styles.line} onMouseDown={onSeekDown} />
<div className={styles.badge} onMouseDown={onSeekDown}>{mileageText}</div>
</div>
);
}

View File

@@ -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);
}

View File

@@ -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 },
];

View File

@@ -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 },
],
},
];

View File

@@ -0,0 +1,6 @@
export const ROUTE_INFO = {
direction: '하 행',
routeName: '회덕-대전조차장',
lengthKm: '4.25',
duration: '11분 12초',
} as const;

View File

@@ -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' },
];

View File

@@ -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'),
],
},
];

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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(/^\//, '');
}

View File

@@ -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`;
}

View File

@@ -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;
}

View File

@@ -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,
}));
}

View File

@@ -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';
}

View File

@@ -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
ecosystem.config.js Normal file → Executable file
View File

371
package-lock.json generated Normal file → Executable file
View File

@@ -46,6 +46,7 @@
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"sass": "^1.101.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.6" "vite": "^5.2.6"
@@ -1079,6 +1080,315 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"optional": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@redis/client": { "node_modules/@redis/client": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
@@ -4529,6 +4839,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immutable": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.6.tgz",
"integrity": "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==",
"dev": true
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5575,6 +5891,13 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"optional": true
},
"node_modules/node-exports-info": { "node_modules/node-exports-info": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -6745,6 +7068,54 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": {
"version": "1.101.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz",
"integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==",
"dev": true,
"dependencies": {
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",

0
package.json Normal file → Executable file
View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -71,6 +71,15 @@ router.post('/:videoId/convert', async (req: Request, res: Response) => {
} }
const outputDir = path.join(config.hlsDir, videoId.replace(/\.[^.]+$/, '')); const outputDir = path.join(config.hlsDir, videoId.replace(/\.[^.]+$/, ''));
// 이미 디스크에 HLS가 생성돼 있으면 재인코딩하지 않고 즉시 완료 처리 (멱등)
// jobs는 메모리라 서버 재시작 후 idle이 되지만, 산출물은 디스크에 남아있음
if (fs.existsSync(path.join(outputDir, 'index.m3u8'))) {
jobs.set(videoId, { status: 'done', percent: 100 });
res.json({ status: 'done', message: 'Already converted (on disk)' });
return;
}
await fsp.mkdir(outputDir, { recursive: true }); await fsp.mkdir(outputDir, { recursive: true });
const job: ConversionJob = { status: 'converting', percent: 0 }; const job: ConversionJob = { status: 'converting', percent: 0 };
@@ -132,8 +141,18 @@ router.post('/:videoId/convert', async (req: Request, res: Response) => {
// GET /api/hls/:videoId/status // GET /api/hls/:videoId/status
router.get('/:videoId/status', (req: Request, res: Response) => { router.get('/:videoId/status', (req: Request, res: Response) => {
const { videoId } = req.params; const { videoId } = req.params;
const job = jobs.get(videoId) || { status: 'idle' as HlsConversionStatus, percent: 0 }; const job = jobs.get(videoId);
res.json(job); if (job) {
res.json(job);
return;
}
// 메모리에 작업 기록이 없어도 디스크에 산출물이 있으면 done
const m3u8Path = path.join(config.hlsDir, videoId.replace(/\.[^.]+$/, ''), 'index.m3u8');
if (fs.existsSync(m3u8Path)) {
res.json({ status: 'done' as HlsConversionStatus, percent: 100 });
return;
}
res.json({ status: 'idle' as HlsConversionStatus, percent: 0 });
}); });
// GET /api/hls/:videoId/index.m3u8 // GET /api/hls/:videoId/index.m3u8

0
tsconfig.json Normal file → Executable file
View File