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:
2026-06-29 14:56:27 +09:00
co-authored by Claude Opus 4.8
parent 78fc090360
commit e9052143d7
36 changed files with 3308 additions and 285 deletions
@@ -19,6 +19,7 @@ import {
} from '../../utils/geoProjection';
import { useGeoStore } from '../../store/geoStore';
import { useSettingsStore } from '../../store/settingsStore';
import { Minimap } from './Minimap';
import type { GeoPoint, CenterlinePoint, PoiOverrideMap, RouteStructure } from '../../types/geo';
const VIDEO_FPS = 30000 / 1001;
@@ -75,6 +76,8 @@ interface Props {
showPanel?: boolean;
/** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */
topPx?: number;
/** 하단 스테이션바 높이(px) — 우측 버튼을 좌측 패널과 같은 높이(bottom)에 맞추기 위함. */
barHeight?: number;
/** 부드러운 단조보간 재생시간(초) ref. VideoPlayer smoothTimeRef 전달 시
* 라벨이 60fps로 매끄럽게 이동(일시정지·시크·배속 보정 포함). 없으면 prop 기반 추정. */
timeRef?: React.MutableRefObject<number>;
@@ -107,10 +110,10 @@ const DISPLAY_DEFAULTS = {
emaAlpha: 1.0,
smoothMinAlpha: 0.12, // 정지/떨림 시 최소 추종(작을수록 강한 평활)
smoothSpeedRef: 0.010, // 즉시추종 기준 속도(클수록 더 강하게 평활)
maxPoiRange: 300,
maxPoiRange: 1000,
// false = 지면고도 기준(POI를 지면고도 z에 고정 → gap=드론고도−지면고도 자동변동, 드론 오르내려도 지물에 붙음).
// true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확 → 기본 off.
poiDroneHeight: false,
// true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확.
poiDroneHeight: true,
droneHeightDrop: 24,
};
@@ -229,7 +232,7 @@ function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange,
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
export default function StationOverlay({ currentFrame, currentTime, fps, visible, videoReady = true, videoWidth = 0, videoHeight = 0, onTogglePlay, showPanel = true, topPx = 72, timeRef }: Props) {
export default function StationOverlay({ currentFrame, currentTime, fps, visible, videoReady = true, videoWidth = 0, videoHeight = 0, onTogglePlay, showPanel = true, topPx = 72, barHeight = 0, timeRef }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasSizeRef = useRef({ w: 0, h: 0 });
@@ -262,6 +265,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const renderCacheRef = useRef<RenderCache | null>(null);
// 나침반 전용(측점선 visible 무관 항상 갱신·표시)
const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null);
// 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩).
const minimapRef = useRef<HTMLDivElement>(null);
const minimapRotRef = useRef(0);
// UI state
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
@@ -279,24 +285,28 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
// POI 표시 범위(m) — 드론 위치와의 수평 직선거리(distH, 고도차 무시). 이 범위 안이면 표시,
// 밖이면 제외. (이전: 진행방향 앞/옆 비등방 → 단일 반경으로 통합.) 패널에서 실시간 조절.
const [maxPoiRange, setMaxPoiRange] = useState(DISPLAY_DEFAULTS.maxPoiRange);
const maxPoiRangeRef = useRef(300);
const maxPoiRangeRef = useRef(DISPLAY_DEFAULTS.maxPoiRange);
// 비교 테스트: POI 높이를 선로표고(false) 대신 '드론 고도 N미터'(true)로. N=droneHeightDrop.
const [poiDroneHeight, setPoiDroneHeight] = useState(DISPLAY_DEFAULTS.poiDroneHeight);
const [droneHeightDrop, setDroneHeightDrop] = useState(DISPLAY_DEFAULTS.droneHeightDrop);
// 드론 경로 가시화 — 드론의 GPS 경로(lat/lon)를 지정 표고 z 에 투영해 선으로 표시.
const [showDronePath, setShowDronePath] = useState(DISPLAY_DEFAULTS.showDronePath);
// 선형/드론궤적 토글은 설정 스토어로 공유 → VideoPlayer 하단 바 버튼에서도 제어.
const showDronePath = useSettingsStore(s => s.showDronePath);
const setShowDronePath = useSettingsStore(s => s.setShowDronePath);
const [dronePathZ, setDronePathZ] = useState(DISPLAY_DEFAULTS.dronePathZ); // 기본 경로 표고
const showDronePathRef = useRef(true);
const dronePathZRef = useRef(78);
useEffect(() => { showDronePathRef.current = showDronePath; }, [showDronePath]);
useEffect(() => { dronePathZRef.current = dronePathZ; }, [dronePathZ]);
// 선형(중심선) 독립 토글 + 드론경로 투명도(0~1)
const [showCenterline, setShowCenterline] = useState(DISPLAY_DEFAULTS.showCenterline);
// 선형(중심선) 독립 토글 + 드론경로 투명도(0~1) — 토글은 설정 스토어 공유(바 버튼 제어).
const showCenterline = useSettingsStore(s => s.showCenterline);
const setShowCenterline = useSettingsStore(s => s.setShowCenterline);
const showCenterlineRef = useRef(true);
useEffect(() => { showCenterlineRef.current = showCenterline; }, [showCenterline]);
const [dronePathAlpha, setDronePathAlpha] = useState(DISPLAY_DEFAULTS.dronePathAlpha);
const dronePathAlphaRef = useRef(0.75);
useEffect(() => { dronePathAlphaRef.current = dronePathAlpha; }, [dronePathAlpha]);
// 측점 진단 토글 버튼은 삭제됨. (HUD 표시는 VideoPlayer 에서 showStationDiag 구독)
// POI 위치 편집(드래그) 모드
const [editMode, setEditMode] = useState(false);
const editModeRef = useRef(false);
@@ -442,6 +452,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude);
const overlapExcludeRef = useRef(true);
useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]);
// 좌측 노선 패널(RoutePanel) 표시 토글은 하단 재생바(VideoPlayer)로 이동.
const storeBaseName = useGeoStore(s => s.baseName);
const storePoiOverrides = useGeoStore(s => s.poiOverrides);
const setPoiOverride = useGeoStore(s => s.setPoiOverride);
@@ -798,7 +809,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
}
};
requestIdleCallback(step, { timeout: 200 });
// 첫 청크(현재 프레임 포함)는 동기 실행 → 토글/재계산이 '정지 중에도' 즉시 반영.
// (idle 대기 시 정지 상태에서 갱신이 지연/누락되던 문제 해결.) 나머지는 step 내부에서 idle 로 이어감.
step();
}, [nearestCL]);
// 모든 데이터 로드 완료 시 사전 계산 시작
@@ -936,6 +949,27 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
: currentDroneFrameRef.current!);
const lines = buildLines(dronePose);
// 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
// 평활이라 회전 시 지연 → 가벼운 평활(±3fr)의 yaw 를 따로 보간해 사용. 360° 누적 언랩(점프 방지).
if (minimapRef.current) {
const arr = allDroneFramesRef.current;
let rawYaw = dronePose.yaw;
if (arr.length) {
let lo = 0, hi = arr.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; }
const i2 = lo, i1 = Math.max(0, lo - 1);
const f1 = arr[i1].frame, f2 = arr[i2].frame;
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
const a = smoothFrame(arr, i1, 3), b = smoothFrame(arr, i2, 3); // ±3fr 가벼운 평활
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180;
rawYaw = a.yaw + dy * frac;
}
const target = -(rawYaw + paramsRef.current.yawOffset);
const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180;
minimapRotRef.current += delta;
minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`);
}
// 선로 중심선 (선형) — 독립 토글
if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
ctx.strokeStyle = 'rgba(255,50,50,0.85)';
@@ -996,7 +1030,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const seenSt = new Set<string>();
const seenPoi = new Set<string>();
// 측점 라벨 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA
// 측점 라벨 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA.
// 선형(중심선) 토글과 함께 ON/OFF (빨간 스테이션 라벨 = 선형의 일부).
if (showCenterlineRef.current) {
ctx.font = 'bold 18px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
@@ -1028,6 +1064,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
const tw = ctx.measureText(cleanTitle(stA.title)).width;
hitBoxes.push({ kind: 'station', title: stA.title, x0: x - 8, y0: y - 12, x1: lx + tw + 2, y1: y + 12 });
});
}
// POI 마커 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA
ctx.font = 'bold 20px sans-serif';
@@ -1102,37 +1139,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
}
// 나침반 HUD — 우측 상단, 측점선 토글과 무관하게 항상 표시
const compass = compassRef.current;
if (compass) {
const r = 52;
const cx = W - (r + 14), cy = 16 + r;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (const [label, deg] of [['N',0],['E',90],['S',180],['W',270]] as const) {
const rad = (deg-90)*Math.PI/180;
ctx.fillStyle = label==='N' ? '#ff6060' : 'rgba(255,255,255,0.5)';
ctx.fillText(label, cx+Math.cos(rad)*(r-9), cy+Math.sin(rad)*(r-9));
}
const yawRad = (compass.effectiveYaw-90)*Math.PI/180;
const tx = cx+Math.cos(yawRad)*(r-14), ty = cy+Math.sin(yawRad)*(r-14);
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty);
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2.5; ctx.stroke();
const ha = 0.42, hl = 9;
ctx.beginPath();
ctx.moveTo(tx, ty); ctx.lineTo(tx-hl*Math.cos(yawRad-ha), ty-hl*Math.sin(yawRad-ha));
ctx.moveTo(tx, ty); ctx.lineTo(tx-hl*Math.cos(yawRad+ha), ty-hl*Math.sin(yawRad+ha));
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2; ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r-2, yawRad-compass.hFovRad/2, yawRad+compass.hFovRad/2); ctx.closePath();
ctx.fillStyle = 'rgba(255,215,0,0.12)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,215,0,0.35)'; ctx.lineWidth = 1; ctx.stroke();
ctx.font = '9px monospace'; ctx.textBaseline = 'top'; ctx.fillStyle = '#ffd700';
ctx.fillText(`${((compass.effectiveYaw+360)%360).toFixed(1)}°`, cx, cy+r+2);
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
}
// 라벨 속성 팝업 위치 — 해당 라벨의 현재 표시좌표를 따라 매 프레임 갱신(재생 중 이동).
// 라벨이 화면에서 사라지면(필터/화면밖) 팝업 숨김, 다시 보이면 표시.
@@ -1155,15 +1162,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
popupMissRef.current.set(id, 0);
const ph = el.offsetHeight || 120;
const pw = el.offsetWidth || 240;
// 팝업은 항상 라벨 '아래'. 라벨 위로는 띄우지 않는다.
// 화면 하단을 넘칠 만큼 라벨이 내려가면(곧 사라지는 중) → 위로 올리지 않고 팝업을 숨김.
const GAP = 26;
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립 →
// 라벨과 겹치거나 화면 밖으로 잘리는 것을 방지. 위로도 부족하면 화면 안으로 클램프.
const GAP = 16;
const tx = Math.min(Math.max(8, sx - 10), Math.max(8, W - pw - 4));
const ty = sy + GAP;
let ty = sy + GAP; // 아래
if (ty + ph > H - 2) {
// 라벨이 화면 하단에 근접 → 아래로 못 펼침. 라벨 위로 올리지 않고 숨김(라벨과 함께 사라짐).
el.style.visibility = 'hidden';
return;
const aboveTy = sy - GAP - ph; // 위로 플립 (팝업 하단이 라벨 위 ~GAP 지점)
ty = aboveTy >= 2 ? aboveTy : Math.max(2, H - 2 - ph);
}
el.style.visibility = 'visible';
const pp = popupPosRef.current.get(id);
@@ -1435,6 +1441,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
onPointerCancel={onPoiPointerUp}
/>
{/* 나침반 미니맵 — 우측 상단(기존 캔버스 나침반 대체). 핀은 RAF 가 드론 방위로 회전. */}
{geoDataLoaded && <Minimap ref={minimapRef} />}
{/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명.
컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). */}
{infoPopups.map(pp => {
@@ -1475,37 +1484,29 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
})}
{showPanel && (
<div className="absolute right-2 z-30 flex flex-col items-end gap-1" style={{ top: Math.max(topPx, 150) }}>
<div className="flex gap-1">
<div className="absolute right-2 z-30 flex flex-col-reverse items-end gap-2" style={{ bottom: (barHeight || 130) + 8 }}>
<div className="flex gap-2">
<button onClick={() => setShowDisplay(v => !v)}
className={`text-[10px] whitespace-nowrap px-2 py-1 rounded border shadow ${showDisplay ? 'bg-sky-700/90 border-sky-400 text-white' : 'bg-black/70 hover:bg-black/90 text-gray-200 border-gray-500'}`}>
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showDisplay ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
{showDisplay ? '▲ 화면표시 옵션' : '▼ 화면표시 옵션'}
</button>
<button onClick={() => setShowControls(v => !v)}
className={`text-[10px] whitespace-nowrap px-2 py-1 rounded border shadow ${showControls ? 'bg-gray-700/90 border-gray-400 text-white' : 'bg-black/70 hover:bg-black/90 text-gray-200 border-gray-500'}`}>
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
{showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
</button>
</div>
{/* 두 패널을 가로로 나란히(우→좌) 펼침 — 둘 다 열어도 세로로 안 쌓여 화면 위로 안 넘침. */}
<div className="flex flex-row-reverse items-end gap-2">
{/* ── 화면표시 옵션 (카메라 외 모든 표시 설정) ── */}
{showDisplay && (
<div className="bg-black/90 border border-sky-700 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2"> </div>
<div className="space-y-2 mb-3">
<button onClick={() => setShowCenterline(v => !v)}
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${showCenterline ? 'bg-red-500/80 border-red-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
{showCenterline ? '■ 선형(중심선) 표시중' : '▶ 선형(중심선) 표시'}
</button>
<button onClick={() => setShowDronePath(v => !v)}
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${showDronePath ? 'bg-sky-500/80 border-sky-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
{showDronePath ? '■ 드론 궤적 표시중' : '▶ 드론 궤적 표시'}
</button>
{showDronePath && (
<>
<ParamRow label="경로 표고" tip={`GPS 측정점(노란 점)을 표고 ${dronePathZ}m 에 투영·연결`} value={dronePathZ} min={-10} max={pathZMax} step={1} unit="m" decimals={0} onChange={v => setDronePathZ(Math.round(v))} />
<ParamRow label="투명도" tip={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
</>
)}
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
{/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */}
<ParamRow label="경로 표고" tip={`GPS 측정점(노란 점)을 표고 ${dronePathZ}m 에 투영·연결`} value={dronePathZ} min={-10} max={pathZMax} step={1} unit="m" decimals={0} onChange={v => setDronePathZ(Math.round(v))} />
<ParamRow label="투명도" tip={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2"> <span className="text-gray-600">( 500ms )</span></div>
<div className="mb-3 space-y-2">
@@ -1630,6 +1631,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
</div>
</div>
)}
</div>
</div>
)}
</>