Files
Aislo/B04_PreProcess/B04_PreProcess_UI_Page.ts
T
eomsangdon 9fc4462d74 feat(B04): 도엽 보간에 TIN(도엽선)을 넣고 안 쓰는 3종을 뺀다
사용자 지시(2026-08-30): IDW·거리비례·TIN 곡면 제거, 5m 도엽등고선 기준 TIN 추가.

TIN(도엽선)은 격자에 구운 라인 셀이 아니라 벡터 등고선 정점의 원좌표를 그대로
Delaunay로 잇는다. 격자 밖은 100m 여유만 물고 자르며, 길이 필터는 두지 않는다
(짧은 봉우리 폐합 링을 버리면 마루가 통째로 평평해진다).

거리비례는 버튼에서만 빼고 함수는 남긴다 — 라플라스·TPS·ANUDEM·다중해상도가
초기추정으로 계속 쓴다. 기본 방식은 distance가 빠져 multires로 옮겼다.

검증: 실데이터 767×840·1m 6종 93.5s. 서피스에서 다시 뽑은 5m 선의 평면 이탈이
TIN(도엽선) 중앙 0.12m·2m초과 8.1%로 6종 중 최소(직전 최선 다중해상도
0.34m/18.3%). pytest 8 passed(격자와 어긋난 링 위 표고 재현 테스트 추가).
2026-08-30 17:36:17 +09:00

613 lines
22 KiB
TypeScript

