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>
This commit is contained in:
@@ -51,9 +51,9 @@ function splitStructLabel(text: string): string[] {
|
||||
interface TimelineProps {
|
||||
posPx: number;
|
||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
/** 통과 구간 트랙 gradient (전진=주황/후진=청록). */
|
||||
/** 통과 구간 트랙 gradient (정방향=주황/역방향=청록). */
|
||||
trackGradient: string;
|
||||
/** 미통과 구간 트랙 gradient (전진=회색/후진=청회색). */
|
||||
/** 미통과 구간 트랙 gradient (정방향=회색/역방향=청회색). */
|
||||
trackGradientIdle: string;
|
||||
/** 색(방향)이 바뀌는 전환점 px(스테이지 좌표) — 구분선 위치. */
|
||||
dividers: number[];
|
||||
@@ -69,6 +69,8 @@ interface TimelineProps {
|
||||
endGapPx?: number;
|
||||
/** 영상 진행 방향('상'|'하'). 평행 상/하행 구조물 겹침 시 해당 방향을 우선 표출. */
|
||||
routeDir?: '상' | '하' | null;
|
||||
/** 겹침제외(POI 옵션). true=겹친 구조물 1개만, false=모두 표시. */
|
||||
overlapExclude?: boolean;
|
||||
}
|
||||
|
||||
export function Timeline({
|
||||
@@ -83,6 +85,7 @@ export function Timeline({
|
||||
endStationName,
|
||||
endGapPx = 0,
|
||||
routeDir = null,
|
||||
overlapExclude = true,
|
||||
}: TimelineProps) {
|
||||
// 하단 스테이션바는 겹쳐도 무방 → 역사(역, KAKAO_RAIL)는 dedup 대상에서 제외하고 항상 표출.
|
||||
// (종점 '대전조차장'이 ~70px 앞 '법동가도교'에 밀려 사라지던 문제 방지.)
|
||||
@@ -90,18 +93,84 @@ export function Timeline({
|
||||
// 평행 상/하행 구조물(예: 회덕터널 상/하)은 ~6px 차이라 한쪽만 남는데, 영상 방향(routeDir)에
|
||||
// 해당하는 쪽(하행=‘(하)’, 상행=‘(상)’)을 우선 남긴다. → 드론이 실제 지나는 선로와 일치.
|
||||
const isStation = (s: StructMark): boolean => s.category === '역사' || s.category === '역';
|
||||
const dirTag = routeDir === '상' ? '(상)' : routeDir === '하' ? '(하)' : '';
|
||||
// '(상' / '(하' 로 매칭 — 이름이 "법동가도교(상,인상,고속)" 처럼 괄호 안 다중 토큰이어도 인식.
|
||||
// (이전 '(상)' 완전일치는 다중토큰 이름에서 매칭 실패해 잘못된 변형이 선택되던 버그)
|
||||
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);
|
||||
const keptOthers: StructMark[] = [];
|
||||
// 같은 구조물의 상/하/인상 변형(같은 base = 같은 좌표)은 '겹침제외 옵션과 무관하게' 진행방향 1개만.
|
||||
// (dir-우선 정렬돼 있어 방향 변형이 먼저 = 채택. 예: 법동가도교(하)/(인상) → (하) 1개)
|
||||
const byBase = new Map<string, StructMark>();
|
||||
for (const it of ordered) {
|
||||
if (keptOthers.every((k) => Math.abs(k.px - it.px) >= 130)) keptOthers.push(it);
|
||||
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);
|
||||
|
||||
@@ -146,7 +215,7 @@ export function Timeline({
|
||||
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색.
|
||||
전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
|
||||
<div className={styles.track}>
|
||||
{/* 미재생(미통과): 회색/청회색 (전체 폭) */}
|
||||
{/* 미재생(미통과): 방향별 저톤 미리보기 (전체 폭) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -155,12 +224,10 @@ export function Timeline({
|
||||
height: '100%',
|
||||
width: px(TRACK_WIDTH_PX),
|
||||
background: trackGradientIdle,
|
||||
opacity: 1,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체.
|
||||
재생영역(회색 idle)보다 약간 밝은 단색 배경으로만 구분(빗금 없음). */}
|
||||
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체. */}
|
||||
{endGapPx > 0 && (
|
||||
<div
|
||||
title="재생 불가 구간 (종점역 미도착)"
|
||||
@@ -176,8 +243,7 @@ export function Timeline({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 재생된 구간: 원래 색 복원 (커서까지 clip). 폭은 CSS 변수(--pos-px)로 매 프레임
|
||||
직접 갱신되어 React 리렌더 없이 부드럽게 늘어난다. */}
|
||||
{/* 재생된 구간: 원래 색 복원 (커서까지 clip, --pos-px CSS변수로 매 프레임 갱신). */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -223,7 +289,7 @@ export function Timeline({
|
||||
{/* 측점값 라벨 — 각 시설물 측점값(아이콘 위) */}
|
||||
<div className={styles.mileageRow}>
|
||||
{structs.map((s, i) =>
|
||||
s.km >= 0 ? (
|
||||
s.km >= 0 && showMileage[i] ? (
|
||||
<MileageMarker
|
||||
key={`stkm-${i}`}
|
||||
marker={{ id: `stkm-${i}`, value: fmtKm(s.km), left: s.px, mileage: 0 }}
|
||||
@@ -295,33 +361,69 @@ export function Timeline({
|
||||
})}
|
||||
</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,
|
||||
)}
|
||||
{/* 구조물 클릭 핫스팟 제거 — 스테이션바 클릭은 속성 팝업을 띄우지 않고 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줄. */}
|
||||
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄.
|
||||
같은 항목 다중통과는 labelGroups(클러스터)로 묶여 클러스터당 라벨 1개만 표출. */}
|
||||
<div className={styles.labelRow}>
|
||||
{structs.map((s, i) => {
|
||||
const W = s.category === '터널' ? 24 : 22;
|
||||
{labelGroups.map((g, i) => {
|
||||
const W = g.category === '터널' ? 24 : 22;
|
||||
// 미도착 종점역은 항상 미통과(neutral) 색 유지.
|
||||
const passed = s.unreached ? false : posPx >= s.px - W / 2;
|
||||
const lines = splitStructLabel(s.title);
|
||||
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(s.px) })}
|
||||
title={`${s.category} · ${s.title} (${fmtKm(s.km)})`}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user