Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
545 lines
26 KiB
TypeScript
545 lines
26 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_UI_Drainage_Facility_Fields.ts
|
||
* 계곡 통과 시설 부속 옵션 서브폼의 입력 조각 — 라벨·숫자칸·격자·소그룹 상자와
|
||
* 기슭막이 한 벌(형태·길이·높이·보호공), BOX암거 본체 규격·날개벽 조각.
|
||
*
|
||
* 서브폼 본체(B05_Profile_UI_Drainage_Facility)가 700줄 한계에 닿아 분리했다.
|
||
* 조각은 전부 순수 DOM 생성·값 읽기라 본체의 시설 전환 흐름과 독립적이다 —
|
||
* 유입·유출·독립 기슭막이(D4)가 같은 조각을 키만 바꿔 쓰는 구조도 그대로다.
|
||
* ========================================================================== */
|
||
|
||
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
|
||
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
|
||
|
||
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
|
||
|
||
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
|
||
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
|
||
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
|
||
|
||
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
|
||
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
|
||
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
|
||
* 유입측 형식(`inlet_structure`). */
|
||
export const INLET_KINDS: ReadonlyArray<{
|
||
label: string;
|
||
type: "기슭막이" | "집수정";
|
||
structure: InletStructureKind;
|
||
}> = [
|
||
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
|
||
{ label: "집수정", type: "집수정", structure: "auto" },
|
||
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
|
||
{ label: "집수정 I형", type: "집수정", structure: "I" },
|
||
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
|
||
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
|
||
];
|
||
|
||
export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
|
||
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
|
||
|
||
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
|
||
|
||
/** 월류 단면 한 벌 — 설계유량을 흘리는 데 **필요한** 수심·단면적과 그때의 유속. */
|
||
export interface FordSection {
|
||
depthM: number;
|
||
areaM2: number;
|
||
velocityMs: number;
|
||
}
|
||
|
||
/** 물넘이(노면 개수로)의 개략 단면 — 설계유량과 월류 폭으로 필요 수심을 되짚는다.
|
||
*
|
||
* 광폭 직사각형(B ≫ h)으로 보면 Manning을 h에 대해 바로 풀 수 있다:
|
||
* Qd = B·h·(1/n)·h^(2/3)·√S → h = (Qd·n / (B·√S))^(3/5)
|
||
* 다만 실제 동수반경은 R = A/P (P = B + 2h)로 h보다 작아, 광폭 근사값을 그대로 쓰면
|
||
* 통수능이 설계유량에 조금 못 미친다. 그래서 그 값을 출발점 삼아 실제 R로 몇 번
|
||
* 되짚어 **B·h·V(h) = Qd**가 되는 h로 수렴시킨다.
|
||
*
|
||
* n·S는 실무 수리계산서 역산값이다(`config_frontend.FORD_MANNING_N`·`FORD_SLOPE`,
|
||
* 2026-08-17 확인). 폭 B는 지식DB에 수치 근거가 없어 사용자가 넣는다 — 프로그램이
|
||
* 지어내지 않는다. 유량이나 폭이 없으면 null(표시할 값 없음)이다.
|
||
*
|
||
* ⚠ 여기서 내는 수심은 **필요 최소 수심**이다. 실무 수리계산서가 적는 수심은 설계자가
|
||
* 가정한 값이고(울진1: B=20m·h=0.27m → 통수능 41.2㎥/s ≫ Qd 2.54㎥/s), 그 시트는
|
||
* 가정 단면의 통수능이 설계유량을 넘는지 검증하는 방식이다. 둘은 의미가 다르다. */
|
||
export function fordSection(designFlowM3s: number | null, widthM: number): FordSection | null {
|
||
if (!designFlowM3s || designFlowM3s <= 0 || !Number.isFinite(widthM) || widthM <= 0) return null;
|
||
const slopeRoot = Math.sqrt(FORD_SLOPE);
|
||
const velocityAt = (depth: number): number =>
|
||
(1 / FORD_MANNING_N) * Math.pow((widthM * depth) / (widthM + 2 * depth), 2 / 3) * slopeRoot;
|
||
// 광폭 근사 출발값 — 실제 R보다 크게 잡히므로 아래 반복이 조금씩 키워 수렴한다.
|
||
let depth = Math.pow((designFlowM3s * FORD_MANNING_N) / (widthM * slopeRoot), 3 / 5);
|
||
for (let index = 0; index < 20; index += 1) {
|
||
const next = designFlowM3s / (widthM * velocityAt(depth));
|
||
if (Math.abs(next - depth) < 1e-6) {
|
||
depth = next;
|
||
break;
|
||
}
|
||
depth = next;
|
||
}
|
||
return { depthM: depth, areaM2: widthM * depth, velocityMs: velocityAt(depth) };
|
||
}
|
||
|
||
/* ── 폼 조각 ────────────────────────────────────────────────────────────── */
|
||
|
||
export function labeled(text: string, input: HTMLElement): HTMLLabelElement {
|
||
const wrapper = document.createElement("label");
|
||
wrapper.className = "b05-route__field";
|
||
const caption = document.createElement("span");
|
||
caption.textContent = text;
|
||
wrapper.append(caption, input);
|
||
// 묶음(스텝 등)을 담으면 라벨의 암묵 대상이 첫 버튼이 된다 — 안쪽 입력을 명시로
|
||
// 가리켜 라벨 hover·클릭이 버튼으로 새지 않게 한다(2026-08-29 사용자 보고).
|
||
const inner = input.querySelector?.("input, select, textarea") as HTMLElement | null;
|
||
if (inner?.id) wrapper.htmlFor = inner.id;
|
||
return wrapper;
|
||
}
|
||
|
||
export function numberInput(step: string, min = "0", placeholder = ""): HTMLInputElement {
|
||
const input = document.createElement("input");
|
||
input.type = "number";
|
||
input.step = step;
|
||
input.min = min;
|
||
if (placeholder) input.placeholder = placeholder;
|
||
return input;
|
||
}
|
||
|
||
/**
|
||
* 숫자 칸 + [-][+] 한 묶음(2026-08-29 사용자 지시 2) — 조정창처럼 눈금으로도
|
||
* 고칠 수 있게 한다. 래퍼 여백은 0이라 기존 격자 레이아웃을 흔들지 않는다.
|
||
* 버튼은 값을 고친 뒤 input·change를 모두 울려 기존 리스너(연동·저장)가 그대로 탄다.
|
||
*/
|
||
/** 스텝 묶음 입력에 붙일 일련번호 — 라벨이 가리킬 대상을 명시하는 데 쓴다. */
|
||
let stepperSeq = 0;
|
||
|
||
/** `decimals` — 표시 자릿수. 련·각도처럼 정수로 세는 칸은 0을 준다(2026-08-30 사용자:
|
||
* 세월교 상세도 같은 양식으로 맞추되 "1.0련"·"45.0°"로 보이면 안 된다). */
|
||
export function stepper(input: HTMLInputElement, stepM: number, decimals = 1): HTMLElement {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "b05-structure__stepper";
|
||
// 라벨이 이 입력을 가리키게 id를 붙인다. 없으면 <label>의 암묵 대상이 묶음의
|
||
// **첫 버튼(-)**이 되어, 라벨·입력 위에 커서만 올려도 - 버튼이 눌린 것처럼
|
||
// 보이고 라벨을 누르면 값이 줄어든다(2026-08-29 사용자 보고).
|
||
if (!input.id) input.id = `b05-step-${(stepperSeq += 1)}`;
|
||
const nudge = (delta: number) => (): void => {
|
||
const current = Number.parseFloat(input.value);
|
||
const min = Number.parseFloat(input.min);
|
||
const floor = Number.isFinite(min) ? min : 0;
|
||
const grain = 10 ** decimals;
|
||
const next = Math.max(
|
||
floor,
|
||
Math.round(((Number.isFinite(current) ? current : 0) + delta) * grain) / grain,
|
||
);
|
||
input.value = next.toFixed(decimals);
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||
};
|
||
const button = (label: string, delta: number): HTMLButtonElement => {
|
||
const element = document.createElement("button");
|
||
element.type = "button";
|
||
element.className = "b05-structure__step";
|
||
element.textContent = label;
|
||
element.title = `${stepM}m ${delta > 0 ? "늘리기" : "줄이기"}`;
|
||
element.addEventListener("click", nudge(delta));
|
||
return element;
|
||
};
|
||
// 배치는 [-][숫자][+] — 셋이 테두리 하나를 나눠 쓰는 한 덩어리로 보인다
|
||
// (2026-08-29 사용자 지시).
|
||
wrap.append(button("-", -stepM), input, button("+", stepM));
|
||
return wrap;
|
||
}
|
||
|
||
/** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */
|
||
export function putNumber(target: Record<string, string | number>, key: string, raw: string): void {
|
||
const value = Number.parseFloat(raw);
|
||
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
|
||
}
|
||
|
||
/** 선택지 셀렉트 — **맨 앞에 빈 보기(「안 정함」)** 를 둠. 첫 보기를 미리 고르지 않음
|
||
* (2026-09-14 브레인 판정 · 옛 2026-08-17 「빈 항목 두지 않음」 걷음) — 고른 적 없는 첫 값이 저장돼
|
||
* 확정으로 굳지 않게. 기본값이 있는 칸은 부르는 쪽이 값을 넣음(그것은 따로 차례). */
|
||
export function optionalSelect(
|
||
blankLabel: string,
|
||
choices: ReadonlyArray<string>,
|
||
): HTMLSelectElement {
|
||
const element = document.createElement("select");
|
||
element.replaceChildren(
|
||
new Option(blankLabel, ""),
|
||
...choices.map((choice) => new Option(choice, choice)),
|
||
);
|
||
return element;
|
||
}
|
||
|
||
/** 2열 격자 한 줄(칸이 둘 이하면 그대로, 넘치면 줄바꿈). */
|
||
export function grid(...fields: HTMLElement[]): HTMLDivElement {
|
||
const div = document.createElement("div");
|
||
div.className = "b05-structure__grid";
|
||
div.append(...fields);
|
||
return div;
|
||
}
|
||
|
||
/** 제목 달린 소그룹 상자(유입구·유출구). */
|
||
export function group(title: string): { root: HTMLFieldSetElement; body: HTMLDivElement } {
|
||
const root = document.createElement("fieldset");
|
||
root.className = "b05-structure__group";
|
||
const legend = document.createElement("legend");
|
||
legend.textContent = title;
|
||
const body = document.createElement("div");
|
||
body.className = "b05-structure__group-body";
|
||
root.append(legend, body);
|
||
return { root, body };
|
||
}
|
||
|
||
/** 기슭막이 형태 — 재질과 표면처리를 한 축으로 합친 목록(2026-08-17 사용자 지시 1).
|
||
* 기존 기슭막이 형식 목록에 돌붙임의 찰·메 구분만 더한 것이다. */
|
||
const REVET_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"];
|
||
|
||
/** 기슭막이 옵션 키 묶음 — 유입·유출부가 같은 조각을 쓰되 키만 다르다.
|
||
* 보호공(돌붙임) 한 벌은 뺐다 — 기슭막이는 사면 공정이라 바닥 돌붙임이 붙을 자리가
|
||
* 아니다(2026-08-19 사용자 지시 1). 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은
|
||
* 레지스트리 옵션 방식으로 옮겼다(같은 지시 3). */
|
||
export interface RevetmentKeys {
|
||
form: string;
|
||
length?: string;
|
||
height: string;
|
||
/** 기준측점 전/후 몫(m) — 3D 예상형상 종방향 배치(2026-08-23 사용자). */
|
||
before?: string;
|
||
after?: string;
|
||
}
|
||
|
||
/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준
|
||
* (함수로 받아 볼 때마다 셈).
|
||
* ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만
|
||
* 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이
|
||
* 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */
|
||
export interface RevetmentDefaults {
|
||
form: string;
|
||
length: string;
|
||
height: string | (() => string);
|
||
before: string;
|
||
after: string;
|
||
}
|
||
|
||
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
|
||
export const REVET_COMMON_DEFAULTS = {
|
||
length: "10",
|
||
height: "2.5",
|
||
before: "5",
|
||
after: "5",
|
||
} as const;
|
||
|
||
export const INLET_REVET_KEYS: RevetmentKeys = {
|
||
form: "inlet_revet_form",
|
||
length: "inlet_revet_length_m",
|
||
height: "inlet_revet_height_m",
|
||
before: "inlet_revet_before_m",
|
||
after: "inlet_revet_after_m",
|
||
};
|
||
|
||
export const OUTLET_REVET_KEYS: RevetmentKeys = {
|
||
form: "outlet_revet_form",
|
||
length: "outlet_revet_length_m",
|
||
height: "outlet_revet_height_m",
|
||
before: "outlet_revet_before_m",
|
||
after: "outlet_revet_after_m",
|
||
};
|
||
|
||
/** 독립 기슭막이 한쪽 칸 — 배관 유출구와 **같은 양식**([구조][형태] + 길이·높이·전/후).
|
||
* 구조 선택지는 기슭막이 하나뿐이지만 레이아웃을 맞추려 그대로 둔다(2026-08-30 사용자). */
|
||
export interface RevetSideGroup {
|
||
root: HTMLFieldSetElement;
|
||
legend: HTMLLegendElement;
|
||
fields: RevetmentFields;
|
||
/** 조정창 행이 옮겨 붙는 자리 — 배관 유입구·유출구 칸과 같은 규칙. */
|
||
slot: HTMLElement;
|
||
}
|
||
|
||
/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호).
|
||
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */
|
||
function sabangBasisLines(): string[] {
|
||
const { items, scope } = revetmentSabang;
|
||
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
|
||
return [
|
||
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
|
||
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
|
||
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
|
||
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
|
||
scope,
|
||
];
|
||
}
|
||
|
||
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
|
||
const box = group("기슭막이");
|
||
const kind = document.createElement("select");
|
||
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
|
||
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
|
||
const fields = createRevetmentFields(keys, {
|
||
...REVET_COMMON_DEFAULTS,
|
||
form: formDefault,
|
||
height: "",
|
||
});
|
||
const basis = document.createElement("p");
|
||
basis.className = "b05-route__note";
|
||
basis.replaceChildren(
|
||
...sabangBasisLines().flatMap((line, index) =>
|
||
index ? [document.createElement("br"), line] : [line],
|
||
),
|
||
);
|
||
const slot = document.createElement("div");
|
||
slot.className = "b05-structure__adjust-slot";
|
||
box.body.append(
|
||
grid(labeled("구조", kind), fields.formField),
|
||
...fields.rows,
|
||
basis,
|
||
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
|
||
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
|
||
slot,
|
||
);
|
||
const legend = box.root.querySelector("legend")!;
|
||
// 이름표는 좌·우가 정해 준다 — 고른 구조물 이름을 덧붙이지 않는다(2026-08-30).
|
||
legend.dataset.fixedLabel = "1";
|
||
return { root: box.root, legend, fields, slot };
|
||
}
|
||
|
||
export interface RevetmentFields {
|
||
/** 형태 칸 — 유입·유출 그룹에서 "구조" 옆 2열째에 놓는다(2026-08-17 지시 3·4). */
|
||
formField: HTMLLabelElement;
|
||
/** 형태를 뺀 나머지 행(길이·높이). */
|
||
rows: HTMLElement[];
|
||
/** 길이 칸(없는 구성이면 null). */
|
||
lengthInput: HTMLInputElement | null;
|
||
write: (options: Record<string, string | number>) => void;
|
||
read: (target: Record<string, string | number>) => void;
|
||
onChange: (handler: () => void) => void;
|
||
}
|
||
|
||
/** 기슭막이 한 벌(형태·길이·높이) — 배수관 유입·유출부가 공유하는 입력 인터페이스
|
||
* (2026-08-17 사용자 지시 9·11). 재질과 표면처리는 "형태" 한 축(지시 1). 바닥
|
||
* 보호공(돌붙임) 한 벌은 뺐다 — 기슭막이는 사면 공정이라 그 아래 돌붙임이 붙을
|
||
* 자리가 아니다(2026-08-19 사용자 지시 1). */
|
||
export function createRevetmentFields(
|
||
keys: RevetmentKeys,
|
||
defaults: RevetmentDefaults,
|
||
): RevetmentFields {
|
||
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
|
||
const suggestHeight = (): string =>
|
||
typeof defaults.height === "function" ? defaults.height() : defaults.height;
|
||
const form = document.createElement("select");
|
||
form.replaceChildren(
|
||
new Option(`제안 ${defaults.form}`, ""),
|
||
...REVET_FORMS.map((value) => new Option(value, value)),
|
||
);
|
||
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
|
||
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
|
||
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
|
||
const height = numberInput("0.1", "0", heightHint());
|
||
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
|
||
height.addEventListener("focus", () => (height.placeholder = heightHint()));
|
||
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
|
||
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
|
||
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
|
||
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
|
||
let lastSplitEdit: "before" | "after" = "before";
|
||
const readNumber = (input: HTMLInputElement | null): number => {
|
||
const value = input ? Number.parseFloat(input.value) : Number.NaN;
|
||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||
};
|
||
const syncSplit = (source: "length" | "before" | "after"): void => {
|
||
// 길이가 비었으면 전/후를 0 으로 채우지 않음 — 빈 칸이 「0 으로 적음」이 되면 안 됨.
|
||
if (!length || !before || !after || length.value === "") return;
|
||
if (source !== "length") lastSplitEdit = source;
|
||
const total = readNumber(length);
|
||
const keep = lastSplitEdit === "after" ? after : before;
|
||
const kept = Math.min(readNumber(keep), total);
|
||
keep.value = kept.toFixed(1);
|
||
(keep === before ? after : before).value = Math.max(total - kept, 0).toFixed(1);
|
||
};
|
||
|
||
// 눈금은 조정창과 같은 값이다 — 길이 1m, 전/후 0.5m, 높이 0.1m(2026-08-29 일원화).
|
||
const formField = labeled("형태", form);
|
||
const lengthField = length ? labeled("길이 (m)", stepper(length, 1)) : null;
|
||
const heightField = labeled("높이 (m)", stepper(height, 0.1));
|
||
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5)) : null;
|
||
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5)) : null;
|
||
|
||
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
|
||
const fill = document.createElement("button");
|
||
fill.type = "button";
|
||
fill.className = "b05-structure__suggest";
|
||
fill.textContent = "제안값 넣기";
|
||
fill.addEventListener("click", () => {
|
||
if (!form.value) form.value = defaults.form;
|
||
if (length && !length.value) length.value = defaults.length;
|
||
if (!height.value) height.value = suggestHeight();
|
||
if (before && !before.value) before.value = defaults.before;
|
||
if (after && !after.value) after.value = defaults.after;
|
||
form.dispatchEvent(new Event("change"));
|
||
});
|
||
|
||
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
|
||
const rows = [
|
||
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
|
||
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
|
||
grid(fill),
|
||
];
|
||
|
||
return {
|
||
formField,
|
||
rows,
|
||
lengthInput: length,
|
||
write(options) {
|
||
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
|
||
const text = (key: string): string =>
|
||
options[key] !== undefined ? String(options[key]) : "";
|
||
form.value = text(keys.form);
|
||
if (length && keys.length) length.value = text(keys.length);
|
||
height.value = text(keys.height);
|
||
height.placeholder = heightHint();
|
||
if (before && keys.before) before.value = text(keys.before);
|
||
if (after && keys.after) after.value = text(keys.after);
|
||
syncSplit("length");
|
||
},
|
||
read(target) {
|
||
const putNumber = (key: string, raw: string): void => {
|
||
const value = Number.parseFloat(raw);
|
||
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
|
||
};
|
||
if (form.value) target[keys.form] = form.value;
|
||
if (length && keys.length) putNumber(keys.length, length.value);
|
||
putNumber(keys.height, height.value);
|
||
// 전/후는 0도 유효(기준이 곧 시작/끝) — putNumber(>0)와 달리 0을 남긴다.
|
||
const putSplit = (key: string | undefined, input: HTMLInputElement | null): void => {
|
||
if (!key || !input) return;
|
||
const value = Number.parseFloat(input.value);
|
||
if (Number.isFinite(value) && value >= 0) target[key] = Number(value.toFixed(1));
|
||
};
|
||
putSplit(keys.before, before);
|
||
putSplit(keys.after, after);
|
||
},
|
||
onChange(handler) {
|
||
[form, height].forEach((input) => input.addEventListener("change", handler));
|
||
length?.addEventListener("change", () => {
|
||
syncSplit("length");
|
||
handler();
|
||
});
|
||
before?.addEventListener("change", () => {
|
||
syncSplit("before");
|
||
handler();
|
||
});
|
||
after?.addEventListener("change", () => {
|
||
syncSplit("after");
|
||
handler();
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
/* ── BOX암거 ────────────────────────────────────────────────────────────── */
|
||
|
||
/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은
|
||
* "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든
|
||
* body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야
|
||
* 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */
|
||
export const BOX_SIZE_PRESETS: ReadonlyArray<readonly [string, number, number]> = [
|
||
["2.0×2.0", 2, 2],
|
||
["3.0×3.0", 3, 3],
|
||
];
|
||
export const BOX_SIZE_CUSTOM = "사용자 지정";
|
||
|
||
/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */
|
||
interface WingKeys {
|
||
install: string;
|
||
height: string;
|
||
length: string;
|
||
angle: string;
|
||
}
|
||
|
||
export const WING_IN_KEYS: WingKeys = {
|
||
install: "wing_in",
|
||
height: "wing_in_height_m",
|
||
length: "wing_in_length_m",
|
||
angle: "wing_in_angle_deg",
|
||
};
|
||
|
||
export const WING_OUT_KEYS: WingKeys = {
|
||
install: "wing_out",
|
||
height: "wing_out_height_m",
|
||
length: "wing_out_length_m",
|
||
angle: "wing_out_angle_deg",
|
||
};
|
||
|
||
/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45°
|
||
* (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */
|
||
const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const;
|
||
|
||
interface WingFields {
|
||
root: HTMLFieldSetElement;
|
||
/** 횡단도에서 그 측 날개벽·측벽을 고를 때 칸을 세우는 자리(B06 전용, 2026-08-30).
|
||
* 배관 유입구·유출구 칸의 `adjust-slot`과 같은 몫이다. */
|
||
slot: HTMLElement;
|
||
inputs: Array<HTMLInputElement | HTMLSelectElement>;
|
||
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(`— 안 정함 (제안 ${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));
|
||
const dimsRow = grid(
|
||
labeled("길이 (m)", stepper(length, 0.1)),
|
||
labeled("각도 (°)", stepper(angle, 5, 0)),
|
||
);
|
||
const box = group(title);
|
||
const slot = document.createElement("div");
|
||
slot.className = "b05-structure__adjust-slot";
|
||
box.body.append(grid(labeled("설치", install), heightField), dimsRow, slot);
|
||
|
||
function syncVisibility(): void {
|
||
const off = install.value === "없음";
|
||
heightField.hidden = off;
|
||
dimsRow.hidden = off;
|
||
}
|
||
|
||
syncVisibility();
|
||
|
||
return {
|
||
root: box.root,
|
||
slot,
|
||
inputs: [install, height, length, angle],
|
||
syncVisibility,
|
||
write(options) {
|
||
const text = (key: string): string =>
|
||
options[key] !== undefined ? String(options[key]) : "";
|
||
install.value = text(keys.install);
|
||
height.value = text(keys.height);
|
||
length.value = text(keys.length);
|
||
angle.value = text(keys.angle);
|
||
syncVisibility();
|
||
},
|
||
read(target) {
|
||
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();
|
||
},
|
||
};
|
||
}
|