fix(B06): 구조물 초안·면적을 상세 저장소 한 곳에서 얹음

페이지마다 따로 얹으니 새로고침 뒤 세션 스냅샷이 저장분 기준으로 되돌아갔음
(초안 벽 3건이 화면에서 사라짐). 캐시·세션·새 요청 어느 길로 들어오든 지나는
공유 저장소에서 ① 저장 안 한 구조물 벽을 얹고 ② 구조물 면적을 다시 계산하게 모음.

검증: 새로고침 뒤에도 벽 5건(저장 2 + 초안 3)이 붙고 3+0.0 성토 8.06 → 5.73㎡ 유지.
테스트 390 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 19:18:58 +09:00
co-authored by Claude Opus 5
parent 155641a782
commit 1136fff0ea
2 changed files with 68 additions and 42 deletions
+57 -4
View File
@@ -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<SectionDetailResponse> {
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<Record<string, unknown>>,
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<SectionDetailResponse>("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);
+11 -38
View File
@@ -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<void> {
}
}
/**
* 아직 저장하지 않은 구조물(C군 벽)을 횡단 제원에 얹는다 — **조작 중 즉시 반영**.
*
* 저장분은 서버가 상세를 내려보낼 때 이미 얹어 준다(`attach_wall_structures`).
* 세션 초안이 있으면 그것이 화면의 정본이므로, 저장분 기준으로 얹힌 벽을 걷어내고
* 초안 기준으로 다시 얹는다. 산식은 서버와 **같은 짝**(`common_util_structure_walls`).
*/
async function applyDraftWalls(detail: SectionDetailResponse | null): Promise<void> {
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<Record<string, unknown>>,
wallSpecsFrom(pending as unknown as WallStructureInput[], names),
);
}
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
async function refreshDetailForStructures(): Promise<void> {
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<void> {
}
// 공유 캐시 — 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?: {