diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 4184ce8c..85b9bb59 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -126,6 +126,10 @@ export const STATE_REGISTRY = { fordadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:fordadjust:${p}:${r}` }, boxadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:boxadjust:${p}:${r}` }, extraspan: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extraspan:${p}:${r}` }, + /** 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택 — 2026-09-06. + * 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고 + * [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */ + crossdesign: { bucket: "draft", scope: "route" }, /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 7402e314..61cde2be 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -119,9 +119,11 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { } /* ── 구조물 정본(structures.json) ──────────────────────────────────── - * 사이드 목록이 바뀌면 곧바로 서버 정본에 저장한다 — 화면에만 남겨 두면 새로고침에 - * 사라지고, 다른 창과도 어긋난다. 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 - * 받아 화면을 맞추고 사용자에게 알린다. */ + * 목록이 바뀌면 **세션 초안**에 담고(`writePending`), 정본에는 [저장]·[확정]에서만 + * 쓴다(`saveStructuresIfDirty`). 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 + * 받아 화면을 맞추고 사용자에게 알린다. + * (옛 주석은 「곧바로 서버에 저장한다」였다 — 2026-08-29 에 초안 방식으로 바뀌었고 + * 주석만 남아 있었다. 2026-09-06 정정.) */ let structureRevision = 0; let structureSaving: Promise = Promise.resolve(); diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 17a2e81a..f0900921 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -506,8 +506,15 @@ export interface CrossSectionPatch { /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */ revet_link_detached?: boolean; revet_follow_grade?: boolean; - /** 구조물이 선 측점의 폐회로 절·성토 면적(㎡) — 브라우저가 계산해 보낸다 - * (2026-09-06 사용자 확정). 구조물이 없는 측점에는 싣지 않는다. */ + /** 카드 버튼 선택(2026-09-06) — 예전에는 버튼을 누를 때 서버가 계산·저장했다. + * 이제 조작은 세션 초안에 쌓이고 [저장]·[확정]에서 이 patch 로만 나간다. */ + ground_type?: string; + section_mode?: string; + ditch_side?: string | null; + ditch_type?: string | null; + paved?: boolean; + two_stage_slope?: boolean; + /** 절·성토 면적(㎡) — 브라우저가 계산해 보낸다(2026-09-06 사용자 확정). */ cut_area_m2?: number; fill_area_m2?: number; cut_soil_area_m2?: number; diff --git a/B06_Section/B06_Section_Cross_Design_Session.ts b/B06_Section/B06_Section_Cross_Design_Session.ts new file mode 100644 index 00000000..7b6ec3f9 --- /dev/null +++ b/B06_Section/B06_Section_Cross_Design_Session.ts @@ -0,0 +1,67 @@ +/* ============================================================================= + * B06_Section_Cross_Design_Session.ts + * 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택의 **세션 보관소**. + * + * 왜 생겼나(2026-09-06 사용자 확정) — 예전에는 버튼을 누를 때마다 + * `POST …/sections/{route}/cross-design` 이 나가 서버가 계산하고 **바로 저장**했다. + * 조작은 캐시에 쌓이고 [저장]·[확정]에서만 정본으로 나가야 한다는 규칙에 어긋난다. + * 계산은 브라우저가 하고(`common_util_cross_design.ts`), 선택값은 여기 담긴다. + * + * 담는 것은 **사용자가 고른 것만**이다 — 계산 결과(면적·설계선)는 담지 않는다. + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; + +/** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */ +export interface CrossDesignChoice { + ground_type: string; + section_mode: string; + ditch_side?: string | null; + ditch_type?: string | null; + paved?: boolean; + two_stage_slope?: boolean; +} + +type ChoiceMap = Record; + +/** 측점 키 — 암 경계선 저장소와 같은 규칙(0.01m 단위). */ +const keyOf = (chainageM: number): string => chainageM.toFixed(2); + +function readAll(projectId: string, routeId: number): ChoiceMap { + return readState("crossdesign", projectId, routeId) ?? {}; +} + +/** 이 측점의 선택값(없으면 null). */ +export function readCrossDesignChoice( + projectId: string, + routeId: number, + chainageM: number, +): CrossDesignChoice | null { + return readAll(projectId, routeId)[keyOf(chainageM)] ?? null; +} + +/** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */ +export function writeCrossDesignChoice( + projectId: string, + routeId: number, + chainageM: number, + choice: CrossDesignChoice, +): void { + const all = readAll(projectId, routeId); + all[keyOf(chainageM)] = choice; + writeState("crossdesign", all, projectId, routeId); +} + +/** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */ +export function crossDesignChoices( + projectId: string | null, + routeId: number | null, +): Map { + const choices = new Map(); + if (!projectId || routeId === null) return choices; + for (const [key, value] of Object.entries(readAll(projectId, routeId))) { + const chainage = Number(key); + if (Number.isFinite(chainage) && value) choices.set(chainage, value); + } + return choices; +} diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 1206212b..2bf89825 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -29,6 +29,7 @@ import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import { effectiveStandardCross, readRockBoundaryDefault, @@ -153,6 +154,10 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); + // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 + // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). + const choices = crossDesignChoices(projectId, input.routeId); + const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); const updated: number[] = []; for (const section of detail.cross_sections) { @@ -169,22 +174,32 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { : typeof storedOffset === "number" ? storedOffset : rockDefault; + const choice = choiceAt(section.chainage_m); let next; try { next = computeCrossDesign( section.samples ?? [], planElevationAt(alignment, section.chainage_m), { - groundType: typeof design.ground_type === "string" ? design.ground_type : "ripping_rock", - sectionMode: typeof design.section_mode === "string" ? design.section_mode : "left_cut", - ditchSide: typeof design.ditch_side === "string" ? design.ditch_side : null, + groundType: + choice?.ground_type ?? + (typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"), + sectionMode: + choice?.section_mode ?? + (typeof design.section_mode === "string" ? design.section_mode : "left_cut"), + ditchSide: + choice?.ditch_side ?? + (typeof design.ditch_side === "string" ? design.ditch_side : null), // 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다. - ditchType: typeof design.ditch_type === "string" ? design.ditch_type : "standard", - paved: Boolean(design.paved), + ditchType: + choice?.ditch_type ?? + (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), + paved: choice?.paved ?? Boolean(design.paved), standard, rockBoundaryOffsetM, twoStageSlope: - design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope), + choice?.two_stage_slope ?? + (design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)), ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, // 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다. surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0, diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 62153fcb..1ef95832 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -140,6 +140,18 @@ async def _apply_section_edits( patch["revet_link_detached"] = patch_item.revet_link_detached if patch_item.revet_follow_grade is not None: patch["revet_follow_grade"] = patch_item.revet_follow_grade + # 카드 버튼 선택 — 브라우저가 고른 값을 그대로 정본에 얹는다(2026-09-06). + for choice_key in ( + "ground_type", + "section_mode", + "ditch_side", + "ditch_type", + "paved", + "two_stage_slope", + ): + choice = getattr(patch_item, choice_key) + if choice is not None: + patch[choice_key] = choice # 구조물 폐회로 면적 — 브라우저가 계산해 보낸 값을 그대로 정본에 얹는다. for area_key in ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2"): value = getattr(patch_item, area_key) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 4ee56495..9a24481b 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -153,6 +153,14 @@ class CrossSectionPatch(BaseModel): # 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다. revet_link_detached: bool | None = None revet_follow_grade: bool | None = None + # 카드 버튼 선택(2026-09-06) — 예전에는 버튼을 누를 때 서버가 계산·저장했다. + # 이제 조작은 세션 초안에 쌓이고 [저장]·[확정]에서 이 patch 로만 나간다. + ground_type: str | None = None + section_mode: str | None = None + ditch_side: str | None = None + ditch_type: str | None = None + paved: bool | None = None + two_stage_slope: bool | None = None # 구조물이 선 측점의 폐회로 절·성토 면적(㎡) — 브라우저가 계산해 보낸다 # (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 서버는 초기값을 만들 때만 # 같은 코드를 Node 로 돌린다(`B06_Section_Server_Calc_Node.ts`). diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 94ef47c5..c1a96354 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -1,3 +1,4 @@ +import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { stateKey } from "../A00_Common/b_page_state"; @@ -13,7 +14,6 @@ import { type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { - computeCrossDesign, fetchSectionContext, getSections, type SectionContextResponse, @@ -199,9 +199,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); - // 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다. - const designRequestSeq = new Map(); - /** * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. @@ -228,33 +225,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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"); - } + // (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고, + // [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로 + // 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다. + writeCrossDesignChoice(projectId, currentRouteId, chainageM, { + ground_type: change.ground_type, + section_mode: change.section_mode, + ditch_side: change.ditch_side ?? null, + ditch_type: change.ditch_type, + paved: change.paved, + two_stage_slope: change.two_stage_slope, + }); + // (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다. + await reconcileStaleDesigns({ force: true }); } /** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */ @@ -294,7 +277,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { * 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형· * 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다. */ - async function reconcileStaleDesigns(): Promise { + async function reconcileStaleDesigns(options?: { force?: boolean }): Promise { if (!sectionDetail || !projectId || currentRouteId === null) return; const draft = readAlignmentDraft(currentRouteId); // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 + @@ -304,7 +287,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. - if (!draft && !hasStaleDesigns(sectionDetail)) return; + if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 70f6f178..b9476cf7 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -24,6 +24,7 @@ import { STRUCTURE_AREA_KEYS, structureAreaRows, } from "./B06_Section_Structure_Layouts"; +import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; @@ -176,20 +177,40 @@ export function collectSectionEdits(ctx: SectionPersistContext): { // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산해 싣는다 // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 카드 그리기가 고쳐 둔 값에 // 기대면 화면에 안 뜬 측점이 표준값으로 남는다. + const byChainage = new Map(crossPatches.map((patch) => [patch.chainage_m, patch])); + const patchFor = (chainageM: number): CrossSectionPatch => { + const existing = byChainage.get(chainageM); + if (existing) return existing; + const created: CrossSectionPatch = { chainage_m: chainageM }; + byChainage.set(chainageM, created); + crossPatches.push(created); + return created; + }; + // 카드 버튼 선택(지반유형·단면유형·측구·포장·2단 비탈) — 세션 초안이 정본으로 나가는 + // 유일한 길이다(2026-09-06 사용자 확정: 버튼을 누를 때 서버가 저장하지 않는다). + crossDesignChoices(ctx.projectId, ctx.routeId()).forEach((choice, chainage) => { + const patch = patchFor(chainage); + patch.ground_type = choice.ground_type; + patch.section_mode = choice.section_mode; + if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side; + if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type; + if (choice.paved !== undefined) patch.paved = choice.paved; + if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope; + }); if (detail) { - const areaRows = structureAreaRows(detail.cross_sections); - // 유토곡선이 **고쳐진 면적 위에서** 쌓이도록 캐시에도 얹는다(Node 진입점과 같은 순서). - applyStructureAreaRows(detail.cross_sections, areaRows); - const byChainage = new Map(crossPatches.map((patch) => [patch.chainage_m, patch])); - for (const row of areaRows) { - let patch = byChainage.get(row.chainage_m); - if (!patch) { - patch = { chainage_m: row.chainage_m }; - byChainage.set(row.chainage_m, patch); - crossPatches.push(patch); - } + // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다 + // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). + applyStructureAreaRows(detail.cross_sections, structureAreaRows(detail.cross_sections)); + // 면적은 **전 측점**을 싣는다 — 브라우저가 만든 값이 곧 작업본이다. 카드 버튼을 + // 바꾸면 구조물이 없는 측점의 면적도 달라지므로 구조물 측점만 보내면 수량이 어긋난다. + for (const section of detail.cross_sections) { + const design = section.design as Record | undefined; + if (!design) continue; + let patch: CrossSectionPatch | null = null; for (const key of STRUCTURE_AREA_KEYS) { - if (typeof row[key] === "number") patch[key] = row[key]; + if (typeof design[key] !== "number") continue; + patch = patch ?? patchFor(section.chainage_m); + patch[key] = design[key] as number; } } }