/** * 클라이언트 지리정보 스토어 (Zustand) * * 폴더 선택으로 파싱한 드론 프레임 / POI / 측점 / 중심선 / ENU 원점을 보관한다. * 4개 소비 컴포넌트(GeoSearch / StationVerify / StationOverlay / RoutePanel)가 * 이 스토어를 단일 소스로 구독한다(서버 /api/geo/* 대체). * * playerStore.ts 패턴을 따른다. * * POI 위치 보정(poiOverrides): 마우스 드래그로 만든 title→{lat,lon,z} 맵. * basePois(파싱 원본)에 applyPoiOverrides 를 적용한 결과가 pois 다. * 내보내기/가져오기 파일(`_poi_overrides.json`)로 영속화한다. */ import { create } from 'zustand'; import type { DroneFrame, GeoPoint, CenterlinePoint, GeoOrigin, RouteMeta, RouteStructure, DirectionChange, PoiOverride, PoiOverrideMap, DetectedCameraInfo, } from '../types/geo'; import { loadFolderGeoData, applyPoiOverrides, parseDroneFrames } from '../utils/geoData'; interface GeoStore { loaded: boolean; frames: DroneFrame[]; pois: GeoPoint[]; // type==='poi' (poiOverrides 적용 결과) basePois: GeoPoint[]; // 파싱 원본 (보정 전) — 보정 재적용용 poiOverrides: PoiOverrideMap; // title → {lat,lon,z} stations: GeoPoint[]; // type==='station' (stationOrder 정렬) centerline: CenterlinePoint[]; origin: GeoOrigin | null; baseName: string | null; routeMeta: RouteMeta | null; /** KMZ 원본 구조물(교량/터널/구교/역사) + route.json 보정. */ structures: RouteStructure[]; /** 측점 비고에서 추출한 방향전환점 목록. */ directionChanges: DirectionChange[]; /** 마지막 폴더 로드에서 KMZ(POI·구조물 원본)가 누락됐는지. true면 재구축 필요. */ kmzMissing: boolean; /** 폴더 내 영상 파일 전체(이름 오름차순) — 분할본 연속재생용. */ videoFiles: File[]; /** videoFiles 각 영상 길이(초). 측정 실패 시 null. */ videoDurations: (number | null)[]; /** 현재 재생 중인 세그먼트 인덱스 (videoFiles 기준). */ videoIndex: number; /** 폴더 원본 파일 전체 — 세그먼트 전환 시 드론 CSV 재파싱용. */ folderFiles: File[]; /** 영상 djmd 트랙에서 감지한 카메라 정보 (예: ZenmuseP1, 초점거리). */ cameraInfo: DetectedCameraInfo | null; /** 폴더의 `<영상 base>.camera.json` 카메라 파라미터 — 감지값/저장값보다 최우선 적용. */ cameraJson: Record | null; /** * 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다. * 발견한 영상 File 을 반환(없으면 null) — 호출자가 loadLocalFile 로 재생. */ loadFromFolder: (files: FileList | File[]) => Promise; /** * 분할 영상 세그먼트 전환 — 드론 로그(시간 기반)를 해당 영상 구간(누적 오프셋)으로 * 재정렬해 frames 를 교체한다. 재생할 영상 File 을 반환(범위 밖이면 null). * baseName 은 폴더(첫 영상) 기준을 유지한다 — 카메라 보정 localStorage 연속성. */ loadSegment: (index: number) => Promise; /** 단일 POI 보정 설정/갱신 (드래그 종료 시). title 키. */ setPoiOverride: (title: string, ov: PoiOverride) => void; /** 단일 POI 보정 해제. */ clearPoiOverride: (title: string) => void; /** 보정맵 전체 교체 (가져오기). */ setPoiOverrides: (map: PoiOverrideMap) => void; clear: () => void; } const EMPTY = { loaded: false, frames: [] as DroneFrame[], pois: [] as GeoPoint[], basePois: [] as GeoPoint[], poiOverrides: {} as PoiOverrideMap, stations: [] as GeoPoint[], centerline: [] as CenterlinePoint[], origin: null as GeoOrigin | null, baseName: null as string | null, routeMeta: null as RouteMeta | null, structures: [] as RouteStructure[], directionChanges: [] as DirectionChange[], kmzMissing: false, videoFiles: [] as File[], videoDurations: [] as (number | null)[], videoIndex: 0, folderFiles: [] as File[], cameraInfo: null as DetectedCameraInfo | null, cameraJson: null as Record | null, }; export const useGeoStore = create((set, get) => ({ ...EMPTY, loadFromFolder: async (files) => { const data = await loadFolderGeoData(files); set({ loaded: true, frames: data.frames, basePois: data.pois, poiOverrides: data.poiOverrides, pois: applyPoiOverrides(data.pois, data.poiOverrides), stations: data.stations, centerline: data.centerline, origin: data.origin, baseName: data.baseName, routeMeta: data.routeMeta, structures: data.structures, directionChanges: data.directionChanges, kmzMissing: data.kmzMissing, videoFiles: data.videoFiles, videoDurations: data.videoDurations, videoIndex: 0, folderFiles: data.folderFiles, cameraInfo: data.cameraInfo, cameraJson: data.cameraJson, }); return data.videoFile; }, loadSegment: async (index) => { const s = get(); const file = s.videoFiles[index]; if (!file) return null; // 누적 오프셋 = 앞 세그먼트 길이 합. 하나라도 측정 실패면 정렬 불가 → 궤적만 비운다(재생은 계속). let offsetSec = 0; let offsetKnown = true; for (let i = 0; i < index; i++) { const d = s.videoDurations[i]; if (d == null) { offsetKnown = false; break; } offsetSec += d; } if (!offsetKnown) { console.warn(`[geo] 세그먼트 ${index + 1} 오프셋 계산 불가(영상 길이 측정 실패) — 드론 궤적 미표시`); set({ frames: [], videoIndex: index }); return file; } const frames = await parseDroneFrames(s.folderFiles, s.baseName, { offsetSec, durationSec: s.videoDurations[index], }); console.log( `[geo] 세그먼트 ${index + 1}/${s.videoFiles.length} 전환 — 로그 ${offsetSec.toFixed(1)}s~ 정렬, ${frames.length}행`, ); set({ frames, videoIndex: index }); return file; }, setPoiOverride: (title, ov) => set((s) => { const poiOverrides = { ...s.poiOverrides, [title]: ov }; return { poiOverrides, pois: applyPoiOverrides(s.basePois, poiOverrides) }; }), clearPoiOverride: (title) => set((s) => { const poiOverrides = { ...s.poiOverrides }; delete poiOverrides[title]; return { poiOverrides, pois: applyPoiOverrides(s.basePois, poiOverrides) }; }), setPoiOverrides: (map) => set((s) => ({ poiOverrides: map, pois: applyPoiOverrides(s.basePois, map) })), clear: () => set({ ...EMPTY }), }));