- 계획노선처럼 그릴 선이 있는 자료에는 범위 점선 상자를 그리지 않음. 점구름·래스터는 그대로 상자를 그림. - 노선 선 색을 녹색으로. 지형 범위를 벗어나면 붉은 경고색으로 덮어쓰는 규칙은 유지. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
/* =============================================================================
|
||
* 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);
|
||
|
||
// 자료가 덮는 땅 범위 — 그릴 도형이 없는 자료에만 그린다. 계획노선은 선 자체가
|
||
// 범위를 보여 주므로 점선 상자를 빼고 선만 남긴다(2026-09-04 사용자 지시).
|
||
if (!path) {
|
||
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");
|
||
}
|