Files
Aislo/B06_Section/B06_Section_UI_Page.ts
T
eomsangdonandClaude Fable 5 54954a05e5 refactor(B05,B06): B05_wf2_Route -> B05_Profile, B06_wf3_ProfileCross -> B06_Section 동시 개명
- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수)
- B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존)
- 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section),
  라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로
- 로직 변경 없음. typecheck·백엔드 import 검증 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:03:11 +09:00

729 lines
31 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,
saveSections,
type CrossSectionPatch,
fetchSectionContext,
fetchSectionDetail,
getSections,
previewCrossDesigns,
regenerateSections,
type SectionContextResponse,
type SectionDetailResponse,
type StandardCrossSection,
} from "./B06_Section_Api_Fetch";
import { type StationWidthControl } from "./B06_Section_UI_Cross_View";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import {
type CrossDesignChange,
createSectionView,
type RockBoundaryControl,
} from "./B06_Section_UI_Section_View";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { designElevationAt } from "./B06_Section_UI_Section_Common";
import {
createStandardPanel,
type StandardPanelController,
} from "./B06_Section_UI_Standard_Panel";
import "./B06_Section_UI_Style.css";
import "./B06_Section_UI_Style_Cross.css";
import "./B06_Section_UI_Style_Cross_Areas.css";
import { loadSectionDetail, replaceSectionDetail } from "./B06_Section_Section_Store";
import "@util/common_util_mass_haul.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");
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
group.className = `b06-profile__group ui-collapsible ui-sidebar-section${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;
// 높이 배율 옵션은 폐지 — 항상 1배율(2026-08-06 사용자 지시). 표시 옵션 그룹도 함께
// 사라지고, 횡단 반폭은 표준 횡단면 설정 컨테이너의 [전체 측점 반영] 위로 옮겼다.
// 반폭 적용도 그 버튼이 담당한다(재계산 버튼 폐지) — 요청 반폭이 보유 샘플 폭(기본
// 20m, config SECTION_CROSS_HALF_WIDTH_M) 이하면 표시만 바꾸고, 넘으면 B05부터
// 재생성해 영구저장 후 다시 로드한다.
const crossHalfWidthField = createInputField({
label: L("B05_Route_Field_CrossHalfWidth"),
type: "number",
});
crossHalfWidthField.input.min = "0.1";
crossHalfWidthField.input.step = "0.1";
// 임시 저장 — 확정과 같은 내용을 남기되 페이지 이동이 없다(2026-08-02 사용자 지시).
const saveButton = createButton({
label: L("B06_Profile_Btn_Save"),
variant: "ghost",
onClick: () => void saveCurrentSections(),
});
saveButton.title = L("B06_Profile_Btn_Save_Tip");
saveButton.disabled = true;
const confirmButton = createButton({
label: L("B06_Profile_Btn_Confirm"),
variant: "filled",
onClick: () => void confirmCurrentSections(),
});
confirmButton.disabled = true;
const actionRow = document.createElement("div");
// 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤에서 제외(2026-08-05 사용자 지시).
actionRow.className = "b06-profile__actions ui-sidebar-actions";
actionRow.append(saveButton, confirmButton);
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
leftForm.append(standardGroup, 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
* 호출이 없다.
*
* 재계산은 측점별 순차 호출이 아니라 **일괄 프리뷰 1회**로 한다(2026-08-04 사용자 확인
* — 예전 for-await 루프는 stale 측점 수만큼 왕복하며 카드가 하나씩 바뀌어 "이력 재생"처럼
* 보였고 제일 느렸다). 편집 델타는 B05 세션 초안이 있으면 그것을(두 화면 동일 계획선),
* 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형·
* 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다.
*/
async function reconcileStaleDesigns(): Promise<void> {
if (!sectionDetail || !projectId || currentRouteId === null) 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 {
const alignment = sectionDetail.longitudinal.profile_alignment as
| {
edits?: {
station_offsets?: Record<string, number>;
curve_radii?: Record<string, number>;
};
}
| undefined;
const edits = readAlignmentDraft(currentRouteId) ?? {
station_offsets: alignment?.edits?.station_offsets ?? {},
curve_radii: alignment?.edits?.curve_radii ?? {},
};
const response = await previewCrossDesigns(
projectId,
currentRouteId,
edits,
standardPanel?.getValues(),
{ fullDesigns: true, rockBoundaryOffsets: Object.fromEntries(rockOffsets) },
);
const designByChainage = new Map(
response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
for (const section of sectionDetail.cross_sections) {
const next = designByChainage.get(section.chainage_m.toFixed(3));
if (next) {
// full_designs 응답은 설계 전체(설계선 좌표 포함)라 통째로 교체한다.
section.design = next as typeof section.design;
sectionView.refreshCard(section.chainage_m);
}
}
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다.
*
* 횡단 반폭 적용도 여기서 한다(2026-08-06 사용자 지시 — 재계산 버튼 폐지):
* 요청 반폭 ≤ 보유 샘플 폭 → 표시 범위만 바뀌므로 재렌더로 끝(재계산 없음).
* 요청 반폭 > 보유 샘플 폭 → B05부터 재생성해 영구 저장 후 상세를 다시 로드한다
* (기본 설계는 상세 조회가 자동으로 얹는다. 느려도 허용 — 2026-08-06 사용자 확정). */
async function applyPanelToAll(): Promise<void> {
if (!sectionDetail || !projectId || currentRouteId === null) return;
const requested = crossHalfWidth();
if (requested !== undefined && requested > sampledHalfWidth() + 1e-6) {
showLoadingOverlay();
try {
await regenerateSections(projectId, currentRouteId, requested);
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다.
const fresh = await fetchSectionDetail(projectId, currentRouteId);
sectionDetail = fresh;
// 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다.
replaceSectionDetail(projectId, currentRouteId, fresh);
showToast(L("B06_Profile_Regenerate_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error");
hideLoadingOverlay();
return;
}
hideLoadingOverlay();
}
// 전체 반영 = 개별 반폭도 전역값으로 초기화(2026-08-06). 저장된 개별값
// (design.display_half_width_m)이 남아 있으면 전역 반폭이 무시되므로, 세션에
// 전역값을 명시해 저장값보다 우선하게 한다(확정 시 저장값도 전역으로 덮인다).
stationWidths.clear();
if (requested !== undefined) {
for (const section of sectionDetail.cross_sections) {
stationWidths.set(widthKey(section.chainage_m), clampStationWidth(requested));
}
}
persistStationWidths();
persistDisplayHalfWidth();
renderSectionDetail();
updateActionState();
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;
/**
* 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서
* 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다.
* DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다.
*/
const clampRockOffset = (value: number): number => Math.min(value, 0);
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) =>
clampRockOffset(
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 = clampRockOffset(rockOffsets.get(key) ?? stored ?? rockBoundaryDefault);
rockOffsets.set(key, clampRockOffset(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);
},
};
/* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ──────────────────────
* 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때
* cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다.
* 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */
const stationWidths = new Map<string, number>();
const widthKey = (chainageM: number): string => chainageM.toFixed(2);
const widthSessionKey = (): string | null =>
projectId && currentRouteId !== null ? `b06:crossw:${projectId}:${currentRouteId}` : null;
function loadStationWidths(): void {
stationWidths.clear();
const key = widthSessionKey();
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, width]) => {
if (Number.isFinite(width) && width > 0) stationWidths.set(chainage, width);
});
} catch {
/* 손상된 세션 값은 무시 — 저장값·전역 반폭으로 재시작. */
}
}
function persistStationWidths(): void {
const key = widthSessionKey();
if (!key) return;
try {
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(stationWidths)));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
}
/** 개별 반폭 하한 2m·상한은 보유 샘플 폭 — 표시용이라 샘플 밖은 의미가 없다. */
const clampStationWidth = (value: number): number =>
Math.min(Math.max(value, 2), Math.max(sampledHalfWidth(), 2));
const stationWidthControl: StationWidthControl = {
widthFor: (section) => {
const session = stationWidths.get(widthKey(section.chainage_m));
if (session !== undefined) return session;
const stored = section.design?.display_half_width_m;
return typeof stored === "number" && stored > 0 ? stored : undefined;
},
adjust: (chainageM, deltaM) => {
const key = widthKey(chainageM);
const section = sectionDetail?.cross_sections.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < 0.01,
);
const stored = section?.design?.display_half_width_m;
const current =
stationWidths.get(key) ??
(typeof stored === "number" && stored > 0 ? stored : undefined) ??
crossHalfWidth() ??
sampledHalfWidth();
stationWidths.set(key, clampStationWidth(Math.round(current + deltaM)));
persistStationWidths();
sectionView.refreshCard(chainageM);
},
reset: (chainageM) => {
// 초기화 = 전역 반폭 복귀. 저장값(design)도 무시해야 하므로 세션에 전역값을 명시한다.
const globalWidth = crossHalfWidth();
if (globalWidth === undefined) stationWidths.delete(widthKey(chainageM));
else stationWidths.set(widthKey(chainageM), clampStationWidth(globalWidth));
persistStationWidths();
sectionView.refreshCard(chainageM);
},
};
const sectionView = createSectionView(
(chainageM, change) => {
void handleDesignChange(chainageM, change);
},
rockBoundaryControl,
stationWidthControl,
);
// 메인 영역: 종·횡단 도면(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 crossHalfWidth(): number | undefined {
const parsed = Number(crossHalfWidthField.input.value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
/** 보유 샘플의 최대 반폭(m) — 이 폭 안에서는 표시만 바꾸면 되고, 넘으면 재생성이 필요하다. */
function sampledHalfWidth(): number {
if (!sectionDetail) return 0;
return Math.max(
0,
...sectionDetail.cross_sections.flatMap((section) =>
section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
),
);
}
/** 표시 반폭 세션 키 — 페이지를 떠났다 와도 조절값이 유지되게 한다. */
const displaySessionKey = (): string | null =>
projectId && currentRouteId !== null
? `b06:cross-display:${projectId}:${currentRouteId}`
: null;
function persistDisplayHalfWidth(): void {
const key = displaySessionKey();
const width = crossHalfWidth();
if (!key || width === undefined) return;
try {
window.sessionStorage.setItem(key, String(width));
} catch {
/* 무시 */
}
}
function updateActionState(): void {
saveButton.disabled = sectionDetail === null;
confirmButton.disabled = sectionDetail === null;
}
function renderSectionDetail(): void {
if (sectionDetail) {
showSectionView();
// 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지).
sectionView.render(
sectionDetail,
1,
crossHalfWidth(),
stationInterval,
context?.earthwork_conversion,
context?.haul_equipment_limits,
`${projectId ?? "-"}:${currentRouteId ?? "-"}`,
context?.natural_spoil_min_ground_slope ?? undefined,
);
}
}
/** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */
function collectSectionEdits(): {
crossPatches: CrossSectionPatch[];
massHaul: Record<string, unknown> | undefined;
} {
// 암 경계 오프셋 + 측점 개별 표시 반폭을 chainage 기준으로 합쳐 한 패치로 보낸다.
const patchByChainage = new Map<number, CrossSectionPatch>();
const patchFor = (chainageM: number): CrossSectionPatch => {
const existing = patchByChainage.get(chainageM);
if (existing) return existing;
const created: CrossSectionPatch = { chainage_m: chainageM };
patchByChainage.set(chainageM, created);
return created;
};
rockOffsets.forEach((offset, chainage) => {
patchFor(Number(chainage)).rock_boundary_offset_m = offset;
});
// 개별 표시 반폭(2026-08-06) — 확정·임시저장 시 design에 병합돼 재접근 시 유지된다.
stationWidths.forEach((width, chainage) => {
patchFor(Number(chainage)).display_half_width_m = width;
});
const crossPatches: CrossSectionPatch[] = [...patchByChainage.values()];
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
const result =
sectionDetail && context?.earthwork_conversion
? computeMassHaul(
sectionDetail.cross_sections,
context.earthwork_conversion,
context.natural_spoil_min_ground_slope ?? undefined,
)
: null;
return {
crossPatches,
massHaul: result
? massHaulPayload(
result,
computeHaulPlan(result, context?.haul_equipment_limits),
balloonOffsetsPayload(),
)
: undefined,
};
}
/** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */
async function saveCurrentSections(): Promise<void> {
if (!projectId || currentRouteId === null) return;
showLoadingOverlay();
try {
const edits = collectSectionEdits();
await saveSections(
projectId,
currentRouteId,
standardPanel?.getValues(),
edits.crossPatches.length ? edits.crossPatches : undefined,
edits.massHaul,
);
showToast(L("B06_Profile_Save_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
async function confirmCurrentSections(): Promise<void> {
if (!projectId || currentRouteId === null) return;
showLoadingOverlay();
try {
const edits = collectSectionEdits();
await confirmSections(
projectId,
currentRouteId,
standardPanel?.getValues(),
edits.crossPatches.length ? edits.crossPatches : undefined,
edits.massHaul,
);
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;
}
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 기본값).
// 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시).
standardPanel = createStandardPanel(
projectId,
context.standard_cross_section,
applyPanelToAll,
crossHalfWidthField.root,
);
standardPanelSlot.append(standardPanel.root);
if (context.route_id === null) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
currentRouteId = context.route_id;
loadRockOffsets();
loadStationWidths();
try {
const existing = await getSections(projectId, context.route_id);
if (!existing.longitudinal) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
// 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심).
sectionDetail = await loadSectionDetail(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);
// 입력칸의 기준값은 **백엔드 기본 반폭**(config SECTION_CROSS_HALF_WIDTH_M = 20m, 위에서
// 이미 넣었다)이고, 사용자가 값을 지정해 확정한 이력(data.options)이 있을 때만 그 값으로
// 바꾼다(2026-08-08 사용자 지시). 예전에는 options가 없으면 보유 샘플의 최대 offset을 계산해
// 덮었는데, 옛 기본값(15m)으로 만들어진 노선이 15.0으로 보여 기준값 구실을 못 했다.
// 표시값이 보유 샘플 폭보다 크면 [전체 측점 반영]이 알아서 재생성한다(applyPanelToAll).
const storedHalfWidth = storedOptions?.cross_half_width_m ?? 0;
if (storedHalfWidth > 0) crossHalfWidthField.input.value = storedHalfWidth.toFixed(1);
// 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값).
const sessionDisplayKey = displaySessionKey();
if (sessionDisplayKey) {
const sessionDisplay = Number(window.sessionStorage.getItem(sessionDisplayKey));
if (Number.isFinite(sessionDisplay) && sessionDisplay > 0)
crossHalfWidthField.input.value = sessionDisplay.toFixed(1);
}
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
stationInterval = storedOptions.station_interval_m;
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");
}
}