/** * 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱 (v2.0 데이터 형상 전용) * * `` 로 선택된 File[] 에서 v2.0 폴더 구조를 * 식별해 영상 / 드론 CSV / 측점·POI·구조물 CSV 를 파싱한다. * * v2.0 폴더 구조 (영상 base = 영상파일명에서 확장자 제거): * .MP4 영상 * .csv 드론 프레임 (frame_cnt,latitude,...) * _POI.csv POI (source,query_label,title,category_clean,...,lat,lon,...) * building/01)측점.csv 측점 (측점,X좌표,...,비고,lat,lon,...) * building/02)지장물.csv POI (명칭,...,lat,lon) * building/03)교량.csv 구조물 bridge (구분,...,연장(m),...,lat,lon,...) * building/04)터널.csv 구조물 tunnel (구분,...,연장(m),...,lat,lon,...) * building/05)출입문번호.csv 출입문 (기본 표시 제외 — 아래 참고) * building/06)구교.csv 구조물 bridge (역구간,시설물명,...,연장(m),lat,lon,...) * * v2.0엔 center.csv 가 없다 → 측점(01)측점)을 측점값 순으로 이어 중심선을 생성한다. * * 인코딩(혼재 — 자동 감지): ArrayBuffer 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8, * 아니면 EUC-KR. 디코딩 후 잔여 BOM(U+FEFF)을 제거한다. 파일별 하드코딩 없음. */ import type { DroneFrame, GeoPoint, CenterlinePoint, GeoOrigin, FolderGeoData, RouteMeta, RouteStructure, DirectionChange, PoiOverrideMap, } from '../types/geo'; import { stationOrder, getWorldOrigin } from './geoSearch'; import { unzipSync, strFromU8 } from 'fflate'; // ── CSV 파싱 헬퍼 ───────────────────────────────────────────────────── export function parseCsvLine(line: string): string[] { const result: string[] = []; let current = ''; let inQuotes = false; for (const ch of line) { if (ch === '"') { inQuotes = !inQuotes; } else if (ch === ',' && !inQuotes) { result.push(current.trim()); current = ''; } else { current += ch; } } result.push(current.trim()); return result; } /** * ArrayBuffer → 문자열. 인코딩 자동 감지: * 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8, 아니면 EUC-KR. * 디코딩 후 남을 수 있는 BOM(U+FEFF)을 제거한다. */ export function decodeBytes(buf: ArrayBuffer): string { const head = new Uint8Array(buf, 0, Math.min(3, buf.byteLength)); const isUtf8Bom = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf; const encoding = isUtf8Bom ? 'utf-8' : 'euc-kr'; const text = new TextDecoder(encoding).decode(buf); // 디코딩 후 잔여 BOM(U+FEFF) 제거 return text.replace(/^/, ''); } /** File → 파싱된 행 배열. 인코딩은 BOM 기반 자동 감지. */ export async function readCsv(file: File): Promise { const buf = await file.arrayBuffer(); const text = decodeBytes(buf); return text .split(/\r?\n/) .filter(Boolean) .map(parseCsvLine); } /** 헤더 행 → (헤더명|위치인덱스) → 값 추출기. 헤더가 깨지면 위치 인덱스로 폴백. */ function makeFieldIndexer(header: string[]): (name: string, fallback?: number) => number { const cleaned = header.map((h) => h.trim().replace(/^/, '')); return (name: string, fallback?: number): number => { const i = cleaned.indexOf(name); if (i >= 0) return i; return fallback ?? -1; }; } /** 안전한 셀 접근 (인덱스 음수/범위 밖이면 ''). */ function cell(row: string[], i: number): string { return i >= 0 && i < row.length ? row[i] : ''; } // ── 파일 식별 헬퍼 ──────────────────────────────────────────────────── /** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */ function relPath(f: File): string { return f.webkitRelativePath || f.name; } /** 경로의 마지막 세그먼트(파일명). */ function baseNameOf(p: string): string { const parts = p.split('/'); return parts[parts.length - 1]; } /** building/ 하위 파일 여부 (마지막 디렉토리 세그먼트가 building). */ function isInBuilding(f: File): boolean { const parts = relPath(f).split('/'); return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building'; } /** building/ 하위에서 파일명이 키워드를 포함하는 첫 파일. (예 '01)측점', '03)교량') */ function findBuildingFile(files: File[], keyword: string): File | null { return files.find((f) => isInBuilding(f) && baseNameOf(relPath(f)).includes(keyword)) ?? null; } const VIDEO_EXT = /\.(mp4|webm)$/i; /** 영상 파일 찾기 (mp4/webm, building 제외). */ export function findVideoFile(files: File[]): File | null { return ( files.find((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) ?? null ); } /** 영상 파일명에서 base(확장자 제거) 추출. */ export function deriveBaseName(videoFile: File | null): string | null { if (!videoFile) return null; return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, ''); } // ── 파서: 드론 프레임 (변경 없음) ───────────────────────────────────── /** * 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스). * 헤더: frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len * 루트(building 제외)의 .csv 를 식별한다. base 가 없으면 루트 .csv 중 * POI 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백). */ export async function parseDroneFrames( files: File[], baseName: string | null, ): Promise { const rootCsv = files.filter((f) => { if (isInBuilding(f)) return false; const name = baseNameOf(relPath(f)); if (!/\.csv$/i.test(name)) return false; if (/POI/i.test(name)) return false; return true; }); if (!rootCsv.length) return []; // base 일치 우선, 없으면 첫 루트 csv const droneFile = (baseName && rootCsv.find((f) => baseNameOf(relPath(f)) === `${baseName}.csv`)) || rootCsv[0]; const rows = await readCsv(droneFile); if (rows.length < 2) return []; const fi = makeFieldIndexer(rows[0]); return rows .slice(1) .map((r) => ({ frame: parseInt(cell(r, fi('frame_cnt', 0)), 10), lat: parseFloat(cell(r, fi('latitude', 1))), lon: parseFloat(cell(r, fi('longitude', 2))), altitude: parseFloat(cell(r, fi('altitude', 3))), yaw: parseFloat(cell(r, fi('yaw', 4))), pitch: parseFloat(cell(r, fi('pitch', 5))), roll: parseFloat(cell(r, fi('roll', 6))), focalLen: parseFloat(cell(r, fi('focal_len', 7))), })) .filter((f) => !isNaN(f.lat)); } // ── 파서: 측점 + 방향전환점 ─────────────────────────────────────────── /** "02:05" → 125 (초). 파싱 실패 시 NaN. */ function mmssToSeconds(s: string): number { const m = s.match(/(\d+):(\d+)/); if (!m) return NaN; return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); } /** * 측점 CSV 파싱 (building/01)측점.csv, EUC-KR). * 헤더: 측점,X좌표,Y좌표,Z좌표,비고,lat,lon,... * title ← 측점, lat ← lat, lon ← lon, z ← Z좌표, category='측점', type='station' * 비고(방향전환점) 도 함께 추출한다: `방향전환점(상행->하행, 02:05)`. */ export async function parseStations(files: File[]): Promise<{ stations: GeoPoint[]; directionChanges: DirectionChange[]; }> { // 측점 파일 — building/ 또는 root(영상 옆) 어디든 허용(building 폴더 삭제 대비). const file = findBuildingFile(files, '01)측점') ?? files.find((f) => baseNameOf(relPath(f)).includes('01)측점')) ?? files.find((f) => baseNameOf(relPath(f)).includes('측점')); if (!file) return { stations: [], directionChanges: [] }; const rows = await readCsv(file); if (rows.length < 2) return { stations: [], directionChanges: [] }; const fi = makeFieldIndexer(rows[0]); const iTitle = fi('측점', 0); // Z좌표(col 3)는 로컬좌표(≈0). 실제 표고는 Z좌표_한국(EPSG:5186, 정표고)에 있다. // 없으면 Z좌표로 폴백. const iZKorea = fi('Z좌표_한국'); const iZ = iZKorea >= 0 ? iZKorea : fi('Z좌표', 3); const iNote = fi('비고', 4); const iLat = fi('lat', 5); const iLon = fi('lon', 6); const stations: GeoPoint[] = []; const directionChanges: DirectionChange[] = []; for (const r of rows.slice(1)) { const lat = parseFloat(cell(r, iLat)); const lon = parseFloat(cell(r, iLon)); const title = cell(r, iTitle); if (isNaN(lat) || isNaN(lon)) continue; stations.push({ title, category: '측점', lat, lon, z: parseFloat(cell(r, iZ)) || 0, type: 'station', }); // 비고: 방향전환점(상행->하행, 02:05) const note = cell(r, iNote); const m = note.match(/방향전환점\s*\(\s*([^->]+?)\s*->\s*([^,)]+?)\s*,\s*([\d:]+)\s*\)/); if (m) { const atSeconds = mmssToSeconds(m[3]); directionChanges.push({ station: title, from: m[1].trim(), to: m[2].trim(), atSeconds: isNaN(atSeconds) ? -1 : atSeconds, }); } } return { stations, directionChanges }; } // ── 파서: KMZ (원본) ───────────────────────────────────────────────── // // KMZ(=doc.kml zip)는 building CSV의 원본이다. 폴더가 02)지장물·03)교량·04)터널·05)출입문번호· // 06)구교 로 1:1 대응하고, 각 Placemark 가 좌표 + description HTML표(속성)를 담는다. // → KMZ 에서 직접 POI/구조물을 추출하면 CSV 추출 중복이 사라진다. (측점(01)은 KMZ에 없음 → CSV 유지) /** HTML 태그 제거 + 엔티티 디코드 + 트림. */ function stripHtml(s: string): string { return s .replace(/<[^>]*>/g, ' ') .replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&') .replace(/ /g, ' ').replace(/"/g, '"').replace(/'/g, "'") .replace(/\s+/g, ' ').trim(); } /** * description CDATA(HTML표) → 속성 배열. 행/셀 단위로 견고하게 파싱. * 구글어스 재저장본은 ``, `` 뒤 `
`, `>` 엔티티 등 변형이 있어 * `` 안의 두 `` 셀에서 태그를 벗겨 (키, 값)으로 만든다(옛/새 형식 모두 처리). */ function parseKmlDescProps(descHtml: string): { k: string; v: string }[] { const out: { k: string; v: string }[] = []; const trRe = /]*>([\s\S]*?)<\/tr>/gi; let tr: RegExpExecArray | null; while ((tr = trRe.exec(descHtml))) { const tds = [...tr[1].matchAll(/]*>([\s\S]*?)<\/td>/gi)].map((m) => stripHtml(m[1])); if (tds.length >= 2 && tds[0]) out.push({ k: tds[0], v: tds[1] }); } return out; } /** 폴더 내 직계 자식 중 localName 일치 첫 요소. */ function childLocal(el: Element, local: string): Element | null { for (const c of Array.from(el.children)) if (c.localName === local) return c; return null; } /** KMZ/KML 파싱 — 있으면 { pois, structures }, 없거나 실패 시 null. * bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제. */ export async function parseKmz( files: File[], ): Promise<{ pois: GeoPoint[]; structures: RouteStructure[] } | null> { const kmlFile = files.find( (f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))), ); const kmzFile = files.find( (f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))), ); if (!kmlFile && !kmzFile) return null; let kmlText: string; try { if (kmlFile) { kmlText = await kmlFile.text(); // 구글 KML 은 UTF-8 } else { const buf = new Uint8Array(await kmzFile!.arrayBuffer()); const entries = unzipSync(buf); const kmlName = Object.keys(entries).find((n) => /(^|\/)doc\.kml$/i.test(n)) ?? Object.keys(entries).find((n) => /\.kml$/i.test(n)); if (!kmlName || !entries[kmlName]) return null; kmlText = strFromU8(entries[kmlName]); } } catch (e) { console.warn('[KMZ/KML] 읽기 실패 → CSV 폴백', e); return null; } const doc = new DOMParser().parseFromString(kmlText, 'application/xml'); if (doc.getElementsByTagName('parsererror').length) return null; const pois: GeoPoint[] = []; const structures: RouteStructure[] = []; let nStruct = 0; const handlePlacemark = (pm: Element, folder: string): void => { const name = childLocal(pm, 'name')?.textContent?.trim() ?? ''; const desc = childLocal(pm, 'description')?.textContent ?? ''; const props = parseKmlDescProps(desc); const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v; // 좌표: lon,lat[,alt] 우선, 없으면 속성 lat/lon. let lat = NaN, lon = NaN; const coordEl = pm.getElementsByTagName('coordinates')[0]; if (coordEl?.textContent) { const p = coordEl.textContent.trim().split(/[\s,]+/).map(Number); if (p.length >= 2 && isFinite(p[0]) && isFinite(p[1])) { lon = p[0]; lat = p[1]; } } if (isNaN(lat) || isNaN(lon)) { lat = parseFloat(pget('lat') ?? pget('latitude') ?? ''); lon = parseFloat(pget('lon') ?? pget('longitude') ?? ''); } if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) return; const lenNum = parseFloat(pget('연장(m)') ?? ''); if (folder.includes('교량')) { structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props }); } else if (folder.includes('터널')) { structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props }); } else if (folder.includes('구교')) { structures.push({ id: `구교-${nStruct++}`, type: 'bridge', category: '구교', name: pget('시설물명') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), props }); } else if (folder.includes('출입문')) { const title = pget('출입문번호') || name; if (title) pois.push({ title, category: '출입문', lat, lon, z: parseFloat(pget('z') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props }); } else { // 02)지장물 등 = 지오코딩 POI. 모든 POI 는 영상에 표출(철도역 포함). if (!name) return; const src = pget('source'); const cat = pget('category_clean') || '지장물'; // 라벨: KML '구분' → 'title' → (없으면) placemark name 순. const label = pget('구분') || pget('title') || name; pois.push({ title: label, category: cat, lat, lon, z: parseFloat(pget('z') ?? pget('Z좌표') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props }); // 철도역(KAKAO_RAIL)은 영상 POI 에 더해 '역사'로 하단 스테이션바에도 표출(lat/lon 으로 배치). if (src === 'KAKAO_RAIL' || cat === '철도역' || cat === '역사') { structures.push({ id: `역사-${nStruct++}`, type: 'station', category: '역사', name: label, lat, lon, props }); } } }; // 폴더 트리 재귀 — Placemark 가 직접 속한 (가장 안쪽) 폴더명을 folder 로 전달. const walk = (el: Element, folder: string): void => { for (const c of Array.from(el.children)) { if (c.localName === 'Folder') { walk(c, childLocal(c, 'name')?.textContent?.trim() ?? folder); } else if (c.localName === 'Document') { walk(c, folder); } else if (c.localName === 'Placemark') { handlePlacemark(c, folder); } } }; if (doc.documentElement) walk(doc.documentElement, ''); return { pois, structures }; } // ── route.json 보정 ─────────────────────────────────────────────────── /** * 폴더 보조 파일 route.json 파싱 (UTF-8, JSON). * `.route.json` 우선(case-insensitive), 없으면 `route.json`. * building/ 제외, 루트 파일만. 파싱 실패 시 null. */ export async function parseRouteMeta( files: File[], baseName: string | null, ): Promise { const rootJson = files.filter( (f) => !isInBuilding(f) && /\.json$/i.test(baseNameOf(relPath(f))), ); if (!rootJson.length) return null; const wantBase = baseName ? `${baseName}.route.json`.toLowerCase() : null; const file = (wantBase && rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) || rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json'); if (!file) return null; try { const text = await file.text(); return JSON.parse(text) as RouteMeta; } catch { return null; } } /** 이름 정규화(괄호 이하 제거) — route.json ↔ CSV 구조물 매칭용. */ function structBaseName(name: string): string { return name.replace(/\s*[((].*$/, '').trim(); } /** * CSV 유래 구조물에 route.json structures 의 offset/station/이정 등을 보정(augment)한다. * name 이 일치(괄호 이하 제거 후 부분일치)하는 항목의 값으로 override 하고, * CSV 에 없는 구조물(route.json 전용)은 그대로 추가한다. */ export function mergeStructures( csvStructures: RouteStructure[], meta: RouteMeta | null, ): RouteStructure[] { const metaList = meta?.structures; if (!metaList || !metaList.length) return csvStructures; const result = csvStructures.map((s) => ({ ...s })); const used = new Set(); for (const m of metaList) { const mBase = structBaseName(m.name); const idx = result.findIndex((s) => { const sBase = structBaseName(s.name); return sBase === mBase || sBase.includes(mBase) || mBase.includes(sBase); }); if (idx >= 0) { // 보정: route.json 에 명시된 필드만 override (CSV 좌표/연장은 유지). const tgt = result[idx]; if (m.station != null) tgt.station = m.station; if (m.offset != null) tgt.offset = m.offset; if (m.startMileage != null) tgt.startMileage = m.startMileage; if (m.endMileage != null) tgt.endMileage = m.endMileage; if (m.lat != null) tgt.lat = m.lat; if (m.lon != null) tgt.lon = m.lon; used.add(idx); } else { // CSV 에 없는 route.json 전용 구조물 → 그대로 추가. result.push({ ...m }); } } return result; } // ── POI 위치 보정 (마우스 드래그 저장값) ────────────────────────────── /** * 폴더에서 POI 보정 파일을 읽는다. `_poi_overrides.json` 우선, * 없으면 `poi_overrides.json`. building/ 제외, 루트만. 파싱 실패 시 {}. * 형식: { baseName?, overrides: { "": {lat,lon,z}, ... } } (또는 평면 맵). */ export async function parsePoiOverrides( files: File[], baseName: string | null, ): Promise<PoiOverrideMap> { const rootJson = files.filter( (f) => !isInBuilding(f) && /_poi_overrides\.json$/i.test(baseNameOf(relPath(f))), ); if (!rootJson.length) return {}; const wantBase = baseName ? `${baseName}_poi_overrides.json`.toLowerCase() : null; const file = (wantBase && rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) || rootJson[0]; try { const obj = JSON.parse(await file.text()); const map = (obj && typeof obj === 'object' && obj.overrides) ? obj.overrides : obj; const out: PoiOverrideMap = {}; for (const [title, v] of Object.entries(map as Record<string, unknown>)) { const o = v as { lat?: number; lon?: number; z?: number }; if (o && isFinite(o.lat as number) && isFinite(o.lon as number)) { out[title] = { lat: o.lat as number, lon: o.lon as number, z: Number(o.z) || 0 }; } } return out; } catch { return {}; } } /** 보정맵을 POI 배열에 적용 (title 일치 시 lat/lon/z 교체). 원본 불변. */ export function applyPoiOverrides(pois: GeoPoint[], overrides: PoiOverrideMap): GeoPoint[] { if (!overrides || !Object.keys(overrides).length) return pois; return pois.map((p) => { const o = overrides[p.title]; return o ? { ...p, lat: o.lat, lon: o.lon, z: o.z } : p; }); } // ── 중심선 생성 (v2.0엔 center.csv 없음) ────────────────────────────── /** * 측점을 측점값(mileage) 순으로 정렬한 폴리라인을 중심선으로 생성한다. * v2.0엔 center.csv 가 없으므로 측점(01)측점)이 중심선의 소스다. * 이 중심선이 체이니지 투영/RoutePanel 에 쓰인다. */ export function buildCenterlineFromStations(stations: GeoPoint[]): CenterlinePoint[] { return [...stations] .filter((s) => !isNaN(s.lat) && !isNaN(s.lon)) .sort((a, b) => stationOrder(a.title) - stationOrder(b.title)) .map((s) => ({ lat: s.lat, lon: s.lon, z: s.z })); } // ── 통합 로더 ───────────────────────────────────────────────────────── /** * 폴더 내 파일에서 영상 + 지리정보(v2.0 형상)를 모두 파싱한다. * 반환값을 geoStore.loadFromFolder 가 스토어에 적재한다. */ export async function loadFolderGeoData( input: FileList | File[], ): Promise<FolderGeoData> { const files = Array.from(input); const videoFile = findVideoFile(files); const baseName = deriveBaseName(videoFile); const [frames, stationResult, routeMeta, poiOverrides, kmz] = await Promise.all([ parseDroneFrames(files, baseName), parseStations(files), parseRouteMeta(files, baseName), parsePoiOverrides(files, baseName), parseKmz(files), ]); // POI/구조물 출처: KMZ(원본)가 유일한 소스다(지장물·출입문·교량/터널/구교·철도역). // KMZ 정책상 필수 — 없거나 비어 있으면 "데이터 누락"으로 보고 경고만 남기고 빈 상태로 둔다 // (CSV 폴백 없음. building/ POI·구조물 CSV는 사용하지 않음). 측점·중심선은 항상 CSV(측점)에서 온다. const kmzMissing = !kmz || (kmz.pois.length === 0 && kmz.structures.length === 0); if (kmzMissing) { console.warn( '[KMZ] 누락/비어있음 — POI·구조물이 표시되지 않습니다. ' + 'KMZ(원본)를 포함해 데이터를 재구축·전달하세요. (측점·드론 정보는 정상 로드)', ); } else { console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} 로드(원본)`); } const pois = kmz?.pois ?? []; const baseStructures = kmz?.structures ?? []; const stations = stationResult.stations.sort( (a, b) => stationOrder(a.title) - stationOrder(b.title), ); const directionChanges = stationResult.directionChanges; // route.json 은 선택적 보정 레이어 — 있으면 구조물에 override/augment. // 철도역(역사)은 KMZ의 KAKAO_RAIL placemark에서 구조물로 생성됨(스테이션바 표출). const structures = mergeStructures(baseStructures, routeMeta); // v2.0엔 center.csv 가 없다 → 측점 폴리라인으로 중심선 생성. const centerline = buildCenterlineFromStations(stations); let origin: GeoOrigin | null = null; if (stations.length || frames.length) { origin = getWorldOrigin(frames, [...stations, ...pois]); } else if (centerline.length) { origin = { lat: centerline[0].lat, lon: centerline[0].lon, alt: centerline[0].z }; } return { videoFile, baseName, frames, pois, stations, centerline, origin, routeMeta, structures, directionChanges, poiOverrides, kmzMissing, }; }