diff --git a/client/src/components/overlay/Minimap.tsx b/client/src/components/overlay/Minimap.tsx new file mode 100644 index 0000000..76e7102 --- /dev/null +++ b/client/src/components/overlay/Minimap.tsx @@ -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(function Minimap(_props, ref) { + return ( +
+ {/* 고정 — 배경 원 + 외곽 링(두껍게). 프레임만 고정, 그 외 전부 회전. */} + + + + + {/* 회전 카드 — 눈금 + N + 빨간 바늘(N) + 흰 삼각형(S) + 빨간 눈금. 전체가 함께 회전. */} + + {TICKS.map((t) => ( + + ))} + {/* 빨간 화살표 — N(정북). 끝(tip)이 테두리에 맞닿고, 기단은 중심 쪽. */} + + {/* N 문자 — 빨간 화살표 '안쪽'(중심 방향). */} + + N + + {/* 흰 삼각형 — S방향, 빨간 화살표와 '정반대'(아래로). 끝이 하단 테두리에 맞닿음. */} + + {/* 중심 점 */} + + +
+ ); +}); diff --git a/client/src/components/overlay/RoutePanel.tsx b/client/src/components/overlay/RoutePanel.tsx index 0afb8f4..74a8df6 100644 --- a/client/src/components/overlay/RoutePanel.tsx +++ b/client/src/components/overlay/RoutePanel.tsx @@ -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(); 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 (
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 (
; @@ -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(null); const canvasSizeRef = useRef({ w: 0, h: 0 }); @@ -262,6 +265,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const renderCacheRef = useRef(null); // 나침반 전용(측점선 visible 무관 항상 갱신·표시) const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null); + // 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩). + const minimapRef = useRef(null); + const minimapRotRef = useRef(0); // UI state const [params, setParams] = useState(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(); const seenPoi = new Set(); - // 측점 라벨 — 연속 포즈로 직접 투영 → 이상치 거부 → 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 && } + {/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명. 컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). */} {infoPopups.map(pp => { @@ -1475,37 +1484,29 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible })} {showPanel && ( -
-
+
+
+ {/* 두 패널을 가로로 나란히(우→좌) 펼침 — 둘 다 열어도 세로로 안 쌓여 화면 위로 안 넘침. */} +
{/* ── 화면표시 옵션 (카메라 외 모든 표시 설정) ── */} {showDisplay && (
표시 토글
- - - {showDronePath && ( - <> - setDronePathZ(Math.round(v))} /> - setDronePathAlpha(v)} /> - - )} + {/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */} + {/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */} + setDronePathZ(Math.round(v))} /> + setDronePathAlpha(v)} />
스무딩 (재계산 500ms 후)
@@ -1630,6 +1631,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
)} +
)} diff --git a/client/src/components/player/VideoPlayer.tsx b/client/src/components/player/VideoPlayer.tsx index f904ded..9d19f53 100644 --- a/client/src/components/player/VideoPlayer.tsx +++ b/client/src/components/player/VideoPlayer.tsx @@ -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 { + 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; @@ -96,11 +117,20 @@ const VideoPlayer = forwardRef( }, [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( 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( 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 (
( /> {/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */} - {/* 좌하단 그룹(재생바 위): 위→아래 = 배속 → 토글(측점선/영상제어) + 프레임정보 */} + {/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */} {source && (
- {/* 재생 배속 (영상제어 ON) */} + {/* 영상제어 토글 — 버튼만, 고정폭(가장 긴 'OFF' 기준이라 ON/OFF로 폭이 안 변함) */} + {showVideoControls && ( -
- 배속 - {[0.5, 1, 1.5, 2, 3, 4].map((r) => ( - - ))} -
+ <> + {/* 배속 패널 (영상제어 우측) */} +
+ 배속 + {[0.5, 1, 1.5, 2, 3, 4].map((r) => ( + + ))} +
+ {/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */} + + {secondsToTimecode(currentTime)} | F{frame} | {fps}fps + + {geoLoaded && ( + <> + {/* 좌측 패널 토글 — 선형 왼편, 동일 폭(min-w-[96px]) */} + + {/* 선형(중심선) 토글 — 겹침제외와 동일 폭(min-w-[96px]) */} + + {/* 드론 궤적 토글 — 겹침제외와 동일 폭(min-w-[96px]) */} + + {/* 드론 위치정보 — 드론궤적 버튼 바로 우측에 붙임. GPS·고도 항상 표시. */} + {stationDiag && ( +
+ GPS {stationDiag.f.lat.toFixed(6)}, {stationDiag.f.lon.toFixed(6)} + · 절대고도 {stationDiag.f.altitude.toFixed(1)}m +
+ )} + {/* 겹침제외 토글 — UI 숨김(기능/상태는 보존, 기본 ON). 다시 보이려면 주석 해제. */} + {/* */} + {/* 시설등급 패널 */} +
+ 시설등급 + {KNOWN_GRADES.map((g) => ( + + ))} +
+ + )} + )} - {/* 한 줄(좌→우): 영상제어 토글 → 프레임표시 패널 → POI옵션 패널 → 시설등급 패널 */} -
- - {showVideoControls && ( - <> - {/* 영상 프레임 표시 패널 */} - - {secondsToTimecode(currentTime)} | F{frame} | {fps}fps - - {geoLoaded && ( - <> - {/* POI 옵션 패널 (시설등급 왼쪽) — 겹침제외 토글 */} -
- POI 옵션 - -
- {/* 시설등급 패널 (프레임표시 우측) */} -
- 시설등급 - {KNOWN_GRADES.map((g) => ( - - ))} -
- - )} - - )} -
)} {/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */} playerRef.current?.currentTime(time)} topPx={paramTop + 40} bottomPx={(barHeight || 130) + 90} @@ -357,6 +439,7 @@ const VideoPlayer = forwardRef( onTogglePlay={handleTogglePlay} showPanel={showVideoControls && !!source} topPx={paramTop} + barHeight={barHeight} /> {/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */} diff --git a/client/src/stationbar/StationBar.module.scss b/client/src/stationbar/StationBar.module.scss index 1062590..b23a825 100755 --- a/client/src/stationbar/StationBar.module.scss +++ b/client/src/stationbar/StationBar.module.scss @@ -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; } diff --git a/client/src/stationbar/StationBar.tsx b/client/src/stationbar/StationBar.tsx index 8abbef3..2f24f8f 100755 --- a/client/src/stationbar/StationBar.tsx +++ b/client/src/stationbar/StationBar.tsx @@ -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([]); + // 이동거리축: 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 = {}; + 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(() => { 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); - // 미도착 구간(END−endGapPx 우측)을 클릭하면 영상 끝(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} />
): 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 (
@@ -67,6 +68,7 @@ export function PlaybackControls({ value={query} onChange={handleQueryChange} onKeyDown={handleQueryKeyDown} + onBlur={handleQueryBlur} placeholder="측점입력" />
diff --git a/client/src/stationbar/components/Timeline/Timeline.tsx b/client/src/stationbar/components/Timeline/Timeline.tsx index a619a82..8d3dbe7 100755 --- a/client/src/stationbar/components/Timeline/Timeline.tsx +++ b/client/src/stationbar/components/Timeline/Timeline.tsx @@ -51,9 +51,9 @@ function splitStructLabel(text: string): string[] { interface TimelineProps { posPx: number; onSeekDown: (e: MouseEvent) => 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(); 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(); + 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({ {/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색. 전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
- {/* 미재생(미통과): 회색/청회색 (전체 폭) */} + {/* 미재생(미통과): 방향별 저톤 미리보기 (전체 폭) */}
- {/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체. - 재생영역(회색 idle)보다 약간 밝은 단색 배경으로만 구분(빗금 없음). */} + {/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체. */} {endGapPx > 0 && (
)} - {/* 재생된 구간: 원래 색 복원 (커서까지 clip). 폭은 CSS 변수(--pos-px)로 매 프레임 - 직접 갱신되어 React 리렌더 없이 부드럽게 늘어난다. */} + {/* 재생된 구간: 원래 색 복원 (커서까지 clip, --pos-px CSS변수로 매 프레임 갱신). */}
{structs.map((s, i) => - s.km >= 0 ? ( + s.km >= 0 && showMileage[i] ? ( - {/* 구조물 클릭 핫스팟 — 클릭 시 속성 팝업(회덕화물역 등 역사 포함). seekArea(z20) 위로. */} -
- {structs.map((s, i) => - s.props && s.props.length ? ( -
{ 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 가 위에 그려져 글자가 선을 덮는다. */} +
+ {labelGroups + .filter((g) => g.bracketPxs.length > 1) + .map((g, gi) => { + const lo = g.bracketPxs[0]; + const hi = g.bracketPxs[g.bracketPxs.length - 1]; + // 라벨: 절대 top 53, 줄높이 18px(structName). 문자 세로 중앙 y = 53 + 줄수*9. + const lineCount = splitStructLabel(g.title).length; + const lineY = 53 + lineCount * 9; + const dash = '1.5px dashed rgba(232, 232, 232, 0.6)'; + return ( + + {/* 문자 세로 중앙을 지나는 수평 점선 (라벨 뒤) */} +
+ {/* 각 통과 수직 점선 (트랙 하단 → 수평선) */} + {g.bracketPxs.map((bp, j) => ( +
+ ))} + + ); + })}
- {/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄. */} + {/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄. + 같은 항목 다중통과는 labelGroups(클러스터)로 묶여 클러스터당 라벨 1개만 표출. */}
- {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 (
{lines.length === 1 ? lines[0] : <>{lines[0]}
{lines[1]}}
diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index fabfa16..a2d601b 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -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 = { @@ -39,6 +51,14 @@ export const useSettingsStore = create()( 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', diff --git a/client/src/utils/chainage.ts b/client/src/utils/chainage.ts new file mode 100644 index 0000000..734b729 --- /dev/null +++ b/client/src/utils/chainage.ts @@ -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) }; +} diff --git a/docs/assets/report.css b/docs/assets/report.css new file mode 100644 index 0000000..350b375 --- /dev/null +++ b/docs/assets/report.css @@ -0,0 +1,77 @@ +@page { + size: A4; + margin: 18mm 16mm 16mm 16mm; + @bottom-center { + content: counter(page) " / " counter(pages); + font-family: "Malgun Gothic", sans-serif; + font-size: 9pt; + color: #999; + } +} + +html { font-size: 11pt; } + +body { + font-family: "Malgun Gothic", "Hancom Gothic", sans-serif; + color: #23272e; + line-height: 1.65; + max-width: 920px; + margin: 0 auto; + padding: 24px; +} + +h1 { + font-size: 1.7rem; + color: #b45309; /* amber-700 — 앱 활성색 계열 */ + border-bottom: 3px solid #f59e0b; /* amber-500 */ + padding-bottom: 8px; + margin: 0 0 4px; +} + +h2 { + font-size: 1.25rem; + color: #b45309; + border-bottom: 1px solid #e5d3b3; + padding-bottom: 5px; + margin-top: 1.6em; +} + +h3 { font-size: 1.05rem; color: #92400e; margin-top: 1.1em; } + +a { color: #b45309; } + +hr { border: none; border-top: 1px solid #e2e2e2; margin: 1.6em 0; } + +ul { padding-left: 1.25em; } +li { margin: 0.18em 0; } + +strong { color: #1f2937; } + +code { + font-family: "D2Coding", Consolas, monospace; + background: #f4f1ea; + border: 1px solid #e7e0d2; + border-radius: 3px; + padding: 0.5px 5px; + font-size: 0.92em; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 0.8em 0; + font-size: 0.95em; +} +th, td { border: 1px solid #d8d2c4; padding: 6px 10px; text-align: left; vertical-align: top; } +th { background: #fdf3df; color: #7c2d12; } + +blockquote { + border-left: 4px solid #f59e0b; + margin: 0.8em 0; + padding: 0.2em 0 0.2em 14px; + color: #555; + background: #fffbf2; +} + +/* ★ 강조 항목이 페이지 경계에서 잘리지 않게 */ +h1, h2, h3 { break-after: avoid; } diff --git a/docs/history/2026-06-26_0913_동일항목-묶음-브래킷.md b/docs/history/2026-06-26_0913_동일항목-묶음-브래킷.md new file mode 100644 index 0000000..54b21c5 --- /dev/null +++ b/docs/history/2026-06-26_0913_동일항목-묶음-브래킷.md @@ -0,0 +1,36 @@ +# 동일 항목 묶음 브래킷 표시 + +**소요 시간**: 약 15분 +**Context 사용량**: input ~55k / output ~6k tokens + +## 결론 +스테이션바 타임라인에서 같은 항목(동일 역명)을 드론이 여러 번 통과할 때, 두 번째 참고 이미지처럼 +**점선 브래킷(각 통과 수직 드롭 + 하단 수평 연결선)** 으로 묶고 **클러스터 중앙에 라벨 1개** 만 +표시하도록 변경. + +## 근거 / 변경 내용 +- 파일: `client/src/stationbar/components/Timeline/Timeline.tsx` +- 기존: 다중통과 시 `labelEntries`(마지막 통과 라벨 1개 + 나머지 중앙 라벨 1개) + 통과별 점선 틱(`leaderPxs`) + → 같은 항목인데 라벨이 2개로 쪼개져 표시됨. +- 변경: `labelGroups` 로 재구성. + - 같은 title 통과들을 px 간격(`GROUP_GAP_PX = 350`)으로 **클러스터 분할** — 가까운 통과는 한 묶음, + 멀리 떨어진 통과(예: 대전조차장 두 차례 진입)는 별도 묶음 → 각자 브래킷/라벨 1개. + - 클러스터당: 각 통과 px에 수직 드롭(top 46, h4) + 양끝을 잇는 수평 점선(top 50) + 중앙(centerPx) 라벨 1개. + - 단일 통과는 브래킷 없이 자기 위치 라벨 1개(기존 동작 유지). + - 색(passed/neutral) 판정 기준을 통과 px → 클러스터 `centerPx` 로 변경. 미도착(unreached)은 묶음 내 + 하나라도 있으면 neutral 유지. + +## 검증 +- `tsc --noEmit` 통과 (node 20 환경). + +## 후속 수정 (가로 점선 미표시 → 거리 클러스터링 제거) +- 1차 구현은 `GROUP_GAP_PX = 350` 으로 동명 통과를 거리별 클러스터로 분할 → 대전조차장역 두 통과가 + ~1050px 떨어져 각각 단일 묶음이 되어 브래킷(가로 점선)이 안 나옴. +- 수정: 거리 클러스터링 제거. **동명(title) 통과는 거리와 무관하게 하나로 묶어** lo~hi 전체에 + 수평 점선 + 각 통과 드롭 + 중앙 라벨 1개를 그림. (사용자 요청: "가로 점선이 보여야해") + +## 다음 단계 참고 +- 브래킷 수직 위치(드롭 46~50, 수평 50, 라벨행 51)는 트랙 하단(46.5)/labelRow(51) 기준. 라벨 폰트/행간 + 변경 시 함께 점검. +- 동명 통과가 매우 멀리 떨어진 경우 중앙 라벨이 빈 트랙 중앙에 놓일 수 있음(가로 점선으로 연결됨). 추후 + 묶음 분할이 다시 필요하면 거리 임계값 방식을 재도입할 것. diff --git a/docs/history/2026-06-26_1411_스테이션바-거리축-전환.md b/docs/history/2026-06-26_1411_스테이션바-거리축-전환.md new file mode 100644 index 0000000..b7ec67c --- /dev/null +++ b/docs/history/2026-06-26_1411_스테이션바-거리축-전환.md @@ -0,0 +1,35 @@ +# 스테이션바 거리축(이동량 기반) 전환 + +**소요 시간**: 약 20분 +**Context 사용량**: input ~90k / output ~9k tokens + +## 결론 +스테이션바 커서/구간/마커의 가로축을 **시간(프레임) 선형 → 누적 이동거리(거리축)** 로 전환. +드론이 호버링(이동 없음)하면 커서가 멈추고, 실제로 이동할 때만 커서가 진행한다. (사용자 요청 A안) + +## 근거 / 변경 내용 +- 파일: `client/src/stationbar/StationBar.tsx` +- 기존: `px = 시작 + (currentTime/duration) × 트랙폭` — 순수 시간 정비례. 호버 중에도 커서 전진(배지 측점값은 정지 → 불일치). +- 변경: 시간↔진행도 매핑을 거리축으로 교체. 핵심은 매핑 함수 한 곳에 집중되어 커서·구간색·구조물 마커가 일관되게 따라옴. + 1. `progRef`: 프레임별 `{time, frac}` 배열. `frac` = 누적 이동거리 비율(0~1, 단조 비감소). + 2. 진행도 산출(viewedRef 빌드 useEffect): 연속 측점값(chain)을 ±W(8프레임) 이동평균으로 평활(GPS 지터 제거) 후 + 프레임간 `|Δ|` 누적 = 실제 이동량. 전체로 정규화. + 3. `cumFracAtTime(t)`: 시간→진행비율(이진탐색+선형보간). progRef 미준비 시 시간선형 폴백. + 4. `timeAtFrac(f)`: 역변환(진행비율→시간) — 바 클릭/측점 seek 용. + 5. `pxAtTime(t) = 시작 + cumFracAtTime(t) × 트랙폭` — 모든 배치가 공유. + 6. 라이브 커서 RAF, `seekToTrackX` 도 거리축 함수 사용. + +## 동작 +- 호버(이동 無): frac 정체 → 커서 정지. 이동: frac 증가 → 좌→우 진행. +- 전진/후진 모두 이동량으로 더해져 커서는 단조 진행(방향은 기존 주황/청록 구간색으로 구분). +- 배지 측점값과 커서가 동일 데이터(연속 측점값)에서 파생 → 표시·이동 일관. + +## 검증 +- `tsc --noEmit` 통과 + `client/dist` 빌드 완료. (pm2 ghiVideo 는 정적 서빙 → 재시작 불필요, 브라우저 새로고침) + +## 다음 단계 참고 / 되돌리기 +- 사용자가 "이상하면 원복" 예정. 되돌리려면 `pxAtTime`/`cumFracAtTime`/`timeAtFrac` 를 기존 시간선형 + (`(t/duration)`)으로 복귀시키고 `progRef`/평활 블록 제거하면 됨. +- 평활 반폭 `W=8`, 호버 판정은 평활로 처리(별도 데드밴드 없음). 호버 중 커서가 미세하게 기어가면 W를 키우거나 + 소량 데드밴드 추가. +- 부작용: 호버가 길어도 바에서 폭 0으로 수렴 → 바 클릭으로 호버 내부 시간 미세 seek 불가(영상 타임라인 사용). diff --git a/docs/history/2026-06-26_1520_스테이션바-측점축-전환.md b/docs/history/2026-06-26_1520_스테이션바-측점축-전환.md new file mode 100644 index 0000000..b9d8c7a --- /dev/null +++ b/docs/history/2026-06-26_1520_스테이션바-측점축-전환.md @@ -0,0 +1,43 @@ +# 스테이션바 측점거리(체이니지) 비례 축 전환 (B안) + +**소요 시간**: 약 30분 +**Context 사용량**: input ~150k / output ~14k tokens + +## 결론 +스테이션바 x축을 직전 A안(누적 이동거리) → **측점값(체이니지) 비례 축**으로 전환. +바 가로위치가 실제 측점거리에 비례하고, 커서는 드론의 현재 측점 위치를 표시한다(멈추면 정지, 후진하면 좌로 이동). +측점 10m 차이 구조물이 바에서도 가깝게 표시된다. 색: '진행색 + 커서색' 안 선택. + +## 배경 +- A안(누적 이동거리)은 두 구조물이 측점상 10m여도 그 사이 드론이 앞뒤로 많이 이동하면 누적거리가 커져 바에서 멀게 표시됨 → 사용자 지적. +- 요구: 바는 측점거리에 비례, 재생 중 실제 드론 위치를 바에 표시. + +## 변경 내용 (client/src/stationbar/StationBar.tsx) +- `chainRef = {time, chain(평활), minC, maxC}` 생성(ready useEffect). chain 은 ±8프레임 이동평균(지터 제거). +- 매핑: `pxAtChain(측점) = START + clamp((c-minC)/(maxC-minC)) * timeTrackWidth`, + `chainAtTime(t)`(이진탐색+보간), `pxAtTime(t) = pxAtChain(chainAtTime(t))`. 커서·구조물 공유. +- 커서: `progressPx = pxAtTime(currentTime)`, RAF 도 `pxAtTime(liveTime)`. +- 색(측점축은 방향 리본 표현 불가): `trackGradient`=주황 음영 단일, `trackGradientIdle`=회색 단일, + `dividers=[]`. 방향은 `currentReverse`(측점값 0.4s 전 대비 감소=후진)로 커서 색만 구분. +- 구조물 중복 제거: `dedupeMarksByTitle`(title 당 1개) — 측점축에선 동명 다중통과가 한 자리에 겹침. + → Timeline 다중통과 묶음 점선(브래킷)은 자연 소멸. +- seek: 클릭 px → 목표 측점 → 허용오차 내 프레임 중 현재 시간에 가장 가까운 통과로 onSeek. +- `barSegments` 는 렌더 미사용(쿼리 useMemo 는 kmLabels 위해 유지), `cumFracAtTime/timeAtFrac/progRef` 제거. + +## 후속 수정 — 축 범위 '노선 고정' (데이터 무관 절대 측점) +- 문제: 축 범위를 드론 커버 구간 [minC,maxC] 로 잡아 영상마다 양끝 측점이 바뀜 → 같은 구조물이 다른 + 위치/측점으로 표시됨(사용자 지적: "어떤 데이터로 재생하느냐에 따라 측점이 바뀌면 안돼"). +- 수정: + 1. 축 범위 minC/maxC = stationLine(역 POI, km 오름차순) 첫/끝 측점값 → 노선 고정. 드론 chain 은 + projectChainage 로 이 범위 안에 들어옴. + 2. 구조물 px = pxAtChain(구조물 측점값) → 통과 시각이 아닌 '절대 측점'으로 배치(마일리지 라벨과 일치, + 영상 달라도 동일 위치). dedupeMarksByTitle 로 동명 1개. +- 효과: 좌(낮은 측점)→우(높은 측점) 고정 자. 구조물 위치 불변, 커서만 드론 현재 측점을 가리킴. + +## 검증 +- `tsc --noEmit` 통과 + client 빌드 완료. + +## 알려진 한계 / 다음 단계 +- 진짜 비례라 측점이 매우 가까운 라벨끼리 겹쳐 보일 수 있음(법동가도교/대전조차장역). 필요시 라벨 충돌회피 후처리. +- 방향 전진/후진 색 리본 제거됨(커서 색으로만 방향). endGapPx(미도착 종점역) 우측 밴드/표식은 유지. +- 이상 시 A안(누적거리 축) 또는 그 이전 시간선형으로 되돌릴 수 있음. 핵심 매핑은 pxAtChain/chainAtTime/pxAtTime 3개에 집중. diff --git a/docs/history/2026-06-29_1029_역사-통과-좌표근접-보강.md b/docs/history/2026-06-29_1029_역사-통과-좌표근접-보강.md new file mode 100644 index 0000000..f638522 --- /dev/null +++ b/docs/history/2026-06-29_1029_역사-통과-좌표근접-보강.md @@ -0,0 +1,40 @@ +# 역사(역) 통과 마커 누락 수정 — 좌표근접 합집합 보강 + +**소요 시간**: 약 30분 +**Context 사용량**: input ~150k / output ~9k tokens + +## 결론 +대전조차장역처럼 같은 역을 여러 번 지날 때, 일부 통과(예: 162k080 재진입)에 스테이션바 동그라미(역사 원형 마커)가 +빠지던 문제를 수정. 측점-기준 탐지에 더해 **좌표근접(`pxPassesTo`) 통과를 합집합으로 보강**해 누락된 재진입까지 마커를 찍는다. + +## 원인 +- 파일: `client/src/stationbar/StationBar.tsx` (`structureMarks`) +- 역사(대전조차장역)는 `geoData`에서 좌표(lat/lon)만 있고 측점값(`station`)이 없는 RouteStructure 로 들어온다. +- 통과 탐지가 `projectChainage(좌표)` 한 값으로 `pxPassesAtMileage` 만 수행. +- 탐지 체인이 `if (!passes.length)` **단락 평가**라, 측점-기준이 통과를 하나라도 찾으면 좌표근접 탐지가 안 돌아감. +- 종점/조차장 구역은 측점선(폴리라인) 투영이 불안정 → 한 통과는 투영 측점이 ±`stationTolerance`(기본 20m) + 안에 들어와 잡히지만, 다른 통과는 20m 밖으로 벗어나 누락. 첫 통과를 이미 찾았으니 좌표근접 보강도 안 돌아 영영 빠짐. + +## 변경 내용 +- `cat === '역사'` 이고 lat/lon 이 있으면, 측점-기준 탐지 결과에 `pxPassesTo(lat, lon, off)` 통과를 합집합으로 추가. +- 같은 통과(거의 동일 px)는 24px 임계로 중복 제거 → 멀리 떨어진 실제 재진입만 추가됨. + (TRACK_WIDTH_PX = 1509.5, 24px ≈ 1.6% → 동일 best 프레임 중복만 합쳐지고 떨어진 재진입은 보존) +- km 라벨 동작(stationKmVal 우선)은 기존 유지 — 이번 수정은 '마커 누락'만 대상. + +## 검증 +- `tsc --noEmit` + `vite build` 통과 (node v20.20.2, `~/.nvm/versions/node/v20.20.2/bin`). +- 사용자 화면(포트 55000)에서 폴더 재선택 후 162k080 재진입 동그라미 표출 **확인 완료**. + +## ★ 진짜 블로커 — 빌드/서빙 구조 (이번 세션 최대 교훈) +- 증상: 소스를 여러 번 고쳐도 화면이 전혀 안 바뀜("여전히 빠져있어" 반복). +- 원인: 사용자가 보는 화면은 dev 서버가 아니라 **PM2 앱 `ghiVideo`(포트 55000)** 이고, + `server/dist/.../app.js` 가 `express.static(client/dist)` 로 **빌드 결과를 서빙**한다(ecosystem.config.js). + → `client/src` 만 고치면 절대 반영 안 됨. `client/dist` 가 6/26 빌드로 고정돼 있었음. +- 형제 디렉토리 혼선도 있었음: `videoplayer`(PM2, 5173), `defVideo`(PM2) 가 함께 떠 있음. 작업 경로는 GhiVideo. +- 해결: `npm run build -w client` (node 20) 로 `client/dist` 갱신 → 강력 새로고침(Ctrl+Shift+R) → 폴더 재선택. +- 메모리에 구조 기록: `ghivideo-serve-build`. + +## 참고 / 후속 +- `stationTolerance` 타입 주석(geo.ts:139)은 "기본 40"으로 적혀 있으나 실제 코드 기본값은 `?? 20`. 불일치 정리 권장. +- 좌표근접 탐지 임계는 `pxPassesTo` 내부 `Math.max(100, gmin*3)`(StationBar.tsx:499) — 측점 tolerance(20m)와 별개. +- 출발점 부근 '조기 통과' 오탐 우려는 역사 마커에 한해 합집합으로 한정 → 일반 측점-기준 동작에는 영향 없음. diff --git a/docs/history/2026-06-29_1057_측점값-라벨-겹침-숨김.md b/docs/history/2026-06-29_1057_측점값-라벨-겹침-숨김.md new file mode 100644 index 0000000..2ecee50 --- /dev/null +++ b/docs/history/2026-06-29_1057_측점값-라벨-겹침-숨김.md @@ -0,0 +1,23 @@ +# 측점값 라벨 겹침 숨김 (먼저 나온 것만 표시) + +**소요 시간**: 약 10분 +**Context 사용량**: input ~190k / output ~11k tokens + +## 결론 +스테이션바에서 측점값(예: 157k990) 라벨이 근접 통과로 겹쳐 보일 때, px 오름차순으로 **먼저 나온(좌측) 라벨만 +표시하고 뒤엣것은 숨김** 처리. 아이콘(동그라미)과 점선 브래킷은 그대로 둔다(측점값 텍스트만 숨김). + +## 변경 내용 +- 파일: `client/src/stationbar/components/Timeline/Timeline.tsx` +- `structs`(px 오름차순) 직후 `showMileage: boolean[]` 산출: + - `labelW(v) = v.length * 7.5 + 4` (13px bold + 2px stroke 대략 폭) + - 직전 '표시한' 라벨과 중앙 간격이 `(lastW + w)/2` 미만이면 겹침으로 보고 숨김(`showMileage[i]=false`). + - 표시한 경우에만 `lastPx/lastW` 갱신 → 연쇄 겹침도 누적 기준으로 정확히 판정. +- `mileageRow` 렌더 조건을 `s.km >= 0` → `s.km >= 0 && showMileage[i]` 로 변경. + +## 검증 +- `tsc + vite build` 통과(node 20). client/dist 재빌드 → 55000 강력 새로고침으로 확인. + +## 참고 +- 라벨은 trackWrapper transform 하위라 px 공간 비교가 시각 겹침과 동치(균일 스케일). +- 폭 추정 상수(7.5/+4)는 폰트/스트로크 변경 시 재조정. diff --git a/docs/history/2026-06-29_1106_선형-드론궤적-바버튼-이동.md b/docs/history/2026-06-29_1106_선형-드론궤적-바버튼-이동.md new file mode 100644 index 0000000..36f4e9a --- /dev/null +++ b/docs/history/2026-06-29_1106_선형-드론궤적-바버튼-이동.md @@ -0,0 +1,26 @@ +# 선형·드론궤적 토글을 재생바 버튼으로 이동 + +**소요 시간**: 약 12분 +**Context 사용량**: input ~210k / output ~13k tokens + +## 결론 +영상 오버레이의 "선형(중심선)"·"드론 궤적" 표시 토글을 **하단 재생바(VideoPlayer)의 겹침제외 버튼 왼쪽**에 +버튼 2개로 추가하고, 화면표시 옵션 창의 기존 동일 기능 버튼 2개는 제거. 버튼 폭은 겹침제외와 동일(min-w-[96px]). + +## 변경 내용 +- `client/src/store/settingsStore.ts` + - `showCenterline`(기본 true), `showDronePath`(기본 true) + 각 setter 추가. persist 로 지속. +- `client/src/components/overlay/StationOverlay.tsx` + - 로컬 `useState` 였던 `showCenterline`/`showDronePath` → 설정 스토어 구독으로 전환(refs/리셋은 그대로). + - 화면표시 옵션 창의 선형/드론궤적 토글 버튼 2개 제거. (드론궤적 경로표고·투명도 슬라이더는 유지) +- `client/src/components/player/VideoPlayer.tsx` + - 스토어 셀렉터 추가, 겹침제외 버튼 바로 앞(좌측)에 `선형 ON/OFF`·`드론궤적 ON/OFF` 버튼 삽입. + - 스타일: 겹침제외와 동일 폭(min-w-[96px]), 색은 기능별(선형=red, 드론궤적=sky). + - 노출 조건은 겹침제외와 동일: 영상제어 ON + geoLoaded. + +## 검증 +- `tsc + vite build` 통과(node 20). client/dist 재빌드 → 55000 강력 새로고침 확인. + +## 참고 +- 상태를 스토어로 올려 바 버튼과 오버레이 렌더가 단일 소스 공유. +- '기본값' 리셋(StationOverlay)도 스토어 setter 로 동작 유지. diff --git a/docs/history/2026-06-29_1111_경로표고-투명도-항상표시+겹침제외숨김.md b/docs/history/2026-06-29_1111_경로표고-투명도-항상표시+겹침제외숨김.md new file mode 100644 index 0000000..43a99c3 --- /dev/null +++ b/docs/history/2026-06-29_1111_경로표고-투명도-항상표시+겹침제외숨김.md @@ -0,0 +1,22 @@ +# 경로표고·투명도 항상 표시 + 겹침제외 버튼 숨김 + +**소요 시간**: 약 5분 +**Context 사용량**: input ~225k / output ~14k tokens + +## 결론 +화면표시 옵션 창의 "경로 표고"·"투명도" 슬라이더를 드론궤적 ON/OFF와 무관하게 항상 표시하도록 변경. +재생바의 "겹침제외 ON/OFF" 버튼은 UI에서 숨김(기능/상태는 보존). + +## 변경 내용 +- `client/src/components/overlay/StationOverlay.tsx` + - 경로표고·투명도 ParamRow 를 감싸던 `{showDronePath && (...)}` 게이트 제거 → 항상 렌더. +- `client/src/components/player/VideoPlayer.tsx` + - 겹침제외 토글 버튼 JSX 주석 처리(숨김). `poiOverlapExclude` 상태는 스토어에 그대로 존재(기본 ON), + StationOverlay/StationBar/RoutePanel 이 계속 구독. 버튼만 비표시. 다시 보이려면 주석 해제. + +## 검증 +- `tsc --noEmit` + `vite build` 통과(node 20). client/dist 재빌드(11:11). + +## 참고 +- 겹침제외 selector(poiOverlapExclude/setPoiOverlapExclude)는 VideoPlayer 에 선언만 남아 미사용 상태지만 + tsc 통과(noUnusedLocals 미적용). 버튼 재노출 대비 보존. diff --git a/docs/history/2026-06-29_1115_좌측패널-바이동+측점진단버튼삭제.md b/docs/history/2026-06-29_1115_좌측패널-바이동+측점진단버튼삭제.md new file mode 100644 index 0000000..8baac9a --- /dev/null +++ b/docs/history/2026-06-29_1115_좌측패널-바이동+측점진단버튼삭제.md @@ -0,0 +1,28 @@ +# 좌측 패널 버튼 바 이동 + 측점진단 버튼 삭제(GPS·고도 항상 표시) + +**소요 시간**: 약 8분 +**Context 사용량**: input ~245k / output ~16k tokens + +## 결론 +- "좌측 노선 패널 표시" 버튼을 화면표시 옵션 창에서 제거하고, 하단 재생바의 **선형 버튼 왼편**에 + "좌측 패널 ON/OFF" 버튼으로 이동(동일 폭 min-w-[96px], emerald). +- "측점 진단" 토글 버튼 삭제. 드론 GPS·절대고도 HUD 는 **항상 표시**(토글 없음)로 변경. + +## 변경 내용 +- `client/src/components/overlay/StationOverlay.tsx` + - 표시 토글 영역에서 "좌측 노선 패널", "측점 진단" 버튼 2개 제거. + - 미사용된 `showRoutePanel`/`setShowRoutePanel`, `showStationDiag`/`setShowStationDiag` 선언 제거. +- `client/src/components/player/VideoPlayer.tsx` + - `setShowRoutePanel` 셀렉터 추가, 선형 버튼 앞(좌측)에 "좌측 패널 ON/OFF" 버튼 삽입. + - `stationDiag` useMemo 의 `!showStationDiag` 게이트 제거 → 프레임만 있으면 항상 계산. + - GPS·고도 HUD 렌더의 `showStationDiag &&` 게이트 제거 → 항상 표시. + - 미사용된 `showStationDiag` 셀렉터 제거. + +## 바 버튼 순서 (영상제어 ON + geoLoaded) +좌측 패널 → 선형 → 드론궤적 (겹침제외는 이전 요청대로 숨김) + +## 검증 +- `tsc + vite build` 통과(node 20). client/dist 재빌드(11:15). + +## 참고 +- settingsStore 의 showStationDiag 필드는 남겨둠(다른 잠재 구독 대비). 현재 토글 UI 는 없음. diff --git a/docs/history/2026-06-29_1119_재생바-드론정보-시설등급-재배치.md b/docs/history/2026-06-29_1119_재생바-드론정보-시설등급-재배치.md new file mode 100644 index 0000000..80ebc2c --- /dev/null +++ b/docs/history/2026-06-29_1119_재생바-드론정보-시설등급-재배치.md @@ -0,0 +1,19 @@ +# 재생바 요소 재배치 — 드론궤적+위치정보 인접, 시설등급 우측 + +**소요 시간**: 약 3분 +**Context 사용량**: input ~260k / output ~17k tokens + +## 결론 +하단 재생바에서 드론 위치정보(GPS·고도) HUD 를 드론궤적 버튼 바로 우측에 붙이고, 시설등급 옵션 패널을 +그 오른편(맨 뒤)으로 옮겨 순서를 정리. + +## 변경 내용 +- `client/src/components/player/VideoPlayer.tsx` + - 드론 위치정보 HUD(`{stationDiag && ...}`)를 시설등급 패널 뒤 → **드론궤적 버튼 직후**로 이동. + - 시설등급 패널이 HUD 뒤(우측)에 오도록 순서 변경. + +## 바 버튼/요소 순서 (영상제어 ON + geoLoaded) +좌측 패널 → 선형 → 드론궤적 → 드론 위치정보(GPS·고도) → 시설등급 + +## 검증 +- `tsc + vite build` 통과(node 20). client/dist 재빌드(11:19). diff --git a/docs/history/2026-06-29_1123_활성색-amber-통일.md b/docs/history/2026-06-29_1123_활성색-amber-통일.md new file mode 100644 index 0000000..7e29b67 --- /dev/null +++ b/docs/history/2026-06-29_1123_활성색-amber-통일.md @@ -0,0 +1,25 @@ +# 활성화 상태 색상 amber(겨자/오렌지)로 통일 + +**소요 시간**: 약 4분 +**Context 사용량**: input ~280k / output ~18k tokens + +## 결론 +재생바·우측 토글의 활성화(ON/선택) 표시 색을 제각각(파랑/초록/빨강/하늘/회색)에서 **amber-400 + 검정 글씨**로 +통일. 사용자 요청(노란/겨자/오렌지 계열). + +## 변경 내용 +- `client/src/components/player/VideoPlayer.tsx` + - 영상제어 ON: blue → `bg-amber-400 border-amber-300 text-black` + - 배속 선택: `bg-blue-600 text-white` → `bg-amber-400 text-black` + - 좌측 패널 / 선형 / 드론궤적 ON: emerald/red/sky → amber-400 + - 시설등급 체크박스: `accent-blue-500` → `accent-amber-400` +- `client/src/components/overlay/StationOverlay.tsx` + - "화면표시 옵션"(showDisplay) 활성: sky-700 → amber-400 + - "카메라 파라미터"(showControls) 활성: gray-700 → amber-400 + +## 검증 +- `tsc + vite build` 통과(node 20). client/dist 재빌드(11:23). + +## 참고 +- 비활성(OFF)은 기존 `bg-black/70 border-gray-600 text-gray-300` 유지. +- 더 진하게 원하면 amber-500 으로 교체. diff --git a/docs/history/2026-06-29_1146_보고서-md-html-pdf-생성.md b/docs/history/2026-06-29_1146_보고서-md-html-pdf-생성.md new file mode 100644 index 0000000..6750032 --- /dev/null +++ b/docs/history/2026-06-29_1146_보고서-md-html-pdf-생성.md @@ -0,0 +1,27 @@ +# 작업 보고서 md/html/pdf 3종 생성 + 변환 스크립트 + +**소요 시간**: 약 12분 +**Context 사용량**: input ~310k / output ~21k tokens + +## 결론 +오늘 작업 내용을 팀장 보고용 문서로 작성하고, **md(베이스) → html + pdf** 3종 산출물을 생성. +재생성용 변환 스크립트와 스타일을 저장소에 추가. + +## 산출물 +- `docs/보고서_2026-06-29_스테이션바_UI개선.md` (베이스) +- `docs/보고서_2026-06-29_스테이션바_UI개선.html` (자체완결, CSS 인라인) +- `docs/보고서_2026-06-29_스테이션바_UI개선.pdf` (A4 3p, 한글 임베드, 페이지번호) + +## 추가 파일 +- `scripts/md2docs.sh` — `md2docs.sh <파일.md>` 로 같은 경로에 html+pdf 생성. +- `docs/assets/report.css` — 보고서 스타일(앰버 헤더/표/@page). + +## 변환 환경 (메모: report-doc-toolchain) +- pandoc 2.11 `--self-contained` 로 md→html (CSS 인라인 임베드). +- weasyprint 61.2 로 html→pdf. **pydyf 는 0.10 으로 핀**(0.11 은 API 변경으로 TypeError). +- 한글 폰트: 환경에 CJK 폰트 부재 → Windows 맑은고딕(`/mnt/c/Windows/Fonts/malgun*.ttf`)을 + `~/.local/share/fonts` 로 복사 후 `fc-cache`. WSL 이라 가능. + +## 검증 +- PDF 헤더 %PDF-1.7, 3페이지, `/FontFile`(Malgun 글리프 임베드) 확인 → 모든 뷰어에서 한글 정상. +- scripts/md2docs.sh 재실행으로 동일 산출물 재현 확인. diff --git a/docs/history/2026-06-29_1212_측점검색-순환점프+입력유지.md b/docs/history/2026-06-29_1212_측점검색-순환점프+입력유지.md new file mode 100644 index 0000000..ae7018f --- /dev/null +++ b/docs/history/2026-06-29_1212_측점검색-순환점프+입력유지.md @@ -0,0 +1,27 @@ +# 측점 검색 순환 점프 + 입력값 유지/블러삭제 + +**소요 시간**: 약 12분 +**Context 사용량**: input ~360k / output ~24k tokens + +## 결론 +측점값이 여러 곳(여러 통과)에 존재할 때, 측점 검색을 **통과방향 순환(find-next)** 으로 개선. +Enter 반복으로 다음 통과로 이동, 끝에서 처음으로 순환. 입력값은 Enter 후 유지하고 blur 시 삭제. + +## 변경 내용 +- `client/src/stationbar/StationBar.tsx` — `handleJumpToMileage` 재작성 + - 입력 측점의 '모든 통과'를 허용오차(stationTolerance, 기본 20m) 내 연속구간=1통과로 묶어 시간 오름차순 수집. + - 기준 시각(baseT)에서 통과방향(시간 증가) 다음 통과로 이동, 없으면 passes[0] 으로 순환. + - `jumpRef`(km,time)로 연속 검색 판정: 같은 측점이고 커서가 직전 점프 위치 그대로(|Δ|<0.1s)일 때만 + 연속(다음 통과). 재생/클릭으로 커서 이동했거나 다른 측점이면 현재 커서(라이브)에서 새로 시작. + - 라이브 시각은 `timeRef?.current ?? currentTime`. 커버 범위 밖(±20m) 입력은 무시(기존 유지). + - 허용오차 내 통과가 없으면 전역 최근접 1개로 폴백. +- `client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx` + - Enter 시 `setQuery('')` 제거 → 입력값 유지(같은 값 재Enter 로 순환). + - input `onBlur` → 입력값 삭제(다른 곳 클릭 시 언셀렉트). + +## 검증 +- `tsc + vite build` 통과(node 20). client/dist 재빌드(12:12). + +## 참고 +- 순환 순서는 '시간(이동거리축 진행) 오름차순' = 화면상 좌→우. +- 향후 입력창 옆 'n/m' 위치 표시·매칭 하이라이트를 추가하면 인지성 더 향상. diff --git a/docs/history/2026-06-29_1219_측점검색-마커기준-전환.md b/docs/history/2026-06-29_1219_측점검색-마커기준-전환.md new file mode 100644 index 0000000..d514f0b --- /dev/null +++ b/docs/history/2026-06-29_1219_측점검색-마커기준-전환.md @@ -0,0 +1,27 @@ +# 측점 검색을 '화면 마커' 기준으로 전환 (누락 통과까지 검색) + +**소요 시간**: 약 7분 +**Context 사용량**: input ~390k / output ~26k tokens + +## 결론 +측점 검색 점프가 일부 통과로 가지 못하던 문제 해결. 검색 후보를 chain 기준 → **화면에 보이는 +마커(structureMarks) 기준**으로 바꿔, 바에 표시된 모든 동그라미(좌표근접 탐지 통과 포함)를 순환하도록 함. + +## 원인 +- 마커(structureMarks)는 측점기준 + 좌표근접(pxPassesTo) 합집합으로 통과를 찍음(조차장 등 chain 어긋난 통과 포함). +- 그러나 검색(handleJumpToMileage)은 `|arr[i].chain - km| < TOL` 측점기준으로만 통과를 찾음 + → chain 이 162080 에서 벗어난 통과(마커는 있음)는 검색이 못 감. + +## 변경 내용 (`client/src/stationbar/StationBar.tsx`) +- handleJumpToMileage 후보 산출 1순위: + `structureMarks.filter(표시 10m 반올림값 == 입력값).map(px → timeAtFrac 로 시각 역변환)`. +- 2순위(마커 없는 임의 측점): 기존 chain 연속구간 탐지 → 없으면 전역 최근접 폴백. +- 시간 정렬 + 0.3s 근접 중복 제거 후, 통과방향 순환(기존 jumpRef 로직 유지). +- deps 에 structureMarks, timeAtFrac, timeTrackWidth 추가. + +## 검증 +- `tsc + vite build` 통과(node 20). client/dist 재빌드(12:19). + +## 참고 +- 매칭은 표시 라벨과 동일하게 10m 반올림 일치 → 사용자가 보는 값과 검색 일치. +- 종점 미도착으로 px=TRACK_END 로 이동된 마커는 역변환 시 끝 시각이 됨(허용). diff --git a/docs/history/2026-06-29_1228_좌표반경-마커-측점값숨김.md b/docs/history/2026-06-29_1228_좌표반경-마커-측점값숨김.md new file mode 100644 index 0000000..0eaa87e --- /dev/null +++ b/docs/history/2026-06-29_1228_좌표반경-마커-측점값숨김.md @@ -0,0 +1,30 @@ +# 좌표 반경으로만 잡힌 마커의 측점값 라벨/검색 제외 + +**소요 시간**: 약 8분 +**Context 사용량**: input ~420k / output ~28k tokens + +## 결론 +역 마커가 좌표 반경(pxPassesTo)으로만 잡혀 그 위치에 실제 측점값이 존재하지 않는 경우, +**측점값 라벨을 숨기고 검색 대상에서도 제외**. 마커(동그라미)는 유지. + +## 판정 (kmExists) +- 각 통과의 실제 측점 = 그 통과 프레임의 연속 chain(p.km, 좌표탐지) / 측점기준 탐지는 targetM(=stationKmVal). +- 표시 측점값 = stationKmVal(역 좌표 투영). +- `kmExists = stationKmVal==null || |p.km - stationKmVal| <= stationTolerance(기본 20m)` + - 측점기준 통과 → 일치 → true + - 좌표 반경만으로 잡힌 통과(측점 동떨어짐) → false + +## 변경 내용 +- `client/src/stationbar/StationBar.tsx` + - `StructMark`에 `kmExists?: boolean` 추가. + - structureMarks 두 경로(storeStructures / POI 폴백) push 시 kmExists 계산. + - handleJumpToMileage 마커 후보 필터에 `m.kmExists !== false` 추가(값 없는 마커로는 검색 점프 안 함). +- `client/src/stationbar/components/Timeline/Timeline.tsx` + - 측점값 라벨 표시 루프에서 `s.kmExists === false`면 라벨 생략(동그라미·이름 라벨은 유지). + +## 검증 +- `tsc + vite build` 통과(node 20). client/dist 재빌드(12:27). + +## 참고 +- 측점기준 통과는 km=targetM=stationKmVal 로 동일 → 항상 kmExists=true(정상 측점은 영향 없음). +- 마커 동그라미/브래킷/역명 라벨은 그대로. 숨기는 것은 '측점값(10m)' 라벨과 그 값으로의 검색뿐. diff --git a/docs/history/2026-06-29_1338_55000-외부접속-포트포워딩.md b/docs/history/2026-06-29_1338_55000-외부접속-포트포워딩.md new file mode 100644 index 0000000..eb3b772 --- /dev/null +++ b/docs/history/2026-06-29_1338_55000-외부접속-포트포워딩.md @@ -0,0 +1,28 @@ +# 55000 포트 외부(LAN) 접속 활성화 + +**소요 시간**: 약 10분 +**Context 사용량**: input ~450k / output ~30k tokens + +## 결론 +PM2 ghiVideo(55000) 를 같은 네트워크의 다른 기기에서 접속 가능하게 설정. +접속 주소: **http://172.16.40.179:55000/** (Windows 이더넷 LAN IP). + +## 점검 결과 +- 서버: `0.0.0.0:55000` 바인딩 ✓, CORS `origin:true`(모든 origin 허용) ✓ → 서버측 준비 완료. +- 차단 원인: WSL2 기본 NAT 모드 → WSL IP(172.21.50.250)는 LAN 직접 도달 불가. + +## 조치 (Windows 관리자/UAC) +- netsh portproxy 추가: `0.0.0.0:55000 → 172.21.50.250:55000` + (기존에 8080/3000/5173 등 동일 패턴 사용 중이라 55000만 추가) +- 방화벽 인바운드 허용 규칙 "WSL ghiVideo 55000" (TCP 55000) 추가. +- 스크립트: `C:\Windows\Temp\wsl_port_55000.cmd` (UAC 로 실행). + +## 검증 +- portproxy 목록에 55000 등록 확인. +- 방화벽 규칙 Enabled/Inbound/Allow 확인. +- Windows→`http://localhost:55000/` Invoke-WebRequest → **200**. + +## 주의 / 후속 +- WSL IP 는 재부팅/`wsl --shutdown` 시 변경 → portproxy(고정 172.21.50.250) 끊김. 재적용 필요. + (요청 시 WSL IP 자동감지 재설정 스크립트 작성 가능) +- 172.16.x 사내망 한정. 인터넷 접속은 공유기/회사 방화벽 포트포워딩 추가 필요. diff --git a/docs/history/2026-06-29_1436_드론자세평활-오버레이렌더링-기술문서화.md b/docs/history/2026-06-29_1436_드론자세평활-오버레이렌더링-기술문서화.md new file mode 100644 index 0000000..215817f --- /dev/null +++ b/docs/history/2026-06-29_1436_드론자세평활-오버레이렌더링-기술문서화.md @@ -0,0 +1,302 @@ +# 드론 자세 평활/보간 + 영상 오버레이 렌더링 파이프라인 + +> 후임자 인계 + 특허 문서용 상세 기술 문서 +> 대상 소스: [client/src/components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx) (1639줄), 투영 유틸 [client/src/utils/geoProjection.ts](../../client/src/utils/geoProjection.ts) + +--- + +## 1. 기술 개요 / 목적 + +드론으로 촬영한 철도 선로 주행영상 위에, 측점(스테이션)·선로구조물(교량/터널/구교)·POI·선로 중심선·드론 GPS 궤적·나침반을 **영상 픽셀에 정확히 정합(AR 오버레이)** 시켜 표출하는 렌더링 파이프라인이다. + +입력 데이터는 두 종류로, (a) 프레임별 드론 자세/위치 로그(SRT 추출: `frame, lat, lon, altitude, yaw, pitch, roll`)와 (b) 지리정보(측점·중심선·POI·구조물의 위경도+표고)이다. 핵심 변환은 핀홀 카메라 모델 기반의 월드(EPSG:5186 TM ENU) → 카메라 → 정규화 픽셀 투영([geoProjection.ts#L301](../../client/src/utils/geoProjection.ts#L301), [#L126](../../client/src/utils/geoProjection.ts#L126))이다. + +이 문서가 다루는 본질적 과제는 **두 가지 노이즈/지연 문제**이다. + +1. **자세/GPS 노이즈** — 원시 SRT 로그는 떨림이 있어 그대로 투영하면 라벨이 매 프레임 진동한다. 그러나 단순 평균은 회전 구간을 뭉개 라벨이 늦게 따라온다(지연). → **에지 보존 적응형 평활(smoothFrame)** 로 해결. +2. **렌더 부드러움 vs 계산 비용** — 라벨 가시성/겹침 판정은 비싸고(전 프레임 × 전 항목), 60fps 매끄러운 이동은 매 프레임 재투영을 요구한다. → **가시성은 사전계산(precompute), 좌표는 RAF에서 연속포즈로 재투영**하는 2단 분리로 해결. + +--- + +## 2. 핵심 알고리즘 + +### 2.1 에지(회전경계) 보존 적응형 이동평균 — `smoothFrame` + +[StationOverlay.tsx#L577](../../client/src/components/overlay/StationOverlay.tsx#L577) + +원시 드론 프레임 배열에서 인덱스 `i`를 중심으로 좌우 최대 `halfWin`(기본 60프레임 ≈ 2초)까지 이동평균을 취하되, **중심 프레임의 yaw와 차이가 임계 `YAW_EDGE = 8°` 이내인 인접 프레임까지만 창을 확장**한다. + +```typescript +// StationOverlay.tsx:577 +const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => { + let lo = i, hi = i; + if (frames.length > 1 && halfWin > 0) { + const YAW_EDGE = 8; // deg — 중심 대비 이 이상 벌어지면 다른(회전) 구간으로 보고 평균 제외 + const yc = frames[i].yaw; + const near = (idx: number) => Math.abs(((frames[idx].yaw - yc + 540) % 360) - 180) <= YAW_EDGE; + const loMin = Math.max(0, i - halfWin); + const hiMax = Math.min(frames.length - 1, i + halfWin); + while (lo > loMin && near(lo - 1)) lo--; // 회전경계에서 멈춤 + while (hi < hiMax && near(hi + 1)) hi++; + } +``` + +**수식.** 창 `[lo, hi]`(요소 수 `n = hi − lo + 1`)에 대해 lat/lon/altitude/pitch/roll은 산술평균. yaw는 각도이므로 단순 산술평균 시 0°/360° 경계에서 깨진다 → **원형평균(circular mean)**: + +``` +yaw̄ = atan2( (1/n)·Σ sin(yawₖ), (1/n)·Σ cos(yawₖ) ) +``` + +[StationOverlay.tsx#L590](../../client/src/components/overlay/StationOverlay.tsx#L590): + +```typescript +const yr = f.yaw * Math.PI / 180; +sinYaw += Math.sin(yr); cosYaw += Math.cos(yr); +// ... +yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI, +``` + +`near()`의 `((Δ + 540) % 360) − 180`은 yaw 차를 항상 `[−180°, 180°]`로 정규화하는 최단각 차이 패턴이다. + +**의도 / 트레이드오프.** +- 직선 주행: yaw가 거의 일정 → 창이 풀폭(±60fr)으로 펴져 GPS/자세 노이즈를 강하게 억제. +- 회전 중: 회전경계에서 창이 좁아져(한쪽 또는 양쪽이 중심 근처에서 멈춤) **실제 자세를 즉시 추종(지연 없음)**. +- 회전 종료 후: 회전 프레임을 배제한 채 창이 **한 프레임씩** 점진적으로 재확장 → 튐 없음. +- 트레이드오프: `YAW_EDGE`가 너무 작으면 완만한 곡선도 회전으로 보아 노이즈 억제가 약해지고, 너무 크면 회전이 평균에 섞여 지연. 8°는 떨림(보통 <2°)과 실제 회전을 가르는 경험값. +- (주석에 기록된) 과거 '변화율 기반 창 축소' 방식은 회전 종료 시 창이 한꺼번에 재확장되며 회전 프레임이 다시 섞여 한 번 튀는 부작용이 있어 경계보존 방식으로 교체됨 ([StationOverlay.tsx#L569](../../client/src/components/overlay/StationOverlay.tsx#L569) 주석). + +### 2.2 연속 프레임번호 보간 포즈 — `poseAt` + +[StationOverlay.tsx#L670](../../client/src/components/overlay/StationOverlay.tsx#L670) + +영상의 부드러운 재생시간(60fps 벽시계 보간, §4.1)에서 산출된 **실수(實數) 프레임번호 `estFrame`** 에 대응하는 자세를 만든다. 이산 SRT 프레임만으로는 30fps 격자에 갇혀 라벨이 계단식으로 움직이므로, 양 이웃 프레임의 **평활 포즈(smoothFrame 결과)** 를 선형보간한다. + +```typescript +// StationOverlay.tsx:670 — 이진탐색으로 estFrame을 끼우는 두 프레임 찾기 +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, h), b = smoothFrame(arr, i2, h); +const L = (x: number, y: number) => x + (y - x) * frac; +let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각 +return { ...a, frame: estFrame, + lat: L(a.lat, b.lat), lon: L(a.lon, b.lon), altitude: L(a.altitude, b.altitude), + pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll), yaw: a.yaw + dy * frac }; +``` + +**수식.** 보간계수 `frac = (estFrame − f1)/(f2 − f1)`. 위치/피치/롤은 `L(x,y) = x + (y−x)·frac`. yaw는 359°→1° 같은 경계에서 긴 쪽으로 도는 것을 막기 위해 **최단각 보간**: `Δ = ((b.yaw − a.yaw + 540) % 360) − 180`, `yaw = a.yaw + Δ·frac`. + +**트레이드오프.** O(log n) 이진탐색으로 호출당 비용이 낮아 매 RAF 프레임 호출 가능. smoothFrame을 매 프레임 두 번 호출하므로(`a`, `b`) 평활창이 매우 클 때는 비용이 늘 수 있으나, 실데이터 규모에서는 무시 가능. + +### 2.3 속도적응 EMA + 이상치 거부 — `smoothStep` (One-Euro 방식) + +[StationOverlay.tsx#L48](../../client/src/components/overlay/StationOverlay.tsx#L48) + +`poseAt`로 재투영한 화면 정규좌표를 매 프레임 그대로 쓰면 잔여 떨림이 보인다. 화면단에서 One-Euro 필터 사상(idea)을 적용: **느릴 때(떨림)는 강하게 평활, 빠를 때(실제 팬)는 즉시 추종**. + +```typescript +// StationOverlay.tsx:48 +function smoothStep(prev, tx, ty, maxAlpha, minAlpha, speedRef): DispPos { + if (!prev) return { x: tx, y: ty, rej: 0, vx: 0, vy: 0 }; + const dx = tx - prev.x, dy = ty - prev.y; + const d = Math.hypot(dx, dy); + if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) { + return { x: prev.x, y: prev.y, rej: prev.rej + 1, vx: prev.vx, vy: prev.vy }; // 이상치: 위치 유지 + } + // 속도 벡터 평활 → 떨림(왕복)은 상쇄돼 작게, 실제 이동은 유지. + const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA; + const vy = prev.vy + (dy - prev.vy) * SMOOTH_VEL_BETA; + const speed = Math.hypot(vx, vy); + const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed / Math.max(1e-4, speedRef))); + return { x: prev.x + dx * a, y: prev.y + dy * a, rej: 0, vx, vy }; +} +``` + +**수식.** +- 이상치 거부: 프레임당 점프 `d = ‖(dx,dy)‖`가 `REJECT_DIST = 0.12`(정규화 화면거리)를 넘으면 갱신을 버리고 이전 위치 유지. 단 연속 거부가 `MAX_REJECT_FRAMES = 8`을 넘으면(=시크/재등장 같은 진짜 큰 이동) 수용 → 영구 고착 방지. +- 속도 평활: `v ← v + (Δ − v)·β`, `β = SMOOTH_VEL_BETA = 0.25`. 떨림은 매 프레임 방향이 번갈아(왕복) `v`가 상쇄돼 ≈0, 실제 이동은 방향이 일관돼 `v` 유지(저크 제거). +- 적응 alpha: `α = min(maxAlpha, minAlpha + (maxAlpha − minAlpha)·min(1, speed/speedRef))`. + - 정지/떨림(`speed≈0`) → `α≈minAlpha`(기본 0.12, 강한 평활). + - 빠른 팬(`speed ≥ speedRef`, 기본 `speedRef=0.010`) → `α=maxAlpha`(기본 `emaAlpha=1.0`, 즉시 추종). +- 위치 갱신: `pos ← pos + Δ·α`. + +**의도 / 트레이드오프.** 입력이 이미 smoothFrame(±60fr) + poseAt 보간으로 한 차례 평활됐으므로 화면 EMA의 `maxAlpha`는 가볍게 둔다(주석: 과거 고정 α=0.01은 지연 ~1.65s로 라벨이 늦게 따라옴). `minAlpha`↓ / `speedRef`↑ 일수록 1배속 떨림은 줄지만 미세 추종 지연이 늘어난다. 이 세 값은 패널에서 데이터셋별로 실시간 튜닝·저장된다([StationOverlay.tsx#L494](../../client/src/components/overlay/StationOverlay.tsx#L494)). + +추가로 **픽셀 히스테리시스**가 EMA 위에 한 겹 더 적용된다: 화면 px 위치가 0.75px 이상 움직일 때만 정수 좌표를 갱신해 저속에서 반올림 깜빡임을 제거한다([StationOverlay.tsx#L1048](../../client/src/components/overlay/StationOverlay.tsx#L1048), [#L1084](../../client/src/components/overlay/StationOverlay.tsx#L1084)). + +--- + +## 3. 라벨 사전계산 + 겹침 억제 + +### 3.1 `startLabelPrecompute` — requestIdleCallback 청크 처리, 월드좌표 저장 + +[StationOverlay.tsx#L692](../../client/src/components/overlay/StationOverlay.tsx#L692) + +데이터 로드 완료 시(또는 params/평활/범위 변경 시 500ms debounce, [#L824](../../client/src/components/overlay/StationOverlay.tsx#L824)) 전 프레임에 대해 **어떤 라벨이 보이는가(가시집합)와 겹침 해소 결과**를 미리 계산해 `Map`에 적재한다. 핵심 설계는 **화면좌표가 아니라 월드좌표(lat/lon/z)를 저장**한다는 점이다([LabelCache 정의 #L123](../../client/src/components/overlay/StationOverlay.tsx#L123)). 가시성/겹침은 한 번만 결정하고, 실제 화면 위치는 RAF가 매 프레임 연속포즈로 재투영한다. + +**청크 처리.** 메인스레드 블로킹을 피하려 `requestIdleCallback`으로 한 번에 `CHUNK = 200`프레임씩 처리한다. + +```typescript +// StationOverlay.tsx:717 +const step = () => { + if (precomputeIdRef.current !== id) return; // 취소됨 (새 계산이 시작되면 id 불일치 → 중단) + const end = Math.min(processed + CHUNK, total); + while (processed < end) { + const idx = (startIdx + processed) % total; // startIdx→끝→0→… 순환 + processed++; + const drone = smoothFrame(frames, idx, SMOOTH_HALF); + // ... 측점/POI 가시판정 + 겹침억제 → newMap.set(drone.frame, {...}) + } + if (!published) { labelMapRef.current = newMap; published = true; } // 첫 청크 후 즉시 노출 + if (processed < total) requestIdleCallback(step, { timeout: 200 }); +}; +step(); // 첫 청크는 동기 실행 → 정지 중에도 토글/재계산 즉시 반영 +``` + +세 가지 영리한 설계가 있다. +- **현재 프레임부터 순환 처리** (`startIdx = currentFrameIdxRef.current`, `idx = (startIdx + processed) % total`): 지금 화면에 보이는 구간을 먼저 계산해 재생 즉시 라벨이 뜬다. +- **재계산 시 옛 맵 복사로 시작** ([#L696](../../client/src/components/overlay/StationOverlay.tsx#L696)): 빈 구간 없이 새 청크가 덮어쓰며 갱신. +- **취소 토큰** `precomputeIdRef`: 새 계산이 시작되면 `id`가 증가해 진행 중이던 step이 자동 중단(중복 작업 방지). + +가시판정은 카메라 클리핑(`Zc < CLIP_Z = 0.1` 제외 = 카메라 뒤/너무 가까움), 화면 밖(`pxRaw/pyRaw`가 [−0.02, 1.02] 밖) 제외, POI는 수평직선거리 `distH > MAX_RANGE` 제외로 이뤄진다([#L731](../../client/src/components/overlay/StationOverlay.tsx#L731), [#L748](../../client/src/components/overlay/StationOverlay.tsx#L748)). + +### 3.2 겹침 억제 / 우선순위 / 진행방향 변형 + +[StationOverlay.tsx#L755](../../client/src/components/overlay/StationOverlay.tsx#L755) + +화면상 가까이 겹치는 라벨을 솎아낸다. 임계: 가로 `POI_MERGE_X = 0.10`, 세로 `POI_MERGE_Y = 0.035`(정규화). 비등방(가로 관대, 세로 엄격)은 텍스트 라벨이 가로로 길기 때문. + +**우선순위 정렬**(겹침 시 누가 남는가): ① 철도역/역사(`railRank=0`)를 최우선, ② 같은 등급 내에서는 드론에 가까운 것(`dist` 작은 것)을 우선. + +```typescript +// StationOverlay.tsx:757 +const railRank = (cat: string): number => (cat === '철도역' || cat === '역사' ? 0 : 1); +poiCand.sort((a, b) => railRank(a.category) - railRank(b.category) || a.dist - b.dist); +const dirTag = routeDirRef.current === '상' ? '(상)' : routeDirRef.current === '하' ? '(하)' : ''; +``` + +**진행방향 변형 우선**: 같은 구조물이 상/하행 형제로 두 개 있을 때(예: `회덕제1가도교(상)` / `회덕제1가도교(하)`), 드론이 실제 지나는 선로 방향(routeMeta의 direction, [#L447](../../client/src/components/overlay/StationOverlay.tsx#L447))의 변형을 남긴다. `baseStruct()`로 괄호 변형을 떼 형제임을 판정하고, 이미 수용된 라벨이 비-방향태그인데 새 후보가 방향태그면 교체한다([#L777](../../client/src/components/overlay/StationOverlay.tsx#L777)): + +```typescript +const k = accepted[overlapIdx]; +if (dirTag && baseStruct(c.title) === baseStruct(k.title) && c.title.includes(dirTag) && !k.title.includes(dirTag)) { + accepted[overlapIdx] = c; +} +``` + +겹침제외 토글 OFF면 억제 없이 전부 표시하며, 동일좌표 라벨은 `labelRow / labelRowCount`로 세로 행 배치된다([#L784](../../client/src/components/overlay/StationOverlay.tsx#L784))(렌더 시 `ROW_H = 24px`로 중앙정렬, [#L1101](../../client/src/components/overlay/StationOverlay.tsx#L1101)). + +--- + +## 4. RAF 렌더 루프 (계산 없이 조회 + 투영만) + +[StationOverlay.tsx#L906](../../client/src/components/overlay/StationOverlay.tsx#L906) + +매 프레임 `requestAnimationFrame`에서 캔버스를 다시 그린다. **무거운 계산(가시성/겹침)은 하지 않고**, precompute가 채운 가시집합을 조회 + 연속포즈로 재투영 + 그리기만 한다. + +### 4.1 연속 시간 → 보간 포즈 + +```typescript +// StationOverlay.tsx:941 +const estTime = timeRef + ? timeRef.current + : currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000; +const estFrame = estTime * VIDEO_FPS; // VIDEO_FPS = 30000/1001 ≈ 29.97 +const dronePose = poseAt(estFrame) ?? (...폴백 smoothFrame...); +const lines = buildLines(dronePose); +``` + +`timeRef`는 VideoPlayer의 `smoothTimeRef`로, 매 RAF마다 단조(monotonic) 벽시계 보간으로 갱신되는 재생시간이다([VideoPlayer.tsx#L84](../../client/src/components/player/VideoPlayer.tsx#L84)). 일시정지·시크·배속을 모두 보정하므로 라벨/선/궤적이 영상과 동기된 채 60fps로 매끄럽게 흐른다. + +### 4.2 object-fit:cover 정규좌표 → 화면 변환 (`coverRef`) + +영상은 컨테이너를 비율유지·크롭하며 채우므로(CSS `object-fit:cover`), 오버레이도 같은 cover 사각형에 매핑해야 정합된다. + +```typescript +// StationOverlay.tsx:923 +const { w: vW, h: vH } = videoSizeRef.current; +let dispW = W, dispH = H, offX = 0, offY = 0; +if (vW > 0 && vH > 0) { + const s = Math.max(W / vW, H / vH); // cover: 더 큰 배율(넘침=크롭) + dispW = vW * s; dispH = vH * s; + offX = (W - dispW) / 2; offY = (H - dispH) / 2; // 음수 = 화면 밖(크롭) +} +coverRef.current = { offX, offY, dispW, dispH, W, H }; +const vx = (nx: number) => offX + nx * dispW; // 정규(0~1, 영상프레임) → 화면 px +const vy = (ny: number) => offY + ny * dispH; +``` + +`coverRef`는 RAF가 매 프레임 갱신하고, 포인터 핸들러(클릭→정규좌표 역변환 `clientToVideoNorm`, [#L1211](../../client/src/components/overlay/StationOverlay.tsx#L1211))와 자동팝업 동기화도 공유한다. + +### 4.3 중심선 / 드론궤적 투영 (`buildLines`) + +[StationOverlay.tsx#L606](../../client/src/components/overlay/StationOverlay.tsx#L606). 보간포즈를 받아 매 프레임 직접 투영한다(캐시 미사용 → 라인이 라벨처럼 부드럽게 이동). +- **근거리 클리핑**: 세그먼트 양 끝의 `Zc`가 `CLIP_Z=0.1` 경계를 가로지르면 교점 `t = (CLIP_Z − z1)/(z2 − z1)`로 잘라 투영([#L622](../../client/src/components/overlay/StationOverlay.tsx#L622)) → 카메라 뒤 점이 화면 반대편으로 튀는 것 방지. +- **화면밖 컬링**: 비트마스크 `oc()` outcode로 양 끝이 같은 변 밖이면 세그먼트 폐기([#L612](../../client/src/components/overlay/StationOverlay.tsx#L612)). +- 드론궤적: GPS 경로(lat/lon)를 표고 `Z`에 투영, `PW=15` 폭 이동평균으로 평활, `STEP=3` 샘플, `MAX_FWD=200m` 앞까지만([#L646](../../client/src/components/overlay/StationOverlay.tsx#L646)). + +라벨 자체도 캐시의 월드좌표를 `dronePose`로 재투영 → `smoothStep` → 히스테리시스 후 그린다([#L1039](../../client/src/components/overlay/StationOverlay.tsx#L1039) 측점, [#L1071](../../client/src/components/overlay/StationOverlay.tsx#L1071) POI). + +### 4.4 나침반 미니맵 (heading-up) + +[StationOverlay.tsx#L954](../../client/src/components/overlay/StationOverlay.tsx#L954). 본 포즈는 ±60fr 평활이라 회전 시 나침반이 지연된다 → **별도로 ±3fr 가벼운 평활 yaw**를 보간해 회전에 즉시 반응. `--rot` CSS 변수로 DOM 회전을 갱신하며, **360° 누적 언랩**으로 359°→0° 점프를 방지한다: + +```typescript +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`); +``` + +### 4.5 자동 컴팩트 팝업 동기화 (setInterval 150ms) + +[StationOverlay.tsx#L376](../../client/src/components/overlay/StationOverlay.tsx#L376). 가시 구조물(`visStructRef`, RAF가 매 프레임 채움)마다 라벨 옆에 선명한 DOM 팝업(시설종별/구조형식/연장 등 컴팩트 3필드)을 자동 생성/제거한다. **60fps RAF에서 setState를 남발하면 리렌더 폭주**하므로, 가시집합 변화 감지를 **150ms 인터벌**로 분리해 추가/제거가 있을 때만 `setInfoPopups`를 호출한다. 팝업의 실제 화면 위치는 다시 RAF가 라벨 표시좌표를 따라 매 프레임 갱신(EMA `PA=0.18`, 화면하단 근접 시 위로 플립, [#L1146](../../client/src/components/overlay/StationOverlay.tsx#L1146)). + +--- + +## 5. 성능 설계 — 60fps 유지 전략 + +| 작업 | 비용 | 배치 | 빈도 | +|------|------|------|------| +| 가시성/겹침 판정 (전 프레임 × 전 항목) | 높음 | precompute | 1회(+설정변경 시 debounce 재계산), idle 청크 200fr | +| 좌표 투영 (보간포즈 → 화면) | 중간 | RAF | 매 프레임 | +| 평활/EMA/히스테리시스 | 낮음 | RAF | 매 프레임 | +| 팝업 가시집합 setState | 리렌더 유발 | setInterval 150ms | 변화 시에만 | + +**핵심 = precompute(가시성)와 RAF(좌표) 분리.** "어떤 라벨을 그릴지"(비싼 결정)는 영상 변화와 무관하게 한 번 계산해 두고, "어디에 그릴지"(가벼운 투영)만 매 프레임 한다. 이로써 RAF 루프는 Map 조회 + 행렬 투영 + 캔버스 드로잉만 남아 60fps를 유지한다([파일 상단 주석 #L1](../../client/src/components/overlay/StationOverlay.tsx#L1)). + +부가 전략: 사전계산의 idle 청크 처리(메인스레드 비블로킹) + 취소토큰, 현재 프레임 우선 순환계산, 첫 청크 즉시노출, 픽셀 히스테리시스(불필요한 재드로 억제), 팝업 위치 EMA(물결/잔상 방지), 데이터셋별 보정값 localStorage 영속화([#L490](../../client/src/components/overlay/StationOverlay.tsx#L490)). + +--- + +## 6. 한계 / 업그레이드 포인트 + +- **단일 캐시 프레임키 의존 잔존**: 라벨 가시집합은 `Math.floor(estFrame)`로 정수 프레임의 캐시를 조회([#L1022](../../client/src/components/overlay/StationOverlay.tsx#L1022)). 좌표는 연속이지만 가시집합 자체는 프레임 경계에서 바뀐다 → 빠른 배속에서 라벨 등장/퇴장이 미세하게 튈 수 있음. +- **중심선 per-frame 투영 비용**: `buildLines`가 전 중심선 점을 매 프레임 투영. 점 수가 많아지면 부담 → 화면 근방 구간만 투영하는 공간색인(앞서 컬링 전 prefilter)으로 개선 여지. +- **POI 표고 부정확**: POI는 실측 표고가 없어 최근접 선로표고 또는 '드론고도 − N'으로 가정([#L744](../../client/src/components/overlay/StationOverlay.tsx#L744)) → 드래그/DEM override로 수동보정. DEM 자동조회 통합이 업그레이드 포인트. +- **VFR/프레임 누락**: `VIDEO_FPS` 상수(29.97) 가정. 가변프레임율 영상은 frame↔time 매핑 오차 발생. +- **평활 파라미터 수동 튜닝**: YAW_EDGE/REJECT_DIST/speedRef 등이 상수 또는 수동슬라이더 → 데이터셋 통계 기반 자동 캘리브레이션 가능. +- **단일 스레드**: precompute가 메인스레드 idle 의존. Web Worker로 옮기면 대용량에서 더 안정. + +--- + +## 7. 특허 관점 신규성 + +1. **에지(회전경계) 보존 적응형 이동평균** — yaw 차 임계로 평균창을 회전경계에서 멈추고, 회전 종료 후 한 프레임씩 점진 재확장하여 *직선구간 노이즈 억제*와 *회전구간 무지연 추종*을 동시에 달성. 회전 프레임 재혼입에 의한 1회 튐을 구조적으로 제거(변화율 기반 방식 대비). + +2. **월드좌표 사전계산 + 연속포즈 재투영의 2단 분리** — 라벨 가시성/겹침/우선순위(비싼 결정)는 월드좌표로 1회 사전계산하고, 화면 위치(가벼운 투영)는 매 RAF 연속 보간포즈로 재계산. "무엇을 그릴지"와 "어디에 그릴지"의 분리로 60fps AR 오버레이를 단일스레드에서 달성. + +3. **연속 프레임번호 보간 포즈 + 최단각 yaw** — 단조 벽시계 보간 재생시간을 실수 프레임번호로 변환, 이산 SRT 자세를 평활-후-보간하여 이산격자(30fps)를 넘는 60fps 연속 자세를 생성. yaw는 원형평균(평활)·최단각(보간)으로 360° 경계 안정. + +4. **속도적응 화면 평활(One-Euro 사상) + 연속거부 한계** — 속도벡터 평활로 떨림(왕복상쇄)과 실이동(방향일관)을 분리해 alpha를 적응시키고, 이상치 거부에 *연속거부 한계*를 두어 시크/재등장 시 영구고착을 방지. + +5. **진행방향 인지 형제 라벨 해소** — 상/하행 형제 구조물이 화면상 겹칠 때 노선 진행방향((상)/(하)) 변형을 우선 표시. 철도역 최우선 + 근접거리 차선의 다단 우선순위와 결합. + +6. **object-fit:cover 정합 + 양방향 좌표 변환** — 영상 크롭 사각형에 오버레이를 일치시키는 정규좌표 변환과 그 역변환(클릭→월드 역투영)으로, 영상 위 라벨 클릭/드래그 보정(focal 역산·표고 역산·지면교차)을 동일 좌표계에서 처리. + +--- + +**소요 시간**: 18분 +**Context 사용량**: input 88k / output 12k tokens diff --git a/docs/history/2026-06-29_1447_종합기술명세서-작성.md b/docs/history/2026-06-29_1447_종합기술명세서-작성.md new file mode 100644 index 0000000..41d2991 --- /dev/null +++ b/docs/history/2026-06-29_1447_종합기술명세서-작성.md @@ -0,0 +1,32 @@ +# GhiVideo 종합 기술 명세서 작성 (인계+특허 기초) + +**소요 시간**: 약 35분 +**Context 사용량**: input ~600k / output ~36k tokens + +## 결론 +사용된 전 기술을 소스 인용과 함께 1개 종합 문서로 정리. 후임 인계 + 특허 출원 기초자료 겸용. +md/html/pdf 3종 생성(PDF 18p, 한글 임베드). + +## 산출물 +- `docs/기술명세_GhiVideo_종합기술문서.md` (베이스) +- `docs/기술명세_GhiVideo_종합기술문서.html` (자체완결) +- `docs/기술명세_GhiVideo_종합기술문서.pdf` (A4 18p, Malgun 임베드, 페이지번호) + +## 방법 +- 5개 서브시스템을 병렬 서브에이전트로 정밀 조사 후 종합: + 1) 카메라 투영·FOV 정합(geoProjection.ts) 2) 드론 자세 평활·오버레이 렌더(StationOverlay.tsx) + 3) 측점·스테이션바(chainage.ts/StationBar/Timeline) 4) 백엔드·데이터·상태(server/geoData/store) + 5) 기존 특허/발명 문서 + history 타임라인 +- 투영 파이프라인은 정본 1개(§2)로 통합, 지오이드 N=25.8m 통일, 특허/인계 섹션 통합. + +## 문서 구성 (13장) +개요·아키텍처 / 좌표·투영 정본 / 카메라·FOV / 측점표고정합(발명B) / 자세평활·렌더 / +측점·스테이션바 / 위치보정 역투영(발명C) / 백엔드·데이터 / 개발타임라인 / 특허종합 / +검증결과표 / 후임자 인계 / 한계·출원전 확인. + +## 검증 +- scripts/md2docs.sh 로 html+pdf 생성, %PDF-1.7 18페이지 /FontFile(Malgun) 임베드 확인. + +## 참고 +- 특허 출원 시 §3~§7·§10 + history 원문(특히 2026-06-22 POI투영오차)을 변리사에 함께 제공 권장. +- KIPRIS 전문검색은 미수행 — 출원 전 필수. diff --git a/docs/기술명세_GhiVideo_종합기술문서.html b/docs/기술명세_GhiVideo_종합기술문서.html new file mode 100644 index 0000000..30c52a5 --- /dev/null +++ b/docs/기술명세_GhiVideo_종합기술문서.html @@ -0,0 +1,834 @@ + + + + + + + 기술명세_GhiVideo_종합기술문서 + + + + + +
+

기술명세_GhiVideo_종합기술문서

+
+

GhiVideo 종합 기술 명세서

+
+

목적: 본 문서는 (1) 특허 출원/발명 신고의 기초 자료와 (2) 후임 개발자 인계·업그레이드 자료를 겸한다. 대상 시스템: GhiVideo — 시간축이 아니라 노선 위치(측점·체이니지) 를 1차 좌표로 삼아 드론 주행영상을 색인·탐색하고, 프레임별 카메라 자세로 측점·POI·선로중심선을 영상 위에 실시간 정합(geo-registration)하는 철도 주행영상 분석 플레이어. 작성일: 2026-06-29 · 모든 소스 참조는 [경로](경로#Lxx) 형식(클릭 가능).

+
+
+

0. 문서 구성과 읽는 법

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
내용주 독자
1시스템 개요·아키텍처·데이터 흐름전체
2좌표·투영 정본 파이프라인 (모든 정합의 기반)전체
3카메라 투영 & 화각(FOV) 정합개발/특허
4측점 표고 정합 (DEM-free) — 발명 B특허
5드론 자세 평활/보간 + 오버레이 렌더링개발
6측점(체이니지) 시스템 & 스테이션바개발/특허
7위치보정 역투영 — 발명 C특허
8백엔드 스트리밍/FFmpeg·데이터·상태관리개발
9개발 타임라인(주요 의사결정)전체
10특허 관점 종합(인벤토리·후보·청구포인트·선행기술)특허
11검증 결과표전체
12후임자 인계(데이터흐름·튜닝상수·빌드·업그레이드)개발
13한계 / 출원 전 확인사항전체
+
+

1. 시스템 개요 & 아키텍처

+

1.1 핵심 아이디어

+

드론으로 촬영한 철도 선로 주행영상의 각 프레임에는 SRT 메타데이터로 드론 위치(lat/lon/고도)·자세(yaw/pitch/roll)·초점거리가 기록된다. 한편 철도 GIS에는 측점·POI·선로 중심선의 WGS84 좌표(+표고) 가 있다. 이 둘을 핀홀 카메라 투영으로 결합하면:

+
    +
  1. 순방향 투영 — "이 GIS 지점이 지금 화면 어느 픽셀에 보이는가" → 라벨/중심선/궤적 오버레이.
  2. +
  3. 역투영 — "사용자가 화면에서 끈 픽셀은 어느 지리좌표인가" → POI 위치/표고 보정.
  4. +
  5. 측점 색인 — 드론 GPS를 측점 폴리라인에 투영해 영상 매 시점을 측점값(체이니지) 으로 환산 → 측점 기반 탐색/스크러빙.
  6. +
+

1.2 데이터 흐름

+
[폴더 선택: base.csv(드론프레임) + building/측점·교량·터널·역사 CSV + route.json + KMZ]
+        │  geoData.loadFolderGeoData (인코딩 자동감지·병렬 파싱·KMZ 우선)
+        ▼
+[geoStore]  frames / stations / pois / structures / centerline / origin / routeMeta
+        │
+        ├─▶ StationOverlay (영상 위 AR 오버레이)
+        │     precompute(가시성/겹침, requestIdleCallback) → RAF(연속포즈 재투영) → Canvas
+        │     geoProjection: WGS84→TM→ENU→카메라→핀홀
+        │
+        ├─▶ StationBar (하단 측점 스크러버)
+        │     chainage: 드론GPS→측점폴리라인 수직투영→연속 체이니지
+        │     이동거리/측점축, 방향색, 구조물 마커, 측점검색 순환
+        │
+        └─▶ VideoPlayer (Video.js + hls.js, 이중 재생경로, 프레임 정확도)
+

1.3 기술 스택

+
    +
  • 프론트엔드: React 18 + TypeScript, Video.js 8.23 + @videojs/http-streaming, hls.js 1.6, Zustand(상태), Vite, Canvas 2D(오버레이), proj4(좌표변환), fflate(KMZ 압축해제).
  • +
  • 백엔드: Node.js 20 + Express, child_process.spawn FFmpeg 래퍼, Range Request 스트리밍, HLS 변환 + SSE 진행률.
  • +
  • 배포: PM2(ghiVideo, 포트 55000)가 client/dist 정적 서빙(빌드 결과물). 소스 변경 시 npm run build -w client(Node 20) 후 반영.
  • +
+
+

2. 좌표·투영 정본 파이프라인 (★ 모든 정합의 기반)

+
+

본 절을 정본으로 하고, 3~7장은 이를 참조한다. Python 레퍼런스 advanced_tuner_v2.py와 회전행렬·축정렬·정규화 공식이 1:1 동치로 이식되어 있다(geoProjection.ts:1-12).

+
+

2.1 5단계 변환

+
[1] WGS84 (lat, lon, EL정표고)
+      │  proj4 EPSG:4326 → EPSG:5186 (한국 중부원점 TM)
+      ▼
+[2] 월드 ENU [East(m), North(m), Up(m)]   (East=X, North=Y)
+      │  rel = target_ENU − (drone_ENU + offset)
+      ▼
+[3] 카메라좌표 [Xc, Yc, Zc]   (R_w2c = R_align · R_b2w(yaw,pitch,roll)ᵀ)
+      │  핀홀 + focal/sensor
+      ▼
+[4] 정규 이미지 좌표 [px, py] ∈ (0~1)   (0.5 = 영상 중심)
+      │  object-fit:cover 변환
+      ▼
+[5] 화면 px
+

2.2 단계별 수식·코드

+

단계 1 — WGS84 → TM (geoProjection.ts:75-85)

+
proj4.defs('EPSG:5186', '+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs');
+function latLonToTM(lat, lon) { const [e, n] = _toTM.forward([lon, lat]); return [e, n]; }
+

단계 2 — TM → ENU + 표고 datum 정합 (geoProjection.ts:91-97, 263-271). X/Y는 절대 TM, Z만 기준고도 대비 상대(상대벡터를 빼므로 무관). 핵심: GIS 정표고(EL) + 지오이드고(N) → 타원체고로 변환해 드론 GPS 타원체고와 datum을 맞춘다.

+
const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), ...); // EL + N
+const drEnuAdj = [drEnu[0]+offX, drEnu[1]+offY, drEnu[2]+offZ];
+const relEnu = [stEnu[0]-drEnuAdj[0], stEnu[1]-drEnuAdj[1], stEnu[2]-drEnuAdj[2]];
+
+

지오이드고 N: 대전지역 ≈ 25.8m(= 84.244 − 16.853 − 41.59, KNGeoid 검증값). 초기 문서엔 25.449m로 기록된 바 있으나 본 명세는 25.8m로 통일(§11). 지역 상수이므로 타 지역 데이터셋은 재튜닝 필요.

+
+

단계 3 — ENU → 카메라좌표 (geoProjection.ts:275-295). R_b2w = Rz(−yaw)·Rx(pitch)·Ry(roll), R_w2c = R_align·R_b2wᵀ, R_align=[[1,0,0],[0,0,−1],[0,1,0]]. 행렬을 명시 생성하지 않고 전치(행=열)·부호치환으로 직접 계산:

+
function applyRw2c(b2w, rel) {
+  return {
+    Xc:   b2w[0][0]*rel[0] + b2w[1][0]*rel[1] + b2w[2][0]*rel[2],  //  col0
+    Yc: -(b2w[0][2]*rel[0] + b2w[1][2]*rel[1] + b2w[2][2]*rel[2]), // -col2
+    Zc:   b2w[0][1]*rel[0] + b2w[1][1]*rel[1] + b2w[2][1]*rel[2],  //  col1
+  };
+}
+

규약: Zc>0 = 카메라 정면(영상 깊이축), Xc=우, Yc=하. Zc≤0은 카메라 뒤 → null.

+

단계 4 — 카메라좌표 → 정규 픽셀(핀홀) (geoProjection.ts:126-137). 영상 중심 = 0.5, 해상도 독립:

+
pxRaw = (0.5 + cx0) + (Xc/Zc) * (f / sensorW);   // 가로
+pyRaw = (0.5 + cy0) + (Yc/Zc) * (f / sensorH);   // 세로
+

단계 5 — 정규 → 화면 px (object-fit:cover) (StationOverlay.tsx:923-933). 영상 크롭 사각형에 맞춰 정합:

+
const s = Math.max(W / vW, H / vH);              // cover: 더 큰 배율(넘침=크롭)
+dispW = vW * s; dispH = vH * s; offX = (W - dispW)/2; offY = (H - dispH)/2;
+const vx = (nx) => offX + nx * dispW;  const vy = (ny) => offY + ny * dispH;
+

2.3 두 투영 경로의 의도적 분리

+
    +
  • 렌더(정밀): geoProjection.ts — proj4 EPSG:5186 TM + 회전행렬 + geoid/포커스 보정. (3~5장)
  • +
  • 검색(경량): geoSearch.ts — cos-lat 근사 ENU. "현재 시야 내 POI" 또는 "특정 POI가 보이는 프레임 구간" 산출용.
  • +
+

ENU 월드원점은 첫 측점(없으면 첫 드론프레임, 없으면 중심선 첫 점)(geoSearch.ts:117-128).

+
+

3. 카메라 투영 & 화각(FOV) 정합

+
+

좌표 변환 파이프라인은 §2 참조. 본 절은 화각·내부표정 보정과 역투영을 다룬다.

+
+

3.1 focal ↔︎ FOV 관계

+

화각은 focal과 센서 치수로 결정된다(핀홀): FOV = 2·atan(sensor / (2·focal)) (StationOverlay.tsx:864). 기본값 focal=24mm, sensorW=36mm(풀프레임 환산), sensorH=20.25mm(=36×9/16, 16:9) (geoProjection.ts:48-66). 이 hFOV는 나침반/미니맵 시야부채꼴에도 사용.

+

3.2 fovMode — 세로 화각(sensorH) 역산 보정 (★)

+

라벨이 멀리/가까이에서 상하로 어긋날 때, 사용자가 POI 하나를 영상 속 실제 위치로 드래그하면 그 한 점의 대응으로 세로 화각을 역산한다. 설계 핵심: 가로 focal(f/sensorW)은 건드리지 않고 sensorH만 역산 → 빨간 중심선이 좌우로 틀어지지 않으면서 상하 배율만 교정.

+

투영식 py = 0.5 + cy0 + (Yc/Zc)·(f/sH) 를 sH에 대해 풀면 (StationOverlay.tsx:1326-1344):

+
const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo);
+const b = cc.Yc / cc.Zc;
+const v = pos.y - 0.5 - (p.cy0 ?? 0);
+const sHNew = (b * p.focalLen) / v;            //  sH = (Yc/Zc)·f / (py − 0.5 − cy0)
+setParam('sensorH', Math.max(6, Math.min(36, sHNew)));
+

영상 세로 중심(v≈0)은 발산 → |v|<0.03이면 거부하고 안내.

+

3.3 핵심 함수

+
    +
  • toCameraCoords (geoProjection.ts:301-317) — GIS점→카메라좌표. 부산물 distH(수평 직선거리), fwd/side(진행방향 앞/옆 성분, 비등방 거리필터용). pixelFromCamera와 분리한 이유: 중심선처럼 선분 단위 근거리 Zc 클리핑·보간 후 투영해야 하기 때문.
  • +
  • pixelFromCamera (geoProjection.ts:126-137) — 카메라좌표→정규픽셀(클램프 없음).
  • +
  • groundPointFromPixel (geoProjection.ts:218-251) — 화면픽셀→지면 lat/lon. 광선-수평면 교차: +
    const t = relUpTarget / dir[2];          // dir = R_w2cᵀ·((px−0.5−cx0)·sW/f, (py−0.5−cy0)·sH/f, 1)
    +if (t <= 0) return null;                 // 카메라 뒤/위
    +const E = drEnu[0]+offX + t*dir[0]; const N = drEnu[1]+offY + t*dir[1];
    +const [lon, lat] = _toTM.inverse([E, N]);
  • +
  • 관련 역투영 2종: worldFromPixel(슬랜트거리 유지 lat/lon/z 동시복원, 149-183), solveZForPixelY(lat/lon 고정, py에 맞는 z만 1차식 역산, 194-211). → 발명 C(§7).
  • +
+

3.4 보정값 영속화 (데이터셋별)

+
    +
  • 카메라/내부표정·표시설정: ghivideo:calib:{baseName} (로드 1회, 저장 800ms debounce, StationOverlay.tsx:473-500).
  • +
  • POI 위치/표고 override(DEM·드래그): ghivideo:poiov:{baseName} (폴더 파일 override 위에 localStorage 병합, 502-524).
  • +
+

3.5 설계 트레이드오프·한계

+
    +
  • 렌즈 왜곡 미보정: 핀홀만 사용(방사/접선 왜곡 계수 없음). 분석 보조 라벨 정합엔 표준 정확도로 충분(가장자리 수 px 오차 가능).
  • +
  • 클라이언트 순수 계산: proj4만 의존, 서버 왕복 없이 60fps RAF에서 직접 투영.
  • +
  • focal 분리 보정: 한 점 대응으로 sensorH 한 축만 역산(가로 정합 보존).
  • +
  • POI 표고 가정: 실측 표고 없으면 최근접 선로 지면표고(또는 "드론고도−이격") → §4·드래그·DEM 보정.
  • +
+
+

4. 측점 표고 정합 (DEM-free 영상정합) — 발명 B

+

4.1 무엇을 / 왜

+

표시할 시설물(POI)은 주소 기반이라 표고값이 없는 경우가 대부분이다. 표고가 없으면 부각(수직각) 산출 불가 → 종래는 지면을 평면으로 가정. 그러나 노선은 실제 기복(정표고 41.6m→57.5m)이 있어 평면 가정 시 라벨이 화면 밖/지면 아래로 이탈한다. 실측에서 잘못된 고도(≈0) 사용 시 부각오차 42.7°.

+

→ 별도 DEM 취득 없이, 노선을 따라 실측된 측점의 정표고를 지면고도원으로 삼아 정합한다.

+

4.2 구성(독립항 필수요소)

+
    +
  1. 측점 표고 수열 보유 — 노선 따라 실측된 복수 측점의 (lat/lon + 정표고 EL). (실데이터: 측점 47개, 41.6→57.5m)
  2. +
  3. 최근접 측점 지면고도 채택 — 표고 미상 대상에 가장 가까운 측점의 정표고를 지면고도로. 프레임 진행에 따라 최근접 측점이 바뀌어 지면고도가 노선 기복을 따라 갱신.
  4. +
  5. 지오이드 datum 정합 — 정표고(EL) + 지오이드고(N) → 타원체고 → 드론 GPS와 동일 기준. rel_up = (targetZ + N) − droneAlt.
  6. +
  7. 정합 투영 — §2 파이프라인 수행.
  8. +
  9. (종속) 대상별 표고 오프셋(poiZOffset) — 건물높이/지반차 보정, 측점·중심선엔 미적용.
  10. +
+

4.3 검증값

+
타원체고 = 정표고(EL) + 지오이드고(N≈25.8m, 대전)
+# 측점 157K900, frame0, 수평거리 91.3m
+정표고≈0     → 부각 42.7°  ❌
+정표고 41.5  → 부각 25.1°
+정표고+N≈67  → 부각 11.0°  ✅ (드론 rel_alt 16.85m와 일치)
+
    +
  • 데이터 함정(핵심): building/01)측점.csv에 표고 컬럼이 둘 — Z좌표(로컬 ≈0) / Z좌표_한국(EPSG:5186 정표고 ≈41.5m). 기존 코드가 Z좌표(≈0)를 읽어 42.7° 오차 발생 → Z좌표_한국 우선으로 교정(geoData.ts 측점 파서).
  • +
  • 효과: 부각오차 42.7°→11.0°, 구글어스 DEM 대비 0.2m 일치(2160p에서 ~6px). DEM 취득비용 불필요. 공개 DEM(SRTM 30m)보다 측량 측점이 더 정밀.
  • +
+
+

5. 드론 자세 평활/보간 + 영상 오버레이 렌더링

+
+

두 본질 과제: (a) 자세/GPS 노이즈(단순평균은 회전 뭉갬→지연), (b) 렌더 부드러움 vs 계산비용(가시성 판정은 비싸고 60fps는 매 프레임 재투영 요구).

+
+

5.1 에지(회전경계) 보존 적응형 이동평균 — smoothFrame

+

StationOverlay.tsx:577-604. 중심 i에서 좌우 최대 halfWin(기본 60fr≈2초)까지 평균하되, 중심 yaw와 차이가 YAW_EDGE=8° 이내인 인접까지만 창 확장.

+
const YAW_EDGE = 8;
+const near = (idx) => Math.abs(((frames[idx].yaw - yc + 540) % 360) - 180) <= YAW_EDGE;
+while (lo > loMin && near(lo - 1)) lo--;   // 회전경계에서 멈춤
+while (hi < hiMax && near(hi + 1)) hi++;
+// yaw는 원형평균: atan2(Σsin, Σcos)
+yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI,
+
    +
  • 직선: 창 풀폭 → 노이즈 강하게 억제. 회전 중: 창 좁아져 즉시 추종(지연 없음). 회전 후: 한 프레임씩 점진 재확장(튐 없음). 과거 '변화율 기반 축소'는 회전 종료 시 한꺼번에 재확장돼 회전 프레임이 다시 섞여 1회 튀던 부작용 → 경계보존으로 교체.
  • +
+

5.2 연속 프레임번호 보간 포즈 — poseAt

+

StationOverlay.tsx:670-690. 실수 프레임번호 estFrame에 대해 양 이웃의 평활포즈를 선형보간(이산 30fps 격자→60fps 연속). yaw는 최단각 보간:

+
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180;   // 최단각
+return { ...a, frame: estFrame, lat:L(a.lat,b.lat), ..., yaw: a.yaw + dy*frac };
+

O(log n) 이진탐색으로 매 RAF 호출 가능.

+

5.3 속도적응 EMA + 이상치 거부 — smoothStep (One-Euro 사상)

+

StationOverlay.tsx:48-61. 느릴 때(떨림)는 강하게 평활, 빠를 때(실제 팬)는 즉시 추종.

+
if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES)  // 0.12 초과 점프 = 이상치 → 위치 유지
+  return { x: prev.x, y: prev.y, rej: prev.rej + 1, ... };
+const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA; // β=0.25 속도평활(떨림 왕복은 상쇄)
+const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed/speedRef));
+return { x: prev.x + dx*a, y: prev.y + dy*a, ... };
+
    +
  • 이상치 거부에 연속거부 한계(MAX_REJECT_FRAMES=8) → 시크/재등장 같은 진짜 큰 이동에 영구 고착 방지.
  • +
  • 추가로 픽셀 히스테리시스(0.75px 이상일 때만 정수좌표 갱신)로 저속 반올림 깜빡임 제거(1048).
  • +
+

5.4 라벨 사전계산 + 겹침 억제

+
    +
  • startLabelPrecompute (StationOverlay.tsx:692-746): 전 프레임의 가시집합·겹침해소 결과Map<frameNum, LabelCache>에 사전계산. 화면좌표가 아니라 월드좌표(lat/lon/z)를 저장 → RAF가 매 프레임 연속포즈로 재투영. requestIdleCallback 200프레임 청크, 취소토큰(precomputeIdRef), 현재 프레임부터 순환 처리(재생 즉시 라벨), 첫 청크 즉시 노출, 재계산 시 옛 맵 복사.
  • +
  • 겹침 억제 (755-784): 임계 가로 POI_MERGE_X=0.10/세로 0.035(비등방, 텍스트가 가로로 긴 특성). 우선순위 ①철도역/역사 최우선 ②근접거리. 진행방향 변형 우선: 상/하행 형제 겹침 시 노선 방향((상)/(하)) 변형을 남김.
  • +
+

5.5 RAF 렌더 루프 & 성능 설계

+

매 프레임 계산 없이 조회+투영+드로잉만. 라이브 시간은 VideoPlayer smoothTimeRef(단조 벽시계 보간, 일시정지·시크·배속 보정)에서 받음.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
작업비용배치빈도
가시성/겹침 판정높음precompute1회(+설정변경 debounce), idle 청크
좌표 투영중간RAF매 프레임
평활/EMA/히스테리시스낮음RAF매 프레임
팝업 가시집합 setState리렌더setInterval 150ms변화 시만
+

핵심 = "무엇을 그릴지"(precompute) ↔︎ "어디에 그릴지"(RAF) 분리로 단일스레드 60fps 달성. 중심선/드론궤적은 buildLines로 매 프레임 직접 투영(근거리 Zc 클리핑·outcode 컬링). 나침반은 별도 ±3fr 평활 yaw + 360° 누적 언랩.

+
+

6. 측점(체이니지) 시스템 & 스테이션바

+

6.1 연속 체이니지 수직투영 (핵심 ①)

+

드론 한 프레임의 위경도를 측점 폴리라인에 수직 투영해 100m 양자화가 아닌 10m 해상도 연속 측점값을 얻는다(부산물: 선로 수직 이격 offsetM).

+
    +
  • 폴리라인 구축 buildChainLine (chainage.ts:22-32): 측점값 오름차순 정렬, 평면투영 x=lon·k, y=lat·111000 (k=cos(lat0)·111000).
  • +
  • 점-선분 투영 projectToChain (chainage.ts:35-50): +
    const t = L2===0?0:Math.max(0,Math.min(1, ((px-a.x)*dx + (py-a.y)*dy)/L2));
    +const d = (px-cx)**2 + (py-cy)**2;
    +if (d < bestD) { bestD = d; bestKm = a.km + (b.km-a.km)*t; }   // 최근접 선분 위 km 보간
  • +
  • 왜 연속 투영인가: 최근접 측점점(100m 양자화)은 값이 계단처럼 튀고 두 측점 중간에서 전진/후진 전환 시점이 어긋난다. 수직투영은 선분 보간으로 매 프레임 부드럽게 변해 방향 전환점(극값) 타이밍이 실제 주행과 일치.
  • +
+

6.2 이중 측점값: 배지용 km vs 방향용 chain

+

프레임별 precompute(StationBar.tsx:233-246): km=최근접 측점점(100m, 배지/폴백), chain=연속투영(10m, 방향/축/탐지). videoFps는 실데이터(마지막 프레임/재생시간)에서 유도, 미비 시 30000/1001 폴백.

+

6.3 축 설계: 시간축이 아닌 위치축 (핵심 ②)

+

선로 점검 영상은 호버(정지비행)전·후진 반복 특성이 있어 시간축이 부적합하다(호버 시 커서가 위치와 어긋남).

+
    +
  • 이동거리축 (StationBar.tsx:250-271): 연속 측점값을 ±8fr 평활 후 프레임간 절대 변화량 누적(cum += |sm[i]−sm[i−1]|)을 정규화(frac, 단조 비감소). 호버=frac 정체(커서 정지), 이동=frac 증가. 방향은 색 리본으로 직교 분리.
  • +
  • 측점축(후속 채택안, history 2026-06-26_1520): x축을 측점값 비례로 두고, 축 범위를 역 POI 첫/끝으로 노선 고정(영상 무관 절대 측점) → 어떤 데이터로 재생하든 같은 구조물이 같은 위치/측점에 표시(회차 간 정렬).
  • +
  • 시간↔︎축 양방향 변환: cumFracAtTime/timeAtFrac(또는 chainAtTime/pxAtChain)를 이진탐색+보간으로. 커서·구간색·구조물 마커가 동일 매핑 공유. 라이브 커서는 React 리렌더 없이 RAF에서 CSS 변수(--pos-px)만 갱신(771-786).
  • +
+

6.4 방향 판정 & 방향색 트랙

+

평활 측점값 추세를 극값추적 + 히스테리시스 HYST=10m로 방향 전환점 확정(395-458). forwardDir = arrChain≥depChain?1:−1. 정방향=주황 음영, 역방향=청록. 이동거리/측점축은 구간 px가 단조라 linear-gradient 1장으로 경량 처리(686-726).

+

6.5 구조물 마커 — 좌표/측점 이중 탐지 & kmExists (핵심 ③)

+

두 통과 탐지기:

+
    +
  • pxPassesTo(lat,lon,off) (489-534) — 좌표 근접. 임계 TH=max(100, gmin·3).
  • +
  • pxPassesAtMileage(targetM) (537-559) — 연속 체이니지가 TH=stationTolerance(기본 20m) 이내.
  • +
+

탐지 우선순위(단락평가): ①명시 측점값 ②좌표를 측점선 투영→측점기준(통과순서 정확) ③이름매칭 POI 투영 ④좌표근접 폴백 … (612-639).

+

역사 합집합 보강: 종점/조차장은 측점선 투영이 불안정해 재진입을 놓침(예 대전조차장 162k080). 단락평가 탓에 한 통과를 찾으면 좌표근접이 안 돌아 누락 → 역사에 한해 좌표근접 통과를 합집합으로 합치되 24px 임계 중복제거(629-634).

+

kmExists (★): 좌표 반경만으로 잡힌 통과는 실제 측점값이 그 위치에 없을 수 있다.

+
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;  // existTol = stationTolerance
+

(StationBar.tsx:648) false측점값 라벨·측점검색에서 제외하되 마커(동그라미)는 유지 — "위치 표시"와 "측점 단정"의 분리.

+

종점 미도착: endGapPx=min(폭·0.15, (gapM/lenM)·폭), placeUnreachedTerminal이 최우측 역사를 트랙 끝에 미도착(속 빈 링)으로 고정(303-308, 574-586).

+

6.6 측점검색 순환 — handleJumpToMileage

+

StationBar.tsx:850-916. 측점값 입력 시 여러 통과를 통과방향 순환:

+
    +
  1. 후보 1순위: 화면 마커(structureMarks) 중 kmExists!==false 이고 표시 10m값 일치 → px→timeAtFrac 역변환. (좌표근접 통과 포함 → 모든 동그라미 도달)
  2. +
  3. 2순위: 마커 없으면 chain 연속구간, 최종 전역 최근접 폴백.
  4. +
  5. 정렬 + 0.3s 중복제거 → baseT 기준 다음 통과(끝→처음 순환). jumpRef로 연속검색 판정. Enter 후 입력값 유지, blur 시 삭제(PlaybackControls.tsx).
  6. +
+

6.7 타임라인 보조 기술 (Timeline.tsx)

+
    +
  • 동일항목 브래킷 클러스터: 동명 다통과를 거리 무관 한 묶음으로(드롭+수평 점선) 중앙 라벨 1개(143-172).
  • +
  • 라벨 2줄 균등분할 splitStructLabel: CJK 1.0/기타 0.55폭, 괄호그룹은 통째로 아랫줄(30-49).
  • +
  • 측점값 라벨 겹침 숨김 showMileage: 직전 표시와 (lastW+w)/2 미만이면 숨김, kmExists===false는 애초 생략(117-137).
  • +
  • 아이콘 passed 전환: upcoming/passed 두 PNG를 opacity로 전환(무깜빡임), 전 상태 사전로드.
  • +
+
+

7. 위치보정 역투영 (단일 제스처) — 발명 C

+

7.1 무엇을 / 왜

+

지오코딩 POI는 ±수십 m 오차. 2D 화면 이동만으로는 카메라로부터의 거리(깊이)를 결정 못함(깊이 모호성). 종래는 수평/높이를 따로 조정하거나 깊이맵(LiDAR) 요구. → 단일 드래그로 수평·수직(3D) 동시 보정, 깊이맵 불요.

+

7.2 구성(독립항 필수요소)

+
    +
  1. 슬랜트거리 보존 — 드래그 개시 시점의 카메라–객체 슬랜트거리(range)를 고정 → 깊이 모호성 해소 핵심 구속.
  2. +
  3. 역투영 — 드롭 픽셀을 카메라 방향벡터로 환산 후 보존 거리만큼 투사.
  4. +
  5. 월드좌표 복원 — 자세 회전·평행이동 역변환으로 lat/lon/z 복원(지오이드 역환원). 한 번의 드래그로 수평·수직 동시.
  6. +
  7. 영속·재적용 — 복원 좌표를 객체 식별자(title)에 연계해 <base>_poi_overrides.json + ghivideo:poiov:<baseName> 저장.
  8. +
  9. (종속) 가시영역 한정 — 전방(Zc>0)만 보정.
  10. +
+
dir_cam  ((px−0.5−cx0)·sW/f, (py−0.5−cy0)·sH/f, 1)
+cam = dir_cam/|dir_cam| · range          // ★거리 보존(드래그 시작값)
+rel = R_w2cᵀ · cam                        // 회전 역변환
+z   = droneAlt + offZ + rel.up − N_geoid  // 정표고 복원
+(lat,lon) = TM⁻¹(droneE+offX+rel.x, droneN+offY+rel.y)
+

변형 — z 전용 역산 solveZForPixelY: lat/lon 고정, 드롭 세로위치로 z만 1차식 역산. 왕복검증 z=42→py→42.000.

+

7.3 효과

+

2D 드래그 1회로 3D 보정(단계 절반), 역투영 왕복오차 ≈0, 깊이맵 불요, 파일+localStorage 재현성. "depth map 없이 거리보존" 을 명시해 US9188444B2와 구별(§10).

+
+

8. 백엔드 스트리밍/FFmpeg · 데이터 파이프라인 · 상태관리 · 플레이어

+

8.1 대용량(2GB+) 재생 — 이중 경로

+
    +
  • 로컬(useVideoPlayer.ts:80-88): URL.createObjectURL Blob URL 직접 재생(업로드/변환 없음), 교체 시 revokeObjectURL(누수 방지). 현 운영 주 경로.
  • +
  • 서버: Range Request 즉시 재생, 비표준 코덱(HEVC 등)이면 HLS 자동 전환(15-17). (서버/HLS 경로는 보존되나 UI는 SHOW_TOOLBAR=false로 숨김.)
  • +
+

8.2 Range Request (streaming.ts:52-77)

+

핵심: 한 응답에 전체를 싣지 않음. 끝값 없으면 start+10MB−1. pipeline()(백프레셔/에러 자동), highWaterMark:1MB, Accept-Ranges:bytes, path traversal 방어(resolveVideoPath). JS Number(2^53−1)로 4GB+ 안전(BigInt 불요). 멀티레인지 미지원.

+

8.3 HLS 변환 + SSE (hls.ts)

+

멱등(디스크에 index.m3u8 있으면 즉시 done), 코덱 분기(H.264 -c copy 수 초 / 그 외 libx264), /progress SSE로 stderr time= 파싱 푸시. hls.js 필수: backBufferLength:30(기본 Infinity면 장시간 재생 메모리 누수→크래시), maxBufferLength 30, enableWorker(useVideoPlayer.ts:7-13).

+

8.4 FFmpeg spawn 래퍼 (ffmpeg.ts)

+

-y auto, stderr time=HH:MM:SS.cc 정규식 진행률, exit≠0 시 stderr 끝 500자 reject. 프레임 추출은 -accurate_seek -ss {t} -i {f} -frames:v 1(플래그 순서 = 입력단 정확 시킹, frame.ts:37-44).

+

8.5 데이터 파이프라인 (geoData.ts)

+

<input webkitdirectory>/드래그로 받은 File[]을 병렬 파싱(Promise.all). 인코딩 자동감지(BOM→UTF-8/EUC-KR), 헤더명 우선+위치 폴백(makeFieldIndexer), 좌표 유효성 게이트(lat 33~39/lon 124~132), KMZ 우선(있으면 CSV POI/구조물 미사용, fflate unzipSync).

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
소스파서대상매핑
<base>.csvparseDroneFramesDroneFrame[]frame_cnt, lat/lon/alt, yaw/pitch/roll, focal
building/01)측점.csvparseStationsstation + DirectionChange측점→title, Z좌표_한국(정표고) 우선, 비고→방향전환
<base>_POI.csv / 02)지장물.csvparsePoispoicategory_clean/명칭, lat/lon
02)지장물_역사.csvparseHistoricStations역사 구조물명칭→name(스테이션바 마커)
03)교량/04)터널/06)구교.csvparseStructuresbridge/tunnel구분/시설물명, 연장→lengthM, 시설종별→grade
05)출입문번호.csvparseAccessDoors출입문 poi출입문번호→title
*.kmz/kmlparseKmzpoi+구조물Placemark + description표→props
route.jsonmergeStructures보정station/offset/이정 override
+

중심선은 v2.0에 별도 파일이 없어 측점을 측점값 순 정렬한 폴리라인으로 합성.

+

8.6 상태관리

+
    +
  • geoStore(메모리, persist 없음): frames/stations/pois/structures/centerline/origin/routeMeta. basePois(원본) vs pois(override 적용) 분리로 원본 불변성(geoStore.ts:96-110).
  • +
  • settingsStore(localStorage ghivideo.settings): 시설종별 필터, 표시 토글들. 커스텀 merge로 스키마 진화 대비. isGradeVisible 공유 규칙.
  • +
+

8.7 플레이어 통합 & 프레임 정확도

+
    +
  • Video.js data-vjs-player + Strict Mode 가드, controls:false(측점바로 대체), fill:true.
  • +
  • FPS 감지(useFrameStep.ts:21-49): requestVideoFrameCallback, 첫 콜백은 시작점만 기록(31fps 오감지 방지), 연속 2회 일치 확정. 스텝은 1/fps(누적 금지=드리프트 방지).
  • +
  • 이원 fps: 스텝 이동=실측 fps, 프레임번호 표시=29.97 고정(SRT 기준). 의도된 분리.
  • +
  • 매끄러운 커서: rAF 단조 벽시계 보간 smoothTimeRef(매 프레임, transform) + smoothTime(throttle, 배지) 분리.
  • +
+
+

9. 개발 타임라인 (주요 기술 의사결정)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
시기핵심 결정/구현
2026-04-01labelMap 사전계산 + requestIdleCallback + RAF, ±10fr 이동평균(yaw sin/cos), 30→60fps 보간
2026-04-02투영 파이프라인 정립: EPSG:5186 TM, 핀홀(Rz·Rx·Ry), 타원체고=정표고+지오이드, Cohen-Sutherland 클리핑
2026-06-19v1→v2 데이터 형상 전환(실측 폴더), 인코딩 자동감지, 측점 비고→방향전환 추출, 시설종별 필터
2026-06-22측점 표고 컬럼 교정(Z좌표_한국) → 부각 42.7°→11°(발명 B). 역투영 드래그 보정+override(발명 C 최초). title-키 보간+이상치 거부. 비등방 거리필터
2026-06-23에지보존 평활(YAW_EDGE=8°). 글로벌+KIPRIS 선행기술 조사 → B·C 신규성 확인
2026-06-241클릭 FOV 보정(→sensorH 역산), object-fit:cover 정렬, 라벨 월드좌표 저장+RAF 재투영, One-Euro EMA, KMZ 직접 로딩
2026-06-25~26종점역 누락 수정, 양끝 역 고정, 라벨 2줄분할, 동일항목 브래킷, 거리축→측점축 전환
2026-06-29좌표/측점 이중탐지+합집합 보강, 측점값 라벨 겹침숨김, 측점검색 순환+마커기준 전환+kmExists, UI 통합(토글 재생바 이동, 활성색 amber), 보고서 3종 생성, 55000 외부접속
+

상세 근거: docs/history/2026-06-22_POI투영오차-개선.md(발명 B·C 1차 출처), 2026-06-26_1520_측점축-전환, 2026-06-29_1029_역사-통과-좌표근접-보강 등.

+
+

10. 특허 관점 종합

+
+

출처: docs/특허_스테이션기반_주행영상_플레이어.html(기술발굴), docs/발명신고서_측점표고정합_역투영보정.html(변리사 제출용).

+
+

10.1 기술요소 인벤토리 (E1~E9)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
코드요소특허 적합성
E1측점(체이니지) 기반 영상 색인·탐색후보 A(좁게)
E2지리객체 실시간 영상 정합(labelMap 사전계산)공지(큰 틀)
E3측점 표고 기반 DEM-free 지면고도 추정후보 B(상)
E4역투영+슬랜트거리 보존 단일드래그 위치보정후보 C(상)
E5비등방 거리필터+소실점 클러터 억제후보 D(종속)
E6노이즈 강건 오버레이(이중평활·title짝·이상치거부)후보 E(종속)
E7측점 폴리라인→중심선제외(통상 처리)
E8이종 CSV 융합(인코딩 자동감지)제외
E9비고 텍스트 방향전환 추출제외
+

10.2 출원 권고 우선순위

+
    +
  • 1순위: 발명 B + C 결합 — 신규성/진보성 우수, 글로벌+한국 신규성 확인.
  • +
  • 2순위: 후보 A(좁게) — LRS 비선형 재좌표화 + 회차간 위치앵커 + 다중회차 동기. 한국 선행기술(KR102268318B1·KR100411587B1) 위험 → 좁게 청구. 본 명세의 측점축 노선좌표 고정(§6.3)·측점검색 순환(§6.6)·이중탐지 kmExists(§6.5)가 A의 구현 근거.
  • +
  • 3순위: D·E 종속항 흡수. 제외: E7·E8·E9.
  • +
+

10.3 청구 포인트 핵심 (B·C)

+
    +
  • 발명 B 독립항: ①측점 실측 정표고 수열 → ②표고미상 대상에 최근접 측점 정표고를 지면고도 채택(노선기복 반영) → ③지오이드고 가산으로 타원체고 변환·datum 정합 → ④정합 투영. ※ ②+③ 결합을 한 청구항에(개별요소는 공지).
  • +
  • 발명 C 독립항: ①드래그 개시 시 슬랜트거리 보존 → ②드롭픽셀 역투영 → ③단일 제스처로 수평·수직 동시 복원 → ④식별자 연계 영속. ※ "depth map 없이 거리보존" 명시(US9188444B2 구별).
  • +
+

10.4 선행기술 (조사 완료분)

+
    +
  • E1·E2 큰 틀은 공지: Esri ArcGIS Pro FMV, US9996976B2, Mandli Roadview, Remote GeoSystems LineVision, ENSCO Virtual Track Walk.
  • +
  • B·C는 글로벌+한국 신규성 확인. 단, KIPRIS 전문(專門)검색 미수행 — 출원 전 필수.
  • +
+

10.5 신규성 후보 종합 (구현 기반)

+
    +
  1. 드론 SRT pose 기반 실시간 GIS-영상 정합 파이프라인(타원체고/정표고 datum 자동 정합 포함).
  2. +
  3. 단일 대응점 sensorH 역산 인터랙티브 FOV 보정(가로 정합 보존, 중심부 발산 거부).
  4. +
  5. 깊이 모호성 분리형 역투영 3종(슬랜트유지/지면교차/표고 1차역산)을 보정 의도별 선택.
  6. +
  7. 에지(회전경계) 보존 적응형 평활 + 속도적응 EMA(연속거부 한계).
  8. +
  9. 월드좌표 precompute + 연속포즈 재투영 2단 분리로 단일스레드 60fps AR.
  10. +
  11. 누적 이동거리/측점 비례 축 + 방향색 직교 분리 진행바, 노선좌표 고정(회차 정렬).
  12. +
  13. 좌표/측점 이중 탐지 + 합집합 보강 + kmExists 신뢰도 플래그("위치 표시"와 "측점 단정" 분리).
  14. +
+
+

11. 검증 결과표

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
항목개선 전개선 후근거
부각 오차(측점 157K900, 91.3m)42.7°11.0°§4.3
지면고도 정확도(측점 vs 구글 DEM)0.2m(~6px@2160p)§4.3
역투영 왕복오차(z=42→py→z)≈0 (42.000)§7.2
화면 EMA 지연1.65s(α=0.01)~25ms(α=0.4)§5.3 / history 06-22
렌더끊김60fps(precompute+RAF 분리)§5.5
역 재진입 마커누락좌표근접 합집합 보강으로 표출§6.5
+

지오이드고 N: 본 명세 25.8m(KNGeoid 검증) 통일. 초기 문서값 25.449m는 각주 처리.

+
+

12. 후임자 인계

+

12.1 데이터 흐름 한눈에

+

geoStore(frames/stations/structures/routeMeta) → useEffect precompute(viewedRef=프레임별 km/chain/time, chainRef=time/frac/chain) → pxAtTime/pxAtChain·barSegments·structureMarks → Timeline 렌더. 모든 px는 단일 매핑(pxAtTime/pxAtChain)을 통과, 역변환은 timeAtFrac/chainAtTime만 사용.

+

12.2 튜닝 포인트

+
    +
  • 폴더 route.json: stationTolerance(통과/검색 허용오차, 20m), direction/directionChanges(방향 변형 필터), endStationGapMeters/lengthKm(미도착 폭), startStationName/endStationName.
  • +
  • 카메라(패널, 데이터셋별 저장): focalLen, sensorW, sensorH(FOV), yawOffset/pitch/roll, geoidOffset(지역 N), poiZOffset.
  • +
  • 코드 상수: 평활 W=8, YAW_EDGE=8°, HYST=10m, 좌표통과 TH=max(100,gmin·3), 역사 합집합 24px, 검색 중복 0.3s, EMA β=0.25/REJECT_DIST=0.12/MAX_REJECT_FRAMES=8.
  • +
+

12.3 빌드/배포 (★ 필수 숙지)

+

55000은 PM2 ghiVideoclient/dist(빌드 결과)를 정적 서빙한다. 소스만 고치면 화면에 반영 안 됨.

+
export PATH="$HOME/.nvm/versions/node/v20.20.2/bin:$PATH"   # 시스템 node v12tsc/vite 실패
+npm run build -w client        # = tsc && vite build → client/dist 갱신
+# 브라우저 Ctrl+Shift+R, 폴더 데이터는 런타임 로드라 폴더 재선택 필요
+

외부(LAN) 접속: netsh portproxy(Windows :55000 → WSL IP:55000) + 방화벽. WSL IP는 재부팅 시 변동.

+

12.4 업그레이드 포인트

+
    +
  1. 렌즈 왜곡(Brown-Conrady) 추가 → 가장자리 정합.
  2. +
  3. 다점 대응 자동 캘리브레이션(focal·주점·왜곡 번들조정).
  4. +
  5. 지오이드 격자(KNGeoid18) 보간으로 geoidOffset 자동화.
  6. +
  7. POI 표고 DEM 자동조회(현 부분지원).
  8. +
  9. 중심선 per-frame 투영 공간색인, precompute Web Worker화.
  10. +
  11. frame.ts fps 고정(30)→getVideoFps, HLS jobs DB 큐, 멀티 Range 지원.
  12. +
  13. 코드 통합: 측점 파서/포매터(chainage.ts ↔︎ StationBar.tsx), 투영함수(projectToChain ↔︎ projectChainage) 단일화.
  14. +
+
+

13. 한계 / 출원 전 확인사항

+
    +
  • KIPRIS 전문검색 미수행 — 출원 전 필수.
  • +
  • 지오코딩 수평오차는 데이터 한계(코드 보정은 드래그 수동).
  • +
  • geoidOffset 지역 상수(대전 25.8m) — 타 지역 재튜닝.
  • +
  • VFR/abs_alt 노이즈, 먼 거리 역투영 민감.
  • +
  • 미확정 항목(특허문서 [확인 필요] 유지): 다중회차 동기화 구현 범위, 위치앵커 주석 데이터모델, 권리귀속, 공개이력.
  • +
  • 렌즈 왜곡 미보정(핀홀만) — 가장자리 정확도 트레이드오프.
  • +
+
+

본 문서는 5개 서브시스템(카메라 투영/자세 평활/측점·스테이션바/백엔드·데이터/기존 특허문서) 정밀 조사 결과를 종합한 것이다. 각 소스 참조는 클릭 가능한 파일:라인으로 추적 가능하며, 특허 출원 시 본 문서의 §3~§7·§10과 history 원문을 함께 변리사에게 제공할 것을 권장한다.

+ + diff --git a/docs/기술명세_GhiVideo_종합기술문서.md b/docs/기술명세_GhiVideo_종합기술문서.md new file mode 100644 index 0000000..76a688a --- /dev/null +++ b/docs/기술명세_GhiVideo_종합기술문서.md @@ -0,0 +1,541 @@ +# GhiVideo 종합 기술 명세서 + +> **목적**: 본 문서는 (1) **특허 출원/발명 신고**의 기초 자료와 (2) **후임 개발자 인계·업그레이드** 자료를 겸한다. +> **대상 시스템**: GhiVideo — 시간축이 아니라 **노선 위치(측점·체이니지)** 를 1차 좌표로 삼아 드론 주행영상을 색인·탐색하고, 프레임별 카메라 자세로 측점·POI·선로중심선을 영상 위에 실시간 정합(geo-registration)하는 철도 주행영상 분석 플레이어. +> **작성일**: 2026-06-29 · 모든 소스 참조는 `[경로](경로#Lxx)` 형식(클릭 가능). + +--- + +## 0. 문서 구성과 읽는 법 + +| 장 | 내용 | 주 독자 | +|----|------|---------| +| 1 | 시스템 개요·아키텍처·데이터 흐름 | 전체 | +| 2 | **좌표·투영 정본 파이프라인** (모든 정합의 기반) | 전체 | +| 3 | 카메라 투영 & 화각(FOV) 정합 | 개발/특허 | +| 4 | 측점 표고 정합 (DEM-free) — 발명 B | 특허 | +| 5 | 드론 자세 평활/보간 + 오버레이 렌더링 | 개발 | +| 6 | 측점(체이니지) 시스템 & 스테이션바 | 개발/특허 | +| 7 | 위치보정 역투영 — 발명 C | 특허 | +| 8 | 백엔드 스트리밍/FFmpeg·데이터·상태관리 | 개발 | +| 9 | 개발 타임라인(주요 의사결정) | 전체 | +| 10 | **특허 관점 종합**(인벤토리·후보·청구포인트·선행기술) | 특허 | +| 11 | 검증 결과표 | 전체 | +| 12 | 후임자 인계(데이터흐름·튜닝상수·빌드·업그레이드) | 개발 | +| 13 | 한계 / 출원 전 확인사항 | 전체 | + +--- + +## 1. 시스템 개요 & 아키텍처 + +### 1.1 핵심 아이디어 + +드론으로 촬영한 철도 선로 주행영상의 각 프레임에는 SRT 메타데이터로 **드론 위치(lat/lon/고도)·자세(yaw/pitch/roll)·초점거리**가 기록된다. 한편 철도 GIS에는 측점·POI·선로 중심선의 **WGS84 좌표(+표고)** 가 있다. 이 둘을 **핀홀 카메라 투영**으로 결합하면: + +1. **순방향 투영** — "이 GIS 지점이 지금 화면 어느 픽셀에 보이는가" → 라벨/중심선/궤적 오버레이. +2. **역투영** — "사용자가 화면에서 끈 픽셀은 어느 지리좌표인가" → POI 위치/표고 보정. +3. **측점 색인** — 드론 GPS를 측점 폴리라인에 투영해 영상 매 시점을 **측점값(체이니지)** 으로 환산 → 측점 기반 탐색/스크러빙. + +### 1.2 데이터 흐름 + +``` +[폴더 선택: base.csv(드론프레임) + building/측점·교량·터널·역사 CSV + route.json + KMZ] + │ geoData.loadFolderGeoData (인코딩 자동감지·병렬 파싱·KMZ 우선) + ▼ +[geoStore] frames / stations / pois / structures / centerline / origin / routeMeta + │ + ├─▶ StationOverlay (영상 위 AR 오버레이) + │ precompute(가시성/겹침, requestIdleCallback) → RAF(연속포즈 재투영) → Canvas + │ geoProjection: WGS84→TM→ENU→카메라→핀홀 + │ + ├─▶ StationBar (하단 측점 스크러버) + │ chainage: 드론GPS→측점폴리라인 수직투영→연속 체이니지 + │ 이동거리/측점축, 방향색, 구조물 마커, 측점검색 순환 + │ + └─▶ VideoPlayer (Video.js + hls.js, 이중 재생경로, 프레임 정확도) +``` + +### 1.3 기술 스택 + +- **프론트엔드**: React 18 + TypeScript, Video.js 8.23 + @videojs/http-streaming, hls.js 1.6, Zustand(상태), Vite, Canvas 2D(오버레이), proj4(좌표변환), fflate(KMZ 압축해제). +- **백엔드**: Node.js 20 + Express, `child_process.spawn` FFmpeg 래퍼, Range Request 스트리밍, HLS 변환 + SSE 진행률. +- **배포**: PM2(`ghiVideo`, 포트 55000)가 `client/dist` 정적 서빙(빌드 결과물). 소스 변경 시 `npm run build -w client`(Node 20) 후 반영. + +--- + +## 2. 좌표·투영 정본 파이프라인 (★ 모든 정합의 기반) + +> 본 절을 **정본**으로 하고, 3~7장은 이를 참조한다. Python 레퍼런스 `advanced_tuner_v2.py`와 회전행렬·축정렬·정규화 공식이 1:1 동치로 이식되어 있다([geoProjection.ts:1-12](client/src/utils/geoProjection.ts#L1)). + +### 2.1 5단계 변환 + +``` +[1] WGS84 (lat, lon, EL정표고) + │ proj4 EPSG:4326 → EPSG:5186 (한국 중부원점 TM) + ▼ +[2] 월드 ENU [East(m), North(m), Up(m)] (East=X, North=Y) + │ rel = target_ENU − (drone_ENU + offset) + ▼ +[3] 카메라좌표 [Xc, Yc, Zc] (R_w2c = R_align · R_b2w(yaw,pitch,roll)ᵀ) + │ 핀홀 + focal/sensor + ▼ +[4] 정규 이미지 좌표 [px, py] ∈ (0~1) (0.5 = 영상 중심) + │ object-fit:cover 변환 + ▼ +[5] 화면 px +``` + +### 2.2 단계별 수식·코드 + +**단계 1 — WGS84 → TM** ([geoProjection.ts:75-85](client/src/utils/geoProjection.ts#L75)) +```ts +proj4.defs('EPSG:5186', '+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs'); +function latLonToTM(lat, lon) { const [e, n] = _toTM.forward([lon, lat]); return [e, n]; } +``` + +**단계 2 — TM → ENU + 표고 datum 정합** ([geoProjection.ts:91-97](client/src/utils/geoProjection.ts#L91), [263-271](client/src/utils/geoProjection.ts#L263)). X/Y는 절대 TM, Z만 기준고도 대비 상대(상대벡터를 빼므로 무관). **핵심: GIS 정표고(EL) + 지오이드고(N) → 타원체고**로 변환해 드론 GPS 타원체고와 datum을 맞춘다. +```ts +const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), ...); // EL + N +const drEnuAdj = [drEnu[0]+offX, drEnu[1]+offY, drEnu[2]+offZ]; +const relEnu = [stEnu[0]-drEnuAdj[0], stEnu[1]-drEnuAdj[1], stEnu[2]-drEnuAdj[2]]; +``` +> **지오이드고 N**: 대전지역 ≈ **25.8m**(= 84.244 − 16.853 − 41.59, KNGeoid 검증값). 초기 문서엔 25.449m로 기록된 바 있으나 본 명세는 25.8m로 통일(§11). 지역 상수이므로 타 지역 데이터셋은 재튜닝 필요. + +**단계 3 — ENU → 카메라좌표** ([geoProjection.ts:275-295](client/src/utils/geoProjection.ts#L275)). `R_b2w = Rz(−yaw)·Rx(pitch)·Ry(roll)`, `R_w2c = R_align·R_b2wᵀ`, `R_align=[[1,0,0],[0,0,−1],[0,1,0]]`. 행렬을 명시 생성하지 않고 전치(행=열)·부호치환으로 직접 계산: +```ts +function applyRw2c(b2w, rel) { + return { + Xc: b2w[0][0]*rel[0] + b2w[1][0]*rel[1] + b2w[2][0]*rel[2], // col0 + Yc: -(b2w[0][2]*rel[0] + b2w[1][2]*rel[1] + b2w[2][2]*rel[2]), // -col2 + Zc: b2w[0][1]*rel[0] + b2w[1][1]*rel[1] + b2w[2][1]*rel[2], // col1 + }; +} +``` +규약: **Zc>0 = 카메라 정면**(영상 깊이축), Xc=우, Yc=하. Zc≤0은 카메라 뒤 → null. + +**단계 4 — 카메라좌표 → 정규 픽셀(핀홀)** ([geoProjection.ts:126-137](client/src/utils/geoProjection.ts#L126)). 영상 중심 = 0.5, 해상도 독립: +```ts +pxRaw = (0.5 + cx0) + (Xc/Zc) * (f / sensorW); // 가로 +pyRaw = (0.5 + cy0) + (Yc/Zc) * (f / sensorH); // 세로 +``` + +**단계 5 — 정규 → 화면 px (object-fit:cover)** ([StationOverlay.tsx:923-933](client/src/components/overlay/StationOverlay.tsx#L923)). 영상 크롭 사각형에 맞춰 정합: +```ts +const s = Math.max(W / vW, H / vH); // cover: 더 큰 배율(넘침=크롭) +dispW = vW * s; dispH = vH * s; offX = (W - dispW)/2; offY = (H - dispH)/2; +const vx = (nx) => offX + nx * dispW; const vy = (ny) => offY + ny * dispH; +``` + +### 2.3 두 투영 경로의 의도적 분리 + +- **렌더(정밀)**: [geoProjection.ts](client/src/utils/geoProjection.ts) — proj4 EPSG:5186 TM + 회전행렬 + geoid/포커스 보정. (3~5장) +- **검색(경량)**: [geoSearch.ts](client/src/utils/geoSearch.ts) — cos-lat 근사 ENU. "현재 시야 내 POI" 또는 "특정 POI가 보이는 프레임 구간" 산출용. + +ENU 월드원점은 **첫 측점**(없으면 첫 드론프레임, 없으면 중심선 첫 점)([geoSearch.ts:117-128](client/src/utils/geoSearch.ts#L117)). + +--- + +## 3. 카메라 투영 & 화각(FOV) 정합 + +> 좌표 변환 파이프라인은 §2 참조. 본 절은 화각·내부표정 보정과 역투영을 다룬다. + +### 3.1 focal ↔ FOV 관계 + +화각은 focal과 센서 치수로 결정된다(핀홀): `FOV = 2·atan(sensor / (2·focal))` ([StationOverlay.tsx:864](client/src/components/overlay/StationOverlay.tsx#L864)). 기본값 `focal=24mm, sensorW=36mm(풀프레임 환산), sensorH=20.25mm(=36×9/16, 16:9)` ([geoProjection.ts:48-66](client/src/utils/geoProjection.ts#L48)). 이 hFOV는 나침반/미니맵 시야부채꼴에도 사용. + +### 3.2 fovMode — 세로 화각(sensorH) 역산 보정 (★) + +라벨이 멀리/가까이에서 **상하로 어긋날 때**, 사용자가 POI 하나를 영상 속 실제 위치로 드래그하면 그 한 점의 대응으로 세로 화각을 역산한다. **설계 핵심: 가로 focal(f/sensorW)은 건드리지 않고 sensorH만 역산** → 빨간 중심선이 좌우로 틀어지지 않으면서 상하 배율만 교정. + +투영식 `py = 0.5 + cy0 + (Yc/Zc)·(f/sH)` 를 sH에 대해 풀면 ([StationOverlay.tsx:1326-1344](client/src/components/overlay/StationOverlay.tsx#L1326)): +```ts +const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo); +const b = cc.Yc / cc.Zc; +const v = pos.y - 0.5 - (p.cy0 ?? 0); +const sHNew = (b * p.focalLen) / v; // sH = (Yc/Zc)·f / (py − 0.5 − cy0) +setParam('sensorH', Math.max(6, Math.min(36, sHNew))); +``` +영상 세로 중심(v≈0)은 발산 → `|v|<0.03`이면 거부하고 안내. + +### 3.3 핵심 함수 + +- **`toCameraCoords`** ([geoProjection.ts:301-317](client/src/utils/geoProjection.ts#L301)) — GIS점→카메라좌표. 부산물 `distH`(수평 직선거리), `fwd/side`(진행방향 앞/옆 성분, 비등방 거리필터용). `pixelFromCamera`와 분리한 이유: 중심선처럼 선분 단위 근거리 Zc 클리핑·보간 후 투영해야 하기 때문. +- **`pixelFromCamera`** ([geoProjection.ts:126-137](client/src/utils/geoProjection.ts#L126)) — 카메라좌표→정규픽셀(클램프 없음). +- **`groundPointFromPixel`** ([geoProjection.ts:218-251](client/src/utils/geoProjection.ts#L218)) — 화면픽셀→지면 lat/lon. 광선-수평면 교차: + ```ts + const t = relUpTarget / dir[2]; // dir = R_w2cᵀ·((px−0.5−cx0)·sW/f, (py−0.5−cy0)·sH/f, 1) + if (t <= 0) return null; // 카메라 뒤/위 + const E = drEnu[0]+offX + t*dir[0]; const N = drEnu[1]+offY + t*dir[1]; + const [lon, lat] = _toTM.inverse([E, N]); + ``` +- 관련 역투영 2종: `worldFromPixel`(슬랜트거리 유지 lat/lon/z 동시복원, [149-183](client/src/utils/geoProjection.ts#L149)), `solveZForPixelY`(lat/lon 고정, py에 맞는 z만 1차식 역산, [194-211](client/src/utils/geoProjection.ts#L194)). → 발명 C(§7). + +### 3.4 보정값 영속화 (데이터셋별) + +- 카메라/내부표정·표시설정: `ghivideo:calib:{baseName}` (로드 1회, 저장 800ms debounce, [StationOverlay.tsx:473-500](client/src/components/overlay/StationOverlay.tsx#L473)). +- POI 위치/표고 override(DEM·드래그): `ghivideo:poiov:{baseName}` (폴더 파일 override 위에 localStorage 병합, [502-524](client/src/components/overlay/StationOverlay.tsx#L502)). + +### 3.5 설계 트레이드오프·한계 + +- **렌즈 왜곡 미보정**: 핀홀만 사용(방사/접선 왜곡 계수 없음). 분석 보조 라벨 정합엔 표준 정확도로 충분(가장자리 수 px 오차 가능). +- **클라이언트 순수 계산**: proj4만 의존, 서버 왕복 없이 60fps RAF에서 직접 투영. +- **focal 분리 보정**: 한 점 대응으로 sensorH 한 축만 역산(가로 정합 보존). +- **POI 표고 가정**: 실측 표고 없으면 최근접 선로 지면표고(또는 "드론고도−이격") → §4·드래그·DEM 보정. + +--- + +## 4. 측점 표고 정합 (DEM-free 영상정합) — 발명 B + +### 4.1 무엇을 / 왜 + +표시할 시설물(POI)은 주소 기반이라 **표고값이 없는** 경우가 대부분이다. 표고가 없으면 부각(수직각) 산출 불가 → 종래는 지면을 평면으로 가정. 그러나 노선은 실제 기복(정표고 41.6m→57.5m)이 있어 평면 가정 시 라벨이 화면 밖/지면 아래로 이탈한다. 실측에서 잘못된 고도(≈0) 사용 시 **부각오차 42.7°**. + +→ 별도 DEM 취득 없이, **노선을 따라 실측된 측점의 정표고**를 지면고도원으로 삼아 정합한다. + +### 4.2 구성(독립항 필수요소) + +1. **측점 표고 수열 보유** — 노선 따라 실측된 복수 측점의 (lat/lon + 정표고 EL). (실데이터: 측점 47개, 41.6→57.5m) +2. **최근접 측점 지면고도 채택** — 표고 미상 대상에 가장 가까운 측점의 정표고를 지면고도로. 프레임 진행에 따라 최근접 측점이 바뀌어 **지면고도가 노선 기복을 따라 갱신**. +3. **지오이드 datum 정합** — 정표고(EL) + 지오이드고(N) → 타원체고 → 드론 GPS와 동일 기준. `rel_up = (targetZ + N) − droneAlt`. +4. **정합 투영** — §2 파이프라인 수행. +5. (종속) **대상별 표고 오프셋**(poiZOffset) — 건물높이/지반차 보정, 측점·중심선엔 미적용. + +### 4.3 검증값 + +``` +타원체고 = 정표고(EL) + 지오이드고(N≈25.8m, 대전) +# 측점 157K900, frame0, 수평거리 91.3m +정표고≈0 → 부각 42.7° ❌ +정표고 41.5 → 부각 25.1° +정표고+N≈67 → 부각 11.0° ✅ (드론 rel_alt 16.85m와 일치) +``` +- **데이터 함정(핵심)**: `building/01)측점.csv`에 표고 컬럼이 둘 — `Z좌표`(로컬 ≈0) / `Z좌표_한국`(EPSG:5186 정표고 ≈41.5m). 기존 코드가 `Z좌표`(≈0)를 읽어 42.7° 오차 발생 → `Z좌표_한국` 우선으로 교정([geoData.ts 측점 파서](client/src/utils/geoData.ts)). +- **효과**: 부각오차 42.7°→**11.0°**, 구글어스 DEM 대비 **0.2m 일치**(2160p에서 ~6px). DEM 취득비용 불필요. 공개 DEM(SRTM 30m)보다 측량 측점이 더 정밀. + +--- + +## 5. 드론 자세 평활/보간 + 영상 오버레이 렌더링 + +> 두 본질 과제: **(a) 자세/GPS 노이즈**(단순평균은 회전 뭉갬→지연), **(b) 렌더 부드러움 vs 계산비용**(가시성 판정은 비싸고 60fps는 매 프레임 재투영 요구). + +### 5.1 에지(회전경계) 보존 적응형 이동평균 — `smoothFrame` + +[StationOverlay.tsx:577-604](client/src/components/overlay/StationOverlay.tsx#L577). 중심 `i`에서 좌우 최대 `halfWin`(기본 60fr≈2초)까지 평균하되, **중심 yaw와 차이가 `YAW_EDGE=8°` 이내인 인접까지만 창 확장**. +```ts +const YAW_EDGE = 8; +const near = (idx) => Math.abs(((frames[idx].yaw - yc + 540) % 360) - 180) <= YAW_EDGE; +while (lo > loMin && near(lo - 1)) lo--; // 회전경계에서 멈춤 +while (hi < hiMax && near(hi + 1)) hi++; +// yaw는 원형평균: atan2(Σsin, Σcos) +yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI, +``` +- 직선: 창 풀폭 → 노이즈 강하게 억제. 회전 중: 창 좁아져 **즉시 추종**(지연 없음). 회전 후: **한 프레임씩** 점진 재확장(튐 없음). 과거 '변화율 기반 축소'는 회전 종료 시 한꺼번에 재확장돼 회전 프레임이 다시 섞여 1회 튀던 부작용 → 경계보존으로 교체. + +### 5.2 연속 프레임번호 보간 포즈 — `poseAt` + +[StationOverlay.tsx:670-690](client/src/components/overlay/StationOverlay.tsx#L670). 실수 프레임번호 `estFrame`에 대해 양 이웃의 평활포즈를 선형보간(이산 30fps 격자→60fps 연속). yaw는 **최단각** 보간: +```ts +let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각 +return { ...a, frame: estFrame, lat:L(a.lat,b.lat), ..., yaw: a.yaw + dy*frac }; +``` +O(log n) 이진탐색으로 매 RAF 호출 가능. + +### 5.3 속도적응 EMA + 이상치 거부 — `smoothStep` (One-Euro 사상) + +[StationOverlay.tsx:48-61](client/src/components/overlay/StationOverlay.tsx#L48). **느릴 때(떨림)는 강하게 평활, 빠를 때(실제 팬)는 즉시 추종.** +```ts +if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) // 0.12 초과 점프 = 이상치 → 위치 유지 + return { x: prev.x, y: prev.y, rej: prev.rej + 1, ... }; +const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA; // β=0.25 속도평활(떨림 왕복은 상쇄) +const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed/speedRef)); +return { x: prev.x + dx*a, y: prev.y + dy*a, ... }; +``` +- 이상치 거부에 **연속거부 한계(MAX_REJECT_FRAMES=8)** → 시크/재등장 같은 진짜 큰 이동에 영구 고착 방지. +- 추가로 **픽셀 히스테리시스**(0.75px 이상일 때만 정수좌표 갱신)로 저속 반올림 깜빡임 제거([1048](client/src/components/overlay/StationOverlay.tsx#L1048)). + +### 5.4 라벨 사전계산 + 겹침 억제 + +- **`startLabelPrecompute`** ([StationOverlay.tsx:692-746](client/src/components/overlay/StationOverlay.tsx#L692)): 전 프레임의 **가시집합·겹침해소 결과**를 `Map`에 사전계산. **화면좌표가 아니라 월드좌표(lat/lon/z)를 저장** → RAF가 매 프레임 연속포즈로 재투영. `requestIdleCallback` 200프레임 청크, 취소토큰(`precomputeIdRef`), **현재 프레임부터 순환 처리**(재생 즉시 라벨), 첫 청크 즉시 노출, 재계산 시 옛 맵 복사. +- **겹침 억제** ([755-784](client/src/components/overlay/StationOverlay.tsx#L755)): 임계 가로 `POI_MERGE_X=0.10`/세로 `0.035`(비등방, 텍스트가 가로로 긴 특성). 우선순위 ①철도역/역사 최우선 ②근접거리. **진행방향 변형 우선**: 상/하행 형제 겹침 시 노선 방향((상)/(하)) 변형을 남김. + +### 5.5 RAF 렌더 루프 & 성능 설계 + +매 프레임 **계산 없이 조회+투영+드로잉만**. 라이브 시간은 VideoPlayer `smoothTimeRef`(단조 벽시계 보간, 일시정지·시크·배속 보정)에서 받음. + +| 작업 | 비용 | 배치 | 빈도 | +|------|------|------|------| +| 가시성/겹침 판정 | 높음 | precompute | 1회(+설정변경 debounce), idle 청크 | +| 좌표 투영 | 중간 | RAF | 매 프레임 | +| 평활/EMA/히스테리시스 | 낮음 | RAF | 매 프레임 | +| 팝업 가시집합 setState | 리렌더 | setInterval 150ms | 변화 시만 | + +**핵심 = "무엇을 그릴지"(precompute) ↔ "어디에 그릴지"(RAF) 분리**로 단일스레드 60fps 달성. 중심선/드론궤적은 `buildLines`로 매 프레임 직접 투영(근거리 Zc 클리핑·outcode 컬링). 나침반은 별도 ±3fr 평활 yaw + 360° 누적 언랩. + +--- + +## 6. 측점(체이니지) 시스템 & 스테이션바 + +### 6.1 연속 체이니지 수직투영 (핵심 ①) + +드론 한 프레임의 위경도를 **측점 폴리라인에 수직 투영**해 100m 양자화가 아닌 **10m 해상도 연속 측점값**을 얻는다(부산물: 선로 수직 이격 offsetM). +- **폴리라인 구축** `buildChainLine` ([chainage.ts:22-32](client/src/utils/chainage.ts#L22)): 측점값 오름차순 정렬, 평면투영 `x=lon·k, y=lat·111000` (k=cos(lat0)·111000). +- **점-선분 투영** `projectToChain` ([chainage.ts:35-50](client/src/utils/chainage.ts#L35)): + ```ts + const t = L2===0?0:Math.max(0,Math.min(1, ((px-a.x)*dx + (py-a.y)*dy)/L2)); + const d = (px-cx)**2 + (py-cy)**2; + if (d < bestD) { bestD = d; bestKm = a.km + (b.km-a.km)*t; } // 최근접 선분 위 km 보간 + ``` +- **왜 연속 투영인가**: 최근접 측점점(100m 양자화)은 값이 계단처럼 튀고 두 측점 중간에서 전진/후진 전환 시점이 어긋난다. 수직투영은 선분 보간으로 매 프레임 부드럽게 변해 **방향 전환점(극값) 타이밍이 실제 주행과 일치**. + +### 6.2 이중 측점값: 배지용 `km` vs 방향용 `chain` + +프레임별 precompute([StationBar.tsx:233-246](client/src/stationbar/StationBar.tsx#L233)): `km`=최근접 측점점(100m, 배지/폴백), `chain`=연속투영(10m, 방향/축/탐지). `videoFps`는 실데이터(마지막 프레임/재생시간)에서 유도, 미비 시 `30000/1001` 폴백. + +### 6.3 축 설계: 시간축이 아닌 위치축 (핵심 ②) + +선로 점검 영상은 **호버(정지비행)** 와 **전·후진 반복** 특성이 있어 시간축이 부적합하다(호버 시 커서가 위치와 어긋남). +- **이동거리축** ([StationBar.tsx:250-271](client/src/stationbar/StationBar.tsx#L250)): 연속 측점값을 ±8fr 평활 후 프레임간 **절대 변화량 누적**(`cum += |sm[i]−sm[i−1]|`)을 정규화(`frac`, 단조 비감소). 호버=frac 정체(커서 정지), 이동=frac 증가. 방향은 색 리본으로 직교 분리. +- **측점축**(후속 채택안, [history 2026-06-26_1520](docs/history/2026-06-26_1520_스테이션바-측점축-전환.md)): x축을 **측점값 비례**로 두고, 축 범위를 역 POI 첫/끝으로 **노선 고정**(영상 무관 절대 측점) → 어떤 데이터로 재생하든 같은 구조물이 같은 위치/측점에 표시(회차 간 정렬). +- 시간↔축 양방향 변환: `cumFracAtTime`/`timeAtFrac`(또는 `chainAtTime`/`pxAtChain`)를 이진탐색+보간으로. 커서·구간색·구조물 마커가 **동일 매핑 공유**. 라이브 커서는 React 리렌더 없이 RAF에서 CSS 변수(`--pos-px`)만 갱신([771-786](client/src/stationbar/StationBar.tsx#L771)). + +### 6.4 방향 판정 & 방향색 트랙 + +평활 측점값 추세를 극값추적 + **히스테리시스 HYST=10m**로 방향 전환점 확정([395-458](client/src/stationbar/StationBar.tsx#L395)). `forwardDir = arrChain≥depChain?1:−1`. 정방향=주황 음영, 역방향=청록. 이동거리/측점축은 구간 px가 단조라 `linear-gradient` 1장으로 경량 처리([686-726](client/src/stationbar/StationBar.tsx#L686)). + +### 6.5 구조물 마커 — 좌표/측점 이중 탐지 & `kmExists` (핵심 ③) + +두 통과 탐지기: +- **`pxPassesTo(lat,lon,off)`** ([489-534](client/src/stationbar/StationBar.tsx#L489)) — 좌표 근접. 임계 `TH=max(100, gmin·3)`. +- **`pxPassesAtMileage(targetM)`** ([537-559](client/src/stationbar/StationBar.tsx#L537)) — 연속 체이니지가 `TH=stationTolerance(기본 20m)` 이내. + +탐지 우선순위(단락평가): ①명시 측점값 ②좌표를 측점선 투영→측점기준(통과순서 정확) ③이름매칭 POI 투영 ④좌표근접 폴백 … ([612-639](client/src/stationbar/StationBar.tsx#L612)). + +**역사 합집합 보강**: 종점/조차장은 측점선 투영이 불안정해 재진입을 놓침(예 대전조차장 162k080). 단락평가 탓에 한 통과를 찾으면 좌표근접이 안 돌아 누락 → **역사에 한해 좌표근접 통과를 합집합으로 합치되 24px 임계 중복제거**([629-634](client/src/stationbar/StationBar.tsx#L629)). + +**`kmExists` (★)**: 좌표 반경만으로 잡힌 통과는 실제 측점값이 그 위치에 없을 수 있다. +```ts +const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol; // existTol = stationTolerance +``` +([StationBar.tsx:648](client/src/stationbar/StationBar.tsx#L648)) `false`면 **측점값 라벨·측점검색에서 제외하되 마커(동그라미)는 유지** — "위치 표시"와 "측점 단정"의 분리. + +**종점 미도착**: `endGapPx=min(폭·0.15, (gapM/lenM)·폭)`, `placeUnreachedTerminal`이 최우측 역사를 트랙 끝에 미도착(속 빈 링)으로 고정([303-308](client/src/stationbar/StationBar.tsx#L303), [574-586](client/src/stationbar/StationBar.tsx#L574)). + +### 6.6 측점검색 순환 — `handleJumpToMileage` + +[StationBar.tsx:850-916](client/src/stationbar/StationBar.tsx#L850). 측점값 입력 시 여러 통과를 **통과방향 순환**: +1. 후보 **1순위**: 화면 마커(`structureMarks`) 중 `kmExists!==false` 이고 표시 10m값 일치 → px→`timeAtFrac` 역변환. (좌표근접 통과 포함 → 모든 동그라미 도달) +2. **2순위**: 마커 없으면 chain 연속구간, 최종 전역 최근접 폴백. +3. 정렬 + 0.3s 중복제거 → baseT 기준 다음 통과(끝→처음 순환). `jumpRef`로 연속검색 판정. Enter 후 입력값 유지, blur 시 삭제([PlaybackControls.tsx](client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx)). + +### 6.7 타임라인 보조 기술 ([Timeline.tsx](client/src/stationbar/components/Timeline/Timeline.tsx)) + +- **동일항목 브래킷 클러스터**: 동명 다통과를 거리 무관 한 묶음으로(드롭+수평 점선) 중앙 라벨 1개([143-172](client/src/stationbar/components/Timeline/Timeline.tsx#L143)). +- **라벨 2줄 균등분할** `splitStructLabel`: CJK 1.0/기타 0.55폭, 괄호그룹은 통째로 아랫줄([30-49](client/src/stationbar/components/Timeline/Timeline.tsx#L30)). +- **측점값 라벨 겹침 숨김** `showMileage`: 직전 표시와 `(lastW+w)/2` 미만이면 숨김, `kmExists===false`는 애초 생략([117-137](client/src/stationbar/components/Timeline/Timeline.tsx#L117)). +- **아이콘 passed 전환**: upcoming/passed 두 PNG를 opacity로 전환(무깜빡임), 전 상태 사전로드. + +--- + +## 7. 위치보정 역투영 (단일 제스처) — 발명 C + +### 7.1 무엇을 / 왜 + +지오코딩 POI는 ±수십 m 오차. 2D 화면 이동만으로는 카메라로부터의 거리(깊이)를 결정 못함(깊이 모호성). 종래는 수평/높이를 따로 조정하거나 깊이맵(LiDAR) 요구. → **단일 드래그로 수평·수직(3D) 동시 보정, 깊이맵 불요.** + +### 7.2 구성(독립항 필수요소) + +1. **슬랜트거리 보존** — 드래그 개시 시점의 카메라–객체 슬랜트거리(range)를 고정 → 깊이 모호성 해소 핵심 구속. +2. **역투영** — 드롭 픽셀을 카메라 방향벡터로 환산 후 보존 거리만큼 투사. +3. **월드좌표 복원** — 자세 회전·평행이동 역변환으로 lat/lon/z 복원(지오이드 역환원). 한 번의 드래그로 수평·수직 동시. +4. **영속·재적용** — 복원 좌표를 객체 식별자(title)에 연계해 `_poi_overrides.json` + `ghivideo:poiov:` 저장. +5. (종속) **가시영역 한정** — 전방(Zc>0)만 보정. + +```ts +dir_cam ∝ ((px−0.5−cx0)·sW/f, (py−0.5−cy0)·sH/f, 1) +cam = dir_cam/|dir_cam| · range // ★거리 보존(드래그 시작값) +rel = R_w2cᵀ · cam // 회전 역변환 +z = droneAlt + offZ + rel.up − N_geoid // 정표고 복원 +(lat,lon) = TM⁻¹(droneE+offX+rel.x, droneN+offY+rel.y) +``` +**변형 — z 전용 역산** `solveZForPixelY`: lat/lon 고정, 드롭 세로위치로 z만 1차식 역산. 왕복검증 z=42→py→42.000. + +### 7.3 효과 + +2D 드래그 1회로 3D 보정(단계 절반), 역투영 왕복오차 ≈0, 깊이맵 불요, 파일+localStorage 재현성. **"depth map 없이 거리보존"** 을 명시해 US9188444B2와 구별(§10). + +--- + +## 8. 백엔드 스트리밍/FFmpeg · 데이터 파이프라인 · 상태관리 · 플레이어 + +### 8.1 대용량(2GB+) 재생 — 이중 경로 + +- **로컬**([useVideoPlayer.ts:80-88](client/src/hooks/useVideoPlayer.ts#L80)): `URL.createObjectURL` Blob URL 직접 재생(업로드/변환 없음), 교체 시 `revokeObjectURL`(누수 방지). **현 운영 주 경로.** +- **서버**: Range Request 즉시 재생, 비표준 코덱(HEVC 등)이면 HLS 자동 전환([15-17](client/src/hooks/useVideoPlayer.ts#L15)). (서버/HLS 경로는 보존되나 UI는 `SHOW_TOOLBAR=false`로 숨김.) + +### 8.2 Range Request ([streaming.ts:52-77](server/src/services/streaming.ts#L52)) + +핵심: 한 응답에 전체를 싣지 않음. 끝값 없으면 `start+10MB−1`. `pipeline()`(백프레셔/에러 자동), `highWaterMark:1MB`, `Accept-Ranges:bytes`, path traversal 방어(`resolveVideoPath`). JS Number(2^53−1)로 4GB+ 안전(BigInt 불요). 멀티레인지 미지원. + +### 8.3 HLS 변환 + SSE ([hls.ts](server/src/routes/hls.ts)) + +멱등(디스크에 `index.m3u8` 있으면 즉시 done), 코덱 분기(H.264 `-c copy` 수 초 / 그 외 libx264), `/progress` SSE로 stderr `time=` 파싱 푸시. hls.js 필수: `backBufferLength:30`(기본 Infinity면 장시간 재생 메모리 누수→크래시), maxBufferLength 30, enableWorker([useVideoPlayer.ts:7-13](client/src/hooks/useVideoPlayer.ts#L7)). + +### 8.4 FFmpeg spawn 래퍼 ([ffmpeg.ts](server/src/services/ffmpeg.ts)) + +`-y` auto, stderr `time=HH:MM:SS.cc` 정규식 진행률, exit≠0 시 stderr 끝 500자 reject. 프레임 추출은 `-accurate_seek -ss {t} -i {f} -frames:v 1`(플래그 순서 = 입력단 정확 시킹, [frame.ts:37-44](server/src/routes/frame.ts#L37)). + +### 8.5 데이터 파이프라인 ([geoData.ts](client/src/utils/geoData.ts)) + +``/드래그로 받은 `File[]`을 병렬 파싱(`Promise.all`). **인코딩 자동감지**(BOM→UTF-8/EUC-KR), **헤더명 우선+위치 폴백**(`makeFieldIndexer`), 좌표 유효성 게이트(lat 33~39/lon 124~132), KMZ 우선(있으면 CSV POI/구조물 미사용, fflate unzipSync). + +| 소스 | 파서 | 대상 | 매핑 | +|------|------|------|------| +| `.csv` | parseDroneFrames | DroneFrame[] | frame_cnt, lat/lon/alt, yaw/pitch/roll, focal | +| `building/01)측점.csv` | parseStations | station + DirectionChange | 측점→title, **Z좌표_한국(정표고) 우선**, 비고→방향전환 | +| `_POI.csv` / `02)지장물.csv` | parsePois | poi | category_clean/명칭, lat/lon | +| `02)지장물_역사.csv` | parseHistoricStations | 역사 구조물 | 명칭→name(스테이션바 마커) | +| `03)교량/04)터널/06)구교.csv` | parseStructures | bridge/tunnel | 구분/시설물명, 연장→lengthM, 시설종별→grade | +| `05)출입문번호.csv` | parseAccessDoors | 출입문 poi | 출입문번호→title | +| `*.kmz/kml` | parseKmz | poi+구조물 | Placemark + description표→props | +| `route.json` | mergeStructures | 보정 | station/offset/이정 override | + +중심선은 v2.0에 별도 파일이 없어 측점을 측점값 순 정렬한 폴리라인으로 합성. + +### 8.6 상태관리 + +- **geoStore**(메모리, persist 없음): frames/stations/pois/structures/centerline/origin/routeMeta. `basePois`(원본) vs `pois`(override 적용) 분리로 원본 불변성([geoStore.ts:96-110](client/src/store/geoStore.ts#L96)). +- **settingsStore**(localStorage `ghivideo.settings`): 시설종별 필터, 표시 토글들. 커스텀 `merge`로 스키마 진화 대비. `isGradeVisible` 공유 규칙. + +### 8.7 플레이어 통합 & 프레임 정확도 + +- Video.js `data-vjs-player` + Strict Mode 가드, `controls:false`(측점바로 대체), `fill:true`. +- **FPS 감지**([useFrameStep.ts:21-49](client/src/hooks/useFrameStep.ts#L21)): `requestVideoFrameCallback`, 첫 콜백은 시작점만 기록(31fps 오감지 방지), 연속 2회 일치 확정. 스텝은 `1/fps`(누적 금지=드리프트 방지). +- **이원 fps**: 스텝 이동=실측 fps, 프레임번호 표시=29.97 고정(SRT 기준). 의도된 분리. +- **매끄러운 커서**: rAF 단조 벽시계 보간 `smoothTimeRef`(매 프레임, transform) + `smoothTime`(throttle, 배지) 분리. + +--- + +## 9. 개발 타임라인 (주요 기술 의사결정) + +| 시기 | 핵심 결정/구현 | +|------|----------------| +| 2026-04-01 | labelMap 사전계산 + requestIdleCallback + RAF, ±10fr 이동평균(yaw sin/cos), 30→60fps 보간 | +| 2026-04-02 | **투영 파이프라인 정립**: EPSG:5186 TM, 핀홀(Rz·Rx·Ry), 타원체고=정표고+지오이드, Cohen-Sutherland 클리핑 | +| 2026-06-19 | **v1→v2 데이터 형상 전환**(실측 폴더), 인코딩 자동감지, 측점 비고→방향전환 추출, 시설종별 필터 | +| 2026-06-22 | **측점 표고 컬럼 교정(Z좌표_한국) → 부각 42.7°→11°**(발명 B). 역투영 드래그 보정+override(발명 C 최초). title-키 보간+이상치 거부. 비등방 거리필터 | +| 2026-06-23 | 에지보존 평활(YAW_EDGE=8°). **글로벌+KIPRIS 선행기술 조사** → B·C 신규성 확인 | +| 2026-06-24 | **1클릭 FOV 보정(→sensorH 역산)**, object-fit:cover 정렬, 라벨 월드좌표 저장+RAF 재투영, One-Euro EMA, KMZ 직접 로딩 | +| 2026-06-25~26 | 종점역 누락 수정, 양끝 역 고정, 라벨 2줄분할, **동일항목 브래킷**, **거리축→측점축 전환** | +| 2026-06-29 | **좌표/측점 이중탐지+합집합 보강**, 측점값 라벨 겹침숨김, **측점검색 순환+마커기준 전환+kmExists**, UI 통합(토글 재생바 이동, 활성색 amber), 보고서 3종 생성, 55000 외부접속 | + +상세 근거: [docs/history/2026-06-22_POI투영오차-개선.md](docs/history/2026-06-22_POI투영오차-개선.md)(발명 B·C 1차 출처), [2026-06-26_1520_측점축-전환](docs/history/2026-06-26_1520_스테이션바-측점축-전환.md), [2026-06-29_1029_역사-통과-좌표근접-보강](docs/history/2026-06-29_1029_역사-통과-좌표근접-보강.md) 등. + +--- + +## 10. 특허 관점 종합 + +> 출처: [docs/특허_스테이션기반_주행영상_플레이어.html](docs/특허_스테이션기반_주행영상_플레이어.html)(기술발굴), [docs/발명신고서_측점표고정합_역투영보정.html](docs/발명신고서_측점표고정합_역투영보정.html)(변리사 제출용). + +### 10.1 기술요소 인벤토리 (E1~E9) + +| 코드 | 요소 | 특허 적합성 | +|------|------|------------| +| E1 | 측점(체이니지) 기반 영상 색인·탐색 | 후보 A(좁게) | +| E2 | 지리객체 실시간 영상 정합(labelMap 사전계산) | 공지(큰 틀) | +| E3 | **측점 표고 기반 DEM-free 지면고도 추정** | **후보 B(상)** | +| E4 | **역투영+슬랜트거리 보존 단일드래그 위치보정** | **후보 C(상)** | +| E5 | 비등방 거리필터+소실점 클러터 억제 | 후보 D(종속) | +| E6 | 노이즈 강건 오버레이(이중평활·title짝·이상치거부) | 후보 E(종속) | +| E7 | 측점 폴리라인→중심선 | 제외(통상 처리) | +| E8 | 이종 CSV 융합(인코딩 자동감지) | 제외 | +| E9 | 비고 텍스트 방향전환 추출 | 제외 | + +### 10.2 출원 권고 우선순위 + +- **1순위: 발명 B + C 결합** — 신규성/진보성 우수, 글로벌+한국 신규성 확인. +- **2순위: 후보 A(좁게)** — LRS 비선형 재좌표화 + 회차간 위치앵커 + 다중회차 동기. 한국 선행기술(KR102268318B1·KR100411587B1) 위험 → 좁게 청구. **본 명세의 측점축 노선좌표 고정(§6.3)·측점검색 순환(§6.6)·이중탐지 kmExists(§6.5)가 A의 구현 근거.** +- **3순위: D·E 종속항 흡수**. **제외: E7·E8·E9**. + +### 10.3 청구 포인트 핵심 (B·C) + +- **발명 B 독립항**: ①측점 실측 정표고 수열 → ②표고미상 대상에 최근접 측점 정표고를 지면고도 채택(노선기복 반영) → ③지오이드고 가산으로 타원체고 변환·datum 정합 → ④정합 투영. **※ ②+③ 결합을 한 청구항에**(개별요소는 공지). +- **발명 C 독립항**: ①드래그 개시 시 슬랜트거리 보존 → ②드롭픽셀 역투영 → ③단일 제스처로 수평·수직 동시 복원 → ④식별자 연계 영속. **※ "depth map 없이 거리보존"** 명시(US9188444B2 구별). + +### 10.4 선행기술 (조사 완료분) + +- E1·E2 큰 틀은 공지: Esri ArcGIS Pro FMV, US9996976B2, Mandli Roadview, Remote GeoSystems LineVision, ENSCO Virtual Track Walk. +- B·C는 글로벌+한국 신규성 확인. **단, KIPRIS 전문(專門)검색 미수행 — 출원 전 필수.** + +### 10.5 신규성 후보 종합 (구현 기반) + +1. 드론 SRT pose 기반 **실시간 GIS-영상 정합 파이프라인**(타원체고/정표고 datum 자동 정합 포함). +2. **단일 대응점 sensorH 역산** 인터랙티브 FOV 보정(가로 정합 보존, 중심부 발산 거부). +3. **깊이 모호성 분리형 역투영 3종**(슬랜트유지/지면교차/표고 1차역산)을 보정 의도별 선택. +4. **에지(회전경계) 보존 적응형 평활** + 속도적응 EMA(연속거부 한계). +5. **월드좌표 precompute + 연속포즈 재투영 2단 분리**로 단일스레드 60fps AR. +6. **누적 이동거리/측점 비례 축 + 방향색 직교 분리** 진행바, 노선좌표 고정(회차 정렬). +7. **좌표/측점 이중 탐지 + 합집합 보강 + kmExists 신뢰도 플래그**("위치 표시"와 "측점 단정" 분리). + +--- + +## 11. 검증 결과표 + +| 항목 | 개선 전 | 개선 후 | 근거 | +|------|---------|---------|------| +| 부각 오차(측점 157K900, 91.3m) | 42.7° | **11.0°** | §4.3 | +| 지면고도 정확도(측점 vs 구글 DEM) | — | **0.2m(~6px@2160p)** | §4.3 | +| 역투영 왕복오차(z=42→py→z) | — | **≈0 (42.000)** | §7.2 | +| 화면 EMA 지연 | 1.65s(α=0.01) | ~25ms(α=0.4) | §5.3 / history 06-22 | +| 렌더 | 끊김 | **60fps**(precompute+RAF 분리) | §5.5 | +| 역 재진입 마커 | 누락 | 좌표근접 합집합 보강으로 표출 | §6.5 | + +지오이드고 N: 본 명세 **25.8m**(KNGeoid 검증) 통일. 초기 문서값 25.449m는 각주 처리. + +--- + +## 12. 후임자 인계 + +### 12.1 데이터 흐름 한눈에 + +geoStore(frames/stations/structures/routeMeta) → `useEffect` precompute(`viewedRef`=프레임별 km/chain/time, `chainRef`=time/frac/chain) → `pxAtTime`/`pxAtChain`·`barSegments`·`structureMarks` → Timeline 렌더. **모든 px는 단일 매핑(`pxAtTime`/`pxAtChain`)을 통과**, 역변환은 `timeAtFrac`/`chainAtTime`만 사용. + +### 12.2 튜닝 포인트 + +- **폴더 route.json**: `stationTolerance`(통과/검색 허용오차, 20m), `direction`/`directionChanges`(방향 변형 필터), `endStationGapMeters`/`lengthKm`(미도착 폭), `startStationName`/`endStationName`. +- **카메라(패널, 데이터셋별 저장)**: `focalLen`, `sensorW`, `sensorH`(FOV), `yawOffset`/`pitch`/`roll`, `geoidOffset`(지역 N), `poiZOffset`. +- **코드 상수**: 평활 `W=8`, `YAW_EDGE=8°`, `HYST=10m`, 좌표통과 `TH=max(100,gmin·3)`, 역사 합집합 `24px`, 검색 중복 `0.3s`, EMA `β=0.25`/`REJECT_DIST=0.12`/`MAX_REJECT_FRAMES=8`. + +### 12.3 빌드/배포 (★ 필수 숙지) + +55000은 **PM2 `ghiVideo`가 `client/dist`(빌드 결과)를 정적 서빙**한다. 소스만 고치면 화면에 반영 안 됨. +```bash +export PATH="$HOME/.nvm/versions/node/v20.20.2/bin:$PATH" # 시스템 node v12 → tsc/vite 실패 +npm run build -w client # = tsc && vite build → client/dist 갱신 +# 브라우저 Ctrl+Shift+R, 폴더 데이터는 런타임 로드라 폴더 재선택 필요 +``` +외부(LAN) 접속: netsh portproxy(Windows :55000 → WSL IP:55000) + 방화벽. WSL IP는 재부팅 시 변동. + +### 12.4 업그레이드 포인트 + +1. 렌즈 왜곡(Brown-Conrady) 추가 → 가장자리 정합. +2. 다점 대응 자동 캘리브레이션(focal·주점·왜곡 번들조정). +3. 지오이드 격자(KNGeoid18) 보간으로 `geoidOffset` 자동화. +4. POI 표고 DEM 자동조회(현 부분지원). +5. 중심선 per-frame 투영 공간색인, precompute Web Worker화. +6. frame.ts fps 고정(30)→`getVideoFps`, HLS jobs DB 큐, 멀티 Range 지원. +7. **코드 통합**: 측점 파서/포매터(chainage.ts ↔ StationBar.tsx), 투영함수(`projectToChain` ↔ `projectChainage`) 단일화. + +--- + +## 13. 한계 / 출원 전 확인사항 + +- **KIPRIS 전문검색 미수행** — 출원 전 필수. +- 지오코딩 수평오차는 데이터 한계(코드 보정은 드래그 수동). +- `geoidOffset` 지역 상수(대전 25.8m) — 타 지역 재튜닝. +- VFR/abs_alt 노이즈, 먼 거리 역투영 민감. +- 미확정 항목(특허문서 `[확인 필요]` 유지): 다중회차 동기화 구현 범위, 위치앵커 주석 데이터모델, 권리귀속, 공개이력. +- 렌즈 왜곡 미보정(핀홀만) — 가장자리 정확도 트레이드오프. + +--- + +*본 문서는 5개 서브시스템(카메라 투영/자세 평활/측점·스테이션바/백엔드·데이터/기존 특허문서) 정밀 조사 결과를 종합한 것이다. 각 소스 참조는 클릭 가능한 파일:라인으로 추적 가능하며, 특허 출원 시 본 문서의 §3~§7·§10과 history 원문을 함께 변리사에게 제공할 것을 권장한다.* diff --git a/docs/기술명세_GhiVideo_종합기술문서.pdf b/docs/기술명세_GhiVideo_종합기술문서.pdf new file mode 100644 index 0000000..c845cea Binary files /dev/null and b/docs/기술명세_GhiVideo_종합기술문서.pdf differ diff --git a/docs/보고서_2026-06-29_스테이션바_UI개선.html b/docs/보고서_2026-06-29_스테이션바_UI개선.html new file mode 100644 index 0000000..d4603ca --- /dev/null +++ b/docs/보고서_2026-06-29_스테이션바_UI개선.html @@ -0,0 +1,158 @@ + + + + + + + 보고서_2026-06-29_스테이션바_UI개선 + + + + + +
+

보고서_2026-06-29_스테이션바_UI개선

+
+

작업 보고서 — 주행영상 플레이어 스테이션바/UI 개선

+
    +
  • 작성일: 2026-06-29
  • +
  • 대상: 스테이션(측점) 기반 주행영상 플레이어 (GhiVideo)
  • +
  • 범위: 하단 스테이션바 마커 정확도 개선, 영상 컨트롤 UI 재구성, 표시 일관성 정리
  • +
+
+

1. 개요

+

하단 스테이션바의 측점 마커 표시 정확도 문제 2건을 수정하고, 영상 화면의 표시 옵션 컨트롤을 재생바로 통합·정리하여 조작 동선을 단순화했습니다. 또한 활성 상태 색상을 통일해 UI 일관성을 높였습니다.

+
+

2. 버그 수정 (정확도)

+

2-1. 역(驛) 재진입 시 마커 누락 수정 ★

+
    +
  • 현상: 대전조차장처럼 드론이 같은 역을 두 번 지날 때, 한쪽 통과(예: 측점 162k080)에 역 마커(동그라미)가 표시되지 않음.
  • +
  • 원인: 역 마커를 '측점선 투영값' 한 가지 방식으로만 탐지 → 종점/조차장처럼 선형이 복잡한 구역에서는 투영 오차로 일부 통과가 인정 범위(±20m) 밖으로 벗어나 누락.
  • +
  • 조치: 측점 기준 탐지에 좌표 근접 탐지를 합집합으로 보강(중복은 자동 제거). 누락되던 재진입 통과까지 마커가 정상 표출.
  • +
  • 효과: 동일 역 다중 통과 구간의 마커 신뢰도 향상.
  • +
+

2-2. 측점값 라벨 겹침 정리

+
    +
  • 현상: 근접 통과 시 측점값(예: 157k990) 텍스트가 서로 겹쳐 판독 불가.
  • +
  • 조치: 라벨이 시각적으로 겹치면 먼저 나온(좌측) 값만 표시하고 뒤엣값은 숨김(아이콘·연결 점선은 유지).
  • +
  • 효과: 측점값 가독성 확보.
  • +
+
+

3. UI/UX 개선 (영상 컨트롤 재구성)

+

기존에 '화면표시 옵션' 창에 흩어져 있던 표시 토글을 하단 재생바로 통합하여, 영상을 보면서 바로 켜고 끌 수 있도록 동선을 개선했습니다.

+

3-1. 표시 토글을 재생바로 이동

+
    +
  • 선형(중심선), 드론 궤적, 좌측 노선 패널 토글을 재생바 버튼으로 이동.
  • +
  • 기존 '화면표시 옵션' 창의 중복 버튼은 제거(기능은 동일, 단일 위치로 일원화).
  • +
  • 상태를 설정 저장소로 통합 → 어디서 켜도 즉시 반영되고 새로고침해도 유지.
  • +
+

3-2. 항상 표시 항목 정리

+
    +
  • 드론 GPS·절대고도 정보: ON/OFF 버튼 제거하고 상시 표시로 변경(요청사항).
  • +
  • 경로 표고 / 투명도 슬라이더: 드론 궤적 ON/OFF와 무관하게 항상 노출.
  • +
+

3-3. 재생바 요소 배치 정리

+
    +
  • 최종 순서: 좌측 패널 → 선형 → 드론궤적 → 드론 위치정보(GPS·고도) → 시설등급
  • +
  • 드론 궤적 버튼과 드론 위치정보를 인접 배치, 시설등급 옵션을 그 우측에 배치.
  • +
  • 잘 쓰지 않는 'POI 겹침제외' 버튼은 숨김(기능/상태는 보존, 기본 ON).
  • +
+

3-4. 활성 상태 색상 통일

+
    +
  • 제각각이던 활성(ON) 색(파랑/초록/빨강/하늘색)을 앰버(겨자·오렌지 계열)로 통일 → UI 일관성 향상.
  • +
+
+

4. 운영 메모 (배포 방식)

+
    +
  • 사용자 화면(포트 55000)은 빌드 결과물을 서빙하는 구조로, 소스 수정 후 클라이언트 빌드 → 새로고침해야 반영됩니다. (이번 작업 중 빌드 누락으로 변경이 보이지 않던 이슈를 확인·정리)
  • +
  • 데이터(드론 프레임/POI)는 폴더 선택으로 로드되므로, 새로고침 시 폴더 재선택 필요.
  • +
+
+

5. 검증

+
    +
  • 모든 변경 TypeScript 타입검사 + 프로덕션 빌드 통과.
  • +
  • 역 재진입 마커 표출은 실제 화면(55000)에서 표시 확인 완료.
  • +
+
+

6. 향후 참고(선택 개선안)

+
    +
  • 측점값 라벨 폭 추정 상수는 폰트 변경 시 재조정 필요.
  • +
  • 활성 색조(앰버 명도)는 가독성에 따라 미세 조정 가능.
  • +
  • 'POI 겹침제외'는 현재 숨김 상태이므로, 재노출이 필요하면 토글 복원 가능.
  • +
+ + diff --git a/docs/보고서_2026-06-29_스테이션바_UI개선.md b/docs/보고서_2026-06-29_스테이션바_UI개선.md new file mode 100644 index 0000000..0b82257 --- /dev/null +++ b/docs/보고서_2026-06-29_스테이션바_UI개선.md @@ -0,0 +1,71 @@ +# 작업 보고서 — 주행영상 플레이어 스테이션바/UI 개선 + +- **작성일**: 2026-06-29 +- **대상**: 스테이션(측점) 기반 주행영상 플레이어 (GhiVideo) +- **범위**: 하단 스테이션바 마커 정확도 개선, 영상 컨트롤 UI 재구성, 표시 일관성 정리 + +--- + +## 1. 개요 + +하단 스테이션바의 **측점 마커 표시 정확도 문제 2건을 수정**하고, 영상 화면의 **표시 옵션 컨트롤을 재생바로 통합·정리**하여 조작 동선을 단순화했습니다. 또한 활성 상태 색상을 통일해 UI 일관성을 높였습니다. + +--- + +## 2. 버그 수정 (정확도) + +### 2-1. 역(驛) 재진입 시 마커 누락 수정 ★ +- **현상**: 대전조차장처럼 드론이 같은 역을 두 번 지날 때, 한쪽 통과(예: 측점 162k080)에 역 마커(동그라미)가 표시되지 않음. +- **원인**: 역 마커를 '측점선 투영값' 한 가지 방식으로만 탐지 → 종점/조차장처럼 선형이 복잡한 구역에서는 투영 오차로 일부 통과가 인정 범위(±20m) 밖으로 벗어나 누락. +- **조치**: 측점 기준 탐지에 **좌표 근접 탐지를 합집합으로 보강**(중복은 자동 제거). 누락되던 재진입 통과까지 마커가 정상 표출. +- **효과**: 동일 역 다중 통과 구간의 마커 신뢰도 향상. + +### 2-2. 측점값 라벨 겹침 정리 +- **현상**: 근접 통과 시 측점값(예: 157k990) 텍스트가 서로 겹쳐 판독 불가. +- **조치**: 라벨이 시각적으로 겹치면 **먼저 나온(좌측) 값만 표시하고 뒤엣값은 숨김**(아이콘·연결 점선은 유지). +- **효과**: 측점값 가독성 확보. + +--- + +## 3. UI/UX 개선 (영상 컨트롤 재구성) + +기존에 '화면표시 옵션' 창에 흩어져 있던 표시 토글을 **하단 재생바로 통합**하여, 영상을 보면서 바로 켜고 끌 수 있도록 동선을 개선했습니다. + +### 3-1. 표시 토글을 재생바로 이동 +- **선형(중심선)**, **드론 궤적**, **좌측 노선 패널** 토글을 재생바 버튼으로 이동. +- 기존 '화면표시 옵션' 창의 중복 버튼은 제거(기능은 동일, 단일 위치로 일원화). +- 상태를 설정 저장소로 통합 → 어디서 켜도 즉시 반영되고 **새로고침해도 유지**. + +### 3-2. 항상 표시 항목 정리 +- **드론 GPS·절대고도 정보**: ON/OFF 버튼 제거하고 **상시 표시**로 변경(요청사항). +- **경로 표고 / 투명도** 슬라이더: 드론 궤적 ON/OFF와 무관하게 **항상 노출**. + +### 3-3. 재생바 요소 배치 정리 +- 최종 순서: **좌측 패널 → 선형 → 드론궤적 → 드론 위치정보(GPS·고도) → 시설등급** +- 드론 궤적 버튼과 드론 위치정보를 인접 배치, 시설등급 옵션을 그 우측에 배치. +- 잘 쓰지 않는 'POI 겹침제외' 버튼은 숨김(기능/상태는 보존, 기본 ON). + +### 3-4. 활성 상태 색상 통일 +- 제각각이던 활성(ON) 색(파랑/초록/빨강/하늘색)을 **앰버(겨자·오렌지 계열)로 통일** → UI 일관성 향상. + +--- + +## 4. 운영 메모 (배포 방식) + +- 사용자 화면(포트 55000)은 **빌드 결과물을 서빙**하는 구조로, 소스 수정 후 **클라이언트 빌드 → 새로고침**해야 반영됩니다. (이번 작업 중 빌드 누락으로 변경이 보이지 않던 이슈를 확인·정리) +- 데이터(드론 프레임/POI)는 폴더 선택으로 로드되므로, 새로고침 시 **폴더 재선택** 필요. + +--- + +## 5. 검증 + +- 모든 변경 TypeScript 타입검사 + 프로덕션 빌드 통과. +- 역 재진입 마커 표출은 실제 화면(55000)에서 표시 확인 완료. + +--- + +## 6. 향후 참고(선택 개선안) + +- 측점값 라벨 폭 추정 상수는 폰트 변경 시 재조정 필요. +- 활성 색조(앰버 명도)는 가독성에 따라 미세 조정 가능. +- 'POI 겹침제외'는 현재 숨김 상태이므로, 재노출이 필요하면 토글 복원 가능. diff --git a/docs/보고서_2026-06-29_스테이션바_UI개선.pdf b/docs/보고서_2026-06-29_스테이션바_UI개선.pdf new file mode 100644 index 0000000..8577bbd Binary files /dev/null and b/docs/보고서_2026-06-29_스테이션바_UI개선.pdf differ diff --git a/docs/특허_스테이션기반_주행영상_플레이어.html b/docs/특허_스테이션기반_주행영상_플레이어.html index 1856cec..2bd3325 100644 --- a/docs/특허_스테이션기반_주행영상_플레이어.html +++ b/docs/특허_스테이션기반_주행영상_플레이어.html @@ -58,7 +58,7 @@ td code,p code,li code{background:#0c0f14;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:12.5px;color:#9ad0ff;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace} .badge{display:inline-block;font-size:11.5px;font-weight:700;padding:2px 9px;border-radius:999px;border:1px solid transparent} - .b-high{background:rgba(126,231,135,.13);color:var(--good);border-color:rgba(126,231,135,.35)} + .b-high{background:rgba(36, 70, 39, 0.13);color:var(--good);border-color:rgba(126,231,135,.35)} .b-mid{background:rgba(255,204,102,.13);color:var(--mid);border-color:rgba(255,204,102,.35)} .b-low{background:rgba(255,123,114,.13);color:var(--bad);border-color:rgba(255,123,114,.35)} .chk{color:var(--warn);font-weight:600} diff --git a/scripts/md2docs.sh b/scripts/md2docs.sh new file mode 100755 index 0000000..5c13fea --- /dev/null +++ b/scripts/md2docs.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# md2docs.sh — 마크다운(.md) 보고서를 베이스로 HTML + PDF 를 생성한다. +# 결과물: <입력>.html (자체완결, CSS 인라인), <입력>.pdf (A4, 한글 임베드) +# +# 사용법: scripts/md2docs.sh docs/보고서_xxxx.md +# +# 의존성(이미 환경에 설치됨): +# - pandoc : md -> html (--self-contained 로 CSS 인라인) +# - weasyprint : html -> pdf (pydyf 0.10 호환, py3.8 anaconda) +# - 한글 폰트 : ~/.local/share/fonts 의 Malgun Gothic (Windows 폰트 복사) +# 최초 1회 환경구성이 필요하면 scripts/README 또는 메모(report-doc-toolchain) 참고. +set -euo pipefail + +MD="${1:-}" +[ -z "$MD" ] && { echo "사용법: $0 <파일.md>"; exit 1; } +[ -f "$MD" ] || { echo "파일 없음: $MD"; exit 1; } + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CSS="$ROOT/docs/assets/report.css" +BASE="${MD%.md}" +HTML="$BASE.html" +PDF="$BASE.pdf" +TITLE="$(basename "$BASE")" + +export PATH="$HOME/.local/bin:$PATH" + +echo "[1/2] HTML 생성: $HTML" +pandoc "$MD" -f gfm -t html5 -s --self-contained -c "$CSS" \ + --metadata title="$TITLE" -o "$HTML" + +echo "[2/2] PDF 생성: $PDF" +python3 -m weasyprint "$HTML" "$PDF" + +echo "완료:" +ls -la "$MD" "$HTML" "$PDF"