스테이션바/측점(체이니지) - 역 재진입 마커 누락 수정: 측점기준 탐지에 좌표근접(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>
481 lines
21 KiB
TypeScript
Executable File
481 lines
21 KiB
TypeScript
Executable File
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>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|