Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-09-04 07:31:20 +09:00
10 changed files with 338 additions and 2 deletions
+2
View File
@@ -167,6 +167,8 @@ export interface UploadOverviewFile {
uploaded_at: string | null;
/** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */
relative_path: string | null;
/** 업로드 분석 결과 — 카드 미리보기(범위 사각형·노선 선)의 재료. */
metadata?: Record<string, unknown> | null;
}
export interface UploadOverviewSession {
@@ -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),
}
+1 -1
View File
@@ -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
+14
View File
@@ -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
],
+3
View File
@@ -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):
+43
View File
@@ -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<void> {
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<HTMLDivElement>(".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<void> {
state.serverUploaded = {
name: file.original_filename,
sizeMb: file.file_size_mb,
metadata: file.metadata ?? undefined,
};
}
}
+191
View File
@@ -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<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");
}
+43
View File
@@ -590,11 +590,54 @@
}
.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) 12%, transparent);
stroke: var(--color-border, #9aa);
stroke-dasharray: 4 3;
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;
}
+2 -1
View File
@@ -39,7 +39,7 @@ export interface FileSlotState extends SlotConfig {
* ,
* (2026-08-04 ).
*/
serverUploaded?: { name: string; sizeMb: number };
serverUploaded?: { name: string; sizeMb: number; metadata?: Record<string, unknown> };
}
export interface StoredUploadSession {
@@ -219,6 +219,7 @@ export function createFileCardTemplate(): HTMLTemplateElement {
<span class="b03-file__file-name"></span>
<span class="b03-file__file-size"></span>
</div>
<div class="b03-file__preview"></div>
<div class="b03-file__progress-section">
<div class="b03-file__progress-bar-container">
<div class="b03-file__progress-bar"></div>
+10
View File
@@ -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"],