297 lines
11 KiB
TypeScript
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");
|
|
}
|
|
}
|