defVideo 작업분 반영
This commit is contained in:
21
client/src/stationbar/components/MileageMarker/MileageMarker.module.scss
Executable file
21
client/src/stationbar/components/MileageMarker/MileageMarker.module.scss
Executable file
@@ -0,0 +1,21 @@
|
||||
.marker {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 72px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.2px;
|
||||
-webkit-text-stroke-width: 2px;
|
||||
-webkit-text-stroke-color: #4F2000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.selected {
|
||||
text-shadow: 0 0 6px rgba(255, 148, 71, 0.55);
|
||||
}
|
||||
|
||||
22
client/src/stationbar/components/MileageMarker/MileageMarker.tsx
Executable file
22
client/src/stationbar/components/MileageMarker/MileageMarker.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import type { MileageMarkerSpec } from '../../types/timeline';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import styles from './MileageMarker.module.scss';
|
||||
|
||||
interface MileageMarkerProps {
|
||||
marker: MileageMarkerSpec;
|
||||
}
|
||||
|
||||
export function MileageMarker({ marker }: MileageMarkerProps) {
|
||||
const classNames = [styles.marker];
|
||||
if (marker.selected) classNames.push(styles.selected);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(' ')}
|
||||
style={cssVars({ '--x': px(marker.left) })}
|
||||
title={marker.value}
|
||||
>
|
||||
{marker.value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
307
client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss
Executable file
307
client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss
Executable file
@@ -0,0 +1,307 @@
|
||||
$transport-gradient: linear-gradient(
|
||||
180deg,
|
||||
rgb(96, 79, 50) 0%,
|
||||
rgb(85, 75, 49) 7%,
|
||||
rgb(64, 51, 39) 11%,
|
||||
rgb(40, 34, 22) 100%
|
||||
);
|
||||
|
||||
$tool-gradient: linear-gradient(
|
||||
180deg,
|
||||
rgb(96, 79, 50) 0%,
|
||||
rgb(59, 52, 34) 7%,
|
||||
rgb(54, 43, 31) 11%,
|
||||
rgb(66, 55, 35) 100%
|
||||
);
|
||||
|
||||
$group-border: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 182, 135, 0.5) 0%,
|
||||
rgba(255, 255, 255, 0) 100%,
|
||||
);
|
||||
|
||||
$default-border: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.25) 25%,
|
||||
rgba(153, 122, 101, 0.63) 63%,
|
||||
rgba(98, 62, 39, 0.81) 81%,
|
||||
rgba(80, 67, 60, 1) 100%
|
||||
);
|
||||
|
||||
$hover-border: linear-gradient(
|
||||
180deg,
|
||||
#ffa812 8%,
|
||||
#ffdfa7 38%,
|
||||
#ffa812 72%,
|
||||
#a96e09 95%
|
||||
);
|
||||
|
||||
// ── 컨트롤 패널 치수 (이 값들만 바꾸면 관련 위치/크기가 자동 계산됨) ──
|
||||
$panel-h: 96.67px; // transportGroup 높이
|
||||
$panel-inset: 2px; // 그룹 가장자리 공통 인셋
|
||||
|
||||
// 재생·정지·측점입력 그룹
|
||||
$transport-group-w: 161.33px;
|
||||
$transport-btn-w: 78px;
|
||||
$transport-btn-h: 55px;
|
||||
$transport-btn-left: 3px; // 첫(재생) 버튼 좌측 인셋
|
||||
$transport-btn-top: 4px;
|
||||
$transport-btn-gap: 1px; // 재생 ↔ 정지 간격
|
||||
|
||||
// 화면캡처·측점선 그룹
|
||||
$tool-group-w: 76px;
|
||||
$tool-group-h: 96px;
|
||||
$tool-btn-h: 47px;
|
||||
$tool-btn-top: 0.5px; // 화면캡처(첫 버튼) top — 47×2가 96 안에 들어가도록 축소
|
||||
$tool-btn-gap: 1px; // 화면캡처 ↔ 측점선 간격 (47px 버튼이 바 안에 들어가도록)
|
||||
|
||||
/* 컨트롤 묶음 — bottomBar flex 자식 (좌측 정렬, 세로 중앙) */
|
||||
.controlsRow {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding-left: 1.33px;
|
||||
align-items: center;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
%control-button {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: 4px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
background: $transport-gradient padding-box, $default-border border-box;
|
||||
box-shadow: 0 0 0 2px rgb(0, 0, 0);
|
||||
transition: box-shadow 150ms ease-out;
|
||||
|
||||
&:hover {
|
||||
background: $transport-gradient padding-box, $hover-border border-box;
|
||||
box-shadow:
|
||||
0 0 0 2px #000,
|
||||
0 0 8px rgba(255, 168, 18, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
/* 플레이·정지·측점 입력 패널 */
|
||||
.transportGroup {
|
||||
position: relative;
|
||||
width: $transport-group-w;
|
||||
height: $panel-h;
|
||||
}
|
||||
|
||||
.leftPanel {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
width: calc(100% + 4px);
|
||||
height: 100%;
|
||||
/* 그라데이션 테두리 두께 = 이 border 폭 (숫자를 키우면 더 굵어짐) */
|
||||
border: 2px solid transparent;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
background:
|
||||
linear-gradient(rgb(38, 29, 13), transparent) padding-box,
|
||||
$group-border border-box;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.transportBtn {
|
||||
@extend %control-button;
|
||||
left: $transport-btn-left;
|
||||
top: $transport-btn-top;
|
||||
width: $transport-btn-w;
|
||||
height: $transport-btn-h;
|
||||
}
|
||||
|
||||
.stopBtn {
|
||||
@extend %control-button;
|
||||
// 재생 버튼 우측 + 간격 → 폭이 바뀌면 자동으로 따라옴
|
||||
left: $transport-btn-left + $transport-btn-w + $transport-btn-gap + 1px;
|
||||
top: $transport-btn-top;
|
||||
width: $transport-btn-w;
|
||||
height: $transport-btn-h;
|
||||
}
|
||||
|
||||
/* 버튼 아이콘: 실제 SVG 파일(그라데이션·그림자·테두리 포함)을 배경으로 사용.
|
||||
호버 시 버튼의 :hover 로 _on 버전으로 교체한다. 박스 크기는 SVG viewBox의 2/3
|
||||
(= 기존 렌더 크기 유지). 새 아이콘은 SVG만 교체하면 되어 유지보수가 쉽다. */
|
||||
%btn-icon {
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playIcon {
|
||||
@extend %btn-icon;
|
||||
width: 40px; // 60 × 2/3
|
||||
height: 40px;
|
||||
background-image: url('/assets/icons/ico_play.png');
|
||||
}
|
||||
|
||||
.transportBtn:hover .playIcon {
|
||||
background-image: url('/assets/icons/ico_play_on.png');
|
||||
}
|
||||
|
||||
.pauseIcon {
|
||||
@extend %btn-icon;
|
||||
width: 33.33px; // 50 × 2/3
|
||||
height: 33.33px;
|
||||
background-image: url('/assets/icons/icon_pause.png');
|
||||
}
|
||||
|
||||
.transportBtn:hover .pauseIcon {
|
||||
background-image: url('/assets/icons/icon_pause_on.png');
|
||||
}
|
||||
|
||||
.stopIcon {
|
||||
@extend %btn-icon;
|
||||
width: 40px; // 60 × 2/3
|
||||
height: 40px;
|
||||
background-image: url('/assets/icons/icon_stop.png');
|
||||
}
|
||||
|
||||
.stopBtn:hover .stopIcon {
|
||||
background-image: url('/assets/icons/icon_stop_on.png');
|
||||
}
|
||||
|
||||
.mileageInput {
|
||||
position: absolute;
|
||||
left: $transport-btn-left;
|
||||
top: 62px;
|
||||
z-index: 4;
|
||||
width: $transport-group-w - $transport-btn-left - $panel-inset;
|
||||
height: 32.67px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
box-shadow:
|
||||
0 0 0 2px rgb(0, 0, 0),
|
||||
0 0.5px 0 0 rgba(255, 255, 255, 0.1);
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
font-family: 'Noto Sans KR', var(--font-ui);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 화면캡처·측점선 보기 패널 */
|
||||
.toolGroup {
|
||||
position: relative;
|
||||
width: $tool-group-w;
|
||||
height: $tool-group-h;
|
||||
margin-left: 1px; // transportGroup 패널 확장분만큼 1px 우측 이동
|
||||
border-radius: 4px;
|
||||
background: #1a1309;
|
||||
}
|
||||
|
||||
.toolBtn {
|
||||
@extend %control-button;
|
||||
left: $panel-inset;
|
||||
width: $tool-group-w - 2 * $panel-inset; // 좌우 인셋 제외 → 그룹 폭 바뀌면 자동
|
||||
height: $tool-btn-h; // 바 영역을 벗어나지 않도록 축소 + 두 버튼 사이 간격 확보
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
background: $tool-gradient padding-box, $default-border border-box;
|
||||
|
||||
&:hover {
|
||||
background: $tool-gradient padding-box, $hover-border border-box;
|
||||
box-shadow:
|
||||
0 0 0 2px #000,
|
||||
0 0 8px rgba(255, 168, 18, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.captureBtn {
|
||||
top: $tool-btn-top + $tool-btn-top;
|
||||
}
|
||||
|
||||
/* 화면캡처 아래 = 첫 버튼 top + 높이 + 간격 → 높이/간격 바꾸면 자동으로 따라옴 */
|
||||
.lineBtn {
|
||||
top: $tool-btn-top + $tool-btn-h + $tool-btn-gap + $tool-btn-top + 1px;
|
||||
}
|
||||
|
||||
.cameraIcon {
|
||||
@extend %btn-icon;
|
||||
width: 24px; // 확대
|
||||
height: 20px;
|
||||
background-image: url('/assets/icons/icon-camera-body.png');
|
||||
}
|
||||
|
||||
.captureBtn:hover .cameraIcon {
|
||||
background-image: url('/assets/icons/icon-camera-body_on.png');
|
||||
}
|
||||
|
||||
.lineIcon {
|
||||
@extend %btn-icon;
|
||||
width: 19.2px; // 확대
|
||||
height: 20px;
|
||||
background-image: url('/assets/icons/icon-sturn.png');
|
||||
}
|
||||
|
||||
.lineBtn:hover .lineIcon {
|
||||
background-image: url('/assets/icons/icon-sturn_on.png');
|
||||
}
|
||||
|
||||
.toolLabel {
|
||||
font-family: 'Noto Sans KR', var(--font-ui);
|
||||
font-weight: 700;
|
||||
font-size: 13.33px; // 살짝 축소 (기존 14.67)
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.04em;
|
||||
white-space: nowrap;
|
||||
color: rgb(204, 199, 189);
|
||||
text-shadow: 0 0.5px 1px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.toolBtn:hover .toolLabel {
|
||||
color: #ffa812;
|
||||
}
|
||||
|
||||
/* 측점선 보기 토글 ON 상태(클릭): 호버와 동일한 아이콘·라벨 색에,
|
||||
눌린 어두운 그라데이션 배경 + 주황 그라데이션 테두리. 별도 동작 없음.
|
||||
(채움 레이어는 padding-box, 테두리 그라데이션은 border-box) */
|
||||
.lineBtn.active {
|
||||
background:
|
||||
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%) padding-box,
|
||||
linear-gradient(
|
||||
180deg,
|
||||
#211601 5%,
|
||||
#211601 10%,
|
||||
#352509 15%,
|
||||
#352509 85%,
|
||||
#231700 90%,
|
||||
#000 95%
|
||||
) padding-box,
|
||||
$hover-border border-box;
|
||||
box-shadow: 0 0 0 2px #000;
|
||||
}
|
||||
|
||||
.lineBtn.active .lineIcon {
|
||||
background-image: url('/assets/icons/icon-sturn_on.png');
|
||||
}
|
||||
|
||||
.lineBtn.active .toolLabel {
|
||||
color: #ffa812;
|
||||
}
|
||||
98
client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx
Executable file
98
client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx
Executable file
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { parseMileageQuery } from '../../utils/mileage';
|
||||
import styles from './PlaybackControls.module.scss';
|
||||
|
||||
interface PlaybackControlsProps {
|
||||
playing: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onStop: () => void;
|
||||
onCapture: () => void;
|
||||
onJumpToMileage: (mileage: number) => void;
|
||||
/** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */
|
||||
lineOn?: boolean;
|
||||
onToggleLine?: () => void;
|
||||
}
|
||||
|
||||
export function PlaybackControls({
|
||||
playing,
|
||||
onTogglePlay,
|
||||
onStop,
|
||||
onCapture,
|
||||
onJumpToMileage,
|
||||
lineOn: lineOnProp,
|
||||
onToggleLine,
|
||||
}: PlaybackControlsProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [lineOnInternal, setLineOnInternal] = useState(false);
|
||||
const lineOn = lineOnProp ?? lineOnInternal;
|
||||
const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v));
|
||||
|
||||
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setQuery(e.target.value);
|
||||
};
|
||||
|
||||
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const mileage = parseMileageQuery(query);
|
||||
if (mileage !== null) {
|
||||
onJumpToMileage(mileage);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.controlsRow}>
|
||||
<div className={styles.transportGroup}>
|
||||
<div className={styles.leftPanel} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={onTogglePlay}
|
||||
aria-label={playing ? '일시정지' : '재생'}
|
||||
>
|
||||
{/* 호버 시 _on SVG 로 교체(:hover). 재생/일시정지는 상태로 아이콘 전환 */}
|
||||
<span className={playing ? styles.pauseIcon : styles.playIcon} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stopBtn}
|
||||
onClick={onStop}
|
||||
aria-label="정지"
|
||||
>
|
||||
<span className={styles.stopIcon} />
|
||||
</button>
|
||||
<input
|
||||
className={styles.mileageInput}
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onKeyDown={handleQueryKeyDown}
|
||||
placeholder="측점입력"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.toolGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toolBtn} ${styles.captureBtn}`}
|
||||
onClick={onCapture}
|
||||
aria-label="화면캡처"
|
||||
>
|
||||
<span className={styles.cameraIcon} />
|
||||
<span className={styles.toolLabel}>화면캡처</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toolBtn} ${styles.lineBtn}${lineOn ? ` ${styles.active}` : ''}`}
|
||||
onClick={toggleLine}
|
||||
aria-pressed={lineOn}
|
||||
aria-label="측점선 보기"
|
||||
>
|
||||
<span className={styles.lineIcon} />
|
||||
<span className={styles.toolLabel}>
|
||||
{lineOn ? '측점선 끄기' : '측점선 보기'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
client/src/stationbar/components/RouteSegment/RouteSegment.module.scss
Executable file
107
client/src/stationbar/components/RouteSegment/RouteSegment.module.scss
Executable file
@@ -0,0 +1,107 @@
|
||||
.segment {
|
||||
position: absolute;
|
||||
left: var(--seg-left);
|
||||
top: var(--seg-top);
|
||||
width: var(--seg-width);
|
||||
height: var(--seg-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.capLeft,
|
||||
.capRight {
|
||||
flex: 0 0 var(--seg-cap-width);
|
||||
width: var(--seg-cap-width);
|
||||
height: var(--seg-height);
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.center {
|
||||
flex: 1;
|
||||
margin: 0 -3px;
|
||||
height: var(--seg-center-height);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.bridge {
|
||||
&.upcoming {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-upcoming-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-upcoming-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-upcoming-right.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.passed {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-passed-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-passed-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-passed-right.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.revisit {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-revisit-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-revisit-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/bridge/bridge-revisit-right.png');
|
||||
}
|
||||
|
||||
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
.tunnel {
|
||||
&.upcoming {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-right.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.passed {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-passed-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-passed-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-passed-right.png');
|
||||
}
|
||||
}
|
||||
|
||||
&.revisit {
|
||||
.capLeft {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-left.png');
|
||||
}
|
||||
.center {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-center.png');
|
||||
}
|
||||
.capRight {
|
||||
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-right.png');
|
||||
}
|
||||
|
||||
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
|
||||
}
|
||||
}
|
||||
34
client/src/stationbar/components/RouteSegment/RouteSegment.tsx
Executable file
34
client/src/stationbar/components/RouteSegment/RouteSegment.tsx
Executable file
@@ -0,0 +1,34 @@
|
||||
import type { RouteProgressState, StructureSegment } from '../../types/timeline';
|
||||
import { isStructureAssetState } from '../../constants/routeSegmentAssets';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import styles from './RouteSegment.module.scss';
|
||||
|
||||
interface RouteSegmentProps {
|
||||
segment: StructureSegment;
|
||||
state: RouteProgressState;
|
||||
}
|
||||
|
||||
/** Three-slice structure marker: fixed caps, stretchable center. */
|
||||
export function RouteSegment({ segment, state }: RouteSegmentProps) {
|
||||
const assetState = isStructureAssetState(state) ? state : 'upcoming';
|
||||
const classNames = [styles.segment, styles[segment.type], styles[assetState]];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(' ')}
|
||||
title={segment.label}
|
||||
style={cssVars({
|
||||
'--seg-left': px(segment.left),
|
||||
'--seg-top': px(segment.top),
|
||||
'--seg-width': px(segment.width),
|
||||
'--seg-height': px(segment.height),
|
||||
'--seg-cap-width': px(segment.capWidth),
|
||||
'--seg-center-height': px(segment.centerHeight),
|
||||
})}
|
||||
>
|
||||
<div className={styles.capLeft} />
|
||||
<div className={styles.center} />
|
||||
<div className={styles.capRight} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.marker {
|
||||
position: absolute;
|
||||
left: var(--marker-left);
|
||||
top: var(--marker-top);
|
||||
width: var(--marker-width);
|
||||
height: var(--marker-height);
|
||||
z-index: 7;
|
||||
}
|
||||
28
client/src/stationbar/components/TerminalMarker/TerminalMarker.tsx
Executable file
28
client/src/stationbar/components/TerminalMarker/TerminalMarker.tsx
Executable file
@@ -0,0 +1,28 @@
|
||||
import { terminalCircleAsset } from '../../constants/routeSegmentAssets';
|
||||
import type { TerminalMarker as TerminalMarkerSpec } from '../../types/timeline';
|
||||
import { resolveTerminalState } from '../../utils/routeProgress';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import styles from './TerminalMarker.module.scss';
|
||||
|
||||
interface TerminalMarkerProps {
|
||||
marker: TerminalMarkerSpec;
|
||||
posPx: number;
|
||||
}
|
||||
|
||||
export function TerminalMarker({ marker, posPx }: TerminalMarkerProps) {
|
||||
const state = resolveTerminalState(posPx, marker);
|
||||
|
||||
return (
|
||||
<img
|
||||
className={styles.marker}
|
||||
src={terminalCircleAsset(state)}
|
||||
alt=""
|
||||
style={cssVars({
|
||||
'--marker-left': px(marker.left),
|
||||
'--marker-top': px(marker.top),
|
||||
'--marker-width': px(marker.width),
|
||||
'--marker-height': px(marker.height),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
304
client/src/stationbar/components/Timeline/Timeline.module.scss
Executable file
304
client/src/stationbar/components/Timeline/Timeline.module.scss
Executable file
@@ -0,0 +1,304 @@
|
||||
/* 트랙 본체 가로 압축 래퍼. transform(translateX·scaleX)은 route.ts 단일 소스에서
|
||||
파생되어 Timeline.tsx 에서 인라인으로 적용된다(여기에 하드코딩하지 않음). */
|
||||
.trackWrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1920px;
|
||||
height: 100px;
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trackFade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -23px;
|
||||
width: 1920px;
|
||||
height: 23px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(102, 102, 102, 0) 0%,
|
||||
rgba(41, 41, 41, 0.25) 50%,
|
||||
rgba(0, 0, 0, 0.5) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trackFrame {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 27.5px;
|
||||
width: var(--track-w);
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, rgb(11, 8, 3) 0%, rgb(40, 26, 7) 100%);
|
||||
box-shadow: -1px 1px 1px 0 rgb(71, 61, 39);
|
||||
}
|
||||
|
||||
.trackFrameStripesH {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 27.5px;
|
||||
width: var(--track-w);
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.07) 0,
|
||||
rgba(255, 255, 255, 0.07) 1px,
|
||||
transparent 1px,
|
||||
transparent 33.33%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trackFrameStripesV {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 31.5px;
|
||||
width: var(--track-w);
|
||||
height: 45.5px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(61, 52, 34, 0.55) 0,
|
||||
rgba(61, 52, 34, 0.55) 2px,
|
||||
transparent 2px,
|
||||
transparent 36.65px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trackBase {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 34.5px;
|
||||
width: var(--track-w);
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgb(122, 122, 122);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 역명 레이어: 스테이지 전체를 덮어 자식들의 위치 기준이 된다.
|
||||
--track-start / --track-end (트랙 시작·끝, 단일 소스)을 상속받아
|
||||
아래 역 라벨들이 트랙 가장자리 기준 calc()로 배치된다(시작/끝 바꾸면 자동 이동). */
|
||||
.stationLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 트랙 가장자리 기준 오프셋(좌우 대칭): 텍스트 안쪽끝 40px · 점 32/27px · 점선 23/5px */
|
||||
.stationStart {
|
||||
position: absolute;
|
||||
/* 오른쪽(안쪽) 끝을 '트랙 시작-40px'에 고정 → 대전과 트랙 중앙 기준 좌우 대칭.
|
||||
글자 폭과 무관하게 점선·텍스트 공백이 양쪽 동일(점까지 8px, 트랙까지 50px). */
|
||||
right: calc(100% - var(--track-start) + 40px);
|
||||
top: 31px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-station);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stationEnd {
|
||||
position: absolute;
|
||||
left: calc(var(--track-end) + 40px);
|
||||
top: 31px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-station);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dotStart,
|
||||
.dotEnd {
|
||||
position: absolute;
|
||||
top: 39px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-station-dot);
|
||||
}
|
||||
|
||||
.dotStart {
|
||||
left: calc(var(--track-start) - 32px);
|
||||
}
|
||||
|
||||
.dotEnd {
|
||||
left: calc(var(--track-end) + 27px);
|
||||
}
|
||||
|
||||
.leaderStart,
|
||||
.leaderEnd {
|
||||
position: absolute;
|
||||
top: 40.5px;
|
||||
height: 2.5px;
|
||||
width: 18px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--color-station-leader) 0 5px,
|
||||
transparent 5px 9px
|
||||
);
|
||||
}
|
||||
|
||||
.leaderStart {
|
||||
left: calc(var(--track-start) - 23px);
|
||||
}
|
||||
|
||||
.leaderEnd {
|
||||
left: calc(var(--track-end) + 5px);
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 34.5px;
|
||||
width: var(--track-w);
|
||||
height: 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.leg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 12px;
|
||||
left: var(--leg-left);
|
||||
width: var(--leg-width);
|
||||
|
||||
&.down {
|
||||
background: var(--color-track-down);
|
||||
}
|
||||
|
||||
&.up {
|
||||
background: var(--color-track-up);
|
||||
}
|
||||
}
|
||||
|
||||
.legPassed {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 12px;
|
||||
width: var(--passed-width);
|
||||
|
||||
.down & {
|
||||
background: var(--gradient-track-down-passed);
|
||||
}
|
||||
|
||||
.up & {
|
||||
background: var(--gradient-track-up-passed);
|
||||
}
|
||||
}
|
||||
|
||||
.turnDivider {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 12px;
|
||||
border-left: 1px dashed rgba(0, 0, 0, 0.7);
|
||||
border-right: 1px dashed rgba(255, 255, 255, 0.3);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mileageRow {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 6px;
|
||||
width: 1912px;
|
||||
height: 13px;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 51px;
|
||||
width: 1920px;
|
||||
height: 26px;
|
||||
font-size: 16.5px;
|
||||
font-weight: 800;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.zone {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: -6px;
|
||||
width: var(--zone-width);
|
||||
height: 15.5px;
|
||||
border: 1.5px dashed rgb(218, 203, 185);
|
||||
border-radius: 1px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zoneInner {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: -6px;
|
||||
width: 0;
|
||||
height: 15.5px;
|
||||
border-left: 1.5px dashed rgb(218, 203, 185);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.segmentLabel {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: 2px;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
|
||||
&.box {
|
||||
transform: none;
|
||||
width: var(--label-width);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 텍스트 오른쪽 끝이 left 좌표에 닿음 (시작 라인 왼쪽 배치) */
|
||||
&.anchorEnd {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
/* 텍스트 왼쪽 끝이 left 좌표에서 시작 (종료 라인 오른쪽 배치) */
|
||||
&.anchorStart {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&.endpoint {
|
||||
color: #fff;
|
||||
-webkit-text-stroke-width: 4px;
|
||||
-webkit-text-stroke-color: #4f2000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
&.accent {
|
||||
color: #ffd4b7;
|
||||
-webkit-text-stroke-width: 2px;
|
||||
-webkit-text-stroke-color: #4f2000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
color: #e4e4e4;
|
||||
-webkit-text-stroke-width: 3px;
|
||||
-webkit-text-stroke-color: #0d0d0d;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
}
|
||||
|
||||
.seekArea {
|
||||
position: absolute;
|
||||
left: var(--track-x);
|
||||
top: 27.5px;
|
||||
width: var(--track-w);
|
||||
height: 48px;
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
147
client/src/stationbar/components/Timeline/Timeline.tsx
Executable file
147
client/src/stationbar/components/Timeline/Timeline.tsx
Executable file
@@ -0,0 +1,147 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import {
|
||||
TRACK_RENDER_END_PX,
|
||||
TRACK_RENDER_START_PX,
|
||||
TRACK_START_PX,
|
||||
TRACK_WIDTH_PX,
|
||||
TRACK_WRAPPER_TRANSFORM,
|
||||
} from '../../mocks/route';
|
||||
import type { KmLabel, StructMark } from '../../StationBar';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import { MileageMarker } from '../MileageMarker/MileageMarker';
|
||||
import styles from './Timeline.module.scss';
|
||||
|
||||
interface TimelineProps {
|
||||
posPx: number;
|
||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
/** 방향 색 트랙 CSS gradient (전진=주황/후진=하늘색, 회전구간 부드러운 전환). */
|
||||
trackGradient: string;
|
||||
/** 데이터 기반 측점값 라벨 (방향 전환점·시종점). */
|
||||
kmLabels: KmLabel[];
|
||||
/** 데이터 기반 구조물 (교량/터널/역사). */
|
||||
structures: StructMark[];
|
||||
}
|
||||
|
||||
export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structures }: TimelineProps) {
|
||||
// 라벨 겹침 방지: x px 순으로 gap px 이내는 1개만 표시.
|
||||
const dedup = <T extends { px: number }>(items: T[], gap: number): T[] => {
|
||||
const sorted = [...items].sort((a, b) => a.px - b.px);
|
||||
const out: T[] = [];
|
||||
for (const it of sorted) {
|
||||
if (!out.length || it.px - out[out.length - 1].px >= gap) out.push(it);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const labels = dedup(kmLabels, 28);
|
||||
// 구조물명은 텍스트가 길어 더 넓은 간격으로 (겹침 방지).
|
||||
const structs = dedup(structures, 90);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
|
||||
<div className={styles.trackFade} />
|
||||
|
||||
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 신탄진·대전 배치 */}
|
||||
<div
|
||||
className={styles.stationLayer}
|
||||
style={cssVars({
|
||||
'--track-start': px(TRACK_RENDER_START_PX),
|
||||
'--track-end': px(TRACK_RENDER_END_PX),
|
||||
})}
|
||||
>
|
||||
<div className={styles.stationStart}>신탄진</div>
|
||||
<div className={styles.dotStart} />
|
||||
<div className={styles.leaderStart} />
|
||||
<div className={styles.leaderEnd} />
|
||||
<div className={styles.dotEnd} />
|
||||
<div className={styles.stationEnd}>대전</div>
|
||||
</div>
|
||||
|
||||
{/* 트랙 본체 (가로 압축 래퍼) */}
|
||||
<div
|
||||
className={styles.trackWrapper}
|
||||
style={{
|
||||
...cssVars({
|
||||
'--track-x': px(TRACK_START_PX),
|
||||
'--track-w': px(TRACK_WIDTH_PX),
|
||||
}),
|
||||
transform: TRACK_WRAPPER_TRANSFORM,
|
||||
}}
|
||||
>
|
||||
<div className={styles.trackFrame} />
|
||||
<div className={styles.trackFrameStripesH} />
|
||||
<div className={styles.trackFrameStripesV} />
|
||||
<div className={styles.trackBase} />
|
||||
|
||||
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색.
|
||||
전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
|
||||
<div className={styles.track}>
|
||||
{/* 미재생: 방향색 저톤 (전체 폭) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: px(TRACK_WIDTH_PX),
|
||||
background: trackGradient,
|
||||
opacity: 0.18,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{/* 재생된 구간: 원래 색 복원 (커서까지 clip) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: px(
|
||||
Math.max(0, Math.min(TRACK_WIDTH_PX, posPx - TRACK_START_PX)),
|
||||
),
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: px(TRACK_WIDTH_PX),
|
||||
background: trackGradient,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 측점값 라벨 (데이터) */}
|
||||
<div className={styles.mileageRow}>
|
||||
{labels.map((l, i) => (
|
||||
<MileageMarker
|
||||
key={i}
|
||||
marker={{ id: `km-${i}`, value: l.text, left: l.px, mileage: 0 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 구조물 라벨 (데이터: 교량/터널/역사) */}
|
||||
<div className={styles.labelRow}>
|
||||
{structs.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.segmentLabel} ${styles.neutral}`}
|
||||
style={cssVars({ '--x': px(s.px) })}
|
||||
title={`${s.category} · ${s.title}`}
|
||||
>
|
||||
{s.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.seekArea} onMouseDown={onSeekDown} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss
Executable file
72
client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss
Executable file
@@ -0,0 +1,72 @@
|
||||
.cursor {
|
||||
position: absolute;
|
||||
left: var(--cursor-x);
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 991px;
|
||||
width: 2px;
|
||||
height: 37px;
|
||||
background: var(--color-cursor);
|
||||
z-index: 40;
|
||||
/* 라인을 직접 잡아 드래그 가능하게 */
|
||||
pointer-events: auto;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 966px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 41;
|
||||
/* 뱃지를 직접 잡아 드래그 가능하게 (라인과 동일) */
|
||||
pointer-events: auto;
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
background: linear-gradient(180deg, #ff6a35 0%, #f8430d 45%, #ef3c08 100%);
|
||||
border-radius: 8px;
|
||||
height: 40px;
|
||||
min-width: 130px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 22.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
0 2px 6px rgba(0, 0, 0, 0.45);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -8px;
|
||||
transform: translateX(-50%);
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-top: 9px solid #ef3c08;
|
||||
}
|
||||
}
|
||||
|
||||
/* 역방향(km 감소, 하늘색 구간) 통과 시 커서도 동일한 파란색으로 표시 */
|
||||
.cursor.reverse {
|
||||
.line {
|
||||
background: #06a4c8;
|
||||
}
|
||||
.badge {
|
||||
background: linear-gradient(180deg, #3fb6d4 0%, #0a9bc0 45%, #067f9e 100%);
|
||||
|
||||
&::after {
|
||||
border-top-color: #067f9e;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx
Executable file
25
client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx
Executable file
@@ -0,0 +1,25 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { renderX } from '../../mocks/route';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import styles from './TimelineCursor.module.scss';
|
||||
|
||||
interface TimelineCursorProps {
|
||||
posPx: number;
|
||||
mileageText: string;
|
||||
/** 현재 위치가 역방향(km 감소) 구간이면 커서를 파란색으로. */
|
||||
reverse?: boolean;
|
||||
/** 라인·뱃지를 직접 드래그해 이동시킬 때 호출 (seekArea와 동일 핸들러). */
|
||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function TimelineCursor({ posPx, mileageText, reverse, onSeekDown }: TimelineCursorProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.cursor} ${reverse ? styles.reverse : ''}`}
|
||||
style={cssVars({ '--cursor-x': px(renderX(posPx)) })}
|
||||
>
|
||||
<div className={styles.line} onMouseDown={onSeekDown} />
|
||||
<div className={styles.badge} onMouseDown={onSeekDown}>{mileageText}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user