486 lines
19 KiB
TypeScript
486 lines
19 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 { attachCollapsible } from "@ui/ui_template_collapsible";
|
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
|
import {
|
|
fetchWorkflowState,
|
|
goToWorkflowStage,
|
|
WORKFLOW_STEP_ROUTES,
|
|
type WorkflowState,
|
|
} from "../A00_Common/b_workflow_nav";
|
|
import {
|
|
computeCrossDesign,
|
|
confirmSections,
|
|
type CrossSectionPatch,
|
|
fetchSectionContext,
|
|
fetchSectionDetail,
|
|
getSections,
|
|
regenerateSections,
|
|
type SectionContextResponse,
|
|
type SectionDetailResponse,
|
|
type StandardCrossSection,
|
|
} from "./B06_wf3_ProfileCross_Api_Fetch";
|
|
import {
|
|
type CrossDesignChange,
|
|
createSectionView,
|
|
type RockBoundaryControl,
|
|
} from "./B06_wf3_ProfileCross_UI_Section_View";
|
|
import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common";
|
|
import {
|
|
createStandardPanel,
|
|
type StandardPanelController,
|
|
} from "./B06_wf3_ProfileCross_UI_Standard_Panel";
|
|
import "./B06_wf3_ProfileCross_UI_Style.css";
|
|
import "./B06_wf3_ProfileCross_UI_Style_Cross.css";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
// B05 사이드패널 접기 컨테이너 템플릿 재사용(N-4-1): 제목 행 클릭 토글, 우측 ▾/▸ 캐럿.
|
|
function buildGroup(legend: string, collapsed = false): HTMLElement {
|
|
const group = document.createElement("section");
|
|
group.className = `b06-profile__group ui-collapsible${collapsed ? " is-collapsed" : ""}`;
|
|
const legendElement = document.createElement("h3");
|
|
legendElement.className = "b06-profile__group-legend ui-collapsible__title";
|
|
legendElement.textContent = legend;
|
|
group.append(legendElement);
|
|
return group;
|
|
}
|
|
|
|
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;
|
|
|
|
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
|
|
const standardGroup = buildGroup(L("B06_Std_Title"));
|
|
const standardPanelSlot = document.createElement("div");
|
|
standardGroup.append(standardPanelSlot);
|
|
let standardPanel: StandardPanelController | null = null;
|
|
|
|
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(standardGroup, displayGroup, actionRow);
|
|
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
|
|
attachCollapsible(leftForm);
|
|
|
|
// 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다.
|
|
const designRequestSeq = new Map<number, number>();
|
|
|
|
/**
|
|
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
|
|
* (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음.
|
|
* 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다.
|
|
*/
|
|
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
|
|
if (!projectId || currentRouteId === null || !sectionDetail) return;
|
|
const target = sectionDetail.cross_sections.find(
|
|
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
|
|
);
|
|
if (!target) return;
|
|
|
|
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
|
|
if (target.design) {
|
|
target.design = {
|
|
...target.design,
|
|
ground_type: change.ground_type,
|
|
section_mode: change.section_mode,
|
|
ditch_side: change.ditch_side ?? target.design.ditch_side,
|
|
ditch_type: change.ditch_type,
|
|
paved: change.paved,
|
|
two_stage_slope: change.two_stage_slope,
|
|
};
|
|
sectionView.refreshCard(chainageM);
|
|
}
|
|
|
|
// (2) 서버 계산 — 최신 요청만 반영. 암 경계 오프셋은 세션 우선값을 실어 2단계 무릎을 계산시킨다.
|
|
const seq = (designRequestSeq.get(chainageM) ?? 0) + 1;
|
|
designRequestSeq.set(chainageM, seq);
|
|
try {
|
|
const response = await computeCrossDesign(projectId, currentRouteId, {
|
|
chainage_m: chainageM,
|
|
...change,
|
|
rock_boundary_offset_m: rockBoundaryControl.offsetFor(target),
|
|
standard_cross_section: standardPanel?.getValues(),
|
|
});
|
|
if (designRequestSeq.get(chainageM) !== seq) return;
|
|
target.design = response.design;
|
|
sectionView.refreshCard(chainageM);
|
|
} catch (error) {
|
|
if (designRequestSeq.get(chainageM) !== seq) return;
|
|
const detail = error instanceof Error ? ` ${error.message}` : "";
|
|
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
|
|
}
|
|
}
|
|
|
|
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
|
|
function changeFromDesign(chainageM: number): CrossDesignChange | null {
|
|
const target = sectionDetail?.cross_sections.find(
|
|
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
|
|
);
|
|
const design = target?.design;
|
|
if (!design) return null;
|
|
return {
|
|
ground_type: design.ground_type,
|
|
section_mode: design.section_mode,
|
|
ditch_side: design.ditch_side ?? null,
|
|
ditch_type: design.ditch_type ?? "standard",
|
|
paved: design.paved,
|
|
two_stage_slope: design.two_stage_slope ?? true,
|
|
ditch_enabled: design.ditch_enabled ?? null,
|
|
};
|
|
}
|
|
|
|
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
|
|
function recomputeIfRock(chainageM: number): void {
|
|
const change = changeFromDesign(chainageM);
|
|
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
|
|
}
|
|
|
|
/**
|
|
* 로드 시 stale design을 최신 엔진·최신 종단 계획고로 자동 재계산한다(E-1 + N-6).
|
|
* 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이
|
|
* 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선
|
|
* (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API
|
|
* 호출이 없다. 측점별 버튼 선택값은 `changeFromDesign()` 경유로 보존한다.
|
|
*/
|
|
async function reconcileStaleDesigns(): Promise<void> {
|
|
if (!sectionDetail) return;
|
|
const profiles = sectionDetail.longitudinal.design_profiles;
|
|
const stale = sectionDetail.cross_sections.filter((section) => {
|
|
const design = section.design;
|
|
if (!design) return false;
|
|
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
|
|
const planZ = designElevationAt(profiles, section.chainage_m);
|
|
return planZ !== undefined && Math.abs(planZ - design.design_elevation_m) > 1e-3;
|
|
});
|
|
if (!stale.length) return;
|
|
showLoadingOverlay();
|
|
try {
|
|
for (const section of stale) {
|
|
const change = changeFromDesign(section.chainage_m);
|
|
if (change) await handleDesignChange(section.chainage_m, change);
|
|
}
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
|
|
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
|
|
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
|
|
async function applyPanelToAll(): Promise<void> {
|
|
if (!sectionDetail) return;
|
|
const targets = sectionDetail.cross_sections.filter((section) => section.design);
|
|
if (!targets.length) return;
|
|
showLoadingOverlay();
|
|
try {
|
|
for (const section of targets) {
|
|
const change = changeFromDesign(section.chainage_m);
|
|
if (change) await handleDesignChange(section.chainage_m, change);
|
|
}
|
|
showToast(L("B06_Std_ApplyAll_Success"), "success");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
/* ── 암 경계선 오프셋(측점별) 세션 저장소 ─────────────────────────────
|
|
* 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시
|
|
* cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다.
|
|
* 기본 오프셋·스텝은 context(config) 값으로 갱신된다. */
|
|
let rockBoundaryDefault = -0.5;
|
|
let rockBoundaryStep = 0.1;
|
|
const rockOffsets = new Map<string, number>();
|
|
const rockKey = (chainageM: number): string => chainageM.toFixed(2);
|
|
const rockSessionKey = (): string | null =>
|
|
projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null;
|
|
|
|
function loadRockOffsets(): void {
|
|
rockOffsets.clear();
|
|
const key = rockSessionKey();
|
|
if (!key) return;
|
|
try {
|
|
const raw = window.sessionStorage.getItem(key);
|
|
if (!raw) return;
|
|
const parsed = JSON.parse(raw) as Record<string, number>;
|
|
Object.entries(parsed).forEach(([chainage, offset]) => {
|
|
if (Number.isFinite(offset)) rockOffsets.set(chainage, offset);
|
|
});
|
|
} catch {
|
|
/* 손상된 세션 값은 무시 — 기본값으로 재시작. */
|
|
}
|
|
}
|
|
|
|
function persistRockOffsets(): void {
|
|
const key = rockSessionKey();
|
|
if (!key) return;
|
|
try {
|
|
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(rockOffsets)));
|
|
} catch {
|
|
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
|
}
|
|
}
|
|
|
|
const rockBoundaryControl: RockBoundaryControl = {
|
|
get stepM() {
|
|
return rockBoundaryStep;
|
|
},
|
|
get defaultOffsetM() {
|
|
return rockBoundaryDefault;
|
|
},
|
|
offsetFor: (section) =>
|
|
rockOffsets.get(rockKey(section.chainage_m)) ??
|
|
section.design?.rock_boundary_offset_m ??
|
|
rockBoundaryDefault,
|
|
adjust: (chainageM, deltaM) => {
|
|
const key = rockKey(chainageM);
|
|
const stored = sectionDetail?.cross_sections.find(
|
|
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
|
|
)?.design?.rock_boundary_offset_m;
|
|
const current = rockOffsets.get(key) ?? stored ?? rockBoundaryDefault;
|
|
rockOffsets.set(key, Math.round((current + deltaM) * 100) / 100);
|
|
persistRockOffsets();
|
|
sectionView.refreshCard(chainageM);
|
|
recomputeIfRock(chainageM); // 경계 이동 → 2단계 무릎·단면적 재계산
|
|
},
|
|
reset: (chainageM) => {
|
|
rockOffsets.set(rockKey(chainageM), rockBoundaryDefault);
|
|
persistRockOffsets();
|
|
sectionView.refreshCard(chainageM);
|
|
recomputeIfRock(chainageM);
|
|
},
|
|
};
|
|
|
|
const sectionView = createSectionView((chainageM, change) => {
|
|
void handleDesignChange(chainageM, change);
|
|
}, rockBoundaryControl);
|
|
|
|
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
|
|
const mainArea = document.createElement("div");
|
|
mainArea.className = "b06-profile__main";
|
|
mainArea.append(sectionView.root);
|
|
|
|
function showSectionView(): void {
|
|
if (!mainArea.contains(sectionView.root)) mainArea.replaceChildren(sectionView.root);
|
|
}
|
|
|
|
function renderMessage(message: string): void {
|
|
const text = document.createElement("p");
|
|
text.className = "b06-profile__empty";
|
|
text.textContent = message;
|
|
mainArea.replaceChildren(text);
|
|
}
|
|
|
|
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) {
|
|
showSectionView();
|
|
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 {
|
|
// 세션 보관 중인 측점별 암 경계선 오프셋을 확정 시점에 DB로 병합한다.
|
|
const crossPatches: CrossSectionPatch[] = [...rockOffsets.entries()].map(
|
|
([chainage, offset]) => ({
|
|
chainage_m: Number(chainage),
|
|
rock_boundary_offset_m: offset,
|
|
}),
|
|
);
|
|
await confirmSections(
|
|
projectId,
|
|
currentRouteId,
|
|
standardPanel?.getValues(),
|
|
crossPatches.length ? crossPatches : undefined,
|
|
);
|
|
showToast(L("B06_Profile_Confirm_Success"), "success");
|
|
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]);
|
|
} 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: mainArea,
|
|
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;
|
|
}
|
|
|
|
verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration);
|
|
crossHalfWidthField.input.value = String(context.defaults.cross_half_width_m);
|
|
stationInterval = context.defaults.station_interval_m;
|
|
rockBoundaryDefault = context.rock_boundary_default_offset_m;
|
|
rockBoundaryStep = context.rock_boundary_step_m;
|
|
|
|
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
|
|
standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll);
|
|
standardPanelSlot.append(standardPanel.root);
|
|
|
|
if (context.route_id === null) {
|
|
renderMessage(L("B06_Profile_Calculate_In_B05"));
|
|
return;
|
|
}
|
|
|
|
currentRouteId = context.route_id;
|
|
loadRockOffsets();
|
|
try {
|
|
const existing = await getSections(projectId, context.route_id);
|
|
if (!existing.longitudinal) {
|
|
renderMessage(L("B06_Profile_Calculate_In_B05"));
|
|
return;
|
|
}
|
|
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;
|
|
standard_cross_section?: StandardCrossSection;
|
|
};
|
|
} | null;
|
|
const storedOptions = summaryData?.options;
|
|
// 확정 이력의 표준 횡단면 설정값 복원(진행 중 세션 편집값이 있으면 패널이 무시).
|
|
if (storedOptions?.standard_cross_section)
|
|
standardPanel?.applyStored(storedOptions.standard_cross_section);
|
|
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();
|
|
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
|
|
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");
|
|
}
|
|
}
|