/* ============================================================================= * B05_Profile_UI_Drainage_Facility.ts * 계곡 통과 시설(배관/BOX암거/물넘이포장/세월교)의 부속 옵션 서브폼 + 시설 보관소. * * 자동 배관이든 수동 배관이든 같은 구조물이다 — 별도 편집 UI를 두지 않고 사이드 * 「구조물 배치」 폼의 옵션 자리에 이 서브폼이 끼워진다(2026-08-17 사용자 지시). * 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치 * 칸이 담당하므로 여기에는 부속 옵션 필드만 있다. * * 배관 부속(2026-08-17 UI 개편): 관종·관경(기본 1000) 한 행 + 유입구·유출구 * 소그룹. 유입구는 집수정/기슭막이 택일이고, 유출구는 기슭막이뿐이다(현재 다른 * 선택지 없음). 기슭막이 조각은 형태·길이·높이·바닥 보호공을 한 벌로 묶어 * 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다. * * 보호공(2026-08-17 사용자 지시): 구 "돌붙임 있음/없음 + 표면처리(찰/메)" 두 축을 * `돌붙임(찰)·돌붙임(메)·도수로` 한 축으로 합쳤다. 도수로·산비탈수로(B4)는 유출부에서 * 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 이 선택지로 받는다. 치수는 돌붙임이면 * 면적(㎡), 도수로면 폭(m)이며 둘은 배타다. * 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로 * 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다. * * BOX암거(2026-08-17 사용자 확정): 본체는 실무 관측 규격 2.0×2.0·3.0×3.0을 프리셋 * 셀렉트로 받고(기본 2.0×2.0), 그 밖의 규격만 "사용자 지정"으로 폭·높이를 직접 * 받는다 — 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로다. * 날개벽은 유입·유출이 같은 조각(createWingFields)을 쓰고 설치 있음·짧은쪽 높이 * 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다. * ========================================================================== */ import type { DetailPipeInput, PipeFacility, PipeSource, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { BOX_SIZE_CUSTOM, BOX_SIZE_PRESETS, createRevetmentFields, createRevetSideGroup, createWingFields, grid, group, INLET_KINDS, INLET_REVET_KEYS, inletKindOf, type InletStructureKind, labeled, numberInput, optionalSelect, OUTLET_REVET_KEYS, putNumber, REVET_COMMON_DEFAULTS, stepper, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets"; import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford"; export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로 /** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을 * 가지면 저장한다. */ export interface FacilityAttributes { facility: PipeFacility; start_m?: number; end_m?: number; options?: Record; } /** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */ function hasExtras(attributes: FacilityAttributes): boolean { if (attributes.facility !== "pipe") return true; if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true; return !!attributes.options && Object.keys(attributes.options).length > 0; } /** 관 이동·재계산 왕복에서 시설 정보를 잃지 않게 chainage 최근접으로 되붙이는 보관소. * 백엔드 `carry_facility_attributes`와 같은 기준(정확 일치 → 최근접)이다. */ export function createFacilityStore() { let entries: Array<{ chainage: number; attributes: FacilityAttributes }> = []; return { /** 서버 응답으로 전체를 다시 채운다 — 응답이 정본이다. */ replaceFromResponse( points: ReadonlyArray<{ chainage_m: number; facility?: PipeFacility; start_m?: number; end_m?: number; options?: Record; }>, ): void { entries = points .map((point) => ({ chainage: point.chainage_m, attributes: { facility: (point.facility ?? "pipe") as PipeFacility, start_m: point.start_m, end_m: point.end_m, options: point.options, }, })) .filter((entry) => hasExtras(entry.attributes)); }, /** 한 지점의 시설을 바꾼다(맨 배관으로 되돌리면 엔트리 삭제). */ set(chainageM: number, attributes: FacilityAttributes | null): void { entries = entries.filter((entry) => Math.abs(entry.chainage - chainageM) > 0.005); if (attributes && hasExtras(attributes)) { entries.push({ chainage: chainageM, attributes }); } }, get(chainageM: number): FacilityAttributes | null { const hit = entries.find((entry) => Math.abs(entry.chainage - chainageM) <= 0.005); return hit ? hit.attributes : null; }, /** 요청 목록에 시설 정보를 되붙인다. 관이 끌려 이동한 뒤에도(정확 일치 없음) * 가장 가까운 엔트리를 한 번씩만 소비해 승계한다. */ attach(points: ReadonlyArray<{ chainage_m: number; source: PipeSource }>): DetailPipeInput[] { const pool = [...entries]; return points.map((point) => { if (!pool.length) return { ...point }; let best = 0; for (let index = 1; index < pool.length; index += 1) { if ( Math.abs(pool[index].chainage - point.chainage_m) < Math.abs(pool[best].chainage - point.chainage_m) ) best = index; } const [entry] = pool.splice(best, 1); return { ...point, ...entry.attributes }; }); }, }; } export type FacilityStore = ReturnType; interface FacilityOptionsFormCallbacks { /** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */ onChange?: () => void; } export interface FacilityOptionsForm { root: HTMLElement; /** 시설 종류에 맞는 옵션 필드를 보여주고 값을 채운다. null이면 통째로 숨긴다. * 독립 기슭막이(D4)는 레지스트리 옵션 방식으로 옮겨 서브폼 대상이 아니다(2026-08-19). */ setFacility: ( facility: PipeFacility | null, options?: Record, /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. 없으면 안내만. */ designFlow?: number | null, ) => void; /** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */ readOptions: () => Record; /** 설계유량만 갱신한다 — 필드 값은 건드리지 않는다. 임시 배치(2026-08-18)의 * 유역 재계산 결과를 입력 중인 폼에 살짝 반영하는 용도. */ setDesignFlow: (designFlow: number | null) => void; /** 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — B06만 채운다(2026-08-29 지시 4). */ inletSlot: HTMLElement; outletSlot: HTMLElement; /** 다단(추가) 기슭막이 행이 옮겨 붙는 자리 — 비면 그룹째 숨는다(2026-08-30 지시 4). */ extraSlot: HTMLElement; /** 독립 기슭막이 좌·우 칸의 이식 자리 — 배관 유입구·유출구 자리와 같은 몫이다. */ revetInletSlot: HTMLElement; revetOutletSlot: HTMLElement; /** 세월교·BOX암거 날개벽 칸의 이식 자리 — 세월교 유입·유출 측벽을 고르면 이 칸이 * 선다(2026-08-30 사용자 지시 1: 배관 측점·기슭막이와 같게). */ wingInSlot: HTMLElement; wingOutSlot: HTMLElement; /** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */ inletStructure: () => InletStructureKind; setInletStructure: (value: InletStructureKind) => void; /** 독립 기슭막이 좌·우 칸의 이름표를 그 측점 방향으로 맞춘다(B06 전용). */ setRevetSideLabels: (labels: { inlet: "좌" | "우"; outlet: "좌" | "우" }) => void; /** 구조가 바뀔 때 B06 유입측 형식 제어에 알린다(B05는 등록하지 않는다). */ onInletStructureChange: (handler: (value: InletStructureKind) => void) => void; } /** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */ export function createFacilityOptionsForm( callbacks: FacilityOptionsFormCallbacks = {}, ): FacilityOptionsForm { const root = document.createElement("div"); root.className = "b05-drainage__facility"; root.hidden = true; // ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ────────────── // 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③). const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]); const pipeDiameter = document.createElement("select"); pipeDiameter.replaceChildren( new Option("— 안 정함 (제안 Ø1000) —", ""), ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), ); // 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다 // (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다. const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]); const pipeRow = grid(labeled("관종", pipeMaterial), labeled("관경", pipeDiameter)); const wingRow = grid(labeled("관보호공 날개벽", wingWall)); // ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ───────────────── const inletGroup = group("유입구"); // 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③). const inletType = document.createElement("select"); inletType.replaceChildren( new Option("— 안 정함 (제안 기슭막이) —", ""), ...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)), ); const basinForm = optionalSelect("선택", [ "동물이동형", "□형(기본형)", "돌집수정 ㄴ형", "돌집수정 ㄷ형", ]); // 집수정도 재질·표면처리를 형태 한 축으로 합친다(사용자 지시 1). const basinMaterial = optionalSelect("선택", [ "콘크리트", "조립식 주철맨홀", "돌쌓기(찰)", "돌쌓기(메)", ]); const basinMaterialField = labeled("형태", basinMaterial); // 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자). const basinLength = numberInput("0.1", "0", "제안 2"); const basinRows = [ grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))), ]; const height = (): string => String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000)); const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(찰)", height, }); // 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다 // (2026-08-17 사용자 지시 3). // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4). // 비어 있으면 아무 자리도 차지하지 않는다. const inletSlot = document.createElement("div"); inletSlot.className = "b05-structure__adjust-slot"; const outletSlot = inletSlot.cloneNode() as HTMLDivElement; inletGroup.body.append( grid(labeled("구조", inletType), basinMaterialField, inletRevet.formField), ...basinRows, ...inletRevet.rows, inletSlot, ); // ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ── const outletGroup = group("유출구"); const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]); const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(메)", height, }); outletGroup.body.append( grid(labeled("구조", outletType), outletRevet.formField), ...outletRevet.rows, outletSlot, ); // ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자 // 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다 // (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다). const extraGroup = group("추가 기슭막이"); const extraSlot = document.createElement("div"); extraSlot.className = "b05-structure__adjust-slot"; extraGroup.body.append(extraSlot); extraGroup.root.hidden = true; // ── 독립 기슭막이(2026-08-28 이관: 배관처럼 pipe_points 관리) ───────────── // 설치 측은 **컨테이너 밖**(배관의 관종·관경과 같은 층), 벽 제원은 좌·우 두 칸으로 // 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다. // 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만 // 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다. const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]); const revetTopRow = grid(labeled("설치 측", revetSide)); const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)"); const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)"); /** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */ let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = { inlet: "좌", outlet: "우", }; /** 옛 전용 키(`form`·`length_m`…)와 한 벌 저장분을 **양쪽 칸**으로 펼쳐 읽는다. */ const legacyRevetOptions = ( options: Record, ): Record => { const merged = { ...options }; const pairs: Array<[string, string, string]> = [ ["outlet_revet_form", "inlet_revet_form", "form"], ["outlet_revet_length_m", "inlet_revet_length_m", "length_m"], ["outlet_revet_height_m", "inlet_revet_height_m", "height_m"], ["outlet_revet_before_m", "inlet_revet_before_m", "before_m"], ["outlet_revet_after_m", "inlet_revet_after_m", "after_m"], ]; for (const [outletKey, inletKey, legacy] of pairs) { if (merged[outletKey] === undefined && options[legacy] !== undefined) { merged[outletKey] = options[legacy]; } // 한쪽만 저장된 옛 자료 — 반대쪽 칸도 같은 값으로 세운다. if (merged[inletKey] === undefined && merged[outletKey] !== undefined) { merged[inletKey] = merged[outletKey]; } if (merged[outletKey] === undefined && merged[inletKey] !== undefined) { merged[outletKey] = merged[inletKey]; } } return merged; }; // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [ ...BOX_SIZE_PRESETS.map(([label]) => label), BOX_SIZE_CUSTOM, ]); const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`); const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`); const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight)); const boxWrap = document.createElement("div"); boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow); const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); /** 고른 프리셋을 폭·높이 칸에 실어 둔다 — 저장도, "사용자 지정"으로 바꿨을 때의 * 출발값도 이 두 칸이 정본이다. 사용자 지정 상태에서는 손대지 않는다. */ function syncBoxSize(): void { const preset = BOX_SIZE_PRESETS.find(([label]) => label === boxSize.value); if (preset) { boxWidth.value = String(preset[1]); boxHeight.value = String(preset[2]); } // 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름). boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM; } // ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만). const ford = createFordFields(() => emit()); // [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류). // 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③). const suggest = document.createElement("button"); suggest.type = "button"; suggest.className = "b05-structure__suggest"; suggest.textContent = "제안값 넣기"; suggest.addEventListener("click", () => { if (!pipeMaterial.value) pipeMaterial.value = "파형강관"; if (!pipeDiameter.value) pipeDiameter.value = "1000"; if (!inletType.value) inletType.value = INLET_KINDS[0].label; if (!outletType.value) outletType.value = "기슭막이"; if (!basinLength.value) basinLength.value = "2"; if (!revetSide.value) revetSide.value = "양쪽"; if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0]; wingInFields.fillSuggested(); wingOutFields.fillSuggested(); if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current); emit(); }); // 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정). const saveNote = document.createElement("p"); saveNote.className = "b05-structure__owner-note"; saveNote.textContent = "[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀"; // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( grid(suggest), saveNote, pipeRow, wingRow, ford.widthRow, ford.slopeRow, ford.countRow, ford.summary, inletGroup.root, outletGroup.root, extraGroup.root, revetTopRow, revetInlet.root, revetOutlet.root, boxWrap, wingInFields.root, wingOutFields.root, ); let current: PipeFacility | null = null; function syncVisibility(): void { root.hidden = current === null; if (current === null) return; const isPipe = current === "pipe"; const isBox = current === "box_culvert"; // 관종·관경은 배수관과 세월교가 공유한다. pipeRow.hidden = !isPipe && current !== "ford_bridge"; // 관보호공 날개벽 원단위는 **배수관 Ø800·Ø1000** 것뿐이다 — 다른 시설에서는 숨긴다. wingRow.hidden = !isPipe; inletGroup.root.hidden = !isPipe; outletGroup.root.hidden = !isPipe; boxWrap.hidden = !isBox; // 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정). const hasWing = isBox || current === "ford_bridge"; wingInFields.root.hidden = !hasWing; wingOutFields.root.hidden = !hasWing; const isFord = current === "ford_pavement" || current === "ford_bridge"; ford.countRow.hidden = current !== "ford_bridge"; ford.widthRow.hidden = !isFord; // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. ford.slopeRow.hidden = current !== "ford_pavement"; ford.summary.hidden = !isFord; const isRevet = current === "revetment"; revetTopRow.hidden = !isRevet; // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임). const side = revetSide.value || "양쪽"; revetInlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.inlet); revetOutlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.outlet); revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`; revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`; // 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고, // 선택이 풀리면 스스로 사라진다. extraGroup.root.hidden = extraSlot.childElementCount === 0; if (isFord) ford.sync(); if (isBox) syncBoxSize(); if (hasWing) { wingInFields.syncVisibility(); wingOutFields.syncVisibility(); } if (isPipe) { // 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8). // 병합 목록이라 판정은 라벨이 아니라 매핑된 정본 종류로 한다(2026-08-29). const kind = inletKindOf(inletType.value); const basin = kind.type === "집수정"; const revet = kind.type === "기슭막이"; basinRows.forEach((row) => (row.hidden = !basin)); basinMaterialField.hidden = !basin; inletRevet.formField.hidden = !revet; inletRevet.rows.forEach((row) => (row.hidden = !revet)); } } function emit(): void { syncVisibility(); callbacks.onChange?.(); } [inletType, outletType, wingWall, basinForm, basinMaterial, basinLength].forEach((input) => input.addEventListener("change", emit), ); inletRevet.onChange(emit); outletRevet.onChange(emit); revetInlet.fields.onChange(emit); revetOutlet.fields.onChange(emit); revetSide.addEventListener("change", emit); [ pipeMaterial, pipeDiameter, boxSize, boxWidth, boxHeight, ...wingInFields.inputs, ...wingOutFields.inputs, ...ford.inputs, ].forEach((input) => input.addEventListener("change", emit)); /** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */ let inletStructureHandler: ((value: InletStructureKind) => void) | null = null; inletType.addEventListener("change", () => inletStructureHandler?.(inletKindOf(inletType.value).structure), ); return { root, inletSlot, outletSlot, extraSlot, revetInletSlot: revetInlet.slot, revetOutletSlot: revetOutlet.slot, wingInSlot: wingInFields.slot, wingOutSlot: wingOutFields.slot, setRevetSideLabels(labels) { revetSideLabels = labels; syncVisibility(); }, inletStructure: () => inletKindOf(inletType.value).structure, setInletStructure(value) { // 지금 고른 정본 종류(기슭막이/집수정)는 지키고 형식만 맞춘다. const type = inletKindOf(inletType.value).type; const hit = INLET_KINDS.find((kind) => kind.type === type && kind.structure === value) ?? INLET_KINDS.find((kind) => kind.structure === value); if (hit) inletType.value = hit.label; syncVisibility(); }, onInletStructureChange(handler) { inletStructureHandler = handler; }, setFacility(facility, options = {}, designFlow = null) { current = facility; ford.setDesignFlow(designFlow ?? null); if (facility === null) { syncVisibility(); return; } const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; // ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③). pipeMaterial.value = text("pipe_kind"); pipeDiameter.value = text("pipe_diameter_mm"); // 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다. const savedType = text("inlet_type"); const savedStructure = text("inlet_structure"); inletType.value = savedType ? ( INLET_KINDS.find( (kind) => kind.type === savedType && (!savedStructure || kind.structure === savedStructure), ) ?? inletKindOf(savedType) ).label : ""; outletType.value = text("outlet_type"); wingWall.value = text("wing_wall_type"); basinForm.value = text("inlet_basin_form"); basinMaterial.value = text("inlet_basin_material"); basinLength.value = text("inlet_basin_length_m"); inletRevet.write(options); outletRevet.write(options); // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. // 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만). const savedWidth = Number.parseFloat(text("body_width_m")); const savedHeight = Number.parseFloat(text("body_height_m")); const hasSaved = Number.isFinite(savedWidth) && savedWidth > 0 && Number.isFinite(savedHeight) && savedHeight > 0; const preset = hasSaved ? BOX_SIZE_PRESETS.find( ([, width, height]) => Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05, ) : undefined; boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : ""; boxWidth.value = hasSaved ? String(savedWidth) : ""; boxHeight.value = hasSaved ? String(savedHeight) : ""; wingInFields.write(options); wingOutFields.write(options); if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options); revetSide.value = text("side"); const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); revetOutlet.fields.write(spread); syncVisibility(); }, setDesignFlow(designFlow) { ford.setDesignFlow(designFlow ?? null); syncVisibility(); }, readOptions() { const options: Record = {}; // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). const putPipe = (): void => { if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value); if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value; }; if (current === "pipe") { putPipe(); const inletKind = inletKindOf(inletType.value); if (wingWall.value) options.wing_wall_type = wingWall.value; // B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29). if (inletType.value) { options.inlet_type = inletKind.type; options.inlet_structure = inletKind.structure; } if (inletKind.type === "집수정") { if (basinForm.value) options.inlet_basin_form = basinForm.value; if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value; putNumber(options, "inlet_basin_length_m", basinLength.value); } else { inletRevet.read(options); } if (outletType.value) options.outlet_type = outletType.value; outletRevet.read(options); } else if (current === "box_culvert") { // 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다. putNumber(options, "body_width_m", boxWidth.value); putNumber(options, "body_height_m", boxHeight.value); wingInFields.read(options); wingOutFields.read(options); } else if (current === "ford_pavement") { ford.read(current, options); } else if (current === "ford_bridge") { putPipe(); ford.read(current, options); wingInFields.read(options); wingOutFields.read(options); } else if (current === "revetment") { if (revetSide.value) options.side = revetSide.value; // 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고 // 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제). // 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30). revetInlet.fields.read(options); revetOutlet.fields.read(options); } return options; }, }; }