GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+331
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user