GhiVideo 소스 복제 — v4 작업 시작 기준

기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제.
(node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:32:51 +09:00
co-authored by Claude Fable 5
commit d38b842e8d
362 changed files with 46358 additions and 0 deletions
@@ -0,0 +1,21 @@
.marker {
position: absolute;
left: var(--x);
transform: translateX(-50%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 72px;
color: #fff;
font-weight: 700;
font-size: 13px;
letter-spacing: -0.2px;
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: #4F2000;
paint-order: stroke fill;
}
.selected {
text-shadow: 0 0 6px rgba(255, 148, 71, 0.55);
}
@@ -0,0 +1,22 @@
import type { MileageMarkerSpec } from '../../types/timeline';
import { cssVars, px } from '../../utils/cssVars';
import styles from './MileageMarker.module.scss';
interface MileageMarkerProps {
marker: MileageMarkerSpec;
}
export function MileageMarker({ marker }: MileageMarkerProps) {
const classNames = [styles.marker];
if (marker.selected) classNames.push(styles.selected);
return (
<div
className={classNames.join(' ')}
style={cssVars({ '--x': px(marker.left) })}
title={marker.value}
>
{marker.value}
</div>
);
}
@@ -0,0 +1,331 @@
$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;
}
}
/* '측점 없음' 안내 — 입력창 위에 겹쳐 표시(패널 자식이 absolute 라 일반 흐름은 가려짐). */
.notFound {
position: absolute;
left: $transport-btn-left;
top: 62px;
height: 32.67px;
width: $transport-group-w - $transport-btn-left - $panel-inset;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
z-index: 6; // mileageInput(z:4) 위
border-radius: 4px;
background: rgba(20, 0, 0, 0.88);
box-shadow: 0 0 0 2px #000;
color: #ff6b6b;
font-family: 'Noto Sans KR', var(--font-ui);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
pointer-events: none;
}
/* 화면캡처·측점선 보기 패널 */
.toolGroup {
position: relative;
width: $tool-group-w;
height: $tool-group-h;
margin-left: 1px; // transportGroup 패널 확장분만큼 1px 우측 이동
border-radius: 4px;
background: #1a1309;
}
.toolBtn {
@extend %control-button;
left: $panel-inset;
width: $tool-group-w - 2 * $panel-inset; // 좌우 인셋 제외 → 그룹 폭 바뀌면 자동
height: $tool-btn-h; // 바 영역을 벗어나지 않도록 축소 + 두 버튼 사이 간격 확보
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
background: $tool-gradient padding-box, $default-border border-box;
&:hover {
background: $tool-gradient padding-box, $hover-border border-box;
box-shadow:
0 0 0 2px #000,
0 0 8px rgba(255, 168, 18, 0.35);
}
}
.captureBtn {
top: $tool-btn-top + $tool-btn-top;
}
/* 화면캡처 아래 = 첫 버튼 top + 높이 + 간격 → 높이/간격 바꾸면 자동으로 따라옴 */
.lineBtn {
top: $tool-btn-top + $tool-btn-h + $tool-btn-gap + $tool-btn-top + 1px;
}
.cameraIcon {
@extend %btn-icon;
width: 24px; // 확대
height: 20px;
background-image: url('/assets/icons/icon-camera-body.png');
}
.captureBtn:hover .cameraIcon {
background-image: url('/assets/icons/icon-camera-body_on.png');
}
.lineIcon {
@extend %btn-icon;
width: 19.2px; // 확대
height: 20px;
background-image: url('/assets/icons/icon-sturn.png');
}
.lineBtn:hover .lineIcon {
background-image: url('/assets/icons/icon-sturn_on.png');
}
.toolLabel {
font-family: 'Noto Sans KR', var(--font-ui);
font-weight: 700;
font-size: 13.33px; // 살짝 축소 (기존 14.67)
line-height: 1.2;
letter-spacing: -0.04em;
white-space: nowrap;
color: rgb(204, 199, 189);
text-shadow: 0 0.5px 1px rgb(0, 0, 0);
}
.toolBtn:hover .toolLabel {
color: #ffa812;
}
/* 측점선 보기 토글 ON 상태(클릭): 호버와 동일한 아이콘·라벨 색에,
눌린 어두운 그라데이션 배경 + 주황 그라데이션 테두리. 별도 동작 없음.
(채움 레이어는 padding-box, 테두리 그라데이션은 border-box) */
.lineBtn.active {
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%) padding-box,
linear-gradient(
180deg,
#211601 5%,
#211601 10%,
#352509 15%,
#352509 85%,
#231700 90%,
#000 95%
) padding-box,
$hover-border border-box;
box-shadow: 0 0 0 2px #000;
}
.lineBtn.active .lineIcon {
background-image: url('/assets/icons/icon-sturn_on.png');
}
.lineBtn.active .toolLabel {
color: #ffa812;
}
@@ -0,0 +1,115 @@
import { useEffect, 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;
/** 측점 검색: 이동했으면 true, 이 영상에 없는 측점이라 이동 못 했으면 false. */
onJumpToMileage: (mileage: number) => boolean;
/** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */
lineOn?: boolean;
onToggleLine?: () => void;
}
export function PlaybackControls({
playing,
onTogglePlay,
onStop,
onCapture,
onJumpToMileage,
lineOn: lineOnProp,
onToggleLine,
}: PlaybackControlsProps) {
const [query, setQuery] = useState('');
const [notFound, setNotFound] = useState(false);
const [lineOnInternal, setLineOnInternal] = useState(false);
const lineOn = lineOnProp ?? lineOnInternal;
const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v));
// '측점 없음' 안내는 잠깐만 표시(1.8초 후 자동 사라짐).
useEffect(() => {
if (!notFound) return;
const t = setTimeout(() => setNotFound(false), 1800);
return () => clearTimeout(t);
}, [notFound]);
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>): void => {
setQuery(e.target.value);
setNotFound(false); // 다시 입력하면 안내 숨김
};
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key !== 'Enter') return;
const mileage = parseMileageQuery(query);
// Enter 후에도 입력값 유지 → 같은 측점 재Enter 시 통과방향 다음 위치로 순환 검색.
// 이 영상에 없는 측점(이동 실패) 또는 형식 오류면 '측점 없음' 안내를 잠깐 띄운다.
const moved = mileage !== null ? onJumpToMileage(mileage) : false;
setNotFound(!moved);
};
// 포커스를 잃으면(다른 곳 클릭 등) 입력값 삭제.
const handleQueryBlur = (): void => { setQuery(''); setNotFound(false); };
return (
<div className={styles.controlsRow}>
<div className={styles.transportGroup}>
<div className={styles.leftPanel} />
<button
type="button"
className={styles.transportBtn}
onClick={onTogglePlay}
aria-label={playing ? '일시정지' : '재생'}
>
{/* 호버 시 _on SVG 로 교체(:hover). 재생/일시정지는 상태로 아이콘 전환 */}
<span className={playing ? styles.pauseIcon : styles.playIcon} />
</button>
<button
type="button"
className={styles.stopBtn}
onClick={onStop}
aria-label="정지"
>
<span className={styles.stopIcon} />
</button>
<input
className={styles.mileageInput}
value={query}
onChange={handleQueryChange}
onKeyDown={handleQueryKeyDown}
onBlur={handleQueryBlur}
placeholder="측점입력"
/>
{notFound && (
<span role="alert" className={styles.notFound}> </span>
)}
</div>
<div className={styles.toolGroup}>
<button
type="button"
className={`${styles.toolBtn} ${styles.captureBtn}`}
onClick={onCapture}
aria-label="화면캡처"
>
<span className={styles.cameraIcon} />
<span className={styles.toolLabel}></span>
</button>
<button
type="button"
className={`${styles.toolBtn} ${styles.lineBtn}${lineOn ? ` ${styles.active}` : ''}`}
onClick={toggleLine}
aria-pressed={lineOn}
aria-label="측점선 보기"
>
<span className={styles.lineIcon} />
<span className={styles.toolLabel}>
{lineOn ? '측점선 끄기' : '측점선 보기'}
</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,107 @@
.segment {
position: absolute;
left: var(--seg-left);
top: var(--seg-top);
width: var(--seg-width);
height: var(--seg-height);
display: flex;
align-items: center;
z-index: 6;
}
.capLeft,
.capRight {
flex: 0 0 var(--seg-cap-width);
width: var(--seg-cap-width);
height: var(--seg-height);
background-size: 100% 100%;
background-repeat: no-repeat;
}
.center {
flex: 1;
margin: 0 -3px;
height: var(--seg-center-height);
background-repeat: no-repeat;
background-size: 100% 100%;
}
.bridge {
&.upcoming {
.capLeft {
background-image: url('/assets/route-segment/bridge/bridge-upcoming-left.png');
}
.center {
background-image: url('/assets/route-segment/bridge/bridge-upcoming-center.png');
}
.capRight {
background-image: url('/assets/route-segment/bridge/bridge-upcoming-right.png');
}
}
&.passed {
.capLeft {
background-image: url('/assets/route-segment/bridge/bridge-passed-left.png');
}
.center {
background-image: url('/assets/route-segment/bridge/bridge-passed-center.png');
}
.capRight {
background-image: url('/assets/route-segment/bridge/bridge-passed-right.png');
}
}
&.revisit {
.capLeft {
background-image: url('/assets/route-segment/bridge/bridge-revisit-left.png');
}
.center {
background-image: url('/assets/route-segment/bridge/bridge-revisit-center.png');
}
.capRight {
background-image: url('/assets/route-segment/bridge/bridge-revisit-right.png');
}
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
}
}
.tunnel {
&.upcoming {
.capLeft {
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-left.png');
}
.center {
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-center.png');
}
.capRight {
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-right.png');
}
}
&.passed {
.capLeft {
background-image: url('/assets/route-segment/tunnel/tunnel-passed-left.png');
}
.center {
background-image: url('/assets/route-segment/tunnel/tunnel-passed-center.png');
}
.capRight {
background-image: url('/assets/route-segment/tunnel/tunnel-passed-right.png');
}
}
&.revisit {
.capLeft {
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-left.png');
}
.center {
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-center.png');
}
.capRight {
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-right.png');
}
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
}
}
@@ -0,0 +1,34 @@
import type { RouteProgressState, StructureSegment } from '../../types/timeline';
import { isStructureAssetState } from '../../constants/routeSegmentAssets';
import { cssVars, px } from '../../utils/cssVars';
import styles from './RouteSegment.module.scss';
interface RouteSegmentProps {
segment: StructureSegment;
state: RouteProgressState;
}
/** Three-slice structure marker: fixed caps, stretchable center. */
export function RouteSegment({ segment, state }: RouteSegmentProps) {
const assetState = isStructureAssetState(state) ? state : 'upcoming';
const classNames = [styles.segment, styles[segment.type], styles[assetState]];
return (
<div
className={classNames.join(' ')}
title={segment.label}
style={cssVars({
'--seg-left': px(segment.left),
'--seg-top': px(segment.top),
'--seg-width': px(segment.width),
'--seg-height': px(segment.height),
'--seg-cap-width': px(segment.capWidth),
'--seg-center-height': px(segment.centerHeight),
})}
>
<div className={styles.capLeft} />
<div className={styles.center} />
<div className={styles.capRight} />
</div>
);
}
@@ -0,0 +1,8 @@
.marker {
position: absolute;
left: var(--marker-left);
top: var(--marker-top);
width: var(--marker-width);
height: var(--marker-height);
z-index: 7;
}
@@ -0,0 +1,28 @@
import { terminalCircleAsset } from '../../constants/routeSegmentAssets';
import type { TerminalMarker as TerminalMarkerSpec } from '../../types/timeline';
import { resolveTerminalState } from '../../utils/routeProgress';
import { cssVars, px } from '../../utils/cssVars';
import styles from './TerminalMarker.module.scss';
interface TerminalMarkerProps {
marker: TerminalMarkerSpec;
posPx: number;
}
export function TerminalMarker({ marker, posPx }: TerminalMarkerProps) {
const state = resolveTerminalState(posPx, marker);
return (
<img
className={styles.marker}
src={terminalCircleAsset(state)}
alt=""
style={cssVars({
'--marker-left': px(marker.left),
'--marker-top': px(marker.top),
'--marker-width': px(marker.width),
'--marker-height': px(marker.height),
})}
/>
);
}
@@ -0,0 +1,320 @@
/* 트랙 본체 가로 압축 래퍼. transform(translateX·scaleX)은 route.ts 단일 소스에서
파생되어 Timeline.tsx 에서 인라인으로 적용된다(여기에 하드코딩하지 않음). */
.trackWrapper {
position: absolute;
left: 0;
top: 0;
width: 1920px;
height: 100px;
transform-origin: 0 0;
pointer-events: none;
}
.trackFade {
position: absolute;
left: 0;
top: -23px;
width: 1920px;
height: 23px;
background: linear-gradient(
180deg,
rgba(102, 102, 102, 0) 0%,
rgba(41, 41, 41, 0.25) 50%,
rgba(0, 0, 0, 0.5) 100%
);
pointer-events: none;
}
.trackFrame {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
border-radius: 4px;
background: linear-gradient(180deg, rgb(11, 8, 3) 0%, rgb(40, 26, 7) 100%);
box-shadow: -1px 1px 1px 0 rgb(71, 61, 39);
}
.trackFrameStripesH {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
border-radius: 4px;
background: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.07) 0,
rgba(255, 255, 255, 0.07) 1px,
transparent 1px,
transparent 33.33%
);
pointer-events: none;
}
.trackFrameStripesV {
position: absolute;
left: var(--track-x);
top: 31.5px;
width: var(--track-w);
height: 45.5px;
background: repeating-linear-gradient(
90deg,
rgba(61, 52, 34, 0.55) 0,
rgba(61, 52, 34, 0.55) 2px,
transparent 2px,
transparent 36.65px
);
pointer-events: none;
}
.trackBase {
position: absolute;
left: var(--track-x);
top: 34.5px;
width: var(--track-w);
height: 12px;
border-radius: 6px;
background: rgb(122, 122, 122);
z-index: 1;
}
/* 역명 레이어: 스테이지 전체를 덮어 자식들의 위치 기준이 된다.
--track-start / --track-end (트랙 시작·끝, 단일 소스)을 상속받아
아래 역 라벨들이 트랙 가장자리 기준 calc()로 배치된다(시작/끝 바꾸면 자동 이동). */
.stationLayer {
position: absolute;
inset: 0;
pointer-events: none;
}
/* 트랙 가장자리 기준 오프셋(좌우 대칭): 텍스트 안쪽끝 40px · 점 32/27px · 점선 23/5px */
.stationStart {
position: absolute;
/* 오른쪽(안쪽) 끝을 '트랙 시작-40px'에 고정 → 대전과 트랙 중앙 기준 좌우 대칭.
글자 폭과 무관하게 점선·텍스트 공백이 양쪽 동일(점까지 8px, 트랙까지 50px). */
right: calc(100% - var(--track-start) + 40px);
top: 31px;
font-size: 16px;
font-weight: 700;
color: var(--color-station);
white-space: nowrap;
}
.stationEnd {
position: absolute;
/* 우측 끝(1920) 기준으로 고정 → 긴 역명(대전조차장 등)도 안쪽(좌)으로 펼쳐져 화면 밖으로 안 잘림. */
right: 8px;
top: 31px;
font-size: 16px;
font-weight: 700;
color: var(--color-station);
white-space: nowrap;
}
.dotStart,
.dotEnd {
position: absolute;
top: 39px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--color-station-dot);
}
.dotStart {
left: calc(var(--track-start) - 32px);
}
.dotEnd {
left: calc(var(--track-end) + 27px);
}
.leaderStart,
.leaderEnd {
position: absolute;
top: 40.5px;
height: 2.5px;
width: 18px;
background: repeating-linear-gradient(
90deg,
var(--color-station-leader) 0 5px,
transparent 5px 9px
);
}
.leaderStart {
left: calc(var(--track-start) - 23px);
}
.leaderEnd {
left: calc(var(--track-end) + 5px);
}
.track {
position: absolute;
left: var(--track-x);
top: 34.5px;
width: var(--track-w);
height: 12px;
z-index: 2;
}
.leg {
position: absolute;
top: 0;
height: 12px;
left: var(--leg-left);
width: var(--leg-width);
&.down {
background: var(--color-track-down);
}
&.up {
background: var(--color-track-up);
}
}
.legPassed {
position: absolute;
left: 0;
top: 0;
height: 12px;
width: var(--passed-width);
.down & {
background: var(--gradient-track-down-passed);
}
.up & {
background: var(--gradient-track-up-passed);
}
}
.turnDivider {
position: absolute;
left: var(--x);
top: 0;
width: 0;
height: 12px;
border-left: 1px dashed rgba(0, 0, 0, 0.7);
border-right: 1px dashed rgba(255, 255, 255, 0.3);
z-index: 3;
pointer-events: none;
}
.mileageRow {
position: absolute;
left: 0;
top: 6px;
width: 1920px;
height: 13px;
white-space: nowrap;
z-index: 3;
}
.labelRow {
position: absolute;
left: 0;
top: 51px;
width: 1920px;
height: 26px;
font-family: 'Noto Sans KR', sans-serif;
font-size: 16.5px;
font-weight: 800;
line-height: 24px;
}
.zone {
position: absolute;
left: var(--x);
top: -6px;
width: var(--zone-width);
height: 15.5px;
border: 1.5px dashed rgb(218, 203, 185);
border-radius: 1px;
box-sizing: border-box;
pointer-events: none;
}
.zoneInner {
position: absolute;
left: var(--x);
top: -6px;
width: 0;
height: 15.5px;
border-left: 1.5px dashed rgb(218, 203, 185);
pointer-events: none;
}
.segmentLabel {
position: absolute;
left: var(--x);
top: 2px;
transform: translateX(-50%);
white-space: nowrap;
&.box {
transform: none;
width: var(--label-width);
text-align: center;
}
/* 텍스트 오른쪽 끝이 left 좌표에 닿음 (시작 라인 왼쪽 배치) */
&.anchorEnd {
transform: translateX(-100%);
}
/* 텍스트 왼쪽 끝이 left 좌표에서 시작 (종료 라인 오른쪽 배치) */
&.anchorStart {
transform: none;
}
&.endpoint {
color: #fff;
-webkit-text-stroke-width: 4px;
-webkit-text-stroke-color: #4f2000;
paint-order: stroke fill;
}
&.accent {
color: #ffd4b7;
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: #4f2000;
paint-order: stroke fill;
}
&.neutral {
color: #e4e4e4;
-webkit-text-stroke-width: 3px;
-webkit-text-stroke-color: #0d0d0d;
paint-order: stroke fill;
}
}
/* 구조물명: 줄바꿈은 JS(splitStructLabel)가 위/아래 글자수 균등하게 <br>로 처리.
박스는 내용 폭에 맞게(최대 140px) 줄어들어 옆 라벨과 간격을 최대 확보.
각 줄은 nowrap(명시적 줄바꿈만), 아이콘 기준 가운데 정렬.
폰트 크기는 라벨행 기본(16.5px) 유지 — 작게 줄이지 않음. */
.structName {
width: max-content;
max-width: 140px;
white-space: nowrap;
text-align: center;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
}
.seekArea {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
cursor: pointer;
z-index: 20;
pointer-events: auto;
}
+480
View File
@@ -0,0 +1,480 @@
import { Fragment, useState } from 'react';
import type { MouseEvent } from 'react';
import { RouteSegment } from '../RouteSegment/RouteSegment';
import type { StructureType, StructureSegment } from '../../types/timeline';
import {
TRACK_RENDER_END_PX,
TRACK_RENDER_START_PX,
TRACK_START_PX,
TRACK_WIDTH_PX,
TRACK_WRAPPER_TRANSFORM,
} from '../../mocks/route';
import type { KmLabel, StructMark } from '../../StationBar';
import { cssVars, px } from '../../utils/cssVars';
import { MileageMarker } from '../MileageMarker/MileageMarker';
import styles from './Timeline.module.scss';
/** 미터값 → "158k160" (10m 단위). 음수면 빈 문자열. */
function fmtKm(m: number): string {
if (m < 0) return '';
const r = Math.round(m / 10) * 10;
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
}
/**
* 구조물명 라벨 분할 — 한 줄에 안 들어가면 위/아래 '글자 수'가 최대한 같게 2줄로 나눈다.
* (균등 분할 → 가장 넓은 줄 폭 최소화 → 옆 라벨과 간격 최대 확보)
* 한글/CJK 1.0, 그 외(영문·괄호·숫자) 0.55폭으로 한 줄 용량(≈140px/16.5px) 추정.
* 분할점은 글자수 절반. 단, 절반 근처(±2)에 여는 괄호가 있으면 괄호를 통째로 아랫줄로.
*/
function splitStructLabel(text: string): string[] {
const chars = [...text];
const isCJK = (c: string): boolean => /[ -鿿가-힣]/.test(c);
const width = chars.reduce((a, c) => a + (isCJK(c) ? 1 : 0.55), 0);
if (width <= 8.2) return [text]; // 한 줄에 들어감 → 그대로
const len = chars.length;
// 기본 분할점 = 글자수 절반(위/아래 글자수 균등).
let cut = Math.round(len / 2);
// 분할점이 괄호 그룹 "(...)" 내부를 가르면 → 괄호 시작 앞으로 옮겨 통째로 아랫줄.
let open = -1;
for (let i = 0; i < len; i++) {
const c = chars[i];
if (c === '(' || c === '') open = i;
else if (c === ')' || c === '') {
if (open >= 0 && open < cut && i >= cut) { cut = open; break; }
open = -1;
}
}
return [chars.slice(0, cut).join(''), chars.slice(cut).join('')];
}
interface TimelineProps {
posPx: number;
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
/** 통과 구간 트랙 gradient (정방향=주황/역방향=청록). */
trackGradient: string;
/** 미통과 구간 트랙 gradient (정방향=회색/역방향=청회색). */
trackGradientIdle: string;
/** 색(방향)이 바뀌는 전환점 px(스테이지 좌표) — 구분선 위치. */
dividers: number[];
/** 데이터 기반 측점값 라벨 (방향 전환점·시종점). */
kmLabels: KmLabel[];
/** 데이터 기반 구조물 (교량/터널/역사). */
structures: StructMark[];
/** 시점 역명 (route.json routeInfo 우선, 없으면 첫 측점 title). */
startStationName?: string;
/** 종점 역명 (route.json routeInfo 우선, 없으면 끝 측점 title). */
endStationName?: string;
/** 종점역 미도착 폭(px) — 트랙 우측 이 구간은 '재생 불가'(영상이 도달 못 함)로 별색 표시. */
endGapPx?: number;
/** 영상 진행 방향('상'|'하'). 평행 상/하행 구조물 겹침 시 해당 방향을 우선 표출. */
routeDir?: '상' | '하' | null;
/** 겹침제외(POI 옵션). true=겹친 구조물 1개만, false=모두 표시. */
overlapExclude?: boolean;
}
export function Timeline({
posPx,
onSeekDown,
trackGradient,
trackGradientIdle,
dividers,
kmLabels,
structures,
startStationName,
endStationName,
endGapPx = 0,
routeDir = null,
overlapExclude = true,
}: TimelineProps) {
// 하단 스테이션바는 겹쳐도 무방 → 역사(역, KAKAO_RAIL)는 dedup 대상에서 제외하고 항상 표출.
// (종점 '대전조차장'이 ~70px 앞 '법동가도교'에 밀려 사라지던 문제 방지.)
// 교량/터널만 라벨 텍스트가 길어 130px 겹침 방지 적용. 위치는 실제 통과 px 그대로.
// 평행 상/하행 구조물(예: 회덕터널 상/하)은 ~6px 차이라 한쪽만 남는데, 영상 방향(routeDir)에
// 해당하는 쪽(하행=‘(하)’, 상행=‘(상)’)을 우선 남긴다. → 드론이 실제 지나는 선로와 일치.
const isStation = (s: StructMark): boolean => s.category === '역사' || s.category === '역';
// '(상' / '(하' 로 매칭 — 이름이 "법동가도교(상,인상,고속)" 처럼 괄호 안 다중 토큰이어도 인식.
// (이전 '(상)' 완전일치는 다중토큰 이름에서 매칭 실패해 잘못된 변형이 선택되던 버그)
const dirTag = routeDir === '상' ? '(상' : routeDir === '하' ? '(하' : '';
const baseStruct = (t: string): string => t.replace(/\s*[(].*$/, '').trim();
const others = structures.filter((s) => !isStation(s));
const ordered = dirTag
? [...others].sort((a, b) =>
(a.title.includes(dirTag) ? 0 : 1) - (b.title.includes(dirTag) ? 0 : 1) || a.px - b.px)
: [...others].sort((a, b) => a.px - b.px);
// 같은 구조물의 상/하/인상 변형(같은 base = 같은 좌표)은 '겹침제외 옵션과 무관하게' 진행방향 1개만.
// (dir-우선 정렬돼 있어 방향 변형이 먼저 = 채택. 예: 법동가도교(하)/(인상) → (하) 1개)
const byBase = new Map<string, StructMark>();
for (const it of ordered) {
const b = baseStruct(it.title);
if (!byBase.has(b)) byBase.set(b, it);
}
const keptOthers = [...byBase.values()];
// 라벨은 긴 이름 2줄(아래 splitStructLabel)로 폭을 줄여 표시. 그래도 가까워 겹치면 그대로 둠(엇갈림 없음).
void overlapExclude;
const structs = [...structures.filter(isStation), ...keptOthers].sort((a, b) => a.px - b.px);
// 측점값 라벨 겹침 숨김: px 오름차순으로 보며, 직전에 '표시한' 라벨과 시각적으로 겹치면
// (중앙 간격 < 두 라벨 평균 폭) 뒤엣것을 숨긴다 → 먼저 나온(좌측) 것만 1개 표시.
// 같은/근접 측점을 여러 번 통과해 측점값이 겹쳐 보이던 문제 방지. (아이콘·점선은 그대로 둠)
const labelW = (v: string): number => v.length * 7.5 + 4; // 13px bold + 2px stroke 대략 폭(px)
const showMileage: boolean[] = new Array(structs.length).fill(false);
{
let lastPx = -Infinity;
let lastW = 0;
for (let i = 0; i < structs.length; i++) {
const s = structs[i];
// 측점값 없음(s.km<0) 또는 '그 위치에 실제 측점값이 존재하지 않음'(kmExists=false,
// 좌표 반경만으로 잡힌 통과)은 측점값 라벨을 그리지 않는다. (마커 동그라미는 별도로 유지)
if (s.km < 0 || s.kmExists === false) continue;
const w = labelW(fmtKm(s.km));
if (s.px - lastPx >= (lastW + w) / 2) {
showMileage[i] = true;
lastPx = s.px;
lastW = w;
}
}
}
// 라벨 그룹핑: 같은 항목(동일 title)을 드론이 여러 번 지나면 통과 마커가 흩어져 이름이 반복된다.
// → 동명 통과를 거리와 무관하게 하나로 묶어, 각 통과에 점선 드롭 + 양끝을 잇는 하단 수평 점선(브래킷)을
// 그리고 묶음 중앙에 라벨 1개만 표시한다. (단일 통과는 브래킷 없이 자기 위치 라벨 1개)
// 아이콘·측점값은 통과별로 그대로 표출.
const labelGroups: {
title: string;
km: number;
category: string;
unreached?: boolean;
centerPx: number; // 라벨 위치 및 passed 판정 기준 (묶음 중앙)
bracketPxs: number[]; // 길이 ≥2면 브래킷(드롭+수평선) 표시 대상 통과 px (정렬)
}[] = [];
{
const byTitle = new Map<string, StructMark[]>();
for (const s of structs) {
const a = byTitle.get(s.title);
if (a) a.push(s);
else byTitle.set(s.title, [s]);
}
for (const arr of byTitle.values()) {
const sorted = [...arr].sort((a, b) => a.px - b.px);
// 동명 통과(역사 포함)는 하나로 묶어 브래킷(드롭+수평선) + 묶음 라벨 1개. 단일 통과는 자기 위치 라벨.
const lo = sorted[0].px;
const hi = sorted[sorted.length - 1].px;
labelGroups.push({
title: sorted[0].title,
km: sorted[0].km,
category: sorted[0].category,
unreached: sorted.some((m) => m.unreached),
centerPx: (lo + hi) / 2,
bracketPxs: sorted.length > 1 ? sorted.map((m) => m.px) : [],
});
}
}
// 구조물 마커 클릭 시 속성 팝업(클릭 좌표 기준 fixed — 트랙 래퍼 transform 영향 회피).
const [sel, setSel] = useState<{ s: StructMark; x: number; y: number } | null>(null);
return (
<>
{/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
<div className={styles.trackFade} />
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 시·종점 역명 배치 (route.json/측점 유래) */}
<div
className={styles.stationLayer}
style={cssVars({
'--track-start': px(TRACK_RENDER_START_PX),
'--track-end': px(TRACK_RENDER_END_PX),
})}
>
{/* 좌측(시점)=역방향 하늘색, 우측(종점)=정방향 주황 */}
<div className={styles.stationStart} style={{ color: '#06a4c8' }}>{startStationName ?? ''}</div>
<div className={styles.dotStart} style={{ background: '#06a4c8' }} />
<div className={styles.leaderStart} style={cssVars({ '--color-station-leader': '#06a4c8' })} />
<div className={styles.leaderEnd} style={cssVars({ '--color-station-leader': '#ff8a25' })} />
<div className={styles.dotEnd} style={{ background: '#ff8a25' }} />
<div className={styles.stationEnd} style={{ color: '#ff8a25' }}>{endStationName ?? ''}</div>
</div>
{/* 트랙 본체 (가로 압축 래퍼) */}
<div
className={styles.trackWrapper}
style={{
...cssVars({
'--track-x': px(TRACK_START_PX),
'--track-w': px(TRACK_WIDTH_PX),
}),
transform: TRACK_WRAPPER_TRANSFORM,
}}
>
<div className={styles.trackFrame} />
<div className={styles.trackFrameStripesH} />
<div className={styles.trackFrameStripesV} />
<div className={styles.trackBase} />
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색.
전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
<div className={styles.track}>
{/* 미재생(미통과): 방향별 저톤 미리보기 (전체 폭) */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: px(TRACK_WIDTH_PX),
background: trackGradientIdle,
borderRadius: 'inherit',
}}
/>
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체. */}
{endGapPx > 0 && (
<div
title="재생 불가 구간 (종점역 미도착)"
style={{
position: 'absolute',
left: px(TRACK_WIDTH_PX - endGapPx),
top: 0,
height: '100%',
width: px(endGapPx),
background: '#54545c',
borderRadius: 'inherit',
zIndex: 2,
}}
/>
)}
{/* 재생된 구간: 원래 색 복원 (커서까지 clip, --pos-px CSS변수로 매 프레임 갱신). */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: `max(0px, min(${TRACK_WIDTH_PX}px, calc(var(--pos-px, ${TRACK_START_PX}px) - ${TRACK_START_PX}px)))`,
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: px(TRACK_WIDTH_PX),
background: trackGradient,
borderRadius: 'inherit',
}}
/>
</div>
{/* 색(방향) 전환점 구분선 — videoplayer leg 솔기와 동일(검정+흰색 점선) */}
{dividers.map((dpx, i) => (
<div
key={`div-${i}`}
style={{
position: 'absolute',
left: dpx - TRACK_START_PX,
top: 0,
width: 0,
height: '100%',
transform: 'translateX(-1px)',
borderLeft: '1px dashed rgba(0, 0, 0, 0.7)',
borderRight: '1px dashed rgba(255, 255, 255, 0.3)',
zIndex: 3,
pointerEvents: 'none',
}}
/>
))}
</div>
{/* 측점값 라벨 — 각 시설물 측점값(아이콘 위) */}
<div className={styles.mileageRow}>
{structs.map((s, i) =>
s.km >= 0 && showMileage[i] ? (
<MileageMarker
key={`stkm-${i}`}
marker={{ id: `stkm-${i}`, value: fmtKm(s.km), left: s.px, mileage: 0 }}
/>
) : null,
)}
</div>
{/* 구조물 아이콘 — 재생바 위에 겹쳐 표시 (videoplayer 3-slice) */}
<div style={{ position: 'absolute', inset: 0, zIndex: 4, pointerEvents: 'none' }}>
{structs.map((s, i) => {
// 두 상태(무채색 upcoming / 유채색 passed) 아이콘을 겹쳐두고 opacity 로 전환 →
// 배경이미지 교체가 없어 전환 순간 깜빡임(사라짐)이 없다.
// 역사/터미널 → 원형 아이콘
if (s.category === '역사' || s.category === '역') {
const D = 18;
// 라벨과 동일 트리거(라벨 W=22 → s.px-11). 아이콘·라벨 동시 전환.
// 미도착(종점역): 영상이 도달 못 함 → 항상 '예정(무채색)' 상태로 고정.
// (불투명 아이콘이 뒤의 재생불가 빗금을 덮도록 opacity 1 유지.)
const passed = s.unreached ? false : posPx >= s.px - 11;
const base = import.meta.env.BASE_URL;
const common = {
position: 'absolute' as const,
left: s.px - D / 2,
top: 40.5 - D / 2,
width: D,
height: D,
transition: 'opacity 0.12s',
};
return (
<Fragment key={i}>
<img src={`${base}assets/route-segment/terminal/circle-upcoming.png`} alt="" style={{ ...common, opacity: passed ? 0 : 1 }} />
<img src={`${base}assets/route-segment/terminal/circle-passed.png`} alt="" style={{ ...common, opacity: passed ? 1 : 0 }} />
</Fragment>
);
}
const segType: StructureType | null =
s.category === '터널' ? 'tunnel' : s.category === '교량' ? 'bridge' : null;
if (!segType) return null;
// 원본(videoplayer) 치수: 교량 22×16/cap6/top32.5, 터널 24×14/cap10/top33.75
const isTunnel = segType === 'tunnel';
const W = isTunnel ? 24 : 22;
const H = isTunnel ? 14 : 16;
const CAP = isTunnel ? 10 : 6;
const TOP = isTunnel ? 33.75 : 32.5;
const passed = posPx >= s.px - W / 2;
const seg: StructureSegment = {
id: `st-${i}`,
type: segType,
label: s.title,
startMileage: 0,
endMileage: 0,
left: s.px - W / 2,
top: TOP,
width: W,
height: H,
capWidth: CAP,
centerHeight: H,
passedAtPx: 0,
};
const layer = (op: number) =>
({ position: 'absolute' as const, inset: 0, opacity: op, transition: 'opacity 0.12s' });
return (
<Fragment key={i}>
<div style={layer(passed ? 0 : 1)}><RouteSegment segment={seg} state="upcoming" /></div>
<div style={layer(passed ? 1 : 0)}><RouteSegment segment={seg} state="passed" /></div>
</Fragment>
);
})}
</div>
{/* 구조물 클릭 핫스팟 제거 — 스테이션바 클릭은 속성 팝업을 띄우지 않고 seek 으로만 동작.
(POI 정보는 화면 오버레이 라벨 클릭에서 확인) */}
{/* 동일 항목 묶음 — 각 통과 위치를 수직 점선으로 내려, 라벨 '문자 세로 중앙'을 지나는
수평 점선으로 잇는다. 수평선은 라벨 뒤로 지나가 글자 양옆으로만 보인다(두번째 참고 이미지).
z-index 미지정(auto) → DOM 상 뒤의 labelRow 가 위에 그려져 글자가 선을 덮는다. */}
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{labelGroups
.filter((g) => g.bracketPxs.length > 1)
.map((g, gi) => {
const lo = g.bracketPxs[0];
const hi = g.bracketPxs[g.bracketPxs.length - 1];
// 라벨: 절대 top 53, 줄높이 18px(structName). 문자 세로 중앙 y = 53 + 줄수*9.
const lineCount = splitStructLabel(g.title).length;
const lineY = 53 + lineCount * 9;
const dash = '1.5px dashed rgba(232, 232, 232, 0.6)';
return (
<Fragment key={`grp-${gi}`}>
{/* 문자 세로 중앙을 지나는 수평 점선 (라벨 뒤) */}
<div
style={{
position: 'absolute',
left: lo,
top: lineY,
width: hi - lo,
height: 0,
borderTop: dash,
}}
/>
{/* 각 통과 수직 점선 (트랙 하단 → 수평선) */}
{g.bracketPxs.map((bp, j) => (
<div
key={j}
style={{
position: 'absolute',
left: bp,
top: 46,
width: 0,
height: lineY - 46,
borderLeft: dash,
}}
/>
))}
</Fragment>
);
})}
</div>
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄.
같은 항목 다중통과는 labelGroups(클러스터)로 묶여 클러스터당 라벨 1개만 표출. */}
<div className={styles.labelRow}>
{labelGroups.map((g, i) => {
const W = g.category === '터널' ? 24 : 22;
// 미도착 종점역은 항상 미통과(neutral) 색 유지.
const passed = g.unreached ? false : posPx >= g.centerPx - W / 2;
// 폭 처리: 긴 이름은 2줄(균등 분할)로 폭을 줄여 표시. 그래도 가까워 겹치면 그대로 둠.
const lines = splitStructLabel(g.title);
return (
<div
key={i}
className={`${styles.segmentLabel} ${styles.structName} ${passed ? styles.accent : styles.neutral}`}
style={cssVars({ '--x': px(g.centerPx) })}
title={`${g.category} · ${g.title} (${fmtKm(g.km)})`}
>
{lines.length === 1 ? lines[0] : <>{lines[0]}<br />{lines[1]}</>}
</div>
);
})}
</div>
<div className={styles.seekArea} onMouseDown={onSeekDown} />
</div>
{/* 구조물 속성 팝업 — 클릭 좌표 기준 fixed(트랙 래퍼 밖). 배경 클릭 시 닫힘. */}
{sel && (
<>
<div
onMouseDown={() => setSel(null)}
style={{ position: 'fixed', inset: 0, zIndex: 999 }}
/>
<div
onMouseDown={(e) => e.stopPropagation()}
style={{
position: 'fixed',
left: Math.min(sel.x + 4, window.innerWidth - 264),
top: Math.max(8, Math.min(sel.y - 8, window.innerHeight - 220)),
zIndex: 1000,
maxWidth: 260,
maxHeight: '45vh',
overflowY: 'auto',
background: 'rgba(0,0,0,0.92)',
border: '1px solid rgba(16,185,129,0.7)',
borderRadius: 6,
padding: 8,
color: '#fff',
fontSize: 11,
boxShadow: '0 6px 24px rgba(0,0,0,0.5)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
<b style={{ color: '#6ee7b7', wordBreak: 'break-all' }}>📍 {sel.s.title}</b>
<span onMouseDown={() => setSel(null)} style={{ cursor: 'pointer', color: '#9ca3af' }}></span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 8, rowGap: 2, fontFamily: 'monospace', fontSize: 10, color: '#d1d5db' }}>
{(sel.s.props ?? []).map((p, i) => (
<Fragment key={i}>
<span style={{ color: '#6b7280', whiteSpace: 'nowrap' }}>{p.k}</span>
<span style={{ wordBreak: 'break-all' }}>{p.v}</span>
</Fragment>
))}
</div>
</div>
</>
)}
</>
);
}
@@ -0,0 +1,75 @@
.cursor {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
transform: translateX(var(--cursor-x, 0px));
will-change: transform;
}
.line {
position: absolute;
left: -1px;
top: 991px;
width: 2px;
height: 37px;
background: var(--color-cursor);
z-index: 40;
/* 라인을 직접 잡아 드래그 가능하게 */
pointer-events: auto;
cursor: ew-resize;
}
.badge {
position: absolute;
left: 0;
/* 패널 상단(스테이지 980) 근처까지 키움 — 위 도구모음과는 안 겹치게 top 은 974 까지만. */
top: 974px;
transform: translateX(-50%);
z-index: 41;
/* 뱃지를 직접 잡아 드래그 가능하게 (라인과 동일) */
pointer-events: auto;
cursor: ew-resize;
user-select: none;
background: linear-gradient(180deg, #ff6a35 0%, #f8430d 45%, #ef3c08 100%);
border-radius: 7px;
height: 36px;
min-width: 112px;
padding: 0 9px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.4px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.35),
0 2px 6px rgba(0, 0, 0, 0.45);
&::after {
content: '';
position: absolute;
left: 50%;
bottom: -7px;
transform: translateX(-50%);
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 8px solid #ef3c08;
}
}
/* 역방향(km 감소, 하늘색 구간) 통과 시 커서도 동일한 파란색으로 표시 */
.cursor.reverse {
.line {
background: #06a4c8;
}
.badge {
background: linear-gradient(180deg, #3fb6d4 0%, #0a9bc0 45%, #067f9e 100%);
&::after {
border-top-color: #067f9e;
}
}
}
@@ -0,0 +1,20 @@
import type { MouseEvent } from 'react';
import styles from './TimelineCursor.module.scss';
interface TimelineCursorProps {
mileageText: string;
/** 현재 위치가 역방향(km 감소) 구간이면 커서를 파란색으로. */
reverse?: boolean;
/** 라인·뱃지를 직접 드래그해 이동시킬 때 호출 (seekArea와 동일 핸들러). */
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
}
// 커서 x 위치(--cursor-x)는 상위(StationBar wrapRef)에서 rAF로 직접 갱신한다(transform).
export function TimelineCursor({ mileageText, reverse, onSeekDown }: TimelineCursorProps) {
return (
<div className={`${styles.cursor} ${reverse ? styles.reverse : ''}`}>
<div className={styles.line} onMouseDown={onSeekDown} />
<div className={styles.badge} onMouseDown={onSeekDown}>{mileageText}</div>
</div>
);
}