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
+69
View File
@@ -0,0 +1,69 @@
import { forwardRef } from 'react';
/**
* 나침반 (heading-up, 아날로그 눈금형) — 눈금 링 + N + 빨간 바늘이 드론 방위(--rot = -yaw)로 회전.
* - 회전 카드(눈금/N/빨간 바늘/빨간 눈금): 부모 RAF 가 `--rot`(누적 언랩) 갱신 → 부드럽게 회전.
* - 상단 흰 삼각형 인덱스 + 외곽 링은 고정(위 = 드론 진행방향 = 헤딩).
* ref 는 root div — `style.setProperty('--rot', `${-yaw}deg`)`.
*/
const D = 150; // 나침반 한 변(px)
// 눈금: 6° 간격(60개). 0/90/180/270=장, 30°배수=중, 그 외=단.
const TICKS = Array.from({ length: 60 }, (_, i) => {
const deg = i * 6;
const long = deg % 90 === 0;
const mid = deg % 30 === 0;
const a = (deg * Math.PI) / 180;
const r1 = long ? 37 : mid ? 40 : 42; // 안쪽 끝
const sin = Math.sin(a), cos = Math.cos(a);
return {
deg,
x1: 50 + r1 * sin, y1: 50 - r1 * cos,
x2: 50 + 46 * sin, y2: 50 - 46 * cos,
w: long ? 1.7 : mid ? 1.2 : 0.8,
color: long ? '#fff' : 'rgba(255,255,255,0.6)',
};
});
export const Minimap = forwardRef<HTMLDivElement, { className?: string }>(function Minimap(_props, ref) {
return (
<div ref={ref} style={{ position: 'absolute', top: 14, right: 14, width: D, height: D, zIndex: 30, pointerEvents: 'none' }}>
{/* 고정 — 배경 원 + 외곽 링(두껍게). 프레임만 고정, 그 외 전부 회전. */}
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
<circle cx="50" cy="50" r="46.5" fill="rgba(16,22,32,0.45)" stroke="rgba(255,255,255,0.92)" strokeWidth="3" />
</svg>
{/* 회전 카드 — 눈금 + N + 빨간 바늘(N) + 흰 삼각형(S) + 빨간 눈금. 전체가 함께 회전. */}
<svg
viewBox="0 0 100 100"
width={D}
height={D}
style={{ position: 'absolute', inset: 0, transform: 'rotate(var(--rot, 0deg))', transformOrigin: '50% 50%' }}
>
{TICKS.map((t) => (
<line key={t.deg} x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={t.color} strokeWidth={t.w} strokeLinecap="round" />
))}
{/* 빨간 화살표 — N(정북). 끝(tip)이 테두리에 맞닿고, 기단은 중심 쪽. */}
<polygon points="50,7 43,32 57,32" fill="#e2231a" />
{/* N 문자 — 빨간 화살표 '안쪽'(중심 방향). */}
<text
x="50"
y="45"
textAnchor="middle"
fontSize="12"
fontWeight="700"
fill="#fff"
stroke="rgba(0,0,0,0.55)"
strokeWidth="0.7"
style={{ paintOrder: 'stroke' }}
>
N
</text>
{/* 흰 삼각형 — S방향, 빨간 화살표와 '정반대'(아래로). 끝이 하단 테두리에 맞닿음. */}
<polygon points="50,93 43,68 57,68" fill="rgba(255,255,255,0.95)" />
{/* 중심 점 */}
<circle cx="50" cy="50" r="2.3" fill="#fff" />
</svg>
</div>
);
});
+21 -4
View File
@@ -66,6 +66,7 @@ export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, b
const pois = useGeoStore(s => s.pois);
const structures = useGeoStore(s => s.structures);
const gradeFilter = useSettingsStore(s => s.gradeFilter);
const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude);
const droneFrames = useGeoStore(s => s.frames);
const routeMeta = useGeoStore(s => s.routeMeta);
const [currentKm, setCurrentKm] = useState(0);
@@ -250,9 +251,21 @@ export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, b
const kmToY = (km: number) => (1 - (km - minKm) / (maxKm - minKm)) * 100;
// 교량/터널만 표시 + 시설종별 필터(설정 스토어, 재생바와 동일 규칙).
// \uACB9\uCE68\uC81C\uC678 ON \uC2DC \uAC19\uC740 \uC704\uCE58 \uD615\uC81C \uC911 \uC9C4\uD589\uBC29\uD5A5((\uD558)/(\uC0C1))\uC744 \uC6B0\uC120 \uB0A8\uAE30\uB3C4\uB85D dir-\uC6B0\uC120 \uC815\uB82C.
const routeDir = routeMeta?.routeInfo?.direction?.includes('\uD558') ? '\uD558'
: routeMeta?.routeInfo?.direction?.includes('\uC0C1') ? '\uC0C1' : null;
const dirTag = routeDir === '\uC0C1' ? '(\uC0C1)' : routeDir === '\uD558' ? '(\uD558)' : '';
// \uACBD\uB85C\uC0C1 \uC2DC\uC124\uBB3C\uB9CC: \uBC29\uD5A5\uD45C\uAE30((\uC0C1\u2026/(\uD558\u2026)\uAC00 \uC788\uC73C\uBA74 \uC601\uC0C1 \uBC29\uD5A5 \uC77C\uCE58\uD558\uB294 \uAC83\uB9CC(\uBC18\uB300 \uBC29\uD5A5=\uB2E4\uB978 \uC120\uB85C \uC81C\uC678).
const oppCh = routeDir === '\uD558' ? '\uC0C1' : routeDir === '\uC0C1' ? '\uD558' : '';
const isOppositeDir = (name: string): boolean => !!oppCh && new RegExp(`[(\uFF08]${oppCh}`).test(name);
const baseStruct = (t: string): string => t.replace(/\s*[(\uFF08].*$/, '').trim();
// \uAC19\uC740 \uC88C\uD45C(\uAC19\uC740 base \uBCC0\uD615)\uB294 \uACB9\uCE68\uC81C\uC678 \uC635\uC158\uACFC \uBB34\uAD00\uD558\uAC8C \uC9C4\uD589\uBC29\uD5A5 1\uAC1C\uB9CC(dir-\uC6B0\uC120 \uC815\uB82C \uD6C4 base\uBCC4 \uCCAB\uC9F8).
const seenBase = new Set<string>();
const filteredPois = structures
.filter(s => (s.type === 'bridge' || s.type === 'tunnel') && typeof s.lat === 'number' && typeof s.lon === 'number' && isGradeVisible(s.grade, gradeFilter))
.map(s => ({ title: s.name, category: s.type === 'tunnel' ? '\uD130\uB110' : '\uAD50\uB7C9', lat: s.lat as number, lon: s.lon as number, z: 0, type: 'poi' as const }));
.filter(s => (s.type === 'bridge' || s.type === 'tunnel') && typeof s.lat === 'number' && typeof s.lon === 'number' && isGradeVisible(s.grade, gradeFilter) && !isOppositeDir(s.name))
.map(s => ({ title: s.name, category: s.type === 'tunnel' ? '\uD130\uB110' : '\uAD50\uB7C9', lat: s.lat as number, lon: s.lon as number, z: 0, type: 'poi' as const }))
.sort((a, b) => (dirTag && a.title.includes(dirTag) ? 0 : 1) - (dirTag && b.title.includes(dirTag) ? 0 : 1))
.filter(p => { const b = baseStruct(p.title); if (seenBase.has(b)) return false; seenBase.add(b); return true; });
return (
<div
@@ -287,8 +300,12 @@ export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, b
if (km < 0) return null;
const y = kmToY(km);
if (y < 5 || y > 95) return null;
if (placed.some(py => Math.abs(py - y) < MIN_GAP)) return null;
placed.push(y);
// 겹침제외 ON 일 때만 겹친 것(같은 위치 형제 포함) 제외 → 방향 우선(위 dir-정렬)으로 1개만.
// OFF 면 모두 표시(겹쳐도). 영상 오버레이 토글과 동일 규칙.
if (poiOverlapExclude) {
if (placed.some(py => Math.abs(py - y) < MIN_GAP)) return null;
placed.push(y);
}
return (
<div
key={`poi-${i}`}
@@ -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>
)}
</>
+156 -73
View File
@@ -1,4 +1,4 @@
import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect } from 'react';
import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect, useMemo } from 'react';
import StationOverlay from '../overlay/StationOverlay';
import RoutePanel from '../overlay/RoutePanel';
import RouteInfoOverlay from '../overlay/RouteInfoOverlay';
@@ -15,6 +15,27 @@ import { useCaptureStore } from '../../store/captureStore';
import HlsConversionStatus from './HlsConversionStatus';
import { StationBar } from '../../stationbar/StationBar';
/** 드롭된 디렉토리/파일 엔트리에서 모든 파일을 재귀 수집 (폴더 드래그&드롭 지원). */
function collectDropEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
return new Promise((resolve) => {
if (entry.isFile) {
(entry as FileSystemFileEntry).file((f) => { out.push(f); resolve(); }, () => resolve());
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
const readBatch = () => {
reader.readEntries(async (ents) => {
if (!ents.length) { resolve(); return; } // 모든 배치 소진
await Promise.all(ents.map((en) => collectDropEntry(en, out)));
readBatch();
}, () => resolve());
};
readBatch();
} else {
resolve();
}
});
}
export interface VideoPlayerHandle {
loadLocalFile: (file: File) => void;
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
@@ -96,11 +117,20 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
}, [playerRef, source]);
const loadFromFolder = useGeoStore((s) => s.loadFromFolder);
const geoLoaded = useGeoStore((s) => s.loaded);
// 드론 정보 — 하단바에 GPS/고도 항상 표시.
const storeFrames = useGeoStore((s) => s.frames);
// 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어.
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
const setGradeFilter = useSettingsStore((s) => s.setGradeFilter);
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
const setPoiOverlapExclude = useSettingsStore((s) => s.setPoiOverlapExclude);
// 선형(중심선)·드론 궤적 영상 오버레이 토글 — StationOverlay 와 공유(설정 스토어).
const showCenterline = useSettingsStore((s) => s.showCenterline);
const setShowCenterline = useSettingsStore((s) => s.setShowCenterline);
const showDronePath = useSettingsStore((s) => s.showDronePath);
const setShowDronePath = useSettingsStore((s) => s.setShowDronePath);
const showRoutePanel = useSettingsStore((s) => s.showRoutePanel);
const setShowRoutePanel = useSettingsStore((s) => s.setShowRoutePanel);
// 하단 도구 패널: UI에서 숨김(코드는 보존). 다시 표시하려면 true 로.
const SHOW_TOOLBAR = false;
@@ -200,11 +230,30 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
containerRef: wrapperRef,
});
// Drag and drop local video file
// 드래그&드롭 — 폴더(영상+측점/POI) 또는 단일 영상 파일.
// 폴더는 dataTransfer.files 가 비어 있으므로 webkitGetAsEntry 로 디렉토리를 재귀 순회한다.
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file?.type.startsWith('video/')) loadLocalFile(file);
// await 후엔 dataTransfer 가 무효화될 수 있어 동기적으로 먼저 캡처.
const entries: FileSystemEntry[] = [];
const dt = e.dataTransfer;
if (dt.items) {
for (let i = 0; i < dt.items.length; i++) {
const en = dt.items[i].webkitGetAsEntry?.();
if (en) entries.push(en);
}
}
const flatFiles = Array.from(dt.files);
void (async () => {
if (entries.length) {
const out: File[] = [];
await Promise.all(entries.map((en) => collectDropEntry(en, out)));
if (out.length) { handleSelectFolder(out); return; }
}
if (flatFiles.length > 1) { handleSelectFolder(flatFiles); return; }
const file = flatFiles[0];
if (file?.type.startsWith('video/')) loadLocalFile(file);
})();
};
// VIDEO_FPS: 영상 실제 fps (29.97 = 30000/1001). Python SRT FrameCnt 기준과 일치.
@@ -213,6 +262,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
const frame = secondsToFrame(currentTime, VIDEO_FPS);
const videoId = source?.kind === 'server' ? source.videoId : null;
// 드론 정보(측점진단) — 현재 프레임에 가장 가까운 드론 프레임의 GPS/고도만 표시.
// 드론 GPS·고도 HUD 는 항상 표시 → 토글 없이 프레임만 있으면 계산.
const stationDiag = useMemo(() => {
if (!storeFrames.length) return null;
let best = storeFrames[0], bd = Math.abs(storeFrames[0].frame - frame);
for (const df of storeFrames) {
const d = Math.abs(df.frame - frame);
if (d < bd) { bd = d; best = df; }
}
return { f: best };
}, [storeFrames, frame]);
return (
<div
ref={wrapperRef}
@@ -235,86 +296,107 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
/>
{/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */}
<RouteInfoOverlay />
{/* 좌하단 그룹(재생바 위): 위→아래 = 배속 → 토글(측점선/영상제어) + 프레임정보 */}
{/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */}
{source && (
<div
className="absolute left-2 z-30 flex flex-col items-start gap-2"
className="absolute left-2 z-30 flex items-center gap-2 flex-wrap pointer-events-auto"
style={{ bottom: (barHeight || 130) + 8 }}
>
{/* 재생 배속 (영상제어 ON) */}
{/* 영상제어 토글 — 버튼만, 고정폭(가장 긴 'OFF' 기준이라 ON/OFF로 폭이 안 변함) */}
<button
type="button"
onClick={() => setShowVideoControls((v) => !v)}
className={`text-xs px-2 py-1 rounded border font-semibold text-center min-w-[96px] ${showVideoControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
> {showVideoControls ? 'ON' : 'OFF'}</button>
{showVideoControls && (
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded pointer-events-auto">
<span className="text-gray-400 text-xs"></span>
{[0.5, 1, 1.5, 2, 3, 4].map((r) => (
<button
key={r}
type="button"
onClick={() => playerRef.current?.playbackRate(r)}
className={`text-xs px-1.5 py-0.5 rounded font-semibold ${
Math.abs(playbackRate - r) < 0.01
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
}`}
>
{r}x
</button>
))}
</div>
<>
{/* 배속 패널 (영상제어 우측) */}
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs"></span>
{[0.5, 1, 1.5, 2, 3, 4].map((r) => (
<button
key={r}
type="button"
onClick={() => playerRef.current?.playbackRate(r)}
className={`text-xs px-1.5 py-0.5 rounded ${
Math.abs(playbackRate - r) < 0.01
? 'bg-amber-400 text-black'
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
}`}
>
{r}x
</button>
))}
</div>
{/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */}
<span className="bg-black/70 text-gray-200 text-xs px-2 py-1 rounded font-mono whitespace-nowrap">
{secondsToTimecode(currentTime)} | F<span className="inline-block text-left" style={{ minWidth: '6ch' }}>{frame}</span> | {fps}fps
</span>
{geoLoaded && (
<>
{/* 좌측 패널 토글 — 선형 왼편, 동일 폭(min-w-[96px]) */}
<button
type="button"
onClick={() => setShowRoutePanel(!showRoutePanel)}
title="좌측 노선 패널 표시/숨김"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showRoutePanel ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
> {showRoutePanel ? 'ON' : 'OFF'}</button>
{/* 선형(중심선) 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
<button
type="button"
onClick={() => setShowCenterline(!showCenterline)}
title="선형(중심선) 표시/숨김"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showCenterline ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
> {showCenterline ? 'ON' : 'OFF'}</button>
{/* 드론 궤적 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
<button
type="button"
onClick={() => setShowDronePath(!showDronePath)}
title="드론 궤적 표시/숨김"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showDronePath ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
> {showDronePath ? 'ON' : 'OFF'}</button>
{/* 드론 위치정보 — 드론궤적 버튼 바로 우측에 붙임. GPS·고도 항상 표시. */}
{stationDiag && (
<div className="bg-black/70 px-2 py-1 rounded font-mono text-xs text-gray-200 whitespace-nowrap">
GPS {stationDiag.f.lat.toFixed(6)}, {stationDiag.f.lon.toFixed(6)}
<span className="text-gray-400"> · {stationDiag.f.altitude.toFixed(1)}m</span>
</div>
)}
{/* 겹침제외 토글 — UI 숨김(기능/상태는 보존, 기본 ON). 다시 보이려면 주석 해제. */}
{/* <button
type="button"
onClick={() => setPoiOverlapExclude(!poiOverlapExclude)}
title="ON=겹친 POI 숨김 / OFF=모든 POI 표시"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${poiOverlapExclude ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>겹침제외 {poiOverlapExclude ? 'ON' : 'OFF'}</button> */}
{/* 시설등급 패널 */}
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs"></span>
{KNOWN_GRADES.map((g) => (
<label
key={g}
className="flex items-center gap-1 text-xs text-gray-200 cursor-pointer select-none"
>
<input
type="checkbox"
className="accent-amber-400"
checked={gradeFilter[g] ?? false}
onChange={(e) => setGradeFilter(g, e.target.checked)}
/>
{g}
</label>
))}
</div>
</>
)}
</>
)}
{/* 한 줄(좌→우): 영상제어 토글 → 프레임표시 패널 → POI옵션 패널 → 시설등급 패널 */}
<div className="flex items-center gap-2 pointer-events-auto flex-wrap">
<button
type="button"
onClick={() => setShowVideoControls((v) => !v)}
className={`text-xs px-2 py-1 rounded border font-semibold ${showVideoControls ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
> {showVideoControls ? 'ON' : 'OFF'}</button>
{showVideoControls && (
<>
{/* 영상 프레임 표시 패널 */}
<span className="bg-black/70 text-white text-xs px-2 py-1 rounded font-mono">
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
</span>
{geoLoaded && (
<>
{/* POI 옵션 패널 (시설등급 왼쪽) — 겹침제외 토글 */}
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs">POI </span>
<button
type="button"
onClick={() => setPoiOverlapExclude(!poiOverlapExclude)}
title="ON=겹친 POI 숨김 / OFF=모든 POI 표시"
className={`text-xs px-2 py-0.5 rounded border font-semibold ${poiOverlapExclude ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
> {poiOverlapExclude ? 'ON' : 'OFF'}</button>
</div>
{/* 시설등급 패널 (프레임표시 우측) */}
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs"></span>
{KNOWN_GRADES.map((g) => (
<label
key={g}
className="flex items-center gap-1 text-xs text-gray-200 cursor-pointer select-none"
>
<input
type="checkbox"
className="accent-blue-500"
checked={gradeFilter[g] ?? false}
onChange={(e) => setGradeFilter(g, e.target.checked)}
/>
{g}
</label>
))}
</div>
</>
)}
</>
)}
</div>
</div>
)}
{/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */}
<RoutePanel
currentTime={currentTime}
visible={showStations}
visible={showStations && showRoutePanel}
onSeek={(time) => playerRef.current?.currentTime(time)}
topPx={paramTop + 40}
bottomPx={(barHeight || 130) + 90}
@@ -357,6 +439,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
onTogglePlay={handleTogglePlay}
showPanel={showVideoControls && !!source}
topPx={paramTop}
barHeight={barHeight}
/>
{/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */}
+4 -2
View File
@@ -20,8 +20,10 @@
bottom: 0;
width: 1920px;
height: 1080px;
transform: scale(var(--bar-scale));
transform-origin: bottom left;
/* transform: scale() 는 텍스트를 비트맵으로 축소해 흐려진다. zoom 은 축소된 크기로 '다시 렌더'
→ 글자가 네이티브 해상도로 또렷. 내부 px 좌표/커서/seek 계산은 그대로 동작.
(지원: Chrome·Edge·Safari·Firefox 126+) */
zoom: var(--bar-scale);
pointer-events: none;
}
+280 -104
View File
@@ -94,6 +94,8 @@ export interface StructMark {
props?: { k: string; v: string }[];
/** 종점역인데 영상이 도착하지 못함 → 끝에 '미도착' 스타일(속 빈 링)로 표시. */
unreached?: boolean;
/** 표시 측점값이 그 위치에 실제로 존재하는가. false=좌표 반경만으로 잡힌 통과(값 라벨·검색 제외). */
kmExists?: boolean;
}
interface StationBarProps {
@@ -182,15 +184,23 @@ export function StationBar({
);
const storeFrames = useGeoStore((s) => s.frames);
const routeMeta = useGeoStore((s) => s.routeMeta);
const directionChanges = useGeoStore((s) => s.directionChanges);
// v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용.
const storeStructuresRaw = useGeoStore((s) => s.structures);
// 시설종별 필터(설정 스토어) — 체크 변경 시 즉시 반영(구독).
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
const storeStructures = useMemo(
() => storeStructuresRaw.filter((s) => isGradeVisible(s.grade, gradeFilter)),
[storeStructuresRaw, gradeFilter],
);
const viewedRef = useRef<ViewedPoint[]>([]);
// 이동거리축: time[i] ↔ frac[i](누적 이동거리 비율 0~1, 단조 비감소).
// 드론의 '실제 이동거리'를 옆으로 펴서 배치 → 같은 측점이라도 이동량만큼 떨어져 보인다.
// 호버(드론 이동 無) 구간은 frac 정체 → 커서 정지. 방향(전진/후진)은 barSegments 색 리본으로 구분.
// 이동거리(누적) 축: time[i]↔frac[i](누적 이동거리 비율 0~1, 단조 비감소) → 커서 항상 우측 이동.
// depChain=출발측점, arrChain=도착측점 → 방향색 기준(도착방향=주황/반대=파랑).
const chainRef = useRef<{ time: Float64Array; frac: Float64Array; depChain: number; arrChain: number } | null>(null);
const [ready, setReady] = useState(false);
const stations = useMemo(
@@ -223,8 +233,9 @@ export function StationBar({
useEffect(() => {
const frames = storeFrames;
if (!frames.length || !stations.length || !stationLine) { setReady(false); return; }
const out: ViewedPoint[] = new Array(frames.length);
for (let i = 0; i < frames.length; i++) {
const n = frames.length;
const out: ViewedPoint[] = new Array(n);
for (let i = 0; i < n; i++) {
const f = frames[i];
out[i] = {
frameNum: f.frame,
@@ -233,6 +244,31 @@ export function StationBar({
time: f.frame / videoFps,
};
}
// 이동거리 축: 평활 측점값(±W 이동평균)의 프레임간 |Δ| 누적 = 실제 이동량 → 전체로 정규화(frac).
// 호버(이동 無)면 frac 정체 → 커서 정지. 이동하면(전진/후진 무관) frac 증가 → 커서 항상 우측.
// depChain/arrChain(앞·뒤 5% 평균)은 방향색 기준.
const W = 8; // 평활 반폭(프레임)
const pre = new Float64Array(n + 1);
for (let i = 0; i < n; i++) pre[i + 1] = pre[i] + out[i].chain;
const time = new Float64Array(n);
const sm = new Float64Array(n);
const frac = new Float64Array(n);
let cum = 0;
for (let i = 0; i < n; i++) {
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
sm[i] = (pre[hi] - pre[lo]) / (hi - lo);
if (i > 0) cum += Math.abs(sm[i] - sm[i - 1]);
time[i] = out[i].time;
frac[i] = cum;
}
const total = cum > 0 ? cum : 1;
for (let i = 0; i < n; i++) frac[i] /= total;
const seg = Math.max(1, Math.floor(n * 0.05));
let dep = 0, arr = 0;
for (let i = 0; i < seg; i++) { dep += sm[i]; arr += sm[n - 1 - i]; }
dep /= seg; arr /= seg;
if (Math.abs(arr - dep) < 1) arr = dep + 1; // 퇴화 방지
chainRef.current = { time, frac, depChain: dep, arrChain: arr };
viewedRef.current = out;
setReady(true);
}, [stations, stationLine, storeFrames, videoFps]);
@@ -273,35 +309,87 @@ export function StationBar({
// 시간→px 변환에 쓰는 유효 트랙폭 (미도착폭 제외).
const timeTrackWidth = TRACK_WIDTH_PX - endGapPx;
// 프레임(시간) 진행률 px — 커서의 단조 이동 기준.
// 영상은 노선을 시간순으로 한 번 통과(leg 01→10)하므로, 시간 진행률이 곧
// 트랙을 좌→우로 지나는 경로 진행률이다.
const progressPx =
duration > 0
? TRACK_START_PX + clamp(currentTime / duration, 0, 1) * timeTrackWidth
: TRACK_START_PX;
// 노선 진행방향 '상'|'하' — 동명 시설물의 (상)/(하) 변형 중 어느 쪽을 바에 표출할지 결정.
// 1순위 route.json direction, 없으면 directionChanges(상행↔하행 전환점)에서 가장 오래 지속된 방향.
const routeDirCh = useMemo<'상' | '하' | null>(() => {
const d = routeMeta?.routeInfo?.direction ?? '';
const up = d.includes('상'), down = d.includes('하');
if (up && !down) return '상';
if (down && !up) return '하';
const dc = directionChanges;
if (dc && dc.length && duration > 0) {
const acc: Record<string, number> = {};
let prevT = 0;
let cur = dc[0].from;
for (const c of dc) {
acc[cur] = (acc[cur] ?? 0) + Math.max(0, c.atSeconds - prevT);
prevT = c.atSeconds;
cur = c.to;
}
acc[cur] = (acc[cur] ?? 0) + Math.max(0, duration - prevT);
let best = '', bv = -1;
for (const k in acc) if (acc[k] > bv) { bv = acc[k]; best = k; }
if (best.includes('상') && !best.includes('하')) return '상';
if (best.includes('하') && !best.includes('상')) return '하';
}
return null;
}, [routeMeta, directionChanges, duration]);
// 커서 위치 = 프레임 진행률(progressPx)로만 단조 이동.
// 측점 km은 노선에서 여러 번 반복(증가↔감소)되어, km으로 px를 역산하면
// 같은 km의 여러 후보 중 하나로 스냅되며 구간 점프가 생긴다(사용자 보고).
// → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다.
const cursorPx = progressPx;
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시.
// mock ROUTE_LEGS(mileageAtPx) 의존 제거. 데이터 없으면 빈 문자열.
const cursorText =
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
// 진행도(이동거리축): 시간 t → 누적 이동거리 비율(frac, 0~1). chainRef 미준비 시 시간선형 폴백.
const cumFracAtTime = useCallback(
(t: number): number => {
const c = chainRef.current;
if (!c || !ready || duration <= 0)
return duration > 0 ? clamp(t / duration, 0, 1) : 0;
const { time, frac } = c;
const n = time.length;
if (n === 0) return 0;
if (t <= time[0]) return frac[0];
if (t >= time[n - 1]) return frac[n - 1];
let lo = 0, hi = n - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (time[m] <= t) lo = m; else hi = m; }
const span = time[hi] - time[lo];
const r = span > 0 ? (t - time[lo]) / span : 0;
return frac[lo] + (frac[hi] - frac[lo]) * r;
},
[ready, duration],
);
// 역변환(이동거리 비율 → 시간): 바 클릭 seek 용. frac 단조 비감소 → 이진 탐색.
const timeAtFrac = useCallback(
(f: number): number => {
const c = chainRef.current;
if (!c || !ready || duration <= 0) return clamp(f, 0, 1) * duration;
const { time, frac } = c;
const n = frac.length;
if (n === 0) return 0;
const ff = clamp(f, 0, 1);
if (ff <= frac[0]) return time[0];
if (ff >= frac[n - 1]) return time[n - 1];
let lo = 0, hi = n - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (frac[m] <= ff) lo = m; else hi = m; }
const span = frac[hi] - frac[lo];
const r = span > 0 ? (ff - frac[lo]) / span : 0;
return time[lo] + (time[hi] - time[lo]) * r;
},
[ready, duration],
);
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
// 시간(프레임) 진행을 트랙 px로 선형 변환.
// 시간(프레임) 트랙 px (이동거리축). 커서·구간색·구조물 마커가 이 매핑을 공유한다.
const pxAtTime = useCallback(
(t: number): number =>
duration > 0
? TRACK_START_PX + clamp(t / duration, 0, 1) * timeTrackWidth
: TRACK_START_PX,
[duration, timeTrackWidth],
TRACK_START_PX + clamp(cumFracAtTime(t), 0, 1) * timeTrackWidth,
[cumFracAtTime, timeTrackWidth],
);
// 커서 위치 = 이동거리축 진행도 → 전진/후진 무관하게 항상 우측으로 이동. 색만 방향에 따라 바뀜.
const progressPx = pxAtTime(currentTime);
const cursorPx = progressPx;
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열.
const cursorText =
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
// viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
// km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
const { barSegments, kmLabels } = useMemo<{
@@ -309,25 +397,35 @@ export function StationBar({
kmLabels: KmLabel[];
}>(() => {
const arr = viewedRef.current;
if (!ready || !arr.length || duration <= 0)
const n = arr.length;
if (!ready || !n || duration <= 0)
return { barSegments: [], kmLabels: [] };
// 방향은 연속 체이니지(chain)로 판정 — 100m 양자화 km은 전환을 ~13s 빨리 잡아 영상과 어긋남.
const HYST = 100; // m — 이 이상 반대로 움직여야 방향 전환으로 인정 (GPS 노이즈 무시)
// 측점값(chain)을 ±W 이동평균으로 평활 → GPS 지터 제거. 평활했으므로 HYST를 작게 잡아
// '목적지에 가까워짐/멀어짐'의 작은 추세 전환(20m+)도 구간으로 잡는다. (커서 배지 = 그 구간 색)
const W = 8;
const pre = new Float64Array(n + 1);
for (let i = 0; i < n; i++) pre[i + 1] = pre[i] + arr[i].chain;
const sm = new Float64Array(n);
for (let i = 0; i < n; i++) {
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
sm[i] = (pre[hi] - pre[lo]) / (hi - lo);
}
const HYST = 10; // m — 평활 후 기준. 이 이상 반대로 움직이면 방향(가까워짐↔멀어짐) 전환.
const segs: BarSegment[] = [];
const bounds: { chain: number; time: number; turn: boolean }[] = [
{ chain: arr[0].chain, time: arr[0].time, turn: false },
{ chain: sm[0], time: arr[0].time, turn: false },
];
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정. 기본 증가 가정이 틀리면 첫 구간 색이 반대로 나옴.
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정.
let dir: 1 | -1 = 1;
for (let i = 1; i < arr.length; i++) {
const d = arr[i].chain - arr[0].chain;
for (let i = 1; i < n; i++) {
const d = sm[i] - sm[0];
if (Math.abs(d) >= HYST) { dir = d > 0 ? 1 : -1; break; }
}
let extCh = arr[0].chain;
let extCh = sm[0];
let extIdx = 0;
let startIdx = 0;
for (let i = 1; i < arr.length; i++) {
const c = arr[i].chain;
for (let i = 1; i < n; i++) {
const c = sm[i];
if (dir > 0 ? c > extCh : c < extCh) {
extCh = c;
extIdx = i;
@@ -346,10 +444,10 @@ export function StationBar({
}
segs.push({
startPx: pxAtTime(arr[startIdx].time),
endPx: pxAtTime(arr[arr.length - 1].time),
endPx: pxAtTime(arr[n - 1].time),
dir,
});
bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time, turn: false });
bounds.push({ chain: sm[n - 1], time: arr[n - 1].time, turn: false });
// 전환(턴) 지점은 실제 위치를 10m 단위로, 시·종점 등 기본 라벨은 100m 단위로 표시.
const labels: KmLabel[] = bounds.map((b) => ({
px: pxAtTime(b.time),
@@ -469,50 +567,71 @@ export function StationBar({
if (!poiByName.has(base)) poiByName.set(base, { lat: p.lat, lon: p.lon, category: p.category });
}
// 첫·마지막 스테이션(역사)을 트랙 양끝에 고정 표출.
// 역사 마커 중 최좌측 → 트랙 시작(TRACK_START_PX), 최우측 → 트랙 끝(TRACK_END_PX)으로 스냅.
// (드론 항로가 역사에서 떨어져 지나가도 첫/끝 역이 바 양끝에 앵커되도록.)
const snapStationEnds = (marks: StructMark[]): StructMark[] => {
const st = marks
.map((m, i) => ({ i, px: m.px }))
.filter((_, i) => marks[i].category === '역사' || marks[i].category === '역');
if (st.length < 2) return marks;
let lo = st[0], hi = st[0];
for (const x of st) {
if (x.px < lo.px) lo = x;
if (x.px > hi.px) hi = x;
}
if (lo.i !== hi.i) {
marks[lo.i] = { ...marks[lo.i], px: TRACK_START_PX };
// 종점역: 끝(END)에 두되, 미도착폭이 설정돼 있으면 '미도착'으로 표시
// (영상 진행바·커서는 END−endGapPx 까지만 도달 → 끝의 회색 구간 = 미도착).
marks[hi.i] = { ...marks[hi.i], px: TRACK_END_PX, unreached: endGapPx > 0 };
// 양끝 스냅 제거: 각 역사/구조물은 '실제 통과 위치(시간축 px)' 그대로 둔다
// 마커 위치가 그 지점 커서 측점값과 일치(스냅으로 인한 측점 어긋남 해소).
// 종점역 '미도착'만 별도 처리: 영상이 종점 측점에 못 미치면(endGapPx>0) 종점역(최우측 역사)을
// 트랙 끝(TRACK_END = 미도착 gap 안, 실제 종점 측점 위치)에 두고 미도착 스타일로 표시한다.
const placeUnreachedTerminal = (marks: StructMark[]): StructMark[] => {
if (endGapPx <= 0) return marks;
let hiIdx = -1;
let hiPx = -Infinity;
for (let i = 0; i < marks.length; i++) {
if ((marks[i].category === '역사' || marks[i].category === '역') && marks[i].px > hiPx) {
hiPx = marks[i].px;
hiIdx = i;
}
}
if (hiIdx >= 0) marks[hiIdx] = { ...marks[hiIdx], px: TRACK_END_PX, unreached: true };
return marks;
};
// 경로상 시설물만: 시설물명에 방향표기((상…/(하…)가 있으면 영상 진행방향과 일치하는 것만.
// 하행이면 '(상…' 제외, 상행이면 '(하…' 제외. (예: 회덕천교(상)/(상인상)은 하행 영상에서 숨김)
// 진행방향(routeDirCh)의 반대 변형은 바에서 제외 → 상행이면 (상), 하행이면 (하) 만 남는다.
const oppDirCh = routeDirCh === '하' ? '상' : routeDirCh === '상' ? '하' : '';
const isOppositeDir = (name: string): boolean =>
!!oppDirCh && new RegExp(`[(]${oppDirCh}`).test(name);
// v2.0: CSV 유래 구조물(storeStructures) 우선. route.json 보정은 geoData 에서 이미 병합됨.
if (storeStructures && storeStructures.length) {
const out: StructMark[] = [];
for (const s of storeStructures) {
// 구교(06)는 영상 오버레이에 표출 → 스테이션바에서는 제외(교량/터널만).
if (s.category === '구교') continue;
if (isOppositeDir(s.name)) continue; // 반대 방향(다른 선로) 시설물 제외
const cat = s.type === 'tunnel' ? '터널' : s.type === 'bridge' ? '교량' : '역사';
const sBase = s.name.replace(/\s*[(].*$/, '').trim();
// 이름 매칭 POI 실좌표 우선 → 없으면 route.json 이정값 폴백.
const match =
poiByName.get(sBase) ??
[...poiByName.entries()].find(([k]) => k.includes(sBase) || sBase.includes(k))?.[1];
// 우선순위: station(측점값) → 직접 좌표 → POI 이름매칭 → 이정값 폴백.
// 우선순위: station(측점값) → 좌표를 측점선에 투영한 '측점 기준' 탐지 → 좌표 근접(최후 폴백) → 이정값.
// 각 통과 지점마다 마커(드론이 2번 지나면 2개). 동명 시설물은 각자 station 으로 구분.
// ※ 좌표 근접(pxPassesTo)은 출발점 부근 등에서 엉뚱한 '조기 통과'를 잡아 마커가 앞쪽(좌측)으로
// 잘못 배치될 수 있다 → 좌표를 측점값으로 환산해 station-기준으로 탐지하면 실제 통과순서와 일치.
let passes: { px: number; km: number }[] = [];
const off = s.offset ?? 0;
if (s.station != null) {
const sM = mileageToMeters(s.station);
if (sM >= 0) passes = pxPassesAtMileage(sM);
}
if (!passes.length && s.lat != null && s.lon != null && stationLine)
passes = pxPassesAtMileage(projectChainage(s.lat, s.lon, stationLine));
if (!passes.length && match && stationLine)
passes = pxPassesAtMileage(projectChainage(match.lat, match.lon, stationLine));
if (!passes.length && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
if (!passes.length && match) passes = pxPassesTo(match.lat, match.lon, off);
// 역사(역) 통과 보강: 측점선 투영이 불안정한 종점/조차장 구역에서는 측점-기준 탐지가
// 일부 통과를 놓친다(예: 대전조차장 162k080 재진입은 투영 측점이 ±20m 밖이라 누락).
// 이미 한 통과를 찾으면 위 단락평가로 좌표근접 탐지가 안 돌아 영영 빠지므로, 여기서
// 좌표근접(pxPassesTo) 통과를 '합집합'으로 합쳐 빠진 재진입까지 마커를 찍는다.
// 같은 통과(거의 같은 px)는 24px 임계로 중복 제거 → 멀리 떨어진 실제 재진입만 추가됨.
if (cat === '역사' && s.lat != null && s.lon != null) {
for (const cp of pxPassesTo(s.lat, s.lon, off)) {
if (!passes.some((p) => Math.abs(p.px - cp.px) < 24)) passes.push(cp);
}
passes.sort((a, b) => a.px - b.px);
}
if (!passes.length && s.startMileage != null && s.endMileage != null) {
const mid = (s.startMileage + s.endMileage) / 2;
const px = pxAtMileage(mid);
@@ -522,33 +641,54 @@ export function StationBar({
// 역은 바 양끝에 스냅되므로, 시작/끝 커서값(= 역 위치 측점)과 일치시키기 위함.
const stationKmVal = cat === '역사' && s.lat != null && s.lon != null && stationLine
? projectChainage(s.lat, s.lon, stationLine) : null;
for (const p of passes) out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, props: s.props });
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
for (const p of passes) {
// 표시 측점값(stationKmVal)과 그 통과의 실제 측점(p.km)이 허용오차 밖이면(=좌표 반경만으로
// 잡힌 통과, 그 위치엔 실제로 그 측점값이 없음) 측점값 라벨/검색에서 제외. 마커(동그라미)는 유지.
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
}
}
return snapStationEnds(out);
return placeUnreachedTerminal(out);
}
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
const out: StructMark[] = [];
for (const [base, info] of poiByName) {
if (isOppositeDir(base)) continue; // 반대 방향(다른 선로) 시설물 제외
const stationKmVal = info.category === '역사' && stationLine
? projectChainage(info.lat, info.lon, stationLine) : null;
for (const p of pxPassesTo(info.lat, info.lon)) {
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km });
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
// 측점 기준 탐지 우선(통과순서 정확), 측점선 없으면 좌표 근접 폴백.
const ps = stationLine
? pxPassesAtMileage(projectChainage(info.lat, info.lon, stationLine))
: pxPassesTo(info.lat, info.lon);
for (const p of ps) {
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists });
}
}
return snapStationEnds(out);
return placeUnreachedTerminal(out);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
// 방향 색 트랙 gradient 빌더(전진/후진 색을 받아 구성). 구간 경계는 부드럽게 섞는다.
// 여정 정방향(목적지=도착 방향): 측점값이 출발(depChain)→도착(arrChain) 으로 변하는 방향.
// 그 방향으로 움직이는 구간=정방향(황색), 반대=역방향(청색).
const forwardDir = useMemo<1 | -1>(() => {
const c = chainRef.current;
if (!ready || !c) return 1;
return c.arrChain >= c.depChain ? 1 : -1;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, storeFrames]);
// 방향 색 트랙 gradient — 이동거리축은 구간 px가 좌→우 단조라 gradient 1장으로 충분(경량).
// 정방향(도착방향)=FWD, 역방향=BWD. 구간 경계는 하드 스톱.
const buildGradient = useCallback(
(FWD: string, BWD: string): string => {
const segs = barSegments;
if (!segs.length) return '';
const col = (d: 1 | -1) => (d > 0 ? FWD : BWD);
const pct = (px: number) =>
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
// 전환점에서 색이 '한번에' 바뀌도록 하드 스톱(같은 위치에 두 색).
const col = (d: 1 | -1) => (d === forwardDir ? FWD : BWD);
const pct = (px: number) => clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
const stops: string[] = [`${col(segs[0].dir)} 0%`];
for (let i = 1; i < segs.length; i++) {
const bp = pct(segs[i].startPx).toFixed(2);
@@ -558,31 +698,27 @@ export function StationBar({
stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
return `linear-gradient(to right, ${stops.join(', ')})`;
},
[barSegments, duration],
[barSegments, forwardDir],
);
// 통과 구간 음영 그라데이션(videoplayer 원본): 구간마다 좌→우 3색 셰이딩, 구간 경계는 하드.
// 전진 주황: #ffc257 → #ff8a25 → #ff7b1b / 역방향 청록: #5ca887 → #35a7a7 → #06a4c8
// 통과 음영(구간마다 좌→우 3색). 정방향 주황 / 역방향 청록.
const buildShaded = useCallback(
(fwd: [string, string, string], bwd: [string, string, string]): string => {
const segs = barSegments;
if (!segs.length) return '';
const cols = (d: 1 | -1) => (d > 0 ? fwd : bwd);
const pct = (px: number) =>
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
const cols = (d: 1 | -1) => (d === forwardDir ? fwd : bwd);
const pct = (px: number) => clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
const stops: string[] = [];
for (const s of segs) {
const a = pct(s.startPx);
const b = pct(s.endPx);
const c = cols(s.dir);
const a = pct(s.startPx), b = pct(s.endPx), c = cols(s.dir);
stops.push(`${c[0]} ${a.toFixed(2)}%`);
stops.push(`${c[1]} ${((a + b) / 2).toFixed(2)}%`);
stops.push(`${c[2]} ${b.toFixed(2)}%`);
}
return `linear-gradient(to right, ${stops.join(', ')})`;
},
[barSegments],
[barSegments, forwardDir],
);
// 통과(지나간): 구간별 음영 그라데이션. 미통과: 단색(전진 회색 / 역방향 청회색).
// 통과(지나간): 음영. 미통과: 단색(정방향 회색 / 역방향 청회색).
const trackGradient = useMemo(
() => buildShaded(['#ffc257', '#ff8a25', '#ff7b1b'], ['#5ca887', '#35a7a7', '#06a4c8']),
[buildShaded],
@@ -591,13 +727,14 @@ export function StationBar({
// 방향(색)이 바뀌는 전환점 px — 구분선 위치.
const dividers = useMemo(() => barSegments.slice(1).map((s) => s.startPx), [barSegments]);
// 현재 위치가 역방향(km 감소, 하늘색) 구간이면 커서도 파란색.
// 커서 배지 색 = 현재 위치한 '구간' 색과 동일(바 배경과 일치). 역방향(목적지 반대) 구간이면 파란색.
// → 배지·바 배경이 항상 같은 색(기존 방식). 구간 색은 forwardDir 기준 정/역방향.
const currentReverse = useMemo<boolean>(() => {
for (const s of barSegments) {
if (cursorPx >= s.startPx && cursorPx <= s.endPx) return s.dir < 0;
if (cursorPx >= s.startPx && cursorPx <= s.endPx) return s.dir !== forwardDir;
}
return false;
}, [barSegments, cursorPx]);
}, [barSegments, cursorPx, forwardDir]);
// 컨테이너 폭 기준 균등 스케일
useEffect(() => {
@@ -638,7 +775,7 @@ export function StationBar({
const el = wrapRef.current;
if (el && duration > 0) {
const t = timeRef.current ?? 0;
const pos = TRACK_START_PX + clamp(t / duration, 0, 1) * timeTrackWidth;
const pos = pxAtTime(t); // 이동거리축: 현재 진행도 → px
el.style.setProperty('--pos-px', `${pos}px`);
el.style.setProperty('--cursor-x', `${renderX(pos)}px`);
}
@@ -646,18 +783,17 @@ export function StationBar({
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [timeRef, duration, timeTrackWidth]);
}, [timeRef, duration, pxAtTime]);
// 클릭/드래그 트랙 px → 시간(프레임) 선형 변환으로 seek (시간축 일관).
// 클릭/드래그 트랙 px → 이동거리 비율 → 시간(역변환)으로 seek (이동거리축 일관).
const seekToTrackX = useCallback(
(trackX: number) => {
if (duration <= 0) return;
const px = clamp(trackX, TRACK_START_PX, TRACK_END_PX);
// 미도착 구간(ENDendGapPx 우측)을 클릭하면 영상 끝(duration)으로 클램프.
const targetTime = ((px - TRACK_START_PX) / timeTrackWidth) * duration;
const targetTime = timeAtFrac((px - TRACK_START_PX) / timeTrackWidth);
onSeek(clamp(targetTime, 0, duration));
},
[duration, onSeek, timeTrackWidth],
[duration, onSeek, timeTrackWidth, timeAtFrac],
);
const seekFromClientX = useCallback(
@@ -709,31 +845,74 @@ export function StationBar({
// 측점입력(예: 158k200) → 그 측점을 보는(최근접) 프레임으로 seek (실데이터 기반).
// 해당 영상이 커버하는 측점(체이니지) 범위 밖이면 무시(seek 안 함).
// 측점 검색 순환 상태 — 같은 측점값을 연속 Enter 할 때 '직전 점프 위치'에서 다음으로 이어가기 위함.
const jumpRef = useRef<{ km: number; time: number } | null>(null);
const handleJumpToMileage = useCallback(
(km: number) => {
const arr = viewedRef.current;
if (!arr.length || duration <= 0) return;
// 연속 체이니지(chain) 기준으로 입력 측점에 가장 가까운 프레임 탐색 + 커버 범위 산출.
let best = -1;
let bd = Infinity;
let lo = Infinity;
let hi = -Infinity;
// 커버 측점 범위 산출 (구간 밖 입력은 무시).
let lo = Infinity, hi = -Infinity;
for (let i = 0; i < arr.length; i++) {
const c = arr[i].chain;
if (c < lo) lo = c;
if (c > hi) hi = c;
const d = Math.abs(c - km);
if (d < bd) {
bd = d;
best = i;
}
}
// 구간 밖(커버 측점 범위 ± 여유 20m)이면 무시.
const MARGIN = 20;
if (km < lo - MARGIN || km > hi + MARGIN) return;
if (best >= 0) onSeek(clamp(arr[best].time, 0, duration));
// 1순위: '화면에 보이는 마커(structureMarks)' 중 표시 측점값이 일치하는 것들의 시각.
// → 마커는 좌표근접 탐지까지 포함하므로(조차장 등 chain 이 어긋난 통과 포함) 검색이 모든
// 동그라미 위치로 이동 가능. px 는 timeAtFrac 로 시각 역변환. 매칭은 표시(10m 반올림) 일치.
const tgt10 = Math.round(km / 10);
let times: number[] = structureMarks
.filter((m) => m.km >= 0 && m.kmExists !== false && Math.round(m.km / 10) === tgt10)
.map((m) => timeAtFrac((m.px - TRACK_START_PX) / timeTrackWidth));
// 2순위(마커 없음=임의 측점): 연속 체이니지 기준 통과(허용오차 내 연속구간=1통과).
if (!times.length) {
const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
let i = 0;
while (i < arr.length) {
if (Math.abs(arr[i].chain - km) < TOL) {
let j = i, bj = i, bd = Math.abs(arr[i].chain - km);
while (j < arr.length && Math.abs(arr[j].chain - km) < TOL) {
const d = Math.abs(arr[j].chain - km);
if (d < bd) { bd = d; bj = j; }
j++;
}
times.push(arr[bj].time);
i = j;
} else i++;
}
if (!times.length) {
let best = 0, bd = Infinity;
for (let k = 0; k < arr.length; k++) {
const d = Math.abs(arr[k].chain - km);
if (d < bd) { bd = d; best = k; }
}
times.push(arr[best].time);
}
}
// 시간 오름차순 + 근접 중복 제거(같은 통과가 두 경로/마커로 잡힌 경우).
times.sort((a, b) => a - b);
const passes: number[] = [];
for (const t of times) if (!passes.length || t - passes[passes.length - 1] > 0.3) passes.push(t);
// 기준 시각: 같은 측점을 '직전 점프 위치 그대로'에서 다시 Enter 하면 연속(다음 통과),
// 재생/클릭으로 커서가 움직였거나 다른 측점이면 현재 커서(라이브) 시각에서 새로 시작.
const liveT = timeRef?.current ?? currentTime;
const same = !!jumpRef.current
&& Math.abs(jumpRef.current.km - km) < 1e-6
&& Math.abs(liveT - jumpRef.current.time) < 0.1;
const baseT = same ? jumpRef.current!.time : liveT;
// 통과방향(시간 증가)으로 baseT 다음 통과. 끝까지 가면 처음으로 순환.
const EPS = 1e-3;
const target = passes.find((t) => t > baseT + EPS) ?? passes[0];
jumpRef.current = { km, time: target };
onSeek(clamp(target, 0, duration));
},
[duration, onSeek],
[duration, onSeek, routeMeta, currentTime, timeRef, structureMarks, timeAtFrac, timeTrackWidth],
);
return (
@@ -764,11 +943,8 @@ export function StationBar({
startStationName={startStationName}
endStationName={endStationName}
endGapPx={endGapPx}
routeDir={
routeMeta?.routeInfo?.direction?.includes('하') ? '하'
: routeMeta?.routeInfo?.direction?.includes('상') ? '상'
: null
}
routeDir={routeDirCh}
overlapExclude={poiOverlapExclude}
/>
</div>
<TimelineCursor
@@ -35,12 +35,13 @@ export function PlaybackControls({
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key !== 'Enter') return;
const mileage = parseMileageQuery(query);
if (mileage !== null) {
onJumpToMileage(mileage);
setQuery('');
}
// Enter 후에도 입력값 유지 → 같은 측점 재Enter 시 통과방향 다음 위치로 순환 검색.
if (mileage !== null) onJumpToMileage(mileage);
};
// 포커스를 잃으면(다른 곳 클릭 등) 입력값 삭제.
const handleQueryBlur = (): void => setQuery('');
return (
<div className={styles.controlsRow}>
<div className={styles.transportGroup}>
@@ -67,6 +68,7 @@ export function PlaybackControls({
value={query}
onChange={handleQueryChange}
onKeyDown={handleQueryKeyDown}
onBlur={handleQueryBlur}
placeholder="측점입력"
/>
</div>
@@ -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>
+20
View File
@@ -22,6 +22,18 @@ interface SettingsStore {
/** 화면 POI 겹침제외(겹치면 1개만). true=겹친 것 숨김(기본), false=모든 POI 표시. */
poiOverlapExclude: boolean;
setPoiOverlapExclude: (v: boolean) => void;
/** 좌측 노선 패널(RoutePanel) 표시 여부. '화면표시 옵션' 토글로 제어. 기본 표시. */
showRoutePanel: boolean;
setShowRoutePanel: (v: boolean) => void;
/** 측점 진단 HUD(드론 GPS·투영측점) 하단바 표시 여부. 기본 off. */
showStationDiag: boolean;
setShowStationDiag: (v: boolean) => void;
/** 선형(중심선) 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
showCenterline: boolean;
setShowCenterline: (v: boolean) => void;
/** 드론 궤적 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
showDronePath: boolean;
setShowDronePath: (v: boolean) => void;
}
const DEFAULT_GRADE_FILTER: Record<string, boolean> = {
@@ -39,6 +51,14 @@ export const useSettingsStore = create<SettingsStore>()(
set((s) => ({ gradeFilter: { ...s.gradeFilter, [grade]: checked } })),
poiOverlapExclude: true,
setPoiOverlapExclude: (v) => set({ poiOverlapExclude: v }),
showRoutePanel: true,
setShowRoutePanel: (v) => set({ showRoutePanel: v }),
showStationDiag: false,
setShowStationDiag: (v) => set({ showStationDiag: v }),
showCenterline: true,
setShowCenterline: (v) => set({ showCenterline: v }),
showDronePath: true,
setShowDronePath: (v) => set({ showDronePath: v }),
}),
{
name: 'ghivideo.settings',
+64
View File
@@ -0,0 +1,64 @@
/** 측점값(체이니지) 공유 유틸 — 드론 GPS 를 측점 폴리라인에 투영해 측점값/최근접측점 산출.
* StationBar(배지)·VideoPlayer(측점 진단 HUD) 가 동일 계산을 공유한다. */
export interface ChainLine {
pts: { x: number; y: number; km: number; lat: number; lon: number; title: string }[];
k: number; // 경도→m 환산 (cos(lat0)*111000)
}
/** "157K970" → 157970(m). 실패 시 -1. */
export function kmFromTitle(title: string): number {
const m = title.match(/(\d+)[Kk](\d+)/);
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
}
/** 미터값 → "157k970" (10m 단위). */
export function fmtKm10(m: number): string {
const r = Math.round(m / 10) * 10;
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
}
/** 측점 POI 목록 → 평면투영 폴리라인(km 오름차순). 측점이 없으면 null. */
export function buildChainLine(stations: { title: string; lat: number; lon: number }[]): ChainLine | null {
const sts = stations.filter((s) => kmFromTitle(s.title) >= 0);
if (!sts.length) return null;
const sorted = [...sts].sort((a, b) => kmFromTitle(a.title) - kmFromTitle(b.title));
const lat0 = sorted.reduce((s, p) => s + p.lat, 0) / sorted.length;
const k = Math.cos((lat0 * Math.PI) / 180) * 111000;
return {
pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: kmFromTitle(p.title), lat: p.lat, lon: p.lon, title: p.title })),
k,
};
}
/** 드론 lat/lon 을 폴리라인에 투영 → { km(측점값), offsetM(선로 수직이격) }. */
export function projectToChain(lat: number, lon: number, line: ChainLine): { km: number; offsetM: number } {
const px = lon * line.k;
const py = lat * 111000;
const pts = line.pts;
let bestD = Infinity;
let bestKm = pts.length ? pts[0].km : -1;
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
const dx = b.x - a.x, dy = b.y - a.y, L2 = dx * dx + dy * dy;
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / L2));
const cx = a.x + dx * t, cy = a.y + dy * t;
const d = (px - cx) ** 2 + (py - cy) ** 2;
if (d < bestD) { bestD = d; bestKm = a.km + (b.km - a.km) * t; }
}
return { km: bestKm, offsetM: Math.sqrt(bestD) };
}
/** 드론 lat/lon 의 최근접 측점(점) → { title, km, distM }. */
export function nearestChainPoint(lat: number, lon: number, line: ChainLine): { title: string; km: number; distM: number } | null {
const pts = line.pts;
if (!pts.length) return null;
let nIdx = 0, nD = Infinity;
for (let i = 0; i < pts.length; i++) {
const dx = (lat - pts[i].lat) * 111000;
const dy = (lon - pts[i].lon) * line.k;
const d = dx * dx + dy * dy;
if (d < nD) { nD = d; nIdx = i; }
}
return { title: pts[nIdx].title, km: pts[nIdx].km, distM: Math.sqrt(nD) };
}