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>
This commit is contained in:
2026-09-04 07:04:33 +09:00
co-authored by Claude Opus 5
parent af85dea714
commit ab16361125
10 changed files with 337 additions and 2 deletions
+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,
};
}
}