Files
Aislo/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.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

510 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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";
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
/** 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;
}
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault });
const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
box.body.append(
grid(labeled("구조", kind), fields.formField),
...fields.rows,
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
// 같은 값이라 두 벌이면 헷갈린다(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 height = numberInput("0.1", "0", `제안 ${suggestHeight()}`);
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = `제안 ${suggestHeight()}`));
// 기준측점 전/후(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 = `제안 ${suggestHeight()}`;
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;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */
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;
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(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) || 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;
syncVisibility();
},
read(target) {
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);
},
};
}