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

36 KiB
Raw Blame History

GhiVideo 구현 상세 — 기술과 소스코드 매칭

작성일: 2026-06-30 본 문서는 쉬운설명_GhiVideo_기술이야기DefVideo→GhiVideo_업그레이드_상세에서 설명한 기술들을 **실제 소스코드(파일·라인·핵심 코드)**와 1:1로 매칭하여, "어떻게 구현했는가"를 코드 근거와 함께 상세히 기술한 문서다. 라인 번호는 작성 시점(2026-06-30) 기준이며 코드 수정 시 다소 달라질 수 있다.


목차

  1. 전체 데이터 흐름
  2. 데이터 로딩 — V2.0 폴더 파싱
  3. 좌표 투영 엔진
  4. 역투영 — 화면을 다시 세계로
  5. 측점·체이니지 계산
  6. 영상 오버레이 렌더링
  7. 라벨 안정화 — 평활과 이상치 거부
  8. POI 겹침 억제·컴팩트 팝업
  9. 위치 보정 — 드래그·DEM·세로화각
  10. 나침반 미니맵
  11. 하단 측점바 — 이동거리축
  12. 60fps 부드러운 커서 — smoothTimeRef
  13. 상태 관리 — geoStore·settingsStore·playerStore
  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

데이터 소스 우선순위 (중요 — 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에서 구조물로 생성된다.)

// 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을 자동 판별한다. 구버전은 파일별로 인코딩을 하드코딩했으나, 신버전은 혼재 환경을 자동 처리한다.

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 헤더에도 안전하도록, 헤더명 검색 실패 시 위치 인덱스로 폴백한다.

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좌표로 폴백한다. 비고 컬럼은 정규식으로 방향전환점을 추출한다.

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). 원본과 보정을 분리해 재적용 가능하게 둔 것이 핵심 설계다.

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가 없으므로, 측점을 측점값 순으로 정렬해 중심선 폴리라인을 만든다.

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

월드좌표(위경도+표고)를 영상 화면의 정규픽셀(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기준 높이를 빼서 "상대 높이"로 만들어요.
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 = 옆으로 몇 미터 (오른쪽 +, 왼쪽 )

이게 왜 필요할까요? 👉 "앞에 있는 먼 다리는 보여주되, 옆으로 멀리 떨어진 건 가리고 싶다" 같은 똑똑한 필터(앞은 멀리, 옆은 가깝게 = 비등방 거리필터)를 만들 수 있어요.

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)에 가깝게 찍혀요.

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 기준으로 맞춰줘요. 이 한 줄 덕분에 라벨이 다리에 정확히 붙어요.

// 지도 높이(정표고) + 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 (149251행)

드래그 편집·세로화각 보정 기능의 수학적 기반. 화면 픽셀을 다시 월드좌표로 되돌리는 3개 함수.

함수 위치 입력→출력 용도
worldFromPixel 149183 픽셀 + 슬랜트거리 → lat/lon/z POI 드래그(수평+수직 동시)
solveZForPixelY 194211 화면 세로위치 → 표고 z "앞으로 밀려 보임" 보정
groundPointFromPixel 218251 픽셀 + 지면고도 → lat/lon 지면 좌표 보정(z 유지)

핵심 아이디어: 투영의 회전행렬 b2w를 전치(w2c)해 카메라 방향벡터를 월드 ENU 방향으로 되돌린 뒤, 거리(또는 평면 교차)로 스케일한다.

// 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 };
// 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 (신규)

드론 GPS를 "선로 위 측점값(km)"과 "선로 수직이격(m)"으로 환산한다. StationBar와 오버레이 HUD가 공유.

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

6.1 RAF 60fps 루프 (9051198행)

timeupdate(~250ms, 부정확) 대신 requestAnimationFrame 루프에서 매 프레임 Canvas를 다시 그린다. CLAUDE.md의 "오버레이 성능" 규칙을 정확히 따른다.

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로 정확히 변환한다. 이게 있어야 라벨이 영상 위 실제 지점에 정렬된다.

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 (4861행)

