Files
Aislo/B05_Profile/B05_Profile_UI_Drainage_Facility.ts
T
eomsangdonandClaude Opus 5 7eecfacca3 fix(b05): 기본값 없는 고르기 칸은 「안 정함」이 실제 상태 — 첫 선택지를 미리 고르지 않음(폼 ①)
- 뿌리: 사용자가 고른 적 없는 첫 선택지가 저장돼 확정으로 굳음(옛 2026-08-17 「빈 보기 두지 않음」 걷음)
- 구조물 목록 일반 폼: 기본값 없는 고르기 칸은 늘 빈 보기(필수 「— 선택 —」 · 아니면 「— 안 정함 —」)
- 시설 폼 optionalSelect: 맨 앞 빈 보기(날개벽 「안 놓음」 등) — 기본값이 있는 칸은 다음 차례(②③)
- ORCA 936be972 B05 관 폼: 날개벽 「안 놓음」 빈 값으로 섬 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:14:50 +09:00

700 lines
33 KiB
TypeScript

/* =============================================================================
* 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<string, string | number>;
}
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
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<string, string | number>;
}>,
): 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<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,
) => void;
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */
readOptions: () => Record<string, string | number>;
/** 설계유량만 갱신한다 — 필드 값은 건드리지 않는다. 임시 배치(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<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암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(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<string, string | number>): 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<string, string | number> = {};
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;
},
};
}