/** * KML 표고(DEM) 파생 파일 생성 — `<이름>_byDem.kml` * * 폴더 로드 시 측점/지장물 KML 의 좌표 고도 성분이 없거나 0 이면, DEM(서버 /api/elevation * 프록시 → SRTM 30m, 폴백 90m)으로 지면 표고를 조회해 채운 `<이름>_byDem.kml` 을 만들어 * PC 로 다운로드한다. 사용자가 받은 파일을 영상 폴더에 넣으면, 로더(parseKmz)가 * 원본 대신 _byDem 버전을 읽는다(중복 파싱 방지 규칙). * * - 이미 표고가 있는 좌표(>0)는 그대로 두고 없는 것만 채운다 * - 폴더에 `<이름>_byDem.kml` 이 이미 있으면 해당 원본은 건너뜀 * - DEM 조회는 100좌표/청크, 업스트림 rate limit(1req/s) 고려 1.2s 간격 */ /** File 의 폴더 내 파일명 (webkitRelativePath 마지막 세그먼트). */ function baseNameOf(f: File): string { const p = f.webkitRelativePath || f.name; return p.split('/').pop() ?? f.name; } function isInBuilding(f: File): boolean { const parts = (f.webkitRelativePath || f.name).split('/'); return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building'; } /** 표고가 필요한(없거나 0) 좌표 목록 수집. */ function collectMissing(text: string): { key: string; lat: number; lon: number }[] { const need: { key: string; lat: number; lon: number }[] = []; const seen = new Set(); for (const block of text.matchAll(/([\s\S]*?)<\/coordinates>/g)) { for (const tok of block[1].trim().split(/\s+/)) { const p = tok.split(','); if (p.length < 2) continue; const lon = parseFloat(p[0]), lat = parseFloat(p[1]); if (!isFinite(lon) || !isFinite(lat)) continue; const alt = p.length >= 3 ? parseFloat(p[2]) : NaN; if (isFinite(alt) && alt > 0) continue; // 이미 표고 보유 const key = `${p[0]},${p[1]}`; if (!seen.has(key)) { seen.add(key); need.push({ key, lat, lon }); } } } return need; } /** * 폴더 파일들에서 표고 없는 KML 을 찾아 `<이름>_byDem.kml` 을 생성·다운로드한다. * 생성한 파일명 목록을 반환(생성할 것이 없으면 빈 배열). */ export async function generateByDemKmls(input: FileList | File[]): Promise { const files = Array.from(input); const kmls = files.filter((f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(f))); const lowerNames = new Set(kmls.map((f) => baseNameOf(f).toLowerCase())); const made: string[] = []; for (const f of kmls) { const base = baseNameOf(f).replace(/\.kml$/i, ''); if (/_bydem$/i.test(base)) continue; // 자신이 DEM 버전 if (lowerNames.has(`${base.toLowerCase()}_bydem.kml`)) continue; // DEM 버전이 이미 폴더에 있음 const text = await f.text(); const need = collectMissing(text); if (!need.length) continue; // 전 좌표 표고 보유 → 불필요 // DEM 조회 (서버 프록시) const elev = new Map(); for (let i = 0; i < need.length; i += 100) { const chunk = need.slice(i, i + 100); const r = await fetch( `/api/elevation?lat=${chunk.map((c) => c.lat).join(',')}&lon=${chunk.map((c) => c.lon).join(',')}`, ); if (!r.ok) throw new Error(`DEM 조회 실패 (HTTP ${r.status})`); const j: { elevation?: (number | null)[] } = await r.json(); chunk.forEach((c, k) => { const e = j.elevation?.[k]; if (typeof e === 'number' && isFinite(e)) elev.set(c.key, e); }); if (i + 100 < need.length) await new Promise((res) => setTimeout(res, 1200)); } if (!elev.size) continue; // 고도 성분 치환(없거나 0 인 것만) — 그 외 텍스트/구조는 원본 그대로 보존 let next = text.replace(/([\s\S]*?)<\/coordinates>/g, (_m, body: string) => `${body.replace(/(\S+)/g, (tok: string) => { const p = tok.split(','); if (p.length < 2) return tok; const alt = p.length >= 3 ? parseFloat(p[2]) : NaN; if (isFinite(alt) && alt > 0) return tok; const e = elev.get(`${p[0]},${p[1]}`); return e === undefined ? tok : `${p[0]},${p[1]},${e.toFixed(1)}`; })}`); // '높이' 속성(지장물 스키마)도 0/빈값이면 같은 DEM 표고로 채운다 — Placemark 단위로 // 자기 좌표의 표고를 대응. 실제 값이 있는 높이는 보존. next = next.replace(/]*>[\s\S]*?<\/Placemark>/g, (pm) => { const cm = pm.match(/\s*([-\d.]+),([-\d.]+)/); if (!cm) return pm; const e = elev.get(`${cm[1]},${cm[2]}`); if (e === undefined) return pm; return pm.replace( /()\s*(?:0(?:\.0+)?)?\s*(<\/SimpleData>)/, `$1${e.toFixed(1)}$2`, ); }); const outName = `${base}_byDem.kml`; const url = URL.createObjectURL(new Blob([next], { type: 'application/vnd.google-earth.kml+xml' })); const a = document.createElement('a'); a.href = url; a.download = outName; a.click(); setTimeout(() => URL.revokeObjectURL(url), 5000); made.push(outName); console.log(`[DEM] ${outName} 생성 — 좌표 ${need.length}개 중 ${elev.size}개 표고 기록`); } return made; }