main_laptop_1 -> main byeonghap (4 hwangyeong 585 commits) #12

Merged
eomsangdon merged 585 commits from main_laptop_1 into main 2026-09-08 17:26:30 +09:00
9 changed files with 175 additions and 56 deletions
Showing only changes of commit 9da3bce1bf - Show all commits
+4
View File
@@ -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" },
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
@@ -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<void> = Promise.resolve();
+9 -2
View File
@@ -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;
@@ -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<string, CrossDesignChoice>;
/** 측점 키 — 암 경계선 저장소와 같은 규칙(0.01m 단위). */
const keyOf = (chainageM: number): string => chainageM.toFixed(2);
function readAll(projectId: string, routeId: number): ChoiceMap {
return readState<ChoiceMap>("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<number, CrossDesignChoice> {
const choices = new Map<number, CrossDesignChoice>();
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;
}
+21 -6
View File
@@ -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,
+12
View File
@@ -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)
+8
View File
@@ -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`).
+16 -33
View File
@@ -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<void> {
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
attachCollapsible(leftForm);
// 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다.
const designRequestSeq = new Map<number, number>();
/**
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
* (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음.
@@ -228,33 +225,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
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<void> {
* 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형·
* 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다.
*/
async function reconcileStaleDesigns(): Promise<void> {
async function reconcileStaleDesigns(options?: { force?: boolean }): Promise<void> {
if (!sectionDetail || !projectId || currentRouteId === null) return;
const draft = readAlignmentDraft(currentRouteId);
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
@@ -304,7 +287,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, 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 {
+33 -12
View File
@@ -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<string, unknown> | 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;
}
}
}