Files
GhiVideo/client/src/stationbar/components/Timeline/Timeline.tsx
T
b23042andClaude Opus 4.8 e9052143d7 feat: 스테이션바 측점 정확도 개선 + 영상 컨트롤 UI 통합 + 종합 기술문서
스테이션바/측점(체이니지)
- 역 재진입 마커 누락 수정: 측점기준 탐지에 좌표근접(pxPassesTo) 합집합 보강(중복 24px 제거)
- kmExists 판정: 좌표 반경만으로 잡힌 통과는 측점값 라벨/검색에서 제외(마커 동그라미는 유지)
- 측점값 라벨 겹침 숨김(좌측 우선), 동일항목 브래킷 클러스터
- 측점 검색 순환(통과방향 다음→끝에서 처음), 마커 기준 후보 산출, 입력값 유지/blur 삭제
- 연속 체이니지 수직투영 유틸 분리(client/src/utils/chainage.ts), Minimap 컴포넌트 추가

영상 컨트롤 UI
- 선형/드론궤적/좌측패널 토글을 재생바로 통합(settingsStore 공유·persist)
- 드론 GPS·고도 HUD 상시 표시, 경로표고·투명도 항상 노출, 겹침제외 버튼 숨김
- 재생바 요소 재배치(좌측패널→선형→드론궤적→드론위치정보→시설등급), 활성색 amber 통일

문서/도구
- 종합 기술명세서(인계+특허 기초) md/html/pdf 3종, UI개선 보고서 3종
- md→html→pdf 변환 스크립트(scripts/md2docs.sh) + 스타일(docs/assets/report.css)
- 작업 히스토리 다수 추가(docs/history/)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:56:27 +09:00

481 lines
21 KiB
TypeScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
)}
</>
);
}