Files
Aislo/B03_FileInput/B03_FileInput_UI_Preview.ts
T
eomsangdonandClaude Opus 5 ab16361125 feat(B03): 업로드 자료 카드 그래픽 미리보기 — 범위 사각형·노선 형상·범위 대조
- 계획노선 shapefile 분석에 `preview_path`(200점 안팎 솎은 좌표열) 추가 — 이미
  메모리에 있는 정점을 쓰므로 파일 재열람 0회(솎기 0.136ms)
- LAS·GeoTIFF 는 사용자 확정대로 **bbox 사각형만** (점구름·래스터 렌더 안 함)
- `upload-overview` 응답에 분석 `metadata` 동봉 — 재접속해도 같은 그림
- 카드에 SVG 미리보기 렌더(신규 `B03_FileInput_UI_Preview.ts`), 값 없으면 미표시
- 계획노선 카드는 지형 자료 범위와 대조 — 벗어나면 경고색·안내, 좌표계가 다르면
  대조 생략(재투영 안 함). shapefile 은 좌표계가 없어 같은 세트 `.prj` 값을 씀

검증: 공용 브라우저 실측(노선 선 121점·범위 안/밖·좌표계 상이 3갈래),
tmp/tests/test_b03_preview_path.py 2건, tsc --noEmit 통과

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

192 lines
7.5 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";
const VIEW_WIDTH = 100;
const VIEW_HEIGHT = 60;
const PADDING = 4;
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;
}
/**
* 좌표계 코드(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>;
/** 지형 자료 범위 — 계획노선 카드에서 「범위 안인가」를 대조할 때만 쓴다. */
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) return;
const path = readPreviewPath(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");
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");
}