떨림/튐을 잡는 핵심 알고리즘. One Euro 필터 방식의 속도 적응형 EMA + 이상치 거부.

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 → aminAlpha로 작아져 강하게 평활. 실제 이동은 방향이 일관 → 평활속도 큼 → amaxAlpha로 커져 지연 없이 추종. 비정상 점프(시크 등)는 8프레임까지 무시하다 수용.


8. POI 겹침 억제·컴팩트 팝업

파일: client/src/components/overlay/StationOverlay.tsx

8.1 겹침 억제 (3536, 770780행)

화면상 가로 10%·세로 3.5% 이내 마커는 겹침으로 보고, 같은 좌표의 형제 구조물(상/하)이면 진행방향 변형을 우선 남긴다.

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필드(시설종별→구조형식→연장→폭→용도→준공연도)를 컴팩트로 보이고, 클릭하면 전체로 확장한다. 다중 팝업 누적 가능.

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

9.1 드래그 편집 (12361254, 1348행)

편집 모드에서 POI를 끌면 groundPointFromPixel로 화면점을 지면과 교차시켜 lat/lon을 역산, geoStore에 보정으로 저장.

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로 외부 직접호출이 막혀 서버가 중계.

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는 불변).

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 + StationOverlay RAF(954971행)

SVG 카드 전체를 transform: rotate(var(--rot))로 회전시키고, RAF에서 매 프레임 --rotyaw로 갱신한다. 360° 누적 언랩으로 회전 점프를 막고, 회전엔 메인 포즈(±60fr 강한 평활)가 아닌 가벼운 ±3fr 평활 yaw를 별도 계산해 지연을 줄였다.

// 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

11.1 영상별 FPS 자동 (228231행)

고정 29.97 대신 마지막 프레임 / 재생시간으로 영상마다 산출 → 측점 배지 정확도 향상.

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)으로 출발·도착 방향을 잡는다.

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)'으로 표시.

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 (62117행)

currentTime은 ~250ms 간격으로 갱신돼 커서가 끊긴다. 이를 벽시계 기준 단조 보간으로 메워 60fps로 만든다. 정지 시엔 실제 시간에 앵커, 재생 중엔 media + 경과×배속으로 추정하되 0.3s 이상 벌어지면 재동기화.

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는 이 timeRefReact 리렌더 없이 직접 읽어 CSS 변수만 갱신한다(StationBar.tsx 769786행) → 커서/진행바가 부드럽게 흐른다.

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 (29110행)

basePois(파싱 원본, 불변) + poiOverrides(보정 맵) → applyPoiOverrides 결과가 pois. 보정 추가/삭제 시 항상 원본에서 재계산하므로 누적 오염이 없다.

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 (4690행)

Zustand persistghivideo.settings 키에 저장. merge로 과거 저장본의 누락 키를 기본값 보충(스키마 진화 대비). isGradeVisible을 공유 함수로 두어 StationBar·RoutePanel·오버레이가 동일 규칙을 쓴다.

{ name: 'ghivideo.settings',
  merge: (persisted, current) => ({ ...current, ...p,
    gradeFilter: { ...DEFAULT_GRADE_FILTER, ...(p.gradeFilter ?? {}) } }) }
// isGradeVisible: 미지정/미등록 등급은 표시, 등록 등급은 체크 상태 따름

13.3 playerStore — videoReady 게이트

파일: client/src/store/playerStore.ts (1418행) + useVideoPlayer.ts (4450행)

loadeddata 전엔 오버레이를 그리지 않아(깜빡임 방지), videoWidth/Height로 cover 정렬을 정확화.

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 폴더를 통째로 로드.

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 (1467행)

브라우저 COEP/CSP 제약으로 클라이언트가 외부 DEM API를 직접 못 부르므로 서버가 중계한다. 배치(콤마 구분) 질의, opentopodata SRTM 30m → open-meteo 90m 2단 폴백.

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.tsapp.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 소스코드를 직접 읽어 함수·라인·핵심 코드를 추출해 작성되었다. 코드 인용은 가독성을 위해 일부 축약·생략(...)했으며, 정확한 전체 구현은 해당 파일을 참조하라.