defVideo 작업분 반영

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

View File

@@ -0,0 +1,21 @@
.marker {
position: absolute;
left: var(--x);
transform: translateX(-50%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 72px;
color: #fff;
font-weight: 700;
font-size: 13px;
letter-spacing: -0.2px;
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: #4F2000;
paint-order: stroke fill;
}
.selected {
text-shadow: 0 0 6px rgba(255, 148, 71, 0.55);
}

View File

@@ -0,0 +1,22 @@
import type { MileageMarkerSpec } from '../../types/timeline';
import { cssVars, px } from '../../utils/cssVars';
import styles from './MileageMarker.module.scss';
interface MileageMarkerProps {
marker: MileageMarkerSpec;
}
export function MileageMarker({ marker }: MileageMarkerProps) {
const classNames = [styles.marker];
if (marker.selected) classNames.push(styles.selected);
return (
<div
className={classNames.join(' ')}
style={cssVars({ '--x': px(marker.left) })}
title={marker.value}
>
{marker.value}
</div>
);
}

View File

@@ -0,0 +1,307 @@
$transport-gradient: linear-gradient(
180deg,
rgb(96, 79, 50) 0%,
rgb(85, 75, 49) 7%,
rgb(64, 51, 39) 11%,
rgb(40, 34, 22) 100%
);
$tool-gradient: linear-gradient(
180deg,
rgb(96, 79, 50) 0%,
rgb(59, 52, 34) 7%,
rgb(54, 43, 31) 11%,
rgb(66, 55, 35) 100%
);
$group-border: linear-gradient(
180deg,
rgba(255, 182, 135, 0.5) 0%,
rgba(255, 255, 255, 0) 100%,
);
$default-border: linear-gradient(
180deg,
rgba(255, 255, 255, 0.25) 25%,
rgba(153, 122, 101, 0.63) 63%,
rgba(98, 62, 39, 0.81) 81%,
rgba(80, 67, 60, 1) 100%
);
$hover-border: linear-gradient(
180deg,
#ffa812 8%,
#ffdfa7 38%,
#ffa812 72%,
#a96e09 95%
);
// ── 컨트롤 패널 치수 (이 값들만 바꾸면 관련 위치/크기가 자동 계산됨) ──
$panel-h: 96.67px; // transportGroup 높이
$panel-inset: 2px; // 그룹 가장자리 공통 인셋
// 재생·정지·측점입력 그룹
$transport-group-w: 161.33px;
$transport-btn-w: 78px;
$transport-btn-h: 55px;
$transport-btn-left: 3px; // 첫(재생) 버튼 좌측 인셋
$transport-btn-top: 4px;
$transport-btn-gap: 1px; // 재생 ↔ 정지 간격
// 화면캡처·측점선 그룹
$tool-group-w: 76px;
$tool-group-h: 96px;
$tool-btn-h: 47px;
$tool-btn-top: 0.5px; // 화면캡처(첫 버튼) top — 47×2가 96 안에 들어가도록 축소
$tool-btn-gap: 1px; // 화면캡처 ↔ 측점선 간격 (47px 버튼이 바 안에 들어가도록)
/* 컨트롤 묶음 — bottomBar flex 자식 (좌측 정렬, 세로 중앙) */
.controlsRow {
position: relative;
display: inline-flex;
gap: 4px;
padding-left: 1.33px;
align-items: center;
z-index: 4;
}
%control-button {
position: absolute;
z-index: 4;
box-sizing: border-box;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid transparent;
border-radius: 4px;
appearance: none;
cursor: pointer;
overflow: hidden;
background: $transport-gradient padding-box, $default-border border-box;
box-shadow: 0 0 0 2px rgb(0, 0, 0);
transition: box-shadow 150ms ease-out;
&:hover {
background: $transport-gradient padding-box, $hover-border border-box;
box-shadow:
0 0 0 2px #000,
0 0 8px rgba(255, 168, 18, 0.35);
}
}
/* 플레이·정지·측점 입력 패널 */
.transportGroup {
position: relative;
width: $transport-group-w;
height: $panel-h;
}
.leftPanel {
position: absolute;
left: -1px;
top: 0;
width: calc(100% + 4px);
height: 100%;
/* 그라데이션 테두리 두께 = 이 border 폭 (숫자를 키우면 더 굵어짐) */
border: 2px solid transparent;
box-sizing: border-box;
border-radius: 4px;
background:
linear-gradient(rgb(38, 29, 13), transparent) padding-box,
$group-border border-box;
z-index: 3;
}
.transportBtn {
@extend %control-button;
left: $transport-btn-left;
top: $transport-btn-top;
width: $transport-btn-w;
height: $transport-btn-h;
}
.stopBtn {
@extend %control-button;
// 재생 버튼 우측 + 간격 → 폭이 바뀌면 자동으로 따라옴
left: $transport-btn-left + $transport-btn-w + $transport-btn-gap + 1px;
top: $transport-btn-top;
width: $transport-btn-w;
height: $transport-btn-h;
}
/* 버튼 아이콘: 실제 SVG 파일(그라데이션·그림자·테두리 포함)을 배경으로 사용.
호버 시 버튼의 :hover 로 _on 버전으로 교체한다. 박스 크기는 SVG viewBox의 2/3
(= 기존 렌더 크기 유지). 새 아이콘은 SVG만 교체하면 되어 유지보수가 쉽다. */
%btn-icon {
background-repeat: no-repeat;
background-position: center;
background-size: contain;
pointer-events: none;
}
.playIcon {
@extend %btn-icon;
width: 40px; // 60 × 2/3
height: 40px;
background-image: url('/assets/icons/ico_play.png');
}
.transportBtn:hover .playIcon {
background-image: url('/assets/icons/ico_play_on.png');
}
.pauseIcon {
@extend %btn-icon;
width: 33.33px; // 50 × 2/3
height: 33.33px;
background-image: url('/assets/icons/icon_pause.png');
}
.transportBtn:hover .pauseIcon {
background-image: url('/assets/icons/icon_pause_on.png');
}
.stopIcon {
@extend %btn-icon;
width: 40px; // 60 × 2/3
height: 40px;
background-image: url('/assets/icons/icon_stop.png');
}
.stopBtn:hover .stopIcon {
background-image: url('/assets/icons/icon_stop_on.png');
}
.mileageInput {
position: absolute;
left: $transport-btn-left;
top: 62px;
z-index: 4;
width: $transport-group-w - $transport-btn-left - $panel-inset;
height: 32.67px;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.65);
box-shadow:
0 0 0 2px rgb(0, 0, 0),
0 0.5px 0 0 rgba(255, 255, 255, 0.1);
box-sizing: border-box;
padding: 0 8px;
font-family: 'Noto Sans KR', var(--font-ui);
font-size: 16px;
font-weight: 400;
letter-spacing: 0.04em;
color: #fff;
&::placeholder {
color: rgba(255, 255, 255, 0.5);
}
&:focus {
outline: none;
}
}
/* 화면캡처·측점선 보기 패널 */
.toolGroup {
position: relative;
width: $tool-group-w;
height: $tool-group-h;
margin-left: 1px; // transportGroup 패널 확장분만큼 1px 우측 이동
border-radius: 4px;
background: #1a1309;
}
.toolBtn {
@extend %control-button;
left: $panel-inset;
width: $tool-group-w - 2 * $panel-inset; // 좌우 인셋 제외 → 그룹 폭 바뀌면 자동
height: $tool-btn-h; // 바 영역을 벗어나지 않도록 축소 + 두 버튼 사이 간격 확보
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
background: $tool-gradient padding-box, $default-border border-box;
&:hover {
background: $tool-gradient padding-box, $hover-border border-box;
box-shadow:
0 0 0 2px #000,
0 0 8px rgba(255, 168, 18, 0.35);
}
}
.captureBtn {
top: $tool-btn-top + $tool-btn-top;
}
/* 화면캡처 아래 = 첫 버튼 top + 높이 + 간격 → 높이/간격 바꾸면 자동으로 따라옴 */
.lineBtn {
top: $tool-btn-top + $tool-btn-h + $tool-btn-gap + $tool-btn-top + 1px;
}
.cameraIcon {
@extend %btn-icon;
width: 24px; // 확대
height: 20px;
background-image: url('/assets/icons/icon-camera-body.png');
}
.captureBtn:hover .cameraIcon {
background-image: url('/assets/icons/icon-camera-body_on.png');
}
.lineIcon {
@extend %btn-icon;
width: 19.2px; // 확대
height: 20px;
background-image: url('/assets/icons/icon-sturn.png');
}
.lineBtn:hover .lineIcon {
background-image: url('/assets/icons/icon-sturn_on.png');
}
.toolLabel {
font-family: 'Noto Sans KR', var(--font-ui);
font-weight: 700;
font-size: 13.33px; // 살짝 축소 (기존 14.67)
line-height: 1.2;
letter-spacing: -0.04em;
white-space: nowrap;
color: rgb(204, 199, 189);
text-shadow: 0 0.5px 1px rgb(0, 0, 0);
}
.toolBtn:hover .toolLabel {
color: #ffa812;
}
/* 측점선 보기 토글 ON 상태(클릭): 호버와 동일한 아이콘·라벨 색에,
눌린 어두운 그라데이션 배경 + 주황 그라데이션 테두리. 별도 동작 없음.
(채움 레이어는 padding-box, 테두리 그라데이션은 border-box) */
.lineBtn.active {
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%) padding-box,
linear-gradient(
180deg,
#211601 5%,
#211601 10%,
#352509 15%,
#352509 85%,
#231700 90%,
#000 95%
) padding-box,
$hover-border border-box;
box-shadow: 0 0 0 2px #000;
}
.lineBtn.active .lineIcon {
background-image: url('/assets/icons/icon-sturn_on.png');
}
.lineBtn.active .toolLabel {
color: #ffa812;
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { parseMileageQuery } from '../../utils/mileage';
import styles from './PlaybackControls.module.scss';
interface PlaybackControlsProps {
playing: boolean;
onTogglePlay: () => void;
onStop: () => void;
onCapture: () => void;
onJumpToMileage: (mileage: number) => void;
/** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */
lineOn?: boolean;
onToggleLine?: () => void;
}
export function PlaybackControls({
playing,
onTogglePlay,
onStop,
onCapture,
onJumpToMileage,
lineOn: lineOnProp,
onToggleLine,
}: PlaybackControlsProps) {
const [query, setQuery] = useState('');
const [lineOnInternal, setLineOnInternal] = useState(false);
const lineOn = lineOnProp ?? lineOnInternal;
const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v));
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>): void => {
setQuery(e.target.value);
};
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key !== 'Enter') return;
const mileage = parseMileageQuery(query);
if (mileage !== null) {
onJumpToMileage(mileage);
setQuery('');
}
};
return (
<div className={styles.controlsRow}>
<div className={styles.transportGroup}>
<div className={styles.leftPanel} />
<button
type="button"
className={styles.transportBtn}
onClick={onTogglePlay}
aria-label={playing ? '일시정지' : '재생'}
>
{/* 호버 시 _on SVG 로 교체(:hover). 재생/일시정지는 상태로 아이콘 전환 */}
<span className={playing ? styles.pauseIcon : styles.playIcon} />
</button>
<button
type="button"
className={styles.stopBtn}
onClick={onStop}
aria-label="정지"
>
<span className={styles.stopIcon} />
</button>
<input
className={styles.mileageInput}
value={query}
onChange={handleQueryChange}
onKeyDown={handleQueryKeyDown}
placeholder="측점입력"
/>
</div>
<div className={styles.toolGroup}>
<button
type="button"
className={`${styles.toolBtn} ${styles.captureBtn}`}
onClick={onCapture}
aria-label="화면캡처"
>
<span className={styles.cameraIcon} />
<span className={styles.toolLabel}></span>
</button>
<button
type="button"
className={`${styles.toolBtn} ${styles.lineBtn}${lineOn ? ` ${styles.active}` : ''}`}
onClick={toggleLine}
aria-pressed={lineOn}
aria-label="측점선 보기"
>
<span className={styles.lineIcon} />
<span className={styles.toolLabel}>
{lineOn ? '측점선 끄기' : '측점선 보기'}
</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
.segment {
position: absolute;
left: var(--seg-left);
top: var(--seg-top);
width: var(--seg-width);
height: var(--seg-height);
display: flex;
align-items: center;
z-index: 6;
}
.capLeft,
.capRight {
flex: 0 0 var(--seg-cap-width);
width: var(--seg-cap-width);
height: var(--seg-height);
background-size: 100% 100%;
background-repeat: no-repeat;
}
.center {
flex: 1;
margin: 0 -3px;
height: var(--seg-center-height);
background-repeat: no-repeat;
background-size: 100% 100%;
}
.bridge {
&.upcoming {
.capLeft {
background-image: url('/assets/route-segment/bridge/bridge-upcoming-left.png');
}
.center {
background-image: url('/assets/route-segment/bridge/bridge-upcoming-center.png');
}
.capRight {
background-image: url('/assets/route-segment/bridge/bridge-upcoming-right.png');
}
}
&.passed {
.capLeft {
background-image: url('/assets/route-segment/bridge/bridge-passed-left.png');
}
.center {
background-image: url('/assets/route-segment/bridge/bridge-passed-center.png');
}
.capRight {
background-image: url('/assets/route-segment/bridge/bridge-passed-right.png');
}
}
&.revisit {
.capLeft {
background-image: url('/assets/route-segment/bridge/bridge-revisit-left.png');
}
.center {
background-image: url('/assets/route-segment/bridge/bridge-revisit-center.png');
}
.capRight {
background-image: url('/assets/route-segment/bridge/bridge-revisit-right.png');
}
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
}
}
.tunnel {
&.upcoming {
.capLeft {
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-left.png');
}
.center {
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-center.png');
}
.capRight {
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-right.png');
}
}
&.passed {
.capLeft {
background-image: url('/assets/route-segment/tunnel/tunnel-passed-left.png');
}
.center {
background-image: url('/assets/route-segment/tunnel/tunnel-passed-center.png');
}
.capRight {
background-image: url('/assets/route-segment/tunnel/tunnel-passed-right.png');
}
}
&.revisit {
.capLeft {
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-left.png');
}
.center {
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-center.png');
}
.capRight {
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-right.png');
}
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
}
}

View File

@@ -0,0 +1,34 @@
import type { RouteProgressState, StructureSegment } from '../../types/timeline';
import { isStructureAssetState } from '../../constants/routeSegmentAssets';
import { cssVars, px } from '../../utils/cssVars';
import styles from './RouteSegment.module.scss';
interface RouteSegmentProps {
segment: StructureSegment;
state: RouteProgressState;
}
/** Three-slice structure marker: fixed caps, stretchable center. */
export function RouteSegment({ segment, state }: RouteSegmentProps) {
const assetState = isStructureAssetState(state) ? state : 'upcoming';
const classNames = [styles.segment, styles[segment.type], styles[assetState]];
return (
<div
className={classNames.join(' ')}
title={segment.label}
style={cssVars({
'--seg-left': px(segment.left),
'--seg-top': px(segment.top),
'--seg-width': px(segment.width),
'--seg-height': px(segment.height),
'--seg-cap-width': px(segment.capWidth),
'--seg-center-height': px(segment.centerHeight),
})}
>
<div className={styles.capLeft} />
<div className={styles.center} />
<div className={styles.capRight} />
</div>
);
}

View File

@@ -0,0 +1,8 @@
.marker {
position: absolute;
left: var(--marker-left);
top: var(--marker-top);
width: var(--marker-width);
height: var(--marker-height);
z-index: 7;
}

View File

@@ -0,0 +1,28 @@
import { terminalCircleAsset } from '../../constants/routeSegmentAssets';
import type { TerminalMarker as TerminalMarkerSpec } from '../../types/timeline';
import { resolveTerminalState } from '../../utils/routeProgress';
import { cssVars, px } from '../../utils/cssVars';
import styles from './TerminalMarker.module.scss';
interface TerminalMarkerProps {
marker: TerminalMarkerSpec;
posPx: number;
}
export function TerminalMarker({ marker, posPx }: TerminalMarkerProps) {
const state = resolveTerminalState(posPx, marker);
return (
<img
className={styles.marker}
src={terminalCircleAsset(state)}
alt=""
style={cssVars({
'--marker-left': px(marker.left),
'--marker-top': px(marker.top),
'--marker-width': px(marker.width),
'--marker-height': px(marker.height),
})}
/>
);
}

View File

@@ -0,0 +1,304 @@
/* 트랙 본체 가로 압축 래퍼. transform(translateX·scaleX)은 route.ts 단일 소스에서
파생되어 Timeline.tsx 에서 인라인으로 적용된다(여기에 하드코딩하지 않음). */
.trackWrapper {
position: absolute;
left: 0;
top: 0;
width: 1920px;
height: 100px;
transform-origin: 0 0;
pointer-events: none;
}
.trackFade {
position: absolute;
left: 0;
top: -23px;
width: 1920px;
height: 23px;
background: linear-gradient(
180deg,
rgba(102, 102, 102, 0) 0%,
rgba(41, 41, 41, 0.25) 50%,
rgba(0, 0, 0, 0.5) 100%
);
pointer-events: none;
}
.trackFrame {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
border-radius: 4px;
background: linear-gradient(180deg, rgb(11, 8, 3) 0%, rgb(40, 26, 7) 100%);
box-shadow: -1px 1px 1px 0 rgb(71, 61, 39);
}
.trackFrameStripesH {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
border-radius: 4px;
background: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.07) 0,
rgba(255, 255, 255, 0.07) 1px,
transparent 1px,
transparent 33.33%
);
pointer-events: none;
}
.trackFrameStripesV {
position: absolute;
left: var(--track-x);
top: 31.5px;
width: var(--track-w);
height: 45.5px;
background: repeating-linear-gradient(
90deg,
rgba(61, 52, 34, 0.55) 0,
rgba(61, 52, 34, 0.55) 2px,
transparent 2px,
transparent 36.65px
);
pointer-events: none;
}
.trackBase {
position: absolute;
left: var(--track-x);
top: 34.5px;
width: var(--track-w);
height: 12px;
border-radius: 6px;
background: rgb(122, 122, 122);
z-index: 1;
}
/* 역명 레이어: 스테이지 전체를 덮어 자식들의 위치 기준이 된다.
--track-start / --track-end (트랙 시작·끝, 단일 소스)을 상속받아
아래 역 라벨들이 트랙 가장자리 기준 calc()로 배치된다(시작/끝 바꾸면 자동 이동). */
.stationLayer {
position: absolute;
inset: 0;
pointer-events: none;
}
/* 트랙 가장자리 기준 오프셋(좌우 대칭): 텍스트 안쪽끝 40px · 점 32/27px · 점선 23/5px */
.stationStart {
position: absolute;
/* 오른쪽(안쪽) 끝을 '트랙 시작-40px'에 고정 → 대전과 트랙 중앙 기준 좌우 대칭.
글자 폭과 무관하게 점선·텍스트 공백이 양쪽 동일(점까지 8px, 트랙까지 50px). */
right: calc(100% - var(--track-start) + 40px);
top: 31px;
font-size: 16px;
font-weight: 700;
color: var(--color-station);
white-space: nowrap;
}
.stationEnd {
position: absolute;
left: calc(var(--track-end) + 40px);
top: 31px;
font-size: 16px;
font-weight: 700;
color: var(--color-station);
white-space: nowrap;
}
.dotStart,
.dotEnd {
position: absolute;
top: 39px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--color-station-dot);
}
.dotStart {
left: calc(var(--track-start) - 32px);
}
.dotEnd {
left: calc(var(--track-end) + 27px);
}
.leaderStart,
.leaderEnd {
position: absolute;
top: 40.5px;
height: 2.5px;
width: 18px;
background: repeating-linear-gradient(
90deg,
var(--color-station-leader) 0 5px,
transparent 5px 9px
);
}
.leaderStart {
left: calc(var(--track-start) - 23px);
}
.leaderEnd {
left: calc(var(--track-end) + 5px);
}
.track {
position: absolute;
left: var(--track-x);
top: 34.5px;
width: var(--track-w);
height: 12px;
z-index: 2;
}
.leg {
position: absolute;
top: 0;
height: 12px;
left: var(--leg-left);
width: var(--leg-width);
&.down {
background: var(--color-track-down);
}
&.up {
background: var(--color-track-up);
}
}
.legPassed {
position: absolute;
left: 0;
top: 0;
height: 12px;
width: var(--passed-width);
.down & {
background: var(--gradient-track-down-passed);
}
.up & {
background: var(--gradient-track-up-passed);
}
}
.turnDivider {
position: absolute;
left: var(--x);
top: 0;
width: 0;
height: 12px;
border-left: 1px dashed rgba(0, 0, 0, 0.7);
border-right: 1px dashed rgba(255, 255, 255, 0.3);
z-index: 3;
pointer-events: none;
}
.mileageRow {
position: absolute;
left: 4px;
top: 6px;
width: 1912px;
height: 13px;
white-space: nowrap;
z-index: 3;
}
.labelRow {
position: absolute;
left: 0;
top: 51px;
width: 1920px;
height: 26px;
font-size: 16.5px;
font-weight: 800;
line-height: 24px;
}
.zone {
position: absolute;
left: var(--x);
top: -6px;
width: var(--zone-width);
height: 15.5px;
border: 1.5px dashed rgb(218, 203, 185);
border-radius: 1px;
box-sizing: border-box;
pointer-events: none;
}
.zoneInner {
position: absolute;
left: var(--x);
top: -6px;
width: 0;
height: 15.5px;
border-left: 1.5px dashed rgb(218, 203, 185);
pointer-events: none;
}
.segmentLabel {
position: absolute;
left: var(--x);
top: 2px;
transform: translateX(-50%);
white-space: nowrap;
&.box {
transform: none;
width: var(--label-width);
text-align: center;
}
/* 텍스트 오른쪽 끝이 left 좌표에 닿음 (시작 라인 왼쪽 배치) */
&.anchorEnd {
transform: translateX(-100%);
}
/* 텍스트 왼쪽 끝이 left 좌표에서 시작 (종료 라인 오른쪽 배치) */
&.anchorStart {
transform: none;
}
&.endpoint {
color: #fff;
-webkit-text-stroke-width: 4px;
-webkit-text-stroke-color: #4f2000;
paint-order: stroke fill;
}
&.accent {
color: #ffd4b7;
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: #4f2000;
paint-order: stroke fill;
}
&.neutral {
color: #e4e4e4;
-webkit-text-stroke-width: 3px;
-webkit-text-stroke-color: #0d0d0d;
paint-order: stroke fill;
}
}
.seekArea {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
cursor: pointer;
z-index: 20;
pointer-events: auto;
}

