Files
Aislo/B06_Section/B06_Section_UI_Page.ts
T
eomsangdon cc2c788122 refactor(B06): 횡단 페이지 저장 흐름·관 옵션 반영 분리 (926→680줄)
- `B06_Section_UI_Page_Persist.ts`(231줄) 신설 — 암 경계선 세션 저장소
  (`createRockBoundaryStore`), 편집분 수집, [저장]·[확정].
- `B06_Section_UI_Page_Pipe_Options.ts`(130줄) 신설 — 폼에서 바꾼 관 옵션을
  횡단 캐시·조정창 제어기에 얹는 경로.
- 페이지는 상태 창구(`SectionPersistContext`·`PipeOptionsContext`)만 넘김.
- 화면 검증: B06 진입 카드 280장·암 버튼 69개·콘솔 오류 0. 암 경계선 ▲ 조작으로
  표시 -0.5m → -0.4m + 세션키 기록, ↺ 로 -0.5m 복귀. [저장] 클릭 시
  `POST /sections/111/save` 200.
- `tsc --noEmit` 0, prettier 적용, pytest 359 passed.
2026-09-02 16:37:48 +09:00

681 lines
31 KiB
TypeScript

import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { navigateTo } from "../A00_Common/router";
import { createButton, createInputField, 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,
fetchSectionContext,
getSections,
previewCrossDesigns,
type SectionContextResponse,
type SectionDetailResponse,
type StandardCrossSection,
} from "./B06_Section_Api_Fetch";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
import {
confirmCurrentSections,
createRockBoundaryStore,
saveCurrentSections,
type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View";
import { staleDesignChainages } from "./B06_Section_UI_Section_Common";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
import {
applyPipeOptionsToCache,
type PipeOptionsContext,
} from "./B06_Section_UI_Page_Pipe_Options";
import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel";
import {
createB06StructuresPanel,
isWallSelecting,
wireStructureSelection,
} from "./B06_Section_UI_Page_Structures_Panel";
import "./B06_Section_UI_Style.css";
import "./B06_Section_UI_Style_Cross.css";
import "./B06_Section_UI_Style_Cross_Controls.css";
import "./B06_Section_UI_Style_Cross_Areas.css";
import { loadSectionDetail } from "./B06_Section_Section_Store";
import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common";
import "@util/common_util_mass_haul.css";
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 기본값으로 채운다.
// 표준 횡단면 설정은 기본 접힘(2026-08-23 사용자 지시) — 자주 여는 칸이 아니다.
const standardGroup = buildGroup(L("B06_Std_Title"), true);
const standardPanelSlot = document.createElement("div");
standardGroup.append(standardPanelSlot);
let standardPanel: StandardPanelController | null = null;
// 높이 배율 옵션은 폐지 — 항상 1배율(2026-08-06 사용자 지시).
// 횡단 반폭은 **자체 컨테이너 + 자체 [보기 반폭 적용] 버튼**이다(2026-08-23 사용자 지시)
// — 표준 횡단면 설정의 [전체 측점 반영]에 물려 있어 반폭만 바꿔도 전 측점 설계
// 재계산을 기다려야 했다. 요청 반폭이 보유 샘플 폭(기본 20m, config
// SECTION_CROSS_HALF_WIDTH_M) 이하면 표시만 바꾸므로 즉시 끝나고, 넘을 때만 재생성한다.
const crossHalfWidthField = createInputField({
label: L("B05_Route_Field_CrossHalfWidth"),
type: "number",
});
crossHalfWidthField.input.min = "0.1";
crossHalfWidthField.input.step = "0.1";
const applyWidthButton = createButton({
label: L("B06_View_Apply"),
variant: "filled",
onClick: () => void applyCrossHalfWidth(),
});
applyWidthButton.title = L("B06_View_Apply_Tip");
const viewGroup = buildGroup(L("B06_View_Title"));
// 반폭 입력 + [적용]은 한 줄(2026-08-23 사용자 지시).
const viewRow = document.createElement("div");
viewRow.className = "b06-profile__field-row";
viewRow.append(crossHalfWidthField.root, applyWidthButton);
viewGroup.append(viewRow);
/** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유
* 샘플 폭은 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시
* 범위만 좁힌다(12 ≤ 20이므로 재계산 없음). 계산 범위와 혼동 금지. */
const DISPLAY_HALF_WIDTH_DEFAULT_M = 12;
// 하단 액션 [종단 이동][임시저장][확정] — 종단 이동은 저장 없이 B05로 페이지 이동만,
// 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 상세설계로 넘어간다
// (2026-08-08 워크플로우 재정의).
const goProfileButton = createButton({
label: L("B06_Profile_Btn_GoProfile"),
variant: "ghost",
onClick: () => navigateTo(ROUTES.B05_PROFILE),
});
const saveButton = createButton({
label: L("B06_Profile_Btn_Save"),
variant: "ghost",
onClick: () => void saveCurrentSections(persistContext),
});
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(persistContext),
});
confirmButton.disabled = true;
const actionRow = document.createElement("div");
actionRow.className = "b06-profile__actions";
actionRow.append(goProfileButton, saveButton, confirmButton);
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
const focusStationAt = (chainageM: number): void => {
const sections = sectionDetail?.cross_sections;
if (!sections?.length) return;
const nearest = sections.reduce((best, entry) =>
Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best,
);
sectionView.focusStation(nearest.station_id);
};
// 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화).
// 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행].
const structuresPanel = createB06StructuresPanel({
projectId,
detail: () => sectionDetail,
// 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산).
wallHeight: (chainageM, role) => {
const owner = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
);
const culvert = owner?.culvert;
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
.pureHeight;
},
// 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라
// 저장된 옵션보다 앞선다 — 안 맞추면 폼은 저장값, 그림은 조작값이 되어
// "유출구 형태를 바꿔도 안 바뀐다"로 보인다(2026-08-30 사용자 보고).
wallForm: (chainageM, role) => {
const owner = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
);
const culvert = owner?.culvert;
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
},
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
wallSideLabels: (chainageM) => {
const owner = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
);
if (!owner) return null;
const inletLeft = (owner.uphill_side ?? "left") === "left";
return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" };
},
stationInterval: () => stationInterval ?? 20,
focusChainage: focusStationAt,
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
movePipe: (fromChainageM, toChainageM) =>
stationControls.queueCulvertMove(fromChainageM, toChainageM),
});
const dockDivider = document.createElement("hr");
dockDivider.className = "b05-structure__divider";
const actionDock = document.createElement("div");
actionDock.className = "b05-route__dock ui-sidebar-actions";
actionDock.append(structuresPanel.listRoot, dockDivider, actionRow);
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock);
// 그룹 제목 행 클릭 시 접기/펼치기(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,
inlet_structure: target.design?.inlet_structure,
basin_adjust: target.design?.basin_adjust,
revet_adjust: target.design?.revet_adjust,
extra_wall_counts: target.design?.extra_wall_counts,
extra_spans: target.design?.extra_spans,
revet_link_detached: target.design?.revet_link_detached,
revet_follow_grade: target.design?.revet_follow_grade,
};
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;
// 계획고 어긋남은 B05와 **같은 규칙**으로 판정한다(공용 staleDesignChainages).
// 옛 암 2단계 필드 누락은 B06 전용 조건이라 여기서 더한다.
const staleByPlan = new Set(staleDesignChainages(sectionDetail));
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;
return staleByPlan.has(section.chainage_m);
});
if (!stale.length) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
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 NonNullable<typeof section.design>),
inlet_structure: section.design?.inlet_structure,
basin_adjust: section.design?.basin_adjust,
revet_adjust: section.design?.revet_adjust,
extra_wall_counts: section.design?.extra_wall_counts,
extra_spans: section.design?.extra_spans,
revet_link_detached: section.design?.revet_link_detached,
revet_follow_grade: section.design?.revet_follow_grade,
};
sectionView.refreshCard(section.chainage_m);
}
}
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
}
}
const ensureSampledWidth = createSampleWidener({
target: () =>
projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null,
sampledHalfWidth,
applyDetail: (fresh) => {
sectionDetail = fresh;
renderSectionDetail();
},
});
/**
* [보기 반폭 적용] — 표시 반폭만 반영한다(2026-08-23 사용자 지시로 분리).
* 보유 샘플(기본 20m) 안이면 재계산 없이 즉시, 넘을 때만 재생성이 돈다.
* 측점 개별 반폭은 전역값으로 초기화한다 — 남아 있으면 전역값이 무시된다.
*/
async function applyCrossHalfWidth(): Promise<void> {
if (!sectionDetail) return;
// 교차점이 샘플 밖인 측점이 있으면 그 폭까지 넓힌다(카드가 자동 줌아웃할 지반 확보).
const requested = crossHalfWidth();
const needed = maxToeFitHalfWidth(sectionDetail.cross_sections);
const target = Math.max(requested ?? 0, needed);
if (target > 0 && !(await ensureSampledWidth(target))) return;
if (!sectionDetail) return;
stationControls.applyGlobalWidth(
requested,
sectionDetail.cross_sections.map((section) => section.chainage_m),
);
persistDisplayHalfWidth();
renderSectionDetail();
showToast(L("B06_View_Apply_Success"), "success");
}
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
let applyingAll = false;
async function applyPanelToAll(): Promise<void> {
if (applyingAll || !sectionDetail || !projectId || currentRouteId === null) return;
const targets = sectionDetail.cross_sections.filter((section) => section.design);
if (!targets.length) return;
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
// 대신 도는 동안 다시 누르는 것만 막는다.
applyingAll = true;
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 {
applyingAll = false;
}
}
// 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리).
const rockStore = createRockBoundaryStore({
sessionKey: () =>
projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null,
detail: () => sectionDetail,
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
recompute: (chainageM) => recomputeIfRock(chainageM),
});
const rockOffsets = rockStore.offsets;
const rockBoundaryControl = rockStore.control;
const stationControls = createStationControls({
sessionKey: (kind) =>
projectId && currentRouteId !== null ? `b06:${kind}:${projectId}:${currentRouteId}` : null,
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
detail: () => sectionDetail,
crossHalfWidth,
sampledHalfWidth,
ensureSampledWidth,
projectId: () => projectId,
onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"),
});
const { stationWidth: stationWidthControl, revetOffset: revetOffsetControl } = stationControls;
const { widths: stationWidths, inletStructures, basinAdjustments } = stationControls;
const sectionView = createSectionView(
(chainageM, change) => {
void handleDesignChange(chainageM, change);
},
rockBoundaryControl,
stationWidthControl,
revetOffsetControl,
stationControls.inletStructure,
stationControls.extraWalls,
stationControls.structureSpan,
stationControls.revetLink,
stationControls.ford,
stationControls.box,
);
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = {
detail: () => sectionDetail,
ford: stationControls.ford,
box: stationControls.box,
revetOffset: stationControls.revetOffset,
overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
};
// 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화).
const structureSelection = wireStructureSelection(
stationControls,
() => sectionDetail,
structuresPanel,
);
// 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자).
// 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다.
sectionView.setStationSelectListener((stationId) => {
const target = stationId
? sectionDetail?.cross_sections.find((section) => section.station_id === stationId)
: null;
if (!target) {
structuresPanel.showAtChainage(null);
return;
}
const owner = stationControls.structureSpan.ownerOf(target);
structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
structureSelection.syncInletStructure();
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
if (!isWallSelecting() && stationControls.revetOffset.selectedFor(target)) {
stationControls.revetOffset.select(target.chainage_m, null);
sectionView.refreshCard(target.chainage_m);
}
});
// 메인 영역: 종·횡단 도면(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,
);
}
}
// [저장]·[확정]과 편집분 수집은 저장 흐름 모듈에 있다(2026-09-02 분리).
const persistContext: SectionPersistContext = {
projectId,
routeId: () => currentRouteId,
detail: () => sectionDetail,
context: () => context,
standardValues: () => standardPanel?.getValues(),
flushCulvertOptions: () => stationControls.flushCulvertOptions(),
patchSources: () => ({
rockOffsets,
stationWidths,
inletStructures,
basinAdjustments,
revetAdjusts: stationControls.revetAdjustsByChainage(),
extraCounts: stationControls.extraCountsByChainage(),
extraSpans: stationControls.extraSpansByChainage(),
fordAdjusts: stationControls.fordAdjustsByChainage(),
boxAdjusts: stationControls.boxAdjustsByChainage(),
linkFlags: stationControls.linkFlagsByChainage(),
}),
};
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;
}
// 표시 기본 12m — 계산 기본(context.defaults.cross_half_width_m = 20m)과 다르다
// (2026-08-19 사용자 지시). 사용자 확정 이력·세션값이 있으면 아래에서 덮는다.
crossHalfWidthField.input.value = String(DISPLAY_HALF_WIDTH_DEFAULT_M);
stationInterval = context.defaults.station_interval_m;
rockStore.setDefaults(context.rock_boundary_default_offset_m, context.rock_boundary_step_m);
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
// 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시).
standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll);
standardPanelSlot.append(standardPanel.root);
if (context.route_id === null) {
// 자료가 통째로 없으면(새 자료가 올라와 옛 결과가 지워진 경우) 대시보드로 돌려보낸다.
leaveForDashboard();
return;
}
currentRouteId = context.route_id;
rockStore.load();
stationControls.load();
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다.
void structuresPanel.load();
try {
const existing = await getSections(projectId, context.route_id);
if (!existing.longitudinal) {
leaveForDashboard();
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);
/* 입력칸의 기준값은 **표시 기본 12m**(2026-08-19 사용자 지시, 위에서 이미 넣었다).
*
* ⚠ `data.options.cross_half_width_m`은 종단·횡단을 만들 때 쓴 **샘플링(계산)
* 반폭**이지 사용자가 고른 표시 폭이 아니다(엔진이 offsets 범위를 만들 때 쓰고
* 그대로 저장한다). 그 값으로 입력칸을 덮으면 표시 기본값이 언제나 계산 기본
* 20m으로 되돌아온다 — 2026-08-19 사용자 보고의 원인이 이것이다. 그래서 여기서는
* 읽지 않고, 사용자가 실제로 고른 표시 폭만 되살린다:
* ① 측점 개별 표시 반폭의 저장값(design.display_half_width_m) 대표값
* ② 세션에 남은 마지막 조절값(같은 노선으로 돌아왔을 때)
* 표시값이 보유 샘플 폭보다 크면 [전체 측점 반영]이 알아서 재생성한다. */
const savedDisplayWidths = sectionDetail.cross_sections
.map((section) => section.design?.display_half_width_m)
.filter((width): width is number => typeof width === "number" && width > 0);
if (savedDisplayWidths.length) {
// 측점마다 다를 수 있다 — 전역 입력칸에는 가장 넓은 값을 올린다(잘림 방지).
crossHalfWidthField.input.value = Math.max(...savedDisplayWidths).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");
}
}