# GhiVideo 구현 상세 — 기술과 소스코드 매칭 > 작성일: 2026-06-30 > 본 문서는 [쉬운설명_GhiVideo_기술이야기](쉬운설명_GhiVideo_기술이야기.md) 및 [DefVideo→GhiVideo_업그레이드_상세](DefVideo→GhiVideo_업그레이드_상세.md)에서 설명한 기술들을 **실제 소스코드(파일·라인·핵심 코드)**와 1:1로 매칭하여, "어떻게 구현했는가"를 코드 근거와 함께 상세히 기술한 문서다. > 라인 번호는 작성 시점(2026-06-30) 기준이며 코드 수정 시 다소 달라질 수 있다. --- ## 목차 1. [전체 데이터 흐름](#1-전체-데이터-흐름-한눈에) 2. [데이터 로딩 — V2.0 폴더 파싱](#2-데이터-로딩--v20-폴더-파싱) 3. [좌표 투영 엔진](#3-좌표-투영-엔진) 4. [역투영 — 화면을 다시 세계로](#4-역투영--화면을-다시-세계로) 5. [측점·체이니지 계산](#5-측점체이니지-계산) 6. [영상 오버레이 렌더링](#6-영상-오버레이-렌더링) 7. [라벨 안정화 — 평활과 이상치 거부](#7-라벨-안정화--평활과-이상치-거부) 8. [POI 겹침 억제·컴팩트 팝업](#8-poi-겹침-억제컴팩트-팝업) 9. [위치 보정 — 드래그·DEM·세로화각](#9-위치-보정--드래그demem세로화각) 10. [나침반 미니맵](#10-나침반-미니맵) 11. [하단 측점바 — 이동거리축](#11-하단-측점바--이동거리축) 12. [60fps 부드러운 커서 — smoothTimeRef](#12-60fps-부드러운-커서--smoothtimeref) 13. [상태 관리 — geoStore·settingsStore·playerStore](#13-상태-관리) 14. [서버 — 고도(DEM) 프록시 API](#14-서버--고도dem-프록시-api) --- ## 1. 전체 데이터 흐름 (한눈에) ``` [폴더 드롭] VideoPlayer.handleDrop (webkitGetAsEntry + collectDropEntry 재귀 수집) │ ▼ [파싱] geoData.loadFolderGeoData ├ decodeBytes (BOM 자동 인코딩) ├ parseKmz (영상 옆 root .kml/.kmz) POI·구조물의 *유일* 소스(필수) │ └ 없거나 비면 kmzMissing=true → 경고(alert+console), POI·구조물 빈 상태 │ (building/ POI·구조물 CSV 폴백 제거됨. 측점은 별개로 항상 로드) ├ parseStations → 측점 + 방향전환점 (측점 CSV: root 또는 building/) ├ parsePoiOverrides (드래그 보정 JSON) └ buildCenterlineFromStations (center.csv 대체) │ ▼ [상태] geoStore (basePois + poiOverrides → applyPoiOverrides → pois) settingsStore (gradeFilter 등 localStorage 영속) playerStore (videoReady, videoWidth/Height) │ ▼ [투영] geoProjection: ENU → 카메라좌표(회전행렬) → 정규픽셀 chainage: 드론 GPS → 측점값(km) / 선로 이격(offsetM) │ ▼ [렌더] StationOverlay (RAF 60fps): 선형·궤적·라벨·팝업·미니맵 StationBar (RAF): 이동거리축 커서/측점배지 VideoPlayer (RAF): smoothTimeRef 단조보간 시간 ``` --- ## 2. 데이터 로딩 — KMZ 우선, CSV 폴백 **파일**: [client/src/utils/geoData.ts](client/src/utils/geoData.ts) > **데이터 소스 우선순위 (중요 — KMZ 필수 정책)** > POI·구조물의 **유일한 소스는 영상 옆(root)의 `.kml`/`.kmz`** 다. KMZ는 **필수**이며, 없거나 비어 있으면 `kmzMissing=true`로 두고 **경고(alert + console)** 만 띄운 채 POI·구조물을 빈 상태로 둔다(데이터 누락 → 재구축 필요). **`building/` POI·구조물 CSV 폴백은 제거**되었다(`parsePois`/`parseStructures`/`parseAccessDoors`/`parseHistoricStations` 삭제). 단 **측점**은 KMZ에 없어 항상 CSV에서 읽으며, 이 측점 CSV는 root 또는 `building/` 어디서든 찾는다. 따라서 지금은 **KMZ(root) + 측점 CSV(root)** 만으로 동작한다. (철도역=역사는 KMZ의 `KAKAO_RAIL` placemark에서 구조물로 생성된다.) > > ```ts > // loadFolderGeoData — KMZ가 유일 소스(필수). 없으면 경고만, 폴백 없음. > const kmzMissing = !kmz || (kmz.pois.length === 0 && kmz.structures.length === 0); > if (kmzMissing) console.warn('[KMZ] 누락/비어있음 — POI·구조물 미표시. 재구축 필요'); > const pois = kmz?.pois ?? []; > const baseStructures = kmz?.structures ?? []; > ``` ### 2.1 자동 인코딩 감지 (`decodeBytes`, 63–70행) ArrayBuffer 앞 3바이트(BOM)로 UTF-8 / EUC-KR을 자동 판별한다. 구버전은 파일별로 인코딩을 하드코딩했으나, 신버전은 혼재 환경을 자동 처리한다. ```ts export function decodeBytes(buf: ArrayBuffer): string { const head = new Uint8Array(buf, 0, Math.min(3, buf.byteLength)); const isUtf8Bom = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf; const encoding = isUtf8Bom ? 'utf-8' : 'euc-kr'; const text = new TextDecoder(encoding).decode(buf); return text.replace(/^/, ''); // 디코딩 후 잔여 BOM 제거 } ``` ### 2.2 파싱 견고성 헬퍼 (83–129행) 깨진 EUC-KR 헤더에도 안전하도록, 헤더명 검색 실패 시 위치 인덱스로 폴백한다. ```ts function makeFieldIndexer(header: string[]): (name: string, fallback?: number) => number { const cleaned = header.map((h) => h.trim().replace(/^/, '')); return (name, fallback) => { const i = cleaned.indexOf(name); return i >= 0 ? i : (fallback ?? -1); // 헤더명 실패 → 위치 폴백 }; } function cell(row: string[], i: number): string { // 음수/범위 밖 → '' (크래시 방지) return i >= 0 && i < row.length ? row[i] : ''; } ``` 측점 CSV는 `findBuildingFile(files, '01)측점')` 키워드 매칭으로 `building/` → root 순으로 찾는다(둘 다 지원). KMZ 도입 후 POI·구조물 CSV 파서는 제거되어, 현재 CSV 파서는 측점·드론 둘뿐이다. | 소스 | 파서 | 산출물 | 위치 | |------|------|--------|------| | 루트 `*.kml` / `*.kmz` | `parseKmz` | POI·구조물·역사 | **유일 소스(root, 필수)** | | `측점.csv` | `parseStations` | 측점 + 방향전환점 | root **또는** building/ | | `.csv` | `parseDroneFrames` | 드론 프레임(자세/위치) | root | > ~~`parsePois`·`parseStructures`·`parseAccessDoors`·`parseHistoricStations`~~ — KMZ 필수 정책으로 **삭제됨**(building/ POI·구조물 CSV 폴백 제거). > 정리: **측점·역사**는 root 폴백이 있어 building 없이도 읽힌다. **지장물·교량·터널·구교·출입문 CSV**는 여전히 building/ 안에서만 찾지만, 이들은 KMZ가 있을 때 통째로 무시되므로 현재 KMZ 기반 데이터에서는 building 폴더가 없어도 정상 동작한다. ### 2.3 측점 표고 우선순위 & 방향전환점 (207–260행) 측점 CSV에서 `Z좌표_한국`(정표고, EPSG:5186)을 우선 사용하고, 없으면 `Z좌표`로 폴백한다. 비고 컬럼은 정규식으로 방향전환점을 추출한다. ```ts const iZKorea = fi('Z좌표_한국'); const iZ = iZKorea >= 0 ? iZKorea : fi('Z좌표', 3); // 정표고 우선 // ... // 비고: "방향전환점(상행->하행, 02:05)" const m = note.match(/방향전환점\s*\(\s*([^->]+?)\s*->\s*([^,)]+?)\s*,\s*([\d:]+)\s*\)/); if (m) directionChanges.push({ station: title, from: m[1].trim(), to: m[2].trim(), atSeconds: mmssToSeconds(m[3]), }); ``` ### 2.4 POI 위치 보정 입출력 (732–767행) 드래그로 만든 보정값을 `_poi_overrides.json`에서 읽고(`parsePoiOverrides`), 원본에 덧씌운다(`applyPoiOverrides`). 원본과 보정을 분리해 **재적용 가능**하게 둔 것이 핵심 설계다. ```ts export function applyPoiOverrides(pois: GeoPoint[], overrides: PoiOverrideMap): GeoPoint[] { if (!overrides || !Object.keys(overrides).length) return pois; return pois.map((p) => { const o = overrides[p.title]; return o ? { ...p, lat: o.lat, lon: o.lon, z: o.z } : p; }); } ``` ### 2.5 중심선 생성 (`buildCenterlineFromStations`, 776–781행) V2.0엔 `center.csv`가 없으므로, 측점을 측점값 순으로 정렬해 중심선 폴리라인을 만든다. ```ts export function buildCenterlineFromStations(stations: GeoPoint[]): CenterlinePoint[] { return [...stations] .filter((s) => !isNaN(s.lat) && !isNaN(s.lon)) .sort((a, b) => stationOrder(a.title) - stationOrder(b.title)) .map((s) => ({ lat: s.lat, lon: s.lon, z: s.z })); } ``` --- ## 3. 좌표 투영 엔진 **파일**: [client/src/utils/geoProjection.ts](client/src/utils/geoProjection.ts) 월드좌표(위경도+표고)를 영상 화면의 정규픽셀(0~1)로 변환하는 핵심이다. 흐름은 **ENU → 카메라좌표(회전) → 정규픽셀** 3단계. ### 🧒 먼저, 아주 쉽게 — "이거 무슨 일을 하는 거야?" 우리는 지도에서 **다리(교량)가 어디 있는지** 알아요. 위도·경도라는 숫자로요. (예: "북위 36.33도, 동경 127.45도") 그런데 드론이 하늘에서 영상을 찍고 있어요. 우리가 하고 싶은 건: > **"그 다리가 지금 드론 영상 화면의 어디쯤에 보일까?"** 를 계산해서, 그 자리에 이름표(라벨)를 딱 붙이는 거예요. 이걸 **투영(projection)** 이라고 해요. 쉽게 말하면 **"지도 위의 한 점 → 영상 화면 위의 한 점"으로 바꾸는 마법**이에요. ``` 지도(현실 세계) 드론 영상 화면 ┌───────────────┐ ┌───────────────┐ │ 🌉 다리 │ ──투영 마법──► │ 🌉 │ ← 여기 이름표! │ (위도, 경도) │ │ (화면 x, y) │ └───────────────┘ └───────────────┘ ``` 이 마법은 한 번에 안 되고 **3단계**를 거쳐요. 마치 요리처럼 순서대로 재료를 다듬어요. ``` 1단계 2단계 3단계 [ENU 변환] → [카메라 눈으로 보기] → [사진 찍기] 둥근 지구를 드론이 고개 돌린 3D 세상을 평평한 모눈 방향에 맞춰 납작한 사진 종이로 폄 좌표를 돌림 한 장으로 ``` --- ### 3.1 1단계 — 둥근 지구를 평평한 모눈종이로 (`geoToEnu`, 91–97행) **문제**: 지구는 둥글어요. 그런데 둥근 표면 위에서 "몇 미터 떨어졌나"를 계산하긴 어려워요. **해결**: 우리가 보는 작은 지역(노선 한 구간)만 **싹둑 잘라서 평평한 모눈종이(graph paper)** 위에 펼쳐요. 그러면 위치를 그냥 "오른쪽으로 몇 미터(E, East), 위로 몇 미터(N, North)"로 말할 수 있어요. 높이(U, Up)는 "기준점보다 몇 미터 높은가"로 둬요. ``` 둥근 지구 표면 평평한 모눈종이 (ENU) 🌐 N(북) ↑ ╱ ╲ ──► │ • 다리 (E=120m, N=300m, U=-5m) (위도,경도) │ ────────┼────────► E(동) │ 기준점(0,0) ``` - `E`, `N` = 옆으로·앞으로 몇 미터 (지도를 평평하게 편 좌표, EPSG:5186 TM) - `U` = 높이. `alt - refAlt` 즉 **기준 높이를 빼서** "상대 높이"로 만들어요. ```ts function geoToEnu(lat, lon, alt, _refLat, _refLon, refAlt): [number, number, number] { const [e, n] = latLonToTM(lat, lon); // 위도·경도 → 평평한 미터 좌표(E, N) return [e, n, alt - refAlt]; // 높이는 기준점 대비 상대값(U) } ``` > 💡 비유: 지구본에 붙은 스티커를 떼어 책상 위에 평평하게 붙이는 것. 작은 조각이라 찌그러짐이 거의 없어요. --- ### 3.2 2단계 — 드론의 눈높이로 고개 돌리기 (`toCameraCoords`, 301–317행) 모눈종이 좌표(E, N, U)는 **'세상' 기준**이에요. 하지만 카메라(드론)는 **자기가 바라보는 방향**이 따로 있어요. 드론이 동쪽을 보든 북쪽을 보든, "내 앞·내 오른쪽·내 위" 기준으로 바꿔줘야 해요. 이때 드론의 자세 3가지를 써요: - **yaw(요)** = 어느 쪽을 향하는지 (나침반 방향, 좌우 회전) - **pitch(피치)** = 위/아래로 얼마나 기울었는지 - **roll(롤)** = 좌우로 얼마나 갸우뚱했는지 이 3개로 **회전 행렬**을 만들어 세상 좌표를 "카메라가 보는 좌표(Xc, Yc, Zc)"로 빙글 돌려요. ``` 세상 기준(N=북 고정) 카메라 기준(드론이 보는 방향) N↑ "앞"(Zc) ↗ │ • 다리 / • 다리 │ ╱ / (앞으로 50m, ────────┼────────► E ──회전──► / 오른쪽 8m) │drone🚁 🚁─────► "오른쪽"(side) (북을 보는 중) 드론이 보는 정면이 기준! ``` 추가로 **진행방향(가는 길) 기준 거리**도 계산해요: - `fwd` = 드론이 **가는 방향으로 앞쪽 몇 미터** (양수면 앞) - `side` = **옆으로 몇 미터** (오른쪽 +, 왼쪽 −) 이게 왜 필요할까요? 👉 "앞에 있는 먼 다리는 보여주되, 옆으로 멀리 떨어진 건 가리고 싶다" 같은 **똑똑한 필터**(앞은 멀리, 옆은 가깝게 = 비등방 거리필터)를 만들 수 있어요. ```ts const cc = applyRw2c(b2w, relEnu); // 세상좌표 → 카메라좌표 (회전 적용) cc.distH = dist; // 평면(수평) 거리 const yaw = toRad(camera.yaw + params.yawOffset); const sy = Math.sin(yaw), cy = Math.cos(yaw); cc.fwd = relEnu[0] * sy + relEnu[1] * cy; // +면 내 앞쪽 cc.side = relEnu[0] * cy - relEnu[1] * sy; // +면 오른쪽 / −면 왼쪽 ``` > 💡 비유: 친구가 "북쪽으로 3걸음"이라고 말해도, 내가 서쪽을 보고 있으면 그건 "내 오른쪽 3걸음"이에요. **내가 보는 방향 기준으로 바꿔 말하는 것**이 2단계예요. --- ### 3.3 3단계 — 3D 세상을 납작한 사진 한 장으로 (`pixelFromCamera`, 126–137행) 이제 진짜 **사진 찍기**예요. 카메라는 입체(3D) 세상을 납작한(2D) 사진으로 눌러 담아요. 핵심 원리는 딱 하나: > **멀리 있는 건 화면 가운데로 작게, 가까이 있는 건 크게.** 옛날 **바늘구멍 사진기(핀홀 카메라)** 와 똑같아요. 작은 구멍으로 빛이 들어와 뒤쪽 종이에 상이 맺혀요. ``` 바늘구멍 사진기 원리 다리 🌉 화면(필름) \ │ \ ┌──┐ │ • ← 다리가 맺힌 점 \ 빛 │ │ 바늘구멍 │ \─────► │ •│ ───────────────► │ 가까울수록 (구멍) │ 화면에서 큼 │ ◄────── 거리(Zc) ──────► ``` 수학으로는 "카메라 좌표를 **깊이(Zc)로 나누는 것**"이 전부예요. Zc(거리)가 크면(멀면) 나눈 값이 작아져서 → 화면 가운데(0.5)에 가깝게 찍혀요. ```ts return { pxRaw: (0.5 + params.cx0) + (cc.Xc / cc.Zc) * (f / sW), // 가로 위치 (0~1) pyRaw: (0.5 + params.cy0) + (cc.Yc / cc.Zc) * (f / sH), // 세로 위치 (0~1) }; ``` - `0.5` = 화면 정중앙. 결과가 0이면 화면 왼쪽 끝, 1이면 오른쪽 끝. - `cc.Xc / cc.Zc` = **옆으로 벌어진 정도 ÷ 거리** → 멀수록 가운데로. - `f / sW` = 렌즈가 얼마나 "확대/광각"인지 (초점거리 ÷ 센서폭 = 화각). 망원렌즈면 크게, 광각이면 넓게 보여요. > 💡 비유: 손가락을 눈앞에 두면 크게 보이고, 멀리 뻗으면 작아 보이죠? 똑같이 "거리로 나누기" 한 거예요. --- ### 3.4 보너스 — 높이의 함정, '두 개의 바다 높이' (`geoidOffset`) 마지막으로 까다로운 문제 하나. **높이(고도)를 재는 자(기준)가 두 종류**예요. 1. **정표고(EL)** — 우리가 흔히 쓰는 "바다 높이(해발)". 측점 데이터(지도)가 쓰는 자. 2. **타원체고** — GPS·드론이 쓰는 "수학적으로 매끈한 지구 모양" 기준의 자. 이 둘은 같은 장소라도 **숫자가 달라요**. 대전 근처에선 약 **25.8m** 차이가 나요. 안 맞추면 다리가 화면에서 위아래로 어긋나 보여요. ``` 타원체고 자 ──────────────── ← GPS/드론이 재는 0 ▲ │ 약 25.8m (geoidOffset) ▼ 정표고(해발) 자 ────────────── ← 지도(측점)가 재는 0 ▲ │ 다리의 실제 높이 ▼ ▓▓▓▓▓ 땅 ▓▓▓▓▓ ``` 그래서 지도 높이에 **25.8m를 더해** GPS 기준으로 맞춰줘요. 이 한 줄 덕분에 라벨이 다리에 정확히 붙어요. ```ts // 지도 높이(정표고) + geoidOffset = GPS 기준 높이(타원체고) const stEnu = geoToEnu(targetLat, targetLon, targetAlt + (params.geoidOffset ?? 0), ...); // DEFAULT_CAMERA_PARAMS: geoidOffset: 25.8 (대전 KNGeoid18), sensorW: 36, sensorH: 20.25 ... ``` > 💡 비유: 친구는 1층을 "0층"이라 부르고 나는 "1층"이라 불러요. 같은 곳을 말해도 숫자가 달라요. 그래서 "네 숫자에 1을 더하면 내 숫자야"라고 약속을 맞춰주는 것 = geoidOffset. --- ### 📋 3단계 한 줄 요약 | 단계 | 하는 일 | 쉬운 말 | 함수 | |------|---------|---------|------| | 1 | 둥근 지구 → 평평한 미터좌표(E,N,U) | 지구본 스티커를 책상에 펴기 | `geoToEnu` | | 2 | 세상좌표 → 카메라가 보는 좌표 | 내가 보는 방향 기준으로 고개 돌리기 | `toCameraCoords` | | 3 | 3D → 2D 화면 점(0~1) | 거리로 나눠 사진 찍기 | `pixelFromCamera` | | + | 높이 기준 맞추기 | 두 개의 '0층'을 약속으로 통일 | `geoidOffset` | --- ## 4. 역투영 — 화면을 다시 세계로 **파일**: [client/src/utils/geoProjection.ts](client/src/utils/geoProjection.ts) (149–251행) 드래그 편집·세로화각 보정 기능의 수학적 기반. 화면 픽셀을 다시 월드좌표로 되돌리는 3개 함수. | 함수 | 위치 | 입력→출력 | 용도 | |------|------|-----------|------| | `worldFromPixel` | 149–183 | 픽셀 + 슬랜트거리 → lat/lon/z | POI 드래그(수평+수직 동시) | | `solveZForPixelY` | 194–211 | 화면 세로위치 → 표고 z | "앞으로 밀려 보임" 보정 | | `groundPointFromPixel` | 218–251 | 픽셀 + 지면고도 → lat/lon | 지면 좌표 보정(z 유지) | 핵심 아이디어: 투영의 회전행렬 `b2w`를 전치(`w2c`)해 카메라 방향벡터를 월드 ENU 방향으로 되돌린 뒤, 거리(또는 평면 교차)로 스케일한다. ```ts // groundPointFromPixel: 광선과 지면(zGround)의 교차 (218–251행) const relUpTarget = (zGround + (params.geoidOffset ?? 0)) - camera.altitude - (params.offZ ?? 0); const t = relUpTarget / dir[2]; // 광선 파라미터 if (t <= 0) return null; const E = drEnu[0] + (params.offX ?? 0) + t * dir[0]; const N = drEnu[1] + (params.offY ?? 0) + t * dir[1]; const [lon, lat] = _toTM.inverse([E, N]); return { lat, lon }; ``` ```ts // solveZForPixelY: 표고만 역산 — u = (R·Zc0 − Yc0)/(dYc − R·dZc) (194–211행) const R = (pyTarget - 0.5 - (params.cy0 ?? 0)) * sH / f; const denom = dYc - R * dZc; if (Math.abs(denom) < 1e-9) return z0; return z0 + (R * cc0.Zc - cc0.Yc) / denom; ``` --- ## 5. 측점·체이니지 계산 **파일**: [client/src/utils/chainage.ts](client/src/utils/chainage.ts) (신규) 드론 GPS를 "선로 위 측점값(km)"과 "선로 수직이격(m)"으로 환산한다. StationBar와 오버레이 HUD가 공유. ```ts export function kmFromTitle(title: string): number { // "157K970" → 157970 const m = title.match(/(\d+)[Kk](\d+)/); return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1; } export function buildChainLine(stations): ChainLine | null { // 측점 → 평면 폴리라인 (22–32) 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; // 경도→m 환산 return { pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: kmFromTitle(p.title), ... })), k }; } export function projectToChain(lat, lon, line): { km; offsetM } { // GPS → 측점값+이격 (35–50) // 각 선분에 점-투영 → 최근접 선분의 보간 km, 수직거리(offsetM) 반환 } ``` --- ## 6. 영상 오버레이 렌더링 **파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx) ### 6.1 RAF 60fps 루프 (905–1198행) `timeupdate`(~250ms, 부정확) 대신 `requestAnimationFrame` 루프에서 매 프레임 Canvas를 다시 그린다. CLAUDE.md의 "오버레이 성능" 규칙을 정확히 따른다. ```tsx const draw = () => { rafId = requestAnimationFrame(draw); ctx.clearRect(0, 0, W, H); // 1. object-fit:cover 변환 계산 // 2. 연속 보간 포즈(estFrame) → dronePose = poseAt(estFrame) // 3. 중심선·드론궤적 투영/렌더 // 4. 측점 라벨 / 5. POI 마커(재투영 + smoothStep 평활) // 6. 히트박스 수집 / 7. 팝업 위치 매 프레임 추종 / 8. 편집 피드백 }; ``` 매 프레임 **사전계산 캐시(가시성/겹침)**는 그대로 두고, 위치만 현재 보간 포즈로 재투영한다 → 선처럼 부드럽게 이동. ### 6.2 object-fit:cover 정렬 (921–930행) 영상이 `object-fit:cover`로 크롭된 실제 표시영역을 매 프레임 계산해, 정규좌표(0~1, 영상 프레임 기준)를 화면 px로 정확히 변환한다. 이게 있어야 라벨이 영상 위 실제 지점에 정렬된다. ```tsx 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) => offX + nx * dispW; // 정규 → 화면 px ``` --- ## 7. 라벨 안정화 — 평활과 이상치 거부 **파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx) (48–61행) 떨림/튐을 잡는 핵심 알고리즘. One Euro 필터 방식의 **속도 적응형 EMA + 이상치 거부**. ```tsx const REJECT_DIST = 0.12; // 1프레임 최대 점프(정규) const MAX_REJECT_FRAMES = 8; // 연속 거부 한계 → 초과 시 수용 const SMOOTH_VEL_BETA = 0.25; // 속도 평활 계수 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, 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 }; } ``` **원리**: 떨림은 방향이 매 프레임 왕복 → 평활속도≈0 → `a`가 `minAlpha`로 작아져 강하게 평활. 실제 이동은 방향이 일관 → 평활속도 큼 → `a`가 `maxAlpha`로 커져 지연 없이 추종. 비정상 점프(시크 등)는 8프레임까지 무시하다 수용. --- ## 8. POI 겹침 억제·컴팩트 팝업 **파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx) ### 8.1 겹침 억제 (35–36, 770–780행) 화면상 가로 10%·세로 3.5% 이내 마커는 겹침으로 보고, 같은 좌표의 형제 구조물(상/하)이면 진행방향 변형을 우선 남긴다. ```tsx const POI_MERGE_X = 0.10, POI_MERGE_Y = 0.035; // ... if (Math.abs(k.x - c.x) < POI_MERGE_X && Math.abs(k.y - c.y) < POI_MERGE_Y) { overlapIdx = i; break; } // 형제 + c가 진행방향 변형이면 교체 if (dirTag && baseStruct(c.title) === baseStruct(k.title) && c.title.includes(dirTag) && !k.title.includes(dirTag)) accepted[overlapIdx] = c; ``` ### 8.2 컴팩트 팝업 (152–176, 1269–1292행) 라벨 옆에 항상 핵심 3~6필드(시설종별→구조형식→연장→폭→용도→준공연도)를 컴팩트로 보이고, 클릭하면 전체로 확장한다. **다중 팝업** 누적 가능. ```tsx const compactFieldsOf = (props) => { // 정해진 순서로 존재하는 것만 (152–166) pick('시설종별', k => k === '시설종별'); pick('구조형식', k => /구조형식/.test(k)); pick('연장(m)', k => /연장/.test(k)); /* 폭/용도/준공 ... */ }; // 클릭: 이미 있으면 전체↔컴팩트 토글, 없으면 추가 (컴팩트필드 없으면 바로 전체 펼침) (1269–1292) expanded: compact.length === 0 ``` --- ## 9. 위치 보정 — 드래그·DEM·세로화각 **파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx) ### 9.1 드래그 편집 (1236–1254, 1348행) 편집 모드에서 POI를 끌면 `groundPointFromPixel`로 화면점을 지면과 교차시켜 lat/lon을 역산, geoStore에 보정으로 저장. ```tsx const ll = groundPointFromPixel(drone, pos.x, pos.y, drag.z0, p, wo); if (ll) setPoiOverride(drag.title, { lat: ll.lat, lon: ll.lon, z: drag.z0 }); ``` ### 9.2 DEM 자동 표고 (1384–1415행) 모든 POI/구조물 좌표를 100개씩 묶어 서버 `/api/elevation`(SRTM 30m)에 질의, 실제 지형고도를 보정값으로 일괄 적용. COEP/CSP로 외부 직접호출이 막혀 서버가 중계. ```tsx const r = await fetch(`/api/elevation?lat=${lat}&lon=${lon}`); // 같은 출처 프록시 const elev = (await r.json())?.elevation; chunk.forEach((p, k) => { if (typeof elev[k] === 'number') next[p.title] = { lat: p.lat, lon: p.lon, z: elev[k] }; }); setPoiOverrides(next); ``` ### 9.3 세로화각 보정 (1331–1345행) 라벨이 상하로 어긋날 때, POI를 영상 속 실제 위치로 끌면 **세로 화각(sensorH)만** 역산해 자동 보정한다(가로 초점 f는 불변). ```tsx const b = cc.Yc / cc.Zc; const v = pos.y - 0.5 - (p.cy0 ?? 0); const sHNew = Math.max(6, Math.min(36, (b * p.focalLen) / v)); setParam('sensorH', sHNew); ``` --- ## 10. 나침반 미니맵 **파일**: [client/src/components/overlay/Minimap.tsx](client/src/components/overlay/Minimap.tsx) + StationOverlay RAF(954–971행) SVG 카드 전체를 `transform: rotate(var(--rot))`로 회전시키고, RAF에서 매 프레임 `--rot`을 `−yaw`로 갱신한다. 360° 누적 언랩으로 회전 점프를 막고, 회전엔 메인 포즈(±60fr 강한 평활)가 아닌 **가벼운 ±3fr 평활 yaw**를 별도 계산해 지연을 줄였다. ```tsx // Minimap.tsx: transform: 'rotate(var(--rot, 0deg))' // StationOverlay.tsx (954–971): 매 프레임 갱신 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`); ``` --- ## 11. 하단 측점바 — 이동거리축 **파일**: [client/src/stationbar/StationBar.tsx](client/src/stationbar/StationBar.tsx) ### 11.1 영상별 FPS 자동 (228–231행) 고정 29.97 대신 `마지막 프레임 / 재생시간`으로 영상마다 산출 → 측점 배지 정확도 향상. ```tsx const videoFps = useMemo(() => { const last = storeFrames.length ? storeFrames[storeFrames.length - 1].frame : 0; return last > 0 && duration > 0 ? last / duration : VIDEO_FPS; }, [storeFrames, duration]); ``` ### 11.2 이동거리축 누적 (233–274행) 드론의 실제 측점값(체이니지)을 ±8프레임 평활한 뒤, 프레임 간 변화량 `|Δ|`을 누적해 "실제 이동거리" 축을 만든다. 호버(공중 정지) 구간에선 누적이 멈춰 커서가 정지 → 대기 시간이 가시화된다. 앞뒤 5% 평균(`depChain`/`arrChain`)으로 출발·도착 방향을 잡는다. ```tsx const W = 8; for (let i = 0; i < n; i++) { // 측점값 ±W 이동평균 sm[i] = (pre[hi] - pre[lo]) / (hi - lo); if (i > 0) cum += Math.abs(sm[i] - sm[i - 1]); // |Δ| 누적 = 이동거리 frac[i] = cum; } for (let i = 0; i < n; i++) frac[i] /= total; // 0~1 정규화 chainRef.current = { time, frac, depChain: dep, arrChain: arr }; ``` ### 11.3 종점역 미도착 (300–310, 574–586행) 영상이 종점역에 미도달하면 트랙 우측을 회색(상한 15%)으로 비우고, 종점 마커를 '속 빈 링(unreached)'으로 표시. ```tsx const endGapPx = Math.min(TRACK_WIDTH_PX * 0.15, (gapM / lenM) * TRACK_WIDTH_PX); const timeTrackWidth = TRACK_WIDTH_PX - endGapPx; // 마지막 역사 마커를 트랙 끝(미도착)으로 if (hiIdx >= 0) marks[hiIdx] = { ...marks[hiIdx], px: TRACK_END_PX, unreached: true }; ``` ### 11.4 Timeline 라벨 그룹핑 (Timeline.tsx 139–172행) 동명 구조물의 여러 통과점을 `Map`으로 묶어, 중앙에 라벨 1개 + 각 통과에 점선 드롭 + 양끝 브래킷으로 표시. 상/하 변형은 진행방향 1개만 남긴다. --- ## 12. 60fps 부드러운 커서 — smoothTimeRef **파일**: [client/src/components/player/VideoPlayer.tsx](client/src/components/player/VideoPlayer.tsx) (62–117행) `currentTime`은 ~250ms 간격으로 갱신돼 커서가 끊긴다. 이를 **벽시계 기준 단조 보간**으로 메워 60fps로 만든다. 정지 시엔 실제 시간에 앵커, 재생 중엔 `media + 경과×배속`으로 추정하되 0.3s 이상 벌어지면 재동기화. ```tsx let est = a.media + ((performance.now() - a.wall) / 1000) * rate; const real = p.currentTime() ?? 0; if (real - est > 0.3 || real < a.media - 0.3) { // 드리프트 보정 est = real; anchorRef.current = { media: real, wall: performance.now() }; } smoothTimeRef.current = t; // ref로 노출 ``` StationBar는 이 `timeRef`를 **React 리렌더 없이** 직접 읽어 CSS 변수만 갱신한다(StationBar.tsx 769–786행) → 커서/진행바가 부드럽게 흐른다. ```tsx const t = timeRef.current ?? 0; el.style.setProperty('--pos-px', `${pxAtTime(t)}px`); el.style.setProperty('--cursor-x', `${renderX(pos)}px`); ``` --- ## 13. 상태 관리 ### 13.1 geoStore — 보정 분리관리 **파일**: [client/src/store/geoStore.ts](client/src/store/geoStore.ts) (29–110행) `basePois`(파싱 원본, 불변) + `poiOverrides`(보정 맵) → `applyPoiOverrides` 결과가 `pois`. 보정 추가/삭제 시 항상 원본에서 재계산하므로 누적 오염이 없다. ```ts setPoiOverride: (title, ov) => set((s) => { const poiOverrides = { ...s.poiOverrides, [title]: ov }; return { poiOverrides, pois: applyPoiOverrides(s.basePois, poiOverrides) }; }), ``` ### 13.2 settingsStore — localStorage 영속 **파일**: [client/src/store/settingsStore.ts](client/src/store/settingsStore.ts) (46–90행) Zustand `persist`로 `ghivideo.settings` 키에 저장. `merge`로 과거 저장본의 누락 키를 기본값 보충(스키마 진화 대비). `isGradeVisible`을 공유 함수로 두어 StationBar·RoutePanel·오버레이가 동일 규칙을 쓴다. ```ts { name: 'ghivideo.settings', merge: (persisted, current) => ({ ...current, ...p, gradeFilter: { ...DEFAULT_GRADE_FILTER, ...(p.gradeFilter ?? {}) } }) } // isGradeVisible: 미지정/미등록 등급은 표시, 등록 등급은 체크 상태 따름 ``` ### 13.3 playerStore — videoReady 게이트 **파일**: [client/src/store/playerStore.ts](client/src/store/playerStore.ts) (14–18행) + [useVideoPlayer.ts](client/src/hooks/useVideoPlayer.ts) (44–50행) `loadeddata` 전엔 오버레이를 그리지 않아(깜빡임 방지), `videoWidth/Height`로 cover 정렬을 정확화. ```ts player.on('loadstart', () => store.setVideoReady(false)); player.on('loadeddata', () => store.setVideoReady(true)); const reportSize = () => store.setVideoSize(player.videoWidth() ?? 0, player.videoHeight() ?? 0); player.on('loadedmetadata', reportSize); player.on('loadeddata', reportSize); ``` ### 13.4 폴더 드롭 (VideoPlayer.tsx 18–37, 233–257행) `webkitGetAsEntry()`로 디렉터리 엔트리를 얻어 `collectDropEntry`로 재귀 수집 → 영상+측점/POI 폴더를 통째로 로드. ```tsx function collectDropEntry(entry, out): Promise { // 디렉터리 재귀 if (entry.isDirectory) { /* readEntries 배치 반복 */ } else (entry).file((f) => { out.push(f); }); } ``` --- ## 14. 서버 — 고도(DEM) 프록시 API **파일**: [server/src/routes/elevation.ts](server/src/routes/elevation.ts) (14–67행) 브라우저 COEP/CSP 제약으로 클라이언트가 외부 DEM API를 직접 못 부르므로 서버가 중계한다. 배치(콤마 구분) 질의, **opentopodata SRTM 30m → open-meteo 90m** 2단 폴백. ```ts router.get('/', async (req, res) => { // 1) open-topodata SRTM 30m const locs = lats.map((la, i) => `${la.trim()},${lons[i].trim()}`).join('|'); const r = await fetch('https://api.opentopodata.org/v1/srtm30m?locations=' + encodeURIComponent(locs)); if (r.ok && elevation.some(v => v != null)) { res.json({ elevation, source: 'opentopodata-srtm30m' }); return; } // 2) open-meteo 90m 폴백 const r2 = await fetch('https://api.open-meteo.com/v1/elevation?latitude=' + latStr + '&longitude=' + lonStr); res.json({ elevation: (await r2.json())?.elevation ?? [], source: 'open-meteo-90m' }); }); ``` `app.ts`에 `app.use('/api/elevation', elevationRouter)`로 등록(서버 변경의 전부). --- ## 부록 — 기능 ↔ 소스 빠른 색인 | 기능 | 파일 | 라인 | |------|------|------| | 자동 인코딩 감지 | geoData.ts | 63–70 | | 파싱 헬퍼(폴백 인덱서) | geoData.ts | 83–129 | | 측점/방향전환점 | geoData.ts | 207–264 | | 구조물 파싱 | geoData.ts | 356–449 | | KMZ 파싱 | geoData.ts | 553–648 | | POI 보정 입출력 | geoData.ts | 732–767 | | 중심선 생성 | geoData.ts | 776–781 | | 카메라좌표 변환 | geoProjection.ts | 301–317 | | 정규픽셀 투영 | geoProjection.ts | 126–137 | | 역투영 3종 | geoProjection.ts | 149–251 | | 체이니지 계산 | chainage.ts | 10–64 | | RAF 렌더 루프 | StationOverlay.tsx | 905–1198 | | cover 정렬 | StationOverlay.tsx | 921–930 | | 라벨 평활/이상치거부 | StationOverlay.tsx | 48–61 | | POI 겹침 억제 | StationOverlay.tsx | 35–36, 770–780 | | 컴팩트 팝업 | StationOverlay.tsx | 152–176, 1269–1292 | | 드래그/DEM/세로화각 | StationOverlay.tsx | 1236–1254, 1331–1415 | | 나침반 미니맵 | Minimap.tsx / StationOverlay.tsx | 전체 / 954–971 | | 이동거리축 | StationBar.tsx | 228–310 | | 라벨 그룹핑 | Timeline.tsx | 101–172 | | smoothTimeRef | VideoPlayer.tsx / StationBar.tsx | 62–117 / 769–786 | | geoStore 보정 | geoStore.ts | 29–110 | | settingsStore | settingsStore.ts | 46–90 | | videoReady 게이트 | playerStore.ts / useVideoPlayer.ts | 14–18 / 44–50 | | 폴더 드롭 | VideoPlayer.tsx | 18–37, 233–257 | | 고도 API | elevation.ts | 14–67 | --- *본 문서는 GhiVideo 소스코드를 직접 읽어 함수·라인·핵심 코드를 추출해 작성되었다. 코드 인용은 가독성을 위해 일부 축약·생략(`...`)했으며, 정확한 전체 구현은 해당 파일을 참조하라.*