/* ============================================================================= * 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 { FORD_BRIDGE_DEFAULT_WIDTH_M, FORD_PAVEMENT_DEFAULT_WIDTH_M, } from "@config/config_frontend"; import type { DetailPipeInput, PipeFacility, PipeSource, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { BOX_SIZE_CUSTOM, BOX_SIZE_PRESETS, createRevetmentFields, createRevetSideGroup, createWingFields, fordSection, 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"; 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) ────────────── // 관종 기본값 = 파형강관(2026-08-17 사용자 확정) — 빈 항목 없이 셋 중 하나. const pipeMaterial = document.createElement("select"); pipeMaterial.replaceChildren( ...["흄관", "VR관", "파형강관"].map((value) => new Option(value, value)), ); pipeMaterial.value = "파형강관"; const pipeDiameter = document.createElement("select"); pipeDiameter.replaceChildren( ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), ); pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17) // 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다 // (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("유입구"); // 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로 // 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시). const inletType = document.createElement("select"); inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label))); inletType.value = INLET_KINDS[0].label; const basinForm = optionalSelect("선택", [ "동물이동형", "□형(기본형)", "돌집수정 ㄴ형", "돌집수정 ㄷ형", ]); // 집수정도 재질·표면처리를 형태 한 축으로 합친다(사용자 지시 1). const basinMaterial = optionalSelect("선택", [ "콘크리트", "조립식 주철맨홀", "돌쌓기(찰)", "돌쌓기(메)", ]); const basinMaterialField = labeled("형태", basinMaterial); // 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자). const basinLength = numberInput("0.1"); basinLength.value = "2"; const basinRows = [ grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))), ]; const height = (): string => String(pipeWallMinHeightM(Number(pipeDiameter.value) / 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 = document.createElement("select"); outletType.replaceChildren(new Option("기슭막이", "기슭막이")); 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("— 안 정함 —", ["양쪽", "좌", "우"]); revetSide.value = "양쪽"; 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.map(([label]) => label), BOX_SIZE_CUSTOM, ]); const boxWidth = numberInput("0.1"); const boxHeight = numberInput("0.1"); 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 = !!preset; } // ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자 // 지시) 수량만 따로 받는다. const fordCount = numberInput("1", "1", "련"); // 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3). // 련은 정수라 소수 자릿수를 두지 않는다. const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0))); // ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시). // 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음). // 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는 // 있으나 계산값(필요 최소 수심) 미만은 되돌린다. const fordWidth = numberInput("0.1"); const fordHeight = numberInput("0.01"); const fordWidthRow = grid( labeled("월류 폭 (m)", stepper(fordWidth, 0.1)), labeled("월류 높이 (m)", stepper(fordHeight, 0.1)), ); // 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정). // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. const fordSlope = numberInput("0.1"); fordSlope.placeholder = "노면 기울기"; // 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(2026-09-14 A3). // 비우면 표가 안 서고 그 까닭이 뜸 — 지어내지 않는다. const fordThickness = numberInput("1"); const fordLength = numberInput("0.1"); const fordSlopeRow = grid( labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)), labeled("포장 두께 (㎝)", stepper(fordThickness, 1, 0)), labeled("포장 길이 노폭 방향 (m)", stepper(fordLength, 0.1)), ); const fordSummary = document.createElement("p"); fordSummary.className = "b05-drainage__facility-note"; /** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */ let designFlowM3s: number | null = null; /** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */ let fordMinDepthM: number | null = null; /** 직전에 자동으로 채워 넣은 필요 수심(m) — 칸이 아직 그 값이면 "자동"으로 보고 * 월류 폭이 바뀔 때마다 계산값을 계속 따라가게 한다. 사용자가 다른 값을 넣으면 * 그때부터 그 값이 이긴다(2026-08-30 사용자: 폭을 바꿔도 높이가 안 따라온다). */ let fordAutoDepthM: number | null = null; function syncFordSummary(): void { const section = designFlowM3s !== null ? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value)) : null; fordMinDepthM = section ? section.depthM : null; if (section) { // 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나, 그보다 작거나, 아직 직전 // 자동값 그대로면 계산값으로 다시 채운다. const min = Number(section.depthM.toFixed(2)); const current = Number.parseFloat(fordHeight.value); const untouched = fordAutoDepthM !== null && Math.abs(current - fordAutoDepthM) < 0.005; if (!Number.isFinite(current) || current < min || untouched) fordHeight.value = min.toFixed(2); fordAutoDepthM = min; } if (designFlowM3s === null) { fordSummary.textContent = "담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다."; return; } const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`; if (!section) { fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`; return; } // 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다. fordSummary.textContent = `${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` + `필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` + `월류 높이는 필요 수심 이상만 입력됩니다.`; } // 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다. fordHeight.addEventListener("change", () => { if (fordMinDepthM !== null) { const min = Number(fordMinDepthM.toFixed(2)); const value = Number.parseFloat(fordHeight.value); if (!Number.isFinite(value) || value < min) { fordHeight.value = min.toFixed(2); fordHeight.classList.add("is-invalid"); window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900); } } emit(); }); // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( pipeRow, wingRow, fordWidthRow, fordSlopeRow, fordRow, fordSummary, 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"; fordRow.hidden = current !== "ford_bridge"; fordWidthRow.hidden = !isFord; // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. fordSlopeRow.hidden = current !== "ford_pavement"; fordSummary.hidden = !isFord; const isRevet = current === "revetment"; revetTopRow.hidden = !isRevet; // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다. revetInlet.root.hidden = !isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.inlet); revetOutlet.root.hidden = !isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.outlet); revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`; revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`; // 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고, // 선택이 풀리면 스스로 사라진다. extraGroup.root.hidden = extraSlot.childElementCount === 0; if (isFord) syncFordSummary(); 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)); } } /** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */ function putFordHeight(options: Record): void { const value = Number.parseFloat(fordHeight.value); if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2)); } 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, fordCount, fordWidth, ].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; designFlowM3s = designFlow ?? null; if (facility === null) { syncVisibility(); return; } const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; const isFord = facility === "ford_bridge"; pipeMaterial.value = text("pipe_kind") || "파형강관"; pipeDiameter.value = text("pipe_diameter_mm") || "1000"; // 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다. const savedType = text("inlet_type") || "기슭막이"; const savedStructure = text("inlet_structure"); inletType.value = ( 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") || "2"; inletRevet.write(options); outletRevet.write(options); // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. // 값이 없으면 첫 프리셋(2.0×2.0)이 기본이다. const [firstLabel, firstWidth, firstHeight] = BOX_SIZE_PRESETS[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) : firstLabel; boxWidth.value = String(hasSaved ? savedWidth : firstWidth); boxHeight.value = String(hasSaved ? savedHeight : firstHeight); wingInFields.write(options); wingOutFields.write(options); fordCount.value = isFord ? text("pipe_count") : ""; fordWidth.value = text("ford_width_m"); fordHeight.value = text("ford_height_m"); // 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다. fordAutoDepthM = null; fordSlope.value = text("ford_slope_pct"); fordThickness.value = text("thickness_cm"); fordLength.value = text("length_m"); revetSide.value = text("side") || "양쪽"; const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); revetOutlet.fields.write(spread); // 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처). if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) { fordWidth.value = String( facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M, ); } syncVisibility(); }, setDesignFlow(designFlow) { designFlowM3s = designFlow ?? null; syncVisibility(); }, readOptions() { const options: Record = {}; if (current === "pipe") { options.pipe_diameter_mm = Number(pipeDiameter.value); options.pipe_kind = pipeMaterial.value; const inletKind = inletKindOf(inletType.value); if (wingWall.value) options.wing_wall_type = wingWall.value; options.inlet_type = inletKind.type; // B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29). 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); } 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") { putNumber(options, "ford_width_m", fordWidth.value); putFordHeight(options); putNumber(options, "ford_slope_pct", fordSlope.value); putNumber(options, "thickness_cm", fordThickness.value); putNumber(options, "length_m", fordLength.value); } else if (current === "ford_bridge") { options.pipe_kind = pipeMaterial.value; options.pipe_diameter_mm = Number(pipeDiameter.value); const count = Number.parseInt(fordCount.value, 10); if (Number.isFinite(count) && count > 0) options.pipe_count = count; putNumber(options, "ford_width_m", fordWidth.value); putFordHeight(options); wingInFields.read(options); wingOutFields.read(options); } else if (current === "revetment") { options.side = revetSide.value; // 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고 // 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제). // 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30). revetInlet.fields.read(options); revetOutlet.fields.read(options); } return options; }, }; }