diff --git a/.eslintrc.json b/.eslintrc.json old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/.prettierrc b/.prettierrc old mode 100644 new mode 100755 diff --git a/CLAUDE.md b/CLAUDE.md old mode 100644 new mode 100755 diff --git a/PLAN.md b/PLAN.md old mode 100644 new mode 100755 diff --git a/PROGRESS.md b/PROGRESS.md old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/VERIFICATION.md b/VERIFICATION.md old mode 100644 new mode 100755 diff --git a/client/package.json b/client/package.json index 81f0cce..e60738d 100644 --- a/client/package.json +++ b/client/package.json @@ -29,6 +29,7 @@ "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", "postcss": "^8.4.38", + "sass": "^1.101.0", "tailwindcss": "^3.4.1", "typescript": "^5.4.5", "vite": "^5.2.6" diff --git a/client/public/assets/background.jpg b/client/public/assets/background.jpg new file mode 100755 index 0000000..f3fed04 Binary files /dev/null and b/client/public/assets/background.jpg differ diff --git a/client/public/assets/icons/ico_play.png b/client/public/assets/icons/ico_play.png new file mode 100755 index 0000000..402360b Binary files /dev/null and b/client/public/assets/icons/ico_play.png differ diff --git a/client/public/assets/icons/ico_play_on.png b/client/public/assets/icons/ico_play_on.png new file mode 100755 index 0000000..0701ac1 Binary files /dev/null and b/client/public/assets/icons/ico_play_on.png differ diff --git a/client/public/assets/icons/icon-camera-body.png b/client/public/assets/icons/icon-camera-body.png new file mode 100755 index 0000000..86c53af Binary files /dev/null and b/client/public/assets/icons/icon-camera-body.png differ diff --git a/client/public/assets/icons/icon-camera-body_on.png b/client/public/assets/icons/icon-camera-body_on.png new file mode 100755 index 0000000..0953afa Binary files /dev/null and b/client/public/assets/icons/icon-camera-body_on.png differ diff --git a/client/public/assets/icons/icon-sturn.png b/client/public/assets/icons/icon-sturn.png new file mode 100755 index 0000000..301abf8 Binary files /dev/null and b/client/public/assets/icons/icon-sturn.png differ diff --git a/client/public/assets/icons/icon-sturn_on.png b/client/public/assets/icons/icon-sturn_on.png new file mode 100755 index 0000000..3001c48 Binary files /dev/null and b/client/public/assets/icons/icon-sturn_on.png differ diff --git a/client/public/assets/icons/icon_pause.png b/client/public/assets/icons/icon_pause.png new file mode 100755 index 0000000..6162c07 Binary files /dev/null and b/client/public/assets/icons/icon_pause.png differ diff --git a/client/public/assets/icons/icon_pause_on.png b/client/public/assets/icons/icon_pause_on.png new file mode 100755 index 0000000..d81ef98 Binary files /dev/null and b/client/public/assets/icons/icon_pause_on.png differ diff --git a/client/public/assets/icons/icon_stop.png b/client/public/assets/icons/icon_stop.png new file mode 100755 index 0000000..29df768 Binary files /dev/null and b/client/public/assets/icons/icon_stop.png differ diff --git a/client/public/assets/icons/icon_stop_on.png b/client/public/assets/icons/icon_stop_on.png new file mode 100755 index 0000000..2dd8f23 Binary files /dev/null and b/client/public/assets/icons/icon_stop_on.png differ diff --git a/client/public/assets/minimap.png b/client/public/assets/minimap.png new file mode 100755 index 0000000..ac4cb8a Binary files /dev/null and b/client/public/assets/minimap.png differ diff --git a/client/public/assets/route-segment/bridge/bridge-passed-center.png b/client/public/assets/route-segment/bridge/bridge-passed-center.png new file mode 100755 index 0000000..6e0c94a Binary files /dev/null and b/client/public/assets/route-segment/bridge/bridge-passed-center.png differ diff --git a/client/public/assets/route-segment/bridge/bridge-passed-left.png b/client/public/assets/route-segment/bridge/bridge-passed-left.png new file mode 100755 index 0000000..def6841 Binary files /dev/null and b/client/public/assets/route-segment/bridge/bridge-passed-left.png differ diff --git a/client/public/assets/route-segment/bridge/bridge-passed-right.png b/client/public/assets/route-segment/bridge/bridge-passed-right.png new file mode 100755 index 0000000..f3db714 Binary files /dev/null and b/client/public/assets/route-segment/bridge/bridge-passed-right.png differ diff --git a/client/public/assets/route-segment/bridge/bridge-revisit-center.png b/client/public/assets/route-segment/bridge/bridge-revisit-center.png new file mode 100755 index 0000000..09d0894 Binary files /dev/null and b/client/public/assets/route-segment/bridge/bridge-revisit-center.png differ diff --git a/client/public/assets/route-segment/bridge/bridge-revisit-left.png b/client/public/assets/route-segment/bridge/bridge-revisit-left.png new file mode 100755 index 0000000..859a3ed Binary files /dev/null and b/client/public/assets/route-segment/bridge/bridge-revisit-left.png differ diff --git a/client/public/assets/route-segment/bridge/bridge-revisit-right.png b/client/public/assets/route-segment/bridge/bridge-revisit-right.png new file mode 100755 index 0000000..f5136b5 Binary files /dev/null and b/client/public/assets/route-segment/bridge/bridge-revisit-right.png differ diff --git a/client/public/assets/route-segment/bridge/bridge-upcoming-center.png b/client/public/assets/route-segment/bridge/bridge-upcoming-center.png new file mode 100755 index 0000000..2c90865 Binary files /dev/null and b/client/public/assets/route-segment/bridge/bridge-upcoming-center.png differ diff --git a/client/public/assets/route-segment/bridge/bridge-upcoming-left.png b/client/public/assets/route-segment/bridge/bridge-upcoming-left.png new file mode 100755 index 0000000..1fdf817 Binary files /dev/null and b/client/public/assets/route-segment/bridge/bridge-upcoming-left.png differ diff --git a/client/public/assets/route-segment/bridge/bridge-upcoming-right.png b/client/public/assets/route-segment/bridge/bridge-upcoming-right.png new file mode 100755 index 0000000..36ed3a2 Binary files /dev/null and b/client/public/assets/route-segment/bridge/bridge-upcoming-right.png differ diff --git a/client/public/assets/route-segment/terminal/circle-passed.png b/client/public/assets/route-segment/terminal/circle-passed.png new file mode 100755 index 0000000..8041522 Binary files /dev/null and b/client/public/assets/route-segment/terminal/circle-passed.png differ diff --git a/client/public/assets/route-segment/terminal/circle-revisit.png b/client/public/assets/route-segment/terminal/circle-revisit.png new file mode 100755 index 0000000..591a674 Binary files /dev/null and b/client/public/assets/route-segment/terminal/circle-revisit.png differ diff --git a/client/public/assets/route-segment/terminal/circle-upcoming.png b/client/public/assets/route-segment/terminal/circle-upcoming.png new file mode 100755 index 0000000..da85c0a Binary files /dev/null and b/client/public/assets/route-segment/terminal/circle-upcoming.png differ diff --git a/client/public/assets/route-segment/tunnel/tunnel-passed-center.png b/client/public/assets/route-segment/tunnel/tunnel-passed-center.png new file mode 100755 index 0000000..9a6be58 Binary files /dev/null and b/client/public/assets/route-segment/tunnel/tunnel-passed-center.png differ diff --git a/client/public/assets/route-segment/tunnel/tunnel-passed-left.png b/client/public/assets/route-segment/tunnel/tunnel-passed-left.png new file mode 100755 index 0000000..2de7684 Binary files /dev/null and b/client/public/assets/route-segment/tunnel/tunnel-passed-left.png differ diff --git a/client/public/assets/route-segment/tunnel/tunnel-passed-right.png b/client/public/assets/route-segment/tunnel/tunnel-passed-right.png new file mode 100755 index 0000000..cafc2ae Binary files /dev/null and b/client/public/assets/route-segment/tunnel/tunnel-passed-right.png differ diff --git a/client/public/assets/route-segment/tunnel/tunnel-revisit-center.png b/client/public/assets/route-segment/tunnel/tunnel-revisit-center.png new file mode 100755 index 0000000..14fc4c8 Binary files /dev/null and b/client/public/assets/route-segment/tunnel/tunnel-revisit-center.png differ diff --git a/client/public/assets/route-segment/tunnel/tunnel-revisit-left.png b/client/public/assets/route-segment/tunnel/tunnel-revisit-left.png new file mode 100755 index 0000000..86f07d5 Binary files /dev/null and b/client/public/assets/route-segment/tunnel/tunnel-revisit-left.png differ diff --git a/client/public/assets/route-segment/tunnel/tunnel-revisit-right.png b/client/public/assets/route-segment/tunnel/tunnel-revisit-right.png new file mode 100755 index 0000000..72f485e Binary files /dev/null and b/client/public/assets/route-segment/tunnel/tunnel-revisit-right.png differ diff --git a/client/public/assets/route-segment/tunnel/tunnel-upcoming-center.png b/client/public/assets/route-segment/tunnel/tunnel-upcoming-center.png new file mode 100755 index 0000000..14fc4c8 Binary files /dev/null and b/client/public/assets/route-segment/tunnel/tunnel-upcoming-center.png differ diff --git a/client/public/assets/route-segment/tunnel/tunnel-upcoming-left.png b/client/public/assets/route-segment/tunnel/tunnel-upcoming-left.png new file mode 100755 index 0000000..86f07d5 Binary files /dev/null and b/client/public/assets/route-segment/tunnel/tunnel-upcoming-left.png differ diff --git a/client/public/assets/route-segment/tunnel/tunnel-upcoming-right.png b/client/public/assets/route-segment/tunnel/tunnel-upcoming-right.png new file mode 100755 index 0000000..72f485e Binary files /dev/null and b/client/public/assets/route-segment/tunnel/tunnel-upcoming-right.png differ diff --git a/client/public/assets/title-panel-bg@2x.png b/client/public/assets/title-panel-bg@2x.png new file mode 100755 index 0000000..5cb70a3 Binary files /dev/null and b/client/public/assets/title-panel-bg@2x.png differ diff --git a/client/src/components/geo/StationVerify.tsx b/client/src/components/geo/StationVerify.tsx index aa6bac8..9be5278 100644 --- a/client/src/components/geo/StationVerify.tsx +++ b/client/src/components/geo/StationVerify.tsx @@ -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(null); const [loading, setLoading] = useState(false); const [seekedFrame, setSeekedFrame] = useState(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 { diff --git a/client/src/components/overlay/RoutePanel.tsx b/client/src/components/overlay/RoutePanel.tsx index fd81ced..c092ba5 100644 --- a/client/src/components/overlay/RoutePanel.tsx +++ b/client/src/components/overlay/RoutePanel.tsx @@ -253,8 +253,8 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP return (
{/* Center vertical line */}
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); }); } }; diff --git a/client/src/components/player/VideoPlayer.tsx b/client/src/components/player/VideoPlayer.tsx index f2cfd7a..25a5727 100644 --- a/client/src/components/player/VideoPlayer.tsx +++ b/client/src/components/player/VideoPlayer.tsx @@ -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; seekTo: (time: number) => void; getVideoElement: () => HTMLVideoElement | null; } @@ -33,7 +33,7 @@ const VideoPlayer = forwardRef( 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( 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( onDrop={handleDrop} onDragOver={(e) => e.preventDefault()} > - {/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */} -
+ {/* 영상 영역 — 타임코드를 '영상 하단'에 앵커하기 위한 relative 래퍼 */} +
+ {/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */} + {/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */} +
{ + if (source) handleTogglePlay(); + }} + /> + {/* 프레임/타임코드 — 영상 좌상단 코너 HUD (정보 그룹) */} + {source && ( +
+ {secondsToTimecode(currentTime)} | F{frame} | {fps}fps +
+ )} + {/* 재생 배속 — 영상 우하단 (항상 보임). 현재 배속은 파랗게 강조 */} + {source && ( +
+ 배속 + {[1, 1.5, 2, 3, 4].map((r) => ( + + ))} +
+ )} + {/* 루트 패널 미니맵 — 영상 영역 기준(아래 StationBar 침범 방지) */} + playerRef.current?.currentTime(time)} + /> +
{/* Empty state placeholder */} {!source && ( @@ -116,21 +176,20 @@ const VideoPlayer = forwardRef( visible={showStations} /> - {/* 루트 패널 미니맵 */} - 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 && ( -
- {secondsToTimecode(currentTime)} | F{frame} | {fps}fps -
- )} - - {/* Bottom controls bar */} + {/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) */}
- - - - {/* 프레임 직접 이동 */}
{ diff --git a/client/src/hooks/useVideoPlayer.ts b/client/src/hooks/useVideoPlayer.ts index c4b9e45..915da37 100644 --- a/client/src/hooks/useVideoPlayer.ts +++ b/client/src/hooks/useVideoPlayer.ts @@ -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) { const playerRef = useRef(null); const hlsRef = useRef(null); @@ -25,7 +29,8 @@ export function useVideoPlayer(containerRef: React.RefObject { + 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); diff --git a/client/src/stationbar/StationBar.module.scss b/client/src/stationbar/StationBar.module.scss new file mode 100755 index 0000000..1062590 --- /dev/null +++ b/client/src/stationbar/StationBar.module.scss @@ -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); +} diff --git a/client/src/stationbar/StationBar.tsx b/client/src/stationbar/StationBar.tsx new file mode 100755 index 0000000..1f9b632 --- /dev/null +++ b/client/src/stationbar/StationBar.tsx @@ -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(null); + const stageRef = useRef(null); + const [scale, setScale] = useState(0.5); + const draggingRef = useRef(false); + + const [pois, setPois] = useState([]); + const framesRef = useRef([]); + const [framesVersion, setFramesVersion] = useState(0); + const viewedRef = useRef([]); + 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(() => { + 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(() => { + 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(() => { + const arr = viewedRef.current; + if (!ready || !arr.length || duration <= 0 || !stations.length) return []; + const cats = new Set(['교량', '터널', '역사']); + const seen = new Set(); + 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(() => { + 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(() => { + 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) => { + 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 ( +
+
+
+ + +
+ +
+
+ ); +} diff --git a/client/src/stationbar/components/MileageMarker/MileageMarker.module.scss b/client/src/stationbar/components/MileageMarker/MileageMarker.module.scss new file mode 100755 index 0000000..6ed5b83 --- /dev/null +++ b/client/src/stationbar/components/MileageMarker/MileageMarker.module.scss @@ -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); +} + diff --git a/client/src/stationbar/components/MileageMarker/MileageMarker.tsx b/client/src/stationbar/components/MileageMarker/MileageMarker.tsx new file mode 100755 index 0000000..b242120 --- /dev/null +++ b/client/src/stationbar/components/MileageMarker/MileageMarker.tsx @@ -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 ( +
+ {marker.value} +
+ ); +} diff --git a/client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss b/client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss new file mode 100755 index 0000000..e736b1f --- /dev/null +++ b/client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss @@ -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; +} diff --git a/client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx b/client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx new file mode 100755 index 0000000..8f30586 --- /dev/null +++ b/client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx @@ -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): void => { + setQuery(e.target.value); + }; + + const handleQueryKeyDown = (e: KeyboardEvent): void => { + if (e.key !== 'Enter') return; + const mileage = parseMileageQuery(query); + if (mileage !== null) { + onJumpToMileage(mileage); + setQuery(''); + } + }; + + return ( +
+
+
+ + + +
+
+ + +
+
+ ); +} diff --git a/client/src/stationbar/components/RouteSegment/RouteSegment.module.scss b/client/src/stationbar/components/RouteSegment/RouteSegment.module.scss new file mode 100755 index 0000000..640fc1d --- /dev/null +++ b/client/src/stationbar/components/RouteSegment/RouteSegment.module.scss @@ -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)); + } +} diff --git a/client/src/stationbar/components/RouteSegment/RouteSegment.tsx b/client/src/stationbar/components/RouteSegment/RouteSegment.tsx new file mode 100755 index 0000000..b7ced9f --- /dev/null +++ b/client/src/stationbar/components/RouteSegment/RouteSegment.tsx @@ -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 ( +
+
+
+
+
+ ); +} diff --git a/client/src/stationbar/components/TerminalMarker/TerminalMarker.module.scss b/client/src/stationbar/components/TerminalMarker/TerminalMarker.module.scss new file mode 100755 index 0000000..92fd4c8 --- /dev/null +++ b/client/src/stationbar/components/TerminalMarker/TerminalMarker.module.scss @@ -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; +} diff --git a/client/src/stationbar/components/TerminalMarker/TerminalMarker.tsx b/client/src/stationbar/components/TerminalMarker/TerminalMarker.tsx new file mode 100755 index 0000000..980a74c --- /dev/null +++ b/client/src/stationbar/components/TerminalMarker/TerminalMarker.tsx @@ -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 ( + + ); +} diff --git a/client/src/stationbar/components/Timeline/Timeline.module.scss b/client/src/stationbar/components/Timeline/Timeline.module.scss new file mode 100755 index 0000000..4385932 --- /dev/null +++ b/client/src/stationbar/components/Timeline/Timeline.module.scss @@ -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; +} diff --git a/client/src/stationbar/components/Timeline/Timeline.tsx b/client/src/stationbar/components/Timeline/Timeline.tsx new file mode 100755 index 0000000..9ed3301 --- /dev/null +++ b/client/src/stationbar/components/Timeline/Timeline.tsx @@ -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) => 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 = (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 ( + <> + {/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */} +
+ + {/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 신탄진·대전 배치 */} +
+
신탄진
+
+
+
+
+
대전
+
+ + {/* 트랙 본체 (가로 압축 래퍼) */} +
+
+
+
+
+ + {/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색. + 전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */} +
+ {/* 미재생: 방향색 저톤 (전체 폭) */} +
+ {/* 재생된 구간: 원래 색 복원 (커서까지 clip) */} +
+
+
+
+ + {/* 측점값 라벨 (데이터) */} +
+ {labels.map((l, i) => ( + + ))} +
+ + {/* 구조물 라벨 (데이터: 교량/터널/역사) */} +
+ {structs.map((s, i) => ( +
+ {s.title} +
+ ))} +
+ +
+
+ + ); +} diff --git a/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss b/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss new file mode 100755 index 0000000..958e660 --- /dev/null +++ b/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss @@ -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; + } + } +} diff --git a/client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx b/client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx new file mode 100755 index 0000000..546ca3a --- /dev/null +++ b/client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx @@ -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) => void; +} + +export function TimelineCursor({ posPx, mileageText, reverse, onSeekDown }: TimelineCursorProps) { + return ( +
+
+
{mileageText}
+
+ ); +} diff --git a/client/src/stationbar/constants/routeSegmentAssets.ts b/client/src/stationbar/constants/routeSegmentAssets.ts new file mode 100755 index 0000000..8f1afac --- /dev/null +++ b/client/src/stationbar/constants/routeSegmentAssets.ts @@ -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, +): string { + return asset(`/assets/route-segment/terminal/circle-${state}.png`); +} + +export function isStructureAssetState( + state: RouteProgressState, +): state is Extract { + return STRUCTURE_STATES.includes(state); +} diff --git a/client/src/stationbar/mocks/mileage.ts b/client/src/stationbar/mocks/mileage.ts new file mode 100755 index 0000000..0765031 --- /dev/null +++ b/client/src/stationbar/mocks/mileage.ts @@ -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 }, +]; diff --git a/client/src/stationbar/mocks/route.ts b/client/src/stationbar/mocks/route.ts new file mode 100755 index 0000000..1b3baa7 --- /dev/null +++ b/client/src/stationbar/mocks/route.ts @@ -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 }, + ], + }, +]; + diff --git a/client/src/stationbar/mocks/routeInfo.ts b/client/src/stationbar/mocks/routeInfo.ts new file mode 100755 index 0000000..801af34 --- /dev/null +++ b/client/src/stationbar/mocks/routeInfo.ts @@ -0,0 +1,6 @@ +export const ROUTE_INFO = { + direction: '하 행', + routeName: '회덕-대전조차장', + lengthKm: '4.25', + duration: '11분 12초', +} as const; diff --git a/client/src/stationbar/mocks/segments.ts b/client/src/stationbar/mocks/segments.ts new file mode 100755 index 0000000..10618ac --- /dev/null +++ b/client/src/stationbar/mocks/segments.ts @@ -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' }, +]; diff --git a/client/src/stationbar/mocks/timeline.ts b/client/src/stationbar/mocks/timeline.ts new file mode 100755 index 0000000..2ac1fe2 --- /dev/null +++ b/client/src/stationbar/mocks/timeline.ts @@ -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'), + ], + }, +]; diff --git a/client/src/stationbar/tokens.css b/client/src/stationbar/tokens.css new file mode 100755 index 0000000..1f422f2 --- /dev/null +++ b/client/src/stationbar/tokens.css @@ -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; +} diff --git a/client/src/stationbar/types/timeline.ts b/client/src/stationbar/types/timeline.ts new file mode 100755 index 0000000..0dfba99 --- /dev/null +++ b/client/src/stationbar/types/timeline.ts @@ -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; + +/** 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; + /** 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[]; +} diff --git a/client/src/stationbar/utils/asset.ts b/client/src/stationbar/utils/asset.ts new file mode 100755 index 0000000..0c6b049 --- /dev/null +++ b/client/src/stationbar/utils/asset.ts @@ -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(/^\//, ''); +} diff --git a/client/src/stationbar/utils/cssVars.ts b/client/src/stationbar/utils/cssVars.ts new file mode 100755 index 0000000..5d9e11f --- /dev/null +++ b/client/src/stationbar/utils/cssVars.ts @@ -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`; +} diff --git a/client/src/stationbar/utils/mileage.ts b/client/src/stationbar/utils/mileage.ts new file mode 100755 index 0000000..478d3ff --- /dev/null +++ b/client/src/stationbar/utils/mileage.ts @@ -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; +} diff --git a/client/src/stationbar/utils/mileageMarkers.ts b/client/src/stationbar/utils/mileageMarkers.ts new file mode 100755 index 0000000..b8baca2 --- /dev/null +++ b/client/src/stationbar/utils/mileageMarkers.ts @@ -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, + })); +} diff --git a/client/src/stationbar/utils/routeProgress.ts b/client/src/stationbar/utils/routeProgress.ts new file mode 100755 index 0000000..4c62396 --- /dev/null +++ b/client/src/stationbar/utils/routeProgress.ts @@ -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 { + 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 { + 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'; +} diff --git a/client/src/stationbar/utils/segmentLabel.ts b/client/src/stationbar/utils/segmentLabel.ts new file mode 100755 index 0000000..3d55bf3 --- /dev/null +++ b/client/src/stationbar/utils/segmentLabel.ts @@ -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'; +} diff --git a/ecosystem.config.js b/ecosystem.config.js old mode 100644 new mode 100755 diff --git a/package-lock.json b/package-lock.json old mode 100644 new mode 100755 index 8abc14f..70f2f54 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", "postcss": "^8.4.38", + "sass": "^1.101.0", "tailwindcss": "^3.4.1", "typescript": "^5.4.5", "vite": "^5.2.6" @@ -1079,6 +1080,315 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@redis/client": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", @@ -4529,6 +4839,12 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.6.tgz", + "integrity": "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==", + "dev": true + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5575,6 +5891,13 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "optional": true + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -6745,6 +7068,54 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "dev": true, + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", diff --git a/package.json b/package.json old mode 100644 new mode 100755 diff --git a/pythonsource/캡처.png b/pythonsource/캡처.png deleted file mode 100644 index 0470c11..0000000 Binary files a/pythonsource/캡처.png and /dev/null differ diff --git a/server/src/routes/hls.ts b/server/src/routes/hls.ts index c8381f6..3523059 100644 --- a/server/src/routes/hls.ts +++ b/server/src/routes/hls.ts @@ -71,6 +71,15 @@ router.post('/:videoId/convert', async (req: Request, res: Response) => { } const outputDir = path.join(config.hlsDir, videoId.replace(/\.[^.]+$/, '')); + + // 이미 디스크에 HLS가 생성돼 있으면 재인코딩하지 않고 즉시 완료 처리 (멱등) + // jobs는 메모리라 서버 재시작 후 idle이 되지만, 산출물은 디스크에 남아있음 + if (fs.existsSync(path.join(outputDir, 'index.m3u8'))) { + jobs.set(videoId, { status: 'done', percent: 100 }); + res.json({ status: 'done', message: 'Already converted (on disk)' }); + return; + } + await fsp.mkdir(outputDir, { recursive: true }); const job: ConversionJob = { status: 'converting', percent: 0 }; @@ -132,8 +141,18 @@ router.post('/:videoId/convert', async (req: Request, res: Response) => { // GET /api/hls/:videoId/status router.get('/:videoId/status', (req: Request, res: Response) => { const { videoId } = req.params; - const job = jobs.get(videoId) || { status: 'idle' as HlsConversionStatus, percent: 0 }; - res.json(job); + const job = jobs.get(videoId); + if (job) { + res.json(job); + return; + } + // 메모리에 작업 기록이 없어도 디스크에 산출물이 있으면 done + const m3u8Path = path.join(config.hlsDir, videoId.replace(/\.[^.]+$/, ''), 'index.m3u8'); + if (fs.existsSync(m3u8Path)) { + res.json({ status: 'done' as HlsConversionStatus, percent: 100 }); + return; + } + res.json({ status: 'idle' as HlsConversionStatus, percent: 0 }); }); // GET /api/hls/:videoId/index.m3u8 diff --git a/tsconfig.json b/tsconfig.json old mode 100644 new mode 100755