import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createTag,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import {
clearPreloadMark,
purgeOtherProjects,
} from "../A00_Common/b_asset_cache";
import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
confirmSurfaceModel,
fetchConfirmedSurface,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSurfaceInputFiles,
listSurfaceModels,
type SurfaceInputFileSummary,
type SurfaceModelSummary,
type SurfacePointCloudSampleResponse,
type SurfaceStatusResponse,
} from "./B04_PreProcess_Api_Fetch";
import { createSurfaceMapViewer } from "./B04_PreProcess_UI_MapViewer";
import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer";
import { createSurfacePointCloudViewer } from "./B04_PreProcess_UI_Viewer";
import "./B04_PreProcess_UI_Style.css";
const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf"] as const;
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
const DEFAULT_FILTER = "csf";
const DEFAULT_METHOD = "dtm";
const ROUTE_STAGE = ROUTES.B05_PROFILE;
// 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다.
const SHEET_METHOD_ORDER = [
"tin_sheet",
"tin",
"biharmonic",
"anudem",
"multires",
"laplace",
];
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function buildInfoLine(label: string, value: unknown): HTMLElement {
const row = document.createElement("div");
row.className = "b04-surface__line";
const key = document.createElement("span");
key.textContent = label;
const val = document.createElement("strong");
val.textContent = value == null || value === "" ? "-" : String(value);
row.append(key, val);
return row;
}
/** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */
function buildSelectField(
label: string,
values: readonly string[],
defaultValue: string,
): { root: HTMLElement; select: HTMLSelectElement } {
const root = document.createElement("label");
root.className = "b04-surface__field";
const caption = document.createElement("span");
caption.textContent = label;
const select = document.createElement("select");
select.className = "b04-surface__select";
values.forEach((value) => {
const option = document.createElement("option");
option.value = value;
option.textContent = value.replaceAll("_", " ").toUpperCase();
select.append(option);
});
select.value = defaultValue;
root.append(caption, select);
return { root, select };
}
/** 제목이 달린 사이드 패널 컨테이너. */
function buildGroup(title: string, ...children: HTMLElement[]): HTMLElement {
const root = document.createElement("section");
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
root.className = "b04-surface__group ui-sidebar-section";
const heading = document.createElement("h3");
heading.className = "b04-surface__panel-title";
heading.textContent = title;
root.append(heading, ...children);
return root;
}
function getModelFilter(model: SurfaceModelSummary): string {
const configured = model.generation_params?.source_filter;
if (typeof configured === "string") return configured.toLowerCase();
const path = model.model_file_path?.toLowerCase() ?? "";
return SOURCE_FILTERS.find((filter) => path.includes(filter)) ?? "";
}
export async function renderB04Surface(root: HTMLElement): Promise<void> {
const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
if (guardedProjectId) void purgeOtherProjects(guardedProjectId);
if (guardedProjectId) {
const user = await fetchDashboardMe();
if (user.role !== "SYSTEM_ADMIN") {
const workflowState = await fetchWorkflowState(guardedProjectId).catch(
() => undefined,
);
const surfaceStage = workflowState?.stages.find(
(stage) => stage.stage_no === 1,
);
goToWorkflowStage(
guardedProjectId,
surfaceStage?.state === "COMPLETE"
? ROUTES.B05_PROFILE
: ROUTES.B03_FILE_INPUT,
);
return;
}
}
let selectedInputFile: SurfaceInputFileSummary | null = null;
let inputFiles: SurfaceInputFileSummary[] = [];
let models: SurfaceModelSummary[] = [];
let pointCloud: SurfacePointCloudSampleResponse | null = null;
const inputSelect = document.createElement("select");
inputSelect.className = "b04-surface__select";
const inputInfo = document.createElement("div");
inputInfo.className = "b04-surface__input-info";
const statusBox = document.createElement("div");
statusBox.className = "b04-surface__status";
const filterGroup = buildSelectField(
L("B04_Surface_Group_Filters"),
SOURCE_FILTERS,
DEFAULT_FILTER,
);
const methodGroup = buildSelectField(
L("B04_Surface_Group_Methods"),
MODEL_METHODS,
DEFAULT_METHOD,
);
const viewer = createSurfacePointCloudViewer();
const terrainViewer = createSurfaceTerrainViewer();
const mapViewer = createSurfaceMapViewer();
// 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천,
// 2026-08-30). 모델 목록에 sheet/dtm이 있을 때만 별도 컨테이너로 보여준다.
const sheetViewer = createSurfaceTerrainViewer();
const sheetSection = document.createElement("section");
sheetSection.className = "b04-surface__sheet-section ui-sidebar-section";
const sheetTitle = document.createElement("h3");
sheetTitle.className = "b04-surface__panel-title";
sheetTitle.textContent = L("B04_Surface_SheetSurface");
// 보간 방식 전환 줄 — 어느 방식이 이 지형에 맞는지 눈으로 비교해 정한다
// (2026-08-30 사용자 지시). 버튼 목록은 실제 생성된 모델에서 만든다.
const sheetToolbar = document.createElement("div");
sheetToolbar.className = "b04-surface__sheet-toolbar";
const sheetMethodButtons = new Map<string, HTMLButtonElement>();
let sheetMethod = "";
function selectSheetMethod(method: string): void {
sheetMethod = method;
for (const [key, button] of sheetMethodButtons) {
button.classList.toggle("is-active", key === method);
}
const projectId = getProjectId();
if (!projectId) return;
sheetViewer.setSelection(`sheet_${method}`, "dtm");
sheetViewer.render(projectId, models);
}
// 라이다 지표면 겹쳐 보기 — 확정 필터의 DTM을 반투명으로 얹는다.
const lidarLabel = document.createElement("label");
lidarLabel.className = "toggle-label toggle-button b04-surface__sheet-lidar";
const lidarCheck = document.createElement("input");
lidarCheck.type = "checkbox";
lidarLabel.append(
lidarCheck,
document.createTextNode(` ${L("B04_Surface_SheetLidar")}`),
);
lidarCheck.addEventListener("change", () => {
void sheetViewer
.showOverlay(
lidarCheck.checked ? filterGroup.select.value : "",
"dtm",
terrainViewer.isSmoothingEnabled(),
)
.then((loaded) => {
if (lidarCheck.checked && !loaded) {
showToast(L("B04_Surface_SheetLidar_Missing"), "warning");
lidarCheck.checked = false;
}
});
});
sheetSection.append(sheetTitle, sheetToolbar, sheetViewer.root);
sheetSection.hidden = true;
let syncingCamera = false;
viewer.onCameraChange((state) => {
if (syncingCamera) return;
syncingCamera = true;
terrainViewer.applyCameraState(state);
syncingCamera = false;
});
terrainViewer.onCameraChange((state) => {
if (syncingCamera) return;
syncingCamera = true;
viewer.applyCameraState(state);
syncingCamera = false;
});
terrainViewer.onAxesVisibilityChange((visible) => {
viewer.setAxesVisible(visible);
});
viewer.setAxesVisible(false);
const confirmButton = createButton({
label: L("B04_Surface_Btn_Confirm"),
variant: "filled",
onClick: () => void onB04_Surface_Confirm_Click(),
});
const resetButton = createButton({
label: L("Common_Btn_Reset"),
variant: "ghost",
onClick: () => void onB04_Surface_Reset_Click(),
});
// 가시성 톤 다운(2026-08-18 사용자 지시) — 붉은 테두리·글자만, 채움 없음(ghost 배경).
resetButton.classList.add("b04-surface__reset");
const inputGroup = document.createElement("section");
inputGroup.className = "b04-surface__group ui-sidebar-section";
const inputTitle = document.createElement("h3");
inputTitle.className = "b04-surface__panel-title";
inputTitle.textContent = L("B04_Surface_InputFiles");
inputGroup.append(inputTitle, inputSelect, inputInfo);
// 지면 필터 · 서피스 · 스무딩은 함께 모델 하나를 정하는 값이라 한 컨테이너에 모은다.
const analysisGroup = buildGroup(
L("B04_Surface_Group_Analysis"),
filterGroup.root,
methodGroup.root,
terrainViewer.smoothingField,
);
// 표시 옵션은 3D 모델 토글과 포인트 슬라이더를 한 칸에 둔다(슬라이더가 맨 아래).
const displayGroup = buildGroup(
L("B04_Surface_Group_Display"),
terrainViewer.optionsContent,
viewer.optionsContent,
);
const panel = document.createElement("div");
panel.className = "b04-surface__form";
// 확정·초기화 버튼은 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤 제외
// (2026-08-05 사용자 지시, B04·B05·B06·B07 공통).
const actionRow = document.createElement("div");
actionRow.className = "ui-sidebar-actions";
actionRow.append(confirmButton, resetButton);
panel.append(
inputGroup,
analysisGroup,
displayGroup,
viewer.controlsGroup,
actionRow,
);
const viewers = document.createElement("div");
viewers.className = "b04-surface__viewers";
viewers.append(viewer.root, terrainViewer.root);
const workspace = document.createElement("div");
workspace.className = "b04-surface__workspace";
workspace.append(statusBox, viewers, sheetSection, mapViewer.root);
let workflowState: WorkflowState | undefined;
const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (layoutProjectId) {
try {
workflowState = await fetchWorkflowState(layoutProjectId);
} catch {
/* 조회 실패 시 전체 이동 허용 */
}
}
const layout = createWorkflowLayout({
title: L("B04_Surface_Title"),
steps: workflowSteps(),
activeStep: 1,
leftPanel: panel,
mainContent: workspace,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (layoutProjectId)
goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
function getProjectId(): string | null {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) showToast(L("B04_Surface_Error_Project"), "error");
return projectId;
}
function enableRouteStep(projectId: string): void {
const route = ROUTE_STAGE;
const routeIndex = WORKFLOW_STEP_ROUTES.indexOf(route);
const routeButton = layout.root.querySelectorAll<HTMLButtonElement>(
".ui-workflow-layout__step",
)[routeIndex];
if (!routeButton) return;
routeButton.disabled = false;
routeButton.classList.add("is-enabled", "state-in_progress");
routeButton.classList.remove("state-not_started", "state-stale");
if (routeButton.dataset.b04RouteEnabled === "true") return;
routeButton.dataset.b04RouteEnabled = "true";
routeButton.addEventListener("click", () => {
goToWorkflowStage(projectId, route);
});
}
function renderStatus(status: SurfaceStatusResponse | null): void {
statusBox.replaceChildren();
if (!status) {
statusBox.append(createTag(L("B04_Surface_Status_Unknown"), "neutral"));
return;
}
const variant =
status.status === "completed"
? "success"
: status.status === "failed"
? "danger"
: "warning";
statusBox.append(
createTag(`${status.progress_percent}%`, variant),
document.createTextNode(status.message),
);
}
function renderInputInfo(): void {
inputInfo.replaceChildren();
if (!selectedInputFile) return;
const bounds = pointCloud?.bounds;
const heightRange = bounds
? `${bounds.z_min.toFixed(2)} m ~ ${bounds.z_max.toFixed(2)} m`
: null;
inputInfo.append(
buildInfoLine(
"좌표계",
selectedInputFile.crs_epsg
? `EPSG:${selectedInputFile.crs_epsg}`
: null,
),
buildInfoLine(
"크기",
selectedInputFile.file_size_mb == null
? null
: `${selectedInputFile.file_size_mb.toFixed(2)} MB`,
),
buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()),
buildInfoLine(
"표시 포인트 수",
pointCloud?.sampled_count.toLocaleString(),
),
buildInfoLine("높이 범위", heightRange),
);
}
function renderInputFiles(files: readonly SurfaceInputFileSummary[]): void {
inputFiles = [...files];
inputSelect.replaceChildren();
selectedInputFile = inputFiles[0] ?? null;
if (!selectedInputFile) {
const option = document.createElement("option");
option.value = "";
option.textContent = L("B04_Surface_InputFiles_Empty");
inputSelect.append(option);
confirmButton.disabled = true;
renderInputInfo();
return;
}
inputFiles.forEach((file) => {
const option = document.createElement("option");
option.value = String(file.id);
option.textContent = `입력 LAS #${file.id}`;
inputSelect.append(option);
});
inputSelect.value = String(selectedInputFile.id);
renderInputInfo();
}
function findSelectedModel(): SurfaceModelSummary | undefined {
return models.find(
(model) =>
model.model_type.toLowerCase() === methodGroup.select.value &&
getModelFilter(model) === filterGroup.select.value,
);
}
function updateSelectedModel(): void {
const projectId = getProjectId();
if (!projectId) return;
terrainViewer.setSelection(
filterGroup.select.value,
methodGroup.select.value,
);
terrainViewer.render(projectId, models);
confirmButton.disabled = !findSelectedModel();
}
async function updatePointCloudForFilter(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
showLoadingOverlay();
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(
projectId,
filterGroup.select.value,
);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
renderInputInfo();
} catch (error) {
pointCloud = null;
viewer.render(null);
const detail =
error instanceof Error
? error.message
: "지면 포인트 조회에 실패했습니다.";
showToast(detail, "error");
} finally {
hideLoadingOverlay();
}
}
async function loadProjectData(projectId: string): Promise<void> {
const [inputs, status, modelResponse, confirmed] = await Promise.all([
listSurfaceInputFiles(projectId),
fetchSurfaceStatus(projectId),
listSurfaceModels(projectId),
// 확정본 구성(필터·표현·평활·등고선 간격)을 그대로 시작값으로 쓴다. 여기서 바꿔도
// DB에는 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01).
fetchConfirmedSurface(projectId),
]);
models = modelResponse.models;
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
if (confirmed.model_id) {
if (confirmed.source_filter)
filterGroup.select.value = confirmed.source_filter;
if (confirmed.method) methodGroup.select.value = confirmed.method;
terrainViewer.setSmoothing(confirmed.smooth ?? false);
}
if (confirmed.contour_interval_m)
terrainViewer.setContourInterval(confirmed.contour_interval_m);
renderInputFiles(inputs.files);
renderStatus(status);
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(
projectId,
filterGroup.select.value,
);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
// 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다.
mapViewer.render(projectId, confirmed.route_bounds);
} catch {
pointCloud = null;
viewer.render(null);
mapViewer.render(projectId, confirmed.route_bounds);
}
renderInputInfo();
updateSelectedModel();
// 도엽등고 3D 서피스 — sheet_* 모델이 있으면 별도 컨테이너로 보여준다.
// 보간 방식마다 모델이 하나씩 있으므로 버튼으로 갈아 끼운다.
const sheetMethods = models
.filter(
(model) =>
model.model_type.toLowerCase() === "dtm" &&
getModelFilter(model).startsWith("sheet_"),
)
.map((model) => ({
key: getModelFilter(model).slice("sheet_".length),
label:
typeof model.generation_params?.interpolation_label === "string"
? (model.generation_params.interpolation_label as string)
: getModelFilter(model).slice("sheet_".length),
}))
// 모델 목록은 최신순이라 버튼이 뒤섞인다 — 정의 순서로 고정한다.
.sort(
(a, b) =>
(SHEET_METHOD_ORDER.indexOf(a.key) + 1 || 99) -
(SHEET_METHOD_ORDER.indexOf(b.key) + 1 || 99),
);
sheetSection.hidden = sheetMethods.length === 0;
if (sheetMethods.length) {
sheetToolbar.replaceChildren();
sheetMethodButtons.clear();
for (const method of sheetMethods) {
const button = document.createElement("button");
button.type = "button";
button.className = "b04-surface__sheet-method";
button.textContent = method.label;
button.addEventListener("click", () => selectSheetMethod(method.key));
sheetToolbar.append(button);
sheetMethodButtons.set(method.key, button);
}
// 스무딩 드롭다운과 라이다 토글은 오른쪽 끝에 함께 둔다.
sheetViewer.smoothingField.classList.add("b04-surface__sheet-smoothing");
sheetToolbar.append(sheetViewer.smoothingField, lidarLabel);
sheetViewer.setSmoothing(true);
selectSheetMethod(
sheetMethods.some((method) => method.key === sheetMethod)
? sheetMethod
: sheetMethods[0].key,
);
}
}
async function onB04_Surface_Confirm_Click(): Promise<void> {
const projectId = getProjectId();
const model = findSelectedModel();
if (!projectId || !model) {
showToast(L("B04_Surface_Error_Selection"), "error");
return;
}
showLoadingOverlay();
try {
await confirmSurfaceModel(projectId, model.id, {
smooth: terrainViewer.isSmoothingEnabled(),
contour_interval_m: terrainViewer.getContourInterval(),
});
// 관 매설 지점·세부유역도 이때 함께 영구저장한다(2026-08-01 사용자 지시).
// 배수유역 분석 전이면 저장할 것이 없으므로 실패해도 모델 확정은 그대로 둔다.
await mapViewer.commitDrainage().catch(() => 0);
// 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다.
// 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다.
clearPreloadMark();
clearRouteLatestCache(projectId);
showToast(
L("B04_Surface_Confirm_Success")
.replace("{filter}", filterGroup.select.value)
.replace("{method}", methodGroup.select.value)
.replace(
"{smoothing}",
terrainViewer.isSmoothingEnabled() ? "ON" : "OFF",
),
"success",
);
await loadProjectData(projectId);
enableRouteStep(projectId);
goToWorkflowStage(projectId, ROUTE_STAGE);
} catch (error) {
const detail =
error instanceof Error
? error.message
: L("B04_Surface_Confirm_Failed");
showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
async function onB04_Surface_Reset_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
filterGroup.select.value = DEFAULT_FILTER;
methodGroup.select.value = DEFAULT_METHOD;
viewer.resetOptions();
terrainViewer.resetOptions();
showLoadingOverlay();
try {
await loadProjectData(projectId);
} catch {
showToast(L("B04_Surface_Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
inputSelect.addEventListener("change", () => {
selectedInputFile =
inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
renderInputInfo();
});
filterGroup.select.addEventListener("change", () => {
updateSelectedModel();
void updatePointCloudForFilter();
});
methodGroup.select.addEventListener("change", updateSelectedModel);
root.replaceChildren(layout.root);
const projectId = getProjectId();
if (projectId) void onB04_Surface_Reset_Click();
}