defVideo 작업분 반영
0
.eslintrc.json
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.prettierrc
Normal file → Executable file
0
PROGRESS.md
Normal file → Executable file
0
VERIFICATION.md
Normal file → Executable 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"
|
||||||
|
|||||||
BIN
client/public/assets/background.jpg
Executable file
|
After Width: | Height: | Size: 13 MiB |
BIN
client/public/assets/icons/ico_play.png
Executable file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
client/public/assets/icons/ico_play_on.png
Executable file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
client/public/assets/icons/icon-camera-body.png
Executable file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
client/public/assets/icons/icon-camera-body_on.png
Executable file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
client/public/assets/icons/icon-sturn.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
client/public/assets/icons/icon-sturn_on.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
client/public/assets/icons/icon_pause.png
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
client/public/assets/icons/icon_pause_on.png
Executable file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
client/public/assets/icons/icon_stop.png
Executable file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
client/public/assets/icons/icon_stop_on.png
Executable file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
client/public/assets/minimap.png
Executable file
|
After Width: | Height: | Size: 497 KiB |
BIN
client/public/assets/route-segment/bridge/bridge-passed-center.png
Executable file
|
After Width: | Height: | Size: 718 B |
BIN
client/public/assets/route-segment/bridge/bridge-passed-left.png
Executable file
|
After Width: | Height: | Size: 957 B |
BIN
client/public/assets/route-segment/bridge/bridge-passed-right.png
Executable file
|
After Width: | Height: | Size: 961 B |
BIN
client/public/assets/route-segment/bridge/bridge-revisit-center.png
Executable file
|
After Width: | Height: | Size: 208 B |
BIN
client/public/assets/route-segment/bridge/bridge-revisit-left.png
Executable file
|
After Width: | Height: | Size: 526 B |
BIN
client/public/assets/route-segment/bridge/bridge-revisit-right.png
Executable file
|
After Width: | Height: | Size: 505 B |
BIN
client/public/assets/route-segment/bridge/bridge-upcoming-center.png
Executable file
|
After Width: | Height: | Size: 649 B |
BIN
client/public/assets/route-segment/bridge/bridge-upcoming-left.png
Executable file
|
After Width: | Height: | Size: 930 B |
BIN
client/public/assets/route-segment/bridge/bridge-upcoming-right.png
Executable file
|
After Width: | Height: | Size: 898 B |
BIN
client/public/assets/route-segment/terminal/circle-passed.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
client/public/assets/route-segment/terminal/circle-revisit.png
Executable file
|
After Width: | Height: | Size: 946 B |
BIN
client/public/assets/route-segment/terminal/circle-upcoming.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
client/public/assets/route-segment/tunnel/tunnel-passed-center.png
Executable file
|
After Width: | Height: | Size: 515 B |
BIN
client/public/assets/route-segment/tunnel/tunnel-passed-left.png
Executable file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/assets/route-segment/tunnel/tunnel-passed-right.png
Executable file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/assets/route-segment/tunnel/tunnel-revisit-center.png
Executable file
|
After Width: | Height: | Size: 466 B |
BIN
client/public/assets/route-segment/tunnel/tunnel-revisit-left.png
Executable file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/assets/route-segment/tunnel/tunnel-revisit-right.png
Executable file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/assets/route-segment/tunnel/tunnel-upcoming-center.png
Executable file
|
After Width: | Height: | Size: 466 B |
BIN
client/public/assets/route-segment/tunnel/tunnel-upcoming-left.png
Executable file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/assets/route-segment/tunnel/tunnel-upcoming-right.png
Executable file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/assets/title-panel-bg@2x.png
Executable file
|
After Width: | Height: | Size: 557 KiB |
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()}
|
||||||
>
|
>
|
||||||
|
{/* 영상 영역 — 타임코드를 '영상 하단'에 앵커하기 위한 relative 래퍼 */}
|
||||||
|
<div className="relative w-full">
|
||||||
{/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */}
|
{/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */}
|
||||||
<div data-vjs-player ref={containerRef} className="w-full" />
|
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
|
||||||
|
<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 => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
43
client/src/stationbar/StationBar.module.scss
Executable 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);
|
||||||
|
}
|
||||||
491
client/src/stationbar/StationBar.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
client/src/stationbar/components/MileageMarker/MileageMarker.module.scss
Executable 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);
|
||||||
|
}
|
||||||
|
|
||||||
22
client/src/stationbar/components/MileageMarker/MileageMarker.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss
Executable 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;
|
||||||
|
}
|
||||||
98
client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
client/src/stationbar/components/RouteSegment/RouteSegment.module.scss
Executable 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
34
client/src/stationbar/components/RouteSegment/RouteSegment.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
28
client/src/stationbar/components/TerminalMarker/TerminalMarker.tsx
Executable 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),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
client/src/stationbar/components/Timeline/Timeline.module.scss
Executable 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;
|
||||||
|
}
|
||||||
147
client/src/stationbar/components/Timeline/Timeline.tsx
Executable 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
client/src/stationbar/constants/routeSegmentAssets.ts
Executable 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);
|
||||||
|
}
|
||||||
15
client/src/stationbar/mocks/mileage.ts
Executable 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 },
|
||||||
|
];
|
||||||
164
client/src/stationbar/mocks/route.ts
Executable 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
6
client/src/stationbar/mocks/routeInfo.ts
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
export const ROUTE_INFO = {
|
||||||
|
direction: '하 행',
|
||||||
|
routeName: '회덕-대전조차장',
|
||||||
|
lengthKm: '4.25',
|
||||||
|
duration: '11분 12초',
|
||||||
|
} as const;
|
||||||
95
client/src/stationbar/mocks/segments.ts
Executable 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' },
|
||||||
|
];
|
||||||
56
client/src/stationbar/mocks/timeline.ts
Executable 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'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
26
client/src/stationbar/tokens.css
Executable 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;
|
||||||
|
}
|
||||||
106
client/src/stationbar/types/timeline.ts
Executable 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[];
|
||||||
|
}
|
||||||
8
client/src/stationbar/utils/asset.ts
Executable 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(/^\//, '');
|
||||||
|
}
|
||||||
13
client/src/stationbar/utils/cssVars.ts
Executable 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`;
|
||||||
|
}
|
||||||
62
client/src/stationbar/utils/mileage.ts
Executable 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;
|
||||||
|
}
|
||||||
26
client/src/stationbar/utils/mileageMarkers.ts
Executable 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
62
client/src/stationbar/utils/routeProgress.ts
Executable 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';
|
||||||
|
}
|
||||||
13
client/src/stationbar/utils/segmentLabel.ts
Executable 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
371
package-lock.json
generated
Normal file → Executable 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
|
Before Width: | Height: | Size: 80 KiB |
@@ -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);
|
||||||
|
if (job) {
|
||||||
res.json(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
|
||||||
|
|||||||