GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱 (v2.0 데이터 형상 전용)
|
||||
*
|
||||
* `<input type="file" webkitdirectory>` 로 선택된 File[] 에서 v2.0 폴더 구조를
|
||||
* 식별해 영상 / 드론 CSV / 측점·POI·구조물 CSV 를 파싱한다.
|
||||
*
|
||||
* v2.0 폴더 구조 (영상 base = 영상파일명에서 확장자 제거):
|
||||
* <base>.MP4 영상
|
||||
* <base>.csv 드론 프레임 (frame_cnt,latitude,...)
|
||||
* <base>_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<string[][]> {
|
||||
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 제외)의 <base>.csv 를 식별한다. base 가 없으면 루트 .csv 중
|
||||
* POI 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백).
|
||||
*/
|
||||
export async function parseDroneFrames(
|
||||
files: File[],
|
||||
baseName: string | null,
|
||||
): Promise<DroneFrame[]> {
|
||||
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표) → 속성 배열. 행/셀 단위로 견고하게 파싱.
|
||||
* 구글어스 재저장본은 `<tbody>`, `</b>` 뒤 `<br>`, `>` 엔티티 등 변형이 있어
|
||||
* `<tr>` 안의 두 `<td>` 셀에서 태그를 벗겨 (키, 값)으로 만든다(옛/새 형식 모두 처리).
|
||||
*/
|
||||
function parseKmlDescProps(descHtml: string): { k: string; v: string }[] {
|
||||
const out: { k: string; v: string }[] = [];
|
||||
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||
let tr: RegExpExecArray | null;
|
||||
while ((tr = trRe.exec(descHtml))) {
|
||||
const tds = [...tr[1].matchAll(/<td[^>]*>([\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;
|
||||
|
||||
// 좌표: <coordinates> 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).
|
||||
* `<base>.route.json` 우선(case-insensitive), 없으면 `route.json`.
|
||||
* building/ 제외, 루트 파일만. 파싱 실패 시 null.
|
||||
*/
|
||||
export async function parseRouteMeta(
|
||||
files: File[],
|
||||
baseName: string | null,
|
||||
): Promise<RouteMeta | null> {
|
||||
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<number>();
|
||||
|
||||
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 보정 파일을 읽는다. `<base>_poi_overrides.json` 우선,
|
||||
* 없으면 `poi_overrides.json`. building/ 제외, 루트만. 파싱 실패 시 {}.
|
||||
* 형식: { baseName?, overrides: { "<title>": {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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user