346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* 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,
|
|
createWorkflowShell,
|
|
hideLoadingOverlay,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
type InputFieldHandle,
|
|
} from "@ui/ui_template_elements";
|
|
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;
|
|
|
|
/** 라벨 + select 요소 하나 생성. */
|
|
function buildSelect(
|
|
label: string,
|
|
values: readonly string[],
|
|
): { root: HTMLElement; select: HTMLSelectElement } {
|
|
const root = document.createElement("div");
|
|
root.className = "ui-field";
|
|
const labelEl = document.createElement("label");
|
|
labelEl.className = "ui-field__label";
|
|
labelEl.textContent = label;
|
|
const select = document.createElement("select");
|
|
select.className = "ui-input b05-route__select";
|
|
for (const value of values) {
|
|
const option = document.createElement("option");
|
|
option.value = value;
|
|
option.textContent = value;
|
|
select.append(option);
|
|
}
|
|
root.append(labelEl, select);
|
|
return { root, select };
|
|
}
|
|
|
|
/** 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 shell = createWorkflowShell({
|
|
title: L("B05_Route_Title"),
|
|
steps: workflowSteps(),
|
|
activeStep: 1,
|
|
});
|
|
|
|
/* ---- 좌측: 경로 제어점 ---- */
|
|
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 = buildSelect(L("B05_Route_Field_GradeClass"), GRADE_CLASSES);
|
|
const algorithmSelect = buildSelect(L("B05_Route_Field_Algorithm"), ALGORITHMS);
|
|
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);
|
|
shell.leftPanel.append(leftForm);
|
|
|
|
/* ---- 우측: 결과 ---- */
|
|
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<string, number | undefined>;
|
|
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);
|
|
shell.rightArea.append(resultCard);
|
|
|
|
/* ---- 이벤트 핸들러 ---- */
|
|
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<void> {
|
|
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<void> {
|
|
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();
|
|
root.replaceChildren(shell.root);
|
|
}
|