auto: 2026-09-01 16:56 (ESD_LAPTOP)

This commit is contained in:
2026-09-01 16:56:32 +09:00
parent f82e60492e
commit e6b4d960bc
5 changed files with 242 additions and 96 deletions
+82 -96
View File
@@ -10,10 +10,7 @@ import {
} 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 { 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 {
@@ -24,11 +21,13 @@ import {
} from "../A00_Common/b_workflow_nav";
import {
analyzeSurface,
buildSheetSurface,
confirmSurfaceModel,
fetchConfirmedSurface,
fetchPlannedRoute,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSheetMethods,
listSurfaceInputFiles,
listSurfaceModels,
type SurfaceInputFileSummary,
@@ -49,14 +48,7 @@ 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",
];
const SHEET_METHOD_ORDER = ["tin_sheet", "tin", "biharmonic", "anudem", "multires", "laplace"];
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -122,17 +114,11 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
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,
);
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,
surfaceStage?.state === "COMPLETE" ? ROUTES.B05_PROFILE : ROUTES.B03_FILE_INPUT,
);
return;
}
@@ -192,15 +178,47 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
sheetViewer.render(projectId, models);
}
/** 아직 안 만든 도엽 방식이면 물어보고 그 방식만 만든다. 만들어 두면 다시 골라도 그대로 쓴다. */
async function onSheetMethodClick(method: string, label: string): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
const built = models.some(
(model) =>
model.model_type.toLowerCase() === "dtm" && getModelFilter(model) === `sheet_${method}`,
);
if (built) {
selectSheetMethod(method);
return;
}
const message = L("B04_Surface_Build_Confirm")
.replace("{filter}", label)
.replace("{method}", "도엽등고");
if (!(await showConfirmDialog(message, L("B04_Surface_Build_Action")))) return;
showLoadingOverlay();
showToast(L("B04_Surface_Build_Running"), "info");
try {
await buildSheetSurface(projectId, method);
models = (await listSurfaceModels(projectId)).models;
showToast(
L("B04_Surface_Build_Done").replace("{filter}", label).replace("{method}", "도엽등고"),
"success",
);
selectSheetMethod(method);
} catch (error) {
const detail = error instanceof Error ? error.message : "";
showToast(`${L("B04_Surface_Build_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
// 라이다 지표면 겹쳐 보기 — 확정 필터의 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")}`),
);
lidarLabel.append(lidarCheck, document.createTextNode(` ${L("B04_Surface_SheetLidar")}`));
lidarCheck.addEventListener("change", () => {
void sheetViewer
.showOverlay(
@@ -278,13 +296,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
const actionRow = document.createElement("div");
actionRow.className = "ui-sidebar-actions";
actionRow.append(confirmButton, resetButton);
panel.append(
inputGroup,
analysisGroup,
displayGroup,
viewer.controlsGroup,
actionRow,
);
panel.append(inputGroup, analysisGroup, displayGroup, viewer.controlsGroup, actionRow);
const viewers = document.createElement("div");
viewers.className = "b04-surface__viewers";
@@ -313,8 +325,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (layoutProjectId)
goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
if (layoutProjectId) goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
@@ -348,11 +359,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
return;
}
const variant =
status.status === "completed"
? "success"
: status.status === "failed"
? "danger"
: "warning";
status.status === "completed" ? "success" : status.status === "failed" ? "danger" : "warning";
statusBox.append(
createTag(`${status.progress_percent}%`, variant),
document.createTextNode(status.message),
@@ -369,9 +376,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
inputInfo.append(
buildInfoLine(
"좌표계",
selectedInputFile.crs_epsg
? `EPSG:${selectedInputFile.crs_epsg}`
: null,
selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null,
),
buildInfoLine(
"크기",
@@ -380,10 +385,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
: `${selectedInputFile.file_size_mb.toFixed(2)} MB`,
),
buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()),
buildInfoLine(
"표시 포인트 수",
pointCloud?.sampled_count.toLocaleString(),
),
buildInfoLine("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()),
buildInfoLine("높이 범위", heightRange),
);
}
@@ -453,9 +455,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
});
models = (await listSurfaceModels(projectId)).models;
showToast(
L("B04_Surface_Build_Done")
.replace("{filter}", filter)
.replace("{method}", method),
L("B04_Surface_Build_Done").replace("{filter}", filter).replace("{method}", method),
"success",
);
return true;
@@ -473,10 +473,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
function updateSelectedModel(): void {
const projectId = getProjectId();
if (!projectId) return;
terrainViewer.setSelection(
filterGroup.select.value,
methodGroup.select.value,
);
terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value);
terrainViewer.render(projectId, models);
confirmButton.disabled = !findSelectedModel();
}
@@ -487,20 +484,14 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
showLoadingOverlay();
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(
projectId,
filterGroup.select.value,
);
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
: "지면 포인트 조회에 실패했습니다.";
const detail = error instanceof Error ? error.message : "지면 포인트 조회에 실패했습니다.";
showToast(detail, "error");
} finally {
hideLoadingOverlay();
@@ -520,8 +511,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
if (confirmed.model_id) {
if (confirmed.source_filter)
filterGroup.select.value = confirmed.source_filter;
if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter;
if (confirmed.method) methodGroup.select.value = confirmed.method;
terrainViewer.setSmoothing(confirmed.smooth ?? false);
}
@@ -539,10 +529,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
.catch(() => terrainViewer.setRoute([]));
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(
projectId,
filterGroup.select.value,
);
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
// 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다.
@@ -555,22 +542,21 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
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),
}))
// 모델 목록은 최신순이라 버튼이 뒤섞인다 — 정의 순서로 고정한다.
// 도엽등고 3D 서피스 — 버튼은 **고를 수 있는 방식 전체**로 세운다. 자동 전처리는
// 기본 하나만 만들므로, 만들어진 모델만 세우면 나머지를 고를 길이 없다.
// 안 만든 방식을 누르면 물어본 뒤 그 방식만 만든다(2026-09-01 사용자 확정).
const available = await listSheetMethods(projectId).catch(() => null);
const builtKeys = new Set(
models
.filter(
(model) =>
model.model_type.toLowerCase() === "dtm" && getModelFilter(model).startsWith("sheet_"),
)
.map((model) => getModelFilter(model).slice("sheet_".length)),
);
const sheetMethods = (available?.methods ?? [])
.map((entry) => ({ key: entry.key, label: entry.label, built: builtKeys.has(entry.key) }))
// 목록 순서가 흔들려도 버튼 자리는 정의 순서로 고정한다.
.sort(
(a, b) =>
(SHEET_METHOD_ORDER.indexOf(a.key) + 1 || 99) -
@@ -585,7 +571,12 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
button.type = "button";
button.className = "b04-surface__sheet-method";
button.textContent = method.label;
button.addEventListener("click", () => selectSheetMethod(method.key));
// 아직 안 만든 방식은 눌러야 만들어진다 — 눌러 보기 전에 알 수 있게 표시한다.
button.classList.toggle("is-unbuilt", !method.built);
if (!method.built) button.title = L("B04_Surface_Build_Action");
button.addEventListener("click", () => {
void onSheetMethodClick(method.key, method.label);
});
sheetToolbar.append(button);
sheetMethodButtons.set(method.key, button);
}
@@ -593,10 +584,12 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
sheetViewer.smoothingField.classList.add("b04-surface__sheet-smoothing");
sheetToolbar.append(sheetViewer.smoothingField, lidarLabel);
sheetViewer.setSmoothing(true);
// 처음 열 때 안 만든 방식을 고르면 묻지도 않았는데 모달이 뜬다 — 만들어 둔 것을 고른다.
const firstBuilt = sheetMethods.find((method) => method.built);
selectSheetMethod(
sheetMethods.some((method) => method.key === sheetMethod)
sheetMethods.some((method) => method.key === sheetMethod && method.built)
? sheetMethod
: sheetMethods[0].key,
: (firstBuilt?.key ?? sheetMethods[0].key),
);
}
}
@@ -625,20 +618,14 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
L("B04_Surface_Confirm_Success")
.replace("{filter}", filterGroup.select.value)
.replace("{method}", methodGroup.select.value)
.replace(
"{smoothing}",
terrainViewer.isSmoothingEnabled() ? "ON" : "OFF",
),
.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");
const detail = error instanceof Error ? error.message : L("B04_Surface_Confirm_Failed");
showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
@@ -666,8 +653,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
}
inputSelect.addEventListener("change", () => {
selectedInputFile =
inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
renderInputInfo();
});
filterGroup.select.addEventListener("change", () => {