# 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/ |
| `<base>.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행)

드래그로 만든 보정값을 `<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`, 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<title, marks[]>`으로 묶어, 중앙에 라벨 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<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) (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 소스코드를 직접 읽어 함수·라인·핵심 코드를 추출해 작성되었다. 코드 인용은 가독성을 위해 일부 축약·생략(`...`)했으며, 정확한 전체 구현은 해당 파일을 참조하라.*
