defVideo 작업분 반영
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface GeoPoint {
|
||||
title: string;
|
||||
@@ -50,6 +50,8 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
||||
const [result, setResult] = useState<StationResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seekedFrame, setSeekedFrame] = useState<number | null>(null);
|
||||
// 드론 프레임(위경도) — 측점 클릭 시 GPS 최근접 프레임 계산용.
|
||||
const framesRef = useRef<{ frame: number; lat: number; lon: number }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/geo/pois')
|
||||
@@ -63,23 +65,43 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
||||
.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) => {
|
||||
setSelected(station.title);
|
||||
setResult(null);
|
||||
setLoading(true);
|
||||
setSeekedFrame(null);
|
||||
// 영상은 드론 GPS 가 그 측점에 가장 가까운 프레임으로 이동한다.
|
||||
// (카메라 FOV 검색은 앞을 보는 카메라 특성상 ~200m 앞쪽으로 치우쳐 위치가 어긋남)
|
||||
const gpsFrame = nearestFrameForStation(station);
|
||||
if (gpsFrame != null) {
|
||||
onSeekToFrame(gpsFrame);
|
||||
setSeekedFrame(gpsFrame);
|
||||
}
|
||||
// 검증 정보(카메라 시야 프레임/투영)는 참고용으로 표시.
|
||||
try {
|
||||
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(station.title)}&margin=1.2&maxDist=2000`);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.frames?.length) {
|
||||
setResult({ frames: [], poi: data.poi ?? station });
|
||||
return;
|
||||
}
|
||||
setResult(data);
|
||||
// 가장 중심에 가까운 첫 번째 결과로 이동
|
||||
const best = data.frames[0];
|
||||
onSeekToFrame(best.frame);
|
||||
setSeekedFrame(best.frame);
|
||||
setResult(res.ok && data.frames?.length ? data : { frames: [], poi: data.poi ?? station });
|
||||
} catch {
|
||||
setResult(null);
|
||||
} finally {
|
||||
|
||||
@@ -253,8 +253,8 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute left-0 w-28 border-r border-white/20 z-30"
|
||||
style={{ top: '8%', height: '80%', background: 'rgba(0,0,0,0.6)' }}
|
||||
className="absolute w-28 border border-white/20 rounded-md z-30"
|
||||
style={{ left: 10, top: '12%', height: '68%', background: 'rgba(0,0,0,0.6)' }}
|
||||
>
|
||||
{/* Center vertical line */}
|
||||
<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.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
|
||||
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.poiCount > 0 ? [{ color: '#64c8ff', text: `+ 지장물 ${cache.poiCount}개` }] : []),
|
||||
];
|
||||
// 좌상단 타임코드(HTML) 아래로 배치 (코너 HUD 정보 그룹)
|
||||
const legendTop = 34;
|
||||
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) => {
|
||||
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 { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
|
||||
import { useCaptureStore } from '../../store/captureStore';
|
||||
import FrameCaptureButton from './FrameCaptureButton';
|
||||
import HlsConversionStatus from './HlsConversionStatus';
|
||||
import { StationBar } from '../../stationbar/StationBar';
|
||||
|
||||
export interface VideoPlayerHandle {
|
||||
loadLocalFile: (file: File) => void;
|
||||
loadServerStream: (videoId: string, filename: string) => void;
|
||||
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
|
||||
seekTo: (time: number) => void;
|
||||
getVideoElement: () => HTMLVideoElement | null;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
useVideoPlayer(containerRef);
|
||||
|
||||
const { stepForward, stepBackward, fps } = useFrameStep(playerRef);
|
||||
const { currentTime, source } = usePlayerStore();
|
||||
const { currentTime, duration, playing, source, playbackRate } = usePlayerStore();
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -66,6 +66,22 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
const handleAddMemo = () => onAddMemo(currentTime);
|
||||
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({
|
||||
playerRef,
|
||||
onStepForward: stepForward,
|
||||
@@ -96,8 +112,52 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */}
|
||||
<div data-vjs-player ref={containerRef} className="w-full" />
|
||||
{/* 영상 영역 — 타임코드를 '영상 하단'에 앵커하기 위한 relative 래퍼 */}
|
||||
<div className="relative w-full">
|
||||
{/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */}
|
||||
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
|
||||
<div
|
||||
data-vjs-player
|
||||
ref={containerRef}
|
||||
className="w-full"
|
||||
style={{ cursor: source ? 'pointer' : 'default' }}
|
||||
onClick={() => {
|
||||
if (source) handleTogglePlay();
|
||||
}}
|
||||
/>
|
||||
{/* 프레임/타임코드 — 영상 좌상단 코너 HUD (정보 그룹) */}
|
||||
{source && (
|
||||
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded font-mono pointer-events-none z-30">
|
||||
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
|
||||
</div>
|
||||
)}
|
||||
{/* 재생 배속 — 영상 우하단 (항상 보임). 현재 배속은 파랗게 강조 */}
|
||||
{source && (
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/70 px-2 py-1 rounded z-30">
|
||||
<span className="text-gray-400 text-xs">배속</span>
|
||||
{[1, 1.5, 2, 3, 4].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => playerRef.current?.playbackRate(r)}
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-semibold ${
|
||||
Math.abs(playbackRate - r) < 0.01
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{r}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 루트 패널 미니맵 — 영상 영역 기준(아래 StationBar 침범 방지) */}
|
||||
<RoutePanel
|
||||
currentTime={currentTime}
|
||||
visible={showStations}
|
||||
onSeek={(time) => playerRef.current?.currentTime(time)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Empty state placeholder */}
|
||||
{!source && (
|
||||
@@ -116,21 +176,20 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
visible={showStations}
|
||||
/>
|
||||
|
||||
{/* 루트 패널 미니맵 */}
|
||||
<RoutePanel
|
||||
{/* 측점 기반 재생 바 — videoplayer-main 스테이션 바 이식 (시간 스크러버 대체) */}
|
||||
<StationBar
|
||||
currentTime={currentTime}
|
||||
visible={showStations}
|
||||
onSeek={(time) => playerRef.current?.currentTime(time)}
|
||||
duration={duration}
|
||||
playing={playing}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
onStop={handleStop}
|
||||
onCapture={handleCaptureFrame}
|
||||
onSeek={handleSeek}
|
||||
showStations={showStations}
|
||||
onToggleStations={() => setShowStations((v) => !v)}
|
||||
/>
|
||||
|
||||
{/* Timecode overlay — positioned over video */}
|
||||
{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 */}
|
||||
{/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) */}
|
||||
<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">
|
||||
파일 선택
|
||||
@@ -145,20 +204,6 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
/>
|
||||
</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
|
||||
onSubmit={e => {
|
||||
|
||||
@@ -12,6 +12,10 @@ const HLS_CONFIG = {
|
||||
enableWorker: true,
|
||||
};
|
||||
|
||||
// PC 브라우저(Chrome/Firefox/Edge)가 HTML5 video로 직접 디코딩 못 하는 코덱.
|
||||
// 이런 영상은 원본 대신 (트랜스코딩된) HLS로 재생해야 한다.
|
||||
const UNSUPPORTED_CODECS = new Set(['hevc', 'h265', 'hvc1', 'hev1']);
|
||||
|
||||
export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
@@ -25,7 +29,8 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
||||
containerRef.current.appendChild(videoEl);
|
||||
|
||||
const player = videojs(videoEl, {
|
||||
controls: true,
|
||||
// 하단 시간 스크러버는 측점 기반 StationBar 로 대체하므로 Video.js 기본 컨트롤바 숨김
|
||||
controls: false,
|
||||
fluid: true,
|
||||
responsive: true,
|
||||
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
|
||||
}, []);
|
||||
|
||||
const loadServerStream = useCallback((videoId: string, filename: string) => {
|
||||
const loadServerStream = useCallback(async (videoId: string, filename: string) => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
hlsRef.current?.destroy();
|
||||
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.setHlsReady(false);
|
||||
const playRaw = () => player.src({ src: `/api/stream/${videoId}`, type: 'video/mp4' });
|
||||
|
||||
// 코덱 확인 — 브라우저가 직접 못 푸는 코덱(HEVC 등)이면 HLS로 자동 재생
|
||||
let needsHls = false;
|
||||
try {
|
||||
const meta = await fetch(`/api/meta/${videoId}`).then((r) => r.json());
|
||||
needsHls = UNSUPPORTED_CODECS.has(String(meta?.codec ?? '').toLowerCase());
|
||||
} catch {
|
||||
// meta 조회 실패 시 일단 원본으로 시도
|
||||
}
|
||||
|
||||
// await 사이에 사용자가 다른 영상을 선택했으면 중단
|
||||
const stillCurrent = () => {
|
||||
const s = usePlayerStore.getState().source;
|
||||
return s?.kind === 'server' && s.videoId === videoId;
|
||||
};
|
||||
|
||||
if (needsHls) {
|
||||
if (!stillCurrent()) return;
|
||||
const hlsId = videoId.replace(/\.[^.]+$/, '');
|
||||
const ready = await fetch(`/api/hls/${hlsId}/index.m3u8`, { method: 'HEAD' })
|
||||
.then((r) => r.ok)
|
||||
.catch(() => false);
|
||||
if (!stillCurrent()) return;
|
||||
if (ready) {
|
||||
switchToHls(videoId, 0);
|
||||
return;
|
||||
}
|
||||
// HLS 미생성 상태: 원본을 시도(에러 표시)하고, 사용자가 'HLS 변환' 버튼으로 생성하도록 유도
|
||||
}
|
||||
|
||||
// 지원 코덱이거나 HLS가 아직 없으면 원본 즉시 재생 (Range Request)
|
||||
playRaw();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const switchToHls = useCallback((videoId: string) => {
|
||||
const switchToHls = useCallback((videoId: string, seekTo?: number) => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
const hlsId = videoId.replace(/\.[^.]+$/, '');
|
||||
const hlsUrl = `/api/hls/${hlsId}/index.m3u8`;
|
||||
const savedTime = player.currentTime() ?? 0;
|
||||
const savedTime = seekTo ?? player.currentTime() ?? 0;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls(HLS_CONFIG);
|
||||
|
||||
43
client/src/stationbar/StationBar.module.scss
Executable file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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';
|
||||
}
|
||||
Reference in New Issue
Block a user