- B04 · B07 · B08 · B09 가 공용 guardProjectLeave 에 {dirty, save, discard} 등록 — 고침 있을 때만 [취소 · 폐기 · 저장]
- B04 = 관 지점 고침(drainageDirty) · 저장 = 관 지점 · 세부유역 영구저장
- B07 = CAD · 탭 고침 · 저장 = saveEditedDrawings · 폐기 = 고친 도면 CAD 자동백업도 버림(discardEditedRecovery)
- B08 = 세션 b08-* 칸 넷 · 저장 = saveShown · B09 = b09-estimation 칸 · 저장 = saveChanged
- 지금 프로젝트 직접 읽기(localStorage) → currentProjectId() · B02 새 프로젝트 = setCurrentProject — B02 · B03 · B04 · B07 · B08 · B09 · B11
- 다른 프로젝트 보관함 지우기 셋(B04 · B05 종단 · B11) 뺌 — enterProject 한 곳 · 다른 탭 보관분 안 지움
- B02 · B03 은 가드 없음 — 저장 안 한 프로젝트 값 없음(B03 올리기 끊김은 이어 올리기 배너가 받음)
- 시험 test_560_4 넓힘(B 가드 · 탭 프로젝트 · 보관함) · helper_298_2 · helper_481_2 · test_204_2 흉내를 currentProjectId 로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
680 lines
26 KiB
TypeScript
680 lines
26 KiB
TypeScript
import { ROUTES } from "@config/config_frontend";
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import {
|
|
createButton,
|
|
createSelectField,
|
|
createTag,
|
|
hideLoadingOverlay,
|
|
showConfirmDialog,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { createKv, type KvRow } from "@ui/ui_template_kv";
|
|
import { createSegment, type SegmentHandle } from "@ui/ui_template_segment";
|
|
import { createToggle } from "@ui/ui_template_toggle";
|
|
import { createToolbar } from "@ui/ui_template_toolbar";
|
|
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
|
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
|
import { clearPreloadMark } 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 {
|
|
analyzeSurface,
|
|
buildSheetSurface,
|
|
confirmSurfaceModel,
|
|
fetchConfirmedSurface,
|
|
fetchPlannedRoute,
|
|
fetchSurfacePointCloud,
|
|
fetchSurfaceStatus,
|
|
listSheetMethods,
|
|
listSurfaceInputFiles,
|
|
listSurfaceModels,
|
|
type SurfaceInputFileSummary,
|
|
type SurfaceModelSummary,
|
|
type SurfacePointCloudSampleResponse,
|
|
type SurfaceStatusResponse,
|
|
} from "./B04_PreProcess_Api_Fetch";
|
|
import { buildSurfaceGroup } from "./B04_PreProcess_UI_Group";
|
|
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";
|
|
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
|
|
import "./B04_PreProcess_UI_Style_Map.css";
|
|
import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch";
|
|
|
|
// 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가
|
|
// 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정).
|
|
const SOURCE_FILTERS = ["classification", "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 infoRow(label: string, value: unknown): KvRow {
|
|
return { label, value: value == null || value === "" ? "-" : String(value) };
|
|
}
|
|
|
|
/** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */
|
|
function buildSelectField(
|
|
label: string,
|
|
values: readonly string[],
|
|
defaultValue: string,
|
|
): { root: HTMLElement; select: HTMLSelectElement } {
|
|
const { root, select } = createSelectField({
|
|
label,
|
|
options: values.map((value) => ({
|
|
value,
|
|
text: value.replaceAll("_", " ").toUpperCase(),
|
|
})),
|
|
value: defaultValue,
|
|
size: 36,
|
|
});
|
|
root.classList.add("b04-surface__field");
|
|
return { root, select };
|
|
}
|
|
|
|
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 = currentProjectId();
|
|
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 inputSelectField = createSelectField({
|
|
options: [],
|
|
size: 36,
|
|
ariaLabel: L("B04_Surface_InputFiles"),
|
|
});
|
|
const inputSelect = inputSelectField.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,
|
|
);
|
|
// 되돌릴 값을 기억해 둔다 — 모달에서 취소하면 드롭다운을 원래 자리로 돌린다.
|
|
let appliedFilter = DEFAULT_FILTER;
|
|
let appliedMethod = DEFAULT_METHOD;
|
|
const viewer = createSurfacePointCloudViewer();
|
|
const terrainViewer = createSurfaceTerrainViewer();
|
|
const mapViewer = createSurfaceMapViewer();
|
|
// 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천,
|
|
// 2026-08-30). 모델 목록에 sheet/dtm이 있을 때만 별도 컨테이너로 보여준다.
|
|
const sheetViewer = createSurfaceTerrainViewer();
|
|
// 보간 방식 전환 줄 — 어느 방식이 이 지형에 맞는지 눈으로 비교해 정한다
|
|
// (2026-08-30 사용자 지시). 방식 목록은 실제 생성된 모델에서 만든다.
|
|
let sheetToolbar = createToolbar({ items: [], size: 28 });
|
|
let sheetSegment: SegmentHandle | null = null;
|
|
let sheetMethod = "";
|
|
|
|
function selectSheetMethod(method: string): void {
|
|
sheetMethod = method;
|
|
sheetSegment?.set(method);
|
|
const projectId = getProjectId();
|
|
if (!projectId) return;
|
|
sheetViewer.setSelection(`sheet_${method}`, "dtm");
|
|
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")))) {
|
|
sheetSegment?.set(sheetMethod); // 취소 — 고름 표시를 원래 방식으로 되돌린다
|
|
return;
|
|
}
|
|
|
|
showLoadingOverlay();
|
|
showToast(L("B04_Surface_Build_Running"), "info");
|
|
try {
|
|
await buildSheetSurface(projectId, method);
|
|
models = (await listSurfaceModels(projectId)).models;
|
|
// 만들었으니 「미생성」 표시를 걷는다 — 버튼 줄을 통째로 다시 세우지는 않는다
|
|
// (현재 선택·스무딩 상태가 날아간다).
|
|
const button = sheetSegment?.root.querySelector<HTMLElement>(`[data-value="${method}"]`);
|
|
if (button) {
|
|
button.classList.remove("is-unbuilt");
|
|
button.removeAttribute("title");
|
|
}
|
|
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");
|
|
sheetSegment?.set(sheetMethod);
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
// 라이다 지표면 겹쳐 보기 — 확정 필터의 DTM을 반투명으로 얹는다.
|
|
const lidarToggle = createToggle({
|
|
kind: "chip",
|
|
label: L("B04_Surface_SheetLidar"),
|
|
size: 28,
|
|
onChange: (on) => {
|
|
void sheetViewer
|
|
.showOverlay(on ? filterGroup.select.value : "", "dtm", terrainViewer.isSmoothingEnabled())
|
|
.then((loaded) => {
|
|
if (on && !loaded) {
|
|
showToast(L("B04_Surface_SheetLidar_Missing"), "warning");
|
|
lidarToggle.set(false);
|
|
}
|
|
});
|
|
},
|
|
});
|
|
|
|
const sheetSection = buildSurfaceGroup(
|
|
L("B04_Surface_SheetSurface"),
|
|
[sheetToolbar, sheetViewer.root],
|
|
"b04-surface__sheet-section",
|
|
);
|
|
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"),
|
|
// 가시성 톤 다운(2026-08-18 사용자 지시) — 붉은 테두리·글자만, 채움 없음(위험 고스트).
|
|
variant: "danger-ghost",
|
|
onClick: () => void onB04_Surface_Reset_Click(),
|
|
});
|
|
|
|
const inputGroup = buildSurfaceGroup(L("B04_Surface_InputFiles"), [
|
|
inputSelectField.root,
|
|
inputInfo,
|
|
]);
|
|
|
|
// 지면 필터 · 서피스 · 스무딩은 함께 모델 하나를 정하는 값이라 한 컨테이너에 모은다.
|
|
const analysisGroup = buildSurfaceGroup(L("B04_Surface_Group_Analysis"), [
|
|
filterGroup.root,
|
|
methodGroup.root,
|
|
terrainViewer.smoothingField,
|
|
]);
|
|
// 표시 옵션은 3D 모델 토글과 포인트 슬라이더를 한 칸에 둔다(슬라이더가 맨 아래).
|
|
const displayGroup = buildSurfaceGroup(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 = currentProjectId();
|
|
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 = currentProjectId();
|
|
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(
|
|
createKv([
|
|
infoRow("좌표계", selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null),
|
|
infoRow(
|
|
"크기",
|
|
selectedInputFile.file_size_mb == null
|
|
? null
|
|
: `${selectedInputFile.file_size_mb.toFixed(2)} MB`,
|
|
),
|
|
infoRow("포인트 수", pointCloud?.point_count.toLocaleString()),
|
|
infoRow("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()),
|
|
infoRow("높이 범위", 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,
|
|
);
|
|
}
|
|
|
|
/** 고른 조합이 영구 저장돼 있으면 그대로 쓰고, 없으면 물어본 뒤 그 조합만 만든다. */
|
|
async function ensureCombinationBuilt(
|
|
previousFilter: string,
|
|
previousMethod: string,
|
|
): Promise<boolean> {
|
|
const projectId = getProjectId();
|
|
const filter = filterGroup.select.value;
|
|
const method = methodGroup.select.value;
|
|
if (!projectId || findSelectedModel()) return true;
|
|
if (!selectedInputFile) {
|
|
showToast(L("B04_Surface_Load_Failed"), "error");
|
|
return false;
|
|
}
|
|
|
|
const message = L("B04_Surface_Build_Confirm")
|
|
.replace("{filter}", filter)
|
|
.replace("{method}", method);
|
|
if (!(await showConfirmDialog(message, L("B04_Surface_Build_Action")))) {
|
|
filterGroup.select.value = previousFilter;
|
|
methodGroup.select.value = previousMethod;
|
|
return false;
|
|
}
|
|
|
|
showLoadingOverlay();
|
|
showToast(L("B04_Surface_Build_Running"), "info");
|
|
try {
|
|
await analyzeSurface(projectId, {
|
|
input_file_id: selectedInputFile.id,
|
|
source_filters: [filter],
|
|
methods: [method],
|
|
force: false,
|
|
});
|
|
models = (await listSurfaceModels(projectId)).models;
|
|
showToast(
|
|
L("B04_Surface_Build_Done").replace("{filter}", filter).replace("{method}", method),
|
|
"success",
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : "";
|
|
showToast(`${L("B04_Surface_Build_Failed")} ${detail}`, "error");
|
|
filterGroup.select.value = previousFilter;
|
|
methodGroup.select.value = previousMethod;
|
|
return false;
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
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);
|
|
// 3D 좌표 환산 기준은 확정 서피스에서 먼저 받는다(수 KB). 포인트클라우드는 수십 MB라
|
|
// 늦거나 실패할 수 있는데, 그때 노선까지 같이 사라지면 안 된다.
|
|
if (confirmed.bounds) terrainViewer.setReferenceBounds(confirmed.bounds);
|
|
// 계획노선을 3D 최고 표고 평면에도 얹는다 — 라이다가 노선을 어디까지 덮는지
|
|
// 평면상으로 바로 보인다(2026-09-01 사용자 지시).
|
|
void fetchPlannedRoute(projectId)
|
|
.then((route) => terrainViewer.setRoute(route.points ?? []))
|
|
.catch(() => terrainViewer.setRoute([]));
|
|
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 서피스 — 버튼은 **고를 수 있는 방식 전체**로 세운다. 자동 전처리는
|
|
// 기본 하나만 만들므로, 만들어진 모델만 세우면 나머지를 고를 길이 없다.
|
|
// 안 만든 방식을 누르면 물어본 뒤 그 방식만 만든다(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) -
|
|
(SHEET_METHOD_ORDER.indexOf(b.key) + 1 || 99),
|
|
);
|
|
sheetSection.hidden = sheetMethods.length === 0;
|
|
if (sheetMethods.length) {
|
|
const segment = createSegment({
|
|
options: sheetMethods.map((method) => ({
|
|
value: method.key,
|
|
label: method.label,
|
|
// 아직 안 만든 방식은 눌러야 만들어진다 — 눌러 보기 전에 알 수 있게 표시한다.
|
|
title: method.built ? undefined : L("B04_Surface_Build_Action"),
|
|
})),
|
|
value: "",
|
|
size: 28,
|
|
ariaLabel: L("B04_Surface_SheetSurface"),
|
|
onChange: (key) =>
|
|
void onSheetMethodClick(key, sheetMethods.find((m) => m.key === key)?.label ?? key),
|
|
});
|
|
for (const method of sheetMethods) {
|
|
segment.root
|
|
.querySelector(`[data-value="${method.key}"]`)
|
|
?.classList.toggle("is-unbuilt", !method.built);
|
|
}
|
|
sheetSegment = segment;
|
|
// 스무딩 드롭다운과 라이다 토글은 오른쪽 끝에 함께 둔다.
|
|
const nextToolbar = createToolbar({
|
|
items: [segment.root, "spacer", sheetViewer.smoothingField, lidarToggle.root],
|
|
size: 28,
|
|
});
|
|
sheetToolbar.replaceWith(nextToolbar);
|
|
sheetToolbar = nextToolbar;
|
|
sheetViewer.setSmoothing(true);
|
|
// 처음 열 때 안 만든 방식을 고르면 묻지도 않았는데 모달이 뜬다 — 만들어 둔 것을 고른다.
|
|
const firstBuilt = sheetMethods.find((method) => method.built);
|
|
selectSheetMethod(
|
|
sheetMethods.some((method) => method.key === sheetMethod && method.built)
|
|
? sheetMethod
|
|
: (firstBuilt?.key ?? 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 {
|
|
// 확정값이 드롭다운에 반영된 뒤이므로, 되돌릴 기준도 여기서 다시 맞춘다.
|
|
appliedFilter = filterGroup.select.value;
|
|
appliedMethod = methodGroup.select.value;
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
inputSelect.addEventListener("change", () => {
|
|
selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
|
|
renderInputInfo();
|
|
});
|
|
filterGroup.select.addEventListener("change", () => {
|
|
void (async () => {
|
|
if (!(await ensureCombinationBuilt(appliedFilter, appliedMethod))) {
|
|
updateSelectedModel();
|
|
return;
|
|
}
|
|
appliedFilter = filterGroup.select.value;
|
|
updateSelectedModel();
|
|
await updatePointCloudForFilter();
|
|
})();
|
|
});
|
|
methodGroup.select.addEventListener("change", () => {
|
|
void (async () => {
|
|
if (await ensureCombinationBuilt(appliedFilter, appliedMethod)) {
|
|
appliedMethod = methodGroup.select.value;
|
|
}
|
|
updateSelectedModel();
|
|
})();
|
|
});
|
|
|
|
root.replaceChildren(layout.root);
|
|
// 572-2 떠남 물음 — 고침 = 관 지점 · 저장 = 관 지점 · 세부유역 영구저장(모델 [확정]과 같은 길)
|
|
guardProjectLeave(layout.root, {
|
|
dirty: mapViewer.drainageDirty,
|
|
save: mapViewer.commitDrainage,
|
|
});
|
|
const projectId = getProjectId();
|
|
if (projectId) void onB04_Surface_Reset_Click();
|
|
}
|