feat: 스테이션바 측점 정확도 개선 + 영상 컨트롤 UI 통합 + 종합 기술문서
스테이션바/측점(체이니지) - 역 재진입 마커 누락 수정: 측점기준 탐지에 좌표근접(pxPassesTo) 합집합 보강(중복 24px 제거) - kmExists 판정: 좌표 반경만으로 잡힌 통과는 측점값 라벨/검색에서 제외(마커 동그라미는 유지) - 측점값 라벨 겹침 숨김(좌측 우선), 동일항목 브래킷 클러스터 - 측점 검색 순환(통과방향 다음→끝에서 처음), 마커 기준 후보 산출, 입력값 유지/blur 삭제 - 연속 체이니지 수직투영 유틸 분리(client/src/utils/chainage.ts), Minimap 컴포넌트 추가 영상 컨트롤 UI - 선형/드론궤적/좌측패널 토글을 재생바로 통합(settingsStore 공유·persist) - 드론 GPS·고도 HUD 상시 표시, 경로표고·투명도 항상 노출, 겹침제외 버튼 숨김 - 재생바 요소 재배치(좌측패널→선형→드론궤적→드론위치정보→시설등급), 활성색 amber 통일 문서/도구 - 종합 기술명세서(인계+특허 기초) md/html/pdf 3종, UI개선 보고서 3종 - md→html→pdf 변환 스크립트(scripts/md2docs.sh) + 스타일(docs/assets/report.css) - 작업 히스토리 다수 추가(docs/history/) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# 동일 항목 묶음 브래킷 표시
|
||||
|
||||
**소요 시간**: 약 15분
|
||||
**Context 사용량**: input ~55k / output ~6k tokens
|
||||
|
||||
## 결론
|
||||
스테이션바 타임라인에서 같은 항목(동일 역명)을 드론이 여러 번 통과할 때, 두 번째 참고 이미지처럼
|
||||
**점선 브래킷(각 통과 수직 드롭 + 하단 수평 연결선)** 으로 묶고 **클러스터 중앙에 라벨 1개** 만
|
||||
표시하도록 변경.
|
||||
|
||||
## 근거 / 변경 내용
|
||||
- 파일: `client/src/stationbar/components/Timeline/Timeline.tsx`
|
||||
- 기존: 다중통과 시 `labelEntries`(마지막 통과 라벨 1개 + 나머지 중앙 라벨 1개) + 통과별 점선 틱(`leaderPxs`)
|
||||
→ 같은 항목인데 라벨이 2개로 쪼개져 표시됨.
|
||||
- 변경: `labelGroups` 로 재구성.
|
||||
- 같은 title 통과들을 px 간격(`GROUP_GAP_PX = 350`)으로 **클러스터 분할** — 가까운 통과는 한 묶음,
|
||||
멀리 떨어진 통과(예: 대전조차장 두 차례 진입)는 별도 묶음 → 각자 브래킷/라벨 1개.
|
||||
- 클러스터당: 각 통과 px에 수직 드롭(top 46, h4) + 양끝을 잇는 수평 점선(top 50) + 중앙(centerPx) 라벨 1개.
|
||||
- 단일 통과는 브래킷 없이 자기 위치 라벨 1개(기존 동작 유지).
|
||||
- 색(passed/neutral) 판정 기준을 통과 px → 클러스터 `centerPx` 로 변경. 미도착(unreached)은 묶음 내
|
||||
하나라도 있으면 neutral 유지.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` 통과 (node 20 환경).
|
||||
|
||||
## 후속 수정 (가로 점선 미표시 → 거리 클러스터링 제거)
|
||||
- 1차 구현은 `GROUP_GAP_PX = 350` 으로 동명 통과를 거리별 클러스터로 분할 → 대전조차장역 두 통과가
|
||||
~1050px 떨어져 각각 단일 묶음이 되어 브래킷(가로 점선)이 안 나옴.
|
||||
- 수정: 거리 클러스터링 제거. **동명(title) 통과는 거리와 무관하게 하나로 묶어** lo~hi 전체에
|
||||
수평 점선 + 각 통과 드롭 + 중앙 라벨 1개를 그림. (사용자 요청: "가로 점선이 보여야해")
|
||||
|
||||
## 다음 단계 참고
|
||||
- 브래킷 수직 위치(드롭 46~50, 수평 50, 라벨행 51)는 트랙 하단(46.5)/labelRow(51) 기준. 라벨 폰트/행간
|
||||
변경 시 함께 점검.
|
||||
- 동명 통과가 매우 멀리 떨어진 경우 중앙 라벨이 빈 트랙 중앙에 놓일 수 있음(가로 점선으로 연결됨). 추후
|
||||
묶음 분할이 다시 필요하면 거리 임계값 방식을 재도입할 것.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 스테이션바 거리축(이동량 기반) 전환
|
||||
|
||||
**소요 시간**: 약 20분
|
||||
**Context 사용량**: input ~90k / output ~9k tokens
|
||||
|
||||
## 결론
|
||||
스테이션바 커서/구간/마커의 가로축을 **시간(프레임) 선형 → 누적 이동거리(거리축)** 로 전환.
|
||||
드론이 호버링(이동 없음)하면 커서가 멈추고, 실제로 이동할 때만 커서가 진행한다. (사용자 요청 A안)
|
||||
|
||||
## 근거 / 변경 내용
|
||||
- 파일: `client/src/stationbar/StationBar.tsx`
|
||||
- 기존: `px = 시작 + (currentTime/duration) × 트랙폭` — 순수 시간 정비례. 호버 중에도 커서 전진(배지 측점값은 정지 → 불일치).
|
||||
- 변경: 시간↔진행도 매핑을 거리축으로 교체. 핵심은 매핑 함수 한 곳에 집중되어 커서·구간색·구조물 마커가 일관되게 따라옴.
|
||||
1. `progRef`: 프레임별 `{time, frac}` 배열. `frac` = 누적 이동거리 비율(0~1, 단조 비감소).
|
||||
2. 진행도 산출(viewedRef 빌드 useEffect): 연속 측점값(chain)을 ±W(8프레임) 이동평균으로 평활(GPS 지터 제거) 후
|
||||
프레임간 `|Δ|` 누적 = 실제 이동량. 전체로 정규화.
|
||||
3. `cumFracAtTime(t)`: 시간→진행비율(이진탐색+선형보간). progRef 미준비 시 시간선형 폴백.
|
||||
4. `timeAtFrac(f)`: 역변환(진행비율→시간) — 바 클릭/측점 seek 용.
|
||||
5. `pxAtTime(t) = 시작 + cumFracAtTime(t) × 트랙폭` — 모든 배치가 공유.
|
||||
6. 라이브 커서 RAF, `seekToTrackX` 도 거리축 함수 사용.
|
||||
|
||||
## 동작
|
||||
- 호버(이동 無): frac 정체 → 커서 정지. 이동: frac 증가 → 좌→우 진행.
|
||||
- 전진/후진 모두 이동량으로 더해져 커서는 단조 진행(방향은 기존 주황/청록 구간색으로 구분).
|
||||
- 배지 측점값과 커서가 동일 데이터(연속 측점값)에서 파생 → 표시·이동 일관.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` 통과 + `client/dist` 빌드 완료. (pm2 ghiVideo 는 정적 서빙 → 재시작 불필요, 브라우저 새로고침)
|
||||
|
||||
## 다음 단계 참고 / 되돌리기
|
||||
- 사용자가 "이상하면 원복" 예정. 되돌리려면 `pxAtTime`/`cumFracAtTime`/`timeAtFrac` 를 기존 시간선형
|
||||
(`(t/duration)`)으로 복귀시키고 `progRef`/평활 블록 제거하면 됨.
|
||||
- 평활 반폭 `W=8`, 호버 판정은 평활로 처리(별도 데드밴드 없음). 호버 중 커서가 미세하게 기어가면 W를 키우거나
|
||||
소량 데드밴드 추가.
|
||||
- 부작용: 호버가 길어도 바에서 폭 0으로 수렴 → 바 클릭으로 호버 내부 시간 미세 seek 불가(영상 타임라인 사용).
|
||||
@@ -0,0 +1,43 @@
|
||||
# 스테이션바 측점거리(체이니지) 비례 축 전환 (B안)
|
||||
|
||||
**소요 시간**: 약 30분
|
||||
**Context 사용량**: input ~150k / output ~14k tokens
|
||||
|
||||
## 결론
|
||||
스테이션바 x축을 직전 A안(누적 이동거리) → **측점값(체이니지) 비례 축**으로 전환.
|
||||
바 가로위치가 실제 측점거리에 비례하고, 커서는 드론의 현재 측점 위치를 표시한다(멈추면 정지, 후진하면 좌로 이동).
|
||||
측점 10m 차이 구조물이 바에서도 가깝게 표시된다. 색: '진행색 + 커서색' 안 선택.
|
||||
|
||||
## 배경
|
||||
- A안(누적 이동거리)은 두 구조물이 측점상 10m여도 그 사이 드론이 앞뒤로 많이 이동하면 누적거리가 커져 바에서 멀게 표시됨 → 사용자 지적.
|
||||
- 요구: 바는 측점거리에 비례, 재생 중 실제 드론 위치를 바에 표시.
|
||||
|
||||
## 변경 내용 (client/src/stationbar/StationBar.tsx)
|
||||
- `chainRef = {time, chain(평활), minC, maxC}` 생성(ready useEffect). chain 은 ±8프레임 이동평균(지터 제거).
|
||||
- 매핑: `pxAtChain(측점) = START + clamp((c-minC)/(maxC-minC)) * timeTrackWidth`,
|
||||
`chainAtTime(t)`(이진탐색+보간), `pxAtTime(t) = pxAtChain(chainAtTime(t))`. 커서·구조물 공유.
|
||||
- 커서: `progressPx = pxAtTime(currentTime)`, RAF 도 `pxAtTime(liveTime)`.
|
||||
- 색(측점축은 방향 리본 표현 불가): `trackGradient`=주황 음영 단일, `trackGradientIdle`=회색 단일,
|
||||
`dividers=[]`. 방향은 `currentReverse`(측점값 0.4s 전 대비 감소=후진)로 커서 색만 구분.
|
||||
- 구조물 중복 제거: `dedupeMarksByTitle`(title 당 1개) — 측점축에선 동명 다중통과가 한 자리에 겹침.
|
||||
→ Timeline 다중통과 묶음 점선(브래킷)은 자연 소멸.
|
||||
- seek: 클릭 px → 목표 측점 → 허용오차 내 프레임 중 현재 시간에 가장 가까운 통과로 onSeek.
|
||||
- `barSegments` 는 렌더 미사용(쿼리 useMemo 는 kmLabels 위해 유지), `cumFracAtTime/timeAtFrac/progRef` 제거.
|
||||
|
||||
## 후속 수정 — 축 범위 '노선 고정' (데이터 무관 절대 측점)
|
||||
- 문제: 축 범위를 드론 커버 구간 [minC,maxC] 로 잡아 영상마다 양끝 측점이 바뀜 → 같은 구조물이 다른
|
||||
위치/측점으로 표시됨(사용자 지적: "어떤 데이터로 재생하느냐에 따라 측점이 바뀌면 안돼").
|
||||
- 수정:
|
||||
1. 축 범위 minC/maxC = stationLine(역 POI, km 오름차순) 첫/끝 측점값 → 노선 고정. 드론 chain 은
|
||||
projectChainage 로 이 범위 안에 들어옴.
|
||||
2. 구조물 px = pxAtChain(구조물 측점값) → 통과 시각이 아닌 '절대 측점'으로 배치(마일리지 라벨과 일치,
|
||||
영상 달라도 동일 위치). dedupeMarksByTitle 로 동명 1개.
|
||||
- 효과: 좌(낮은 측점)→우(높은 측점) 고정 자. 구조물 위치 불변, 커서만 드론 현재 측점을 가리킴.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` 통과 + client 빌드 완료.
|
||||
|
||||
## 알려진 한계 / 다음 단계
|
||||
- 진짜 비례라 측점이 매우 가까운 라벨끼리 겹쳐 보일 수 있음(법동가도교/대전조차장역). 필요시 라벨 충돌회피 후처리.
|
||||
- 방향 전진/후진 색 리본 제거됨(커서 색으로만 방향). endGapPx(미도착 종점역) 우측 밴드/표식은 유지.
|
||||
- 이상 시 A안(누적거리 축) 또는 그 이전 시간선형으로 되돌릴 수 있음. 핵심 매핑은 pxAtChain/chainAtTime/pxAtTime 3개에 집중.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 역사(역) 통과 마커 누락 수정 — 좌표근접 합집합 보강
|
||||
|
||||
**소요 시간**: 약 30분
|
||||
**Context 사용량**: input ~150k / output ~9k tokens
|
||||
|
||||
## 결론
|
||||
대전조차장역처럼 같은 역을 여러 번 지날 때, 일부 통과(예: 162k080 재진입)에 스테이션바 동그라미(역사 원형 마커)가
|
||||
빠지던 문제를 수정. 측점-기준 탐지에 더해 **좌표근접(`pxPassesTo`) 통과를 합집합으로 보강**해 누락된 재진입까지 마커를 찍는다.
|
||||
|
||||
## 원인
|
||||
- 파일: `client/src/stationbar/StationBar.tsx` (`structureMarks`)
|
||||
- 역사(대전조차장역)는 `geoData`에서 좌표(lat/lon)만 있고 측점값(`station`)이 없는 RouteStructure 로 들어온다.
|
||||
- 통과 탐지가 `projectChainage(좌표)` 한 값으로 `pxPassesAtMileage` 만 수행.
|
||||
- 탐지 체인이 `if (!passes.length)` **단락 평가**라, 측점-기준이 통과를 하나라도 찾으면 좌표근접 탐지가 안 돌아감.
|
||||
- 종점/조차장 구역은 측점선(폴리라인) 투영이 불안정 → 한 통과는 투영 측점이 ±`stationTolerance`(기본 20m)
|
||||
안에 들어와 잡히지만, 다른 통과는 20m 밖으로 벗어나 누락. 첫 통과를 이미 찾았으니 좌표근접 보강도 안 돌아 영영 빠짐.
|
||||
|
||||
## 변경 내용
|
||||
- `cat === '역사'` 이고 lat/lon 이 있으면, 측점-기준 탐지 결과에 `pxPassesTo(lat, lon, off)` 통과를 합집합으로 추가.
|
||||
- 같은 통과(거의 동일 px)는 24px 임계로 중복 제거 → 멀리 떨어진 실제 재진입만 추가됨.
|
||||
(TRACK_WIDTH_PX = 1509.5, 24px ≈ 1.6% → 동일 best 프레임 중복만 합쳐지고 떨어진 재진입은 보존)
|
||||
- km 라벨 동작(stationKmVal 우선)은 기존 유지 — 이번 수정은 '마커 누락'만 대상.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` + `vite build` 통과 (node v20.20.2, `~/.nvm/versions/node/v20.20.2/bin`).
|
||||
- 사용자 화면(포트 55000)에서 폴더 재선택 후 162k080 재진입 동그라미 표출 **확인 완료**.
|
||||
|
||||
## ★ 진짜 블로커 — 빌드/서빙 구조 (이번 세션 최대 교훈)
|
||||
- 증상: 소스를 여러 번 고쳐도 화면이 전혀 안 바뀜("여전히 빠져있어" 반복).
|
||||
- 원인: 사용자가 보는 화면은 dev 서버가 아니라 **PM2 앱 `ghiVideo`(포트 55000)** 이고,
|
||||
`server/dist/.../app.js` 가 `express.static(client/dist)` 로 **빌드 결과를 서빙**한다(ecosystem.config.js).
|
||||
→ `client/src` 만 고치면 절대 반영 안 됨. `client/dist` 가 6/26 빌드로 고정돼 있었음.
|
||||
- 형제 디렉토리 혼선도 있었음: `videoplayer`(PM2, 5173), `defVideo`(PM2) 가 함께 떠 있음. 작업 경로는 GhiVideo.
|
||||
- 해결: `npm run build -w client` (node 20) 로 `client/dist` 갱신 → 강력 새로고침(Ctrl+Shift+R) → 폴더 재선택.
|
||||
- 메모리에 구조 기록: `ghivideo-serve-build`.
|
||||
|
||||
## 참고 / 후속
|
||||
- `stationTolerance` 타입 주석(geo.ts:139)은 "기본 40"으로 적혀 있으나 실제 코드 기본값은 `?? 20`. 불일치 정리 권장.
|
||||
- 좌표근접 탐지 임계는 `pxPassesTo` 내부 `Math.max(100, gmin*3)`(StationBar.tsx:499) — 측점 tolerance(20m)와 별개.
|
||||
- 출발점 부근 '조기 통과' 오탐 우려는 역사 마커에 한해 합집합으로 한정 → 일반 측점-기준 동작에는 영향 없음.
|
||||
@@ -0,0 +1,23 @@
|
||||
# 측점값 라벨 겹침 숨김 (먼저 나온 것만 표시)
|
||||
|
||||
**소요 시간**: 약 10분
|
||||
**Context 사용량**: input ~190k / output ~11k tokens
|
||||
|
||||
## 결론
|
||||
스테이션바에서 측점값(예: 157k990) 라벨이 근접 통과로 겹쳐 보일 때, px 오름차순으로 **먼저 나온(좌측) 라벨만
|
||||
표시하고 뒤엣것은 숨김** 처리. 아이콘(동그라미)과 점선 브래킷은 그대로 둔다(측점값 텍스트만 숨김).
|
||||
|
||||
## 변경 내용
|
||||
- 파일: `client/src/stationbar/components/Timeline/Timeline.tsx`
|
||||
- `structs`(px 오름차순) 직후 `showMileage: boolean[]` 산출:
|
||||
- `labelW(v) = v.length * 7.5 + 4` (13px bold + 2px stroke 대략 폭)
|
||||
- 직전 '표시한' 라벨과 중앙 간격이 `(lastW + w)/2` 미만이면 겹침으로 보고 숨김(`showMileage[i]=false`).
|
||||
- 표시한 경우에만 `lastPx/lastW` 갱신 → 연쇄 겹침도 누적 기준으로 정확히 판정.
|
||||
- `mileageRow` 렌더 조건을 `s.km >= 0` → `s.km >= 0 && showMileage[i]` 로 변경.
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드 → 55000 강력 새로고침으로 확인.
|
||||
|
||||
## 참고
|
||||
- 라벨은 trackWrapper transform 하위라 px 공간 비교가 시각 겹침과 동치(균일 스케일).
|
||||
- 폭 추정 상수(7.5/+4)는 폰트/스트로크 변경 시 재조정.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 선형·드론궤적 토글을 재생바 버튼으로 이동
|
||||
|
||||
**소요 시간**: 약 12분
|
||||
**Context 사용량**: input ~210k / output ~13k tokens
|
||||
|
||||
## 결론
|
||||
영상 오버레이의 "선형(중심선)"·"드론 궤적" 표시 토글을 **하단 재생바(VideoPlayer)의 겹침제외 버튼 왼쪽**에
|
||||
버튼 2개로 추가하고, 화면표시 옵션 창의 기존 동일 기능 버튼 2개는 제거. 버튼 폭은 겹침제외와 동일(min-w-[96px]).
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/store/settingsStore.ts`
|
||||
- `showCenterline`(기본 true), `showDronePath`(기본 true) + 각 setter 추가. persist 로 지속.
|
||||
- `client/src/components/overlay/StationOverlay.tsx`
|
||||
- 로컬 `useState` 였던 `showCenterline`/`showDronePath` → 설정 스토어 구독으로 전환(refs/리셋은 그대로).
|
||||
- 화면표시 옵션 창의 선형/드론궤적 토글 버튼 2개 제거. (드론궤적 경로표고·투명도 슬라이더는 유지)
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- 스토어 셀렉터 추가, 겹침제외 버튼 바로 앞(좌측)에 `선형 ON/OFF`·`드론궤적 ON/OFF` 버튼 삽입.
|
||||
- 스타일: 겹침제외와 동일 폭(min-w-[96px]), 색은 기능별(선형=red, 드론궤적=sky).
|
||||
- 노출 조건은 겹침제외와 동일: 영상제어 ON + geoLoaded.
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드 → 55000 강력 새로고침 확인.
|
||||
|
||||
## 참고
|
||||
- 상태를 스토어로 올려 바 버튼과 오버레이 렌더가 단일 소스 공유.
|
||||
- '기본값' 리셋(StationOverlay)도 스토어 setter 로 동작 유지.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 경로표고·투명도 항상 표시 + 겹침제외 버튼 숨김
|
||||
|
||||
**소요 시간**: 약 5분
|
||||
**Context 사용량**: input ~225k / output ~14k tokens
|
||||
|
||||
## 결론
|
||||
화면표시 옵션 창의 "경로 표고"·"투명도" 슬라이더를 드론궤적 ON/OFF와 무관하게 항상 표시하도록 변경.
|
||||
재생바의 "겹침제외 ON/OFF" 버튼은 UI에서 숨김(기능/상태는 보존).
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/components/overlay/StationOverlay.tsx`
|
||||
- 경로표고·투명도 ParamRow 를 감싸던 `{showDronePath && (...)}` 게이트 제거 → 항상 렌더.
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- 겹침제외 토글 버튼 JSX 주석 처리(숨김). `poiOverlapExclude` 상태는 스토어에 그대로 존재(기본 ON),
|
||||
StationOverlay/StationBar/RoutePanel 이 계속 구독. 버튼만 비표시. 다시 보이려면 주석 해제.
|
||||
|
||||
## 검증
|
||||
- `tsc --noEmit` + `vite build` 통과(node 20). client/dist 재빌드(11:11).
|
||||
|
||||
## 참고
|
||||
- 겹침제외 selector(poiOverlapExclude/setPoiOverlapExclude)는 VideoPlayer 에 선언만 남아 미사용 상태지만
|
||||
tsc 통과(noUnusedLocals 미적용). 버튼 재노출 대비 보존.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 좌측 패널 버튼 바 이동 + 측점진단 버튼 삭제(GPS·고도 항상 표시)
|
||||
|
||||
**소요 시간**: 약 8분
|
||||
**Context 사용량**: input ~245k / output ~16k tokens
|
||||
|
||||
## 결론
|
||||
- "좌측 노선 패널 표시" 버튼을 화면표시 옵션 창에서 제거하고, 하단 재생바의 **선형 버튼 왼편**에
|
||||
"좌측 패널 ON/OFF" 버튼으로 이동(동일 폭 min-w-[96px], emerald).
|
||||
- "측점 진단" 토글 버튼 삭제. 드론 GPS·절대고도 HUD 는 **항상 표시**(토글 없음)로 변경.
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/components/overlay/StationOverlay.tsx`
|
||||
- 표시 토글 영역에서 "좌측 노선 패널", "측점 진단" 버튼 2개 제거.
|
||||
- 미사용된 `showRoutePanel`/`setShowRoutePanel`, `showStationDiag`/`setShowStationDiag` 선언 제거.
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- `setShowRoutePanel` 셀렉터 추가, 선형 버튼 앞(좌측)에 "좌측 패널 ON/OFF" 버튼 삽입.
|
||||
- `stationDiag` useMemo 의 `!showStationDiag` 게이트 제거 → 프레임만 있으면 항상 계산.
|
||||
- GPS·고도 HUD 렌더의 `showStationDiag &&` 게이트 제거 → 항상 표시.
|
||||
- 미사용된 `showStationDiag` 셀렉터 제거.
|
||||
|
||||
## 바 버튼 순서 (영상제어 ON + geoLoaded)
|
||||
좌측 패널 → 선형 → 드론궤적 (겹침제외는 이전 요청대로 숨김)
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(11:15).
|
||||
|
||||
## 참고
|
||||
- settingsStore 의 showStationDiag 필드는 남겨둠(다른 잠재 구독 대비). 현재 토글 UI 는 없음.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 재생바 요소 재배치 — 드론궤적+위치정보 인접, 시설등급 우측
|
||||
|
||||
**소요 시간**: 약 3분
|
||||
**Context 사용량**: input ~260k / output ~17k tokens
|
||||
|
||||
## 결론
|
||||
하단 재생바에서 드론 위치정보(GPS·고도) HUD 를 드론궤적 버튼 바로 우측에 붙이고, 시설등급 옵션 패널을
|
||||
그 오른편(맨 뒤)으로 옮겨 순서를 정리.
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- 드론 위치정보 HUD(`{stationDiag && ...}`)를 시설등급 패널 뒤 → **드론궤적 버튼 직후**로 이동.
|
||||
- 시설등급 패널이 HUD 뒤(우측)에 오도록 순서 변경.
|
||||
|
||||
## 바 버튼/요소 순서 (영상제어 ON + geoLoaded)
|
||||
좌측 패널 → 선형 → 드론궤적 → 드론 위치정보(GPS·고도) → 시설등급
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(11:19).
|
||||
@@ -0,0 +1,25 @@
|
||||
# 활성화 상태 색상 amber(겨자/오렌지)로 통일
|
||||
|
||||
**소요 시간**: 약 4분
|
||||
**Context 사용량**: input ~280k / output ~18k tokens
|
||||
|
||||
## 결론
|
||||
재생바·우측 토글의 활성화(ON/선택) 표시 색을 제각각(파랑/초록/빨강/하늘/회색)에서 **amber-400 + 검정 글씨**로
|
||||
통일. 사용자 요청(노란/겨자/오렌지 계열).
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/components/player/VideoPlayer.tsx`
|
||||
- 영상제어 ON: blue → `bg-amber-400 border-amber-300 text-black`
|
||||
- 배속 선택: `bg-blue-600 text-white` → `bg-amber-400 text-black`
|
||||
- 좌측 패널 / 선형 / 드론궤적 ON: emerald/red/sky → amber-400
|
||||
- 시설등급 체크박스: `accent-blue-500` → `accent-amber-400`
|
||||
- `client/src/components/overlay/StationOverlay.tsx`
|
||||
- "화면표시 옵션"(showDisplay) 활성: sky-700 → amber-400
|
||||
- "카메라 파라미터"(showControls) 활성: gray-700 → amber-400
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(11:23).
|
||||
|
||||
## 참고
|
||||
- 비활성(OFF)은 기존 `bg-black/70 border-gray-600 text-gray-300` 유지.
|
||||
- 더 진하게 원하면 amber-500 으로 교체.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 작업 보고서 md/html/pdf 3종 생성 + 변환 스크립트
|
||||
|
||||
**소요 시간**: 약 12분
|
||||
**Context 사용량**: input ~310k / output ~21k tokens
|
||||
|
||||
## 결론
|
||||
오늘 작업 내용을 팀장 보고용 문서로 작성하고, **md(베이스) → html + pdf** 3종 산출물을 생성.
|
||||
재생성용 변환 스크립트와 스타일을 저장소에 추가.
|
||||
|
||||
## 산출물
|
||||
- `docs/보고서_2026-06-29_스테이션바_UI개선.md` (베이스)
|
||||
- `docs/보고서_2026-06-29_스테이션바_UI개선.html` (자체완결, CSS 인라인)
|
||||
- `docs/보고서_2026-06-29_스테이션바_UI개선.pdf` (A4 3p, 한글 임베드, 페이지번호)
|
||||
|
||||
## 추가 파일
|
||||
- `scripts/md2docs.sh` — `md2docs.sh <파일.md>` 로 같은 경로에 html+pdf 생성.
|
||||
- `docs/assets/report.css` — 보고서 스타일(앰버 헤더/표/@page).
|
||||
|
||||
## 변환 환경 (메모: report-doc-toolchain)
|
||||
- pandoc 2.11 `--self-contained` 로 md→html (CSS 인라인 임베드).
|
||||
- weasyprint 61.2 로 html→pdf. **pydyf 는 0.10 으로 핀**(0.11 은 API 변경으로 TypeError).
|
||||
- 한글 폰트: 환경에 CJK 폰트 부재 → Windows 맑은고딕(`/mnt/c/Windows/Fonts/malgun*.ttf`)을
|
||||
`~/.local/share/fonts` 로 복사 후 `fc-cache`. WSL 이라 가능.
|
||||
|
||||
## 검증
|
||||
- PDF 헤더 %PDF-1.7, 3페이지, `/FontFile`(Malgun 글리프 임베드) 확인 → 모든 뷰어에서 한글 정상.
|
||||
- scripts/md2docs.sh 재실행으로 동일 산출물 재현 확인.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 측점 검색 순환 점프 + 입력값 유지/블러삭제
|
||||
|
||||
**소요 시간**: 약 12분
|
||||
**Context 사용량**: input ~360k / output ~24k tokens
|
||||
|
||||
## 결론
|
||||
측점값이 여러 곳(여러 통과)에 존재할 때, 측점 검색을 **통과방향 순환(find-next)** 으로 개선.
|
||||
Enter 반복으로 다음 통과로 이동, 끝에서 처음으로 순환. 입력값은 Enter 후 유지하고 blur 시 삭제.
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/stationbar/StationBar.tsx` — `handleJumpToMileage` 재작성
|
||||
- 입력 측점의 '모든 통과'를 허용오차(stationTolerance, 기본 20m) 내 연속구간=1통과로 묶어 시간 오름차순 수집.
|
||||
- 기준 시각(baseT)에서 통과방향(시간 증가) 다음 통과로 이동, 없으면 passes[0] 으로 순환.
|
||||
- `jumpRef`(km,time)로 연속 검색 판정: 같은 측점이고 커서가 직전 점프 위치 그대로(|Δ|<0.1s)일 때만
|
||||
연속(다음 통과). 재생/클릭으로 커서 이동했거나 다른 측점이면 현재 커서(라이브)에서 새로 시작.
|
||||
- 라이브 시각은 `timeRef?.current ?? currentTime`. 커버 범위 밖(±20m) 입력은 무시(기존 유지).
|
||||
- 허용오차 내 통과가 없으면 전역 최근접 1개로 폴백.
|
||||
- `client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx`
|
||||
- Enter 시 `setQuery('')` 제거 → 입력값 유지(같은 값 재Enter 로 순환).
|
||||
- input `onBlur` → 입력값 삭제(다른 곳 클릭 시 언셀렉트).
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(12:12).
|
||||
|
||||
## 참고
|
||||
- 순환 순서는 '시간(이동거리축 진행) 오름차순' = 화면상 좌→우.
|
||||
- 향후 입력창 옆 'n/m' 위치 표시·매칭 하이라이트를 추가하면 인지성 더 향상.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 측점 검색을 '화면 마커' 기준으로 전환 (누락 통과까지 검색)
|
||||
|
||||
**소요 시간**: 약 7분
|
||||
**Context 사용량**: input ~390k / output ~26k tokens
|
||||
|
||||
## 결론
|
||||
측점 검색 점프가 일부 통과로 가지 못하던 문제 해결. 검색 후보를 chain 기준 → **화면에 보이는
|
||||
마커(structureMarks) 기준**으로 바꿔, 바에 표시된 모든 동그라미(좌표근접 탐지 통과 포함)를 순환하도록 함.
|
||||
|
||||
## 원인
|
||||
- 마커(structureMarks)는 측점기준 + 좌표근접(pxPassesTo) 합집합으로 통과를 찍음(조차장 등 chain 어긋난 통과 포함).
|
||||
- 그러나 검색(handleJumpToMileage)은 `|arr[i].chain - km| < TOL` 측점기준으로만 통과를 찾음
|
||||
→ chain 이 162080 에서 벗어난 통과(마커는 있음)는 검색이 못 감.
|
||||
|
||||
## 변경 내용 (`client/src/stationbar/StationBar.tsx`)
|
||||
- handleJumpToMileage 후보 산출 1순위:
|
||||
`structureMarks.filter(표시 10m 반올림값 == 입력값).map(px → timeAtFrac 로 시각 역변환)`.
|
||||
- 2순위(마커 없는 임의 측점): 기존 chain 연속구간 탐지 → 없으면 전역 최근접 폴백.
|
||||
- 시간 정렬 + 0.3s 근접 중복 제거 후, 통과방향 순환(기존 jumpRef 로직 유지).
|
||||
- deps 에 structureMarks, timeAtFrac, timeTrackWidth 추가.
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(12:19).
|
||||
|
||||
## 참고
|
||||
- 매칭은 표시 라벨과 동일하게 10m 반올림 일치 → 사용자가 보는 값과 검색 일치.
|
||||
- 종점 미도착으로 px=TRACK_END 로 이동된 마커는 역변환 시 끝 시각이 됨(허용).
|
||||
@@ -0,0 +1,30 @@
|
||||
# 좌표 반경으로만 잡힌 마커의 측점값 라벨/검색 제외
|
||||
|
||||
**소요 시간**: 약 8분
|
||||
**Context 사용량**: input ~420k / output ~28k tokens
|
||||
|
||||
## 결론
|
||||
역 마커가 좌표 반경(pxPassesTo)으로만 잡혀 그 위치에 실제 측점값이 존재하지 않는 경우,
|
||||
**측점값 라벨을 숨기고 검색 대상에서도 제외**. 마커(동그라미)는 유지.
|
||||
|
||||
## 판정 (kmExists)
|
||||
- 각 통과의 실제 측점 = 그 통과 프레임의 연속 chain(p.km, 좌표탐지) / 측점기준 탐지는 targetM(=stationKmVal).
|
||||
- 표시 측점값 = stationKmVal(역 좌표 투영).
|
||||
- `kmExists = stationKmVal==null || |p.km - stationKmVal| <= stationTolerance(기본 20m)`
|
||||
- 측점기준 통과 → 일치 → true
|
||||
- 좌표 반경만으로 잡힌 통과(측점 동떨어짐) → false
|
||||
|
||||
## 변경 내용
|
||||
- `client/src/stationbar/StationBar.tsx`
|
||||
- `StructMark`에 `kmExists?: boolean` 추가.
|
||||
- structureMarks 두 경로(storeStructures / POI 폴백) push 시 kmExists 계산.
|
||||
- handleJumpToMileage 마커 후보 필터에 `m.kmExists !== false` 추가(값 없는 마커로는 검색 점프 안 함).
|
||||
- `client/src/stationbar/components/Timeline/Timeline.tsx`
|
||||
- 측점값 라벨 표시 루프에서 `s.kmExists === false`면 라벨 생략(동그라미·이름 라벨은 유지).
|
||||
|
||||
## 검증
|
||||
- `tsc + vite build` 통과(node 20). client/dist 재빌드(12:27).
|
||||
|
||||
## 참고
|
||||
- 측점기준 통과는 km=targetM=stationKmVal 로 동일 → 항상 kmExists=true(정상 측점은 영향 없음).
|
||||
- 마커 동그라미/브래킷/역명 라벨은 그대로. 숨기는 것은 '측점값(10m)' 라벨과 그 값으로의 검색뿐.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 55000 포트 외부(LAN) 접속 활성화
|
||||
|
||||
**소요 시간**: 약 10분
|
||||
**Context 사용량**: input ~450k / output ~30k tokens
|
||||
|
||||
## 결론
|
||||
PM2 ghiVideo(55000) 를 같은 네트워크의 다른 기기에서 접속 가능하게 설정.
|
||||
접속 주소: **http://172.16.40.179:55000/** (Windows 이더넷 LAN IP).
|
||||
|
||||
## 점검 결과
|
||||
- 서버: `0.0.0.0:55000` 바인딩 ✓, CORS `origin:true`(모든 origin 허용) ✓ → 서버측 준비 완료.
|
||||
- 차단 원인: WSL2 기본 NAT 모드 → WSL IP(172.21.50.250)는 LAN 직접 도달 불가.
|
||||
|
||||
## 조치 (Windows 관리자/UAC)
|
||||
- netsh portproxy 추가: `0.0.0.0:55000 → 172.21.50.250:55000`
|
||||
(기존에 8080/3000/5173 등 동일 패턴 사용 중이라 55000만 추가)
|
||||
- 방화벽 인바운드 허용 규칙 "WSL ghiVideo 55000" (TCP 55000) 추가.
|
||||
- 스크립트: `C:\Windows\Temp\wsl_port_55000.cmd` (UAC 로 실행).
|
||||
|
||||
## 검증
|
||||
- portproxy 목록에 55000 등록 확인.
|
||||
- 방화벽 규칙 Enabled/Inbound/Allow 확인.
|
||||
- Windows→`http://localhost:55000/` Invoke-WebRequest → **200**.
|
||||
|
||||
## 주의 / 후속
|
||||
- WSL IP 는 재부팅/`wsl --shutdown` 시 변경 → portproxy(고정 172.21.50.250) 끊김. 재적용 필요.
|
||||
(요청 시 WSL IP 자동감지 재설정 스크립트 작성 가능)
|
||||
- 172.16.x 사내망 한정. 인터넷 접속은 공유기/회사 방화벽 포트포워딩 추가 필요.
|
||||
@@ -0,0 +1,302 @@
|
||||
# 드론 자세 평활/보간 + 영상 오버레이 렌더링 파이프라인
|
||||
|
||||
> 후임자 인계 + 특허 문서용 상세 기술 문서
|
||||
> 대상 소스: [client/src/components/overlay/StationOverlay.tsx](../../client/src/components/overlay/StationOverlay.tsx) (1639줄), 투영 유틸 [client/src/utils/geoProjection.ts](../../client/src/utils/geoProjection.ts)
|
||||
|
||||
---
|
||||
|
||||
## 1. 기술 개요 / 목적
|
||||
|
||||
드론으로 촬영한 철도 선로 주행영상 위에, 측점(스테이션)·선로구조물(교량/터널/구교)·POI·선로 중심선·드론 GPS 궤적·나침반을 **영상 픽셀에 정확히 정합(AR 오버레이)** 시켜 표출하는 렌더링 파이프라인이다.
|
||||
|
||||
입력 데이터는 두 종류로, (a) 프레임별 드론 자세/위치 로그(SRT 추출: `frame, lat, lon, altitude, yaw, pitch, roll`)와 (b) 지리정보(측점·중심선·POI·구조물의 위경도+표고)이다. 핵심 변환은 핀홀 카메라 모델 기반의 월드(EPSG:5186 TM ENU) → 카메라 → 정규화 픽셀 투영([geoProjection.ts#L301](../../client/src/utils/geoProjection.ts#L301), [#L126](../../client/src/utils/geoProjection.ts#L126))이다.
|
||||
|
||||
이 문서가 다루는 본질적 과제는 **두 가지 노이즈/지연 문제**이다.
|
||||
|
||||
1. **자세/GPS 노이즈** — 원시 SRT 로그는 떨림이 있어 그대로 투영하면 라벨이 매 프레임 진동한다. 그러나 단순 평균은 회전 구간을 뭉개 라벨이 늦게 따라온다(지연). → **에지 보존 적응형 평활(smoothFrame)** 로 해결.
|
||||
2. **렌더 부드러움 vs 계산 비용** — 라벨 가시성/겹침 판정은 비싸고(전 프레임 × 전 항목), 60fps 매끄러운 이동은 매 프레임 재투영을 요구한다. → **가시성은 사전계산(precompute), 좌표는 RAF에서 연속포즈로 재투영**하는 2단 분리로 해결.
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 알고리즘
|
||||
|
||||
### 2.1 에지(회전경계) 보존 적응형 이동평균 — `smoothFrame`
|
||||
|
||||
[StationOverlay.tsx#L577](../../client/src/components/overlay/StationOverlay.tsx#L577)
|
||||
|
||||
원시 드론 프레임 배열에서 인덱스 `i`를 중심으로 좌우 최대 `halfWin`(기본 60프레임 ≈ 2초)까지 이동평균을 취하되, **중심 프레임의 yaw와 차이가 임계 `YAW_EDGE = 8°` 이내인 인접 프레임까지만 창을 확장**한다.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:577
|
||||
const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => {
|
||||
let lo = i, hi = i;
|
||||
if (frames.length > 1 && halfWin > 0) {
|
||||
const YAW_EDGE = 8; // deg — 중심 대비 이 이상 벌어지면 다른(회전) 구간으로 보고 평균 제외
|
||||
const yc = frames[i].yaw;
|
||||
const near = (idx: number) => Math.abs(((frames[idx].yaw - yc + 540) % 360) - 180) <= YAW_EDGE;
|
||||
const loMin = Math.max(0, i - halfWin);
|
||||
const hiMax = Math.min(frames.length - 1, i + halfWin);
|
||||
while (lo > loMin && near(lo - 1)) lo--; // 회전경계에서 멈춤
|
||||
while (hi < hiMax && near(hi + 1)) hi++;
|
||||
}
|
||||
```
|
||||
|
||||
**수식.** 창 `[lo, hi]`(요소 수 `n = hi − lo + 1`)에 대해 lat/lon/altitude/pitch/roll은 산술평균. yaw는 각도이므로 단순 산술평균 시 0°/360° 경계에서 깨진다 → **원형평균(circular mean)**:
|
||||
|
||||
```
|
||||
yaw̄ = atan2( (1/n)·Σ sin(yawₖ), (1/n)·Σ cos(yawₖ) )
|
||||
```
|
||||
|
||||
[StationOverlay.tsx#L590](../../client/src/components/overlay/StationOverlay.tsx#L590):
|
||||
|
||||
```typescript
|
||||
const yr = f.yaw * Math.PI / 180;
|
||||
sinYaw += Math.sin(yr); cosYaw += Math.cos(yr);
|
||||
// ...
|
||||
yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI,
|
||||
```
|
||||
|
||||
`near()`의 `((Δ + 540) % 360) − 180`은 yaw 차를 항상 `[−180°, 180°]`로 정규화하는 최단각 차이 패턴이다.
|
||||
|
||||
**의도 / 트레이드오프.**
|
||||
- 직선 주행: yaw가 거의 일정 → 창이 풀폭(±60fr)으로 펴져 GPS/자세 노이즈를 강하게 억제.
|
||||
- 회전 중: 회전경계에서 창이 좁아져(한쪽 또는 양쪽이 중심 근처에서 멈춤) **실제 자세를 즉시 추종(지연 없음)**.
|
||||
- 회전 종료 후: 회전 프레임을 배제한 채 창이 **한 프레임씩** 점진적으로 재확장 → 튐 없음.
|
||||
- 트레이드오프: `YAW_EDGE`가 너무 작으면 완만한 곡선도 회전으로 보아 노이즈 억제가 약해지고, 너무 크면 회전이 평균에 섞여 지연. 8°는 떨림(보통 <2°)과 실제 회전을 가르는 경험값.
|
||||
- (주석에 기록된) 과거 '변화율 기반 창 축소' 방식은 회전 종료 시 창이 한꺼번에 재확장되며 회전 프레임이 다시 섞여 한 번 튀는 부작용이 있어 경계보존 방식으로 교체됨 ([StationOverlay.tsx#L569](../../client/src/components/overlay/StationOverlay.tsx#L569) 주석).
|
||||
|
||||
### 2.2 연속 프레임번호 보간 포즈 — `poseAt`
|
||||
|
||||
[StationOverlay.tsx#L670](../../client/src/components/overlay/StationOverlay.tsx#L670)
|
||||
|
||||
영상의 부드러운 재생시간(60fps 벽시계 보간, §4.1)에서 산출된 **실수(實數) 프레임번호 `estFrame`** 에 대응하는 자세를 만든다. 이산 SRT 프레임만으로는 30fps 격자에 갇혀 라벨이 계단식으로 움직이므로, 양 이웃 프레임의 **평활 포즈(smoothFrame 결과)** 를 선형보간한다.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:670 — 이진탐색으로 estFrame을 끼우는 두 프레임 찾기
|
||||
let lo = 0, hi = arr.length - 1;
|
||||
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; }
|
||||
const i2 = lo, i1 = Math.max(0, lo - 1);
|
||||
const f1 = arr[i1].frame, f2 = arr[i2].frame;
|
||||
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
|
||||
const a = smoothFrame(arr, i1, h), b = smoothFrame(arr, i2, h);
|
||||
const L = (x: number, y: number) => x + (y - x) * frac;
|
||||
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
|
||||
return { ...a, frame: estFrame,
|
||||
lat: L(a.lat, b.lat), lon: L(a.lon, b.lon), altitude: L(a.altitude, b.altitude),
|
||||
pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll), yaw: a.yaw + dy * frac };
|
||||
```
|
||||
|
||||
**수식.** 보간계수 `frac = (estFrame − f1)/(f2 − f1)`. 위치/피치/롤은 `L(x,y) = x + (y−x)·frac`. yaw는 359°→1° 같은 경계에서 긴 쪽으로 도는 것을 막기 위해 **최단각 보간**: `Δ = ((b.yaw − a.yaw + 540) % 360) − 180`, `yaw = a.yaw + Δ·frac`.
|
||||
|
||||
**트레이드오프.** O(log n) 이진탐색으로 호출당 비용이 낮아 매 RAF 프레임 호출 가능. smoothFrame을 매 프레임 두 번 호출하므로(`a`, `b`) 평활창이 매우 클 때는 비용이 늘 수 있으나, 실데이터 규모에서는 무시 가능.
|
||||
|
||||
### 2.3 속도적응 EMA + 이상치 거부 — `smoothStep` (One-Euro 방식)
|
||||
|
||||
[StationOverlay.tsx#L48](../../client/src/components/overlay/StationOverlay.tsx#L48)
|
||||
|
||||
`poseAt`로 재투영한 화면 정규좌표를 매 프레임 그대로 쓰면 잔여 떨림이 보인다. 화면단에서 One-Euro 필터 사상(idea)을 적용: **느릴 때(떨림)는 강하게 평활, 빠를 때(실제 팬)는 즉시 추종**.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:48
|
||||
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;
|
||||
const 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 };
|
||||
}
|
||||
```
|
||||
|
||||
**수식.**
|
||||
- 이상치 거부: 프레임당 점프 `d = ‖(dx,dy)‖`가 `REJECT_DIST = 0.12`(정규화 화면거리)를 넘으면 갱신을 버리고 이전 위치 유지. 단 연속 거부가 `MAX_REJECT_FRAMES = 8`을 넘으면(=시크/재등장 같은 진짜 큰 이동) 수용 → 영구 고착 방지.
|
||||
- 속도 평활: `v ← v + (Δ − v)·β`, `β = SMOOTH_VEL_BETA = 0.25`. 떨림은 매 프레임 방향이 번갈아(왕복) `v`가 상쇄돼 ≈0, 실제 이동은 방향이 일관돼 `v` 유지(저크 제거).
|
||||
- 적응 alpha: `α = min(maxAlpha, minAlpha + (maxAlpha − minAlpha)·min(1, speed/speedRef))`.
|
||||
- 정지/떨림(`speed≈0`) → `α≈minAlpha`(기본 0.12, 강한 평활).
|
||||
- 빠른 팬(`speed ≥ speedRef`, 기본 `speedRef=0.010`) → `α=maxAlpha`(기본 `emaAlpha=1.0`, 즉시 추종).
|
||||
- 위치 갱신: `pos ← pos + Δ·α`.
|
||||
|
||||
**의도 / 트레이드오프.** 입력이 이미 smoothFrame(±60fr) + poseAt 보간으로 한 차례 평활됐으므로 화면 EMA의 `maxAlpha`는 가볍게 둔다(주석: 과거 고정 α=0.01은 지연 ~1.65s로 라벨이 늦게 따라옴). `minAlpha`↓ / `speedRef`↑ 일수록 1배속 떨림은 줄지만 미세 추종 지연이 늘어난다. 이 세 값은 패널에서 데이터셋별로 실시간 튜닝·저장된다([StationOverlay.tsx#L494](../../client/src/components/overlay/StationOverlay.tsx#L494)).
|
||||
|
||||
추가로 **픽셀 히스테리시스**가 EMA 위에 한 겹 더 적용된다: 화면 px 위치가 0.75px 이상 움직일 때만 정수 좌표를 갱신해 저속에서 반올림 깜빡임을 제거한다([StationOverlay.tsx#L1048](../../client/src/components/overlay/StationOverlay.tsx#L1048), [#L1084](../../client/src/components/overlay/StationOverlay.tsx#L1084)).
|
||||
|
||||
---
|
||||
|
||||
## 3. 라벨 사전계산 + 겹침 억제
|
||||
|
||||
### 3.1 `startLabelPrecompute` — requestIdleCallback 청크 처리, 월드좌표 저장
|
||||
|
||||
[StationOverlay.tsx#L692](../../client/src/components/overlay/StationOverlay.tsx#L692)
|
||||
|
||||
데이터 로드 완료 시(또는 params/평활/범위 변경 시 500ms debounce, [#L824](../../client/src/components/overlay/StationOverlay.tsx#L824)) 전 프레임에 대해 **어떤 라벨이 보이는가(가시집합)와 겹침 해소 결과**를 미리 계산해 `Map<frameNum, LabelCache>`에 적재한다. 핵심 설계는 **화면좌표가 아니라 월드좌표(lat/lon/z)를 저장**한다는 점이다([LabelCache 정의 #L123](../../client/src/components/overlay/StationOverlay.tsx#L123)). 가시성/겹침은 한 번만 결정하고, 실제 화면 위치는 RAF가 매 프레임 연속포즈로 재투영한다.
|
||||
|
||||
**청크 처리.** 메인스레드 블로킹을 피하려 `requestIdleCallback`으로 한 번에 `CHUNK = 200`프레임씩 처리한다.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:717
|
||||
const step = () => {
|
||||
if (precomputeIdRef.current !== id) return; // 취소됨 (새 계산이 시작되면 id 불일치 → 중단)
|
||||
const end = Math.min(processed + CHUNK, total);
|
||||
while (processed < end) {
|
||||
const idx = (startIdx + processed) % total; // startIdx→끝→0→… 순환
|
||||
processed++;
|
||||
const drone = smoothFrame(frames, idx, SMOOTH_HALF);
|
||||
// ... 측점/POI 가시판정 + 겹침억제 → newMap.set(drone.frame, {...})
|
||||
}
|
||||
if (!published) { labelMapRef.current = newMap; published = true; } // 첫 청크 후 즉시 노출
|
||||
if (processed < total) requestIdleCallback(step, { timeout: 200 });
|
||||
};
|
||||
step(); // 첫 청크는 동기 실행 → 정지 중에도 토글/재계산 즉시 반영
|
||||
```
|
||||
|
||||
세 가지 영리한 설계가 있다.
|
||||
- **현재 프레임부터 순환 처리** (`startIdx = currentFrameIdxRef.current`, `idx = (startIdx + processed) % total`): 지금 화면에 보이는 구간을 먼저 계산해 재생 즉시 라벨이 뜬다.
|
||||
- **재계산 시 옛 맵 복사로 시작** ([#L696](../../client/src/components/overlay/StationOverlay.tsx#L696)): 빈 구간 없이 새 청크가 덮어쓰며 갱신.
|
||||
- **취소 토큰** `precomputeIdRef`: 새 계산이 시작되면 `id`가 증가해 진행 중이던 step이 자동 중단(중복 작업 방지).
|
||||
|
||||
가시판정은 카메라 클리핑(`Zc < CLIP_Z = 0.1` 제외 = 카메라 뒤/너무 가까움), 화면 밖(`pxRaw/pyRaw`가 [−0.02, 1.02] 밖) 제외, POI는 수평직선거리 `distH > MAX_RANGE` 제외로 이뤄진다([#L731](../../client/src/components/overlay/StationOverlay.tsx#L731), [#L748](../../client/src/components/overlay/StationOverlay.tsx#L748)).
|
||||
|
||||
### 3.2 겹침 억제 / 우선순위 / 진행방향 변형
|
||||
|
||||
[StationOverlay.tsx#L755](../../client/src/components/overlay/StationOverlay.tsx#L755)
|
||||
|
||||
화면상 가까이 겹치는 라벨을 솎아낸다. 임계: 가로 `POI_MERGE_X = 0.10`, 세로 `POI_MERGE_Y = 0.035`(정규화). 비등방(가로 관대, 세로 엄격)은 텍스트 라벨이 가로로 길기 때문.
|
||||
|
||||
**우선순위 정렬**(겹침 시 누가 남는가): ① 철도역/역사(`railRank=0`)를 최우선, ② 같은 등급 내에서는 드론에 가까운 것(`dist` 작은 것)을 우선.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:757
|
||||
const railRank = (cat: string): number => (cat === '철도역' || cat === '역사' ? 0 : 1);
|
||||
poiCand.sort((a, b) => railRank(a.category) - railRank(b.category) || a.dist - b.dist);
|
||||
const dirTag = routeDirRef.current === '상' ? '(상)' : routeDirRef.current === '하' ? '(하)' : '';
|
||||
```
|
||||
|
||||
**진행방향 변형 우선**: 같은 구조물이 상/하행 형제로 두 개 있을 때(예: `회덕제1가도교(상)` / `회덕제1가도교(하)`), 드론이 실제 지나는 선로 방향(routeMeta의 direction, [#L447](../../client/src/components/overlay/StationOverlay.tsx#L447))의 변형을 남긴다. `baseStruct()`로 괄호 변형을 떼 형제임을 판정하고, 이미 수용된 라벨이 비-방향태그인데 새 후보가 방향태그면 교체한다([#L777](../../client/src/components/overlay/StationOverlay.tsx#L777)):
|
||||
|
||||
```typescript
|
||||
const k = accepted[overlapIdx];
|
||||
if (dirTag && baseStruct(c.title) === baseStruct(k.title) && c.title.includes(dirTag) && !k.title.includes(dirTag)) {
|
||||
accepted[overlapIdx] = c;
|
||||
}
|
||||
```
|
||||
|
||||
겹침제외 토글 OFF면 억제 없이 전부 표시하며, 동일좌표 라벨은 `labelRow / labelRowCount`로 세로 행 배치된다([#L784](../../client/src/components/overlay/StationOverlay.tsx#L784))(렌더 시 `ROW_H = 24px`로 중앙정렬, [#L1101](../../client/src/components/overlay/StationOverlay.tsx#L1101)).
|
||||
|
||||
---
|
||||
|
||||
## 4. RAF 렌더 루프 (계산 없이 조회 + 투영만)
|
||||
|
||||
[StationOverlay.tsx#L906](../../client/src/components/overlay/StationOverlay.tsx#L906)
|
||||
|
||||
매 프레임 `requestAnimationFrame`에서 캔버스를 다시 그린다. **무거운 계산(가시성/겹침)은 하지 않고**, precompute가 채운 가시집합을 조회 + 연속포즈로 재투영 + 그리기만 한다.
|
||||
|
||||
### 4.1 연속 시간 → 보간 포즈
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:941
|
||||
const estTime = timeRef
|
||||
? timeRef.current
|
||||
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000;
|
||||
const estFrame = estTime * VIDEO_FPS; // VIDEO_FPS = 30000/1001 ≈ 29.97
|
||||
const dronePose = poseAt(estFrame) ?? (...폴백 smoothFrame...);
|
||||
const lines = buildLines(dronePose);
|
||||
```
|
||||
|
||||
`timeRef`는 VideoPlayer의 `smoothTimeRef`로, 매 RAF마다 단조(monotonic) 벽시계 보간으로 갱신되는 재생시간이다([VideoPlayer.tsx#L84](../../client/src/components/player/VideoPlayer.tsx#L84)). 일시정지·시크·배속을 모두 보정하므로 라벨/선/궤적이 영상과 동기된 채 60fps로 매끄럽게 흐른다.
|
||||
|
||||
### 4.2 object-fit:cover 정규좌표 → 화면 변환 (`coverRef`)
|
||||
|
||||
영상은 컨테이너를 비율유지·크롭하며 채우므로(CSS `object-fit:cover`), 오버레이도 같은 cover 사각형에 매핑해야 정합된다.
|
||||
|
||||
```typescript
|
||||
// StationOverlay.tsx:923
|
||||
const { w: vW, h: vH } = videoSizeRef.current;
|
||||
let dispW = W, dispH = H, offX = 0, offY = 0;
|
||||
if (vW > 0 && vH > 0) {
|
||||
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: number) => offX + nx * dispW; // 정규(0~1, 영상프레임) → 화면 px
|
||||
const vy = (ny: number) => offY + ny * dispH;
|
||||
```
|
||||
|
||||
`coverRef`는 RAF가 매 프레임 갱신하고, 포인터 핸들러(클릭→정규좌표 역변환 `clientToVideoNorm`, [#L1211](../../client/src/components/overlay/StationOverlay.tsx#L1211))와 자동팝업 동기화도 공유한다.
|
||||
|
||||
### 4.3 중심선 / 드론궤적 투영 (`buildLines`)
|
||||
|
||||
[StationOverlay.tsx#L606](../../client/src/components/overlay/StationOverlay.tsx#L606). 보간포즈를 받아 매 프레임 직접 투영한다(캐시 미사용 → 라인이 라벨처럼 부드럽게 이동).
|
||||
- **근거리 클리핑**: 세그먼트 양 끝의 `Zc`가 `CLIP_Z=0.1` 경계를 가로지르면 교점 `t = (CLIP_Z − z1)/(z2 − z1)`로 잘라 투영([#L622](../../client/src/components/overlay/StationOverlay.tsx#L622)) → 카메라 뒤 점이 화면 반대편으로 튀는 것 방지.
|
||||
- **화면밖 컬링**: 비트마스크 `oc()` outcode로 양 끝이 같은 변 밖이면 세그먼트 폐기([#L612](../../client/src/components/overlay/StationOverlay.tsx#L612)).
|
||||
- 드론궤적: GPS 경로(lat/lon)를 표고 `Z`에 투영, `PW=15` 폭 이동평균으로 평활, `STEP=3` 샘플, `MAX_FWD=200m` 앞까지만([#L646](../../client/src/components/overlay/StationOverlay.tsx#L646)).
|
||||
|
||||
라벨 자체도 캐시의 월드좌표를 `dronePose`로 재투영 → `smoothStep` → 히스테리시스 후 그린다([#L1039](../../client/src/components/overlay/StationOverlay.tsx#L1039) 측점, [#L1071](../../client/src/components/overlay/StationOverlay.tsx#L1071) POI).
|
||||
|
||||
### 4.4 나침반 미니맵 (heading-up)
|
||||
|
||||
[StationOverlay.tsx#L954](../../client/src/components/overlay/StationOverlay.tsx#L954). 본 포즈는 ±60fr 평활이라 회전 시 나침반이 지연된다 → **별도로 ±3fr 가벼운 평활 yaw**를 보간해 회전에 즉시 반응. `--rot` CSS 변수로 DOM 회전을 갱신하며, **360° 누적 언랩**으로 359°→0° 점프를 방지한다:
|
||||
|
||||
```typescript
|
||||
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`);
|
||||
```
|
||||
|
||||
### 4.5 자동 컴팩트 팝업 동기화 (setInterval 150ms)
|
||||
|
||||
[StationOverlay.tsx#L376](../../client/src/components/overlay/StationOverlay.tsx#L376). 가시 구조물(`visStructRef`, RAF가 매 프레임 채움)마다 라벨 옆에 선명한 DOM 팝업(시설종별/구조형식/연장 등 컴팩트 3필드)을 자동 생성/제거한다. **60fps RAF에서 setState를 남발하면 리렌더 폭주**하므로, 가시집합 변화 감지를 **150ms 인터벌**로 분리해 추가/제거가 있을 때만 `setInfoPopups`를 호출한다. 팝업의 실제 화면 위치는 다시 RAF가 라벨 표시좌표를 따라 매 프레임 갱신(EMA `PA=0.18`, 화면하단 근접 시 위로 플립, [#L1146](../../client/src/components/overlay/StationOverlay.tsx#L1146)).
|
||||
|
||||
---
|
||||
|
||||
## 5. 성능 설계 — 60fps 유지 전략
|
||||
|
||||
| 작업 | 비용 | 배치 | 빈도 |
|
||||
|------|------|------|------|
|
||||
| 가시성/겹침 판정 (전 프레임 × 전 항목) | 높음 | precompute | 1회(+설정변경 시 debounce 재계산), idle 청크 200fr |
|
||||
| 좌표 투영 (보간포즈 → 화면) | 중간 | RAF | 매 프레임 |
|
||||
| 평활/EMA/히스테리시스 | 낮음 | RAF | 매 프레임 |
|
||||
| 팝업 가시집합 setState | 리렌더 유발 | setInterval 150ms | 변화 시에만 |
|
||||
|
||||
**핵심 = precompute(가시성)와 RAF(좌표) 분리.** "어떤 라벨을 그릴지"(비싼 결정)는 영상 변화와 무관하게 한 번 계산해 두고, "어디에 그릴지"(가벼운 투영)만 매 프레임 한다. 이로써 RAF 루프는 Map 조회 + 행렬 투영 + 캔버스 드로잉만 남아 60fps를 유지한다([파일 상단 주석 #L1](../../client/src/components/overlay/StationOverlay.tsx#L1)).
|
||||
|
||||
부가 전략: 사전계산의 idle 청크 처리(메인스레드 비블로킹) + 취소토큰, 현재 프레임 우선 순환계산, 첫 청크 즉시노출, 픽셀 히스테리시스(불필요한 재드로 억제), 팝업 위치 EMA(물결/잔상 방지), 데이터셋별 보정값 localStorage 영속화([#L490](../../client/src/components/overlay/StationOverlay.tsx#L490)).
|
||||
|
||||
---
|
||||
|
||||
## 6. 한계 / 업그레이드 포인트
|
||||
|
||||
- **단일 캐시 프레임키 의존 잔존**: 라벨 가시집합은 `Math.floor(estFrame)`로 정수 프레임의 캐시를 조회([#L1022](../../client/src/components/overlay/StationOverlay.tsx#L1022)). 좌표는 연속이지만 가시집합 자체는 프레임 경계에서 바뀐다 → 빠른 배속에서 라벨 등장/퇴장이 미세하게 튈 수 있음.
|
||||
- **중심선 per-frame 투영 비용**: `buildLines`가 전 중심선 점을 매 프레임 투영. 점 수가 많아지면 부담 → 화면 근방 구간만 투영하는 공간색인(앞서 컬링 전 prefilter)으로 개선 여지.
|
||||
- **POI 표고 부정확**: POI는 실측 표고가 없어 최근접 선로표고 또는 '드론고도 − N'으로 가정([#L744](../../client/src/components/overlay/StationOverlay.tsx#L744)) → 드래그/DEM override로 수동보정. DEM 자동조회 통합이 업그레이드 포인트.
|
||||
- **VFR/프레임 누락**: `VIDEO_FPS` 상수(29.97) 가정. 가변프레임율 영상은 frame↔time 매핑 오차 발생.
|
||||
- **평활 파라미터 수동 튜닝**: YAW_EDGE/REJECT_DIST/speedRef 등이 상수 또는 수동슬라이더 → 데이터셋 통계 기반 자동 캘리브레이션 가능.
|
||||
- **단일 스레드**: precompute가 메인스레드 idle 의존. Web Worker로 옮기면 대용량에서 더 안정.
|
||||
|
||||
---
|
||||
|
||||
## 7. 특허 관점 신규성
|
||||
|
||||
1. **에지(회전경계) 보존 적응형 이동평균** — yaw 차 임계로 평균창을 회전경계에서 멈추고, 회전 종료 후 한 프레임씩 점진 재확장하여 *직선구간 노이즈 억제*와 *회전구간 무지연 추종*을 동시에 달성. 회전 프레임 재혼입에 의한 1회 튐을 구조적으로 제거(변화율 기반 방식 대비).
|
||||
|
||||
2. **월드좌표 사전계산 + 연속포즈 재투영의 2단 분리** — 라벨 가시성/겹침/우선순위(비싼 결정)는 월드좌표로 1회 사전계산하고, 화면 위치(가벼운 투영)는 매 RAF 연속 보간포즈로 재계산. "무엇을 그릴지"와 "어디에 그릴지"의 분리로 60fps AR 오버레이를 단일스레드에서 달성.
|
||||
|
||||
3. **연속 프레임번호 보간 포즈 + 최단각 yaw** — 단조 벽시계 보간 재생시간을 실수 프레임번호로 변환, 이산 SRT 자세를 평활-후-보간하여 이산격자(30fps)를 넘는 60fps 연속 자세를 생성. yaw는 원형평균(평활)·최단각(보간)으로 360° 경계 안정.
|
||||
|
||||
4. **속도적응 화면 평활(One-Euro 사상) + 연속거부 한계** — 속도벡터 평활로 떨림(왕복상쇄)과 실이동(방향일관)을 분리해 alpha를 적응시키고, 이상치 거부에 *연속거부 한계*를 두어 시크/재등장 시 영구고착을 방지.
|
||||
|
||||
5. **진행방향 인지 형제 라벨 해소** — 상/하행 형제 구조물이 화면상 겹칠 때 노선 진행방향((상)/(하)) 변형을 우선 표시. 철도역 최우선 + 근접거리 차선의 다단 우선순위와 결합.
|
||||
|
||||
6. **object-fit:cover 정합 + 양방향 좌표 변환** — 영상 크롭 사각형에 오버레이를 일치시키는 정규좌표 변환과 그 역변환(클릭→월드 역투영)으로, 영상 위 라벨 클릭/드래그 보정(focal 역산·표고 역산·지면교차)을 동일 좌표계에서 처리.
|
||||
|
||||
---
|
||||
|
||||
**소요 시간**: 18분
|
||||
**Context 사용량**: input 88k / output 12k tokens
|
||||
@@ -0,0 +1,32 @@
|
||||
# GhiVideo 종합 기술 명세서 작성 (인계+특허 기초)
|
||||
|
||||
**소요 시간**: 약 35분
|
||||
**Context 사용량**: input ~600k / output ~36k tokens
|
||||
|
||||
## 결론
|
||||
사용된 전 기술을 소스 인용과 함께 1개 종합 문서로 정리. 후임 인계 + 특허 출원 기초자료 겸용.
|
||||
md/html/pdf 3종 생성(PDF 18p, 한글 임베드).
|
||||
|
||||
## 산출물
|
||||
- `docs/기술명세_GhiVideo_종합기술문서.md` (베이스)
|
||||
- `docs/기술명세_GhiVideo_종합기술문서.html` (자체완결)
|
||||
- `docs/기술명세_GhiVideo_종합기술문서.pdf` (A4 18p, Malgun 임베드, 페이지번호)
|
||||
|
||||
## 방법
|
||||
- 5개 서브시스템을 병렬 서브에이전트로 정밀 조사 후 종합:
|
||||
1) 카메라 투영·FOV 정합(geoProjection.ts) 2) 드론 자세 평활·오버레이 렌더(StationOverlay.tsx)
|
||||
3) 측점·스테이션바(chainage.ts/StationBar/Timeline) 4) 백엔드·데이터·상태(server/geoData/store)
|
||||
5) 기존 특허/발명 문서 + history 타임라인
|
||||
- 투영 파이프라인은 정본 1개(§2)로 통합, 지오이드 N=25.8m 통일, 특허/인계 섹션 통합.
|
||||
|
||||
## 문서 구성 (13장)
|
||||
개요·아키텍처 / 좌표·투영 정본 / 카메라·FOV / 측점표고정합(발명B) / 자세평활·렌더 /
|
||||
측점·스테이션바 / 위치보정 역투영(발명C) / 백엔드·데이터 / 개발타임라인 / 특허종합 /
|
||||
검증결과표 / 후임자 인계 / 한계·출원전 확인.
|
||||
|
||||
## 검증
|
||||
- scripts/md2docs.sh 로 html+pdf 생성, %PDF-1.7 18페이지 /FontFile(Malgun) 임베드 확인.
|
||||
|
||||
## 참고
|
||||
- 특허 출원 시 §3~§7·§10 + history 원문(특히 2026-06-22 POI투영오차)을 변리사에 함께 제공 권장.
|
||||
- KIPRIS 전문검색은 미수행 — 출원 전 필수.
|
||||
Reference in New Issue
Block a user