UI 수정

기획안 반영 및 보완
This commit is contained in:
b23042
2026-06-19 14:35:19 +09:00
committed by 한성일
parent 7d06e384bf
commit 819065a8f5
27 changed files with 2474 additions and 461 deletions

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>