From 1136fff0ea5a9011b566f7cc0aa4d033de5db7ec Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 19:18:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=C2=B7=EB=A9=B4=EC=A0=81=EC=9D=84=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=ED=95=9C=20=EA=B3=B3?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=96=B9=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 페이지마다 따로 얹으니 새로고침 뒤 세션 스냅샷이 저장분 기준으로 되돌아갔음 (초안 벽 3건이 화면에서 사라짐). 캐시·세션·새 요청 어느 길로 들어오든 지나는 공유 저장소에서 ① 저장 안 한 구조물 벽을 얹고 ② 구조물 면적을 다시 계산하게 모음. 검증: 새로고침 뒤에도 벽 5건(저장 2 + 초안 3)이 붙고 3+0.0 성토 8.06 → 5.73㎡ 유지. 테스트 390 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Section_Store.ts | 61 ++++++++++++++++++++++-- B06_Section/B06_Section_UI_Page.ts | 49 +++++-------------- 2 files changed, 68 insertions(+), 42 deletions(-) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index f23fc504..12368851 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -18,6 +18,16 @@ * ========================================================================== */ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { + fetchStructureTypes, + readPendingStructures, +} from "../B05_Profile/B05_Profile_Api_Structures"; +import { + attachWallSpecs, + wallSpecsFrom, + type WallStructureInput, +} from "@util/common_util_structure_walls"; +import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; @@ -28,6 +38,49 @@ function keyOf(projectId: string, routeId: number): string { return `${projectId}:${routeId}`; } +/** + * 아직 저장하지 않은 구조물(C군 벽)을 상세에 얹는다 — **여기가 유일한 자리**다. + * + * 서버는 저장분만 얹어 준다(`attach_wall_structures`). 초안이 있으면 그것이 화면의 + * 정본이므로, 캐시·세션·새 요청 어느 길로 들어오든 이 함수를 지나 같은 상태가 된다. + * 페이지마다 따로 얹으면 새로고침 뒤 세션 스냅샷이 저장분 기준으로 되돌아간다 + * (2026-09-06 실측). 산식은 서버와 짝(`common_util_structure_walls`). + */ +async function withDraftWalls( + detail: SectionDetailResponse, + projectId: string, +): Promise { + const drafts = readPendingStructures(projectId); + if (!drafts) return withStructureAreas(detail); + const types = await fetchStructureTypes().catch(() => []); + const names = new Map( + types + .filter((type) => type.group === "C" && type.placement === "interval") + .map((type) => [type.type_id, type.name] as const), + ); + if (!names.size) return detail; + for (const section of detail.cross_sections) { + if (section.revetment) delete (section as { revetment?: unknown }).revetment; + } + attachWallSpecs( + detail.cross_sections as unknown as Array>, + wallSpecsFrom(drafts as unknown as WallStructureInput[], names), + ); + return withStructureAreas(detail); +} + +/** + * 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다. + * + * 카드는 자기 그림에서 면적을 고쳐 들지만 유토곡선은 카드보다 먼저 계산된다 — 여기서 + * 얹지 않으면 새로고침 직후 곡선만 옛 면적으로 남는다(2026-09-06 실측 553.4 vs 546.4). + * 순수 계산(6ms/67측점)이라 읽기 경로에 두어도 규칙에 어긋나지 않는다. + */ +function withStructureAreas(detail: SectionDetailResponse): SectionDetailResponse { + applyStructureAreaRows(detail.cross_sections, structureAreaRows(detail.cross_sections)); + return detail; +} + /** * 상세를 가져온다 — 캐시가 있으면 **같은 객체**를 즉시 돌려주고, 없으면 한 번만 fetch한다 * (동시 호출은 같은 Promise를 공유). `force`면 캐시를 버리고 다시 받는다. @@ -40,21 +93,21 @@ export async function loadSectionDetail( const key = keyOf(projectId, routeId); if (!options?.force) { const cached = cache.get(key); - if (cached) return cached; + if (cached) return withDraftWalls(cached, projectId); const inFlight = pending.get(key); if (inFlight) return inFlight; // 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다. const stored = readState("section-detail", projectId, routeId); if (stored) { cache.set(key, stored); - return stored; + return withDraftWalls(stored, projectId); } } const request = fetchSectionDetail(projectId, routeId) - .then((detail) => { + .then(async (detail) => { cache.set(key, detail); writeState("section-detail", detail, projectId, routeId); - return detail; + return withDraftWalls(detail, projectId); }) .finally(() => { pending.delete(key); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index d21dccf7..286b4e32 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -57,16 +57,7 @@ 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 { - attachWallSpecs, - wallSpecsFrom, - type WallStructureInput, -} from "@util/common_util_structure_walls"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; -import { - fetchStructureTypes, - readPendingStructures, -} from "../B05_Profile/B05_Profile_Api_Structures"; import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common"; import "@util/common_util_mass_haul.css"; @@ -331,37 +322,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } - /** - * 아직 저장하지 않은 구조물(C군 벽)을 횡단 제원에 얹는다 — **조작 중 즉시 반영**. - * - * 저장분은 서버가 상세를 내려보낼 때 이미 얹어 준다(`attach_wall_structures`). - * 세션 초안이 있으면 그것이 화면의 정본이므로, 저장분 기준으로 얹힌 벽을 걷어내고 - * 초안 기준으로 다시 얹는다. 산식은 서버와 **같은 짝**(`common_util_structure_walls`). - */ - async function applyDraftWalls(detail: SectionDetailResponse | null): Promise { - if (!detail || !projectId) return; - const pending = readPendingStructures(projectId); - if (!pending) return; - const types = await fetchStructureTypes().catch(() => []); - const names = new Map( - types - .filter((type) => type.group === "C" && type.placement === "interval") - .map((type) => [type.type_id, type.name] as const), - ); - for (const section of detail.cross_sections) { - if (section.revetment) delete (section as { revetment?: unknown }).revetment; - } - attachWallSpecs( - detail.cross_sections as unknown as Array>, - wallSpecsFrom(pending as unknown as WallStructureInput[], names), - ); - } - /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; try { - await applyDraftWalls(sectionDetail); + if (projectId && currentRouteId !== null) { + sectionDetail = await loadSectionDetail(projectId, currentRouteId); + } // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측: 카드는 바뀌는데 // 최종 누가토량이 그대로였음). @@ -693,8 +660,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } // 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심). sectionDetail = await loadSectionDetail(projectId, context.route_id); - // 저장하지 않고 나갔던 구조물 초안이 있으면 그것으로 벽을 얹는다(서버 상세는 저장분 기준). - await applyDraftWalls(sectionDetail); + // 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 + // 첫 화면의 카드·유토곡선이 초안 기준으로 선다(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?: {