/* ============================================================================= * B03_FileInput_UI_Preview.ts * 업로드 자료 카드 미리보기 — 분석 메타데이터의 범위(bounds)만으로 그리는 작은 SVG. * * 재료는 이미 서버가 낸다(업로드 분석기의 `bounds`, 계획노선 shapefile은 `preview_path`). * **파일을 다시 읽지 않으므로 추가 지연이 없다** — 사용자 제약 「오래 걸리면 안 됨」 * (2026-09-04 확정). 라이다 점구름·GeoTIFF 래스터는 **범위 사각형만** 그린다. * ========================================================================== */ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } export interface PreviewExtent { xMin: number; xMax: number; yMin: number; yMax: number; } const SVG_NS = "http://www.w3.org/2000/svg"; // 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가 파일마다 // 들쭉날쭉하지 않게. 실제 폭은 CSS 가 100% 로 늘리고, 이 값들은 좌표계 기준일 뿐이다. const VIEW_WIDTH = 320; const VIEW_HEIGHT = 120; const PADDING = 6; function finite(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } /** * 분석기마다 범위를 담는 모양이 다르다 — 세 가지를 한 형태로 모은다. * shapefile·노선 CSV `{x_min,…}` / LAS `{x:[min,max],…}` / GeoTIFF `{left,bottom,right,top}`. */ export function readExtent(metadata: Record | undefined): PreviewExtent | null { const bounds = metadata?.bounds as Record | undefined; if (!bounds) return null; const pairs: [number | null, number | null, number | null, number | null][] = [ [finite(bounds.x_min), finite(bounds.x_max), finite(bounds.y_min), finite(bounds.y_max)], [finite(bounds.left), finite(bounds.right), finite(bounds.bottom), finite(bounds.top)], ]; const xs = bounds.x as unknown[] | undefined; const ys = bounds.y as unknown[] | undefined; if (Array.isArray(xs) && Array.isArray(ys)) { pairs.push([finite(xs[0]), finite(xs[1]), finite(ys[0]), finite(ys[1])]); } for (const [xMin, xMax, yMin, yMax] of pairs) { if (xMin === null || xMax === null || yMin === null || yMax === null) continue; if (xMax <= xMin || yMax <= yMin) continue; return { xMin, xMax, yMin, yMax }; } return null; } /** 계획노선 shapefile이 함께 낸 솎은 좌표열(없으면 null). */ export function readPreviewPath( metadata: Record | undefined, ): number[][][] | null { const raw = metadata?.preview_path; if (!Array.isArray(raw) || raw.length === 0) return null; const parts: number[][][] = []; for (const part of raw) { if (!Array.isArray(part)) continue; const points: number[][] = []; for (const point of part) { if (!Array.isArray(point)) continue; const x = finite(point[0]); const y = finite(point[1]); if (x !== null && y !== null) points.push([x, y]); } if (points.length >= 2) parts.push(points); } return parts.length ? parts : null; } /** 라이다 분석기가 훑는 길에 주워 둔 탑뷰 점(없으면 null). */ export function readPreviewPoints( metadata: Record | undefined, ): number[][] | null { const raw = metadata?.preview_points; if (!Array.isArray(raw) || raw.length === 0) return null; const points: number[][] = []; for (const point of raw) { if (!Array.isArray(point)) continue; const x = finite(point[0]); const y = finite(point[1]); if (x !== null && y !== null) points.push([x, y]); } return points.length ? points : null; } /** GeoTIFF 저해상 썸네일(data URL). 오버뷰가 없는 파일은 만들지 않아 null 이다. */ export function readThumbnail(metadata: Record | undefined): string | null { const raw = metadata?.preview_thumbnail; return typeof raw === "string" && raw.startsWith("data:image/") ? raw : null; } /** * 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **한눈에 구분되는 값**을 뽑는다 * (2026-09-04 사용자 지시). 그림 대신 이 값을 큰 글씨로 보인다. */ export function readFacts(metadata: Record | undefined): string[] { if (!metadata) return []; const facts: string[] = []; const number = (value: unknown): number | null => finite(value); const text = (value: unknown): string | null => typeof value === "string" && value.trim() ? value.trim() : null; const shapes = number(metadata.shape_count); if (shapes !== null) facts.push(`도형 ${shapes.toLocaleString()}개`); const records = number(metadata.record_count); if (records !== null) facts.push(`레코드 ${records.toLocaleString()}개`); const fields = number(metadata.field_count); if (fields !== null) facts.push(`속성 ${fields}개`); const encoding = text(metadata.encoding); if (encoding) facts.push(`인코딩 ${encoding}`); const pixelX = number(metadata.pixel_size_x); const pixelY = number(metadata.pixel_size_y); if (pixelX !== null && pixelY !== null) { facts.push(`픽셀 ${Math.abs(pixelX)}×${Math.abs(pixelY)} m`); const rotated = (number(metadata.rotation_x) ?? 0) !== 0 || (number(metadata.rotation_y) ?? 0) !== 0; facts.push(rotated ? "회전 있음" : "회전 없음"); } // PRJ 는 좌표계 이름이 곧 구분값이다. const name = text(metadata.name); const epsgLabel = readCrsLabel(metadata); if (name) facts.push(epsgLabel ? `${name} (EPSG:${epsgLabel})` : name); else if (epsgLabel && facts.length === 0) facts.push(`EPSG:${epsgLabel}`); return facts.slice(0, 2); } /** * 좌표계 코드(EPSG 숫자). 서로 다르면 범위 대조를 하지 않는다. * 분석기마다 숫자(`5176`)로도, 문자열(`"EPSG:5176"`)로도 온다 — 숫자만 뽑아 맞춘다. */ export function readCrsLabel(metadata: Record | undefined): string | null { const epsg = metadata?.epsg ?? metadata?.crs_epsg ?? metadata?.crs; if (typeof epsg === "number" && Number.isFinite(epsg)) return String(epsg); if (typeof epsg !== "string") return null; const digits = epsg.match(/\d{4,6}/); return digits ? digits[0] : null; } /** a 가 b 안에 들어오는가(경계 포함). */ export function isInside(inner: PreviewExtent, outer: PreviewExtent): boolean { return ( inner.xMin >= outer.xMin && inner.xMax <= outer.xMax && inner.yMin >= outer.yMin && inner.yMax <= outer.yMax ); } /** 자기 범위를 화면 좌표(위가 북쪽)로 옮기는 변환을 만든다. */ function projector(extent: PreviewExtent): (x: number, y: number) => [number, number] { const spanX = extent.xMax - extent.xMin; const spanY = extent.yMax - extent.yMin; const scale = Math.min((VIEW_WIDTH - PADDING * 2) / spanX, (VIEW_HEIGHT - PADDING * 2) / spanY); const offsetX = (VIEW_WIDTH - spanX * scale) / 2; const offsetY = (VIEW_HEIGHT - spanY * scale) / 2; return (x, y) => [ offsetX + (x - extent.xMin) * scale, // SVG 는 y 가 아래로 자라므로 뒤집는다. VIEW_HEIGHT - offsetY - (y - extent.yMin) * scale, ]; } export interface PreviewOptions { metadata?: Record; /** 업로드·분석이 끝난 카드인지 — 끝난 카드에만 「값 없음」 안내를 보인다. */ completed?: boolean; /** 지형 자료 범위 — 계획노선 카드에서 「범위 안인가」를 대조할 때만 쓴다. */ terrainExtent?: PreviewExtent | null; /** 지형 자료 좌표계 — 노선과 다르면 대조를 생략한다(재투영은 하지 않는다). */ terrainCrs?: string | null; /** * 이 자료의 좌표계를 대신 알려 준다. shapefile 자체에는 좌표계가 없어 * 같은 세트의 `.prj` 값이 정본이다(2026-09-04 실측 — `epsg: null`). */ crsFallback?: string | null; } /** * 카드 본문에 미리보기를 그린다. 그릴 것이 없으면 비우고 숨긴다(값 없으면 미표시). */ export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): void { host.replaceChildren(); host.classList.remove("is-visible", "is-warning"); host.removeAttribute("title"); const extent = readExtent(options.metadata); if (!extent) { // 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로 보인다 (2026-09-04 사용자 지시). const facts = readFacts(options.metadata); if (facts.length === 0) { if (!options.completed) return; const empty = document.createElement("div"); empty.className = "b03-file__preview-empty"; empty.textContent = "보여 줄 값이 없음"; host.append(empty); host.classList.add("is-visible"); return; } const box = document.createElement("div"); box.className = "b03-file__preview-facts"; facts.forEach((fact, index) => { const line = document.createElement("span"); line.className = index === 0 ? "b03-file__preview-fact" : "b03-file__preview-fact-sub"; line.textContent = fact; box.append(line); }); host.title = facts.join(" · "); host.append(box); host.classList.add("is-visible"); return; } // GeoTIFF 썸네일이 있으면 그림 그대로 보인다(오버뷰가 있는 파일만 만들어진다). const thumbnail = readThumbnail(options.metadata); if (thumbnail) { const image = document.createElement("img"); image.className = "b03-file__preview-thumb"; image.src = thumbnail; image.alt = L("B03_File_Preview_Extent"); host.title = L("B03_File_Preview_Extent"); host.append(image); host.classList.add("is-visible"); return; } const path = readPreviewPath(options.metadata); const cloud = readPreviewPoints(options.metadata); const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("viewBox", `0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`); svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); svg.setAttribute("role", "img"); const project = projector(extent); // 자료가 덮는 땅 범위 — 어느 자료든 늘 그린다. const [rectX, rectTop] = project(extent.xMin, extent.yMax); const [rectRight, rectBottom] = project(extent.xMax, extent.yMin); const rect = document.createElementNS(SVG_NS, "rect"); rect.setAttribute("class", "b03-file__preview-extent"); rect.setAttribute("x", String(rectX)); rect.setAttribute("y", String(rectTop)); rect.setAttribute("width", String(rectRight - rectX)); rect.setAttribute("height", String(rectBottom - rectTop)); svg.append(rect); let label = L("B03_File_Preview_Extent"); // 라이다 탑뷰 점 그림 — 분석기가 훑는 길에 주워 둔 XY 를 그대로 찍는다. if (cloud) { label = `점 ${cloud.length.toLocaleString()}개 (솎은 탑뷰)`; const dots = document.createElementNS(SVG_NS, "path"); dots.setAttribute("class", "b03-file__preview-cloud"); dots.setAttribute( "d", cloud .map(([x, y]) => { const [sx, sy] = project(x, y); return `M${sx.toFixed(1)} ${sy.toFixed(1)}h0.7`; }) .join(""), ); svg.append(dots); } if (path) { label = L("B03_File_Preview_Route"); for (const part of path) { const line = document.createElementNS(SVG_NS, "polyline"); line.setAttribute("class", "b03-file__preview-route"); line.setAttribute( "points", part .map(([x, y]) => project(x, y) .map((value) => value.toFixed(2)) .join(","), ) .join(" "), ); svg.append(line); } } // 노선이 지형 자료 범위를 벗어나면 카드에서 바로 보이게 경고색을 건다. // 솎은 좌표열이 없어도(옛 업로드) 범위끼리는 대조된다. // 좌표계가 다르면 대조하지 않는다(프론트에서 재투영하지 않음). const routeCrs = readCrsLabel(options.metadata) ?? options.crsFallback ?? null; const sameCrs = !routeCrs || !options.terrainCrs || routeCrs === options.terrainCrs; if (options.terrainExtent && sameCrs) { const inside = isInside(extent, options.terrainExtent); host.classList.toggle("is-warning", !inside); label = L(inside ? "B03_File_Preview_Inside" : "B03_File_Preview_Outside"); } svg.setAttribute("aria-label", label); host.title = label; host.append(svg); host.classList.add("is-visible"); }