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) => 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(); 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(); 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 ( <> {/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 시·종점 역명 배치 (route.json/측점 유래) */}
{/* 좌측(시점)=역방향 하늘색, 우측(종점)=정방향 주황 */}
{startStationName ?? ''}
{endStationName ?? ''}
{/* 트랙 본체 (가로 압축 래퍼) */}
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색. 전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
{/* 미재생(미통과): 방향별 저톤 미리보기 (전체 폭) */}
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체. */} {endGapPx > 0 && (
)} {/* 재생된 구간: 원래 색 복원 (커서까지 clip, --pos-px CSS변수로 매 프레임 갱신). */}
{/* 색(방향) 전환점 구분선 — videoplayer leg 솔기와 동일(검정+흰색 점선) */} {dividers.map((dpx, i) => (
))}
{/* 측점값 라벨 — 각 시설물 측점값(아이콘 위) */}
{structs.map((s, i) => s.km >= 0 && showMileage[i] ? ( ) : null, )}
{/* 구조물 아이콘 — 재생바 위에 겹쳐 표시 (videoplayer 3-slice) */}
{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 ( ); } 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 (
); })}
{/* 구조물 클릭 핫스팟 제거 — 스테이션바 클릭은 속성 팝업을 띄우지 않고 seek 으로만 동작. (POI 정보는 화면 오버레이 라벨 클릭에서 확인) */} {/* 동일 항목 묶음 — 각 통과 위치를 수직 점선으로 내려, 라벨 '문자 세로 중앙'을 지나는 수평 점선으로 잇는다. 수평선은 라벨 뒤로 지나가 글자 양옆으로만 보인다(두번째 참고 이미지). z-index 미지정(auto) → DOM 상 뒤의 labelRow 가 위에 그려져 글자가 선을 덮는다. */}
{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 ( {/* 문자 세로 중앙을 지나는 수평 점선 (라벨 뒤) */}
{/* 각 통과 수직 점선 (트랙 하단 → 수평선) */} {g.bracketPxs.map((bp, j) => (
))} ); })}
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄. 같은 항목 다중통과는 labelGroups(클러스터)로 묶여 클러스터당 라벨 1개만 표출. */}
{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 (
{lines.length === 1 ? lines[0] : <>{lines[0]}
{lines[1]}}
); })}
{/* 구조물 속성 팝업 — 클릭 좌표 기준 fixed(트랙 래퍼 밖). 배경 클릭 시 닫힘. */} {sel && ( <>
setSel(null)} style={{ position: 'fixed', inset: 0, zIndex: 999 }} />
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)', }} >
📍 {sel.s.title} setSel(null)} style={{ cursor: 'pointer', color: '#9ca3af' }}>✕
{(sel.s.props ?? []).map((p, i) => ( {p.k} {p.v} ))}
)} ); }