/* ============================================================================= * B04_wf1_Surface_UI_Page.ts * 로그인 후 04: 1차 워크플로우 (지표면 모델 분석) * * 데스크톱 IDE 스타일 레이아웃 (createWorkflowLayout): * 헤더: 페이지 타이틀 + 진행 단계 스텝바 (숨김/복원 토글) * 좌측 오버레이 패널: 입력 파일 자동 선택 + 지면 필터/지표면 표현 + 실행 옵션 * 우측 메인: 3D 포인트클라우드 뷰어 + 지면 통계 + 지표면 모델 카드 * * 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션] * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). * ========================================================================== */ import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { navigateTo } from "../A00_Common/router"; import { createButton, createTag, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { analyzeSurface, fetchSurfaceGroundStats, fetchSurfacePointCloud, fetchSurfaceStatus, listSurfaceInputFiles, listSurfaceModels, type SurfaceInputFileSummary, type SurfaceModelSummary, type SurfaceStatusResponse, } from "./B04_wf1_Surface_Api_Fetch"; import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout"; import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer"; import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer"; import "./B04_wf1_Surface_UI_Style.css"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 선택 가능한 지면 필터 (config_system.SURFACE_MODEL_SOURCE_FILTERS + ransac) */ const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf", "ransac"] as const; /** 선택 가능한 지표면 표현 (config_system.SURFACE_MODEL_PRECOMPUTE) */ const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const; /** 체크박스 그룹 하나 생성 (라벨 + 항목들). 선택 값 Set을 반환. */ function buildCheckboxGroup( legend: string, values: readonly string[], defaults: readonly string[], ): { root: HTMLElement; selected: Set } { const selected = new Set(defaults); const root = document.createElement("fieldset"); root.className = "b04-surface__group"; const legendEl = document.createElement("legend"); legendEl.className = "b04-surface__group-legend"; legendEl.textContent = legend; root.append(legendEl); for (const value of values) { const item = document.createElement("label"); item.className = "b04-surface__check"; const box = document.createElement("input"); box.type = "checkbox"; box.value = value; box.checked = selected.has(value); box.addEventListener("change", () => { if (box.checked) selected.add(value); else selected.delete(value); }); const text = document.createElement("span"); text.textContent = value; item.append(box, text); root.append(item); } return { root, selected }; } 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; } export async function renderB04Surface(root: HTMLElement): Promise { let selectedInputFile: SurfaceInputFileSummary | null = null; let inputFiles: SurfaceInputFileSummary[] = []; 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 modelList = document.createElement("div"); modelList.className = "b04-surface__models"; const statsList = document.createElement("div"); statsList.className = "b04-surface__stats"; const viewer = createSurfacePointCloudViewer(); const terrainViewer = createSurfaceTerrainViewer(); const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [ "grid_min_z", "csf", "pmf", ]); const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [ "dtm", "tin", ]); const forceLabel = document.createElement("label"); forceLabel.className = "b04-surface__check"; const forceBox = document.createElement("input"); forceBox.type = "checkbox"; const forceText = document.createElement("span"); forceText.textContent = L("B04_Surface_Field_Force"); forceLabel.append(forceBox, forceText); const analyzeButton = createButton({ label: L("B04_Surface_Btn_Analyze"), variant: "filled", onClick: () => void onB04_Surface_Analyze_Click(), }); const refreshButton = createButton({ label: L("B04_Surface_Btn_Refresh"), variant: "ghost", onClick: () => void onB04_Surface_Refresh_Click(), }); const panel = document.createElement("div"); panel.className = "b04-surface__form"; 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); panel.append( inputGroup, filterGroup.root, methodGroup.root, forceLabel, viewer.controlsGroup, viewer.optionsGroup, analyzeButton, refreshButton, ); const workspace = document.createElement("div"); workspace.className = "b04-surface__workspace"; const topbar = document.createElement("div"); topbar.className = "b04-surface__topbar"; const titleWrap = document.createElement("div"); titleWrap.style.display = "flex"; titleWrap.style.alignItems = "baseline"; titleWrap.style.gap = "var(--spacing-12)"; const title = document.createElement("h3"); title.textContent = L("B04_Surface_PointCloud_Title"); titleWrap.append(title, viewer.statusSpan); topbar.append(titleWrap, statusBox); const bottom = document.createElement("div"); bottom.className = "b04-surface__bottom"; const statsSection = document.createElement("section"); statsSection.className = "b04-surface__section"; const statsTitle = document.createElement("h3"); statsTitle.textContent = L("B04_Surface_GroundStats_Title"); statsSection.append(statsTitle, statsList); const modelsSection = document.createElement("section"); modelsSection.className = "b04-surface__section"; const modelsTitle = document.createElement("h3"); modelsTitle.textContent = L("B04_Surface_Result_Title"); modelsSection.append(modelsTitle, modelList); bottom.append(statsSection, modelsSection); workspace.append(topbar, viewer.root, terrainViewer.root, bottom); const workflowRoutes = [ ROUTES.B03_FILE_INPUT, ROUTES.B04_WF1_SURFACE, ROUTES.B05_WF2_ROUTE, ROUTES.B06_WF3_PROFILE_CROSS, ROUTES.B07_WF4_DESIGN_DETAIL, ROUTES.B08_WF5_QUANTITY, ROUTES.B09_WF6_ESTIMATION, ]; let workflowStages: WorkflowStage[] = []; const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (layoutProjectId) { try { const res = await fetch(`/api/projects/${layoutProjectId}/workflow-state`); if (res.ok) { const data = await res.json(); workflowStages = data.workflow_state?.stages ?? data.stages ?? []; } } catch { /* 실패 시 빈 배열 → activeStep 기준 폴백으로 이동 허용 */ } } const layout = createWorkflowLayout({ title: L("B04_Surface_Title"), steps: workflowSteps(), activeStep: 1, leftPanel: panel, mainContent: workspace, stages: workflowStages, routes: workflowRoutes, onStepClick: (_stepIndex, route) => { if (layoutProjectId) { localStorage.setItem(CURRENT_PROJECT_ID_KEY, layoutProjectId); navigateTo(route as RoutePath); } }, }); function getProjectId(): string | null { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) showToast(L("B04_Surface_Error_Project"), "error"); return projectId; } 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 renderInputFiles(files: readonly SurfaceInputFileSummary[]): void { inputFiles = [...files]; inputSelect.replaceChildren(); if (inputFiles.length === 0) { selectedInputFile = null; const option = document.createElement("option"); option.value = ""; option.textContent = L("B04_Surface_InputFiles_Empty"); inputSelect.append(option); inputInfo.replaceChildren(); analyzeButton.disabled = true; return; } selectedInputFile = inputFiles[0]; for (const file of inputFiles) { const option = document.createElement("option"); option.value = String(file.id); option.textContent = `${file.original_filename} (#${file.id})`; inputSelect.append(option); } inputSelect.value = String(selectedInputFile.id); analyzeButton.disabled = false; renderInputInfo(); } function renderInputInfo(): void { inputInfo.replaceChildren(); if (!selectedInputFile) return; inputInfo.append( buildInfoLine(L("B04_Surface_Input_FileName"), selectedInputFile.original_filename), buildInfoLine(L("B04_Surface_Input_Crs"), selectedInputFile.crs_epsg), buildInfoLine(L("B04_Surface_Input_Size"), selectedInputFile.file_size_mb?.toFixed(2)), ); } function renderModels(models: readonly SurfaceModelSummary[]): void { modelList.replaceChildren(); const projectId = getProjectId(); if (projectId) { terrainViewer.render(projectId, models); } if (models.length === 0) { const empty = document.createElement("p"); empty.className = "b04-surface__empty"; empty.textContent = L("B04_Surface_Result_Empty"); modelList.append(empty); return; } for (const model of models) { const card = document.createElement("article"); card.className = "b04-surface__model-card"; const head = document.createElement("div"); head.className = "b04-surface__model-head"; const type = document.createElement("strong"); type.textContent = model.model_type; const variant = model.status === "CONFIRMED" || model.status === "COMPLETE" ? "success" : "neutral"; head.append(type, createTag(model.status, variant)); const meta = document.createElement("div"); meta.className = "b04-surface__model-meta"; meta.append( buildInfoLine(L("B04_Surface_Model_Resolution"), model.resolution_m), buildInfoLine(L("B04_Surface_Model_Path"), model.model_file_path), ); card.append(head, meta); modelList.append(card); } } function renderGroundStats(filters: Record>): void { statsList.replaceChildren(); const entries = Object.entries(filters); if (entries.length === 0) { const empty = document.createElement("p"); empty.className = "b04-surface__empty"; empty.textContent = L("B04_Surface_GroundStats_Empty"); statsList.append(empty); return; } for (const [name, value] of entries) { const item = document.createElement("article"); item.className = "b04-surface__stat-card"; item.append( buildInfoLine(L("B04_Surface_GroundStats_Filter"), name), buildInfoLine(L("B04_Surface_GroundStats_SourcePoints"), value.source_point_count), ); statsList.append(item); } } async function loadProjectData(projectId: string): Promise { const [inputs, status, models, stats] = await Promise.all([ listSurfaceInputFiles(projectId), fetchSurfaceStatus(projectId), listSurfaceModels(projectId), fetchSurfaceGroundStats(projectId), ]); renderInputFiles(inputs.files); renderStatus(status); renderModels(models.models); renderGroundStats(stats.filters); try { viewer.render(await fetchSurfacePointCloud(projectId)); } catch { viewer.render(null); } } async function onB04_Surface_Analyze_Click(): Promise { const projectId = getProjectId(); if (!projectId || !selectedInputFile) return; if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) { showToast(L("B04_Surface_Error_Selection"), "error"); return; } showLoadingOverlay(); try { const response = await analyzeSurface(projectId, { input_file_id: selectedInputFile.id, source_filters: [...filterGroup.selected], methods: [...methodGroup.selected], force: forceBox.checked, }); showToast( `${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`, "success", ); await loadProjectData(projectId); } catch (error) { const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed"); showToast(`${L("B04_Surface_Analyze_Failed")} ${detail}`, "error"); } finally { hideLoadingOverlay(); } } async function onB04_Surface_Refresh_Click(): Promise { const projectId = getProjectId(); if (!projectId) return; 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(); }); root.replaceChildren(layout.root); const projectId = getProjectId(); if (projectId) void onB04_Surface_Refresh_Click(); }