From 819065a8f54c4ea302646852491656358a04b74d Mon Sep 17 00:00:00 2001 From: b23042 Date: Fri, 19 Jun 2026 14:35:19 +0900 Subject: [PATCH] =?UTF-8?q?UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획안 반영 및 보완 --- .../2026-06-18_DefVideo-폴더구동-UI작업.md | 75 ++++ agent-docs/2026-06-18_이슈별-해결정리.md | 108 +++++ agent-docs/feature-folder-load.md | 358 ++++++++++++++++ .../feature-stationbar-folder-driven.md | 64 +++ client/index.html | 6 + client/src/App.tsx | 11 +- client/src/components/geo/GeoSearch.tsx | 92 ++--- client/src/components/geo/StationVerify.tsx | 90 ++-- .../components/overlay/RouteInfo.module.css | 117 ++++++ .../components/overlay/RouteInfoOverlay.tsx | 94 +++++ client/src/components/overlay/RoutePanel.tsx | 99 +++-- .../src/components/overlay/StationOverlay.tsx | 147 ++++--- client/src/components/player/VideoPlayer.tsx | 243 +++++++++-- client/src/hooks/useVideoPlayer.ts | 5 +- client/src/index.css | 6 + client/src/stationbar/StationBar.tsx | 390 ++++++++++++++---- .../components/Timeline/Timeline.module.scss | 5 +- .../components/Timeline/Timeline.tsx | 184 +++++++-- .../TimelineCursor/TimelineCursor.module.scss | 4 +- .../TimelineCursor/TimelineCursor.tsx | 11 +- client/src/store/geoStore.ts | 69 ++++ client/src/types/dom.d.ts | 25 ++ client/src/types/geo.ts | 124 ++++++ client/src/utils/geoData.ts | 299 ++++++++++++++ client/src/utils/geoSearch.ts | 255 ++++++++++++ ecosystem.config.js | 39 +- server/src/services/ffmpeg.ts | 15 +- 27 files changed, 2474 insertions(+), 461 deletions(-) create mode 100644 agent-docs/2026-06-18_DefVideo-폴더구동-UI작업.md create mode 100644 agent-docs/2026-06-18_이슈별-해결정리.md create mode 100644 agent-docs/feature-folder-load.md create mode 100644 agent-docs/feature-stationbar-folder-driven.md create mode 100644 client/src/components/overlay/RouteInfo.module.css create mode 100644 client/src/components/overlay/RouteInfoOverlay.tsx create mode 100644 client/src/store/geoStore.ts create mode 100644 client/src/types/dom.d.ts create mode 100644 client/src/types/geo.ts create mode 100644 client/src/utils/geoData.ts create mode 100644 client/src/utils/geoSearch.ts diff --git a/agent-docs/2026-06-18_DefVideo-폴더구동-UI작업.md b/agent-docs/2026-06-18_DefVideo-폴더구동-UI작업.md new file mode 100644 index 0000000..720d1d7 --- /dev/null +++ b/agent-docs/2026-06-18_DefVideo-폴더구동-UI작업.md @@ -0,0 +1,75 @@ +# 작업 로그 — DefVideo 폴더-구동 UI & StationBar + +- **날짜**: 2026-06-18 +- **작성 시각**: 11:29 KST +- **작업 타이틀**: 폴더 선택 기반 데이터 구동화 + videoplayer RouteInfo 디자인 이식 + StationBar 이정 표시/레이아웃 정리 +- **대상**: `/home/hanmac/projects/gitea/b23042/DefVideo` +- **토큰 사용량(추정)**: 오케스트레이터 input ~250k / output ~45k · 서브에이전트: Explore(파일흐름) ~40k, Explore(StationBar 감사) ~80k, DEV(폴더구동 구현) 55.5k(측정) · **총 추정 ≈ 470k tokens** + > 정확 집계 도구가 없어 추정치(CLAUDE.md 규칙 준수). + +--- + +## 작업 요약 (변경 파일) + +| 파일 | 변경 | +|------|------| +| `client/src/utils/geoData.ts` | `parseRouteMeta()` 추가 — `.route.json`/`route.json` 파싱 | +| `client/src/types/geo.ts` | `RouteMeta/RouteInfo/RouteStructure` 타입, `FolderGeoData.routeMeta` | +| `client/src/store/geoStore.ts` | `routeMeta` 상태 + 로드/clear | +| `client/src/stationbar/StationBar.tsx` | 서버 fetch→geoStore, 커서 배지 10m(chain), 턴 라벨 10m·기본 100m, 구조물/터미널명 폴더화 | +| `client/src/stationbar/components/Timeline/Timeline.tsx` | 하드코딩 신탄진/대전 제거 → props | +| `client/src/components/overlay/RoutePanel.tsx` | 양끝 라벨 route.json 이름 | +| `client/src/components/overlay/RouteInfoOverlay.tsx` | 좌상단 노선 배너(폴더-구동, videoplayer 디자인 이식, 폭/1920 스케일) | +| `client/src/components/overlay/RouteInfo.module.css` | videoplayer RouteInfo 스타일 이식 | +| `client/public/assets/title-panel-bg@2x.png` | 배너 배경 에셋 복사 | +| `client/src/components/player/VideoPlayer.tsx` | 영상 fill(꽉 채움)+오버레이 앵커, StationBar 폴더로드 후 표시, 도구 패널 UI 숨김, 배너 추가 | +| `client/src/hooks/useVideoPlayer.ts` | Video.js `fluid`→`fill` | +| `client/src/index.css` | `.vjs-fill/.vjs-tech { object-fit: cover }` | +| `client/src/App.tsx` | 좌·우 패널 UI 숨김(코드 보존, SHOW_*_PANEL) | +| `samplevideo/하행)회덕-대전조차장.route.json` | 보조 메타데이터 샘플 생성 | +| `agent-docs/feature-folder-load.md`, `feature-stationbar-folder-driven.md` | 설계/검수 문서 | + +**원칙**: 모든 표시 데이터는 선택한 폴더에서 가져온다(하드코딩 0). CSV에 없는 정보(노선명·방향·시종점 역명·구조물)는 `<영상명>.route.json`으로 보완. mock/dead 코드는 삭제하지 않고 보존. + +--- + +## 질의응답 로그 (이 세션) + +### 환경 셋업 +- **Q. 실행방법 / 에러 있어** → Node 12 → `??` SyntaxError. nvm으로 Node 20 설치, `.nvmrc`, 의존성 재설치. +- **Q. ecosystem 환경설정 역할 / 상대경로화** → env가 config.ts/geo로 주입됨을 설명. 절대경로→`__dirname` 기준 상대경로, ffmpeg는 PATH 명령어로 변경. +- **Q. PM2 errored** → `ffprobe ENOENT`(FFmpeg 미설치) → 설치. 이후 false 경고는 `checkFFmpegInstalled`가 `-version` 출력을 JSON.parse 하던 버그 → exit code 확인으로 수정. +- **Q. 한글 깨짐** → 파일명이 CP949 인코딩. iconv로 UTF-8 일괄 변환. +- **Q. 클라이언트 빌드 실패** → Vite8(rolldown) `manualChunks` 객체→함수. + +### 멀티 에이전트 / 진행 방식 +- **Q. 에이전트 여러 개로 속도 올릴 수 있나 / 3개(개발·문서·검수) 구성** → 서브에이전트 병렬·파이프라인 설명. 순서는 설계문서화→개발→테스트→검수로 합의, 문서·토큰 기록 병행. + +### 폴더 선택 → 데이터 구동 +- **Q. "파일 선택"으로 영상 고르면 같은 폴더+building 데이터도 읽어 화면 세팅** → 브라우저 단일파일 보안 제약 설명, **폴더 선택(webkitdirectory) 클라이언트 처리** 방식 선택. geoData/geoStore로 폴더 파싱 구현. +- **Q. 측점 패널 안 보임** → StationBar가 리팩터링서 누락(서버 fetch 잔존) → geoStore 연결. +- **Q. 폴더 선택 시 업로드되나** → 아니오. webkitdirectory의 브라우저 경고 문구일 뿐, 전송 없음(전부 브라우저 메모리 파싱). +- **Q. "파일 선택" 제거, 가운데 반투명 폴더 선택 버튼** → 적용. +- **Q. 하단 도구 패널 collapse / 이후 UI에서 숨김** → SHOW_TOOLBAR=false(코드 보존). +- **Q. 모든 데이터 폴더에서, 없는 건 route.json** → StationBar 완전 폴더-구동화(Explore 감사 → route.json 도입 → 구현). + +### 좌상단 노선 배너 +- **Q. 좌상단 노선정보 표시(폴더 데이터)** → RouteInfoOverlay(이모지) 1차 → **느낌 다름** → videoplayer `RouteInfo` 디자인(배경이미지+절대좌표) 1:1 이식 → **크기 차이** → 영상폭/1920 비율 스케일 적용. +- 연장=route.json 우선, 없으면 측점 구간 계산 / 소요=route.json 우선, 없으면 영상 길이 / 방향·노선명=route.json. + +### 레이아웃 / 배율 +- **Q. 텍스트 배율 차이 이유** → px(+transform) vs Tailwind rem 차이. 사이니지엔 고정+화면비례 권장. +- **Q. 퍼블리싱처럼 꽉 차게** → Video.js `fill`, object-fit cover, 오버레이 하단 앵커. +- **Q. 좌·우 패널** → 처음 collapse → 이후 그냥 숨김(SHOW_*_PANEL=false, 코드 보존). + +### StationBar 이정 표시 +- **Q. 커서 값 10m 단위 표현** → `chain`(연속 체이니지) + `formatMileage10`로 10m. +- **Q. 드론 정지 시 값 고정이 실제 위치 반영?** → 맞음. 배지=프레임 GPS 투영(값), 커서 위치=시간 진행(px). 정지 시 GPS 불변→값 고정, 시간 흐름→커서 이동. +- **Q1. 바 라벨도 폴더 데이터 파싱한 것?** → **예.** 측점선은 `측점 위경도 CSV`, 라벨/체이니지는 프레임 GPS를 측점 폴리라인에 투영한 값(둘 다 폴더 CSV 파싱). [StationBar.tsx kmLabels] +- **Q2. 기본 100m, 실제 턴 지점은 10m** → bounds에 `turn` 플래그, 턴 라벨=`formatMileage10`(10m), 시·종점=100m. (이번 작업) + +--- + +## 빌드/검증 +- 각 변경마다 `npm run build -w client` 통과 확인. +- 클라이언트 전체 live `/api/geo` fetch 0건(폴더-구동). 하드코딩 노선 데이터 0건. diff --git a/agent-docs/2026-06-18_이슈별-해결정리.md b/agent-docs/2026-06-18_이슈별-해결정리.md new file mode 100644 index 0000000..0068a2e --- /dev/null +++ b/agent-docs/2026-06-18_이슈별-해결정리.md @@ -0,0 +1,108 @@ +# DefVideo — 이슈별 해결 정리 + +- **날짜**: 2026-06-18 +- **작성 시각**: 14:00 KST +- **범위**: 폴더-구동 데이터화, RouteInfo 디자인 이식, StationBar 표시/레이아웃, 재생 커서 매끄러움 +- **공통 원칙**: 모든 표시 데이터는 선택한 폴더에서 읽는다(하드코딩 0). 폴더 CSV에 없는 정보는 `<영상명>.route.json`. mock/dead 코드는 삭제하지 않고 보존. + +--- + +## 1. 환경: Node 12 → 실행 실패 (`Unexpected token '?'`) +- **증상**: `npm run dev` 시 `??`(nullish) SyntaxError. +- **원인**: 시스템 Node 12. 프로젝트는 Node 20+ 필요(Vite/better-sqlite3 등). +- **해결**: nvm으로 Node 20 설치, `.nvmrc`(`20`) 추가, `node_modules` 재설치. + +## 2. 환경: PM2 `errored` / `ffprobe ENOENT` +- **원인①**: FFmpeg 미설치 → 서버가 기동 직후 죽음. +- **원인②(버그)**: `checkFFmpegInstalled`가 `ffprobe -version`의 **텍스트** 출력을 `JSON.parse` → 항상 실패해 "FFmpeg not found" 오탐. +- **해결**: `apt install ffmpeg`. 감지 함수를 JSON 파싱 대신 **exit code 0 확인**으로 수정(server/src/services/ffmpeg.ts). ecosystem.config.js는 `__dirname` 기준 상대경로 + PATH 명령어로 정리. + +## 3. 한글 파일명 깨짐 +- **원인**: 파일명이 CP949(EUC-KR) 인코딩(Windows 한글). 시스템 locale UTF-8이라 `???`. +- **해결**: `iconv`로 파일명 일괄 UTF-8 변환(내용은 멀쩡, 파일명만). + +## 4. 클라이언트 빌드 실패 (`manualChunks is not a function`) +- **원인**: Vite 8(rolldown)은 `manualChunks`를 **함수 형식**만 허용. 기존은 객체. +- **해결**: `client/vite.config.ts`의 `manualChunks` 객체 → 함수로 변경. + +--- + +## 5. 폴더 선택 → 연관 데이터 자동 로드 +- **요구**: "파일 선택"으로 영상 고르면 같은 폴더+`building/`의 측점·POI·center·srt를 함께 읽어 화면 세팅. 어떤 영상이든 동작. +- **제약**: ``로 고른 단일 파일은 브라우저 보안상 형제 파일을 못 읽음. +- **결정/해결**: **폴더 선택(webkitdirectory) + 클라이언트 파싱** 방식 채택. + - `client/src/utils/geoData.ts`: 폴더 파일들에서 드론CSV·측점·POI·center·route.json 파싱(UTF-8/EUC-KR 처리). + - `client/src/store/geoStore.ts`: 파싱 결과(frames/stations/pois/centerline/routeMeta/loaded) 보관. + - 영상은 `URL.createObjectURL`로 재생. **서버 업로드/저장 없음.** + +## 6. 측점 패널(StationBar) 안 보임 +- **원인**: 리팩터링 시 StationBar가 누락되어 여전히 서버 `/api/geo/*`를 직접 fetch → 폴더 데이터를 못 읽고, 서버 없으면 빈 화면. +- **해결**: StationBar를 다른 컴포넌트처럼 **geoStore 구독**으로 변경(서버 fetch 제거). 클라이언트 전체 live `/api/geo` fetch 0건 확인. + +## 7. "폴더 선택 시 업로드되나?" +- **답**: 아니오. webkitdirectory의 **브라우저 경고 문구**일 뿐 전송 없음. 코드에 fetch/POST/FormData 0건, 전부 브라우저 메모리 파싱. + +## 8. UI 정리: "파일 선택" 제거 / 도구·좌우 패널 숨김 +- "파일 선택" 버튼 제거, **가운데 반투명 "폴더 선택"** 버튼 추가(`VideoPlayer.tsx`). +- 하단 도구 패널: `SHOW_TOOLBAR=false`로 **UI 숨김(코드 보존)**. +- 좌·우 패널: `SHOW_LEFT_PANEL`/`SHOW_RIGHT_PANEL=false`로 **UI 숨김(코드 보존)**. + +## 9. StationBar 완전 폴더-구동화 (하드코딩/mock 제거) +- **원인**: 노선명·터미널명(신탄진/대전)·구조물·이정이 mock(`mocks/route.ts` ROUTE_LEGS, Timeline 하드코딩, segments.ts)에서 나옴. +- **해결**: + - `<영상명>.route.json`(routeInfo + structures) 도입 + 샘플 생성. geoStore `routeMeta`. + - 커서 이정 배지: mock `mileageAtPx` 제거 → 폴더 데이터(드론 GPS 투영 체이니지). + - 터미널명: route.json → 폴백 첫/끝 측점 title. Timeline 하드코딩 제거(props). + - 구조물: route.json `structures`(없으면 POI category 폴백). + - RoutePanel 양끝 라벨도 route.json 이름. + +## 10. 좌상단 노선정보 배너 (videoplayer 디자인 이식) +- **요구**: 좌상단에 방향/노선명/연장/소요 배너. +- **과정**: 이모지 버전 → "느낌이 다름" → **videoplayer `RouteInfo` 1:1 이식**(배경이미지 `title-panel-bg@2x.png` + 절대좌표 + Noto Sans KR) → "크기 차이" → **영상 폭/1920 비율 스케일** 적용. +- **데이터**: 연장=route.json 우선→측점 구간 계산, 소요=route.json 우선→실제 영상 길이, 방향·노선명=route.json. (`RouteInfoOverlay.tsx`, `RouteInfo.module.css`) + +## 11. 텍스트 배율 / 화면 채움 +- **배율 질문**: 퍼블리싱(px+transform)은 텍스트 줌 무반응, DefVideo(Tailwind rem)는 반응. 사이니지엔 고정+화면비례 권장. +- **꽉 채움**: Video.js `fluid`→`fill`, `.vjs-fill/.vjs-tech { object-fit: cover }`, 오버레이 하단 앵커 → 검은 여백/하단 잘림 해결(`VideoPlayer.tsx`, `useVideoPlayer.ts`, `index.css`). + +--- + +## 12. 커서 이정 10m 단위 표시 +- **원인**: 배지가 최근접 측점 km(100m 양자화). `formatMileage`도 100m 반올림. +- **해결**: 연속 체이니지(`chain`) + `formatMileage10`(10m). 바 라벨은 **턴 지점만 10m**, 시·종점 등 기본은 100m. + +## 13. 초반 감소구간이 주황(증가)으로 표시 +- **원인**: 구간 방향 계산이 시작 방향을 `dir=1`(증가)로 가정. +- **해결**: 첫 유의미 이동(±100m)으로 **시작 방향을 실데이터 판정** → 감소면 하늘색. + +## 14. 시설물(구조물) 표시 = videoplayer 아이콘 +- **요구**: 텍스트만 → videoplayer 3-slice 아이콘으로, 재생바 위에 겹쳐, 통과 시 색 변경. +- **해결**: + - DefVideo에 이미 있던 `RouteSegment`(3-slice PNG, `public/assets/route-segment/`) 사용. + - 아이콘을 **재생바 위 레이어(z-index)** 에 배치(top≈바 중앙). + - **원본 치수 적용**: 교량 22×16/cap6/top32.5, 터널 24×14/cap10/top33.75. + - 라벨 폰트 **Noto Sans KR** 추가(`index.html`) — 글자 스타일 원본 일치. + - 커서가 아이콘 **좌측 끝에 닿는 순간** `passed`로 전환 → **아이콘+라벨 색 동시 변경**(neutral→accent). + +--- + +## 15. 재생 커서가 끊김/물결/흔들림 (핵심 성능 이슈) +단계적으로 진단·수정: + +1. **끊김(250ms 점프)**: `timeupdate`(250ms)로만 갱신 → rAF 도입. +2. **물결(출렁임)**: rAF 60fps로 `currentTime()`을 읽는데 미디어 시계는 29.97fps → 같은값→점프 반복. `requestVideoFrameCallback` 시도(여전히 프레임 단위 계단). +3. **흔들림 잔존(최종 해결)**: 원인 = **4K HEVC 디코딩 + 60fps React 리렌더 경쟁 + 보간 역방향 보정**. + - **단조(monotonic) 보간**: 벽시계 기반 보간, 작은 역행 무시(흔들림 제거), 뒤처짐/시크만 재동기화(`VideoPlayer.tsx`). + - **커서/진행바를 React에서 분리**: 라이브 시간(`smoothTimeRef`)을 StationBar가 rAF로 읽어 **CSS 변수(`--cursor-x`,`--pos-px`)만 직접 갱신**. React 상태는 배지/색용으로 ~10fps throttle. + - 커서 `left`→**`transform: translateX`**(GPU 합성, reflow 제거) + `will-change`. 진행바 폭은 CSS 변수 `calc()`. + - 결과: 합성 스레드에서 부드럽게 이동, 4K 디코딩 부하와 분리. **해결 확인됨.** +- **참고**: 비교 대상 videoplayer는 실제 영상이 없는 순수 애니메이션(17px/s)이라 본래 매끄러움. DefVideo는 실영상 동기라 위 최적화가 필요했음. + +--- + +## 사용자 목적 적합성 확인 +- "접속자가 자기 영상 폴더를 올려 재생" → **클라이언트 방식이라 사용자별 독립·무업로드로 적합.** +- 실전 점검: ① **HEVC 코덱 호환성(최우선)** ② 폴더 파일명 규칙 ③ CSV 인코딩. + +## 토큰 사용량(추정) +- 본 세션 누적 ≈ **600k+ tokens** (오케스트레이터 + 서브에이전트 Explore×2/DEV×1). 정확 집계 도구 없어 추정. diff --git a/agent-docs/feature-folder-load.md b/agent-docs/feature-folder-load.md new file mode 100644 index 0000000..4245e61 --- /dev/null +++ b/agent-docs/feature-folder-load.md @@ -0,0 +1,358 @@ +# 기능: 폴더 선택 기반 영상+데이터 동시 로딩 + +> 설계 문서 (① 설계 단계). 이 문서는 구현 지시서이며, 코드 변경은 ②구현 단계 에이전트가 수행한다. +> 모든 경로는 프로젝트 루트 `/home/hanmac/projects/gitea/b23042/DefVideo` 기준 상대경로로 표기한다. + +--- + +## 목표 / 사용자 시나리오 + +영상과 지리정보(드론 GPS/자세, 측점, POI, 선로 중심선)를 **하나의 폴더 선택으로 동시에 로드**한다. +현재는 영상은 클라이언트 File API로, 지리정보는 서버가 고정 디렉토리(`samplevideo`) + 하드코딩 영상명(`'회덕'`)으로 따로 로드하므로 두 데이터가 연결되지 않는다. + +### 사용자 시나리오 +1. 사용자가 영상 플레이어에서 **"폴더 선택"** 컨트롤을 클릭한다. +2. OS 폴더 선택 다이얼로그에서 영상 + 데이터가 들어있는 폴더(예: `samplevideo/`)를 고른다. +3. 브라우저가 `webkitdirectory`로 폴더 내 모든 파일(하위 `building/` 포함)을 노출한다. +4. 클라이언트가: + - 폴더에서 영상 파일(`.mp4/.MP4/.webm`)을 찾아 `loadLocalFile`로 재생한다. + - `.csv`(드론 프레임), `center.csv`(중심선), `building/_POI_위경도값.csv`, `building/_측점_위경도값.csv`를 **클라이언트에서 직접 파싱**해 Zustand 지리정보 스토어에 적재한다. +5. 우측 패널(지리정보/측점)과 영상 오버레이(측점/POI/중심선)가 **선택한 폴더의 데이터** 기준으로 즉시 표시된다. +6. **영상명에 의존하지 않는다**: `'회덕'` 하드코딩 제거. 동일한 폴더 구조면 어떤 영상이든 동작한다. + +### 폴백 +- 폴더가 아직 선택되지 않았으면 지리정보 스토어는 비어 있다. 각 소비 컴포넌트는 빈 상태(측점 0개, 검색 결과 없음, 오버레이 미표시)를 표시한다. +- (선택) 스토어가 비었을 때 기존 `/api/geo/*` 서버 API로 폴백할 수 있으나, **기본 동작은 클라이언트 스토어 우선**. 이 문서에서는 서버 폴백을 "옵션"으로 두고, 기본 구현은 스토어 단일 소스로 한다. + +--- + +## 제약 + +- **브라우저 폴더 접근**: `` 사용. Chrome/Firefox/Edge(PC) 지원 — 프로젝트 대상 브라우저와 일치(CLAUDE.md). iOS Safari 비대상. + - 선택된 각 `File`은 `file.webkitRelativePath`(예: `samplevideo/building/...csv`)로 폴더 내 상대경로를 노출한다. + - 첫 세그먼트는 사용자가 고른 루트 폴더명이므로, 매칭 시 **basename + 마지막 디렉토리 세그먼트**로 판단한다. +- **클라이언트 전용**: 서버 의존 없음. 파일 업로드 없이 File API로 직접 처리. +- **인코딩**: POI/측점 CSV와 `center.csv`는 **EUC-KR**(서버는 `iconv-lite`로 디코딩). 클라이언트에는 `iconv-lite`가 없으므로 브라우저 **`TextDecoder('euc-kr')`**(Encoding API legacy label, Chrome/FF/Edge 지원)로 디코딩한다. 드론 `.csv`는 **UTF-8 + BOM**. +- **메모리**: 영상은 `URL.createObjectURL()`로 직접 재생, 사용 후 `revokeObjectURL`(기존 `useVideoPlayer.loadLocalFile`가 이미 처리). +- **좌표 투영**: 클라이언트는 이미 `proj4`(EPSG:5186) 기반 투영 유틸을 보유. 서버 `geoMatch.ts`는 단순 ENU(cos-lat 근사) 방식이라 투영식이 다름 — **데이터 로딩/파싱/검색 로직만 포팅**하고 투영은 기존 클라이언트 유틸(`geoProjection.ts`)을 재사용한다(아래 상세). + +--- + +## 파일 명명 규칙 & CSV 스키마 + +`` = 영상 파일명에서 확장자를 제거한 이름. 예: `하행)회덕-대전조차장.MP4` → base `하행)회덕-대전조차장`. + +``` +/ + .MP4 (또는 .mp4/.webm) → 영상 (loadLocalFile / createObjectURL) + .csv → 드론 프레임 (UTF-8 BOM) + .srt → 프레임 메타(현재 geo 시스템 미파싱 — 주의) + center.csv → 중심선 (224행, EUC-KR) + building/_POI_위경도값.csv → POI 건물 (EUC-KR) + building/_측점_위경도값.csv → 측점 (EUC-KR) + building/_POI_XY값.csv → 미사용 + building/_측점_XY값.csv → 미사용 +``` + +### 1) 드론 프레임 `.csv` (UTF-8, BOM 있음) +헤더(BOM 제거 후): `frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len` +``` +frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len +0,36.402256,127.421515,84.244,114.7,-29.8,0.0,24.00 +1,36.402256,127.421515,84.244,114.7,-29.8,0.0,24.00 +``` +- 컬럼은 **헤더 이름으로 인덱스 조회**(`header.indexOf('frame_cnt')` 등). 서버 `geoMatch.ts:290-302`와 동일. +- 매핑: `frame=frame_cnt(int)`, `lat=latitude`, `lon=longitude`, `altitude`, `yaw`, `pitch`, `roll`, `focalLen=focal_len`. +- `!isNaN(lat)` 행만 채택. +- 구분자 `,`. 따옴표 처리는 `parseCsvLine` 동일 규칙. + +### 2) 중심선 `center.csv` (EUC-KR — 헤더 한글이 깨지므로 **위치 인덱스 직접 사용**) +헤더(깨진 상태): `id,lat,lon,표고(H),지오이드높이(N),타원체고(h),67.39,...,x,y` +``` +id,lat,lon,,<깨짐>,<깨짐(h)>,67.39,41.57...,,x,y +0,36.4087069,127.4256135,44.5959549,25.449,70.0449549,2.6549549,...,238176.0008,423480.0717 +``` +- 서버 `geoMatch.ts:251-257` 규칙: **인덱스 1=lat, 2=lon, 5=타원체고(h)=z**. 헤더 이름 의존 금지(EUC-KR 깨짐 회피). +- `!isNaN(lat&&lon&&z)` 행만 채택. 헤더 1행 skip. + +### 3) POI `building/_POI_위경도값.csv` (EUC-KR) +헤더: `title,category_clean,address_road,address_jibun,lat,lon,z` +``` +title,category_clean,address_road,address_jibun,lat,lon,z +한국철도공사대전철도역사,역사,대전 ...,대전 ...,36.371101,127.421776,14.93833585 +대전역,역사,...,...,36.380398,127.422422,17.43774457 +``` +- 매핑(헤더 이름 조회, `geoMatch.ts:323-335`): `title`, `category = category_clean || '건물'`, `lat`, `lon`, `z = z || 0`, `type='poi'`. +- 파일 선택 우선순위(`geoMatch.ts:317-320`): 파일명에 `타원체고` 포함 버전이 있으면 그것 우선, 없으면 `타원체고` 미포함 버전. (현재 샘플엔 `타원체고` 파일 없음 → 기본 `_POI_위경도값.csv` 사용.) +- `category_clean` 값도 EUC-KR(터널/교량/역사/지장물). `TextDecoder('euc-kr')`로 정상 복원돼야 RoutePanel 카테고리 필터/이모지가 동작한다. + +### 4) 측점 `building/_측점_위경도값.csv` (EUC-KR) +헤더: `title,category_clean,address_road,address_jibun,lat,lon,z` (POI와 동일) +``` +title,category_clean,address_road,address_jibun,lat,lon,z +157K900,,,,36.40296455,127.4218765,0.023576703 +158K000,,,,36.40222619,127.4212379,-0.148290668 +``` +- 매핑(`geoMatch.ts:345-355`): `title`, `category='측점'`(강제), `lat`, `lon`, `z = z || 0`, `type='station'`. +- `title`은 `157K900` 형태(km 정렬에 사용: 정규식 `/(\d+)[Kk](\d+)/`). + +### 5) SRT `.srt` (UTF-8) — 참고용, 현재 geo 미사용 +``` +1 +00:00:00,000 --> 00:00:00,033 +FrameCnt: 0 2026-03-05 17:18:28.405 +[... focal_len: 24.00] [latitude: 36.402256] [longitude: 127.421515] [rel_alt: 16.853 abs_alt: 84.244] [gb_yaw: 114.7 gb_pitch: -29.8 gb_roll: 0.0] ... +``` +- 서버 `geoMatch.ts:208-222 loadTerrainOffset`만 SRT를 읽어 `terrain offset = abs_alt - rel_alt`를 계산하나, **현재 코드 경로에서 이 offset은 어디에도 적용되지 않는다**(`_terrainOffset`은 set만 되고 사용처 없음). → 이번 포팅에서 SRT는 **파싱하지 않아도 무방**(현 동작 동일). 문서상 명시. + +--- + +## 현재 구조 (As-Is) + +### 영상 선택 +- `client/src/components/player/VideoPlayer.tsx:194-205` — "파일 선택" 라벨 + ``, `onChange`에서 `loadLocalFile(file)` 호출. +- 드래그앤드롭: `VideoPlayer.tsx:96-100`. +- `client/src/hooks/useVideoPlayer.ts:63-81 loadLocalFile` — `URL.createObjectURL(file)` → `player.src(...)` → `store.setSource({kind:'local', file, objectUrl})` → `emptied` 시 `revokeObjectURL`. +- 소스 타입: `client/src/types/player.ts:1-3 VideoSource`. + +### 지리정보 (서버 단일 소스) +- 데이터 디렉토리: `server/src/routes/geo.ts:8-11` — `GEO_DATA_DIR = process.env.GEO_DATA_DIR || /samplevideo`, `setGeoDataDir()` 호출. +- 서버 서비스 `server/src/services/geoMatch.ts` (싱글턴 캐시 `_frames/_pois/_centerline`): + - CSV 파서 `parseCsvLine`(162-173), `readCsvUtf8`(175-192, EUC-KR/UTF-8 BOM 자동 판별, `iconv-lite`). + - 투영: `geoToEnu`(78-87, cos-lat 근사 ENU), `projectEnu`(90-124), `project3D`(127-156). + - 로더: `loadFrames`(275-305), `loadPois`(307-361), `loadCenterline`(236-261). + - ⚠ `loadFrames:285` — `f.includes('회덕')` **하드코딩**. (제거 대상) + - `loadCenterline:239` — `CENTER_CSV_PATH` 또는 `pythonsource/input/center.csv` 경로(샘플 폴더의 center.csv와 별개일 수 있음). + - 원점: `getWorldOrigin`(378-387) — 첫 측점(`stationOrder` 정렬) 사용, 없으면 frame[0]. + - 검색 API: `findFramesForPoi`(399-468), `findPoisForFrame`(473-512), `getAllPois`(515-517), `getDroneFrames`(520-522), `getCenterlinePoints`(263-265). +- HTTP 라우트 `server/src/routes/geo.ts`: + - `GET /api/geo/pois`(14-21) → `getAllPois()` → `GeoPoint[]` + - `GET /api/geo/search?q=&margin=&maxDist=&yawOffset=`(27-45) → `{poi, frames: FrameMatch[]}` + - `GET /api/geo/frame/:frameNum?margin=&yawOffset=`(51-64) → `{droneFrame, pois: PoiInFrame[]}` + - `GET /api/geo/frames?step=`(70-79) → 샘플링된 `DroneFrame[]` + - `GET /api/geo/centerline`(85-92) → `CenterlinePoint[]` + +### 클라이언트 투영 (이미 존재 — 재사용) +- `client/src/utils/geoProjection.ts`: + - `proj4` EPSG:5186 정의(70-72), `latLonToTM`(76-80), `geoToEnu`(86-92, TM 기반). + - `CameraParams` / `DEFAULT_CAMERA_PARAMS`(35-62), `paramsFromFrame`(65-67). + - `toCameraCoords`(175-184), `pixelFromCamera`(115-126), `projectPoint`(201-294). + - **오버레이(StationOverlay/RoutePanel)는 이 유틸로 자체 투영**한다. 서버 `project3D`와 다른 알고리즘. → 검색용 `findFramesForPoi`/`findPoisForFrame`만 서버에 의존. + +### 클라이언트 소비 컴포넌트 (각 fetch 정리) +| 컴포넌트 | fetch | 기대 응답 형태 | 용도 | +|---|---|---|---| +| `GeoSearch.tsx:61` | `GET /api/geo/pois` | `GeoPoint[]` | 자동완성 목록 | +| `GeoSearch.tsx:82` | `GET /api/geo/search?q=&margin=1.0&maxDist=1500` | `{poi:GeoPoint, frames:FrameMatch[]}` | 건물→프레임 검색 | +| `GeoSearch.tsx:98` | `GET /api/geo/frame/{currentFrame}?margin=1.0` | `{pois:PoiInFrame[]}` | 프레임→건물 역조회 | +| `StationVerify.tsx:57` | `GET /api/geo/pois` | `GeoPoint[]` (station만 필터) | 측점 목록 | +| `StationVerify.tsx:69` | `GET /api/geo/frames?step=1` | `{frame,lat,lon}[]` | GPS 최근접 프레임 | +| `StationVerify.tsx:102` | `GET /api/geo/search?q=&margin=1.2&maxDist=2000` | `{poi,frames:FrameMatch[]}` | 검증 정보 | +| `StationOverlay.tsx:186` | `GET /api/geo/pois` | `GeoPoint[]` (station/poi 분리) | 라벨 사전계산 | +| `StationOverlay.tsx:197` | `GET /api/geo/centerline` | `CenterlinePoint[]` | 중심선 | +| `StationOverlay.tsx:206` | `GET /api/geo/frames?step=1` | `DroneFrameBasic[]` | 드론 프레임 | +| `RoutePanel.tsx:75` | `GET /api/geo/pois` | `GeoPoint[]` | 미니맵 측점/POI | +| `RoutePanel.tsx:87` | `GET /api/geo/frames?step=1` | `DroneFrameBasic[]` | 현재 km 계산 | + +### 상태/배선 +- `client/src/App.tsx` — 우측 탭 `rightTab: 'annotation'|'geo'|'station'`(19), GeoSearch(151-157)/StationVerify(158-163) 렌더. `currentFrame = round(currentTime*fps)`(35). +- `client/src/store/playerStore.ts` — 영상 재생 상태(source/currentTime/fps 등). 지리정보 스토어 없음. +- `playerRef.loadServerStream`은 좌측 VideoList에서, `loadLocalFile`은 VideoPlayer 내부 input/드롭에서 호출. + +--- + +## 변경 설계 (To-Be) + +### 개요 +1. 폴더 선택 UI → 폴더 내 파일 목록(`File[]` + `webkitRelativePath`)을 단일 파서 진입점에 넘긴다. +2. 새 **클라이언트 지리정보 스토어**(`geoStore`)가 파싱 결과(frames/pois/stations/centerline/origin)를 보관. +3. CSV 파싱 + 서버 `geoMatch` 검색 로직을 클라이언트 유틸로 포팅(투영은 기존 `geoProjection.ts` 재사용). +4. 4개 소비 컴포넌트의 `/api/geo/*` fetch → `geoStore` 셀렉터 + 포팅 함수 호출로 교체. +5. 영상 파일은 폴더에서 찾아 기존 `loadLocalFile`로 재생. + +--- + +### New: 폴더 선택 UI +**파일**: `client/src/components/player/VideoPlayer.tsx` 수정. + +- 기존 "파일 선택"(194-205) 옆에 **"폴더 선택"** 라벨+input 추가: + ```tsx + + ``` + - `webkitdirectory` 타입 보강: `client/src/types/`에 전역 선언 추가하거나 `// @ts-expect-error`. (권장: `client/src/vite-env.d.ts` 또는 새 `client/src/types/dom.d.ts`에 `HTMLInputElement` 확장 선언.) +- `onSelectFolder(files: File[])` 핸들러(VideoPlayer 내부 또는 App에서 전달): + 1. `loadFolderGeoData(files)` 호출 → 파싱 + `geoStore` 채움(영상 File도 함께 반환/식별). + 2. 영상 File을 찾으면 `loadLocalFile(videoFile)`. +- 기존 "파일 선택"(단일 영상)은 **유지**(지리정보 없이 영상만). + +--- + +### New: 클라이언트 지리정보 스토어 (Zustand) +**신규 파일**: `client/src/store/geoStore.ts` + +```ts +import { create } from 'zustand'; +// 타입은 client/src/types/geo.ts(신규)로 분리 권장 +export interface DroneFrame { frame:number; lat:number; lon:number; altitude:number; yaw:number; pitch:number; roll:number; focalLen:number; } +export interface GeoPoint { title:string; category:string; lat:number; lon:number; z:number; type:'poi'|'station'; } +export interface CenterlinePoint { lat:number; lon:number; z:number; } + +interface GeoStore { + loaded: boolean; + frames: DroneFrame[]; + pois: GeoPoint[]; // type==='poi' + stations: GeoPoint[]; // type==='station' (stationOrder 정렬) + centerline: CenterlinePoint[]; + origin: { lat:number; lon:number; alt:number } | null; // 첫 측점 = ENU 원점 + baseName: string | null; + setGeoData: (d: Partial) => void; + reset: () => void; +} +export const useGeoStore = create((set) => ({ /* 초기값 + setGeoData/reset */ })); +``` +- `pois`/`stations`는 `getAllPois()` 결과를 `type`으로 분리해 저장(소비처 편의). 또는 통합 `pois` 하나만 두고 셀렉터로 분리해도 됨 — **분리 저장 권장**(현 컴포넌트들이 매번 filter 함). +- `origin` = 첫 측점(`stationOrder` 최소). 서버 `getWorldOrigin` 동치. + +--- + +### New: 클라이언트 CSV 파싱 + geoMatch 포팅 +**신규 파일**: `client/src/utils/geoData.ts` (파싱 + 로딩 + 검색 포팅) + +#### 인코딩/파싱 헬퍼 (서버 `readCsvUtf8`/`parseCsvLine` 포팅) +- `parseCsvLine(line)` — 서버 `geoMatch.ts:162-173` 그대로 포팅(순수 함수, 의존성 없음). +- `decodeBytes(buf: ArrayBuffer, encoding:'utf-8'|'euc-kr')` — `new TextDecoder(encoding).decode(buf)`. UTF-8 BOM 제거(`replace(/^/,'')`). EUC-KR은 `TextDecoder('euc-kr')`. + - 드론 csv: UTF-8(BOM). POI/측점/center: EUC-KR. + - 서버처럼 자동판별(`iconv`)이 아니라 **파일 종류별로 인코딩 지정**(폴더 구조 알고 있으므로 단순). center/POI/측점은 EUC-KR 고정. +- `readCsv(file: File, encoding)` → `Promise` — `await file.arrayBuffer()` → decode → `split(/\r?\n/).filter(Boolean).map(parseCsvLine)`. + +#### 로더 (서버 loader 포팅, but 파일은 `File[]`에서 매칭) +- `findVideoFile(files)` — 확장자 `.mp4/.webm`(대소문자 무시), `building/` 미포함. → base 추출. +- `parseDroneFrames(file): Promise` — 서버 `loadFrames:289-302` 로직(헤더 이름 인덱스). **`회덕` 필터 제거**, base 일치 또는 `building/` 미포함 루트 csv로 식별. +- `parsePois(poiFile, stationFile): Promise` — 서버 `loadPois:321-357` 포팅. POI=`category_clean||'건물'`,`type:'poi'`; 측점=`category:'측점'`,`type:'station'`. 파일 선택은 `building/` 내 `webkitRelativePath`에서 `POI`+`위경도`, `측점`+`위경도` 매칭(`타원체고` 우선규칙 유지). +- `parseCenterline(file): Promise` — 서버 `loadCenterline:253-257` 포팅(인덱스 1/2/5, EUC-KR). +- `loadFolderGeoData(files: File[])` — 위 파서들을 호출해 `geoStore.setGeoData({...})` 채우고, 영상 File을 반환. + +#### 검색 함수 포팅 (서버 `geoMatch.ts` → 클라이언트) +검색은 **서버의 단순 ENU `project3D`(geoMatch.ts) 알고리즘을 그대로 포팅**해 서버 응답과 동일 결과를 보장한다(오버레이용 `geoProjection.ts`와는 별개 — 검색 UI 호환 우선). +| 포팅 함수 | 서버 소스 라인 | 비고 | +|---|---|---| +| `toRad` | `geoMatch.ts:63` | | +| `geoToEnu` (cos-lat ENU) | `geoMatch.ts:78-87` | 검색 전용. 오버레이는 기존 proj4 ENU 사용 | +| `projectEnu` | `geoMatch.ts:90-124` | | +| `project3D` | `geoMatch.ts:127-156` | | +| `stationOrder` | `geoMatch.ts:371-375` | (이미 컴포넌트들에 중복 존재 — 공용화) | +| `getWorldOrigin` | `geoMatch.ts:378-387` | 스토어 `origin`으로 대체 가능 | +| `findFramesForPoi(query, margin, maxDist, yawOffset)` | `geoMatch.ts:399-468` | 스토어 frames/pois 입력으로 변경 | +| `findPoisForFrame(frameNum, margin, yawOffset)` | `geoMatch.ts:473-512` | 동일 | +- 입력을 싱글턴 캐시(`loadFrames()`) 대신 **`geoStore` 데이터를 인자/직접 read**로 변경. 반환 타입은 서버와 동일(`FrameMatch`/`PoiInFrame`) → 컴포넌트 인터페이스 무변경. +- `DEFAULT_FPS=30`, `GAP=30` 등 상수 동일 포팅. + +--- + +### Changes: 소비 컴포넌트 (fetch → 스토어) +각 컴포넌트는 내부 `interface GeoPoint/FrameMatch` 중복 선언을 **`types/geo.ts`로 통합**(선택, 최소변경 시 유지 가능). 핵심은 fetch 제거: + +- **`GeoSearch.tsx`** + - `:61` `fetch('/api/geo/pois')` → `useGeoStore` 의 `[...stations, ...pois]`. + - `:82` `/api/geo/search` → 포팅 `findFramesForPoi(q, 1.0, 1500, 0)` 직접 호출(동기). `loading`/`error`는 데이터 없을 때만 의미. + - `:98` `/api/geo/frame/{n}` → 포팅 `findPoisForFrame(currentFrame, 1.0)`. +- **`StationVerify.tsx`** + - `:57` pois → 스토어 `stations`. + - `:69` frames(`{frame,lat,lon}`) → 스토어 `frames`(필드 일부만 사용). + - `:102` search → 포팅 `findFramesForPoi(title, 1.2, 2000)`. +- **`StationOverlay.tsx`** + - `:186` pois → 스토어 `stations`/`pois`(이미 station/poi 분리 사용). `updateWorldOrigin`은 스토어 `origin`으로 대체. + - `:197` centerline → 스토어 `centerline`. + - `:206` frames → 스토어 `frames`. + - 투영은 기존 `geoProjection.ts` 그대로(변경 없음). +- **`RoutePanel.tsx`** + - `:75` pois → 스토어. `:87` frames → 스토어. +- **폴백/가드**: 스토어 `loaded===false`이면 빈 배열로 동작(현재 fetch 실패 시와 동일한 빈 상태). `useEffect`의 `[visible]` 의존성은 스토어 구독으로 대체(데이터 도착 시 자동 재렌더). + - (옵션) 스토어가 비어 있을 때 기존 `/api/geo/*`로 폴백하려면 각 컴포넌트에 `if (store.loaded) useStore else fetch` 분기. **기본 구현은 스토어 단일 소스 권장**(서버 의존 제거가 목표). + +--- + +### 영상 재생 배선 +- `loadFolderGeoData`가 반환한 영상 `File` → `playerRef.loadLocalFile(file)`(기존 경로 그대로). +- 영상이 폴더에 없으면 지리정보만 로드(오버레이는 동작하나 재생 화면 없음) — 에러 토스트/콘솔 경고. +- `App.tsx`: 폴더 선택 핸들러를 VideoPlayer로 내려보내거나 VideoPlayer 내부에서 `loadLocalFile`(이미 보유) + `loadFolderGeoData` 직접 호출. **VideoPlayer 내부 처리 권장**(이미 `loadLocalFile` 접근 가능). + +--- + +## 구현 태스크 목록 + +- [ ] `client/src/types/geo.ts` 신규 — `DroneFrame`/`GeoPoint`/`CenterlinePoint`/`FrameMatch`/`PoiInFrame` 공용 타입. +- [ ] `client/src/store/geoStore.ts` 신규 — Zustand 스토어(frames/pois/stations/centerline/origin/baseName/loaded + setGeoData/reset). +- [ ] `client/src/utils/geoData.ts` 신규: + - [ ] `parseCsvLine` 포팅(server `geoMatch.ts:162-173`). + - [ ] `decodeBytes`/`readCsv`(TextDecoder utf-8 + euc-kr, BOM 제거). + - [ ] `findVideoFile(files)` (mp4/MP4/webm, building 제외, base 추출). + - [ ] `parseDroneFrames`(헤더 인덱스, **회덕 하드코딩 없음**). + - [ ] `parsePois`(POI: category_clean||건물 / 측점: '측점', 타원체고 우선규칙). + - [ ] `parseCenterline`(인덱스 1/2/5, EUC-KR). + - [ ] `loadFolderGeoData(files)` → setGeoData + return videoFile. +- [ ] `client/src/utils/geoSearch.ts` 신규(또는 geoData.ts 내) — `toRad/geoToEnu/projectEnu/project3D/stationOrder/findFramesForPoi/findPoisForFrame` 포팅(server `geoMatch.ts`). 입력은 스토어 데이터. +- [ ] `client/src/types/dom.d.ts` (또는 vite-env.d.ts) — `HTMLInputElement` `webkitdirectory`/`directory` 보강. +- [ ] `client/src/components/player/VideoPlayer.tsx` — "폴더 선택" UI 추가 + `onSelectFolder` → `loadFolderGeoData` + `loadLocalFile`. +- [ ] `client/src/components/geo/GeoSearch.tsx` — fetch 3곳 제거 → 스토어/포팅 함수. +- [ ] `client/src/components/geo/StationVerify.tsx` — fetch 3곳 제거 → 스토어/포팅 함수. +- [ ] `client/src/components/overlay/StationOverlay.tsx` — fetch 3곳 제거 → 스토어 구독, origin 스토어화. +- [ ] `client/src/components/overlay/RoutePanel.tsx` — fetch 2곳 제거 → 스토어. +- [ ] (정리) 컴포넌트 내 중복 `stationOrder`/`GeoPoint` 선언을 공용 타입/유틸로 통합. +- [ ] `npm run build -w client` 통과 확인(tsc + vite). +- [ ] 수동 검증: `samplevideo/` 폴더 선택 → 영상 재생 + 측점/POI/중심선 오버레이 + 측점 패널 목록 표시. +- [ ] `회덕` 등 영상명 하드코딩이 클라이언트 경로에 없음을 확인(`grep -r 회덕 client/src` → 0건; RoutePanel 주석의 '대전조차장/회덕'은 단순 주석이므로 무관). + +--- + +## 완료 기준 + +- `npm run build -w client` (= `tsc && vite build`)가 **타입 에러 없이** 통과. +- "폴더 선택"으로 `samplevideo/`(또는 동일 구조 폴더) 선택 시: + - 폴더 내 영상이 재생된다(`createObjectURL`). + - 측점/POI/선로 중심선 오버레이가 표시된다(`StationOverlay`). + - 우측 "측점" 탭에 측점 목록이 채워지고, 클릭 시 해당 프레임으로 이동한다. + - "지리정보" 탭 검색/역조회가 클라이언트 데이터로 동작한다. + - RoutePanel 미니맵이 시점/종점/현재 km를 표시한다. +- 동작이 **영상명 비의존적**: `'회덕'` 등 하드코딩 없음. 다른 base명 폴더도 동일 구조면 동작. +- EUC-KR 한글(측점명/POI 카테고리)이 깨지지 않고 표시된다(`TextDecoder('euc-kr')`). + +--- + +## 토큰 사용 기록 + +> 추정치(k = 1,000 tokens). 각 에이전트 자체 보고 입력/출력 추정 + 측정된 subagent 총 토큰(측정 합계) 기준. + +| 단계 | 에이전트 | 입력(추정) | 출력(추정) | 측정 합계 | +|------|----------|-----------|-----------|----------| +| ①설계 | DESIGN/DOCUMENTATION | ~46k | ~9k | 98.7k | +| ②a 구현·기반 | DEV-FOUNDATION (types/store/utils + 폴더선택 UI) | ~60k | ~9k | 99.3k | +| ②b 구현·소비 | DEV-CONSUMER: GeoSearch | ~30k | ~2k | 52.0k | +| ②b 구현·소비 | DEV-CONSUMER: StationVerify | ~24k | ~2k | 48.1k | +| ②b 구현·소비 | DEV-CONSUMER: StationOverlay | ~32k | ~3k | 66.8k | +| ②b 구현·소비 | DEV-CONSUMER: RoutePanel | ~30k | ~3k | 55.8k | +| ③검증 | REVIEW/검수 | ~62k | ~3k | 105.1k | +| — | (기반 1차 시도, 529 과부하로 무산) | 0 | 0 | 0 | +| **합계** | **7개 에이전트** | **~284k** | **~31k** | **≈526k** | + +> ②b 4개 소비 에이전트는 **병렬 실행**(서로 다른 파일)으로 벽시계 시간 단축. 판정: **ship** (빌드 통과, blocker 0, 발견 이슈 전부 nit). + +--- + +## 후속 수정 (2026-06-17) + +### 🔴 누락 컴포넌트: StationBar (측점 바) — 사용자 보고 "측점 패널 안 보임" +- **원인**: 초기 탐색이 geo 소비 컴포넌트로 GeoSearch/StationVerify/StationOverlay/RoutePanel 4개만 식별. 영상 하단의 측점 바 [client/src/stationbar/StationBar.tsx](../client/src/stationbar/StationBar.tsx)는 누락되어, 여전히 `/api/geo/pois`·`/api/geo/frames`를 직접 fetch. → 폴더 선택으로 채운 클라이언트 데이터를 못 읽고, 서버 미기동 시 측점이 표시되지 않음. +- **수정**: StationBar를 다른 컴포넌트와 동일하게 geoStore로 연결. + - `pois` = `[...stations, ...pois]` (store 파생), `frames` = `storeFrames`로 교체. 두 `fetch` useEffect 제거. + - precompute/세그먼트 effect 의존성 `framesVersion` → `storeFrames`. 데이터 비었을 때 `setReady(false)`로 idle 복귀. + - `npm run build -w client` 통과 확인. 클라이언트 전체에 live `/api/geo` fetch 0건. +- **교훈**: 다음 탐색 시 `grep -rn "fetch(.*\/api\/geo"`로 **소비처 전수 조사**를 먼저 수행해 누락 방지. + + diff --git a/agent-docs/feature-stationbar-folder-driven.md b/agent-docs/feature-stationbar-folder-driven.md new file mode 100644 index 0000000..2f15fd8 --- /dev/null +++ b/agent-docs/feature-stationbar-folder-driven.md @@ -0,0 +1,64 @@ +# 기능: StationBar 완전 폴더-구동화 (하드코딩/Mock 제거) + +## 목표 +StationBar(영상 하단 측점 바)가 표시하는 **모든 데이터를 선택한 폴더의 파일에서** 가져오게 한다. 코드에 노선 데이터를 하드코딩하지 않는다. 폴더 CSV에 없는 정보(노선명·방향·시종점 역명·구조물)는 폴더 내 보조 파일 `.route.json`(없으면 `route.json`)에서 읽는다. + +## 사용자 보고 / 현상 +- 빈 화면에서도 mock 노선("신탄진"~"대전", `158k700`)이 보였음 → StationBar 폴더 로드 전 숨김으로 1차 처리 완료. +- 잔여: 폴더 로드 후에도 터미널명·이정(mileage) 배지가 **mock**(ROUTE_LEGS 픽셀 앵커, Timeline 하드코딩)에서 나옴. + +## 현재 소스 (감사 결과 요약) +| 표시 요소 | 현재 소스 | 분류 | +|----------|----------|------| +| 트랙 px 경계/스케일/transform | mocks/route.ts | LAYOUT(유지) | +| 방향 구간색·이정 라벨(kmLabels) | geoStore frames+stations 계산 | 이미 폴더-구동 | +| 커서 이정 배지 텍스트 | `formatMileage(mileageAtPx(cursorPx))` = mock ROUTE_LEGS | ROUTE DATA → 폴더 | +| 터미널명 신탄진/대전 | Timeline.tsx 하드코딩 | ROUTE DATA → route.json | +| 노선명/방향/길이/시간 | mocks/routeInfo.ts (대부분 미사용) | ROUTE DATA → route.json | +| 구조물(교량/터널) | mocks/segments.ts(미사용) / POI category | ROUTE DATA → route.json + POI | + +> mocks/{routeInfo,segments,timeline,mileage}.ts 의 다수 export는 현재 **미사용(dead)**. route.ts 의 ROUTE_LEGS만 mileage.ts 경유로 커서 배지에 실사용. + +## 보조 파일 `route.json` (이미 생성: samplevideo/하행)회덕-대전조차장.route.json) +```json +{ + "routeInfo": { "name","direction","lengthKm","durationSec","startStationName","endStationName" }, + "structures": [ { "id","type":"bridge|tunnel","name","startMileage","endMileage" } ] +} +``` +- 위치: 영상과 같은 폴더 루트. 파일명 `.route.json` 우선, 없으면 `route.json`. UTF-8. +- 모든 필드 optional. 없으면 폴더 CSV에서 유도(터미널명=첫/끝 측점 title) 또는 미표시. + +## 변경 설계 (To-Be) +1. **types/geo.ts**: `RouteMeta`(routeInfo + structures) 추가, `FolderGeoData`·store shape에 `routeMeta: RouteMeta | null`. +2. **utils/geoData.ts**: `parseRouteMeta(files)` — `.route.json`/`route.json` 탐색 → `await file.text()` → `JSON.parse`. `loadFolderGeoData` 반환에 `routeMeta` 추가. +3. **store/geoStore.ts**: `routeMeta` 상태 + `loadFromFolder`에서 set, `clear()`에서 null. +4. **stationbar/StationBar.tsx**: + - 커서 이정 배지: mock `mileageAtPx(cursorPx)` 대신 **폴더 데이터 기반 실제 km**(viewedRef의 km = 드론GPS 최근접 측점, 이미 계산됨) 사용. + - 구조물 마커: `routeMeta.structures`(mileage→최근접 프레임 시간→px)로 생성. (POI category 보조는 유지 가능) + - Timeline에 `startStationName/endStationName` 전달: `routeMeta.routeInfo` 우선, 없으면 첫/끝 측점 title. +5. **stationbar/components/Timeline/Timeline.tsx**: 하드코딩 "신탄진"/"대전" 제거 → props로 수신(없으면 빈 문자열/미표시). +6. **mocks**: 미사용 dead export는 그대로 두되(삭제 안 함), 실사용 ROUTE_LEGS 의존(커서 배지)은 4번으로 제거. LAYOUT 기하 상수는 UI 레이아웃이므로 유지. + +## 완료 기준 +- `npm run build -w client` 통과(타입 에러 0). +- 폴더 선택 시: 커서 배지 이정·터미널명·구조물이 **route.json + 폴더 CSV**에서 나온다. mock ROUTE_LEGS/하드코딩 신탄진·대전 미사용. +- route.json 없는 폴더: 크래시 없이 측점 title 기반 폴백(터미널명=첫/끝 측점) 또는 미표시. +- 영상명/노선 비의존. + +## 토큰 사용 기록 +> 추정치(k=1,000). 측정 합계 = subagent 총 토큰. + +| 단계 | 에이전트 | 입력(추정) | 출력(추정) | 측정 합계 | +|------|----------|-----------|-----------|----------| +| ①조사 | Explore 감사(StationBar mock 매핑) | ~55k | ~7k | ~80k | +| ①설계 | 본 문서 + route.json 작성 | 오케스트레이터 | — | — | +| ②구현 | DEV (geoData/geoStore/types + StationBar/Timeline) | ~50k | ~5k | 55.5k | +| ③검수 | 오케스트레이터 직접(빌드+grep 검증) | — | — | — | + +## 구현 결과 (완료) +- ✅ `route.json` 보조 파일 로딩(`.route.json`→`route.json`), geoStore `routeMeta`. +- ✅ 커서 이정 배지: 폴더 데이터(viewedRef.km, 드론 GPS 최근접 측점) 기반. mock `mileageAtPx`(ROUTE_LEGS) 런타임 의존 제거(주석만 잔존). +- ✅ 터미널명: route.json `routeInfo` → 폴백 첫/끝 측점 title. Timeline 하드코딩 신탄진/대전 제거(props 전환). +- ✅ 구조물: route.json `structures`(mileage→시간→px), 없으면 POI category 폴백. +- ✅ `npm run build -w client` 통과. mock 파일/dead export는 보존(삭제 안 함), LAYOUT 기하 상수 유지. diff --git a/client/index.html b/client/index.html index 7f3b551..a5ae676 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,12 @@ + + + abcvideo — 대용량 동영상 플레이어 diff --git a/client/src/App.tsx b/client/src/App.tsx index c475212..f51abb2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,9 @@ export default function App() { const [showHelp, setShowHelp] = useState(false); const [memoTime, setMemoTime] = useState(0); const [rightTab, setRightTab] = useState<'annotation' | 'geo' | 'station'>('annotation'); + // 좌/우 패널: UI 에서 숨김(코드 보존). 다시 표시하려면 true 로. + const SHOW_LEFT_PANEL = false; + const SHOW_RIGHT_PANEL = false; const playerRef = useRef(null); const rafRef = useRef(0); @@ -76,7 +79,8 @@ export default function App() { return (
- {/* Left sidebar — top: video list, bottom: captures */} + {/* Left sidebar — top: video list, bottom: captures (UI 숨김, 코드 보존) */} + {SHOW_LEFT_PANEL && (

abcvideo

@@ -96,6 +100,7 @@ export default function App() {
+ )} {/* Main — player + memo overlay */}
@@ -115,7 +120,8 @@ export default function App() { />
- {/* Right sidebar — annotation + geo */} + {/* Right sidebar — annotation + geo (UI 숨김, 코드 보존) */} + {SHOW_RIGHT_PANEL && (
{/* 탭 헤더 */}
@@ -163,6 +169,7 @@ export default function App() { )}
+ )} {/* Add annotation modal */} {showModal && ( diff --git a/client/src/components/geo/GeoSearch.tsx b/client/src/components/geo/GeoSearch.tsx index 55c58d5..af00598 100644 --- a/client/src/components/geo/GeoSearch.tsx +++ b/client/src/components/geo/GeoSearch.tsx @@ -5,37 +5,9 @@ */ import React, { useState, useEffect, useCallback, useRef } from 'react'; - -interface GeoPoint { - title: string; - category: string; - lat: number; - lon: number; - z: number; - type: 'poi' | 'station'; -} - -interface FrameMatch { - frame: number; - time: number; - bearingDiff: number; - elevationDiff: number; - distance: number; - pixelX: number; - pixelY: number; - groupSize?: number; - groupStart?: number; - groupEnd?: number; -} - -interface PoiInFrame { - poi: GeoPoint; - bearingDiff: number; - elevationDiff: number; - distance: number; - pixelX: number; - pixelY: number; -} +import { useGeoStore } from '../../store/geoStore'; +import { findFramesForPoi, findPoisForFrame } from '../../utils/geoSearch'; +import type { GeoPoint, FrameMatch, PoiInFrame } from '../../types/geo'; interface Props { currentFrame: number; @@ -49,20 +21,23 @@ export default function GeoSearch({ currentFrame, fps, onSeekToFrame }: Props) { const [tab, setTab] = useState('search'); const [query, setQuery] = useState(''); const [suggestions, setSuggestions] = useState([]); - const [allPois, setAllPois] = useState([]); const [searchResult, setSearchResult] = useState<{ poi: GeoPoint; frames: FrameMatch[] } | null>(null); const [reverseResult, setReverseResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const debounceRef = useRef | null>(null); - // POI 목록 로드 (자동완성용) - useEffect(() => { - fetch('/api/geo/pois') - .then(r => r.json()) - .then(data => setAllPois(Array.isArray(data) ? data : [])) - .catch(() => {}); - }, []); + // 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체) + const loaded = useGeoStore(s => s.loaded); + const frames = useGeoStore(s => s.frames); + const pois = useGeoStore(s => s.pois); + const stations = useGeoStore(s => s.stations); + + // POI 목록 (자동완성용): 측점 + 건물 통합 (서버 /api/geo/pois 동치) + const allPois = React.useMemo( + () => (loaded ? [...stations, ...pois] : []), + [loaded, stations, pois], + ); // 자동완성 필터링 useEffect(() => { @@ -71,45 +46,44 @@ export default function GeoSearch({ currentFrame, fps, onSeekToFrame }: Props) { setSuggestions(allPois.filter(p => p.title.toLowerCase().includes(q)).slice(0, 10)); }, [query, allPois]); - // 건물/측점명으로 프레임 검색 - const handleSearch = useCallback(async (q?: string) => { + // 건물/측점명으로 프레임 검색 (클라이언트 검색 — 서버 /api/geo/search 대체) + const handleSearch = useCallback((q?: string) => { const searchQ = (q ?? query).trim(); if (!searchQ) return; setLoading(true); setError(''); setSuggestions([]); try { - const res = await fetch(`/api/geo/search?q=${encodeURIComponent(searchQ)}&margin=1.0&maxDist=1500`); - const data = await res.json(); - if (!res.ok) { setError(data.error || '검색 실패'); setSearchResult(null); return; } - setSearchResult(data); - } catch { - setError('서버 연결 실패'); + if (!loaded) { setError('폴더를 먼저 선택하세요'); setSearchResult(null); return; } + const origin = useGeoStore.getState().origin; + const combined = [...stations, ...pois]; + const result = findFramesForPoi(frames, combined, searchQ, 1.0, 1500, 0, origin); + if (!result.poi) { setError('일치하는 건물/측점 없음'); setSearchResult(null); return; } + setSearchResult({ poi: result.poi, frames: result.frames }); } finally { setLoading(false); } - }, [query]); + }, [query, loaded, frames, stations, pois]); - // 현재 프레임 역조회 - const handleReverse = useCallback(async () => { + // 현재 프레임 역조회 (클라이언트 검색 — 서버 /api/geo/frame/{n} 대체) + const handleReverse = useCallback(() => { setLoading(true); setError(''); try { - const res = await fetch(`/api/geo/frame/${currentFrame}?margin=1.0`); - const data = await res.json(); - if (!res.ok) { setError(data.error || '조회 실패'); setReverseResult(null); return; } - setReverseResult(data.pois ?? []); - } catch { - setError('서버 연결 실패'); + if (!loaded) { setReverseResult([]); return; } + const origin = useGeoStore.getState().origin; + const combined = [...stations, ...pois]; + const result = findPoisForFrame(frames, combined, currentFrame, 1.0, 0, origin); + setReverseResult(result.pois); } finally { setLoading(false); } - }, [currentFrame]); + }, [currentFrame, loaded, frames, stations, pois]); - // 탭 전환 시 역조회 자동 실행 + // 탭 전환/프레임 변경/데이터 로드 시 역조회 자동 실행 useEffect(() => { if (tab === 'reverse') handleReverse(); - }, [tab, currentFrame]); + }, [tab, currentFrame, handleReverse]); const formatDist = (m: number) => m >= 1000 ? `${(m / 1000).toFixed(2)}km` : `${Math.round(m)}m`; diff --git a/client/src/components/geo/StationVerify.tsx b/client/src/components/geo/StationVerify.tsx index 9be5278..ed2ece0 100644 --- a/client/src/components/geo/StationVerify.tsx +++ b/client/src/components/geo/StationVerify.tsx @@ -2,31 +2,14 @@ * 측점 검증 패널 * - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동 * - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증 + * + * 데이터 소스: 클라이언트 geoStore(폴더 선택으로 파싱). 폴더 미선택 시 빈 상태. */ -import React, { useState, useEffect, useRef } from 'react'; - -interface GeoPoint { - title: string; - category: string; - lat: number; - lon: number; - z: number; - type: 'poi' | 'station'; -} - -interface FrameMatch { - frame: number; - time: number; - bearingDiff: number; - elevationDiff: number; - distance: number; - pixelX: number; - pixelY: number; - groupSize?: number; - groupStart?: number; - groupEnd?: number; -} +import React, { useState } from 'react'; +import { useGeoStore } from '../../store/geoStore'; +import { findFramesForPoi } from '../../utils/geoSearch'; +import type { GeoPoint, FrameMatch } from '../../types/geo'; interface StationResult { frames: FrameMatch[]; @@ -38,43 +21,16 @@ interface Props { onSeekToFrame: (frame: number) => void; } -function stationOrder(title: string): number { - const m = title.match(/(\d+)[Kk](\d+)/); - if (!m) return 0; - return parseInt(m[1]) * 1000 + parseInt(m[2]); -} - export default function StationVerify({ fps, onSeekToFrame }: Props) { - const [stations, setStations] = useState([]); + // stations 는 이미 stationOrder 로 정렬되어 있다. + const stations = useGeoStore(s => s.stations); const [selected, setSelected] = useState(null); const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); const [seekedFrame, setSeekedFrame] = useState(null); - // 드론 프레임(위경도) — 측점 클릭 시 GPS 최근접 프레임 계산용. - const framesRef = useRef<{ frame: number; lat: number; lon: number }[]>([]); - - useEffect(() => { - fetch('/api/geo/pois') - .then(r => r.json()) - .then((data: GeoPoint[]) => { - const s = Array.isArray(data) - ? data.filter(p => p.type === 'station').sort((a, b) => stationOrder(a.title) - stationOrder(b.title)) - : []; - setStations(s); - }) - .catch(() => {}); - }, []); - - useEffect(() => { - fetch('/api/geo/frames?step=1') - .then(r => r.json()) - .then((d) => { framesRef.current = Array.isArray(d) ? d : []; }) - .catch(() => {}); - }, []); // 드론 GPS 가 측점에 가장 가까운 프레임 (StationBar 배지·실제 위치와 동일 기준). const nearestFrameForStation = (st: GeoPoint): number | null => { - const fr = framesRef.current; + const fr = useGeoStore.getState().frames; if (!fr.length) return null; let best = fr[0]; let bd = (fr[0].lat - st.lat) ** 2 + (fr[0].lon - st.lon) ** 2; @@ -85,10 +41,9 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) { return best.frame; }; - const handleClick = async (station: GeoPoint) => { + const handleClick = (station: GeoPoint) => { setSelected(station.title); setResult(null); - setLoading(true); setSeekedFrame(null); // 영상은 드론 GPS 가 그 측점에 가장 가까운 프레임으로 이동한다. // (카메라 FOV 검색은 앞을 보는 카메라 특성상 ~200m 앞쪽으로 치우쳐 위치가 어긋남) @@ -98,15 +53,17 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) { setSeekedFrame(gpsFrame); } // 검증 정보(카메라 시야 프레임/투영)는 참고용으로 표시. - try { - const res = await fetch(`/api/geo/search?q=${encodeURIComponent(station.title)}&margin=1.2&maxDist=2000`); - const data = await res.json(); - setResult(res.ok && data.frames?.length ? data : { frames: [], poi: data.poi ?? station }); - } catch { - setResult(null); - } finally { - setLoading(false); - } + const { frames, pois, stations: sts, origin } = useGeoStore.getState(); + const { poi, frames: matches } = findFramesForPoi( + frames, + [...sts, ...pois], + station.title, + 1.2, + 2000, + 0, + origin, + ); + setResult({ frames: matches, poi: poi ?? station }); }; const pixelQuality = (px: number, py: number) => { @@ -130,11 +87,10 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) { {selected && (
{selected}
- {loading &&
검색 중…
} - {!loading && result && result.frames.length === 0 && ( + {result && result.frames.length === 0 && (
카메라 시야에 들어오는 프레임 없음
)} - {!loading && result && result.frames.length > 0 && (() => { + {result && result.frames.length > 0 && (() => { const f = result.frames[0]; return ( <> diff --git a/client/src/components/overlay/RouteInfo.module.css b/client/src/components/overlay/RouteInfo.module.css new file mode 100644 index 0000000..6f17ad1 --- /dev/null +++ b/client/src/components/overlay/RouteInfo.module.css @@ -0,0 +1,117 @@ +/* videoplayer/src/components/RouteInfo/RouteInfo.module.scss 1:1 이식 (plain CSS). */ +.panel { + position: absolute; + left: 0; + top: 0; + width: 429.5px; + height: 65px; + overflow: hidden; + z-index: 30; + pointer-events: none; + user-select: none; +} + +.bg { + position: absolute; + left: 0; + top: 0; + width: 429.5px; + height: 65px; + display: block; +} + +.direction, +.routeName { + position: absolute; + left: 79.5px; + margin: 0; + font-family: 'Noto Sans KR', var(--font-ui, sans-serif); + font-weight: 700; + line-height: 1.2; + -webkit-text-stroke: 2.5px rgb(13, 44, 36); + paint-order: stroke fill; +} + +.direction { + top: 8px; + font-size: 20px; + color: #fff; +} + +.routeName { + top: 35px; + font-size: 16.5px; + letter-spacing: 0.03em; + color: rgb(255, 132, 54); +} + +.lengthLabel, +.durationLabel { + position: absolute; + margin: 0; + font-family: 'Noto Sans KR', var(--font-ui, sans-serif); + font-weight: 700; + line-height: 1.2; + color: #fff; + text-shadow: + 0 0 4.5px rgb(0, 0, 0), + 0 0 4.5px rgb(0, 0, 0); +} + +.lengthLabel { + left: 261.5px; + top: 13px; + font-size: 15px; +} + +.lengthValue, +.durationValue { + position: absolute; + margin: 0; + font-family: var(--font-ui, sans-serif); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.05em; + color: rgb(255, 132, 54); + text-shadow: + 0 0 4.5px rgb(0, 0, 0), + 0 0 4.5px rgb(0, 0, 0); +} + +.lengthValue { + left: 296px; + top: 8px; + width: 40px; + text-align: right; + font-size: 19px; +} + +.lengthUnit { + position: absolute; + left: 342px; + top: 11px; + margin: 0; + font-family: 'Noto Sans KR', var(--font-ui, sans-serif); + font-weight: 700; + font-size: 16.5px; + line-height: 1.2; + color: #fff; + text-shadow: + 0 0 4.5px rgb(0, 0, 0), + 0 0 4.5px rgb(0, 0, 0); +} + +.durationValue { + left: 257.5px; + top: 33px; + width: 74px; + text-align: right; + font-size: 19px; + white-space: nowrap; +} + +.durationLabel { + left: 337.5px; + top: 35px; + font-size: 16px; +} diff --git a/client/src/components/overlay/RouteInfoOverlay.tsx b/client/src/components/overlay/RouteInfoOverlay.tsx new file mode 100644 index 0000000..7cd382f --- /dev/null +++ b/client/src/components/overlay/RouteInfoOverlay.tsx @@ -0,0 +1,94 @@ +import { useCallback, useRef, useState } from 'react'; +import { useGeoStore } from '../../store/geoStore'; +import { usePlayerStore } from '../../store/playerStore'; +import styles from './RouteInfo.module.css'; + +/** 정적 에셋 경로 (Vite base 반영). */ +const bgUrl = `${import.meta.env.BASE_URL}assets/title-panel-bg@2x.png`; + +/** 원본 디자인 무대 가로폭(px). 배너는 이 기준으로 만들어졌다. */ +const STAGE_WIDTH = 1920; + +/** 초 → "M분 S초". */ +function formatDuration(sec?: number | null): string { + if (sec == null || !isFinite(sec) || sec <= 0) return ''; + const m = Math.floor(sec / 60); + const s = Math.round(sec % 60); + return s > 0 ? `${m}분 ${s}초` : `${m}분`; +} + +/** "158k700" → 158700 (m). 매칭 실패 시 -1. */ +function stationKm(title: string): number { + const m = title.match(/(\d+)[Kk](\d+)/); + return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1; +} + +/** + * 영상 좌상단 노선 정보 배너 — videoplayer 의 RouteInfo 디자인 이식. + * 값은 모두 선택한 폴더에서 가져온다(하드코딩 없음). + * - 연장(lengthKm): route.json 우선 → 측점 CSV 구간(min~max km) 계산 폴백. + * - 소요(durationSec): route.json 우선 → 실제 영상 길이 폴백. + * - 방향/노선명: CSV에 없는 정보 → route.json(routeInfo). + * 표출할 값이 하나도 없으면 렌더하지 않는다. + */ +export default function RouteInfoOverlay() { + const routeInfo = useGeoStore((s) => s.routeMeta?.routeInfo); + const stations = useGeoStore((s) => s.stations); + const videoDuration = usePlayerStore((s) => s.duration); + + // 원본처럼 영상 폭/1920 비율로 배너를 스케일 (부모=영상 영역 폭 관측). + const [scale, setScale] = useState(1); + const roRef = useRef(null); + const setPanelRef = useCallback((el: HTMLDivElement | null) => { + roRef.current?.disconnect(); + const parent = el?.parentElement; + if (!parent) return; + const update = () => setScale(parent.clientWidth / STAGE_WIDTH); + update(); + const ro = new ResizeObserver(update); + ro.observe(parent); + roRef.current = ro; + }, []); + + const direction = routeInfo?.direction; + const name = routeInfo?.name; + + // 연장: route.json 우선 → 측점 구간 계산 폴백 + let lengthKm = routeInfo?.lengthKm ?? null; + if (lengthKm == null && stations.length) { + const kms = stations.map((s) => stationKm(s.title)).filter((k) => k >= 0); + if (kms.length >= 2) { + lengthKm = Math.round((Math.max(...kms) - Math.min(...kms)) / 10) / 100; // m→km, 소수2 + } + } + + // 소요시간: route.json 우선 → 실제 영상 길이 폴백 + const dur = formatDuration(routeInfo?.durationSec ?? videoDuration); + + if (!direction && !name && lengthKm == null && !dur) return null; + + return ( +
+ + {direction &&

{direction}

} + {name &&

{name}

} + {lengthKm != null && ( + <> +

연장

+

{lengthKm}

+

km

+ + )} + {dur && ( + <> +

{dur}

+

소요

+ + )} +
+ ); +} diff --git a/client/src/components/overlay/RoutePanel.tsx b/client/src/components/overlay/RoutePanel.tsx index c092ba5..ea36f2a 100644 --- a/client/src/components/overlay/RoutePanel.tsx +++ b/client/src/components/overlay/RoutePanel.tsx @@ -2,23 +2,19 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { toCameraCoords, pixelFromCamera, - type DroneFrameBasic, DEFAULT_CAMERA_PARAMS, } from '../../utils/geoProjection'; - -interface GeoPoint { - title: string; - category: string; - lat: number; - lon: number; - z: number; - type: string; -} +import { useGeoStore } from '../../store/geoStore'; +import type { GeoPoint } from '../../types/geo'; interface RoutePanelProps { currentTime: number; visible: boolean; onSeek: (time: number) => void; + /** 상단 침범 방지: 컨테이너 top(px). 기본 위쪽(배너/카메라파라미터) 아래. */ + topPx?: number; + /** 하단 침범 방지: 컨테이너 bottom(px). 기본 아래쪽(배속/토글/재생바) 위. */ + bottomPx?: number; } const VIDEO_FPS = 30000 / 1001; @@ -31,6 +27,12 @@ function stationKm(title: string): number { return parseInt(m[1]) * 1000 + parseInt(m[2]); } +/** 미터값 → "158k160" (10m 단위, 재생바 배지와 동일 형식). */ +function formatKm10(m: number): string { + const r = Math.round(m / 10) * 10; + return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`; +} + const CATEGORY_EMOJI: Record = { '\uD130\uB110': '\uD83D\uDE87', '\uAD50\uB7C9': '\uD83C\uDF09', @@ -55,11 +57,14 @@ function poiKm(poi: GeoPoint, stations: GeoPoint[]): number { return Math.round(ka + (kb - ka) * t); } -export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelProps) { - const [stations, setStations] = useState([]); - const [pois, setPois] = useState([]); - const [droneFramesLoaded, setDroneFramesLoaded] = useState(false); - const allDroneFramesRef = useRef([]); +export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, bottomPx = 200 }: RoutePanelProps) { + // 지리정보는 클라이언트 geoStore(폴더 선택 파싱 결과)에서 직접 읽는다. + // 서버 /api/geo/* fetch 대체. 폴더 미선택 시 빈 배열 → idle 렌더. + const loaded = useGeoStore(s => s.loaded); + const stations = useGeoStore(s => s.stations); + const pois = useGeoStore(s => s.pois); + const droneFrames = useGeoStore(s => s.frames); + const routeMeta = useGeoStore(s => s.routeMeta); const [currentKm, setCurrentKm] = useState(0); const [currentStationTitle, setCurrentStationTitle] = useState(''); const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null); @@ -69,29 +74,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP const [dragYPct, setDragYPct] = useState(0); const [dragging, setDragging] = useState(false); - // Load POIs and stations - useEffect(() => { - if (!visible) return; - fetch('/api/geo/pois') - .then(r => r.json()) - .then((data: GeoPoint[]) => { - setStations(data.filter(p => p.type === 'station')); - setPois(data.filter(p => p.type === 'poi')); - }) - .catch(() => {}); - }, [visible]); - - // Load drone frames when visible and not yet loaded - useEffect(() => { - if (!visible || droneFramesLoaded) return; - fetch('/api/geo/frames?step=1') - .then(r => r.json()) - .then((data: DroneFrameBasic[]) => { - allDroneFramesRef.current = data; - setDroneFramesLoaded(true); - }) - .catch(() => {}); - }, [visible, droneFramesLoaded]); + // POI/측점/드론 프레임은 위 geoStore 셀렉터로 구독한다(폴더 데이터 도착 시 자동 재렌더). // 시점/종점: 역사(category=역사) POI 중 km 최소/최대 useEffect(() => { @@ -113,8 +96,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP // Update current km and visible range based on currentTime useEffect(() => { - if (!droneFramesLoaded) return; - const frames = allDroneFramesRef.current; + const frames = droneFrames; if (!frames.length || !stations.length) return; // Find closest frame by time @@ -142,9 +124,26 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP nearestDist = d; } } - setCurrentKm(stationKm(nearestStation.title)); setCurrentStationTitle(nearestStation.title); + // 현재 km = 측점 폴리라인 투영 연속 체이니지 (재생바 StationBar 와 동일 기준). + const sorted = [...validStations].sort((a, b) => stationKm(a.title) - stationKm(b.title)); + const lat0 = sorted.reduce((s, p) => s + p.lat, 0) / sorted.length; + const k = Math.cos((lat0 * Math.PI) / 180) * 111000; + const pts = sorted.map(p => ({ x: p.lon * k, y: p.lat * 111000, km: stationKm(p.title) })); + const cpx = closest.lon * k, cpy = closest.lat * 111000; + let bestD = Infinity, bestKm = pts.length ? pts[0].km : 0; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i], b = pts[i + 1]; + const dx = b.x - a.x, dy = b.y - a.y; + const L2 = dx * dx + dy * dy; + const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((cpx - a.x) * dx + (cpy - a.y) * dy) / L2)); + const ex = a.x + dx * t, ey = a.y + dy * t; + const dd = (cpx - ex) ** 2 + (cpy - ey) ** 2; + if (dd < bestD) { bestD = dd; bestKm = a.km + (b.km - a.km) * t; } + } + setCurrentKm(bestKm); + // Calculate visible range (green box) const allPoints = [...validStations, ...pois]; const visibleKms: number[] = []; @@ -165,7 +164,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP } else { setVisibleRange(null); } - }, [currentTime, droneFramesLoaded, stations, pois]); + }, [currentTime, droneFrames, stations, pois]); // Drag handling const handleMouseDown = useCallback((e: React.MouseEvent) => { @@ -212,7 +211,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP } // Find closest drone frame to that station's lat/lon - const frames = allDroneFramesRef.current; + const frames = droneFrames; if (!frames.length) return; let bestFrame = frames[0]; let bestFrameDist = (bestFrame.lat - bestStation.lat) ** 2 + (bestFrame.lon - bestStation.lon) ** 2; @@ -232,10 +231,10 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; - }, [dragging, stations, pois, onSeek]); + }, [dragging, stations, pois, droneFrames, onSeek]); - // Render guard - if (!visible || stations.length === 0) return null; + // Render guard — 폴더 미선택(!loaded) 또는 측점 없음이면 idle(미표시) + if (!visible || !loaded || stations.length === 0) return null; const validStations = stations.filter(s => stationKm(s.title) >= 0); if (validStations.length < 2) return null; @@ -254,7 +253,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
{/* Center vertical line */}
- {cleanTitle(routeEndTitle)} + {routeMeta?.routeInfo?.endStationName || cleanTitle(routeEndTitle)}
{/* 낮은 km 역명 — 하단 (회덕) */}
- {cleanTitle(routeStartTitle)} + {routeMeta?.routeInfo?.startStationName || cleanTitle(routeStartTitle)}
{/* 교량/터널 POIs — 겹침 방지: Y 간격 7% 미만이면 건너뜀 */} @@ -351,7 +350,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP style={{ position: 'absolute', left: 44 }} className="bg-orange-500 text-white text-[11px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap" > - {cleanTitle(currentStationTitle)} + {formatKm10(currentKm)}
diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx index b3c355b..cda1e68 100644 --- a/client/src/components/overlay/StationOverlay.tsx +++ b/client/src/components/overlay/StationOverlay.tsx @@ -16,21 +16,8 @@ import { type CameraCoords, DEFAULT_CAMERA_PARAMS, } from '../../utils/geoProjection'; - -interface GeoPoint { - title: string; - category: string; - lat: number; - lon: number; - z: number; - type: 'poi' | 'station'; -} - -interface CenterlinePoint { - lat: number; - lon: number; - z: number; -} +import { useGeoStore } from '../../store/geoStore'; +import type { GeoPoint, CenterlinePoint } from '../../types/geo'; const VIDEO_FPS = 30000 / 1001; @@ -39,6 +26,10 @@ interface Props { currentTime: number; fps: number; visible: boolean; + /** 카메라 파라미터 패널 표시 (영상제어 토글). 기본 true. */ + showPanel?: boolean; + /** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */ + topPx?: number; } // category → 이모지 @@ -112,7 +103,7 @@ function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange } // ── 메인 컴포넌트 ───────────────────────────────────────────────────────────── -export default function StationOverlay({ currentFrame, currentTime, fps, visible }: Props) { +export default function StationOverlay({ currentFrame, currentTime, fps, visible, showPanel = true, topPx = 72 }: Props) { const canvasRef = useRef(null); const canvasSizeRef = useRef({ w: 0, h: 0 }); @@ -138,6 +129,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 중심선 + 나침반 렌더 캐시 (per-frame) const renderCacheRef = useRef(null); + // 나침반 전용(측점선 visible 무관 항상 갱신·표시) + const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null); // UI state const [params, setParams] = useState(DEFAULT_CAMERA_PARAMS); @@ -173,43 +166,39 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible return best; }, []); - function updateWorldOrigin() { - const st = allGeoStationsRef.current; - const cl = allCenterlinePointsRef.current; - if (st[0]) worldOriginRef.current = { lat: st[0].lat, lon: st[0].lon, alt: st[0].z }; - else if (cl[0]) worldOriginRef.current = { lat: cl[0].lat, lon: cl[0].lon, alt: cl[0].z }; - } + // 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체) + const storeStations = useGeoStore(s => s.stations); + const storePois = useGeoStore(s => s.pois); + const storeCenterline = useGeoStore(s => s.centerline); + const storeFrames = useGeoStore(s => s.frames); + const storeOrigin = useGeoStore(s => s.origin); - // 데이터 로드 + // 측점 (stationOrder 정렬 — 스토어가 이미 정렬해 두지만 방어적으로 동일 순서 보장) useEffect(() => { - if (!visible) return; - fetch('/api/geo/pois').then(r => r.json()).then((data: GeoPoint[]) => { - allGeoStationsRef.current = (data || []).filter(p => p.type === 'station') - .sort((a, b) => stationOrder(a.title) - stationOrder(b.title)); - allPoisRef.current = (data || []).filter(p => p.type === 'poi'); - updateWorldOrigin(); - setGeoDataLoaded(true); - }).catch(() => {}); - }, [visible]); + allGeoStationsRef.current = [...storeStations] + .sort((a, b) => stationOrder(a.title) - stationOrder(b.title)); + allPoisRef.current = storePois; + setGeoDataLoaded(storeStations.length > 0 || storePois.length > 0); + }, [storeStations, storePois]); + // 중심선 useEffect(() => { - if (!visible) return; - fetch('/api/geo/centerline').then(r => r.json()).then((data: CenterlinePoint[]) => { - allCenterlinePointsRef.current = Array.isArray(data) ? data : []; - updateWorldOrigin(); - setClDataLoaded(true); - }).catch(() => {}); - }, [visible]); + allCenterlinePointsRef.current = storeCenterline; + setClDataLoaded(storeCenterline.length > 0); + }, [storeCenterline]); + // 드론 프레임 useEffect(() => { - if (!visible || droneFramesLoaded) return; - fetch('/api/geo/frames?step=1').then(r => r.json()).then((data: DroneFrameBasic[]) => { - if (Array.isArray(data) && data.length > 0) { - allDroneFramesRef.current = data; - setDroneFramesLoaded(true); - } - }).catch(() => {}); - }, [visible, droneFramesLoaded]); + allDroneFramesRef.current = storeFrames; + setDroneFramesLoaded(storeFrames.length > 0); + }, [storeFrames]); + + // ENU 월드 원점 — 스토어 origin 직접 사용 (서버 getWorldOrigin 대체) + useEffect(() => { + worldOriginRef.current = storeOrigin + ? { lat: storeOrigin.lat, lon: storeOrigin.lon, alt: storeOrigin.alt } + : undefined; + }, [storeOrigin]); // 드론 프레임 이동 평균 (GPS/자세 노이즈 제거) const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => { @@ -307,7 +296,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 현재 재생 시간 → 드론 프레임 ref 갱신 useEffect(() => { - if (!visible || !droneFramesLoaded) return; + // 나침반이 visible 무관 갱신되도록 visible 게이트 제거(프레임 탐색은 가벼움). + if (!droneFramesLoaded) return; const frames = allDroneFramesRef.current; if (!frames.length) return; let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / VIDEO_FPS - currentTime); @@ -324,10 +314,26 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible currentDroneFrameRef.current = best; setPanelDroneFrame(best); } - }, [currentTime, visible, droneFramesLoaded]); + }, [currentTime, droneFramesLoaded]); // 중심선 + 나침반 캐시 빌드 (per-frame, 텍스트 계산 없음) useEffect(() => { + // 나침반(effectiveYaw/hFovRad)은 측점선 visible 과 무관하게 항상 갱신. + const cur = currentDroneFrameRef.current; + if (cur) { + const p = paramsRef.current; + const fr = allDroneFramesRef.current; + const d = fr.length + ? smoothFrame(fr, currentFrameIdxRef.current, smoothHalfRef.current) + : cur; + compassRef.current = { + effectiveYaw: d.yaw + p.yawOffset, + hFovRad: 2 * Math.atan((p.sensorW ?? 36) / (2 * p.focalLen)), + }; + } else { + compassRef.current = null; + } + if (!currentDroneFrameRef.current || !visible) { renderCacheRef.current = null; return; } const t0 = performance.now(); @@ -419,10 +425,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible if (!ctx) return; const { w: W, h: H } = canvasSizeRef.current; ctx.clearRect(0, 0, W, H); - if (!visibleRef.current) return; const cache = renderCacheRef.current; - if (!cache) return; + if (visibleRef.current && cache) { // 선로 중심선 if (cache.centerlineSegs.length > 0) { @@ -508,11 +513,13 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible ctx.textBaseline = 'alphabetic'; }); } + } // end if(visible && cache) — 중심선/측점/POI - // 나침반 HUD — 우측 상단 (상단 '카메라 파라미터' 버튼 아래로 띄워 하단 StationBar/배지와 겹침 방지) - { - const r = 38; - const cx = W-54, cy = 52 + r; + // 나침반 HUD — 우측 상단, 측점선 토글과 무관하게 항상 표시 + const compass = compassRef.current; + if (compass) { + const r = 52; + const cx = W - (r + 14), cy = 16 + r; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke(); @@ -522,7 +529,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible ctx.fillStyle = label==='N' ? '#ff6060' : 'rgba(255,255,255,0.5)'; ctx.fillText(label, cx+Math.cos(rad)*(r-9), cy+Math.sin(rad)*(r-9)); } - const yawRad = (cache.effectiveYaw-90)*Math.PI/180; + const yawRad = (compass.effectiveYaw-90)*Math.PI/180; const tx = cx+Math.cos(yawRad)*(r-14), ty = cy+Math.sin(yawRad)*(r-14); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty); ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2.5; ctx.stroke(); @@ -532,29 +539,15 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible ctx.moveTo(tx, ty); ctx.lineTo(tx-hl*Math.cos(yawRad+ha), ty-hl*Math.sin(yawRad+ha)); ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2; ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx, cy); - ctx.arc(cx, cy, r-2, yawRad-cache.hFovRad/2, yawRad+cache.hFovRad/2); ctx.closePath(); + ctx.arc(cx, cy, r-2, yawRad-compass.hFovRad/2, yawRad+compass.hFovRad/2); ctx.closePath(); ctx.fillStyle = 'rgba(255,215,0,0.12)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,215,0,0.35)'; ctx.lineWidth = 1; ctx.stroke(); ctx.font = '9px monospace'; ctx.textBaseline = 'top'; ctx.fillStyle = '#ffd700'; - ctx.fillText(`${((cache.effectiveYaw+360)%360).toFixed(1)}°`, cx, cy+r+2); + ctx.fillText(`${((compass.effectiveYaw+360)%360).toFixed(1)}°`, cx, cy+r+2); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; } - // 범례 - if (cache.clCount > 0 || cache.poiCount > 0) { - const lines = [ - ...(cache.clCount > 0 ? [{ color: 'rgba(255,60,60,0.9)', text: `— 선로중심선 (${cache.clCount}점)` }] : []), - ...(cache.poiCount > 0 ? [{ color: '#64c8ff', text: `+ 지장물 ${cache.poiCount}개` }] : []), - ]; - // 좌상단 타임코드(HTML) 아래로 배치 (코너 HUD 정보 그룹) - const legendTop = 34; - ctx.fillStyle = 'rgba(0,0,0,0.6)'; - ctx.fillRect(6, legendTop, 160, lines.length*15+6); - lines.forEach(({ color, text }, i) => { - ctx.font = '10px sans-serif'; ctx.fillStyle = color; - ctx.fillText(text, 12, legendTop + 14 + i*15); - }); - } + // 범례(선로중심선/지장물 개수)는 좌상단 카메라파라미터·스테이션맵과 겹쳐 제거함. }; rafId = requestAnimationFrame(draw); @@ -566,11 +559,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible -
+ {showPanel && ( +
{showControls && ( @@ -618,6 +612,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
)}
+ )} ); } diff --git a/client/src/components/player/VideoPlayer.tsx b/client/src/components/player/VideoPlayer.tsx index 25a5727..174ed30 100644 --- a/client/src/components/player/VideoPlayer.tsx +++ b/client/src/components/player/VideoPlayer.tsx @@ -1,11 +1,13 @@ -import React, { useRef, useImperativeHandle, forwardRef, useState } from 'react'; +import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect } from 'react'; import StationOverlay from '../overlay/StationOverlay'; import RoutePanel from '../overlay/RoutePanel'; +import RouteInfoOverlay from '../overlay/RouteInfoOverlay'; import 'video.js/dist/video-js.css'; import { useVideoPlayer } from '../../hooks/useVideoPlayer'; import { useFrameStep } from '../../hooks/useFrameStep'; import { useKeyboard } from '../../hooks/useKeyboard'; import { usePlayerStore } from '../../store/playerStore'; +import { useGeoStore } from '../../store/geoStore'; import { captureFrame, downloadDataUrl } from '../../utils/frameCapture'; import { secondsToTimecode, secondsToFrame } from '../../utils/timecode'; import { useCaptureStore } from '../../store/captureStore'; @@ -35,6 +37,79 @@ const VideoPlayer = forwardRef( const { stepForward, stepBackward, fps } = useFrameStep(playerRef); const { currentTime, duration, playing, source, playbackRate } = usePlayerStore(); + // 커서 매끄러운 이동: + // - 라이브 시간(smoothTimeRef): 매 프레임 '단조' 벽시계 보간. StationBar가 ref로 읽어 + // 커서/진행바를 직접(transform) 갱신 → React 60fps 리렌더 없이 매끄럽게. + // - state(smoothTime): 배지 숫자·색 변경용으로만 throttle(≈10fps) 갱신. + const [smoothTime, setSmoothTime] = useState(0); + const smoothTimeRef = useRef(0); + const anchorRef = useRef({ media: 0, wall: 0 }); + const lastSetRef = useRef(0); + useEffect(() => { + let raf = 0; + // 시크(클릭/드래그 이동) 시 커서를 즉시 그 위치로 재동기화. + // (단조 보간은 앵커보다 앞쪽으로의 뒤로가기 시크를 무시하므로 별도 처리 필요) + const onSeeked = (): void => { + const p = playerRef.current; + if (!p || p.isDisposed()) return; + const t = p.currentTime() ?? 0; + anchorRef.current = { media: t, wall: performance.now() }; + smoothTimeRef.current = t; + lastSetRef.current = t; + setSmoothTime(t); + }; + playerRef.current?.on('seeked', onSeeked); + const tick = (): void => { + const p = playerRef.current; + if (p && !p.isDisposed()) { + const dur = p.duration() ?? 0; + let t: number; + if (p.paused()) { + t = p.currentTime() ?? 0; + anchorRef.current = { media: t, wall: performance.now() }; + } else { + const a = anchorRef.current; + const rate = p.playbackRate() ?? 1; + let est = a.media + ((performance.now() - a.wall) / 1000) * rate; + const real = p.currentTime() ?? 0; + // 단조: 작은 역행은 무시(흔들림 방지). 뒤처짐(real이 앞섬) 또는 시크(뒤로)만 재동기화. + if (real - est > 0.3 || real < a.media - 0.3) { + est = real; + anchorRef.current = { media: real, wall: performance.now() }; + } + t = dur > 0 ? Math.min(est, dur) : est; + } + smoothTimeRef.current = t; + if (Math.abs(t - lastSetRef.current) >= 0.1) { + lastSetRef.current = t; + setSmoothTime(t); + } + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(raf); + playerRef.current?.off('seeked', onSeeked); + }; + }, [playerRef, source]); + const loadFromFolder = useGeoStore((s) => s.loadFromFolder); + const geoLoaded = useGeoStore((s) => s.loaded); + // 하단 도구 패널: UI에서 숨김(코드는 보존). 다시 표시하려면 true 로. + const SHOW_TOOLBAR = false; + + // 폴더 선택: 지리정보 파싱 + 영상 재생 + const handleSelectFolder = async (files: File[]) => { + if (!files.length) return; + try { + const videoFile = await loadFromFolder(files); + if (videoFile) loadLocalFile(videoFile); + else console.warn('[geo] 폴더에 영상 파일(mp4/webm)이 없습니다 — 지리정보만 로드'); + } catch (err) { + console.error('[geo] 폴더 로드 실패', err); + } + }; + // Expose methods to parent via ref useImperativeHandle(ref, () => ({ loadLocalFile, @@ -65,6 +140,33 @@ const VideoPlayer = forwardRef( const handleAddMemo = () => onAddMemo(currentTime); const [showStations, setShowStations] = useState(true); + // 영상제어 토글: 카메라 파라미터·프레임상태·배속 표시 on/off + const [showVideoControls, setShowVideoControls] = useState(true); + // 좌하단 그룹을 재생바 위에 두기 위해 StationBar 높이를 동적 측정. + const barWrapRef = useRef(null); + const [barHeight, setBarHeight] = useState(0); + useEffect(() => { + const el = barWrapRef.current; + if (!el) return; + const update = (): void => setBarHeight(el.offsetHeight); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, [geoLoaded]); + // 노선 배너(429.5×65 @1920 비율) 높이만큼 카메라 파라미터를 아래로. + const [stageWidth, setStageWidth] = useState(0); + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + const update = (): void => setStageWidth(el.clientWidth); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + const paramTop = Math.round((stageWidth / 1920) * 65) + 10; + const [showUtilBar, setShowUtilBar] = useState(false); const handleTogglePlay = (): void => { const p = playerRef.current; @@ -108,63 +210,97 @@ const VideoPlayer = forwardRef( return (
e.preventDefault()} > - {/* 영상 영역 — 타임코드를 '영상 하단'에 앵커하기 위한 relative 래퍼 */} -
- {/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */} + {/* 영상 영역 — 컨테이너 전체를 채우는 relative 래퍼 (사이니지: 화면 가득) */} +
+ {/* Video.js container — 영상이 영역을 꽉 채우는 베이스 레이어 (object-fit:cover) */} {/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
{ if (source) handleTogglePlay(); }} /> - {/* 프레임/타임코드 — 영상 좌상단 코너 HUD (정보 그룹) */} + {/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */} + + {/* 좌하단 그룹(재생바 위): 위→아래 = 배속 → 토글(측점선/영상제어) + 프레임정보 */} {source && ( -
- {secondsToTimecode(currentTime)} | F{frame} | {fps}fps -
- )} - {/* 재생 배속 — 영상 우하단 (항상 보임). 현재 배속은 파랗게 강조 */} - {source && ( -
- 배속 - {[1, 1.5, 2, 3, 4].map((r) => ( +
+ {/* 재생 배속 (영상제어 ON) */} + {showVideoControls && ( +
+ 배속 + {[0.5, 1, 1.5, 2, 3, 4].map((r) => ( + + ))} +
+ )} + {/* 토글: 영상제어(카메라파라미터·프레임상태·배속) */} +
- ))} + onClick={() => setShowVideoControls((v) => !v)} + className={`text-xs px-2 py-1 rounded border font-semibold ${showVideoControls ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`} + >영상제어 {showVideoControls ? 'ON' : 'OFF'} + {/* 프레임/타임코드 — 영상제어 ON 버튼 오른쪽 */} + {showVideoControls && ( + + {secondsToTimecode(currentTime)} | F{frame} | {fps}fps + + )} +
)} - {/* 루트 패널 미니맵 — 영상 영역 기준(아래 StationBar 침범 방지) */} + {/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */} playerRef.current?.currentTime(time)} + topPx={paramTop + 40} + bottomPx={(barHeight || 130) + 90} />
- {/* Empty state placeholder */} + {/* Empty state placeholder — 가운데 반투명 폴더 선택 */} {!source && ( -
-
-

동영상을 드래그하거나 아래에서 선택하세요

-

로컬 파일 또는 서버 영상 재생 지원

+
+
+

동영상 폴더를 드래그하거나 선택하세요

+

영상 + 측점/POI 데이터가 함께 로드됩니다

+
)} @@ -174,11 +310,16 @@ const VideoPlayer = forwardRef( currentTime={currentTime} fps={fps} visible={showStations} + showPanel={showVideoControls && !!source} + topPx={paramTop} /> - {/* 측점 기반 재생 바 — videoplayer-main 스테이션 바 이식 (시간 스크러버 대체) */} + {/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */} + {geoLoaded && ( +
( showStations={showStations} onToggleStations={() => setShowStations((v) => !v)} /> +
+ )} - {/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) */} -
-
); } diff --git a/client/src/hooks/useVideoPlayer.ts b/client/src/hooks/useVideoPlayer.ts index 915da37..c55d2a0 100644 --- a/client/src/hooks/useVideoPlayer.ts +++ b/client/src/hooks/useVideoPlayer.ts @@ -25,13 +25,14 @@ export function useVideoPlayer(containerRef: React.RefObject; duration: number; playing: boolean; onTogglePlay: () => void; @@ -122,6 +127,23 @@ function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number { return best; } +/** station 값(숫자=미터, "158k400" 문자열) → 미터. 실패 시 -1. */ +function mileageToMeters(v: number | string): number { + if (typeof v === 'number') return v; + const m = String(v).match(/(\d+)\s*[kK]\s*(\d+)/); + if (m) return parseInt(m[1], 10) * 1000 + parseInt(m[2], 10); + const n = parseInt(String(v).replace(/[^0-9]/g, ''), 10); + return Number.isFinite(n) ? n : -1; +} + +/** 미터값 → "158k710" (10m 단위 반올림). 커서 배지용 십단위 표시. */ +function formatMileage10(m: number): string { + const r = Math.round(m / 10) * 10; + const km = Math.floor(r / 1000); + const mm = r % 1000; + return `${km}k${String(mm).padStart(3, '0')}`; +} + /** * abcVideo 실제 영상 데이터 기반 측점 바. * - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색). @@ -131,6 +153,7 @@ function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number { */ export function StationBar({ currentTime, + timeRef, duration, playing, onTogglePlay, @@ -145,28 +168,18 @@ export function StationBar({ const [scale, setScale] = useState(0.5); const draggingRef = useRef(false); - const [pois, setPois] = useState([]); - const framesRef = useRef([]); - const [framesVersion, setFramesVersion] = useState(0); + // 측점/POI/드론프레임은 폴더 선택으로 채워지는 geoStore에서 읽는다 (서버 /api/geo fetch 대체). + const storePois = useGeoStore((s) => s.pois); + const storeStations = useGeoStore((s) => s.stations); + const pois = useMemo( + () => [...storeStations, ...storePois], + [storeStations, storePois], + ); + const storeFrames = useGeoStore((s) => s.frames); + const routeMeta = useGeoStore((s) => s.routeMeta); const viewedRef = useRef([]); const [ready, setReady] = useState(false); - useEffect(() => { - fetch('/api/geo/pois') - .then((r) => r.json()) - .then((data: GeoPoint[]) => setPois(Array.isArray(data) ? data : [])) - .catch(() => {}); - }, []); - useEffect(() => { - fetch(`/api/geo/frames?step=${FRAME_STEP}`) - .then((r) => r.json()) - .then((data: DroneFrameBasic[]) => { - framesRef.current = Array.isArray(data) ? data : []; - setFramesVersion((v) => v + 1); - }) - .catch(() => {}); - }, []); - const stations = useMemo( () => pois.filter((p) => p.type === 'station' && stationKm(p.title) >= 0), [pois], @@ -188,8 +201,8 @@ export function StationBar({ // - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일). // - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확). useEffect(() => { - const frames = framesRef.current; - if (!frames.length || !stations.length || !stationLine) return; + const frames = storeFrames; + if (!frames.length || !stations.length || !stationLine) { setReady(false); return; } const out: ViewedPoint[] = new Array(frames.length); for (let i = 0; i < frames.length; i++) { const f = frames[i]; @@ -202,7 +215,7 @@ export function StationBar({ } viewedRef.current = out; setReady(true); - }, [stations, stationLine, framesVersion]); + }, [stations, stationLine, storeFrames]); /** 현재 시간에 가장 가까운 precompute 인덱스. */ const viewedIdxAtTime = useCallback((t: number): number => { @@ -221,11 +234,11 @@ export function StationBar({ return best; }, []); - // 현재 보는 측점 km - const realKm = useMemo(() => { + // 현재 보는 측점값 — 연속 체이니지(chain)로 10m 해상도 (km 필드는 100m 양자화). + const realChain = useMemo(() => { if (!ready) return null; const idx = viewedIdxAtTime(currentTime); - return idx >= 0 ? viewedRef.current[idx].km : null; + return idx >= 0 ? viewedRef.current[idx].chain : null; }, [currentTime, ready, viewedIdxAtTime]); // 프레임(시간) 진행률 px — 커서의 단조 이동 기준. @@ -242,10 +255,10 @@ export function StationBar({ // → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다. const cursorPx = progressPx; + // 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. + // mock ROUTE_LEGS(mileageAtPx) 의존 제거. 데이터 없으면 빈 문자열. const cursorText = - realKm !== null && realKm >= 0 - ? formatMileage(realKm) - : formatMileage(mileageAtPx(cursorPx)); + realChain !== null && realChain >= 0 ? formatMileage10(realChain) : ''; // ── 데이터 기반 측점 바 ────────────────────────────────────────── // 시간(프레임) 진행을 트랙 px로 선형 변환. @@ -269,10 +282,15 @@ export function StationBar({ // 방향은 연속 체이니지(chain)로 판정 — 100m 양자화 km은 전환을 ~13s 빨리 잡아 영상과 어긋남. const HYST = 100; // m — 이 이상 반대로 움직여야 방향 전환으로 인정 (GPS 노이즈 무시) const segs: BarSegment[] = []; - const bounds: { chain: number; time: number }[] = [ - { chain: arr[0].chain, time: arr[0].time }, + const bounds: { chain: number; time: number; turn: boolean }[] = [ + { chain: arr[0].chain, time: arr[0].time, turn: false }, ]; + // 시작 방향을 실제 데이터(첫 유의미 이동)로 판정. 기본 증가 가정이 틀리면 첫 구간 색이 반대로 나옴. let dir: 1 | -1 = 1; + for (let i = 1; i < arr.length; i++) { + const d = arr[i].chain - arr[0].chain; + if (Math.abs(d) >= HYST) { dir = d > 0 ? 1 : -1; break; } + } let extCh = arr[0].chain; let extIdx = 0; let startIdx = 0; @@ -287,7 +305,7 @@ export function StationBar({ endPx: pxAtTime(arr[extIdx].time), dir, }); - bounds.push({ chain: extCh, time: arr[extIdx].time }); + bounds.push({ chain: extCh, time: arr[extIdx].time, turn: true }); dir = dir > 0 ? -1 : 1; startIdx = extIdx; extCh = c; @@ -299,76 +317,217 @@ export function StationBar({ endPx: pxAtTime(arr[arr.length - 1].time), dir, }); - bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time }); - // 라벨은 전환점 체이니지를 최근접 측점(100m)으로 스냅해 표시. + bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time, turn: false }); + // 전환(턴) 지점은 실제 위치를 10m 단위로, 시·종점 등 기본 라벨은 100m 단위로 표시. const labels: KmLabel[] = bounds.map((b) => ({ px: pxAtTime(b.time), - text: formatMileage(Math.round(b.chain / 100) * 100), + text: b.turn ? formatMileage10(b.chain) : formatMileage(Math.round(b.chain / 100) * 100), })); return { barSegments: segs, kmLabels: labels }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready, duration, framesVersion, pxAtTime]); + }, [ready, duration, storeFrames, pxAtTime]); - // 구조물(교량/터널/역사) → 체이니지(최근접 측점 km) → 통과 시점 px. - const structureMarks = useMemo(() => { - const arr = viewedRef.current; - if (!ready || !arr.length || duration <= 0 || !stations.length) return []; - const cats = new Set(['교량', '터널', '역사']); - const seen = new Set(); - const out: StructMark[] = []; - for (const p of pois) { - if (!cats.has(p.category)) continue; - // (상)/(하)/(인상) 등 변형은 같은 구조물 → base 이름으로 통합(중복 라벨 제거). - const base = p.title.replace(/\s*[((].*$/, '').trim(); - if (seen.has(base)) continue; - seen.add(base); - const chain = nearestStationKm( - { lat: p.lat, lon: p.lon } as DroneFrameBasic, - stations, - ); - if (chain < 0) continue; + // viewedRef의 km(최근접 측점)가 주어진 mileage(m)에 가장 가까운 프레임 시간 → px. + const pxAtMileage = useCallback( + (mileage: number): number | null => { + const arr = viewedRef.current; + if (!arr.length) return null; let best = -1; let bd = Infinity; for (let i = 0; i < arr.length; i++) { - const d = Math.abs(arr[i].km - chain); + const d = Math.abs(arr[i].km - mileage); if (d < bd) { bd = d; best = i; } } - if (best >= 0) - out.push({ px: pxAtTime(arr[best].time), title: base, category: p.category }); + return best >= 0 ? pxAtTime(arr[best].time) : null; + }, + [pxAtTime], + ); + + // 구조물(교량/터널/역사) 위치 = 실제 좌표(POI 위경도)에 드론 GPS 가 가장 가까워지는 + // 프레임의 시점 px. → 영상에 구조물이 실제 지나가는 순간과 일치. + // route.json structures 의 이정값은 매칭되는 POI 가 없을 때만 폴백으로 사용. + const structureMarks = useMemo(() => { + const arr = viewedRef.current; + const frames = storeFrames; + if (!ready || !arr.length || duration <= 0 || !frames.length) return []; + + // 실좌표(lat/lon)에 드론이 가까워지는 '모든 통과 구간'의 px 목록. + // (드론이 같은 구조물을 2번 이상 지날 때 각 통과마다 마커를 찍기 위함) + const pxPassesTo = (lat: number, lon: number, offsetM = 0): { px: number; km: number }[] => { + const cosLat = Math.cos((lat * Math.PI) / 180); + const ds: number[] = new Array(frames.length); + let gmin = Infinity; + for (let i = 0; i < frames.length; i++) { + const dy = (frames[i].lat - lat) * 111000; + const dx = (frames[i].lon - lon) * 111000 * cosLat; + const d = Math.hypot(dx, dy); // m + ds[i] = d; + if (d < gmin) gmin = d; + } + if (!isFinite(gmin)) return []; + const TH = Math.max(100, gmin * 3); // 통과 인정 거리(m) + // 통과 프레임에서 진행방향으로 offsetM 만큼 이동(GPS 누적거리 기준). + const shift = (idx: number): number => { + if (!offsetM) return idx; + const step = offsetM >= 0 ? 1 : -1; + const target = Math.abs(offsetM); + let fi = idx, acc = 0; + while (acc < target) { + const n = fi + step; + if (n < 0 || n >= frames.length) break; + const dy = (frames[n].lat - frames[fi].lat) * 111000; + const dx = (frames[n].lon - frames[fi].lon) * 111000 * cosLat; + acc += Math.hypot(dx, dy); + fi = n; + } + return fi; + }; + const out: { px: number; km: number }[] = []; + let i = 0; + while (i < frames.length) { + if (ds[i] < TH) { + let j = i, bj = i, bd = ds[i]; + while (j < frames.length && ds[j] < TH) { + if (ds[j] < bd) { bd = ds[j]; bj = j; } + j++; + } + const fi = shift(bj); + const px = pxAtTime(frames[fi].frame / VIDEO_FPS); + if (px !== null) out.push({ px, km: viewedRef.current[fi]?.chain ?? -1 }); + i = j; + } else i++; + } + return out; + }; + + // 측점값(미터)에 해당하는 모든 통과 지점 px (연속 체이니지 기준). + const pxPassesAtMileage = (targetM: number): { px: number; km: number }[] => { + const arr = viewedRef.current; + if (!arr.length) return []; + // 측점 인정 범위(m): route.json routeInfo.stationTolerance (폴더별), 기본 20. + // 너무 크면 인접 통과가 합쳐져 마커가 밀린다 → 멀리 떨어진 통과만 안 잡힐 때 폴더에서 키울 것. + const TH = routeMeta?.routeInfo?.stationTolerance ?? 20; + const out: { px: number; km: number }[] = []; + let i = 0; + while (i < arr.length) { + if (Math.abs(arr[i].chain - targetM) < TH) { + let j = i, bj = i, bd = Math.abs(arr[i].chain - targetM); + while (j < arr.length && Math.abs(arr[j].chain - targetM) < TH) { + const d = Math.abs(arr[j].chain - targetM); + if (d < bd) { bd = d; bj = j; } + j++; + } + const px = pxAtTime(arr[bj].time); + if (px !== null) out.push({ px, km: targetM }); + i = j; + } else i++; + } + return out; + }; + + // 구조물 후보 POI(교량/터널/역사) base 이름 → 실좌표. + const cats = new Set(['교량', '터널', '역사']); + const poiByName = new Map(); + for (const p of pois) { + if (!cats.has(p.category)) continue; + const base = p.title.replace(/\s*[((].*$/, '').trim(); + if (!poiByName.has(base)) poiByName.set(base, { lat: p.lat, lon: p.lon, category: p.category }); + } + + const metaStructures = routeMeta?.structures; + if (metaStructures && metaStructures.length) { + const out: StructMark[] = []; + for (const s of metaStructures) { + const cat = s.type === 'tunnel' ? '터널' : s.type === 'bridge' ? '교량' : '역사'; + const sBase = s.name.replace(/\s*[((].*$/, '').trim(); + // 이름 매칭 POI 실좌표 우선 → 없으면 route.json 이정값 폴백. + const match = + poiByName.get(sBase) ?? + [...poiByName.entries()].find(([k]) => k.includes(sBase) || sBase.includes(k))?.[1]; + // 우선순위: station(측점값) → 직접 좌표 → POI 이름매칭 → 이정값 폴백. + // 각 통과 지점마다 마커(드론이 2번 지나면 2개). 동명 시설물은 각자 station 으로 구분. + let passes: { px: number; km: number }[] = []; + const off = s.offset ?? 0; + if (s.station != null) { + const sM = mileageToMeters(s.station); + if (sM >= 0) passes = pxPassesAtMileage(sM); + } + if (!passes.length && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off); + if (!passes.length && match) passes = pxPassesTo(match.lat, match.lon, off); + if (!passes.length && s.startMileage != null && s.endMileage != null) { + const mid = (s.startMileage + s.endMileage) / 2; + const px = pxAtMileage(mid); + if (px !== null) passes = [{ px, km: mid }]; + } + for (const p of passes) out.push({ px: p.px, title: s.name, category: cat, km: p.km }); + } + return out; + } + + // 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과). + const out: StructMark[] = []; + for (const [base, info] of poiByName) { + for (const p of pxPassesTo(info.lat, info.lon)) { + out.push({ px: p.px, title: base, category: info.category, km: p.km }); + } } return out; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready, duration, framesVersion, pois, stations, pxAtTime]); + }, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, pxAtMileage]); - // 방향 색 트랙을 CSS linear-gradient 로 구성. 구간 경계(드론 회전, 측점 변화 ~0, - // 약 3~4초)는 딱딱한 경계 대신 부드러운 그라데이션으로 주황↔하늘색이 섞이게 한다. - const trackGradient = useMemo(() => { - const segs = barSegments; - if (!segs.length) return ''; - const FWD = '#ff8a25'; // 전진(km 증가) 주황 - const BWD = '#06a4c8'; // 후진(km 감소) 하늘색 - const col = (d: 1 | -1) => (d > 0 ? FWD : BWD); - // 전환 폭 ≈ 4초 (회전 소요시간). 최소/최대 px 로 가시성 보장. - const transPx = duration > 0 ? clamp((4 / duration) * TRACK_WIDTH_PX, 6, 40) : 10; - const pct = (px: number) => - clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100); - const stops: string[] = [`${col(segs[0].dir)} 0%`]; - for (let i = 1; i < segs.length; i++) { - const b = segs[i].startPx; - const w = Math.min( - transPx / 2, - (segs[i - 1].endPx - segs[i - 1].startPx) / 2, - (segs[i].endPx - segs[i].startPx) / 2, - ); - stops.push(`${col(segs[i - 1].dir)} ${pct(b - w).toFixed(2)}%`); - stops.push(`${col(segs[i].dir)} ${pct(b + w).toFixed(2)}%`); - } - stops.push(`${col(segs[segs.length - 1].dir)} 100%`); - return `linear-gradient(to right, ${stops.join(', ')})`; - }, [barSegments, duration]); + // 방향 색 트랙 gradient 빌더(전진/후진 색을 받아 구성). 구간 경계는 부드럽게 섞는다. + const buildGradient = useCallback( + (FWD: string, BWD: string): string => { + const segs = barSegments; + if (!segs.length) return ''; + const col = (d: 1 | -1) => (d > 0 ? FWD : BWD); + const pct = (px: number) => + clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100); + // 전환점에서 색이 '한번에' 바뀌도록 하드 스톱(같은 위치에 두 색). + const stops: string[] = [`${col(segs[0].dir)} 0%`]; + for (let i = 1; i < segs.length; i++) { + const bp = pct(segs[i].startPx).toFixed(2); + stops.push(`${col(segs[i - 1].dir)} ${bp}%`); + stops.push(`${col(segs[i].dir)} ${bp}%`); + } + stops.push(`${col(segs[segs.length - 1].dir)} 100%`); + return `linear-gradient(to right, ${stops.join(', ')})`; + }, + [barSegments, duration], + ); + // 통과 구간 음영 그라데이션(videoplayer 원본): 구간마다 좌→우 3색 셰이딩, 구간 경계는 하드. + // 전진 주황: #ffc257 → #ff8a25 → #ff7b1b / 역방향 청록: #5ca887 → #35a7a7 → #06a4c8 + const buildShaded = useCallback( + (fwd: [string, string, string], bwd: [string, string, string]): string => { + const segs = barSegments; + if (!segs.length) return ''; + const cols = (d: 1 | -1) => (d > 0 ? fwd : bwd); + const pct = (px: number) => + clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100); + const stops: string[] = []; + for (const s of segs) { + const a = pct(s.startPx); + const b = pct(s.endPx); + const c = cols(s.dir); + stops.push(`${c[0]} ${a.toFixed(2)}%`); + stops.push(`${c[1]} ${((a + b) / 2).toFixed(2)}%`); + stops.push(`${c[2]} ${b.toFixed(2)}%`); + } + return `linear-gradient(to right, ${stops.join(', ')})`; + }, + [barSegments], + ); + // 통과(지나간): 구간별 음영 그라데이션. 미통과: 단색(전진 회색 / 역방향 청회색). + const trackGradient = useMemo( + () => buildShaded(['#ffc257', '#ff8a25', '#ff7b1b'], ['#5ca887', '#35a7a7', '#06a4c8']), + [buildShaded], + ); + const trackGradientIdle = useMemo(() => buildGradient('#7a7a7a', '#637789'), [buildGradient]); + // 방향(색)이 바뀌는 전환점 px — 구분선 위치. + const dividers = useMemo(() => barSegments.slice(1).map((s) => s.startPx), [barSegments]); // 현재 위치가 역방향(km 감소, 하늘색) 구간이면 커서도 파란색. const currentReverse = useMemo(() => { @@ -390,6 +549,43 @@ export function StationBar({ return () => ro.disconnect(); }, []); + // 구조물 아이콘(3-slice PNG) 상태별 이미지를 미리 로드해, passed 전환 시 + // 새 이미지 로딩으로 인한 깜빡임(잠시 사라짐)을 방지한다. + useEffect(() => { + const base = import.meta.env.BASE_URL; + for (const type of ['bridge', 'tunnel']) { + for (const st of ['upcoming', 'passed', 'revisit']) { + for (const slice of ['left', 'center', 'right']) { + const img = new Image(); + img.src = `${base}assets/route-segment/${type}/${type}-${st}-${slice}.png`; + } + } + } + for (const st of ['upcoming', 'passed', 'revisit']) { + const img = new Image(); + img.src = `${base}assets/route-segment/terminal/circle-${st}.png`; + } + }, []); + + // 커서/진행바 매끄러운 이동: 라이브 시간(timeRef)을 매 프레임 읽어 CSS 변수만 직접 갱신. + // React 리렌더(배지/색)는 currentTime(throttle)으로 별도 처리 → 4K 디코딩 중에도 부드러움. + useEffect(() => { + if (!timeRef) return; + let raf = 0; + const tick = (): void => { + const el = wrapRef.current; + if (el && duration > 0) { + const t = timeRef.current ?? 0; + const pos = TRACK_START_PX + clamp(t / duration, 0, 1) * TRACK_WIDTH_PX; + el.style.setProperty('--pos-px', `${pos}px`); + el.style.setProperty('--cursor-x', `${renderX(pos)}px`); + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [timeRef, duration]); + // 클릭/드래그 트랙 px → 시간(프레임) 선형 변환으로 seek (시간축 일관). const seekToTrackX = useCallback( (trackX: number) => { @@ -435,15 +631,30 @@ export function StationBar({ [seekFromClientX], ); + // 터미널 역명: route.json routeInfo 우선, 없으면 측점(stationKm 정렬) 첫/끝 title. + const { startStationName, endStationName } = useMemo(() => { + const sorted = [...stations].sort( + (a, b) => stationKm(a.title) - stationKm(b.title), + ); + const firstTitle = sorted.length ? sorted[0].title : ''; + const lastTitle = sorted.length ? sorted[sorted.length - 1].title : ''; + return { + startStationName: routeMeta?.routeInfo?.startStationName ?? firstTitle, + endStationName: routeMeta?.routeInfo?.endStationName ?? lastTitle, + }; + }, [stations, routeMeta]); + // 측점입력(예: 158k200) → 그 측점을 보는(최근접) 프레임으로 seek (실데이터 기반). const handleJumpToMileage = useCallback( (km: number) => { const arr = viewedRef.current; if (!arr.length || duration <= 0) return; + // 연속 체이니지(chain) 기준으로 입력 측점에 가장 가까운 프레임 탐색. + // (km은 100m 양자화 최근접 측점이라 입력값과 오차 발생) let best = -1; let bd = Infinity; for (let i = 0; i < arr.length; i++) { - const d = Math.abs(arr[i].km - km); + const d = Math.abs(arr[i].chain - km); if (d < bd) { bd = d; best = i; @@ -475,12 +686,15 @@ export function StationBar({ posPx={cursorPx} onSeekDown={handleSeekDown} trackGradient={trackGradient} + trackGradientIdle={trackGradientIdle} + dividers={dividers} kmLabels={kmLabels} structures={structureMarks} + startStationName={startStationName} + endStationName={endStationName} />
) => void; - /** 방향 색 트랙 CSS gradient (전진=주황/후진=하늘색, 회전구간 부드러운 전환). */ + /** 통과 구간 트랙 gradient (전진=주황/후진=청록). */ trackGradient: string; + /** 미통과 구간 트랙 gradient (전진=회색/후진=청회색). */ + trackGradientIdle: string; + /** 색(방향)이 바뀌는 전환점 px(스테이지 좌표) — 구분선 위치. */ + dividers: number[]; /** 데이터 기반 측점값 라벨 (방향 전환점·시종점). */ kmLabels: KmLabel[]; /** 데이터 기반 구조물 (교량/터널/역사). */ structures: StructMark[]; + /** 시점 역명 (route.json routeInfo 우선, 없으면 첫 측점 title). */ + startStationName?: string; + /** 종점 역명 (route.json routeInfo 우선, 없으면 끝 측점 title). */ + endStationName?: string; } -export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structures }: TimelineProps) { +export function Timeline({ + posPx, + onSeekDown, + trackGradient, + trackGradientIdle, + dividers, + kmLabels, + structures, + startStationName, + endStationName, +}: TimelineProps) { // 라벨 겹침 방지: x px 순으로 gap px 이내는 1개만 표시. const dedup = (items: T[], gap: number): T[] => { const sorted = [...items].sort((a, b) => a.px - b.px); @@ -32,7 +60,6 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure } return out; }; - const labels = dedup(kmLabels, 28); // 구조물명은 텍스트가 길어 더 넓은 간격으로 (겹침 방지). const structs = dedup(structures, 90); @@ -41,7 +68,7 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure {/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
- {/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 신탄진·대전 배치 */} + {/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 시·종점 역명 배치 (route.json/측점 유래) */}
-
신탄진
-
-
-
-
-
대전
+ {/* 좌측(시점)=역방향 하늘색, 우측(종점)=정방향 주황 */} +
{startStationName ?? ''}
+
+
+
+
+
{endStationName ?? ''}
{/* 트랙 본체 (가로 압축 래퍼) */} @@ -76,7 +104,7 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure {/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색. 전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
- {/* 미재생: 방향색 저톤 (전체 폭) */} + {/* 미재생(미통과): 회색/청회색 (전체 폭) */}
- {/* 재생된 구간: 원래 색 복원 (커서까지 clip) */} + {/* 재생된 구간: 원래 색 복원 (커서까지 clip). 폭은 CSS 변수(--pos-px)로 매 프레임 + 직접 갱신되어 React 리렌더 없이 부드럽게 늘어난다. */}
@@ -114,30 +141,115 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure }} />
-
- - {/* 측점값 라벨 (데이터) */} -
- {labels.map((l, i) => ( - ( +
))}
- {/* 구조물 라벨 (데이터: 교량/터널/역사) */} + {/* 측점값 라벨 — 각 시설물 측점값(아이콘 위) */} +
+ {structs.map((s, i) => + s.km >= 0 ? ( + + ) : null, + )} +
+ + {/* 구조물 아이콘 — 재생바 위에 겹쳐 표시 (videoplayer 3-slice) */} +
+ {structs.map((s, i) => { + // 두 상태(무채색 upcoming / 유채색 passed) 아이콘을 겹쳐두고 opacity 로 전환 → + // 배경이미지 교체가 없어 전환 순간 깜빡임(사라짐)이 없다. + // 역사/터미널 → 원형 아이콘 + if (s.category === '역사' || s.category === '역') { + const D = 18; + // 라벨과 동일 트리거(라벨 W=22 → s.px-11). 아이콘·라벨 동시 전환. + const passed = posPx >= s.px - 11; + const base = import.meta.env.BASE_URL; + const common = { + position: 'absolute' as const, + left: s.px - D / 2, + top: 40.5 - D / 2, + width: D, + height: D, + transition: 'opacity 0.12s', + }; + return ( + + + + + ); + } + const segType: StructureType | null = + s.category === '터널' ? 'tunnel' : s.category === '교량' ? 'bridge' : null; + if (!segType) return null; + // 원본(videoplayer) 치수: 교량 22×16/cap6/top32.5, 터널 24×14/cap10/top33.75 + const isTunnel = segType === 'tunnel'; + const W = isTunnel ? 24 : 22; + const H = isTunnel ? 14 : 16; + const CAP = isTunnel ? 10 : 6; + const TOP = isTunnel ? 33.75 : 32.5; + const passed = posPx >= s.px - W / 2; + const seg: StructureSegment = { + id: `st-${i}`, + type: segType, + label: s.title, + startMileage: 0, + endMileage: 0, + left: s.px - W / 2, + top: TOP, + width: W, + height: H, + capWidth: CAP, + centerHeight: H, + passedAtPx: 0, + }; + const layer = (op: number) => + ({ position: 'absolute' as const, inset: 0, opacity: op, transition: 'opacity 0.12s' }); + return ( + +
+
+
+ ); + })} +
+ + {/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경 */}
- {structs.map((s, i) => ( -
- {s.title} -
- ))} + {structs.map((s, i) => { + const W = s.category === '터널' ? 24 : 22; + const passed = posPx >= s.px - W / 2; + return ( +
+ {s.title} +
+ ); + })}
diff --git a/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss b/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss index 958e660..14547ad 100755 --- a/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss +++ b/client/src/stationbar/components/TimelineCursor/TimelineCursor.module.scss @@ -1,9 +1,11 @@ .cursor { position: absolute; - left: var(--cursor-x); + left: 0; top: 0; width: 0; height: 0; + transform: translateX(var(--cursor-x, 0px)); + will-change: transform; } .line { diff --git a/client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx b/client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx index 546ca3a..ac6c2dc 100755 --- a/client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx +++ b/client/src/stationbar/components/TimelineCursor/TimelineCursor.tsx @@ -1,10 +1,7 @@ import type { MouseEvent } from 'react'; -import { renderX } from '../../mocks/route'; -import { cssVars, px } from '../../utils/cssVars'; import styles from './TimelineCursor.module.scss'; interface TimelineCursorProps { - posPx: number; mileageText: string; /** 현재 위치가 역방향(km 감소) 구간이면 커서를 파란색으로. */ reverse?: boolean; @@ -12,12 +9,10 @@ interface TimelineCursorProps { onSeekDown: (e: MouseEvent) => void; } -export function TimelineCursor({ posPx, mileageText, reverse, onSeekDown }: TimelineCursorProps) { +// 커서 x 위치(--cursor-x)는 상위(StationBar wrapRef)에서 rAF로 직접 갱신한다(transform). +export function TimelineCursor({ mileageText, reverse, onSeekDown }: TimelineCursorProps) { return ( -
+
{mileageText}
diff --git a/client/src/store/geoStore.ts b/client/src/store/geoStore.ts new file mode 100644 index 0000000..8f8f769 --- /dev/null +++ b/client/src/store/geoStore.ts @@ -0,0 +1,69 @@ +/** + * 클라이언트 지리정보 스토어 (Zustand) + * + * 폴더 선택으로 파싱한 드론 프레임 / POI / 측점 / 중심선 / ENU 원점을 보관한다. + * 4개 소비 컴포넌트(GeoSearch / StationVerify / StationOverlay / RoutePanel)가 + * 이 스토어를 단일 소스로 구독한다(서버 /api/geo/* 대체). + * + * playerStore.ts 패턴을 따른다. + */ + +import { create } from 'zustand'; +import type { + DroneFrame, + GeoPoint, + CenterlinePoint, + GeoOrigin, + RouteMeta, +} from '../types/geo'; +import { loadFolderGeoData } from '../utils/geoData'; + +interface GeoStore { + loaded: boolean; + frames: DroneFrame[]; + pois: GeoPoint[]; // type==='poi' + stations: GeoPoint[]; // type==='station' (stationOrder 정렬) + centerline: CenterlinePoint[]; + origin: GeoOrigin | null; + baseName: string | null; + routeMeta: RouteMeta | null; + + /** + * 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다. + * 발견한 영상 File 을 반환(없으면 null) — 호출자가 loadLocalFile 로 재생. + */ + loadFromFolder: (files: FileList | File[]) => Promise; + clear: () => void; +} + +const EMPTY = { + loaded: false, + frames: [] as DroneFrame[], + pois: [] as GeoPoint[], + stations: [] as GeoPoint[], + centerline: [] as CenterlinePoint[], + origin: null as GeoOrigin | null, + baseName: null as string | null, + routeMeta: null as RouteMeta | null, +}; + +export const useGeoStore = create((set) => ({ + ...EMPTY, + + loadFromFolder: async (files) => { + const data = await loadFolderGeoData(files); + set({ + loaded: true, + frames: data.frames, + pois: data.pois, + stations: data.stations, + centerline: data.centerline, + origin: data.origin, + baseName: data.baseName, + routeMeta: data.routeMeta, + }); + return data.videoFile; + }, + + clear: () => set({ ...EMPTY }), +})); diff --git a/client/src/types/dom.d.ts b/client/src/types/dom.d.ts new file mode 100644 index 0000000..06f37c8 --- /dev/null +++ b/client/src/types/dom.d.ts @@ -0,0 +1,25 @@ +/** + * 비표준 DOM 속성 보강 + * + * `` 폴더 선택과 + * `File.webkitRelativePath` 가 TypeScript 에서 타입체크되도록 확장한다. + * (lib.dom 에 webkitRelativePath 는 있으나 webkitdirectory/directory 속성은 없음) + */ + +import 'react'; + +declare module 'react' { + interface InputHTMLAttributes { + // 폴더 선택용 비표준 속성 (Chrome/Firefox/Edge) + webkitdirectory?: string; + directory?: string; + } +} + +declare global { + interface HTMLInputElement { + webkitdirectory: boolean; + } +} + +export {}; diff --git a/client/src/types/geo.ts b/client/src/types/geo.ts new file mode 100644 index 0000000..fbb081f --- /dev/null +++ b/client/src/types/geo.ts @@ -0,0 +1,124 @@ +/** + * 지리정보 공유 타입 (클라이언트) + * + * 서버 server/src/services/geoMatch.ts 의 타입을 그대로 미러링한다. + * 4개 소비 컴포넌트(GeoSearch / StationVerify / StationOverlay / RoutePanel)와 + * geoStore / geoSearch / geoData 가 공유한다. + * + * 주의: DroneFrame 은 utils/geoProjection.ts 의 DroneFrameBasic 와 필드가 동일하다 + * (frame/lat/lon/altitude/yaw/pitch/roll/focalLen). 오버레이 투영 유틸이 + * DroneFrameBasic 을 받으므로, DroneFrame 은 그 타입에 그대로 대입 가능하다. + */ + +/** 드론 프레임 (frame별 위경도/자세) — geoMatch.ts DroneFrame 동치 */ +export interface DroneFrame { + frame: number; + lat: number; + lon: number; + altitude: number; + yaw: number; // 기수방향 (North=0, 시계방향, degrees) + pitch: number; // 카메라 틸트 (음수=아래, degrees) + roll: number; + focalLen: number; // 35mm 환산 초점거리 (mm) +} + +/** POI/측점 지리점 — geoMatch.ts GeoPoint 동치 */ +export interface GeoPoint { + title: string; + category: string; + lat: number; + lon: number; + z: number; // 표고 (m) + type: 'poi' | 'station'; +} + +/** 의미상 별칭 (POI = GeoPoint, Station = GeoPoint with type==='station') */ +export type Poi = GeoPoint; +export type Station = GeoPoint; + +/** 선로 중심선 점 — geoMatch.ts CenterlinePoint 동치 */ +export interface CenterlinePoint { + lat: number; + lon: number; + z: number; // 타원체고(h) +} + +/** 건물/측점명 검색 결과 프레임 — geoMatch.ts FrameMatch 동치 (+ 그룹 메타) */ +export interface FrameMatch { + frame: number; + time: number; // 초 단위 (frame / fps) + bearingDiff: number; // 수평 각도차 (degrees) + elevationDiff: number; // 수직 각도차 (degrees) + distance: number; // 수평 거리 (m) + pixelX: number; // 0~1 정규화 + pixelY: number; // 0~1 정규화 + groupSize?: number; // 연속 구간 프레임 수 + groupStart?: number; // 구간 시작 프레임 + groupEnd?: number; // 구간 끝 프레임 +} + +/** 프레임→POI 역조회 결과 항목 — geoMatch.ts PoiInFrame 동치 */ +export interface PoiInFrame { + poi: GeoPoint; + bearingDiff: number; + elevationDiff: number; + distance: number; + pixelX: number; + pixelY: number; +} + +/** ENU 월드 원점 (첫 측점 기준) */ +export interface GeoOrigin { + lat: number; + lon: number; + alt: number; +} + +/** 노선 구조물 (교량/터널) — route.json structures 항목 */ +export interface RouteStructure { + id: string; + /** bridge=교량, tunnel=터널, station(또는 그 외)=역사. */ + type: 'bridge' | 'tunnel' | 'station'; + name: string; + /** 선택(최우선): 이 측점값에 시설물을 배치. 숫자(미터, 158400) 또는 "158k400" 문자열. + * 동명 시설물은 각자의 station 값으로 구분 표시된다. */ + station?: number | string; + /** 선택: 구조물 실좌표. station 없을 때 이 좌표로 배치(POI 매칭 불필요). */ + lat?: number; + lon?: number; + /** 선택: 위치는 POI 실좌표(이름 매칭)로 계산. 이정값은 매칭 실패 시 폴백용. */ + startMileage?: number; + endMileage?: number; + /** 선택: POI/좌표 기반 위치를 진행방향으로 ±N미터 미세조정. */ + offset?: number; +} + +/** 노선 기본 정보 — route.json routeInfo */ +export interface RouteInfo { + name?: string; + direction?: string; + lengthKm?: number; + durationSec?: number; + startStationName?: string; + endStationName?: string; + /** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */ + stationTolerance?: number; +} + +/** 폴더 보조 파일 .route.json (없으면 route.json) 파싱 결과 */ +export interface RouteMeta { + routeInfo?: RouteInfo; + structures?: RouteStructure[]; +} + +/** loadFolderGeoData 파싱 결과 */ +export interface FolderGeoData { + videoFile: File | null; + baseName: string | null; + frames: DroneFrame[]; + pois: GeoPoint[]; // type==='poi' + stations: GeoPoint[]; // type==='station' (stationOrder 정렬) + centerline: CenterlinePoint[]; + origin: GeoOrigin | null; + routeMeta: RouteMeta | null; +} diff --git a/client/src/utils/geoData.ts b/client/src/utils/geoData.ts new file mode 100644 index 0000000..c5e4f3a --- /dev/null +++ b/client/src/utils/geoData.ts @@ -0,0 +1,299 @@ +/** + * 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱 + * + * `` 로 선택된 File[] 에서 + * 영상 / 드론 CSV / center.csv / building POI·측점 CSV 를 찾아 파싱한다. + * + * 인코딩(서버 geoMatch.ts readCsvUtf8 대응): + * - 드론 .csv : UTF-8 (+BOM) + * - POI / 측점 / center.csv : EUC-KR → TextDecoder('euc-kr') + * + * center.csv 는 헤더가 EUC-KR 로 깨지므로 위치 인덱스(1=lat, 2=lon, 5=z)로 파싱한다. + * 영상명 비의존 — '회덕' 하드코딩 없음. base 는 영상 파일명에서 유도한다. + */ + +import type { + DroneFrame, + GeoPoint, + CenterlinePoint, + GeoOrigin, + FolderGeoData, + RouteMeta, +} from '../types/geo'; +import { stationOrder, getWorldOrigin } from './geoSearch'; + +// ── CSV 파싱 헬퍼 (geoMatch.ts:162-173 포팅) ────────────────────────── + +export function parseCsvLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + for (const ch of line) { + if (ch === '"') { + inQuotes = !inQuotes; + } else if (ch === ',' && !inQuotes) { + result.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + result.push(current.trim()); + return result; +} + +/** ArrayBuffer → 문자열. UTF-8 BOM 제거, EUC-KR 는 TextDecoder('euc-kr'). */ +export function decodeBytes(buf: ArrayBuffer, encoding: 'utf-8' | 'euc-kr'): string { + const text = new TextDecoder(encoding).decode(buf); + // UTF-8/EUC-KR 디코딩 후 남을 수 있는 BOM(U+FEFF) 제거 + return text.replace(/^/, ''); +} + +/** File → 파싱된 행 배열. */ +export async function readCsv( + file: File, + encoding: 'utf-8' | 'euc-kr', +): Promise { + const buf = await file.arrayBuffer(); + const text = decodeBytes(buf, encoding); + return text + .split(/\r?\n/) + .filter(Boolean) + .map(parseCsvLine); +} + +// ── 파일 식별 헬퍼 ──────────────────────────────────────────────────── + +/** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */ +function relPath(f: File): string { + return f.webkitRelativePath || f.name; +} + +/** 경로의 마지막 세그먼트(파일명). */ +function baseNameOf(p: string): string { + const parts = p.split('/'); + return parts[parts.length - 1]; +} + +/** building/ 하위 파일 여부 (마지막 디렉토리 세그먼트가 building). */ +function isInBuilding(f: File): boolean { + const parts = relPath(f).split('/'); + return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building'; +} + +const VIDEO_EXT = /\.(mp4|webm)$/i; + +/** 영상 파일 찾기 (mp4/webm, building 제외). */ +export function findVideoFile(files: File[]): File | null { + return ( + files.find((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) ?? null + ); +} + +/** 영상 파일명에서 base(확장자 제거) 추출. */ +export function deriveBaseName(videoFile: File | null): string | null { + if (!videoFile) return null; + return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, ''); +} + +// ── 파서 (geoMatch.ts loader 포팅) ──────────────────────────────────── + +/** + * 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스). geoMatch.ts:289-302 + * 루트(building 제외)의 base.csv 를 식별한다. base 가 없으면 루트 .csv 중 + * POI/측점/center 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백). + */ +export async function parseDroneFrames( + files: File[], + baseName: string | null, +): Promise { + const rootCsv = files.filter((f) => { + if (isInBuilding(f)) return false; + const name = baseNameOf(relPath(f)); + if (!/\.csv$/i.test(name)) return false; + if (/POI|측점/i.test(name)) return false; + if (name.toLowerCase() === 'center.csv') return false; + return true; + }); + if (!rootCsv.length) return []; + + // base 일치 우선, 없으면 첫 루트 csv + const droneFile = + (baseName && rootCsv.find((f) => baseNameOf(relPath(f)) === `${baseName}.csv`)) || + rootCsv[0]; + + const rows = await readCsv(droneFile, 'utf-8'); + if (rows.length < 2) return []; + + const header = rows[0].map((h) => h.trim().replace(/^/, '')); + const fi = (name: string) => header.indexOf(name); + + return rows + .slice(1) + .map((r) => ({ + frame: parseInt(r[fi('frame_cnt')], 10), + lat: parseFloat(r[fi('latitude')]), + lon: parseFloat(r[fi('longitude')]), + altitude: parseFloat(r[fi('altitude')]), + yaw: parseFloat(r[fi('yaw')]), + pitch: parseFloat(r[fi('pitch')]), + roll: parseFloat(r[fi('roll')]), + focalLen: parseFloat(r[fi('focal_len')]), + })) + .filter((f) => !isNaN(f.lat)); +} + +/** + * POI + 측점 CSV 파싱 (building/, EUC-KR, 헤더 이름 인덱스). geoMatch.ts:321-357 + * POI: category = category_clean || '건물', type='poi' + * 측점: category = '측점'(강제), type='station' + * 타원체고 버전 우선 규칙 유지. + */ +export async function parsePois(files: File[]): Promise { + const buildingFiles = files.filter((f) => isInBuilding(f)); + const result: GeoPoint[] = []; + + const nameOf = (f: File) => baseNameOf(relPath(f)); + + // POI 위경도 파일 (_타원체고 버전 우선) + const allPoiFiles = buildingFiles.filter( + (f) => nameOf(f).includes('POI') && nameOf(f).includes('위경도'), + ); + const poiFiles = + allPoiFiles.filter((f) => nameOf(f).includes('타원체고')).length > 0 + ? allPoiFiles.filter((f) => nameOf(f).includes('타원체고')) + : allPoiFiles.filter((f) => !nameOf(f).includes('타원체고')); + + for (const f of poiFiles) { + const rows = await readCsv(f, 'euc-kr'); + if (rows.length < 2) continue; + const header = rows[0].map((h) => h.trim().replace(/^/, '')); + const fi = (name: string) => header.indexOf(name); + for (const r of rows.slice(1)) { + const lat = parseFloat(r[fi('lat')]); + const lon = parseFloat(r[fi('lon')]); + if (isNaN(lat) || isNaN(lon)) continue; + result.push({ + title: r[fi('title')] || '', + category: r[fi('category_clean')] || '건물', + lat, + lon, + z: parseFloat(r[fi('z')]) || 0, + type: 'poi', + }); + } + } + + // 측점 위경도 파일 + const stationFiles = buildingFiles.filter( + (f) => nameOf(f).includes('측점') && nameOf(f).includes('위경도'), + ); + for (const f of stationFiles) { + const rows = await readCsv(f, 'euc-kr'); + if (rows.length < 2) continue; + const header = rows[0].map((h) => h.trim().replace(/^/, '')); + const fi = (name: string) => header.indexOf(name); + for (const r of rows.slice(1)) { + const lat = parseFloat(r[fi('lat')]); + const lon = parseFloat(r[fi('lon')]); + if (isNaN(lat) || isNaN(lon)) continue; + result.push({ + title: r[fi('title')] || '', + category: '측점', + lat, + lon, + z: parseFloat(r[fi('z')]) || 0, + type: 'station', + }); + } + } + + return result; +} + +/** + * center.csv 파싱 (EUC-KR, 위치 인덱스 1=lat, 2=lon, 5=z). geoMatch.ts:253-257 + */ +export async function parseCenterline(files: File[]): Promise { + const file = files.find( + (f) => !isInBuilding(f) && baseNameOf(relPath(f)).toLowerCase() === 'center.csv', + ); + if (!file) return []; + + const rows = await readCsv(file, 'euc-kr'); + if (rows.length < 2) return []; + + return rows + .slice(1) + .map((r) => ({ + lat: parseFloat(r[1]), + lon: parseFloat(r[2]), + z: parseFloat(r[5]), + })) + .filter((p) => !isNaN(p.lat) && !isNaN(p.lon) && !isNaN(p.z)); +} + +/** + * 폴더 보조 파일 route.json 파싱 (UTF-8, JSON). + * `.route.json` 우선(case-insensitive), 없으면 `route.json`. + * building/ 제외, 루트 파일만. 파싱 실패 시 null. + */ +export async function parseRouteMeta( + files: File[], + baseName: string | null, +): Promise { + const rootJson = files.filter( + (f) => !isInBuilding(f) && /\.json$/i.test(baseNameOf(relPath(f))), + ); + if (!rootJson.length) return null; + + const wantBase = baseName ? `${baseName}.route.json`.toLowerCase() : null; + const file = + (wantBase && + rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) || + rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json'); + if (!file) return null; + + try { + const text = await file.text(); + return JSON.parse(text) as RouteMeta; + } catch { + return null; + } +} + +// ── 통합 로더 ───────────────────────────────────────────────────────── + +/** + * 폴더 내 파일에서 영상 + 지리정보를 모두 파싱한다. + * 반환값을 geoStore.loadFromFolder 가 스토어에 적재한다. + */ +export async function loadFolderGeoData( + input: FileList | File[], +): Promise { + const files = Array.from(input); + + const videoFile = findVideoFile(files); + const baseName = deriveBaseName(videoFile); + + const [frames, allPoints, centerline, routeMeta] = await Promise.all([ + parseDroneFrames(files, baseName), + parsePois(files), + parseCenterline(files), + parseRouteMeta(files, baseName), + ]); + + const pois = allPoints.filter((p) => p.type === 'poi'); + const stations = allPoints + .filter((p) => p.type === 'station') + .sort((a, b) => stationOrder(a.title) - stationOrder(b.title)); + + let origin: GeoOrigin | null = null; + if (stations.length || frames.length) { + origin = getWorldOrigin(frames, allPoints); + } else if (centerline.length) { + origin = { lat: centerline[0].lat, lon: centerline[0].lon, alt: centerline[0].z }; + } + + return { videoFile, baseName, frames, pois, stations, centerline, origin, routeMeta }; +} diff --git a/client/src/utils/geoSearch.ts b/client/src/utils/geoSearch.ts new file mode 100644 index 0000000..dbb593c --- /dev/null +++ b/client/src/utils/geoSearch.ts @@ -0,0 +1,255 @@ +/** + * 클라이언트 지리정보 검색 알고리즘 + * + * 서버 server/src/services/geoMatch.ts 의 검색/투영 로직을 그대로 포팅한다. + * (검색 UI 결과를 서버 응답과 동일하게 유지하기 위해, 오버레이용 proj4 기반 + * geoProjection.ts 와는 별개의 단순 ENU(cos-lat 근사) 투영을 사용한다.) + * + * 싱글턴 캐시(loadFrames/loadPois) 대신 데이터를 인자로 받는 순수 함수로 변경했다. + * 입력 데이터는 geoStore 에서 읽어 넘긴다. + */ + +import type { + DroneFrame, + GeoPoint, + FrameMatch, + PoiInFrame, + GeoOrigin, +} from '../types/geo'; + +// ── 카메라/지구 상수 (geoMatch.ts 동일) ────────────────────────────── +const SENSOR_W_MM = 36; +const SENSOR_H_MM = 24; +const R_EARTH = 6371000; +const DEFAULT_FPS = 30; +const GAP = 30; + +export function toRad(deg: number): number { + return (deg * Math.PI) / 180; +} + +/** 위경도+표고 → 월드 ENU (m). refLat/refLon/refAlt = 원점. geoMatch.ts:78-87 */ +export function geoToEnu( + lat: number, lon: number, alt: number, + refLat: number, refLon: number, refAlt: number, +): [number, number, number] { + const cosRef = Math.cos(toRad(refLat)); + const e = toRad(lon - refLon) * cosRef * R_EARTH; // East (m) + const n = toRad(lat - refLat) * R_EARTH; // North (m) + const u = alt - refAlt; // Up (m) + return [e, n, u]; +} + +/** 월드 ENU 벡터 + 카메라 자세 → 정규화 픽셀. geoMatch.ts:90-124 */ +export function projectEnu( + relEnu: [number, number, number], + yawDeg: number, + pitchDeg: number, + focalMm: number, + yawOffset = 0, +): { px: number; py: number; cx: number; cy: number; cz: number } | null { + const yaw = toRad(yawDeg + yawOffset); + const pitch = toRad(pitchDeg); + const cosY = Math.cos(yaw), sinY = Math.sin(yaw); + const cosP = Math.cos(pitch), sinP = Math.sin(pitch); + + const fwd: readonly [number, number, number] = [sinY * cosP, cosY * cosP, sinP]; + const right: readonly [number, number, number] = [cosY, -sinY, 0]; + const up: readonly [number, number, number] = [ + right[1] * fwd[2] - right[2] * fwd[1], + right[2] * fwd[0] - right[0] * fwd[2], + right[0] * fwd[1] - right[1] * fwd[0], + ]; + + const dot = (a: readonly [number, number, number], b: readonly [number, number, number]) => + a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + + const cx = dot(relEnu, right); + const cy = dot(relEnu, up); + const cz = dot(relEnu, fwd); + + if (cz <= 0) return null; + + const f = focalMm || 24; + const px = 0.5 + (cx / cz) * (f / SENSOR_W_MM); + const py = 0.5 - (cy / cz) * (f / SENSOR_H_MM); + return { px, py, cx, cy, cz }; +} + +/** DroneFrame + POI + 공통 기준점 → 픽셀. geoMatch.ts:127-156 */ +export function project3D( + drone: DroneFrame, + poi: { lat: number; lon: number; z: number }, + yawOffset = 0, + origin?: GeoOrigin, +): { px: number; py: number; dist: number; h: number; v: number; inFov: boolean } | null { + const ref = origin ?? { lat: drone.lat, lon: drone.lon, alt: drone.altitude }; + + const stEnu = geoToEnu(poi.lat, poi.lon, poi.z, ref.lat, ref.lon, ref.alt); + const drEnu = geoToEnu(drone.lat, drone.lon, drone.altitude, ref.lat, ref.lon, ref.alt); + const relEnu: [number, number, number] = [ + stEnu[0] - drEnu[0], + stEnu[1] - drEnu[1], + stEnu[2] - drEnu[2], + ]; + const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2); + + const res = projectEnu(relEnu, drone.yaw, drone.pitch, drone.focalLen || 24, yawOffset); + if (!res) return null; + + const h = Math.atan2(res.cx, res.cz) * (180 / Math.PI); + const v = Math.atan2(res.cy, res.cz) * (180 / Math.PI); + const inFov = res.px >= 0 && res.px <= 1 && res.py >= 0 && res.py <= 1; + return { + px: Math.max(0, Math.min(1, res.px)), + py: Math.max(0, Math.min(1, res.py)), + dist, h, v, inFov, + }; +} + +/** 측점 km 정렬 키. geoMatch.ts:371-375 */ +export function stationOrder(title: string): number { + const m = title.match(/(\d+)[Kk](\d+)/); + if (!m) return 0; + return parseInt(m[1]) * 1000 + parseInt(m[2]); +} + +/** 첫 번째 측점 위치를 ENU 원점으로 반환. geoMatch.ts:378-387 */ +export function getWorldOrigin(frames: DroneFrame[], pois: GeoPoint[]): GeoOrigin { + const stations = pois + .filter((p) => p.type === 'station') + .sort((a, b) => stationOrder(a.title) - stationOrder(b.title)); + if (stations.length) { + return { lat: stations[0].lat, lon: stations[0].lon, alt: stations[0].z }; + } + return frames[0] + ? { lat: frames[0].lat, lon: frames[0].lon, alt: frames[0].altitude } + : { lat: 0, lon: 0, alt: 0 }; +} + +/** + * 건물/측점명 검색 → 매칭 POI + 카메라 시야 프레임 목록. geoMatch.ts:399-468 + * pois 는 POI+측점 통합 배열을 넘긴다(검색 대상 전체). + */ +export function findFramesForPoi( + frames: DroneFrame[], + pois: GeoPoint[], + query: string, + marginFactor = 1.0, + maxDist = 2000, + yawOffset = 0, + origin?: GeoOrigin | null, +): { poi: GeoPoint | null; frames: FrameMatch[] } { + const q = query.trim().toLowerCase(); + const poi = pois.find((p) => p.title.toLowerCase().includes(q)); + if (!poi) return { poi: null, frames: [] }; + + const matches: FrameMatch[] = []; + const ref = origin ?? getWorldOrigin(frames, pois); + + for (const f of frames) { + const res = project3D(f, poi, yawOffset, ref); + if (!res) continue; + if (res.dist > maxDist) continue; + + const halfW = 0.5 * marginFactor; + const halfH = 0.5 * marginFactor; + const rawPx = 0.5 + (res.px - 0.5); + const rawPy = 0.5 + (res.py - 0.5); + if (Math.abs(rawPx - 0.5) > halfW || Math.abs(rawPy - 0.5) > halfH) continue; + + matches.push({ + frame: f.frame, + time: f.frame / DEFAULT_FPS, + bearingDiff: res.h, + elevationDiff: res.v, + distance: res.dist, + pixelX: res.px, + pixelY: res.py, + }); + } + + matches.sort((a, b) => a.frame - b.frame); + + const groups: FrameMatch[][] = []; + let group: FrameMatch[] = []; + for (const m of matches) { + if (group.length === 0 || m.frame - group[group.length - 1].frame <= GAP) { + group.push(m); + } else { + groups.push(group); + group = [m]; + } + } + if (group.length > 0) groups.push(group); + + const best = groups.map((g) => { + const groupStart = g[0].frame; + const groupEnd = g[g.length - 1].frame; + g.sort( + (a, b) => + a.bearingDiff ** 2 + a.elevationDiff ** 2 - (b.bearingDiff ** 2 + b.elevationDiff ** 2), + ); + return { ...g[0], groupSize: g.length, groupStart, groupEnd }; + }); + + best.sort( + (a, b) => + a.bearingDiff ** 2 + a.elevationDiff ** 2 - (b.bearingDiff ** 2 + b.elevationDiff ** 2), + ); + + return { poi, frames: best }; +} + +/** + * 프레임 번호 → 해당 프레임에서 카메라 시야에 들어오는 POI/측점 목록. geoMatch.ts:473-512 + */ +export function findPoisForFrame( + frames: DroneFrame[], + pois: GeoPoint[], + frameNum: number, + marginFactor = 1.0, + yawOffset = 0, + origin?: GeoOrigin | null, +): { droneFrame: DroneFrame | null; pois: PoiInFrame[] } { + const drone = + frames.find((f) => f.frame === frameNum) ?? + (() => { + let best = frames[0]; + let bestD = Math.abs((best?.frame ?? 0) - frameNum); + for (const f of frames) { + const d = Math.abs(f.frame - frameNum); + if (d < bestD) { + bestD = d; + best = f; + } + if (d === 0) break; + } + return best; + })(); + if (!drone) return { droneFrame: null, pois: [] }; + + const result: PoiInFrame[] = []; + const ref = origin ?? getWorldOrigin(frames, pois); + + for (const poi of pois) { + const res = project3D(drone, poi, yawOffset, ref); + if (!res) continue; + + const halfW = 0.5 * marginFactor; + const halfH = 0.5 * marginFactor; + if (Math.abs(res.px - 0.5) > halfW || Math.abs(res.py - 0.5) > halfH) continue; + + result.push({ + poi, + bearingDiff: res.h, + elevationDiff: res.v, + distance: res.dist, + pixelX: res.px, + pixelY: res.py, + }); + } + + result.sort((a, b) => a.distance - b.distance); + return { droneFrame: drone, pois: result }; +} diff --git a/ecosystem.config.js b/ecosystem.config.js index f434d24..070b8c9 100755 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -1,30 +1,27 @@ -const path = require("path"); - -// 이 설정 파일이 있는 위치(프로젝트 루트) 기준으로 모든 경로를 계산한다. -// → 프로젝트를 다른 경로로 옮겨도 그대로 동작. -const ROOT = __dirname; - module.exports = { apps: [ { name: "defVideo", - // 빌드 산출물 (npm run build 후 생성됨) - script: path.join(ROOT, "server/dist/server/src/app.js"), - cwd: ROOT, - // nvm 으로 설치한 Node 20 을 명시 (PM2 데몬이 시스템 Node 12 를 쓰지 않도록) - interpreter: "/home/hanmac/.nvm/versions/node/v20.20.2/bin/node", + // cwd = 이 설정 파일이 있는 위치(프로젝트 루트)를 작업 디렉토리로 고정. + // 이 한 곳만 앵커로 두고, 아래 경로는 전부 cwd 기준 상대경로로 해석된다. + cwd: __dirname, + // script: cwd 기준 상대경로 (npm run build 후 생성) + script: "server/dist/server/src/app.js", + // PATH 의 node 사용 — pm2 start 전에 nvm 으로 Node 20 이 활성화돼 있어야 함 + interpreter: "node", env: { PORT: 55173, - VIDEOS_DIR: path.join(ROOT, "samplevideo"), - HLS_DIR: path.join(ROOT, "storage/hls"), - FRAMES_DIR: path.join(ROOT, "storage/frames"), - THUMBNAILS_DIR: path.join(ROOT, "storage/thumbnails"), - DB_PATH: path.join(ROOT, "storage/annotations.db"), - // apt 로 설치하면 /usr/bin 에 생성됨 - FFMPEG_PATH: "/usr/bin/ffmpeg", - FFPROBE_PATH: "/usr/bin/ffprobe", - GEO_DATA_DIR: path.join(ROOT, "samplevideo"), - CENTER_CSV_PATH: path.join(ROOT, "pythonsource/input/center.csv"), + // 서버 config.ts 가 path.resolve(cwd 기준)로 해석 → 상대경로 OK + VIDEOS_DIR: "samplevideo", + HLS_DIR: "storage/hls", + FRAMES_DIR: "storage/frames", + THUMBNAILS_DIR: "storage/thumbnails", + DB_PATH: "storage/annotations.db", + // 절대경로 대신 PATH 에서 탐색 (apt 설치 시 /usr/bin 에 있고 PATH 포함됨) + FFMPEG_PATH: "ffmpeg", + FFPROBE_PATH: "ffprobe", + GEO_DATA_DIR: "samplevideo", + CENTER_CSV_PATH: "pythonsource/input/center.csv", }, }, ], diff --git a/server/src/services/ffmpeg.ts b/server/src/services/ffmpeg.ts index 553d564..0246cd0 100644 --- a/server/src/services/ffmpeg.ts +++ b/server/src/services/ffmpeg.ts @@ -109,11 +109,12 @@ export function getVideoFps(probe: FFprobeResult): number { return den ? num / den : 30; } -export async function checkFFmpegInstalled(): Promise { - try { - await runFFprobe(['-version']); - return true; - } catch { - return false; - } +export function checkFFmpegInstalled(): Promise { + // `ffprobe -version` 은 JSON 이 아닌 일반 텍스트를 출력하므로 runFFprobe(JSON 파싱) 로 + // 검사하면 항상 실패한다. 여기서는 실행 가능 여부(exit code 0)만 직접 확인한다. + return new Promise((resolve) => { + const proc = spawn(config.ffprobePath, ['-version'], { stdio: 'ignore' }); + proc.on('close', (code) => resolve(code === 0)); + proc.on('error', () => resolve(false)); + }); }