Files
Aislo/B03_FileInput/B03_FileInput_UI_Preview.ts
T
eomsangdonandClaude Opus 5 9019ca28ef feat(B03): 파일 카드 미리보기 확대 — 전폭·고정 높이, 라이다 점 그림·GeoTIFF 썸네일
- 미리보기를 카드 한 행 전폭·높이 120px 고정으로 키움.
- 라이다: 분류 통계를 훑는 그 길에 XY 를 성기게 주워 탑뷰 점 그림(상한 5천 점,
  파일 재열람 없음). 분류가 없는 파일은 앞 3청크만 봄.
- GeoTIFF: 오버뷰가 있을 때만 128px 흑백 썸네일(2~98 백분위 대비), 없으면 종전 범위 사각형.
- 부속 파일(.shx·.dbf·.cpg·.prj·.tfw)은 그림 대신 구분되는 값 표시 — 머리글만 읽는
  가벼운 분석기 추가.
- 값이 없는 완료 카드는 「보여 줄 값이 없음」 한 줄.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:17:00 +09:00

307 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<string, unknown> | undefined): PreviewExtent | null {
const bounds = metadata?.bounds as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
/** 업로드·분석이 끝난 카드인지 — 끝난 카드에만 「값 없음」 안내를 보인다. */
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");
}