]*>([\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 {
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)) {
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 {
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,
};
}
|