fix(b05): 계곡 통과 시설 폼이 기본값을 값으로 안 보냄 — 관종·관경·구조·집수정 길이·날개벽·BOX·월류·설치 측(폼 ③)

- 관종 파형강관·관경 1000 · 유입/유출 구조 기슭막이 · 집수정 길이 2 · 날개벽 있음·1·2·45 · BOX 2.0×2.0 ·
  월류 폭 5/10 · 월류 높이 필요 수심 · 독립 기슭막이 양쪽 — 전부 「안 정함 (제안 …)」·회색 글씨로만
- [제안값 넣기] 누른 때만 빈 칸에 제안값 · 적은 월류 높이가 필요 수심보다 낮으면 여전히 되돌림
- BOX암거 새로 놓을 때 기본값 싣던 자리도 걷음 · 비운 칸은 횡단도가 등록부 기본값으로 그림(저장만 안 함)
- 700줄 한계로 물넘이·세월교 칸 묶음을 `_Drainage_Facility_Ford.ts` 로 뗌
- ORCA 936be972: 관종 「안 정함」 · 저장된 관경 1000 은 그대로 · 날개벽 고친 요청에 관경·날개벽만 실림 · 캐시 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 09:28:06 +09:00
co-authored by Claude Opus 5
parent 0244749d29
commit fc2ecc0f39
4 changed files with 265 additions and 204 deletions
+90 -184
View File
@@ -26,10 +26,6 @@
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import type {
DetailPipeInput,
PipeFacility,
@@ -42,7 +38,6 @@ import {
createRevetmentFields,
createRevetSideGroup,
createWingFields,
fordSection,
grid,
group,
INLET_KINDS,
@@ -60,6 +55,7 @@ import {
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
@@ -191,17 +187,13 @@ export function createFacilityOptionsForm(
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 = "파형강관";
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 (제안 Ø1000) —", ""),
...["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+집수정"]);
@@ -210,11 +202,12 @@ export function createFacilityOptionsForm(
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 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;
inletType.replaceChildren(
new Option("— 안 정함 (제안 기슭막이) —", ""),
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
);
const basinForm = optionalSelect("선택", [
"동물이동형",
"□형(기본형)",
@@ -230,12 +223,12 @@ export function createFacilityOptionsForm(
]);
const basinMaterialField = labeled("형태", basinMaterial);
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1");
basinLength.value = "2";
const basinLength = numberInput("0.1", "0", "제안 2");
const basinRows = [
grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))),
];
const height = (): string => String(pipeWallMinHeightM(Number(pipeDiameter.value) / 1000));
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(찰)",
@@ -257,8 +250,7 @@ export function createFacilityOptionsForm(
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
const outletGroup = group("유출구");
const outletType = document.createElement("select");
outletType.replaceChildren(new Option("기슭막이", "기슭막이"));
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
@@ -284,8 +276,7 @@ export function createFacilityOptionsForm(
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]);
revetSide.value = "양쪽";
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
const revetTopRow = grid(labeled("설치 측", revetSide));
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
@@ -323,12 +314,12 @@ export function createFacilityOptionsForm(
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
const boxSize = optionalSelect("미지정", [
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
...BOX_SIZE_PRESETS.map(([label]) => label),
BOX_SIZE_CUSTOM,
]);
const boxWidth = numberInput("0.1");
const boxHeight = numberInput("0.1");
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight));
const boxWrap = document.createElement("div");
boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow);
@@ -343,106 +334,43 @@ export function createFacilityOptionsForm(
boxWidth.value = String(preset[1]);
boxHeight.value = String(preset[2]);
}
boxCustomRow.hidden = !!preset;
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름).
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
}
// ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자
// 지시) 수량만 따로 받는다.
const fordCount = numberInput("1", "1", "련");
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3).
// 련은 정수라 소수 자릿수를 두지 않는다.
const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0)));
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만).
const ford = createFordFields(() => emit());
// ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(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);
}
}
// [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류).
// 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③).
const suggest = document.createElement("button");
suggest.type = "button";
suggest.className = "b05-structure__suggest";
suggest.textContent = "제안값 넣기";
suggest.addEventListener("click", () => {
if (!pipeMaterial.value) pipeMaterial.value = "파형강관";
if (!pipeDiameter.value) pipeDiameter.value = "1000";
if (!inletType.value) inletType.value = INLET_KINDS[0].label;
if (!outletType.value) outletType.value = "기슭막이";
if (!basinLength.value) basinLength.value = "2";
if (!revetSide.value) revetSide.value = "양쪽";
if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0];
wingInFields.fillSuggested();
wingOutFields.fillSuggested();
if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current);
emit();
});
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
pipeRow,
wingRow,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
ford.widthRow,
ford.slopeRow,
ford.countRow,
ford.summary,
inletGroup.root,
outletGroup.root,
extraGroup.root,
@@ -473,24 +401,23 @@ export function createFacilityOptionsForm(
wingInFields.root.hidden = !hasWing;
wingOutFields.root.hidden = !hasWing;
const isFord = current === "ford_pavement" || current === "ford_bridge";
fordRow.hidden = current !== "ford_bridge";
fordWidthRow.hidden = !isFord;
ford.countRow.hidden = current !== "ford_bridge";
ford.widthRow.hidden = !isFord;
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
fordSlopeRow.hidden = current !== "ford_pavement";
fordSummary.hidden = !isFord;
ford.slopeRow.hidden = current !== "ford_pavement";
ford.summary.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);
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임).
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;
if (isFord) syncFordSummary();
if (isFord) ford.sync();
if (isBox) syncBoxSize();
if (hasWing) {
wingInFields.syncVisibility();
@@ -509,12 +436,6 @@ export function createFacilityOptionsForm(
}
}
/** 월류 높이는 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?.();
@@ -536,8 +457,7 @@ export function createFacilityOptionsForm(
boxHeight,
...wingInFields.inputs,
...wingOutFields.inputs,
fordCount,
fordWidth,
...ford.inputs,
].forEach((input) => input.addEventListener("change", emit));
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
@@ -574,35 +494,36 @@ export function createFacilityOptionsForm(
},
setFacility(facility, options = {}, designFlow = null) {
current = facility;
designFlowM3s = designFlow ?? null;
ford.setDesignFlow(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";
// ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
const savedType = text("inlet_type") || "기슭막이";
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") || "기슭막이";
inletType.value = savedType
? (
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";
basinLength.value = text("inlet_basin_length_m");
inletRevet.write(options);
outletRevet.write(options);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 첫 프리셋(2.0×2.0)이 기본이다.
const [firstLabel, firstWidth, firstHeight] = BOX_SIZE_PRESETS[0];
// 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만).
const savedWidth = Number.parseFloat(text("body_width_m"));
const savedHeight = Number.parseFloat(text("body_height_m"));
const hasSaved =
@@ -616,45 +537,38 @@ export function createFacilityOptionsForm(
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);
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : "";
boxWidth.value = hasSaved ? String(savedWidth) : "";
boxHeight.value = hasSaved ? String(savedHeight) : "";
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") || "양쪽";
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
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;
ford.setDesignFlow(designFlow ?? null);
syncVisibility();
},
readOptions() {
const options: Record<string, string | number> = {};
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
const putPipe = (): void => {
if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value);
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
};
if (current === "pipe") {
options.pipe_diameter_mm = Number(pipeDiameter.value);
options.pipe_kind = pipeMaterial.value;
putPipe();
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 (inletType.value) {
options.inlet_type = inletKind.type;
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;
@@ -662,7 +576,7 @@ export function createFacilityOptionsForm(
} else {
inletRevet.read(options);
}
options.outlet_type = outletType.value;
if (outletType.value) options.outlet_type = outletType.value;
outletRevet.read(options);
} else if (current === "box_culvert") {
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
@@ -671,22 +585,14 @@ export function createFacilityOptionsForm(
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);
ford.read(current, 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);
putPipe();
ford.read(current, options);
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "revetment") {
options.side = revetSide.value;
if (revetSide.value) options.side = revetSide.value;
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
// 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제).
// 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30).
@@ -450,19 +450,18 @@ interface WingFields {
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
fillSuggested: () => void;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴).
* ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect("미지정", ["있음", "없음"]);
install.value = WING_DEFAULTS.install;
const height = numberInput("0.1");
height.value = WING_DEFAULTS.height;
const length = numberInput("0.1");
length.value = WING_DEFAULTS.length;
const angle = numberInput("1");
angle.value = WING_DEFAULTS.angle;
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1));
@@ -481,7 +480,6 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
dimsRow.hidden = off;
}
// 만들자마자 기본값("있음") 상태를 맞춘다.
syncVisibility();
return {
@@ -492,18 +490,25 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
write(options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
install.value = text(keys.install) || WING_DEFAULTS.install;
height.value = text(keys.height) || WING_DEFAULTS.height;
length.value = text(keys.length) || WING_DEFAULTS.length;
angle.value = text(keys.angle) || WING_DEFAULTS.angle;
install.value = text(keys.install);
height.value = text(keys.height);
length.value = text(keys.length);
angle.value = text(keys.angle);
syncVisibility();
},
read(target) {
target[keys.install] = install.value;
if (install.value !== "음") return;
if (install.value) target[keys.install] = install.value;
if (install.value === "음") return;
putNumber(target, keys.height, height.value);
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
fillSuggested() {
if (!install.value) install.value = WING_DEFAULTS.install;
if (!height.value) height.value = WING_DEFAULTS.height;
if (!length.value) length.value = WING_DEFAULTS.length;
if (!angle.value) angle.value = WING_DEFAULTS.angle;
syncVisibility();
},
};
}
@@ -0,0 +1,151 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Ford.ts
* 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄.
*
* 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14).
* ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」):
* 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은
* **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값.
* 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음).
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import {
fordSection,
grid,
labeled,
numberInput,
putNumber,
stepper,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
type FordKind = "ford_pavement" | "ford_bridge";
export interface FordFields {
widthRow: HTMLElement;
slopeRow: HTMLElement;
countRow: HTMLElement;
summary: HTMLElement;
/** 바뀌면 저장 흐름을 울릴 칸(높이는 자기 검사 뒤 스스로 울림). */
inputs: HTMLInputElement[];
setDesignFlow: (designFlow: number | null) => void;
sync: () => void;
write: (kind: FordKind, options: Record<string, string | number>) => void;
read: (kind: FordKind, target: Record<string, string | number>) => void;
fillSuggested: (kind: FordKind) => void;
}
export function createFordFields(emit: () => void): FordFields {
// 세월교 — 구체 내 배관 수량(련). 련은 정수라 소수 자릿수를 두지 않는다.
const count = numberInput("1", "1", "련");
const countRow = grid(labeled("수량 (련)", stepper(count, 1, 0)));
const width = numberInput("0.1");
const height = numberInput("0.01");
const widthRow = grid(
labeled("월류 폭 (m)", stepper(width, 0.1)),
labeled("월류 높이 (m)", stepper(height, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const slope = numberInput("0.1");
slope.placeholder = "노면 기울기";
// 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(A3).
const thickness = numberInput("1");
const length = numberInput("0.1");
const slopeRow = grid(
labeled("바닥 경사 유입→유출 (%)", stepper(slope, 0.1)),
labeled("포장 두께 (㎝)", stepper(thickness, 1, 0)),
labeled("포장 길이 노폭 방향 (m)", stepper(length, 0.1)),
);
const summary = document.createElement("p");
summary.className = "b05-drainage__facility-note";
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let minDepthM: number | null = null;
const suggestedWidth = (kind: FordKind): number =>
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
function sync(): void {
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
if (designFlowM3s === null) {
summary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
summary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
summary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 적은 값이 계산값 미만이면 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다(빈 칸은 그대로 빈 칸).
height.addEventListener("change", () => {
const value = Number.parseFloat(height.value);
if (
minDepthM !== null &&
height.value !== "" &&
(!Number.isFinite(value) || value < minDepthM)
) {
height.value = minDepthM.toFixed(2);
height.classList.add("is-invalid");
window.setTimeout(() => height.classList.remove("is-invalid"), 900);
}
emit();
});
return {
widthRow,
slopeRow,
countRow,
summary,
inputs: [count, width],
setDesignFlow(designFlow) {
designFlowM3s = designFlow ?? null;
},
sync,
write(kind, options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
width.value = text("ford_width_m");
width.dataset.suggested = String(suggestedWidth(kind));
width.placeholder = `제안 ${suggestedWidth(kind)}`;
height.value = text("ford_height_m");
slope.value = text("ford_slope_pct");
thickness.value = text("thickness_cm");
length.value = text("length_m");
},
read(kind, target) {
putNumber(target, "ford_width_m", width.value);
// 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림).
const value = Number.parseFloat(height.value);
if (Number.isFinite(value) && value > 0) target.ford_height_m = Number(value.toFixed(2));
if (kind === "ford_pavement") {
putNumber(target, "ford_slope_pct", slope.value);
putNumber(target, "thickness_cm", thickness.value);
putNumber(target, "length_m", length.value);
return;
}
const pipes = Number.parseInt(count.value, 10);
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
},
fillSuggested(kind) {
if (!width.value) width.value = String(suggestedWidth(kind));
sync();
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
},
};
}
@@ -14,7 +14,6 @@
* ========================================================================== */
import {
defaultOptions,
isB05Option,
structureAnchorM,
type StructureInstance,
@@ -664,9 +663,9 @@ export function createStructuresSection(
syncPlacementFields();
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
renderOptionFields();
// BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을
// 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지).
syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {});
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
syncFacilityForm({});
// 임시 배치도 즉시 진행(A군).
if (type.managed_by) commitTempPipe();
syncButtons();