refactor(B04): B04_wf1_Surface -> B04_PreProcess 전면 개명
- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존) - 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess), 라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석 - 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
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_wf2_Route/B05_wf2_Route_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_WF2_ROUTE;
|
||||
|
||||
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_WF2_ROUTE : 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();
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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, 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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user