/* ============================================================================= * B05_wf2_Route_UI_Page.ts * 로그인 후 05: 2차 워크플로우 (경로 설계) * * 3단 레이아웃 (frontend.md §2): * 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell) * 좌측: 경로 제어점(BP/EP/CP) 좌표 + 기반 지표면 + 설계 제약 폼 * 우측: 경로 탐색 결과(연장·경사·비용) 카드 * * 이벤트 핸들러 명명 (frontend.md §4): onB05_Route_[기능]_[액션] * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). * ========================================================================== */ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, hideLoadingOverlay, showLoadingOverlay, showToast, type InputFieldHandle, } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { confirmRoute, solveRoute, type RoutePoint, type RouteSolveResponse, } from "./B05_wf2_Route_Api_Fetch"; import "./B05_wf2_Route_UI_Style.css"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 임도 등급 (config_system.ROUTE_GRADE_CLASSES) */ const GRADE_CLASSES = ["trunk", "branch", "work"] as const; /** 경로 알고리즘 (Schema.algorithm 허용값) */ const ALGORITHMS = ["dijkstra", "ridge_valley"] as const; /** X/Y 좌표 한 쌍 입력 행 생성. */ function buildPointRow(label: string): { root: HTMLElement; x: InputFieldHandle; y: InputFieldHandle; } { const root = document.createElement("div"); root.className = "b05-route__point"; const caption = document.createElement("span"); caption.className = "b05-route__point-label"; caption.textContent = label; const x = createInputField({ label: L("B05_Route_Field_X"), type: "number" }); const y = createInputField({ label: L("B05_Route_Field_Y"), type: "number" }); const row = document.createElement("div"); row.className = "b05-route__point-row"; row.append(x.root, y.root); root.append(caption, row); return { root, x, y }; } /** 숫자 입력값을 파싱. 빈 값이면 null. */ function parseNumber(value: string): number | null { const trimmed = value.trim(); if (!trimmed) return null; const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : null; } export function renderB05Route(root: HTMLElement): void { /* ---- 좌측: 경로 제어점 ---- */ const pointsGroup = document.createElement("fieldset"); pointsGroup.className = "b05-route__group"; const pointsLegend = document.createElement("legend"); pointsLegend.className = "b05-route__group-legend"; pointsLegend.textContent = L("B05_Route_Group_Points"); const bpRow = buildPointRow(L("B05_Route_Point_BP")); const epRow = buildPointRow(L("B05_Route_Point_EP")); const cpContainer = document.createElement("div"); cpContainer.className = "b05-route__cp-list"; const cpRows: { root: HTMLElement; x: InputFieldHandle; y: InputFieldHandle }[] = []; function onB05_Route_AddCp_Click(): void { const row = buildPointRow(L("B05_Route_Point_CP")); const removeBtn = createButton({ label: L("B05_Route_Btn_RemoveCp"), variant: "ghost", onClick: () => { const idx = cpRows.indexOf(row); if (idx >= 0) cpRows.splice(idx, 1); row.root.remove(); }, }); row.root.append(removeBtn); cpRows.push(row); cpContainer.append(row.root); } const addCpButton = createButton({ label: L("B05_Route_Btn_AddCp"), variant: "ghost", onClick: () => onB05_Route_AddCp_Click(), }); pointsGroup.append(pointsLegend, bpRow.root, epRow.root, cpContainer, addCpButton); /* ---- 좌측: 기반 지표면 ---- */ const surfaceGroup = document.createElement("fieldset"); surfaceGroup.className = "b05-route__group"; const surfaceLegend = document.createElement("legend"); surfaceLegend.className = "b05-route__group-legend"; surfaceLegend.textContent = L("B05_Route_Group_Surface"); const filterField = createInputField({ label: L("B05_Route_Field_Filter"), type: "text" }); const methodField = createInputField({ label: L("B05_Route_Field_Method"), type: "text", value: "dtm", }); const surfaceIdField = createInputField({ label: L("B05_Route_Field_SurfaceId"), type: "number", min: 1, }); surfaceGroup.append(surfaceLegend, filterField.root, methodField.root, surfaceIdField.root); /* ---- 좌측: 설계 제약 ---- */ const constraintGroup = document.createElement("fieldset"); constraintGroup.className = "b05-route__group"; const constraintLegend = document.createElement("legend"); constraintLegend.className = "b05-route__group-legend"; constraintLegend.textContent = L("B05_Route_Group_Constraints"); const gradeSelect = createSelectField({ label: L("B05_Route_Field_GradeClass"), options: GRADE_CLASSES.map((v) => ({ value: v, text: v })), }); const algorithmSelect = createSelectField({ label: L("B05_Route_Field_Algorithm"), options: ALGORITHMS.map((v) => ({ value: v, text: v })), }); const maxUphillField = createInputField({ label: L("B05_Route_Field_MaxUphill"), type: "number", }); const maxDownhillField = createInputField({ label: L("B05_Route_Field_MaxDownhill"), type: "number", }); const minRadiusField = createInputField({ label: L("B05_Route_Field_MinRadius"), type: "number", }); const smoothLabel = document.createElement("label"); smoothLabel.className = "b05-route__check"; const smoothBox = document.createElement("input"); smoothBox.type = "checkbox"; const smoothText = document.createElement("span"); smoothText.textContent = L("B05_Route_Field_Smooth"); smoothLabel.append(smoothBox, smoothText); constraintGroup.append( constraintLegend, gradeSelect.root, algorithmSelect.root, maxUphillField.root, maxDownhillField.root, minRadiusField.root, smoothLabel, ); const solveButton = createButton({ label: L("B05_Route_Btn_Solve"), variant: "filled", onClick: () => void onB05_Route_Solve_Click(), }); const confirmButton = createButton({ label: L("B05_Route_Btn_Confirm"), variant: "ghost", onClick: () => void onB05_Route_Confirm_Click(), }); const actionRow = document.createElement("div"); actionRow.className = "b05-route__actions"; actionRow.append(solveButton, confirmButton); const leftForm = document.createElement("div"); leftForm.className = "b05-route__form"; leftForm.append(pointsGroup, surfaceGroup, constraintGroup, actionRow); /* ---- 우측: 결과 ---- */ const resultTitle = document.createElement("h3"); resultTitle.className = "b05-route__result-title"; resultTitle.textContent = L("B05_Route_Result_Title"); const resultBody = document.createElement("div"); resultBody.className = "b05-route__result-body"; function renderEmptyResult(): void { resultBody.replaceChildren(); const empty = document.createElement("p"); empty.className = "b05-route__empty"; empty.textContent = L("B05_Route_Result_Empty"); resultBody.append(empty); } function metricRow(label: string, value: string): HTMLElement { const row = document.createElement("div"); row.className = "b05-route__metric"; const key = document.createElement("span"); key.className = "b05-route__metric-key"; key.textContent = label; const val = document.createElement("span"); val.className = "b05-route__metric-val"; val.textContent = value; row.append(key, val); return row; } function renderResult(result: RouteSolveResponse): void { resultBody.replaceChildren(); const metrics = result.metrics as Record; const fmt = (v: number | undefined): string => (v === undefined ? "-" : v.toFixed(3)); resultBody.append( metricRow(L("B05_Route_Result_Length"), result.total_length_m.toFixed(2)), metricRow(L("B05_Route_Result_MinSlope"), fmt(metrics.min_slope)), metricRow(L("B05_Route_Result_MaxSlope"), fmt(metrics.max_slope)), metricRow(L("B05_Route_Result_MeanSlope"), fmt(metrics.mean_slope)), metricRow(L("B05_Route_Result_Cost"), fmt(metrics.cost_score)), metricRow(L("B05_Route_Result_Path"), result.route_data_path), ); } const resultCard = document.createElement("div"); resultCard.className = "b05-route__result"; resultCard.append(resultTitle, resultBody); /* ---- 이벤트 핸들러 ---- */ function getProjectId(): string | null { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) showToast(L("B05_Route_Error_Project"), "error"); return projectId; } /** 필수 좌표(BP/EP)와 필터 키를 검증하고, 유효하면 요청 페이로드를 반환. */ function collectRequest(): { filter_key: string; bp: RoutePoint; ep: RoutePoint; cp: RoutePoint[]; } | null { const bpX = parseNumber(bpRow.x.input.value); const bpY = parseNumber(bpRow.y.input.value); const epX = parseNumber(epRow.x.input.value); const epY = parseNumber(epRow.y.input.value); if (bpX === null || bpY === null || epX === null || epY === null) { showToast(L("B05_Route_Error_Points"), "error"); return null; } const filterKey = filterField.input.value.trim(); if (!filterKey) { filterField.setError(L("B05_Route_Error_Filter")); return null; } filterField.setError(); const cp: RoutePoint[] = []; for (const row of cpRows) { const x = parseNumber(row.x.input.value); const y = parseNumber(row.y.input.value); if (x !== null && y !== null) cp.push({ x, y }); } return { filter_key: filterKey, bp: { x: bpX, y: bpY }, ep: { x: epX, y: epY }, cp }; } async function onB05_Route_Solve_Click(): Promise { const projectId = getProjectId(); if (!projectId) return; const base = collectRequest(); if (!base) return; const surfaceId = parseNumber(surfaceIdField.input.value); showLoadingOverlay(); try { const result = await solveRoute(projectId, { ...base, method: methodField.input.value.trim() || "dtm", smooth: smoothBox.checked, surface_model_id: surfaceId, algorithm: algorithmSelect.select.value, grade_class: gradeSelect.select.value, max_uphill_grade: parseNumber(maxUphillField.input.value), max_downhill_grade: parseNumber(maxDownhillField.input.value), min_curve_radius_m: parseNumber(minRadiusField.input.value), }); renderResult(result); showToast(L("B05_Route_Solve_Success"), "success"); } catch (error) { const detail = error instanceof Error ? error.message : L("B05_Route_Solve_Failed"); showToast(`${L("B05_Route_Solve_Failed")} ${detail}`, "error"); } finally { hideLoadingOverlay(); } } async function onB05_Route_Confirm_Click(): Promise { const projectId = getProjectId(); if (!projectId) return; showLoadingOverlay(); try { await confirmRoute(projectId); showToast(L("B05_Route_Confirm_Success"), "success"); } catch (error) { const detail = error instanceof Error ? error.message : L("B05_Route_Confirm_Failed"); showToast(`${L("B05_Route_Confirm_Failed")} ${detail}`, "error"); } finally { hideLoadingOverlay(); } } renderEmptyResult(); const layout = createWorkflowLayout({ title: L("B05_Route_Title"), steps: workflowSteps(), activeStep: 2, leftPanel: leftForm, mainContent: resultCard, }); root.replaceChildren(layout.root); }