초기 커밋: 스테이션(측점) 기반 주행영상 플레이어

- 클라이언트(React/Vite): Video.js 플레이어, 하단 스테이션바, POI/구조물 영상 오버레이, 카메라 파라미터 보정, 미니맵/RoutePanel
- 서버(Express): Range 스트리밍, HLS 변환, 프레임 추출, tus 업로드, 주석 API
- 최근 작업: 스테이션바 종점역 표출/양끝 정렬/미도착(재생불가) 표시, POI 라벨 '구분' 우선·컴팩트 팝업·겹침제외 토글·동일좌표 다중행, 진행방향(상/하) 우선 표출, 커서 배지 크기·픽셀 떨림 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:37:57 +09:00
co-authored by Claude Opus 4.8
commit 78fc090360
214 changed files with 27897 additions and 0 deletions
+378
View File
@@ -0,0 +1,378 @@
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;
}
export function Timeline({
posPx,
onSeekDown,
trackGradient,
trackGradientIdle,
dividers,
kmLabels,
structures,
startStationName,
endStationName,
endGapPx = 0,
routeDir = null,
}: TimelineProps) {
// 하단 스테이션바는 겹쳐도 무방 → 역사(역, KAKAO_RAIL)는 dedup 대상에서 제외하고 항상 표출.
// (종점 '대전조차장'이 ~70px 앞 '법동가도교'에 밀려 사라지던 문제 방지.)
// 교량/터널만 라벨 텍스트가 길어 130px 겹침 방지 적용. 위치는 실제 통과 px 그대로.
// 평행 상/하행 구조물(예: 회덕터널 상/하)은 ~6px 차이라 한쪽만 남는데, 영상 방향(routeDir)에
// 해당하는 쪽(하행=‘(하)’, 상행=‘(상)’)을 우선 남긴다. → 드론이 실제 지나는 선로와 일치.
const isStation = (s: StructMark): boolean => s.category === '역사' || s.category === '역';
const dirTag = routeDir === '상' ? '(상)' : routeDir === '하' ? '(하)' : '';
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);
const keptOthers: StructMark[] = [];
for (const it of ordered) {
if (keptOthers.every((k) => Math.abs(k.px - it.px) >= 130)) keptOthers.push(it);
}
const structs = [...structures.filter(isStation), ...keptOthers].sort((a, b) => a.px - b.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,
opacity: 1,
borderRadius: 'inherit',
}}
/>
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체.
재생영역(회색 idle)보다 약간 밝은 단색 배경으로만 구분(빗금 없음). */}
{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). 폭은 CSS 변수(--pos-px)로 매 프레임
직접 갱신되어 React 리렌더 없이 부드럽게 늘어난다. */}
<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 ? (
<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>
{/* 구조물 클릭 핫스팟 — 클릭 시 속성 팝업(회덕화물역 등 역사 포함). seekArea(z20) 위로. */}
<div style={{ position: 'absolute', inset: 0, zIndex: 21, pointerEvents: 'none' }}>
{structs.map((s, i) =>
s.props && s.props.length ? (
<div
key={`hit-${i}`}
title={s.title}
onMouseDown={(e) => { e.stopPropagation(); setSel({ s, x: e.clientX, y: e.clientY }); }}
style={{ position: 'absolute', left: s.px - 12, top: 28, width: 24, height: 28, cursor: 'pointer', pointerEvents: 'auto' }}
/>
) : null,
)}
</div>
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄. */}
<div className={styles.labelRow}>
{structs.map((s, i) => {
const W = s.category === '터널' ? 24 : 22;
// 미도착 종점역은 항상 미통과(neutral) 색 유지.
const passed = s.unreached ? false : posPx >= s.px - W / 2;
const lines = splitStructLabel(s.title);
return (
<div
key={i}
className={`${styles.segmentLabel} ${styles.structName} ${passed ? styles.accent : styles.neutral}`}
style={cssVars({ '--x': px(s.px) })}
title={`${s.category} · ${s.title} (${fmtKm(s.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>
</>
)}
</>
);
}