# 기능: 폴더 선택 기반 영상+데이터 동시 로딩 > 설계 문서 (① 설계 단계). 이 문서는 구현 지시서이며, 코드 변경은 ②구현 단계 에이전트가 수행한다. > 모든 경로는 프로젝트 루트 `/home/hanmac/projects/gitea/b23042/DefVideo` 기준 상대경로로 표기한다. --- ## 목표 / 사용자 시나리오 영상과 지리정보(드론 GPS/자세, 측점, POI, 선로 중심선)를 **하나의 폴더 선택으로 동시에 로드**한다. 현재는 영상은 클라이언트 File API로, 지리정보는 서버가 고정 디렉토리(`samplevideo`) + 하드코딩 영상명(`'회덕'`)으로 따로 로드하므로 두 데이터가 연결되지 않는다. ### 사용자 시나리오 1. 사용자가 영상 플레이어에서 **"폴더 선택"** 컨트롤을 클릭한다. 2. OS 폴더 선택 다이얼로그에서 영상 + 데이터가 들어있는 폴더(예: `samplevideo/`)를 고른다. 3. 브라우저가 `webkitdirectory`로 폴더 내 모든 파일(하위 `building/` 포함)을 노출한다. 4. 클라이언트가: - 폴더에서 영상 파일(`.mp4/.MP4/.webm`)을 찾아 `loadLocalFile`로 재생한다. - `.csv`(드론 프레임), `center.csv`(중심선), `building/_POI_위경도값.csv`, `building/_측점_위경도값.csv`를 **클라이언트에서 직접 파싱**해 Zustand 지리정보 스토어에 적재한다. 5. 우측 패널(지리정보/측점)과 영상 오버레이(측점/POI/중심선)가 **선택한 폴더의 데이터** 기준으로 즉시 표시된다. 6. **영상명에 의존하지 않는다**: `'회덕'` 하드코딩 제거. 동일한 폴더 구조면 어떤 영상이든 동작한다. ### 폴백 - 폴더가 아직 선택되지 않았으면 지리정보 스토어는 비어 있다. 각 소비 컴포넌트는 빈 상태(측점 0개, 검색 결과 없음, 오버레이 미표시)를 표시한다. - (선택) 스토어가 비었을 때 기존 `/api/geo/*` 서버 API로 폴백할 수 있으나, **기본 동작은 클라이언트 스토어 우선**. 이 문서에서는 서버 폴백을 "옵션"으로 두고, 기본 구현은 스토어 단일 소스로 한다. --- ## 제약 - **브라우저 폴더 접근**: `` 사용. Chrome/Firefox/Edge(PC) 지원 — 프로젝트 대상 브라우저와 일치(CLAUDE.md). iOS Safari 비대상. - 선택된 각 `File`은 `file.webkitRelativePath`(예: `samplevideo/building/...csv`)로 폴더 내 상대경로를 노출한다. - 첫 세그먼트는 사용자가 고른 루트 폴더명이므로, 매칭 시 **basename + 마지막 디렉토리 세그먼트**로 판단한다. - **클라이언트 전용**: 서버 의존 없음. 파일 업로드 없이 File API로 직접 처리. - **인코딩**: POI/측점 CSV와 `center.csv`는 **EUC-KR**(서버는 `iconv-lite`로 디코딩). 클라이언트에는 `iconv-lite`가 없으므로 브라우저 **`TextDecoder('euc-kr')`**(Encoding API legacy label, Chrome/FF/Edge 지원)로 디코딩한다. 드론 `.csv`는 **UTF-8 + BOM**. - **메모리**: 영상은 `URL.createObjectURL()`로 직접 재생, 사용 후 `revokeObjectURL`(기존 `useVideoPlayer.loadLocalFile`가 이미 처리). - **좌표 투영**: 클라이언트는 이미 `proj4`(EPSG:5186) 기반 투영 유틸을 보유. 서버 `geoMatch.ts`는 단순 ENU(cos-lat 근사) 방식이라 투영식이 다름 — **데이터 로딩/파싱/검색 로직만 포팅**하고 투영은 기존 클라이언트 유틸(`geoProjection.ts`)을 재사용한다(아래 상세). --- ## 파일 명명 규칙 & CSV 스키마 `` = 영상 파일명에서 확장자를 제거한 이름. 예: `하행)회덕-대전조차장.MP4` → base `하행)회덕-대전조차장`. ``` / .MP4 (또는 .mp4/.webm) → 영상 (loadLocalFile / createObjectURL) .csv → 드론 프레임 (UTF-8 BOM) .srt → 프레임 메타(현재 geo 시스템 미파싱 — 주의) center.csv → 중심선 (224행, EUC-KR) building/_POI_위경도값.csv → POI 건물 (EUC-KR) building/_측점_위경도값.csv → 측점 (EUC-KR) building/_POI_XY값.csv → 미사용 building/_측점_XY값.csv → 미사용 ``` ### 1) 드론 프레임 `.csv` (UTF-8, BOM 있음) 헤더(BOM 제거 후): `frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len` ``` frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len 0,36.402256,127.421515,84.244,114.7,-29.8,0.0,24.00 1,36.402256,127.421515,84.244,114.7,-29.8,0.0,24.00 ``` - 컬럼은 **헤더 이름으로 인덱스 조회**(`header.indexOf('frame_cnt')` 등). 서버 `geoMatch.ts:290-302`와 동일. - 매핑: `frame=frame_cnt(int)`, `lat=latitude`, `lon=longitude`, `altitude`, `yaw`, `pitch`, `roll`, `focalLen=focal_len`. - `!isNaN(lat)` 행만 채택. - 구분자 `,`. 따옴표 처리는 `parseCsvLine` 동일 규칙. ### 2) 중심선 `center.csv` (EUC-KR — 헤더 한글이 깨지므로 **위치 인덱스 직접 사용**) 헤더(깨진 상태): `id,lat,lon,표고(H),지오이드높이(N),타원체고(h),67.39,...,x,y` ``` id,lat,lon,,<깨짐>,<깨짐(h)>,67.39,41.57...,,x,y 0,36.4087069,127.4256135,44.5959549,25.449,70.0449549,2.6549549,...,238176.0008,423480.0717 ``` - 서버 `geoMatch.ts:251-257` 규칙: **인덱스 1=lat, 2=lon, 5=타원체고(h)=z**. 헤더 이름 의존 금지(EUC-KR 깨짐 회피). - `!isNaN(lat&&lon&&z)` 행만 채택. 헤더 1행 skip. ### 3) POI `building/_POI_위경도값.csv` (EUC-KR) 헤더: `title,category_clean,address_road,address_jibun,lat,lon,z` ``` title,category_clean,address_road,address_jibun,lat,lon,z 한국철도공사대전철도역사,역사,대전 ...,대전 ...,36.371101,127.421776,14.93833585 대전역,역사,...,...,36.380398,127.422422,17.43774457 ``` - 매핑(헤더 이름 조회, `geoMatch.ts:323-335`): `title`, `category = category_clean || '건물'`, `lat`, `lon`, `z = z || 0`, `type='poi'`. - 파일 선택 우선순위(`geoMatch.ts:317-320`): 파일명에 `타원체고` 포함 버전이 있으면 그것 우선, 없으면 `타원체고` 미포함 버전. (현재 샘플엔 `타원체고` 파일 없음 → 기본 `_POI_위경도값.csv` 사용.) - `category_clean` 값도 EUC-KR(터널/교량/역사/지장물). `TextDecoder('euc-kr')`로 정상 복원돼야 RoutePanel 카테고리 필터/이모지가 동작한다. ### 4) 측점 `building/_측점_위경도값.csv` (EUC-KR) 헤더: `title,category_clean,address_road,address_jibun,lat,lon,z` (POI와 동일) ``` title,category_clean,address_road,address_jibun,lat,lon,z 157K900,,,,36.40296455,127.4218765,0.023576703 158K000,,,,36.40222619,127.4212379,-0.148290668 ``` - 매핑(`geoMatch.ts:345-355`): `title`, `category='측점'`(강제), `lat`, `lon`, `z = z || 0`, `type='station'`. - `title`은 `157K900` 형태(km 정렬에 사용: 정규식 `/(\d+)[Kk](\d+)/`). ### 5) SRT `.srt` (UTF-8) — 참고용, 현재 geo 미사용 ``` 1 00:00:00,000 --> 00:00:00,033 FrameCnt: 0 2026-03-05 17:18:28.405 [... focal_len: 24.00] [latitude: 36.402256] [longitude: 127.421515] [rel_alt: 16.853 abs_alt: 84.244] [gb_yaw: 114.7 gb_pitch: -29.8 gb_roll: 0.0] ... ``` - 서버 `geoMatch.ts:208-222 loadTerrainOffset`만 SRT를 읽어 `terrain offset = abs_alt - rel_alt`를 계산하나, **현재 코드 경로에서 이 offset은 어디에도 적용되지 않는다**(`_terrainOffset`은 set만 되고 사용처 없음). → 이번 포팅에서 SRT는 **파싱하지 않아도 무방**(현 동작 동일). 문서상 명시. --- ## 현재 구조 (As-Is) ### 영상 선택 - `client/src/components/player/VideoPlayer.tsx:194-205` — "파일 선택" 라벨 + ``, `onChange`에서 `loadLocalFile(file)` 호출. - 드래그앤드롭: `VideoPlayer.tsx:96-100`. - `client/src/hooks/useVideoPlayer.ts:63-81 loadLocalFile` — `URL.createObjectURL(file)` → `player.src(...)` → `store.setSource({kind:'local', file, objectUrl})` → `emptied` 시 `revokeObjectURL`. - 소스 타입: `client/src/types/player.ts:1-3 VideoSource`. ### 지리정보 (서버 단일 소스) - 데이터 디렉토리: `server/src/routes/geo.ts:8-11` — `GEO_DATA_DIR = process.env.GEO_DATA_DIR || /samplevideo`, `setGeoDataDir()` 호출. - 서버 서비스 `server/src/services/geoMatch.ts` (싱글턴 캐시 `_frames/_pois/_centerline`): - CSV 파서 `parseCsvLine`(162-173), `readCsvUtf8`(175-192, EUC-KR/UTF-8 BOM 자동 판별, `iconv-lite`). - 투영: `geoToEnu`(78-87, cos-lat 근사 ENU), `projectEnu`(90-124), `project3D`(127-156). - 로더: `loadFrames`(275-305), `loadPois`(307-361), `loadCenterline`(236-261). - ⚠ `loadFrames:285` — `f.includes('회덕')` **하드코딩**. (제거 대상) - `loadCenterline:239` — `CENTER_CSV_PATH` 또는 `pythonsource/input/center.csv` 경로(샘플 폴더의 center.csv와 별개일 수 있음). - 원점: `getWorldOrigin`(378-387) — 첫 측점(`stationOrder` 정렬) 사용, 없으면 frame[0]. - 검색 API: `findFramesForPoi`(399-468), `findPoisForFrame`(473-512), `getAllPois`(515-517), `getDroneFrames`(520-522), `getCenterlinePoints`(263-265). - HTTP 라우트 `server/src/routes/geo.ts`: - `GET /api/geo/pois`(14-21) → `getAllPois()` → `GeoPoint[]` - `GET /api/geo/search?q=&margin=&maxDist=&yawOffset=`(27-45) → `{poi, frames: FrameMatch[]}` - `GET /api/geo/frame/:frameNum?margin=&yawOffset=`(51-64) → `{droneFrame, pois: PoiInFrame[]}` - `GET /api/geo/frames?step=`(70-79) → 샘플링된 `DroneFrame[]` - `GET /api/geo/centerline`(85-92) → `CenterlinePoint[]` ### 클라이언트 투영 (이미 존재 — 재사용) - `client/src/utils/geoProjection.ts`: - `proj4` EPSG:5186 정의(70-72), `latLonToTM`(76-80), `geoToEnu`(86-92, TM 기반). - `CameraParams` / `DEFAULT_CAMERA_PARAMS`(35-62), `paramsFromFrame`(65-67). - `toCameraCoords`(175-184), `pixelFromCamera`(115-126), `projectPoint`(201-294). - **오버레이(StationOverlay/RoutePanel)는 이 유틸로 자체 투영**한다. 서버 `project3D`와 다른 알고리즘. → 검색용 `findFramesForPoi`/`findPoisForFrame`만 서버에 의존. ### 클라이언트 소비 컴포넌트 (각 fetch 정리) | 컴포넌트 | fetch | 기대 응답 형태 | 용도 | |---|---|---|---| | `GeoSearch.tsx:61` | `GET /api/geo/pois` | `GeoPoint[]` | 자동완성 목록 | | `GeoSearch.tsx:82` | `GET /api/geo/search?q=&margin=1.0&maxDist=1500` | `{poi:GeoPoint, frames:FrameMatch[]}` | 건물→프레임 검색 | | `GeoSearch.tsx:98` | `GET /api/geo/frame/{currentFrame}?margin=1.0` | `{pois:PoiInFrame[]}` | 프레임→건물 역조회 | | `StationVerify.tsx:57` | `GET /api/geo/pois` | `GeoPoint[]` (station만 필터) | 측점 목록 | | `StationVerify.tsx:69` | `GET /api/geo/frames?step=1` | `{frame,lat,lon}[]` | GPS 최근접 프레임 | | `StationVerify.tsx:102` | `GET /api/geo/search?q=&margin=1.2&maxDist=2000` | `{poi,frames:FrameMatch[]}` | 검증 정보 | | `StationOverlay.tsx:186` | `GET /api/geo/pois` | `GeoPoint[]` (station/poi 분리) | 라벨 사전계산 | | `StationOverlay.tsx:197` | `GET /api/geo/centerline` | `CenterlinePoint[]` | 중심선 | | `StationOverlay.tsx:206` | `GET /api/geo/frames?step=1` | `DroneFrameBasic[]` | 드론 프레임 | | `RoutePanel.tsx:75` | `GET /api/geo/pois` | `GeoPoint[]` | 미니맵 측점/POI | | `RoutePanel.tsx:87` | `GET /api/geo/frames?step=1` | `DroneFrameBasic[]` | 현재 km 계산 | ### 상태/배선 - `client/src/App.tsx` — 우측 탭 `rightTab: 'annotation'|'geo'|'station'`(19), GeoSearch(151-157)/StationVerify(158-163) 렌더. `currentFrame = round(currentTime*fps)`(35). - `client/src/store/playerStore.ts` — 영상 재생 상태(source/currentTime/fps 등). 지리정보 스토어 없음. - `playerRef.loadServerStream`은 좌측 VideoList에서, `loadLocalFile`은 VideoPlayer 내부 input/드롭에서 호출. --- ## 변경 설계 (To-Be) ### 개요 1. 폴더 선택 UI → 폴더 내 파일 목록(`File[]` + `webkitRelativePath`)을 단일 파서 진입점에 넘긴다. 2. 새 **클라이언트 지리정보 스토어**(`geoStore`)가 파싱 결과(frames/pois/stations/centerline/origin)를 보관. 3. CSV 파싱 + 서버 `geoMatch` 검색 로직을 클라이언트 유틸로 포팅(투영은 기존 `geoProjection.ts` 재사용). 4. 4개 소비 컴포넌트의 `/api/geo/*` fetch → `geoStore` 셀렉터 + 포팅 함수 호출로 교체. 5. 영상 파일은 폴더에서 찾아 기존 `loadLocalFile`로 재생. --- ### New: 폴더 선택 UI **파일**: `client/src/components/player/VideoPlayer.tsx` 수정. - 기존 "파일 선택"(194-205) 옆에 **"폴더 선택"** 라벨+input 추가: ```tsx ``` - `webkitdirectory` 타입 보강: `client/src/types/`에 전역 선언 추가하거나 `// @ts-expect-error`. (권장: `client/src/vite-env.d.ts` 또는 새 `client/src/types/dom.d.ts`에 `HTMLInputElement` 확장 선언.) - `onSelectFolder(files: File[])` 핸들러(VideoPlayer 내부 또는 App에서 전달): 1. `loadFolderGeoData(files)` 호출 → 파싱 + `geoStore` 채움(영상 File도 함께 반환/식별). 2. 영상 File을 찾으면 `loadLocalFile(videoFile)`. - 기존 "파일 선택"(단일 영상)은 **유지**(지리정보 없이 영상만). --- ### New: 클라이언트 지리정보 스토어 (Zustand) **신규 파일**: `client/src/store/geoStore.ts` ```ts import { create } from 'zustand'; // 타입은 client/src/types/geo.ts(신규)로 분리 권장 export interface DroneFrame { frame:number; lat:number; lon:number; altitude:number; yaw:number; pitch:number; roll:number; focalLen:number; } export interface GeoPoint { title:string; category:string; lat:number; lon:number; z:number; type:'poi'|'station'; } export interface CenterlinePoint { lat:number; lon:number; z:number; } interface GeoStore { loaded: boolean; frames: DroneFrame[]; pois: GeoPoint[]; // type==='poi' stations: GeoPoint[]; // type==='station' (stationOrder 정렬) centerline: CenterlinePoint[]; origin: { lat:number; lon:number; alt:number } | null; // 첫 측점 = ENU 원점 baseName: string | null; setGeoData: (d: Partial) => void; reset: () => void; } export const useGeoStore = create((set) => ({ /* 초기값 + setGeoData/reset */ })); ``` - `pois`/`stations`는 `getAllPois()` 결과를 `type`으로 분리해 저장(소비처 편의). 또는 통합 `pois` 하나만 두고 셀렉터로 분리해도 됨 — **분리 저장 권장**(현 컴포넌트들이 매번 filter 함). - `origin` = 첫 측점(`stationOrder` 최소). 서버 `getWorldOrigin` 동치. --- ### New: 클라이언트 CSV 파싱 + geoMatch 포팅 **신규 파일**: `client/src/utils/geoData.ts` (파싱 + 로딩 + 검색 포팅) #### 인코딩/파싱 헬퍼 (서버 `readCsvUtf8`/`parseCsvLine` 포팅) - `parseCsvLine(line)` — 서버 `geoMatch.ts:162-173` 그대로 포팅(순수 함수, 의존성 없음). - `decodeBytes(buf: ArrayBuffer, encoding:'utf-8'|'euc-kr')` — `new TextDecoder(encoding).decode(buf)`. UTF-8 BOM 제거(`replace(/^/,'')`). EUC-KR은 `TextDecoder('euc-kr')`. - 드론 csv: UTF-8(BOM). POI/측점/center: EUC-KR. - 서버처럼 자동판별(`iconv`)이 아니라 **파일 종류별로 인코딩 지정**(폴더 구조 알고 있으므로 단순). center/POI/측점은 EUC-KR 고정. - `readCsv(file: File, encoding)` → `Promise` — `await file.arrayBuffer()` → decode → `split(/\r?\n/).filter(Boolean).map(parseCsvLine)`. #### 로더 (서버 loader 포팅, but 파일은 `File[]`에서 매칭) - `findVideoFile(files)` — 확장자 `.mp4/.webm`(대소문자 무시), `building/` 미포함. → base 추출. - `parseDroneFrames(file): Promise` — 서버 `loadFrames:289-302` 로직(헤더 이름 인덱스). **`회덕` 필터 제거**, base 일치 또는 `building/` 미포함 루트 csv로 식별. - `parsePois(poiFile, stationFile): Promise` — 서버 `loadPois:321-357` 포팅. POI=`category_clean||'건물'`,`type:'poi'`; 측점=`category:'측점'`,`type:'station'`. 파일 선택은 `building/` 내 `webkitRelativePath`에서 `POI`+`위경도`, `측점`+`위경도` 매칭(`타원체고` 우선규칙 유지). - `parseCenterline(file): Promise` — 서버 `loadCenterline:253-257` 포팅(인덱스 1/2/5, EUC-KR). - `loadFolderGeoData(files: File[])` — 위 파서들을 호출해 `geoStore.setGeoData({...})` 채우고, 영상 File을 반환. #### 검색 함수 포팅 (서버 `geoMatch.ts` → 클라이언트) 검색은 **서버의 단순 ENU `project3D`(geoMatch.ts) 알고리즘을 그대로 포팅**해 서버 응답과 동일 결과를 보장한다(오버레이용 `geoProjection.ts`와는 별개 — 검색 UI 호환 우선). | 포팅 함수 | 서버 소스 라인 | 비고 | |---|---|---| | `toRad` | `geoMatch.ts:63` | | | `geoToEnu` (cos-lat ENU) | `geoMatch.ts:78-87` | 검색 전용. 오버레이는 기존 proj4 ENU 사용 | | `projectEnu` | `geoMatch.ts:90-124` | | | `project3D` | `geoMatch.ts:127-156` | | | `stationOrder` | `geoMatch.ts:371-375` | (이미 컴포넌트들에 중복 존재 — 공용화) | | `getWorldOrigin` | `geoMatch.ts:378-387` | 스토어 `origin`으로 대체 가능 | | `findFramesForPoi(query, margin, maxDist, yawOffset)` | `geoMatch.ts:399-468` | 스토어 frames/pois 입력으로 변경 | | `findPoisForFrame(frameNum, margin, yawOffset)` | `geoMatch.ts:473-512` | 동일 | - 입력을 싱글턴 캐시(`loadFrames()`) 대신 **`geoStore` 데이터를 인자/직접 read**로 변경. 반환 타입은 서버와 동일(`FrameMatch`/`PoiInFrame`) → 컴포넌트 인터페이스 무변경. - `DEFAULT_FPS=30`, `GAP=30` 등 상수 동일 포팅. --- ### Changes: 소비 컴포넌트 (fetch → 스토어) 각 컴포넌트는 내부 `interface GeoPoint/FrameMatch` 중복 선언을 **`types/geo.ts`로 통합**(선택, 최소변경 시 유지 가능). 핵심은 fetch 제거: - **`GeoSearch.tsx`** - `:61` `fetch('/api/geo/pois')` → `useGeoStore` 의 `[...stations, ...pois]`. - `:82` `/api/geo/search` → 포팅 `findFramesForPoi(q, 1.0, 1500, 0)` 직접 호출(동기). `loading`/`error`는 데이터 없을 때만 의미. - `:98` `/api/geo/frame/{n}` → 포팅 `findPoisForFrame(currentFrame, 1.0)`. - **`StationVerify.tsx`** - `:57` pois → 스토어 `stations`. - `:69` frames(`{frame,lat,lon}`) → 스토어 `frames`(필드 일부만 사용). - `:102` search → 포팅 `findFramesForPoi(title, 1.2, 2000)`. - **`StationOverlay.tsx`** - `:186` pois → 스토어 `stations`/`pois`(이미 station/poi 분리 사용). `updateWorldOrigin`은 스토어 `origin`으로 대체. - `:197` centerline → 스토어 `centerline`. - `:206` frames → 스토어 `frames`. - 투영은 기존 `geoProjection.ts` 그대로(변경 없음). - **`RoutePanel.tsx`** - `:75` pois → 스토어. `:87` frames → 스토어. - **폴백/가드**: 스토어 `loaded===false`이면 빈 배열로 동작(현재 fetch 실패 시와 동일한 빈 상태). `useEffect`의 `[visible]` 의존성은 스토어 구독으로 대체(데이터 도착 시 자동 재렌더). - (옵션) 스토어가 비어 있을 때 기존 `/api/geo/*`로 폴백하려면 각 컴포넌트에 `if (store.loaded) useStore else fetch` 분기. **기본 구현은 스토어 단일 소스 권장**(서버 의존 제거가 목표). --- ### 영상 재생 배선 - `loadFolderGeoData`가 반환한 영상 `File` → `playerRef.loadLocalFile(file)`(기존 경로 그대로). - 영상이 폴더에 없으면 지리정보만 로드(오버레이는 동작하나 재생 화면 없음) — 에러 토스트/콘솔 경고. - `App.tsx`: 폴더 선택 핸들러를 VideoPlayer로 내려보내거나 VideoPlayer 내부에서 `loadLocalFile`(이미 보유) + `loadFolderGeoData` 직접 호출. **VideoPlayer 내부 처리 권장**(이미 `loadLocalFile` 접근 가능). --- ## 구현 태스크 목록 - [ ] `client/src/types/geo.ts` 신규 — `DroneFrame`/`GeoPoint`/`CenterlinePoint`/`FrameMatch`/`PoiInFrame` 공용 타입. - [ ] `client/src/store/geoStore.ts` 신규 — Zustand 스토어(frames/pois/stations/centerline/origin/baseName/loaded + setGeoData/reset). - [ ] `client/src/utils/geoData.ts` 신규: - [ ] `parseCsvLine` 포팅(server `geoMatch.ts:162-173`). - [ ] `decodeBytes`/`readCsv`(TextDecoder utf-8 + euc-kr, BOM 제거). - [ ] `findVideoFile(files)` (mp4/MP4/webm, building 제외, base 추출). - [ ] `parseDroneFrames`(헤더 인덱스, **회덕 하드코딩 없음**). - [ ] `parsePois`(POI: category_clean||건물 / 측점: '측점', 타원체고 우선규칙). - [ ] `parseCenterline`(인덱스 1/2/5, EUC-KR). - [ ] `loadFolderGeoData(files)` → setGeoData + return videoFile. - [ ] `client/src/utils/geoSearch.ts` 신규(또는 geoData.ts 내) — `toRad/geoToEnu/projectEnu/project3D/stationOrder/findFramesForPoi/findPoisForFrame` 포팅(server `geoMatch.ts`). 입력은 스토어 데이터. - [ ] `client/src/types/dom.d.ts` (또는 vite-env.d.ts) — `HTMLInputElement` `webkitdirectory`/`directory` 보강. - [ ] `client/src/components/player/VideoPlayer.tsx` — "폴더 선택" UI 추가 + `onSelectFolder` → `loadFolderGeoData` + `loadLocalFile`. - [ ] `client/src/components/geo/GeoSearch.tsx` — fetch 3곳 제거 → 스토어/포팅 함수. - [ ] `client/src/components/geo/StationVerify.tsx` — fetch 3곳 제거 → 스토어/포팅 함수. - [ ] `client/src/components/overlay/StationOverlay.tsx` — fetch 3곳 제거 → 스토어 구독, origin 스토어화. - [ ] `client/src/components/overlay/RoutePanel.tsx` — fetch 2곳 제거 → 스토어. - [ ] (정리) 컴포넌트 내 중복 `stationOrder`/`GeoPoint` 선언을 공용 타입/유틸로 통합. - [ ] `npm run build -w client` 통과 확인(tsc + vite). - [ ] 수동 검증: `samplevideo/` 폴더 선택 → 영상 재생 + 측점/POI/중심선 오버레이 + 측점 패널 목록 표시. - [ ] `회덕` 등 영상명 하드코딩이 클라이언트 경로에 없음을 확인(`grep -r 회덕 client/src` → 0건; RoutePanel 주석의 '대전조차장/회덕'은 단순 주석이므로 무관). --- ## 완료 기준 - `npm run build -w client` (= `tsc && vite build`)가 **타입 에러 없이** 통과. - "폴더 선택"으로 `samplevideo/`(또는 동일 구조 폴더) 선택 시: - 폴더 내 영상이 재생된다(`createObjectURL`). - 측점/POI/선로 중심선 오버레이가 표시된다(`StationOverlay`). - 우측 "측점" 탭에 측점 목록이 채워지고, 클릭 시 해당 프레임으로 이동한다. - "지리정보" 탭 검색/역조회가 클라이언트 데이터로 동작한다. - RoutePanel 미니맵이 시점/종점/현재 km를 표시한다. - 동작이 **영상명 비의존적**: `'회덕'` 등 하드코딩 없음. 다른 base명 폴더도 동일 구조면 동작. - EUC-KR 한글(측점명/POI 카테고리)이 깨지지 않고 표시된다(`TextDecoder('euc-kr')`). --- ## 토큰 사용 기록 > 추정치(k = 1,000 tokens). 각 에이전트 자체 보고 입력/출력 추정 + 측정된 subagent 총 토큰(측정 합계) 기준. | 단계 | 에이전트 | 입력(추정) | 출력(추정) | 측정 합계 | |------|----------|-----------|-----------|----------| | ①설계 | DESIGN/DOCUMENTATION | ~46k | ~9k | 98.7k | | ②a 구현·기반 | DEV-FOUNDATION (types/store/utils + 폴더선택 UI) | ~60k | ~9k | 99.3k | | ②b 구현·소비 | DEV-CONSUMER: GeoSearch | ~30k | ~2k | 52.0k | | ②b 구현·소비 | DEV-CONSUMER: StationVerify | ~24k | ~2k | 48.1k | | ②b 구현·소비 | DEV-CONSUMER: StationOverlay | ~32k | ~3k | 66.8k | | ②b 구현·소비 | DEV-CONSUMER: RoutePanel | ~30k | ~3k | 55.8k | | ③검증 | REVIEW/검수 | ~62k | ~3k | 105.1k | | — | (기반 1차 시도, 529 과부하로 무산) | 0 | 0 | 0 | | **합계** | **7개 에이전트** | **~284k** | **~31k** | **≈526k** | > ②b 4개 소비 에이전트는 **병렬 실행**(서로 다른 파일)으로 벽시계 시간 단축. 판정: **ship** (빌드 통과, blocker 0, 발견 이슈 전부 nit). --- ## 후속 수정 (2026-06-17) ### 🔴 누락 컴포넌트: StationBar (측점 바) — 사용자 보고 "측점 패널 안 보임" - **원인**: 초기 탐색이 geo 소비 컴포넌트로 GeoSearch/StationVerify/StationOverlay/RoutePanel 4개만 식별. 영상 하단의 측점 바 [client/src/stationbar/StationBar.tsx](../client/src/stationbar/StationBar.tsx)는 누락되어, 여전히 `/api/geo/pois`·`/api/geo/frames`를 직접 fetch. → 폴더 선택으로 채운 클라이언트 데이터를 못 읽고, 서버 미기동 시 측점이 표시되지 않음. - **수정**: StationBar를 다른 컴포넌트와 동일하게 geoStore로 연결. - `pois` = `[...stations, ...pois]` (store 파생), `frames` = `storeFrames`로 교체. 두 `fetch` useEffect 제거. - precompute/세그먼트 effect 의존성 `framesVersion` → `storeFrames`. 데이터 비었을 때 `setReady(false)`로 idle 복귀. - `npm run build -w client` 통과 확인. 클라이언트 전체에 live `/api/geo` fetch 0건. - **교훈**: 다음 탐색 시 `grep -rn "fetch(.*\/api\/geo"`로 **소비처 전수 조사**를 먼저 수행해 누락 방지.