/* ============================================================================= * B06_Section_UI_Page_Ford_Controls.ts * 세월교 측벽·BOX암거 구체 조작값 제어 — 측점 제어기 * (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25). * * 데이터 흐름은 집수정과 같다: 세션 사본 + 캐시 `design.ford_adjust`에 함께 실어 * 3D(코리도)가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본(`pipe_points`)이 * 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다. * ========================================================================== */ import { boxSpanM, pipeDiameterM, wingSlabExtendM } from "@util/common_util_culvert_sets"; import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford"; import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box"; import type { BoxAdjust, BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; /** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */ type WingPatch = Partial<{ installed: boolean; height_m: number; length_m: number; angle_deg: number; }>; /** 조작값을 관 지점 옵션 키로 옮긴다 — 값이 온 항목만 싣는다. */ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record { const prefix = role === "inlet" ? "wing_in" : "wing_out"; const options: Record = {}; if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음"; if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m; if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m; if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg; return options; } /** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */ function wingPatchFrom(patch: Record, prefix: string): WingPatch { const num = (key: string): number | undefined => { if (patch[key] === undefined) return undefined; const value = Number(patch[key]); return Number.isFinite(value) ? value : undefined; }; const wing: WingPatch = {}; // 설치 값은 폼이 "있음"/"없음" 문자열로 낸다(백엔드 `_wing_spec`과 같은 규약). if (patch[prefix] !== undefined) wing.installed = patch[prefix] !== "없음"; const heightM = num(`${prefix}_height_m`); if (heightM !== undefined) wing.height_m = heightM; const lengthM = num(`${prefix}_length_m`); if (lengthM !== undefined) wing.length_m = lengthM; const angleDeg = num(`${prefix}_angle_deg`); if (angleDeg !== undefined) wing.angle_deg = angleDeg; return wing; } export interface FordControlDeps { /** 세션 보관 키(프로젝트·노선별). 없으면 세션에 담지 않는다. */ sessionKey: () => string | null; sectionAt: (chainageM: number) => CrossSection | undefined; patchCachedDesign: (chainageM: number, patch: Partial) => void; refreshCard: (chainageM: number) => void; /** 관경·수량·날개벽 제원을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */ queuePipeOptions: (chainageM: number, patch: Record) => void; round1: (value: number) => number; clampMove: (value: number) => number; } export interface FordControls { control: FordControl; /** 확정 payload용 — 측점별 조작값. */ byChainage: () => Map; /** 노선이 바뀔 때 세션 값을 다시 읽는다. */ load: () => void; } export function createFordControls(deps: FordControlDeps): FordControls { const { round1, clampMove, sectionAt, patchCachedDesign } = deps; /* ── 세월교 측벽 조작(2026-08-25 사용자) ─────────────────────────────── * 축은 집수정과 같다(높이·좌우·상하 대각). 값은 세션 사본 + 캐시 design에 함께 * 실어 3D가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본이 원천이라 * 배수관 구간값과 같은 저장기(`pipe_points` 되쓰기)로 보낸다. */ const fordAdjustments = new Map(); const fordSelections = new Map(); const fordSessionKey = (): string | null => deps.sessionKey(); function loadFordAdjustments(): void { fordAdjustments.clear(); const key = fordSessionKey(); if (!key) return; try { const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record< string, FordAdjust >; Object.entries(parsed).forEach(([chainage, value]) => fordAdjustments.set(chainage, { inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.inlet }, outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.outlet }, }), ); } catch { /* 손상된 세션 값은 기본값으로 대체. */ } } function persistFordAdjustments(): void { const key = fordSessionKey(); if (key) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(fordAdjustments))); } const fordAdjustAt = (chainageM: number): FordAdjust => { const stored = sectionAt(chainageM)?.design?.ford_adjust; return ( fordAdjustments.get(chainageM.toFixed(2)) ?? { inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) }, outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) }, } ); }; const writeFord = (chainageM: number, next: FordAdjust): void => { fordAdjustments.set(chainageM.toFixed(2), next); persistFordAdjustments(); patchCachedDesign(chainageM, { ford_adjust: next }); deps.refreshCard(chainageM); }; const fordControl: FordControl = { adjustFor: fordAdjustAt, update: (chainageM, role, patch) => { const current = fordAdjustAt(chainageM); const wall: FordWallAdjust = { ...current[role], ...patch }; writeFord(chainageM, { ...current, [role]: { // 높이는 0.1m 눈금, 하한(관경+토피)은 기하가 다시 잡는다. heightM: wall.heightM === null ? null : Math.max(round1(wall.heightM), 0), lateralM: Math.max(0, clampMove(wall.lateralM)), slopeM: clampMove(wall.slopeM), }, }); }, reset: (chainageM, role) => { const current = fordAdjustAt(chainageM); writeFord(chainageM, { ...current, [role]: { ...DEFAULT_FORD_WALL_ADJUST } }); }, setPipe: (chainageM, patch) => { const spec = sectionAt(chainageM)?.ford; if (spec) { // 캐시를 먼저 고쳐 즉시 반영한다 — 저장은 늦게 묶어서 간다. if (patch.pipe_diameter_mm) spec.diameter_m = pipeDiameterM(patch.pipe_diameter_mm); if (patch.pipe_count) spec.pipe_count = patch.pipe_count; if (patch.pipe_kind) spec.pipe_kind = patch.pipe_kind; // 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리. if (patch.ford_width_m) spec.span_m = patch.ford_width_m; } deps.queuePipeOptions(chainageM, patch); deps.refreshCard(chainageM); }, setWing: (chainageM, role, patch) => { const spec = sectionAt(chainageM)?.ford; const wing = role === "inlet" ? spec?.wing_in : spec?.wing_out; if (wing) { // 캐시 먼저 — 바닥판 연장(길이 × cos각)도 백엔드 산식 그대로 다시 계산한다. if (patch.installed !== undefined) wing.installed = patch.installed; if (patch.height_m !== undefined) wing.height_m = patch.height_m; if (patch.length_m !== undefined) wing.length_m = patch.length_m; if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg; wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg); } deps.queuePipeOptions(chainageM, wingOptions(role, patch)); deps.refreshCard(chainageM); }, selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null, select: (chainageM, role) => { fordSelections.set(chainageM.toFixed(2), role); }, }; return { control: fordControl, byChainage: () => { const result = new Map(); fordAdjustments.forEach((adjust, key) => { const value = Number(key); if (Number.isFinite(value)) result.set(value, adjust); }); return result; }, load: loadFordAdjustments, }; } /** * 좌측 「구조물 배치」 폼이 낸 세월교 옵션을 **조정창 제어기**로 흘려보낸다 * (2026-08-30 사용자 확정: 프론트는 구조물 배치의 상세 UI, 로직은 조정창 것을 쓴다). * * 폼 옵션 키(`pipe_*`·`ford_width_m`·`wing_in*`·`wing_out*`)를 제어기 인자로 옮기기만 * 한다 — 캐시 반영·바닥판 연장 재계산·정본 예약·카드 갱신은 제어기 안에 이미 있다. * 값이 온 항목만 보낸다(빈 patch는 부르지 않는다). */ export function applyFordFormOptions( control: FordControl, chainageM: number, patch: Record, ): void { const num = (key: string): number | undefined => { if (patch[key] === undefined) return undefined; const value = Number(patch[key]); return Number.isFinite(value) ? value : undefined; }; const pipe: Parameters[1] = {}; const diameterMm = num("pipe_diameter_mm"); if (diameterMm !== undefined) pipe.pipe_diameter_mm = diameterMm; const count = num("pipe_count"); if (count !== undefined) pipe.pipe_count = count; if (typeof patch.pipe_kind === "string") pipe.pipe_kind = patch.pipe_kind; const widthM = num("ford_width_m"); if (widthM !== undefined) pipe.ford_width_m = widthM; if (Object.keys(pipe).length) control.setPipe(chainageM, pipe); applyWingFormOptions(control, chainageM, patch); } /** 폼이 낸 날개벽 옵션을 제어기로 보낸다 — 세월교·BOX암거가 같은 조각을 쓴다. */ function applyWingFormOptions( control: { setWing: (chainageM: number, role: "inlet" | "outlet", patch: WingPatch) => void; }, chainageM: number, patch: Record, ): void { for (const [role, prefix] of [ ["inlet", "wing_in"], ["outlet", "wing_out"], ] as const) { const wing = wingPatchFrom(patch, prefix); if (Object.keys(wing).length) control.setWing(chainageM, role, wing); } } /** * 좌측 「구조물 배치」 폼이 낸 BOX암거 옵션을 조정창 제어기로 흘려보낸다 — 세월교와 * 같은 규칙이다(2026-08-30 사용자). 본체 규격·날개벽만 폼이 정하고, 구체 길이·표고는 * 조정창 축이라 여기 오지 않는다. */ export function applyBoxFormOptions( control: BoxControl, chainageM: number, patch: Record, ): void { const num = (key: string): number | undefined => { if (patch[key] === undefined) return undefined; const value = Number(patch[key]); return Number.isFinite(value) ? value : undefined; }; const body: Parameters[1] = {}; const widthM = num("body_width_m"); if (widthM !== undefined) body.body_width_m = widthM; const heightM = num("body_height_m"); if (heightM !== undefined) body.body_height_m = heightM; if (Object.keys(body).length) control.setBody(chainageM, body); applyWingFormOptions(control, chainageM, patch); } /** * BOX암거 구체 조작값 제어 — 세월교와 같은 흐름(세션 사본 + 캐시 `design.box_adjust`). * 좌·우 끝을 따로 잡으며 길이는 바깥으로만, 표고는 양방향으로 움직인다. */ export function createBoxControls(deps: FordControlDeps): { control: BoxControl; byChainage: () => Map; load: () => void; } { const { round1, clampMove, sectionAt, patchCachedDesign } = deps; const adjustments = new Map(); const selections = new Map(); function load(): void { adjustments.clear(); const key = deps.sessionKey(); if (!key) return; try { const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record< string, BoxAdjust >; Object.entries(parsed).forEach(([chainage, value]) => adjustments.set(chainage, { left: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.left }, right: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.right }, }), ); } catch { /* 손상된 세션 값은 기본값으로 대체. */ } } function persist(): void { const key = deps.sessionKey(); if (key) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(adjustments))); } const adjustAt = (chainageM: number): BoxAdjust => { const stored = sectionAt(chainageM)?.design?.box_adjust; return ( adjustments.get(chainageM.toFixed(2)) ?? { left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.left ?? {}) }, right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.right ?? {}) }, } ); }; const write = (chainageM: number, next: BoxAdjust): void => { adjustments.set(chainageM.toFixed(2), next); persist(); patchCachedDesign(chainageM, { box_adjust: next }); deps.refreshCard(chainageM); }; const control: BoxControl = { adjustFor: adjustAt, update: (chainageM, role, patch) => { const current = adjustAt(chainageM); const side: BoxSideAdjust = { ...current[role], ...patch }; write(chainageM, { ...current, [role]: { // 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다. lengthM: Math.max(0, round1(side.lengthM)), riseM: clampMove(side.riseM), }, }); }, reset: (chainageM, role) => { const current = adjustAt(chainageM); write(chainageM, { ...current, [role]: { ...DEFAULT_BOX_SIDE_ADJUST } }); }, setBody: (chainageM, patch) => { const spec = sectionAt(chainageM)?.box; if (spec) { // 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다. if (patch.body_width_m) { spec.inner_width_m = patch.body_width_m; spec.span_m = boxSpanM(patch.body_width_m); } if (patch.body_height_m) spec.inner_height_m = patch.body_height_m; } deps.queuePipeOptions(chainageM, patch as Record); deps.refreshCard(chainageM); }, setWing: (chainageM, role, patch) => { const spec = sectionAt(chainageM)?.box; const wing = role === "inlet" ? spec?.wing_in : spec?.wing_out; if (wing) { if (patch.installed !== undefined) wing.installed = patch.installed; if (patch.height_m !== undefined) wing.height_m = patch.height_m; if (patch.length_m !== undefined) wing.length_m = patch.length_m; if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg; wing.slab_extend_m = wing.installed ? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0) : 0; } deps.queuePipeOptions(chainageM, wingOptions(role, patch)); deps.refreshCard(chainageM); }, selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null, select: (chainageM, role) => { selections.set(chainageM.toFixed(2), role); }, }; return { control, byChainage: () => { const result = new Map(); adjustments.forEach((adjust, key) => { const value = Number(key); if (Number.isFinite(value)) result.set(value, adjust); }); return result; }, load, }; } /** 세월교·BOX암거 제어를 한 번에 만든다 — 측점 제어기 쪽 배선을 줄인다(700줄 제한). */ export function createBodyControls( common: Omit, sessionKey: (kind: "fordadjust" | "boxadjust") => string | null, ): { ford: ReturnType; box: ReturnType; } { return { ford: createFordControls({ ...common, sessionKey: () => sessionKey("fordadjust") }), box: createBoxControls({ ...common, sessionKey: () => sessionKey("boxadjust") }), }; }