/* ============================================================================= * 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, createWingFields, fordSection, grid, group, INLET_REVET_KEYS, labeled, numberInput, optionalSelect, OUTLET_REVET_KEYS, putNumber, REVET_COMMON_DEFAULTS, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_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; } /** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */ 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) const pipeRow = grid(labeled("관종", pipeMaterial), labeled("관경", pipeDiameter)); // ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ───────────────── const inletGroup = group("유입구"); // 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로 // 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시). const inletType = document.createElement("select"); inletType.replaceChildren(...["기슭막이", "집수정"].map((value) => new Option(value, value))); inletType.value = "기슭막이"; const basinForm = optionalSelect("선택", [ "동물이동형", "□형(기본형)", "돌집수정 ㄴ형", "돌집수정 ㄷ형", ]); // 집수정도 재질·표면처리를 형태 한 축으로 합친다(사용자 지시 1). const basinMaterial = optionalSelect("선택", [ "콘크리트", "조립식 주철맨홀", "돌쌓기(찰)", "돌쌓기(메)", ]); const basinMaterialField = labeled("형태", basinMaterial); const basinRows = [grid(labeled("형식", basinForm))]; const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(찰)", }); // 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다 // (2026-08-17 사용자 지시 3). inletGroup.body.append( grid(labeled("구조", inletType), basinMaterialField, inletRevet.formField), ...basinRows, ...inletRevet.rows, ); // ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ── const outletGroup = group("유출구"); const outletType = document.createElement("select"); outletType.replaceChildren(new Option("기슭막이", "기슭막이")); const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(메)", }); outletGroup.body.append( grid(labeled("구조", outletType), outletRevet.formField), ...outletRevet.rows, ); // 독립 기슭막이(D4)는 서브폼을 떠났다 — C군과 같은 레지스트리 옵션 방식 // (측점 범위 + 기준측점 전/후, 2026-08-19 사용자 지시 3). // ── 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", "련"); const fordRow = grid(labeled("수량 (련)", fordCount)); // ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시). // 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음). // 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는 // 있으나 계산값(필요 최소 수심) 미만은 되돌린다. const fordWidth = numberInput("0.1"); const fordHeight = numberInput("0.01"); const fordWidthRow = grid( labeled("월류 폭 (m)", fordWidth), labeled("월류 높이 (m)", fordHeight), ); 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; 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); if (!Number.isFinite(current) || current < min) fordHeight.value = min.toFixed(2); } 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(); }); root.append( pipeRow, inletGroup.root, outletGroup.root, boxWrap, wingInFields.root, wingOutFields.root, fordWidthRow, fordRow, fordSummary, ); 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"; inletGroup.root.hidden = !isPipe; outletGroup.root.hidden = !isPipe; boxWrap.hidden = !isBox; wingInFields.root.hidden = !isBox; wingOutFields.root.hidden = !isBox; const isFord = current === "ford_pavement" || current === "ford_bridge"; fordRow.hidden = current !== "ford_bridge"; fordWidthRow.hidden = !isFord; fordSummary.hidden = !isFord; if (isFord) syncFordSummary(); if (isBox) { syncBoxSize(); wingInFields.syncVisibility(); wingOutFields.syncVisibility(); } if (isPipe) { // 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8). const basin = inletType.value === "집수정"; const revet = inletType.value === "기슭막이"; 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, basinForm, basinMaterial].forEach((input) => input.addEventListener("change", emit), ); inletRevet.onChange(emit); outletRevet.onChange(emit); [ pipeMaterial, pipeDiameter, boxSize, boxWidth, boxHeight, ...wingInFields.inputs, ...wingOutFields.inputs, fordCount, fordWidth, ].forEach((input) => input.addEventListener("change", emit)); return { root, 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"; inletType.value = text("inlet_type") || "기슭막이"; outletType.value = text("outlet_type") || "기슭막이"; basinForm.value = text("inlet_basin_form"); basinMaterial.value = text("inlet_basin_material"); 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"); // 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정). if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) { fordWidth.value = facility === "ford_bridge" ? "10" : "5"; } 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; options.inlet_type = inletType.value; if (inletType.value === "집수정") { if (basinForm.value) options.inlet_basin_form = basinForm.value; if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value; } else if (inletType.value === "기슭막이") { 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); } 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); } return options; }, }; }