초기 커밋: 스테이션(측점) 기반 주행영상 플레이어

- 클라이언트(React/Vite): Video.js 플레이어, 하단 스테이션바, POI/구조물 영상 오버레이, 카메라 파라미터 보정, 미니맵/RoutePanel
- 서버(Express): Range 스트리밍, HLS 변환, 프레임 추출, tus 업로드, 주석 API
- 최근 작업: 스테이션바 종점역 표출/양끝 정렬/미도착(재생불가) 표시, POI 라벨 '구분' 우선·컴팩트 팝업·겹침제외 토글·동일좌표 다중행, 진행방향(상/하) 우선 표출, 커서 배지 크기·픽셀 떨림 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:37:57 +09:00
co-authored by Claude Opus 4.8
commit 78fc090360
214 changed files with 27897 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(cd ~/projects/gitea/b23042/import_data/v2.0/ 2>/dev/null && ls -la && echo \"===\" && ls -la \"하행\\)회덕-대전조차장 데이터\\(v2.0\\)\" 2>/dev/null | head -50)",
"Read(//home/hanmac/projects/gitea/b23042/import_data/v2.0/**)",
"Bash(ls node_modules/proj4)",
"Bash(cp /tmp/chk.mjs ./chk.mjs)",
"Bash(node ./chk.mjs)",
"Bash(rm -f ./chk.mjs)",
"Bash(ls -la \"/mnt/c/Users/User/Pictures/Screenshots/\" 2>/dev/null | tail -10 || echo \"no /mnt/c path\")",
"Read(//mnt/c/Users/User/Pictures/Screenshots/**)"
]
}
}
+36
View File
@@ -0,0 +1,36 @@
# 의존성
node_modules/
# 빌드 산출물
client/dist/
server/dist/
shared/dist/
dist/
*.tsbuildinfo
# 파이썬 빌드 산출물 (exe/pkg/pyz 등)
pythonsource/build/
pythonsource/dist/
__pycache__/
*.pyc
# 런타임 저장소 (영상/HLS/프레임/썸네일/DB)
storage/
# 대용량 미디어/바이너리
*.mp4
*.MP4
*.avi
*.mov
*.mkv
*.exe
# 로그/환경
*.log
.env
.env.*
npm-debug.log*
# OS/에디터
.DS_Store
Thumbs.db
Executable
+616
View File
@@ -0,0 +1,616 @@
# CLAUDE.md — abcvideo 프로젝트
이 파일은 Claude Code가 abcvideo 프로젝트에서 작업할 때 참조하는 가이드입니다.
---
## 응답/소통 규칙 (항상 적용)
- **모든 답변은 한글로 작성한다.** (코드·로그·식별자 등 원문 유지가 필요한 부분은 예외)
- 보고는 결론 → 근거 → 다음 행동 순으로 간결하게.
---
## 작업 완료 시 필수 — 히스토리 기록
작업(대화 세션)이 끝날 때 반드시 아래 형식으로 히스토리 파일을 작성한다.
```
파일 경로: docs/history/YYYY-MM-DD_HHMM_{작업명}.md
```
필수 항목 (누락 시 Stop 훅이 응답을 차단함):
```markdown
**소요 시간**: X분
**Context 사용량**: input Xk / output Xk tokens
```
- 날짜는 오늘 날짜(YYYY-MM-DD), 시간은 작업 완료 시각 24시간제(HHMM), 작업명은 작업 내용을 한글 또는 영문으로 간략히
- 같은 날 여러 작업이면 각각 별도 파일 (예: `2026-04-01_1430_StationOverlay-분석.md`)
- 소요 시간: 대화 시작부터 작업 완료까지의 실제 경과 시간
- Context 사용량: 이 대화의 토큰 사용량 (모를 경우 추정값 기재)
---
## 에이전트 협업 규칙
### 필수 파일
| 파일 | 용도 | 누가 관리 |
|------|------|----------|
| `CLAUDE.md` | 프로젝트 가이드 (아키텍처, 규칙, 기술 스택) | 사용자/아키텍트 |
| `PLAN.md` | 전체 구현 계획 + 각 단계별 세부 태스크 | 계획 수립 시 작성, 에이전트가 필요 시 업데이트 |
| `PROGRESS.md` | 각 단계의 진행 상태 + 완료/미완료/이슈 기록 | 작업하는 에이전트가 실시간 업데이트 |
### 에이전트 작업 시작 프로토콜
모든 에이전트는 작업 시작 시 반드시 아래 순서를 따른다:
```
1. CLAUDE.md 읽기 → 프로젝트 규칙/아키텍처 파악
2. PLAN.md 읽기 → 전체 계획에서 자신이 담당할 단계 확인
3. PROGRESS.md 읽기 → 이전 단계 완료 여부, 현재 상태, 알려진 이슈 확인
4. 작업 시작 전 PROGRESS.md에 자신의 단계를 "🔄 진행 중"으로 업데이트
5. 작업 완료 후 PROGRESS.md에 결과 기록 (완료 항목, 남은 이슈, 다음 단계 참고사항)
```
### PLAN.md 구조
```markdown
# PLAN.md
## 단계 N: 단계 이름
### 목표
### 세부 태스크
- [ ] 태스크 1
- [ ] 태스크 2
### 산출물 (완료 기준)
### 다음 단계 의존성
```
- 각 단계는 독립적으로 실행 가능한 단위로 분할
- 태스크는 체크박스(`- [ ]` / `- [x]`)로 관리
- 완료 기준(산출물)이 명확해야 다음 에이전트가 판단 가능
### PROGRESS.md 구조
```markdown
# PROGRESS.md
## 현재 상태 요약
- 마지막 완료 단계: N
- 현재 진행 단계: N+1
- 블로커: (있으면 기록)
## 단계별 진행 기록
### 단계 N: 단계 이름
- 상태: ✅ 완료 | 🔄 진행 중 | ⏳ 대기 | ❌ 실패
- 완료일: YYYY-MM-DD
- 완료 항목:
- 항목 1
- 항목 2
- 미완료/이슈:
- 이슈 설명
- 다음 단계 참고:
- 후속 에이전트가 알아야 할 사항
```
- 에이전트는 작업 중 **의미 있는 마일스톤마다** PROGRESS.md를 업데이트
- 실패 시 원인과 시도한 방법을 기록하여 다음 에이전트가 같은 실수를 반복하지 않도록 함
- 이전 에이전트가 남긴 "다음 단계 참고"를 반드시 확인 후 작업 시작
### 에이전트 간 인계 규칙
1. **이전 단계 산출물 검증**: 코드가 빌드/실행 가능한 상태인지 확인 후 작업 시작
2. **기존 코드 존중**: 이전 에이전트가 작성한 코드를 이유 없이 대폭 변경하지 않음
3. **충돌 방지**: 같은 파일을 동시에 수정하는 병렬 에이전트 실행 금지
4. **이슈 에스컬레이션**: 블로커 발견 시 PROGRESS.md에 기록하고 사용자에게 보고
---
## 프로젝트 개요
웹 브라우저(PC)에서 **2GB 이상의 대용량 동영상**을 안정적으로 재생하는 특화 기능 탑재 플레이어.
단순 재생을 넘어 **프레임 추출, 단위 이동, 텍스트 오버레이** 등 영상 분석/편집 보조 도구로 기능.
### 핵심 요구사항
1. 2GB 이상 동영상 안정 재생 (서버 파일 + 로컬 파일 모두)
2. 프레임 단위 추출 (정확도 우선)
3. 입력 단위 기준 앞뒤 이동 (프레임 / 초 / 장면)
4. 영상 위 텍스트 입력 (자막형 + 메모형 병행 지원)
5. 확장 가능한 플러그인 아키텍처
### 설계 결정 (2026-03-24 확정)
| 항목 | 결정 | 비고 |
|------|------|------|
| 사용자 모델 | **단일 사용자 도구** | 인증/세션/큐 불필요, 구현 단순화 |
| 로컬 파일 | **업로드 없이 직접 재생** | File API → `URL.createObjectURL()` |
| 프레임 정확도 | **표준 수준** | CFR: `1/fps` 산술, VFR: best-effort |
| HLS 변환 전 재생 | **원본 즉시 재생** | Range Request로 바로 재생, HLS는 백그라운드 변환 |
| 브라우저 지원 | **PC 웹 우선** | Chrome/Firefox/Edge, iOS Safari 무시 |
---
## 아키텍처 결정 (ADR)
### 재생 이중 경로
```
서버 파일: GET /api/stream/:videoId (Range Request) → 즉시 재생
POST /api/hls/:videoId/convert → 백그라운드 HLS 변환
HLS 준비 완료 시 → hls.js로 전환 (seek 안정성 향상)
로컬 파일: File API → URL.createObjectURL() → <video src> 직접 재생
프레임 추출 필요 시 → 서버 업로드 또는 Canvas 폴백
```
- 로컬 파일은 HLS 변환 없이 브라우저가 직접 재생
- 서버 파일은 Range Request로 즉시 재생 후, HLS 변환 완료 시 전환
- 2GB 이상 로컬 파일은 `URL.createObjectURL()`이 메모리 효율적 (Blob URL은 참조만 유지)
### 스트리밍 방식: HLS
- Progressive Download는 2GB+ seek 불안정으로 제외
- HLS 세그먼트 분할로 파일 크기 제한 완전 우회
- **세그먼트 크기: 6초** (분석 도구 특성상 seek 반응성 우선)
- **키프레임 간격: 2초** (`-force_key_frames "expr:gte(t,n_forced*2)"`)
- hls.js v1.6.15로 Chrome/Firefox/Edge 지원 (iOS Safari 네이티브는 비대상)
### hls.js 필수 설정
```javascript
const hls = new Hls({
maxBufferLength: 30, // 전방 버퍼: 30초
maxMaxBufferLength: 600, // 최대 10분
maxBufferSize: 60 * 1024 * 1024, // 60MB
backBufferLength: 30, // *** 필수: 재생 완료 버퍼 30초 후 해제 ***
// (기본값 Infinity → 장시간 재생 시 메모리 누수)
enableWorker: true, // Web Worker 트랜스먹싱
});
```
> `backBufferLength: 30` 미설정 시 2GB 영상 30분 재생 후 브라우저 크래시 위험.
### 플레이어: Video.js 8.23.x
- 플러그인 200개+ 생태계 → 확장성 우선
- `@videojs/http-streaming` 내장으로 HLS 기본 지원
- 전체화면 시 오버레이 유지를 위해 **컨테이너 전체를 fullscreen**으로 처리
- Video.js v10 (2026 중반 GA 예정)으로 향후 마이그레이션 가능하도록 플레이어 로직을 훅/서비스 레이어로 분리
### 프레임 추출: 서버 FFmpeg (child_process.spawn)
- Canvas API는 키프레임 의존으로 정확도 불충분
- ffmpeg.wasm은 ~30MB 로딩 + iOS 미지원 + VFR 불안정
- 서버 FFmpeg + `-accurate_seek` 플래그로 정확한 프레임 추출
- **fluent-ffmpeg 아카이브됨 (2025-05)** → `child_process.spawn()` + 얇은 TypeScript 래퍼 사용
- 로컬 파일 프레임 추출: Canvas API 즉시 미리보기 + 정확 추출 시 서버 업로드
### 백엔드: Node.js + Express
- 서버 영상 스트리밍 (Range Request)
- HLS 변환 API (FFmpeg child_process.spawn)
- 프레임 추출 API
- 대용량 업로드 (**@tus/server v2.3.0** — 자동 재개/재시도)
- 주석/메모 저장 (**better-sqlite3 v12.8.0**)
- FFmpeg 변환 진행률: **SSE (Server-Sent Events)**
---
## 기술 스택
### 프론트엔드
```
React 18 + TypeScript
Video.js 8.23.x + @videojs/http-streaming (HLS 내장)
hls.js 1.6.x (Video.js VHS 보완/대체 가능)
Tailwind CSS
Vite
Zustand (상태 관리)
interact.js (메모형 오버레이 드래그/리사이즈)
subsrt (자막 포맷 변환: SRT, VTT, ASS 등)
```
### 백엔드
```
Node.js 20+ LTS + Express
child_process.spawn (FFmpeg 래퍼 — fluent-ffmpeg 대체)
@tus/server 2.3.x + @tus/file-store (대용량 업로드)
better-sqlite3 12.8.x (주석 저장)
check-disk-space 3.4.x (디스크 모니터링)
```
### 인프라/개발 환경
```
FFmpeg 6.x+ (시스템 설치 필요 — PATH 등록)
Node.js 20+ LTS
```
---
## 프로젝트 구조
```
abcvideo/
├── client/ # React 프론트엔드
│ ├── src/
│ │ ├── components/
│ │ │ ├── player/ # Video.js 플레이어 컴포넌트
│ │ │ ├── overlay/ # 텍스트 오버레이 (자막형/메모형)
│ │ │ ├── controls/ # 커스텀 컨트롤 (단위 이동, 속도 등)
│ │ │ └── toolbar/ # 프레임 추출, 주석 관리 툴바
│ │ ├── hooks/
│ │ │ ├── useVideoPlayer.ts # Video.js 초기화/제어
│ │ │ ├── useFrameStep.ts # 프레임 단위 이동
│ │ │ ├── useAnnotations.ts # 주석 CRUD
│ │ │ └── useHls.ts # HLS 로드/에러 처리
│ │ ├── store/ # Zustand 스토어
│ │ ├── types/ # 공유 타입 정의
│ │ ├── utils/
│ │ │ ├── timecode.ts # 타임코드 변환 유틸
│ │ │ ├── vtt.ts # WebVTT 생성/파싱
│ │ │ └── frameCapture.ts # Canvas 프레임 캡처 (로컬 파일용)
│ │ └── App.tsx
│ ├── index.html
│ ├── vite.config.ts
│ └── tsconfig.json
├── server/ # Node.js 백엔드
│ ├── src/
│ │ ├── routes/
│ │ │ ├── stream.ts # Range Request 스트리밍
│ │ │ ├── hls.ts # HLS 변환/서빙
│ │ │ ├── frame.ts # 프레임 추출 API
│ │ │ ├── upload.ts # tus 업로드 연동
│ │ │ ├── local.ts # 로컬 파일 프록시 (선택)
│ │ │ └── annotations.ts # 주석 CRUD API
│ │ ├── services/
│ │ │ ├── ffmpeg.ts # FFmpeg spawn 래퍼 (HLS 변환, 프레임 추출)
│ │ │ ├── streaming.ts # Range Request 로직
│ │ │ └── storage.ts # 파일/DB 접근
│ │ ├── middleware/
│ │ │ ├── security.ts # Path traversal 방어, CORS
│ │ │ └── upload.ts # tus 서버 설정
│ │ ├── db/
│ │ │ └── schema.sql # SQLite 스키마
│ │ └── app.ts
│ └── tsconfig.json
├── shared/ # 클라이언트/서버 공유 타입
│ └── types.ts
├── storage/ # 런타임 생성 (gitignore)
│ ├── videos/ # 업로드된 원본 영상
│ ├── hls/ # HLS 세그먼트 (.m3u8, .ts)
│ ├── frames/ # 추출된 프레임 이미지
│ └── thumbnails/ # 탐색바 썸네일 스프라이트
├── CLAUDE.md # 프로젝트 가이드 (아키텍처, 규칙)
├── PLAN.md # 전체 구현 계획 + 단계별 세부 태스크
├── PROGRESS.md # 진행 상태 추적 (에이전트 실시간 업데이트)
├── README.md
└── package.json # 루트 (워크스페이스)
```
---
## API 설계
### 스트리밍
```
GET /api/stream/:videoId # Range Request 스트리밍 (원본 즉시 재생)
GET /api/hls/:videoId/index.m3u8 # HLS 플레이리스트
GET /api/hls/:videoId/:segment # HLS 세그먼트 (.ts)
POST /api/hls/:videoId/convert # HLS 변환 시작 (비동기)
GET /api/hls/:videoId/progress # 변환 진행 상태 (SSE)
```
### 프레임 추출
```
GET /api/frame/:videoId?time=00:01:30.000 # 특정 시간 프레임
GET /api/frame/:videoId?frame=1234 # 프레임 번호로 추출
GET /api/thumbnails/:videoId/sprite.jpg # 탐색바 썸네일 스프라이트
GET /api/thumbnails/:videoId/sprite.vtt # 썸네일 위치 VTT
```
### 업로드 (tus 프로토콜)
```
# tus 엔드포인트 — @tus/server가 자동 처리
POST /api/upload # 업로드 생성 (tus Creation)
PATCH /api/upload/:uploadId # 청크 전송 (tus 자동 재개)
HEAD /api/upload/:uploadId # 업로드 상태 조회
DELETE /api/upload/:uploadId # 업로드 취소
```
### 주석/메모
```
GET /api/annotations/:videoId # 영상별 주석 목록
POST /api/annotations/:videoId # 주석 추가
PUT /api/annotations/:videoId/:id # 주석 수정
DELETE /api/annotations/:videoId/:id # 주석 삭제
GET /api/annotations/:videoId/export?format=vtt|srt|json|csv # 내보내기
```
### 메타데이터
```
GET /api/meta/:videoId # 영상 메타데이터 (fps, duration, resolution 등)
GET /api/videos # 업로드된 영상 목록
DELETE /api/videos/:videoId # 영상 삭제 (원본 + HLS + 프레임 + 썸네일 일괄)
```
---
## 핵심 구현 규칙
### 대용량 파일 처리
- Range Request: `fs.createReadStream({ start, end, highWaterMark: 1024 * 1024 })` 사용
- 응답 시 반드시 `Accept-Ranges: bytes` 헤더 포함 (Safari seek 호환)
- `stream/promises``pipeline()` 사용 (에러 핸들링 + 백프레셔 자동 처리)
- 4GB 이상 파일: JavaScript `Number``2^53-1`까지 안전 → BigInt 불필요
- HLS 변환은 원본 파일을 직접 수정하지 않고 `storage/hls/{videoId}/`에 출력
- 소스가 이미 H.264인 경우 `-c copy`로 재인코딩 없이 세그먼트 분할만 수행 (수 초 내 완료)
### Range Request 구현 패턴
```typescript
// 핵심: 한 응답에 전체 파일을 보내지 않고 청크 크기 제한
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
const start = requestedStart;
const end = Math.min(requestedEnd || (start + CHUNK_SIZE - 1), fileSize - 1);
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': 'video/mp4',
});
```
### HLS 변환 FFmpeg 명령
```bash
# H.264 소스 → 재인코딩 없이 세그먼트 분할 (빠름)
ffmpeg -i input.mp4 -c copy \
-f hls -hls_time 6 -hls_list_size 0 \
-hls_segment_filename 'output/segment%03d.ts' \
-hls_playlist_type vod \
output/index.m3u8
# 비-H.264 소스 → 트랜스코딩 (느림)
ffmpeg -i input.mkv \
-c:v libx264 -preset medium -crf 23 -profile:v high -level 4.1 \
-c:a aac -b:a 128k -ar 48000 \
-f hls -hls_time 6 -hls_list_size 0 \
-hls_segment_filename 'output/segment%03d.ts' \
-hls_playlist_type vod \
-force_key_frames "expr:gte(t,n_forced*2)" \
output/index.m3u8
```
### FFmpeg spawn 래퍼 패턴
```typescript
import { spawn } from 'child_process';
function runFFmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
proc.stderr.on('data', (chunk) => {
stderr += chunk.toString();
// 진행률 파싱: "time=00:01:30.00" 패턴
});
proc.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`FFmpeg exit ${code}: ${stderr.slice(-500)}`))
);
});
}
```
### 프레임 정확도
- `requestVideoFrameCallback` API로 실제 FPS 동적 감지 (fallback: 30fps)
- 프레임 번호 → 시간 변환: `frameNumber / fps` (currentTime 누적 연산 금지 — 부동소수점 드리프트)
- 서버 FFmpeg 프레임 추출: `-accurate_seek -ss {time} -i {file} -frames:v 1` 순서 준수
- VFR 영상은 best-effort (FFprobe 메타데이터 기반 평균 FPS 사용)
### 타임코드 표현
- 내부 저장: **초 단위 float** (예: `90.033`)
- 표시: `HH:MM:SS.mmm` 형식
- VTT 내보내기: `HH:MM:SS.mmm --> HH:MM:SS.mmm` (점 사용)
- SRT 내보내기: `HH:MM:SS,mmm --> HH:MM:SS,mmm` (콤마 사용)
### 보안
- 서버 파일 경로: `path.resolve()` 정규화 후 허용 디렉토리(`storage/videos/`) 내부 검증 (Path Traversal 방어)
- 업로드 파일 검증: MIME 타입 체크 + **FFprobe로 실제 코덱/컨테이너 확인**
- 허용 MIME: `video/mp4`, `video/quicktime`, `video/x-matroska`, `video/webm`
- 업로드 크기 상한: 환경변수 `MAX_UPLOAD_SIZE` (기본 20GB)
- CORS 설정: 개발 시 `localhost:5173``localhost:3001` 허용
- `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` 헤더 (SharedArrayBuffer 대비)
### 전체화면
- `document.fullscreenElement``<video>`가 아닌 **플레이어 컨테이너 div**여야 오버레이 유지
- Video.js `fluid: true` 모드로 컨테이너 기준 비율 유지
- Safari webkit 접두사: `webkitRequestFullscreen` 분기 불필요 (PC 웹 대상)
### 오버레이 성능
- `timeupdate` 이벤트에서 직접 DOM 조작 금지 (~250ms 간격, 부정확)
- **`requestVideoFrameCallback`** 또는 `requestAnimationFrame` 루프로 현재 시간 읽고 표시할 주석 필터링
- 주석 배열은 `time` 기준 정렬 유지 → **이진 탐색 O(log n)** 조회
- 오버레이 요소에 `will-change: transform` 적용 (GPU 가속)
- 동시 표시 오버레이 50개 미만 유지 (DOM 레이아웃 스래싱 방지)
### 메모리 관리
- `URL.createObjectURL()` 사용 후 반드시 `URL.revokeObjectURL()` 호출
- Canvas 프레임 캡처 시 단일 Canvas 재사용 (매번 새로 생성 금지)
- hls.js `backBufferLength: 30` 필수 설정
- QuotaExceededError 발생 시: `abort()``remove(0, currentTime - 30)` → 재시도
---
## 자막형 vs 메모형 동작 정의
### 자막형 (Subtitle Mode)
- WebVTT `<track kind="subtitles">` 요소 사용
- 시작 시간 ~ 종료 시간 범위 표시
- 위치: 하단 고정 (WebVTT `position`, `line` 속성으로 조정 가능)
- 내보내기: `.vtt`, `.srt`
### 메모형 (Memo Mode)
- 커스텀 DOM 오버레이 레이어 + **interact.js** 드래그/리사이즈
- 특정 시점(포인트) + 표시 지속 시간 (기본 3초)
- 위치: 드래그로 자유 배치 (**x%, y% 정규화 좌표** — 해상도 독립적)
- 크기: width%, height% 정규화 (리사이즈 가능)
- 내보내기: `.json`, `.csv`
### 주석 데이터 모델 (SQLite)
```json
{
"id": "uuid",
"videoId": "string",
"type": "subtitle | memo",
"timeStart": 90.033,
"timeEnd": 95.500,
"position": { "x": 50.0, "y": 75.0 },
"size": { "width": 30.0, "height": 10.0 },
"text": "주석 내용",
"style": {
"fontSize": 16,
"color": "#ffffff",
"backgroundColor": "rgba(0,0,0,0.7)"
},
"createdAt": "ISO-8601",
"updatedAt": "ISO-8601"
}
```
---
## 키보드 단축키
| 키 | 동작 | 비고 |
|----|------|------|
| `Space` | 재생 / 일시정지 | 플레이어 포커스 시에만 |
| `←` / `→` | 5초 뒤로 / 앞으로 | |
| `J` / `L` | 10초 뒤로 / 앞으로 | |
| `,` / `.` | 이전 프레임 / 다음 프레임 | 일시정지 상태에서만 |
| `[` / `]` | 이전 장면 / 다음 장면 | |
| `F` | 전체화면 토글 | |
| `M` | 음소거 토글 | |
| `0`~`9` | 10% 단위 탐색 | |
| `Shift + S` | 현재 프레임 캡처 | |
| `Shift + M` | 현재 시간에 메모 추가 | |
| `+` / `-` | 재생 속도 증가 / 감소 | |
> 텍스트 입력 모드(메모 작성 중)에서는 모든 플레이어 단축키 비활성화.
---
## Video.js React 통합 패턴
```tsx
// ref + useEffect 패턴 (Video.js 공식 권장)
const videoRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Player | null>(null);
useEffect(() => {
if (!playerRef.current) {
const videoElement = document.createElement('video-js');
videoElement.classList.add('vjs-big-play-centered');
videoRef.current!.appendChild(videoElement);
playerRef.current = videojs(videoElement, options, onReady);
}
}, [options]);
useEffect(() => {
return () => {
if (playerRef.current && !playerRef.current.isDisposed()) {
playerRef.current.dispose();
playerRef.current = null;
}
};
}, []);
return <div data-vjs-player ref={videoRef} />;
```
> `data-vjs-player` 래퍼로 Video.js 추가 DOM 래핑 방지.
> React 18 Strict Mode에서 `playerRef.current` 가드 필수 (이중 초기화 방지).
---
## 환경변수 (server/.env)
```env
PORT=3001
VIDEOS_DIR=../storage/videos
HLS_DIR=../storage/hls
FRAMES_DIR=../storage/frames
THUMBNAILS_DIR=../storage/thumbnails
DB_PATH=../storage/annotations.db
MAX_UPLOAD_SIZE=21474836480 # 20GB (bytes)
FFMPEG_PATH=ffmpeg # 시스템 PATH에 등록된 경우 기본값
HLS_SEGMENT_TIME=6 # HLS 세그먼트 길이 (초)
THUMBNAIL_INTERVAL=10 # 썸네일 추출 간격 (초)
```
---
## 개발 단계 (에이전트 계획)
> 세부 태스크와 완료 기준은 `PLAN.md` 참조. 진행 상태는 `PROGRESS.md` 참조.
| 단계 | 내용 | 의존성 |
|------|------|--------|
| 1. 프로젝트 구조 | 모노레포 세팅, TypeScript 설정, 폴더 골격, 패키지 설치 | — |
| 2. 백엔드 서버 | Express + Range Request 스트리밍 + FFmpeg spawn 래퍼 + tus 업로드 + HLS 변환 + SSE 진행률 | FFmpeg 설치 |
| 3. 기본 플레이어 | Video.js + 이중 경로 재생 (로컬 File API + 서버 HLS) + 기본 UI | 단계 2 |
| 4. 프레임 추출 | Canvas 즉시 미리보기 + 서버 FFmpeg API (정확 추출) | 단계 2, 3 |
| 5. 단위 이동 | 프레임/초/장면 seek + requestVideoFrameCallback FPS 감지 + 키보드 단축키 | 단계 3, 4 |
| 6. 텍스트 오버레이 | 자막형(WebVTT track) + 메모형(interact.js DOM 오버레이) + 내보내기(subsrt) | 단계 3 |
| 7. UI/UX 통합 | 전체 통합, 반응형 레이아웃, 썸네일 미리보기, 디스크 정리 | 단계 2~6 |
```
단계 1 → 단계 2 → 단계 4 ──┐
└──→ 단계 3 → 단계 5 ──┤→ 단계 7
→ 단계 6 ──┘
※ 단계 5와 6은 병렬 진행 가능
```
---
## 파일 정리 정책
- 업로드 실패/중단 임시 파일: **24시간 후 자동 삭제** (서버 시작 시 + 주기적 스캔)
- 영상 삭제 시: 원본 + HLS 세그먼트 + 프레임 + 썸네일 + 주석 DB 레코드 일괄 삭제
- 디스크 잔여 공간 **10GB 미만 시 경고** (check-disk-space)
- 영상 1개당 예상 디스크 사용량: 원본 + HLS ≈ 원본 x 2 + 프레임/썸네일 ~50MB
---
## 알려진 제약 / 주의사항
- **VFR 영상**: 가변 프레임율 영상은 프레임 번호 계산 부정확 → FFprobe 평균 FPS 사용 (best-effort)
- **CORS + Canvas**: 크로스 오리진 영상에서 `toDataURL()` 보안 오류 → `crossorigin="anonymous"` 필수
- **HLS 변환 시간**: 2GB 영상 트랜스코딩 시 수 분~십수 분 소요 → SSE 진행률로 클라이언트 통보
- **H.264 소스 감지**: FFprobe로 코덱 확인 후 `-c copy` (재인코딩 불필요) vs 트랜스코딩 분기
- **WebVTT 리전**: Chrome/Edge에서 미지원 → 리전 기능 사용 금지, 기본 positioning만 사용
- **Video.js v10**: 2026 중반 GA 예정, 플러그인 호환 불확실 → 플레이어 로직을 훅으로 분리하여 마이그레이션 비용 최소화
Executable
+282
View File
@@ -0,0 +1,282 @@
# PLAN.md — abcvideo 구현 계획
> 이 파일은 전체 구현 계획과 각 단계별 세부 태스크를 정의합니다.
> 에이전트는 작업 시작 전 반드시 이 파일을 읽고, 자신이 담당할 단계를 확인합니다.
---
## 단계 1: 프로젝트 구조 세팅
### 목표
모노레포 구조 생성, TypeScript/빌드 설정, 개발 환경 구축
### 세부 태스크
- [ ] 루트 `package.json` (npm workspaces: client, server, shared)
- [ ] `client/` — Vite + React 18 + TypeScript 초기화
- [ ] `server/` — TypeScript + ts-node/tsx 설정
- [ ] `shared/` — 공유 타입 패키지 설정
- [ ] Tailwind CSS 설치 및 설정 (client)
- [ ] ESLint + Prettier 설정 (루트)
- [ ] `tsconfig.json` — 루트/client/server/shared 각각 설정 (경로 별칭 포함)
- [ ] `storage/` 디렉토리 구조 생성 + `.gitignore` 설정
- [ ] `server/.env.example` 작성
- [ ] 개발 서버 동시 실행 스크립트 (`concurrently` 또는 npm scripts)
- [ ] 빈 앱 기동 확인 (client dev + server dev 동시 실행)
### 산출물 (완료 기준)
- `npm install` 성공
- `npm run dev`로 client(5173) + server(3001) 동시 기동
- TypeScript 컴파일 에러 없음
- 빈 React 앱이 브라우저에 표시됨
### 다음 단계 의존성
- 단계 2, 3 모두 이 단계 완료 후 시작 가능
---
## 단계 2: 백엔드 서버
### 목표
Express 서버 + Range Request 스트리밍 + FFmpeg spawn 래퍼 + tus 업로드 + HLS 변환 + SSE 진행률
### 세부 태스크
#### 2-1. 서버 기본 골격
- [ ] Express 앱 생성 (`server/src/app.ts`)
- [ ] CORS 미들웨어 설정 (개발: localhost:5173 허용)
- [ ] 보안 미들웨어: Path traversal 방어 (`middleware/security.ts`)
- [ ] 에러 핸들링 미들웨어
- [ ] 환경변수 로드 (dotenv + `server/.env`)
- [ ] `storage/` 하위 디렉토리 자동 생성 로직
#### 2-2. Range Request 스트리밍
- [ ] `routes/stream.ts` — GET /api/stream/:videoId
- [ ] `services/streaming.ts` — Range 파싱, createReadStream, 206 응답
- [ ] Accept-Ranges 헤더, Content-Range 헤더 정확한 구현
- [ ] highWaterMark 1MB, 청크 크기 10MB 제한
- [ ] 테스트: curl로 Range Request 동작 확인
#### 2-3. FFmpeg spawn 래퍼
- [ ] `services/ffmpeg.ts` — runFFmpeg(), runFFprobe() 함수
- [ ] FFmpeg 설치 확인 로직 (서버 시작 시)
- [ ] stderr 진행률 파싱 (`time=HH:MM:SS.ms` 패턴)
- [ ] 코덱 감지: FFprobe로 H.264 여부 확인 → `-c copy` vs 트랜스코딩 분기
- [ ] 프레임 추출: `-accurate_seek -ss {time} -i {file} -frames:v 1`
#### 2-4. HLS 변환
- [ ] `routes/hls.ts` — POST /api/hls/:videoId/convert, GET 플레이리스트/세그먼트
- [ ] 변환 작업 관리 (Map 기반 인메모리 상태: idle/converting/done/error)
- [ ] H.264 → `-c copy` (빠른 리먹스), 비-H.264 → 트랜스코딩
- [ ] 세그먼트 6초, 키프레임 2초 간격
- [ ] SSE 진행률 엔드포인트: GET /api/hls/:videoId/progress
#### 2-5. tus 업로드
- [ ] `@tus/server` + `@tus/file-store` 설정
- [ ] `routes/upload.ts` — /api/upload 경로에 tus 서버 마운트
- [ ] 업로드 완료 훅: `onUploadFinish`에서 파일 이동 + FFprobe 메타데이터 추출
- [ ] 업로드 크기 제한: MAX_UPLOAD_SIZE (기본 20GB)
- [ ] MIME 타입 + FFprobe 실제 코덱 검증
#### 2-6. 메타데이터 & 영상 관리
- [ ] `routes/meta.ts` — GET /api/meta/:videoId (FFprobe 기반)
- [ ] GET /api/videos — 업로드된 영상 목록
- [ ] DELETE /api/videos/:videoId — 원본+HLS+프레임+썸네일+DB 일괄 삭제
#### 2-7. SQLite + 주석 API
- [ ] `db/schema.sql` — annotations 테이블 스키마
- [ ] better-sqlite3 초기화 (`services/storage.ts`)
- [ ] `routes/annotations.ts` — CRUD + 내보내기 (VTT, SRT, JSON, CSV)
#### 2-8. 파일 정리
- [ ] 서버 시작 시 24시간 이상 된 임시 파일 정리
- [ ] 주기적 정리 (setInterval, 1시간마다)
- [ ] 디스크 잔여 공간 체크 (check-disk-space, 10GB 미만 시 로그 경고)
### 산출물 (완료 기준)
- 서버 기동 성공 (FFmpeg 감지 로그 출력)
- curl로 Range Request 스트리밍 동작 확인
- curl로 영상 업로드 (tus) 성공
- HLS 변환 시작/진행률/완료 확인
- 주석 CRUD API 동작 확인
### 다음 단계 의존성
- 단계 3 (기본 플레이어)은 최소 2-1, 2-2 완료 시 시작 가능
- 단계 4 (프레임 추출)은 2-3 완료 필요
- 단계 6 (텍스트 오버레이)은 2-7 완료 필요
---
## 단계 3: 기본 플레이어
### 목표
Video.js + 이중 경로 재생 (로컬 File API + 서버 Range Request/HLS) + 기본 UI
### 세부 태스크
#### 3-1. Video.js 통합
- [ ] Video.js 8.23.x + 타입 정의 설치
- [ ] `components/player/VideoPlayer.tsx` — ref + useEffect 패턴
- [ ] `hooks/useVideoPlayer.ts` — 초기화/제어/이벤트 추상화
- [ ] Video.js 옵션: fluid, responsive, playbackRates 설정
- [ ] 전체화면: 컨테이너 div 기준 fullscreen (오버레이 유지)
#### 3-2. 서버 파일 재생
- [ ] 서버 영상 목록 조회 UI
- [ ] Range Request URL로 즉시 재생
- [ ] HLS 변환 트리거 + SSE 진행률 표시
- [ ] HLS 준비 완료 시 hls.js로 소스 전환
#### 3-3. 로컬 파일 재생
- [ ] 파일 드래그앤드롭 / 파일 선택 UI
- [ ] `URL.createObjectURL()` → Video.js src 설정
- [ ] 재생 종료/파일 변경 시 `URL.revokeObjectURL()` 호출
#### 3-4. hls.js 설정
- [ ] hls.js 1.6.x 설치 및 Video.js VHS와의 역할 분담 결정
- [ ] backBufferLength: 30, maxBufferSize: 60MB 등 필수 설정 적용
- [ ] 에러 복구 로직 (QuotaExceededError 대응)
#### 3-5. 기본 UI 레이아웃
- [ ] 메인 레이아웃: 플레이어 영역 + 사이드 패널 (영상 목록/주석)
- [ ] 반응형 기본 구조 (Tailwind)
- [ ] Zustand 스토어 기본 구조 (현재 영상, 재생 상태, 소스 타입)
### 산출물 (완료 기준)
- 로컬 mp4 파일 드래그앤드롭 → 즉시 재생
- 서버 영상 선택 → Range Request로 재생
- HLS 변환 완료 후 HLS로 전환 재생
- 전체화면 동작 확인
### 다음 단계 의존성
- 단계 4, 5, 6 모두 이 단계 완료 후 시작 가능
---
## 단계 4: 프레임 추출
### 목표
Canvas 즉시 미리보기 + 서버 FFmpeg API를 통한 정확한 프레임 추출
### 세부 태스크
- [ ] `utils/frameCapture.ts` — Canvas 기반 현재 프레임 캡처 (로컬 파일용 즉시 미리보기)
- [ ] 단일 Canvas 재사용 패턴 적용
- [ ] `routes/frame.ts` 연동 — 서버 FFmpeg 프레임 추출 요청
- [ ] 프레임 추출 결과 표시 UI (모달 또는 패널)
- [ ] 프레임 이미지 다운로드 기능
- [ ] Shift+S 단축키 → 현재 프레임 캡처
### 산출물 (완료 기준)
- 일시정지 상태에서 Shift+S → 프레임 이미지 표시 + 다운로드
- 서버 영상: FFmpeg 정확 추출
- 로컬 파일: Canvas 즉시 캡처
### 다음 단계 의존성
- 단계 5에서 FPS 감지 로직과 연계
---
## 단계 5: 단위 이동
### 목표
프레임/초/장면 단위 seek + requestVideoFrameCallback FPS 감지 + 키보드 단축키
### 세부 태스크
- [ ] `hooks/useFrameStep.ts` — requestVideoFrameCallback으로 FPS 동적 감지 (fallback 30fps)
- [ ] 프레임 이동: `,`(이전) / `.`(다음) — 일시정지 상태에서만
- [ ] 초 이동: ←/→(5초), J/L(10초)
- [ ] 장면 이동: `[`/`]` — 키프레임 기반 또는 일정 간격(30초) 이동
- [ ] 10% 단위 탐색: 0~9 키
- [ ] 재생 속도: +/- 키 (0.25x ~ 4x)
- [ ] 전체화면 토글: F 키
- [ ] 음소거 토글: M 키
- [ ] 키보드 이벤트: 플레이어 포커스 시에만 활성, 텍스트 입력 시 비활성
- [ ] 현재 프레임 번호 / 타임코드 표시 UI
### 산출물 (완료 기준)
- 모든 키보드 단축키 동작 확인
- 프레임 단위 이동 시 타임코드 정확 갱신
- FPS 감지 로그 출력
### 다음 단계 의존성
- 없음 (단계 6과 병렬 진행 가능)
---
## 단계 6: 텍스트 오버레이
### 목표
자막형(WebVTT track) + 메모형(interact.js DOM 오버레이) + 내보내기(subsrt)
### 세부 태스크
#### 6-1. 자막형
- [ ] WebVTT `<track kind="subtitles">` 연동
- [ ] 자막 추가/편집 UI (시작 시간, 종료 시간, 텍스트)
- [ ] 서버 주석 API 연동 (type: "subtitle")
- [ ] VTT/SRT 내보내기 (subsrt 라이브러리)
#### 6-2. 메모형
- [ ] `components/overlay/MemoOverlay.tsx` — DOM 오버레이 컨테이너
- [ ] interact.js 드래그/리사이즈 연동
- [ ] 좌표 정규화: px → % 변환/저장
- [ ] 메모 추가 UI: Shift+M → 현재 시점에 메모 생성
- [ ] 메모 편집/삭제 UI
- [ ] 표시 로직: requestAnimationFrame 루프 + 이진 탐색
- [ ] 서버 주석 API 연동 (type: "memo")
- [ ] JSON/CSV 내보내기
#### 6-3. 주석 관리 패널
- [ ] `hooks/useAnnotations.ts` — 주석 CRUD + 서버 동기화
- [ ] 사이드 패널: 시간순 주석 목록, 클릭 시 해당 시점으로 이동
- [ ] 타임라인 위 주석 마커 표시
### 산출물 (완료 기준)
- 자막 추가 → 영상 재생 시 하단에 표시
- 메모 추가 → 드래그로 위치 조정 가능
- VTT/SRT/JSON/CSV 내보내기 동작 확인
### 다음 단계 의존성
- 없음 (단계 5와 병렬 진행 가능)
---
## 단계 7: UI/UX 통합
### 목표
전체 기능 통합, UI 다듬기, 썸네일 미리보기, 디스크 관리
### 세부 태스크
- [ ] 전체 레이아웃 정리 (플레이어 + 컨트롤 + 사이드패널 통합)
- [ ] 탐색바 썸네일 미리보기 (서버 FFmpeg 스프라이트 + VTT)
- [ ] HLS 변환 진행률 UI (프로그레스 바 + 상태 텍스트)
- [ ] 영상 목록 UI 개선 (업로드 상태, HLS 변환 상태, 용량)
- [ ] 디스크 사용량 표시 + 경고
- [ ] 로딩/에러 상태 처리 (스켈레톤, 에러 바운더리)
- [ ] 키보드 단축키 도움말 오버레이 (? 키)
- [ ] 전체 기능 통합 테스트
- [ ] README.md 작성 (설치, 실행, 사용법)
### 산출물 (완료 기준)
- 전체 기능이 하나의 UI에서 동작
- 서버 파일 + 로컬 파일 모두 테스트 완료
- README.md로 프로젝트 세팅/실행 가능
### 다음 단계 의존성
- 모든 이전 단계 완료 필요
---
## 단계 의존성 다이어그램
```
단계 1 (프로젝트 구조)
├── 단계 2 (백엔드 서버)
│ ├── 단계 4 (프레임 추출) ──┐
│ └── 단계 6-자막 (주석 API) │
└── 단계 3 (기본 플레이어) │
├── 단계 4 (프레임 추출) ──┤
├── 단계 5 (단위 이동) ────┤ → 단계 7 (UI/UX 통합)
└── 단계 6 (텍스트 오버레이)┘
※ 단계 5와 6은 병렬 진행 가능
```
Executable
+265
View File
@@ -0,0 +1,265 @@
# PROGRESS.md — abcvideo 진행 상태
> 이 파일은 각 단계의 진행 상태를 기록합니다.
> 에이전트는 작업 시작/완료 시 반드시 이 파일을 업데이트합니다.
---
## 현재 상태 요약
- 마지막 완료 단계: 구조물 시설종별(1종/2종/3종) 표시 필터 (2026-06-19)
- 직전 완료 단계: 폴더 임포트 v2.0 데이터 형상 전용화 (2026-06-19)
- 블로커: FFmpeg 미설치 (HLS/프레임 기능은 FFmpeg 설치 후 동작)
- 검증 결과: VERIFICATION.md 참조 (모든 항목 통과, V3-3 RAF 버그 1건 수정 완료)
- v2.0 전환: geoData.ts/types/geo.ts/geoStore.ts/StationBar.tsx 수정, client 빌드 통과
---
## 단계별 진행 기록
### 단계 1: 프로젝트 구조 세팅
- 상태: ✅ 완료 (2026-03-24)
- 완료 항목:
- 루트 package.json (npm workspaces)
- shared/src/types.ts (공유 타입)
- server/ 스캐폴드 + tsx dev 환경
- client/ Vite + React 18 + TypeScript + Tailwind
- storage/ 디렉토리 구조
- npm install 성공 (514 packages)
- server TypeScript 컴파일 에러 없음
- GET /api/health 응답 확인
- 미완료/이슈:
- better-sqlite3 ^12.8.0으로 업그레이드 (Node 24 prebuilt binary 문제)
- 다음 단계 참고:
- server/package.json에 better-sqlite3 ^12.8.0 사용 중
- client Vite dev는 아직 미확인 (브라우저 확인 필요)
- server/src/app.ts는 최소 스캐폴드 — 단계 2에서 확장
### 단계 2: 백엔드 서버
- 상태: ✅ 완료 (2026-03-24)
- 완료 항목:
- server/src/config.ts — 환경변수 기반 설정
- server/src/services/ffmpeg.ts — FFprobe/FFmpeg spawn 래퍼 (probeVideo, runFFmpeg, getVideoFps 등)
- server/src/services/streaming.ts — Range Request 스트리밍 (10MB 청크, 1MB highWaterMark)
- server/src/services/storage.ts — better-sqlite3 초기화, Annotation CRUD, 임시파일 정리
- server/src/middleware/security.ts — pathTraversalGuard, validateMimeType, securityHeaders
- server/src/routes/stream.ts — GET /api/stream/:videoId
- server/src/routes/hls.ts — POST /convert, GET /status, GET /progress(SSE), GET /index.m3u8, GET /:segment
- server/src/routes/frame.ts — GET /api/frame/:videoId?time=|frame=
- server/src/routes/upload.ts — 청크 업로드 (init/chunk/complete/status), @tus/server 대신 자체 구현
- server/src/routes/meta.ts — GET /api/videos, GET /api/meta/:videoId, DELETE /api/videos/:videoId
- server/src/routes/annotations.ts — CRUD + export(vtt/srt/json/csv)
- server/src/app.ts — 전체 통합, 헬스체크, 에러 핸들러
- server/.env — 환경변수 파일
- server/tsconfig.json — rootDirs 수정 (shared 타입 접근 허용)
- GET /api/health, /api/videos, /api/annotations/:videoId 정상 응답 확인
- 미완료/이슈:
- @tus/server 2.3.0이 ESM-only("type":"module")라 CommonJS 서버와 호환 불가 → 자체 청크 업로드 구현으로 대체
- FFmpeg 시스템 PATH 미등록 상태 → HLS 변환/프레임 추출은 FFmpeg 설치 후 동작
- 다음 단계 참고:
- 청크 업로드 클라이언트는 POST /api/upload/init → POST /api/upload/chunk (X-Upload-Id, X-Chunk-Index 헤더, application/octet-stream body) → POST /api/upload/complete 순서 사용
- HLS 변환 완료 후 /api/hls/:videoId/index.m3u8 로 플레이리스트 접근
- 서버는 포트 3001에서 실행 중
### 단계 3: 기본 플레이어
- 상태: ✅ 완료 (2026-03-24)
- 완료 항목:
- client/src/types/player.ts — VideoSource, PlayerState 타입
- client/src/store/playerStore.ts — Zustand 플레이어 상태 스토어
- client/src/store/annotationStore.ts — 주석 Zustand 스토어
- client/src/hooks/useVideoPlayer.ts — Video.js 8 초기화, loadLocalFile, loadServerStream, switchToHls
- client/src/components/player/VideoPlayer.tsx — forwardRef + VideoPlayerHandle API
- client/src/components/player/HlsConversionStatus.tsx — SSE 진행률 바
- client/src/components/sidebar/VideoList.tsx — 서버 영상 목록
- client/src/App.tsx — 전체 레이아웃 통합
- npm install: video.js@^8.23.0, @videojs/http-streaming, hls.js@^1.6.15, interactjs@^1.10.27, subsrt-ts@^2.0.3
- TypeScript 에러 없음, 빌드 성공 (dist 1.47MB)
- 미완료/이슈:
- interact.js 패키지명 오류 → interactjs (하이픈 없음) 으로 설치
- 빌드 경고: chunk > 500kB (video.js 특성상 정상, 운영환경 분할 불필요)
- 다음 단계 참고:
- VideoPlayer는 forwardRef<VideoPlayerHandle> 사용 — App에서 playerRef.current?.loadServerStream() 호출
- hls.js backBufferLength: 30 설정 완료
- MemoOverlay는 interactjs 사용 (import from 'interactjs')
### 단계 4: 프레임 추출
- 상태: ✅ 완료 (2026-03-24)
- 완료 항목:
- client/src/utils/frameCapture.ts — 단일 Canvas 재사용, captureFrame, downloadDataUrl
- client/src/components/player/FrameCaptureButton.tsx — Shift+S 캡처 버튼
- VideoPlayer에 캡처 통합 (getVideoElement → captureFrame → downloadDataUrl)
- 미완료/이슈: 서버 FFmpeg 정확 추출(로컬 파일 정확 추출 시)은 미구현 — Canvas 즉시 미리보기만 구현
- 다음 단계 참고: (없음)
### 단계 5: 단위 이동
- 상태: ✅ 완료 (2026-03-24)
- 완료 항목:
- client/src/hooks/useFrameStep.ts — requestVideoFrameCallback FPS 감지, stepForward/stepBackward
- client/src/hooks/useKeyboard.ts — CLAUDE.md 키보드 단축키 전체 구현
- 프레임/초/장면 단위 이동 완성 (Space/←/→/J/L/,/./[/]/F/M/+/-/Shift+S/Shift+M/0-9)
- 미완료/이슈: (없음)
- 다음 단계 참고: (없음)
### 단계 6: 텍스트 오버레이
- 상태: ✅ 완료 (2026-03-24)
- 완료 항목:
- client/src/hooks/useAnnotations.ts — 주석 CRUD API 연동
- client/src/components/overlay/MemoOverlay.tsx — interactjs 드래그, x%/y% 좌표
- client/src/components/sidebar/AnnotationPanel.tsx — 자막/메모 탭, 내보내기(VTT/SRT/JSON/CSV)
- client/src/components/AddAnnotationModal.tsx — 주석 추가 모달
- client/src/utils/timecode.ts — secondsToTimecode, timecodeToSeconds, frameToSeconds, secondsToFrame
- 미완료/이슈:
- WebVTT track 요소를 통한 자막형 렌더링은 미구현 (주석 저장/표시는 완성, track 연동은 단계 7)
- 다음 단계 참고:
- 자막형 WebVTT track 연동은 단계 7 UI/UX 통합 시 추가 가능
- 내보내기는 /api/annotations/:videoId/export?format=vtt|srt|json|csv 백엔드 API 사용
### 검증 (VERIFICATION)
- 상태: ✅ 완료 (2026-03-24)
- 완료 항목:
- VERIFICATION.md 작성 및 전 항목 검증 실행
- V1 정적 검증: server/client tsc 에러 없음, 빌드 성공
- V2 서버 API: 헬스체크/영상목록/업로드/주석 CRUD/내보내기/보안 모두 통과
- V3 코드 품질: 10/10 통과 (MemoOverlay RAF 버그 발견 및 수정)
- V4 파일 구조: 19개 필수 파일 전부 존재 확인
- 수정 사항:
- `VideoPlayerHandle``getVideoElement()` 추가 (VideoPlayer.tsx)
- App.tsx RAF 루프 추가 → MemoOverlay `currentTime` 60fps 업데이트
### 기능 확장: 카메라 파라미터 보정 패널 (2026-03-26)
- 상태: ✅ 완료
- 완료 항목:
- client/src/utils/geoProjection.ts — CameraParams 인터페이스 추가, projectPoint 시그니처 변경
- yawOffset(number) → params(CameraParams) 로 통합
- Roll 회전 수학 추가 (Rodrigues, fwd 축 기준): right_r = right0·cosR up0·sinR
- 주점 오프셋(cx0, cy0) 핀홀 투영에 적용
- 초점 배율(focalScale) 적용: f_eff = focalLen × focalScale
- pxRaw/pyRaw 필드 추가 (클램프 전 원본값, FOV 이탈 감지용)
- DEFAULT_CAMERA_PARAMS export
- client/src/components/overlay/StationOverlay.tsx — 파라미터 패널 UI 전면 개편
- ParamRow 컴포넌트: 슬라이더 + 직접 수치 입력(Enter/blur 커밋) 양방향 동기화
- 자세 보정 섹션: Yaw(-180~+180°), Pitch(-45~+45°), Roll(-45~+45°)
- 내부표정 섹션: f×(0.3~3.0배율), cx₀(-0.5~+0.5), cy₀(-0.5~+0.5)
- 현재 유효값 실시간 표시: yaw_eff, pitch_eff, f_eff(mm), hFOV(°)
- 측점 Canvas 마커 구현: 다이아몬드 ◆ + 측점명 레이블 + 거리(m)
- FOV 이탈 측점 → 가장자리 방향 화살표(drawEdgeArrow)
- POI 마커: + 심볼 + 레이블
- 지면 가이드선: 측점 → 화면 하단 점선
- 나침반 HUD: hFOV를 focalScale 반영하여 동적 계산
- 초기화 버튼, 드론 경로 체크박스
- 빌드 성공: tsc + vite build 에러 없음 (2026-03-26)
- 발견 사항:
- SRT gb_roll=0.0 (전 프레임 고정) → Roll 보정은 이 영상에서 효과 없으나 다른 영상 대비 구조 완성
- 자기 편각(-8°) 보정이 yawOffset 슬라이더의 주된 용도
- focalScale로 실효 FOV를 조정할 수 있어 정밀 보정 가능
- 미완료/이슈:
- GCP 클릭 기반 자동 Resection 미구현 (yaw/pitch 자동 계산)
- 파라미터 설정값 영속 저장(localStorage) 미구현
### 기능 확장: 지리정보 오버레이 + 측점 검증 (2026-03-25)
- 상태: ✅ 완료
- 완료 항목:
- server/src/services/geoMatch.ts — 블렌더 방식 월드 ENU 좌표계 구현
- geoToEnu(): 위경도 → ENU(m), 기준점 위도로 cos(lat) 고정 (전 프레임 일관성)
- projectEnu(): 상대 ENU 벡터 + 카메라 회전 → 핀홀 투영
- project3D(): geoToEnu + projectEnu 래퍼, origin 파라미터 지원
- findFramesForPoi / findPoisForFrame — frames[0]를 월드 원점으로 전달
- getDroneFrames() 신규 export
- server/src/routes/geo.ts — yawOffset 쿼리 파라미터, GET /api/geo/frames 엔드포인트
- client/src/utils/geoProjection.ts — 서버와 동일한 블렌더 방식 ENU 투영, ref 파라미터 추가
- client/src/components/overlay/StationOverlay.tsx — yaw 보정 슬라이더(-180~+180°), 드론 경로 오버레이, pathFrames[0] 원점 전달
- client/src/components/geo/StationVerify.tsx — 측점 목록 + 클릭 검증 패널 (신규)
- client/src/App.tsx — 우측 패널에 "측점" 탭 추가
- server/client 빌드 모두 에러 없음 확인
- 발견 사항:
- cos(lat) 보정을 각 프레임 위도가 아닌 기준점(frame 0) 위도로 고정해야 전 구간 일관성 보장
- CSV yaw/pitch/roll = 짐벌(카메라) 방향 (pitch=-29.8°는 짐벌 고정 틸트, roll=0은 짐벌 보정)
- frame 1791 기준 158K300: px=70.1% (사용자 관측 ~73%와 일치)
- 미완료/이슈:
- Z값(표고) 미적용 — 측점 z값이 ~0m이라 수직 오차 존재, 실제 표고 데이터 확보 후 보정 필요
- GCP 캘리브레이션 UI 미구현 (클릭으로 yaw/pitch 오프셋 자동 계산)
### 지리정보 오버레이 — Python advanced_tuner_v2 포팅 (2026-03-30)
- 상태: ✅ 완료
- 완료 항목:
- geoProjection.ts 재구현: R_b2w=Rz(-yaw)*Rx(pitch)*Ry(roll), R_w2c=R_align@R_b2w.T (Python 동치)
- pitch/roll 절대값 → 오프셋 방식 (Python spn_pitch/roll 기본값 0과 동일)
- sensorH 기본값 20.25mm (=36×9/16, 16:9 영상 기준, 기존 24mm 수직 스케일 오류 수정)
- CameraParams에 offX/offY/offZ 추가 (Python off_x/y/z 동치)
- geoMatch.ts: center.csv 224점 로더 추가 (getCenterlinePoints)
- GET /api/geo/centerline 엔드포인트 추가
- StationOverlay: center.csv 중심선 빨간 선분 시각화 (Python 방식), Catmull-Rom next6 스플라인 제거
- 파라미터 패널: 위치 보정(offX/Y/Z), 센서 크기(senW/H) 슬라이더 추가
- DEFAULT_CAMERA_PARAMS = Python 기본값 (모든 오프셋 0, focal=24, sensor=36)
- 다음 단계 참고:
- off_x/y/z 슬라이더로 GPS 위치 오차 보정 필요
- swap_xy 옵션 미구현 (Python에는 있음, 필요시 추가)
### 단계 7: UI/UX 통합
- 상태: ✅ 완료 (2026-03-24)
- 완료 항목:
- client/src/components/ErrorBoundary.tsx — React 에러 바운더리 (오류 발생 시 복구 UI)
- client/src/components/HelpOverlay.tsx — 키보드 단축키 도움말 오버레이
- client/src/main.tsx — ErrorBoundary로 App 래핑
- client/src/hooks/useKeyboard.ts — ? 키 (Shift+Slash) → onToggleHelp 콜백 추가
- client/src/components/player/VideoPlayer.tsx — onToggleHelp prop 수신 및 useKeyboard에 전달
- client/src/App.tsx — showHelp 상태 + HelpOverlay 렌더링 통합
- README.md — 프로젝트 설치/실행/단축키/환경변수 문서 작성
- TypeScript 타입 에러 없음, 프로덕션 빌드 성공 (1,480 kB / gzip 453 kB)
- 미완료/이슈:
- 빌드 경고: chunk > 500kB (video.js 특성상 정상)
- 다음 단계 참고: 전체 7단계 완료
### 기능 확장: 구조물 시설종별(1종/2종/3종) 표시 필터 (2026-06-19)
- 상태: ✅ 완료
- 목표: 구조물 시설종별에 따라 재생바(StationBar)/패널(RoutePanel) 표시 필터. 플레이어에 1/2/3종 체크박스, 기본 1종☑2종☑3종☐, localStorage 지속.
- 완료 항목:
- client/src/types/geo.ts: RouteStructure 에 grade?: string 추가(시설종별 원문).
- client/src/utils/geoData.ts (parseStructures): pushFrom spec 에 gradeFallback 추가. 교량 시설종별 idx1, 터널 idx5 를 헤더명('시설종별') indexOf 우선 + 폴백으로 읽어 trim, 빈 값이면 undefined. 구교(06)는 gradeFallback 미지정 → grade undefined.
- client/src/store/settingsStore.ts (신설): zustand + persist('ghivideo.settings'). gradeFilter 기본 {1종:true,2종:true,3종:false}, setGradeFilter 액션. KNOWN_GRADES, isGradeVisible(공유 표시규칙) export. merge 로 누락 키 기본값 보충.
- 표시규칙 isGradeVisible: grade 없음 || KNOWN 미포함('기타'/'인상' 등) → 항상 표시, 1/2/3종은 gradeFilter[grade]===true 일 때만.
- client/src/stationbar/StationBar.tsx: storeStructuresRaw 를 isGradeVisible 로 필터(useMemo, gradeFilter 구독) → structureMarks 가 필터된 목록 사용.
- client/src/components/overlay/RoutePanel.tsx: filteredPois 에 isGradeVisible 조건 추가(동일 규칙, gradeFilter 구독).
- client/src/components/player/VideoPlayer.tsx: 좌하단 컨트롤 그룹(배속 행 인근)에 "시설등급" 라벨 + 1/2/3종 체크박스 3개. showVideoControls && geoLoaded 일 때 노출. setGradeFilter 바인딩(즉시 토글).
- 실데이터 검증(하행)회덕-대전조차장 v2.0, 좌표필터 적용 후):
- 교량 12건: 시설종별 3종 ×10, 기타 ×2 ("인상" 1건은 깨진 좌표로 이미 필터됨)
- 터널 2건: 시설종별 2종 ×2
- 구교 9건: 시설종별 컬럼 없음 → grade undefined(항상 표시)
- 기본값(1종☑2종☑3종☐) 적용 시: 3종 교량 10건 숨김, 기타 교량 2건·터널 2건(2종)·구교 9건 표시.
- 빌드: nvm use 20 → npm run build -w client → tsc + vite 에러 0 (기존 chunk>500kB 경고만).
- 미완료/후속:
- 이 데이터엔 1종 구조물이 없어 1종 체크박스 효과는 다른 데이터에서 검증 필요(구조는 완성).
- StationOverlay(영상 위 측점 마커)는 structures 미사용이라 필터 미적용 — 요구범위(재생바/패널)대로.
- 상세 기록: docs/history/2026-06-19_시설종별-필터.md
### 기능 전환: 폴더 임포트 v2.0 데이터 형상 전용화 (2026-06-19)
- 상태: ✅ 완료
- 목표: 폴더 선택 시 v2.0 형상만 로딩. v1.0(POI_위경도값/측점_위경도값/center.csv) 지원 코드 제거.
- 완료 항목:
- client/src/types/geo.ts:
- DirectionChange 타입 신설(station/from/to/atSeconds)
- RouteStructure 에 lengthM?, category? 추가
- FolderGeoData 에 structures: RouteStructure[], directionChanges: DirectionChange[] 추가
- client/src/utils/geoData.ts (전면 재작성, v2.0 전용):
- 인코딩 자동 감지: decodeBytes(buf) — 앞 3바이트 EF BB BF → UTF-8, 아니면 EUC-KR, 잔여 BOM 제거
- readCsv(file) 시그니처 단순화(인코딩 인자 제거)
- parseStations: building/01)측점.csv → 측점 + 방향전환점 추출(비고 정규식)
- parsePois: <base>_POI.csv(UTF-8) + building/02)지장물.csv(EUC-KR) 병합
- parseStructures: 03)교량(bridge)+04)터널(tunnel)+06)구교(bridge/category='구교') → RouteStructure[], 위경도 범위(33~39/124~132) 밖 깨진 행 필터
- mergeStructures: route.json structures 를 name 매칭으로 CSV 구조물에 override/augment(선택적 보정 레이어)
- buildCenterlineFromStations: v2.0엔 center.csv 없음 → 측점 mileage 정렬 폴리라인을 중심선으로 생성
- v1.0 코드 제거: POI_위경도값/측점_위경도값/타원체고 식별, center.csv 로더, encoding 하드코딩 분기
- client/src/store/geoStore.ts: structures, directionChanges 적재
- client/src/stationbar/StationBar.tsx: 구조물 마커를 geoStore.structures(CSV 유래) 우선으로 전환(기존 routeMeta.structures 대체). 위치 로직(station→lat/lon→이름매칭→mileage)은 lat/lon 지원으로 그대로 활용.
- 실데이터 검증(하행)회덕-대전조차장 데이터(v2.0)):
- 인코딩: 드론/POI/05)출입문/06)구교=UTF-8(BOM), 01~04=EUC-KR (BOM 자동감지로 일괄 처리)
- directionChanges 2건: 157K930 상행→하행@125s, 158K340 하행→상행@75s
- 측점 47개, POI 137개(<base>_POI 114 + 지장물 23), 구조물 교량 12(깨진 1행 필터)+터널 2+구교 9
- 빌드: nvm use 20 → npm run build -w client → tsc + vite 에러 0 (기존 chunk>500kB 경고만)
- 미완료/후속과제:
- 05)출입문번호.csv: 운영데이터로 재생바 아이콘이 과도해져 기본 표시 제외. 파싱 자체를 스킵(type='door' 보관 비용 대비 효용 낮음, Timeline 렌더는 bridge/tunnel/station만). 추후 필요 시 parseDoors 추가.
- directionChanges 와이어링: 타입/스토어까지 적재 완료. StationBar barSegments 방향전환은 현재 연속 체이니지(chain) 자동 판정 로직을 유지(directionChanges 로 강제 분할은 미적용). chain 자동판정이 영상과 일치하므로 회귀 위험을 피해 후속과제로 둠.
- 다음 단계 참고:
- geoStore.structures/directionChanges 가 신규 노출됨 — 후속 소비처에서 활용 가능
- StationOverlay/StationVerify/RoutePanel/RouteInfoOverlay 는 stations/pois/routeMeta 만 사용하므로 변경 불필요(검증 완료)
- 상세 기록: docs/history/2026-06-19_v2.0-import전환.md
Executable
+77
View File
@@ -0,0 +1,77 @@
# abcvideo
2GB 이상 대용량 동영상을 웹 브라우저에서 안정적으로 재생하는 특화 기능 탑재 플레이어.
## 기능
- 2GB+ 동영상 안정 재생 (로컬 파일 직접 재생 + 서버 Range Request/HLS)
- 프레임 단위 추출 (Canvas 즉시 미리보기)
- 키보드 단축키로 프레임/초/장면 단위 이동
- 자막형 / 메모형 텍스트 오버레이 + VTT/SRT/JSON/CSV 내보내기
- HLS 자동 변환 (FFmpeg 설치 시)
## 사전 요구사항
- Node.js 20+ LTS
- FFmpeg (HLS 변환 및 프레임 정확 추출 시 필요)
- Windows: https://ffmpeg.org/download.html 에서 다운로드 후 PATH 등록
- macOS: `brew install ffmpeg`
- Ubuntu: `sudo apt install ffmpeg`
## 설치 및 실행
```bash
# 1. 의존성 설치
npm install
# 2. 환경변수 설정
cp server/.env.example server/.env
# 3. 개발 서버 실행 (클라이언트 + 서버 동시)
npm run dev
```
브라우저에서 http://localhost:5173 접속
## 개발 서버 포트
| 서비스 | 포트 |
|--------|------|
| 클라이언트 (Vite) | 5173 |
| 백엔드 API | 3001 |
## 키보드 단축키
| 키 | 동작 |
|----|------|
| Space | 재생 / 일시정지 |
| ← / → | 5초 뒤로 / 앞으로 |
| J / L | 10초 뒤로 / 앞으로 |
| , / . | 이전 / 다음 프레임 (일시정지 시) |
| [ / ] | ±30초 이동 |
| 0 ~ 9 | 10% 단위 탐색 |
| F | 전체화면 |
| M | 음소거 |
| + / - | 재생속도 조절 |
| Shift+S | 프레임 캡처 |
| Shift+M | 메모 추가 |
| ? | 단축키 도움말 |
## 프로젝트 구조
```
abcvideo/
├── client/ # React 18 + Video.js 8 프론트엔드
├── server/ # Node.js + Express 백엔드
├── shared/ # 공유 TypeScript 타입
└── storage/ # 런타임 생성 (영상, HLS, 프레임)
```
## 환경변수 (server/.env)
| 변수 | 기본값 | 설명 |
|------|--------|------|
| PORT | 3001 | 서버 포트 |
| FFMPEG_PATH | ffmpeg | FFmpeg 실행 경로 |
| MAX_UPLOAD_SIZE | 21474836480 | 최대 업로드 크기 (20GB) |
| HLS_SEGMENT_TIME | 6 | HLS 세그먼트 길이 (초) |
+98
View File
@@ -0,0 +1,98 @@
# VERIFICATION.md — abcvideo 검증 결과
> 전체 7단계 구현 완료 후 진행한 검증 결과입니다.
> 검증 일자: 2026-03-24
---
## 검증 결과 요약
| 범주 | 통과 | 실패 | 상태 |
|------|------|------|------|
| V1 정적 검증 (빌드/타입) | 4/4 | 0 | ✅ |
| V2 서버 API 검증 | 13/14 | 1(문서 불일치) | ✅ |
| V3 코드 품질 검증 | 9/10 → 10/10 | 0 (수정 완료) | ✅ |
| V4 파일 구조 검증 | 19/19 | 0 | ✅ |
**전체: 모든 항목 통과 (1건 코드 수정으로 해결)**
---
## V1. 정적 검증
| 항목 | 상태 | 비고 |
|------|------|------|
| V1-1: server `tsc --noEmit` | ✅ PASS | 에러 없음 |
| V1-2: client `tsc --noEmit` | ✅ PASS | 에러 없음 (RAF 수정 후 재확인 완료) |
| V1-3: client `npm run build` | ✅ PASS | 1,480 kB / gzip 453 kB (Video.js 특성상 정상) |
| V1-4: 필수 파일 19개 존재 | ✅ PASS | 모든 파일 확인 |
---
## V2. 서버 API 검증
| 항목 | 상태 | 실제 응답 |
|------|------|----------|
| V2-1: `GET /api/health` | ✅ PASS | `{"status":"ok","timestamp":"..."}` |
| V2-2: `GET /api/videos` | ✅ PASS | `[]` (빈 목록) |
| V2-3: `GET /api/stream/nonexistent` | ✅ PASS | HTTP 404 |
| V2-4: `POST /api/upload/init` | ✅ PASS | `{"uploadId":"..."}` (필드명: `totalChunks`) |
| V2-5: `GET /api/annotations/test-video` | ✅ PASS | `[]` |
| V2-6: `POST /api/annotations/test-video` | ✅ PASS | UUID `id` 포함 주석 객체 반환 |
| V2-7: `PUT /api/annotations/test-video/:id` | ✅ PASS | 수정된 text 반영 |
| V2-8: `DELETE /api/annotations/test-video/:id` | ✅ PASS | `{"success":true}` |
| V2-9: export VTT | ✅ PASS | `WEBVTT\n\n1\n00:00:10.500 --> 00:00:15.000\n...` |
| V2-10: export SRT | ✅ PASS | `1\n00:00:10,500 --> 00:00:15,000\n...` (콤마 정확) |
| V2-11: export JSON | ✅ PASS | JSON 배열 |
| V2-12: export CSV | ✅ PASS | 헤더 + 데이터 행 |
| V2-13a: Path traversal (URL인코딩) | ✅ PASS | HTTP 400 |
| V2-13b: Path traversal (평문) | ✅ PASS | HTTP 404 (Express 라우터 정규화) |
**참고:** V2-4 테스트 스펙에 `fileSize` 대신 `totalChunks` 사용해야 함 — API 동작 정상, 문서 불일치만 존재 (tus → 자체 구현 전환 시 발생).
---
## V3. 코드 품질 검증
| 항목 | 상태 | 코드 위치 |
|------|------|---------|
| V3-1: `backBufferLength: 30` | ✅ PASS | `useVideoPlayer.ts:11` |
| V3-2: `requestVideoFrameCallback` FPS 감지 | ✅ PASS | `useFrameStep.ts` |
| V3-3: `requestAnimationFrame` 오버레이 동기화 | ✅ PASS (수정) | `App.tsx` RAF 루프 추가 |
| V3-4: 단일 Canvas 재사용 | ✅ PASS | `frameCapture.ts:1` (모듈 싱글턴) |
| V3-5: Path traversal guard | ✅ PASS | `security.ts` `path.resolve()` + `startsWith()` |
| V3-6: FFmpeg `-accurate_seek` 순서 | ✅ PASS | `frame.ts` `-accurate_seek -ss -i` 순서 |
| V3-7: VTT `.` / SRT `,` 구분자 | ✅ PASS | `annotations.ts` `sep` 파라미터 |
| V3-8: 키보드 단축키 완전 구현 | ✅ PASS | `useKeyboard.ts` |
| V3-9: ErrorBoundary App 래핑 | ✅ PASS | `main.tsx` |
| V3-10: HLS 6초 세그먼트 + 키프레임 | ✅ PASS | `hls.ts` + `config.ts` |
**수정 내역 (V3-3):**
- 문제: `MemoOverlay`가 Zustand 스토어의 `currentTime`(timeupdate 기반)을 prop으로 받아 표시
- 수정: `VideoPlayerHandle``getVideoElement()` 추가, `App.tsx`에 RAF 루프 추가 → `memoTime` state를 60fps로 업데이트
- 효과: timeupdate의 ~4fps 제한 → RAF 60fps 정밀 업데이트
---
## V4. 파일 구조 검증
| 항목 | 상태 |
|------|------|
| server/src/app.ts | ✅ |
| server/src/routes/{stream,hls,upload,annotations,frame,meta}.ts | ✅ |
| server/src/services/{ffmpeg,streaming,storage}.ts | ✅ |
| server/src/middleware/security.ts | ✅ |
| client/src/hooks/{useVideoPlayer,useFrameStep,useKeyboard,useAnnotations}.ts | ✅ |
| client/src/components/player/VideoPlayer.tsx | ✅ |
| client/src/components/overlay/MemoOverlay.tsx | ✅ |
| client/src/components/{ErrorBoundary,HelpOverlay}.tsx | ✅ |
| README.md | ✅ |
---
## 알려진 제약 (검증 범위 외)
- **FFmpeg 미설치**: HLS 변환(`POST /api/hls/:videoId/convert`), 프레임 추출(`GET /api/frame/:videoId`)은 FFmpeg 설치 후 동작
- **브라우저 UI 수동 테스트 항목**: 로컬 파일 드래그앤드롭 재생, HLS 전환 재생, 전체화면, 메모 드래그, Shift+S 캡처
- **번들 크기**: 1,480 kB (Video.js 특성, 코드 스플리팅으로 개선 가능)
- **장면 이동 `[`/`]`**: 실제 키프레임 탐지 대신 ±30초 이동으로 구현됨 (FFmpeg 연동 시 개선 가능)
@@ -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건.
@@ -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
View 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>
@@ -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 기하 상수 유지.
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@abcvideo/client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx"
},
"dependencies": {
"@abcvideo/shared": "*",
"@types/proj4": "^2.5.6",
"@videojs/http-streaming": "^3.17.4",
"fflate": "^0.8.3",
"hls.js": "^1.6.15",
"interactjs": "^1.10.27",
"proj4": "^2.20.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"subsrt-ts": "^2.1.2",
"uuid": "14.0.0",
"video.js": "^8.23.7",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/react": "^18.2.73",
"@types/react-dom": "^18.2.23",
"@types/video.js": "^7.3.58",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"sass": "^1.101.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.5",
"vite": "^8.0.16"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

+187
View File
@@ -0,0 +1,187 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import VideoPlayer, { type VideoPlayerHandle } from './components/player/VideoPlayer';
import VideoList from './components/sidebar/VideoList';
import CaptureList from './components/sidebar/CaptureList';
import AnnotationPanel from './components/sidebar/AnnotationPanel';
import MemoOverlay from './components/overlay/MemoOverlay';
import AddAnnotationModal from './components/AddAnnotationModal';
import HelpOverlay from './components/HelpOverlay';
import GeoSearch from './components/geo/GeoSearch';
import StationVerify from './components/geo/StationVerify';
import { usePlayerStore } from './store/playerStore';
import { useAnnotations } from './hooks/useAnnotations';
export default function App() {
const [showModal, setShowModal] = useState(false);
const [modalTime, setModalTime] = useState(0);
const [showHelp, setShowHelp] = useState(false);
const [memoTime, setMemoTime] = useState(0);
const [rightTab, setRightTab] = useState<'annotation' | 'geo' | 'station'>('annotation');
// 좌/우 패널: UI 에서 숨김(코드 보존). 다시 표시하려면 true 로.
const SHOW_LEFT_PANEL = false;
const SHOW_RIGHT_PANEL = false;
const playerRef = useRef<VideoPlayerHandle>(null);
const rafRef = useRef<number>(0);
// RAF loop for MemoOverlay — avoids timeupdate (~4fps) imprecision
useEffect(() => {
const tick = () => {
const video = playerRef.current?.getVideoElement();
if (video) setMemoTime(video.currentTime);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, []);
const { source, currentTime, fps } = usePlayerStore();
const currentFrame = Math.round(currentTime * (fps || 30));
const videoId =
source?.kind === 'server'
? source.videoId
: source?.kind === 'local'
? source.file.name
: null;
const { annotations, create, update, remove } = useAnnotations(videoId);
const handleAddMemo = useCallback((time: number) => {
setModalTime(time);
setShowModal(true);
}, []);
const handleAnnotationCreate = async (
type: 'subtitle' | 'memo',
text: string,
timeStart: number,
timeEnd: number
) => {
await create({
type,
text,
timeStart,
timeEnd,
position: { x: 10, y: 10 },
size: { width: 30, height: 10 },
style: { fontSize: 14, color: '#ffffff', backgroundColor: 'rgba(0,0,0,0.75)' },
});
};
const handleExport = (format: string) => {
if (!videoId) return;
window.open(`/api/annotations/${videoId}/export?format=${format}`, '_blank');
};
const handleSeek = (time: number) => {
playerRef.current?.seekTo(time);
};
return (
<div className="flex h-screen bg-gray-950 text-white overflow-hidden">
{/* 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="px-4 py-3 border-b border-gray-800">
<h1 className="text-sm font-bold text-white">abcvideo</h1>
<p className="text-xs text-gray-500"> </p>
<p className="text-xs text-gray-600 mt-0.5">v1.0.0 · {new Date(__BUILD_TIME__).toLocaleString('ko-KR', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' })}</p>
</div>
{/* 상단: 서버 영상 목록 */}
<div className="flex-1 overflow-y-auto border-b border-gray-700 min-h-0">
<div className="px-4 py-2 text-xs text-gray-500 uppercase tracking-wide"> </div>
<VideoList
onSelect={(id, name) => playerRef.current?.loadServerStream(id, name)}
/>
</div>
{/* 하단: 캡처 프레임 미리보기 */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="px-4 py-2 text-xs text-gray-500 uppercase tracking-wide"> </div>
<CaptureList onSeek={handleSeek} />
</div>
</div>
)}
{/* Main — player + memo overlay */}
<div className="flex-1 flex flex-col min-w-0 relative">
<VideoPlayer
ref={playerRef}
onAddMemo={handleAddMemo}
onToggleHelp={() => setShowHelp(h => !h)}
/>
{/* Memo overlay — positioned absolute over the player area */}
<MemoOverlay
annotations={annotations}
currentTime={memoTime}
onUpdate={(id, pos) => update(id, { position: pos })}
onDelete={remove}
containerRef={{ current: null }}
/>
</div>
{/* 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="flex border-b border-gray-800 flex-shrink-0">
<button
className={`flex-1 py-2.5 text-xs font-semibold transition-colors ${rightTab === 'annotation' ? 'text-white border-b-2 border-blue-500' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setRightTab('annotation')}
>
</button>
<button
className={`flex-1 py-2.5 text-xs font-semibold transition-colors ${rightTab === 'geo' ? 'text-white border-b-2 border-blue-500' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setRightTab('geo')}
>
</button>
<button
className={`flex-1 py-2.5 text-xs font-semibold transition-colors ${rightTab === 'station' ? 'text-white border-b-2 border-yellow-500' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setRightTab('station')}
>
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{rightTab === 'annotation' && (
<AnnotationPanel
annotations={annotations}
currentTime={currentTime}
onSeek={handleSeek}
onDelete={remove}
onExport={handleExport}
/>
)}
{rightTab === 'geo' && (
<GeoSearch
currentFrame={currentFrame}
fps={fps}
onSeekToFrame={(frame) => handleSeek(frame / fps)}
/>
)}
{rightTab === 'station' && (
<StationVerify
fps={fps}
onSeekToFrame={(frame) => handleSeek(frame / (fps || 30))}
/>
)}
</div>
</div>
)}
{/* Add annotation modal */}
{showModal && (
<AddAnnotationModal
currentTime={modalTime}
onAdd={handleAnnotationCreate}
onClose={() => setShowModal(false)}
/>
)}
{/* Help overlay */}
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
</div>
);
}
@@ -0,0 +1,83 @@
import React, { useState } from 'react';
import { secondsToTimecode } from '../utils/timecode';
interface Props {
currentTime: number;
onAdd: (type: 'subtitle' | 'memo', text: string, timeStart: number, timeEnd: number) => void;
onClose: () => void;
}
export default function AddAnnotationModal({ currentTime, onAdd, onClose }: Props) {
const [type, setType] = useState<'subtitle' | 'memo'>('memo');
const [text, setText] = useState('');
const [duration, setDuration] = useState(3);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) return;
onAdd(type, text.trim(), currentTime, currentTime + duration);
onClose();
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 rounded-lg p-6 w-96 shadow-xl">
<h2 className="text-lg font-semibold mb-4"> </h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1"> </label>
<div className="text-white font-mono">{secondsToTimecode(currentTime)}</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1"></label>
<select
value={type}
onChange={(e) => setType(e.target.value as 'subtitle' | 'memo')}
className="w-full bg-gray-800 text-white rounded px-3 py-2"
>
<option value="subtitle"></option>
<option value="memo"></option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1"></label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
className="w-full bg-gray-800 text-white rounded px-3 py-2 resize-none"
placeholder="주석 내용을 입력하세요"
autoFocus
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1"> ()</label>
<input
type="number"
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
min={1}
max={300}
className="w-full bg-gray-800 text-white rounded px-3 py-2"
/>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-400 hover:text-white"
>
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-white"
>
</button>
</div>
</form>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
interface State { hasError: boolean; error?: Error }
export default class ErrorBoundary extends React.Component<React.PropsWithChildren, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; }
render() {
if (this.state.hasError) return (
<div className="flex items-center justify-center h-screen bg-gray-950 text-white">
<div className="text-center p-8 max-w-lg">
<div className="text-5xl mb-4"></div>
<h2 className="text-xl font-bold mb-2"> </h2>
<p className="text-gray-400 text-sm mb-4">{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded">
</button>
</div>
</div>
);
return this.props.children;
}
}
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
interface Props { onClose: () => void }
const shortcuts = [
['Space', '재생 / 일시정지'],
['← / →', '5초 뒤로 / 앞으로'],
['J / L', '10초 뒤로 / 앞으로'],
[', / .', '이전 / 다음 프레임 (일시정지 시)'],
['[ / ]', '이전 / 다음 장면 (±30초)'],
['0 ~ 9', '10% 단위 탐색'],
['F', '전체화면 토글'],
['M', '음소거 토글'],
['+ / -', '재생 속도 증가 / 감소'],
['Shift+S', '현재 프레임 캡처'],
['Shift+M', '현재 시점에 메모 추가'],
['?', '이 도움말 열기 / 닫기'],
];
export default function HelpOverlay({ onClose }: Props) {
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-gray-900 rounded-lg p-6 w-96 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"> </h2>
<button onClick={onClose} className="text-gray-400 hover:text-white text-xl">×</button>
</div>
<table className="w-full text-sm">
<tbody className="divide-y divide-gray-800">
{shortcuts.map(([key, desc]) => (
<tr key={key} className="py-1">
<td className="py-1.5 pr-4 font-mono text-yellow-400 whitespace-nowrap">{key}</td>
<td className="py-1.5 text-gray-300">{desc}</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-gray-500 mt-4 text-center"> </p>
</div>
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
/**
* 드론 지리정보 검색 패널
* - 건물/측점명 → 해당 프레임 탐색
* - 현재 프레임 → 보이는 건물/측점 목록
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useGeoStore } from '../../store/geoStore';
import { findFramesForPoi, findPoisForFrame } from '../../utils/geoSearch';
import type { GeoPoint, FrameMatch, PoiInFrame } from '../../types/geo';
interface Props {
currentFrame: number;
fps: number;
onSeekToFrame: (frame: number) => void;
}
type Tab = 'search' | 'reverse';
export default function GeoSearch({ currentFrame, fps, onSeekToFrame }: Props) {
const [tab, setTab] = useState<Tab>('search');
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<GeoPoint[]>([]);
const [searchResult, setSearchResult] = useState<{ poi: GeoPoint; frames: FrameMatch[] } | null>(null);
const [reverseResult, setReverseResult] = useState<PoiInFrame[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
const loaded = useGeoStore(s => s.loaded);
const frames = useGeoStore(s => s.frames);
const pois = useGeoStore(s => s.pois);
const stations = useGeoStore(s => s.stations);
// POI 목록 (자동완성용): 측점 + 건물 통합 (서버 /api/geo/pois 동치)
const allPois = React.useMemo<GeoPoint[]>(
() => (loaded ? [...stations, ...pois] : []),
[loaded, stations, pois],
);
// 자동완성 필터링
useEffect(() => {
if (!query.trim()) { setSuggestions([]); return; }
const q = query.toLowerCase();
setSuggestions(allPois.filter(p => p.title.toLowerCase().includes(q)).slice(0, 10));
}, [query, allPois]);
// 건물/측점명으로 프레임 검색 (클라이언트 검색 — 서버 /api/geo/search 대체)
const handleSearch = useCallback((q?: string) => {
const searchQ = (q ?? query).trim();
if (!searchQ) return;
setLoading(true);
setError('');
setSuggestions([]);
try {
if (!loaded) { setError('폴더를 먼저 선택하세요'); setSearchResult(null); return; }
const origin = useGeoStore.getState().origin;
const combined = [...stations, ...pois];
const result = findFramesForPoi(frames, combined, searchQ, 1.0, 1500, 0, origin);
if (!result.poi) { setError('일치하는 건물/측점 없음'); setSearchResult(null); return; }
setSearchResult({ poi: result.poi, frames: result.frames });
} finally {
setLoading(false);
}
}, [query, loaded, frames, stations, pois]);
// 현재 프레임 역조회 (클라이언트 검색 — 서버 /api/geo/frame/{n} 대체)
const handleReverse = useCallback(() => {
setLoading(true);
setError('');
try {
if (!loaded) { setReverseResult([]); return; }
const origin = useGeoStore.getState().origin;
const combined = [...stations, ...pois];
const result = findPoisForFrame(frames, combined, currentFrame, 1.0, 0, origin);
setReverseResult(result.pois);
} finally {
setLoading(false);
}
}, [currentFrame, loaded, frames, stations, pois]);
// 탭 전환/프레임 변경/데이터 로드 시 역조회 자동 실행
useEffect(() => {
if (tab === 'reverse') handleReverse();
}, [tab, currentFrame, handleReverse]);
const formatDist = (m: number) =>
m >= 1000 ? `${(m / 1000).toFixed(2)}km` : `${Math.round(m)}m`;
const formatAngle = (deg: number) =>
`${deg >= 0 ? '+' : ''}${deg.toFixed(1)}°`;
return (
<div className="flex flex-col h-full text-sm">
{/* 탭 */}
<div className="flex border-b border-gray-700 flex-shrink-0">
<button
className={`flex-1 py-2 text-xs font-medium transition-colors ${tab === 'search' ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setTab('search')}
>
</button>
<button
className={`flex-1 py-2 text-xs font-medium transition-colors ${tab === 'reverse' ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setTab('reverse')}
>
</button>
</div>
{/* 검색 탭 */}
{tab === 'search' && (
<div className="flex flex-col h-full min-h-0">
<div className="p-2 flex-shrink-0 relative">
<div className="flex gap-1">
<input
className="flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
placeholder="건물명 또는 측점번호..."
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
/>
<button
onClick={() => handleSearch()}
disabled={loading}
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 rounded text-xs transition-colors"
>
{loading ? '…' : '검색'}
</button>
</div>
{/* 자동완성 */}
{suggestions.length > 0 && (
<div className="absolute left-2 right-2 mt-0.5 bg-gray-800 border border-gray-600 rounded shadow-lg z-50 max-h-48 overflow-y-auto">
{suggestions.map((s, i) => (
<button
key={i}
className="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-xs text-white flex items-center gap-2"
onClick={() => { setQuery(s.title); handleSearch(s.title); }}
>
<span className={`text-xs px-1 rounded ${s.type === 'station' ? 'bg-green-800 text-green-300' : 'bg-blue-900 text-blue-300'}`}>
{s.type === 'station' ? '측점' : s.category || 'POI'}
</span>
{s.title}
</button>
))}
</div>
)}
</div>
{error && <div className="px-3 py-1 text-xs text-red-400">{error}</div>}
{searchResult && (
<div className="flex-1 overflow-y-auto min-h-0">
{/* POI 정보 */}
<div className="px-3 py-2 bg-gray-800/50 border-b border-gray-700 flex-shrink-0">
<div className="text-xs font-semibold text-white">{searchResult.poi.title}</div>
<div className="text-xs text-gray-400 mt-0.5">
{searchResult.poi.category} · {searchResult.poi.z.toFixed(1)}m
</div>
<div className="text-xs text-gray-500">
{searchResult.poi.lat.toFixed(6)}, {searchResult.poi.lon.toFixed(6)}
</div>
<div className="text-xs text-blue-400 mt-1">
{searchResult.frames.length}
</div>
</div>
{searchResult.frames.length === 0 && (
<div className="text-xs text-gray-500 p-3 text-center">
<br />
<button
className="mt-1 text-blue-400 hover:text-blue-300"
onClick={() => handleSearch()}
>
</button>
</div>
)}
<div className="space-y-1 p-1">
{searchResult.frames.map((fm, i) => (
<button
key={fm.frame}
className="w-full text-left px-2 py-2 rounded hover:bg-gray-700 transition-colors border border-gray-700/50"
onClick={() => onSeekToFrame(fm.frame)}
>
<div className="flex items-center justify-between">
<span className="text-xs text-white font-mono font-bold">
#{i + 1} Frame {fm.frame}
</span>
<span className="text-xs text-gray-300">{formatDist(fm.distance)}</span>
</div>
{(fm as any).groupSize > 1 && (
<div className="text-xs text-yellow-600 mt-0.5">
{(fm as any).groupStart}~{(fm as any).groupEnd} ({(fm as any).groupSize})
</div>
)}
<div className="flex gap-2 mt-0.5">
<span className="text-xs text-gray-400"> {formatAngle(fm.bearingDiff)}</span>
<span className="text-xs text-gray-400"> {formatAngle(fm.elevationDiff)}</span>
<span className="text-xs text-gray-600">
({(fm.pixelX * 100).toFixed(0)}%, {(fm.pixelY * 100).toFixed(0)}%)
</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
)}
{/* 역조회 탭 */}
{tab === 'reverse' && (
<div className="flex flex-col h-full min-h-0">
<div className="px-3 py-2 bg-gray-800/50 border-b border-gray-700 flex-shrink-0 flex items-center justify-between">
<div>
<span className="text-xs text-gray-400"> : </span>
<span className="text-xs text-white font-mono">#{currentFrame}</span>
</div>
<button
onClick={handleReverse}
disabled={loading}
className="text-xs text-blue-400 hover:text-blue-300 disabled:text-gray-600"
>
{loading ? '조회 중…' : '새로고침'}
</button>
</div>
{error && <div className="px-3 py-1 text-xs text-red-400">{error}</div>}
<div className="flex-1 overflow-y-auto min-h-0">
{reverseResult && reverseResult.length === 0 && (
<div className="text-xs text-gray-500 p-3 text-center">
/
</div>
)}
{reverseResult && reverseResult.length > 0 && (
<div className="space-y-0.5 p-1">
{reverseResult.map((item, i) => (
<div key={i} className="px-2 py-1.5 rounded bg-gray-800/30 hover:bg-gray-800/60">
<div className="flex items-center justify-between">
<span className="text-xs text-white">{item.poi.title}</span>
<span className="text-xs text-gray-400">{formatDist(item.distance)}</span>
</div>
<div className="flex gap-2 mt-0.5 items-center">
<span className={`text-xs px-1 rounded ${item.poi.type === 'station' ? 'bg-green-800 text-green-300' : 'bg-blue-900 text-blue-300'}`}>
{item.poi.type === 'station' ? '측점' : item.poi.category}
</span>
<span className="text-xs text-gray-500">
{formatAngle(item.bearingDiff)} / {formatAngle(item.elevationDiff)}
</span>
</div>
<div className="text-xs text-gray-600 mt-0.5">
({(item.pixelX * 100).toFixed(0)}%, {(item.pixelY * 100).toFixed(0)}%)
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
/**
* 측점 검증 패널
* - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
*
* 데이터 소스: 클라이언트 geoStore(폴더 선택으로 파싱). 폴더 미선택 시 빈 상태.
*/
import React, { useState } from 'react';
import { useGeoStore } from '../../store/geoStore';
import { findFramesForPoi } from '../../utils/geoSearch';
import type { GeoPoint, FrameMatch } from '../../types/geo';
interface StationResult {
frames: FrameMatch[];
poi: GeoPoint;
}
interface Props {
fps: number;
onSeekToFrame: (frame: number) => void;
}
export default function StationVerify({ fps, onSeekToFrame }: Props) {
// stations 는 이미 stationOrder 로 정렬되어 있다.
const stations = useGeoStore(s => s.stations);
const [selected, setSelected] = useState<string | null>(null);
const [result, setResult] = useState<StationResult | null>(null);
const [seekedFrame, setSeekedFrame] = useState<number | null>(null);
// 드론 GPS 가 측점에 가장 가까운 프레임 (StationBar 배지·실제 위치와 동일 기준).
const nearestFrameForStation = (st: GeoPoint): number | null => {
const fr = useGeoStore.getState().frames;
if (!fr.length) return null;
let best = fr[0];
let bd = (fr[0].lat - st.lat) ** 2 + (fr[0].lon - st.lon) ** 2;
for (const f of fr) {
const d = (f.lat - st.lat) ** 2 + (f.lon - st.lon) ** 2;
if (d < bd) { bd = d; best = f; }
}
return best.frame;
};
const handleClick = (station: GeoPoint) => {
setSelected(station.title);
setResult(null);
setSeekedFrame(null);
// 영상은 드론 GPS 가 그 측점에 가장 가까운 프레임으로 이동한다.
// (카메라 FOV 검색은 앞을 보는 카메라 특성상 ~200m 앞쪽으로 치우쳐 위치가 어긋남)
const gpsFrame = nearestFrameForStation(station);
if (gpsFrame != null) {
onSeekToFrame(gpsFrame);
setSeekedFrame(gpsFrame);
}
// 검증 정보(카메라 시야 프레임/투영)는 참고용으로 표시.
const { frames, pois, stations: sts, origin } = useGeoStore.getState();
const { poi, frames: matches } = findFramesForPoi(
frames,
[...sts, ...pois],
station.title,
1.2,
2000,
0,
origin,
);
setResult({ frames: matches, poi: poi ?? station });
};
const pixelQuality = (px: number, py: number) => {
const dx = Math.abs(px - 0.5);
const dy = Math.abs(py - 0.5);
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 0.15) return 'text-green-400';
if (dist < 0.35) return 'text-yellow-400';
return 'text-orange-400';
};
return (
<div className="flex flex-col h-full text-sm">
<div className="px-3 py-2 bg-gray-800/50 border-b border-gray-700 flex-shrink-0">
<div className="text-xs text-gray-400"> + </div>
<div className="text-xs text-gray-600 mt-0.5">{stations.length} </div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{/* 선택된 측점 결과 */}
{selected && (
<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>
{result && result.frames.length === 0 && (
<div className="text-xs text-red-400 mt-1"> </div>
)}
{result && result.frames.length > 0 && (() => {
const f = result.frames[0];
return (
<>
<div className="text-xs text-gray-300 mt-1">
F{f.frame} · {f.distance >= 1000 ? `${(f.distance/1000).toFixed(2)}km` : `${Math.round(f.distance)}m`}
</div>
<div className={`text-xs mt-0.5 font-mono ${pixelQuality(f.pixelX, f.pixelY)}`}>
({(f.pixelX * 100).toFixed(0)}%, {(f.pixelY * 100).toFixed(0)}%)
&nbsp; {f.bearingDiff >= 0 ? '+' : ''}{f.bearingDiff.toFixed(1)}°
</div>
{result.frames.length > 1 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{result.frames.slice(1).map((fm, i) => (
<button
key={fm.frame}
className="text-[10px] px-1.5 py-0.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
onClick={() => { onSeekToFrame(fm.frame); setSeekedFrame(fm.frame); }}
>
#{i + 2} F{fm.frame}
</button>
))}
</div>
)}
</>
);
})()}
</div>
)}
{/* 측점 목록 */}
<div className="space-y-px px-1 pb-2">
{stations.map(st => (
<button
key={st.title}
onClick={() => handleClick(st)}
className={`w-full text-left px-2 py-2 rounded transition-colors flex items-center justify-between ${
selected === st.title
? 'bg-yellow-500/20 border border-yellow-500/50'
: 'hover:bg-gray-800 border border-transparent'
}`}
>
<span className={`text-xs font-mono font-bold ${selected === st.title ? 'text-yellow-400' : 'text-gray-200'}`}>
{st.title}
</span>
<span className="text-[10px] text-gray-600">
{st.z.toFixed(0)}m
</span>
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
import React, { useEffect, useRef } from 'react';
import interact from 'interactjs';
import type { Annotation } from '@abcvideo/shared';
interface Props {
annotations: Annotation[];
currentTime: number;
onUpdate: (id: string, pos: { x: number; y: number }) => void;
onDelete: (id: string) => void;
containerRef: React.RefObject<HTMLElement | null>;
}
function isVisible(a: Annotation, t: number): boolean {
return a.type === 'memo' && t >= a.timeStart && t <= a.timeEnd;
}
export default function MemoOverlay({
annotations,
currentTime,
onUpdate,
onDelete,
}: Props) {
const visible = annotations.filter((a) => isVisible(a, currentTime));
return (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{visible.map((a) => (
<MemoItem
key={a.id}
annotation={a}
onUpdate={onUpdate}
onDelete={onDelete}
/>
))}
</div>
);
}
function MemoItem({
annotation: a,
onUpdate,
onDelete,
}: {
annotation: Annotation;
onUpdate: (id: string, pos: { x: number; y: number }) => void;
onDelete: (id: string) => void;
}) {
const elRef = useRef<HTMLDivElement>(null);
// Track cumulative pixel offset from initial position
const offsetRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const el = elRef.current;
if (!el) return;
// Reset pixel offset when annotation changes position externally
offsetRef.current = { x: 0, y: 0 };
el.style.transform = 'translate(0px, 0px)';
const interactable = interact(el).draggable({
listeners: {
move(event) {
const parent = el.parentElement;
if (!parent) return;
const pw = parent.offsetWidth;
const ph = parent.offsetHeight;
offsetRef.current.x += event.dx;
offsetRef.current.y += event.dy;
el.style.transform = `translate(${offsetRef.current.x}px, ${offsetRef.current.y}px)`;
const newX = Math.max(0, Math.min(100, a.position.x + (offsetRef.current.x / pw) * 100));
const newY = Math.max(0, Math.min(100, a.position.y + (offsetRef.current.y / ph) * 100));
onUpdate(a.id, { x: newX, y: newY });
},
},
});
return () => interactable.unset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [a.id]);
return (
<div
ref={elRef}
className="absolute pointer-events-auto cursor-move select-none"
style={{
left: `${a.position.x}%`,
top: `${a.position.y}%`,
willChange: 'transform',
}}
>
<div
className="rounded px-2 py-1 text-sm max-w-xs shadow-lg"
style={{
backgroundColor: a.style.backgroundColor ?? 'rgba(0,0,0,0.75)',
color: a.style.color ?? '#ffffff',
fontSize: `${a.style.fontSize ?? 14}px`,
}}
>
<div className="flex items-start gap-1">
<span className="flex-1">{a.text}</span>
<button
onClick={() => onDelete(a.id)}
className="text-gray-400 hover:text-white text-xs leading-none ml-1 flex-shrink-0"
>
x
</button>
</div>
</div>
</div>
);
}
@@ -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;
}
@@ -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>
);
}
@@ -0,0 +1,363 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import {
toCameraCoords,
pixelFromCamera,
DEFAULT_CAMERA_PARAMS,
} from '../../utils/geoProjection';
import { useGeoStore } from '../../store/geoStore';
import { useSettingsStore, isGradeVisible } from '../../store/settingsStore';
import type { GeoPoint } from '../../types/geo';
interface RoutePanelProps {
currentTime: number;
visible: boolean;
onSeek: (time: number) => void;
/** 상단 침범 방지: 컨테이너 top(px). 기본 위쪽(배너/카메라파라미터) 아래. */
topPx?: number;
/** 하단 침범 방지: 컨테이너 bottom(px). 기본 아래쪽(배속/토글/재생바) 위. */
bottomPx?: number;
}
const VIDEO_FPS = 30000 / 1001;
const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();
function stationKm(title: string): number {
const m = title.match(/(\d+)[Kk](\d+)/);
if (!m) return -1;
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> = {
'\uD130\uB110': '\uD83D\uDE87',
'\uAD50\uB7C9': '\uD83C\uDF09',
'\uC5ED\uC0AC': '\uD83D\uDE89',
'\uC9C0\uC7A5\uBB3C': '\uD83C\uDFE2',
'\uCE21\uC810': '\uD83D\uDCCD',
};
function poiKm(poi: GeoPoint, stations: GeoPoint[]): number {
if (!stations.length) return -1;
const sorted = [...stations]
.map(st => ({
st,
d: (poi.lat - st.lat) ** 2 + (poi.lon - st.lon) ** 2,
}))
.sort((a, b) => a.d - b.d);
const a = sorted[0], b = sorted[1];
if (!b || b.d === 0) return stationKm(a.st.title);
const ka = stationKm(a.st.title), kb = stationKm(b.st.title);
if (ka < 0 || kb < 0) return ka >= 0 ? ka : kb;
const t = a.d / (a.d + b.d);
return Math.round(ka + (kb - ka) * t);
}
export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, bottomPx = 200 }: RoutePanelProps) {
// 지리정보는 클라이언트 geoStore(폴더 선택 파싱 결과)에서 직접 읽는다.
// 서버 /api/geo/* fetch 대체. 폴더 미선택 시 빈 배열 → idle 렌더.
const loaded = useGeoStore(s => s.loaded);
const stations = useGeoStore(s => s.stations);
const pois = useGeoStore(s => s.pois);
const structures = useGeoStore(s => s.structures);
const gradeFilter = useSettingsStore(s => s.gradeFilter);
const droneFrames = useGeoStore(s => s.frames);
const routeMeta = useGeoStore(s => s.routeMeta);
const [currentKm, setCurrentKm] = useState(0);
const [currentStationTitle, setCurrentStationTitle] = useState('');
const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null);
const [routeStartTitle, setRouteStartTitle] = useState('');
const [routeEndTitle, setRouteEndTitle] = useState('');
const panelRef = useRef<HTMLDivElement>(null);
const [dragYPct, setDragYPct] = useState(0);
const [dragging, setDragging] = useState(false);
// POI/측점/드론 프레임은 위 geoStore 셀렉터로 구독한다(폴더 데이터 도착 시 자동 재렌더).
// 시점/종점: 역사(category=역사) POI 중 km 최소/최대
useEffect(() => {
if (!stations.length || !pois.length) return;
const validSt = stations.filter(s => stationKm(s.title) >= 0);
if (!validSt.length) return;
const stationPois = pois.filter(p => p.category === '\uC5ED\uC0AC' || p.category === '\uCCA0\uB3C4\uC5ED'); // 역사
if (!stationPois.length) return;
let minKmPoi = stationPois[0], maxKmPoi = stationPois[0];
let minK = poiKm(stationPois[0], validSt), maxK = minK;
for (let i = 1; i < stationPois.length; i++) {
const k = poiKm(stationPois[i], validSt);
if (k >= 0 && k < minK) { minK = k; minKmPoi = stationPois[i]; }
if (k >= 0 && k > maxK) { maxK = k; maxKmPoi = stationPois[i]; }
}
setRouteStartTitle(minKmPoi.title);
setRouteEndTitle(maxKmPoi.title);
}, [stations, pois]);
// Update current km and visible range based on currentTime
useEffect(() => {
const frames = droneFrames;
if (!frames.length || !stations.length) return;
// Find closest frame by time
const targetFrame = Math.round(currentTime * VIDEO_FPS);
let closest = frames[0];
let closestDist = Math.abs(closest.frame - targetFrame);
for (let i = 1; i < frames.length; i++) {
const d = Math.abs(frames[i].frame - targetFrame);
if (d < closestDist) {
closest = frames[i];
closestDist = d;
}
}
// Find nearest station to current drone position
const validStations = stations.filter(s => stationKm(s.title) >= 0);
if (!validStations.length) return;
let nearestStation = validStations[0];
let nearestDist = (closest.lat - nearestStation.lat) ** 2 + (closest.lon - nearestStation.lon) ** 2;
for (let i = 1; i < validStations.length; i++) {
const d = (closest.lat - validStations[i].lat) ** 2 + (closest.lon - validStations[i].lon) ** 2;
if (d < nearestDist) {
nearestStation = validStations[i];
nearestDist = d;
}
}
setCurrentStationTitle(nearestStation.title);
// 현재 km = 측점 폴리라인 투영 연속 체이니지 (재생바 StationBar 와 동일 기준).
const sorted = [...validStations].sort((a, b) => stationKm(a.title) - stationKm(b.title));
const lat0 = sorted.reduce((s, p) => s + p.lat, 0) / sorted.length;
const k = Math.cos((lat0 * Math.PI) / 180) * 111000;
const pts = sorted.map(p => ({ x: p.lon * k, y: p.lat * 111000, km: stationKm(p.title) }));
const cpx = closest.lon * k, cpy = closest.lat * 111000;
let bestD = Infinity, bestKm = pts.length ? pts[0].km : 0;
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
const dx = b.x - a.x, dy = b.y - a.y;
const L2 = dx * dx + dy * dy;
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((cpx - a.x) * dx + (cpy - a.y) * dy) / L2));
const ex = a.x + dx * t, ey = a.y + dy * t;
const dd = (cpx - ex) ** 2 + (cpy - ey) ** 2;
if (dd < bestD) { bestD = dd; bestKm = a.km + (b.km - a.km) * t; }
}
setCurrentKm(bestKm);
// Calculate visible range (green box)
const allPoints = [...validStations, ...pois];
const visibleKms: number[] = [];
for (const pt of allPoints) {
const cc = toCameraCoords(closest, pt.lat, pt.lon, pt.z, DEFAULT_CAMERA_PARAMS);
if (cc.Zc <= 0) continue;
const { pyRaw } = pixelFromCamera(cc, DEFAULT_CAMERA_PARAMS);
if (pyRaw >= 0.0 && pyRaw <= 1.0) {
const km = pt.type === 'station' ? stationKm(pt.title) : poiKm(pt, validStations);
if (km >= 0) visibleKms.push(km);
}
}
if (visibleKms.length >= 2) {
setVisibleRange({
minKm: Math.min(...visibleKms),
maxKm: Math.max(...visibleKms),
});
} else {
setVisibleRange(null);
}
}, [currentTime, droneFrames, stations, pois]);
// Drag handling
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setDragging(true);
}, []);
useEffect(() => {
if (!dragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
setDragYPct(y * 100);
};
const handleMouseUp = (e: MouseEvent) => {
setDragging(false);
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
const yPct = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
const validStations = stations.filter(s => stationKm(s.title) >= 0);
if (validStations.length < 2) return;
const allKms = [
...validStations.map(s => stationKm(s.title)),
...pois.map(p => poiKm(p, validStations)).filter(k => k >= 0),
];
const minK = Math.min(...allKms);
const maxK = Math.max(...allKms);
const targetKm = maxK - yPct * (maxK - minK);
// Find closest station to target km
let bestStation = validStations[0];
let bestDiff = Math.abs(stationKm(bestStation.title) - targetKm);
for (let i = 1; i < validStations.length; i++) {
const diff = Math.abs(stationKm(validStations[i].title) - targetKm);
if (diff < bestDiff) {
bestStation = validStations[i];
bestDiff = diff;
}
}
// Find closest drone frame to that station's lat/lon
const frames = droneFrames;
if (!frames.length) return;
let bestFrame = frames[0];
let bestFrameDist = (bestFrame.lat - bestStation.lat) ** 2 + (bestFrame.lon - bestStation.lon) ** 2;
for (let i = 1; i < frames.length; i++) {
const d = (frames[i].lat - bestStation.lat) ** 2 + (frames[i].lon - bestStation.lon) ** 2;
if (d < bestFrameDist) {
bestFrame = frames[i];
bestFrameDist = d;
}
}
onSeek(bestFrame.frame / VIDEO_FPS);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragging, stations, pois, droneFrames, onSeek]);
// Render guard — 폴더 미선택(!loaded) 또는 측점 없음이면 idle(미표시)
if (!visible || !loaded || stations.length === 0) return null;
const validStations = stations.filter(s => stationKm(s.title) >= 0);
if (validStations.length < 2) return null;
const allKms = [
...validStations.map(s => stationKm(s.title)),
...pois.map(p => poiKm(p, validStations)).filter(k => k >= 0),
];
const minKm = Math.min(...allKms);
const maxKm = Math.max(...allKms);
const kmToY = (km: number) => (1 - (km - minKm) / (maxKm - minKm)) * 100;
// 교량/터널만 표시 + 시설종별 필터(설정 스토어, 재생바와 동일 규칙).
const filteredPois = structures
.filter(s => (s.type === 'bridge' || s.type === 'tunnel') && typeof s.lat === 'number' && typeof s.lon === 'number' && isGradeVisible(s.grade, gradeFilter))
.map(s => ({ title: s.name, category: s.type === 'tunnel' ? '\uD130\uB110' : '\uAD50\uB7C9', lat: s.lat as number, lon: s.lon as number, z: 0, type: 'poi' as const }));
return (
<div
ref={panelRef}
className="absolute w-28 border border-white/20 rounded-md z-30"
style={{ left: 8, top: topPx, bottom: bottomPx, background: 'rgba(0,0,0,0.6)' }}
>
{/* Center vertical line */}
<div
className="absolute"
style={{ left: 38, width: 2, top: 22, bottom: 22, background: 'rgba(255,255,255,0.5)' }}
/>
{/* 높은 km 역명 — 상단 (대전조차장) */}
<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 }} />
<span className="text-[11px] text-white/90 font-semibold truncate">{routeMeta?.routeInfo?.endStationName || cleanTitle(routeEndTitle)}</span>
</div>
{/* 낮은 km 역명 — 하단 (회덕) */}
<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 }} />
<span className="text-[11px] text-white/90 font-semibold truncate">{routeMeta?.routeInfo?.startStationName || cleanTitle(routeStartTitle)}</span>
</div>
{/* 교량/터널 POIs — 겹침 방지: Y 간격 7% 미만이면 건너뜀 */}
{(() => {
const MIN_GAP = 9; // %
const placed: number[] = [];
return filteredPois.map((poi, i) => {
const km = poiKm(poi, validStations);
if (km < 0) return null;
const y = kmToY(km);
if (y < 5 || y > 95) return null;
if (placed.some(py => Math.abs(py - y) < MIN_GAP)) return null;
placed.push(y);
return (
<div
key={`poi-${i}`}
className="absolute flex items-center pointer-events-none"
style={{ top: `${y}%`, transform: 'translateY(-50%)', left: 0, right: 0 }}
>
<div style={{ position: 'absolute', left: 30, width: 16, display: 'flex', justifyContent: 'center' }}>
<div
className="w-3 h-3 rounded-sm"
style={{ background: poi.category === '\uD130\uB110' ? '#818cf8' : '#38bdf8' }}
/>
</div>
<div
className="text-[10px] truncate font-medium"
style={{
position: 'absolute', left: 48, right: 2,
color: poi.category === '\uD130\uB110' ? '#c7d2fe' : '#bae6fd',
}}
>
{cleanTitle(poi.title)}
</div>
</div>
);
});
})()}
{/* Green visible range box */}
{visibleRange && (
<div
className="absolute pointer-events-none"
style={{
left: 30,
right: 4,
top: `${kmToY(visibleRange.maxKm)}%`,
bottom: `${100 - kmToY(visibleRange.minKm)}%`,
border: '1px solid rgba(74,222,128,0.7)',
background: 'rgba(74,222,128,0.08)',
}}
/>
)}
{/* Orange current position marker */}
<div
className="absolute left-0 right-0 flex items-center cursor-grab z-10"
style={{
top: `${dragging ? dragYPct : kmToY(currentKm)}%`,
transform: 'translateY(-50%)',
}}
onMouseDown={handleMouseDown}
>
<div
style={{
position: 'absolute',
left: 30,
width: 12,
height: 12,
borderRadius: '50%',
background: '#f97316',
border: '2px solid white',
transform: 'translateX(-50%)',
}}
/>
<div
style={{ position: 'absolute', left: 44 }}
className="bg-orange-500 text-white text-[11px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap"
>
{formatKm10(currentKm)}
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
import React from 'react';
interface Props { onCapture: () => void; }
export default function FrameCaptureButton({ onCapture }: Props) {
return (
<button
onClick={onCapture}
title="현재 프레임 캡처 (Shift+S)"
className="bg-gray-700 hover:bg-gray-600 text-white text-sm px-3 py-1.5 rounded"
>
</button>
);
}
@@ -0,0 +1,63 @@
import React, { useState } from 'react';
interface Props {
videoId: string;
onConversionDone: () => void;
}
export default function HlsConversionStatus({ videoId, onConversionDone }: Props) {
const [status, setStatus] = useState<'idle' | 'converting' | 'done' | 'error'>('idle');
const [percent, setPercent] = useState(0);
const startConversion = async () => {
setStatus('converting');
await fetch(`/api/hls/${videoId}/convert`, { method: 'POST' });
const es = new EventSource(`/api/hls/${videoId}/progress`);
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
setPercent(Math.round(data.percent ?? 0));
setStatus(data.status);
if (data.status === 'done') {
es.close();
onConversionDone();
} else if (data.status === 'error') {
es.close();
}
} catch {
// ignore parse errors
}
};
es.onerror = () => {
es.close();
setStatus('error');
};
};
return (
<div className="flex items-center gap-2 text-sm">
{status === 'idle' && (
<button
onClick={startConversion}
className="bg-green-700 hover:bg-green-600 text-white px-3 py-1.5 rounded"
>
HLS
</button>
)}
{status === 'converting' && (
<div className="flex items-center gap-2">
<div className="w-24 bg-gray-700 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all"
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-gray-400">{percent}%</span>
</div>
)}
{status === 'done' && <span className="text-green-400">HLS </span>}
{status === 'error' && <span className="text-red-400"> </span>}
</div>
);
}
@@ -0,0 +1,458 @@
import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect } from 'react';
import StationOverlay from '../overlay/StationOverlay';
import RoutePanel from '../overlay/RoutePanel';
import RouteInfoOverlay from '../overlay/RouteInfoOverlay';
import 'video.js/dist/video-js.css';
import { useVideoPlayer } from '../../hooks/useVideoPlayer';
import { useFrameStep } from '../../hooks/useFrameStep';
import { useKeyboard } from '../../hooks/useKeyboard';
import { usePlayerStore } from '../../store/playerStore';
import { useGeoStore } from '../../store/geoStore';
import { useSettingsStore, KNOWN_GRADES } from '../../store/settingsStore';
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
import { useCaptureStore } from '../../store/captureStore';
import HlsConversionStatus from './HlsConversionStatus';
import { StationBar } from '../../stationbar/StationBar';
export interface VideoPlayerHandle {
loadLocalFile: (file: File) => void;
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
seekTo: (time: number) => void;
getVideoElement: () => HTMLVideoElement | null;
}
interface VideoPlayerProps {
onAddMemo: (time: number) => void;
onToggleHelp?: () => void;
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
function VideoPlayer({ onAddMemo, onToggleHelp }, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement } =
useVideoPlayer(containerRef);
const { stepForward, stepBackward, fps } = useFrameStep(playerRef);
const { currentTime, duration, playing, source, playbackRate, videoReady, videoWidth, videoHeight } = 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);
// 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어.
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
const setGradeFilter = useSettingsStore((s) => s.setGradeFilter);
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
const setPoiOverlapExclude = useSettingsStore((s) => s.setPoiOverlapExclude);
// 하단 도구 패널: UI에서 숨김(코드는 보존). 다시 표시하려면 true 로.
const SHOW_TOOLBAR = false;
// 폴더 선택: 지리정보 파싱 + 영상 재생
const handleSelectFolder = async (files: File[]) => {
if (!files.length) return;
try {
const videoFile = await loadFromFolder(files);
if (videoFile) loadLocalFile(videoFile);
else console.warn('[geo] 폴더에 영상 파일(mp4/webm)이 없습니다 — 지리정보만 로드');
} catch (err) {
console.error('[geo] 폴더 로드 실패', err);
}
};
// Expose methods to parent via ref
useImperativeHandle(ref, () => ({
loadLocalFile,
loadServerStream,
seekTo: (time: number) => {
playerRef.current?.currentTime(time);
},
getVideoElement,
}));
const addCapture = useCaptureStore((s) => s.addCapture);
const handleCaptureFrame = () => {
const video = getVideoElement();
if (!video) return;
const dataUrl = captureFrame(video);
if (!dataUrl) return;
const filename = `frame_${secondsToTimecode(currentTime).replace(/[:.]/g, '-')}.jpg`;
downloadDataUrl(dataUrl, filename);
addCapture({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
dataUrl,
time: currentTime,
filename,
createdAt: Date.now(),
});
};
const handleAddMemo = () => onAddMemo(currentTime);
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 p = playerRef.current;
if (!p) return;
if (playing) p.pause();
else void p.play();
};
const handleStop = (): void => {
const p = playerRef.current;
if (!p) return;
p.pause();
p.currentTime(0);
};
const handleSeek = (t: number): void => {
playerRef.current?.currentTime(t);
};
useKeyboard({
playerRef,
onStepForward: stepForward,
onStepBackward: stepBackward,
onCaptureFrame: handleCaptureFrame,
onAddMemo: handleAddMemo,
onToggleHelp,
containerRef: wrapperRef,
});
// Drag and drop local video file
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file?.type.startsWith('video/')) loadLocalFile(file);
};
// VIDEO_FPS: 영상 실제 fps (29.97 = 30000/1001). Python SRT FrameCnt 기준과 일치.
// stableFps(VFC 감지)는 31fps 오감지가 있어 프레임 번호 표시에는 사용하지 않음.
const VIDEO_FPS = 30000 / 1001;
const frame = secondsToFrame(currentTime, VIDEO_FPS);
const videoId = source?.kind === 'server' ? source.videoId : null;
return (
<div
ref={wrapperRef}
className="relative bg-black w-full h-full"
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
{/* 영상 영역 — 컨테이너 전체를 채우는 relative 래퍼 (사이니지: 화면 가득) */}
<div className="relative w-full h-full">
{/* Video.js container — 영상이 영역을 꽉 채우는 베이스 레이어 (object-fit:cover) */}
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
<div
data-vjs-player
ref={containerRef}
className="absolute inset-0 w-full h-full"
style={{ cursor: source ? 'pointer' : 'default' }}
onClick={() => {
if (source) handleTogglePlay();
}}
/>
{/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */}
<RouteInfoOverlay />
{/* 좌하단 그룹(재생바 위): 위→아래 = 배속 → 토글(측점선/영상제어) + 프레임정보 */}
{source && (
<div
className="absolute left-2 z-30 flex flex-col items-start gap-2"
style={{ bottom: (barHeight || 130) + 8 }}
>
{/* 재생 배속 (영상제어 ON) */}
{showVideoControls && (
<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>
{[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>
)}
{/* 한 줄(좌→우): 영상제어 토글 → 프레임표시 패널 → POI옵션 패널 → 시설등급 패널 */}
<div className="flex items-center gap-2 pointer-events-auto flex-wrap">
<button
type="button"
onClick={() => setShowVideoControls((v) => !v)}
className={`text-xs px-2 py-1 rounded border font-semibold ${showVideoControls ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
> {showVideoControls ? 'ON' : 'OFF'}</button>
{showVideoControls && (
<>
{/* 영상 프레임 표시 패널 */}
<span className="bg-black/70 text-white text-xs px-2 py-1 rounded font-mono">
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
</span>
{geoLoaded && (
<>
{/* POI 옵션 패널 (시설등급 왼쪽) — 겹침제외 토글 */}
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs">POI </span>
<button
type="button"
onClick={() => setPoiOverlapExclude(!poiOverlapExclude)}
title="ON=겹친 POI 숨김 / OFF=모든 POI 표시"
className={`text-xs px-2 py-0.5 rounded border font-semibold ${poiOverlapExclude ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
> {poiOverlapExclude ? 'ON' : 'OFF'}</button>
</div>
{/* 시설등급 패널 (프레임표시 우측) */}
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs"></span>
{KNOWN_GRADES.map((g) => (
<label
key={g}
className="flex items-center gap-1 text-xs text-gray-200 cursor-pointer select-none"
>
<input
type="checkbox"
className="accent-blue-500"
checked={gradeFilter[g] ?? false}
onChange={(e) => setGradeFilter(g, e.target.checked)}
/>
{g}
</label>
))}
</div>
</>
)}
</>
)}
</div>
</div>
)}
{/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */}
<RoutePanel
currentTime={currentTime}
visible={showStations}
onSeek={(time) => playerRef.current?.currentTime(time)}
topPx={paramTop + 40}
bottomPx={(barHeight || 130) + 90}
/>
</div>
{/* Empty state placeholder — 가운데 반투명 폴더 선택 */}
{!source && (
<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 opacity-60">&#9654;</div>
<p className="text-lg"> </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>
)}
{/* 측점 오버레이 */}
<StationOverlay
currentFrame={frame}
currentTime={currentTime}
timeRef={smoothTimeRef}
fps={fps}
visible={showStations}
videoReady={videoReady}
videoWidth={videoWidth}
videoHeight={videoHeight}
onTogglePlay={handleTogglePlay}
showPanel={showVideoControls && !!source}
topPx={paramTop}
/>
{/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */}
{geoLoaded && (
<div ref={barWrapRef} className="absolute bottom-0 left-0 right-0 z-20">
<StationBar
currentTime={smoothTime}
timeRef={smoothTimeRef}
duration={duration}
playing={playing}
onTogglePlay={handleTogglePlay}
onStop={handleStop}
onCapture={handleCaptureFrame}
onSeek={handleSeek}
showStations={showStations}
onToggleStations={() => setShowStations((v) => !v)}
/>
</div>
)}
{/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) — UI 숨김(코드 보존, SHOW_TOOLBAR 로 제어) */}
{SHOW_TOOLBAR && (
<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' : ''}`}>&#9656;</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
type="file"
className="hidden"
webkitdirectory=""
directory=""
multiple
onChange={(e) => {
const files = e.target.files;
if (files?.length) void handleSelectFolder(Array.from(files));
}}
/>
</label>
{/* 프레임 직접 이동 */}
<form
onSubmit={e => {
e.preventDefault();
const input = (e.currentTarget.elements.namedItem('frameInput') as HTMLInputElement);
const frameNum = parseInt(input.value, 10);
if (!isNaN(frameNum)) {
playerRef.current?.currentTime(frameNum / VIDEO_FPS);
}
input.blur();
}}
className="flex items-center gap-1"
>
<span className="text-gray-500 text-xs">F</span>
<input
name="frameInput"
type="number"
min={0}
step={1}
placeholder="프레임"
className="w-20 bg-black/60 border border-gray-600 rounded px-1.5 py-1 text-xs text-yellow-300 font-mono
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="submit"
className="text-xs px-2 py-1 rounded border border-gray-600 bg-gray-800 text-gray-300 hover:text-white"
>
</button>
</form>
{videoId && (
<HlsConversionStatus
videoId={videoId}
onConversionDone={() => switchToHls(videoId)}
/>
)}
<span className="text-gray-500 text-xs ml-auto hidden sm:inline">
Space | / 5 | J/L 10 | ,/. | Shift+S
</span>
</div>
)}
</div>
)}
</div>
);
}
);
export default VideoPlayer;
@@ -0,0 +1,86 @@
import React, { useState } from 'react';
import type { Annotation } from '@abcvideo/shared';
import { secondsToTimecode } from '../../utils/timecode';
interface Props {
annotations: Annotation[];
currentTime: number;
onSeek: (time: number) => void;
onDelete: (id: string) => void;
onExport: (format: string) => void;
}
export default function AnnotationPanel({
annotations,
currentTime,
onSeek,
onDelete,
onExport,
}: Props) {
const [tab, setTab] = useState<'subtitle' | 'memo'>('subtitle');
const filtered = annotations.filter((a) => a.type === tab);
return (
<div className="flex flex-col h-full">
{/* Tabs */}
<div className="flex border-b border-gray-700">
{(['subtitle', 'memo'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-2 text-sm ${
tab === t ? 'text-white border-b-2 border-blue-500' : 'text-gray-400'
}`}
>
{t === 'subtitle' ? '자막' : '메모'}
</button>
))}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto divide-y divide-gray-800">
{filtered.length === 0 && (
<p className="text-gray-500 text-sm text-center py-6">
{tab === 'subtitle' ? '자막이 없습니다' : '메모가 없습니다'}
</p>
)}
{filtered.map((a) => {
const active = currentTime >= a.timeStart && currentTime <= a.timeEnd;
return (
<div
key={a.id}
className={`px-3 py-2 cursor-pointer hover:bg-gray-800 ${
active ? 'bg-gray-800 border-l-2 border-yellow-400' : ''
}`}
onClick={() => onSeek(a.timeStart)}
>
<div className="text-xs text-gray-400 font-mono">
{secondsToTimecode(a.timeStart)} {secondsToTimecode(a.timeEnd)}
</div>
<div className="text-sm text-white mt-0.5 truncate">{a.text}</div>
<button
onClick={(e) => { e.stopPropagation(); onDelete(a.id); }}
className="text-xs text-red-400 hover:text-red-300 mt-1"
>
</button>
</div>
);
})}
</div>
{/* Export buttons */}
<div className="p-2 border-t border-gray-700 flex gap-1 flex-wrap">
{['vtt', 'srt', 'json', 'csv'].map((f) => (
<button
key={f}
onClick={() => onExport(f)}
className="text-xs bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded"
>
{f.toUpperCase()}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import React from 'react';
import { useCaptureStore } from '../../store/captureStore';
import { secondsToTimecode } from '../../utils/timecode';
interface Props {
onSeek: (time: number) => void;
}
export default function CaptureList({ onSeek }: Props) {
const { captures, removeCapture, clearCaptures } = useCaptureStore();
if (captures.length === 0) {
return (
<div className="text-gray-500 text-xs p-4 text-center">
<br />
<span className="text-gray-600">Shift+S로 </span>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs text-gray-400">{captures.length}</span>
<button
onClick={clearCaptures}
className="text-xs text-gray-500 hover:text-red-400 transition-colors"
>
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-1 px-2 pb-2">
{captures.map((cap) => (
<div
key={cap.id}
className="group relative cursor-pointer rounded overflow-hidden border border-gray-700 hover:border-blue-500 transition-colors"
onClick={() => onSeek(cap.time)}
>
<img
src={cap.dataUrl}
alt={cap.filename}
className="w-full h-auto object-cover"
draggable={false}
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 px-2 py-0.5 flex items-center justify-between">
<span className="text-xs text-white font-mono">
{secondsToTimecode(cap.time)}
</span>
<button
onClick={(e) => { e.stopPropagation(); removeCapture(cap.id); }}
className="text-xs text-gray-400 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="삭제"
>
</button>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react';
import { usePlayerStore } from '../../store/playerStore';
interface VideoItem { videoId: string; filename: string; }
interface Props {
onSelect: (videoId: string, filename: string) => void;
}
export default function VideoList({ onSelect }: Props) {
const [videos, setVideos] = useState<VideoItem[]>([]);
const { source } = usePlayerStore();
const activeId = source?.kind === 'server' ? source.videoId : null;
useEffect(() => {
fetch('/api/videos')
.then((r) => r.json())
.then(setVideos)
.catch(() => {});
}, []);
if (videos.length === 0) {
return (
<div className="text-gray-500 text-sm p-4 text-center">
</div>
);
}
return (
<div className="divide-y divide-gray-800">
{videos.map((v) => (
<button
key={v.videoId}
onClick={() => onSelect(v.videoId, v.filename)}
className={`w-full text-left px-4 py-3 hover:bg-gray-800 transition-colors ${
activeId === v.videoId ? 'bg-gray-800 border-l-2 border-blue-500' : ''
}`}
>
<div className="text-sm text-white truncate">{v.filename}</div>
<div className="text-xs text-gray-500 mt-0.5">{v.videoId}</div>
</button>
))}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useCallback } from 'react';
import { useAnnotationStore } from '../store/annotationStore';
import type { Annotation, CreateAnnotationInput, UpdateAnnotationInput } from '@abcvideo/shared';
export function useAnnotations(videoId: string | null) {
const { annotations, setAnnotations, addAnnotation, updateAnnotation, removeAnnotation } =
useAnnotationStore();
useEffect(() => {
if (!videoId) { setAnnotations([]); return; }
fetch(`/api/annotations/${videoId}`)
.then((r) => r.json())
.then((data: Annotation[]) => setAnnotations(data))
.catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoId]);
const create = useCallback(
async (input: Omit<CreateAnnotationInput, 'videoId'>) => {
if (!videoId) return;
const res = await fetch(`/api/annotations/${videoId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...input, videoId }),
});
const annotation: Annotation = await res.json();
addAnnotation(annotation);
return annotation;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[videoId]
);
const update = useCallback(
async (id: string, input: UpdateAnnotationInput) => {
if (!videoId) return;
const res = await fetch(`/api/annotations/${videoId}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const annotation: Annotation = await res.json();
updateAnnotation(id, annotation);
return annotation;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[videoId]
);
const remove = useCallback(
async (id: string) => {
if (!videoId) return;
await fetch(`/api/annotations/${videoId}/${id}`, { method: 'DELETE' });
removeAnnotation(id);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[videoId]
);
return { annotations, create, update, remove };
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react';
import type Player from 'video.js/dist/types/player';
import { usePlayerStore } from '../store/playerStore';
export function useFrameStep(playerRef: React.RefObject<Player | null>) {
const detectedFps = useRef(30);
// 안정적인 fps: 재생 중 연속 2회 이상 동일 값이 나와야 확정
const [stableFps, setStableFps] = useState(30);
const lastMeasured = useRef(30);
const stableCount = useRef(0);
const store = usePlayerStore();
useEffect(() => {
const video = playerRef.current?.tech(true)?.el() as HTMLVideoElement | null;
if (!video || !('requestVideoFrameCallback' in video)) return;
let frameCount = 0;
let startTime: number | null = null;
let handle = 0;
const measure = (_now: number, meta: { mediaTime: number }) => {
if (startTime === null) {
// 첫 호출은 시작점만 기록하고 카운트하지 않음.
// 카운트에 포함하면 elapsed ≈ 1.001s에 frameCount=31이 되어 31fps로 오감지됨.
startTime = meta.mediaTime;
handle = (video as any).requestVideoFrameCallback(measure);
return;
}
frameCount++;
const elapsed = meta.mediaTime - startTime;
if (elapsed >= 1.0) {
const fps = Math.round(frameCount / elapsed);
if (fps >= 24 && fps <= 120) {
// 같은 값이 연속 2회 이상 나와야 안정된 fps로 확정
if (fps === lastMeasured.current) {
stableCount.current++;
if (stableCount.current >= 2) {
detectedFps.current = fps;
setStableFps(fps);
store.setFps(fps);
}
} else {
lastMeasured.current = fps;
stableCount.current = 1;
}
}
frameCount = 0;
startTime = null;
}
handle = (video as any).requestVideoFrameCallback(measure);
};
handle = (video as any).requestVideoFrameCallback(measure);
return () => { (video as any).cancelVideoFrameCallback(handle); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerRef.current]);
const stepForward = () => {
const player = playerRef.current;
if (!player || player.paused() === false) return;
const step = 1 / detectedFps.current;
player.currentTime((player.currentTime() ?? 0) + step);
};
const stepBackward = () => {
const player = playerRef.current;
if (!player || player.paused() === false) return;
const step = 1 / detectedFps.current;
player.currentTime(Math.max(0, (player.currentTime() ?? 0) - step));
};
return { stepForward, stepBackward, fps: stableFps };
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useCallback } from 'react';
import type Player from 'video.js/dist/types/player';
interface KeyboardOptions {
playerRef: React.RefObject<Player | null>;
onStepForward: () => void;
onStepBackward: () => void;
onCaptureFrame: () => void;
onAddMemo: () => void;
onToggleHelp?: () => void;
containerRef: React.RefObject<HTMLDivElement | null>;
}
export function useKeyboard({
playerRef,
onStepForward,
onStepBackward,
onCaptureFrame,
onAddMemo,
onToggleHelp,
containerRef,
}: KeyboardOptions) {
const handleKey = useCallback(
(e: KeyboardEvent) => {
const target = e.target as HTMLElement;
// Don't fire when typing in an input
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
const player = playerRef.current;
if (!player) return;
const ct = player.currentTime() ?? 0;
const dur = player.duration() ?? 0;
switch (e.code) {
case 'Space':
e.preventDefault();
player.paused() ? player.play() : player.pause();
break;
case 'ArrowLeft':
e.preventDefault();
player.currentTime(Math.max(0, ct - 5));
break;
case 'ArrowRight':
e.preventDefault();
player.currentTime(Math.min(dur, ct + 5));
break;
case 'KeyJ':
player.currentTime(Math.max(0, ct - 10));
break;
case 'KeyL':
player.currentTime(Math.min(dur, ct + 10));
break;
case 'Comma':
e.preventDefault();
onStepBackward();
break;
case 'Period':
e.preventDefault();
onStepForward();
break;
case 'BracketLeft':
player.currentTime(Math.max(0, ct - 30));
break;
case 'BracketRight':
player.currentTime(Math.min(dur, ct + 30));
break;
case 'KeyF':
if (containerRef.current) {
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen();
} else {
document.exitFullscreen();
}
}
break;
case 'KeyM':
if (e.shiftKey) {
e.preventDefault();
onAddMemo();
} else {
player.muted(!player.muted());
}
break;
case 'Equal':
case 'NumpadAdd':
player.playbackRate(Math.min(4, (player.playbackRate() ?? 1) + 0.25));
break;
case 'Minus':
case 'NumpadSubtract':
player.playbackRate(Math.max(0.25, (player.playbackRate() ?? 1) - 0.25));
break;
case 'KeyS':
if (e.shiftKey) { e.preventDefault(); onCaptureFrame(); }
break;
case 'Slash':
if (e.shiftKey) { e.preventDefault(); onToggleHelp?.(); }
break;
default:
if (e.code >= 'Digit0' && e.code <= 'Digit9') {
const pct = parseInt(e.code.replace('Digit', ''), 10) / 10;
player.currentTime(dur * pct);
}
}
},
[playerRef, onStepForward, onStepBackward, onCaptureFrame, onAddMemo, onToggleHelp, containerRef]
);
useEffect(() => {
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [handleKey]);
}
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useRef, useCallback } from 'react';
import videojs from 'video.js';
import type Player from 'video.js/dist/types/player';
import Hls from 'hls.js';
import { usePlayerStore } from '../store/playerStore';
const HLS_CONFIG = {
maxBufferLength: 30,
maxMaxBufferLength: 600,
maxBufferSize: 60 * 1024 * 1024,
backBufferLength: 30,
enableWorker: true,
};
// PC 브라우저(Chrome/Firefox/Edge)가 HTML5 video로 직접 디코딩 못 하는 코덱.
// 이런 영상은 원본 대신 (트랜스코딩된) HLS로 재생해야 한다.
const UNSUPPORTED_CODECS = new Set(['hevc', 'h265', 'hvc1', 'hev1']);
export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | null>) {
const playerRef = useRef<Player | null>(null);
const hlsRef = useRef<Hls | null>(null);
const store = usePlayerStore();
useEffect(() => {
if (!containerRef.current || playerRef.current) return;
const videoEl = document.createElement('video-js');
// fill: 컨테이너를 꽉 채움(사이니지처럼 화면 가득). object-fit:cover 로 비율 유지 크롭.
videoEl.classList.add('vjs-big-play-centered', 'vjs-fill');
containerRef.current.appendChild(videoEl);
const player = videojs(videoEl, {
// 하단 시간 스크러버는 측점 기반 StationBar 로 대체하므로 Video.js 기본 컨트롤바 숨김
controls: false,
fill: true,
responsive: true,
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4],
html5: { vhs: { overrideNative: true } },
});
player.on('play', () => store.setPlaying(true));
player.on('pause', () => store.setPlaying(false));
// 영상 첫 프레임 표시 가능 시점 — 오버레이(라벨/선)를 영상보다 먼저 그리지 않도록 게이트.
player.on('loadstart', () => store.setVideoReady(false));
player.on('loadeddata', () => store.setVideoReady(true));
player.on('playing', () => store.setVideoReady(true));
// 영상 원본 해상도 — object-fit:cover 크롭 영역 계산용(오버레이 정렬). 메타 로드 시 확정.
const reportSize = () => store.setVideoSize(player.videoWidth() ?? 0, player.videoHeight() ?? 0);
player.on('loadedmetadata', reportSize);
player.on('loadeddata', reportSize);
player.on('timeupdate', () => store.setCurrentTime(player.currentTime() ?? 0));
player.on('durationchange', () => store.setDuration(player.duration() ?? 0));
player.on('volumechange', () => {
store.setVolume(player.volume() ?? 1);
store.setMuted(player.muted() ?? false);
});
player.on('ratechange', () => store.setPlaybackRate(player.playbackRate() ?? 1));
playerRef.current = player;
return () => {
hlsRef.current?.destroy();
hlsRef.current = null;
if (playerRef.current && !playerRef.current.isDisposed()) {
playerRef.current.dispose();
playerRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadLocalFile = useCallback((file: File) => {
const player = playerRef.current;
if (!player) return;
// Clean up previous hls
hlsRef.current?.destroy();
hlsRef.current = null;
const objectUrl = URL.createObjectURL(file);
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
store.setSource({ kind: 'local', file, objectUrl });
store.setHlsReady(false);
// Revoke objectUrl when video element is reset
player.one('emptied', () => {
URL.revokeObjectURL(objectUrl);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadServerStream = useCallback(async (videoId: string, filename: string) => {
const player = playerRef.current;
if (!player) return;
hlsRef.current?.destroy();
hlsRef.current = null;
store.setSource({ kind: 'server', videoId, filename });
store.setHlsReady(false);
const playRaw = () => player.src({ src: `/api/stream/${videoId}`, type: 'video/mp4' });
// 코덱 확인 — 브라우저가 직접 못 푸는 코덱(HEVC 등)이면 HLS로 자동 재생
let needsHls = false;
try {
const meta = await fetch(`/api/meta/${videoId}`).then((r) => r.json());
needsHls = UNSUPPORTED_CODECS.has(String(meta?.codec ?? '').toLowerCase());
} catch {
// meta 조회 실패 시 일단 원본으로 시도
}
// await 사이에 사용자가 다른 영상을 선택했으면 중단
const stillCurrent = () => {
const s = usePlayerStore.getState().source;
return s?.kind === 'server' && s.videoId === videoId;
};
if (needsHls) {
if (!stillCurrent()) return;
const hlsId = videoId.replace(/\.[^.]+$/, '');
const ready = await fetch(`/api/hls/${hlsId}/index.m3u8`, { method: 'HEAD' })
.then((r) => r.ok)
.catch(() => false);
if (!stillCurrent()) return;
if (ready) {
switchToHls(videoId, 0);
return;
}
// HLS 미생성 상태: 원본을 시도(에러 표시)하고, 사용자가 'HLS 변환' 버튼으로 생성하도록 유도
}
// 지원 코덱이거나 HLS가 아직 없으면 원본 즉시 재생 (Range Request)
playRaw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const switchToHls = useCallback((videoId: string, seekTo?: number) => {
const player = playerRef.current;
if (!player) return;
const hlsId = videoId.replace(/\.[^.]+$/, '');
const hlsUrl = `/api/hls/${hlsId}/index.m3u8`;
const savedTime = seekTo ?? player.currentTime() ?? 0;
if (Hls.isSupported()) {
const hls = new Hls(HLS_CONFIG);
hls.loadSource(hlsUrl);
const videoEl = player.tech(true)?.el() as HTMLVideoElement;
hls.attachMedia(videoEl);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
player.currentTime(savedTime);
hlsRef.current = hls;
store.setHlsReady(true);
});
} else {
player.src({ src: hlsUrl, type: 'application/x-mpegURL' });
player.currentTime(savedTime);
store.setHlsReady(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getVideoElement = useCallback((): HTMLVideoElement | null => {
return (playerRef.current?.tech(true)?.el() as HTMLVideoElement | null) ?? null;
}, []);
return { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement };
}
+20
View File
@@ -0,0 +1,20 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #030712;
color: #f9fafb;
}
/* 영상이 컨테이너를 비율 유지하며 꽉 채움(사이니지 fill, 넘침만 크롭) */
.video-js.vjs-fill,
.video-js .vjs-tech {
object-fit: cover;
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);
+43
View File
@@ -0,0 +1,43 @@
/* abcVideo 임베드용 래퍼.
videoplayer-main 은 1920×1080 풀스크린 스테이지를 window 폭으로 스케일했지만,
여기서는 플레이어 컨테이너 폭(clientWidth)에 맞춰 균등 스케일한다.
바 본체(.bottomBar)는 1080 레이어의 하단 100px 에 위치(top:980)하며,
레이어를 컨테이너 하단에 앵커(bottom:0)해 바가 래퍼 하단에 정확히 붙는다.
커서 배지는 바 위(영상 쪽)로 넘쳐야 하므로 overflow:visible. */
.wrap {
position: relative;
width: 100%;
height: calc(100px * var(--bar-scale));
overflow: visible;
font-family: var(--font-ui);
user-select: none;
background: linear-gradient(180deg, rgb(62, 53, 35) 0%, rgb(29, 24, 16) 100%);
}
.layer {
position: absolute;
left: 0;
bottom: 0;
width: 1920px;
height: 1080px;
transform: scale(var(--bar-scale));
transform-origin: bottom left;
pointer-events: none;
}
/* videoplayer VideoPlayer.module.scss .bottomBar 와 동일 좌표/스타일 */
.bottomBar {
position: absolute;
left: 0;
top: 980px;
width: 1920px;
height: 100px;
pointer-events: auto;
display: flex;
align-items: center;
background: linear-gradient(180deg, rgb(62, 53, 35) 0%, rgb(29, 24, 16) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.25),
inset 0 -1px 0 rgba(255, 255, 255, 0.15),
0 -1px 4px rgba(0, 0, 0, 0.6);
}
+782
View File
@@ -0,0 +1,782 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
import type { DroneFrameBasic } from '../utils/geoProjection';
import { useGeoStore } from '../store/geoStore';
import { useSettingsStore, isGradeVisible } from '../store/settingsStore';
import {
STAGE_WIDTH,
TRACK_END_PX,
TRACK_START_PX,
TRACK_WIDTH_PX,
renderX,
trackXFromRender,
} from './mocks/route';
import { cssVars } from './utils/cssVars';
import { formatMileage } from './utils/mileage';
import { PlaybackControls } from './components/PlaybackControls/PlaybackControls';
import { Timeline } from './components/Timeline/Timeline';
import { TimelineCursor } from './components/TimelineCursor/TimelineCursor';
import styles from './StationBar.module.scss';
import './tokens.css';
/** 영상 실제 fps (RoutePanel·StationOverlay 와 동일 기준). */
const VIDEO_FPS = 30000 / 1001;
/** 프레임→현재측점 precompute 시 프레임 샘플 간격(영상 step). */
const FRAME_STEP = 10;
interface GeoPoint {
title: string;
category: string;
lat: number;
lon: number;
z: number;
type: string;
}
/** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지. */
interface ViewedPoint {
frameNum: number;
km: number;
chain: number;
time: number;
}
/** 측점 폴리라인(평면 투영). 연속 체이니지 계산용. */
interface StationLine {
pts: { x: number; y: number; km: number }[];
k: number; // 경도→m 환산 (cos(lat0)*111000)
}
/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m).
* 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치. */
function projectChainage(lat: number, lon: number, line: StationLine): number {
const px = lon * line.k;
const py = lat * 111000;
const pts = line.pts;
let bestD = Infinity;
let bestKm = pts.length ? pts[0].km : -1;
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i];
const b = pts[i + 1];
const dx = b.x - a.x;
const dy = b.y - a.y;
const L2 = dx * dx + dy * dy;
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / L2));
const cx = a.x + dx * t;
const cy = a.y + dy * t;
const d = (px - cx) ** 2 + (py - cy) ** 2;
if (d < bestD) {
bestD = d;
bestKm = a.km + (b.km - a.km) * t;
}
}
return bestKm;
}
/** 데이터 기반 트랙 구간: km 증가(dir 1, 주황) / 감소(dir -1, 하늘색). px는 시간축. */
export interface BarSegment {
startPx: number;
endPx: number;
dir: 1 | -1;
}
/** 측점값 라벨 (방향 전환점·시종점). px는 시간축. */
export interface KmLabel {
px: number;
text: string;
}
/** 구조물 마크 (교량/터널/역사). px는 통과 시점(시간축), km은 측점값(연속 체이니지, m). */
export interface StructMark {
px: number;
title: string;
category: string;
km: number;
/** 원본 속성(KMZ/CSV) — 마커 클릭 팝업 표시용. */
props?: { k: string; v: string }[];
/** 종점역인데 영상이 도착하지 못함 → 끝에 '미도착' 스타일(속 빈 링)로 표시. */
unreached?: boolean;
}
interface StationBarProps {
currentTime: number;
/** 라이브 재생시간 ref (매 프레임 갱신). 커서/진행바를 React 리렌더 없이 직접 이동. */
timeRef?: RefObject<number>;
duration: number;
playing: boolean;
onTogglePlay: () => void;
onStop: () => void;
onCapture: () => void;
onSeek: (time: number) => void;
showStations: boolean;
onToggleStations: () => void;
}
const clamp = (v: number, lo: number, hi: number): number =>
Math.min(hi, Math.max(lo, v));
function stationKm(title: string): number {
const m = title.match(/(\d+)[Kk](\d+)/);
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
}
/** 드론 GPS 최근접 측점 km (FOV 안에 측점이 없을 때 폴백). */
function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number {
let best = -1;
let bd = Infinity;
for (const st of stations) {
const d = (f.lat - st.lat) ** 2 + (f.lon - st.lon) ** 2;
if (d < bd) {
bd = d;
best = stationKm(st.title);
}
}
return best;
}
/** station 값(숫자=미터, "158k400" 문자열) → 미터. 실패 시 -1. */
function mileageToMeters(v: number | string): number {
if (typeof v === 'number') return v;
const m = String(v).match(/(\d+)\s*[kK]\s*(\d+)/);
if (m) return parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
const n = parseInt(String(v).replace(/[^0-9]/g, ''), 10);
return Number.isFinite(n) ? n : -1;
}
/** 미터값 → "158k710" (10m 단위 반올림). 커서 배지용 십단위 표시. */
function formatMileage10(m: number): string {
const r = Math.round(m / 10) * 10;
const km = Math.floor(r / 1000);
const mm = r % 1000;
return `${km}k${String(mm).padStart(3, '0')}`;
}
/**
* abcVideo 실제 영상 데이터 기반 측점 바.
* - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색).
* - 측점값 라벨·구조물(교량/터널/역사) = 재생 파일의 측점/POI 데이터로 배치.
* - 커서 = 프레임 진행, 배지 = 드론 GPS 최근접 측점.
* - 클릭/드래그/측점입력 seek = 시간(프레임) 기준 이동.
*/
export function StationBar({
currentTime,
timeRef,
duration,
playing,
onTogglePlay,
onStop,
onCapture,
onSeek,
showStations,
onToggleStations,
}: StationBarProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(0.5);
const draggingRef = useRef(false);
// 측점/POI/드론프레임은 폴더 선택으로 채워지는 geoStore에서 읽는다 (서버 /api/geo fetch 대체).
const storePois = useGeoStore((s) => s.pois);
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);
// v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용.
const storeStructuresRaw = useGeoStore((s) => s.structures);
// 시설종별 필터(설정 스토어) — 체크 변경 시 즉시 반영(구독).
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
const storeStructures = useMemo(
() => storeStructuresRaw.filter((s) => isGradeVisible(s.grade, gradeFilter)),
[storeStructuresRaw, gradeFilter],
);
const viewedRef = useRef<ViewedPoint[]>([]);
const [ready, setReady] = useState(false);
const stations = useMemo(
() => pois.filter((p) => p.type === 'station' && stationKm(p.title) >= 0),
[pois],
);
// 측점 폴리라인(평면 투영) — 연속 체이니지 계산용.
const stationLine = useMemo<StationLine | null>(() => {
if (!stations.length) return null;
const sorted = [...stations].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;
return {
pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: stationKm(p.title) })),
k,
};
}, [stations]);
// 프레임별 precompute (frames·stations 준비되면 1회).
// - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일).
// - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
// FPS는 고정값(29.97)이 아니라 실데이터(마지막 프레임 번호 / 재생시간)에서 유도 → 영상별 자동 적응.
// 데이터 미비 시에만 VIDEO_FPS(29.97) 폴백. (frame/fps = 절대시간)
const videoFps = useMemo<number>(() => {
const last = storeFrames.length ? storeFrames[storeFrames.length - 1].frame : 0;
return last > 0 && duration > 0 ? last / duration : VIDEO_FPS;
}, [storeFrames, duration]);
useEffect(() => {
const frames = storeFrames;
if (!frames.length || !stations.length || !stationLine) { setReady(false); return; }
const out: ViewedPoint[] = new Array(frames.length);
for (let i = 0; i < frames.length; i++) {
const f = frames[i];
out[i] = {
frameNum: f.frame,
km: nearestStationKm(f, stations),
chain: projectChainage(f.lat, f.lon, stationLine),
time: f.frame / videoFps,
};
}
viewedRef.current = out;
setReady(true);
}, [stations, stationLine, storeFrames, videoFps]);
/** 현재 시간에 가장 가까운 precompute 인덱스. */
const viewedIdxAtTime = useCallback((t: number): number => {
const arr = viewedRef.current;
if (!arr.length) return -1;
const target = t * videoFps;
let best = 0;
let bd = Math.abs(arr[0].frameNum - target);
for (let i = 1; i < arr.length; i++) {
const d = Math.abs(arr[i].frameNum - target);
if (d < bd) {
bd = d;
best = i;
}
}
return best;
}, [videoFps]);
// 현재 보는 측점값 — 연속 체이니지(chain)로 10m 해상도 (km 필드는 100m 양자화).
const realChain = useMemo<number | null>(() => {
if (!ready) return null;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].chain : null;
}, [currentTime, ready, viewedIdxAtTime]);
// 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다.
// 영상 타임라인은 [START … ENDendGapPx] 에만 매핑되고, 종점역 마커는 END(미도착)에 둔다.
// 폭 = 미도착거리(m) / 노선길이(m) × 트랙폭 (옵션 2: 실제 비례). 안전상 15%로 상한.
const endGapPx = useMemo<number>(() => {
const gapM = routeMeta?.routeInfo?.endStationGapMeters ?? 0;
const lenM = (routeMeta?.routeInfo?.lengthKm ?? 0) * 1000;
if (gapM <= 0 || lenM <= 0) return 0;
return Math.min(TRACK_WIDTH_PX * 0.15, (gapM / lenM) * TRACK_WIDTH_PX);
}, [routeMeta]);
// 시간→px 변환에 쓰는 유효 트랙폭 (미도착폭 제외).
const timeTrackWidth = TRACK_WIDTH_PX - endGapPx;
// 프레임(시간) 진행률 px — 커서의 단조 이동 기준.
// 영상은 노선을 시간순으로 한 번 통과(leg 01→10)하므로, 시간 진행률이 곧
// 트랙을 좌→우로 지나는 경로 진행률이다.
const progressPx =
duration > 0
? TRACK_START_PX + clamp(currentTime / duration, 0, 1) * timeTrackWidth
: TRACK_START_PX;
// 커서 위치 = 프레임 진행률(progressPx)로만 단조 이동.
// 측점 km은 노선에서 여러 번 반복(증가↔감소)되어, km으로 px를 역산하면
// 같은 km의 여러 후보 중 하나로 스냅되며 구간 점프가 생긴다(사용자 보고).
// → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다.
const cursorPx = progressPx;
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시.
// mock ROUTE_LEGS(mileageAtPx) 의존 제거. 데이터 없으면 빈 문자열.
const cursorText =
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
// 시간(프레임) 진행을 트랙 px로 선형 변환.
const pxAtTime = useCallback(
(t: number): number =>
duration > 0
? TRACK_START_PX + clamp(t / duration, 0, 1) * timeTrackWidth
: TRACK_START_PX,
[duration, timeTrackWidth],
);
// viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
// km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
const { barSegments, kmLabels } = useMemo<{
barSegments: BarSegment[];
kmLabels: KmLabel[];
}>(() => {
const arr = viewedRef.current;
if (!ready || !arr.length || duration <= 0)
return { barSegments: [], kmLabels: [] };
// 방향은 연속 체이니지(chain)로 판정 — 100m 양자화 km은 전환을 ~13s 빨리 잡아 영상과 어긋남.
const HYST = 100; // m — 이 이상 반대로 움직여야 방향 전환으로 인정 (GPS 노이즈 무시)
const segs: BarSegment[] = [];
const bounds: { chain: number; time: number; turn: boolean }[] = [
{ chain: arr[0].chain, time: arr[0].time, turn: false },
];
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정. 기본 증가 가정이 틀리면 첫 구간 색이 반대로 나옴.
let dir: 1 | -1 = 1;
for (let i = 1; i < arr.length; i++) {
const d = arr[i].chain - arr[0].chain;
if (Math.abs(d) >= HYST) { dir = d > 0 ? 1 : -1; break; }
}
let extCh = arr[0].chain;
let extIdx = 0;
let startIdx = 0;
for (let i = 1; i < arr.length; i++) {
const c = arr[i].chain;
if (dir > 0 ? c > extCh : c < extCh) {
extCh = c;
extIdx = i;
} else if (dir > 0 ? extCh - c >= HYST : c - extCh >= HYST) {
segs.push({
startPx: pxAtTime(arr[startIdx].time),
endPx: pxAtTime(arr[extIdx].time),
dir,
});
bounds.push({ chain: extCh, time: arr[extIdx].time, turn: true });
dir = dir > 0 ? -1 : 1;
startIdx = extIdx;
extCh = c;
extIdx = i;
}
}
segs.push({
startPx: pxAtTime(arr[startIdx].time),
endPx: pxAtTime(arr[arr.length - 1].time),
dir,
});
bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time, turn: false });
// 전환(턴) 지점은 실제 위치를 10m 단위로, 시·종점 등 기본 라벨은 100m 단위로 표시.
const labels: KmLabel[] = bounds.map((b) => ({
px: pxAtTime(b.time),
text: b.turn ? formatMileage10(b.chain) : formatMileage(Math.round(b.chain / 100) * 100),
}));
return { barSegments: segs, kmLabels: labels };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, duration, storeFrames, pxAtTime]);
// viewedRef의 km(최근접 측점)가 주어진 mileage(m)에 가장 가까운 프레임 시간 → px.
const pxAtMileage = useCallback(
(mileage: number): number | null => {
const arr = viewedRef.current;
if (!arr.length) return null;
let best = -1;
let bd = Infinity;
for (let i = 0; i < arr.length; i++) {
const d = Math.abs(arr[i].km - mileage);
if (d < bd) {
bd = d;
best = i;
}
}
return best >= 0 ? pxAtTime(arr[best].time) : null;
},
[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 / videoFps);
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 });
}
// 첫·마지막 스테이션(역사)을 트랙 양끝에 고정 표출.
// 역사 마커 중 최좌측 → 트랙 시작(TRACK_START_PX), 최우측 → 트랙 끝(TRACK_END_PX)으로 스냅.
// (드론 항로가 역사에서 떨어져 지나가도 첫/끝 역이 바 양끝에 앵커되도록.)
const snapStationEnds = (marks: StructMark[]): StructMark[] => {
const st = marks
.map((m, i) => ({ i, px: m.px }))
.filter((_, i) => marks[i].category === '역사' || marks[i].category === '역');
if (st.length < 2) return marks;
let lo = st[0], hi = st[0];
for (const x of st) {
if (x.px < lo.px) lo = x;
if (x.px > hi.px) hi = x;
}
if (lo.i !== hi.i) {
marks[lo.i] = { ...marks[lo.i], px: TRACK_START_PX };
// 종점역: 끝(END)에 두되, 미도착폭이 설정돼 있으면 '미도착'으로 표시
// (영상 진행바·커서는 END−endGapPx 까지만 도달 → 끝의 회색 구간 = 미도착).
marks[hi.i] = { ...marks[hi.i], px: TRACK_END_PX, unreached: endGapPx > 0 };
}
return marks;
};
// v2.0: CSV 유래 구조물(storeStructures) 우선. route.json 보정은 geoData 에서 이미 병합됨.
if (storeStructures && storeStructures.length) {
const out: StructMark[] = [];
for (const s of storeStructures) {
// 구교(06)는 영상 오버레이에 표출 → 스테이션바에서는 제외(교량/터널만).
if (s.category === '구교') continue;
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 }];
}
// 역사 km 라벨 = '역 좌표 자체'의 측점값(드론 최근접 프레임 chain 아님).
// 역은 바 양끝에 스냅되므로, 시작/끝 커서값(= 역 위치 측점)과 일치시키기 위함.
const stationKmVal = cat === '역사' && s.lat != null && s.lon != null && stationLine
? projectChainage(s.lat, s.lon, stationLine) : null;
for (const p of passes) out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, props: s.props });
}
return snapStationEnds(out);
}
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
const out: StructMark[] = [];
for (const [base, info] of poiByName) {
const stationKmVal = info.category === '역사' && stationLine
? projectChainage(info.lat, info.lon, stationLine) : null;
for (const p of pxPassesTo(info.lat, info.lon)) {
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km });
}
}
return snapStationEnds(out);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
// 방향 색 트랙 gradient 빌더(전진/후진 색을 받아 구성). 구간 경계는 부드럽게 섞는다.
const buildGradient = useCallback(
(FWD: string, BWD: string): string => {
const segs = barSegments;
if (!segs.length) return '';
const col = (d: 1 | -1) => (d > 0 ? FWD : BWD);
const pct = (px: number) =>
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
// 전환점에서 색이 '한번에' 바뀌도록 하드 스톱(같은 위치에 두 색).
const stops: string[] = [`${col(segs[0].dir)} 0%`];
for (let i = 1; i < segs.length; i++) {
const bp = pct(segs[i].startPx).toFixed(2);
stops.push(`${col(segs[i - 1].dir)} ${bp}%`);
stops.push(`${col(segs[i].dir)} ${bp}%`);
}
stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
return `linear-gradient(to right, ${stops.join(', ')})`;
},
[barSegments, duration],
);
// 통과 구간 음영 그라데이션(videoplayer 원본): 구간마다 좌→우 3색 셰이딩, 구간 경계는 하드.
// 전진 주황: #ffc257 → #ff8a25 → #ff7b1b / 역방향 청록: #5ca887 → #35a7a7 → #06a4c8
const buildShaded = useCallback(
(fwd: [string, string, string], bwd: [string, string, string]): string => {
const segs = barSegments;
if (!segs.length) return '';
const cols = (d: 1 | -1) => (d > 0 ? fwd : bwd);
const pct = (px: number) =>
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
const stops: string[] = [];
for (const s of segs) {
const a = pct(s.startPx);
const b = pct(s.endPx);
const c = cols(s.dir);
stops.push(`${c[0]} ${a.toFixed(2)}%`);
stops.push(`${c[1]} ${((a + b) / 2).toFixed(2)}%`);
stops.push(`${c[2]} ${b.toFixed(2)}%`);
}
return `linear-gradient(to right, ${stops.join(', ')})`;
},
[barSegments],
);
// 통과(지나간): 구간별 음영 그라데이션. 미통과: 단색(전진 회색 / 역방향 청회색).
const trackGradient = useMemo(
() => buildShaded(['#ffc257', '#ff8a25', '#ff7b1b'], ['#5ca887', '#35a7a7', '#06a4c8']),
[buildShaded],
);
const trackGradientIdle = useMemo(() => buildGradient('#7a7a7a', '#637789'), [buildGradient]);
// 방향(색)이 바뀌는 전환점 px — 구분선 위치.
const dividers = useMemo(() => barSegments.slice(1).map((s) => s.startPx), [barSegments]);
// 현재 위치가 역방향(km 감소, 하늘색) 구간이면 커서도 파란색.
const currentReverse = useMemo<boolean>(() => {
for (const s of barSegments) {
if (cursorPx >= s.startPx && cursorPx <= s.endPx) return s.dir < 0;
}
return false;
}, [barSegments, cursorPx]);
// 컨테이너 폭 기준 균등 스케일
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const update = (): void =>
setScale((el.clientWidth || STAGE_WIDTH) / STAGE_WIDTH);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
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) * timeTrackWidth;
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, timeTrackWidth]);
// 클릭/드래그 트랙 px → 시간(프레임) 선형 변환으로 seek (시간축 일관).
const seekToTrackX = useCallback(
(trackX: number) => {
if (duration <= 0) return;
const px = clamp(trackX, TRACK_START_PX, TRACK_END_PX);
// 미도착 구간(ENDendGapPx 우측)을 클릭하면 영상 끝(duration)으로 클램프.
const targetTime = ((px - TRACK_START_PX) / timeTrackWidth) * duration;
onSeek(clamp(targetTime, 0, duration));
},
[duration, onSeek, timeTrackWidth],
);
const seekFromClientX = useCallback(
(clientX: number) => {
const stage = stageRef.current;
if (!stage) return;
const rect = stage.getBoundingClientRect();
const stageX = ((clientX - rect.left) / rect.width) * STAGE_WIDTH;
seekToTrackX(trackXFromRender(stageX));
},
[seekToTrackX],
);
useEffect(() => {
const move = (e: globalThis.MouseEvent): void => {
if (draggingRef.current) seekFromClientX(e.clientX);
};
const up = (): void => {
draggingRef.current = false;
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
return () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
}, [seekFromClientX]);
const handleSeekDown = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
draggingRef.current = true;
seekFromClientX(e.clientX);
},
[seekFromClientX],
);
// 터미널 역명: route.json routeInfo.startStationName/endStationName 값. 없으면 측점 첫/끝 폴백.
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 (실데이터 기반).
// 해당 영상이 커버하는 측점(체이니지) 범위 밖이면 무시(seek 안 함).
const handleJumpToMileage = useCallback(
(km: number) => {
const arr = viewedRef.current;
if (!arr.length || duration <= 0) return;
// 연속 체이니지(chain) 기준으로 입력 측점에 가장 가까운 프레임 탐색 + 커버 범위 산출.
let best = -1;
let bd = Infinity;
let lo = Infinity;
let hi = -Infinity;
for (let i = 0; i < arr.length; i++) {
const c = arr[i].chain;
if (c < lo) lo = c;
if (c > hi) hi = c;
const d = Math.abs(c - km);
if (d < bd) {
bd = d;
best = i;
}
}
// 구간 밖(커버 측점 범위 ± 여유 20m)이면 무시.
const MARGIN = 20;
if (km < lo - MARGIN || km > hi + MARGIN) return;
if (best >= 0) onSeek(clamp(arr[best].time, 0, duration));
},
[duration, onSeek],
);
return (
<div
ref={wrapRef}
className={styles.wrap}
style={cssVars({ '--bar-scale': scale })}
>
<div ref={stageRef} className={styles.layer}>
<div className={styles.bottomBar}>
<PlaybackControls
playing={playing}
onTogglePlay={onTogglePlay}
onStop={onStop}
onCapture={onCapture}
onJumpToMileage={handleJumpToMileage}
lineOn={showStations}
onToggleLine={onToggleStations}
/>
<Timeline
posPx={cursorPx}
onSeekDown={handleSeekDown}
trackGradient={trackGradient}
trackGradientIdle={trackGradientIdle}
dividers={dividers}
kmLabels={kmLabels}
structures={structureMarks}
startStationName={startStationName}
endStationName={endStationName}
endGapPx={endGapPx}
routeDir={
routeMeta?.routeInfo?.direction?.includes('하') ? '하'
: routeMeta?.routeInfo?.direction?.includes('상') ? '상'
: null
}
/>
</div>
<TimelineCursor
mileageText={cursorText}
reverse={currentReverse}
onSeekDown={handleSeekDown}
/>
</div>
</div>
);
}
@@ -0,0 +1,21 @@
.marker {
position: absolute;
left: var(--x);
transform: translateX(-50%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 72px;
color: #fff;
font-weight: 700;
font-size: 13px;
letter-spacing: -0.2px;
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: #4F2000;
paint-order: stroke fill;
}
.selected {
text-shadow: 0 0 6px rgba(255, 148, 71, 0.55);
}
@@ -0,0 +1,22 @@
import type { MileageMarkerSpec } from '../../types/timeline';
import { cssVars, px } from '../../utils/cssVars';
import styles from './MileageMarker.module.scss';
interface MileageMarkerProps {
marker: MileageMarkerSpec;
}
export function MileageMarker({ marker }: MileageMarkerProps) {
const classNames = [styles.marker];
if (marker.selected) classNames.push(styles.selected);
return (
<div
className={classNames.join(' ')}
style={cssVars({ '--x': px(marker.left) })}
title={marker.value}
>
{marker.value}
</div>
);
}
@@ -0,0 +1,307 @@
$transport-gradient: linear-gradient(
180deg,
rgb(96, 79, 50) 0%,
rgb(85, 75, 49) 7%,
rgb(64, 51, 39) 11%,
rgb(40, 34, 22) 100%
);
$tool-gradient: linear-gradient(
180deg,
rgb(96, 79, 50) 0%,
rgb(59, 52, 34) 7%,
rgb(54, 43, 31) 11%,
rgb(66, 55, 35) 100%
);
$group-border: linear-gradient(
180deg,
rgba(255, 182, 135, 0.5) 0%,
rgba(255, 255, 255, 0) 100%,
);
$default-border: linear-gradient(
180deg,
rgba(255, 255, 255, 0.25) 25%,
rgba(153, 122, 101, 0.63) 63%,
rgba(98, 62, 39, 0.81) 81%,
rgba(80, 67, 60, 1) 100%
);
$hover-border: linear-gradient(
180deg,
#ffa812 8%,
#ffdfa7 38%,
#ffa812 72%,
#a96e09 95%
);
// ── 컨트롤 패널 치수 (이 값들만 바꾸면 관련 위치/크기가 자동 계산됨) ──
$panel-h: 96.67px; // transportGroup 높이
$panel-inset: 2px; // 그룹 가장자리 공통 인셋
// 재생·정지·측점입력 그룹
$transport-group-w: 161.33px;
$transport-btn-w: 78px;
$transport-btn-h: 55px;
$transport-btn-left: 3px; // 첫(재생) 버튼 좌측 인셋
$transport-btn-top: 4px;
$transport-btn-gap: 1px; // 재생 ↔ 정지 간격
// 화면캡처·측점선 그룹
$tool-group-w: 76px;
$tool-group-h: 96px;
$tool-btn-h: 47px;
$tool-btn-top: 0.5px; // 화면캡처(첫 버튼) top — 47×2가 96 안에 들어가도록 축소
$tool-btn-gap: 1px; // 화면캡처 ↔ 측점선 간격 (47px 버튼이 바 안에 들어가도록)
/* 컨트롤 묶음 — bottomBar flex 자식 (좌측 정렬, 세로 중앙) */
.controlsRow {
position: relative;
display: inline-flex;
gap: 4px;
padding-left: 1.33px;
align-items: center;
z-index: 4;
}
%control-button {
position: absolute;
z-index: 4;
box-sizing: border-box;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid transparent;
border-radius: 4px;
appearance: none;
cursor: pointer;
overflow: hidden;
background: $transport-gradient padding-box, $default-border border-box;
box-shadow: 0 0 0 2px rgb(0, 0, 0);
transition: box-shadow 150ms ease-out;
&:hover {
background: $transport-gradient padding-box, $hover-border border-box;
box-shadow:
0 0 0 2px #000,
0 0 8px rgba(255, 168, 18, 0.35);
}
}
/* 플레이·정지·측점 입력 패널 */
.transportGroup {
position: relative;
width: $transport-group-w;
height: $panel-h;
}
.leftPanel {
position: absolute;
left: -1px;
top: 0;
width: calc(100% + 4px);
height: 100%;
/* 그라데이션 테두리 두께 = 이 border 폭 (숫자를 키우면 더 굵어짐) */
border: 2px solid transparent;
box-sizing: border-box;
border-radius: 4px;
background:
linear-gradient(rgb(38, 29, 13), transparent) padding-box,
$group-border border-box;
z-index: 3;
}
.transportBtn {
@extend %control-button;
left: $transport-btn-left;
top: $transport-btn-top;
width: $transport-btn-w;
height: $transport-btn-h;
}
.stopBtn {
@extend %control-button;
// 재생 버튼 우측 + 간격 → 폭이 바뀌면 자동으로 따라옴
left: $transport-btn-left + $transport-btn-w + $transport-btn-gap + 1px;
top: $transport-btn-top;
width: $transport-btn-w;
height: $transport-btn-h;
}
/* 버튼 아이콘: 실제 SVG 파일(그라데이션·그림자·테두리 포함)을 배경으로 사용.
호버 시 버튼의 :hover 로 _on 버전으로 교체한다. 박스 크기는 SVG viewBox의 2/3
(= 기존 렌더 크기 유지). 새 아이콘은 SVG만 교체하면 되어 유지보수가 쉽다. */
%btn-icon {
background-repeat: no-repeat;
background-position: center;
background-size: contain;
pointer-events: none;
}
.playIcon {
@extend %btn-icon;
width: 40px; // 60 × 2/3
height: 40px;
background-image: url('/assets/icons/ico_play.png');
}
.transportBtn:hover .playIcon {
background-image: url('/assets/icons/ico_play_on.png');
}
.pauseIcon {
@extend %btn-icon;
width: 33.33px; // 50 × 2/3
height: 33.33px;
background-image: url('/assets/icons/icon_pause.png');
}
.transportBtn:hover .pauseIcon {
background-image: url('/assets/icons/icon_pause_on.png');
}
.stopIcon {
@extend %btn-icon;
width: 40px; // 60 × 2/3
height: 40px;
background-image: url('/assets/icons/icon_stop.png');
}
.stopBtn:hover .stopIcon {
background-image: url('/assets/icons/icon_stop_on.png');
}
.mileageInput {
position: absolute;
left: $transport-btn-left;
top: 62px;
z-index: 4;
width: $transport-group-w - $transport-btn-left - $panel-inset;
height: 32.67px;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.65);
box-shadow:
0 0 0 2px rgb(0, 0, 0),
0 0.5px 0 0 rgba(255, 255, 255, 0.1);
box-sizing: border-box;
padding: 0 8px;
font-family: 'Noto Sans KR', var(--font-ui);
font-size: 16px;
font-weight: 400;
letter-spacing: 0.04em;
color: #fff;
&::placeholder {
color: rgba(255, 255, 255, 0.5);
}
&:focus {
outline: none;
}
}
/* 화면캡처·측점선 보기 패널 */
.toolGroup {
position: relative;
width: $tool-group-w;
height: $tool-group-h;
margin-left: 1px; // transportGroup 패널 확장분만큼 1px 우측 이동
border-radius: 4px;
background: #1a1309;
}
.toolBtn {
@extend %control-button;
left: $panel-inset;
width: $tool-group-w - 2 * $panel-inset; // 좌우 인셋 제외 → 그룹 폭 바뀌면 자동
height: $tool-btn-h; // 바 영역을 벗어나지 않도록 축소 + 두 버튼 사이 간격 확보
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
background: $tool-gradient padding-box, $default-border border-box;
&:hover {
background: $tool-gradient padding-box, $hover-border border-box;
box-shadow:
0 0 0 2px #000,
0 0 8px rgba(255, 168, 18, 0.35);
}
}
.captureBtn {
top: $tool-btn-top + $tool-btn-top;
}
/* 화면캡처 아래 = 첫 버튼 top + 높이 + 간격 → 높이/간격 바꾸면 자동으로 따라옴 */
.lineBtn {
top: $tool-btn-top + $tool-btn-h + $tool-btn-gap + $tool-btn-top + 1px;
}
.cameraIcon {
@extend %btn-icon;
width: 24px; // 확대
height: 20px;
background-image: url('/assets/icons/icon-camera-body.png');
}
.captureBtn:hover .cameraIcon {
background-image: url('/assets/icons/icon-camera-body_on.png');
}
.lineIcon {
@extend %btn-icon;
width: 19.2px; // 확대
height: 20px;
background-image: url('/assets/icons/icon-sturn.png');
}
.lineBtn:hover .lineIcon {
background-image: url('/assets/icons/icon-sturn_on.png');
}
.toolLabel {
font-family: 'Noto Sans KR', var(--font-ui);
font-weight: 700;
font-size: 13.33px; // 살짝 축소 (기존 14.67)
line-height: 1.2;
letter-spacing: -0.04em;
white-space: nowrap;
color: rgb(204, 199, 189);
text-shadow: 0 0.5px 1px rgb(0, 0, 0);
}
.toolBtn:hover .toolLabel {
color: #ffa812;
}
/* 측점선 보기 토글 ON 상태(클릭): 호버와 동일한 아이콘·라벨 색에,
눌린 어두운 그라데이션 배경 + 주황 그라데이션 테두리. 별도 동작 없음.
(채움 레이어는 padding-box, 테두리 그라데이션은 border-box) */
.lineBtn.active {
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.1) 100%) padding-box,
linear-gradient(
180deg,
#211601 5%,
#211601 10%,
#352509 15%,
#352509 85%,
#231700 90%,
#000 95%
) padding-box,
$hover-border border-box;
box-shadow: 0 0 0 2px #000;
}
.lineBtn.active .lineIcon {
background-image: url('/assets/icons/icon-sturn_on.png');
}
.lineBtn.active .toolLabel {
color: #ffa812;
}
@@ -0,0 +1,98 @@
import { useState } from 'react';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { parseMileageQuery } from '../../utils/mileage';
import styles from './PlaybackControls.module.scss';
interface PlaybackControlsProps {
playing: boolean;
onTogglePlay: () => void;
onStop: () => void;
onCapture: () => void;
onJumpToMileage: (mileage: number) => void;
/** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */
lineOn?: boolean;
onToggleLine?: () => void;
}
export function PlaybackControls({
playing,
onTogglePlay,
onStop,
onCapture,
onJumpToMileage,
lineOn: lineOnProp,
onToggleLine,
}: PlaybackControlsProps) {
const [query, setQuery] = useState('');
const [lineOnInternal, setLineOnInternal] = useState(false);
const lineOn = lineOnProp ?? lineOnInternal;
const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v));
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>): void => {
setQuery(e.target.value);
};
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key !== 'Enter') return;
const mileage = parseMileageQuery(query);
if (mileage !== null) {
onJumpToMileage(mileage);
setQuery('');
}
};
return (
<div className={styles.controlsRow}>
<div className={styles.transportGroup}>
<div className={styles.leftPanel} />
<button
type="button"
className={styles.transportBtn}
onClick={onTogglePlay}
aria-label={playing ? '일시정지' : '재생'}
>
{/* 호버 시 _on SVG 로 교체(:hover). 재생/일시정지는 상태로 아이콘 전환 */}
<span className={playing ? styles.pauseIcon : styles.playIcon} />
</button>
<button
type="button"
className={styles.stopBtn}
onClick={onStop}
aria-label="정지"
>
<span className={styles.stopIcon} />
</button>
<input
className={styles.mileageInput}
value={query}
onChange={handleQueryChange}
onKeyDown={handleQueryKeyDown}
placeholder="측점입력"
/>
</div>
<div className={styles.toolGroup}>
<button
type="button"
className={`${styles.toolBtn} ${styles.captureBtn}`}
onClick={onCapture}
aria-label="화면캡처"
>
<span className={styles.cameraIcon} />
<span className={styles.toolLabel}></span>
</button>
<button
type="button"
className={`${styles.toolBtn} ${styles.lineBtn}${lineOn ? ` ${styles.active}` : ''}`}
onClick={toggleLine}
aria-pressed={lineOn}
aria-label="측점선 보기"
>
<span className={styles.lineIcon} />
<span className={styles.toolLabel}>
{lineOn ? '측점선 끄기' : '측점선 보기'}
</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,107 @@
.segment {
position: absolute;
left: var(--seg-left);
top: var(--seg-top);
width: var(--seg-width);
height: var(--seg-height);
display: flex;
align-items: center;
z-index: 6;
}
.capLeft,
.capRight {
flex: 0 0 var(--seg-cap-width);
width: var(--seg-cap-width);
height: var(--seg-height);
background-size: 100% 100%;
background-repeat: no-repeat;
}
.center {
flex: 1;
margin: 0 -3px;
height: var(--seg-center-height);
background-repeat: no-repeat;
background-size: 100% 100%;
}
.bridge {
&.upcoming {
.capLeft {
background-image: url('/assets/route-segment/bridge/bridge-upcoming-left.png');
}
.center {
background-image: url('/assets/route-segment/bridge/bridge-upcoming-center.png');
}
.capRight {
background-image: url('/assets/route-segment/bridge/bridge-upcoming-right.png');
}
}
&.passed {
.capLeft {
background-image: url('/assets/route-segment/bridge/bridge-passed-left.png');
}
.center {
background-image: url('/assets/route-segment/bridge/bridge-passed-center.png');
}
.capRight {
background-image: url('/assets/route-segment/bridge/bridge-passed-right.png');
}
}
&.revisit {
.capLeft {
background-image: url('/assets/route-segment/bridge/bridge-revisit-left.png');
}
.center {
background-image: url('/assets/route-segment/bridge/bridge-revisit-center.png');
}
.capRight {
background-image: url('/assets/route-segment/bridge/bridge-revisit-right.png');
}
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
}
}
.tunnel {
&.upcoming {
.capLeft {
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-left.png');
}
.center {
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-center.png');
}
.capRight {
background-image: url('/assets/route-segment/tunnel/tunnel-upcoming-right.png');
}
}
&.passed {
.capLeft {
background-image: url('/assets/route-segment/tunnel/tunnel-passed-left.png');
}
.center {
background-image: url('/assets/route-segment/tunnel/tunnel-passed-center.png');
}
.capRight {
background-image: url('/assets/route-segment/tunnel/tunnel-passed-right.png');
}
}
&.revisit {
.capLeft {
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-left.png');
}
.center {
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-center.png');
}
.capRight {
background-image: url('/assets/route-segment/tunnel/tunnel-revisit-right.png');
}
filter: drop-shadow(0 0 0.5px rgba(200, 200, 200, 0.8));
}
}
@@ -0,0 +1,34 @@
import type { RouteProgressState, StructureSegment } from '../../types/timeline';
import { isStructureAssetState } from '../../constants/routeSegmentAssets';
import { cssVars, px } from '../../utils/cssVars';
import styles from './RouteSegment.module.scss';
interface RouteSegmentProps {
segment: StructureSegment;
state: RouteProgressState;
}
/** Three-slice structure marker: fixed caps, stretchable center. */
export function RouteSegment({ segment, state }: RouteSegmentProps) {
const assetState = isStructureAssetState(state) ? state : 'upcoming';
const classNames = [styles.segment, styles[segment.type], styles[assetState]];
return (
<div
className={classNames.join(' ')}
title={segment.label}
style={cssVars({
'--seg-left': px(segment.left),
'--seg-top': px(segment.top),
'--seg-width': px(segment.width),
'--seg-height': px(segment.height),
'--seg-cap-width': px(segment.capWidth),
'--seg-center-height': px(segment.centerHeight),
})}
>
<div className={styles.capLeft} />
<div className={styles.center} />
<div className={styles.capRight} />
</div>
);
}
@@ -0,0 +1,8 @@
.marker {
position: absolute;
left: var(--marker-left);
top: var(--marker-top);
width: var(--marker-width);
height: var(--marker-height);
z-index: 7;
}
@@ -0,0 +1,28 @@
import { terminalCircleAsset } from '../../constants/routeSegmentAssets';
import type { TerminalMarker as TerminalMarkerSpec } from '../../types/timeline';
import { resolveTerminalState } from '../../utils/routeProgress';
import { cssVars, px } from '../../utils/cssVars';
import styles from './TerminalMarker.module.scss';
interface TerminalMarkerProps {
marker: TerminalMarkerSpec;
posPx: number;
}
export function TerminalMarker({ marker, posPx }: TerminalMarkerProps) {
const state = resolveTerminalState(posPx, marker);
return (
<img
className={styles.marker}
src={terminalCircleAsset(state)}
alt=""
style={cssVars({
'--marker-left': px(marker.left),
'--marker-top': px(marker.top),
'--marker-width': px(marker.width),
'--marker-height': px(marker.height),
})}
/>
);
}
@@ -0,0 +1,320 @@
/* 트랙 본체 가로 압축 래퍼. transform(translateX·scaleX)은 route.ts 단일 소스에서
파생되어 Timeline.tsx 에서 인라인으로 적용된다(여기에 하드코딩하지 않음). */
.trackWrapper {
position: absolute;
left: 0;
top: 0;
width: 1920px;
height: 100px;
transform-origin: 0 0;
pointer-events: none;
}
.trackFade {
position: absolute;
left: 0;
top: -23px;
width: 1920px;
height: 23px;
background: linear-gradient(
180deg,
rgba(102, 102, 102, 0) 0%,
rgba(41, 41, 41, 0.25) 50%,
rgba(0, 0, 0, 0.5) 100%
);
pointer-events: none;
}
.trackFrame {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
border-radius: 4px;
background: linear-gradient(180deg, rgb(11, 8, 3) 0%, rgb(40, 26, 7) 100%);
box-shadow: -1px 1px 1px 0 rgb(71, 61, 39);
}
.trackFrameStripesH {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
border-radius: 4px;
background: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.07) 0,
rgba(255, 255, 255, 0.07) 1px,
transparent 1px,
transparent 33.33%
);
pointer-events: none;
}
.trackFrameStripesV {
position: absolute;
left: var(--track-x);
top: 31.5px;
width: var(--track-w);
height: 45.5px;
background: repeating-linear-gradient(
90deg,
rgba(61, 52, 34, 0.55) 0,
rgba(61, 52, 34, 0.55) 2px,
transparent 2px,
transparent 36.65px
);
pointer-events: none;
}
.trackBase {
position: absolute;
left: var(--track-x);
top: 34.5px;
width: var(--track-w);
height: 12px;
border-radius: 6px;
background: rgb(122, 122, 122);
z-index: 1;
}
/* 역명 레이어: 스테이지 전체를 덮어 자식들의 위치 기준이 된다.
--track-start / --track-end (트랙 시작·끝, 단일 소스)을 상속받아
아래 역 라벨들이 트랙 가장자리 기준 calc()로 배치된다(시작/끝 바꾸면 자동 이동). */
.stationLayer {
position: absolute;
inset: 0;
pointer-events: none;
}
/* 트랙 가장자리 기준 오프셋(좌우 대칭): 텍스트 안쪽끝 40px · 점 32/27px · 점선 23/5px */
.stationStart {
position: absolute;
/* 오른쪽(안쪽) 끝을 '트랙 시작-40px'에 고정 → 대전과 트랙 중앙 기준 좌우 대칭.
글자 폭과 무관하게 점선·텍스트 공백이 양쪽 동일(점까지 8px, 트랙까지 50px). */
right: calc(100% - var(--track-start) + 40px);
top: 31px;
font-size: 16px;
font-weight: 700;
color: var(--color-station);
white-space: nowrap;
}
.stationEnd {
position: absolute;
/* 우측 끝(1920) 기준으로 고정 → 긴 역명(대전조차장 등)도 안쪽(좌)으로 펼쳐져 화면 밖으로 안 잘림. */
right: 8px;
top: 31px;
font-size: 16px;
font-weight: 700;
color: var(--color-station);
white-space: nowrap;
}
.dotStart,
.dotEnd {
position: absolute;
top: 39px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--color-station-dot);
}
.dotStart {
left: calc(var(--track-start) - 32px);
}
.dotEnd {
left: calc(var(--track-end) + 27px);
}
.leaderStart,
.leaderEnd {
position: absolute;
top: 40.5px;
height: 2.5px;
width: 18px;
background: repeating-linear-gradient(
90deg,
var(--color-station-leader) 0 5px,
transparent 5px 9px
);
}
.leaderStart {
left: calc(var(--track-start) - 23px);
}
.leaderEnd {
left: calc(var(--track-end) + 5px);
}
.track {
position: absolute;
left: var(--track-x);
top: 34.5px;
width: var(--track-w);
height: 12px;
z-index: 2;
}
.leg {
position: absolute;
top: 0;
height: 12px;
left: var(--leg-left);
width: var(--leg-width);
&.down {
background: var(--color-track-down);
}
&.up {
background: var(--color-track-up);
}
}
.legPassed {
position: absolute;
left: 0;
top: 0;
height: 12px;
width: var(--passed-width);
.down & {
background: var(--gradient-track-down-passed);
}
.up & {
background: var(--gradient-track-up-passed);
}
}
.turnDivider {
position: absolute;
left: var(--x);
top: 0;
width: 0;
height: 12px;
border-left: 1px dashed rgba(0, 0, 0, 0.7);
border-right: 1px dashed rgba(255, 255, 255, 0.3);
z-index: 3;
pointer-events: none;
}
.mileageRow {
position: absolute;
left: 0;
top: 6px;
width: 1920px;
height: 13px;
white-space: nowrap;
z-index: 3;
}
.labelRow {
position: absolute;
left: 0;
top: 51px;
width: 1920px;
height: 26px;
font-family: 'Noto Sans KR', sans-serif;
font-size: 16.5px;
font-weight: 800;
line-height: 24px;
}
.zone {
position: absolute;
left: var(--x);
top: -6px;
width: var(--zone-width);
height: 15.5px;
border: 1.5px dashed rgb(218, 203, 185);
border-radius: 1px;
box-sizing: border-box;
pointer-events: none;
}
.zoneInner {
position: absolute;
left: var(--x);
top: -6px;
width: 0;
height: 15.5px;
border-left: 1.5px dashed rgb(218, 203, 185);
pointer-events: none;
}
.segmentLabel {
position: absolute;
left: var(--x);
top: 2px;
transform: translateX(-50%);
white-space: nowrap;
&.box {
transform: none;
width: var(--label-width);
text-align: center;
}
/* 텍스트 오른쪽 끝이 left 좌표에 닿음 (시작 라인 왼쪽 배치) */
&.anchorEnd {
transform: translateX(-100%);
}
/* 텍스트 왼쪽 끝이 left 좌표에서 시작 (종료 라인 오른쪽 배치) */
&.anchorStart {
transform: none;
}
&.endpoint {
color: #fff;
-webkit-text-stroke-width: 4px;
-webkit-text-stroke-color: #4f2000;
paint-order: stroke fill;
}
&.accent {
color: #ffd4b7;
-webkit-text-stroke-width: 2px;
-webkit-text-stroke-color: #4f2000;
paint-order: stroke fill;
}
&.neutral {
color: #e4e4e4;
-webkit-text-stroke-width: 3px;
-webkit-text-stroke-color: #0d0d0d;
paint-order: stroke fill;
}
}
/* 구조물명: 줄바꿈은 JS(splitStructLabel)가 위/아래 글자수 균등하게 <br>로 처리.
박스는 내용 폭에 맞게(최대 140px) 줄어들어 옆 라벨과 간격을 최대 확보.
각 줄은 nowrap(명시적 줄바꿈만), 아이콘 기준 가운데 정렬.
폰트 크기는 라벨행 기본(16.5px) 유지 — 작게 줄이지 않음. */
.structName {
width: max-content;
max-width: 140px;
white-space: nowrap;
text-align: center;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
}
.seekArea {
position: absolute;
left: var(--track-x);
top: 27.5px;
width: var(--track-w);
height: 48px;
cursor: pointer;
z-index: 20;
pointer-events: auto;
}
+378
View File
@@ -0,0 +1,378 @@
import { Fragment, useState } from 'react';
import type { MouseEvent } from 'react';
import { RouteSegment } from '../RouteSegment/RouteSegment';
import type { StructureType, StructureSegment } from '../../types/timeline';
import {
TRACK_RENDER_END_PX,
TRACK_RENDER_START_PX,
TRACK_START_PX,
TRACK_WIDTH_PX,
TRACK_WRAPPER_TRANSFORM,
} from '../../mocks/route';
import type { KmLabel, StructMark } from '../../StationBar';
import { cssVars, px } from '../../utils/cssVars';
import { MileageMarker } from '../MileageMarker/MileageMarker';
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')}`;
}
/**
* 구조물명 라벨 분할 — 한 줄에 안 들어가면 위/아래 '글자 수'가 최대한 같게 2줄로 나눈다.
* (균등 분할 → 가장 넓은 줄 폭 최소화 → 옆 라벨과 간격 최대 확보)
* 한글/CJK 1.0, 그 외(영문·괄호·숫자) 0.55폭으로 한 줄 용량(≈140px/16.5px) 추정.
* 분할점은 글자수 절반. 단, 절반 근처(±2)에 여는 괄호가 있으면 괄호를 통째로 아랫줄로.
*/
function splitStructLabel(text: string): string[] {
const chars = [...text];
const isCJK = (c: string): boolean => /[ -鿿가-힣]/.test(c);
const width = chars.reduce((a, c) => a + (isCJK(c) ? 1 : 0.55), 0);
if (width <= 8.2) return [text]; // 한 줄에 들어감 → 그대로
const len = chars.length;
// 기본 분할점 = 글자수 절반(위/아래 글자수 균등).
let cut = Math.round(len / 2);
// 분할점이 괄호 그룹 "(...)" 내부를 가르면 → 괄호 시작 앞으로 옮겨 통째로 아랫줄.
let open = -1;
for (let i = 0; i < len; i++) {
const c = chars[i];
if (c === '(' || c === '') open = i;
else if (c === ')' || c === '') {
if (open >= 0 && open < cut && i >= cut) { cut = open; break; }
open = -1;
}
}
return [chars.slice(0, cut).join(''), chars.slice(cut).join('')];
}
interface TimelineProps {
posPx: number;
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
/** 통과 구간 트랙 gradient (전진=주황/후진=청록). */
trackGradient: string;
/** 미통과 구간 트랙 gradient (전진=회색/후진=청회색). */
trackGradientIdle: string;
/** 색(방향)이 바뀌는 전환점 px(스테이지 좌표) — 구분선 위치. */
dividers: number[];
/** 데이터 기반 측점값 라벨 (방향 전환점·시종점). */
kmLabels: KmLabel[];
/** 데이터 기반 구조물 (교량/터널/역사). */
structures: StructMark[];
/** 시점 역명 (route.json routeInfo 우선, 없으면 첫 측점 title). */
startStationName?: string;
/** 종점 역명 (route.json routeInfo 우선, 없으면 끝 측점 title). */
endStationName?: string;
/** 종점역 미도착 폭(px) — 트랙 우측 이 구간은 '재생 불가'(영상이 도달 못 함)로 별색 표시. */
endGapPx?: number;
/** 영상 진행 방향('상'|'하'). 평행 상/하행 구조물 겹침 시 해당 방향을 우선 표출. */
routeDir?: '상' | '하' | null;
}
export function Timeline({
posPx,
onSeekDown,
trackGradient,
trackGradientIdle,
dividers,
kmLabels,
structures,
startStationName,
endStationName,
endGapPx = 0,
routeDir = null,
}: TimelineProps) {
// 하단 스테이션바는 겹쳐도 무방 → 역사(역, KAKAO_RAIL)는 dedup 대상에서 제외하고 항상 표출.
// (종점 '대전조차장'이 ~70px 앞 '법동가도교'에 밀려 사라지던 문제 방지.)
// 교량/터널만 라벨 텍스트가 길어 130px 겹침 방지 적용. 위치는 실제 통과 px 그대로.
// 평행 상/하행 구조물(예: 회덕터널 상/하)은 ~6px 차이라 한쪽만 남는데, 영상 방향(routeDir)에
// 해당하는 쪽(하행=‘(하)’, 상행=‘(상)’)을 우선 남긴다. → 드론이 실제 지나는 선로와 일치.
const isStation = (s: StructMark): boolean => s.category === '역사' || s.category === '역';
const dirTag = routeDir === '상' ? '(상)' : routeDir === '하' ? '(하)' : '';
const others = structures.filter((s) => !isStation(s));
const ordered = dirTag
? [...others].sort((a, b) =>
(a.title.includes(dirTag) ? 0 : 1) - (b.title.includes(dirTag) ? 0 : 1) || a.px - b.px)
: [...others].sort((a, b) => a.px - b.px);
const keptOthers: StructMark[] = [];
for (const it of ordered) {
if (keptOthers.every((k) => Math.abs(k.px - it.px) >= 130)) keptOthers.push(it);
}
const structs = [...structures.filter(isStation), ...keptOthers].sort((a, b) => a.px - b.px);
// 구조물 마커 클릭 시 속성 팝업(클릭 좌표 기준 fixed — 트랙 래퍼 transform 영향 회피).
const [sel, setSel] = useState<{ s: StructMark; x: number; y: number } | null>(null);
return (
<>
{/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
<div className={styles.trackFade} />
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 시·종점 역명 배치 (route.json/측점 유래) */}
<div
className={styles.stationLayer}
style={cssVars({
'--track-start': px(TRACK_RENDER_START_PX),
'--track-end': px(TRACK_RENDER_END_PX),
})}
>
{/* 좌측(시점)=역방향 하늘색, 우측(종점)=정방향 주황 */}
<div className={styles.stationStart} style={{ color: '#06a4c8' }}>{startStationName ?? ''}</div>
<div className={styles.dotStart} style={{ background: '#06a4c8' }} />
<div className={styles.leaderStart} style={cssVars({ '--color-station-leader': '#06a4c8' })} />
<div className={styles.leaderEnd} style={cssVars({ '--color-station-leader': '#ff8a25' })} />
<div className={styles.dotEnd} style={{ background: '#ff8a25' }} />
<div className={styles.stationEnd} style={{ color: '#ff8a25' }}>{endStationName ?? ''}</div>
</div>
{/* 트랙 본체 (가로 압축 래퍼) */}
<div
className={styles.trackWrapper}
style={{
...cssVars({
'--track-x': px(TRACK_START_PX),
'--track-w': px(TRACK_WIDTH_PX),
}),
transform: TRACK_WRAPPER_TRANSFORM,
}}
>
<div className={styles.trackFrame} />
<div className={styles.trackFrameStripesH} />
<div className={styles.trackFrameStripesV} />
<div className={styles.trackBase} />
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색.
전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
<div className={styles.track}>
{/* 미재생(미통과): 회색/청회색 (전체 폭) */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: px(TRACK_WIDTH_PX),
background: trackGradientIdle,
opacity: 1,
borderRadius: 'inherit',
}}
/>
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체.
재생영역(회색 idle)보다 약간 밝은 단색 배경으로만 구분(빗금 없음). */}
{endGapPx > 0 && (
<div
title="재생 불가 구간 (종점역 미도착)"
style={{
position: 'absolute',
left: px(TRACK_WIDTH_PX - endGapPx),
top: 0,
height: '100%',
width: px(endGapPx),
background: '#54545c',
borderRadius: 'inherit',
zIndex: 2,
}}
/>
)}
{/* 재생된 구간: 원래 색 복원 (커서까지 clip). 폭은 CSS 변수(--pos-px)로 매 프레임
직접 갱신되어 React 리렌더 없이 부드럽게 늘어난다. */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: `max(0px, min(${TRACK_WIDTH_PX}px, calc(var(--pos-px, ${TRACK_START_PX}px) - ${TRACK_START_PX}px)))`,
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: px(TRACK_WIDTH_PX),
background: trackGradient,
borderRadius: 'inherit',
}}
/>
</div>
{/* 색(방향) 전환점 구분선 — videoplayer leg 솔기와 동일(검정+흰색 점선) */}
{dividers.map((dpx, i) => (
<div
key={`div-${i}`}
style={{
position: 'absolute',
left: dpx - TRACK_START_PX,
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 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). 아이콘·라벨 동시 전환.
// 미도착(종점역): 영상이 도달 못 함 → 항상 '예정(무채색)' 상태로 고정.
// (불투명 아이콘이 뒤의 재생불가 빗금을 덮도록 opacity 1 유지.)
const passed = s.unreached ? false : 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>
{/* 구조물 클릭 핫스팟 — 클릭 시 속성 팝업(회덕화물역 등 역사 포함). seekArea(z20) 위로. */}
<div style={{ position: 'absolute', inset: 0, zIndex: 21, pointerEvents: 'none' }}>
{structs.map((s, i) =>
s.props && s.props.length ? (
<div
key={`hit-${i}`}
title={s.title}
onMouseDown={(e) => { e.stopPropagation(); setSel({ s, x: e.clientX, y: e.clientY }); }}
style={{ position: 'absolute', left: s.px - 12, top: 28, width: 24, height: 28, cursor: 'pointer', pointerEvents: 'auto' }}
/>
) : null,
)}
</div>
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄. */}
<div className={styles.labelRow}>
{structs.map((s, i) => {
const W = s.category === '터널' ? 24 : 22;
// 미도착 종점역은 항상 미통과(neutral) 색 유지.
const passed = s.unreached ? false : posPx >= s.px - W / 2;
const lines = splitStructLabel(s.title);
return (
<div
key={i}
className={`${styles.segmentLabel} ${styles.structName} ${passed ? styles.accent : styles.neutral}`}
style={cssVars({ '--x': px(s.px) })}
title={`${s.category} · ${s.title} (${fmtKm(s.km)})`}
>
{lines.length === 1 ? lines[0] : <>{lines[0]}<br />{lines[1]}</>}
</div>
);
})}
</div>
<div className={styles.seekArea} onMouseDown={onSeekDown} />
</div>
{/* 구조물 속성 팝업 — 클릭 좌표 기준 fixed(트랙 래퍼 밖). 배경 클릭 시 닫힘. */}
{sel && (
<>
<div
onMouseDown={() => setSel(null)}
style={{ position: 'fixed', inset: 0, zIndex: 999 }}
/>
<div
onMouseDown={(e) => e.stopPropagation()}
style={{
position: 'fixed',
left: Math.min(sel.x + 4, window.innerWidth - 264),
top: Math.max(8, Math.min(sel.y - 8, window.innerHeight - 220)),
zIndex: 1000,
maxWidth: 260,
maxHeight: '45vh',
overflowY: 'auto',
background: 'rgba(0,0,0,0.92)',
border: '1px solid rgba(16,185,129,0.7)',
borderRadius: 6,
padding: 8,
color: '#fff',
fontSize: 11,
boxShadow: '0 6px 24px rgba(0,0,0,0.5)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
<b style={{ color: '#6ee7b7', wordBreak: 'break-all' }}>📍 {sel.s.title}</b>
<span onMouseDown={() => setSel(null)} style={{ cursor: 'pointer', color: '#9ca3af' }}></span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 8, rowGap: 2, fontFamily: 'monospace', fontSize: 10, color: '#d1d5db' }}>
{(sel.s.props ?? []).map((p, i) => (
<Fragment key={i}>
<span style={{ color: '#6b7280', whiteSpace: 'nowrap' }}>{p.k}</span>
<span style={{ wordBreak: 'break-all' }}>{p.v}</span>
</Fragment>
))}
</div>
</div>
</>
)}
</>
);
}
@@ -0,0 +1,75 @@
.cursor {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
transform: translateX(var(--cursor-x, 0px));
will-change: transform;
}
.line {
position: absolute;
left: -1px;
top: 991px;
width: 2px;
height: 37px;
background: var(--color-cursor);
z-index: 40;
/* 라인을 직접 잡아 드래그 가능하게 */
pointer-events: auto;
cursor: ew-resize;
}
.badge {
position: absolute;
left: 0;
/* 패널 상단(스테이지 980) 근처까지 키움 — 위 도구모음과는 안 겹치게 top 은 974 까지만. */
top: 974px;
transform: translateX(-50%);
z-index: 41;
/* 뱃지를 직접 잡아 드래그 가능하게 (라인과 동일) */
pointer-events: auto;
cursor: ew-resize;
user-select: none;
background: linear-gradient(180deg, #ff6a35 0%, #f8430d 45%, #ef3c08 100%);
border-radius: 7px;
height: 36px;
min-width: 112px;
padding: 0 9px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.4px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.35),
0 2px 6px rgba(0, 0, 0, 0.45);
&::after {
content: '';
position: absolute;
left: 50%;
bottom: -7px;
transform: translateX(-50%);
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 8px solid #ef3c08;
}
}
/* 역방향(km 감소, 하늘색 구간) 통과 시 커서도 동일한 파란색으로 표시 */
.cursor.reverse {
.line {
background: #06a4c8;
}
.badge {
background: linear-gradient(180deg, #3fb6d4 0%, #0a9bc0 45%, #067f9e 100%);
&::after {
border-top-color: #067f9e;
}
}
}
@@ -0,0 +1,20 @@
import type { MouseEvent } from 'react';
import styles from './TimelineCursor.module.scss';
interface TimelineCursorProps {
mileageText: string;
/** 현재 위치가 역방향(km 감소) 구간이면 커서를 파란색으로. */
reverse?: boolean;
/** 라인·뱃지를 직접 드래그해 이동시킬 때 호출 (seekArea와 동일 핸들러). */
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
}
// 커서 x 위치(--cursor-x)는 상위(StationBar wrapRef)에서 rAF로 직접 갱신한다(transform).
export function TimelineCursor({ mileageText, reverse, onSeekDown }: TimelineCursorProps) {
return (
<div className={`${styles.cursor} ${reverse ? styles.reverse : ''}`}>
<div className={styles.line} onMouseDown={onSeekDown} />
<div className={styles.badge} onMouseDown={onSeekDown}>{mileageText}</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { RouteProgressState } from '../types/timeline';
import { asset } from '../utils/asset';
const STRUCTURE_STATES: RouteProgressState[] = ['upcoming', 'passed', 'revisit'];
// 교량/터널 3분할 이미지는 RouteSegment.module.scss 에서 직접 url() 로 쓴다.
export function terminalCircleAsset(
state: Extract<RouteProgressState, 'upcoming' | 'passed' | 'revisit'>,
): string {
return asset(`/assets/route-segment/terminal/circle-${state}.png`);
}
export function isStructureAssetState(
state: RouteProgressState,
): state is Extract<RouteProgressState, 'upcoming' | 'passed' | 'revisit'> {
return STRUCTURE_STATES.includes(state);
}
+15
View File
@@ -0,0 +1,15 @@
import type { MileageMarkerSpec } from '../types/timeline';
/** Mileage figures along the top edge of the timeline bar. */
export const MILEAGE_MARKERS: MileageMarkerSpec[] = [
{ id: 'm-158k400', value: '158k400', left: 349.5, mileage: 158400 },
{ id: 'm-158k000', value: '158k000', left: 412.5, mileage: 158000 },
{ id: 'm-163k500', value: '163k500', left: 995, mileage: 163500 },
{ id: 'm-161k800', value: '161k800', left: 1186, mileage: 161800 },
{ id: 'm-163k100', value: '163k100', left: 1332, mileage: 163100 },
{ id: 'm-162k300', value: '162k300', left: 1420.5, mileage: 162300 },
{ id: 'm-163k400', value: '163k400', left: 1544, mileage: 163400 },
{ id: 'm-162k100-a', value: '162k100', left: 1688.5, mileage: 162100 },
{ id: 'm-162k600', value: '162k600', left: 1744, mileage: 162600 },
{ id: 'm-162k100-b', value: '162k100', left: 1803, mileage: 162100 },
];
+164
View File
@@ -0,0 +1,164 @@
import type { RouteLeg } from '../types/timeline';
export const STAGE_WIDTH = 1920;
export const STAGE_HEIGHT = 1080;
/** Horizontal bounds of the seekable track on the design stage. */
export const TRACK_START_PX = 297;
export const TRACK_END_PX = 1806.5;
export const TRACK_WIDTH_PX = TRACK_END_PX - TRACK_START_PX;
/**
* 타임라인 시작/끝 화면 좌표 — 좌표계의 단일 소스(single source of truth).
* 여백을 조정하려면 이 두 값만 바꾸면 된다. 압축 변환(scaleX/translateX),
* 커서 renderX(), 시킹 역변환, 트랙 래퍼 CSS transform 이 모두 여기서 파생된다.
*
* 트랙 좌표(297~1806.5)를 화면상 TRACK_RENDER_START~END 로 매핑.
* 두 역의 점 중앙 기준으로 좌우 대칭이라 양쪽 공백이 동일하게 유지된다.
*/
// 좌우 여백 기준 시작/끝점: 왼쪽 여백(컨트롤러→신탄진) 10px, 오른쪽 여백
// (대전→화면끝) 16px. 양쪽을 안쪽으로 당긴 만큼 트랙 가로폭이 늘어난다.
export const TRACK_RENDER_START_PX = 342;
export const TRACK_RENDER_END_PX = 1836;
/** 압축 비율·오프셋은 위 시작/끝에서 자동 계산 (수동 동기화 불필요). */
export const RENDER_SCALE_X =
(TRACK_RENDER_END_PX - TRACK_RENDER_START_PX) / TRACK_WIDTH_PX;
export const RENDER_TRANSLATE_X =
TRACK_RENDER_START_PX - RENDER_SCALE_X * TRACK_START_PX;
/** 트랙 좌표 → 화면(스테이지) x좌표 변환. */
export function renderX(px: number): number {
return RENDER_TRANSLATE_X + RENDER_SCALE_X * px;
}
/** 화면(스테이지) x좌표 → 트랙 좌표 역변환 (시킹용). */
export function trackXFromRender(stageX: number): number {
return (stageX - RENDER_TRANSLATE_X) / RENDER_SCALE_X;
}
/** 트랙 래퍼(.trackWrapper)에 인라인으로 적용할 transform 문자열. */
export const TRACK_WRAPPER_TRANSFORM = `translateX(${RENDER_TRANSLATE_X}px) scaleX(${RENDER_SCALE_X})`;
/** video_player 3840→1920 (×0.5) track chrome */
export const TRACK_FRAME_TOP_PX = 17.5;
export const TRACK_FRAME_HEIGHT_PX = 40.5;
export const TRACK_BAR_TOP_PX = 24.5;
export const TRACK_BAR_HEIGHT_PX = 12;
export const TRACK_BAR_RADIUS_PX = 6;
/** Matches the "진행 초기" mockup: cursor at 158k200 (on leg-02). */
export const INITIAL_POS_PX = 381.25;
/** Cursor speed while playing, in design px per second. */
export const PLAY_SPEED_PX_PER_SEC = 17;
/**
* Ten driving legs measured from the 4K mockup. Direction alternates at each
* turn; mileage anchors are interpolated linearly between calibration points.
*/
export const ROUTE_LEGS: RouteLeg[] = [
{
id: 'leg-01',
direction: 'down',
startPx: 297,
endPx: 349.5,
anchors: [
{ px: 297, mileage: 158700 },
{ px: 349.5, mileage: 158400 },
],
},
{
id: 'leg-02',
direction: 'up',
startPx: 349.5,
endPx: 413,
anchors: [
{ px: 349.5, mileage: 158400 },
{ px: 413, mileage: 158000 },
],
},
{
id: 'leg-03',
direction: 'down',
startPx: 413,
endPx: 996,
anchors: [
{ px: 413, mileage: 158000 },
{ px: 622, mileage: 160100 },
{ px: 913.5, mileage: 162100 },
{ px: 996, mileage: 162700 },
],
},
{
id: 'leg-04',
direction: 'up',
startPx: 996,
endPx: 1189,
anchors: [
{ px: 996, mileage: 163500 },
{ px: 1189, mileage: 161800 },
],
},
{
id: 'leg-05',
direction: 'down',
startPx: 1189,
endPx: 1333,
anchors: [
{ px: 1189, mileage: 161800 },
{ px: 1333, mileage: 163100 },
],
},
{
id: 'leg-06',
direction: 'up',
startPx: 1333,
endPx: 1423,
anchors: [
{ px: 1333, mileage: 163100 },
{ px: 1423, mileage: 162300 },
],
},
{
id: 'leg-07',
direction: 'down',
startPx: 1423,
endPx: 1545,
anchors: [
{ px: 1423, mileage: 162300 },
{ px: 1545, mileage: 163400 },
],
},
{
id: 'leg-08',
direction: 'up',
startPx: 1545,
endPx: 1691,
anchors: [
{ px: 1545, mileage: 163400 },
{ px: 1691, mileage: 162100 },
],
},
{
id: 'leg-09',
direction: 'down',
startPx: 1691,
endPx: 1746,
anchors: [
{ px: 1691, mileage: 162100 },
{ px: 1746, mileage: 162600 },
],
},
{
id: 'leg-10',
direction: 'up',
startPx: 1746,
endPx: 1806.5,
anchors: [
{ px: 1746, mileage: 162600 },
{ px: 1806.5, mileage: 162100 },
],
},
];
+6
View File
@@ -0,0 +1,6 @@
export const ROUTE_INFO = {
direction: '하 행',
routeName: '회덕-대전조차장',
lengthKm: '4.25',
duration: '11분 12초',
} as const;
+95
View File
@@ -0,0 +1,95 @@
import type { StructureSegment, TerminalMarker } from '../types/timeline';
import { mileageAtPx } from '../utils/mileage';
const BRIDGE_LEFT_PX = 339;
const BRIDGE_WIDTH_PX = 22;
const TUNNEL_LEFT_PX = 610;
const TUNNEL_WIDTH_PX = 24;
/**
* 메인 교량은 시작점(좌측)을 지나면 색이 바뀌고(passed), 우측 끝을 벗어나면
* 뒤의 반복 교량과 모양을 교환한다(메인 → revisit, 반복 → passed).
*/
export const BRIDGE_PASSED_AT_PX = BRIDGE_LEFT_PX; // 339 — 시작점 통과 시 색 변경
/**
* 아이콘을 완전히 지난 뒤(우측 끝 361), 다음 방향 전환 지점에서 반전한다.
* 메인 교량은 leg-02(상행)에 걸쳐 있고, 그 다음 상행→하행 전환점은 leg-03 시작(413).
*/
export const BRIDGE_SWAP_AT_PX = 413;
// 커서가 터널 아이콘 좌측 끝과 교차하는 순간부터 색이 바뀐다.
export const TUNNEL_PASSED_AT_PX = TUNNEL_LEFT_PX; // 610
/** video_player INACTIVE_BOW ×0.5 — 항상 테두리만 있는 회색 교량. */
const INACTIVE_BOW_LEFT_PX = 446.5;
const INACTIVE_BOW_WIDTH_PX = 22;
/** 라벨·점선 정렬 기준이 되는 교량 아이콘 가로 중앙 좌표. */
export const SINDAE_BRIDGE_CENTER_PX = BRIDGE_LEFT_PX + BRIDGE_WIDTH_PX / 2;
export const INACTIVE_BOW_CENTER_PX =
INACTIVE_BOW_LEFT_PX + INACTIVE_BOW_WIDTH_PX / 2;
/** Three-slice bridge/tunnel markers drawn on top of the track. */
export const STRUCTURE_SEGMENTS: StructureSegment[] = [
{
id: 'bridge-sindaecheon',
type: 'bridge',
label: '신대천과선교',
startMileage: mileageAtPx(BRIDGE_LEFT_PX),
endMileage: mileageAtPx(BRIDGE_LEFT_PX + BRIDGE_WIDTH_PX),
left: BRIDGE_LEFT_PX,
top: 32.5, // 높이 16 짝수화 후 중앙(40.5) 유지
width: BRIDGE_WIDTH_PX,
height: 16,
capWidth: 6,
centerHeight: 16, // 센터 높이를 양옆 캡(= height 16)과 동일하게
passedAtPx: BRIDGE_PASSED_AT_PX,
swapAtPx: BRIDGE_SWAP_AT_PX,
},
{
id: 'bridge-inactive',
type: 'bridge',
label: '미통과 교량',
startMileage: mileageAtPx(INACTIVE_BOW_LEFT_PX),
endMileage: mileageAtPx(INACTIVE_BOW_LEFT_PX + INACTIVE_BOW_WIDTH_PX),
left: INACTIVE_BOW_LEFT_PX,
top: 32.5, // 높이 16 짝수화 후 중앙(40.5) 유지
width: INACTIVE_BOW_WIDTH_PX,
height: 16,
capWidth: 6,
centerHeight: 16, // 센터 높이를 양옆 캡(= height 16)과 동일하게
// 같은 구간을 두 번째로 지나는 교량:
// ① 처음엔 반전 아이콘(revisit)
// ② 방향 전환점(413)을 지나면 일반 회색(upcoming)
// ③ 커서가 좌측 끝(446.5)과 교차하면 주황(passed)
appearance: 'revisit',
flipAtPx: BRIDGE_SWAP_AT_PX,
passedAtPx: INACTIVE_BOW_LEFT_PX,
},
{
id: 'tunnel-hoedeok',
type: 'tunnel',
label: '회덕터널(하)',
startMileage: mileageAtPx(TUNNEL_LEFT_PX),
endMileage: mileageAtPx(TUNNEL_LEFT_PX + TUNNEL_WIDTH_PX),
left: TUNNEL_LEFT_PX,
top: 33.75, // 높이 14 짝수화 후 중앙(40.75) 유지
width: TUNNEL_WIDTH_PX,
height: 14,
capWidth: 10,
centerHeight: 14, // 센터 높이를 양옆 캡(= height 14)과 동일하게
passedAtPx: TUNNEL_PASSED_AT_PX,
},
];
/** Terminal circles. Passed markers keep their orange state permanently. */
export const TERMINAL_MARKERS: TerminalMarker[] = [
// 시작/종료 원형은 트랙 라인(297 / 1806.5)에 원 중앙이 오도록 배치
// 사이즈 짝수화(13×13.5→14×14, 11×11.5→12×12), 원 중앙은 유지(left/top 보정)
{ id: 'terminal-01', left: 290, top: 33.75, width: 14, height: 14, state: 'passed' },
// 커서가 원 좌측 끝(906.5)과 교차하는 순간부터 passed(주황)로 바뀐다.
{ id: 'terminal-02', left: 906.5, top: 33.75, width: 14, height: 14, state: 'passed', passedAtPx: 906.5 },
{ id: 'terminal-03', left: 1148, top: 34.25, width: 12, height: 12, state: 'revisit' },
{ id: 'terminal-04', left: 1216.5, top: 34.25, width: 12, height: 12, state: 'revisit' },
{ id: 'terminal-05', left: 1685, top: 34.25, width: 12, height: 12, state: 'revisit' },
{ id: 'terminal-06', left: 1799.5, top: 33.75, width: 14, height: 14, state: 'passed' },
];
+56
View File
@@ -0,0 +1,56 @@
import type { SegmentLabel, ZoneRule } from '../types/timeline';
import { TRACK_END_PX, TRACK_START_PX } from './route';
import {
BRIDGE_PASSED_AT_PX,
INACTIVE_BOW_CENTER_PX,
SINDAE_BRIDGE_CENTER_PX,
TERMINAL_MARKERS,
TUNNEL_PASSED_AT_PX,
} from './segments';
/** 터미널 원 중앙 x좌표 — 존 점선이 원 중앙에 걸리도록 파생. */
function terminalCenter(id: string): number {
const marker = TERMINAL_MARKERS.find((m) => m.id === id);
if (!marker) throw new Error(`Unknown terminal marker: ${id}`);
return marker.left + marker.width / 2;
}
/** Section / structure names below the track. */
export const SEGMENT_LABELS: SegmentLabel[] = [
// 텍스트 센터를 트랙 시작점에 맞춤 (중앙 정렬)
{ text: '회덕', left: TRACK_START_PX, color: 'fixed-accent' },
{
text: '신대천과선교',
left: (SINDAE_BRIDGE_CENTER_PX + INACTIVE_BOW_CENTER_PX) / 2,
color: { dynamic: BRIDGE_PASSED_AT_PX },
},
{ text: '회덕터널(하)', left: 622, color: { dynamic: TUNNEL_PASSED_AT_PX } },
{
text: '대전조차장',
left: 1358,
color: { dynamic: terminalCenter('terminal-02') },
},
{
// 텍스트 센터를 트랙 끝점에 맞춤 (중앙 정렬)
text: '대전조차장',
left: TRACK_END_PX,
color: 'fixed-accent',
},
];
/**
* Dashed zone connectors under the track. 첫/끝 앵커 = 박스 양 끝,
* 중간 앵커 = 세로 점선만 내려와 아이콘들이 한 줄로 연결돼 보인다.
*/
export const LABEL_ZONES: ZoneRule[] = [
{ anchors: [SINDAE_BRIDGE_CENTER_PX, INACTIVE_BOW_CENTER_PX] },
{
anchors: [
terminalCenter('terminal-02'),
terminalCenter('terminal-03'),
terminalCenter('terminal-04'),
terminalCenter('terminal-05'),
terminalCenter('terminal-06'),
],
},
];
+26
View File
@@ -0,0 +1,26 @@
/* videoplayer-main globals.scss 의 디자인 토큰(:root)만 이식.
html/body 리셋·box-sizing 은 abcVideo(Tailwind preflight)가 이미 제공하므로 제외. */
:root {
--color-stage-bg: #0d0b08;
--color-cursor: #ed4a17;
--color-accent: #ff9447;
--color-label: #f3f0ea;
--color-label-outline: #241409;
--color-track-down: #7a7a7a;
--color-track-up: #637789;
--color-track-divider: #241d12;
--color-station: #b7b5b0;
--color-station-leader: #8f8c85;
--color-station-dot: #98958e;
--color-input-text: #d8d2c6;
--color-input-placeholder: #878074;
--color-projection-line: rgba(46, 221, 255, 0.85);
--color-projection-text: #aef4ff;
--color-projection-bg: rgba(10, 30, 36, 0.72);
--color-projection-border: rgba(46, 221, 255, 0.55);
--gradient-track-down-passed: linear-gradient(90deg, #ffc257, #ff8a25 45%, #ff7b1b);
--gradient-track-up-passed: linear-gradient(90deg, #5ca887, #35a7a7 55%, #06a4c8);
--font-ui: 'Noto Sans KR', sans-serif;
}
+106
View File
@@ -0,0 +1,106 @@
export type Direction = 'down' | 'up';
export type RouteProgressState = 'upcoming' | 'current' | 'passed' | 'revisit';
export type SegmentType = 'bridge' | 'tunnel' | 'station' | 'normal' | 'obstacle';
export type StructureType = Extract<SegmentType, 'bridge' | 'tunnel'>;
/** Timeline mileage label with interaction states. */
export interface MileageMarkerSpec {
id: string;
value: string;
/** Design-stage px position. */
left: number;
/** Mileage in meters for progress resolution. */
mileage: number;
active?: boolean;
selected?: boolean;
}
export type StructureAppearance = 'dynamic' | 'passed' | 'revisit';
/** A px↔mileage calibration point on the 1920px design stage. */
export interface MileageAnchor {
px: number;
/** Mileage in meters (158200 → "158k200"). */
mileage: number;
}
/**
* One driving leg of the route. Mileage is non-monotonic across legs because
* the route contains turns; each leg carries its own piecewise-linear mapping.
*/
export interface RouteLeg {
id: string;
direction: Direction;
startPx: number;
endPx: number;
anchors: MileageAnchor[];
}
/** A bridge/tunnel drawn with a three-slice (left cap / stretch / right cap) asset. */
export interface StructureSegment {
id: string;
type: StructureType;
label: string;
startMileage: number;
endMileage: number;
left: number;
top: number;
width: number;
height: number;
capWidth: number;
centerHeight: number;
/** Cursor px at/after which the structure counts as passed (시작점 통과 시 색 변경). */
passedAtPx: number;
/**
* 커서가 아이콘 우측 끝을 벗어나는 px. 설정 시 반복쌍이 모양을 교환한다:
* 메인(dynamic) 아이콘은 이 지점을 지나면 passed → revisit,
* 뒤의 반복 아이콘(appearance:'revisit')은 revisit → passed 로 바뀐다.
*/
swapAtPx?: number;
/**
* appearance:'revisit' 전용. 방향 전환 지점 px. 이 지점 전에는 반전(revisit)으로
* 보이고, 지난 뒤에는 일반 upcoming → (passedAtPx) passed 로 진행한다.
*/
flipAtPx?: number;
/** Fixed visual mode; default is cursor-driven upcoming/passed. */
appearance?: StructureAppearance;
}
export interface TerminalMarker {
id: string;
left: number;
top: number;
width: number;
height: number;
state: Extract<RouteProgressState, 'passed' | 'revisit'>;
/** When set, the marker is dynamic: passed at/after this px, upcoming before. */
passedAtPx?: number;
}
export type SegmentLabelColor =
| 'fixed-accent'
| 'fixed-neutral'
| { dynamic: number };
export interface SegmentLabel {
text: string;
left: number;
/** When set, label is centered inside a fixed-width box (video_player Pos pattern). */
width?: number;
/**
* 앵커 기준 정렬. 기본은 left 좌표 중앙 정렬.
* 'end' = 텍스트 오른쪽 끝이 left에 닿음, 'start' = 텍스트 왼쪽 끝이 left에서 시작.
*/
anchor?: 'start' | 'end';
/** fixed-* = Figma 고정색, dynamic = 커서 passedAtPx 이후 accent */
color: SegmentLabelColor;
}
/** Dashed-border zone box under the track (video_player "zone" pattern). */
export interface ZoneRule {
/** 연결 지점 x좌표(아이콘 중앙). 첫/끝 = 박스 양 끝, 중간 = 세로 점선. */
anchors: number[];
}
+8
View File
@@ -0,0 +1,8 @@
/**
* 정적 에셋(public) 절대경로에 Vite의 base(`import.meta.env.BASE_URL`)를 붙인다.
* GitHub Pages 하위 경로(/videoplayer/) 배포에서도 JS로 만든 경로가 깨지지 않게 한다.
* CSS url() 은 Vite가 자동으로 base를 붙이므로 이 헬퍼가 필요 없다.
*/
export function asset(path: string): string {
return import.meta.env.BASE_URL + path.replace(/^\//, '');
}
+13
View File
@@ -0,0 +1,13 @@
import type { CSSProperties } from 'react';
/**
* Bridges data-driven values (positions, widths) into SCSS modules as CSS
* custom properties, keeping all declarative styling in the stylesheets.
*/
export function cssVars(vars: Record<`--${string}`, string | number>): CSSProperties {
return vars as CSSProperties;
}
export function px(value: number): string {
return `${value}px`;
}
+62
View File
@@ -0,0 +1,62 @@
import { ROUTE_LEGS } from '../mocks/route';
import type { RouteLeg } from '../types/timeline';
const MILEAGE_ROUND_M = 10;
const METERS_PER_KM = 1000;
/** Mileage in meters at a cursor px, via piecewise-linear leg anchors. */
export function mileageAtPx(px: number, legs: RouteLeg[] = ROUTE_LEGS): number {
const leg =
legs.find((l) => px >= l.startPx && px <= l.endPx) ?? legs[legs.length - 1];
const anchors = leg.anchors;
for (let i = 0; i < anchors.length - 1; i++) {
const a = anchors[i];
const b = anchors[i + 1];
if (px <= b.px || i === anchors.length - 2) {
const t = Math.min(1, Math.max(0, (px - a.px) / (b.px - a.px)));
return a.mileage + (b.mileage - a.mileage) * t;
}
}
return anchors[0].mileage;
}
/**
* First px at which the given mileage occurs. Leg start anchors win so a jump
* to a turn mileage lands on the start of that leg.
*/
export function pxForMileage(
mileage: number,
legs: RouteLeg[] = ROUTE_LEGS,
): number | null {
for (const leg of legs) {
if (leg.anchors[0].mileage === mileage) return leg.startPx;
}
for (const leg of legs) {
const anchors = leg.anchors;
for (let i = 0; i < anchors.length - 1; i++) {
const a = anchors[i];
const b = anchors[i + 1];
const lo = Math.min(a.mileage, b.mileage);
const hi = Math.max(a.mileage, b.mileage);
if (mileage >= lo && mileage <= hi) {
return a.px + (b.px - a.px) * ((mileage - a.mileage) / (b.mileage - a.mileage));
}
}
}
return null;
}
/** 158204 → "158k200". */
export function formatMileage(mileage: number): string {
const rounded = Math.round(mileage / MILEAGE_ROUND_M) * MILEAGE_ROUND_M;
const km = Math.floor(rounded / METERS_PER_KM);
const m = rounded % METERS_PER_KM;
return `${km}k${String(m).padStart(3, '0')}`;
}
/** Accepts "158k200", "158200", "161800"… Returns meters or null. */
export function parseMileageQuery(raw: string): number | null {
const digits = raw.toLowerCase().replace(/k/g, '').replace(/[^0-9]/g, '');
const mileage = parseInt(digits, 10);
return Number.isFinite(mileage) && mileage > 0 ? mileage : null;
}
+26
View File
@@ -0,0 +1,26 @@
import type { MileageMarkerSpec } from '../types/timeline';
const SELECT_THRESHOLD_PX = 12;
/** Derives active (passed) and selected (near cursor) flags for mileage markers. */
export function enrichMileageMarkers(
markers: MileageMarkerSpec[],
posPx: number,
): MileageMarkerSpec[] {
let selectedId: string | null = null;
let minDist = Infinity;
for (const marker of markers) {
const dist = Math.abs(posPx - marker.left);
if (dist < minDist) {
minDist = dist;
selectedId = marker.id;
}
}
return markers.map((marker) => ({
...marker,
active: posPx >= marker.left,
selected: marker.id === selectedId && minDist <= SELECT_THRESHOLD_PX,
}));
}
+62
View File
@@ -0,0 +1,62 @@
import type { RouteProgressState, StructureSegment, TerminalMarker } from '../types/timeline';
/** Resolves bridge/tunnel marker state from cursor position and segment metadata. */
export function resolveStructureState(
posPx: number,
segment: StructureSegment,
): Extract<RouteProgressState, 'upcoming' | 'passed' | 'revisit'> {
if (segment.appearance === 'passed') {
return 'passed';
}
if (segment.appearance === 'revisit') {
// 같은 구간을 두 번째로 지나는 아이콘:
// 방향 전환점(flipAtPx) 전엔 반전(revisit), 이후엔 일반 upcoming →
// 커서가 지나면(passedAtPx) passed.
if (segment.flipAtPx !== undefined) {
if (posPx < segment.flipAtPx) return 'revisit';
return posPx >= segment.passedAtPx ? 'passed' : 'upcoming';
}
// 반복쌍의 뒤 아이콘: 메인이 교환되는 지점을 지나면 passed, 그 전엔 revisit
if (segment.swapAtPx !== undefined) {
return posPx >= segment.swapAtPx ? 'passed' : 'revisit';
}
return posPx >= segment.passedAtPx ? 'revisit' : 'upcoming';
}
// 메인 아이콘: upcoming → (시작점 통과) passed → (아이콘 벗어남) revisit
if (segment.swapAtPx !== undefined && posPx >= segment.swapAtPx) {
return 'revisit';
}
return posPx >= segment.passedAtPx ? 'passed' : 'upcoming';
}
/** Resolves terminal circle state. Passed terminals keep orange permanently. */
export function resolveTerminalState(
posPx: number,
marker: TerminalMarker,
): Extract<RouteProgressState, 'upcoming' | 'passed' | 'revisit'> {
if (marker.state === 'revisit') return 'revisit';
if (marker.state === 'passed' && marker.passedAtPx === undefined) return 'passed';
if (marker.passedAtPx !== undefined) {
return posPx >= marker.passedAtPx ? 'passed' : 'upcoming';
}
return marker.state;
}
/** Derives segment progress state from mileage range and current mileage. */
export function resolveSegmentStateFromMileage(
currentMileage: number,
startMileage: number,
endMileage: number,
appearance: StructureSegment['appearance'] = 'dynamic',
): RouteProgressState {
const lo = Math.min(startMileage, endMileage);
const hi = Math.max(startMileage, endMileage);
if (appearance === 'passed') return 'passed';
if (appearance === 'revisit') {
return currentMileage >= lo ? 'revisit' : 'upcoming';
}
if (currentMileage > hi) return 'passed';
if (currentMileage >= lo && currentMileage <= hi) return 'current';
return 'upcoming';
}
+13
View File
@@ -0,0 +1,13 @@
import type { SegmentLabelColor } from '../types/timeline';
export type SegmentLabelTone = 'endpoint' | 'accent' | 'neutral';
/** Maps segment label color spec to SCSS tone classes. */
export function segmentLabelTone(
color: SegmentLabelColor,
posPx: number,
): SegmentLabelTone {
if (color === 'fixed-accent') return 'endpoint';
if (color === 'fixed-neutral') return 'neutral';
return posPx >= color.dynamic ? 'accent' : 'neutral';
}
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
import type { Annotation } from '@abcvideo/shared';
interface AnnotationStore {
annotations: Annotation[];
setAnnotations: (a: Annotation[]) => void;
addAnnotation: (a: Annotation) => void;
updateAnnotation: (id: string, a: Partial<Annotation>) => void;
removeAnnotation: (id: string) => void;
}
export const useAnnotationStore = create<AnnotationStore>((set) => ({
annotations: [],
setAnnotations: (annotations) => set({ annotations }),
addAnnotation: (annotation) =>
set((s) => ({
annotations: [...s.annotations, annotation].sort((a, b) => a.timeStart - b.timeStart),
})),
updateAnnotation: (id, update) =>
set((s) => ({
annotations: s.annotations.map((a) => (a.id === id ? { ...a, ...update } : a)),
})),
removeAnnotation: (id) =>
set((s) => ({ annotations: s.annotations.filter((a) => a.id !== id) })),
}));

Some files were not shown because too many files have changed in this diff Show More