Files
Aislo/B06_Section/B06_Section_UI_Page_Persist.ts
T
eomsangdonandClaude Opus 5.5 cfdcf0aed7 fix(472-5 남음): 암선 · 암 경사 · B05 상단측 메모리 맵 걷음 · 노선 저장 뒤 옛 노선 칸 · hold 비움
- 암 경계 · 암 절토 경사 저장소(`B06_Section_UI_Page_Persist`) = 세션 칸이 주인 — 읽을 때마다 측점 열쇠 칸(`readCells`) · 고칠 때 지금 칸을 읽어 그 측점만 바꿔 씀(`putCell`) · 메모리 맵 · load · 갈아끼움 다시 읽기(keyedStores · reloadKeyedStores) 걷음 · ↶ 뒤 다음 조작이 되돌림을 덮던 흠(N5) 고침
- 저장 재료 = `offsets()` · `ratios()`(새 Map) · ↶ ↷ 가 rockb · cutslope 칸을 바꾸면 조정창과 같은 onRestore 로 카드 · 면적 다시
- B05 상단측 메모리 맵(uphillOverrides · load/saveUphillOverrides) 걷음 — 읽는 곳 없이 저장 뒤 비우기만 하던 사본 · 값 주인 = 세션 칸 `uphill` · 상단측 차례 = 기본 조각 그대로
- 노선 바뀐 [저장] 성공 뒤(페이지 다시 세운 뒤) 옛 노선 번호의 노선 범위 초안(`clearRouteDrafts`) · `route-draft-hold` 비움 · 넘김 규칙 그대로 · 같은 번호면 노선 칸 안 지움 · 실패면 그대로
- 시험 test_472_5r_rock_owner 7(옛 코드 빨강 5) · 옛 단언 고침(176_3 · 271 · 443 상단측 조각 · 344_3 저장소 load)
- 위키 암선 · 암 경사 · ↶ ↷ 규칙 · 노선 저장 뒤 정리 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:13:30 +09:00

