Files
Aislo/B06_Section/B06_Section_UI_Page_Persist.ts
T
eomsangdonandClaude Opus 5 7833b9e677 feat(구조물): 소단을 「구조물 배치」로 옮김 — C군 사면안정 신설 (계획서 3-9, 사용자 재확정)
사용자 확정(2026-09-07) — 소단은 좌측 별도 폼이 아니라 **다른 옹벽·기슭막이와 같은 자리**
(구조물 배치)에서 놓는 것으로 바뀜. 기하는 그대로 두고 **입구만 옮겼음.**

· 레지스트리에 **C군(사면안정) 「소단」** 신설. 옵션은 다른 C군 구간형과 같은 꼴 —
  길이·기준측점 전·후 + 폭·간격(사면길이)·안쪽 기울기.
· ⚠ **소단은 C군 구간형이지만 벽이 아님** — 기슭막이 제원 자리에 얹히지 않게 파이썬·TS
  양쪽 벽 필터에서 뺐음. 안 뺐으면 소단을 놓는 순간 「독립 기슭막이」로 그려졌을 것임.
· 구조물 목록이 바뀌면 소단 구간을 세션 사본(`berm`)으로 펴고, 달라졌을 때만 재계산함.
  계산 계통(브라우저·서버·`USER_TOUCHED_KEYS`·옹벽 의무 판정)은 **한 줄도 안 바꿨음**.
· ④에서 만든 좌측 별도 「소단」 패널은 걷어냈음(모듈 삭제).

**안쪽 기울기 기본값 2° → 0°** (사용자 재확정). 기울이는 것은 실무이나 법령·교본 근거가
없어 기본으로 넣지 않고 폼에서 받음. 주석·지식DB 방침도 그에 맞춰 고침.

**소단측구**(B군 종단배수)도 같은 꼴로 옵션을 붙였음 — 소단 위에 놓이는 시설이라 폭·간격이
소단을 따르고, 기울기만 **사면 쪽**(소단과 반대). 소단이 실제로 서면서
`PENDING_TYPE_IDS` 빈 칸이 풀려 연장 수량이 다른 구조물과 같은 길로 나옴.

자체검증 — 「근거 없는 기본값 금지」 시험에 두 타입 12개 기본값을 근거와 함께 등재.
소단측구 연장 시험을 「빈 칸」에서 「연장 100m」로 뒤집음. 전체 544 passed · 18 skipped.
TS 타입 검사·ruff 통과. 화면 확인은 다음 단계.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:29:50 +09:00

