/* ============================================================================= * design_view_cross.ts * 한 페이지의 **횡단 보기**(계획서 24-3b) — 가운데 위 칸 = 횡단 카드(처음 보일 때 그린다 · * 그 안에서 스크롤). 좌측 패널 · 아래 단추 · 종단도는 한 벌(`design_shared`)을 종단 보기와 * 같은 노드로 쓴다. * * 여기서는 횡단 보기 몫만 건다 — 좌측 횡단 칸 재료(횡단 보기 설정 · 표준 횡단면) · 구조물 폼 * 알림 · 카드 조정 제어 · 저장 몫. (옛 `B06_Section_UI_Page.ts` 의 본체다. 저장 흐름은 * `_Page_Persist`, 설계 반영 · 재계산은 `_Page_Design_Sync` 가 맡는다.) * * 종단 보기의 [확정]이 횡단 자료를 얻으려고 이 보기를 **숨긴 채** 세우기도 한다 — 숨긴 칸은 * 폭이 0 이라 카드는 그리지 않고, 처음 보일 때 보임 알림이 그린다. * ========================================================================== */ import { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync"; import { leaveForDashboard } from "../b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../b_page_state"; import { showToast } from "@ui/ui_template_elements"; import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations"; import type { StructureInstance, StructureType, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { fetchSectionContext, getSections, type SectionContextResponse, type SectionDetailResponse, type StandardCrossSection, } from "../../B06_Section/B06_Section_Api_Fetch"; import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls"; import { createCutSlopeStore, createRockBoundaryStore, crossSaveParts, type SectionPersistContext, } from "../../B06_Section/B06_Section_UI_Page_Persist"; import { maxToeFitHalfWidth } from "../../B06_Section/B06_Section_UI_Cross_Fit"; import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; import { revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, replayDraftPipeOptions, type PipeOptionsContext, } from "../../B06_Section/B06_Section_UI_Page_Pipe_Options"; import { createStandardPanel, effectiveStandardCross, rememberRockBoundaryDefault, type StandardPanelController, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; import { createB06StructuresPanel, isWallSelecting, wireStructureSelection, } from "../../B06_Section/B06_Section_UI_Page_Structures_Panel"; import "../../B06_Section/B06_Section_UI_Style.css"; import "../../B06_Section/B06_Section_UI_Style_Cross.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { applyStructureAreaRows, structureAreaRows, } from "../../B06_Section/B06_Section_Structure_Layouts"; import { createSampleWidener, L } from "../../B06_Section/B06_Section_UI_Page_Common"; import { createCrossViewPart, createStandardSlot, } from "../../B06_Section/B06_Section_UI_Page_Panel"; import type { DesignPageHost } from "./design_page"; import { bindDesignView, bindPipeOptions, designReady, sharedPanel, type DesignPageShared, } from "./design_shared"; import "@util/common_util_mass_haul.css"; /** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유 샘플 폭은 * 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시 범위만 좁힌다. */ const DISPLAY_HALF_WIDTH_DEFAULT_M = 12; export async function mountCrossView( center: HTMLElement, host: DesignPageHost, shared: DesignPageShared, ): Promise { const projectId = shared.projectId || null; let currentRouteId: number | null = null; let sectionDetail: SectionDetailResponse | null = null; let stationInterval: number | undefined; // 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다. const standardPanelSlot = createStandardSlot(); let standardPanel: StandardPanelController | null = null; // 「횡단 보기 설정」 칸 재료(반폭 입력 + [보기 반폭 적용])는 `_Page_Panel` 이 만든다. const crossView = createCrossViewPart(() => void applyCrossHalfWidth()); const crossHalfWidthField = crossView.field; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */ 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); }; /** 그 누가거리를 가진 측점 카드(관 찾는 거리 한 값). */ const ownerAt = (chainageM: number) => sectionDetail?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M, ); // 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b). let structureMarksSink: ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; /** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */ let pipeReloadTimer = 0; const structuresPanel = createB06StructuresPanel({ projectId, shared, // 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4). onMarks: (structures, types) => { structureMarksSink?.(structures, types); syncBermSpans(structures); }, // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06). onStructuresChanged: () => void refreshDetailForStructures(), onPipesWritten: () => { window.clearTimeout(pipeReloadTimer); pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200); }, detail: () => sectionDetail, // 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산). wallHeight: (chainageM, role) => { const owner = ownerAt(chainageM); const culvert = owner?.culvert; if (!owner || !culvert) return null; const spec = role === "outlet" ? culvert.outlet : culvert.inlet; const adjust = stationControls.revetOffset.adjustFor(owner, role); // 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음 // (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」). if (adjust?.h == null) return null; return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m) .pureHeight; }, // 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라 저장된 옵션보다 // 앞선다(2026-08-30 사용자 보고: 유출구 형태를 바꿔도 안 바뀐다). wallForm: (chainageM, role) => { const owner = ownerAt(chainageM); const culvert = owner?.culvert; if (!owner || !culvert) return null; const spec = role === "outlet" ? culvert.outlet : culvert.inlet; const adjust = stationControls.revetOffset.adjustFor(owner, role); if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④) return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form; }, // 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다. wallSideLabels: (chainageM) => { const owner = ownerAt(chainageM); if (!owner) return null; const inletLeft = (owner.uphill_side ?? "left") === "left"; return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" }; }, stationInterval: () => stationInterval ?? 20, // 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저 // 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다. focusChainage: (chainageM) => { if (!isWallSelecting()) focusStationAt(chainageM); }, applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), }); // 좌측 패널 = 한 벌 등록부 — 횡단 보기 칸 재료만 넣는다. 칸 차례: 구조물 배치(최상단 — // 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. 아래 단추 넷 · 저장은 한 벌이 낸다. Object.assign(shared.parts, { crossView, standardSlot: standardPanelSlot }); sharedPanel(shared); // 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리). const { handleDesignChange, recomputeIfRock, reconcileStaleDesigns, syncBermSpans, applyPanelToAll, } = createDesignSync({ projectId, routeId: () => currentRouteId, detail: () => sectionDetail, view: () => sectionView, }); /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; try { sectionDetail = await loadSectionDetail(projectId, currentRouteId); // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측). if (sectionDetail) { applyStructureAreaRows( sectionDetail.cross_sections, structureAreaRows(sectionDetail.cross_sections), ); } renderSectionDetail(); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`구조물을 횡단에 반영하지 못했습니다.${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"); } // 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리). const rockStore = createRockBoundaryStore({ sessionKey: () => stateKey("rockb", projectId, currentRouteId), detail: () => sectionDetail, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), recompute: (chainageM) => recomputeIfRock(chainageM), }); // 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다. const cutSlopeStore = createCutSlopeStore({ sessionKey: () => stateKey("cutslope", projectId, currentRouteId), standard: () => (projectId ? effectiveStandardCross(projectId) : null), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), recompute: (chainageM) => recomputeIfRock(chainageM), }); const stationControls = createStationControls({ // 키는 등록표(`b_page_state`)가 만든다 — 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다. sessionKey: (kind) => stateKey(kind, projectId, currentRouteId), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), detail: () => sectionDetail, crossHalfWidth, sampledHalfWidth, ensureSampledWidth, projectId: () => projectId, onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"), }); const sectionView = createSectionView( (chainageM, change) => { void handleDesignChange(chainageM, change); }, rockStore.control, stationControls.stationWidth, stationControls.revetOffset, stationControls.inletStructure, stationControls.extraWalls, stationControls.structureSpan, stationControls.revetLink, stationControls.ford, stationControls.box, cutSlopeStore.control, shared, ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); // 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙. // 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로 // 들어와도 좌측 「구조물 배치」와 같은 함수를 타므로 목록·폼·알약이 함께 선다. sectionView.setStructureEdit({ addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM), removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM), addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId), removeStructure: (structureId) => { structuresPanel.removeStructureById(structureId); }, movePipe: (fromChainageM, toChainageM) => structuresPanel.movePipeTo(fromChainageM, toChainageM), moveStructure: (structureId, toChainageM) => { structuresPanel.moveStructureTo(structureId, toChainageM); }, }); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(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-09-04 — 두 화면이 한 페이지처럼). (chainageM, key) => selectDesign(projectId, structuresPanel.selectionAt(chainageM), { partKey: key, from: "b06", }), ); // 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자). // 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다. sectionView.setStationSelectListener((stationId) => { const target = stationId ? sectionDetail?.cross_sections.find((section) => section.station_id === stationId) : null; if (!target) { structuresPanel.showAtChainage(null); selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다. return; } const owner = stationControls.structureSpan.ownerOf(target); const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); // 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다. if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" }); 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); center.append(mainArea); 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 => stateKey("cross-display", projectId, currentRouteId); function persistDisplayHalfWidth(): void { const key = displaySessionKey(); const width = crossHalfWidth(); if (!key || width === undefined) return; try { writeByKey(key, String(width)); } catch { /* 무시 */ } } /** 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼). **넘김값이 바뀌었을 때만** * 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이 튄다(2026-09-06). */ let appliedPick: string | null = null; function restoreStructurePick(): void { if (!sectionDetail) return; const handoff = readStructurePick(projectId); const signature = handoff ? `${handoff.at}|${handoff.key ?? ""}|${handoff.structureId ?? ""}` : null; if (signature === null || signature === appliedPick) return; // 카드를 못 찾았으면(구조물 목록이 아직 안 실림) 적용한 것으로 치지 않는다 — 목록이 실린 뒤 // `load().then(restoreStructurePick)` 가 다시 부른다. const applied = applyStructurePick( handoff, sectionDetail, { focusStation: (stationId) => sectionView.focusStation(stationId), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role), boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role), }, structuresPanel.getStructures(), ); if (applied) appliedPick = signature; } function renderSectionDetail(): void { if (!sectionDetail) return; 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, ); restoreStructurePick(); } // [저장]·[확정]과 편집분 수집은 저장 흐름 모듈에 있다(2026-09-02 분리). const persistContext: SectionPersistContext = { projectId, routeId: () => currentRouteId, detail: () => sectionDetail, context: () => context, standardValues: () => standardPanel?.getValues(), flushCulvertOptions: () => stationControls.flushCulvertOptions(), // 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로 // 실려 나가던 자리(2026-09-09 실측). reconcileDesigns: () => reconcileStaleDesigns({ force: true }), patchSources: () => ({ rockOffsets: rockStore.offsets, cutSlopeRatios: cutSlopeStore.ratios, stationWidths: stationControls.widths, inletStructures: stationControls.inletStructures, basinAdjustments: stationControls.basinAdjustments, revetAdjusts: stationControls.revetAdjustsByChainage(), extraCounts: stationControls.extraCountsByChainage(), extraSpans: stationControls.extraSpansByChainage(), fordAdjusts: stationControls.fordAdjustsByChainage(), boxAdjusts: stationControls.boxAdjustsByChainage(), linkFlags: stationControls.linkFlagsByChainage(), }), }; bindDesignView(shared, "cross", { save: crossSaveParts(persistContext) }); // 한 페이지 두 보기(계획서 24-3a · 24-3b) — 다시 보이면 종단 보기에서 바뀐 구조물 · 관 · // 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다. host.onShow(() => { if (currentRouteId === null) return; void structuresPanel.load().then(restoreStructurePick); void refreshDetailForStructures().then(() => reconcileStaleDesigns()); }); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); sectionView.dispose(); }); let context: SectionContextResponse | null = null; if (!projectId) { renderMessage(L("B06_Profile_Error_Project")); return; } try { context = await fetchSectionContext(projectId); } catch { showToast(L("B06_Profile_Context_Failed"), "error"); } if (host.disposed()) 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 기본값). standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); // 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다. rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { // 자료가 통째로 없으면(새 자료가 올라와 옛 결과가 지워진 경우) 대시보드로 돌려보낸다. leaveForDashboard(); return; } currentRouteId = context.route_id; rockStore.load(); cutSlopeStore.load(); stationControls.load(); // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. 숨긴 채 // 섰으면(종단 보기 [확정]) 한 벌 폼은 종단 보기 몫이라 싣지 않는다 — 보일 때 싣는다. if (host.visible()) void structuresPanel.load().then(restoreStructurePick); try { const existing = await getSections(projectId, context.route_id); if (!existing.longitudinal) { leaveForDashboard(); return; } // 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다. sectionDetail = await loadSectionDetail(projectId, context.route_id); // 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 // 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측). if (sectionDetail) { applyStructureAreaRows( sectionDetail.cross_sections, structureAreaRows(sectionDetail.cross_sections), ); } // 단일 소스(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**. ⚠ `data.options.cross_half_width_m`은 종단·횡단을 * 만들 때 쓴 **샘플링(계산) 반폭**이지 사용자가 고른 표시 폭이 아니다 — 그 값으로 덮으면 * 표시 기본값이 언제나 20m로 되돌아온다(2026-08-19 사용자 보고의 원인). 사용자가 실제로 * 고른 표시 폭만 되살린다: ① 측점 개별 표시 반폭 저장값의 대표값 ② 세션의 마지막 조절값. */ 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(readByKey(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; // 종단 보기에서 고친 관 옵션(세월교 · BOX 스펙)을 캐시에 얹는 창구 — 담아 둔 것부터(24-3b). bindPipeOptions(shared, (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), ); // 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a). replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId)); renderSectionDetail(); // 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다 // (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만. void mountMissingStationNotice(mainArea, projectId, refreshDetailForStructures); void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) designReady(shared); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; renderMessage(L("B06_Profile_Calculate_In_B05")); showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error"); } }