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, 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, getSections, previewCrossDesigns, type SectionContextResponse, type SectionDetailResponse, type StandardCrossSection, } from "./B06_Section_Api_Fetch"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; import { buildCrossPatches } from "./B06_Section_UI_Page_Patches"; import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; 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 { staleDesignChainages } from "./B06_Section_UI_Section_Common"; 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 { 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(), }); 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"); 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, stationInterval: () => stationInterval ?? 20, focusChainage: focusStationAt, queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch), applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(chainageM, patch), movePipe: (fromChainageM, toChainageM) => stationControls.queueCulvertMove(fromChainageM, toChainageM), }); /** * 폼에서 바꾼 관 옵션을 **횡단 캐시**(`section.culvert` 스펙)에 얹고, 그 구조물이 * 덮는 측점 카드를 다시 그린다 — 조정창 조작과 같은 흐름이라 도면이 바로 따라온다 * (2026-08-29 사용자 보고: 값만 바뀌고 횡단도가 그대로였다). * 여기서는 화면 캐시만 고친다 — 영구저장은 [저장]·[확정] 몫이다. */ function applyPipeOptionsToCache( chainageM: number, patch: Record, ): void { const owner = sectionDetail?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < 0.51, ); const culvert = owner?.culvert; if (!owner || !culvert) return; const num = (key: string): number | undefined => { const value = Number(patch[key]); return Number.isFinite(value) ? value : undefined; }; const put = (target: T, field: keyof T, value: unknown): void => { if (value !== undefined) (target as Record)[field as string] = value; }; put(culvert, "pipe_kind", patch.pipe_kind); const diameterMm = num("pipe_diameter_mm"); if (diameterMm !== undefined) culvert.diameter_m = diameterMm / 1000; // 유입·유출 기슭막이 제원과 집수정 구간값 — 폼 옵션 키를 스펙 자리로 옮긴다. for (const [side, prefix] of [ [culvert.inlet, "inlet"], [culvert.outlet, "outlet"], ] as const) { put(side, "revet_form", patch[`${prefix}_revet_form`]); put(side, "revet_height_m", num(`${prefix}_revet_height_m`)); put(side, "revet_length_m", num(`${prefix}_revet_length_m`)); put(side, "revet_before_m", num(`${prefix}_revet_before_m`)); put(side, "revet_after_m", num(`${prefix}_revet_after_m`)); put(side, "structure", patch[`${prefix}_type`]); } put(culvert.inlet, "basin_length_m", num("inlet_basin_length_m")); put(culvert.inlet, "basin_before_m", num("inlet_basin_before_m")); put(culvert.inlet, "basin_after_m", num("inlet_basin_after_m")); // 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다. const reach = Math.max( culvert.inlet.revet_before_m ?? 0, culvert.inlet.revet_after_m ?? 0, culvert.outlet.revet_before_m ?? 0, culvert.outlet.revet_after_m ?? 0, num("inlet_revet_length_m") ?? 0, num("outlet_revet_length_m") ?? 0, ); for (const other of sectionDetail?.cross_sections ?? []) { if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) { sectionView.refreshCard(other.chainage_m); } } } 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(); /** * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. * 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다. */ async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise { 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 { 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; curve_radii?: Record; }; } | 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), 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 { 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 { 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; } } /* ── 암 경계선 오프셋(측점별) 세션 저장소 ───────────────────────────── * 서버 재계산 없이 프론트 세션(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(); 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; 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); }, }; 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-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, ); } } /** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */ function collectSectionEdits(): { crossPatches: CrossSectionPatch[]; massHaul: Record | undefined; } { const crossPatches = buildCrossPatches({ rockOffsets, stationWidths, inletStructures, basinAdjustments, revetAdjusts: stationControls.revetAdjustsByChainage(), extraCounts: stationControls.extraCountsByChainage(), extraSpans: stationControls.extraSpansByChainage(), fordAdjusts: stationControls.fordAdjustsByChainage(), boxAdjusts: stationControls.boxAdjustsByChainage(), linkFlags: stationControls.linkFlagsByChainage(), }); // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다. 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 { if (!projectId || currentRouteId === null) return; showLoadingOverlay(); try { // 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). await stationControls.flushCulvertOptions(); // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. await flushPendingStructures(projectId); 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 { if (!projectId || currentRouteId === null) return; showLoadingOverlay(); try { // 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). await stationControls.flushCulvertOptions(); // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. await flushPendingStructures(projectId); 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; } // 표시 기본 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; 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); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { // 자료가 통째로 없으면(새 자료가 올라와 옛 결과가 지워진 경우) 대시보드로 돌려보낸다. leaveForDashboard(); return; } currentRouteId = context.route_id; loadRockOffsets(); 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"); } }