]*>([\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;
}
/** 한국어 색상명 → CSS 색 (KML 텍스트박스_색상 속성). 어두운 색은 렌더 시 밝은 외곽선 처리. */
const KOR_LABEL_COLORS: Record = {
검정: '#1f2937', 흰색: '#ffffff', 회색: '#9ca3af',
빨강: '#ef4444', 주황: '#fb923c', 노랑: '#facc15',
연두: '#a3e635', 초록: '#22c55e', 하늘: '#38bdf8',
파랑: '#3b82f6', 남색: '#3730a3', 보라: '#a855f7', 분홍: '#f472b6',
};
function koreanColorToCss(v?: string): string | undefined {
if (!v) return undefined;
const key = v.trim();
if (KOR_LABEL_COLORS[key]) return KOR_LABEL_COLORS[key];
for (const k in KOR_LABEL_COLORS) if (key.includes(k)) return KOR_LABEL_COLORS[k];
return undefined;
}
/** Placemark 인라인 IconStyle 색 — KML aabbggrr → CSS #rrggbb. */
function inlineIconColor(pm: Element): string | undefined {
const icon = pm.getElementsByTagName('IconStyle')[0];
const c = icon?.getElementsByTagName('color')[0]?.textContent?.trim();
if (!c || !/^[0-9a-fA-F]{8}$/.test(c)) return undefined;
const bb = c.slice(2, 4), gg = c.slice(4, 6), rr = c.slice(6, 8);
return `#${rr}${gg}${bb}`.toLowerCase();
}
/** KMZ/KML 파싱 — 있으면 { pois, structures, stations }, 없거나 실패 시 null.
* bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제.
*
* 속성 소스 2종을 모두 props 로 수집한다:
* - description CDATA HTML 표 (구글어스/지오코딩 산출물 — 회덕 v2.0)
* - ExtendedData/SchemaData/SimpleData (CAD·GIS 내보내기 — 제주 도로 KML)
*
* 측점(STA): 이름이 도로 체이니지(`6+998`) 형식이거나 STA 속성이 있는 Point 는
* POI 가 아니라 측점(type='station')으로 수집한다. km 속성(단위 km) 우선, 없으면
* 이름에서 미터값을 파싱해 기존 철도식 제목(`6k998`)으로 변환 — stationOrder/stationKm
* 등 측점 파이프라인(스테이션바·노선패널·검색)과 그대로 호환된다. */
export async function parseKmz(
files: File[],
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[]; stations: GeoPoint[]; centerline: CenterlinePoint[] } | null> {
// 폴더 내 모든 KML/KMZ 를 파싱해 병합한다 (예: 제주 — 측점 KML + 지장물 KML 별도 파일).
// 단, `<이름>_byDem.kml`(DEM 표고 채운 파생본)이 있으면 그 원본 `<이름>.kml` 은 제외 —
// 같은 지물이 두 번 파싱되는 것 방지 + 표고 보유 버전 우선.
let kmlFiles = files.filter(
(f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))),
);
const byDemBases = new Set(
kmlFiles
.map((f) => baseNameOf(relPath(f)).toLowerCase().match(/^(.+)_bydem\.kml$/)?.[1])
.filter((b): b is string => !!b),
);
if (byDemBases.size) {
kmlFiles = kmlFiles.filter((f) => {
const name = baseNameOf(relPath(f)).toLowerCase();
const m = name.match(/^(.+)\.kml$/);
return !(m && !m[1].endsWith('_bydem') && byDemBases.has(m[1]));
});
}
const kmzFiles = files.filter(
(f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))),
);
if (!kmlFiles.length && !kmzFiles.length) return null;
const kmlTexts: string[] = [];
for (const kf of kmlFiles) {
try {
kmlTexts.push(await kf.text()); // 구글 KML 은 UTF-8
} catch (e) {
console.warn(`[KML] 읽기 실패: ${baseNameOf(relPath(kf))}`, e);
}
}
for (const kf of kmzFiles) {
try {
const buf = new Uint8Array(await kf.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]) kmlTexts.push(strFromU8(entries[kmlName]));
} catch (e) {
console.warn(`[KMZ] 읽기 실패: ${baseNameOf(relPath(kf))}`, e);
}
}
if (!kmlTexts.length) return null;
const pois: GeoPoint[] = [];
const structures: RouteStructure[] = [];
const stations: GeoPoint[] = [];
// 선형중심선 (LineString) — 도로 중심선 폴리라인. 여러 개면 가장 긴 것 사용.
let centerline: CenterlinePoint[] = [];
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);
// ExtendedData/SchemaData/SimpleData 속성 (CAD·GIS 내보내기) — HTML 표 props 와 통합.
for (const sd of Array.from(pm.getElementsByTagName('SimpleData'))) {
const k = sd.getAttribute('name') ?? '';
const v = sd.textContent?.trim() ?? '';
if (k) props.push({ k, v });
}
const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v;
// 선형중심선 (LineString) — 도로 중심선 폴리라인으로 수집(POI 아님).
// CAD 내보내기 KML(예: 제주_중산간도로.kml Folder 선형중심선)의 실제 도로 곡선.
const lineEl = pm.getElementsByTagName('LineString')[0];
if (lineEl) {
const coordText = lineEl.getElementsByTagName('coordinates')[0]?.textContent ?? '';
const pts: CenterlinePoint[] = [];
let prevKey = '';
for (const tok of coordText.trim().split(/\s+/)) {
const p = tok.split(',').map(Number);
if (p.length < 2 || !isFinite(p[0]) || !isFinite(p[1])) continue;
if (p[1] < 33 || p[1] > 39 || p[0] < 124 || p[0] > 132) continue;
const key = `${p[0].toFixed(8)},${p[1].toFixed(8)}`;
if (key === prevKey) continue; // 연속 중복 정점 제거
prevKey = key;
pts.push({ lat: p[1], lon: p[0], z: p.length >= 3 && isFinite(p[2]) ? p[2] : 0 });
}
if (pts.length >= 2 && pts.length > centerline.length) {
// z 톱니 노이즈 평활 — 원본(지형고도 샘플링)의 ±1m 양자화 잔차가 인접 정점에서
// 부호 반전 톱니를 만들고, 영상의 낮은 시야각 투영에서 선이 지그재그로 꺾여 보임.
// 도로 종단 경사는 완만하므로 ±3점 이동평균으로 톱니만 제거(경사 보존).
const zs = pts.map((p) => p.z);
for (let i = 0; i < pts.length; i++) {
if (zs[i] <= 0) continue;
let sum = 0, cnt = 0;
for (let j = Math.max(0, i - 3); j <= Math.min(pts.length - 1, i + 3); j++) {
if (zs[j] > 0) { sum += zs[j]; cnt++; }
}
if (cnt) pts[i].z = sum / cnt;
}
centerline = pts;
}
return;
}
// 좌표: lon,lat[,alt] 우선, 없으면 속성 lat/lon.
// alt(3번째 성분)는 DEM 일괄 기록(docs/history 2026-07-13 참조) 등으로 채워진 지면 표고 —
// 있으면(>0) POI/측점의 z 로 사용해 지형 기복을 정확히 따른다.
let lat = NaN, lon = NaN;
let coordAlt: number | undefined;
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 (p.length >= 3 && isFinite(p[2]) && p[2] > 0) coordAlt = p[2];
}
}
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;
// 새 포맷: 구글어스 CSV 변환 지장물 KML — Schema(타입/타입상세/이름/연장/텍스트박스_색상/높이),
// 폴더 구분 없이 Placemark 직속. '타입' 키 존재로 감지한다(회덕 desc표/STA 스키마엔 없음).
// 좌표는 SimpleData X/Y(저정밀)가 아니라 Point coordinates(고정밀)를 쓴다(위에서 이미 추출).
if (pget('타입') !== undefined) {
const title = pget('이름') || name;
if (!title) return;
const category = pget('타입') || '지장물';
const lenM = parseFloat(pget('연장') ?? '');
// 라벨 색: 텍스트박스_색상(한국어명) 우선, 없으면 인라인 IconStyle 색.
const labelColor = koreanColorToCss(pget('텍스트박스_색상')) ?? inlineIconColor(pm);
// z: 좌표 고도(DEM 기록 지면 표고) 있으면 사용 — 지형 기복을 따라 정확히 배치.
// ('높이' 속성은 표고가 아니라 구조물 높이라 사용하지 않음 — 팝업 props 로만 노출.)
// 미상(0)이면 오버레이가 지면고도 모드에서 경로표고 앵커로 폴백.
pois.push({
title, category, lat, lon, z: coordAlt ?? 0, type: 'poi',
...(labelColor ? { labelColor } : {}),
props,
});
// 교량/터널은 스테이션바·노선패널 마크용 구조물로도 등록.
if (category.includes('교량')) {
structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props });
} else if (category.includes('터널')) {
structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props });
}
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 {
// 도로 체이니지 측점 (예: "0+100" 또는 STA 속성) → type='station' 으로 수집.
// km 속성(단위 km) 우선, 없으면 이름의 "k+m" 파싱. 제목은 철도식 NkNNN 으로 변환.
const staName = pget('STA') || name;
const staMatch = staName.match(/^(\d+)\+(\d+)$/);
if (staMatch || pget('STA') !== undefined) {
const kmProp = parseFloat(pget('km') ?? '');
const meters = isFinite(kmProp)
? Math.round(kmProp * 1000)
: staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN;
if (!isFinite(meters)) return;
const title = `${Math.floor(meters / 1000)}+${String(meters % 1000).padStart(3, '0')}`;
// z: 좌표 고도(DEM 기록값) 있으면 사용 — 없으면 0(미상, 오버레이가 경로표고 앵커).
stations.push({ title, category: '측점', lat, lon, z: coordAlt ?? 0, type: 'station', props });
return;
}
// 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);
}
}
};
for (const text of kmlTexts) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.getElementsByTagName('parsererror').length) {
console.warn('[KML] XML 파싱 실패 — 해당 파일 건너뜀');
continue;
}
if (doc.documentElement) walk(doc.documentElement, '');
}
// 선형이 측점 POI 를 정확히 통과하도록 정비 — 측점 기준.
// ① 측점 위치를 선형 폴리라인에 '정점으로 삽입' (긴 직선 현 중간의 측점도 통과 보장 —
// 라벨과 선이 같은 점(lat/lon/z)을 투영하므로 어떤 카메라 자세에서도 화면에서 일치)
// ② 나머지 원본 정점 z 는 측점 z 의 체이니지 보간으로 교체 (톱니 평활 대체, 종단 = 측점)
const staZ = stations.filter((s) => isFinite(s.z) && s.z > 0);
if (centerline.length >= 2 && staZ.length >= 2) {
const cosLat = Math.cos((centerline[0].lat * Math.PI) / 180);
const ex = (p: { lat: number; lon: number }): number => (p.lon - centerline[0].lon) * 111320 * cosLat;
const ey = (p: { lat: number; lon: number }): number => (p.lat - centerline[0].lat) * 111320;
const cum: number[] = [0];
for (let i = 1; i < centerline.length; i++) {
cum.push(cum[i - 1] + Math.hypot(ex(centerline[i]) - ex(centerline[i - 1]), ey(centerline[i]) - ey(centerline[i - 1])));
}
// 측점 → 선형 세그먼트 투영 (선형에서 50m 이상 떨어진 측점은 제외)
const anchors: { s: number; z: number; seg: number; t: number; lat: number; lon: number }[] = [];
for (const st of staZ) {
const px = ex(st), py = ey(st);
let bestS = 0, bestD = Infinity, bestSeg = 0, bestT = 0;
for (let i = 0; i < centerline.length - 1; i++) {
const ax = ex(centerline[i]), ay = ey(centerline[i]);
const dx = ex(centerline[i + 1]) - ax, dy = ey(centerline[i + 1]) - ay;
const len2 = dx * dx + dy * dy;
const t = len2 > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0;
const d = Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
if (d < bestD) { bestD = d; bestS = cum[i] + Math.sqrt(len2) * t; bestSeg = i; bestT = t; }
}
if (bestD < 50) anchors.push({ s: bestS, z: st.z, seg: bestSeg, t: bestT, lat: st.lat, lon: st.lon });
}
anchors.sort((a, b) => a.s - b.s);
if (anchors.length >= 2) {
// ② 원본 정점 z ← 측점 체이니지 보간
for (let i = 0; i < centerline.length; i++) {
const s = cum[i];
if (s <= anchors[0].s) { centerline[i].z = anchors[0].z; continue; }
if (s >= anchors[anchors.length - 1].s) { centerline[i].z = anchors[anchors.length - 1].z; continue; }
let lo = 0, hi = anchors.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (anchors[m].s <= s) lo = m; else hi = m; }
const a = anchors[lo], b = anchors[hi];
const r = b.s > a.s ? (s - a.s) / (b.s - a.s) : 0;
centerline[i].z = a.z + (b.z - a.z) * r;
}
// ① 측점 정점 삽입 — 세그먼트별로 t 순 삽입. 기존 정점과 1m 이내면 생략(중복 방지).
const rebuilt: CenterlinePoint[] = [];
let inserted = 0;
for (let i = 0; i < centerline.length; i++) {
rebuilt.push(centerline[i]);
if (i === centerline.length - 1) break;
const onSeg = anchors.filter((a) => a.seg === i).sort((a, b) => a.t - b.t);
for (const a of onSeg) {
const near = (p: CenterlinePoint): boolean =>
Math.hypot(ex(a) - ex(p), ey(a) - ey(p)) < 1;
if (near(centerline[i]) || near(centerline[i + 1])) continue;
rebuilt.push({ lat: a.lat, lon: a.lon, z: a.z });
inserted++;
}
}
centerline = rebuilt;
console.log(`[KML] 선형-측점 정합: 측점 정점 ${inserted}개 삽입 + 정점 z 를 측점 ${anchors.length}점 기준 재정렬`);
}
}
// 곡선 평활 — 측점 정점 삽입/정비가 끝난 최종 선형에 적용(원 정점 전부 보존).
if (centerline.length >= 4) {
const before = centerline.length;
centerline = smoothCenterlineCR(centerline);
if (centerline.length !== before) {
console.log(`[KML] 선형 곡선 평활(Catmull-Rom): ${before} → ${centerline.length}점`);
}
}
return { pois, structures, stations, centerline };
}
/**
* 선형 곡선 평활 — Centripetal Catmull-Rom 으로 정점 사이를 세분(모든 원 정점 통과).
* 곡선부(정점 ~20m 간격)의 직선 연결이 화면에서 각져 보이는 것을 해소한다.
* 측점 삽입 정점도 그대로 통과하므로 선형-측점 정합 유지. 60m 이상 장대 세그먼트
* (직선 구간)와 양끝 세그먼트는 세분하지 않는다(불필요·경계 불안정 방지).
*/
function smoothCenterlineCR(pts: CenterlinePoint[]): CenterlinePoint[] {
if (pts.length < 4) return pts;
const k = Math.cos((pts[0].lat * Math.PI) / 180) * 111320;
const dist = (a: CenterlinePoint, b: CenterlinePoint): number =>
Math.hypot((b.lon - a.lon) * k, (b.lat - a.lat) * 111320);
const out: CenterlinePoint[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const p1 = pts[i], p2 = pts[i + 1];
out.push(p1);
if (i === 0 || i === pts.length - 2) continue; // 양끝 세그먼트는 직선 유지
const len = dist(p1, p2);
if (len < 2 || len >= 60) continue; // 중복/장대 세그먼트 제외
const p0 = pts[i - 1], p3 = pts[i + 2];
const sub = Math.min(6, Math.max(2, Math.ceil(len / 6)));
const t0 = 0;
const t1 = t0 + Math.max(0.7, Math.sqrt(dist(p0, p1)));
const t2 = t1 + Math.max(0.7, Math.sqrt(len));
const t3 = t2 + Math.max(0.7, Math.sqrt(dist(p2, p3)));
const bg = (c0: number, c1: number, c2: number, c3: number, t: number): number => {
const A1 = ((t1 - t) * c0 + (t - t0) * c1) / (t1 - t0);
const A2 = ((t2 - t) * c1 + (t - t1) * c2) / (t2 - t1);
const A3 = ((t3 - t) * c2 + (t - t2) * c3) / (t3 - t2);
const B1 = ((t2 - t) * A1 + (t - t0) * A2) / (t2 - t0);
const B2 = ((t3 - t) * A2 + (t - t1) * A3) / (t3 - t1);
return ((t2 - t) * B1 + (t - t1) * B2) / (t2 - t1);
};
for (let j = 1; j < sub; j++) {
const t = t1 + ((t2 - t1) * j) / sub;
out.push({
lat: bg(p0.lat, p1.lat, p2.lat, p3.lat, t),
lon: bg(p0.lon, p1.lon, p2.lon, p3.lon, t),
z: bg(p0.z, p1.z, p2.z, p3.z, t),
});
}
}
out.push(pts[pts.length - 1]);
return out;
}
// ── 카메라 파라미터 파일 (.camera.json) ─────────────────────────
/**
* 폴더에서 카메라 파라미터 파일을 읽는다. 'PC에 저장' 버튼이 만든 형식:
* { "camera": { focalLen, sensorW, sensorH, yawOffset, pitch, roll, offX, offY, offZ, geoidOffset, cx0, cy0 }, ... }
* 폴더 공용 설정 — 파일명 규칙(우선순위):
* ① `camera.json` (단순 이름 — 권장)
* ② `<영상 base>.camera.json` / `<영상 base>.json` (어느 분할본 base 든)
* ③ 임의 이름의 `*.camera.json` (예: "제주 중산간도로 경로 1-1.camera.json")
* 같은 폴더의 모든 영상(분할본 전체)에 동일하게 적용된다. 숫자 필드만 통과시킨다.
*/
export async function parseCameraJson(
files: File[],
videoFiles: File[],
): Promise | null> {
const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase());
const rootJson = files.filter((f) => {
if (isInBuilding(f)) return false;
const name = baseNameOf(relPath(f)).toLowerCase();
return /\.json$/.test(name) && !/^route\.json$|\.route\.json$|^display\.json$|\.display\.json$|_poi_overrides\.json$/.test(name);
});
const file =
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'camera.json') ??
rootJson.find((f) => {
const m = baseNameOf(relPath(f)).toLowerCase().match(/^(.+?)(\.camera)?\.json$/);
return !!m && bases.includes(m[1]);
}) ??
rootJson.find((f) => /\.camera\.json$/i.test(baseNameOf(relPath(f))));
if (!file) return null;
try {
const obj = JSON.parse(await file.text());
const cam = obj && typeof obj === 'object' ? (obj.camera ?? obj) : null;
if (!cam || typeof cam !== 'object') return null;
const out: Record = {};
for (const [k, v] of Object.entries(cam)) {
if (typeof v === 'number' && isFinite(v)) out[k] = v;
}
return Object.keys(out).length ? out : null;
} catch {
return null;
}
}
// ── 화면표시 옵션 파일 (.display.json) ──────────────────────────
/**
* 폴더에서 화면표시 옵션 파일을 읽는다. '화면표시 옵션 PC 저장' 버튼이 만든
* `{ display: {...}, savedAt }` 형식. 폴더 공용 설정 — 파일명 규칙(우선순위):
* ① `display.json` (단순 이름 — 권장)
* ② `<영상 base>.display.json`
* ③ 임의 이름의 `*.display.json` (예: "제주 중산간도로 경로 1-1.display.json")
* 폴더 내 모든 영상에 동일 적용.
*/
export async function parseDisplayJson(
files: File[],
videoFiles: File[],
): Promise | null> {
const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase());
const rootDisplay = files.filter(
(f) => !isInBuilding(f) && /(^|\.)display\.json$/i.test(baseNameOf(relPath(f)).toLowerCase()),
);
const file =
rootDisplay.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'display.json') ??
rootDisplay.find((f) => {
const m = baseNameOf(relPath(f)).toLowerCase().match(/^(.+?)\.display\.json$/);
return !!m && bases.includes(m[1]);
}) ?? rootDisplay[0];
if (!file) return null;
try {
const obj = JSON.parse(await file.text());
const d = obj && typeof obj === 'object' ? (obj.display ?? obj) : null;
return d && typeof d === 'object' ? (d as Record) : null;
} catch {
return null;
}
}
// ── 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') ||
// 임의 접두 `<이름>.route.json` 허용 (예: 제주 중산간도로 경로 1-1.route.json)
rootJson.find((f) => /\.route\.json$/i.test(baseNameOf(relPath(f))));
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 videoFiles = findVideoFiles(files);
const videoFile = videoFiles[0] ?? null;
const baseName = deriveBaseName(videoFile);
// 각 영상 길이를 메타데이터로 측정 — DJI 시간 기반 로그의 세그먼트 정렬(누적 오프셋)과
// ended 연속재생(geoStore.loadSegment)에 쓴다. 분할본이 아니면 1개만 측정.
const videoDurations: (number | null)[] = [];
for (const vf of videoFiles) videoDurations.push(await probeVideoDuration(vf));
// 첫 세그먼트 창 — 로그는 전체 녹화(분할본 합)를 커버하므로 첫 영상 구간만 잘라 쓴다.
const segmentWindow: SegmentWindow | null = videoFile
? { offsetSec: 0, durationSec: videoDurations[0] }
: null;
if (videoFiles.length > 1) {
console.log(
`[geo] 분할 영상 ${videoFiles.length}개 감지 (이름 오름차순 연속재생): ` +
videoFiles.map((vf, i) => `${baseNameOf(relPath(vf))}(${videoDurations[i]?.toFixed(1) ?? '?'}s)`).join(' → '),
);
}
const [frames, fullFrames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson, displayJson] = await Promise.all([
parseDroneFrames(files, baseName, segmentWindow),
// 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용(표시용 frames 와 별개)
parseDroneFrames(files, baseName, null),
parseStations(files),
parseRouteMeta(files, baseName),
parsePoiOverrides(files, baseName),
parseKmz(files),
videoFile ? detectCameraInfo(videoFile) : Promise.resolve(null),
parseCameraJson(files, videoFiles),
parseDisplayJson(files, videoFiles),
]);
if (displayJson) {
console.log('[display] .display.json 발견 — 저장된 화면표시 옵션 적용 예정');
}
if (cameraJson) {
console.log('[camera] .camera.json 발견 — 저장된 카메라 파라미터 적용:', cameraJson);
}
// 타원체고 기준 데이터셋 자동 변환 — route.json 에 ellipsoidal_height 가 선언된 폴더는
// 지물(측점/지장물) z 가 타원체고이므로, 드론 고도(CSV 정표고)를 영상 메타(djmd)의
// 타원체고와의 차이(= 그 지점 지오이드고)만큼 올려 기준을 통일한다. 지오이드/offZ 수동 보정 불필요.
let altitudeOffsetM = 0;
if (
routeMeta?.routeInfo?.ellipsoidal_height != null &&
cameraInfo?.ellipsoidalAltM != null &&
frames.length
) {
const geoidN = cameraInfo.ellipsoidalAltM - frames[0].altitude;
if (isFinite(geoidN) && Math.abs(geoidN) < 60) {
altitudeOffsetM = Math.round(geoidN * 100) / 100;
for (const f of frames) f.altitude += altitudeOffsetM;
for (const f of fullFrames) f.altitude += altitudeOffsetM;
console.log(
`[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+${altitudeOffsetM.toFixed(2)}m = ` +
`djmd ${cameraInfo.ellipsoidalAltM.toFixed(1)} − CSV ${(frames[0].altitude - altitudeOffsetM).toFixed(1)})`,
);
}
}
// 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성).
if (cameraInfo?.focalLen35) {
for (const f of frames) f.focalLen = cameraInfo.focalLen35;
for (const f of fullFrames) f.focalLen = cameraInfo.focalLen35;
console.log(
`[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` +
(cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''),
);
} else if (cameraInfo) {
console.log(`[camera] ${cameraInfo.model} 감지 (초점거리 정보 없음)`);
}
// POI/구조물 출처: KMZ(원본)가 유일한 소스다(지장물·출입문·교량/터널/구교·철도역).
// KMZ 파일 자체가 없을 때만 "데이터 누락"(kmzMissing=alert 대상)으로 본다.
// 파일은 있는데 POI/구조물이 0건인 경우(예: 제주 — 노선 KML만 있고 POI 없음)는
// 정상 데이터셋으로 취급하고 콘솔 경고만 남긴다.
const kmzMissing = !kmz;
if (kmzMissing) {
console.warn(
'[KMZ] 누락 — POI·구조물이 표시되지 않습니다. ' +
'KMZ(원본)를 포함해 데이터를 재구축·전달하세요. (측점·드론 정보는 정상 로드)',
);
} else if (kmz!.pois.length === 0 && kmz!.structures.length === 0 && kmz!.stations.length === 0) {
console.warn('[KMZ] POI·구조물·측점 0건 — 이 데이터셋엔 표출할 지물이 없습니다(드론 궤적은 무관).');
} else {
console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} · 측점 ${kmz!.stations.length} · 중심선 ${kmz!.centerline.length}점 로드(원본)`);
}
const pois = kmz?.pois ?? [];
const baseStructures = kmz?.structures ?? [];
// 측점 소스: CSV(01)측점 — 실측 표고 보유) 우선, 없으면 KML 체이니지 측점(STA) 사용.
// KML 측점은 표고 미상 → z=0 그대로 둔다(미상 표식). 드론고도 기반 근사(−24m)는
// 실제 지면과 수십 m 어긋나 라벨이 공중에 떠서 시차 불일치(카메라 전진 시 라벨 밀림)를
// 유발했음 → 오버레이가 z=0 라벨을 '경로 표고' 평면에 앵커하는 방식으로 대체.
let rawStations = stationResult.stations;
if (!rawStations.length && kmz?.stations.length) {
rawStations = kmz.stations;
const withZ = rawStations.filter((s) => s.z > 0).length;
console.log(
`[KMZ] 체이니지 측점 ${rawStations.length}개 로드` +
(withZ ? ` (DEM 표고 보유 ${withZ}개)` : ' (표고 미상 → 오버레이 경로표고 앵커)'),
);
}
const stations = rawStations.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);
// 중심선 소스 우선순위: KML 선형중심선(LineString — 실제 도로 곡선, 예: 제주 146정점)
// → 없으면 측점 폴리라인(100m 간격 직선 연결). v2.0엔 center.csv 가 없다.
const centerline = (kmz?.centerline.length ?? 0) >= 2
? kmz!.centerline
: buildCenterlineFromStations(stations);
if ((kmz?.centerline.length ?? 0) >= 2) {
console.log(`[geo] 중심선: KML 선형중심선 ${kmz!.centerline.length}점 사용`);
}
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,
fullFrames,
pois,
stations,
centerline,
origin,
routeMeta,
structures,
directionChanges,
poiOverrides,
kmzMissing,
videoFiles,
videoDurations,
folderFiles: files,
cameraInfo,
cameraJson,
displayJson,
altitudeOffsetM,
};
}
|