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>
This commit is contained in:
2026-09-04 19:17:00 +09:00
co-authored by Claude Opus 5
parent 4e4bfa2354
commit 9019ca28ef
4 changed files with 313 additions and 6 deletions
+119 -4
View File
@@ -21,9 +21,11 @@ export interface PreviewExtent {
}
const SVG_NS = "http://www.w3.org/2000/svg";
const VIEW_WIDTH = 100;
const VIEW_HEIGHT = 60;
const PADDING = 4;
// 카드 한 행을 전부 쓰고 높이는 고정한다 (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;
@@ -75,6 +77,63 @@ export function readPreviewPath(
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"`)로도 온다 — 숫자만 뽑아 맞춘다.
@@ -113,6 +172,8 @@ function projector(extent: PreviewExtent): (x: number, y: number) => [number, nu
export interface PreviewOptions {
metadata?: Record<string, unknown>;
/** 업로드·분석이 끝난 카드인지 — 끝난 카드에만 「값 없음」 안내를 보인다. */
completed?: boolean;
/** 지형 자료 범위 — 계획노선 카드에서 「범위 안인가」를 대조할 때만 쓴다. */
terrainExtent?: PreviewExtent | null;
/** 지형 자료 좌표계 — 노선과 다르면 대조를 생략한다(재투영은 하지 않는다). */
@@ -133,9 +194,47 @@ export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): v
host.removeAttribute("title");
const extent = readExtent(options.metadata);
if (!extent) return;
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");
@@ -154,6 +253,22 @@ export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): v
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) {