422 lines
21 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Page_Persist.ts
* B06 페이지의 **저장 흐름** — 암 경계선 세션 저장소, 편집분 수집, [저장]·[확정].
*
* `B06_Section_UI_Page` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 화면 조립·조정창
* 배선은 페이지에 남고, 여기에는 "세션에 쌓인 조작을 정본으로 내보내는" 경로만 둔다
* (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만).
* ========================================================================== */
import {
sectionConfirmBody,
warnSectionSave,
type CrossSectionPatch,
type SectionContextResponse,
type SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import type { DesignViewSave } from "../A00_Common/design/design_shared";
import { buildCrossPatches, type CrossPatchSources } from "./logic/B06_Section_UI_Page_Patches";
import {
clearState,
readByKey,
readState,
writeByKey,
writeState,
} from "../A00_Common/b_page_state";
import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options";
import {
crossDesignChoices,
readBermDepths,
readShoulderExtends,
} from "./B06_Section_Cross_Design_Session";
import { readStructureNotes } from "./B06_Section_UI_Cross_Detail";
import { readStructureNoteCads } from "./B06_Section_UI_Cross_Detail_Cad";
import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table";
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
// 소단 구간(C4) — common_util 로 옮김 · 부르는 곳 그대로(다시 내보냄).
import type { BermSpan } from "@util/common_util_berm_spans";
export {
bermSpansFromStructures,
bermSpecAt,
isBermType,
type BermSessionSpec,
type BermSpan,
} from "@util/common_util_berm_spans";
import { readKeyed, writeKeyed, type KeyedState } from "./B06_Section_Station_Keys";
/**
* 세션에 쌓인 암 경계선 오프셋(측점키 → 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> {
// 세션 열쇠 = 측점 열쇠(344-3) — 부르는 쪽(다시 셈 · 서버 폴백)에는 누가거리(0.01 m 글)로
return Object.fromEntries(readKeyed<number>("rockb", projectId, routeId));
}
/** 측점별 암 절토 경사 세션값 — 누가거리(0.01 m 글) → 경사비(0 = 표준으로 되돌림 · 344-3). */
export function readCutSlopeSession(projectId: string, routeId: number): Record<string, number> {
return Object.fromEntries(readKeyed<number>("cutslope", projectId, routeId));
}
/** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */
function keyedScope(storageKey: string): { projectId: string; routeId: number } | null {
const [projectId, route] = storageKey.split(":").slice(-2);
const routeId = Number(route);
return projectId && Number.isInteger(routeId) ? { projectId, routeId } : null;
}
/** 저장소 값 읽기 — 측점 열쇠 칸이면 누가거리로 옮겨 · 아니면 옛 꼴 그대로. */
function loadKeyed(name: KeyedState, storageKey: string): Array<[string, number]> {
const scope = keyedScope(storageKey);
if (scope) return [...readKeyed<number>(name, scope.projectId, scope.routeId)];
const raw = readByKey(storageKey);
return raw ? Object.entries(JSON.parse(raw) as Record<string, number>) : [];
}
/** 저장소 값 쓰기 — 측점 열쇠로(노선 밖 옛 고침은 그대로 둠). */
function persistKeyed(name: KeyedState, storageKey: string, values: Map<string, number>): void {
const scope = keyedScope(storageKey);
if (scope) writeKeyed(name, scope.projectId, scope.routeId, values);
else writeByKey(storageKey, JSON.stringify(Object.fromEntries(values)));
}
/** 지금 세션 칸(읽을 때마다 · 메모리 사본 없음 · 472-5) — ↶ ↷ · 측점 갈아끼움이 칸을 되써도 다음 조작이 옛 값으로
* 안 덮음(N5). 손상된 값은 빈 칸(기본값으로). */
function readCells(
name: KeyedState,
sessionKey: () => string | null,
accept: (value: number) => boolean,
): Map<string, number> {
const values = new Map<string, number>();
const storageKey = sessionKey();
if (!storageKey) return values;
try {
for (const [chainage, value] of loadKeyed(name, storageKey))
if (Number.isFinite(value) && accept(value)) values.set(chainage, value);
} catch {
/* 손상된 세션 값은 무시 — 기본값으로 재시작. */
}
return values;
}
/** 한 측점 값 바꿈 — 지금 칸을 읽어 그 칸만 바꿔 씀. 세션 저장 실패(용량 초과 등)는 그 조작만 안 남음. */
function putCell(
name: KeyedState,
sessionKey: () => string | null,
values: Map<string, number>,
chainageM: number,
value: number,
): void {
const storageKey = sessionKey();
if (!storageKey) return;
values.set(chainageM.toFixed(2), value);
try {
persistKeyed(name, storageKey, values);
} catch {
/* 무시 */
}
}
/**
* 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록.
*
* 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어
* 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 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);
}
/** 암 경계선 오프셋 저장소 — 값(세션 칸)과 조정창 제어기를 함께 낸다. */
export interface RockBoundaryStore {
/** 지금 세션 칸 — 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 재료(새 Map). */
offsets: () => Map<string, number>;
control: RockBoundaryControl;
/** 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 = (): Map<string, number> => readCells("rockb", sessionKey, () => true);
const key = (chainageM: number): string => chainageM.toFixed(2);
const put = (chainageM: number, value: number): void =>
putCell("rockb", sessionKey, offsets(), chainageM, value);
return {
offsets,
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);
put(chainageM, clamp(Math.round((current + deltaM) * 100) / 100));
refreshCard(chainageM);
recompute(chainageM);
},
reset: (chainageM) => {
put(chainageM, rockBoundaryDefault);
refreshCard(chainageM);
recompute(chainageM);
},
},
};
}
/** 측점별 암 절토 경사비 저장소 — 값(세션 칸)과 카드 제어기를 함께 낸다(2026-09-07). */
export interface CutSlopeStore {
/** 지금 세션 칸 — 측점키(누가거리 2자리) → 경사비(1:n 의 n). `buildCrossPatches` 재료(새 Map).
* **0 은 「표준값을 씀」**이다 — 되돌리기(↺)가 남기는 값이라, 저장분에 옛 사용자 값이
* 남아 있어도 0 이 그것을 덮어 표준으로 돌려놓는다(지우기만 하면 옛 값이 되살아난다). */
ratios: () => Map<string, number>;
control: CutSlopeControl;
}
/**
* 측점 하나만 다른 **암 절토 경사** 세션 저장소.
*
* 암 경계선 저장소와 같은 꼴이다 — 세션(sessionStorage)에 쌓고 [저장]·[확정]에서
* `cross_patches`(`design.cut_slope_ratio_user`)로 정본에 나간다.
*
* ⚠ 값을 넣으면 **재계산을 부른다**. 경사는 나르는 값이 아니라 기하 입력이라, 값만 바꾸고
* 다시 계산하지 않으면 설계선은 옛 경사로 남고 숫자만 새것이 된다.
*/
export function createCutSlopeStore(options: {
sessionKey: () => string | null;
/** 지금 표준 횡단면 설정(세션 편집값 우선) — 되돌릴 자리를 여기서 읽는다. */
standard: () => StandardCrossSection | null;
refreshCard: (chainageM: number) => void;
/** 경사 변경 → 사면·소단·단면적 재계산. */
recompute: (chainageM: number) => void;
}): CutSlopeStore {
const { sessionKey, standard, refreshCard, recompute } = options;
// 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다.
const ratios = (): Map<string, number> =>
readCells("cutslope", sessionKey, (ratio) => ratio >= 0);
const key = (chainageM: number): string => chainageM.toFixed(2);
const put = (chainageM: number, value: number): void =>
putCell("cutslope", sessionKey, ratios(), chainageM, value);
/** 이 측점이 쓰는 표준 경사비 — 지반 프리셋(암/토사)의 값. */
const standardRatio = (section: { design?: { geometry_preset?: string } | null }): number => {
const preset = section.design?.geometry_preset === "soil" ? "soil" : "rock";
const group = standard()?.[preset] as { cut_slope_ratio?: number } | undefined;
const value = group?.cut_slope_ratio;
return typeof value === "number" && value > 0 ? value : 0.4;
};
return {
ratios,
control: {
standardRatioFor: (section) => standardRatio(section),
ratioFor: (section) => {
const session = ratios().get(key(section.chainage_m));
if (session === 0) return standardRatio(section); // 되돌림 — 저장분보다 세션이 먼저다.
if (typeof session === "number" && session > 0) return session;
const stored = (section.design as { cut_slope_ratio_user?: number } | undefined)
?.cut_slope_ratio_user;
if (typeof stored === "number" && stored > 0) return stored;
return standardRatio(section);
},
set: (chainageM, ratio) => {
// null = 되돌리기. 지우지 않고 0 을 남겨야 저장분에 있던 옛 값까지 표준으로 돌아간다.
if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) put(chainageM, 0);
else put(chainageM, Math.round(ratio * 10000) / 10000);
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;
/** 저장 직전 **전 측점을 다시 계산**한다(표준단면·계획선 최신값으로).
* ⚠ 없으면 옛 면적이 그대로 실려 나간다 — 2026-09-09 실측: 표준단면 암 측구 상폭을
* 0.69 → 0.9 로 고친 뒤 저장했더니 정본 한 줄에 **새 측구 기하(0.9) + 옛 절토 면적
* (0.69 기준 3.33㎡)** 이 섞여 남았다. 만진 측점만 새 값으로 서던 자리다. */
reconcileDesigns: () => Promise<void>;
/** 저장 뒤 새 상세로 카드 · 그림 한 번 다시(보일 때만 · 323-9). */
redraw: () => Promise<void>;
}
/** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */
export function collectSectionEdits(ctx: SectionPersistContext): {
crossPatches: CrossSectionPatch[];
massHaul: Record<string, unknown> | undefined;
} {
const crossPatches = buildCrossPatches(ctx.patchSources());
// 측점별 patch — 없는 측점은 새로 만듦
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;
// ⚠ 「자동」(null)은 patch 로 못 보낸다 — 서버 병합이 최상위 null 을 걷는다. 카드 토글은
// 자동으로 되돌아가지 않으므로(켬/끔 둘 뿐) 지금은 손해가 없다(2026-09-09).
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
});
// 측점 구조물 글(138-4 · [상세] 모달) — 세션 초안 그대로(빈 글 = 지움)
const routeId = ctx.routeId();
if (ctx.projectId && routeId !== null)
readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => {
patchFor(chainage).structure_note = note;
});
// 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 세션 초안 그대로(빈 엔티티 = 비움)
if (ctx.projectId && routeId !== null)
readStructureNoteCads(ctx.projectId, routeId).forEach((cad, chainage) => {
patchFor(chainage).structure_note_cad = cad;
});
// [상세] 표 손질(179-4) — 세션 초안 그대로 · 값은 서버가 그릴 때 다시 셈
if (ctx.projectId && routeId !== null)
readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => {
patchFor(chainage).detail_table = table;
});
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 서버가 [저장] 때 이 값으로 설계선을 다시 냄
readShoulderExtends(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => {
const patch = patchFor(chainage);
if (sides.left !== undefined) patch.shoulder_extend_left_m = sides.left;
if (sides.right !== undefined) patch.shoulder_extend_right_m = sides.right;
});
// 소단 쪽마다 옮긴 깊이(194-1) — 서버가 [저장] 때 구조물 정본의 소단 구간 + 이 값으로 다시 냄
readBermDepths(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => {
const patch = patchFor(chainage);
if (sides.left !== undefined) patch.berm_depth_left_m = sides.left;
if (sides.right !== undefined) patch.berm_depth_right_m = sides.right;
});
// 관 종류를 바꾼 측점(318-4) — 서버가 옛 종류 조정값을 먼저 비우고 이 patch 값만 얹음
for (const at of kindChangedChainages(ctx.projectId)) patchFor(at).structure_reset = true;
// 면적 · 관 길이는 안 보냄 — 서버가 [저장] · [확정] 때 정본으로 다시 셈(388-2)
// 유토곡선 **정본은 서버가 낸다**(2026-09-06 사용자 확정) — 저장 뒤 `recompute_server_side`
// 가 Node 로 다시 계산해 덮어쓴다. 그래서 여기서는 곡선도 배분도 만들지 않는다.
// 서버가 만들 수 없는 것 하나만 보낸다: 사용자가 끌어 옮긴 balloon 위치(화면값).
const offsets = balloonOffsetsPayload();
return {
crossPatches,
massHaul: offsets ? { balloon_offsets: offsets } : undefined,
};
}
/**
* [저장] · [확정]에 횡단 보기가 보태는 몫(계획서 24-2a · 24-3b) — 차례는 `saveDesign` 이 정하고
* 한 벌(`design_shared`)이 두 보기 몫을 모아 보낸다. 여기서는 횡단 보기 몫만 준다: 저장 앞 전 측점
* 재계산 · 조정창 구간값 · 횡단 본문 · 뒤처리(알림 · 다음 단계는 한 벌이 한다).
*/
export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave {
return {
routeId: ctx.routeId,
// ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤
// 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측).
prepare: () => ctx.reconcileDesigns(),
// 조정창 구간값은 조정창 저장기가 낸다 — 실패하면 알리고 저장을 멈춘다(예전 그대로).
// 관 옵션(조정창 구간값) = 기본 조각(세션 `culvertopt`) — 서버가 저장된 관 위에 얹음(472-6)
crossPart: async () => {
const routeId = ctx.routeId();
if (routeId === null || !ctx.projectId) return null;
const projectId = ctx.projectId;
const edits = collectSectionEdits(ctx);
// 바탕 = 캐시 설계의 사용자 칸(측점마다 통째 · 옮긴 측점은 지반 등 입력까지) + 세션 고침(445 H2 · H3 · M2)
// 가게는 늦게 불러옴(가게 → 다시 셈 → 이 파일 순환 없음)
const { cachedCrossPatches } = await import("./B06_Section_Section_Store");
const patches = cachedCrossPatches(projectId, routeId, edits.crossPatches);
return {
body: sectionConfirmBody(
ctx.standardValues(),
patches.length ? patches : undefined,
edits.massHaul,
),
after: (response) => {
clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4)
if (response) warnSectionSave(response); // 버린 고침 · 측점 맞춤(445 M5)
},
};
},
done: async () => {
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
// 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다
// (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음).
// 저장 도중 바뀜 신호는 안 그렸음 — 비운 캐시로 서버 새 상세를 받아 한 번 다시(323-9)
// 캐시 비우기 = 한 벌(`design_shared`)이 저장 끝에 한 번(416 · 두 보기가 같은 새 객체를 받게)
await ctx.redraw();
},
};
}