/* ============================================================================= * B06_Section_UI_Page_Patches.ts * 확정·임시저장이 보내는 **측점별 편집분(cross_patches) 조립** — 페이지 * (`_UI_Page.ts`)에서 700줄 제한으로 분리했다(2026-08-25). * * 세션·제어기에 흩어져 있던 조작값을 누가거리 하나로 합친다. 값은 만들지 않고 * 모으기만 한다 — 판정·한계는 각 제어기가 이미 끝냈다. * ========================================================================== */ import type { CrossSectionPatch, StoredWallAdjust, StoredWallSpan } from "../B06_Section_Api_Fetch"; import type { BasinAdjust } from "./B06_Section_Cross_Culvert_Types"; import type { FordAdjust } from "./B06_Section_Cross_Ford_Geom"; import type { BoxAdjust } from "./B06_Section_Cross_Box_Geom"; import type { InletStructureChoice } from "./B06_Section_Cross_Culvert"; export interface CrossPatchSources { /** 암 경계선 오프셋(누가거리 문자열 키). */ rockOffsets: Map; /** 측점별 암 절토 경사비(1:n 의 n). **0 은 「표준값을 씀」**(되돌리기)이다. */ cutSlopeRatios: Map; /** 측점 개별 표시 반폭(m). */ stationWidths: Map; inletStructures: Map; basinAdjustments: Map; /** 벽 역할 → 값 · null = 되돌림(바탕 dict 에서 그 벽을 뺌 · 445 M2). */ revetAdjusts: Map>; /** 세션에 있는 쪽만(445 H3 — 없는 쪽은 바탕 값 그대로). */ extraCounts: Map>; /** 다단 단별 구간값(2026-08-29) — 키는 벽 키("extra0"…/"bextra0"…). */ extraSpans: Map>; fordAdjusts: Map; boxAdjusts: Map; linkFlags: Map; } /** 누가거리별 패치를 합쳐 한 배열로 돌려준다. */ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch[] { const patchByChainage = new Map(); const patchFor = (chainageM: number): CrossSectionPatch => { const existing = patchByChainage.get(chainageM); if (existing) return existing; const created: CrossSectionPatch = { chainage_m: chainageM }; patchByChainage.set(chainageM, created); return created; }; sources.rockOffsets.forEach((offset, chainage) => { patchFor(Number(chainage)).rock_boundary_offset_m = offset; }); // 측점별 암 절토 경사(2026-09-07) — 0 이면 「표준값을 씀」이라 정본의 옛 값을 덮어 지운다. sources.cutSlopeRatios.forEach((ratio, chainage) => { patchFor(Number(chainage)).cut_slope_ratio_user = ratio; }); // 개별 표시 반폭(2026-08-06) — design에 병합돼 재접근 시 유지된다. sources.stationWidths.forEach((width, chainage) => { patchFor(Number(chainage)).display_half_width_m = width; }); sources.inletStructures.forEach((structure, chainage) => { patchFor(Number(chainage)).inlet_structure = structure; }); sources.basinAdjustments.forEach((adjust, chainage) => { patchFor(Number(chainage)).basin_adjust = adjust; }); // 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 실어 확정한다(2026-08-24). // 되돌림(null) · 한쪽 단 수는 바탕과 합칠 때 풀림(`mergeCrossPatches`) — 혼자 나가면 null 걷고 빈 쪽 0 sources.revetAdjusts.forEach((adjusts, chainage) => { patchFor(chainage).revet_adjust = adjusts as Record; }); sources.extraCounts.forEach((counts, chainage) => { patchFor(chainage).extra_wall_counts = counts as { outlet: number; basin: number }; }); // 다단 단별 구간값 — 세션에서 만진 값을 정본에 싣는다(2026-08-29 사용자). sources.extraSpans.forEach((spans, chainage) => { patchFor(chainage).extra_spans = spans; }); // 세월교 측벽 조작값(2026-08-25) — 배수관 값과 같은 자리에 실어 3D·재계산이 잇는다. sources.fordAdjusts.forEach((adjust, chainage) => { patchFor(chainage).ford_adjust = adjust; }); sources.boxAdjusts.forEach((adjust, chainage) => { patchFor(chainage).box_adjust = adjust; }); // 연동 해제(측점별)·종단경사 반영(전체 공통) — 같은 체계로 정본에 싣는다. sources.linkFlags.forEach((flags, chainage) => { if (flags.detached !== undefined) patchFor(chainage).revet_link_detached = flags.detached; if (flags.followGrade !== undefined) patchFor(chainage).revet_follow_grade = flags.followGrade; }); return [...patchByChainage.values()]; } /** 벽 · 단 · 쪽 열쇠로 담는 칸 — 세션은 만진 열쇠만 들고 있어 바탕(캐시 설계)과 **열쇠마다** 합침(445 H3). */ const DICT_KEYS = new Set(["revet_adjust", "extra_spans", "extra_wall_counts"]); type Loose = Record; const isDict = (value: unknown): value is Loose => !!value && typeof value === "object" && !Array.isArray(value); /** 안쪽 null = 되돌림 — 그 열쇠를 뺌(서버는 dict 를 통째 갈아 끼움). */ const withoutNulls = (dict: Loose): Loose => Object.fromEntries(Object.entries(dict).filter(([, value]) => value !== null)); /** * 저장 patch = 바탕(캐시 설계의 사용자 칸 · 측점마다 통째) 위에 세션 고침을 얹음(445 H2 · H3 · M2). * 같은 측점 = 누가거리 0.01 m 글. 벽 · 단 · 쪽 dict 는 열쇠마다 합치고 안쪽 null 은 뺌 · 나머지 칸은 세션이 이김. * 바탕에 없는 측점의 세션 고침도 그대로 나감(안쪽 null 걷고 · 단 수 빈 쪽 0). */ export function mergeCrossPatches( base: readonly CrossSectionPatch[], overlay: readonly CrossSectionPatch[], ): CrossSectionPatch[] { const out = new Map(); const join = (patch: CrossSectionPatch): void => { const at = patch.chainage_m.toFixed(2); const into = out.get(at) ?? { chainage_m: patch.chainage_m }; for (const [key, value] of Object.entries(patch)) { if (key === "chainage_m" || value === undefined) continue; into[key] = DICT_KEYS.has(key) && isDict(value) ? withoutNulls({ ...(isDict(into[key]) ? into[key] : {}), ...value }) : value; } out.set(at, into); }; base.forEach(join); overlay.forEach(join); for (const patch of out.values()) { const counts = patch.extra_wall_counts; if (isDict(counts)) patch.extra_wall_counts = { outlet: 0, basin: 0, ...counts }; } return [...out.values()] as unknown as CrossSectionPatch[]; }