View File

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

View File

@@ -0,0 +1,72 @@
.cursor {
position: absolute;
left: var(--cursor-x);
top: 0;
width: 0;
height: 0;
}
.line {
position: absolute;
left: -1px;
top: 991px;
width: 2px;
height: 37px;
background: var(--color-cursor);
z-index: 40;
/* 라인을 직접 잡아 드래그 가능하게 */
pointer-events: auto;
cursor: ew-resize;
}
.badge {
position: absolute;
left: 0;
top: 966px;
transform: translateX(-50%);
z-index: 41;
/* 뱃지를 직접 잡아 드래그 가능하게 (라인과 동일) */
pointer-events: auto;
cursor: ew-resize;
user-select: none;
background: linear-gradient(180deg, #ff6a35 0%, #f8430d 45%, #ef3c08 100%);
border-radius: 8px;
height: 40px;
min-width: 130px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22.5px;
font-weight: 700;
letter-spacing: 0.5px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.35),
0 2px 6px rgba(0, 0, 0, 0.45);
&::after {
content: '';
position: absolute;
left: 50%;
bottom: -8px;
transform: translateX(-50%);
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 9px solid #ef3c08;
}
}
/* 역방향(km 감소, 하늘색 구간) 통과 시 커서도 동일한 파란색으로 표시 */
.cursor.reverse {
.line {
background: #06a4c8;
}
.badge {
background: linear-gradient(180deg, #3fb6d4 0%, #0a9bc0 45%, #067f9e 100%);
&::after {
border-top-color: #067f9e;
}
}
}

View File

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