feat: 지도 나침반(OSM/위성) 추가 + 측점 검색·마커 정확도 개선 + POI/영상 수정

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 18:03:30 +09:00
co-authored by Claude Opus 4.8
parent e9052143d7
commit e0ff5dd6d0
58 changed files with 9635 additions and 378 deletions
@@ -0,0 +1,35 @@
# 드론 흔들림 잡기 + 글자 붙이기 — 초등학생용 쉬운 설명 작성
## 작업 내용
기존 기술문서 [2026-06-29_1436_드론자세평활-오버레이렌더링-기술문서화.md](2026-06-29_1436_드론자세평활-오버레이렌더링-기술문서화.md)의 내용이 실제 코드에 적용되어 있는지 검증한 뒤, 그 기술을 **초등학생 눈높이 + SVG 그림**으로 쉽게 풀어쓴 문서를 신규 작성.
### 1. 기술문서 ↔ 실제 코드 적용 여부 검증
- [StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx)(1639줄)에서 문서가 인용한 함수/상수 위치를 grep으로 대조
- `smoothFrame`(L577), `poseAt`(L670), `smoothStep`(L48), `startLabelPrecompute`(L692), `buildLines`(L606), `coverRef`(L257), `VIDEO_FPS=30000/1001`(L25), `YAW_EDGE=8`(L580), `REJECT_DIST=0.12`, `MAX_REJECT_FRAMES=8`, `SMOOTH_VEL_BETA=0.25` 모두 실제 존재·일치 확인
- 결론: 기술문서는 가상이 아니라 실제 구현 코드를 정확히 기술한 것
### 2. 초등학생용 쉬운설명 문서 작성
- 신규 파일: [docs/쉬운설명_드론흔들림잡기와_글자붙이기.md](../쉬운설명_드론흔들림잡기와_글자붙이기.md)
- 기존 `쉬운설명_GhiVideo_기술이야기.md`의 SVG figure 스타일을 따름
- 8개 SVG 삽화 포함, 비유 중심 서술 (도시락/만화 사이그림/거북이토끼 등)
- 다룬 핵심 기술 4+1가지:
1. 에지보존 적응형 평활(smoothFrame) → "곧을 땐 많이, 돌 땐 멈춰서"
2. 연속프레임 보간(poseAt) → "30장을 60장처럼 사이 채우기"
3. 속도적응 EMA + 이상치거부(smoothStep) → "느리면 살살, 빠르면 빨리"
4. precompute + RAF 2단 분리 → "미리 정하기 vs 그때그때 그리기 (도시락 비유)"
5. 겹침 우선순위 + 상/하행 형제 라벨 + 나침반(보너스)
### 3. HTML/PDF 변환
- `scripts/md2docs.sh`로 변환 완료
- 산출물: `쉬운설명_드론흔들림잡기와_글자붙이기.{html,pdf}` (PDF 118KB)
## 산출물
- `docs/쉬운설명_드론흔들림잡기와_글자붙이기.md` (22KB)
- `docs/쉬운설명_드론흔들림잡기와_글자붙이기.html` (28KB)
- `docs/쉬운설명_드론흔들림잡기와_글자붙이기.pdf` (118KB)
---
**소요 시간**: 12분
**Context 사용량**: input 95k / output 11k tokens
@@ -0,0 +1,39 @@
# 지도 POI → 영상 매핑 기술 — 초등학생용 쉬운 설명 작성
## 작업 내용
"지도상의 POI 좌표를 영상에 매핑한 기술들"을 실제 코드에서 모두 조사·나열하고, 초등학생 눈높이 + SVG 그림으로 설명하는 문서를 신규 작성.
### 1. 실제 구현 기술 조사
- [geoProjection.ts](../../client/src/utils/geoProjection.ts) 전체 정독 + [StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx)의 POI 처리부 분석
- 매핑 파이프라인을 구성하는 12개 기술 식별:
1. 좌표계 변환 (proj4, EPSG:4326→5186 한국 TM)
2. ENU 월드 3D 좌표 (East-North-Up)
3. 지오이드 높이 보정 (정표고↔타원체고, 대전≈25.8m)
4. 자세 회전 행렬 (yaw/pitch/roll → R_b2w/R_w2c)
5. 상대 위치 벡터 (대상−드론, offX/Y/Z)
6. 핀홀 카메라 원근투영 (Xc/Zc·f/sensor)
7. 화각(초점거리 24mm·센서 36×20.25)
8. 카메라 뒤 클리핑 (Zc<CLIP_Z)
9. POI 표고 추정 (최근접 중심선 nearestCL / 드론−24m)
10. 거리 필터 (수평거리 distH<MAX_RANGE 1000m, 앞/옆 fwd/side)
11. 역투영 보정 (worldFromPixel/solveZForPixelY/groundPointFromPixel + focal 역산)
12. object-fit:cover 정규좌표→화면 변환
### 2. 쉬운설명 문서 작성
- 신규 파일: [docs/쉬운설명_지도POI를_영상에_붙이는기술.md](../쉬운설명_지도POI를_영상에_붙이는기술.md)
- 기존 쉬운설명 문서 스타일 계승, SVG 삽화 14개 + 비유 중심 서술
- 기술 한눈에 보기 표 → 각 기술별 그림 상세 → 전체 요약 → 특허포인트 → 용어사전
### 3. HTML/PDF 변환
- `scripts/md2docs.sh`로 변환 완료 (PDF 137KB)
## 산출물
- `docs/쉬운설명_지도POI를_영상에_붙이는기술.md` (33KB)
- `docs/쉬운설명_지도POI를_영상에_붙이는기술.html` (41KB)
- `docs/쉬운설명_지도POI를_영상에_붙이는기술.pdf` (137KB)
---
**소요 시간**: 15분
**Context 사용량**: input 130k / output 18k tokens
@@ -0,0 +1,20 @@
# DefVideo → GhiVideo 업그레이드 문서화
**소요 시간**: 약 20분
**Context 사용량**: input ~70k / output ~9k tokens
## 작업 내용
- `b23042/DefVideo`(구버전)와 `b23042/GhiVideo`(신버전) 소스 트리를 `diff -r`로 전체 비교
- 두 저장소는 git 히스토리 분리(공통 커밋 없음) → 워킹 트리 직접 비교로 차이 분석
- 변경 규모 정량화(파일별 +/− 라인 수, 신규 파일 줄수)
- 주요 변경 파일 3그룹을 병렬 Explore 에이전트로 상세 분석
- StationOverlay/Minimap/RoutePanel
- geoData/geoProjection/chainage/geo타입/geoStore
- StationBar/Timeline/VideoPlayer/settingsStore/elevation
## 결론
- 서버·공유타입은 거의 동일(elevation API 라우트만 신규), 클라이언트는 약 +3,000줄 순증으로 대폭 발전
- 핵심 3축: ① V2.0 building/ 폴더 CSV 형식 대응 ② 투영 정확도 개선(지오이드/DEM/역투영) ③ 영상 오버레이·편집 UI 고도화
## 산출물
- `docs/DefVideo→GhiVideo_업그레이드_상세.md` (상세 업그레이드 문서)
@@ -0,0 +1,42 @@
# KMZ 필수화 — building POI/구조물 CSV 폴백 제거 + 누락 경고
## 배경 / 결정
- 정책: **KMZ는 항상 필수**. 없으면 "데이터 누락"으로 보고 재구축해서 KMZ를 전달받음.
- 기존 코드는 KMZ 없으면 **조용히 building CSV로 폴백** → 불완전 데이터가 에러 없이 통과(정책과 상충).
- 사용자 선택: **"경고만 표시"** (앱은 계속 동작, CSV POI/구조물 폴백 제거, 측점은 유지).
## 변경 내용
### 1. geoData.ts — 죽은 파서 제거 + KMZ 단일 소스화
- 삭제: `parsePois`, `parseStructures`, `parseHistoricStations`, `parseAccessDoors` + 전용 헬퍼 `rowProps`, `cleanHeader`
- `loadFolderGeoData`:
- `Promise.all`에서 위 4개 파서 호출 제거(헛수고 파싱 제거)
- `useKmz` 분기 → `kmzMissing` 판정으로 교체: `!kmz || (pois==0 && structures==0)`
- KMZ 누락 시 `console.warn`, 정상 시 `console.log`
- `pois = kmz?.pois ?? []`, `baseStructures = kmz?.structures ?? []`
- `structures`에서 `historicStations` concat 제거 → **역사 중복 위험 제거**(역사는 KMZ KAKAO_RAIL에서만 생성)
- 반환값에 `kmzMissing` 추가
- 유지: `parseStations`(측점), `parseDroneFrames`, `parseKmz`, `mergeStructures`, `parseRouteMeta`, `parsePoiOverrides`, `buildCenterlineFromStations` 및 공용 헬퍼(`makeFieldIndexer`/`cell`/`readCsv`/`findBuildingFile`/`mmssToSeconds` 등)
### 2. 타입/스토어 (kmzMissing 전파)
- [types/geo.ts](../../client/src/types/geo.ts): `FolderGeoData.kmzMissing: boolean` 추가
- [store/geoStore.ts](../../client/src/store/geoStore.ts): `GeoStore.kmzMissing` + `EMPTY` 기본값 + `loadFromFolder`에서 set
### 3. UI 경고
- [VideoPlayer.tsx](../../client/src/components/player/VideoPlayer.tsx) `handleSelectFolder`: 로드 후 `kmzMissing && (stations||frames 존재)``alert`로 "KMZ 누락 → 재구축 필요(측점·드론은 정상)" 안내
### 4. 문서 갱신
- [docs/구현상세_GhiVideo_기술-소스코드매칭.md](../구현상세_GhiVideo_기술-소스코드매칭.md): 데이터 흐름도 + 소스 우선순위 콜아웃 + 파서 표를 KMZ 필수 정책으로 갱신(폴백 제거 명시)
## 검증
- `tsc --noEmit` (node v20.20.2): **통과** (시스템 기본 node v12라 nvm node20 사용)
- `npm run build` (tsc && vite build): **성공** (219 modules, 빌드 1.49s)
## 비고
- 측점.csv(루트)는 KMZ에 없어 그대로 필수 — 빠지면 스테이션바/체이니지/중심선 동작 불가.
- 빌드 산출물 반영됨 → 55000 PM2 서빙 시 재시작/재배포 필요할 수 있음.
---
**소요 시간**: 25분
**Context 사용량**: input 175k / output 22k tokens
@@ -0,0 +1,22 @@
# 구현 상세 — 기술·소스코드 매칭 문서화
**소요 시간**: 약 15분
**Context 사용량**: input ~120k / output ~16k tokens
## 작업 내용
- 앞서 설명한 기술들을 실제 소스코드(파일·라인·핵심 코드)와 1:1 매칭한 구현 상세 문서 작성
- 영역별 3개 Explore 에이전트로 정확한 함수 위치/라인번호/코드 스니펫 추출
- 오버레이/미니맵/RoutePanel
- geoData/geoProjection/chainage/타입/geoStore
- StationBar/Timeline/VideoPlayer/settingsStore/elevation
- 추출 결과를 14개 섹션 + 빠른 색인표로 종합
## 산출물
- `docs/구현상세_GhiVideo_기술-소스코드매칭.md`
- 전체 데이터 흐름도 → 파싱 → 투영/역투영 → 체이니지 → 오버레이 렌더 → 라벨평활 → 겹침/팝업 → 위치보정(드래그/DEM/세로화각) → 미니맵 → 측점바/이동거리축 → smoothTimeRef → 상태관리 → 서버 고도 API
- 기능↔소스 빠른 색인표(파일:라인) 포함
- 각 기능마다 핵심 코드 스니펫 + 동작 원리 설명
## 참고
- 라인번호는 2026-06-30 기준이며 코드 수정 시 달라질 수 있음
- 기존 문서 `DefVideo→GhiVideo_업그레이드_상세.md`, `쉬운설명_GhiVideo_기술이야기.md`와 상호 참조
@@ -0,0 +1,20 @@
# 발표 문서 작성 — 좌표 정합과 측점 기반 구현
**소요 시간**: 약 10분
**Context 사용량**: input ~150k / output ~20k tokens
## 작업 내용
- 개발 내용을 발표용 슬라이드형 마크다운으로 작성
- 주제: 드론 GPS·영상·POI 좌표를 영상 위 정확한 위치에 배치하는 기술 + 프레임→측점 기반 전환
- 기존 소스 분석(geoProjection/chainage/StationBar/StationOverlay/VideoPlayer) 결과를 재활용해 그림·도식 중심으로 구성
## 산출물
- `docs/발표_GhiVideo_좌표정합과측점기반구현.md`
- 9개 슬라이드 섹션 + 부록 소스 색인
- 핵심 ①공간(4단계 투영+지오이드) ②시간(smoothTimeRef) ③안정(One Euro) ④프레임→측점 전환
- ASCII 도식: 투영 파이프라인, 핀홀 카메라, ENU, 측점 투영, 이동거리축(호버), 측점바
- 프레임 vs 측점 비교표, 핵심 소스 색인표
## 참고
- `---` 슬라이드 구분으로 Marp/reveal.js 변환 가능
- 상세 구현 문서(`구현상세_...md`)와 상호 참조
@@ -0,0 +1,37 @@
# 재생 중 데이터 폴더 교체 시 "media could not be loaded" 수정
## 증상
재생 중에 다른 데이터 폴더를 드래그&드롭하면 지오 데이터(측점/POI/스테이션바)는 정상 로드되는데
**영상만** 상단에 "The media could not be loaded …" 에러가 뜨고 검은 화면.
## 원인 — objectURL 조기 해제 race
[useVideoPlayer.loadLocalFile](../../client/src/hooks/useVideoPlayer.ts#L72)이 blob URL 해제를 `player.one('emptied', …)` 이벤트에 의존.
소스 교체 시:
1. 첫 로드 → `.one('emptied', revoke url1)` 등록
2. 드롭 → `createObjectURL`**url2** 생성 → `player.src(url2)` (이때 `'emptied'`**비동기 예약**) → 직후 `.one('emptied', revoke url2)` 등록
3. 예약된 `'emptied'` 발화 → url1·url2 핸들러가 **동시 실행** → 방금 만든 **url2까지 revoke** → 소스 무효 → 로드 실패
첫 로드는 이전 'emptied'가 없어 정상, **교체 시에만** 재현.
## 수정 — ref 기반 결정적 해제
- `objectUrlRef`로 현재 blob URL 추적, `'emptied'` 핸들러 제거
- `loadLocalFile`: 새 `src` 설정 후 **이전 URL만** revoke (새 URL은 보존)
- `loadServerStream`: 로컬→서버 전환 시 이전 blob URL revoke
- 언마운트(dispose) cleanup: 남은 blob URL revoke
- 부가: 교체 직전 재생 중이었으면(=드롭 교체) 새 영상도 `player.play()`로 이어서 재생
(drop은 사용자 제스처라 자동재생 정책 통과. 최초 로드는 `prevUrl` 없음 → 기존처럼 자동재생 안 함)
## 파일
- [client/src/hooks/useVideoPlayer.ts](../../client/src/hooks/useVideoPlayer.ts)
## 검증
- `tsc --noEmit`: 통과 / `npm run build`: 성공 (node v20.20.2)
- 재현/회귀: 재생 중 폴더 드롭 → 새 영상 로드+재생, 콘솔 에러 없음(코드 경로상). 실브라우저 확인은 하드 새로고침 후 폴더 재드롭 필요.
## 반영 방법
- client만 변경 → **하드 새로고침(Ctrl+Shift+R)** 후 폴더 드롭으로 확인. PM2 재시작 불필요.
---
**소요 시간**: 12분
**Context 사용량**: input 198k / output 26k tokens
@@ -0,0 +1,24 @@
# 발표자료 SVG 도식화 전면 개편
## 작업 내용
기존 [발표_GhiVideo_좌표정합과측점기반구현.md](../발표_GhiVideo_좌표정합과측점기반구현.md)가 ASCII 아트 도식이라 가독성이 낮아, **인라인 SVG 그림 16개로 전면 교체**하고 색상 체계·표로 도식화해 이해도를 높임.
## 변경점
- ASCII 다이어그램 → **SVG figure 16개** (기존 쉬운설명 문서들의 `figure.fig` 스타일 계승)
- 색상 의미 통일: 🟦공간(투영, 파랑) · 🟩시간(초록) · 🟧안정(주황) · 🟦측점(청록) · 드론(보라)
- 주요 그림: 목표 화면 목업, 입력3종 결합, 전체 파이프라인(레인), 4단계 투영, ENU·회전·핀홀·지오이드, 시간보간(sparse↔dense), One Euro 3케이스, 시간축 한계, GPS→측점 투영, 이동거리축(호버), 측점바 목업, 역투영 보정, 최종 정리도
- 내용/코드근거/소스색인 표는 유지(정확도 보존), 슬라이드 `---` 구분 유지(Marp 호환)
- `<style>` 블록 추가(figure 스타일 + lead 콜아웃)
## 산출물
- `docs/발표_GhiVideo_좌표정합과측점기반구현.md` (41KB)
- `.html` (59KB) / `.pdf` (181KB) — md2docs.sh 변환
## 비고
- weasyprint 경고 `user-select: none`(31행)은 무시됨(렌더 영향 없음).
- 원본 ASCII 버전은 덮어씀(내용은 보존+개선).
---
**소요 시간**: 18분
**Context 사용량**: input 215k / output 32k tokens
@@ -0,0 +1,103 @@
# POI 라벨 사라진 뒤 팝업 재등장 수정
**소요 시간**: 약 10분
**Context 사용량**: input ~80k / output ~3k tokens
## 문제
POI 라벨이 화면 밖으로 사라졌는데, 방금 전 위치에 POI 팝업이 다시 뜨는 문제.
## 원인
[client/src/components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx) 의
마커 그리기 루프(1114행)가 라벨이 화면(비디오 크롭) 밖으로 나가도 `cc.Zc >= CLIP_Z` 이기만 하면
매 프레임 `visStructRef` 에 계속 등록.
- 팝업 제거 로직(RAF): 라벨이 off-container 상태 4프레임이면 팝업 제거.
- 자동 sync 인터벌(150ms): `vis.has(title)` 이 아직 true → 팝업을 즉시 다시 추가.
→ 둘이 싸워 팝업이 마지막 위치에 되살아남.
## 수정
`visStructRef.set` 을 팝업 off 판정과 **동일한 화면 범위**(`px/py ∈ [-16, W+16]/[-16, H+16]`)로 제한.
`vx(nx)=offX+nx*dispW` 는 팝업 off-check 의 `sx=offX+disp.x*dispW` 와 같은 좌표계라 일치.
→ 라벨이 화면 밖이면 vis 에서도 빠져 sync 가 되살리지 않음(제거 로직과 일관).
## 추가 수정 (잔여 1프레임 번쩍임)
`visStructRef` 가드만으론 sync 인터벌이 팝업 DOM 을 **새로 mount** 하는 순간의 번쩍임이 남음:
새 팝업 요소는 React 초기 스타일(`top: sy+27`)로 **보이는 상태**로 붙고, 다음 RAF 가
'화면 밖→숨김' 판정을 내리기 전 한 프레임 stale 위치에 번쩍임.
→ 팝업 ref 콜백에서 `popupPosRef`(RAF 위치 확정 시 기록)에 아직 없는 '신규' 팝업은
`visibility:hidden` 으로 mount. RAF 가 온스크린 확인 후에만 visible 로 전환(off 면 계속 숨김→제거).
visibility 는 RAF 명령형으로만 관리(style prop 미포함)라 재렌더에도 유지됨.
## 추가 수정 2 (hidden-mount 가드 무력화 — 진짜 원인)
hidden-mount 가드는 `!popupPosRef.has(id)` 일 때만 숨긴다. 그런데 팝업 제거 경로가 여럿인데
보조 맵(popupPosRef/popupMissRef) 정리는 **RAF off-4프레임 경로만** 했음.
→ sync `toRemoveIds` / 수동(✕·ESC·빈곳클릭) 제거 시 `popupPosRef` 잔존 → 같은 title 재추가 시
`popupPosRef.has=true` → 숨김 안 됨 → React 초기 스타일(`top: sy+27`)로 **보이는 채 mount**
라벨 없이 팝업만 stale 위치에 나타남(+ RAF 가 stale 위치에서 EMA 로 미끄러짐).
수정:
1. `infoPopups` 변경 effect 에서 사라진 id 의 보조 맵 일괄 정리(단일 출처).
2. sync `toRemoveIds` 처리 시 즉시 `popupPosRef`/`popupMissRef` 정리(재추가 전에 확실히 비움).
## 추가 수정 3 (definitive — 라벨 가시성과 완전 결합)
증상이 계속 동일해 근본 재분석: 팝업 off 판정이 라벨 **십자마커 중심** ±16px 슬랙 + 화면 안 **clamp**
조합이라, 마커가 경계 근처(예: 오른쪽 끝 밖 ~16px)면 라벨 텍스트는 화면 밖(안 보임)인데
팝업만 clamp 되어 화면 안에 뜸 → "라벨 없이 팝업만".
수정: 이번 프레임에 라벨이 **실제로 화면 안에 그려졌는지**(히트박스∩뷰포트)를 `onScreenLabels`
집합으로 만들고, 팝업 표시/visStruct 등록을 이 집합에만 의존하도록 통일.
- `off = !disp || !onScreenLabels.has(id)` (좌표 슬랙·clamp 기반 판정 제거)
- visStruct 등록도 `poiVisible`(히트박스 온스크린) 기준
## 추가 수정 4 (사라졌다 잠깐 재등장 → 완전 제거)
증상: 라벨 사라짐 → 팝업 사라짐 → 잠깐 재등장 → 사라짐. 원인은 sync(150ms 추가) ↔ RAF(제거) 충돌.
- 경계에서 라벨이 1~2프레임 깜빡이면 visStruct 재등록 → sync 가 팝업 재생성 → RAF 가 다시 제거.
수정:
1. 추가 히스테리시스: 라벨이 **연속 ADD_STREAK(6프레임 ~100ms)** 이상 화면 안일 때만 visStruct 등록
(poiOnScreenStreakRef). 짧은 깜빡임으론 팝업 생성 안 됨.
2. 제거 단일화: sync 의 팝업 제거 로직 삭제 → **RAF(onScreenLabels + 4프레임 유예)가 유일한 제거 권한**.
add/remove 충돌 제거 → 라벨 사라지면 팝업도 사라진 뒤 재생성 안 됨.
## 추가 수정 5 (진짜 리그레션 원인 — flip 왕복)
사용자 지적: 이 증상은 '하단 겹침' 수정(추가수정 없음, 첫 위치 수정) 이후 생긴 리그레션.
→ 가시성 문제가 아니라 flip(아래↔위 전환) 로직에 **히스테리시스가 없어서** 발생.
라벨이 하단 flip 임계값 부근이면 드론 흔들림/평활로 `sy` 가 미세하게 오르내릴 때마다
`ty+ph > H-2` 가 참↔거짓 왕복 → 팝업이 아래↔위로 **순간이동(bigJump 스냅)** → "사라졌다 나타났다".
수정: `popupFlipRef`(위/아래 상태) + 히스테리시스(HYST=48px). '위'로 간 뒤엔 아래에 48px 이상
여유가 생겨야만 '아래'로 복귀 → 경계 왕복 제거. flip 상태도 제거 경로에서 정리.
## 추가 수정 6 (하단 이탈 즉시 제거 — 유예 삭제)
요구: 라벨이 하단으로 벗어나면 팝업 즉시 사라지고 재등장 금지.
잔여 원인: 제거에 4프레임 '유예'가 있어, 그 사이 흔들림으로 라벨이 경계로 잠깐 되들어오면
RAF 가 아직 안 지워진 팝업을 다시 visible 로 바꿈 → "사라졌다 잠깐 나타났다".
수정: off(라벨 화면 밖) 판정 시 유예 없이 **즉시 toRemove**(+숨김). 재등장은 sync 의
ADD_STREAK(6프레임 연속 안정) 게이트가 막음 → 짧은 복귀로는 재생성 안 됨.
mid-screen 은 라벨 히트박스가 가장자리에서 멀어 off 가 안 나므로 영향 없음.
## 추가 수정 7 (하단 기준 = 영상 영역(스테이션바 위), 모니터 아님)
요구: 팝업이 스테이션바에 걸치지 않게, 라벨이 바 밑으로 내려가면 팝업 제거. 기준은 영상 재생 영역.
- 스테이션바는 영상 위 z-20 오버레이(absolute bottom-0), 팝업은 z-40 → 바 위에 걸쳐 보였음.
- 유효 하단 `HB = H barHeight` 도입(barHeightRef, ResizeObserver 실측 ~130px).
- `labelOnScreen` 하단 기준 `H-2``HB-2`: 라벨 히트박스가 바 밑이면 off → 즉시 제거.
- 팝업 flip/clamp 하단 기준도 `H``HB`: 팝업이 바 위로만 배치(걸침 방지).
## 추가 수정 8 (flip 제거 + 바 뒤로 슬라이드)
요구: 팝업이 라벨 위로 flip하면 안 됨. 항상 라벨 아래에 있다가 라벨이 바 밑으로 사라질 때 같이 사라짐.
- flip/clamp 둘 다 문제였음(flip=위로 튐, clamp=바에 걸쳐 멈춤).
수정:
1. flip·하단 clamp 제거 → 팝업 `ty = sy + LABEL_HALF + GAP` (항상 라벨 아래, 자유 하강).
2. 팝업들을 클립 컨테이너로 감쌈: `absolute left-0 right-0 top-0 overflow-hidden` + `bottom: barHeight`
(높이 = 영상영역 HB). 팝업이 라벨 따라 내려가면 바 영역에서 클립돼 '바 뒤로 슬라이드'되어 사라짐.
3. 제거는 label off(HB 기준) 그대로 → 라벨이 바 밑이면 팝업 제거.
## 배포 확인
- :55000 서빙 index.html → `assets/index-yfJyCKp1.js` (이번 빌드 해시와 일치)
- ecosystem: ghiVideo, cwd=프로젝트루트, clientDist=client/dist (정적 즉시 반영)
## 검증
- `tsc --noEmit` 통과
- `npm run build` 성공 → :55000 프로덕션 반영 (브라우저 강력 새로고침 필요)
@@ -0,0 +1,23 @@
# POI 팝업 ↔ 라벨 겹침 수정
**소요 시간**: 약 15분
**Context 사용량**: input ~60k / output ~4k tokens
## 문제
POI 팝업이 화면 아래쪽에 다가올수록 라벨(십자마커+글자)과 팝업창이 겹치는 문제.
## 원인
[client/src/components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx) 의 RAF 팝업 위치 계산에서
여백 `GAP=16` 을 라벨 **중심**(`sy`, 십자마커 위치) 기준으로 잡음.
라벨 글자·아이콘이 `sy` 위아래로 ~13px 차지 → 실제 여백은 3px뿐. 하단 위로 플립해도 동일 3px라 계속 겹침.
## 수정
- `LABEL_HALF=15`(라벨 반높이) 도입 → 라벨 **바깥 가장자리** 기준으로 띄움.
- 아래: `ty = sy + LABEL_HALF + GAP`
- 위 플립: `aboveTy = sy - LABEL_HALF - GAP - ph`
- `GAP` 16→12 (실제 여백 3px → 12px)
- 초기 렌더 top `sy+16 → sy+27` 로 맞춰 1프레임 튐 방지.
## 검증
- `tsc --noEmit` 통과
- `npm run build` 성공 → :55000 프로덕션 반영
@@ -0,0 +1,27 @@
# 역 마커 — 실제 측점 일치 기준으로 정정 (162080 누락 수정)
**소요 시간**: 약 15분
**Context 사용량**: input ~230k / output ~4k tokens
## 문제
직전 수정(kmExists 게이트)이 너무 세서, **진짜 162080 위치의 대전조차장 마커까지 사라짐**.
## 원인
kmExists 게이트가 `|p.km stationKmVal|` 비교인데:
- 탐지는 `s.station`(등록 측점 162080) → p.km = 162080
- stationKmVal = `projectChainage(역 좌표)` (좌표 투영) — 등록값과 어긋날 수 있음
→ 둘이 다르면 게이트가 실제 162080 통과까지 제거.
## 수정 ([StationBar.tsx](../../client/src/stationbar/StationBar.tsx))
게이트 방식 폐기, 탐지 소스로 구분:
1. 역사(역)는 **좌표근접(pxPassesTo) 폴백을 배제**`pxPassesAtMileage`(실제 측점 일치)로만 탐지.
- `if (!passes.length && cat !== '역사' && ...) pxPassesTo(...)` (교량/터널만 허용)
2. `if (!kmExists) continue` 게이트 제거(주 경로 + 폴백 경로). kmExists 는 라벨/검색 참고용으로만 유지.
## 효과
- ① 좌표만 가까운(측점 다른) 조차장 재진입(드론 162210인데 162080)은 안 찍힘.
- ② 진짜 162080 통과에는 정상 표시.
- 검색은 이미 chain 기준(별도 수정)이라 마커 변경과 무관하게 정확.
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → 해시 `index-Cdsf6M1x.js`(HUD `· 검색v3`) :55000 서빙.
@@ -0,0 +1,26 @@
# 역(스테이션) 마커 — 직교 투영 측점 일치 시에만 표시
**소요 시간**: 약 15분
**Context 사용량**: input ~180k / output ~4k tokens
## 문제
스테이션바에서 드론이 대전조차장(역 측점 162k080) '좌표에만' 가까우면, 실제 드론 측점이
162k290(210m 밖)이어도 대전조차장역 마커가 찍혔다. → 직교 위치가 안 맞는데 역 표시됨.
## 원인
[StationBar.tsx](../../client/src/stationbar/StationBar.tsx) 구조물 마크 생성:
- 역사 '보강 탐지'가 `pxPassesTo`(좌표 근접) 통과를 합집합으로 추가 → 측점 불일치 통과까지 마커.
- `kmExists`(=드론 측점 p.km 와 역 측점 stationKmVal 차이 ≤ tol)가 false여도 마커(동그라미)는 유지했음.
## 수정
1. 역사 좌표근접 '보강 탐지' 블록 제거 (측점 불일치 재진입을 억지로 추가하던 부분).
2. 마커 push 시 `if (!kmExists) continue;` 게이트 추가(주 경로 + 폴백 경로 both).
→ 역은 **드론의 직교 투영 측점이 역 측점과 stationTolerance(기본 20m) 이내일 때만** 표시.
→ 교량/터널은 stationKmVal=null → kmExists 항상 true → 영향 없음.
## 효과
- 대전조차장에서 드론이 162k290일 때 162k080 역 마커가 더 이상 안 뜸.
- 측점 일치 통과(pxPassesAtMileage)는 그대로 유지 → 정상 통과·재진입은 계속 표시.
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → 새 해시 `index-CJflcoyT.js` :55000 서빙 확인.
@@ -0,0 +1,34 @@
# 영상 fps 데이터 기반 자동 산출 (고정 29.97 → 가변)
**소요 시간**: 약 20분
**Context 사용량**: input ~150k / output ~6k tokens
## 배경
프레임번호↔시간 변환이 `VIDEO_FPS = 30000/1001`(29.97)로 하드코딩 → 30fps 영상 전용.
24/60fps 등 다른 영상은 이름표가 어긋남. 어떤 fps 영상이 로드될지 미리 알 수 없음.
## 문제 데이터
드론 CSV(`frame_cnt,latitude,...`)엔 **시간이 없고 frame_cnt(프레임 번호)만** 있음.
→ fps 를 CSV 단독으로 못 구함. **영상 길이(duration)** 와 결합 필요.
## 해결
`effectiveFps = maxFrameCnt / duration` → 표준 fps(23.976/24/25/29.97/30/50/59.94/60) 중
±10% 이내면 스냅, 아니면 원시값. 데이터/길이 미확보 시 29.97 폴백.
### 변경 파일 (2개, 소규모)
- [VideoPlayer.tsx](../../client/src/components/player/VideoPlayer.tsx)
- `effectiveFps` useMemo 신설(storeFrames+duration). `VIDEO_FPS` 하드코딩 4곳 제거
(frame 계산 / HUD 표시 / 프레임입력 이동 / StationOverlay prop).
- useFrameStep 의 fps(VFC 감지, 31 오감지)는 미사용 → destructure 제거.
- [StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx)
- `fpsRef`(prop fps 기반) 추가. 프레임↔시간 변환 VIDEO_FPS 사용처(최근접 프레임 탐색, estFrame)를
fpsRef.current 로 교체. VIDEO_FPS 상수는 폴백 기본값으로만 유지.
## 주의/전제
- 드론 CSV 가 영상 전체 구간을 덮는다는 전제(마지막 frame_cnt ≈ 영상 끝). 부분만 덮으면 fps 과소 →
스냅 실패 시 원시값 사용. 필요 시 메타데이터/FFprobe 로 보강 가능.
- 표준 목록 밖 fps 는 스냅 없이 원시값 사용.
## 검증
- `tsc --noEmit` 통과
- `npm run build` 성공 → 새 해시 `index-BbUe4Gr4.js` :55000 서빙 확인
@@ -0,0 +1,33 @@
# 종점(대전조차장) 마커 끝-이동 제거 → 실제 직교 위치에 그대로 표시
**소요 시간**: 약 15분
**Context 사용량**: input ~260k / output ~5k tokens
## 사용자 원칙
- POI(영상 오버레이) 정보: 현재 위치 반경 안만 표시.
- **스테이션바 항목: 반경 없이, 측점선과 직교로 만나는 시설물이면 무조건 실제 위치에 표시.**
## 문제
검색이 찾은 162080 자리에 대전조차장 마커가 없음.
원인: `placeUnreachedTerminal` 이 endGapPx>0 일 때 **최우측 역사 마커를 트랙 끝(TRACK_END)** 으로
이동('미도착' 표시) → 드론이 종점 야드에서 162080을 여러 번 지나는데 마지막 통과 마커가 끝으로
옮겨져 검색 위치(실제 162080)와 어긋남.
## 수정 ([StationBar.tsx](../../client/src/stationbar/StationBar.tsx))
`placeUnreachedTerminal`**no-op**(marks 그대로 반환)으로 변경 → 마커 끝-이동 제거.
- 모든 162080 통과 마커가 실제 직교 위치에 유지(사용자 원칙과 일치).
- 종점 이름(endStationName=대전조차장역)은 오른쪽 끝 고정 표시 유지.
- 회색 미도착 gap(endGapPx) 자체는 트랙 색으로만 유지(마커는 안 옮김).
## 관련 상태
- 역사: pxPassesAtMileage(측점 일치)로만 탐지(좌표근접·게이트 없음) → 직교 일치 통과에만, 반경 없음.
- 교량/터널: pxPassesAtMileage 우선 + (측점/좌표 투영 없을 때만) pxPassesTo 폴백. 필요 시 폴백 제거 검토.
## 보정: 미도착 종점 마커는 '추가'로 복원
no-op 로 두니 드론이 못 간 '종점 162080'의 대전조차장 마커까지 사라짐(사용자 지적).
→ placeUnreachedTerminal 을 '이동'이 아니라 '추가'로 변경:
- endGapPx>0 이면 최우측 역사 마커를 **복사**해 TRACK_END 에 unreached 로 push(기존 마커 유지).
- 결과: 지나간 162080 통과는 실제 위치에 그대로 + 못 간 종점 162080은 끝에 미도착 표시.
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → 해시 `index-D66Yy1Dn.js`(HUD `· 검색v5`) :55000 서빙.
@@ -0,0 +1,57 @@
# 지도 기반 나침반(OSM, 노스업) 추가 + 타입 전환
**소요 시간**: 약 30분
**Context 사용량**: input ~340k / output ~9k tokens
## 요구
우측 상단 나침반을 2종 중 선택. 새 '지도 기반 나침반' = 나침반 안에 지도 + 실시간 위치.
사용자 선택: **OSM 온라인 타일 · 노스업(북쪽 고정) · 화면표시 옵션에 전환 버튼.**
## 구현
### 클라이언트
- [store/settingsStore.ts](../../client/src/store/settingsStore.ts): `compassType: 'analog'|'map'` + setter (persist).
- [components/overlay/MapCompass.tsx](../../client/src/components/overlay/MapCompass.tsx) (신규):
- OSM 타일을 Web Mercator 로 배치, 원형 클립. 노스업(지도 고정) + 현재위치 중심 점 + 진행방향 화살표(yaw 회전) + 상단 N.
- `poseRef`(부모 RAF 갱신 {lat,lon,yaw}) 를 내부 RAF 로 읽어: 화살표 즉시 회전 + 지도 팬(transform) + 임계 이동 시 타일 재배치(setState).
- 타일 URL = **서버 프록시** `/api/tile/{z}/{x}/{y}.png` (외부 직접 로드는 CSP/COEP 로 차단됨).
- [components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx):
- `mapPoseRef` 추가, RAF 나침반 블록에서 headingDeg 항상 계산해 기록(아날로그 회전은 렌더 중일 때만).
- 렌더: compassType 에 따라 `<MapCompass>` / `<Minimap>` 선택.
- 화면표시 옵션 '표시 토글'에 나침반 [눈금|지도] 버튼 추가.
### 서버 (프록시 필수)
- CSP `img-src 'self'` + COEP `require-corp` 라 외부 타일 `<img>` 직접 로드 불가(elevation.ts 와 동일 제약).
- [server/src/routes/tile.ts](../../server/src/routes/tile.ts) (신규): `GET /api/tile/:z/:x/:y`
OSM 타일 fetch(식별 User-Agent) 후 같은 출처로 중계. `Cross-Origin-Resource-Policy: same-origin`, 7일 캐시, 좌표 검증(범위 밖 400).
- [server/src/app.ts](../../server/src/app.ts): `app.use('/api/tile', tileRouter)`.
## 배포
- server/client 빌드 + **PM2 restart ghiVideo**(서버 라우트 반영 필수) 완료.
- 검증: `/api/tile/16/x/y.png` → 200 image/png, CORP same-origin, 7일 캐시. 범위 밖 → 400. 클라 `index-BS7bYMJ3.js`.
## 추가: 줌 + 호버 확대 + 휠 줌
- settingsStore `compassZoom`(12~19, 기본16) + setter(persist). MapCompass 에서 project(z 동적) 사용.
- 나침반 하단 /z/+ 버튼(pointer-events auto). 줌 변경 시 현재위치 기준 타일 재배치.
- **마우스 오버 → 2배 확대**(transform scale, 우상단 고정 origin, 140ms), **벗어나면 원복**.
- **휠 줌**: 컨테이너 native wheel 리스너(passive:false) → deltaY 로 compassZoom ±1(위=확대).
- 호버/휠 위해 컨테이너 pointer-events auto(해당 영역만 이벤트 캡처).
## 개선(사용자 피드백)
- 확대: CSS scale(흐릿) → **위젯 지름 자체 확대**(BASE_D 150 → HOVER_D 300, 타일 재배치) → 더 넓은
영역을 선명하게. 마우스 오버 시 확대, **벗어나면 기본 크기+기본 줌(16) 복귀**.
- 방향 표시: 빨간 화살표 → **반투명 역삼각형(▽)** = 드론 시야(중심에서 촬영방향으로 벌어짐, yaw 회전).
- **줌 +/− 버튼·z값 표시 삭제.** 줌은 휠만(오버 중), 벗어나면 기본값.
- 상단 **N**: SVG text 로(위젯 크기에 맞춰 스케일) **크게·붉은색·반투명**.
- settingsStore 의 compassZoom 제거(줌은 MapCompass 로컬 상태로 이동, 세션 임시).
## 추가: 위성지도 옵션
- 타일 프록시를 소스별로 확장: [server/routes/tile.ts](../../server/src/routes/tile.ts) `GET /api/tile/:source/:z/:x/:y`
- source=osm → OpenStreetMap 일반, source=sat → **Esri World Imagery 위성**(z/row/col 순서, jpeg).
- settingsStore `compassMapStyle: 'street'|'sat'`(persist). MapCompass 타일 URL `/api/tile/${src}/...`.
- 화면표시 옵션에 지도 [일반|위성] 토글(지도 타입일 때만 노출).
- server 재빌드 + PM2 restart. 검증: osm→200 png, sat→200 jpeg.
- 주의: Esri World Imagery 는 출처표기 필요(내부 도구, 저부하). 대량이면 라이선스 검토.
## 주의/추후
- OSM 타일 사용 정책: 저부하 유지(줌16 고정 + 클라 96px 이동 시에만 재배치 + 서버 7일 캐시). 대량이면 자체 타일/유료 서비스 검토.
- 인터넷 없으면 타일 미표시(테두리/점/화살표만). 오프라인 필요 시 '노선 개요도(데이터 기반)' 타입 추가 검토.
@@ -0,0 +1,45 @@
# 측점 검색 — 없는 측점 검색 시 엉뚱한 곳으로 튀는 문제 수정
**소요 시간**: 약 10분
**Context 사용량**: input ~200k / output ~3k tokens
## 문제
스테이션바에서 `162080`(162k080) 검색 → 130m 떨어진 `162k210`으로 이동.
## 원인
[StationBar.tsx `handleJumpToMileage`](../../client/src/stationbar/StationBar.tsx#L845) 검색 3단계:
1. 마커(structureMarks) 표시측점 일치
2. 연속 측점(chain)이 허용오차(stationTolerance 20m) 안
3. **폴백: 둘 다 없으면 '무조건 가장 가까운 측점'으로 이동** ← 문제
162080이 이 영상에 (직교 투영으로) 없으니(조차장에서 드론 투영측점=162210) 3번 폴백이
130m 밖 162210으로 점프. '직교 일치 시에만' 원칙과도 불일치.
## 수정
폴백(882~889) 제거 → `if (!times.length) return;`.
TOL(기본 20m) 안에 없으면 그 측점은 (직교로) 존재하지 않으므로 **점프하지 않음**.
프레임이 조밀(30fps)해 실제 지나는 측점은 항상 TOL 안에서 잡히므로 폴백 불필요.
## 효과
- 없는 측점 검색 시 엉뚱한 곳으로 안 튐(아무 동작 안 함).
- 존재하는 측점(마커/연속측점)은 그대로 검색·순환 이동.
## 추가: '측점 없음' 안내 (사용자 선택 = 안내+이동안함)
'이동 안 함'만으로는 커서가 직전 위치(162210)에 남아 "검색이 거기로 간 것처럼" 보이는 혼동 →
검색 실패 시 **안내 메시지** 표시.
- `handleJumpToMileage` 가 boolean 반환(이동 true / 못 찾음 false).
- [PlaybackControls.tsx](../../client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx):
Enter 시 못 찾으면 입력창 옆에 **"측점 없음"**(빨강) 1.8초 표시, 재입력/블러 시 숨김.
## 추가 2: 검색을 '실제 측점(chain)' 기준으로만 (마커 라벨 기준 제거)
증상: 162080 검색 시 여러 162080 위치를 순환하다 '맨 끝에' 162210으로 이동.
원인: 검색 1순위가 structureMarks 를 '표시 측점 라벨(round km)'로 매칭 → 라벨은 162080이지만
실제 위치가 다른(조차장 좌표근접) 마커가 순환에 끼어듦.
수정: 검색 1순위(마커 라벨 매칭) 제거. **드론 실제 투영 측점(arr[].chain)이 입력값과 TOL 이내인
통과로만** 이동(연속구간=1통과, 순환). 없으면 false → '측점 없음'.
- deps 에서 structureMarks/timeAtFrac/timeTrackWidth 제거.
- 진단용 HUD 표시 `· 검색v2`(빌드 확인용, 확인 후 제거 예정).
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → 해시 `index-BrZ52ILs.js` :55000 서빙 확인.
@@ -0,0 +1,25 @@
# '측점 없음' 안내가 안 뜨던 문제 수정 (CSS 레이아웃)
**소요 시간**: 약 10분
**Context 사용량**: input ~300k / output ~2k tokens
## 문제
없는 측점 검색 시 "측점 없음" 안내가 화면에 안 떴다(로직은 정상).
## 원인
재생/입력 패널 `.transportGroup` 은 고정 크기 + 자식 요소가 모두 `position: absolute`.
안내 `<span>` 이 일반 흐름이라 패널 좌상단에 깔려 버튼/패널 뒤에 가려짐.
## 수정
- [PlaybackControls.module.scss](../../client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss)
`.notFound` 클래스 추가: 입력창과 같은 위치·크기로 **절대배치**, `z-index:6`, 어두운 배경 + 빨간 글씨.
- [PlaybackControls.tsx](../../client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx)
안내 span 을 인라인 스타일 → `className={styles.notFound}` (문구 `⚠ 측점 없음`).
- 1.8초 후 자동 숨김 / 재입력 시 숨김(기존 로직 유지).
## 부수 정리
- 진단용 HUD 표시 `· 검색v5` 제거(빌드 확인용이었음).
## 검증
- `tsc --noEmit` 통과, `npm run build` 성공 → :55000 서빙.
- 168000(범위 밖) 검색 시 '⚠ 측점 없음' 정상 표시 확인(사용자).