Files
Aislo/B03_FileInput/B03_FileInput_UI_Preview.ts
T
eomsangdonandClaude Opus 5 41c1771fad feat(B03): 부속 파일 카드에 파일 속 값 표시·래스터 썸네일 점선 테두리
개수만 보이던 부속 파일 카드에 파일 안의 중요한 값을 함께 보임.

- .shx — 도형 종류(폴리라인 등)와 범위 크기를 머리글에서 읽음.
- .dbf — 속성 이름 목록을 머리글 서술자에서 읽음(앞 4개).
- .cpg — 같은 세트 .dbf 의 첫 글자 속성을 그 인코딩으로 읽어 견본으로 보임.
- .prj — 중앙자오선(투영 매개변수)과 길이 단위. proj 문자열 변환을 쓰지 않아
  정보 손실 경고가 나지 않음.
- 카드 표시 줄 수를 2 → 3 으로 늘리고, 길이 단위 표기를 m 으로 통일.
- GeoTIFF 썸네일에 다른 미리보기와 같은 점선 테두리.

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

368 lines
15 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;
}
/** SHP/SHX 머리글의 도형 종류 코드 — 카드에 「무엇이 들었나」로 보인다. */
const SHAPE_TYPE_LABELS: Record<number, string> = {
0: "빈 도형",
1: "점",
3: "폴리라인",
5: "폴리곤",
8: "다중점",
11: "점(3D)",
13: "폴리라인(3D)",
15: "폴리곤(3D)",
18: "다중점(3D)",
21: "점(M)",
23: "폴리라인(M)",
25: "폴리곤(M)",
28: "다중점(M)",
31: "복합면",
};
/** 129.002890277778 → 「129.0029°E」. */
function meridianLabel(degrees: number): string {
const rounded = Math.abs(Math.round(degrees * 10000) / 10000);
return `${rounded}°${degrees < 0 ? "W" : "E"}`;
}
/**
* 그릴 도형이 없는 부속 파일(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 round = (value: number): string => Math.round(value).toLocaleString();
// SHX — 도형 종류·개수와 그 도형이 덮는 범위.
const shapes = number(metadata.shape_count);
if (shapes !== null) {
const kind = SHAPE_TYPE_LABELS[number(metadata.shape_type) ?? -1];
const count = `도형 ${shapes.toLocaleString()}개`;
facts.push(kind ? `${kind} · ${count}` : count);
const width = number(metadata.extent_width_m);
const height = number(metadata.extent_height_m);
if (width !== null && height !== null) facts.push(`범위 ${round(width)}×${round(height)} m`);
}
// DBF — 레코드·속성 개수와 속성 이름(무엇이 든 표인지).
const records = number(metadata.record_count);
const fields = number(metadata.field_count);
if (records !== null) {
const count = `레코드 ${records.toLocaleString()}개`;
facts.push(fields !== null ? `${count} · 속성 ${fields}개` : count);
}
const names = Array.isArray(metadata.field_names)
? metadata.field_names.filter((name): name is string => typeof name === "string")
: [];
if (names.length) facts.push(names.slice(0, 4).join(", ") + (names.length > 4 ? " …" : ""));
// CPG — 인코딩 이름과 그 인코딩으로 읽은 글자 견본(깨지면 눈에 보인다).
const encoding = text(metadata.encoding);
if (encoding) facts.push(`인코딩 ${encoding}`);
const sample = text(metadata.encoding_sample);
if (sample) facts.push(`견본 「${sample}」`);
// TFW — 픽셀 크기와 회전.
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 — 좌표계 이름·EPSG 와 원점(중앙자오선)·길이 단위.
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}`);
const meridian = number(metadata.central_meridian_deg);
const unit = text(metadata.unit_name);
const origin = [
meridian === null ? null : `중앙자오선 ${meridianLabel(meridian)}`,
unit ? `단위 ${/^met(er|re)s?$/i.test(unit) ? "m" : unit}` : null,
].filter((part): part is string => part !== null);
if (origin.length) facts.push(origin.join(" · "));
const vertical = metadata.vertical_crs as Record<string, unknown> | null | undefined;
if (vertical && typeof vertical === "object") {
const verticalName = text(vertical.name);
if (verticalName) facts.push(`높이 기준 ${verticalName}`);
}
return facts.slice(0, 3);
}
/**
* 좌표계 코드(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");
}