Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts
T
2026-07-19 11:08:16 +09:00

297 lines
11 KiB
TypeScript

import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
confirmSections,
fetchSectionContext,
fetchSectionDetail,
getSections,
regenerateSections,
type SectionContextResponse,
type SectionDetailResponse,
type SectionSummaryResponse,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import { createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View";
import "./B06_wf3_ProfileCross_UI_Style.css";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function buildGroup(legend: string): HTMLElement {
const group = document.createElement("fieldset");
group.className = "b06-profile__group";
const legendElement = document.createElement("legend");
legendElement.className = "b06-profile__group-legend";
legendElement.textContent = legend;
group.append(legendElement);
return group;
}
function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement } {
const root = document.createElement("div");
root.className = "b06-profile__info-line";
const key = document.createElement("span");
key.textContent = label;
const value = document.createElement("strong");
value.textContent = "-";
root.append(key, value);
return { root, value };
}
function metricRow(label: string, value: string): HTMLElement {
const row = document.createElement("div");
row.className = "b06-profile__metric";
const key = document.createElement("span");
key.className = "b06-profile__metric-key";
key.textContent = label;
const metricValue = document.createElement("span");
metricValue.className = "b06-profile__metric-val";
metricValue.textContent = value;
row.append(key, metricValue);
return row;
}
export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
let currentRouteId: number | null = null;
let sectionDetail: SectionDetailResponse | null = null;
let stationInterval: number | undefined;
const routeGroup = buildGroup(L("B06_Profile_Group_Route"));
const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId"));
const filterInfo = buildInfoLine(L("B06_Profile_Field_Filter"));
const methodInfo = buildInfoLine(L("B06_Profile_Field_Method"));
const smoothInfo = buildInfoLine(L("B06_Profile_Field_Smooth"));
const crsInfo = buildInfoLine(L("B06_Profile_Field_Crs"));
routeGroup.append(
routeIdInfo.root,
filterInfo.root,
methodInfo.root,
smoothInfo.root,
crsInfo.root,
);
const resultGroup = buildGroup(L("B06_Profile_Result_Title"));
const resultBody = document.createElement("div");
resultBody.className = "b06-profile__result-body";
resultGroup.append(resultBody);
const displayGroup = buildGroup(L("B06_Profile_Group_Display"));
const verticalExaggerationField = createInputField({
label: L("B06_Profile_Field_VerticalExaggeration"),
type: "number",
});
verticalExaggerationField.input.min = "0.1";
verticalExaggerationField.input.step = "0.1";
const crossHalfWidthField = createInputField({
label: L("B05_Route_Field_CrossHalfWidth"),
type: "number",
});
crossHalfWidthField.input.min = "0.1";
crossHalfWidthField.input.step = "0.1";
displayGroup.append(crossHalfWidthField.root, verticalExaggerationField.root);
const recalcButton = createButton({
label: L("B06_Profile_Btn_Recalc"),
variant: "ghost",
onClick: () => void applyCrossHalfWidth(),
});
recalcButton.disabled = true;
const confirmButton = createButton({
label: L("B06_Profile_Btn_Confirm"),
variant: "filled",
onClick: () => void confirmCurrentSections(),
});
confirmButton.disabled = true;
const actionRow = document.createElement("div");
actionRow.className = "b06-profile__actions";
actionRow.append(recalcButton, confirmButton);
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
leftForm.append(routeGroup, resultGroup, displayGroup, actionRow);
const sectionView = createSectionView();
function renderMessage(message: string): void {
const text = document.createElement("p");
text.className = "b06-profile__empty";
text.textContent = message;
resultBody.replaceChildren(text);
}
function renderSummary(result: SectionSummaryResponse): void {
const path = result.longitudinal?.longitudinal_file_path;
resultBody.replaceChildren(
metricRow(
L("B06_Profile_Result_Length"),
result.length_m === null ? "-" : result.length_m.toFixed(2),
),
metricRow(L("B06_Profile_Result_CrossCount"), String(result.cross_section_count)),
metricRow(L("B06_Profile_Result_Path"), typeof path === "string" ? path : "-"),
);
}
function verticalExaggeration(): number {
const parsed = Number(verticalExaggerationField.input.value);
return Number.isFinite(parsed) && parsed >= 0.1 ? parsed : 1;
}
function crossHalfWidth(): number | undefined {
const parsed = Number(crossHalfWidthField.input.value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
let appliedHalfWidth: number | undefined;
/** 반폭 미적용 상태에서는 [재계산]만 활성, 적용 완료 상태에서는 [확정]만 활성. */
function updateActionState(): void {
const width = crossHalfWidth();
const stale = sectionDetail !== null && width !== undefined && width !== appliedHalfWidth;
recalcButton.disabled = !stale;
confirmButton.disabled = sectionDetail === null || stale;
}
function renderSectionDetail(): void {
if (sectionDetail)
sectionView.render(sectionDetail, verticalExaggeration(), crossHalfWidth(), stationInterval);
}
async function applyCrossHalfWidth(): Promise<void> {
const width = crossHalfWidth();
if (!projectId || currentRouteId === null || width === undefined) return;
showLoadingOverlay();
try {
sectionDetail = await regenerateSections(projectId, currentRouteId, width);
appliedHalfWidth = width;
renderSectionDetail();
showToast(L("B06_Profile_Regenerate_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error");
} finally {
hideLoadingOverlay();
updateActionState();
}
}
verticalExaggerationField.input.addEventListener("input", renderSectionDetail);
crossHalfWidthField.input.addEventListener("input", updateActionState);
async function confirmCurrentSections(): Promise<void> {
if (!projectId || currentRouteId === null) return;
showLoadingOverlay();
try {
await confirmSections(projectId, currentRouteId);
showToast(L("B06_Profile_Confirm_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
let workflowState: WorkflowState | undefined;
let context: SectionContextResponse | null = null;
if (projectId) {
const [contextResult, workflowResult] = await Promise.allSettled([
fetchSectionContext(projectId),
fetchWorkflowState(projectId),
]);
if (contextResult.status === "fulfilled") context = contextResult.value;
else showToast(L("B06_Profile_Context_Failed"), "error");
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
}
const layout = createWorkflowLayout({
title: L("B06_Profile_Title"),
steps: workflowSteps(),
activeStep: 3,
leftPanel: leftForm,
mainContent: sectionView.root,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
layout.root.classList.add("b06-profile-layout");
root.replaceChildren(layout.root);
if (!projectId) {
renderMessage(L("B06_Profile_Error_Project"));
return;
}
if (!context) {
renderMessage(L("B06_Profile_Context_Failed"));
return;
}
routeIdInfo.value.textContent = context.route_id === null ? "-" : String(context.route_id);
filterInfo.value.textContent = context.filter_key ?? "-";
methodInfo.value.textContent = context.method ?? "-";
smoothInfo.value.textContent = context.smooth
? L("B06_Profile_Smooth_On")
: L("B06_Profile_Smooth_Off");
crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`;
verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration);
crossHalfWidthField.input.value = String(context.defaults.cross_half_width_m);
stationInterval = context.defaults.station_interval_m;
if (context.route_id === null) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
currentRouteId = context.route_id;
try {
const existing = await getSections(projectId, context.route_id);
if (!existing.longitudinal) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
renderSummary(existing);
sectionDetail = await fetchSectionDetail(projectId, context.route_id);
// 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정
const summaryData = existing.longitudinal.data as {
options?: { cross_half_width_m?: number; station_interval_m?: number };
} | null;
const storedOptions = summaryData?.options;
const storedHalfWidth =
storedOptions?.cross_half_width_m && storedOptions.cross_half_width_m > 0
? storedOptions.cross_half_width_m
: Math.max(
0,
...sectionDetail.cross_sections.flatMap((section) =>
section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
),
);
if (storedHalfWidth > 0) crossHalfWidthField.input.value = storedHalfWidth.toFixed(1);
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
stationInterval = storedOptions.station_interval_m;
appliedHalfWidth = crossHalfWidth();
renderSectionDetail();
updateActionState();
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
renderMessage(L("B06_Profile_Calculate_In_B05"));
showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error");
}
}