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 { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { confirmSurfaceModel, fetchSurfacePointCloud, fetchSurfaceStatus, listSurfaceInputFiles, listSurfaceModels, type SurfaceInputFileSummary, type SurfaceModelSummary, type SurfacePointCloudSampleResponse, type SurfaceStatusResponse, } from "./B04_wf1_Surface_Api_Fetch"; import { createSurfaceMapViewer } from "./B04_wf1_Surface_UI_MapViewer"; import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer"; import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer"; import "./B04_wf1_Surface_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 buildSelectGroup( title: string, values: readonly string[], defaultValue: string, ): { root: HTMLElement; select: HTMLSelectElement } { const root = document.createElement("section"); root.className = "b04-surface__group"; const heading = document.createElement("h3"); heading.className = "b04-surface__panel-title"; heading.textContent = title; 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(heading, select); 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 { 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 = buildSelectGroup("지면 필터 선택", SOURCE_FILTERS, DEFAULT_FILTER); const methodGroup = buildSelectGroup("지표면 표현 선택", 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: "모델 확정", variant: "filled", onClick: () => void onB04_Surface_Confirm_Click(), }); const resetButton = createButton({ label: "초기화", variant: "ghost", onClick: () => void onB04_Surface_Reset_Click(), }); const inputGroup = document.createElement("section"); inputGroup.className = "b04-surface__group"; const inputTitle = document.createElement("h3"); inputTitle.className = "b04-surface__panel-title"; inputTitle.textContent = L("B04_Surface_InputFiles"); inputGroup.append(inputTitle, inputSelect, inputInfo); const panel = document.createElement("div"); panel.className = "b04-surface__form"; panel.append( inputGroup, filterGroup.root, methodGroup.root, viewer.optionsGroup, terrainViewer.optionsGroup, viewer.controlsGroup, confirmButton, resetButton, ); 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( ".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 { 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 { const [inputs, status, modelResponse] = await Promise.all([ listSurfaceInputFiles(projectId), fetchSurfaceStatus(projectId), listSurfaceModels(projectId), ]); models = modelResponse.models; renderInputFiles(inputs.files); renderStatus(status); viewer.setLoading("포인트 데이터 로딩 중…"); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); mapViewer.render(projectId, pointCloud.bounds); } catch { pointCloud = null; viewer.render(null); mapViewer.render(projectId); } renderInputInfo(); updateSelectedModel(); } async function onB04_Surface_Confirm_Click(): Promise { 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(), }); 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 { 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(); }