UI 수정
기획안 반영 및 보완
This commit is contained in:
75
agent-docs/2026-06-18_DefVideo-폴더구동-UI작업.md
Normal file
75
agent-docs/2026-06-18_DefVideo-폴더구동-UI작업.md
Normal file
@@ -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()` 추가 — `<base>.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건.
|
||||||
108
agent-docs/2026-06-18_이슈별-해결정리.md
Normal file
108
agent-docs/2026-06-18_이슈별-해결정리.md
Normal file
@@ -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를 함께 읽어 화면 세팅. 어떤 영상이든 동작.
|
||||||
|
- **제약**: `<input type=file>`로 고른 단일 파일은 브라우저 보안상 형제 파일을 못 읽음.
|
||||||
|
- **결정/해결**: **폴더 선택(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). 정확 집계 도구 없어 추정.
|
||||||
358
agent-docs/feature-folder-load.md
Normal file
358
agent-docs/feature-folder-load.md
Normal file
@@ -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`로 재생한다.
|
||||||
|
- `<base>.csv`(드론 프레임), `center.csv`(중심선), `building/<base>_POI_위경도값.csv`, `building/<base>_측점_위경도값.csv`를 **클라이언트에서 직접 파싱**해 Zustand 지리정보 스토어에 적재한다.
|
||||||
|
5. 우측 패널(지리정보/측점)과 영상 오버레이(측점/POI/중심선)가 **선택한 폴더의 데이터** 기준으로 즉시 표시된다.
|
||||||
|
6. **영상명에 의존하지 않는다**: `'회덕'` 하드코딩 제거. 동일한 폴더 구조면 어떤 영상이든 동작한다.
|
||||||
|
|
||||||
|
### 폴백
|
||||||
|
- 폴더가 아직 선택되지 않았으면 지리정보 스토어는 비어 있다. 각 소비 컴포넌트는 빈 상태(측점 0개, 검색 결과 없음, 오버레이 미표시)를 표시한다.
|
||||||
|
- (선택) 스토어가 비었을 때 기존 `/api/geo/*` 서버 API로 폴백할 수 있으나, **기본 동작은 클라이언트 스토어 우선**. 이 문서에서는 서버 폴백을 "옵션"으로 두고, 기본 구현은 스토어 단일 소스로 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 제약
|
||||||
|
|
||||||
|
- **브라우저 폴더 접근**: `<input type="file" webkitdirectory>` 사용. 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 지원)로 디코딩한다. 드론 `<base>.csv`는 **UTF-8 + BOM**.
|
||||||
|
- **메모리**: 영상은 `URL.createObjectURL()`로 직접 재생, 사용 후 `revokeObjectURL`(기존 `useVideoPlayer.loadLocalFile`가 이미 처리).
|
||||||
|
- **좌표 투영**: 클라이언트는 이미 `proj4`(EPSG:5186) 기반 투영 유틸을 보유. 서버 `geoMatch.ts`는 단순 ENU(cos-lat 근사) 방식이라 투영식이 다름 — **데이터 로딩/파싱/검색 로직만 포팅**하고 투영은 기존 클라이언트 유틸(`geoProjection.ts`)을 재사용한다(아래 상세).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 명명 규칙 & CSV 스키마
|
||||||
|
|
||||||
|
`<base>` = 영상 파일명에서 확장자를 제거한 이름. 예: `하행)회덕-대전조차장.MP4` → base `하행)회덕-대전조차장`.
|
||||||
|
|
||||||
|
```
|
||||||
|
<folder>/
|
||||||
|
<base>.MP4 (또는 .mp4/.webm) → 영상 (loadLocalFile / createObjectURL)
|
||||||
|
<base>.csv → 드론 프레임 (UTF-8 BOM)
|
||||||
|
<base>.srt → 프레임 메타(현재 geo 시스템 미파싱 — 주의)
|
||||||
|
center.csv → 중심선 (224행, EUC-KR)
|
||||||
|
building/<base>_POI_위경도값.csv → POI 건물 (EUC-KR)
|
||||||
|
building/<base>_측점_위경도값.csv → 측점 (EUC-KR)
|
||||||
|
building/<base>_POI_XY값.csv → 미사용
|
||||||
|
building/<base>_측점_XY값.csv → 미사용
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1) 드론 프레임 `<base>.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,<EUC-KR깨짐>,<깨짐>,<깨짐(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/<base>_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/<base>_측점_위경도값.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 `<base>.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` — "파일 선택" 라벨 + `<input type="file" accept="video/*">`, `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 || <root>/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
|
||||||
|
<label className="cursor-pointer bg-emerald-600 hover:bg-emerald-700 ... rounded">
|
||||||
|
폴더 선택
|
||||||
|
<input type="file" className="hidden"
|
||||||
|
// @ts-expect-error - 비표준 속성
|
||||||
|
webkitdirectory="" directory="" multiple
|
||||||
|
onChange={(e) => { const files = e.target.files; if (files?.length) onSelectFolder(Array.from(files)); }} />
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
- `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<GeoStore>) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
export const useGeoStore = create<GeoStore>((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<string[][]>` — `await file.arrayBuffer()` → decode → `split(/\r?\n/).filter(Boolean).map(parseCsvLine)`.
|
||||||
|
|
||||||
|
#### 로더 (서버 loader 포팅, but 파일은 `File[]`에서 매칭)
|
||||||
|
- `findVideoFile(files)` — 확장자 `.mp4/.webm`(대소문자 무시), `building/` 미포함. → base 추출.
|
||||||
|
- `parseDroneFrames(file): Promise<DroneFrame[]>` — 서버 `loadFrames:289-302` 로직(헤더 이름 인덱스). **`회덕` 필터 제거**, base 일치 또는 `building/` 미포함 루트 csv로 식별.
|
||||||
|
- `parsePois(poiFile, stationFile): Promise<GeoPoint[]>` — 서버 `loadPois:321-357` 포팅. POI=`category_clean||'건물'`,`type:'poi'`; 측점=`category:'측점'`,`type:'station'`. 파일 선택은 `building/` 내 `webkitRelativePath`에서 `POI`+`위경도`, `측점`+`위경도` 매칭(`타원체고` 우선규칙 유지).
|
||||||
|
- `parseCenterline(file): Promise<CenterlinePoint[]>` — 서버 `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"`로 **소비처 전수 조사**를 먼저 수행해 누락 방지.
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
64
agent-docs/feature-stationbar-folder-driven.md
Normal file
64
agent-docs/feature-stationbar-folder-driven.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 기능: StationBar 완전 폴더-구동화 (하드코딩/Mock 제거)
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
StationBar(영상 하단 측점 바)가 표시하는 **모든 데이터를 선택한 폴더의 파일에서** 가져오게 한다. 코드에 노선 데이터를 하드코딩하지 않는다. 폴더 CSV에 없는 정보(노선명·방향·시종점 역명·구조물)는 폴더 내 보조 파일 `<base>.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" } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 위치: 영상과 같은 폴더 루트. 파일명 `<base>.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)` — `<base>.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` 보조 파일 로딩(`<base>.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 기하 상수 유지.
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap"
|
||||||
|
/>
|
||||||
<title>abcvideo — 대용량 동영상 플레이어</title>
|
<title>abcvideo — 대용량 동영상 플레이어</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export default function App() {
|
|||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [memoTime, setMemoTime] = useState(0);
|
const [memoTime, setMemoTime] = useState(0);
|
||||||
const [rightTab, setRightTab] = useState<'annotation' | 'geo' | 'station'>('annotation');
|
const [rightTab, setRightTab] = useState<'annotation' | 'geo' | 'station'>('annotation');
|
||||||
|
// 좌/우 패널: UI 에서 숨김(코드 보존). 다시 표시하려면 true 로.
|
||||||
|
const SHOW_LEFT_PANEL = false;
|
||||||
|
const SHOW_RIGHT_PANEL = false;
|
||||||
const playerRef = useRef<VideoPlayerHandle>(null);
|
const playerRef = useRef<VideoPlayerHandle>(null);
|
||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
@@ -76,7 +79,8 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-950 text-white overflow-hidden">
|
<div className="flex h-screen bg-gray-950 text-white overflow-hidden">
|
||||||
{/* Left sidebar — top: video list, bottom: captures */}
|
{/* Left sidebar — top: video list, bottom: captures (UI 숨김, 코드 보존) */}
|
||||||
|
{SHOW_LEFT_PANEL && (
|
||||||
<div className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
<div className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||||
<div className="px-4 py-3 border-b border-gray-800">
|
<div className="px-4 py-3 border-b border-gray-800">
|
||||||
<h1 className="text-sm font-bold text-white">abcvideo</h1>
|
<h1 className="text-sm font-bold text-white">abcvideo</h1>
|
||||||
@@ -96,6 +100,7 @@ export default function App() {
|
|||||||
<CaptureList onSeek={handleSeek} />
|
<CaptureList onSeek={handleSeek} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main — player + memo overlay */}
|
{/* Main — player + memo overlay */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
<div className="flex-1 flex flex-col min-w-0 relative">
|
||||||
@@ -115,7 +120,8 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right sidebar — annotation + geo */}
|
{/* Right sidebar — annotation + geo (UI 숨김, 코드 보존) */}
|
||||||
|
{SHOW_RIGHT_PANEL && (
|
||||||
<div className="w-64 flex-shrink-0 bg-gray-900 border-l border-gray-800 flex flex-col">
|
<div className="w-64 flex-shrink-0 bg-gray-900 border-l border-gray-800 flex flex-col">
|
||||||
{/* 탭 헤더 */}
|
{/* 탭 헤더 */}
|
||||||
<div className="flex border-b border-gray-800 flex-shrink-0">
|
<div className="flex border-b border-gray-800 flex-shrink-0">
|
||||||
@@ -163,6 +169,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add annotation modal */}
|
{/* Add annotation modal */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
|
|||||||
@@ -5,37 +5,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useGeoStore } from '../../store/geoStore';
|
||||||
interface GeoPoint {
|
import { findFramesForPoi, findPoisForFrame } from '../../utils/geoSearch';
|
||||||
title: string;
|
import type { GeoPoint, FrameMatch, PoiInFrame } from '../../types/geo';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentFrame: number;
|
currentFrame: number;
|
||||||
@@ -49,20 +21,23 @@ export default function GeoSearch({ currentFrame, fps, onSeekToFrame }: Props) {
|
|||||||
const [tab, setTab] = useState<Tab>('search');
|
const [tab, setTab] = useState<Tab>('search');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [suggestions, setSuggestions] = useState<GeoPoint[]>([]);
|
const [suggestions, setSuggestions] = useState<GeoPoint[]>([]);
|
||||||
const [allPois, setAllPois] = useState<GeoPoint[]>([]);
|
|
||||||
const [searchResult, setSearchResult] = useState<{ poi: GeoPoint; frames: FrameMatch[] } | null>(null);
|
const [searchResult, setSearchResult] = useState<{ poi: GeoPoint; frames: FrameMatch[] } | null>(null);
|
||||||
const [reverseResult, setReverseResult] = useState<PoiInFrame[] | null>(null);
|
const [reverseResult, setReverseResult] = useState<PoiInFrame[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// POI 목록 로드 (자동완성용)
|
// 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
|
||||||
useEffect(() => {
|
const loaded = useGeoStore(s => s.loaded);
|
||||||
fetch('/api/geo/pois')
|
const frames = useGeoStore(s => s.frames);
|
||||||
.then(r => r.json())
|
const pois = useGeoStore(s => s.pois);
|
||||||
.then(data => setAllPois(Array.isArray(data) ? data : []))
|
const stations = useGeoStore(s => s.stations);
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
// POI 목록 (자동완성용): 측점 + 건물 통합 (서버 /api/geo/pois 동치)
|
||||||
|
const allPois = React.useMemo<GeoPoint[]>(
|
||||||
|
() => (loaded ? [...stations, ...pois] : []),
|
||||||
|
[loaded, stations, pois],
|
||||||
|
);
|
||||||
|
|
||||||
// 자동완성 필터링
|
// 자동완성 필터링
|
||||||
useEffect(() => {
|
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));
|
setSuggestions(allPois.filter(p => p.title.toLowerCase().includes(q)).slice(0, 10));
|
||||||
}, [query, allPois]);
|
}, [query, allPois]);
|
||||||
|
|
||||||
// 건물/측점명으로 프레임 검색
|
// 건물/측점명으로 프레임 검색 (클라이언트 검색 — 서버 /api/geo/search 대체)
|
||||||
const handleSearch = useCallback(async (q?: string) => {
|
const handleSearch = useCallback((q?: string) => {
|
||||||
const searchQ = (q ?? query).trim();
|
const searchQ = (q ?? query).trim();
|
||||||
if (!searchQ) return;
|
if (!searchQ) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(searchQ)}&margin=1.0&maxDist=1500`);
|
if (!loaded) { setError('폴더를 먼저 선택하세요'); setSearchResult(null); return; }
|
||||||
const data = await res.json();
|
const origin = useGeoStore.getState().origin;
|
||||||
if (!res.ok) { setError(data.error || '검색 실패'); setSearchResult(null); return; }
|
const combined = [...stations, ...pois];
|
||||||
setSearchResult(data);
|
const result = findFramesForPoi(frames, combined, searchQ, 1.0, 1500, 0, origin);
|
||||||
} catch {
|
if (!result.poi) { setError('일치하는 건물/측점 없음'); setSearchResult(null); return; }
|
||||||
setError('서버 연결 실패');
|
setSearchResult({ poi: result.poi, frames: result.frames });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [query]);
|
}, [query, loaded, frames, stations, pois]);
|
||||||
|
|
||||||
// 현재 프레임 역조회
|
// 현재 프레임 역조회 (클라이언트 검색 — 서버 /api/geo/frame/{n} 대체)
|
||||||
const handleReverse = useCallback(async () => {
|
const handleReverse = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/geo/frame/${currentFrame}?margin=1.0`);
|
if (!loaded) { setReverseResult([]); return; }
|
||||||
const data = await res.json();
|
const origin = useGeoStore.getState().origin;
|
||||||
if (!res.ok) { setError(data.error || '조회 실패'); setReverseResult(null); return; }
|
const combined = [...stations, ...pois];
|
||||||
setReverseResult(data.pois ?? []);
|
const result = findPoisForFrame(frames, combined, currentFrame, 1.0, 0, origin);
|
||||||
} catch {
|
setReverseResult(result.pois);
|
||||||
setError('서버 연결 실패');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentFrame]);
|
}, [currentFrame, loaded, frames, stations, pois]);
|
||||||
|
|
||||||
// 탭 전환 시 역조회 자동 실행
|
// 탭 전환/프레임 변경/데이터 로드 시 역조회 자동 실행
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === 'reverse') handleReverse();
|
if (tab === 'reverse') handleReverse();
|
||||||
}, [tab, currentFrame]);
|
}, [tab, currentFrame, handleReverse]);
|
||||||
|
|
||||||
const formatDist = (m: number) =>
|
const formatDist = (m: number) =>
|
||||||
m >= 1000 ? `${(m / 1000).toFixed(2)}km` : `${Math.round(m)}m`;
|
m >= 1000 ? `${(m / 1000).toFixed(2)}km` : `${Math.round(m)}m`;
|
||||||
|
|||||||
@@ -2,31 +2,14 @@
|
|||||||
* 측점 검증 패널
|
* 측점 검증 패널
|
||||||
* - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동
|
* - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동
|
||||||
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
|
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
|
||||||
|
*
|
||||||
|
* 데이터 소스: 클라이언트 geoStore(폴더 선택으로 파싱). 폴더 미선택 시 빈 상태.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useGeoStore } from '../../store/geoStore';
|
||||||
interface GeoPoint {
|
import { findFramesForPoi } from '../../utils/geoSearch';
|
||||||
title: string;
|
import type { GeoPoint, FrameMatch } from '../../types/geo';
|
||||||
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 StationResult {
|
interface StationResult {
|
||||||
frames: FrameMatch[];
|
frames: FrameMatch[];
|
||||||
@@ -38,43 +21,16 @@ interface Props {
|
|||||||
onSeekToFrame: (frame: number) => void;
|
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) {
|
export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
||||||
const [stations, setStations] = useState<GeoPoint[]>([]);
|
// stations 는 이미 stationOrder 로 정렬되어 있다.
|
||||||
|
const stations = useGeoStore(s => s.stations);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<StationResult | null>(null);
|
const [result, setResult] = useState<StationResult | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [seekedFrame, setSeekedFrame] = useState<number | null>(null);
|
const [seekedFrame, setSeekedFrame] = useState<number | null>(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 배지·실제 위치와 동일 기준).
|
// 드론 GPS 가 측점에 가장 가까운 프레임 (StationBar 배지·실제 위치와 동일 기준).
|
||||||
const nearestFrameForStation = (st: GeoPoint): number | null => {
|
const nearestFrameForStation = (st: GeoPoint): number | null => {
|
||||||
const fr = framesRef.current;
|
const fr = useGeoStore.getState().frames;
|
||||||
if (!fr.length) return null;
|
if (!fr.length) return null;
|
||||||
let best = fr[0];
|
let best = fr[0];
|
||||||
let bd = (fr[0].lat - st.lat) ** 2 + (fr[0].lon - st.lon) ** 2;
|
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;
|
return best.frame;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = async (station: GeoPoint) => {
|
const handleClick = (station: GeoPoint) => {
|
||||||
setSelected(station.title);
|
setSelected(station.title);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setLoading(true);
|
|
||||||
setSeekedFrame(null);
|
setSeekedFrame(null);
|
||||||
// 영상은 드론 GPS 가 그 측점에 가장 가까운 프레임으로 이동한다.
|
// 영상은 드론 GPS 가 그 측점에 가장 가까운 프레임으로 이동한다.
|
||||||
// (카메라 FOV 검색은 앞을 보는 카메라 특성상 ~200m 앞쪽으로 치우쳐 위치가 어긋남)
|
// (카메라 FOV 검색은 앞을 보는 카메라 특성상 ~200m 앞쪽으로 치우쳐 위치가 어긋남)
|
||||||
@@ -98,15 +53,17 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
|||||||
setSeekedFrame(gpsFrame);
|
setSeekedFrame(gpsFrame);
|
||||||
}
|
}
|
||||||
// 검증 정보(카메라 시야 프레임/투영)는 참고용으로 표시.
|
// 검증 정보(카메라 시야 프레임/투영)는 참고용으로 표시.
|
||||||
try {
|
const { frames, pois, stations: sts, origin } = useGeoStore.getState();
|
||||||
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(station.title)}&margin=1.2&maxDist=2000`);
|
const { poi, frames: matches } = findFramesForPoi(
|
||||||
const data = await res.json();
|
frames,
|
||||||
setResult(res.ok && data.frames?.length ? data : { frames: [], poi: data.poi ?? station });
|
[...sts, ...pois],
|
||||||
} catch {
|
station.title,
|
||||||
setResult(null);
|
1.2,
|
||||||
} finally {
|
2000,
|
||||||
setLoading(false);
|
0,
|
||||||
}
|
origin,
|
||||||
|
);
|
||||||
|
setResult({ frames: matches, poi: poi ?? station });
|
||||||
};
|
};
|
||||||
|
|
||||||
const pixelQuality = (px: number, py: number) => {
|
const pixelQuality = (px: number, py: number) => {
|
||||||
@@ -130,11 +87,10 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
|||||||
{selected && (
|
{selected && (
|
||||||
<div className="mx-2 my-2 p-2 bg-gray-800 rounded border border-gray-600 flex-shrink-0">
|
<div className="mx-2 my-2 p-2 bg-gray-800 rounded border border-gray-600 flex-shrink-0">
|
||||||
<div className="text-xs font-bold text-white">{selected}</div>
|
<div className="text-xs font-bold text-white">{selected}</div>
|
||||||
{loading && <div className="text-xs text-gray-400 mt-1">검색 중…</div>}
|
{result && result.frames.length === 0 && (
|
||||||
{!loading && result && result.frames.length === 0 && (
|
|
||||||
<div className="text-xs text-red-400 mt-1">카메라 시야에 들어오는 프레임 없음</div>
|
<div className="text-xs text-red-400 mt-1">카메라 시야에 들어오는 프레임 없음</div>
|
||||||
)}
|
)}
|
||||||
{!loading && result && result.frames.length > 0 && (() => {
|
{result && result.frames.length > 0 && (() => {
|
||||||
const f = result.frames[0];
|
const f = result.frames[0];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
117
client/src/components/overlay/RouteInfo.module.css
Normal file
117
client/src/components/overlay/RouteInfo.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
94
client/src/components/overlay/RouteInfoOverlay.tsx
Normal file
94
client/src/components/overlay/RouteInfoOverlay.tsx
Normal file
@@ -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<ResizeObserver | null>(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 (
|
||||||
|
<div
|
||||||
|
ref={setPanelRef}
|
||||||
|
className={styles.panel}
|
||||||
|
style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
||||||
|
>
|
||||||
|
<img className={styles.bg} src={bgUrl} alt="" />
|
||||||
|
{direction && <p className={styles.direction}>{direction}</p>}
|
||||||
|
{name && <p className={styles.routeName}>{name}</p>}
|
||||||
|
{lengthKm != null && (
|
||||||
|
<>
|
||||||
|
<p className={styles.lengthLabel}>연장</p>
|
||||||
|
<p className={styles.lengthValue}>{lengthKm}</p>
|
||||||
|
<p className={styles.lengthUnit}>km</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{dur && (
|
||||||
|
<>
|
||||||
|
<p className={styles.durationValue}>{dur}</p>
|
||||||
|
<p className={styles.durationLabel}>소요</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,23 +2,19 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
toCameraCoords,
|
toCameraCoords,
|
||||||
pixelFromCamera,
|
pixelFromCamera,
|
||||||
type DroneFrameBasic,
|
|
||||||
DEFAULT_CAMERA_PARAMS,
|
DEFAULT_CAMERA_PARAMS,
|
||||||
} from '../../utils/geoProjection';
|
} from '../../utils/geoProjection';
|
||||||
|
import { useGeoStore } from '../../store/geoStore';
|
||||||
interface GeoPoint {
|
import type { GeoPoint } from '../../types/geo';
|
||||||
title: string;
|
|
||||||
category: string;
|
|
||||||
lat: number;
|
|
||||||
lon: number;
|
|
||||||
z: number;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoutePanelProps {
|
interface RoutePanelProps {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onSeek: (time: number) => void;
|
onSeek: (time: number) => void;
|
||||||
|
/** 상단 침범 방지: 컨테이너 top(px). 기본 위쪽(배너/카메라파라미터) 아래. */
|
||||||
|
topPx?: number;
|
||||||
|
/** 하단 침범 방지: 컨테이너 bottom(px). 기본 아래쪽(배속/토글/재생바) 위. */
|
||||||
|
bottomPx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIDEO_FPS = 30000 / 1001;
|
const VIDEO_FPS = 30000 / 1001;
|
||||||
@@ -31,6 +27,12 @@ function stationKm(title: string): number {
|
|||||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
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<string, string> = {
|
const CATEGORY_EMOJI: Record<string, string> = {
|
||||||
'\uD130\uB110': '\uD83D\uDE87',
|
'\uD130\uB110': '\uD83D\uDE87',
|
||||||
'\uAD50\uB7C9': '\uD83C\uDF09',
|
'\uAD50\uB7C9': '\uD83C\uDF09',
|
||||||
@@ -55,11 +57,14 @@ function poiKm(poi: GeoPoint, stations: GeoPoint[]): number {
|
|||||||
return Math.round(ka + (kb - ka) * t);
|
return Math.round(ka + (kb - ka) * t);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelProps) {
|
export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, bottomPx = 200 }: RoutePanelProps) {
|
||||||
const [stations, setStations] = useState<GeoPoint[]>([]);
|
// 지리정보는 클라이언트 geoStore(폴더 선택 파싱 결과)에서 직접 읽는다.
|
||||||
const [pois, setPois] = useState<GeoPoint[]>([]);
|
// 서버 /api/geo/* fetch 대체. 폴더 미선택 시 빈 배열 → idle 렌더.
|
||||||
const [droneFramesLoaded, setDroneFramesLoaded] = useState(false);
|
const loaded = useGeoStore(s => s.loaded);
|
||||||
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
|
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 [currentKm, setCurrentKm] = useState(0);
|
||||||
const [currentStationTitle, setCurrentStationTitle] = useState('');
|
const [currentStationTitle, setCurrentStationTitle] = useState('');
|
||||||
const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null);
|
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 [dragYPct, setDragYPct] = useState(0);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
// Load POIs and stations
|
// POI/측점/드론 프레임은 위 geoStore 셀렉터로 구독한다(폴더 데이터 도착 시 자동 재렌더).
|
||||||
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]);
|
|
||||||
|
|
||||||
// 시점/종점: 역사(category=역사) POI 중 km 최소/최대
|
// 시점/종점: 역사(category=역사) POI 중 km 최소/최대
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -113,8 +96,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
|
|
||||||
// Update current km and visible range based on currentTime
|
// Update current km and visible range based on currentTime
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!droneFramesLoaded) return;
|
const frames = droneFrames;
|
||||||
const frames = allDroneFramesRef.current;
|
|
||||||
if (!frames.length || !stations.length) return;
|
if (!frames.length || !stations.length) return;
|
||||||
|
|
||||||
// Find closest frame by time
|
// Find closest frame by time
|
||||||
@@ -142,9 +124,26 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
nearestDist = d;
|
nearestDist = d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCurrentKm(stationKm(nearestStation.title));
|
|
||||||
setCurrentStationTitle(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)
|
// Calculate visible range (green box)
|
||||||
const allPoints = [...validStations, ...pois];
|
const allPoints = [...validStations, ...pois];
|
||||||
const visibleKms: number[] = [];
|
const visibleKms: number[] = [];
|
||||||
@@ -165,7 +164,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
} else {
|
} else {
|
||||||
setVisibleRange(null);
|
setVisibleRange(null);
|
||||||
}
|
}
|
||||||
}, [currentTime, droneFramesLoaded, stations, pois]);
|
}, [currentTime, droneFrames, stations, pois]);
|
||||||
|
|
||||||
// Drag handling
|
// Drag handling
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
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
|
// Find closest drone frame to that station's lat/lon
|
||||||
const frames = allDroneFramesRef.current;
|
const frames = droneFrames;
|
||||||
if (!frames.length) return;
|
if (!frames.length) return;
|
||||||
let bestFrame = frames[0];
|
let bestFrame = frames[0];
|
||||||
let bestFrameDist = (bestFrame.lat - bestStation.lat) ** 2 + (bestFrame.lon - bestStation.lon) ** 2;
|
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('mousemove', handleMouseMove);
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [dragging, stations, pois, onSeek]);
|
}, [dragging, stations, pois, droneFrames, onSeek]);
|
||||||
|
|
||||||
// Render guard
|
// Render guard — 폴더 미선택(!loaded) 또는 측점 없음이면 idle(미표시)
|
||||||
if (!visible || stations.length === 0) return null;
|
if (!visible || !loaded || stations.length === 0) return null;
|
||||||
const validStations = stations.filter(s => stationKm(s.title) >= 0);
|
const validStations = stations.filter(s => stationKm(s.title) >= 0);
|
||||||
if (validStations.length < 2) return null;
|
if (validStations.length < 2) return null;
|
||||||
|
|
||||||
@@ -254,7 +253,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="absolute w-28 border border-white/20 rounded-md z-30"
|
className="absolute w-28 border border-white/20 rounded-md z-30"
|
||||||
style={{ left: 10, top: '12%', height: '68%', background: 'rgba(0,0,0,0.6)' }}
|
style={{ left: 8, top: topPx, bottom: bottomPx, background: 'rgba(0,0,0,0.6)' }}
|
||||||
>
|
>
|
||||||
{/* Center vertical line */}
|
{/* Center vertical line */}
|
||||||
<div
|
<div
|
||||||
@@ -265,13 +264,13 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
{/* 높은 km 역명 — 상단 (대전조차장) */}
|
{/* 높은 km 역명 — 상단 (대전조차장) */}
|
||||||
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ top: 4 }}>
|
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ top: 4 }}>
|
||||||
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
||||||
<span className="text-[11px] text-white/90 font-semibold truncate">{cleanTitle(routeEndTitle)}</span>
|
<span className="text-[11px] text-white/90 font-semibold truncate">{routeMeta?.routeInfo?.endStationName || cleanTitle(routeEndTitle)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 낮은 km 역명 — 하단 (회덕) */}
|
{/* 낮은 km 역명 — 하단 (회덕) */}
|
||||||
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ bottom: 4 }}>
|
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ bottom: 4 }}>
|
||||||
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
||||||
<span className="text-[11px] text-white/90 font-semibold truncate">{cleanTitle(routeStartTitle)}</span>
|
<span className="text-[11px] text-white/90 font-semibold truncate">{routeMeta?.routeInfo?.startStationName || cleanTitle(routeStartTitle)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 교량/터널 POIs — 겹침 방지: Y 간격 7% 미만이면 건너뜀 */}
|
{/* 교량/터널 POIs — 겹침 방지: Y 간격 7% 미만이면 건너뜀 */}
|
||||||
@@ -351,7 +350,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
style={{ position: 'absolute', left: 44 }}
|
style={{ position: 'absolute', left: 44 }}
|
||||||
className="bg-orange-500 text-white text-[11px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap"
|
className="bg-orange-500 text-white text-[11px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{cleanTitle(currentStationTitle)}
|
{formatKm10(currentKm)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,21 +16,8 @@ import {
|
|||||||
type CameraCoords,
|
type CameraCoords,
|
||||||
DEFAULT_CAMERA_PARAMS,
|
DEFAULT_CAMERA_PARAMS,
|
||||||
} from '../../utils/geoProjection';
|
} from '../../utils/geoProjection';
|
||||||
|
import { useGeoStore } from '../../store/geoStore';
|
||||||
interface GeoPoint {
|
import type { GeoPoint, CenterlinePoint } from '../../types/geo';
|
||||||
title: string;
|
|
||||||
category: string;
|
|
||||||
lat: number;
|
|
||||||
lon: number;
|
|
||||||
z: number;
|
|
||||||
type: 'poi' | 'station';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CenterlinePoint {
|
|
||||||
lat: number;
|
|
||||||
lon: number;
|
|
||||||
z: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VIDEO_FPS = 30000 / 1001;
|
const VIDEO_FPS = 30000 / 1001;
|
||||||
|
|
||||||
@@ -39,6 +26,10 @@ interface Props {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
fps: number;
|
fps: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
/** 카메라 파라미터 패널 표시 (영상제어 토글). 기본 true. */
|
||||||
|
showPanel?: boolean;
|
||||||
|
/** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */
|
||||||
|
topPx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// category → 이모지
|
// 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<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const canvasSizeRef = useRef({ w: 0, h: 0 });
|
const canvasSizeRef = useRef({ w: 0, h: 0 });
|
||||||
|
|
||||||
@@ -138,6 +129,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
|
|
||||||
// 중심선 + 나침반 렌더 캐시 (per-frame)
|
// 중심선 + 나침반 렌더 캐시 (per-frame)
|
||||||
const renderCacheRef = useRef<RenderCache | null>(null);
|
const renderCacheRef = useRef<RenderCache | null>(null);
|
||||||
|
// 나침반 전용(측점선 visible 무관 항상 갱신·표시)
|
||||||
|
const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null);
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
|
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
|
||||||
@@ -173,43 +166,39 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
return best;
|
return best;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function updateWorldOrigin() {
|
// 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
|
||||||
const st = allGeoStationsRef.current;
|
const storeStations = useGeoStore(s => s.stations);
|
||||||
const cl = allCenterlinePointsRef.current;
|
const storePois = useGeoStore(s => s.pois);
|
||||||
if (st[0]) worldOriginRef.current = { lat: st[0].lat, lon: st[0].lon, alt: st[0].z };
|
const storeCenterline = useGeoStore(s => s.centerline);
|
||||||
else if (cl[0]) worldOriginRef.current = { lat: cl[0].lat, lon: cl[0].lon, alt: cl[0].z };
|
const storeFrames = useGeoStore(s => s.frames);
|
||||||
}
|
const storeOrigin = useGeoStore(s => s.origin);
|
||||||
|
|
||||||
// 데이터 로드
|
// 측점 (stationOrder 정렬 — 스토어가 이미 정렬해 두지만 방어적으로 동일 순서 보장)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
allGeoStationsRef.current = [...storeStations]
|
||||||
fetch('/api/geo/pois').then(r => r.json()).then((data: GeoPoint[]) => {
|
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
|
||||||
allGeoStationsRef.current = (data || []).filter(p => p.type === 'station')
|
allPoisRef.current = storePois;
|
||||||
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
|
setGeoDataLoaded(storeStations.length > 0 || storePois.length > 0);
|
||||||
allPoisRef.current = (data || []).filter(p => p.type === 'poi');
|
}, [storeStations, storePois]);
|
||||||
updateWorldOrigin();
|
|
||||||
setGeoDataLoaded(true);
|
|
||||||
}).catch(() => {});
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
|
// 중심선
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
allCenterlinePointsRef.current = storeCenterline;
|
||||||
fetch('/api/geo/centerline').then(r => r.json()).then((data: CenterlinePoint[]) => {
|
setClDataLoaded(storeCenterline.length > 0);
|
||||||
allCenterlinePointsRef.current = Array.isArray(data) ? data : [];
|
}, [storeCenterline]);
|
||||||
updateWorldOrigin();
|
|
||||||
setClDataLoaded(true);
|
|
||||||
}).catch(() => {});
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
|
// 드론 프레임
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || droneFramesLoaded) return;
|
allDroneFramesRef.current = storeFrames;
|
||||||
fetch('/api/geo/frames?step=1').then(r => r.json()).then((data: DroneFrameBasic[]) => {
|
setDroneFramesLoaded(storeFrames.length > 0);
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
}, [storeFrames]);
|
||||||
allDroneFramesRef.current = data;
|
|
||||||
setDroneFramesLoaded(true);
|
// ENU 월드 원점 — 스토어 origin 직접 사용 (서버 getWorldOrigin 대체)
|
||||||
}
|
useEffect(() => {
|
||||||
}).catch(() => {});
|
worldOriginRef.current = storeOrigin
|
||||||
}, [visible, droneFramesLoaded]);
|
? { lat: storeOrigin.lat, lon: storeOrigin.lon, alt: storeOrigin.alt }
|
||||||
|
: undefined;
|
||||||
|
}, [storeOrigin]);
|
||||||
|
|
||||||
// 드론 프레임 이동 평균 (GPS/자세 노이즈 제거)
|
// 드론 프레임 이동 평균 (GPS/자세 노이즈 제거)
|
||||||
const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => {
|
const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => {
|
||||||
@@ -307,7 +296,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
|
|
||||||
// 현재 재생 시간 → 드론 프레임 ref 갱신
|
// 현재 재생 시간 → 드론 프레임 ref 갱신
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !droneFramesLoaded) return;
|
// 나침반이 visible 무관 갱신되도록 visible 게이트 제거(프레임 탐색은 가벼움).
|
||||||
|
if (!droneFramesLoaded) return;
|
||||||
const frames = allDroneFramesRef.current;
|
const frames = allDroneFramesRef.current;
|
||||||
if (!frames.length) return;
|
if (!frames.length) return;
|
||||||
let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / VIDEO_FPS - currentTime);
|
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;
|
currentDroneFrameRef.current = best;
|
||||||
setPanelDroneFrame(best);
|
setPanelDroneFrame(best);
|
||||||
}
|
}
|
||||||
}, [currentTime, visible, droneFramesLoaded]);
|
}, [currentTime, droneFramesLoaded]);
|
||||||
|
|
||||||
// 중심선 + 나침반 캐시 빌드 (per-frame, 텍스트 계산 없음)
|
// 중심선 + 나침반 캐시 빌드 (per-frame, 텍스트 계산 없음)
|
||||||
useEffect(() => {
|
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; }
|
if (!currentDroneFrameRef.current || !visible) { renderCacheRef.current = null; return; }
|
||||||
|
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
@@ -419,10 +425,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { w: W, h: H } = canvasSizeRef.current;
|
const { w: W, h: H } = canvasSizeRef.current;
|
||||||
ctx.clearRect(0, 0, W, H);
|
ctx.clearRect(0, 0, W, H);
|
||||||
if (!visibleRef.current) return;
|
|
||||||
|
|
||||||
const cache = renderCacheRef.current;
|
const cache = renderCacheRef.current;
|
||||||
if (!cache) return;
|
if (visibleRef.current && cache) {
|
||||||
|
|
||||||
// 선로 중심선
|
// 선로 중심선
|
||||||
if (cache.centerlineSegs.length > 0) {
|
if (cache.centerlineSegs.length > 0) {
|
||||||
@@ -508,11 +513,13 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} // end if(visible && cache) — 중심선/측점/POI
|
||||||
|
|
||||||
// 나침반 HUD — 우측 상단 (상단 '카메라 파라미터' 버튼 아래로 띄워 하단 StationBar/배지와 겹침 방지)
|
// 나침반 HUD — 우측 상단, 측점선 토글과 무관하게 항상 표시
|
||||||
{
|
const compass = compassRef.current;
|
||||||
const r = 38;
|
if (compass) {
|
||||||
const cx = W-54, cy = 52 + r;
|
const r = 52;
|
||||||
|
const cx = W - (r + 14), cy = 16 + r;
|
||||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
|
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
|
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
|
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.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));
|
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);
|
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.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty);
|
||||||
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2.5; ctx.stroke();
|
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.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.strokeStyle = '#ffd700'; ctx.lineWidth = 2; ctx.stroke();
|
||||||
ctx.beginPath(); ctx.moveTo(cx, cy);
|
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.fillStyle = 'rgba(255,215,0,0.12)'; ctx.fill();
|
||||||
ctx.strokeStyle = 'rgba(255,215,0,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
ctx.strokeStyle = 'rgba(255,215,0,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
ctx.font = '9px monospace'; ctx.textBaseline = 'top'; ctx.fillStyle = '#ffd700';
|
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';
|
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);
|
rafId = requestAnimationFrame(draw);
|
||||||
@@ -566,11 +559,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="absolute inset-0 pointer-events-none z-20"
|
className="absolute inset-0 pointer-events-none z-20"
|
||||||
style={{ width: '100%', height: '100%', display: visible ? undefined : 'none' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2 right-2 z-30 flex flex-col items-end gap-1">
|
{showPanel && (
|
||||||
|
<div className="absolute left-2 z-30 flex flex-col items-start gap-1" style={{ top: topPx }}>
|
||||||
<button onClick={() => setShowControls(v => !v)}
|
<button onClick={() => setShowControls(v => !v)}
|
||||||
className="text-xs bg-black/70 hover:bg-black/90 text-gray-200 px-2 py-1 rounded border border-gray-500 shadow">
|
className="w-28 text-center text-[10px] whitespace-nowrap bg-black/70 hover:bg-black/90 text-gray-200 px-1 py-1 rounded border border-gray-500 shadow">
|
||||||
{showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
|
{showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
|
||||||
</button>
|
</button>
|
||||||
{showControls && (
|
{showControls && (
|
||||||
@@ -618,6 +612,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 StationOverlay from '../overlay/StationOverlay';
|
||||||
import RoutePanel from '../overlay/RoutePanel';
|
import RoutePanel from '../overlay/RoutePanel';
|
||||||
|
import RouteInfoOverlay from '../overlay/RouteInfoOverlay';
|
||||||
import 'video.js/dist/video-js.css';
|
import 'video.js/dist/video-js.css';
|
||||||
import { useVideoPlayer } from '../../hooks/useVideoPlayer';
|
import { useVideoPlayer } from '../../hooks/useVideoPlayer';
|
||||||
import { useFrameStep } from '../../hooks/useFrameStep';
|
import { useFrameStep } from '../../hooks/useFrameStep';
|
||||||
import { useKeyboard } from '../../hooks/useKeyboard';
|
import { useKeyboard } from '../../hooks/useKeyboard';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
|
import { useGeoStore } from '../../store/geoStore';
|
||||||
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
|
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
|
||||||
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
|
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
|
||||||
import { useCaptureStore } from '../../store/captureStore';
|
import { useCaptureStore } from '../../store/captureStore';
|
||||||
@@ -35,6 +37,79 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
const { stepForward, stepBackward, fps } = useFrameStep(playerRef);
|
const { stepForward, stepBackward, fps } = useFrameStep(playerRef);
|
||||||
const { currentTime, duration, playing, source, playbackRate } = usePlayerStore();
|
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
|
// Expose methods to parent via ref
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
loadLocalFile,
|
loadLocalFile,
|
||||||
@@ -65,6 +140,33 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
|
|
||||||
const handleAddMemo = () => onAddMemo(currentTime);
|
const handleAddMemo = () => onAddMemo(currentTime);
|
||||||
const [showStations, setShowStations] = useState(true);
|
const [showStations, setShowStations] = useState(true);
|
||||||
|
// 영상제어 토글: 카메라 파라미터·프레임상태·배속 표시 on/off
|
||||||
|
const [showVideoControls, setShowVideoControls] = useState(true);
|
||||||
|
// 좌하단 그룹을 재생바 위에 두기 위해 StationBar 높이를 동적 측정.
|
||||||
|
const barWrapRef = useRef<HTMLDivElement>(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 handleTogglePlay = (): void => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
@@ -108,63 +210,97 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
className="relative bg-black w-full"
|
className="relative bg-black w-full h-full"
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{/* 영상 영역 — 타임코드를 '영상 하단'에 앵커하기 위한 relative 래퍼 */}
|
{/* 영상 영역 — 컨테이너 전체를 채우는 relative 래퍼 (사이니지: 화면 가득) */}
|
||||||
<div className="relative w-full">
|
<div className="relative w-full h-full">
|
||||||
{/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */}
|
{/* Video.js container — 영상이 영역을 꽉 채우는 베이스 레이어 (object-fit:cover) */}
|
||||||
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
|
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
|
||||||
<div
|
<div
|
||||||
data-vjs-player
|
data-vjs-player
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
style={{ cursor: source ? 'pointer' : 'default' }}
|
style={{ cursor: source ? 'pointer' : 'default' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (source) handleTogglePlay();
|
if (source) handleTogglePlay();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* 프레임/타임코드 — 영상 좌상단 코너 HUD (정보 그룹) */}
|
{/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */}
|
||||||
|
<RouteInfoOverlay />
|
||||||
|
{/* 좌하단 그룹(재생바 위): 위→아래 = 배속 → 토글(측점선/영상제어) + 프레임정보 */}
|
||||||
{source && (
|
{source && (
|
||||||
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded font-mono pointer-events-none z-30">
|
<div
|
||||||
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
|
className="absolute left-2 z-30 flex flex-col items-start gap-2"
|
||||||
</div>
|
style={{ bottom: (barHeight || 130) + 8 }}
|
||||||
)}
|
>
|
||||||
{/* 재생 배속 — 영상 우하단 (항상 보임). 현재 배속은 파랗게 강조 */}
|
{/* 재생 배속 (영상제어 ON) */}
|
||||||
{source && (
|
{showVideoControls && (
|
||||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/70 px-2 py-1 rounded z-30">
|
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded pointer-events-auto">
|
||||||
<span className="text-gray-400 text-xs">배속</span>
|
<span className="text-gray-400 text-xs">배속</span>
|
||||||
{[1, 1.5, 2, 3, 4].map((r) => (
|
{[0.5, 1, 1.5, 2, 3, 4].map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => playerRef.current?.playbackRate(r)}
|
||||||
|
className={`text-xs px-1.5 py-0.5 rounded font-semibold ${
|
||||||
|
Math.abs(playbackRate - r) < 0.01
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 토글: 영상제어(카메라파라미터·프레임상태·배속) */}
|
||||||
|
<div className="flex items-center gap-2 pointer-events-auto">
|
||||||
<button
|
<button
|
||||||
key={r}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => playerRef.current?.playbackRate(r)}
|
onClick={() => setShowVideoControls((v) => !v)}
|
||||||
className={`text-xs px-1.5 py-0.5 rounded font-semibold ${
|
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'}`}
|
||||||
Math.abs(playbackRate - r) < 0.01
|
>영상제어 {showVideoControls ? 'ON' : 'OFF'}</button>
|
||||||
? 'bg-blue-600 text-white'
|
{/* 프레임/타임코드 — 영상제어 ON 버튼 오른쪽 */}
|
||||||
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
{showVideoControls && (
|
||||||
}`}
|
<span className="bg-black/70 text-white text-xs px-2 py-1 rounded font-mono">
|
||||||
>
|
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
|
||||||
{r}x
|
</span>
|
||||||
</button>
|
)}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 루트 패널 미니맵 — 영상 영역 기준(아래 StationBar 침범 방지) */}
|
{/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */}
|
||||||
<RoutePanel
|
<RoutePanel
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
visible={showStations}
|
visible={showStations}
|
||||||
onSeek={(time) => playerRef.current?.currentTime(time)}
|
onSeek={(time) => playerRef.current?.currentTime(time)}
|
||||||
|
topPx={paramTop + 40}
|
||||||
|
bottomPx={(barHeight || 130) + 90}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty state placeholder */}
|
{/* Empty state placeholder — 가운데 반투명 폴더 선택 */}
|
||||||
{!source && (
|
{!source && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-500 pointer-events-none select-none" style={{ minHeight: '240px' }}>
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none select-none" style={{ minHeight: '240px' }}>
|
||||||
<div className="text-5xl mb-3">▶</div>
|
<div className="text-5xl mb-3 opacity-60">▶</div>
|
||||||
<p className="text-lg">동영상을 드래그하거나 아래에서 선택하세요</p>
|
<p className="text-lg">동영상 폴더를 드래그하거나 선택하세요</p>
|
||||||
<p className="text-sm mt-1">로컬 파일 또는 서버 영상 재생 지원</p>
|
<p className="text-sm mt-1 mb-6">영상 + 측점/POI 데이터가 함께 로드됩니다</p>
|
||||||
|
<label className="pointer-events-auto cursor-pointer bg-emerald-500/20 hover:bg-emerald-500/40 backdrop-blur-sm border border-emerald-300/40 text-white text-base font-medium px-7 py-3 rounded-xl shadow-lg transition-colors">
|
||||||
|
폴더 선택
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
webkitdirectory=""
|
||||||
|
directory=""
|
||||||
|
multiple
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files?.length) void handleSelectFolder(Array.from(files));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -174,11 +310,16 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
fps={fps}
|
fps={fps}
|
||||||
visible={showStations}
|
visible={showStations}
|
||||||
|
showPanel={showVideoControls && !!source}
|
||||||
|
topPx={paramTop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 측점 기반 재생 바 — videoplayer-main 스테이션 바 이식 (시간 스크러버 대체) */}
|
{/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */}
|
||||||
|
{geoLoaded && (
|
||||||
|
<div ref={barWrapRef} className="absolute bottom-0 left-0 right-0 z-20">
|
||||||
<StationBar
|
<StationBar
|
||||||
currentTime={currentTime}
|
currentTime={smoothTime}
|
||||||
|
timeRef={smoothTimeRef}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
playing={playing}
|
playing={playing}
|
||||||
onTogglePlay={handleTogglePlay}
|
onTogglePlay={handleTogglePlay}
|
||||||
@@ -188,18 +329,33 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
showStations={showStations}
|
showStations={showStations}
|
||||||
onToggleStations={() => setShowStations((v) => !v)}
|
onToggleStations={() => setShowStations((v) => !v)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) */}
|
{/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) — UI 숨김(코드 보존, SHOW_TOOLBAR 로 제어) */}
|
||||||
<div className="flex items-center gap-2 p-2 bg-gray-900 flex-wrap">
|
{SHOW_TOOLBAR && (
|
||||||
<label className="cursor-pointer bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 rounded">
|
<div className="bg-gray-900 border-t border-gray-800">
|
||||||
파일 선택
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUtilBar((v) => !v)}
|
||||||
|
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<span className={`inline-block transition-transform ${showUtilBar ? 'rotate-90' : ''}`}>▸</span>
|
||||||
|
도구 {showUtilBar ? '접기' : '펼치기'}
|
||||||
|
</button>
|
||||||
|
{showUtilBar && (
|
||||||
|
<div className="flex items-center gap-2 px-2 pb-2 flex-wrap">
|
||||||
|
<label className="cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-sm px-3 py-1.5 rounded">
|
||||||
|
폴더 선택
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="video/*"
|
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
webkitdirectory=""
|
||||||
|
directory=""
|
||||||
|
multiple
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const files = e.target.files;
|
||||||
if (file) loadLocalFile(file);
|
if (files?.length) void handleSelectFolder(Array.from(files));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -245,7 +401,10 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
<span className="text-gray-500 text-xs ml-auto hidden sm:inline">
|
<span className="text-gray-500 text-xs ml-auto hidden sm:inline">
|
||||||
Space 재생 | ←/→ 5초 | J/L 10초 | ,/. 프레임 | Shift+S 캡처
|
Space 재생 | ←/→ 5초 | J/L 10초 | ,/. 프레임 | Shift+S 캡처
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,14 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
|||||||
if (!containerRef.current || playerRef.current) return;
|
if (!containerRef.current || playerRef.current) return;
|
||||||
|
|
||||||
const videoEl = document.createElement('video-js');
|
const videoEl = document.createElement('video-js');
|
||||||
videoEl.classList.add('vjs-big-play-centered', 'vjs-fluid');
|
// fill: 컨테이너를 꽉 채움(사이니지처럼 화면 가득). object-fit:cover 로 비율 유지 크롭.
|
||||||
|
videoEl.classList.add('vjs-big-play-centered', 'vjs-fill');
|
||||||
containerRef.current.appendChild(videoEl);
|
containerRef.current.appendChild(videoEl);
|
||||||
|
|
||||||
const player = videojs(videoEl, {
|
const player = videojs(videoEl, {
|
||||||
// 하단 시간 스크러버는 측점 기반 StationBar 로 대체하므로 Video.js 기본 컨트롤바 숨김
|
// 하단 시간 스크러버는 측점 기반 StationBar 로 대체하므로 Video.js 기본 컨트롤바 숨김
|
||||||
controls: false,
|
controls: false,
|
||||||
fluid: true,
|
fill: true,
|
||||||
responsive: true,
|
responsive: true,
|
||||||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4],
|
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4],
|
||||||
html5: { vhs: { overrideNative: true } },
|
html5: { vhs: { overrideNative: true } },
|
||||||
|
|||||||
@@ -12,3 +12,9 @@ body {
|
|||||||
background: #030712;
|
background: #030712;
|
||||||
color: #f9fafb;
|
color: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 영상이 컨테이너를 비율 유지하며 꽉 채움(사이니지 fill, 넘침만 크롭) */
|
||||||
|
.video-js.vjs-fill,
|
||||||
|
.video-js .vjs-tech {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||||||
import type { DroneFrameBasic } from '../utils/geoProjection';
|
import type { DroneFrameBasic } from '../utils/geoProjection';
|
||||||
|
import { useGeoStore } from '../store/geoStore';
|
||||||
import {
|
import {
|
||||||
STAGE_WIDTH,
|
STAGE_WIDTH,
|
||||||
TRACK_END_PX,
|
TRACK_END_PX,
|
||||||
TRACK_START_PX,
|
TRACK_START_PX,
|
||||||
TRACK_WIDTH_PX,
|
TRACK_WIDTH_PX,
|
||||||
|
renderX,
|
||||||
trackXFromRender,
|
trackXFromRender,
|
||||||
} from './mocks/route';
|
} from './mocks/route';
|
||||||
import { cssVars } from './utils/cssVars';
|
import { cssVars } from './utils/cssVars';
|
||||||
import { formatMileage, mileageAtPx } from './utils/mileage';
|
import { formatMileage } from './utils/mileage';
|
||||||
import { PlaybackControls } from './components/PlaybackControls/PlaybackControls';
|
import { PlaybackControls } from './components/PlaybackControls/PlaybackControls';
|
||||||
import { Timeline } from './components/Timeline/Timeline';
|
import { Timeline } from './components/Timeline/Timeline';
|
||||||
import { TimelineCursor } from './components/TimelineCursor/TimelineCursor';
|
import { TimelineCursor } from './components/TimelineCursor/TimelineCursor';
|
||||||
@@ -81,15 +83,18 @@ export interface KmLabel {
|
|||||||
px: number;
|
px: number;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
/** 구조물 마크 (교량/터널/역사). px는 통과 시점(시간축). */
|
/** 구조물 마크 (교량/터널/역사). px는 통과 시점(시간축), km은 측점값(연속 체이니지, m). */
|
||||||
export interface StructMark {
|
export interface StructMark {
|
||||||
px: number;
|
px: number;
|
||||||
title: string;
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
km: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StationBarProps {
|
interface StationBarProps {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
|
/** 라이브 재생시간 ref (매 프레임 갱신). 커서/진행바를 React 리렌더 없이 직접 이동. */
|
||||||
|
timeRef?: RefObject<number>;
|
||||||
duration: number;
|
duration: number;
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
onTogglePlay: () => void;
|
onTogglePlay: () => void;
|
||||||
@@ -122,6 +127,23 @@ function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number {
|
|||||||
return best;
|
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 실제 영상 데이터 기반 측점 바.
|
* abcVideo 실제 영상 데이터 기반 측점 바.
|
||||||
* - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색).
|
* - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색).
|
||||||
@@ -131,6 +153,7 @@ function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number {
|
|||||||
*/
|
*/
|
||||||
export function StationBar({
|
export function StationBar({
|
||||||
currentTime,
|
currentTime,
|
||||||
|
timeRef,
|
||||||
duration,
|
duration,
|
||||||
playing,
|
playing,
|
||||||
onTogglePlay,
|
onTogglePlay,
|
||||||
@@ -145,28 +168,18 @@ export function StationBar({
|
|||||||
const [scale, setScale] = useState(0.5);
|
const [scale, setScale] = useState(0.5);
|
||||||
const draggingRef = useRef(false);
|
const draggingRef = useRef(false);
|
||||||
|
|
||||||
const [pois, setPois] = useState<GeoPoint[]>([]);
|
// 측점/POI/드론프레임은 폴더 선택으로 채워지는 geoStore에서 읽는다 (서버 /api/geo fetch 대체).
|
||||||
const framesRef = useRef<DroneFrameBasic[]>([]);
|
const storePois = useGeoStore((s) => s.pois);
|
||||||
const [framesVersion, setFramesVersion] = useState(0);
|
const storeStations = useGeoStore((s) => s.stations);
|
||||||
|
const pois = useMemo<GeoPoint[]>(
|
||||||
|
() => [...storeStations, ...storePois],
|
||||||
|
[storeStations, storePois],
|
||||||
|
);
|
||||||
|
const storeFrames = useGeoStore((s) => s.frames);
|
||||||
|
const routeMeta = useGeoStore((s) => s.routeMeta);
|
||||||
const viewedRef = useRef<ViewedPoint[]>([]);
|
const viewedRef = useRef<ViewedPoint[]>([]);
|
||||||
const [ready, setReady] = useState(false);
|
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(
|
const stations = useMemo(
|
||||||
() => pois.filter((p) => p.type === 'station' && stationKm(p.title) >= 0),
|
() => pois.filter((p) => p.type === 'station' && stationKm(p.title) >= 0),
|
||||||
[pois],
|
[pois],
|
||||||
@@ -188,8 +201,8 @@ export function StationBar({
|
|||||||
// - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일).
|
// - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일).
|
||||||
// - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
|
// - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frames = framesRef.current;
|
const frames = storeFrames;
|
||||||
if (!frames.length || !stations.length || !stationLine) return;
|
if (!frames.length || !stations.length || !stationLine) { setReady(false); return; }
|
||||||
const out: ViewedPoint[] = new Array(frames.length);
|
const out: ViewedPoint[] = new Array(frames.length);
|
||||||
for (let i = 0; i < frames.length; i++) {
|
for (let i = 0; i < frames.length; i++) {
|
||||||
const f = frames[i];
|
const f = frames[i];
|
||||||
@@ -202,7 +215,7 @@ export function StationBar({
|
|||||||
}
|
}
|
||||||
viewedRef.current = out;
|
viewedRef.current = out;
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [stations, stationLine, framesVersion]);
|
}, [stations, stationLine, storeFrames]);
|
||||||
|
|
||||||
/** 현재 시간에 가장 가까운 precompute 인덱스. */
|
/** 현재 시간에 가장 가까운 precompute 인덱스. */
|
||||||
const viewedIdxAtTime = useCallback((t: number): number => {
|
const viewedIdxAtTime = useCallback((t: number): number => {
|
||||||
@@ -221,11 +234,11 @@ export function StationBar({
|
|||||||
return best;
|
return best;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 현재 보는 측점 km
|
// 현재 보는 측점값 — 연속 체이니지(chain)로 10m 해상도 (km 필드는 100m 양자화).
|
||||||
const realKm = useMemo<number | null>(() => {
|
const realChain = useMemo<number | null>(() => {
|
||||||
if (!ready) return null;
|
if (!ready) return null;
|
||||||
const idx = viewedIdxAtTime(currentTime);
|
const idx = viewedIdxAtTime(currentTime);
|
||||||
return idx >= 0 ? viewedRef.current[idx].km : null;
|
return idx >= 0 ? viewedRef.current[idx].chain : null;
|
||||||
}, [currentTime, ready, viewedIdxAtTime]);
|
}, [currentTime, ready, viewedIdxAtTime]);
|
||||||
|
|
||||||
// 프레임(시간) 진행률 px — 커서의 단조 이동 기준.
|
// 프레임(시간) 진행률 px — 커서의 단조 이동 기준.
|
||||||
@@ -242,10 +255,10 @@ export function StationBar({
|
|||||||
// → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다.
|
// → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다.
|
||||||
const cursorPx = progressPx;
|
const cursorPx = progressPx;
|
||||||
|
|
||||||
|
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시.
|
||||||
|
// mock ROUTE_LEGS(mileageAtPx) 의존 제거. 데이터 없으면 빈 문자열.
|
||||||
const cursorText =
|
const cursorText =
|
||||||
realKm !== null && realKm >= 0
|
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
|
||||||
? formatMileage(realKm)
|
|
||||||
: formatMileage(mileageAtPx(cursorPx));
|
|
||||||
|
|
||||||
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
|
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
|
||||||
// 시간(프레임) 진행을 트랙 px로 선형 변환.
|
// 시간(프레임) 진행을 트랙 px로 선형 변환.
|
||||||
@@ -269,10 +282,15 @@ export function StationBar({
|
|||||||
// 방향은 연속 체이니지(chain)로 판정 — 100m 양자화 km은 전환을 ~13s 빨리 잡아 영상과 어긋남.
|
// 방향은 연속 체이니지(chain)로 판정 — 100m 양자화 km은 전환을 ~13s 빨리 잡아 영상과 어긋남.
|
||||||
const HYST = 100; // m — 이 이상 반대로 움직여야 방향 전환으로 인정 (GPS 노이즈 무시)
|
const HYST = 100; // m — 이 이상 반대로 움직여야 방향 전환으로 인정 (GPS 노이즈 무시)
|
||||||
const segs: BarSegment[] = [];
|
const segs: BarSegment[] = [];
|
||||||
const bounds: { chain: number; time: number }[] = [
|
const bounds: { chain: number; time: number; turn: boolean }[] = [
|
||||||
{ chain: arr[0].chain, time: arr[0].time },
|
{ chain: arr[0].chain, time: arr[0].time, turn: false },
|
||||||
];
|
];
|
||||||
|
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정. 기본 증가 가정이 틀리면 첫 구간 색이 반대로 나옴.
|
||||||
let dir: 1 | -1 = 1;
|
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 extCh = arr[0].chain;
|
||||||
let extIdx = 0;
|
let extIdx = 0;
|
||||||
let startIdx = 0;
|
let startIdx = 0;
|
||||||
@@ -287,7 +305,7 @@ export function StationBar({
|
|||||||
endPx: pxAtTime(arr[extIdx].time),
|
endPx: pxAtTime(arr[extIdx].time),
|
||||||
dir,
|
dir,
|
||||||
});
|
});
|
||||||
bounds.push({ chain: extCh, time: arr[extIdx].time });
|
bounds.push({ chain: extCh, time: arr[extIdx].time, turn: true });
|
||||||
dir = dir > 0 ? -1 : 1;
|
dir = dir > 0 ? -1 : 1;
|
||||||
startIdx = extIdx;
|
startIdx = extIdx;
|
||||||
extCh = c;
|
extCh = c;
|
||||||
@@ -299,76 +317,217 @@ export function StationBar({
|
|||||||
endPx: pxAtTime(arr[arr.length - 1].time),
|
endPx: pxAtTime(arr[arr.length - 1].time),
|
||||||
dir,
|
dir,
|
||||||
});
|
});
|
||||||
bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time });
|
bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time, turn: false });
|
||||||
// 라벨은 전환점 체이니지를 최근접 측점(100m)으로 스냅해 표시.
|
// 전환(턴) 지점은 실제 위치를 10m 단위로, 시·종점 등 기본 라벨은 100m 단위로 표시.
|
||||||
const labels: KmLabel[] = bounds.map((b) => ({
|
const labels: KmLabel[] = bounds.map((b) => ({
|
||||||
px: pxAtTime(b.time),
|
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 };
|
return { barSegments: segs, kmLabels: labels };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ready, duration, framesVersion, pxAtTime]);
|
}, [ready, duration, storeFrames, pxAtTime]);
|
||||||
|
|
||||||
// 구조물(교량/터널/역사) → 체이니지(최근접 측점 km) → 통과 시점 px.
|
// viewedRef의 km(최근접 측점)가 주어진 mileage(m)에 가장 가까운 프레임 시간 → px.
|
||||||
const structureMarks = useMemo<StructMark[]>(() => {
|
const pxAtMileage = useCallback(
|
||||||
const arr = viewedRef.current;
|
(mileage: number): number | null => {
|
||||||
if (!ready || !arr.length || duration <= 0 || !stations.length) return [];
|
const arr = viewedRef.current;
|
||||||
const cats = new Set(['교량', '터널', '역사']);
|
if (!arr.length) return null;
|
||||||
const seen = new Set<string>();
|
|
||||||
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;
|
|
||||||
let best = -1;
|
let best = -1;
|
||||||
let bd = Infinity;
|
let bd = Infinity;
|
||||||
for (let i = 0; i < arr.length; i++) {
|
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) {
|
if (d < bd) {
|
||||||
bd = d;
|
bd = d;
|
||||||
best = i;
|
best = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (best >= 0)
|
return best >= 0 ? pxAtTime(arr[best].time) : null;
|
||||||
out.push({ px: pxAtTime(arr[best].time), title: base, category: p.category });
|
},
|
||||||
|
[pxAtTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 구조물(교량/터널/역사) 위치 = 실제 좌표(POI 위경도)에 드론 GPS 가 가장 가까워지는
|
||||||
|
// 프레임의 시점 px. → 영상에 구조물이 실제 지나가는 순간과 일치.
|
||||||
|
// route.json structures 의 이정값은 매칭되는 POI 가 없을 때만 폴백으로 사용.
|
||||||
|
const structureMarks = useMemo<StructMark[]>(() => {
|
||||||
|
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<string, { lat: number; lon: number; category: string }>();
|
||||||
|
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;
|
return out;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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,
|
// 방향 색 트랙 gradient 빌더(전진/후진 색을 받아 구성). 구간 경계는 부드럽게 섞는다.
|
||||||
// 약 3~4초)는 딱딱한 경계 대신 부드러운 그라데이션으로 주황↔하늘색이 섞이게 한다.
|
const buildGradient = useCallback(
|
||||||
const trackGradient = useMemo<string>(() => {
|
(FWD: string, BWD: string): string => {
|
||||||
const segs = barSegments;
|
const segs = barSegments;
|
||||||
if (!segs.length) return '';
|
if (!segs.length) return '';
|
||||||
const FWD = '#ff8a25'; // 전진(km 증가) 주황
|
const col = (d: 1 | -1) => (d > 0 ? FWD : BWD);
|
||||||
const BWD = '#06a4c8'; // 후진(km 감소) 하늘색
|
const pct = (px: number) =>
|
||||||
const col = (d: 1 | -1) => (d > 0 ? FWD : BWD);
|
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||||
// 전환 폭 ≈ 4초 (회전 소요시간). 최소/최대 px 로 가시성 보장.
|
// 전환점에서 색이 '한번에' 바뀌도록 하드 스톱(같은 위치에 두 색).
|
||||||
const transPx = duration > 0 ? clamp((4 / duration) * TRACK_WIDTH_PX, 6, 40) : 10;
|
const stops: string[] = [`${col(segs[0].dir)} 0%`];
|
||||||
const pct = (px: number) =>
|
for (let i = 1; i < segs.length; i++) {
|
||||||
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
const bp = pct(segs[i].startPx).toFixed(2);
|
||||||
const stops: string[] = [`${col(segs[0].dir)} 0%`];
|
stops.push(`${col(segs[i - 1].dir)} ${bp}%`);
|
||||||
for (let i = 1; i < segs.length; i++) {
|
stops.push(`${col(segs[i].dir)} ${bp}%`);
|
||||||
const b = segs[i].startPx;
|
}
|
||||||
const w = Math.min(
|
stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
|
||||||
transPx / 2,
|
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||||
(segs[i - 1].endPx - segs[i - 1].startPx) / 2,
|
},
|
||||||
(segs[i].endPx - segs[i].startPx) / 2,
|
[barSegments, duration],
|
||||||
);
|
);
|
||||||
stops.push(`${col(segs[i - 1].dir)} ${pct(b - w).toFixed(2)}%`);
|
// 통과 구간 음영 그라데이션(videoplayer 원본): 구간마다 좌→우 3색 셰이딩, 구간 경계는 하드.
|
||||||
stops.push(`${col(segs[i].dir)} ${pct(b + w).toFixed(2)}%`);
|
// 전진 주황: #ffc257 → #ff8a25 → #ff7b1b / 역방향 청록: #5ca887 → #35a7a7 → #06a4c8
|
||||||
}
|
const buildShaded = useCallback(
|
||||||
stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
|
(fwd: [string, string, string], bwd: [string, string, string]): string => {
|
||||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
const segs = barSegments;
|
||||||
}, [barSegments, duration]);
|
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 감소, 하늘색) 구간이면 커서도 파란색.
|
// 현재 위치가 역방향(km 감소, 하늘색) 구간이면 커서도 파란색.
|
||||||
const currentReverse = useMemo<boolean>(() => {
|
const currentReverse = useMemo<boolean>(() => {
|
||||||
@@ -390,6 +549,43 @@ export function StationBar({
|
|||||||
return () => ro.disconnect();
|
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 (시간축 일관).
|
// 클릭/드래그 트랙 px → 시간(프레임) 선형 변환으로 seek (시간축 일관).
|
||||||
const seekToTrackX = useCallback(
|
const seekToTrackX = useCallback(
|
||||||
(trackX: number) => {
|
(trackX: number) => {
|
||||||
@@ -435,15 +631,30 @@ export function StationBar({
|
|||||||
[seekFromClientX],
|
[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 (실데이터 기반).
|
// 측점입력(예: 158k200) → 그 측점을 보는(최근접) 프레임으로 seek (실데이터 기반).
|
||||||
const handleJumpToMileage = useCallback(
|
const handleJumpToMileage = useCallback(
|
||||||
(km: number) => {
|
(km: number) => {
|
||||||
const arr = viewedRef.current;
|
const arr = viewedRef.current;
|
||||||
if (!arr.length || duration <= 0) return;
|
if (!arr.length || duration <= 0) return;
|
||||||
|
// 연속 체이니지(chain) 기준으로 입력 측점에 가장 가까운 프레임 탐색.
|
||||||
|
// (km은 100m 양자화 최근접 측점이라 입력값과 오차 발생)
|
||||||
let best = -1;
|
let best = -1;
|
||||||
let bd = Infinity;
|
let bd = Infinity;
|
||||||
for (let i = 0; i < arr.length; i++) {
|
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) {
|
if (d < bd) {
|
||||||
bd = d;
|
bd = d;
|
||||||
best = i;
|
best = i;
|
||||||
@@ -475,12 +686,15 @@ export function StationBar({
|
|||||||
posPx={cursorPx}
|
posPx={cursorPx}
|
||||||
onSeekDown={handleSeekDown}
|
onSeekDown={handleSeekDown}
|
||||||
trackGradient={trackGradient}
|
trackGradient={trackGradient}
|
||||||
|
trackGradientIdle={trackGradientIdle}
|
||||||
|
dividers={dividers}
|
||||||
kmLabels={kmLabels}
|
kmLabels={kmLabels}
|
||||||
structures={structureMarks}
|
structures={structureMarks}
|
||||||
|
startStationName={startStationName}
|
||||||
|
endStationName={endStationName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TimelineCursor
|
<TimelineCursor
|
||||||
posPx={cursorPx}
|
|
||||||
mileageText={cursorText}
|
mileageText={cursorText}
|
||||||
reverse={currentReverse}
|
reverse={currentReverse}
|
||||||
onSeekDown={handleSeekDown}
|
onSeekDown={handleSeekDown}
|
||||||
|
|||||||
@@ -206,9 +206,9 @@
|
|||||||
|
|
||||||
.mileageRow {
|
.mileageRow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 4px;
|
left: 0;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
width: 1912px;
|
width: 1920px;
|
||||||
height: 13px;
|
height: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
@@ -220,6 +220,7 @@
|
|||||||
top: 51px;
|
top: 51px;
|
||||||
width: 1920px;
|
width: 1920px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
font-size: 16.5px;
|
font-size: 16.5px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
|
import { RouteSegment } from '../RouteSegment/RouteSegment';
|
||||||
|
import type { StructureType, StructureSegment } from '../../types/timeline';
|
||||||
import {
|
import {
|
||||||
TRACK_RENDER_END_PX,
|
TRACK_RENDER_END_PX,
|
||||||
TRACK_RENDER_START_PX,
|
TRACK_RENDER_START_PX,
|
||||||
@@ -11,18 +14,43 @@ import { cssVars, px } from '../../utils/cssVars';
|
|||||||
import { MileageMarker } from '../MileageMarker/MileageMarker';
|
import { MileageMarker } from '../MileageMarker/MileageMarker';
|
||||||
import styles from './Timeline.module.scss';
|
import styles from './Timeline.module.scss';
|
||||||
|
|
||||||
|
/** 미터값 → "158k160" (10m 단위). 음수면 빈 문자열. */
|
||||||
|
function fmtKm(m: number): string {
|
||||||
|
if (m < 0) return '';
|
||||||
|
const r = Math.round(m / 10) * 10;
|
||||||
|
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface TimelineProps {
|
interface TimelineProps {
|
||||||
posPx: number;
|
posPx: number;
|
||||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||||
/** 방향 색 트랙 CSS gradient (전진=주황/후진=하늘색, 회전구간 부드러운 전환). */
|
/** 통과 구간 트랙 gradient (전진=주황/후진=청록). */
|
||||||
trackGradient: string;
|
trackGradient: string;
|
||||||
|
/** 미통과 구간 트랙 gradient (전진=회색/후진=청회색). */
|
||||||
|
trackGradientIdle: string;
|
||||||
|
/** 색(방향)이 바뀌는 전환점 px(스테이지 좌표) — 구분선 위치. */
|
||||||
|
dividers: number[];
|
||||||
/** 데이터 기반 측점값 라벨 (방향 전환점·시종점). */
|
/** 데이터 기반 측점값 라벨 (방향 전환점·시종점). */
|
||||||
kmLabels: KmLabel[];
|
kmLabels: KmLabel[];
|
||||||
/** 데이터 기반 구조물 (교량/터널/역사). */
|
/** 데이터 기반 구조물 (교량/터널/역사). */
|
||||||
structures: StructMark[];
|
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개만 표시.
|
// 라벨 겹침 방지: x px 순으로 gap px 이내는 1개만 표시.
|
||||||
const dedup = <T extends { px: number }>(items: T[], gap: number): T[] => {
|
const dedup = <T extends { px: number }>(items: T[], gap: number): T[] => {
|
||||||
const sorted = [...items].sort((a, b) => a.px - b.px);
|
const sorted = [...items].sort((a, b) => a.px - b.px);
|
||||||
@@ -32,7 +60,6 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
const labels = dedup(kmLabels, 28);
|
|
||||||
// 구조물명은 텍스트가 길어 더 넓은 간격으로 (겹침 방지).
|
// 구조물명은 텍스트가 길어 더 넓은 간격으로 (겹침 방지).
|
||||||
const structs = dedup(structures, 90);
|
const structs = dedup(structures, 90);
|
||||||
|
|
||||||
@@ -41,7 +68,7 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
|||||||
{/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
|
{/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
|
||||||
<div className={styles.trackFade} />
|
<div className={styles.trackFade} />
|
||||||
|
|
||||||
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 신탄진·대전 배치 */}
|
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 시·종점 역명 배치 (route.json/측점 유래) */}
|
||||||
<div
|
<div
|
||||||
className={styles.stationLayer}
|
className={styles.stationLayer}
|
||||||
style={cssVars({
|
style={cssVars({
|
||||||
@@ -49,12 +76,13 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
|||||||
'--track-end': px(TRACK_RENDER_END_PX),
|
'--track-end': px(TRACK_RENDER_END_PX),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className={styles.stationStart}>신탄진</div>
|
{/* 좌측(시점)=역방향 하늘색, 우측(종점)=정방향 주황 */}
|
||||||
<div className={styles.dotStart} />
|
<div className={styles.stationStart} style={{ color: '#06a4c8' }}>{startStationName ?? ''}</div>
|
||||||
<div className={styles.leaderStart} />
|
<div className={styles.dotStart} style={{ background: '#06a4c8' }} />
|
||||||
<div className={styles.leaderEnd} />
|
<div className={styles.leaderStart} style={cssVars({ '--color-station-leader': '#06a4c8' })} />
|
||||||
<div className={styles.dotEnd} />
|
<div className={styles.leaderEnd} style={cssVars({ '--color-station-leader': '#ff8a25' })} />
|
||||||
<div className={styles.stationEnd}>대전</div>
|
<div className={styles.dotEnd} style={{ background: '#ff8a25' }} />
|
||||||
|
<div className={styles.stationEnd} style={{ color: '#ff8a25' }}>{endStationName ?? ''}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 트랙 본체 (가로 압축 래퍼) */}
|
{/* 트랙 본체 (가로 압축 래퍼) */}
|
||||||
@@ -76,7 +104,7 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
|||||||
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색.
|
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색.
|
||||||
전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
|
전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
|
||||||
<div className={styles.track}>
|
<div className={styles.track}>
|
||||||
{/* 미재생: 방향색 저톤 (전체 폭) */}
|
{/* 미재생(미통과): 회색/청회색 (전체 폭) */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -84,21 +112,20 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
|||||||
top: 0,
|
top: 0,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: px(TRACK_WIDTH_PX),
|
width: px(TRACK_WIDTH_PX),
|
||||||
background: trackGradient,
|
background: trackGradientIdle,
|
||||||
opacity: 0.18,
|
opacity: 1,
|
||||||
borderRadius: 'inherit',
|
borderRadius: 'inherit',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* 재생된 구간: 원래 색 복원 (커서까지 clip) */}
|
{/* 재생된 구간: 원래 색 복원 (커서까지 clip). 폭은 CSS 변수(--pos-px)로 매 프레임
|
||||||
|
직접 갱신되어 React 리렌더 없이 부드럽게 늘어난다. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: px(
|
width: `max(0px, min(${TRACK_WIDTH_PX}px, calc(var(--pos-px, ${TRACK_START_PX}px) - ${TRACK_START_PX}px)))`,
|
||||||
Math.max(0, Math.min(TRACK_WIDTH_PX, posPx - TRACK_START_PX)),
|
|
||||||
),
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -114,30 +141,115 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* 색(방향) 전환점 구분선 — videoplayer leg 솔기와 동일(검정+흰색 점선) */}
|
||||||
|
{dividers.map((dpx, i) => (
|
||||||
{/* 측점값 라벨 (데이터) */}
|
<div
|
||||||
<div className={styles.mileageRow}>
|
key={`div-${i}`}
|
||||||
{labels.map((l, i) => (
|
style={{
|
||||||
<MileageMarker
|
position: 'absolute',
|
||||||
key={i}
|
left: dpx - TRACK_START_PX,
|
||||||
marker={{ id: `km-${i}`, value: l.text, left: l.px, mileage: 0 }}
|
top: 0,
|
||||||
|
width: 0,
|
||||||
|
height: '100%',
|
||||||
|
transform: 'translateX(-1px)',
|
||||||
|
borderLeft: '1px dashed rgba(0, 0, 0, 0.7)',
|
||||||
|
borderRight: '1px dashed rgba(255, 255, 255, 0.3)',
|
||||||
|
zIndex: 3,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구조물 라벨 (데이터: 교량/터널/역사) */}
|
{/* 측점값 라벨 — 각 시설물 측점값(아이콘 위) */}
|
||||||
|
<div className={styles.mileageRow}>
|
||||||
|
{structs.map((s, i) =>
|
||||||
|
s.km >= 0 ? (
|
||||||
|
<MileageMarker
|
||||||
|
key={`stkm-${i}`}
|
||||||
|
marker={{ id: `stkm-${i}`, value: fmtKm(s.km), left: s.px, mileage: 0 }}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구조물 아이콘 — 재생바 위에 겹쳐 표시 (videoplayer 3-slice) */}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, zIndex: 4, pointerEvents: 'none' }}>
|
||||||
|
{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 (
|
||||||
|
<Fragment key={i}>
|
||||||
|
<img src={`${base}assets/route-segment/terminal/circle-upcoming.png`} alt="" style={{ ...common, opacity: passed ? 0 : 1 }} />
|
||||||
|
<img src={`${base}assets/route-segment/terminal/circle-passed.png`} alt="" style={{ ...common, opacity: passed ? 1 : 0 }} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<Fragment key={i}>
|
||||||
|
<div style={layer(passed ? 0 : 1)}><RouteSegment segment={seg} state="upcoming" /></div>
|
||||||
|
<div style={layer(passed ? 1 : 0)}><RouteSegment segment={seg} state="passed" /></div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경 */}
|
||||||
<div className={styles.labelRow}>
|
<div className={styles.labelRow}>
|
||||||
{structs.map((s, i) => (
|
{structs.map((s, i) => {
|
||||||
<div
|
const W = s.category === '터널' ? 24 : 22;
|
||||||
key={i}
|
const passed = posPx >= s.px - W / 2;
|
||||||
className={`${styles.segmentLabel} ${styles.neutral}`}
|
return (
|
||||||
style={cssVars({ '--x': px(s.px) })}
|
<div
|
||||||
title={`${s.category} · ${s.title}`}
|
key={i}
|
||||||
>
|
className={`${styles.segmentLabel} ${passed ? styles.accent : styles.neutral}`}
|
||||||
{s.title}
|
style={cssVars({ '--x': px(s.px) })}
|
||||||
</div>
|
title={`${s.category} · ${s.title} (${fmtKm(s.km)})`}
|
||||||
))}
|
>
|
||||||
|
{s.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.seekArea} onMouseDown={onSeekDown} />
|
<div className={styles.seekArea} onMouseDown={onSeekDown} />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
.cursor {
|
.cursor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: var(--cursor-x);
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
transform: translateX(var(--cursor-x, 0px));
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
import { renderX } from '../../mocks/route';
|
|
||||||
import { cssVars, px } from '../../utils/cssVars';
|
|
||||||
import styles from './TimelineCursor.module.scss';
|
import styles from './TimelineCursor.module.scss';
|
||||||
|
|
||||||
interface TimelineCursorProps {
|
interface TimelineCursorProps {
|
||||||
posPx: number;
|
|
||||||
mileageText: string;
|
mileageText: string;
|
||||||
/** 현재 위치가 역방향(km 감소) 구간이면 커서를 파란색으로. */
|
/** 현재 위치가 역방향(km 감소) 구간이면 커서를 파란색으로. */
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
@@ -12,12 +9,10 @@ interface TimelineCursorProps {
|
|||||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimelineCursor({ posPx, mileageText, reverse, onSeekDown }: TimelineCursorProps) {
|
// 커서 x 위치(--cursor-x)는 상위(StationBar wrapRef)에서 rAF로 직접 갱신한다(transform).
|
||||||
|
export function TimelineCursor({ mileageText, reverse, onSeekDown }: TimelineCursorProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`${styles.cursor} ${reverse ? styles.reverse : ''}`}>
|
||||||
className={`${styles.cursor} ${reverse ? styles.reverse : ''}`}
|
|
||||||
style={cssVars({ '--cursor-x': px(renderX(posPx)) })}
|
|
||||||
>
|
|
||||||
<div className={styles.line} onMouseDown={onSeekDown} />
|
<div className={styles.line} onMouseDown={onSeekDown} />
|
||||||
<div className={styles.badge} onMouseDown={onSeekDown}>{mileageText}</div>
|
<div className={styles.badge} onMouseDown={onSeekDown}>{mileageText}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
69
client/src/store/geoStore.ts
Normal file
69
client/src/store/geoStore.ts
Normal file
@@ -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<File | null>;
|
||||||
|
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<GeoStore>((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 }),
|
||||||
|
}));
|
||||||
25
client/src/types/dom.d.ts
vendored
Normal file
25
client/src/types/dom.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 비표준 DOM 속성 보강
|
||||||
|
*
|
||||||
|
* `<input type="file" webkitdirectory>` 폴더 선택과
|
||||||
|
* `File.webkitRelativePath` 가 TypeScript 에서 타입체크되도록 확장한다.
|
||||||
|
* (lib.dom 에 webkitRelativePath 는 있으나 webkitdirectory/directory 속성은 없음)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'react';
|
||||||
|
|
||||||
|
declare module 'react' {
|
||||||
|
interface InputHTMLAttributes<T> {
|
||||||
|
// 폴더 선택용 비표준 속성 (Chrome/Firefox/Edge)
|
||||||
|
webkitdirectory?: string;
|
||||||
|
directory?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLInputElement {
|
||||||
|
webkitdirectory: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
124
client/src/types/geo.ts
Normal file
124
client/src/types/geo.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 폴더 보조 파일 <base>.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;
|
||||||
|
}
|
||||||
299
client/src/utils/geoData.ts
Normal file
299
client/src/utils/geoData.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱
|
||||||
|
*
|
||||||
|
* `<input type="file" webkitdirectory>` 로 선택된 File[] 에서
|
||||||
|
* 영상 / 드론 CSV / center.csv / building POI·측점 CSV 를 찾아 파싱한다.
|
||||||
|
*
|
||||||
|
* 인코딩(서버 geoMatch.ts readCsvUtf8 대응):
|
||||||
|
* - 드론 <base>.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<string[][]> {
|
||||||
|
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<DroneFrame[]> {
|
||||||
|
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<GeoPoint[]> {
|
||||||
|
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<CenterlinePoint[]> {
|
||||||
|
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).
|
||||||
|
* `<base>.route.json` 우선(case-insensitive), 없으면 `route.json`.
|
||||||
|
* building/ 제외, 루트 파일만. 파싱 실패 시 null.
|
||||||
|
*/
|
||||||
|
export async function parseRouteMeta(
|
||||||
|
files: File[],
|
||||||
|
baseName: string | null,
|
||||||
|
): Promise<RouteMeta | null> {
|
||||||
|
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<FolderGeoData> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
255
client/src/utils/geoSearch.ts
Normal file
255
client/src/utils/geoSearch.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
const path = require("path");
|
|
||||||
|
|
||||||
// 이 설정 파일이 있는 위치(프로젝트 루트) 기준으로 모든 경로를 계산한다.
|
|
||||||
// → 프로젝트를 다른 경로로 옮겨도 그대로 동작.
|
|
||||||
const ROOT = __dirname;
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "defVideo",
|
name: "defVideo",
|
||||||
// 빌드 산출물 (npm run build 후 생성됨)
|
// cwd = 이 설정 파일이 있는 위치(프로젝트 루트)를 작업 디렉토리로 고정.
|
||||||
script: path.join(ROOT, "server/dist/server/src/app.js"),
|
// 이 한 곳만 앵커로 두고, 아래 경로는 전부 cwd 기준 상대경로로 해석된다.
|
||||||
cwd: ROOT,
|
cwd: __dirname,
|
||||||
// nvm 으로 설치한 Node 20 을 명시 (PM2 데몬이 시스템 Node 12 를 쓰지 않도록)
|
// script: cwd 기준 상대경로 (npm run build 후 생성)
|
||||||
interpreter: "/home/hanmac/.nvm/versions/node/v20.20.2/bin/node",
|
script: "server/dist/server/src/app.js",
|
||||||
|
// PATH 의 node 사용 — pm2 start 전에 nvm 으로 Node 20 이 활성화돼 있어야 함
|
||||||
|
interpreter: "node",
|
||||||
env: {
|
env: {
|
||||||
PORT: 55173,
|
PORT: 55173,
|
||||||
VIDEOS_DIR: path.join(ROOT, "samplevideo"),
|
// 서버 config.ts 가 path.resolve(cwd 기준)로 해석 → 상대경로 OK
|
||||||
HLS_DIR: path.join(ROOT, "storage/hls"),
|
VIDEOS_DIR: "samplevideo",
|
||||||
FRAMES_DIR: path.join(ROOT, "storage/frames"),
|
HLS_DIR: "storage/hls",
|
||||||
THUMBNAILS_DIR: path.join(ROOT, "storage/thumbnails"),
|
FRAMES_DIR: "storage/frames",
|
||||||
DB_PATH: path.join(ROOT, "storage/annotations.db"),
|
THUMBNAILS_DIR: "storage/thumbnails",
|
||||||
// apt 로 설치하면 /usr/bin 에 생성됨
|
DB_PATH: "storage/annotations.db",
|
||||||
FFMPEG_PATH: "/usr/bin/ffmpeg",
|
// 절대경로 대신 PATH 에서 탐색 (apt 설치 시 /usr/bin 에 있고 PATH 포함됨)
|
||||||
FFPROBE_PATH: "/usr/bin/ffprobe",
|
FFMPEG_PATH: "ffmpeg",
|
||||||
GEO_DATA_DIR: path.join(ROOT, "samplevideo"),
|
FFPROBE_PATH: "ffprobe",
|
||||||
CENTER_CSV_PATH: path.join(ROOT, "pythonsource/input/center.csv"),
|
GEO_DATA_DIR: "samplevideo",
|
||||||
|
CENTER_CSV_PATH: "pythonsource/input/center.csv",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -109,11 +109,12 @@ export function getVideoFps(probe: FFprobeResult): number {
|
|||||||
return den ? num / den : 30;
|
return den ? num / den : 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkFFmpegInstalled(): Promise<boolean> {
|
export function checkFFmpegInstalled(): Promise<boolean> {
|
||||||
try {
|
// `ffprobe -version` 은 JSON 이 아닌 일반 텍스트를 출력하므로 runFFprobe(JSON 파싱) 로
|
||||||
await runFFprobe(['-version']);
|
// 검사하면 항상 실패한다. 여기서는 실행 가능 여부(exit code 0)만 직접 확인한다.
|
||||||
return true;
|
return new Promise((resolve) => {
|
||||||
} catch {
|
const proc = spawn(config.ffprobePath, ['-version'], { stdio: 'ignore' });
|
||||||
return false;
|
proc.on('close', (code) => resolve(code === 0));
|
||||||
}
|
proc.on('error', () => resolve(false));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user