diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index e07dbf98..4768fecd 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -167,6 +167,8 @@ export interface UploadOverviewFile { uploaded_at: string | null; /** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */ relative_path: string | null; + /** 업로드 분석 결과 — 카드 미리보기(범위 사각형·노선 선)의 재료. */ + metadata?: Record | null; } export interface UploadOverviewSession { diff --git a/B03_FileInput/B03_FileInput_Engine_Shapefile.py b/B03_FileInput/B03_FileInput_Engine_Shapefile.py index aeb56ce8..f67e94b8 100644 --- a/B03_FileInput/B03_FileInput_Engine_Shapefile.py +++ b/B03_FileInput/B03_FileInput_Engine_Shapefile.py @@ -213,6 +213,33 @@ def _route_name_from_attributes(attributes: dict[str, str], fallback: str) -> st return fallback +_PREVIEW_MAX_POINTS = 200 + + +def _thin_preview_path( + parts: list[list[tuple]], limit: int = _PREVIEW_MAX_POINTS +) -> list[list[list[float]]]: + """카드 미리보기용으로 노선 정점을 솎는다 — 파일을 다시 읽지 않는다. + + 전 정점은 이미 메모리에 있고(`read_shapefile_parts`), 화면에 그릴 선은 60~80px + 높이라 200점이면 모양이 충분히 산다. 파트별로 시작·끝점은 반드시 남긴다. + """ + total = sum(len(part) for part in parts) + if total == 0: + return [] + step = max(1, total // limit) + preview: list[list[list[float]]] = [] + for part in parts: + if not part: + continue + thinned = [[float(point[0]), float(point[1])] for point in part[::step]] + last = [float(part[-1][0]), float(part[-1][1])] + if thinned[-1] != last: + thinned.append(last) + preview.append(thinned) + return preview + + def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]: """계획노선 shapefile의 B03 메타데이터를 만든다.""" source = Path(path) @@ -241,4 +268,6 @@ def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]: "bounds": header["bounds"], "start_point": list(parts[0][0]) if parts else None, "end_point": list(parts[-1][-1]) if parts else None, + # 카드 미리보기용 솎은 좌표열 — 파일 재열람 없음(2026-09-04 사용자 지시). + "preview_path": _thin_preview_path(parts), } diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index de194576..1c73b7dc 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -327,7 +327,7 @@ async def list_project_input_files( await cursor.execute( """ SELECT f.id, f.file_type, f.original_filename, f.file_size_mb, f.status, - f.upload_at, f.raw_file_path + f.upload_at, f.raw_file_path, f.metadata FROM input_files f INNER JOIN ( SELECT MAX(id) AS id diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index d63f236c..222b0666 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -781,6 +781,19 @@ async def get_project_upload_status( ) +def _parse_metadata(raw: Any) -> dict[str, Any] | None: + """DB에 JSON 문자열로 저장된 분석 메타데이터를 dict로 돌린다(깨지면 생략).""" + if isinstance(raw, dict): + return raw + if not raw: + return None + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return None + return parsed if isinstance(parsed, dict) else None + + @router.get("/{project_id}/upload-overview", response_model=UploadOverviewResponse) async def get_project_upload_overview( project_id: UUID, @@ -821,6 +834,7 @@ async def get_project_upload_overview( status=str(row["status"]), uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None, relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None), + metadata=_parse_metadata(row.get("metadata")), ) for row in files ], diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 18d24b7a..5211862d 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -131,6 +131,9 @@ class UploadOverviewFile(BaseModel): # PRJ는 노선용·지형용 두 장이 온다. 확장자로는 못 가리므로 저장 폴더로 가린다 # (노선 세트는 `B03_FileInput/input/shp/`에 모인다, 2026-08-31). relative_path: str | None = None + # 업로드 분석기가 낸 메타데이터 — 카드 미리보기(범위 사각형·노선 선)를 그리는 재료다 + # (2026-09-04). 재접속해도 같은 그림이 서도록 서버 정본을 그대로 실어 보낸다. + metadata: dict[str, Any] | None = None class UploadOverviewSession(BaseModel): diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 4a74dc82..307eaaf7 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -25,6 +25,12 @@ import { WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { restoreB03ProjectState } from "./B03_FileInput_State"; +import { + readCrsLabel, + readExtent, + renderSlotPreview, + type PreviewExtent, +} from "./B03_FileInput_UI_Preview"; import { confirmReplaceUpload, isInitialPipelineRunning, @@ -242,9 +248,45 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (state.error) setCardState(slot, "failed"); else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty"); else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus); + + const preview = card.querySelector(".b03-file__preview"); + if (preview) { + const terrain = terrainCoverage(); + renderSlotPreview(preview, { + metadata: state.serverUploaded?.metadata, + terrainExtent: ROUTE_SLOTS.includes(slot) ? terrain.extent : null, + terrainCrs: terrain.crs, + crsFallback: readCrsLabel(slots.get("route_prj")?.serverUploaded?.metadata), + }); + } updateUploadButton(); } + /** + * 지형 자료(라이다·GeoTIFF)가 덮는 범위를 하나로 합친다 — 계획노선이 그 안에 + * 들어오는지 대조하는 기준. 초기 계산 실패의 주된 원인이 범위 불일치라 + * 카드에서 바로 보이게 한다(2026-09-04). + */ + function terrainCoverage(): { extent: PreviewExtent | null; crs: string | null } { + let extent: PreviewExtent | null = null; + let crs: string | null = null; + for (const slot of TERRAIN_SLOTS) { + const metadata = slots.get(slot)?.serverUploaded?.metadata; + const next = readExtent(metadata); + if (!next) continue; + crs ??= readCrsLabel(metadata); + extent = extent + ? { + xMin: Math.min(extent.xMin, next.xMin), + xMax: Math.max(extent.xMax, next.xMax), + yMin: Math.min(extent.yMin, next.yMin), + yMax: Math.max(extent.yMax, next.yMax), + } + : next; + } + return { extent, crs }; + } + function showErrorMessage(slot: FileSlot, error: string): void { const state = slots.get(slot); if (!state) return; @@ -447,6 +489,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb, + metadata: file.metadata ?? undefined, }; } } diff --git a/B03_FileInput/B03_FileInput_UI_Preview.ts b/B03_FileInput/B03_FileInput_UI_Preview.ts new file mode 100644 index 00000000..4c893c10 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Preview.ts @@ -0,0 +1,191 @@ +/* ============================================================================= + * 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 | undefined): PreviewExtent | null { + const bounds = metadata?.bounds as Record | 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 | 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 | 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; + /** 지형 자료 범위 — 계획노선 카드에서 「범위 안인가」를 대조할 때만 쓴다. */ + 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"); +} diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index ac1f2dcc..5c9f6146 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -590,11 +590,53 @@ } .b03-file__card--empty .b03-file__file-info, +.b03-file__card--empty .b03-file__preview, .b03-file__card--empty .b03-file__progress-section, .b03-file__card--empty .b03-file__error-message { display: none; } +/* 업로드 자료 미리보기(2026-09-04) — 분석 메타데이터의 범위만으로 그리는 작은 도형. + 값이 없으면 `is-visible` 이 안 붙어 자리를 차지하지 않는다. */ +.b03-file__preview { + display: none; + margin-top: 6px; +} + +.b03-file__preview.is-visible { + display: block; +} + +.b03-file__preview svg { + display: block; + width: 100%; + height: 60px; +} + +.b03-file__preview-extent { + fill: color-mix(in srgb, var(--color-border, #9aa) 35%, transparent); + stroke: var(--color-border, #9aa); + stroke-width: 1; +} + +.b03-file__preview-route { + fill: none; + stroke: var(--color-primary, #4a3aff); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; +} + +/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */ +.b03-file__preview.is-warning .b03-file__preview-route { + stroke: var(--color-danger, #d33); +} + +.b03-file__preview.is-warning .b03-file__preview-extent { + stroke: var(--color-danger, #d33); + stroke-dasharray: 3 2; +} + .b03-file__card--selected .b03-file__progress-section { display: none; } diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 7b4e1707..609e9fad 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -39,7 +39,7 @@ export interface FileSlotState extends SlotConfig { * 로컬 파일을 새로 고르지 않아도 카드에 완료 상태로 표시하고, 이 슬롯에 새 파일을 * 올리면 교체 확인 모달을 띄우는 근거가 된다(2026-08-04 사용자 지시). */ - serverUploaded?: { name: string; sizeMb: number }; + serverUploaded?: { name: string; sizeMb: number; metadata?: Record }; } export interface StoredUploadSession { @@ -219,6 +219,7 @@ export function createFileCardTemplate(): HTMLTemplateElement { +
diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index cbcaa673..e73e12d1 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -346,6 +346,16 @@ export const ui_locales_b1 = { "Files moved, but analysis did not start (no point cloud file).", ], + B03_File_Preview_Extent: ["자료 범위", "Data extent"], + B03_File_Preview_Route: ["계획노선 형상", "Planned route shape"], + B03_File_Preview_Outside: [ + "계획노선이 지형 자료 범위를 벗어납니다.", + "The planned route falls outside the terrain data extent.", + ], + B03_File_Preview_Inside: [ + "계획노선이 지형 자료 범위 안에 있습니다.", + "The planned route is inside the terrain data extent.", + ], B03_File_Card_Select: ["파일 선택", "Select file"], B03_File_Card_Remove: ["파일 제거", "Remove file"], B03_File_Card_Optional: ["선택", "Optional"],