Files
GhiVideo/docs/구현상세_GhiVideo_기술-소스코드매칭.md
T
b23042andClaude Opus 4.8 e0ff5dd6d0 feat: 지도 나침반(OSM/위성) 추가 + 측점 검색·마커 정확도 개선 + POI/영상 수정
- 지도 기반 나침반(노스업, 현재위치, 시야 역삼각형): 호버 확대·휠 줌·클릭 위성전환, OSM/Esri 타일 서버 프록시(/api/tile)
- 스테이션 검색: 실제 측점(chain) 기준 이동, 없으면 '측점 없음' 안내
- 역 마커: 직교 투영 측점 일치 시에만 표시, 미도착 종점은 추가 방식
- POI 팝업 겹침/재등장·라벨 정합 수정
- 영상 fps 데이터 기반 자동 산출
- 기술/발표/쉬운설명 문서 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:03:30 +09:00

746 lines
36 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`, 6370행)
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 파싱 견고성 헬퍼 (83129행)
깨진 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/ |
| `<base>.csv` | `parseDroneFrames` | 드론 프레임(자세/위치) | root |
> ~~`parsePois`·`parseStructures`·`parseAccessDoors`·`parseHistoricStations`~~ — KMZ 필수 정책으로 **삭제됨**(building/ POI·구조물 CSV 폴백 제거).
> 정리: **측점·역사**는 root 폴백이 있어 building 없이도 읽힌다. **지장물·교량·터널·구교·출입문 CSV**는 여전히 building/ 안에서만 찾지만, 이들은 KMZ가 있을 때 통째로 무시되므로 현재 KMZ 기반 데이터에서는 building 폴더가 없어도 정상 동작한다.
### 2.3 측점 표고 우선순위 & 방향전환점 (207260행)
측점 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 위치 보정 입출력 (732767행)
드래그로 만든 보정값을 `<base>_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`, 776781행)
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`, 9197행)
**문제**: 지구는 둥글어요. 그런데 둥근 표면 위에서 "몇 미터 떨어졌나"를 계산하긴 어려워요.
**해결**: 우리가 보는 작은 지역(노선 한 구간)만 **싹둑 잘라서 평평한 모눈종이(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`, 301317행)
모눈종이 좌표(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`, 126137행)
이제 진짜 **사진 찍기**예요. 카메라는 입체(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) (149251행)
드래그 편집·세로화각 보정 기능의 수학적 기반. 화면 픽셀을 다시 월드좌표로 되돌리는 3개 함수.
| 함수 | 위치 | 입력→출력 | 용도 |
|------|------|-----------|------|
| `worldFromPixel` | 149–183 | 픽셀 + 슬랜트거리 → lat/lon/z | POI 드래그(수평+수직 동시) |
| `solveZForPixelY` | 194–211 | 화면 세로위치 → 표고 z | "앞으로 밀려 보임" 보정 |
| `groundPointFromPixel` | 218251 | 픽셀 + 지면고도 → lat/lon | 지면 좌표 보정(z 유지) |
핵심 아이디어: 투영의 회전행렬 `b2w`를 전치(`w2c`)해 카메라 방향벡터를 월드 ENU 방향으로 되돌린 뒤, 거리(또는 평면 교차)로 스케일한다.
```ts
// groundPointFromPixel: 광선과 지면(zGround)의 교차 (218251행)
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) (194211행)
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 → 측점값+이격 (3550)
// 각 선분에 점-투영 → 최근접 선분의 보간 km, 수직거리(offsetM) 반환
}
```
---
## 6. 영상 오버레이 렌더링
**파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx)
### 6.1 RAF 60fps 루프 (9051198행)
`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 정렬 (921930행)
영상이 `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) (4861행)
떨림/튐을 잡는 핵심 알고리즘. 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 겹침 억제 (3536, 770780행)
화면상 가로 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 컴팩트 팝업 (152176, 12691292행)
라벨 옆에 항상 핵심 3~6필드(시설종별→구조형식→연장→폭→용도→준공연도)를 컴팩트로 보이고, 클릭하면 전체로 확장한다. **다중 팝업** 누적 가능.
```tsx
const compactFieldsOf = (props) => { // 정해진 순서로 존재하는 것만 (152–166)
pick('시설종별', k => k === '시설종별'); pick('구조형식', k => /구조형식/.test(k));
pick('연장(m)', k => /연장/.test(k)); /* 폭/용도/준공 ... */
};
// 클릭: 이미 있으면 전체↔컴팩트 토글, 없으면 추가 (컴팩트필드 없으면 바로 전체 펼침) (12691292)
expanded: compact.length === 0
```
---
## 9. 위치 보정 — 드래그·DEM·세로화각
**파일**: [client/src/components/overlay/StationOverlay.tsx](client/src/components/overlay/StationOverlay.tsx)
### 9.1 드래그 편집 (12361254, 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 자동 표고 (13841415행)
모든 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 세로화각 보정 (13311345행)
라벨이 상하로 어긋날 때, 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(954971행)
SVG 카드 전체를 `transform: rotate(var(--rot))`로 회전시키고, RAF에서 매 프레임 `--rot``yaw`로 갱신한다. 360° 누적 언랩으로 회전 점프를 막고, 회전엔 메인 포즈(±60fr 강한 평활)가 아닌 **가벼운 ±3fr 평활 yaw**를 별도 계산해 지연을 줄였다.
```tsx
// Minimap.tsx: transform: 'rotate(var(--rot, 0deg))'
// StationOverlay.tsx (954971): 매 프레임 갱신
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 자동 (228231행)
고정 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 이동거리축 누적 (233274행)
드론의 실제 측점값(체이니지)을 ±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 종점역 미도착 (300310, 574586행)
영상이 종점역에 미도달하면 트랙 우측을 회색(상한 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 139172행)
동명 구조물의 여러 통과점을 `Map<title, marks[]>`으로 묶어, 중앙에 라벨 1개 + 각 통과에 점선 드롭 + 양끝 브래킷으로 표시. 상/하 변형은 진행방향 1개만 남긴다.
---
## 12. 60fps 부드러운 커서 — smoothTimeRef
**파일**: [client/src/components/player/VideoPlayer.tsx](client/src/components/player/VideoPlayer.tsx) (62117행)
`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 769786행) → 커서/진행바가 부드럽게 흐른다.
```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) (29110행)
`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) (4690행)
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) (1418행) + [useVideoPlayer.ts](client/src/hooks/useVideoPlayer.ts) (4450행)
`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 1837, 233257행)
`webkitGetAsEntry()`로 디렉터리 엔트리를 얻어 `collectDropEntry`로 재귀 수집 → 영상+측점/POI 폴더를 통째로 로드.
```tsx
function collectDropEntry(entry, out): Promise<void> { // 디렉터리 재귀
if (entry.isDirectory) { /* readEntries 배치 반복 */ }
else (entry).file((f) => { out.push(f); });
}
```
---
## 14. 서버 — 고도(DEM) 프록시 API
**파일**: [server/src/routes/elevation.ts](server/src/routes/elevation.ts) (1467행)
브라우저 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 | 6370 |
| 파싱 헬퍼(폴백 인덱서) | geoData.ts | 83129 |
| 측점/방향전환점 | geoData.ts | 207264 |
| 구조물 파싱 | geoData.ts | 356449 |
| KMZ 파싱 | geoData.ts | 553648 |
| POI 보정 입출력 | geoData.ts | 732767 |
| 중심선 생성 | geoData.ts | 776781 |
| 카메라좌표 변환 | geoProjection.ts | 301317 |
| 정규픽셀 투영 | geoProjection.ts | 126137 |
| 역투영 3종 | geoProjection.ts | 149251 |
| 체이니지 계산 | chainage.ts | 1064 |
| RAF 렌더 루프 | StationOverlay.tsx | 9051198 |
| cover 정렬 | StationOverlay.tsx | 921930 |
| 라벨 평활/이상치거부 | StationOverlay.tsx | 4861 |
| POI 겹침 억제 | StationOverlay.tsx | 3536, 770780 |
| 컴팩트 팝업 | StationOverlay.tsx | 152176, 12691292 |
| 드래그/DEM/세로화각 | StationOverlay.tsx | 12361254, 13311415 |
| 나침반 미니맵 | Minimap.tsx / StationOverlay.tsx | 전체 / 954971 |
| 이동거리축 | StationBar.tsx | 228310 |
| 라벨 그룹핑 | Timeline.tsx | 101172 |
| smoothTimeRef | VideoPlayer.tsx / StationBar.tsx | 62117 / 769786 |
| geoStore 보정 | geoStore.ts | 29110 |
| settingsStore | settingsStore.ts | 4690 |
| videoReady 게이트 | playerStore.ts / useVideoPlayer.ts | 1418 / 4450 |
| 폴더 드롭 | VideoPlayer.tsx | 1837, 233257 |
| 고도 API | elevation.ts | 1467 |
---
*본 문서는 GhiVideo 소스코드를 직접 읽어 함수·라인·핵심 코드를 추출해 작성되었다. 코드 인용은 가독성을 위해 일부 축약·생략(`...`)했으며, 정확한 전체 구현은 해당 파일을 참조하라.*