Files
Aislo/B05_Profile/B05_Profile_UI_Drainage_Facility.ts
T
eomsangdonandClaude Opus 5.5 11b782543e merge: main_laptop_1 → sub_laptop_7 52-3 — 충돌 다섯 = 양쪽 다 살림
- 명세 목록 = 박스암거 · 세월교 · 물넘이 포장 · 노면공 · 기타 전부
- B05 시설 폼 = BOX 명세 폼(메인) + 물넘이 포장 명세 폼(52-3) · 옛 BOX 프리셋 · 월류 칸 뺌
- 병합 키 = BOX 명세 키 + 물넘이 포장 경사각도 · 도수로/돌붙임 머리
- 표 채우기 컨테이너 = 물넘이 포장 도수로/돌붙임 + 기타 상세 내용
- 구조물정의 = 양쪽 그룹(box_culvert · ford_pavement) · 판 12 · 직전 메인 판 11 은 이력 판_11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-06 00:25:11 +09:00

550 lines
27 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Drainage_Facility.ts
* 계곡 통과 시설(배관/BOX암거/물넘이포장/세월교)의 부속 옵션 서브폼 + 시설 보관소.
*
* 자동 배관이든 수동 배관이든 같은 구조물이다 — 별도 편집 UI를 두지 않고 사이드
* 「구조물 배치」 폼의 옵션 자리에 이 서브폼이 끼워진다(2026-08-17 사용자 지시).
* 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치
* 칸이 담당하므로 여기에는 부속 옵션 필드만 있다.
*
* 배관 부속(PLAN 52-1): 사용자 구조물 표 01 명세 폼(`A00_Common/design/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,
optionalSelect,
OUTLET_REVET_KEYS,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import {
BOX_SPEC,
bodyOf,
bodyOptions,
boxOptionsIn,
} from "../A00_Common/design/design_structure_box";
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import { guardHeightHint, pipeLengthM } 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/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<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(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<number | null> {
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<string, string | number>;
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<typeof createFacilityStore>;
interface FacilityOptionsFormCallbacks {
/** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */
onChange?: () => void;
}
export interface FacilityOptionsForm {
root: HTMLElement;
/** 시설 종류에 맞는 옵션 필드를 보여주고 값을 채운다. null이면 통째로 숨긴다.
* 독립 기슭막이(D4)는 레지스트리 옵션 방식으로 옮겨 서브폼 대상이 아니다(2026-08-19). */
setFacility: (
facility: PipeFacility | null,
options?: Record<string, string | number>,
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. 없으면 안내만. */
designFlow?: number | null,
/** 종류 정의 — 배관 명세 이름 바꿈(`spec_names`) · 그룹 이름(PLAN 52). */
definition?: {
spec_names?: Record<string, string>;
groups?: Array<{ id: string; name: string }>;
} | null,
) => void;
/** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */
bindLinks: (links: SpecLinks) => void;
paintLinks: () => void;
/** 고른 관 측점 — 관 길이(자동) 계산에 씀. */
setChainage: (chainageM: number | null) => void;
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). 숨긴 칸은 폼이 받은 옛 값 그대로. */
readOptions: () => Record<string, string | number>;
/** 「구조물 옵션 보이기」에서 끈 레지스트리 옵션 키 — 그 칸을 가린다(값은 옛 값 보존).
* `setFacility` 뒤에 부른다. */
setHiddenOptions: (facility: PipeFacility | null, registryKeys: Iterable<string>) => 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),
};
const pipeForm = createSpecForm(specLinks, {
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
},
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
onChange: (c, item) => {
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
},
});
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(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) — 세월교 · 유입 구조 · 유출 구조 ──
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`)
let fordFlow: number | null = null;
const fordNeed = (): number | null =>
fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? ""));
const fordForm = createSpecForm(specLinks, {
hint: (c, item, state) =>
c.id === "ford" && item.id === "height"
? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? ""))
: "",
onChange: (c, item) => {
const need = c.id === "ford" ? fordNeed() : null;
const height = fordForm.state().ford?.[0]?.height ?? "";
const low = height !== "" && !(Number(height) >= (need ?? 0));
if (need !== null && (item.id === "width" || (item.id === "height" && low)))
fordForm.setValue("ford", "height", need.toFixed(2));
emit();
},
});
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(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, "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<string, string | number>,
): Record<string, string | number> => {
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) — 본체 · 유입 구조 · 유출 구조 ──
const boxForm = createSpecForm(specLinks, { onChange: () => emit() });
// 날개벽 칸 — 세월교 · 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) — 경사각도 · 도수로/돌붙임 ·
// 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
const paveForm = createSpecForm(specLinks, { onChange: () => 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(
saveNote,
pipeForm.root,
fordForm.root,
paveForm.root,
extraGroup.root,
revetTopRow,
revetInlet.root,
revetOutlet.root,
boxForm.root,
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";
const isBridge = current === "ford_bridge";
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 = current !== "ford_pavement";
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;
}
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") 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 (current === "pipe") pipeForm.refresh();
},
onInletStructureChange(handler) {
inletStructureHandler = handler;
},
setHiddenOptions(facility, registryKeys) {
hiddenOptions.set(facility, registryKeys);
},
setFacility(facility, options = {}, designFlow = null, definition = null) {
current = facility;
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 ?? {};
if (facility === "pipe") pipeForm.write(PIPE_SPEC, options, names, groups);
if (facility === "box_culvert") {
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups);
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);
if (facility === "ford_pavement") paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups);
wingInFields.write(options);
wingOutFields.write(options);
revetSide.value = text("side");
const spread = legacyRevetOptions(options);
revetInlet.fields.write(spread);
revetOutlet.fields.write(spread);
syncVisibility();
},
setDesignFlow(designFlow) {
fordFlow = designFlow ?? null;
if (current === "ford_bridge") fordForm.refresh(); // 높이 안내
syncVisibility();
},
readOptions() {
const options: Record<string, string | number> = {};
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
if (current === "pipe") {
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
} else if (current === "box_culvert") {
// 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2)
Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0]));
} else if (current === "ford_pavement") {
Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3)
} else if (current === "ford_bridge") {
Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4)
} 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);
}
hiddenOptions.restore(options); // 숨긴 칸은 폼이 받은 옛 값 그대로
return options;
},
};
}