/* ============================================================================= * B05_Profile_UI_Drainage_Facility.ts * 계곡 통과 시설(배관/BOX암거/물넘이포장/세월교)의 부속 옵션 서브폼 + 시설 보관소. * * 자동 배관이든 수동 배관이든 같은 구조물이다 — 별도 편집 UI를 두지 않고 사이드 * 「구조물 배치」 폼의 옵션 자리에 이 서브폼이 끼워진다(2026-08-17 사용자 지시). * 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치 * 칸이 담당하므로 여기에는 부속 옵션 필드만 있다. * * 배관 부속(PLAN 52-1): 사용자 구조물 표 01 명세 폼(`A00_Common/design/logic/design_structure_pipe`) * — 관 · 관 보호공(유입 · 유출) · 흙막이 추가 · 도수로/돌붙임 컨테이너. 저장 키는 옛 키 그대로 · * 옛 관종 · 날개벽 · 집수정 형식 칸은 화면에서 빠지고 값은 남음(지난 정의 값). * * 보호공(2026-08-17 사용자 지시): 구 "돌붙임 있음/없음 + 표면처리(찰/메)" 두 축을 * `돌붙임(찰)·돌붙임(메)·도수로` 한 축으로 합쳤다. 도수로·산비탈수로(B4)는 유출부에서 * 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 이 선택지로 받는다. 치수는 돌붙임이면 * 면적(㎡), 도수로면 폭(m)이며 둘은 배타다. * 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로 * 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다. * * BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2 · `design_structure_box`) — 본체 규격 · * 유입 · 유출 구조. 저장 키는 body_width_m·body_height_m 숫자 · wing_in·wing_out 그대로. * 날개벽 조각(createWingFields)은 숨김 — B06 조정 행 자리만 BOX · 세월교 명세 유입 · 유출 칸으로 옮겨 붙음. * * 짝: 시설 속성 붙이기 `carryIndices` ↔ 서버 `common_util/common_util_drainage_pipes.py` * `carry_indices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry. * ========================================================================== */ import type { DetailPipeInput, PipeFacility, PipeSource, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createRevetSideGroup, createWingFields, grid, group, INLET_REVET_KEYS, type InletStructureKind, labeled, OUTLET_REVET_KEYS, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { guardChoiceFields, guardSoilCount, outletTiersRow, revetChoiceRow, } from "./B05_Profile_UI_Drainage_Revet_Choice"; import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { createSpecForm, type SpecForm, type SpecLinks, } from "../A00_Common/design/design_structure_form"; import { PIPE_SPEC } from "../A00_Common/design/logic/design_structure_pipe"; import { catalogRow } from "../common_util/common_util_structure_catalog"; import { BOX_SPEC, bodyOf, bodyOptions, boxOptionsIn, } from "../A00_Common/design/logic/design_structure_box"; import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/logic/design_structure_ford"; import { overflowSync } from "../A00_Common/design/design_structure_ford_calc"; import { pipeDrawn, pipeExtraCount, pipeLengthM, pipeLengthText, } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, linkFields, type GroupItems, type SummaryItem, } from "../common_util/common_util_summary_item"; import { FORD_PAVEMENT_SPEC } from "../A00_Common/design/logic/design_structure_ford_pavement"; import { createHiddenOptions } from "./B05_Profile_UI_Drainage_Facility_Hidden"; export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로 /** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을 * 가지면 저장한다. */ export interface FacilityAttributes { facility: PipeFacility; start_m?: number; end_m?: number; options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖) — null/없음 = 미지정. */ summary_item?: SummaryItem | null; /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */ group_items?: GroupItems | null; } /** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */ function hasExtras(attributes: FacilityAttributes): boolean { if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다 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; } /** 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭. * 서버 `common_util_drainage_pipes.CARRY_TOLERANCE_M` 와 짝. */ export const CARRY_TOLERANCE_M = 0.05; /** 관 측점마다 속성을 가져올 원본 번호(못 붙이면 null) — 허용 오차 안 가장 가까운 원본 · * 한 원본은 한 번만. 서버 `carry_indices` 와 짝(거울 시험 test_b05_facility_carry). */ export function carryIndices( targets: readonly number[], sources: readonly number[], ): Array { const free = new Set(sources.keys()); return targets.map((target) => { let best: number | null = null; for (const index of free) { const gap = Math.abs(sources[index] - target); if (gap <= CARRY_TOLERANCE_M && (best === null || gap < Math.abs(sources[best] - target))) best = index; } if (best !== null) free.delete(best); return best; }); } /** 재계산 왕복에서 시설 정보를 잃지 않게 측점으로 되붙이는 보관소 — 붙이기는 `carryIndices` * (서버 `carry_facility_attributes` 와 같은 규칙) · 관을 옮기면 `move` · `follow` 가 속성을 옮김. */ 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; summary_item?: SummaryItem | null; group_items?: GroupItems | null; }>, ): void { entries = points .map((point) => { // 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔 // 값을 이어받았는데, 횡단 보기에서 [해제]한 초안이 응답으로 돌아올 때 옛 지정이 되살아났다. return { chainage: point.chainage_m, attributes: { facility: (point.facility ?? "pipe") as PipeFacility, start_m: point.start_m, end_m: point.end_m, options: point.options, ...linkFields(point), } as FacilityAttributes, }; }) .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; }, /** 옮긴 관의 속성을 새 측점으로 옮긴다(지도에서 끌기). */ move(fromM: number, toM: number): void { const hit = entries.find((entry) => Math.abs(entry.chainage - fromM) <= 0.005); if (hit) hit.chainage = toM; }, /** 관 목록이 통째로 바뀔 때(되돌리기 · 구조물 폼) — 사라진 관 하나 · 새 관 하나면 옮긴 것으로 * 보고 속성을 따라 옮김. 둘 이상이 한꺼번에 바뀌면 옮기지 않음(제자리 관만 속성 유지). */ follow(before: readonly number[], after: readonly number[]): void { const near = (list: readonly number[], value: number): boolean => list.some((other) => Math.abs(other - value) <= CARRY_TOLERANCE_M); const gone = before.filter((value) => !near(after, value)); const added = after.filter((value) => !near(before, value)); if (gone.length === 1 && added.length === 1) this.move(gone[0], added[0]); }, /** 요청 목록에 시설 정보를 되붙인다 — 허용 오차 안의 원본만 · 못 붙인 관은 속성 없음. */ attach(points: ReadonlyArray<{ chainage_m: number; source: PipeSource }>): DetailPipeInput[] { const picks = carryIndices( points.map((point) => point.chainage_m), entries.map((entry) => entry.chainage), ); return points.map((point, index) => { const pick = picks[index]; if (pick === null) return { ...point }; const { summary_item: _s, group_items: _g, ...rest } = entries[pick].attributes; // 미지정(null)은 칸을 안 싣는다 — 서버가 모르는 칸을 거절하는 동안에도 계산이 안 깨짐. return { ...point, ...rest, ...linkFields(entries[pick].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, /** 종류 정의 — 배관 명세 이름 바꿈(`spec_names`) · 그룹 이름(PLAN 52). */ definition?: { spec_names?: Record; groups?: Array<{ id: string; name: string }>; /** 옵션 정의 기본값 — 필수 상자 빈 고르기 칸이 받는 값(54-43). */ options?: Array<{ key: string; default: string | number | null }>; } | null, /** 새 시설(등록부 기본값으로 염) — 세월교 · 물넘이 빈 월류 높이를 유량이 오면 필요 수심으로(65-4). */ fresh?: boolean, ) => void; /** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */ bindLinks: (links: SpecLinks) => void; paintLinks: () => void; /** 고른 관 측점 — 관 길이(자동) 계산에 씀. */ setChainage: (chainageM: number | null) => void; /** 횡단 「추가 기슭막이(단)」이 바뀜 — 배관 흙막이 컨테이너 수를 다시 맞춤(54-1 · B06 이 부름). */ recount: () => void; /** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). 숨긴 칸은 폼이 받은 옛 값 그대로. */ readOptions: () => Record; /** 「구조물 옵션 보이기」에서 끈 레지스트리 옵션 키 — 그 칸을 가린다(값은 옛 값 보존). * `setFacility` 뒤에 부른다. */ setHiddenOptions: (facility: PipeFacility | null, registryKeys: Iterable) => void; /** 설계유량만 갱신한다 — 필드 값은 건드리지 않는다. 임시 배치(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; // ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ── let linkSource: SpecLinks | null = null; let chainage: number | null = null; const specLinks: SpecLinks = { get: () => linkSource?.get() ?? {}, pick: (key) => linkSource?.pick(key), clear: (key) => linkSource?.clear(key), set: (key, item) => linkSource?.set(key, item), }; // 기본값은 새 시설 칸에 실제 값으로만(54-39) — 빈 칸에 「(기본)」 꼬리 · 안내 없음 // 유입 「자동(규칙)」 = 기슭막이 + 유입 형식 auto(옛 값 · 정의 기본) — 그림은 지형 규칙이 집수정 · 흙막이를 // 정함. 사용자가 유입 구조를 고를 때까지 저장도 그대로(흙막이 고름의 형식 revet 을 안 적음) let inletAuto: { structure?: string | number } | null = null; const inletIsAuto = (): boolean => !!inletAuto && pipeForm.state().inlet?.[0]?.structure === "기슭막이"; // 그림 계산은 한 번 그릴 동안 한 번만(칸마다 부름) let drawnMemo: ReturnType | undefined; const drawnNow = (): ReturnType => { if (drawnMemo === undefined) { drawnMemo = pipeDrawn(chainage); queueMicrotask(() => (drawnMemo = undefined)); } return drawnMemo; }; // 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감 const tiersRow = outletTiersRow( () => chainage, () => pipeForm.recount(), (structure) => !structure || structure === "기슭막이", ); const inletChoice = revetChoiceRow( () => chainage, () => pipeForm.refresh(), ); const outletChoice = revetChoiceRow( () => chainage, () => pipeForm.refresh(), ); const pipeForm = createSpecForm(specLinks, { // 관 보호공 [연동] · [경사](54-30) — 유입 · 유출 상자 각각 한 줄(같은 값) · 길이 있는 흙막이만(54-40 · 집수정 · 안 고름은 숨김) fields: (c, values) => c.id === "outlet" ? [tiersRow.paint(values), outletChoice.paint(values.structure !== "기슭막이")] : c.id === "inlet" ? [inletChoice.paint(values.structure !== "기슭막이")] : c.id === "soil_add" // 추가 흙막이 상자도 같은 줄(54-36 · 상자마다 한 줄 · 같은 값) ? [ revetChoiceRow( () => chainage, () => pipeForm.refresh(), ).paint(false), ] : [], shown: (c, item) => { if (c.id === "inlet" && item.id === "structure" && inletIsAuto()) { const drawn = drawnNow(); return drawn ? { value: drawn.basin ? "집수정" : "흙막이", tag: "자동" } : { value: "", tag: "자동(지형 규칙)" }; } // 빈 고르기 칸 = 「선택 안 함」 그대로(54-39 · 기본값 꼬리 없음) return null; }, auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); return length === null ? "횡단 설계 뒤 셈" : pipeLengthText(length); }, // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), onChange: (c, item) => { if (c.id === "inlet" && item.id === "structure") inletAuto = null; if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) inletStructureHandler?.(inletStructureOf()); emit(); // 고른 뒤 그림이 바뀜 — 자동 표시를 새 그림으로 다시 그림(54-13) if (c.id === "inlet") { drawnMemo = undefined; pipeForm.refresh(); } }, }); // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4) const inletSlot = pipeForm.slot("inlet"); const outletSlot = pipeForm.slot("outlet"); /** 유입 보호공 구조 → B06 유입측 형식 값(조정창과 같은 값). */ const inletStructureOf = (): InletStructureKind => { const inlet = pipeForm.state().inlet?.[0] ?? {}; if (inlet.structure === "기슭막이") return "revet"; return inlet.structure === "집수정" && ["I", "L", "U"].includes(inlet.form ?? "") ? (inlet.form as InletStructureKind) : "auto"; }; // ── 세월교 = 사용자 구조물 표 04 명세(PLAN 52-4 · 65장) — 세월교 · 유입 구조 · 유출 구조 · 흙막이 · 도수로/돌붙임 ── // 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) — // 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4) let fordFlow: number | null = null; const fordFlowBox = overflowSync( () => fordForm, "ford", () => fordFlow, emit, ); const guardFields = guardChoiceFields( () => chainage, () => fordForm.refresh(), () => fordForm.recount(), ); // 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1) const fordForm: SpecForm = createSpecForm(specLinks, { onChange: fordFlowBox.onChange, fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)], count: guardSoilCount( () => chainage, () => fordForm.state().outlet?.[0]?.structure, ), }); // ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(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`) — 값은 언제나 그 채널 키로 오간다. // 설치 측 = 필수 칸 — 빈 보기 없음(54-43 · 빈 옛 값 = 양쪽) const revetSide = document.createElement("select"); revetSide.replaceChildren(...["양쪽", "좌", "우"].map((v) => new Option(v, v))); const revetTopRow = grid(labeled("설치 측", revetSide, "side")); 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암거 = 사용자 구조물 표 02 명세(PLAN 52-2 · 65장) — 본체 · 유입 구조 · 유출 구조 · 흙막이 · 도수로/돌붙임 ── const boxForm: SpecForm = createSpecForm(specLinks, { onChange: () => emit(), fields: guardChoiceFields( () => chainage, () => boxForm.refresh(), () => boxForm.recount(), ), count: guardSoilCount( () => chainage, () => boxForm.state().outlet?.[0]?.structure, ), }); // 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음 const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); const wingInHome = wingInFields.slot.parentElement!; const wingOutHome = wingOutFields.slot.parentElement!; const hiddenOptions = createHiddenOptions(root); // ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 · // 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ── const paveFlowBox = overflowSync( () => paveForm, "slope", () => fordFlow, emit, ); const paveForm: SpecForm = createSpecForm(specLinks, { onChange: paveFlowBox.onChange, fields: paveFlowBox.fields, }); // 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`. // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( pipeForm.root, fordForm.root, paveForm.root, extraGroup.root, revetTopRow, revetInlet.root, revetOutlet.root, boxForm.root, wingInFields.root, wingOutFields.root, ); let current: PipeFacility | null = null; /** 서브폼 = 구조물 표 형상(323-7 · 종류 키 대신 · 관 지점 시설은 형상마다 손코딩 폼 하나). */ const shapeOf = (facility: PipeFacility | null) => (facility && catalogRow(facility)?.shape) || null; function syncVisibility(): void { root.hidden = current === null; if (current === null) return; const isPipe = shapeOf(current) === "pipe_set"; const isBox = shapeOf(current) === "box"; const isBridge = shapeOf(current) === "ford"; pipeForm.root.hidden = !isPipe; boxForm.root.hidden = !isBox; fordForm.root.hidden = !isBridge; // 날개벽 칸 = 옛 자리 — BOX(52-2) · 세월교(52-4)는 명세 유입 · 유출 구조 컨테이너 · B06 조정 행 자리만 그 컨테이너 끝으로 const spec = isBox ? boxForm : isBridge ? fordForm : null; wingInFields.root.hidden = true; wingOutFields.root.hidden = true; (spec ? spec.slot("inlet") : wingInHome).append(wingInFields.slot); (spec ? spec.slot("outlet") : wingOutHome).append(wingOutFields.slot); paveForm.root.hidden = shapeOf(current) !== "ford_pavement"; const isRevet = shapeOf(current) === "hidden_revet"; 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; } function emit(): void { syncVisibility(); callbacks.onChange?.(); } revetInlet.fields.onChange(emit); revetOutlet.fields.onChange(emit); revetSide.addEventListener("change", emit); [...wingInFields.inputs, ...wingOutFields.inputs].forEach((input) => input.addEventListener("change", emit), ); /** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */ let inletStructureHandler: ((value: InletStructureKind) => void) | null = null; return { root, inletSlot, outletSlot, extraSlot, revetInletSlot: revetInlet.slot, revetOutletSlot: revetOutlet.slot, wingInSlot: wingInFields.slot, wingOutSlot: wingOutFields.slot, setRevetSideLabels(labels) { revetSideLabels = labels; syncVisibility(); }, inletStructure: inletStructureOf, setInletStructure(value) { // 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움) if (value === "revet") { inletAuto = null; pipeForm.setValue("inlet", "structure", "기슭막이"); } else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정"); if (value !== "revet" && pipeForm.state().inlet?.[0]?.structure === "집수정") pipeForm.setValue("inlet", "form", value === "auto" ? "" : value); syncVisibility(); }, bindLinks(links) { linkSource = links; pipeForm.paintLinks(); boxForm.paintLinks(); fordForm.paintLinks(); paveForm.paintLinks(); }, paintLinks: () => { pipeForm.paintLinks(); boxForm.paintLinks(); fordForm.paintLinks(); paveForm.paintLinks(); }, setChainage(chainageM) { chainage = chainageM; if (shapeOf(current) === "pipe_set") pipeForm.refresh(); }, recount() { if (shapeOf(current) === "pipe_set") pipeForm.recount(); }, onInletStructureChange(handler) { inletStructureHandler = handler; }, setHiddenOptions(facility, registryKeys) { hiddenOptions.set(facility, registryKeys); }, setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) { current = facility; [fordFlowBox, paveFlowBox].forEach((box) => box.arm(fresh)); hiddenOptions.remember(options); fordFlow = designFlow ?? null; if (facility === null) { syncVisibility(); return; } const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); const names = definition?.spec_names ?? {}; // 필수 상자 빈 고르기 칸이 받는 정의 기본값(54-43) — 옛 키는 열 때와 같은 길로 바꿈 const defaults = optionDefaults(definition?.options ?? []); const hidden = hiddenOptionKeys(definition?.options ?? []); // 정의 「표시」 숨김(322-2) if (facility === "pipe") { // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) const { soil_add: _old, ...pipeNames } = names; const kind = options.inlet_structure; inletAuto = options.inlet_type === "기슭막이" && !["revet", "I", "L", "U"].includes(String(kind ?? "")) ? { structure: kind } : null; pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults, hidden); } if (facility === "box_culvert") { // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽 boxForm.write( BOX_SPEC, boxOptionsIn(options), names, groups, boxOptionsIn(defaults), hidden, ); for (const [id, value] of Object.entries(bodyOf(options))) boxForm.setValue("body", id, value); } if (facility === "ford_bridge") fordForm.write( FORD_SPEC, withLegacyGuards(options), names, groups, withLegacyGuards(defaults), hidden, ); if (facility === "ford_pavement") paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults, hidden); if (facility === "ford_bridge") fordFlowBox.fill(); if (facility === "ford_pavement") paveFlowBox.fill(); wingInFields.write(options); wingOutFields.write(options); revetSide.value = text("side") || "양쪽"; const spread = legacyRevetOptions(options); // 빈 형태 = 정의 기본값(54-43 · 필수 칸) for (const key of [INLET_REVET_KEYS.form, OUTLET_REVET_KEYS.form]) if (!spread[key] && defaults.form) spread[key] = defaults.form; revetInlet.fields.write(spread); revetOutlet.fields.write(spread); syncVisibility(); }, setDesignFlow(designFlow) { fordFlow = designFlow ?? null; if (shapeOf(current) === "ford") fordFlowBox.fill(); if (shapeOf(current) === "ford_pavement") paveFlowBox.fill(); syncVisibility(); }, readOptions() { const options: Record = {}; // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). if (shapeOf(current) === "pipe_set") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) // 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13) if (inletIsAuto()) { if (inletAuto!.structure === undefined) delete options.inlet_structure; else options.inlet_structure = inletAuto!.structure; } } else if (shapeOf(current) === "box") { // 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2) Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0])); } else if (shapeOf(current) === "ford_pavement") { Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3) } else if (shapeOf(current) === "ford") { Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4) } else if (shapeOf(current) === "hidden_revet") { 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); } hiddenOptions.restore(options); // 숨긴 칸은 폼이 받은 옛 값 그대로 return options; }, }; }