399 lines
18 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Page_Persist.ts
* B06 페이지의 **저장 흐름** — 암 경계선 세션 저장소, 편집분 수집, [저장]·[확정].
*
* `B06_Section_UI_Page` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 화면 조립·조정창
* 배선은 페이지에 남고, 여기에는 "세션에 쌓인 조작을 정본으로 내보내는" 경로만 둔다
* (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만).
* ========================================================================== */
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import {
confirmSections,
saveSections,
type CrossSectionPatch,
type SectionContextResponse,
type SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "./B06_Section_Section_Store";
import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state";
import {
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
} from "@util/common_util_cross_berm";
import {
applyStructureAreaRows,
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 { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { L } from "./B06_Section_UI_Page_Common";
/**
* 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체.
*
* **등록표를 거쳐 읽는다**(2026-09-07 고침). 예전에는 옛 키 `b06:rockb:{p}:{r}` 를 날문자열로
* 읽었는데, 쓰는 쪽(`B06_Section_UI_Page.ts` 의 `stateKey("rockb", …)`)은 새 키
* `aislo:draft:rockb:{p}:{r}` 에 쓰고 있어 **읽는 쪽이 늘 빈 값**을 받았다. 그래서
* 계획선을 고쳐 횡단을 다시 계산할 때(`B06_Section_Cross_Refresh`) 사용자가 옮긴 암 경계선이
* 안 실려 나가 토사/암 나눔이 기본값으로 돌아갔다 — 수량이 갈리는 자리다.
* 실측(용화 5601e828, route 169): 경계선을 -0.5 → -0.8m 로 옮기니 새 키에만 `{"0.00":-0.8}`
* 가 쌓이고 옛 키는 아예 없었다.
*/
export function readRockBoundarySession(
projectId: string,
routeId: number,
): Record<string, number> {
const stored = readState<Record<string, number>>("rockb", projectId, routeId);
return stored && typeof stored === "object" ? stored : {};
}
/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */
export interface BermSessionSpec {
width_m: number;
interval_m: number;
slope_deg: number;
}
/**
* 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원.
*
* 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」).
* 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로
* 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다.
*/
export interface BermSpan extends BermSessionSpec {
start_m: number;
end_m: number;
}
/**
* 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록.
*
* 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어
* 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9).
*/
export function readBermSpans(projectId: string, routeId: number): BermSpan[] {
const stored = readState<BermSpan[]>("berm", projectId, routeId);
return Array.isArray(stored) ? stored : [];
}
export function writeBermSpans(projectId: string, routeId: number, spans: BermSpan[]): void {
writeState("berm", spans, projectId, routeId);
}
/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */
export const BERM_TYPE_ID = "berm";
/**
* 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다
* (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리).
*
* 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을
* 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은
* 그 사본만 본다.
*/
export function bermSpansFromStructures(
structures: ReadonlyArray<{
type_id: string;
start_m?: number | null;
end_m?: number | null;
chainage_m?: number | null;
options?: Record<string, string | number>;
}>,
): BermSpan[] {
const spans: BermSpan[] = [];
for (const item of structures) {
if (item.type_id !== BERM_TYPE_ID) continue;
const anchor = item.chainage_m ?? item.start_m ?? null;
const start = item.start_m ?? anchor;
const end = item.end_m ?? anchor;
if (start === null || end === null) continue;
const options = item.options ?? {};
const number = (key: string, fallback: number): number => {
const parsed = Number(options[key]);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
const width = number("width_m", BERM_DEFAULT_WIDTH_M);
const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M);
if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다
spans.push({
start_m: Math.min(start, end),
end_m: Math.max(start, end),
width_m: width,
interval_m: interval,
slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG),
});
}
return spans;
}
/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */
export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null {
const found = spans.find(
(span) =>
chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 &&
chainageM <= Math.max(span.start_m, span.end_m) + 1e-6,
);
return found
? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg }
: null;
}
/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */
export interface RockBoundaryStore {
/** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */
offsets: Map<string, number>;
control: RockBoundaryControl;
/** 세션값 읽기 — 프로젝트·노선이 정해진 뒤에 부른다. */
load: () => void;
/** context(config) 기본값·스텝 반영. */
setDefaults: (defaultOffsetM: number, stepM: number) => void;
}
/**
* 암 경계선 오프셋(측점별) 세션 저장소.
*
* 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시
* cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다.
*/
export function createRockBoundaryStore(options: {
sessionKey: () => string | null;
detail: () => SectionDetailResponse | null;
refreshCard: (chainageM: number) => void;
/** 경계 이동 → 2단계 무릎·단면적 재계산. */
recompute: (chainageM: number) => void;
}): RockBoundaryStore {
const { sessionKey, detail, refreshCard, recompute } = options;
let rockBoundaryDefault = -0.5;
let rockBoundaryStep = 0.1;
/**
* 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서
* 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다.
* DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다.
*/
const clamp = (value: number): number => Math.min(value, 0);
const offsets = new Map<string, number>();
const key = (chainageM: number): string => chainageM.toFixed(2);
function persist(): void {
const storageKey = sessionKey();
if (!storageKey) return;
try {
writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets)));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
}
return {
offsets,
load(): void {
offsets.clear();
const storageKey = sessionKey();
if (!storageKey) return;
try {
const raw = readByKey(storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([chainage, offset]) => {
if (Number.isFinite(offset)) offsets.set(chainage, offset);
});
} catch {
/* 손상된 세션 값은 무시 — 기본값으로 재시작. */
}
},
setDefaults(defaultOffsetM: number, stepM: number): void {
rockBoundaryDefault = defaultOffsetM;
rockBoundaryStep = stepM;
},
control: {
get stepM() {
return rockBoundaryStep;
},
get defaultOffsetM() {
return rockBoundaryDefault;
},
offsetFor: (section) =>
clamp(
offsets.get(key(section.chainage_m)) ??
section.design?.rock_boundary_offset_m ??
rockBoundaryDefault,
),
adjust: (chainageM, deltaM) => {
const stored = detail()?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
)?.design?.rock_boundary_offset_m;
const current = clamp(offsets.get(key(chainageM)) ?? stored ?? rockBoundaryDefault);
offsets.set(key(chainageM), clamp(Math.round((current + deltaM) * 100) / 100));
persist();
refreshCard(chainageM);
recompute(chainageM);
},
reset: (chainageM) => {
offsets.set(key(chainageM), rockBoundaryDefault);
persist();
refreshCard(chainageM);
recompute(chainageM);
},
},
};
}
/** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */
export interface SectionPersistContext {
projectId: string | null;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
context: () => SectionContextResponse | null;
standardValues: () => StandardCrossSection | undefined;
/** 조정창 구간값을 정본으로 내보낸다(세션 → 서버). */
flushCulvertOptions: () => Promise<void>;
/** 측점별 편집분의 출처 묶음 — 세션·제어기에 흩어진 값을 페이지가 모아 준다. */
patchSources: () => CrossPatchSources;
}
/** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */
export function collectSectionEdits(ctx: SectionPersistContext): {
crossPatches: CrossSectionPatch[];
massHaul: Record<string, unknown> | undefined;
} {
const crossPatches = buildCrossPatches(ctx.patchSources());
const detail = ctx.detail();
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산해 싣는다
// (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) {
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
// (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 design[key] !== "number") continue;
patch = patch ?? patchFor(section.chainage_m);
patch[key] = design[key] as number;
}
}
}
// 유토곡선 **정본은 서버가 낸다**(2026-09-06 사용자 확정) — 저장 뒤 `recompute_server_side`
// 가 Node 로 다시 계산해 덮어쓴다. 그래서 여기서는 곡선도 배분도 만들지 않는다.
// 서버가 만들 수 없는 것 하나만 보낸다: 사용자가 끌어 옮긴 balloon 위치(화면값).
const offsets = balloonOffsetsPayload();
return {
crossPatches,
massHaul: offsets ? { balloon_offsets: offsets } : undefined,
};
}
/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */
async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise<void> {
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
await ctx.flushCulvertOptions();
// B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만
// 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다
// (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래
// 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다.
await flushUphillOverrides(projectId).catch(() => undefined);
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
// B05 배수유역도에서 고친 관 목록(추가·이동·삭제)도 여기서 정본에 남긴다 — 예전에는
// B05 [임시저장]에만 실려, B06 에서 저장하면 그 편집이 사라졌다(2026-09-06 대응표).
await flushPendingPipes(projectId).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`배수관 저장에 실패했습니다.${detail}`, "error");
});
await flushPendingStructures(projectId).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`구조물 저장에 실패했습니다.${detail}`, "error");
});
}
/** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */
export async function saveCurrentSections(ctx: SectionPersistContext): Promise<void> {
const routeId = ctx.routeId();
if (!ctx.projectId || routeId === null) return;
showLoadingOverlay();
try {
await flushPendingEdits(ctx, ctx.projectId);
const edits = collectSectionEdits(ctx);
await saveSections(
ctx.projectId,
routeId,
ctx.standardValues(),
edits.crossPatches.length ? edits.crossPatches : undefined,
edits.massHaul,
);
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
// 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다
// (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음).
invalidateSectionDetail(ctx.projectId, routeId);
showToast(L("B06_Profile_Save_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
/** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */
export async function confirmCurrentSections(ctx: SectionPersistContext): Promise<void> {
const routeId = ctx.routeId();
if (!ctx.projectId || routeId === null) return;
showLoadingOverlay();
try {
await flushPendingEdits(ctx, ctx.projectId);
const edits = collectSectionEdits(ctx);
await confirmSections(
ctx.projectId,
routeId,
ctx.standardValues(),
edits.crossPatches.length ? edits.crossPatches : undefined,
edits.massHaul,
);
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(ctx.projectId, WORKFLOW_STEP_ROUTES[4]);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}